chore: initial project scaffold with admin web, backend, desktop client, and deployment setup
Add monorepo structure for NexaVPN WireGuard control plane including: - .gitignore for node_modules, build artifacts, and environment files - README with project overview, monorepo layout, and quick start guide - Admin web UI with React, Vite, TypeScript, and nginx reverse proxy - API client with type definitions for users, devices, policies, gateways, and audit logs - Admin pages for dashboard, users, devices, policies, g
This commit is contained in:
22
desktop-client/src-tauri/Cargo.toml
Normal file
22
desktop-client/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "nexavpn-desktop"
|
||||
version = "0.1.0"
|
||||
description = "NexaVPN desktop client"
|
||||
authors = ["NexaVPN"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "nexavpn_desktop"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0.2" }
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tauri = { version = "2.3.1", features = [] }
|
||||
x25519-dalek = "2.0"
|
||||
3
desktop-client/src-tauri/build.rs
Normal file
3
desktop-client/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
204
desktop-client/src-tauri/src/lib.rs
Normal file
204
desktop-client/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
|
||||
use rand_core::OsRng;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
struct AppState {
|
||||
session: Mutex<Option<SessionState>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SessionState {
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
server_url: String,
|
||||
enrollment: EnrollmentResult,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EnrollmentPayload {
|
||||
server_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EnrollmentResult {
|
||||
assigned_ip: String,
|
||||
resources: Vec<String>,
|
||||
profile_revision: u32,
|
||||
gateway_endpoint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LoginRequest<'a> {
|
||||
username: &'a str,
|
||||
password: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LoginResponse {
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EnrollRequest<'a> {
|
||||
name: &'a str,
|
||||
platform: &'a str,
|
||||
os_version: &'a str,
|
||||
app_version: &'a str,
|
||||
device_fingerprint: String,
|
||||
public_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EnrollResponse {
|
||||
peer: PeerView,
|
||||
resources: Vec<ResourceView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PeerView {
|
||||
assigned_ip: String,
|
||||
gateway: GatewayView,
|
||||
profile_revision: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GatewayView {
|
||||
endpoint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResourceView {
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn enroll_device(payload: EnrollmentPayload, state: State<'_, AppState>) -> Result<EnrollmentResult, String> {
|
||||
if payload.server_url.trim().is_empty() || payload.username.trim().is_empty() || payload.password.trim().is_empty() {
|
||||
return Err("Server URL, username, and password are required".into());
|
||||
}
|
||||
|
||||
let client = Client::builder()
|
||||
.use_rustls_tls()
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let login_response = client
|
||||
.post(format!("{}/api/v1/auth/login", payload.server_url.trim_end_matches('/')))
|
||||
.json(&LoginRequest {
|
||||
username: &payload.username,
|
||||
password: &payload.password,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("Login failed: {}", err))?;
|
||||
|
||||
if !login_response.status().is_success() {
|
||||
return Err(format!("Login failed with status {}", login_response.status()));
|
||||
}
|
||||
|
||||
let login = login_response
|
||||
.json::<LoginResponse>()
|
||||
.await
|
||||
.map_err(|err| format!("Unable to decode login response: {}", err))?;
|
||||
|
||||
let (private_key, public_key) = generate_keypair();
|
||||
let enroll_response = client
|
||||
.post(format!("{}/api/v1/devices/enroll", payload.server_url.trim_end_matches('/')))
|
||||
.bearer_auth(&login.access_token)
|
||||
.json(&EnrollRequest {
|
||||
name: "NexaVPN Desktop",
|
||||
platform: if cfg!(target_os = "macos") { "macos" } else { "windows" },
|
||||
os_version: std::env::consts::OS,
|
||||
app_version: env!("CARGO_PKG_VERSION"),
|
||||
device_fingerprint: build_fingerprint(&payload.server_url, &payload.username, &public_key),
|
||||
public_key,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("Enrollment failed: {}", err))?;
|
||||
|
||||
if !enroll_response.status().is_success() {
|
||||
return Err(format!("Enrollment failed with status {}", enroll_response.status()));
|
||||
}
|
||||
|
||||
let enroll = enroll_response
|
||||
.json::<EnrollResponse>()
|
||||
.await
|
||||
.map_err(|err| format!("Unable to decode enrollment response: {}", err))?;
|
||||
|
||||
let result = EnrollmentResult {
|
||||
assigned_ip: enroll.peer.assigned_ip,
|
||||
resources: enroll.resources.into_iter().map(|resource| resource.value).collect(),
|
||||
profile_revision: enroll.peer.profile_revision,
|
||||
gateway_endpoint: enroll.peer.gateway.endpoint,
|
||||
};
|
||||
|
||||
let mut session = state.session.lock().map_err(|_| "Unable to store client state".to_string())?;
|
||||
*session = Some(SessionState {
|
||||
access_token: login.access_token,
|
||||
refresh_token: login.refresh_token,
|
||||
server_url: payload.server_url,
|
||||
enrollment: result.clone(),
|
||||
});
|
||||
|
||||
let _ = private_key;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn connect_tunnel(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
|
||||
if session.is_none() {
|
||||
return Err("No enrolled profile is available yet".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn disconnect_tunnel(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
|
||||
if session.is_none() {
|
||||
return Err("No active session is available".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_keypair() -> (String, String) {
|
||||
let private = StaticSecret::random_from_rng(OsRng);
|
||||
let public = PublicKey::from(&private);
|
||||
(
|
||||
STANDARD_NO_PAD.encode(private.to_bytes()),
|
||||
STANDARD_NO_PAD.encode(public.to_bytes()),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_fingerprint(server_url: &str, username: &str, public_key: &str) -> String {
|
||||
format!("nexavpn:{}:{}:{}", server_url, username, public_key)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(AppState {
|
||||
session: Mutex::new(None),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![enroll_device, connect_tunnel, disconnect_tunnel])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
3
desktop-client/src-tauri/src/main.rs
Normal file
3
desktop-client/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
nexavpn_desktop::run();
|
||||
}
|
||||
30
desktop-client/src-tauri/tauri.conf.json
Normal file
30
desktop-client/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "NexaVPN",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.nexavpn.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"devUrl": "http://localhost:4173",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "NexaVPN",
|
||||
"width": 1120,
|
||||
"height": 760,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user