From cea4b0ef8842a9c061266701d55f298906a5be71 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 15 Apr 2026 22:14:36 +0300 Subject: [PATCH 01/20] 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) +} From b610160cd1855fc79770661a3ce457859073698f Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 15 Apr 2026 22:24:23 +0300 Subject: [PATCH 02/20] feat(windows): run numa as a real SCM service, drop Run-key autostart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hooks the service-dispatcher scaffolding from the previous commit to actually serve DNS, and replaces the HKLM\…\Run login-time autostart with a proper Windows service created via sc.exe. **Refactor** - Extract main.rs's inline server body (~500 lines) into `numa::serve::run` so both the interactive CLI entry and the service dispatcher drive the same startup/serve loop. main.rs is now a thin subcommand router. - main.rs goes sync (no #[tokio::main]); each branch that needs async builds its own runtime and block_on's. Required so the --service path can hand off to SCM without fighting tokio for the entry thread. **Windows service wrapper** - `numa::windows_service::run_service` now builds a multi-thread tokio runtime on a dedicated thread and runs `serve::run` inside it. Stop/ Shutdown from SCM aborts the wait loop and reports SERVICE_STOPPED. - Config path resolves to `%PROGRAMDATA%\numa\numa.toml` when running under SCM (SYSTEM's cwd is System32, relative paths don't work). **Install/uninstall** - `install_windows` now copies numa.exe to a stable `%PROGRAMDATA%\numa\bin\numa.exe` and registers it via `sc create` with start=auto, obj=LocalSystem, and a failure policy of restart/5000/restart/5000/restart/10000. Starts the service immediately when no reboot is pending. - `uninstall_windows` stops + deletes the service and removes the binary copy before restoring DNS. - Drops the old `register_autostart` / `remove_autostart` helpers that wrote to `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run` — that path runs at user login in the user's session with no stderr capture and no crash-restart policy, which is why we've been flying blind in every Windows debug session. DNS-set bugs (netsh destructive static, IPv6 not touched, uninstall secondary-drop) and file logging are orthogonal — tracked for follow-up. --- src/lib.rs | 1 + src/main.rs | 654 +---------------------------------------- src/serve.rs | 646 ++++++++++++++++++++++++++++++++++++++++ src/system_dns.rs | 185 ++++++++++-- src/windows_service.rs | 59 +++- 5 files changed, 868 insertions(+), 677 deletions(-) create mode 100644 src/serve.rs diff --git a/src/lib.rs b/src/lib.rs index 346c739..0370c37 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub mod query_log; pub mod question; pub mod record; pub mod recursive; +pub mod serve; pub mod service_store; pub mod setup_phone; pub mod srtt; diff --git a/src/main.rs b/src/main.rs index 0459005..88f2128 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,30 +1,6 @@ -use std::net::SocketAddr; -use std::sync::{Arc, Mutex, RwLock}; -use std::time::Duration; +use numa::system_dns::{install_service, restart_service, service_status, uninstall_service}; -use arc_swap::ArcSwap; -use log::{error, info}; -use tokio::net::UdpSocket; - -use numa::blocklist::{download_blocklists, parse_blocklist, BlocklistStore}; -use numa::buffer::BytePacketBuffer; -use numa::cache::DnsCache; -use numa::config::{build_zone_map, load_config, ConfigLoad}; -use numa::ctx::{handle_query, ServerCtx}; -use numa::forward::{parse_upstream, Upstream, UpstreamPool}; -use numa::override_store::OverrideStore; -use numa::query_log::QueryLog; -use numa::service_store::ServiceStore; -use numa::stats::{ServerStats, Transport}; -use numa::system_dns::{ - discover_system_dns, install_service, restart_service, service_status, uninstall_service, -}; - -const QUAD9_IP: &str = "9.9.9.9"; -const DOH_FALLBACK: &str = "https://9.9.9.9/dns-query"; - -#[tokio::main] -async fn main() -> numa::Result<()> { +fn main() -> numa::Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) .format_timestamp_millis() .init(); @@ -35,7 +11,7 @@ async fn main() -> numa::Result<()> { #[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. + // Blocks until SCM sends Stop; never returns normally. numa::windows_service::run_as_service() .map_err(|e| format!("windows service dispatcher failed: {}", e))?; return Ok(()); @@ -63,7 +39,12 @@ async fn main() -> numa::Result<()> { }; } "setup-phone" => { - return numa::setup_phone::run().await.map_err(|e| e.into()); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + return runtime + .block_on(numa::setup_phone::run()) + .map_err(|e| e.into()); } "lan" => { let sub = std::env::args().nth(2).unwrap_or_default(); @@ -126,552 +107,11 @@ async fn main() -> numa::Result<()> { } else { arg1 // treat as config path for backwards compatibility }; - let ConfigLoad { - config, - path: resolved_config_path, - found: config_found, - } = load_config(&config_path)?; - // Discover system DNS in a single pass (upstream + forwarding rules) - let system_dns = discover_system_dns(); - - let root_hints = numa::recursive::parse_root_hints(&config.upstream.root_hints); - - let recursive_pool = || { - let dummy = UpstreamPool::new(vec![Upstream::Udp("0.0.0.0:0".parse().unwrap())], vec![]); - (dummy, "recursive (root hints)".to_string()) - }; - - let (resolved_mode, upstream_auto, pool, upstream_label) = match config.upstream.mode { - numa::config::UpstreamMode::Auto => { - info!("auto mode: probing recursive resolution..."); - if numa::recursive::probe_recursive(&root_hints).await { - info!("recursive probe succeeded — self-sovereign mode"); - let (pool, label) = recursive_pool(); - (numa::config::UpstreamMode::Recursive, false, pool, label) - } else { - log::warn!("recursive probe failed — falling back to Quad9 DoH"); - let client = reqwest::Client::builder() - .use_rustls_tls() - .build() - .unwrap_or_default(); - let url = DOH_FALLBACK.to_string(); - let label = url.clone(); - let pool = UpstreamPool::new(vec![Upstream::Doh { url, client }], vec![]); - (numa::config::UpstreamMode::Forward, false, pool, label) - } - } - numa::config::UpstreamMode::Recursive => { - let (pool, label) = recursive_pool(); - (numa::config::UpstreamMode::Recursive, false, pool, label) - } - numa::config::UpstreamMode::Forward => { - let addrs = if config.upstream.address.is_empty() { - let detected = system_dns - .default_upstream - .or_else(numa::system_dns::detect_dhcp_dns) - .unwrap_or_else(|| { - info!("could not detect system DNS, falling back to Quad9 DoH"); - DOH_FALLBACK.to_string() - }); - vec![detected] - } else { - config.upstream.address.clone() - }; - - let primary: Vec = addrs - .iter() - .map(|s| parse_upstream(s, config.upstream.port)) - .collect::>>()?; - let fallback: Vec = config - .upstream - .fallback - .iter() - .map(|s| parse_upstream(s, config.upstream.port)) - .collect::>>()?; - - let pool = UpstreamPool::new(primary, fallback); - let label = pool.label(); - ( - numa::config::UpstreamMode::Forward, - config.upstream.address.is_empty(), - pool, - label, - ) - } - }; - let api_port = config.server.api_port; - - let mut blocklist = BlocklistStore::new(); - for domain in &config.blocking.allowlist { - blocklist.add_to_allowlist(domain); - } - if !config.blocking.enabled { - blocklist.set_enabled(false); - } - - // Build service store: config services + persisted user services - let mut service_store = ServiceStore::new(); - service_store.insert_from_config("numa", config.server.api_port, Vec::new()); - for svc in &config.services { - service_store.insert_from_config(&svc.name, svc.target_port, svc.routes.clone()); - } - service_store.load_persisted(); - - for fwd in &config.forwarding { - for suffix in &fwd.suffix { - info!("forwarding .{} to {} (config rule)", suffix, fwd.upstream); - } - } - let forwarding_rules = - numa::config::merge_forwarding_rules(&config.forwarding, system_dns.forwarding_rules)?; - - // Resolve data_dir from config, falling back to the platform default. - // Used for TLS CA storage below and stored on ServerCtx for runtime use. - let resolved_data_dir = config - .server - .data_dir - .clone() - .unwrap_or_else(numa::data_dir); - - // Build initial TLS config before ServerCtx (so ArcSwap is ready at construction) - let initial_tls = if config.proxy.enabled && config.proxy.tls_port > 0 { - let service_names = service_store.names(); - match numa::tls::build_tls_config( - &config.proxy.tld, - &service_names, - Vec::new(), - &resolved_data_dir, - ) { - Ok(tls_config) => Some(ArcSwap::from(tls_config)), - Err(e) => { - if let Some(advisory) = numa::tls::try_data_dir_advisory(&e, &resolved_data_dir) { - eprint!("{}", advisory); - } else { - log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e); - } - None - } - } - } else { - None - }; - - let doh_enabled = initial_tls.is_some(); - let health_meta = numa::health::HealthMeta::build( - &resolved_data_dir, - config.dot.enabled, - config.dot.port, - config.mobile.port, - config.dnssec.enabled, - resolved_mode == numa::config::UpstreamMode::Recursive, - config.lan.enabled, - config.blocking.enabled, - doh_enabled, - ); - - let ca_pem = std::fs::read_to_string(resolved_data_dir.join("ca.pem")).ok(); - - let socket = match UdpSocket::bind(&config.server.bind_addr).await { - Ok(s) => s, - Err(e) => { - if let Some(advisory) = - numa::system_dns::try_port53_advisory(&config.server.bind_addr, &e) - { - eprint!("{}", advisory); - std::process::exit(1); - } - return Err(e.into()); - } - }; - - let ctx = Arc::new(ServerCtx { - socket, - zone_map: build_zone_map(&config.zones)?, - cache: RwLock::new(DnsCache::new( - config.cache.max_entries, - config.cache.min_ttl, - config.cache.max_ttl, - )), - refreshing: Mutex::new(std::collections::HashSet::new()), - stats: Mutex::new(ServerStats::new()), - overrides: RwLock::new(OverrideStore::new()), - blocklist: RwLock::new(blocklist), - query_log: Mutex::new(QueryLog::new(1000)), - services: Mutex::new(service_store), - lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)), - forwarding_rules, - upstream_pool: Mutex::new(pool), - upstream_auto, - upstream_port: config.upstream.port, - lan_ip: Mutex::new(numa::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)), - timeout: Duration::from_millis(config.upstream.timeout_ms), - hedge_delay: Duration::from_millis(config.upstream.hedge_ms), - proxy_tld_suffix: if config.proxy.tld.is_empty() { - String::new() - } else { - format!(".{}", config.proxy.tld) - }, - proxy_tld: config.proxy.tld.clone(), - lan_enabled: config.lan.enabled, - config_path: resolved_config_path, - config_found, - config_dir: numa::config_dir(), - data_dir: resolved_data_dir, - tls_config: initial_tls, - upstream_mode: resolved_mode, - root_hints, - srtt: std::sync::RwLock::new(numa::srtt::SrttCache::new(config.upstream.srtt)), - inflight: std::sync::Mutex::new(std::collections::HashMap::new()), - dnssec_enabled: config.dnssec.enabled, - dnssec_strict: config.dnssec.strict, - health_meta, - ca_pem, - mobile_enabled: config.mobile.enabled, - mobile_port: config.mobile.port, - }); - - let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); - // Build banner rows, then size the box to fit the longest value - let api_url = format!("http://localhost:{}", api_port); - let proxy_label = if config.proxy.enabled { - if config.proxy.tls_port > 0 { - Some(format!( - "http://:{} https://:{}", - config.proxy.port, config.proxy.tls_port - )) - } else { - Some(format!( - "http://*.{} on :{}", - config.proxy.tld, config.proxy.port - )) - } - } else { - None - }; - let config_label = if ctx.config_found { - ctx.config_path.clone() - } else { - format!("{} (defaults)", ctx.config_path) - }; - let data_label = ctx.data_dir.display().to_string(); - let services_label = ctx.config_dir.join("services.json").display().to_string(); - - // label (10) + value + padding (2) = inner width; minimum 40 for the title row - let val_w = [ - config.server.bind_addr.len(), - api_url.len(), - upstream_label.len(), - config_label.len(), - data_label.len(), - services_label.len(), - ] - .into_iter() - .chain(proxy_label.as_ref().map(|s| s.len())) - .max() - .unwrap_or(30); - let w = (val_w + 12).max(42); // 10 label + 2 padding, min 42 for title - - let o = "\x1b[38;2;192;98;58m"; // orange - let g = "\x1b[38;2;107;124;78m"; // green - let d = "\x1b[38;2;163;152;136m"; // dim - let r = "\x1b[0m"; // reset - let b = "\x1b[1;38;2;192;98;58m"; // bold orange - let it = "\x1b[3;38;2;163;152;136m"; // italic dim - - let bar_top = "═".repeat(w); - let bar_mid = "─".repeat(w); - let row = |label: &str, color: &str, value: &str| { - eprintln!( - "{o} ║{r} {color}{:<9}{r} {: 0 && ctx.tls_config.is_some() { - let proxy_ctx = Arc::clone(&ctx); - let tls_port = config.proxy.tls_port; - tokio::spawn(async move { - numa::proxy::start_proxy_tls(proxy_ctx, tls_port, proxy_bind).await; - }); - } - - // Spawn network change watcher (upstream re-detection, LAN IP update, peer flush) - { - let watch_ctx = Arc::clone(&ctx); - tokio::spawn(async move { - network_watch_loop(watch_ctx).await; - }); - } - - // Spawn LAN service discovery - if config.lan.enabled { - let lan_ctx = Arc::clone(&ctx); - let lan_config = config.lan.clone(); - tokio::spawn(async move { - numa::lan::start_lan_discovery(lan_ctx, &lan_config).await; - }); - } - - // Spawn DNS-over-TLS listener (RFC 7858) - if config.dot.enabled { - let dot_ctx = Arc::clone(&ctx); - let dot_config = config.dot.clone(); - tokio::spawn(async move { - numa::dot::start_dot(dot_ctx, &dot_config).await; - }); - } - - // UDP DNS listener - #[allow(clippy::infinite_loop)] - loop { - let mut buffer = BytePacketBuffer::new(); - let (len, src_addr) = match ctx.socket.recv_from(&mut buffer.buf).await { - Ok(r) => r, - Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => { - // Windows delivers ICMP port-unreachable as ConnectionReset on UDP sockets - continue; - } - Err(e) => return Err(e.into()), - }; - let ctx = Arc::clone(&ctx); - tokio::spawn(async move { - if let Err(e) = handle_query(buffer, len, src_addr, &ctx, Transport::Udp).await { - error!("{} | HANDLER ERROR | {}", src_addr, e); - } - }); - } -} - -async fn network_watch_loop(ctx: Arc) { - let mut tick: u64 = 0; - - let mut interval = tokio::time::interval(Duration::from_secs(5)); - interval.tick().await; // skip immediate tick - - loop { - interval.tick().await; - tick += 1; - let mut changed = false; - - // Check LAN IP change (every 5s — cheap, one UDP socket call) - if let Some(new_ip) = numa::lan::detect_lan_ip() { - let mut current_ip = ctx.lan_ip.lock().unwrap(); - if new_ip != *current_ip { - info!("LAN IP changed: {} → {}", current_ip, new_ip); - *current_ip = new_ip; - changed = true; - numa::recursive::reset_udp_state(); - } - } - - // Re-detect upstream every 30s or on LAN IP change (auto-detect only) - if ctx.upstream_auto && (changed || tick.is_multiple_of(6)) { - let dns_info = numa::system_dns::discover_system_dns(); - let new_addr = dns_info - .default_upstream - .or_else(numa::system_dns::detect_dhcp_dns) - .unwrap_or_else(|| QUAD9_IP.to_string()); - let mut pool = ctx.upstream_pool.lock().unwrap(); - if pool.maybe_update_primary(&new_addr, ctx.upstream_port) { - info!("upstream changed → {}", pool.label()); - changed = true; - } - } - - // Flush stale LAN peers on any network change - if changed { - ctx.lan_peers.lock().unwrap().clear(); - info!("flushed LAN peers after network change"); - } - - // Re-probe UDP every 5 minutes when disabled - if tick.is_multiple_of(60) { - numa::recursive::probe_udp(&ctx.root_hints).await; - } - } + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + runtime.block_on(numa::serve::run(config_path)) } fn set_lan_enabled(enabled: bool, path: &str) -> numa::Result<()> { @@ -738,71 +178,3 @@ fn print_lan_status(enabled: bool) { eprintln!(" Restart Numa to start mDNS discovery"); } } - -async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) { - let downloaded = download_blocklists(lists).await; - - // Parse outside the lock to avoid blocking DNS queries during parse (~100ms) - let mut all_domains = std::collections::HashSet::new(); - let mut sources = Vec::new(); - for (source, text) in &downloaded { - let domains = parse_blocklist(text); - info!("blocklist: {} domains from {}", domains.len(), source); - all_domains.extend(domains); - sources.push(source.clone()); - } - let total = all_domains.len(); - - // Swap under lock — sub-microsecond - ctx.blocklist - .write() - .unwrap() - .swap_domains(all_domains, sources); - info!( - "blocking enabled: {} unique domains from {} lists", - total, - downloaded.len() - ); -} - -async fn warm_domain(ctx: &ServerCtx, domain: &str) { - for qtype in [ - numa::question::QueryType::A, - numa::question::QueryType::AAAA, - ] { - numa::ctx::refresh_entry(ctx, domain, qtype).await; - } -} - -async fn doh_keepalive_loop(ctx: Arc) { - let mut interval = tokio::time::interval(Duration::from_secs(25)); - interval.tick().await; // skip first immediate tick - loop { - interval.tick().await; - let pool = ctx.upstream_pool.lock().unwrap().clone(); - if let Some(upstream) = pool.preferred() { - numa::forward::keepalive_doh(upstream).await; - } - } -} - -async fn cache_warm_loop(ctx: Arc, domains: Vec) { - tokio::time::sleep(Duration::from_secs(2)).await; - - for domain in &domains { - warm_domain(&ctx, domain).await; - } - info!("cache warm: {} domains resolved at startup", domains.len()); - - let mut interval = tokio::time::interval(Duration::from_secs(30)); - interval.tick().await; - loop { - interval.tick().await; - for domain in &domains { - let refresh = ctx.cache.read().unwrap().needs_warm(domain); - if refresh { - warm_domain(&ctx, domain).await; - } - } - } -} diff --git a/src/serve.rs b/src/serve.rs new file mode 100644 index 0000000..db0465b --- /dev/null +++ b/src/serve.rs @@ -0,0 +1,646 @@ +//! The main DNS-server runtime. +//! +//! Extracted from `main.rs` so both the interactive CLI entry and the +//! Windows service dispatcher (`windows_service` module) can drive the +//! same startup/serve loop. + +use std::net::SocketAddr; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::Duration; + +use arc_swap::ArcSwap; +use log::{error, info}; +use tokio::net::UdpSocket; + +use crate::blocklist::{download_blocklists, parse_blocklist, BlocklistStore}; +use crate::buffer::BytePacketBuffer; +use crate::cache::DnsCache; +use crate::config::{build_zone_map, load_config, ConfigLoad}; +use crate::ctx::{handle_query, ServerCtx}; +use crate::forward::{parse_upstream, Upstream, UpstreamPool}; +use crate::override_store::OverrideStore; +use crate::query_log::QueryLog; +use crate::service_store::ServiceStore; +use crate::stats::{ServerStats, Transport}; +use crate::system_dns::discover_system_dns; + +const QUAD9_IP: &str = "9.9.9.9"; +const DOH_FALLBACK: &str = "https://9.9.9.9/dns-query"; + +/// Boot the DNS server and run until the UDP listener errors out. +pub async fn run(config_path: String) -> crate::Result<()> { + let ConfigLoad { + config, + path: resolved_config_path, + found: config_found, + } = load_config(&config_path)?; + + // Discover system DNS in a single pass (upstream + forwarding rules) + let system_dns = discover_system_dns(); + + let root_hints = crate::recursive::parse_root_hints(&config.upstream.root_hints); + + let recursive_pool = || { + let dummy = UpstreamPool::new(vec![Upstream::Udp("0.0.0.0:0".parse().unwrap())], vec![]); + (dummy, "recursive (root hints)".to_string()) + }; + + let (resolved_mode, upstream_auto, pool, upstream_label) = match config.upstream.mode { + crate::config::UpstreamMode::Auto => { + info!("auto mode: probing recursive resolution..."); + if crate::recursive::probe_recursive(&root_hints).await { + info!("recursive probe succeeded — self-sovereign mode"); + let (pool, label) = recursive_pool(); + (crate::config::UpstreamMode::Recursive, false, pool, label) + } else { + log::warn!("recursive probe failed — falling back to Quad9 DoH"); + let client = reqwest::Client::builder() + .use_rustls_tls() + .build() + .unwrap_or_default(); + let url = DOH_FALLBACK.to_string(); + let label = url.clone(); + let pool = UpstreamPool::new(vec![Upstream::Doh { url, client }], vec![]); + (crate::config::UpstreamMode::Forward, false, pool, label) + } + } + crate::config::UpstreamMode::Recursive => { + let (pool, label) = recursive_pool(); + (crate::config::UpstreamMode::Recursive, false, pool, label) + } + crate::config::UpstreamMode::Forward => { + let addrs = if config.upstream.address.is_empty() { + let detected = system_dns + .default_upstream + .or_else(crate::system_dns::detect_dhcp_dns) + .unwrap_or_else(|| { + info!("could not detect system DNS, falling back to Quad9 DoH"); + DOH_FALLBACK.to_string() + }); + vec![detected] + } else { + config.upstream.address.clone() + }; + + let primary: Vec = addrs + .iter() + .map(|s| parse_upstream(s, config.upstream.port)) + .collect::>>()?; + let fallback: Vec = config + .upstream + .fallback + .iter() + .map(|s| parse_upstream(s, config.upstream.port)) + .collect::>>()?; + + let pool = UpstreamPool::new(primary, fallback); + let label = pool.label(); + ( + crate::config::UpstreamMode::Forward, + config.upstream.address.is_empty(), + pool, + label, + ) + } + }; + let api_port = config.server.api_port; + + let mut blocklist = BlocklistStore::new(); + for domain in &config.blocking.allowlist { + blocklist.add_to_allowlist(domain); + } + if !config.blocking.enabled { + blocklist.set_enabled(false); + } + + // Build service store: config services + persisted user services + let mut service_store = ServiceStore::new(); + service_store.insert_from_config("numa", config.server.api_port, Vec::new()); + for svc in &config.services { + service_store.insert_from_config(&svc.name, svc.target_port, svc.routes.clone()); + } + service_store.load_persisted(); + + for fwd in &config.forwarding { + for suffix in &fwd.suffix { + info!("forwarding .{} to {} (config rule)", suffix, fwd.upstream); + } + } + let forwarding_rules = + crate::config::merge_forwarding_rules(&config.forwarding, system_dns.forwarding_rules)?; + + // Resolve data_dir from config, falling back to the platform default. + // Used for TLS CA storage below and stored on ServerCtx for runtime use. + let resolved_data_dir = config + .server + .data_dir + .clone() + .unwrap_or_else(crate::data_dir); + + // Build initial TLS config before ServerCtx (so ArcSwap is ready at construction) + let initial_tls = if config.proxy.enabled && config.proxy.tls_port > 0 { + let service_names = service_store.names(); + match crate::tls::build_tls_config( + &config.proxy.tld, + &service_names, + Vec::new(), + &resolved_data_dir, + ) { + Ok(tls_config) => Some(ArcSwap::from(tls_config)), + Err(e) => { + if let Some(advisory) = crate::tls::try_data_dir_advisory(&e, &resolved_data_dir) { + eprint!("{}", advisory); + } else { + log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e); + } + None + } + } + } else { + None + }; + + let doh_enabled = initial_tls.is_some(); + let health_meta = crate::health::HealthMeta::build( + &resolved_data_dir, + config.dot.enabled, + config.dot.port, + config.mobile.port, + config.dnssec.enabled, + resolved_mode == crate::config::UpstreamMode::Recursive, + config.lan.enabled, + config.blocking.enabled, + doh_enabled, + ); + + let ca_pem = std::fs::read_to_string(resolved_data_dir.join("ca.pem")).ok(); + + let socket = match UdpSocket::bind(&config.server.bind_addr).await { + Ok(s) => s, + Err(e) => { + if let Some(advisory) = + crate::system_dns::try_port53_advisory(&config.server.bind_addr, &e) + { + eprint!("{}", advisory); + std::process::exit(1); + } + return Err(e.into()); + } + }; + + let ctx = Arc::new(ServerCtx { + socket, + zone_map: build_zone_map(&config.zones)?, + cache: RwLock::new(DnsCache::new( + config.cache.max_entries, + config.cache.min_ttl, + config.cache.max_ttl, + )), + refreshing: Mutex::new(std::collections::HashSet::new()), + stats: Mutex::new(ServerStats::new()), + overrides: RwLock::new(OverrideStore::new()), + blocklist: RwLock::new(blocklist), + query_log: Mutex::new(QueryLog::new(1000)), + services: Mutex::new(service_store), + lan_peers: Mutex::new(crate::lan::PeerStore::new(config.lan.peer_timeout_secs)), + forwarding_rules, + upstream_pool: Mutex::new(pool), + upstream_auto, + upstream_port: config.upstream.port, + lan_ip: Mutex::new(crate::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)), + timeout: Duration::from_millis(config.upstream.timeout_ms), + hedge_delay: Duration::from_millis(config.upstream.hedge_ms), + proxy_tld_suffix: if config.proxy.tld.is_empty() { + String::new() + } else { + format!(".{}", config.proxy.tld) + }, + proxy_tld: config.proxy.tld.clone(), + lan_enabled: config.lan.enabled, + config_path: resolved_config_path, + config_found, + config_dir: crate::config_dir(), + data_dir: resolved_data_dir, + tls_config: initial_tls, + upstream_mode: resolved_mode, + root_hints, + srtt: std::sync::RwLock::new(crate::srtt::SrttCache::new(config.upstream.srtt)), + inflight: std::sync::Mutex::new(std::collections::HashMap::new()), + dnssec_enabled: config.dnssec.enabled, + dnssec_strict: config.dnssec.strict, + health_meta, + ca_pem, + mobile_enabled: config.mobile.enabled, + mobile_port: config.mobile.port, + }); + + let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); + // Build banner rows, then size the box to fit the longest value + let api_url = format!("http://localhost:{}", api_port); + let proxy_label = if config.proxy.enabled { + if config.proxy.tls_port > 0 { + Some(format!( + "http://:{} https://:{}", + config.proxy.port, config.proxy.tls_port + )) + } else { + Some(format!( + "http://*.{} on :{}", + config.proxy.tld, config.proxy.port + )) + } + } else { + None + }; + let config_label = if ctx.config_found { + ctx.config_path.clone() + } else { + format!("{} (defaults)", ctx.config_path) + }; + let data_label = ctx.data_dir.display().to_string(); + let services_label = ctx.config_dir.join("services.json").display().to_string(); + + // label (10) + value + padding (2) = inner width; minimum 40 for the title row + let val_w = [ + config.server.bind_addr.len(), + api_url.len(), + upstream_label.len(), + config_label.len(), + data_label.len(), + services_label.len(), + ] + .into_iter() + .chain(proxy_label.as_ref().map(|s| s.len())) + .max() + .unwrap_or(30); + let w = (val_w + 12).max(42); // 10 label + 2 padding, min 42 for title + + let o = "\x1b[38;2;192;98;58m"; // orange + let g = "\x1b[38;2;107;124;78m"; // green + let d = "\x1b[38;2;163;152;136m"; // dim + let r = "\x1b[0m"; // reset + let b = "\x1b[1;38;2;192;98;58m"; // bold orange + let it = "\x1b[3;38;2;163;152;136m"; // italic dim + + let bar_top = "═".repeat(w); + let bar_mid = "─".repeat(w); + let row = |label: &str, color: &str, value: &str| { + eprintln!( + "{o} ║{r} {color}{:<9}{r} {: 0 && ctx.tls_config.is_some() { + let proxy_ctx = Arc::clone(&ctx); + let tls_port = config.proxy.tls_port; + tokio::spawn(async move { + crate::proxy::start_proxy_tls(proxy_ctx, tls_port, proxy_bind).await; + }); + } + + // Spawn network change watcher (upstream re-detection, LAN IP update, peer flush) + { + let watch_ctx = Arc::clone(&ctx); + tokio::spawn(async move { + network_watch_loop(watch_ctx).await; + }); + } + + // Spawn LAN service discovery + if config.lan.enabled { + let lan_ctx = Arc::clone(&ctx); + let lan_config = config.lan.clone(); + tokio::spawn(async move { + crate::lan::start_lan_discovery(lan_ctx, &lan_config).await; + }); + } + + // Spawn DNS-over-TLS listener (RFC 7858) + if config.dot.enabled { + let dot_ctx = Arc::clone(&ctx); + let dot_config = config.dot.clone(); + tokio::spawn(async move { + crate::dot::start_dot(dot_ctx, &dot_config).await; + }); + } + + // UDP DNS listener + #[allow(clippy::infinite_loop)] + loop { + let mut buffer = BytePacketBuffer::new(); + let (len, src_addr) = match ctx.socket.recv_from(&mut buffer.buf).await { + Ok(r) => r, + Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => { + // Windows delivers ICMP port-unreachable as ConnectionReset on UDP sockets + continue; + } + Err(e) => return Err(e.into()), + }; + let ctx = Arc::clone(&ctx); + tokio::spawn(async move { + if let Err(e) = handle_query(buffer, len, src_addr, &ctx, Transport::Udp).await { + error!("{} | HANDLER ERROR | {}", src_addr, e); + } + }); + } +} + +async fn network_watch_loop(ctx: Arc) { + let mut tick: u64 = 0; + + let mut interval = tokio::time::interval(Duration::from_secs(5)); + interval.tick().await; // skip immediate tick + + loop { + interval.tick().await; + tick += 1; + let mut changed = false; + + // Check LAN IP change (every 5s — cheap, one UDP socket call) + if let Some(new_ip) = crate::lan::detect_lan_ip() { + let mut current_ip = ctx.lan_ip.lock().unwrap(); + if new_ip != *current_ip { + info!("LAN IP changed: {} → {}", current_ip, new_ip); + *current_ip = new_ip; + changed = true; + crate::recursive::reset_udp_state(); + } + } + + // Re-detect upstream every 30s or on LAN IP change (auto-detect only) + if ctx.upstream_auto && (changed || tick.is_multiple_of(6)) { + let dns_info = crate::system_dns::discover_system_dns(); + let new_addr = dns_info + .default_upstream + .or_else(crate::system_dns::detect_dhcp_dns) + .unwrap_or_else(|| QUAD9_IP.to_string()); + let mut pool = ctx.upstream_pool.lock().unwrap(); + if pool.maybe_update_primary(&new_addr, ctx.upstream_port) { + info!("upstream changed → {}", pool.label()); + changed = true; + } + } + + // Flush stale LAN peers on any network change + if changed { + ctx.lan_peers.lock().unwrap().clear(); + info!("flushed LAN peers after network change"); + } + + // Re-probe UDP every 5 minutes when disabled + if tick.is_multiple_of(60) { + crate::recursive::probe_udp(&ctx.root_hints).await; + } + } +} + +async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) { + let downloaded = download_blocklists(lists).await; + + // Parse outside the lock to avoid blocking DNS queries during parse (~100ms) + let mut all_domains = std::collections::HashSet::new(); + let mut sources = Vec::new(); + for (source, text) in &downloaded { + let domains = parse_blocklist(text); + info!("blocklist: {} domains from {}", domains.len(), source); + all_domains.extend(domains); + sources.push(source.clone()); + } + let total = all_domains.len(); + + // Swap under lock — sub-microsecond + ctx.blocklist + .write() + .unwrap() + .swap_domains(all_domains, sources); + info!( + "blocking enabled: {} unique domains from {} lists", + total, + downloaded.len() + ); +} + +async fn warm_domain(ctx: &ServerCtx, domain: &str) { + for qtype in [ + crate::question::QueryType::A, + crate::question::QueryType::AAAA, + ] { + crate::ctx::refresh_entry(ctx, domain, qtype).await; + } +} + +async fn doh_keepalive_loop(ctx: Arc) { + let mut interval = tokio::time::interval(Duration::from_secs(25)); + interval.tick().await; // skip first immediate tick + loop { + interval.tick().await; + let pool = ctx.upstream_pool.lock().unwrap().clone(); + if let Some(upstream) = pool.preferred() { + crate::forward::keepalive_doh(upstream).await; + } + } +} + +async fn cache_warm_loop(ctx: Arc, domains: Vec) { + tokio::time::sleep(Duration::from_secs(2)).await; + + for domain in &domains { + warm_domain(&ctx, domain).await; + } + info!("cache warm: {} domains resolved at startup", domains.len()); + + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.tick().await; + loop { + interval.tick().await; + for domain in &domains { + let refresh = ctx.cache.read().unwrap().needs_warm(domain); + if refresh { + warm_domain(&ctx, domain).await; + } + } + } +} diff --git a/src/system_dns.rs b/src/system_dns.rs index 96ae372..b39f661 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -697,7 +697,23 @@ fn install_windows() -> Result<(), String> { } let needs_reboot = disable_dnscache()?; - register_autostart(); + + // Copy the binary to a stable path under ProgramData and register it + // as a real Windows service (SCM-managed, boot-time, auto-restart). + let service_exe = install_service_binary()?; + register_service_scm(&service_exe)?; + + // If no reboot is pending (Dnscache wasn't running, port 53 free), + // start the service immediately. Otherwise it'll launch on next boot. + if !needs_reboot { + match start_service_scm() { + Ok(_) => eprintln!(" Service started."), + Err(e) => eprintln!( + " warning: service registered but could not start now: {}", + e + ), + } + } eprintln!(); if !has_useful_existing { @@ -707,51 +723,160 @@ fn install_windows() -> Result<(), String> { if needs_reboot { eprintln!(" *** Reboot required. Numa will start automatically. ***\n"); } else { - eprintln!(" Numa will start automatically on next boot.\n"); + eprintln!(" Numa is running.\n"); } print_recursive_hint(); Ok(()) } -/// Register numa to auto-start on boot via registry Run key. #[cfg(windows)] -fn register_autostart() { - let exe = std::env::current_exe() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| "numa".into()); - let _ = std::process::Command::new("reg") - .args([ - "add", - "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", - "/v", - "Numa", - "/t", - "REG_SZ", - "/d", - &exe, - "/f", - ]) - .status(); - eprintln!(" Registered auto-start on boot."); +const WINDOWS_SERVICE_NAME: &str = "Numa"; + +/// Stable install location for the service binary. SCM keeps a handle to +/// this path; the user's Downloads folder (where `current_exe()` points at +/// install time) is not durable. +#[cfg(windows)] +fn windows_service_exe_path() -> std::path::PathBuf { + std::path::PathBuf::from( + std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".into()), + ) + .join("numa") + .join("bin") + .join("numa.exe") } -/// Remove numa auto-start registry key. +/// Copy the currently-running binary to the service install location. SCM +/// keeps a handle to this path, so it must be stable across user sessions. #[cfg(windows)] -fn remove_autostart() { - let _ = std::process::Command::new("reg") +fn install_service_binary() -> Result { + let src = std::env::current_exe().map_err(|e| format!("current_exe(): {}", e))?; + let dst = windows_service_exe_path(); + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("failed to create {}: {}", parent.display(), e))?; + } + // Copy only if source and destination differ; running the binary from + // its install location is a supported (re-install) case. + if src != dst { + std::fs::copy(&src, &dst).map_err(|e| { + format!( + "failed to copy {} -> {}: {}", + src.display(), + dst.display(), + e + ) + })?; + } + Ok(dst) +} + +/// Remove the service binary on uninstall. Ignore failures — the service +/// is already deleted; a leftover file in ProgramData is not a hard error. +#[cfg(windows)] +fn remove_service_binary() { + let _ = std::fs::remove_file(windows_service_exe_path()); +} + +/// Register numa with the Service Control Manager, boot-time auto-start, +/// LocalSystem context, with a failure policy of restart-after-5s. +#[cfg(windows)] +fn register_service_scm(exe: &std::path::Path) -> Result<(), String> { + let bin_path = format!("\"{}\" --service", exe.display()); + + // sc.exe uses a leading space as its `name= value` delimiter; the space + // after `=` is mandatory. + let create = std::process::Command::new("sc") .args([ - "delete", - "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", - "/v", - "Numa", - "/f", + "create", + WINDOWS_SERVICE_NAME, + "binPath=", + &bin_path, + "DisplayName=", + "Numa DNS", + "start=", + "auto", + "obj=", + "LocalSystem", ]) + .output() + .map_err(|e| format!("failed to run sc create: {}", e))?; + if !create.status.success() { + let out = String::from_utf8_lossy(&create.stdout); + // "service already exists" is 1073 — treat as idempotent success. + if !out.contains("1073") { + return Err(format!("sc create failed: {}", out.trim())); + } + } + + let _ = std::process::Command::new("sc") + .args([ + "description", + WINDOWS_SERVICE_NAME, + "Self-sovereign DNS resolver (ad blocking, DoH/DoT, local zones).", + ]) + .status(); + + // Restart on crash: 5s, 5s, 10s; reset failure counter after 60s. + let _ = std::process::Command::new("sc") + .args([ + "failure", + WINDOWS_SERVICE_NAME, + "reset=", + "60", + "actions=", + "restart/5000/restart/5000/restart/10000", + ]) + .status(); + + eprintln!( + " Registered service '{}' (boot-time).", + WINDOWS_SERVICE_NAME + ); + Ok(()) +} + +/// Start the service. Safe to call on a freshly-registered service — SCM +/// will fail with 1056 ("already running") or 1058 ("disabled") and we +/// return the underlying error string rather than masking it. +#[cfg(windows)] +fn start_service_scm() -> Result<(), String> { + let out = std::process::Command::new("sc") + .args(["start", WINDOWS_SERVICE_NAME]) + .output() + .map_err(|e| format!("failed to run sc start: {}", e))?; + if !out.status.success() { + let text = String::from_utf8_lossy(&out.stdout); + if text.contains("1056") { + return Ok(()); // already running + } + return Err(format!("sc start failed: {}", text.trim())); + } + Ok(()) +} + +/// Stop the service. Returns Ok if already stopped — idempotent. +#[cfg(windows)] +fn stop_service_scm() { + let _ = std::process::Command::new("sc") + .args(["stop", WINDOWS_SERVICE_NAME]) + .status(); +} + +/// Remove the service from SCM. Safe if already absent. +#[cfg(windows)] +fn delete_service_scm() { + let _ = std::process::Command::new("sc") + .args(["delete", WINDOWS_SERVICE_NAME]) .status(); } #[cfg(windows)] fn uninstall_windows() -> Result<(), String> { - remove_autostart(); + // Stop + remove the service before touching DNS, so port 53 is released + // cleanly and the failure-restart policy doesn't resurrect it. + stop_service_scm(); + delete_service_scm(); + remove_service_binary(); let path = windows_backup_path(); let json = std::fs::read_to_string(&path) .map_err(|e| format!("no backup found at {}: {}", path.display(), e))?; diff --git a/src/windows_service.rs b/src/windows_service.rs index 8751f23..c51339c 100644 --- a/src/windows_service.rs +++ b/src/windows_service.rs @@ -57,12 +57,50 @@ fn run_service() -> windows_service::Result<()> { 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(); + // Spin up a multi-threaded tokio runtime and run the server on it. A + // dedicated thread runs the runtime so this function can return cleanly + // once the SCM tells us to stop — we can't block the dispatcher thread + // forever without preventing graceful shutdown. + let config_path = service_config_path(); + let (runtime_stop_tx, runtime_stop_rx) = mpsc::channel::<()>(); + + let server_thread = std::thread::spawn(move || { + let runtime = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + log::error!("failed to build tokio runtime: {}", e); + let _ = runtime_stop_tx.send(()); + 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)) { + log::error!("numa serve exited with error: {}", e); + } + let _ = runtime_stop_tx.send(()); + }); + + // Wait for either SCM stop or server termination. + loop { + if shutdown_rx.try_recv().is_ok() { + break; + } + if runtime_stop_rx.try_recv().is_ok() { + break; + } + std::thread::sleep(Duration::from_millis(200)); + } + + // The server's tokio runtime runs detached inside server_thread. Abandon + // it — the process is about to report Stopped and the SCM will terminate + // us if we linger. Future work: plumb a cancellation signal into + // serve::run() for a clean teardown of listeners and in-flight queries. + drop(server_thread); status_handle.set_service_status(ServiceStatus { service_type: ServiceType::OWN_PROCESS, @@ -83,3 +121,12 @@ fn run_service() -> windows_service::Result<()> { pub fn run_as_service() -> windows_service::Result<()> { service_dispatcher::start(SERVICE_NAME, ffi_service_main) } + +/// Path to the config file used when running under SCM. SCM launches the +/// service with SYSTEM's working directory (usually `C:\Windows\System32`), +/// so a relative `numa.toml` lookup won't find anything meaningful — use an +/// absolute path under `%PROGRAMDATA%` instead. +fn service_config_path() -> String { + let base = std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".into()); + format!("{}\\numa\\numa.toml", base) +} From 7bb484ada3a6efd7521a44e3cc5bbd9dc7fb9dec Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 15 Apr 2026 23:48:09 +0300 Subject: [PATCH 03/20] refactor(windows): deduplicate after simplify review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the duplicate WINDOWS_SERVICE_NAME constant; call sites use the single source of truth at windows_service::SERVICE_NAME. - windows_service_exe_path and service_config_path now compose from crate::data_dir() instead of re-parsing %PROGRAMDATA% locally. - Factor the 6× sc.exe invocation boilerplate into a run_sc helper. - Replace the 200ms try_recv polling loop in the service dispatcher with a recv_timeout wait — cuts shutdown latency and idle CPU. - stop_service_scm/delete_service_scm now log warnings instead of silently swallowing failures, so unexpected errors are visible. --- src/system_dns.rs | 108 +++++++++++++++++++---------------------- src/windows_service.rs | 22 ++++----- 2 files changed, 61 insertions(+), 69 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index b39f661..826101d 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -729,20 +729,24 @@ fn install_windows() -> Result<(), String> { Ok(()) } -#[cfg(windows)] -const WINDOWS_SERVICE_NAME: &str = "Numa"; - /// Stable install location for the service binary. SCM keeps a handle to /// this path; the user's Downloads folder (where `current_exe()` points at /// install time) is not durable. #[cfg(windows)] fn windows_service_exe_path() -> std::path::PathBuf { - std::path::PathBuf::from( - std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".into()), - ) - .join("numa") - .join("bin") - .join("numa.exe") + crate::data_dir().join("bin").join("numa.exe") +} + +/// Run `sc.exe` with the given args and return its merged stdout/stderr on +/// failure. `sc` emits errors on stdout (not stderr) on Windows, so the +/// caller reads stdout to format a useful error. +#[cfg(windows)] +fn run_sc(args: &[&str]) -> Result { + 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 @@ -782,24 +786,22 @@ fn remove_service_binary() { #[cfg(windows)] fn register_service_scm(exe: &std::path::Path) -> Result<(), String> { 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 // after `=` is mandatory. - let create = std::process::Command::new("sc") - .args([ - "create", - WINDOWS_SERVICE_NAME, - "binPath=", - &bin_path, - "DisplayName=", - "Numa DNS", - "start=", - "auto", - "obj=", - "LocalSystem", - ]) - .output() - .map_err(|e| format!("failed to run sc create: {}", e))?; + let create = run_sc(&[ + "create", + name, + "binPath=", + &bin_path, + "DisplayName=", + "Numa DNS", + "start=", + "auto", + "obj=", + "LocalSystem", + ])?; if !create.status.success() { let out = String::from_utf8_lossy(&create.stdout); // "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") - .args([ - "description", - WINDOWS_SERVICE_NAME, - "Self-sovereign DNS resolver (ad blocking, DoH/DoT, local zones).", - ]) - .status(); + let _ = run_sc(&[ + "description", + name, + "Self-sovereign DNS resolver (ad blocking, DoH/DoT, local zones).", + ]); // Restart on crash: 5s, 5s, 10s; reset failure counter after 60s. - let _ = std::process::Command::new("sc") - .args([ - "failure", - WINDOWS_SERVICE_NAME, - "reset=", - "60", - "actions=", - "restart/5000/restart/5000/restart/10000", - ]) - .status(); + let _ = run_sc(&[ + "failure", + name, + "reset=", + "60", + "actions=", + "restart/5000/restart/5000/restart/10000", + ]); - eprintln!( - " Registered service '{}' (boot-time).", - WINDOWS_SERVICE_NAME - ); + eprintln!(" Registered service '{}' (boot-time).", name); Ok(()) } @@ -840,10 +835,7 @@ fn register_service_scm(exe: &std::path::Path) -> Result<(), String> { /// return the underlying error string rather than masking it. #[cfg(windows)] fn start_service_scm() -> Result<(), String> { - let out = std::process::Command::new("sc") - .args(["start", WINDOWS_SERVICE_NAME]) - .output() - .map_err(|e| format!("failed to run sc start: {}", e))?; + let out = run_sc(&["start", crate::windows_service::SERVICE_NAME])?; if !out.status.success() { let text = String::from_utf8_lossy(&out.stdout); if text.contains("1056") { @@ -854,20 +846,22 @@ fn start_service_scm() -> Result<(), String> { 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)] fn stop_service_scm() { - let _ = std::process::Command::new("sc") - .args(["stop", WINDOWS_SERVICE_NAME]) - .status(); + if let Err(e) = run_sc(&["stop", crate::windows_service::SERVICE_NAME]) { + log::warn!("sc stop failed: {}", e); + } } -/// Remove the service from SCM. Safe if already absent. +/// Remove the service from SCM. Idempotent — see `stop_service_scm`. #[cfg(windows)] fn delete_service_scm() { - let _ = std::process::Command::new("sc") - .args(["delete", WINDOWS_SERVICE_NAME]) - .status(); + if let Err(e) = run_sc(&["delete", crate::windows_service::SERVICE_NAME]) { + log::warn!("sc delete failed: {}", e); + } } #[cfg(windows)] diff --git a/src/windows_service.rs b/src/windows_service.rs index c51339c..a1403d7 100644 --- a/src/windows_service.rs +++ b/src/windows_service.rs @@ -62,7 +62,7 @@ fn run_service() -> windows_service::Result<()> { // once the SCM tells us to stop — we can't block the dispatcher thread // forever without preventing graceful shutdown. 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 runtime = match tokio::runtime::Builder::new_multi_thread() @@ -72,28 +72,25 @@ fn run_service() -> windows_service::Result<()> { Ok(rt) => rt, Err(e) => { log::error!("failed to build tokio runtime: {}", e); - let _ = runtime_stop_tx.send(()); + let _ = server_done_tx.send(()); 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)) { 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. loop { - if shutdown_rx.try_recv().is_ok() { + if shutdown_rx.recv_timeout(Duration::from_millis(500)).is_ok() { break; } - if runtime_stop_rx.try_recv().is_ok() { + if server_done_rx.try_recv().is_ok() { break; } - std::thread::sleep(Duration::from_millis(200)); } // 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 /// service with SYSTEM's working directory (usually `C:\Windows\System32`), -/// so a relative `numa.toml` lookup won't find anything meaningful — use an -/// absolute path under `%PROGRAMDATA%` instead. +/// so a relative `numa.toml` lookup won't find anything meaningful. fn service_config_path() -> String { - let base = std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".into()); - format!("{}\\numa\\numa.toml", base) + crate::data_dir() + .join("numa.toml") + .to_string_lossy() + .into_owned() } From cc635f2f73e5fac3f7999f27eb352d6c00c18386 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 06:15:48 +0300 Subject: [PATCH 04/20] feat(dashboard): show version in header, restructure footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #108. - Add `version` field to /stats (from CARGO_PKG_VERSION). - Show `v0.13.1` next to the Numa wordmark in the dashboard header. - Restructure the footer into two semantic rows: Row 1 (paths): Config · Data · Logs (platform-detected) Row 2 (runtime): Upstream · DNSSEC · SRTT · GitHub - Drop Mode from the footer (redundant with Upstream label). - Show only the matching-platform log path instead of both macOS and Linux unconditionally. --- site/dashboard.html | 19 ++++++++++++------- src/api.rs | 2 ++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index 77018fc..de286ab 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -561,6 +561,7 @@ body {
+
DNS that governs itself
@@ -1136,16 +1137,20 @@ async function refresh() { document.getElementById('totalQueries').textContent = formatNumber(q.total); document.getElementById('uptime').textContent = formatUptime(stats.uptime_secs); document.getElementById('uptimeSub').textContent = formatUptimeSub(stats.uptime_secs); + document.getElementById('headerVersion').textContent = stats.version ? 'v' + stats.version : ''; document.getElementById('footerUpstream').textContent = stats.upstream || ''; document.getElementById('footerConfig').textContent = stats.config_path || ''; document.getElementById('footerData').textContent = stats.data_dir || ''; - const modeEl = document.getElementById('footerMode'); - modeEl.textContent = stats.mode || '—'; - modeEl.style.color = stats.mode === 'recursive' ? 'var(--emerald)' : 'var(--amber)'; document.getElementById('footerDnssec').textContent = stats.dnssec ? 'on' : 'off'; document.getElementById('footerDnssec').style.color = stats.dnssec ? 'var(--emerald)' : 'var(--text-dim)'; document.getElementById('footerSrtt').textContent = stats.srtt ? 'on' : 'off'; document.getElementById('footerSrtt').style.color = stats.srtt ? 'var(--emerald)' : 'var(--text-dim)'; + if (!document.getElementById('footerLogs').textContent) { + const isMac = stats.data_dir && stats.data_dir.includes('/usr/local/'); + document.getElementById('footerLogs').textContent = isMac + ? '/usr/local/var/log/numa.log' + : 'journalctl -u numa -f'; + } // LAN status indicator const lanEl = document.getElementById('lanToggle'); @@ -1504,14 +1509,14 @@ refresh(); setInterval(refresh, 2000); -
+
Config: · Data: - · Upstream: - · Mode: + · Logs: +
+ Upstream: · DNSSEC: · SRTT: - · Logs: macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f · GitHub
diff --git a/src/api.rs b/src/api.rs index 17c4614..f8b2702 100644 --- a/src/api.rs +++ b/src/api.rs @@ -160,6 +160,7 @@ struct QueryLogResponse { #[derive(Serialize)] struct StatsResponse { + version: &'static str, uptime_secs: u64, upstream: String, mode: &'static str, // "recursive" or "forward" — never "auto" at runtime @@ -539,6 +540,7 @@ async fn stats(State(ctx): State>) -> Json { }; Json(StatsResponse { + version: env!("CARGO_PKG_VERSION"), uptime_secs: snap.uptime_secs, upstream, mode: ctx.upstream_mode.as_str(), From 1c5e703330bab7ca1a822246f346f79677d52863 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 06:39:29 +0300 Subject: [PATCH 05/20] =?UTF-8?q?fix(dashboard):=20collapse=20header=20on?= =?UTF-8?q?=20mobile=20(=E2=89=A4700px)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hide tagline, version tag, and Phone Setup on narrow viewports so the header stays single-row: logo + status dot + blocking toggle. Reduces logo font-size from 1.8rem to 1.4rem on mobile. --- site/dashboard.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/site/dashboard.html b/site/dashboard.html index de286ab..85b6984 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -552,7 +552,11 @@ body { @media (max-width: 700px) { .stats-row { grid-template-columns: repeat(2, 1fr); } .dashboard { padding: 1rem; } - .header { padding: 1rem; } + .header { padding: 0.8rem 1rem; } + .logo { font-size: 1.4rem; } + .tagline { display: none; } + #headerVersion { display: none; } + #phoneSetup { display: none; } } From 0118ab0f442e638274aaa68b32a3470d00fee4cf Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 13:02:25 +0300 Subject: [PATCH 06/20] feat: embed git SHA in version string via build.rs Adds a build.rs that runs `git describe --tags --always --dirty` and sets NUMA_BUILD_VERSION at compile time. A new `numa::version()` helper returns the build version, falling back to CARGO_PKG_VERSION when git is unavailable (source tarballs, Docker builds without .git). Version strings: tagged release: 0.13.1 commits ahead: 0.13.1+a87f907 uncommitted changes: 0.13.1+a87f907-dirty no git: 0.13.1 Replaces all 6 inline env!("CARGO_PKG_VERSION") call sites with the single version() function. --- build.rs | 47 +++++++++++++++++++++++++++++++++++++++++++++++ src/api.rs | 2 +- src/health.rs | 4 ++-- src/lib.rs | 8 ++++++++ src/main.rs | 10 ++++------ 5 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 build.rs diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..e3375af --- /dev/null +++ b/build.rs @@ -0,0 +1,47 @@ +fn main() { + let git_version = std::process::Command::new("git") + .args(["describe", "--tags", "--always", "--dirty"]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| { + let s = s.trim(); + let s = s.strip_prefix('v').unwrap_or(s); + // "0.13.1" → clean tag → "0.13.1" + // "0.13.1-9-ga87f907" → ahead → "0.13.1+a87f907" + // "0.13.1-9-ga87f907-dirty" → dirty → "0.13.1+a87f907-dirty" + // "a87f907" → no tags → "0.0.0+a87f907" + // "a87f907-dirty" → no tags → "0.0.0+a87f907-dirty" + if let Some((base, rest)) = s.split_once("-") { + // Could be "0.13.1-9-ga87f907[-dirty]" or "a87f907-dirty" + if base.contains('.') { + // Tagged: extract sha from "-N-gSHA[-dirty]" + let parts: Vec<&str> = rest.splitn(3, '-').collect(); + match parts.as_slice() { + [_n, sha] => format!("{}+{}", base, sha.strip_prefix('g').unwrap_or(sha)), + [_n, sha, "dirty"] => { + format!("{}+{}-dirty", base, sha.strip_prefix('g').unwrap_or(sha)) + } + _ => s.to_string(), + } + } else { + // Untagged: "sha-dirty" + format!("0.0.0+{}", s) + } + } else if s.contains('.') { + // Exact tag match: "0.13.1" + s.to_string() + } else { + // Bare sha, no tags at all + format!("0.0.0+{}", s) + } + }); + + if let Some(v) = git_version { + println!("cargo:rustc-env=NUMA_BUILD_VERSION={}", v); + } + + println!("cargo:rerun-if-changed=.git/HEAD"); + println!("cargo:rerun-if-changed=.git/refs/tags/"); +} diff --git a/src/api.rs b/src/api.rs index f8b2702..dd1fe78 100644 --- a/src/api.rs +++ b/src/api.rs @@ -540,7 +540,7 @@ async fn stats(State(ctx): State>) -> Json { }; Json(StatsResponse { - version: env!("CARGO_PKG_VERSION"), + version: crate::version(), uptime_secs: snap.uptime_secs, upstream, mode: ctx.upstream_mode.as_str(), diff --git a/src/health.rs b/src/health.rs index e55c569..5767f4b 100644 --- a/src/health.rs +++ b/src/health.rs @@ -43,7 +43,7 @@ impl HealthMeta { #[cfg(test)] pub fn test_fixture() -> Self { HealthMeta { - version: env!("CARGO_PKG_VERSION"), + version: crate::version(), hostname: "test-host".to_string(), sni: "numa.numa".to_string(), dot_enabled: false, @@ -99,7 +99,7 @@ impl HealthMeta { } HealthMeta { - version: env!("CARGO_PKG_VERSION"), + version: crate::version(), hostname: crate::hostname(), sni: "numa.numa".to_string(), dot_enabled, diff --git a/src/lib.rs b/src/lib.rs index 8933e2a..a9d38fc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,14 @@ pub(crate) mod testutil; pub type Error = Box; pub type Result = std::result::Result; +/// Build version string. On tagged releases: `0.13.1`. On commits ahead +/// of a tag: `0.13.1+a87f907`. With uncommitted changes: `0.13.1+a87f907-dirty`. +/// Falls back to `CARGO_PKG_VERSION` when built outside a git repo (e.g. +/// from a source tarball). +pub fn version() -> &'static str { + option_env!("NUMA_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")) +} + /// Detect the machine hostname via the `hostname` command. Returns the /// full hostname (e.g., `macbook-pro.local`), or `"numa"` if the command /// fails. Call sites that need the short form (e.g., mDNS instance diff --git a/src/main.rs b/src/main.rs index bce7add..faf2e22 100644 --- a/src/main.rs +++ b/src/main.rs @@ -72,7 +72,7 @@ async fn main() -> numa::Result<()> { }; } "version" | "--version" | "-V" => { - eprintln!("numa {}", env!("CARGO_PKG_VERSION")); + eprintln!("numa {}", numa::version()); return Ok(()); } "help" | "--help" | "-h" => { @@ -383,12 +383,10 @@ async fn main() -> numa::Result<()> { }; // Title row: center within the box - let title = format!( - "{b}NUMA{r} {it}DNS that governs itself{r} {d}v{}{r}", - env!("CARGO_PKG_VERSION") - ); + let ver = numa::version(); + let title = format!("{b}NUMA{r} {it}DNS that governs itself{r} {d}v{ver}{r}",); // The title contains ANSI codes; visible length is ~38 chars. Pad to fill the box. - let title_visible_len = 4 + 2 + 24 + 2 + 1 + env!("CARGO_PKG_VERSION").len() + 1; + let title_visible_len = 4 + 2 + 24 + 2 + 1 + ver.len() + 1; let title_pad = w.saturating_sub(title_visible_len); eprintln!("\n{o} ╔{bar_top}╗{r}"); eprint!("{o} ║{r} {title}"); From 30bb7365c9b2f0faa0e2456b4b6ac04f84109d1f Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 13:18:56 +0300 Subject: [PATCH 07/20] refactor: robust git-describe parsing for pre-release tags Switch to --long flag so format is always TAG-N-gSHA[-dirty], then split from the right. Handles pre-release tags (v0.14.0-rc1) that broke the previous left-split approach. Remove ineffective directory watch on .git/refs/tags/. Trim comments. --- build.rs | 69 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/build.rs b/build.rs index e3375af..463100c 100644 --- a/build.rs +++ b/build.rs @@ -1,47 +1,48 @@ fn main() { + // --long forces "TAG-N-gSHA[-dirty]" format even on exact tag matches, + // making parsing unambiguous for pre-release tags like v0.14.0-rc1. let git_version = std::process::Command::new("git") - .args(["describe", "--tags", "--always", "--dirty"]) + .args(["describe", "--tags", "--always", "--dirty", "--long"]) .output() .ok() .filter(|o| o.status.success()) .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| { - let s = s.trim(); - let s = s.strip_prefix('v').unwrap_or(s); - // "0.13.1" → clean tag → "0.13.1" - // "0.13.1-9-ga87f907" → ahead → "0.13.1+a87f907" - // "0.13.1-9-ga87f907-dirty" → dirty → "0.13.1+a87f907-dirty" - // "a87f907" → no tags → "0.0.0+a87f907" - // "a87f907-dirty" → no tags → "0.0.0+a87f907-dirty" - if let Some((base, rest)) = s.split_once("-") { - // Could be "0.13.1-9-ga87f907[-dirty]" or "a87f907-dirty" - if base.contains('.') { - // Tagged: extract sha from "-N-gSHA[-dirty]" - let parts: Vec<&str> = rest.splitn(3, '-').collect(); - match parts.as_slice() { - [_n, sha] => format!("{}+{}", base, sha.strip_prefix('g').unwrap_or(sha)), - [_n, sha, "dirty"] => { - format!("{}+{}-dirty", base, sha.strip_prefix('g').unwrap_or(sha)) - } - _ => s.to_string(), - } - } else { - // Untagged: "sha-dirty" - format!("0.0.0+{}", s) - } - } else if s.contains('.') { - // Exact tag match: "0.13.1" - s.to_string() - } else { - // Bare sha, no tags at all - format!("0.0.0+{}", s) - } - }); + .and_then(|raw| parse_git_describe(raw.trim())); if let Some(v) = git_version { println!("cargo:rustc-env=NUMA_BUILD_VERSION={}", v); } println!("cargo:rerun-if-changed=.git/HEAD"); - println!("cargo:rerun-if-changed=.git/refs/tags/"); +} + +/// Parse `git describe --long` output into a SemVer-compatible string. +/// "v0.13.1-0-ga87f907" → "0.13.1" +/// "v0.13.1-9-ga87f907" → "0.13.1+a87f907" +/// "v0.14.0-rc1-0-ga87f907" → "0.14.0-rc1" +/// "v0.14.0-rc1-3-ga87f907-dirty" → "0.14.0-rc1+a87f907-dirty" +/// "a87f907" → "0.0.0+a87f907" +fn parse_git_describe(s: &str) -> Option { + let s = s.strip_prefix('v').unwrap_or(s); + let dirty = s.ends_with("-dirty"); + let s = s.strip_suffix("-dirty").unwrap_or(s); + + // --long format: TAG-N-gSHA. Split from the right so tags with hyphens work. + let gpos = s.rfind("-g")?; + let sha = &s[gpos + 2..]; + let rest = &s[..gpos]; + let npos = rest.rfind('-')?; + let n: u32 = rest[npos + 1..].parse().ok()?; + let tag = &rest[..npos]; + + if tag.is_empty() { + return Some(format!("0.0.0+{}", sha)); + } + + Some(match (n, dirty) { + (0, false) => tag.to_string(), + (0, true) => format!("{}+{}-dirty", tag, sha), + (_, false) => format!("{}+{}", tag, sha), + (_, true) => format!("{}+{}-dirty", tag, sha), + }) } From b69cc89d385f80ae86d5dabcb1fd9fd5cb554520 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 15:12:00 +0300 Subject: [PATCH 08/20] fix(dashboard): skip allowlist re-render while input has focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The polling refresh replaced the entire allowlist panel innerHTML every 2 seconds, destroying the input field mid-typing. Users had to paste-and-enter faster than the refresh interval — #106 reported this as text "timing out and erasing." Guard: skip renderAllowlist() when allowDomainInput has focus. --- site/dashboard.html | 1 + 1 file changed, 1 insertion(+) diff --git a/site/dashboard.html b/site/dashboard.html index 85b6984..d3b1820 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -1354,6 +1354,7 @@ function renderBlockingInfo(info) { } function renderAllowlist(entries) { + if (document.activeElement && document.activeElement.id === 'allowDomainInput') return; const el = document.getElementById('blockingAllowlist'); const count = entries.length; el.innerHTML = ` From d3eab73a31b2065b713bcb47dfb3d08a9fbcb451 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 16:13:15 +0300 Subject: [PATCH 09/20] fix: use sort_by_key to satisfy clippy unnecessary_sort_by --- src/system_dns.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index 826101d..ca587b8 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -211,7 +211,7 @@ fn discover_macos() -> SystemDnsInfo { } // Sort longest suffix first for most-specific matching - rules.sort_by(|a, b| b.suffix.len().cmp(&a.suffix.len())); + rules.sort_by_key(|r| std::cmp::Reverse(r.suffix.len())); for rule in &rules { info!( From 65e65028a063521b05801b6c9aec34cdd3b325b8 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 16:59:54 +0300 Subject: [PATCH 10/20] fix(windows): separate service lifecycle from install flow service start/stop/restart/status now map to proper SCM operations instead of re-running the full install/uninstall flow. On re-install, stop the running service first so the binary can be overwritten. --- src/main.rs | 9 ++-- src/system_dns.rs | 113 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index 88f2128..b8893b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,7 @@ -use numa::system_dns::{install_service, restart_service, service_status, uninstall_service}; +use numa::system_dns::{ + install_service, restart_service, service_status, start_service, stop_service, + uninstall_service, +}; fn main() -> numa::Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) @@ -28,8 +31,8 @@ fn main() -> numa::Result<()> { let sub = std::env::args().nth(2).unwrap_or_default(); eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — service management\n"); return match sub.as_str() { - "start" => install_service().map_err(|e| e.into()), - "stop" => uninstall_service().map_err(|e| e.into()), + "start" => start_service().map_err(|e| e.into()), + "stop" => stop_service().map_err(|e| e.into()), "restart" => restart_service().map_err(|e| e.into()), "status" => service_status().map_err(|e| e.into()), _ => { diff --git a/src/system_dns.rs b/src/system_dns.rs index ca587b8..c4279cd 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -698,6 +698,13 @@ fn install_windows() -> Result<(), String> { let needs_reboot = disable_dnscache()?; + // On re-install, stop the running service first so the binary can be + // overwritten (SCM holds a handle to the exe while it's running). + let reinstall = is_service_registered(); + if reinstall { + stop_service_scm(); + } + // Copy the binary to a stable path under ProgramData and register it // as a real Windows service (SCM-managed, boot-time, auto-restart). let service_exe = install_service_binary()?; @@ -864,6 +871,41 @@ fn delete_service_scm() { } } +/// Check whether the service is registered with SCM (regardless of state). +#[cfg(windows)] +fn is_service_registered() -> bool { + run_sc(&["query", crate::windows_service::SERVICE_NAME]) + .map(|o| { + // sc query exits 0 if the service exists (running or stopped). + // Error 1060 = "service does not exist". + if o.status.success() { + return true; + } + let text = String::from_utf8_lossy(&o.stdout); + !text.contains("1060") + }) + .unwrap_or(false) +} + +/// Print service state from SCM. +#[cfg(windows)] +fn service_status_windows() -> Result<(), String> { + let out = run_sc(&["query", crate::windows_service::SERVICE_NAME])?; + let text = String::from_utf8_lossy(&out.stdout); + if text.contains("1060") { + eprintln!(" Service is not installed.\n"); + return Ok(()); + } + // Parse STATE line, e.g. "STATE : 4 RUNNING" + let state = text + .lines() + .find(|l| l.contains("STATE")) + .map(|l| l.trim().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + eprintln!(" {}\n", state); + Ok(()) +} + #[cfg(windows)] fn uninstall_windows() -> Result<(), String> { // Stop + remove the service before touching DNS, so port 53 is released @@ -1167,6 +1209,62 @@ pub fn install_service() -> Result<(), String> { result } +/// Start the service. If already installed, just starts it via the platform +/// service manager. If not installed, falls through to a full install. +pub fn start_service() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + install_service() + } + #[cfg(target_os = "linux")] + { + install_service() + } + #[cfg(windows)] + { + if is_service_registered() { + start_service_scm()?; + eprintln!(" Service started.\n"); + Ok(()) + } else { + install_service() + } + } + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] + { + Err("service start not supported on this OS".to_string()) + } +} + +/// Stop the service without uninstalling it. +pub fn stop_service() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + uninstall_service() + } + #[cfg(target_os = "linux")] + { + uninstall_service() + } + #[cfg(windows)] + { + let out = run_sc(&["stop", crate::windows_service::SERVICE_NAME])?; + if !out.status.success() { + let text = String::from_utf8_lossy(&out.stdout); + // 1062 = not started, 1060 = does not exist + if !text.contains("1062") && !text.contains("1060") { + return Err(format!("sc stop failed: {}", text.trim())); + } + } + eprintln!(" Service stopped.\n"); + Ok(()) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] + { + Err("service stop not supported on this OS".to_string()) + } +} + /// Uninstall the Numa system service. pub fn uninstall_service() -> Result<(), String> { let _ = untrust_ca(); @@ -1236,7 +1334,14 @@ pub fn restart_service() -> Result<(), String> { eprintln!(" Service restarted → {}\n", version); Ok(()) } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(windows)] + { + stop_service_scm(); + start_service_scm()?; + eprintln!(" Service restarted.\n"); + Ok(()) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] { Err("service restart not supported on this OS".to_string()) } @@ -1252,7 +1357,11 @@ pub fn service_status() -> Result<(), String> { { service_status_linux() } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(windows)] + { + service_status_windows() + } + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] { Err("service status not supported on this OS".to_string()) } From da40a8dbfccd06e4ff49aab1ee5656659b511aec Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 18:08:48 +0300 Subject: [PATCH 11/20] ci: fetch full history on Windows so build.rs embeds git SHA --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ad7e45..33e25a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,8 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: build From 6789c321bc6938c7c5b0254720a5e548498e1243 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 18:35:09 +0300 Subject: [PATCH 12/20] fix(windows): defer DNS redirect until port 53 is free Probe port 53 after disabling Dnscache instead of assuming reboot is needed. Skip DNS redirect when port is blocked (service does it on first boot). Fix readiness probe: TCP connect to API port instead of broken UDP send_to that always succeeded. --- src/system_dns.rs | 89 +++++++++++++++++++++++++++--------------- src/windows_service.rs | 17 ++++++++ 2 files changed, 75 insertions(+), 31 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index c4279cd..35490ae 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -572,7 +572,7 @@ fn windows_backup_path() -> std::path::PathBuf { #[cfg(windows)] fn disable_dnscache() -> Result { - // Check if Dnscache is running (it holds port 53 at kernel level) + // Check if Dnscache is running (it can hold port 53) let output = std::process::Command::new("sc") .args(["query", "Dnscache"]) .output() @@ -603,8 +603,16 @@ fn disable_dnscache() -> Result { return Err("failed to disable Dnscache via registry (run as Administrator?)".into()); } - eprintln!(" Dnscache disabled. A reboot is required to free port 53."); - Ok(true) + // Dnscache is disabled for next boot. Check whether port 53 is + // actually blocked right now — on many Windows configurations + // Dnscache doesn't bind port 53 even while running. + let port_blocked = std::net::UdpSocket::bind("127.0.0.1:53").is_err(); + if port_blocked { + eprintln!(" Dnscache disabled. A reboot is required to free port 53."); + } else { + eprintln!(" Dnscache disabled. Port 53 is free."); + } + Ok(port_blocked) } #[cfg(windows)] @@ -671,31 +679,6 @@ fn install_windows() -> Result<(), String> { std::fs::write(&path, json).map_err(|e| format!("failed to write backup: {}", e))?; } - for name in interfaces.keys() { - let status = std::process::Command::new("netsh") - .args([ - "interface", - "ipv4", - "set", - "dnsservers", - name, - "static", - "127.0.0.1", - "primary", - ]) - .status() - .map_err(|e| format!("failed to set DNS for {}: {}", name, e))?; - - if status.success() { - eprintln!(" set DNS for \"{}\" -> 127.0.0.1", name); - } else { - eprintln!( - " warning: failed to set DNS for \"{}\" (run as Administrator?)", - name - ); - } - } - let needs_reboot = disable_dnscache()?; // On re-install, stop the running service first so the binary can be @@ -710,9 +693,14 @@ fn install_windows() -> Result<(), String> { let service_exe = install_service_binary()?; register_service_scm(&service_exe)?; - // If no reboot is pending (Dnscache wasn't running, port 53 free), - // start the service immediately. Otherwise it'll launch on next boot. - if !needs_reboot { + if needs_reboot { + // Dnscache still holds port 53 until reboot. Do NOT redirect DNS + // yet — nothing is listening on 127.0.0.1:53, so redirecting now + // would kill DNS. The service will call redirect_dns_to_localhost() + // on its first startup after reboot. + } else { + redirect_dns_with_interfaces(&interfaces)?; + match start_service_scm() { Ok(_) => eprintln!(" Service started."), Err(e) => eprintln!( @@ -756,6 +744,45 @@ fn run_sc(args: &[&str]) -> Result { Ok(out) } +/// Point all active network interfaces at 127.0.0.1 so Numa handles DNS. +/// Called from the service on first boot after a reboot that freed Dnscache. +#[cfg(windows)] +pub fn redirect_dns_to_localhost() -> Result<(), String> { + let interfaces = get_windows_interfaces()?; + redirect_dns_with_interfaces(&interfaces) +} + +#[cfg(windows)] +fn redirect_dns_with_interfaces( + interfaces: &std::collections::HashMap, +) -> Result<(), String> { + for name in interfaces.keys() { + let status = std::process::Command::new("netsh") + .args([ + "interface", + "ipv4", + "set", + "dnsservers", + name, + "static", + "127.0.0.1", + "primary", + ]) + .status() + .map_err(|e| format!("failed to set DNS for {}: {}", name, e))?; + + if status.success() { + eprintln!(" set DNS for \"{}\" -> 127.0.0.1", name); + } else { + eprintln!( + " warning: failed to set DNS for \"{}\" (run as Administrator?)", + name + ); + } + } + Ok(()) +} + /// Copy the currently-running binary to the service install location. SCM /// keeps a handle to this path, so it must be stable across user sessions. #[cfg(windows)] diff --git a/src/windows_service.rs b/src/windows_service.rs index a1403d7..a363359 100644 --- a/src/windows_service.rs +++ b/src/windows_service.rs @@ -83,6 +83,23 @@ fn run_service() -> windows_service::Result<()> { let _ = server_done_tx.send(()); }); + // Wait for the API to be ready, then ensure DNS points at localhost. + // On first boot after install (Dnscache was disabled, reboot freed + // port 53), the installer deferred the DNS redirect — do it now. + let api_up = (0..20).any(|i| { + if i > 0 { + std::thread::sleep(Duration::from_millis(500)); + } + std::net::TcpStream::connect(("127.0.0.1", crate::config::DEFAULT_API_PORT)).is_ok() + }); + if api_up { + if let Err(e) = crate::system_dns::redirect_dns_to_localhost() { + log::warn!("could not redirect DNS to localhost: {}", e); + } + } else { + log::error!("numa API did not start within 10s — DNS not redirected"); + } + // Wait for either SCM stop or server termination. loop { if shutdown_rx.recv_timeout(Duration::from_millis(500)).is_ok() { From f0a1dd7106b632957e9a5cfef5e16910ce599362 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 19:01:34 +0300 Subject: [PATCH 13/20] fix(dashboard): hide logs path on Windows (no log sink yet) --- site/dashboard.html | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index d3b1820..0e26752 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -1150,10 +1150,16 @@ async function refresh() { document.getElementById('footerSrtt').textContent = stats.srtt ? 'on' : 'off'; document.getElementById('footerSrtt').style.color = stats.srtt ? 'var(--emerald)' : 'var(--text-dim)'; if (!document.getElementById('footerLogs').textContent) { + const isWin = stats.data_dir && stats.data_dir.includes(':\\'); const isMac = stats.data_dir && stats.data_dir.includes('/usr/local/'); - document.getElementById('footerLogs').textContent = isMac - ? '/usr/local/var/log/numa.log' - : 'journalctl -u numa -f'; + const logsEl = document.getElementById('footerLogs'); + if (isWin) { + document.getElementById('footerLogsWrap').style.display = 'none'; + } else { + logsEl.textContent = isMac + ? '/usr/local/var/log/numa.log' + : 'journalctl -u numa -f'; + } } // LAN status indicator @@ -1517,7 +1523,7 @@ setInterval(refresh, 2000);
Config: · Data: - · Logs: + · Logs:
Upstream: · DNSSEC: From 9bea038cb607c044b185979ddb9260d3a72bd9f0 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 19:12:42 +0300 Subject: [PATCH 14/20] fix(windows): unify config/data dir and add service log file config_dir() on Windows now returns data_dir() (ProgramData) so config, services.json, and log file are in the same place for both interactive and service contexts. Service mode writes logs to numa.log via env_logger pipe. Dashboard shows correct log path per OS. --- site/dashboard.html | 13 +++++-------- src/lib.rs | 7 ++----- src/main.rs | 31 +++++++++++++++++++++---------- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index 0e26752..fa2d965 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -1153,13 +1153,10 @@ async function refresh() { const isWin = stats.data_dir && stats.data_dir.includes(':\\'); const isMac = stats.data_dir && stats.data_dir.includes('/usr/local/'); const logsEl = document.getElementById('footerLogs'); - if (isWin) { - document.getElementById('footerLogsWrap').style.display = 'none'; - } else { - logsEl.textContent = isMac - ? '/usr/local/var/log/numa.log' - : 'journalctl -u numa -f'; - } + logsEl.textContent = isWin + ? stats.data_dir + '\\numa.log' + : isMac ? '/usr/local/var/log/numa.log' + : 'journalctl -u numa -f'; } // LAN status indicator @@ -1523,7 +1520,7 @@ setInterval(refresh, 2000);
Config: · Data: - · Logs: + · Logs:
Upstream: · DNSSEC: diff --git a/src/lib.rs b/src/lib.rs index 8bb28d6..a16568b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -101,14 +101,11 @@ where /// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa /// if a pre-v0.10.1 install already lives there. /// macOS root daemon: /usr/local/var/numa (Homebrew prefix) -/// Windows: %APPDATA%\numa +/// Windows: %PROGRAMDATA%\numa (same as data_dir — no per-user config on Windows) pub fn config_dir() -> std::path::PathBuf { #[cfg(windows)] { - std::path::PathBuf::from( - std::env::var("APPDATA").unwrap_or_else(|_| "C:\\ProgramData".into()), - ) - .join("numa") + data_dir() } #[cfg(not(windows))] { diff --git a/src/main.rs b/src/main.rs index b8893b3..34bf747 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,21 +4,32 @@ use numa::system_dns::{ }; fn main() -> numa::Result<()> { + // Handle CLI subcommands + let arg1 = std::env::args().nth(1).unwrap_or_default(); + + #[cfg(windows)] + if arg1 == "--service" { + // Running under SCM — stderr goes nowhere. Redirect logs to a file. + let log_path = numa::data_dir().join("numa.log"); + let log_file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .expect("failed to open log file"); + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .format_timestamp_millis() + .target(env_logger::Target::Pipe(Box::new(log_file))) + .init(); + numa::windows_service::run_as_service() + .map_err(|e| format!("windows service dispatcher failed: {}", e))?; + return Ok(()); + } + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) .format_timestamp_millis() .init(); - // 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"`). - // Blocks until SCM sends Stop; never returns normally. - 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()); From 9f08d8b4896bc2b0a2f72ca8bb18dd393ad9ef93 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 19:21:56 +0300 Subject: [PATCH 15/20] fix(windows): stop service before port probe, wait for full exit Stop the running service before disabling Dnscache so the port 53 probe sees the real state (not Numa's own binding). Wait for SCM STOPPED state before copying the binary to avoid os error 32 (file in use). --- src/system_dns.rs | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index 35490ae..7e2d16a 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -679,15 +679,15 @@ fn install_windows() -> Result<(), String> { std::fs::write(&path, json).map_err(|e| format!("failed to write backup: {}", e))?; } - let needs_reboot = disable_dnscache()?; - // On re-install, stop the running service first so the binary can be - // overwritten (SCM holds a handle to the exe while it's running). - let reinstall = is_service_registered(); - if reinstall { + // overwritten and port 53 is released for the Dnscache probe. + if is_service_registered() { + eprintln!(" Stopping existing service..."); stop_service_scm(); } + let needs_reboot = disable_dnscache()?; + // Copy the binary to a stable path under ProgramData and register it // as a real Windows service (SCM-managed, boot-time, auto-restart). let service_exe = install_service_binary()?; @@ -880,14 +880,24 @@ fn start_service_scm() -> Result<(), String> { Ok(()) } -/// 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. +/// Stop the service and wait for it to fully exit. Idempotent — +/// already-stopped or missing service is not an error. #[cfg(windows)] fn stop_service_scm() { - if let Err(e) = run_sc(&["stop", crate::windows_service::SERVICE_NAME]) { - log::warn!("sc stop failed: {}", e); + let name = crate::windows_service::SERVICE_NAME; + let _ = run_sc(&["stop", name]); + // Wait up to 10s for the service to reach STOPPED state so the + // binary file handle is released before we try to overwrite it. + for _ in 0..20 { + if let Ok(out) = run_sc(&["query", name]) { + let text = String::from_utf8_lossy(&out.stdout); + if text.contains("STOPPED") || text.contains("1060") { + return; + } + } + std::thread::sleep(std::time::Duration::from_millis(500)); } + eprintln!(" warning: service did not stop within 10s"); } /// Remove the service from SCM. Idempotent — see `stop_service_scm`. From fe9f31616e574b9c3c4ae97b0b646de6e65705ce Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 19:31:26 +0300 Subject: [PATCH 16/20] test: add SCM output parsing and config path regression tests Extract parse_sc_registered and parse_sc_state as testable pure functions. 8 new tests covering: service registration detection, service state parsing, and Windows config_dir == data_dir invariant. --- src/system_dns.rs | 95 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 17 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index 7e2d16a..941c053 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -912,35 +912,43 @@ fn delete_service_scm() { #[cfg(windows)] fn is_service_registered() -> bool { run_sc(&["query", crate::windows_service::SERVICE_NAME]) - .map(|o| { - // sc query exits 0 if the service exists (running or stopped). - // Error 1060 = "service does not exist". - if o.status.success() { - return true; - } - let text = String::from_utf8_lossy(&o.stdout); - !text.contains("1060") - }) + .map(|o| parse_sc_registered(o.status.success(), &String::from_utf8_lossy(&o.stdout))) .unwrap_or(false) } +/// Parse `sc query` output to determine if a service is registered. +/// Extracted for testability — the actual `sc` call is in `is_service_registered`. +#[cfg(any(windows, test))] +fn parse_sc_registered(exit_success: bool, stdout: &str) -> bool { + if exit_success { + return true; + } + // Error 1060 = "The specified service does not exist as an installed service." + !stdout.contains("1060") +} + /// Print service state from SCM. #[cfg(windows)] fn service_status_windows() -> Result<(), String> { let out = run_sc(&["query", crate::windows_service::SERVICE_NAME])?; let text = String::from_utf8_lossy(&out.stdout); - if text.contains("1060") { - eprintln!(" Service is not installed.\n"); - return Ok(()); + let display = parse_sc_state(&text); + eprintln!(" {}\n", display); + Ok(()) +} + +/// Parse the STATE line from `sc query` output. Returns a human-readable +/// string like "STATE : 4 RUNNING" or "Service is not installed." +#[cfg(any(windows, test))] +fn parse_sc_state(sc_output: &str) -> String { + if sc_output.contains("1060") { + return "Service is not installed.".to_string(); } - // Parse STATE line, e.g. "STATE : 4 RUNNING" - let state = text + sc_output .lines() .find(|l| l.contains("STATE")) .map(|l| l.trim().to_string()) - .unwrap_or_else(|| "unknown".to_string()); - eprintln!(" {}\n", state); - Ok(()) + .unwrap_or_else(|| "unknown".to_string()) } #[cfg(windows)] @@ -2132,4 +2140,57 @@ Wireless LAN adapter Wi-Fi: let err = std::io::Error::from(std::io::ErrorKind::AddrInUse); assert!(try_port53_advisory("not-an-address", &err).is_none()); } + + #[test] + fn sc_query_running_service_is_registered() { + assert!(parse_sc_registered(true, "")); + } + + #[test] + fn sc_query_stopped_service_is_registered() { + let output = "SERVICE_NAME: Numa\n TYPE: 10 WIN32_OWN\n STATE: 1 STOPPED\n"; + assert!(parse_sc_registered(true, output)); + } + + #[test] + fn sc_query_missing_service_not_registered() { + let output = "[SC] EnumQueryServicesStatus:OpenService FAILED 1060:\n\nThe specified service does not exist as an installed service.\n"; + assert!(!parse_sc_registered(false, output)); + } + + #[test] + fn sc_query_other_error_assumes_registered() { + // Permission denied or other errors — don't assume unregistered. + let output = "[SC] OpenService FAILED 5:\n\nAccess is denied.\n"; + assert!(parse_sc_registered(false, output)); + } + + #[test] + fn parse_sc_state_running() { + let output = "SERVICE_NAME: Numa\n TYPE : 10 WIN32_OWN_PROCESS\n STATE : 4 RUNNING\n WIN32_EXIT_CODE : 0\n"; + assert!(parse_sc_state(output).contains("RUNNING")); + } + + #[test] + fn parse_sc_state_stopped() { + let output = "SERVICE_NAME: Numa\n TYPE : 10 WIN32_OWN_PROCESS\n STATE : 1 STOPPED\n"; + assert!(parse_sc_state(output).contains("STOPPED")); + } + + #[test] + fn parse_sc_state_not_installed() { + let output = "[SC] EnumQueryServicesStatus:OpenService FAILED 1060:\n\n"; + assert_eq!(parse_sc_state(output), "Service is not installed."); + } + + #[test] + fn parse_sc_state_empty_output() { + assert_eq!(parse_sc_state(""), "unknown"); + } + + #[cfg(windows)] + #[test] + fn windows_config_dir_equals_data_dir() { + assert_eq!(crate::config_dir(), crate::data_dir()); + } } From 9e56054f37d4c2e1e03b6468effbfb695a0e3c8a Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 19:56:44 +0300 Subject: [PATCH 17/20] ci: add integration tests for install/uninstall lifecycle Release-build + install/verify/re-install/uninstall cycle on Linux and macOS. Runs after lint/test passes (needs dependency). Cleanup step uses if: always() to handle cancellation. --- .github/workflows/ci.yml | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33e25a4..4b4972e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,3 +71,53 @@ jobs: with: name: numa-windows-x86_64 path: target/debug/numa.exe + + integration-linux: + needs: [check] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: build + run: cargo build --release + - name: install / verify / re-install / uninstall + run: | + sudo ./target/release/numa install + sleep 2 + curl -sf http://127.0.0.1:5380/health + dig @127.0.0.1 example.com +short +timeout=5 | grep -q '.' + sudo ./target/release/numa install + sleep 2 + curl -sf http://127.0.0.1:5380/health + sudo ./target/release/numa uninstall + sleep 1 + ! curl -sf http://127.0.0.1:5380/health 2>/dev/null + - name: cleanup + if: always() + run: sudo ./target/release/numa uninstall 2>/dev/null || true + + integration-macos: + needs: [check-macos] + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: build + run: cargo build --release + - name: install / verify / re-install / uninstall + run: | + sudo ./target/release/numa install + sleep 2 + curl -sf http://127.0.0.1:5380/health + dig @127.0.0.1 example.com +short +timeout=5 | grep -q '.' + sudo ./target/release/numa install + sleep 2 + curl -sf http://127.0.0.1:5380/health + sudo ./target/release/numa uninstall + sleep 1 + ! curl -sf http://127.0.0.1:5380/health 2>/dev/null + - name: cleanup + if: always() + run: sudo ./target/release/numa uninstall 2>/dev/null || true From 99af97a67bc32ff478c7e44a9c99b825d6b374a5 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 20:20:53 +0300 Subject: [PATCH 18/20] ci: wait for DNS recovery after uninstall on Linux systemd-resolved needs a moment to restore its stub listener after the numa drop-in is removed. Without a wait, the runner can't resolve GitHub's API to report job completion. --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b4972e..502279d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,10 @@ jobs: sudo ./target/release/numa uninstall sleep 1 ! curl -sf http://127.0.0.1:5380/health 2>/dev/null + # Wait for systemd-resolved to restore DNS so the runner can + # phone home to GitHub after the job completes. + sleep 3 + dig @127.0.0.1 github.com +short +timeout=5 || dig github.com +short +timeout=5 || true - name: cleanup if: always() run: sudo ./target/release/numa uninstall 2>/dev/null || true From 34b75833b8da63e39b9df83efc069f5008b6e41d Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 17 Apr 2026 01:11:20 +0300 Subject: [PATCH 19/20] ci: poll for DNS recovery in cleanup, not test step Move DNS recovery wait into the cleanup step (if: always) so it runs regardless of test outcome. Use getent hosts loop instead of sleep+dig to match what post-steps actually use for resolution. --- .github/workflows/ci.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 502279d..f29c51a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,13 +93,16 @@ jobs: sudo ./target/release/numa uninstall sleep 1 ! curl -sf http://127.0.0.1:5380/health 2>/dev/null - # Wait for systemd-resolved to restore DNS so the runner can - # phone home to GitHub after the job completes. - sleep 3 - dig @127.0.0.1 github.com +short +timeout=5 || dig github.com +short +timeout=5 || true - name: cleanup if: always() - run: sudo ./target/release/numa uninstall 2>/dev/null || true + run: | + sudo ./target/release/numa uninstall 2>/dev/null || true + # Wait for systemd-resolved to fully restore DNS so post-job + # steps (rust-cache upload, log shipping) can reach GitHub. + for i in $(seq 1 30); do + if getent hosts github.com >/dev/null 2>&1; then break; fi + sleep 1 + done integration-macos: needs: [check-macos] From 1d9495c013a9ee6b2fd1a79e3aad6c71369d95c7 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 17 Apr 2026 01:32:36 +0300 Subject: [PATCH 20/20] ci: bridge DNS gap with direct upstream instead of polling systemd-resolved has a ~40s reconfiguration stall after restart (systemd #22521) that breaks the GHA runner's persistent connection to results-receiver.actions.githubusercontent.com. Polling for DNS recovery isn't enough since the .NET runner agent caches DNS at the connection-pool level. Replace the broken stub-resolv symlink with a direct upstream so DNS works instantly. --- .github/workflows/ci.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f29c51a..e116744 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,12 +97,14 @@ jobs: if: always() run: | sudo ./target/release/numa uninstall 2>/dev/null || true - # Wait for systemd-resolved to fully restore DNS so post-job - # steps (rust-cache upload, log shipping) can reach GitHub. - for i in $(seq 1 30); do - if getent hosts github.com >/dev/null 2>&1; then break; fi - sleep 1 - done + # systemd-resolved has a ~40s DNS reconfiguration stall after + # restart (systemd issue #22521) that breaks the runner agent's + # connection to GitHub. Bridge it by replacing the stub-resolv + # symlink with a direct upstream — DNS works instantly and the + # runner can phone home for post-job steps. + sudo rm -f /etc/resolv.conf + echo "nameserver 8.8.8.8" | sudo tee /etc/resolv.conf > /dev/null + getent hosts github.com >/dev/null integration-macos: needs: [check-macos]