diff --git a/deploy/scripts/gateway-entrypoint.sh b/deploy/scripts/gateway-entrypoint.sh index d1dbf94..f2d0228 100644 --- a/deploy/scripts/gateway-entrypoint.sh +++ b/deploy/scripts/gateway-entrypoint.sh @@ -143,6 +143,7 @@ EOF echo "Applied WireGuard config from ${WG_CONF}" echo "Applied nftables config from ${NFT_CONF}" + wg show "${IFACE}" latest-handshakes transfer 2>/dev/null || true } while true; do diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs index 6b17850..21859e6 100644 --- a/desktop-client/src-tauri/src/lib.rs +++ b/desktop-client/src-tauri/src/lib.rs @@ -304,6 +304,13 @@ fn disconnect_tunnel(app: AppHandle, state: State<'_, AppState>) -> Result<(), S tunnel_manager::disconnect(&app, std::path::Path::new(&session.profile_path)) } +#[tauri::command] +fn tunnel_status(app: AppHandle, state: State<'_, AppState>) -> Result { + 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::is_active(&app, std::path::Path::new(&session.profile_path)) +} + fn generate_keypair() -> (String, String) { let private = StaticSecret::random_from_rng(OsRng); let public = PublicKey::from(&private); @@ -431,7 +438,7 @@ pub fn run() { } _ => {} }) - .invoke_handler(tauri::generate_handler![load_state, clear_session, enroll_device, sync_profile, connect_tunnel, disconnect_tunnel]) + .invoke_handler(tauri::generate_handler![load_state, clear_session, enroll_device, sync_profile, connect_tunnel, disconnect_tunnel, tunnel_status]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/desktop-client/src-tauri/src/tunnel_manager.rs b/desktop-client/src-tauri/src/tunnel_manager.rs index 473c8de..ec452a0 100644 --- a/desktop-client/src-tauri/src/tunnel_manager.rs +++ b/desktop-client/src-tauri/src/tunnel_manager.rs @@ -47,6 +47,23 @@ pub fn disconnect(app: &AppHandle, profile_path: &Path) -> Result<(), String> { Ok(()) } +pub fn is_active(app: &AppHandle, profile_path: &Path) -> Result { + let backend = bundled_backend(app)?; + let output = Command::new(backend) + .arg("status") + .arg("--profile") + .arg(profile_path) + .output() + .map_err(|err| format!("Unable to query embedded tunnel backend: {}", err))?; + + if !output.status.success() { + return Err(format_helper_error("status", &output)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(stdout.trim().eq_ignore_ascii_case("active")) +} + fn bundled_backend(app: &AppHandle) -> Result { let resource_dir = app .path() diff --git a/desktop-client/src/App.tsx b/desktop-client/src/App.tsx index 2fb79fe..d7b0ad8 100644 --- a/desktop-client/src/App.tsx +++ b/desktop-client/src/App.tsx @@ -51,9 +51,15 @@ export function App() { useEffect(() => { void invoke("load_state") - .then((value) => { + .then(async (value) => { if (value) { setState(value); + try { + const active = await invoke("tunnel_status"); + setConnected(active); + } catch { + setConnected(false); + } } }) .catch(() => undefined); @@ -96,8 +102,13 @@ export function App() { const command = connected ? "disconnect_tunnel" : "connect_tunnel"; try { await invoke(command); - setConnected((value) => !value); - setError(null); + const active = await invoke("tunnel_status"); + setConnected(active); + if (!connected && !active) { + setError("Tunnel was installed, but no active interface could be verified yet."); + } else { + setError(null); + } } catch (err) { setError(formatInvokeError(err, "Tunnel action failed")); } diff --git a/desktop-client/tunnel-helper/src/main.rs b/desktop-client/tunnel-helper/src/main.rs index a2f6bc8..4974343 100644 --- a/desktop-client/tunnel-helper/src/main.rs +++ b/desktop-client/tunnel-helper/src/main.rs @@ -54,6 +54,7 @@ struct TunnelRequest { struct TunnelResponse { ok: bool, error: Option, + active: Option, } fn main() -> ExitCode { @@ -76,6 +77,12 @@ fn run() -> Result<(), String> { "service" => run_windows_service_dispatcher(), "install-service" => install_windows_service(), "uninstall-service" => uninstall_windows_service(), + "status" => { + let profile = parse_profile_arg(args)?; + let active = windows_client_status(&profile)?; + println!("{}", if active { "active" } else { "inactive" }); + Ok(()) + } "connect" | "disconnect" => { let profile = parse_profile_arg(args)?; windows_client_request(command.as_str(), &profile) @@ -88,6 +95,11 @@ fn run() -> Result<(), String> { { let profile = parse_profile_arg(args)?; match command.as_str() { + "status" => { + let active = tunnel_is_active(&profile)?; + println!("{}", if active { "active" } else { "inactive" }); + Ok(()) + } "connect" => connect_direct(&profile), "disconnect" => disconnect_direct(&profile), _ => Err("unsupported command".into()), @@ -201,22 +213,37 @@ fn handle_service_client(mut stream: TcpStream) -> Result<(), String> { .map_err(|err| format!("Unable to decode IPC request: {err}"))?; let response = match request.action.as_str() { "connect" => match connect_direct(Path::new(&request.profile)) { - Ok(()) => TunnelResponse { ok: true, error: None }, + Ok(()) => TunnelResponse { ok: true, error: None, active: None }, Err(err) => TunnelResponse { ok: false, error: Some(err), + active: None, }, }, "disconnect" => match disconnect_direct(Path::new(&request.profile)) { - Ok(()) => TunnelResponse { ok: true, error: None }, + Ok(()) => TunnelResponse { ok: true, error: None, active: None }, Err(err) => TunnelResponse { ok: false, error: Some(err), + active: None, + }, + }, + "status" => match tunnel_is_active(Path::new(&request.profile)) { + Ok(active) => TunnelResponse { + ok: true, + error: None, + active: Some(active), + }, + Err(err) => TunnelResponse { + ok: false, + error: Some(err), + active: None, }, }, _ => TunnelResponse { ok: false, error: Some("unsupported tunnel action".into()), + active: None, }, }; @@ -314,6 +341,41 @@ fn windows_client_request(action: &str, profile: &Path) -> Result<(), String> { .unwrap_or_else(|| "NexaVPN tunnel service reported an unknown error".into())) } +#[cfg(target_os = "windows")] +fn windows_client_status(profile: &Path) -> Result { + let mut stream = TcpStream::connect(IPC_BIND_ADDR).map_err(|_| { + "NexaVPN background service is not available. Reinstall NexaVPN or install the tunnel service once as administrator." + .to_string() + })?; + + let payload = serde_json::to_string(&TunnelRequest { + action: "status".to_string(), + profile: profile.display().to_string(), + }) + .map_err(|err| format!("Unable to encode tunnel status request: {err}"))?; + + stream + .write_all(payload.as_bytes()) + .and_then(|_| stream.write_all(b"\n")) + .map_err(|err| format!("Unable to send tunnel status request: {err}"))?; + + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader + .read_line(&mut line) + .map_err(|err| format!("Unable to read tunnel status response: {err}"))?; + + let response = serde_json::from_str::(&line) + .map_err(|err| format!("Unable to decode tunnel status response: {err}"))?; + if !response.ok { + return Err(response + .error + .unwrap_or_else(|| "NexaVPN tunnel service status check failed".into())); + } + + Ok(response.active.unwrap_or(false)) +} + fn connect_direct(profile: &Path) -> Result<(), String> { #[cfg(target_os = "windows")] { @@ -386,6 +448,47 @@ fn disconnect_direct(profile: &Path) -> Result<(), String> { Err("unsupported platform".into()) } +fn tunnel_is_active(profile: &Path) -> Result { + #[cfg(target_os = "windows")] + { + let tunnel_name = profile + .file_stem() + .and_then(|value| value.to_str()) + .ok_or_else(|| "invalid profile filename".to_string())?; + let service_name = format!("WireGuardTunnel${}", tunnel_name); + let output = Command::new("sc") + .arg("query") + .arg(&service_name) + .creation_flags(CREATE_NO_WINDOW) + .output() + .map_err(|err| format!("Unable to query tunnel status: {err}"))?; + + if !output.status.success() { + return Ok(false); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + return Ok(stdout.contains("RUNNING")); + } + + #[cfg(target_os = "macos")] + { + let tunnel_name = profile + .file_stem() + .and_then(|value| value.to_str()) + .ok_or_else(|| "invalid profile filename".to_string())?; + let status = Command::new("wg") + .arg("show") + .arg(tunnel_name) + .status() + .map_err(|err| format!("Unable to query tunnel status: {err}"))?; + return Ok(status.success()); + } + + #[allow(unreachable_code)] + Err("unsupported platform".into()) +} + #[cfg(target_os = "windows")] fn find_windows_wireguard() -> Result { let candidates = [