docs: update README with desktop requirements, helper builds, and realistic MVP usage notes
Expand README with desktop platform requirements (Windows x86, macOS ARM), helper build commands, gateway utility scripts, and updated local test flow. Add realistic MVP usage section clarifying current platform build status, gateway configuration needs, and admin debug profile behavior with client private key handling.
This commit is contained in:
6
desktop-client/src-tauri/bundled/macos-arm64/README.txt
Normal file
6
desktop-client/src-tauri/bundled/macos-arm64/README.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Bundle the macOS ARM NexaVPN tunnel helper here.
|
||||
|
||||
Expected filename:
|
||||
- nexavpn-tunnel-helper
|
||||
|
||||
This helper encapsulates the WireGuard runtime so the end user only interacts with NexaVPN.
|
||||
6
desktop-client/src-tauri/bundled/windows-x86/README.txt
Normal file
6
desktop-client/src-tauri/bundled/windows-x86/README.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Bundle the Windows x86 NexaVPN tunnel helper here.
|
||||
|
||||
Expected filename:
|
||||
- nexavpn-tunnel-helper.exe
|
||||
|
||||
This helper encapsulates the WireGuard runtime so the end user only interacts with NexaVPN.
|
||||
@@ -1,21 +1,27 @@
|
||||
use std::sync::Mutex;
|
||||
mod tunnel_manager;
|
||||
|
||||
use std::{fs, path::PathBuf, 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 tauri::{AppHandle, Manager, State};
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
const PROFILE_NAME: &str = "NexaVPN";
|
||||
|
||||
struct AppState {
|
||||
session: Mutex<Option<SessionState>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SessionState {
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
server_url: String,
|
||||
profile_path: String,
|
||||
enrollment: EnrollmentResult,
|
||||
}
|
||||
|
||||
@@ -27,13 +33,16 @@ struct EnrollmentPayload {
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EnrollmentResult {
|
||||
assigned_ip: String,
|
||||
resources: Vec<String>,
|
||||
profile_revision: u32,
|
||||
gateway_endpoint: String,
|
||||
profile_path: String,
|
||||
last_sync_time: String,
|
||||
tunnel_strategy: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -65,6 +74,7 @@ struct EnrollRequest<'a> {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EnrollResponse {
|
||||
peer: PeerView,
|
||||
profile: ProfileView,
|
||||
resources: Vec<ResourceView>,
|
||||
}
|
||||
|
||||
@@ -87,8 +97,17 @@ struct ResourceView {
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProfileView {
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn enroll_device(payload: EnrollmentPayload, state: State<'_, AppState>) -> Result<EnrollmentResult, String> {
|
||||
async fn enroll_device(
|
||||
app: AppHandle,
|
||||
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());
|
||||
}
|
||||
@@ -117,7 +136,7 @@ async fn enroll_device(payload: EnrollmentPayload, state: State<'_, AppState>) -
|
||||
.await
|
||||
.map_err(|err| format!("Unable to decode login response: {}", err))?;
|
||||
|
||||
let (private_key, public_key) = generate_keypair();
|
||||
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)
|
||||
@@ -142,41 +161,52 @@ async fn enroll_device(payload: EnrollmentPayload, state: State<'_, AppState>) -
|
||||
.await
|
||||
.map_err(|err| format!("Unable to decode enrollment response: {}", err))?;
|
||||
|
||||
let profile_path = write_profile(&app, &enroll.profile.content)?;
|
||||
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,
|
||||
profile_path: profile_path.display().to_string(),
|
||||
last_sync_time: "just now".into(),
|
||||
tunnel_strategy: tunnel_manager::current_tunnel_strategy().into(),
|
||||
};
|
||||
|
||||
let mut session = state.session.lock().map_err(|_| "Unable to store client state".to_string())?;
|
||||
*session = Some(SessionState {
|
||||
let session_state = SessionState {
|
||||
access_token: login.access_token,
|
||||
refresh_token: login.refresh_token,
|
||||
server_url: payload.server_url,
|
||||
profile_path: result.profile_path.clone(),
|
||||
enrollment: result.clone(),
|
||||
});
|
||||
};
|
||||
|
||||
write_session_state(&app, &session_state)?;
|
||||
let mut session = state.session.lock().map_err(|_| "Unable to store client state".to_string())?;
|
||||
*session = Some(session_state);
|
||||
|
||||
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(())
|
||||
fn load_state(app: AppHandle, state: State<'_, AppState>) -> Result<Option<EnrollmentResult>, String> {
|
||||
let loaded = read_session_state(&app)?;
|
||||
let mut session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
|
||||
*session = loaded.clone();
|
||||
Ok(loaded.map(|value| value.enrollment))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn disconnect_tunnel(state: State<'_, AppState>) -> Result<(), String> {
|
||||
fn connect_tunnel(app: AppHandle, 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(())
|
||||
let session = session.as_ref().ok_or_else(|| "No enrolled profile is available yet".to_string())?;
|
||||
tunnel_manager::connect(&app, std::path::Path::new(&session.profile_path))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn disconnect_tunnel(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
||||
let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
|
||||
let session = session.as_ref().ok_or_else(|| "No active session is available".to_string())?;
|
||||
tunnel_manager::disconnect(&app, std::path::Path::new(&session.profile_path))
|
||||
}
|
||||
|
||||
fn generate_keypair() -> (String, String) {
|
||||
@@ -192,13 +222,47 @@ fn build_fingerprint(server_url: &str, username: &str, public_key: &str) -> Stri
|
||||
format!("nexavpn:{}:{}:{}", server_url, username, public_key)
|
||||
}
|
||||
|
||||
fn write_profile(app: &AppHandle, profile_content: &str) -> Result<PathBuf, String> {
|
||||
let app_dir = ensure_app_dir(app)?;
|
||||
let profile_path = app_dir.join(format!("{}.conf", PROFILE_NAME));
|
||||
fs::write(&profile_path, profile_content).map_err(|err| format!("Unable to store profile: {}", err))?;
|
||||
Ok(profile_path)
|
||||
}
|
||||
|
||||
fn write_session_state(app: &AppHandle, session: &SessionState) -> Result<(), String> {
|
||||
let app_dir = ensure_app_dir(app)?;
|
||||
let session_path = app_dir.join("session.json");
|
||||
let json = serde_json::to_vec_pretty(session).map_err(|err| err.to_string())?;
|
||||
fs::write(session_path, json).map_err(|err| format!("Unable to persist session state: {}", err))
|
||||
}
|
||||
|
||||
fn read_session_state(app: &AppHandle) -> Result<Option<SessionState>, String> {
|
||||
let app_dir = ensure_app_dir(app)?;
|
||||
let session_path = app_dir.join("session.json");
|
||||
if !session_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let raw = fs::read(session_path).map_err(|err| format!("Unable to read session state: {}", err))?;
|
||||
let value = serde_json::from_slice::<SessionState>(&raw).map_err(|err| format!("Unable to parse session state: {}", err))?;
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn ensure_app_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map_err(|err| format!("Unable to resolve app config dir: {}", err))?;
|
||||
fs::create_dir_all(&dir).map_err(|err| format!("Unable to create app dir: {}", err))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
#[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])
|
||||
.invoke_handler(tauri::generate_handler![load_state, enroll_device, connect_tunnel, disconnect_tunnel])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
70
desktop-client/src-tauri/src/tunnel_manager.rs
Normal file
70
desktop-client/src-tauri/src/tunnel_manager.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
pub fn current_tunnel_strategy() -> &'static str {
|
||||
if cfg!(target_os = "windows") {
|
||||
"embedded-wireguard-windows-x86"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"embedded-wireguard-macos-arm"
|
||||
} else {
|
||||
"unsupported"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connect(app: &AppHandle, profile_path: &Path) -> Result<(), String> {
|
||||
let backend = bundled_backend(app)?;
|
||||
let status = Command::new(backend)
|
||||
.arg("connect")
|
||||
.arg("--profile")
|
||||
.arg(profile_path)
|
||||
.status()
|
||||
.map_err(|err| format!("Unable to start embedded tunnel backend: {}", err))?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(format!("Embedded tunnel backend connect failed with status {}", status));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn disconnect(app: &AppHandle, profile_path: &Path) -> Result<(), String> {
|
||||
let backend = bundled_backend(app)?;
|
||||
let status = Command::new(backend)
|
||||
.arg("disconnect")
|
||||
.arg("--profile")
|
||||
.arg(profile_path)
|
||||
.status()
|
||||
.map_err(|err| format!("Unable to stop embedded tunnel backend: {}", err))?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(format!("Embedded tunnel backend disconnect failed with status {}", status));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bundled_backend(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let resource_dir = app
|
||||
.path()
|
||||
.resource_dir()
|
||||
.map_err(|err| format!("Unable to resolve resource dir: {}", err))?;
|
||||
|
||||
let relative = if cfg!(target_os = "windows") {
|
||||
PathBuf::from("bundled/windows-x86/nexavpn-tunnel-helper.exe")
|
||||
} else if cfg!(target_os = "macos") {
|
||||
PathBuf::from("bundled/macos-arm64/nexavpn-tunnel-helper")
|
||||
} else {
|
||||
return Err("This NexaVPN client build supports embedded tunnel backends only for Windows x86 and macOS ARM".into());
|
||||
};
|
||||
|
||||
let path = resource_dir.join(relative);
|
||||
if !path.exists() {
|
||||
return Err("Embedded NexaVPN tunnel backend is not bundled in this build yet.".into());
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
@@ -25,6 +25,9 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": []
|
||||
"icon": [],
|
||||
"resources": [
|
||||
"bundled/**/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user