From cea4b0ef8842a9c061266701d55f298906a5be71 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 15 Apr 2026 22:14:36 +0300 Subject: [PATCH] feat(windows): add windows-service crate + SCM dispatcher scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets numa.exe act as a real Windows service registered with the SCM, replacing the HKLM\...\Run login-time autostart that runs in the user session without stderr capture. - New `numa::windows_service` module (cfg(windows)) wraps Mullvad's `windows-service` crate: registers with SCM, reports Running, handles Stop/Shutdown, reports Stopped. - `numa.exe --service` is the entry point SCM uses (`sc create … binPath="numa.exe --service"`); interactive invocations are unchanged. - Dep is gated `[target.'cfg(windows)'.dependencies]` — zero impact on macOS/Linux builds or binary size. Scaffold only. The service currently blocks on an mpsc channel until Stop arrives; the actual serve loop will hook in once main.rs's inline server body is extracted into `numa::serve(config_path)` in a follow-up. This lets `sc start Numa` / `sc stop Numa` be verified end to end today. --- Cargo.lock | 12 ++++++ Cargo.toml | 3 ++ src/lib.rs | 3 ++ src/main.rs | 8 ++++ src/windows_service.rs | 85 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+) create mode 100644 src/windows_service.rs diff --git a/Cargo.lock b/Cargo.lock index 9cd1b7d..cf25b3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1359,6 +1359,7 @@ dependencies = [ "toml", "tower", "webpki-roots 1.0.6", + "windows-service", "x509-parser", ] @@ -2583,6 +2584,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-service" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a" +dependencies = [ + "bitflags", + "widestring", + "windows-sys 0.52.0", +] + [[package]] name = "windows-strings" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 0b13af2..3b3234f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,9 @@ rustls-pemfile = "2.2.0" qrcode = { version = "0.14", default-features = false, features = ["svg"] } webpki-roots = "1" +[target.'cfg(windows)'.dependencies] +windows-service = "0.7" + [dev-dependencies] criterion = { version = "0.8", features = ["html_reports"] } tower = { version = "0.5", features = ["util"] } diff --git a/src/lib.rs b/src/lib.rs index 8933e2a..346c739 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,9 @@ pub mod system_dns; pub mod tls; pub mod wire; +#[cfg(windows)] +pub mod windows_service; + #[cfg(test)] pub(crate) mod testutil; diff --git a/src/main.rs b/src/main.rs index bce7add..0459005 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,14 @@ async fn main() -> numa::Result<()> { // Handle CLI subcommands let arg1 = std::env::args().nth(1).unwrap_or_default(); match arg1.as_str() { + #[cfg(windows)] + "--service" => { + // Entry point used by Windows SCM (`sc create … binPath="numa.exe --service"`). + // Hands control to the service dispatcher and blocks until Stop. + numa::windows_service::run_as_service() + .map_err(|e| format!("windows service dispatcher failed: {}", e))?; + return Ok(()); + } "install" => { eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — installing\n"); return install_service().map_err(|e| e.into()); diff --git a/src/windows_service.rs b/src/windows_service.rs new file mode 100644 index 0000000..8751f23 --- /dev/null +++ b/src/windows_service.rs @@ -0,0 +1,85 @@ +//! Windows service wrapper. +//! +//! Lets the `numa.exe` binary act as a real Windows service registered with +//! the Service Control Manager (SCM). Invoked via `numa.exe --service` (the +//! form that `sc create … binPath=` uses). +//! +//! Interactive runs (`numa.exe`, `numa.exe run`, `numa.exe install`) do not +//! go through this module — they keep their existing console-attached +//! behaviour. + +use std::ffi::OsString; +use std::sync::mpsc; +use std::time::Duration; + +use windows_service::service::{ + ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, ServiceType, +}; +use windows_service::service_control_handler::{self, ServiceControlHandlerResult}; +use windows_service::{define_windows_service, service_dispatcher}; + +pub const SERVICE_NAME: &str = "Numa"; + +define_windows_service!(ffi_service_main, service_main); + +/// Entry point the SCM hands control to after `StartServiceCtrlDispatcherW`. +/// Any panic here vanishes silently into the service host — log instead of +/// unwrapping. +fn service_main(_arguments: Vec) { + if let Err(e) = run_service() { + log::error!("numa service exited with error: {:?}", e); + } +} + +fn run_service() -> windows_service::Result<()> { + let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>(); + + let event_handler = move |control_event| -> ServiceControlHandlerResult { + match control_event { + ServiceControl::Stop | ServiceControl::Shutdown => { + let _ = shutdown_tx.send(()); + ServiceControlHandlerResult::NoError + } + ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, + _ => ServiceControlHandlerResult::NotImplemented, + } + }; + + let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)?; + + status_handle.set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: ServiceState::Running, + controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN, + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + + // TODO(windows-service): call numa's async serve loop here once main.rs's + // server body is extracted into `numa::serve(config_path)`. For now the + // service registers, reports Running, and blocks until SCM sends Stop — + // useful for verifying the SCM plumbing end to end with `sc start Numa` + // and `sc stop Numa`. + let _ = shutdown_rx.recv(); + + 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, + })?; + + Ok(()) +} + +/// Hand control to the SCM dispatcher. Blocks until the service stops. +/// Call only from the `--service` command path — interactive invocations +/// will hang here waiting for an SCM that isn't talking to them. +pub fn run_as_service() -> windows_service::Result<()> { + service_dispatcher::start(SERVICE_NAME, ffi_service_main) +}