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
417 lines
14 KiB
Rust
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)
|
|
}
|