diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs index 90bdd28..27e205b 100644 --- a/desktop-client/src-tauri/src/lib.rs +++ b/desktop-client/src-tauri/src/lib.rs @@ -1,6 +1,6 @@ mod tunnel_manager; -use std::{fs, path::PathBuf, sync::Mutex}; +use std::{fs, path::PathBuf, sync::Mutex, time::{SystemTime, UNIX_EPOCH}}; use base64::{engine::general_purpose::STANDARD, Engine as _}; use rand_core::OsRng; @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use tauri::{ menu::{MenuBuilder, MenuItemBuilder}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - AppHandle, Manager, State, WebviewWindow, WindowEvent, + AppHandle, Manager, State, WebviewWindow, Window, WindowEvent, }; use x25519_dalek::{PublicKey, StaticSecret}; @@ -80,11 +80,17 @@ struct EnrollRequest<'a> { #[derive(Debug, Deserialize)] struct EnrollResponse { + device: DeviceView, peer: PeerView, profile: ProfileView, resources: Vec, } +#[derive(Debug, Deserialize)] +struct DeviceView { + id: String, +} + #[derive(Debug, Deserialize)] struct PeerView { #[serde(rename = "assigned_ip")] @@ -180,7 +186,7 @@ async fn enroll_device( 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(), + last_sync_time: now_label(), tunnel_strategy: tunnel_manager::current_tunnel_strategy().into(), }; @@ -207,6 +213,65 @@ fn load_state(app: AppHandle, state: State<'_, AppState>) -> Result) -> Result { + let existing = { + let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?; + session.clone().ok_or_else(|| "No enrolled profile is available yet".to_string())? + }; + + let client = Client::builder() + .use_rustls_tls() + .build() + .map_err(|err| err.to_string())?; + + let response = client + .get(format!("{}/api/v1/me/profile", existing.server_url.trim_end_matches('/'))) + .bearer_auth(&existing.access_token) + .send() + .await + .map_err(|err| format!("Profile sync failed: {}", err))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "".into()); + return Err(format!("Profile sync failed with status {}: {}", status, body)); + } + + let enroll = response + .json::() + .await + .map_err(|err| format!("Unable to decode profile sync 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: now_label(), + tunnel_strategy: tunnel_manager::current_tunnel_strategy().into(), + }; + + let session_state = SessionState { + access_token: existing.access_token, + refresh_token: existing.refresh_token, + server_url: existing.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); + + Ok(result) +} + #[tauri::command] fn connect_tunnel(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> { let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?; @@ -230,6 +295,14 @@ fn generate_keypair() -> (String, String) { ) } +fn now_label() -> String { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + format!("synced at {}", seconds) +} + fn build_fingerprint(server_url: &str, username: &str, public_key: &str) -> String { format!("nexavpn:{}:{}:{}", server_url, username, public_key) } @@ -268,13 +341,13 @@ fn ensure_app_dir(app: &AppHandle) -> Result { Ok(dir) } -fn restore_main_window(window: &WebviewWindow) { +fn restore_main_window(window: &Window) { let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); } -fn hide_main_window(window: &WebviewWindow) { +fn hide_main_window(window: &Window) { let _ = window.hide(); } @@ -335,7 +408,7 @@ pub fn run() { } _ => {} }) - .invoke_handler(tauri::generate_handler![load_state, enroll_device, connect_tunnel, disconnect_tunnel]) + .invoke_handler(tauri::generate_handler![load_state, enroll_device, sync_profile, connect_tunnel, disconnect_tunnel]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/desktop-client/src/App.tsx b/desktop-client/src/App.tsx index de93e8a..cdc58b2 100644 --- a/desktop-client/src/App.tsx +++ b/desktop-client/src/App.tsx @@ -1,4 +1,4 @@ -import { FormEvent, useEffect, useState } from "react"; +import { FormEvent, useEffect, useMemo, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; type EnrollmentState = { @@ -23,11 +23,28 @@ function formatInvokeError(err: unknown, fallback: string) { return fallback; } +function currentProfileLabel(state: EnrollmentState | null) { + if (!state) { + return "No profile provisioned"; + } + + if (state.resources.includes("0.0.0.0/0")) { + return "Full tunnel"; + } + + if (state.resources.length === 1) { + return "Single resource access"; + } + + return `Split tunnel (${state.resources.length} resources)`; +} + export function App() { const [serverUrl, setServerUrl] = useState("http://localhost"); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); + const [syncing, setSyncing] = useState(false); const [error, setError] = useState(null); const [connected, setConnected] = useState(false); const [state, setState] = useState(null); @@ -42,6 +59,8 @@ export function App() { .catch(() => undefined); }, []); + const profileLabel = useMemo(() => currentProfileLabel(state), [state]); + async function onSubmit(event: FormEvent) { event.preventDefault(); setLoading(true); @@ -59,6 +78,20 @@ export function App() { } } + async function onSyncProfile() { + setSyncing(true); + setError(null); + + try { + const result = await invoke("sync_profile"); + setState(result); + } catch (err) { + setError(formatInvokeError(err, "Profile sync failed")); + } finally { + setSyncing(false); + } + } + async function toggleConnection() { const command = connected ? "disconnect_tunnel" : "connect_tunnel"; try { @@ -72,81 +105,161 @@ export function App() { return (
-
-

NexaVPN

-

Private access without manual WireGuard setup.

-

- Enter your VPN address, username, and password. NexaVPN provisions and stores the profile for you. -

-
+
+
+
+ NexaVPN +
+

NexaVPN

+

Desktop Access Client

+

Provisioned WireGuard access with profile sync and one-click reconnect.

+
+
+
+ {state ? ( + + ) : null} + +
+
- {!state ? ( -
- - - - {error ?
{error}
: null} - -
- ) : ( -
-
-
-

Connection

-

{connected ? "Connected" : "Disconnected"}

-
- +
+
+

Managed Tunnel

+

Private access with fewer moving parts.

+

+ NexaVPN signs you in, enrolls the device, stores the WireGuard profile locally, and lets you resync the + assigned access profile after policy changes. +

-
-
- Assigned VPN IP - {state.assignedIp} +
+
+ Current profile + {profileLabel}
-
- Gateway - {state.gatewayEndpoint} -
-
- Profile revision - {state.profileRevision} -
-
- Last sync - {state.lastSyncTime} +
+ Last sync + {state?.lastSyncTime ?? "Not synced yet"}
-
-
- Profile path - {state.profilePath} +
+ +
+ {!state ? ( +
+
+
+

Onboarding

+

Sign in and provision this device

+
+
+
+ + + + {error ?
{error}
: null} +
+ +
+
+
+ ) : ( +
+
+
+

Connection

+

{connected ? "Connected" : "Disconnected"}

+
+ + {connected ? "Tunnel active" : "Ready to connect"} +
+
+
+ +
+
+
+

Session

+

Provisioned device state

+
+
+
+
+ Assigned VPN IP + {state.assignedIp} +
+
+ Gateway endpoint + {state.gatewayEndpoint} +
+
+ Profile revision + {state.profileRevision} +
+
+ Last sync + {state.lastSyncTime} +
+
+
+ +
+
+
+

Profile

+

Assigned access profile

+
+
+
+
+ Profile path + {state.profilePath} +
+
+ Tunnel strategy + {state.tunnelStrategy} +
+
+
+ + {error ?
{error}
: null} +
+ )} + +
- {error ?
{error}
: null} -
-

Allowed resources

+

+ The current backend issues one effective profile per device. After policy changes, use Sync + profile to pull the latest assigned access. +

    - {state.resources.map((resource) => ( + {(state?.resources ?? ["No resources assigned yet"]).map((resource) => (
  • {resource}
  • ))}
-
+
- )} +
); } diff --git a/desktop-client/src/styles.css b/desktop-client/src/styles.css index 6c0a8d1..c8ebc81 100644 --- a/desktop-client/src/styles.css +++ b/desktop-client/src/styles.css @@ -1,111 +1,329 @@ :root { - font-family: "Segoe UI", "SF Pro Text", sans-serif; - color: #f5f7fb; + font-family: "Segoe UI", "SF Pro Text", "Helvetica Neue", sans-serif; + color: #eef4ff; background: - radial-gradient(circle at top, rgba(79, 208, 164, 0.18), transparent 25%), - linear-gradient(180deg, #08111f 0%, #0d1727 100%); + radial-gradient(circle at 12% 10%, rgba(50, 196, 167, 0.2), transparent 24%), + radial-gradient(circle at 90% 18%, rgba(89, 133, 255, 0.16), transparent 22%), + linear-gradient(180deg, #07101c 0%, #0c1524 100%); } * { box-sizing: border-box; } +html, +body, +#root { + min-height: 100vh; +} + body { margin: 0; } +button, +input { + font: inherit; +} + .client-shell { min-height: 100vh; + padding: 28px; +} + +.app-frame { + width: min(1120px, 100%); + margin: 0 auto; display: grid; - place-items: center; - gap: 24px; - padding: 32px 20px; + gap: 22px; } -.hero, -.panel { - width: min(560px, 100%); +.top-strip { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; } -.hero { - text-align: center; +.brand-lockup { + display: flex; + align-items: center; + gap: 14px; +} + +.brand-lockup img { + width: 54px; + height: 54px; + border-radius: 16px; + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28); +} + +.brand-copy { + display: grid; + gap: 4px; } .eyebrow { - color: #74e0b8; + margin: 0; + color: #75e3ba; letter-spacing: 0.18em; text-transform: uppercase; - font-size: 0.78rem; + font-size: 0.74rem; } -.lede { - color: #a9b8d3; +.brand-copy h1, +.hero-copy h2, +.status-panel h3 { + margin: 0; } -.panel { +.brand-copy p, +.hero-copy p, +.status-panel p, +.surface-header p, +.detail-card span, +.profile-card span { + margin: 0; + color: #9eb1d1; +} + +.top-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.shell-button, +.shell-button-secondary { + border: 0; + border-radius: 999px; + padding: 12px 18px; + font-weight: 700; + cursor: pointer; + transition: 160ms ease; +} + +.shell-button { + background: linear-gradient(135deg, #74e0b8 0%, #1fb67a 100%); + color: #04131a; +} + +.shell-button-secondary { + background: rgba(255, 255, 255, 0.04); + color: #eef4ff; + border: 1px solid rgba(177, 197, 229, 0.16); +} + +.shell-button:disabled, +.shell-button-secondary:disabled { + opacity: 0.58; + cursor: default; +} + +.hero-surface, +.surface, +.status-panel, +.login-panel { + background: rgba(11, 20, 35, 0.78); + border: 1px solid rgba(177, 197, 229, 0.12); + box-shadow: 0 24px 70px rgba(2, 8, 18, 0.32); + backdrop-filter: blur(18px); +} + +.hero-surface { + border-radius: 30px; + padding: 28px; + display: grid; + grid-template-columns: 1.2fr 0.8fr; + gap: 20px; + align-items: center; +} + +.hero-copy { + display: grid; + gap: 14px; +} + +.hero-copy h2 { + font-size: clamp(2rem, 4vw, 3.6rem); + line-height: 1.04; + max-width: 10ch; +} + +.hero-copy p { + max-width: 56ch; + font-size: 1.03rem; + line-height: 1.6; +} + +.hero-meta { + display: grid; + gap: 14px; +} + +.meta-tile { + padding: 18px; + border-radius: 22px; + background: linear-gradient(180deg, rgba(117, 227, 186, 0.08), rgba(117, 227, 186, 0.02)); + border: 1px solid rgba(117, 227, 186, 0.16); +} + +.meta-tile strong { + display: block; + margin-top: 8px; + font-size: 1.1rem; +} + +.body-grid { + display: grid; + grid-template-columns: 1.3fr 0.7fr; + gap: 22px; +} + +.login-panel, +.status-panel { + border-radius: 28px; padding: 24px; - border-radius: 24px; - background: rgba(12, 22, 38, 0.84); - border: 1px solid rgba(167, 185, 219, 0.14); - box-shadow: 0 24px 60px rgba(2, 8, 18, 0.36); - backdrop-filter: blur(16px); + display: grid; + gap: 20px; } -form { +.status-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.status-state { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + color: #c8d6ee; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 999px; + background: #ff8a7d; +} + +.status-dot.online { + background: #74e0b8; +} + +.surface { + border-radius: 24px; + padding: 18px; + display: grid; + gap: 14px; +} + +.surface-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.surface-header h4 { + margin: 0; + font-size: 1rem; +} + +.status-grid, +.profile-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.detail-card, +.profile-card { + padding: 14px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(177, 197, 229, 0.1); + display: grid; + gap: 6px; +} + +.detail-card strong, +.profile-card strong { + font-size: 1rem; + word-break: break-word; +} + +.resource-list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.resource-list li { + padding: 10px 12px; + border-radius: 999px; + background: rgba(117, 227, 186, 0.08); + border: 1px solid rgba(117, 227, 186, 0.15); + color: #dffaf0; +} + +.login-panel form { display: grid; gap: 16px; } -label { +.login-panel label { display: grid; gap: 8px; color: #c2cfe5; } -input { - border: 1px solid rgba(167, 185, 219, 0.16); - background: rgba(7, 14, 27, 0.85); +.login-panel input { + border: 1px solid rgba(177, 197, 229, 0.16); + background: rgba(7, 14, 27, 0.9); color: #f5f7fb; - border-radius: 14px; - padding: 14px 16px; + border-radius: 16px; + padding: 15px 16px; } -button { - border: 0; - border-radius: 999px; - padding: 13px 18px; - font-weight: 700; - background: linear-gradient(135deg, #74e0b8 0%, #1fb67a 100%); - color: #04141a; -} - -.error { - color: #ffb7b7; -} - -.status { - display: grid; - gap: 18px; -} - -.status-row, -.details { +.login-card-actions { display: flex; - justify-content: space-between; - gap: 16px; + gap: 12px; flex-wrap: wrap; } -.details div { - display: grid; - gap: 6px; +.error { + padding: 12px 14px; + border-radius: 16px; + background: rgba(255, 115, 115, 0.08); + border: 1px solid rgba(255, 115, 115, 0.16); + color: #ffc3c3; + white-space: pre-wrap; } -.details span { - color: #9db0cf; -} +@media (max-width: 960px) { + .hero-surface, + .body-grid { + grid-template-columns: 1fr; + } -.resource-list { - margin: 0; - padding-left: 18px; + .top-strip, + .status-top, + .surface-header { + align-items: flex-start; + flex-direction: column; + } + + .status-grid, + .profile-grid { + grid-template-columns: 1fr; + } } diff --git a/desktop-client/tunnel-helper/src/main.rs b/desktop-client/tunnel-helper/src/main.rs index 318e215..c24a420 100644 --- a/desktop-client/tunnel-helper/src/main.rs +++ b/desktop-client/tunnel-helper/src/main.rs @@ -4,6 +4,12 @@ use std::{ process::{Command, ExitCode}, }; +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; + +#[cfg(target_os = "windows")] +const CREATE_NO_WINDOW: u32 = 0x08000000; + fn main() -> ExitCode { match run() { Ok(()) => ExitCode::SUCCESS, @@ -33,14 +39,16 @@ fn run() -> Result<(), String> { fn connect(profile: &Path) -> Result<(), String> { #[cfg(target_os = "windows")] { + ensure_windows_admin()?; let wireguard = find_windows_wireguard()?; - let status = Command::new(wireguard) + let output = Command::new(wireguard) .arg("/installtunnelservice") .arg(profile) - .status() + .creation_flags(CREATE_NO_WINDOW) + .output() .map_err(|err| format!("unable to launch WireGuard runtime: {err}"))?; - if !status.success() { - return Err(format!("WireGuard runtime connect failed with status {status}")); + if !output.status.success() { + return Err(format_windows_runtime_error("connect", &output)); } return Ok(()); } @@ -66,18 +74,20 @@ fn connect(profile: &Path) -> Result<(), String> { fn disconnect(profile: &Path) -> Result<(), String> { #[cfg(target_os = "windows")] { + ensure_windows_admin()?; let wireguard = find_windows_wireguard()?; let tunnel_name = profile .file_stem() .and_then(|value| value.to_str()) .ok_or_else(|| "invalid profile filename".to_string())?; - let status = Command::new(wireguard) + let output = Command::new(wireguard) .arg("/uninstalltunnelservice") .arg(tunnel_name) - .status() + .creation_flags(CREATE_NO_WINDOW) + .output() .map_err(|err| format!("unable to launch WireGuard runtime: {err}"))?; - if !status.success() { - return Err(format!("WireGuard runtime disconnect failed with status {status}")); + if !output.status.success() { + return Err(format_windows_runtime_error("disconnect", &output)); } return Ok(()); } @@ -112,3 +122,35 @@ fn find_windows_wireguard() -> Result { .find(|path| path.exists()) .ok_or_else(|| "required Windows tunnel runtime is not available".to_string()) } + +#[cfg(target_os = "windows")] +fn ensure_windows_admin() -> Result<(), String> { + let status = Command::new("net") + .arg("session") + .creation_flags(CREATE_NO_WINDOW) + .status() + .map_err(|err| format!("unable to determine Windows privilege level: {err}"))?; + + if status.success() { + return Ok(()); + } + + Err("Administrator rights are required to activate the VPN tunnel on Windows. Start NexaVPN as Administrator for now.".into()) +} + +#[cfg(target_os = "windows")] +fn format_windows_runtime_error(action: &str, output: &std::process::Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let details = if !stderr.trim().is_empty() { + stderr.trim() + } else { + stdout.trim() + }; + + if details.is_empty() { + return format!("WireGuard runtime {} failed with status {}", action, output.status); + } + + format!("WireGuard runtime {} failed: {}", action, details) +}