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:
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();
|
||||
}
|
||||
Reference in New Issue
Block a user