From 7aca3b1991097d573977289fd76d0c24139d96ad Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 22 Mar 2026 09:31:49 +0200 Subject: [PATCH 1/4] fix DNS failure on network change with upstream re-detection Upstream DNS was resolved once at startup and never updated. Switching Wi-Fi networks made all queries fail until restart. Now spawns a background task (every 30s) that re-runs system DNS discovery and swaps the upstream atomically if it changed. Also flushes stale LAN peers from the old network on change. Only activates when upstream is auto-detected (not explicitly configured). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/api.rs | 3 ++- src/ctx.rs | 6 ++++-- src/lan.rs | 4 ++++ src/main.rs | 45 ++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/api.rs b/src/api.rs index a9bd7ab..0c6bc54 100644 --- a/src/api.rs +++ b/src/api.rs @@ -341,8 +341,9 @@ async fn diagnose( } // Check upstream (async, no locks held) + let upstream = *ctx.upstream.lock().unwrap(); let (upstream_matched, upstream_detail) = - forward_query_for_diagnose(&domain_lower, ctx.upstream, ctx.timeout).await; + forward_query_for_diagnose(&domain_lower, upstream, ctx.timeout).await; steps.push(DiagnoseStep { source: "upstream".to_string(), matched: upstream_matched, diff --git a/src/ctx.rs b/src/ctx.rs index 1a2f424..ebcbf97 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -32,7 +32,9 @@ pub struct ServerCtx { pub services: Mutex, pub lan_peers: Mutex, pub forwarding_rules: Vec, - pub upstream: SocketAddr, + pub upstream: Mutex, + pub upstream_auto: bool, // true = auto-detected, false = explicitly configured + pub upstream_port: u16, pub timeout: Duration, pub proxy_tld: String, pub proxy_tld_suffix: String, // pre-computed ".{tld}" to avoid per-query allocation @@ -132,7 +134,7 @@ pub async fn handle_query( } else { let upstream = crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) - .unwrap_or(ctx.upstream); + .unwrap_or_else(|| *ctx.upstream.lock().unwrap()); match forward_query(&query, upstream, ctx.timeout).await { Ok(resp) => { ctx.cache.lock().unwrap().insert(&qname, qtype, &resp); diff --git a/src/lan.rs b/src/lan.rs index 9a24b1e..917c886 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -57,6 +57,10 @@ impl PeerStore { }) .collect() } + + pub fn clear(&mut self) { + self.peers.clear(); + } } // --- Multicast --- diff --git a/src/main.rs b/src/main.rs index dd6cead..f706f6a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -129,7 +129,9 @@ async fn main() -> numa::Result<()> { services: Mutex::new(service_store), lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)), forwarding_rules, - upstream, + upstream: Mutex::new(upstream), + upstream_auto: config.upstream.address.is_empty(), + upstream_port: config.upstream.port, timeout: Duration::from_millis(config.upstream.timeout_ms), proxy_tld_suffix: if config.proxy.tld.is_empty() { String::new() @@ -240,6 +242,14 @@ async fn main() -> numa::Result<()> { } } + // Spawn upstream re-detection (only for auto-detected upstream) + if ctx.upstream_auto { + let redetect_ctx = Arc::clone(&ctx); + tokio::spawn(async move { + upstream_redetect_loop(redetect_ctx).await; + }); + } + // Spawn LAN service discovery if config.lan.enabled { let lan_ctx = Arc::clone(&ctx); @@ -264,6 +274,39 @@ async fn main() -> numa::Result<()> { } } +async fn upstream_redetect_loop(ctx: Arc) { + use numa::system_dns::discover_system_dns; + + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.tick().await; // skip immediate tick + + loop { + interval.tick().await; + + let dns_info = discover_system_dns(); + let new_addr = match dns_info.default_upstream { + Some(addr) => addr, + None => continue, + }; + let new_upstream: SocketAddr = match format!("{}:{}", new_addr, ctx.upstream_port).parse() { + Ok(addr) => addr, + Err(_) => continue, + }; + + let mut upstream = ctx.upstream.lock().unwrap(); + let current = *upstream; + if new_upstream != current { + *upstream = new_upstream; + drop(upstream); + info!("upstream changed: {} → {}", current, new_upstream); + + // Flush stale LAN peers from old network + ctx.lan_peers.lock().unwrap().clear(); + info!("flushed LAN peers after network change"); + } + } +} + async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) { let downloaded = download_blocklists(lists).await; From 995916d01b14a927abe79f4ca88be30e9d062eae Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 22 Mar 2026 09:38:09 +0200 Subject: [PATCH 2/4] generalize upstream re-detection into network change watcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always detect network changes (LAN IP, upstream, peers) regardless of upstream config. LAN IP is now tracked in ServerCtx and updated every 30s — multicast announcements use the current IP instead of the startup IP. Upstream re-detection still only runs when auto-detected. Peer flush triggers on any network change. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ctx.rs | 3 ++- src/lan.rs | 6 +++--- src/main.rs | 57 ++++++++++++++++++++++++++++++++--------------------- 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index ebcbf97..6892a56 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -33,8 +33,9 @@ pub struct ServerCtx { pub lan_peers: Mutex, pub forwarding_rules: Vec, pub upstream: Mutex, - pub upstream_auto: bool, // true = auto-detected, false = explicitly configured + pub upstream_auto: bool, pub upstream_port: u16, + pub lan_ip: Mutex, pub timeout: Duration, pub proxy_tld: String, pub proxy_tld_suffix: String, // pre-computed ".{tld}" to avoid per-query allocation diff --git a/src/lan.rs b/src/lan.rs index 917c886..360ed5d 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -113,7 +113,7 @@ pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { .as_nanos() as u64; pid ^ ts }; - let local_ip = detect_lan_ip().unwrap_or(Ipv4Addr::LOCALHOST); + let local_ip = *ctx.lan_ip.lock().unwrap(); info!( "LAN discovery on {}:{}, local IP {}, instance {:016x}", multicast_group, port, local_ip, instance_id @@ -142,7 +142,6 @@ pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { // Spawn sender let sender_ctx = Arc::clone(&ctx); let sender_socket = Arc::clone(&socket); - let local_ip_str = local_ip.to_string(); let dest = SocketAddr::new(IpAddr::V4(multicast_group), port); tokio::spawn(async move { let mut ticker = tokio::time::interval(interval); @@ -162,9 +161,10 @@ pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { if services.is_empty() { continue; } + let current_ip = sender_ctx.lan_ip.lock().unwrap().to_string(); let announcement = Announcement { instance_id, - host: local_ip_str.clone(), + host: current_ip, services, }; if let Ok(json) = serde_json::to_vec(&announcement) { diff --git a/src/main.rs b/src/main.rs index f706f6a..3934d61 100644 --- a/src/main.rs +++ b/src/main.rs @@ -132,6 +132,7 @@ async fn main() -> numa::Result<()> { upstream: Mutex::new(upstream), upstream_auto: config.upstream.address.is_empty(), 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), proxy_tld_suffix: if config.proxy.tld.is_empty() { String::new() @@ -242,11 +243,11 @@ async fn main() -> numa::Result<()> { } } - // Spawn upstream re-detection (only for auto-detected upstream) - if ctx.upstream_auto { - let redetect_ctx = Arc::clone(&ctx); + // Spawn network change watcher (upstream re-detection, LAN IP update, peer flush) + { + let watch_ctx = Arc::clone(&ctx); tokio::spawn(async move { - upstream_redetect_loop(redetect_ctx).await; + network_watch_loop(watch_ctx).await; }); } @@ -274,33 +275,43 @@ async fn main() -> numa::Result<()> { } } -async fn upstream_redetect_loop(ctx: Arc) { - use numa::system_dns::discover_system_dns; - +async fn network_watch_loop(ctx: Arc) { let mut interval = tokio::time::interval(Duration::from_secs(30)); interval.tick().await; // skip immediate tick loop { interval.tick().await; + let mut changed = false; - let dns_info = discover_system_dns(); - let new_addr = match dns_info.default_upstream { - Some(addr) => addr, - None => continue, - }; - let new_upstream: SocketAddr = match format!("{}:{}", new_addr, ctx.upstream_port).parse() { - Ok(addr) => addr, - Err(_) => continue, - }; + // Check LAN IP change + 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; + } + } - let mut upstream = ctx.upstream.lock().unwrap(); - let current = *upstream; - if new_upstream != current { - *upstream = new_upstream; - drop(upstream); - info!("upstream changed: {} → {}", current, new_upstream); + // Check upstream change (only for auto-detected upstream) + if ctx.upstream_auto { + let dns_info = numa::system_dns::discover_system_dns(); + if let Some(new_addr) = dns_info.default_upstream { + if let Ok(new_upstream) = + format!("{}:{}", new_addr, ctx.upstream_port).parse::() + { + let mut upstream = ctx.upstream.lock().unwrap(); + if new_upstream != *upstream { + info!("upstream changed: {} → {}", *upstream, new_upstream); + *upstream = new_upstream; + changed = true; + } + } + } + } - // Flush stale LAN peers from old network + // Flush stale LAN peers on any network change + if changed { ctx.lan_peers.lock().unwrap().clear(); info!("flushed LAN peers after network change"); } From 06850de728cc44dce6ee8acdfac6c6e26dd681c2 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 22 Mar 2026 10:24:54 +0200 Subject: [PATCH 3/4] fix circular reference: detect DHCP DNS when scutil shows loopback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When numa install is active, scutil --dns only returns 127.0.0.1. Previously fell back to 9.9.9.9 (Quad9) which fails on networks that block external DNS. Now reads DHCP-provided DNS from ipconfig getpacket en0/en1 as intermediate fallback before Quad9. Tested on a network that blocks 8.8.8.8, 9.9.9.9, 1.1.1.1 but allows ISP DNS (213.154.124.25) — Numa now auto-detects and uses it. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 34 ++++++++++++++++++++-------------- src/system_dns.rs | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/src/main.rs b/src/main.rs index 3934d61..5de9a15 100644 --- a/src/main.rs +++ b/src/main.rs @@ -86,10 +86,13 @@ async fn main() -> numa::Result<()> { let system_dns = discover_system_dns(); let upstream_addr = if config.upstream.address.is_empty() { - system_dns.default_upstream.unwrap_or_else(|| { - info!("could not detect system DNS, falling back to 9.9.9.9 (Quad9)"); - "9.9.9.9".to_string() - }) + system_dns + .default_upstream + .or_else(numa::system_dns::detect_dhcp_dns) + .unwrap_or_else(|| { + info!("could not detect system DNS, falling back to 9.9.9.9 (Quad9)"); + "9.9.9.9".to_string() + }) } else { config.upstream.address.clone() }; @@ -296,16 +299,19 @@ async fn network_watch_loop(ctx: Arc) { // Check upstream change (only for auto-detected upstream) if ctx.upstream_auto { let dns_info = numa::system_dns::discover_system_dns(); - if let Some(new_addr) = dns_info.default_upstream { - if let Ok(new_upstream) = - format!("{}:{}", new_addr, ctx.upstream_port).parse::() - { - let mut upstream = ctx.upstream.lock().unwrap(); - if new_upstream != *upstream { - info!("upstream changed: {} → {}", *upstream, new_upstream); - *upstream = new_upstream; - changed = true; - } + // Use detected upstream, or try DHCP-provided DNS, or fall back to Quad9 + let new_addr = dns_info + .default_upstream + .or_else(numa::system_dns::detect_dhcp_dns) + .unwrap_or_else(|| "9.9.9.9".to_string()); + if let Ok(new_upstream) = + format!("{}:{}", new_addr, ctx.upstream_port).parse::() + { + let mut upstream = ctx.upstream.lock().unwrap(); + if new_upstream != *upstream { + info!("upstream changed: {} → {}", *upstream, new_upstream); + *upstream = new_upstream; + changed = true; } } } diff --git a/src/system_dns.rs b/src/system_dns.rs index 9d46ea3..57559b5 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -205,6 +205,53 @@ fn read_upstream_from_file(path: &str) -> Option { None } +/// Detect DNS server from DHCP lease — fallback when scutil/resolv.conf only shows 127.0.0.1. +/// On macOS: parses `ipconfig getpacket en0` for domain_name_server. +/// On Linux/Windows: returns None (not implemented yet). +pub fn detect_dhcp_dns() -> Option { + #[cfg(target_os = "macos")] + { + detect_dhcp_dns_macos() + } + #[cfg(not(target_os = "macos"))] + { + None + } +} + +#[cfg(target_os = "macos")] +fn detect_dhcp_dns_macos() -> Option { + // Try common interfaces + for iface in &["en0", "en1"] { + let output = std::process::Command::new("ipconfig") + .args(["getpacket", iface]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + if line.contains("domain_name_server") { + // Format: "domain_name_server (ip_mult): {213.154.124.25, 1.0.0.1}" + if let Some(braces) = line.split('{').nth(1) { + let inner = braces.trim_end_matches('}').trim(); + // 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() + { + log::info!("detected DHCP DNS: {}", addr); + return Some(addr.to_string()); + } + } + } + } + } + } + None +} + // --- Windows implementation --- #[cfg(windows)] From 5810ee5aac6111b47d8fa0504c191c6be8b53061 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 22 Mar 2026 11:04:54 +0200 Subject: [PATCH 4/4] show upstream DNS in stats API and dashboard footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose current upstream address in /stats response. Dashboard footer now shows "Upstream: x.x.x.x:53" — updates live when the network watcher swaps the upstream. Co-Authored-By: Claude Opus 4.6 (1M context) --- site/dashboard.html | 4 +++- src/api.rs | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/site/dashboard.html b/site/dashboard.html index f600a0a..a0434a1 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -873,6 +873,7 @@ 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('footerUpstream').textContent = stats.upstream || ''; document.getElementById('overrideCount').textContent = stats.overrides.active; document.getElementById('blockedCount').textContent = formatNumber(q.blocked); const bl = stats.blocking; @@ -1150,7 +1151,8 @@ setInterval(refresh, 2000);
- Logs: macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f + Upstream: + · 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 0c6bc54..b3ae490 100644 --- a/src/api.rs +++ b/src/api.rs @@ -126,6 +126,7 @@ struct QueryLogResponse { #[derive(Serialize)] struct StatsResponse { uptime_secs: u64, + upstream: String, queries: QueriesStats, cache: CacheStats, overrides: OverrideStats, @@ -435,8 +436,11 @@ async fn stats(State(ctx): State>) -> Json { let override_count = ctx.overrides.lock().unwrap().active_count(); let bl_stats = ctx.blocklist.lock().unwrap().stats(); + let upstream = ctx.upstream.lock().unwrap().to_string(); + Json(StatsResponse { uptime_secs: snap.uptime_secs, + upstream, queries: QueriesStats { total: snap.total, forwarded: snap.forwarded,