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)]