Fix DNS failure on network change #9
@@ -873,6 +873,7 @@ async function refresh() {
|
|||||||
document.getElementById('totalQueries').textContent = formatNumber(q.total);
|
document.getElementById('totalQueries').textContent = formatNumber(q.total);
|
||||||
document.getElementById('uptime').textContent = formatUptime(stats.uptime_secs);
|
document.getElementById('uptime').textContent = formatUptime(stats.uptime_secs);
|
||||||
document.getElementById('uptimeSub').textContent = formatUptimeSub(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('overrideCount').textContent = stats.overrides.active;
|
||||||
document.getElementById('blockedCount').textContent = formatNumber(q.blocked);
|
document.getElementById('blockedCount').textContent = formatNumber(q.blocked);
|
||||||
const bl = stats.blocking;
|
const bl = stats.blocking;
|
||||||
@@ -1150,7 +1151,8 @@ setInterval(refresh, 2000);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div style="text-align:center;padding:0.8rem;font-family:var(--font-mono);font-size:0.68rem;color:var(--text-dim);">
|
<div style="text-align:center;padding:0.8rem;font-family:var(--font-mono);font-size:0.68rem;color:var(--text-dim);">
|
||||||
Logs: <span id="logPath" style="user-select:all;">macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f</span>
|
Upstream: <span id="footerUpstream" style="user-select:all;color:var(--emerald);"></span>
|
||||||
|
· Logs: <span id="logPath" style="user-select:all;">macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f</span>
|
||||||
· <a href="https://github.com/razvandimescu/numa" target="_blank" rel="noopener" style="color:var(--amber);text-decoration:none;">GitHub</a>
|
· <a href="https://github.com/razvandimescu/numa" target="_blank" rel="noopener" style="color:var(--amber);text-decoration:none;">GitHub</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ struct QueryLogResponse {
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct StatsResponse {
|
struct StatsResponse {
|
||||||
uptime_secs: u64,
|
uptime_secs: u64,
|
||||||
|
upstream: String,
|
||||||
queries: QueriesStats,
|
queries: QueriesStats,
|
||||||
cache: CacheStats,
|
cache: CacheStats,
|
||||||
overrides: OverrideStats,
|
overrides: OverrideStats,
|
||||||
@@ -341,8 +342,9 @@ async fn diagnose(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check upstream (async, no locks held)
|
// Check upstream (async, no locks held)
|
||||||
|
let upstream = *ctx.upstream.lock().unwrap();
|
||||||
let (upstream_matched, upstream_detail) =
|
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 {
|
steps.push(DiagnoseStep {
|
||||||
source: "upstream".to_string(),
|
source: "upstream".to_string(),
|
||||||
matched: upstream_matched,
|
matched: upstream_matched,
|
||||||
@@ -434,8 +436,11 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
|||||||
let override_count = ctx.overrides.lock().unwrap().active_count();
|
let override_count = ctx.overrides.lock().unwrap().active_count();
|
||||||
let bl_stats = ctx.blocklist.lock().unwrap().stats();
|
let bl_stats = ctx.blocklist.lock().unwrap().stats();
|
||||||
|
|
||||||
|
let upstream = ctx.upstream.lock().unwrap().to_string();
|
||||||
|
|
||||||
Json(StatsResponse {
|
Json(StatsResponse {
|
||||||
uptime_secs: snap.uptime_secs,
|
uptime_secs: snap.uptime_secs,
|
||||||
|
upstream,
|
||||||
queries: QueriesStats {
|
queries: QueriesStats {
|
||||||
total: snap.total,
|
total: snap.total,
|
||||||
forwarded: snap.forwarded,
|
forwarded: snap.forwarded,
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ pub struct ServerCtx {
|
|||||||
pub services: Mutex<ServiceStore>,
|
pub services: Mutex<ServiceStore>,
|
||||||
pub lan_peers: Mutex<PeerStore>,
|
pub lan_peers: Mutex<PeerStore>,
|
||||||
pub forwarding_rules: Vec<ForwardingRule>,
|
pub forwarding_rules: Vec<ForwardingRule>,
|
||||||
pub upstream: SocketAddr,
|
pub upstream: Mutex<SocketAddr>,
|
||||||
|
pub upstream_auto: bool,
|
||||||
|
pub upstream_port: u16,
|
||||||
|
pub lan_ip: Mutex<std::net::Ipv4Addr>,
|
||||||
pub timeout: Duration,
|
pub timeout: Duration,
|
||||||
pub proxy_tld: String,
|
pub proxy_tld: String,
|
||||||
pub proxy_tld_suffix: String, // pre-computed ".{tld}" to avoid per-query allocation
|
pub proxy_tld_suffix: String, // pre-computed ".{tld}" to avoid per-query allocation
|
||||||
@@ -132,7 +135,7 @@ pub async fn handle_query(
|
|||||||
} else {
|
} else {
|
||||||
let upstream =
|
let upstream =
|
||||||
crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules)
|
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 {
|
match forward_query(&query, upstream, ctx.timeout).await {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
ctx.cache.lock().unwrap().insert(&qname, qtype, &resp);
|
ctx.cache.lock().unwrap().insert(&qname, qtype, &resp);
|
||||||
|
|||||||
10
src/lan.rs
10
src/lan.rs
@@ -57,6 +57,10 @@ impl PeerStore {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.peers.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Multicast ---
|
// --- Multicast ---
|
||||||
@@ -109,7 +113,7 @@ pub async fn start_lan_discovery(ctx: Arc<ServerCtx>, config: &LanConfig) {
|
|||||||
.as_nanos() as u64;
|
.as_nanos() as u64;
|
||||||
pid ^ ts
|
pid ^ ts
|
||||||
};
|
};
|
||||||
let local_ip = detect_lan_ip().unwrap_or(Ipv4Addr::LOCALHOST);
|
let local_ip = *ctx.lan_ip.lock().unwrap();
|
||||||
info!(
|
info!(
|
||||||
"LAN discovery on {}:{}, local IP {}, instance {:016x}",
|
"LAN discovery on {}:{}, local IP {}, instance {:016x}",
|
||||||
multicast_group, port, local_ip, instance_id
|
multicast_group, port, local_ip, instance_id
|
||||||
@@ -138,7 +142,6 @@ pub async fn start_lan_discovery(ctx: Arc<ServerCtx>, config: &LanConfig) {
|
|||||||
// Spawn sender
|
// Spawn sender
|
||||||
let sender_ctx = Arc::clone(&ctx);
|
let sender_ctx = Arc::clone(&ctx);
|
||||||
let sender_socket = Arc::clone(&socket);
|
let sender_socket = Arc::clone(&socket);
|
||||||
let local_ip_str = local_ip.to_string();
|
|
||||||
let dest = SocketAddr::new(IpAddr::V4(multicast_group), port);
|
let dest = SocketAddr::new(IpAddr::V4(multicast_group), port);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut ticker = tokio::time::interval(interval);
|
let mut ticker = tokio::time::interval(interval);
|
||||||
@@ -158,9 +161,10 @@ pub async fn start_lan_discovery(ctx: Arc<ServerCtx>, config: &LanConfig) {
|
|||||||
if services.is_empty() {
|
if services.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let current_ip = sender_ctx.lan_ip.lock().unwrap().to_string();
|
||||||
let announcement = Announcement {
|
let announcement = Announcement {
|
||||||
instance_id,
|
instance_id,
|
||||||
host: local_ip_str.clone(),
|
host: current_ip,
|
||||||
services,
|
services,
|
||||||
};
|
};
|
||||||
if let Ok(json) = serde_json::to_vec(&announcement) {
|
if let Ok(json) = serde_json::to_vec(&announcement) {
|
||||||
|
|||||||
64
src/main.rs
64
src/main.rs
@@ -86,7 +86,10 @@ async fn main() -> numa::Result<()> {
|
|||||||
let system_dns = discover_system_dns();
|
let system_dns = discover_system_dns();
|
||||||
|
|
||||||
let upstream_addr = if config.upstream.address.is_empty() {
|
let upstream_addr = if config.upstream.address.is_empty() {
|
||||||
system_dns.default_upstream.unwrap_or_else(|| {
|
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)");
|
info!("could not detect system DNS, falling back to 9.9.9.9 (Quad9)");
|
||||||
"9.9.9.9".to_string()
|
"9.9.9.9".to_string()
|
||||||
})
|
})
|
||||||
@@ -129,7 +132,10 @@ async fn main() -> numa::Result<()> {
|
|||||||
services: Mutex::new(service_store),
|
services: Mutex::new(service_store),
|
||||||
lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)),
|
lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)),
|
||||||
forwarding_rules,
|
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),
|
timeout: Duration::from_millis(config.upstream.timeout_ms),
|
||||||
proxy_tld_suffix: if config.proxy.tld.is_empty() {
|
proxy_tld_suffix: if config.proxy.tld.is_empty() {
|
||||||
String::new()
|
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
|
// Spawn LAN service discovery
|
||||||
if config.lan.enabled {
|
if config.lan.enabled {
|
||||||
let lan_ctx = Arc::clone(&ctx);
|
let lan_ctx = Arc::clone(&ctx);
|
||||||
@@ -264,6 +278,52 @@ async fn main() -> numa::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
|
||||||
|
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::<SocketAddr>()
|
||||||
|
{
|
||||||
|
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]) {
|
async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) {
|
||||||
let downloaded = download_blocklists(lists).await;
|
let downloaded = download_blocklists(lists).await;
|
||||||
|
|
||||||
|
|||||||
@@ -205,6 +205,53 @@ fn read_upstream_from_file(path: &str) -> Option<String> {
|
|||||||
None
|
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<String> {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
detect_dhcp_dns_macos()
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn detect_dhcp_dns_macos() -> Option<String> {
|
||||||
|
// 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::<std::net::Ipv4Addr>().is_ok()
|
||||||
|
{
|
||||||
|
log::info!("detected DHCP DNS: {}", addr);
|
||||||
|
return Some(addr.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
// --- Windows implementation ---
|
// --- Windows implementation ---
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
|
|||||||
Reference in New Issue
Block a user