Files
NexaVPN/desktop-client/tunnel-helper/src/main.rs
nessi a3e5eb32ec 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
2026-03-17 21:56:13 +01:00

417 lines
14 KiB
Rust

#![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<String>,
}
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<Item = String>) -> Result<PathBuf, String> {
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<OsString>) {
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::<TunnelRequest>(&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::<TunnelResponse>(&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<PathBuf, String> {
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)
}