From e608e120004a64bc0aa98035fb02f3a3afd54d47 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 1 Apr 2026 05:55:26 +0300 Subject: [PATCH] feat: auto recursive mode, fix Linux install Auto mode (new default): probes a root server on startup; uses recursive resolution if outbound DNS works, falls back to Quad9 DoH if blocked. Dashboard shows mode indicator (green/yellow). Linux install fixes: - Add DNSStubListener=no to resolved drop-in (frees port 53) - Configure DNS before starting service (correct ordering) - Skip 127.0.0.53 in upstream detection - `numa install` now does everything (service + DNS + CA) - `numa uninstall` mirrors install (stop service + restore DNS) - Extract is_loopback_or_stub() for consistent filtering Co-Authored-By: Claude Opus 4.6 --- install.sh | 10 +++-- site/dashboard.html | 4 ++ src/api.rs | 2 + src/config.rs | 11 +++++ src/main.rs | 92 ++++++++++++++++++++++++++-------------- src/recursive.rs | 14 ++++++ src/system_dns.rs | 101 +++++++++++++++----------------------------- 7 files changed, 133 insertions(+), 101 deletions(-) diff --git a/install.sh b/install.sh index a388659..cfac11a 100755 --- a/install.sh +++ b/install.sh @@ -70,8 +70,10 @@ echo "" echo " \033[38;2;107;124;78mInstalled:\033[0m $INSTALL_DIR/numa ($TAG)" echo "" echo " Get started:" -echo " sudo numa # start the DNS server" -echo " sudo numa install # set as system DNS" -echo " sudo numa service start # run as persistent service" -echo " open http://localhost:5380 # dashboard" +echo " sudo numa install # install service + set as system DNS" +echo " open http://localhost:5380 # dashboard" +echo "" +echo " Other commands:" +echo " sudo numa # run in foreground (no service)" +echo " sudo numa uninstall # restore original DNS" echo "" diff --git a/site/dashboard.html b/site/dashboard.html index e90fbea..372470d 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -882,6 +882,9 @@ async function refresh() { 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'; @@ -1236,6 +1239,7 @@ setInterval(refresh, 2000); Config: · Data: · Upstream: + · Mode: · DNSSEC: · SRTT: · Logs: macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f diff --git a/src/api.rs b/src/api.rs index 04a81bf..61935f2 100644 --- a/src/api.rs +++ b/src/api.rs @@ -160,6 +160,7 @@ struct QueryLogResponse { struct StatsResponse { uptime_secs: u64, upstream: String, + mode: String, config_path: String, data_dir: String, dnssec: bool, @@ -486,6 +487,7 @@ async fn stats(State(ctx): State>) -> Json { Json(StatsResponse { uptime_secs: snap.uptime_secs, upstream, + mode: ctx.upstream_mode.as_str().to_string(), config_path: ctx.config_path.clone(), data_dir: ctx.data_dir.to_string_lossy().to_string(), dnssec: ctx.dnssec_enabled, diff --git a/src/config.rs b/src/config.rs index b022fd5..d38f275 100644 --- a/src/config.rs +++ b/src/config.rs @@ -67,10 +67,21 @@ fn default_api_port() -> u16 { #[serde(rename_all = "lowercase")] pub enum UpstreamMode { #[default] + Auto, Forward, Recursive, } +impl UpstreamMode { + pub fn as_str(&self) -> &'static str { + match self { + UpstreamMode::Auto => "auto", + UpstreamMode::Forward => "forward", + UpstreamMode::Recursive => "recursive", + } + } +} + #[derive(Deserialize)] pub struct UpstreamConfig { #[serde(default)] diff --git a/src/main.rs b/src/main.rs index 5505392..3a5b004 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,8 +17,7 @@ use numa::query_log::QueryLog; use numa::service_store::ServiceStore; use numa::stats::ServerStats; use numa::system_dns::{ - discover_system_dns, install_service, install_system_dns, restart_service, service_status, - uninstall_service, uninstall_system_dns, + discover_system_dns, install_service, restart_service, service_status, uninstall_service, }; #[tokio::main] @@ -31,12 +30,12 @@ async fn main() -> numa::Result<()> { let arg1 = std::env::args().nth(1).unwrap_or_default(); match arg1.as_str() { "install" => { - eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — configuring system DNS\n"); - return install_system_dns().map_err(|e| e.into()); + eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — installing\n"); + return install_service().map_err(|e| e.into()); } "uninstall" => { - eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — restoring system DNS\n"); - return uninstall_system_dns().map_err(|e| e.into()); + eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — uninstalling\n"); + return uninstall_service().map_err(|e| e.into()); } "service" => { let sub = std::env::args().nth(2).unwrap_or_default(); @@ -107,32 +106,63 @@ async fn main() -> numa::Result<()> { // Discover system DNS in a single pass (upstream + forwarding rules) let system_dns = discover_system_dns(); - let upstream_addr = if config.upstream.address.is_empty() { - 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"); - "https://dns.quad9.net/dns-query".to_string() - }) - } else { - config.upstream.address.clone() - }; + let root_hints = numa::recursive::parse_root_hints(&config.upstream.root_hints); - let upstream: Upstream = if upstream_addr.starts_with("https://") { - let client = reqwest::Client::builder() - .use_rustls_tls() - .build() - .unwrap_or_default(); - Upstream::Doh { - url: upstream_addr, - client, + // Resolve upstream mode + address in one block + let resolved_mode; + let upstream_auto; + let (upstream, upstream_label) = if 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"); + resolved_mode = numa::config::UpstreamMode::Recursive; + upstream_auto = false; + let dummy_upstream = Upstream::Udp("0.0.0.0:0".parse().unwrap()); + (dummy_upstream, "recursive (root hints)".to_string()) + } else { + log::warn!("recursive probe failed — falling back to Quad9 DoH"); + resolved_mode = numa::config::UpstreamMode::Forward; + upstream_auto = false; + let client = reqwest::Client::builder() + .use_rustls_tls() + .build() + .unwrap_or_default(); + let url = "https://dns.quad9.net/dns-query".to_string(); + let label = url.clone(); + (Upstream::Doh { url, client }, label) } } else { - let addr: SocketAddr = format!("{}:{}", upstream_addr, config.upstream.port).parse()?; - Upstream::Udp(addr) + resolved_mode = config.upstream.mode; + upstream_auto = config.upstream.address.is_empty(); + + let upstream_addr = if config.upstream.address.is_empty() { + 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"); + "https://dns.quad9.net/dns-query".to_string() + }) + } else { + config.upstream.address.clone() + }; + + let upstream: Upstream = if upstream_addr.starts_with("https://") { + let client = reqwest::Client::builder() + .use_rustls_tls() + .build() + .unwrap_or_default(); + Upstream::Doh { + url: upstream_addr, + client, + } + } else { + let addr: SocketAddr = format!("{}:{}", upstream_addr, config.upstream.port).parse()?; + Upstream::Udp(addr) + }; + let label = upstream.to_string(); + (upstream, label) }; - let upstream_label = upstream.to_string(); let api_port = config.server.api_port; let mut blocklist = BlocklistStore::new(); @@ -183,7 +213,7 @@ async fn main() -> numa::Result<()> { lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)), forwarding_rules, upstream: Mutex::new(upstream), - upstream_auto: config.upstream.address.is_empty(), + 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), @@ -199,8 +229,8 @@ async fn main() -> numa::Result<()> { config_dir: numa::config_dir(), data_dir: numa::data_dir(), tls_config: initial_tls, - upstream_mode: config.upstream.mode, - root_hints: numa::recursive::parse_root_hints(&config.upstream.root_hints), + 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, diff --git a/src/recursive.rs b/src/recursive.rs index 82f9879..aed93e7 100644 --- a/src/recursive.rs +++ b/src/recursive.rs @@ -65,6 +65,20 @@ pub async fn probe_udp(root_hints: &[SocketAddr]) { } } +/// Probe whether recursive resolution works by querying a root server. +pub async fn probe_recursive(root_hints: &[SocketAddr]) -> bool { + let hint = match root_hints.first() { + Some(h) => *h, + None => return false, + }; + let mut probe = DnsPacket::query(next_id(), ".", QueryType::NS); + probe.header.recursion_desired = false; + match forward_udp(&probe, hint, Duration::from_secs(3)).await { + Ok(resp) => !resp.answers.is_empty() || !resp.authorities.is_empty(), + Err(_) => false, + } +} + pub async fn prime_tld_cache( cache: &RwLock, root_hints: &[SocketAddr], diff --git a/src/system_dns.rs b/src/system_dns.rs index 57559b5..65e5adf 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -2,6 +2,10 @@ use std::net::SocketAddr; use log::info; +fn is_loopback_or_stub(addr: &str) -> bool { + matches!(addr, "127.0.0.1" | "127.0.0.53" | "0.0.0.0" | "::1" | "") +} + /// A conditional forwarding rule: domains matching `suffix` are forwarded to `upstream`. #[derive(Debug, Clone)] pub struct ForwardingRule { @@ -102,11 +106,7 @@ fn discover_macos() -> SystemDnsInfo { if ns.parse::().is_ok() { current_nameserver = Some(ns.clone()); // Capture first non-supplemental, non-loopback nameserver as default upstream - if !is_supplemental - && default_upstream.is_none() - && ns != "127.0.0.1" - && ns != "0.0.0.0" - { + if !is_supplemental && default_upstream.is_none() && !is_loopback_or_stub(&ns) { default_upstream = Some(ns); } } @@ -196,7 +196,7 @@ fn read_upstream_from_file(path: &str) -> Option { let line = line.trim(); if line.starts_with("nameserver") { if let Some(ns) = line.split_whitespace().nth(1) { - if ns != "127.0.0.1" && ns != "0.0.0.0" && ns != "::1" { + if !is_loopback_or_stub(ns) { return Some(ns.to_string()); } } @@ -236,10 +236,7 @@ fn detect_dhcp_dns_macos() -> Option { // Take the first non-loopback DNS server for addr in inner.split(',') { let addr = addr.trim(); - if !addr.is_empty() - && addr != "127.0.0.1" - && addr != "0.0.0.0" - && addr.parse::().is_ok() + if !is_loopback_or_stub(addr) && addr.parse::().is_ok() { log::info!("detected DHCP DNS: {}", addr); return Some(addr.to_string()); @@ -278,7 +275,7 @@ fn discover_windows() -> SystemDnsInfo { if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") { if let Some(ip) = trimmed.split(':').next_back() { let ip = ip.trim(); - if !ip.is_empty() && ip != "127.0.0.1" && ip != "::1" { + if !is_loopback_or_stub(ip) { upstream = Some(ip.to_string()); break; } @@ -316,43 +313,6 @@ pub fn match_forwarding_rule(domain: &str, rules: &[ForwardingRule]) -> Option Result<(), String> { - #[cfg(target_os = "macos")] - let result = install_macos(); - #[cfg(target_os = "linux")] - let result = install_linux(); - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - let result = Err("system DNS configuration not supported on this OS".to_string()); - - if result.is_ok() { - if let Err(e) = trust_ca() { - eprintln!(" warning: could not trust CA: {}", e); - eprintln!(" HTTPS proxy will work but browsers will show certificate warnings.\n"); - } - } - result -} - -/// Restore the original system DNS settings saved during install. -pub fn uninstall_system_dns() -> Result<(), String> { - let _ = untrust_ca(); - - #[cfg(target_os = "macos")] - { - uninstall_macos() - } - #[cfg(target_os = "linux")] - { - uninstall_linux() - } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - { - Err("system DNS configuration not supported on this OS".to_string()) - } -} - // --- macOS implementation --- #[cfg(target_os = "macos")] @@ -500,21 +460,25 @@ const SYSTEMD_UNIT: &str = "/etc/systemd/system/numa.service"; /// Install Numa as a system service that starts on boot and auto-restarts. pub fn install_service() -> Result<(), String> { #[cfg(target_os = "macos")] - { - install_service_macos() - } + let result = install_service_macos(); #[cfg(target_os = "linux")] - { - install_service_linux() - } + let result = install_service_linux(); #[cfg(not(any(target_os = "macos", target_os = "linux")))] - { - Err("service installation not supported on this OS".to_string()) + let result = Err::<(), String>("service installation not supported on this OS".to_string()); + + if result.is_ok() { + if let Err(e) = trust_ca() { + eprintln!(" warning: could not trust CA: {}", e); + eprintln!(" HTTPS proxy will work but browsers will show certificate warnings.\n"); + } } + result } /// Uninstall the Numa system service. pub fn uninstall_service() -> Result<(), String> { + let _ = untrust_ca(); + #[cfg(target_os = "macos")] { uninstall_service_macos() @@ -609,6 +573,11 @@ fn install_service_macos() -> Result<(), String> { std::fs::write(PLIST_DEST, plist) .map_err(|e| format!("failed to write {}: {}", PLIST_DEST, e))?; + // Configure system DNS before starting service + if let Err(e) = install_macos() { + eprintln!(" warning: failed to configure system DNS: {}", e); + } + // Load the service let status = std::process::Command::new("launchctl") .args(["load", "-w", PLIST_DEST]) @@ -619,11 +588,7 @@ fn install_service_macos() -> Result<(), String> { return Err("launchctl load failed".to_string()); } - // Set system DNS to 127.0.0.1 now that the service is running eprintln!(" Service installed and started."); - if let Err(e) = install_macos() { - eprintln!(" warning: failed to configure system DNS: {}", e); - } eprintln!(" Numa will auto-start on boot and restart if killed."); eprintln!(" Logs: /usr/local/var/log/numa.log"); eprintln!(" Run 'sudo numa service stop' to fully uninstall.\n"); @@ -708,8 +673,11 @@ fn install_linux() -> Result<(), String> { .map_err(|e| format!("failed to create {}: {}", resolved_dir.display(), e))?; let drop_in = resolved_dir.join("numa.conf"); - std::fs::write(&drop_in, "[Resolve]\nDNS=127.0.0.1\nDomains=~.\n") - .map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?; + std::fs::write( + &drop_in, + "[Resolve]\nDNS=127.0.0.1\nDomains=~.\nDNSStubListener=no\n", + ) + .map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?; let _ = run_systemctl(&["restart", "systemd-resolved"]); eprintln!(" systemd-resolved detected."); @@ -802,14 +770,15 @@ fn install_service_linux() -> Result<(), String> { run_systemctl(&["daemon-reload"])?; run_systemctl(&["enable", "numa"])?; - run_systemctl(&["start", "numa"])?; - eprintln!(" Service installed and started."); - - // Set system DNS now that the service is running + // Configure system DNS before starting numa so resolved releases port 53 first if let Err(e) = install_linux() { eprintln!(" warning: failed to configure system DNS: {}", e); } + + run_systemctl(&["start", "numa"])?; + + eprintln!(" Service installed and started."); eprintln!(" Numa will auto-start on boot and restart if killed."); eprintln!(" Logs: journalctl -u numa -f"); eprintln!(" Run 'sudo numa service stop' to fully uninstall.\n");