#![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, 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, Err(err) => { eprintln!("{err}"); ExitCode::FAILURE } } } 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()); } Ok(PathBuf::from( args.next().ok_or_else(|| "missing profile path".to_string())?, )) } #[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}"); } } #[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")] { let wireguard = find_windows_wireguard()?; let output = Command::new(wireguard) .arg("/installtunnelservice") .arg(profile) .creation_flags(CREATE_NO_WINDOW) .output() .map_err(|err| format!("unable to launch WireGuard runtime: {err}"))?; if !output.status.success() { return Err(format_windows_runtime_error("connect", &output)); } return Ok(()); } #[cfg(target_os = "macos")] { let command = format!("wg-quick up '{}'", profile.display()); let status = Command::new("osascript") .arg("-e") .arg(format!("do shell script \"{}\" with administrator privileges", command)) .status() .map_err(|err| format!("unable to start tunnel: {err}"))?; if !status.success() { return Err(format!("macOS tunnel connect failed with status {status}")); } return Ok(()); } #[allow(unreachable_code)] Err("unsupported platform".into()) } fn disconnect_direct(profile: &Path) -> Result<(), String> { #[cfg(target_os = "windows")] { 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 output = Command::new(wireguard) .arg("/uninstalltunnelservice") .arg(tunnel_name) .creation_flags(CREATE_NO_WINDOW) .output() .map_err(|err| format!("unable to launch WireGuard runtime: {err}"))?; if !output.status.success() { return Err(format_windows_runtime_error("disconnect", &output)); } return Ok(()); } #[cfg(target_os = "macos")] { let command = format!("wg-quick down '{}'", profile.display()); let status = Command::new("osascript") .arg("-e") .arg(format!("do shell script \"{}\" with administrator privileges", command)) .status() .map_err(|err| format!("unable to stop tunnel: {err}"))?; if !status.success() { return Err(format!("macOS tunnel disconnect failed with status {status}")); } return Ok(()); } #[allow(unreachable_code)] Err("unsupported platform".into()) } #[cfg(target_os = "windows")] fn find_windows_wireguard() -> Result { let candidates = [ PathBuf::from(r"C:\Program Files\WireGuard\wireguard.exe"), PathBuf::from(r"C:\Program Files (x86)\WireGuard\wireguard.exe"), ]; candidates .into_iter() .find(|path| path.exists()) .ok_or_else(|| "required Windows tunnel runtime is not available".to_string()) } #[cfg(target_os = "windows")] 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() { 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) }