From a3e5eb32ec00a5ebaabc68cd2e64f1feeaf5f737 Mon Sep 17 00:00:00 2001 From: nessi Date: Tue, 17 Mar 2026 21:56:13 +0100 Subject: [PATCH] feat: add Windows service for elevated tunnel operations with IPC communication Add Windows service to handle WireGuard tunnel operations with elevated privileges. Implement IPC server on TCP port 53189 for client-service communication using JSON protocol. Add install-service and uninstall-service commands to NSIS installer hooks for automatic service installation. Replace direct WireGuard calls with IPC requests when running on Windows. Add TunnelRequest and TunnelResponse types for IPC protocol --- .../src-tauri/src/tunnel_manager.rs | 32 +- .../src-tauri/tauri.windows.conf.json | 8 +- desktop-client/src-tauri/windows/hooks.nsh | 7 + desktop-client/tunnel-helper/Cargo.toml | 5 + desktop-client/tunnel-helper/src/main.rs | 312 ++++++++++++++++-- 5 files changed, 329 insertions(+), 35 deletions(-) create mode 100644 desktop-client/src-tauri/windows/hooks.nsh diff --git a/desktop-client/src-tauri/src/tunnel_manager.rs b/desktop-client/src-tauri/src/tunnel_manager.rs index 0b267d9..473c8de 100644 --- a/desktop-client/src-tauri/src/tunnel_manager.rs +++ b/desktop-client/src-tauri/src/tunnel_manager.rs @@ -17,15 +17,15 @@ pub fn current_tunnel_strategy() -> &'static str { pub fn connect(app: &AppHandle, profile_path: &Path) -> Result<(), String> { let backend = bundled_backend(app)?; - let status = Command::new(backend) + let output = Command::new(backend) .arg("connect") .arg("--profile") .arg(profile_path) - .status() + .output() .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)); + if !output.status.success() { + return Err(format_helper_error("connect", &output)); } Ok(()) @@ -33,15 +33,15 @@ pub fn connect(app: &AppHandle, profile_path: &Path) -> Result<(), String> { pub fn disconnect(app: &AppHandle, profile_path: &Path) -> Result<(), String> { let backend = bundled_backend(app)?; - let status = Command::new(backend) + let output = Command::new(backend) .arg("disconnect") .arg("--profile") .arg(profile_path) - .status() + .output() .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)); + if !output.status.success() { + return Err(format_helper_error("disconnect", &output)); } Ok(()) @@ -68,3 +68,19 @@ fn bundled_backend(app: &AppHandle) -> Result { Ok(path) } + +fn format_helper_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!("NexaVPN tunnel backend {} failed with status {}", action, output.status); + } + + details.to_string() +} diff --git a/desktop-client/src-tauri/tauri.windows.conf.json b/desktop-client/src-tauri/tauri.windows.conf.json index 957ae3a..cc2a7d9 100644 --- a/desktop-client/src-tauri/tauri.windows.conf.json +++ b/desktop-client/src-tauri/tauri.windows.conf.json @@ -1,6 +1,12 @@ { "$schema": "https://schema.tauri.app/config/2", "bundle": { - "targets": ["nsis"] + "targets": ["nsis"], + "windows": { + "nsis": { + "installMode": "perMachine", + "installerHooks": "./windows/hooks.nsh" + } + } } } diff --git a/desktop-client/src-tauri/windows/hooks.nsh b/desktop-client/src-tauri/windows/hooks.nsh new file mode 100644 index 0000000..ff54271 --- /dev/null +++ b/desktop-client/src-tauri/windows/hooks.nsh @@ -0,0 +1,7 @@ +!macro NSIS_HOOK_POSTINSTALL + nsExec::ExecToLog '"$INSTDIR\resources\bundled\windows-x64\nexavpn-tunnel-helper.exe" install-service' +!macroend + +!macro NSIS_HOOK_PREUNINSTALL + nsExec::ExecToLog '"$INSTDIR\resources\bundled\windows-x64\nexavpn-tunnel-helper.exe" uninstall-service' +!macroend diff --git a/desktop-client/tunnel-helper/Cargo.toml b/desktop-client/tunnel-helper/Cargo.toml index b8c7e3f..63362f8 100644 --- a/desktop-client/tunnel-helper/Cargo.toml +++ b/desktop-client/tunnel-helper/Cargo.toml @@ -5,3 +5,8 @@ edition = "2021" description = "Bundled tunnel helper for NexaVPN" [dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[target.'cfg(windows)'.dependencies] +windows-service = "0.7" diff --git a/desktop-client/tunnel-helper/src/main.rs b/desktop-client/tunnel-helper/src/main.rs index c24a420..d12f766 100644 --- a/desktop-client/tunnel-helper/src/main.rs +++ b/desktop-client/tunnel-helper/src/main.rs @@ -1,15 +1,61 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use serde::{Deserialize, Serialize}; use std::{ env, + io::{BufRead, BufReader, Write}, + net::TcpStream, path::{Path, PathBuf}, - process::{Command, ExitCode}, + process::{Command, ExitCode, Output}, +}; + +#[cfg(target_os = "windows")] +use std::{ + ffi::OsString, + net::TcpListener, + sync::mpsc, + time::Duration, }; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; +#[cfg(target_os = "windows")] +use windows_service::{ + define_windows_service, + service::{ + ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl, ServiceExitCode, + ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType, + }, + service_control_handler::{self, ServiceControlHandlerResult}, + service_dispatcher, + service_manager::{ServiceManager, ServiceManagerAccess}, +}; + #[cfg(target_os = "windows")] const CREATE_NO_WINDOW: u32 = 0x08000000; +#[cfg(target_os = "windows")] +const SERVICE_NAME: &str = "NexaVPNTunnelService"; + +#[cfg(target_os = "windows")] +const SERVICE_DISPLAY_NAME: &str = "NexaVPN Tunnel Service"; + +#[cfg(target_os = "windows")] +const IPC_BIND_ADDR: &str = "127.0.0.1:53189"; + +#[derive(Debug, Serialize, Deserialize)] +struct TunnelRequest { + action: String, + profile: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct TunnelResponse { + ok: bool, + error: Option, +} + fn main() -> ExitCode { match run() { Ok(()) => ExitCode::SUCCESS, @@ -23,23 +69,253 @@ fn main() -> ExitCode { fn run() -> Result<(), String> { let mut args = env::args().skip(1); let command = args.next().ok_or_else(|| "missing command".to_string())?; + + #[cfg(target_os = "windows")] + { + return match command.as_str() { + "service" => run_windows_service_dispatcher(), + "install-service" => install_windows_service(), + "uninstall-service" => uninstall_windows_service(), + "connect" | "disconnect" => { + let profile = parse_profile_arg(args)?; + windows_client_request(command.as_str(), &profile) + } + _ => Err("unsupported command".into()), + }; + } + + #[cfg(not(target_os = "windows"))] + { + let profile = parse_profile_arg(args)?; + match command.as_str() { + "connect" => connect_direct(&profile), + "disconnect" => disconnect_direct(&profile), + _ => Err("unsupported command".into()), + } + } +} + +fn parse_profile_arg(mut args: impl Iterator) -> Result { let flag = args.next().ok_or_else(|| "missing --profile flag".to_string())?; if flag != "--profile" { return Err("expected --profile flag".into()); } - let profile = PathBuf::from(args.next().ok_or_else(|| "missing profile path".to_string())?); + Ok(PathBuf::from( + args.next().ok_or_else(|| "missing profile path".to_string())?, + )) +} - match command.as_str() { - "connect" => connect(&profile), - "disconnect" => disconnect(&profile), - _ => Err("unsupported command".into()), +#[cfg(target_os = "windows")] +fn run_windows_service_dispatcher() -> Result<(), String> { + service_dispatcher::start(SERVICE_NAME, ffi_service_main) + .map_err(|err| format!("Unable to start NexaVPN Windows service dispatcher: {err}")) +} + +#[cfg(target_os = "windows")] +define_windows_service!(ffi_service_main, service_main); + +#[cfg(target_os = "windows")] +fn service_main(_arguments: Vec) { + if let Err(err) = run_service_loop() { + eprintln!("{err}"); } } -fn connect(profile: &Path) -> Result<(), String> { +#[cfg(target_os = "windows")] +fn run_service_loop() -> Result<(), String> { + let (shutdown_tx, shutdown_rx) = mpsc::channel(); + let status_handle = service_control_handler::register(SERVICE_NAME, move |control_event| match control_event { + ServiceControl::Stop => { + let _ = shutdown_tx.send(()); + ServiceControlHandlerResult::NoError + } + ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, + _ => ServiceControlHandlerResult::NotImplemented, + }) + .map_err(|err| format!("Unable to register NexaVPN service handler: {err}"))?; + + status_handle + .set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: ServiceState::Running, + controls_accepted: ServiceControlAccept::STOP, + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + }) + .map_err(|err| format!("Unable to publish service status: {err}"))?; + + let listener = TcpListener::bind(IPC_BIND_ADDR) + .map_err(|err| format!("Unable to bind NexaVPN service IPC socket: {err}"))?; + listener + .set_nonblocking(true) + .map_err(|err| format!("Unable to configure IPC socket: {err}"))?; + + loop { + if shutdown_rx.try_recv().is_ok() { + break; + } + + match listener.accept() { + Ok((stream, _)) => { + let _ = handle_service_client(stream); + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(200)); + } + Err(err) => { + return Err(format!("IPC accept failed: {err}")); + } + } + } + + status_handle + .set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: ServiceState::Stopped, + controls_accepted: ServiceControlAccept::empty(), + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + }) + .map_err(|err| format!("Unable to publish stopped service state: {err}"))?; + + Ok(()) +} + +#[cfg(target_os = "windows")] +fn handle_service_client(mut stream: TcpStream) -> Result<(), String> { + let mut reader = BufReader::new( + stream + .try_clone() + .map_err(|err| format!("Unable to clone IPC socket: {err}"))?, + ); + let mut line = String::new(); + reader + .read_line(&mut line) + .map_err(|err| format!("Unable to read IPC request: {err}"))?; + + let request = serde_json::from_str::(&line) + .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 }, + Err(err) => TunnelResponse { + ok: false, + error: Some(err), + }, + }, + "disconnect" => match disconnect_direct(Path::new(&request.profile)) { + Ok(()) => TunnelResponse { ok: true, error: None }, + Err(err) => TunnelResponse { + ok: false, + error: Some(err), + }, + }, + _ => TunnelResponse { + ok: false, + error: Some("unsupported tunnel action".into()), + }, + }; + + let payload = serde_json::to_vec(&response).map_err(|err| format!("Unable to encode IPC response: {err}"))?; + stream + .write_all(&payload) + .and_then(|_| stream.write_all(b"\n")) + .map_err(|err| format!("Unable to write IPC response: {err}"))?; + Ok(()) +} + +#[cfg(target_os = "windows")] +fn install_windows_service() -> Result<(), String> { + let manager = ServiceManager::local_computer( + None::<&str>, + ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE, + ) + .map_err(|err| format!("Unable to connect to Service Control Manager: {err}"))?; + + let helper_path = env::current_exe().map_err(|err| format!("Unable to resolve helper path: {err}"))?; + let service_info = ServiceInfo { + name: OsString::from(SERVICE_NAME), + display_name: OsString::from(SERVICE_DISPLAY_NAME), + service_type: ServiceType::OWN_PROCESS, + start_type: ServiceStartType::Automatic, + error_control: ServiceErrorControl::Normal, + executable_path: helper_path, + launch_arguments: vec![OsString::from("service")], + dependencies: vec![], + account_name: None, + account_password: None, + }; + + let service = manager + .create_service(&service_info, ServiceAccess::CHANGE_CONFIG | ServiceAccess::START | ServiceAccess::QUERY_STATUS) + .or_else(|_| { + manager.open_service( + SERVICE_NAME, + ServiceAccess::CHANGE_CONFIG | ServiceAccess::START | ServiceAccess::QUERY_STATUS, + ) + }) + .map_err(|err| format!("Unable to install NexaVPN tunnel service: {err}"))?; + + let _ = service.start(&[]); + Ok(()) +} + +#[cfg(target_os = "windows")] +fn uninstall_windows_service() -> Result<(), String> { + let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT) + .map_err(|err| format!("Unable to connect to Service Control Manager: {err}"))?; + let service = manager + .open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE) + .map_err(|err| format!("Unable to open NexaVPN tunnel service: {err}"))?; + + let _ = service.stop(); + service + .delete() + .map_err(|err| format!("Unable to delete NexaVPN tunnel service: {err}")) +} + +#[cfg(target_os = "windows")] +fn windows_client_request(action: &str, profile: &Path) -> Result<(), String> { + 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: action.to_string(), + profile: profile.display().to_string(), + }) + .map_err(|err| format!("Unable to encode tunnel request: {err}"))?; + + stream + .write_all(payload.as_bytes()) + .and_then(|_| stream.write_all(b"\n")) + .map_err(|err| format!("Unable to send tunnel 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 service response: {err}"))?; + + let response = serde_json::from_str::(&line) + .map_err(|err| format!("Unable to decode tunnel service response: {err}"))?; + if response.ok { + return Ok(()); + } + + Err(response + .error + .unwrap_or_else(|| "NexaVPN tunnel service reported an unknown error".into())) +} + +fn connect_direct(profile: &Path) -> Result<(), String> { #[cfg(target_os = "windows")] { - ensure_windows_admin()?; let wireguard = find_windows_wireguard()?; let output = Command::new(wireguard) .arg("/installtunnelservice") @@ -71,10 +347,9 @@ fn connect(profile: &Path) -> Result<(), String> { Err("unsupported platform".into()) } -fn disconnect(profile: &Path) -> Result<(), String> { +fn disconnect_direct(profile: &Path) -> Result<(), String> { #[cfg(target_os = "windows")] { - ensure_windows_admin()?; let wireguard = find_windows_wireguard()?; let tunnel_name = profile .file_stem() @@ -124,22 +399,7 @@ fn find_windows_wireguard() -> Result { } #[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 { +fn format_windows_runtime_error(action: &str, output: &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() {