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
This commit is contained in:
2026-03-17 21:56:13 +01:00
parent 767c633afa
commit a3e5eb32ec
5 changed files with 329 additions and 35 deletions

View File

@@ -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<String>,
}
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<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());
}
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<OsString>) {
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::<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")]
{
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<PathBuf, 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 {
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() {