fix: NS cache lookup from authorities, UDP re-probe, shield alignment

- 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) <noreply@anthropic.com>
This commit is contained in:
Razvan Dimescu
2026-03-27 23:58:30 +02:00
parent 2cdf90c382
commit 231adc523d
4 changed files with 82 additions and 5 deletions

View File

@@ -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. 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.
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.
## The crypto ## The crypto

View File

@@ -766,7 +766,7 @@ function applyLogFilter() {
<td>${e.query_type}</td> <td>${e.query_type}</td>
<td class="domain-cell" title="${e.domain}">${e.domain}${allowBtn}</td> <td class="domain-cell" title="${e.domain}">${e.domain}${allowBtn}</td>
<td><span class="path-tag ${e.path}">${e.path}</span></td> <td><span class="path-tag ${e.path}">${e.path}</span></td>
<td>${e.dnssec === 'secure' ? '<svg title="DNSSEC verified" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--emerald)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>' : ''}${e.rescode}</td> <td style="white-space:nowrap;"><span style="display:inline-block;width:15px;text-align:center;">${e.dnssec === 'secure' ? '<svg title="DNSSEC verified" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--emerald)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>' : ''}</span>${e.rescode}</td>
<td>${e.latency_ms.toFixed(1)}ms</td> <td>${e.latency_ms.toFixed(1)}ms</td>
</tr>`; </tr>`;
}).join(''); }).join('');

View File

@@ -480,6 +480,11 @@ async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
ctx.lan_peers.lock().unwrap().clear(); ctx.lan_peers.lock().unwrap().clear();
info!("flushed LAN peers after network change"); 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;
}
} }
} }

View File

@@ -35,6 +35,29 @@ pub fn reset_udp_state() {
UDP_FAILURES.store(0, Ordering::Relaxed); 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<DnsCache>, root_hints: &[SocketAddr], tlds: &[String]) { pub async fn prime_tld_cache(cache: &RwLock<DnsCache>, root_hints: &[SocketAddr], tlds: &[String]) {
if root_hints.is_empty() || tlds.is_empty() { if root_hints.is_empty() || tlds.is_empty() {
return; return;
@@ -315,7 +338,16 @@ fn find_closest_ns(
let zone = &qname[pos..]; let zone = &qname[pos..];
if let Some(cached) = guard.lookup(zone, QueryType::NS) { if let Some(cached) = guard.lookup(zone, QueryType::NS) {
let mut addrs = Vec::new(); 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 { if let DnsRecord::NS { host, .. } = ns_rec {
for qt in [QueryType::A, QueryType::AAAA] { for qt in [QueryType::A, QueryType::AAAA] {
if let Some(resp) = guard.lookup(host, qt) { if let Some(resp) = guard.lookup(host, qt) {
@@ -719,6 +751,48 @@ mod tests {
assert_eq!(addrs, hints); 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] #[test]
fn minimize_query_from_root() { fn minimize_query_from_root() {
// At root, only reveal TLD // At root, only reveal TLD