From 231adc523d480cf0d18bd88a0b7bc688de8259c6 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 27 Mar 2026 23:58:30 +0200 Subject: [PATCH] fix: NS cache lookup from authorities, UDP re-probe, shield alignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - find_closest_ns checks authorities (not just answers) for NS records, fixing TLD priming cache misses that caused redundant root queries - Periodic UDP re-probe every 5min when disabled — re-enables UDP after switching from a restrictive network to an open one - Dashboard DNSSEC shield uses fixed-width container for alignment - Blog post: tuck key-tag into trust anchor paragraph Co-Authored-By: Claude Opus 4.6 (1M context) --- blog/dnssec-from-scratch.md | 4 +- site/dashboard.html | 2 +- src/main.rs | 5 +++ src/recursive.rs | 76 ++++++++++++++++++++++++++++++++++++- 4 files changed, 82 insertions(+), 5 deletions(-) diff --git a/blog/dnssec-from-scratch.md b/blog/dnssec-from-scratch.md index b95adaa..82c712f 100644 --- a/blog/dnssec-from-scratch.md +++ b/blog/dnssec-from-scratch.md @@ -79,9 +79,7 @@ const ROOT_KSK_PUBLIC_KEY: &[u8] = &[ ]; ``` -When IANA rolls this key (rare — the previous key lasted from 2010 to 2018), every DNSSEC validator on the internet needs updating. For Numa, that means a binary update. Something to watch. - -Every DNSKEY has a key tag — a 16-bit checksum over its RDATA (RFC 4034 Appendix B). The first test I wrote: compute the root KSK's key tag and assert it equals 20326. Instant confidence that the RDATA encoding is correct. +When IANA rolls this key (rare — the previous key lasted from 2010 to 2018), every DNSSEC validator on the internet needs updating. For Numa, that means a binary update. Something to watch. Every DNSKEY also has a key tag — a 16-bit checksum over its RDATA. The first test I wrote: compute the root KSK's key tag and assert it equals 20326. Instant confidence that the encoding is correct. ## The crypto diff --git a/site/dashboard.html b/site/dashboard.html index 366db80..6b89b45 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -766,7 +766,7 @@ function applyLogFilter() { ${e.query_type} ${e.domain}${allowBtn} ${e.path} - ${e.dnssec === 'secure' ? '' : ''}${e.rescode} + ${e.dnssec === 'secure' ? '' : ''}${e.rescode} ${e.latency_ms.toFixed(1)}ms `; }).join(''); diff --git a/src/main.rs b/src/main.rs index 536831f..c8e90e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -480,6 +480,11 @@ async fn network_watch_loop(ctx: Arc) { ctx.lan_peers.lock().unwrap().clear(); info!("flushed LAN peers after network change"); } + + // Re-probe UDP every 5 minutes when disabled + if tick.is_multiple_of(60) { + numa::recursive::probe_udp(&ctx.root_hints).await; + } } } diff --git a/src/recursive.rs b/src/recursive.rs index 3bf4c3b..4a24dda 100644 --- a/src/recursive.rs +++ b/src/recursive.rs @@ -35,6 +35,29 @@ pub fn reset_udp_state() { UDP_FAILURES.store(0, Ordering::Relaxed); } +/// Probe whether UDP works again. Called periodically from the network watch loop. +pub async fn probe_udp(root_hints: &[SocketAddr]) { + if !UDP_DISABLED.load(Ordering::Relaxed) { + return; + } + let hint = match root_hints.first() { + Some(h) => *h, + None => return, + }; + let mut probe = DnsPacket::new(); + probe.header.id = next_id(); + probe + .questions + .push(DnsQuestion::new(".".to_string(), QueryType::NS)); + if forward_udp(&probe, hint, Duration::from_millis(1500)) + .await + .is_ok() + { + info!("UDP probe succeeded — re-enabling UDP"); + reset_udp_state(); + } +} + pub async fn prime_tld_cache(cache: &RwLock, root_hints: &[SocketAddr], tlds: &[String]) { if root_hints.is_empty() || tlds.is_empty() { return; @@ -315,7 +338,16 @@ fn find_closest_ns( let zone = &qname[pos..]; if let Some(cached) = guard.lookup(zone, QueryType::NS) { let mut addrs = Vec::new(); - for ns_rec in &cached.answers { + let ns_records = if cached + .answers + .iter() + .any(|r| matches!(r, DnsRecord::NS { .. })) + { + &cached.answers + } else { + &cached.authorities + }; + for ns_rec in ns_records { if let DnsRecord::NS { host, .. } = ns_rec { for qt in [QueryType::A, QueryType::AAAA] { if let Some(resp) = guard.lookup(host, qt) { @@ -719,6 +751,48 @@ mod tests { assert_eq!(addrs, hints); } + #[test] + fn find_closest_ns_uses_authority_ns_records() { + // Simulate what TLD priming does: cache a referral response where + // NS records are in authorities (not answers), with glue in resources. + let cache = RwLock::new(DnsCache::new(100, 60, 86400)); + let hints = vec![dns_addr(Ipv4Addr::new(198, 41, 0, 4))]; + + // Build a referral-style response (NS in authorities, glue in resources) + let mut referral = DnsPacket::new(); + referral.header.response = true; + referral.authorities.push(DnsRecord::NS { + domain: "com".into(), + host: "ns1.com".into(), + ttl: 3600, + }); + referral.resources.push(DnsRecord::A { + domain: "ns1.com".into(), + addr: Ipv4Addr::new(192, 5, 6, 30), + ttl: 3600, + }); + + // Cache the referral under "com" NS (same as prime_tld_cache does) + { + let mut c = cache.write().unwrap(); + c.insert("com", QueryType::NS, &referral); + // Cache glue separately (as prime_tld_cache does) + let mut glue_pkt = DnsPacket::new(); + glue_pkt.header.response = true; + glue_pkt.answers.push(DnsRecord::A { + domain: "ns1.com".into(), + addr: Ipv4Addr::new(192, 5, 6, 30), + ttl: 3600, + }); + c.insert("ns1.com", QueryType::A, &glue_pkt); + } + + // find_closest_ns should find "com" zone from authority NS records + let (zone, addrs) = find_closest_ns("www.example.com", &cache, &hints); + assert_eq!(zone, "com"); + assert_eq!(addrs, vec![dns_addr(Ipv4Addr::new(192, 5, 6, 30))]); + } + #[test] fn minimize_query_from_root() { // At root, only reveal TLD