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 a9bd7ab..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, @@ -341,8 +342,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, @@ -434,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, diff --git a/src/ctx.rs b/src/ctx.rs index 1a2f424..6892a56 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -32,7 +32,10 @@ pub struct ServerCtx { pub services: Mutex, pub lan_peers: Mutex, pub forwarding_rules: Vec, - pub upstream: SocketAddr, + pub upstream: Mutex, + 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 @@ -132,7 +135,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..360ed5d 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 --- @@ -109,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 @@ -138,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); @@ -158,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 dd6cead..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() }; @@ -129,7 +132,10 @@ 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, + 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() @@ -240,6 +246,14 @@ async fn main() -> numa::Result<()> { } } + // 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); @@ -264,6 +278,52 @@ async fn main() -> numa::Result<()> { } } +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; + + // 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; + } + } + + // Check upstream change (only for auto-detected upstream) + if ctx.upstream_auto { + let dns_info = numa::system_dns::discover_system_dns(); + // 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; + } + } + } + + // Flush stale LAN peers on any network change + if changed { + 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; 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)]