feat(windows): run as a real SCM service, not a Run-key autostart #107

Merged
razvandimescu merged 16 commits from feat/windows-service into main 2026-04-17 07:02:43 +08:00
2 changed files with 61 additions and 69 deletions
Showing only changes of commit 7bb484ada3 - Show all commits

View File

@@ -729,20 +729,24 @@ fn install_windows() -> Result<(), String> {
Ok(()) Ok(())
} }
#[cfg(windows)]
const WINDOWS_SERVICE_NAME: &str = "Numa";
/// Stable install location for the service binary. SCM keeps a handle to /// Stable install location for the service binary. SCM keeps a handle to
/// this path; the user's Downloads folder (where `current_exe()` points at /// this path; the user's Downloads folder (where `current_exe()` points at
/// install time) is not durable. /// install time) is not durable.
#[cfg(windows)] #[cfg(windows)]
fn windows_service_exe_path() -> std::path::PathBuf { fn windows_service_exe_path() -> std::path::PathBuf {
std::path::PathBuf::from( crate::data_dir().join("bin").join("numa.exe")
std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".into()), }
)
.join("numa") /// Run `sc.exe` with the given args and return its merged stdout/stderr on
.join("bin") /// failure. `sc` emits errors on stdout (not stderr) on Windows, so the
.join("numa.exe") /// caller reads stdout to format a useful error.
#[cfg(windows)]
fn run_sc(args: &[&str]) -> Result<std::process::Output, String> {
let out = std::process::Command::new("sc")
.args(args)
.output()
.map_err(|e| format!("failed to run sc {}: {}", args.first().unwrap_or(&""), e))?;
Ok(out)
} }
/// Copy the currently-running binary to the service install location. SCM /// Copy the currently-running binary to the service install location. SCM
@@ -782,13 +786,13 @@ fn remove_service_binary() {
#[cfg(windows)] #[cfg(windows)]
fn register_service_scm(exe: &std::path::Path) -> Result<(), String> { fn register_service_scm(exe: &std::path::Path) -> Result<(), String> {
let bin_path = format!("\"{}\" --service", exe.display()); let bin_path = format!("\"{}\" --service", exe.display());
let name = crate::windows_service::SERVICE_NAME;
// sc.exe uses a leading space as its `name= value` delimiter; the space // sc.exe uses a leading space as its `name= value` delimiter; the space
// after `=` is mandatory. // after `=` is mandatory.
let create = std::process::Command::new("sc") let create = run_sc(&[
.args([
"create", "create",
WINDOWS_SERVICE_NAME, name,
"binPath=", "binPath=",
&bin_path, &bin_path,
"DisplayName=", "DisplayName=",
@@ -797,9 +801,7 @@ fn register_service_scm(exe: &std::path::Path) -> Result<(), String> {
"auto", "auto",
"obj=", "obj=",
"LocalSystem", "LocalSystem",
]) ])?;
.output()
.map_err(|e| format!("failed to run sc create: {}", e))?;
if !create.status.success() { if !create.status.success() {
let out = String::from_utf8_lossy(&create.stdout); let out = String::from_utf8_lossy(&create.stdout);
// "service already exists" is 1073 — treat as idempotent success. // "service already exists" is 1073 — treat as idempotent success.
@@ -808,30 +810,23 @@ fn register_service_scm(exe: &std::path::Path) -> Result<(), String> {
} }
} }
let _ = std::process::Command::new("sc") let _ = run_sc(&[
.args([
"description", "description",
WINDOWS_SERVICE_NAME, name,
"Self-sovereign DNS resolver (ad blocking, DoH/DoT, local zones).", "Self-sovereign DNS resolver (ad blocking, DoH/DoT, local zones).",
]) ]);
.status();
// Restart on crash: 5s, 5s, 10s; reset failure counter after 60s. // Restart on crash: 5s, 5s, 10s; reset failure counter after 60s.
let _ = std::process::Command::new("sc") let _ = run_sc(&[
.args([
"failure", "failure",
WINDOWS_SERVICE_NAME, name,
"reset=", "reset=",
"60", "60",
"actions=", "actions=",
"restart/5000/restart/5000/restart/10000", "restart/5000/restart/5000/restart/10000",
]) ]);
.status();
eprintln!( eprintln!(" Registered service '{}' (boot-time).", name);
" Registered service '{}' (boot-time).",
WINDOWS_SERVICE_NAME
);
Ok(()) Ok(())
} }
@@ -840,10 +835,7 @@ fn register_service_scm(exe: &std::path::Path) -> Result<(), String> {
/// return the underlying error string rather than masking it. /// return the underlying error string rather than masking it.
#[cfg(windows)] #[cfg(windows)]
fn start_service_scm() -> Result<(), String> { fn start_service_scm() -> Result<(), String> {
let out = std::process::Command::new("sc") let out = run_sc(&["start", crate::windows_service::SERVICE_NAME])?;
.args(["start", WINDOWS_SERVICE_NAME])
.output()
.map_err(|e| format!("failed to run sc start: {}", e))?;
if !out.status.success() { if !out.status.success() {
let text = String::from_utf8_lossy(&out.stdout); let text = String::from_utf8_lossy(&out.stdout);
if text.contains("1056") { if text.contains("1056") {
@@ -854,20 +846,22 @@ fn start_service_scm() -> Result<(), String> {
Ok(()) Ok(())
} }
/// Stop the service. Returns Ok if already stopped — idempotent. /// Stop the service. Idempotent — already-stopped or missing service logs
/// a warning but doesn't error, since both callers (install re-run,
/// uninstall) want best-effort cleanup rather than hard failure.
#[cfg(windows)] #[cfg(windows)]
fn stop_service_scm() { fn stop_service_scm() {
let _ = std::process::Command::new("sc") if let Err(e) = run_sc(&["stop", crate::windows_service::SERVICE_NAME]) {
.args(["stop", WINDOWS_SERVICE_NAME]) log::warn!("sc stop failed: {}", e);
.status(); }
} }
/// Remove the service from SCM. Safe if already absent. /// Remove the service from SCM. Idempotent — see `stop_service_scm`.
#[cfg(windows)] #[cfg(windows)]
fn delete_service_scm() { fn delete_service_scm() {
let _ = std::process::Command::new("sc") if let Err(e) = run_sc(&["delete", crate::windows_service::SERVICE_NAME]) {
.args(["delete", WINDOWS_SERVICE_NAME]) log::warn!("sc delete failed: {}", e);
.status(); }
} }
#[cfg(windows)] #[cfg(windows)]

View File

@@ -62,7 +62,7 @@ fn run_service() -> windows_service::Result<()> {
// once the SCM tells us to stop — we can't block the dispatcher thread // once the SCM tells us to stop — we can't block the dispatcher thread
// forever without preventing graceful shutdown. // forever without preventing graceful shutdown.
let config_path = service_config_path(); let config_path = service_config_path();
let (runtime_stop_tx, runtime_stop_rx) = mpsc::channel::<()>(); let (server_done_tx, server_done_rx) = mpsc::channel::<()>();
let server_thread = std::thread::spawn(move || { let server_thread = std::thread::spawn(move || {
let runtime = match tokio::runtime::Builder::new_multi_thread() let runtime = match tokio::runtime::Builder::new_multi_thread()
@@ -72,28 +72,25 @@ fn run_service() -> windows_service::Result<()> {
Ok(rt) => rt, Ok(rt) => rt,
Err(e) => { Err(e) => {
log::error!("failed to build tokio runtime: {}", e); log::error!("failed to build tokio runtime: {}", e);
let _ = runtime_stop_tx.send(()); let _ = server_done_tx.send(());
return; return;
} }
}; };
// block_on returns when serve::run's UDP loop errors out OR when the
// runtime is dropped from another thread. Either signals exit.
if let Err(e) = runtime.block_on(crate::serve::run(config_path)) { if let Err(e) = runtime.block_on(crate::serve::run(config_path)) {
log::error!("numa serve exited with error: {}", e); log::error!("numa serve exited with error: {}", e);
} }
let _ = runtime_stop_tx.send(()); let _ = server_done_tx.send(());
}); });
// Wait for either SCM stop or server termination. // Wait for either SCM stop or server termination.
loop { loop {
if shutdown_rx.try_recv().is_ok() { if shutdown_rx.recv_timeout(Duration::from_millis(500)).is_ok() {
break; break;
} }
if runtime_stop_rx.try_recv().is_ok() { if server_done_rx.try_recv().is_ok() {
break; break;
} }
std::thread::sleep(Duration::from_millis(200));
} }
// The server's tokio runtime runs detached inside server_thread. Abandon // The server's tokio runtime runs detached inside server_thread. Abandon
@@ -124,9 +121,10 @@ pub fn run_as_service() -> windows_service::Result<()> {
/// Path to the config file used when running under SCM. SCM launches the /// Path to the config file used when running under SCM. SCM launches the
/// service with SYSTEM's working directory (usually `C:\Windows\System32`), /// service with SYSTEM's working directory (usually `C:\Windows\System32`),
/// so a relative `numa.toml` lookup won't find anything meaningful — use an /// so a relative `numa.toml` lookup won't find anything meaningful.
/// absolute path under `%PROGRAMDATA%` instead.
fn service_config_path() -> String { fn service_config_path() -> String {
let base = std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".into()); crate::data_dir()
format!("{}\\numa\\numa.toml", base) .join("numa.toml")
.to_string_lossy()
.into_owned()
} }