feat: recursive resolution + full DNSSEC validation

Numa becomes a true DNS resolver — resolves from root nameservers
with complete DNSSEC chain-of-trust verification.

Recursive resolution:
- Iterative RFC 1034 from configurable root hints (13 default)
- CNAME chasing (depth 8), referral following (depth 10)
- A+AAAA glue extraction, IPv6 nameserver support
- TLD priming: NS + DS + DNSKEY for 34 gTLDs + EU ccTLDs
- Config: mode = "recursive" in [upstream], root_hints, prime_tlds

DNSSEC (all 4 phases):
- EDNS0 OPT pseudo-record (DO bit, 1232 payload per DNS Flag Day 2020)
- DNSKEY, DS, RRSIG, NSEC, NSEC3 record types with wire read/write
- Signature verification via ring: RSA/SHA-256, ECDSA P-256, Ed25519
- Chain-of-trust: zone DNSKEY → parent DS → root KSK (key tag 20326)
- DNSKEY RRset self-signature verification (RRSIG(DNSKEY) by KSK)
- RRSIG expiration/inception time validation
- NSEC: NXDOMAIN gap proofs, NODATA type absence, wildcard denial
- NSEC3: SHA-1 iterated hashing, closest encloser proof, hash range
- Authority RRSIG verification for denial proofs
- Config: [dnssec] enabled/strict (default false, opt-in)
- AD bit on Secure, SERVFAIL on Bogus+strict
- DnssecStatus cached per entry, ValidationStats logging

Performance:
- TLD chain pre-warmed on startup (root DNSKEY + TLD DS/DNSKEY)
- Referral DS piggybacking from authority sections
- DNSKEY prefetch before validation loop
- Cold-cache validation: ~1 DNSKEY fetch (down from 5)
- Benchmarks: RSA 10.9µs, ECDSA 174ns, DS verify 257ns

Also:
- write_qname fix for root domain "." (was producing malformed queries)
- write_record_header() dedup, write_bytes() bulk writes
- DnsRecord::domain() + query_type() accessors
- UpstreamMode enum, DEFAULT_EDNS_PAYLOAD const
- Real glue TTL (was hardcoded 3600)
- DNSSEC restricted to recursive mode only

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Razvan Dimescu
2026-03-27 16:45:36 +02:00
parent cc8d3c7a83
commit 637b374d8b
23 changed files with 4325 additions and 98 deletions

1
Cargo.lock generated
View File

@@ -1154,6 +1154,7 @@ dependencies = [
"log",
"rcgen",
"reqwest",
"ring",
"rustls",
"serde",
"serde_json",

View File

@@ -28,6 +28,7 @@ time = "0.3"
rustls = "0.23"
tokio-rustls = "0.26"
arc-swap = "1"
ring = "0.17"
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
@@ -39,3 +40,7 @@ harness = false
[[bench]]
name = "throughput"
harness = false
[[bench]]
name = "dnssec"
harness = false

View File

@@ -1,6 +1,6 @@
.PHONY: all build lint fmt check audit test bench clean deploy blog
all: lint build
all: lint build test
build:
cargo build

View File

@@ -8,7 +8,7 @@
A portable DNS resolver in a single binary. Block ads on any network, name your local services (`frontend.numa`), and override any hostname with auto-revert — all from your laptop, no cloud account or Raspberry Pi required.
Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. One ~8MB binary, no PHP, no web server, no database — everything is embedded.
Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. Recursive resolution from root nameservers with full DNSSEC validation (chain-of-trust + NSEC/NSEC3 denial proofs). One ~8MB binary, no PHP, no web server, no database — everything is embedded.
![Numa dashboard](assets/hero-demo.gif)
@@ -135,6 +135,7 @@ bind_addr = "0.0.0.0:53"
| Path-based routing | No | No | No | No | Prefix match + strip |
| LAN service discovery | No | No | No | No | mDNS, opt-in |
| Developer overrides | No | No | No | No | REST API + auto-expiry |
| Recursive resolver | No | No | Cloud only | Cloud only | From root hints, DNSSEC |
| Encrypted upstream (DoH) | No (needs cloudflared) | Yes | Cloud only | Cloud only | Native, single binary |
| Portable (travels with laptop) | No (appliance) | No (appliance) | Cloud only | Cloud only | Single binary |
| Zero config | Complex | Docker/setup | Yes | Yes | Works out of the box |
@@ -144,9 +145,11 @@ bind_addr = "0.0.0.0:53"
## How It Works
```
Query → Overrides → .numa TLD → Blocklist → Local Zones → Cache → Upstream
Query → Overrides → .numa TLD → Blocklist → Local Zones → Cache → Recursive/Forward
```
Two resolution modes: **forward** (relay to upstream like Quad9/Cloudflare) or **recursive** (resolve from root nameservers — no upstream dependency). Set `mode = "recursive"` in `[upstream]` to resolve independently.
No DNS libraries — no `hickory-dns`, no `trust-dns`. The wire protocol — headers, labels, compression pointers, record types — is parsed and serialized by hand. Runs on `tokio` + `axum`, async per-query task spawning.
[Configuration reference](numa.toml)
@@ -161,6 +164,8 @@ No DNS libraries — no `hickory-dns`, no `trust-dns`. The wire protocol — hea
- [x] Path-based routing — URL prefix routing with optional strip, REST API
- [x] LAN service discovery — mDNS auto-discovery (opt-in), cross-machine DNS + proxy
- [x] DNS-over-HTTPS — encrypted upstream via DoH (Quad9, Cloudflare, any provider)
- [x] Recursive resolution — resolve from root nameservers, no upstream dependency
- [x] DNSSEC validation — chain-of-trust, NSEC/NSEC3 denial proofs, AD bit (RSA, ECDSA, Ed25519)
- [ ] pkarr integration — self-sovereign DNS via Mainline DHT (15M nodes)
- [ ] Global `.numa` names — self-publish, DHT-backed, first-come-first-served

183
benches/dnssec.rs Normal file
View File

@@ -0,0 +1,183 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use numa::dnssec;
use numa::question::QueryType;
use numa::record::DnsRecord;
// Realistic ECDSA P-256 key (64 bytes) and signature (64 bytes)
fn make_ecdsa_key() -> Vec<u8> {
vec![0xAB; 64]
}
fn make_ecdsa_sig() -> Vec<u8> {
vec![0xCD; 64]
}
// Realistic RSA-2048 key (RFC 3110 format: exp_len=3, exp=65537, mod=256 bytes)
fn make_rsa_key() -> Vec<u8> {
let mut key = vec![3u8]; // exponent length
key.extend(&[0x01, 0x00, 0x01]); // exponent = 65537
key.extend(vec![0xFF; 256]); // modulus (256 bytes = 2048 bits)
key
}
fn make_ed25519_key() -> Vec<u8> {
vec![0xEF; 32]
}
fn make_dnskey(algorithm: u8, public_key: Vec<u8>) -> DnsRecord {
DnsRecord::DNSKEY {
domain: "example.com".into(),
flags: 257,
protocol: 3,
algorithm,
public_key,
ttl: 3600,
}
}
fn make_rrsig(algorithm: u8, signature: Vec<u8>) -> DnsRecord {
DnsRecord::RRSIG {
domain: "example.com".into(),
type_covered: QueryType::A.to_num(),
algorithm,
labels: 2,
original_ttl: 300,
expiration: 2000000000,
inception: 1600000000,
key_tag: 12345,
signer_name: "example.com".into(),
signature,
ttl: 300,
}
}
fn make_rrset() -> Vec<DnsRecord> {
vec![
DnsRecord::A {
domain: "example.com".into(),
addr: "93.184.216.34".parse().unwrap(),
ttl: 300,
},
DnsRecord::A {
domain: "example.com".into(),
addr: "93.184.216.35".parse().unwrap(),
ttl: 300,
},
]
}
fn bench_key_tag(c: &mut Criterion) {
let key = make_rsa_key();
c.bench_function("key_tag_rsa2048", |b| {
b.iter(|| {
dnssec::compute_key_tag(black_box(257), black_box(3), black_box(8), black_box(&key))
})
});
let key = make_ecdsa_key();
c.bench_function("key_tag_ecdsa_p256", |b| {
b.iter(|| {
dnssec::compute_key_tag(black_box(257), black_box(3), black_box(13), black_box(&key))
})
});
}
fn bench_name_to_wire(c: &mut Criterion) {
c.bench_function("name_to_wire_short", |b| {
b.iter(|| dnssec::name_to_wire(black_box("example.com")))
});
c.bench_function("name_to_wire_long", |b| {
b.iter(|| dnssec::name_to_wire(black_box("sub.deep.nested.example.co.uk")))
});
}
fn bench_build_signed_data(c: &mut Criterion) {
let rrsig = make_rrsig(13, make_ecdsa_sig());
let rrset = make_rrset();
let rrset_refs: Vec<&DnsRecord> = rrset.iter().collect();
c.bench_function("build_signed_data_2_A_records", |b| {
b.iter(|| dnssec::build_signed_data(black_box(&rrsig), black_box(&rrset_refs)))
});
}
fn bench_verify_signature(c: &mut Criterion) {
// These will fail verification (keys/sigs are random), but we measure the
// crypto overhead — ring still does the full algorithm before returning error.
let data = vec![0u8; 128]; // typical signed data size
let rsa_key = make_rsa_key();
let rsa_sig = vec![0xAA; 256]; // RSA-2048 signature
c.bench_function("verify_rsa_sha256_2048", |b| {
b.iter(|| {
dnssec::verify_signature(
black_box(8),
black_box(&rsa_key),
black_box(&data),
black_box(&rsa_sig),
)
})
});
let ecdsa_key = make_ecdsa_key();
let ecdsa_sig = make_ecdsa_sig();
c.bench_function("verify_ecdsa_p256", |b| {
b.iter(|| {
dnssec::verify_signature(
black_box(13),
black_box(&ecdsa_key),
black_box(&data),
black_box(&ecdsa_sig),
)
})
});
let ed_key = make_ed25519_key();
let ed_sig = vec![0xBB; 64];
c.bench_function("verify_ed25519", |b| {
b.iter(|| {
dnssec::verify_signature(
black_box(15),
black_box(&ed_key),
black_box(&data),
black_box(&ed_sig),
)
})
});
}
fn bench_ds_verification(c: &mut Criterion) {
let dk = make_dnskey(8, make_rsa_key());
// Compute correct DS digest
let owner_wire = dnssec::name_to_wire("example.com");
let mut dnskey_rdata = vec![1u8, 1, 3, 8]; // flags=257, proto=3, algo=8
dnskey_rdata.extend(&make_rsa_key());
let mut input = Vec::new();
input.extend(&owner_wire);
input.extend(&dnskey_rdata);
let digest = ring::digest::digest(&ring::digest::SHA256, &input);
let ds = DnsRecord::DS {
domain: "example.com".into(),
key_tag: dnssec::compute_key_tag(257, 3, 8, &make_rsa_key()),
algorithm: 8,
digest_type: 2,
digest: digest.as_ref().to_vec(),
ttl: 86400,
};
c.bench_function("verify_ds_sha256", |b| {
b.iter(|| dnssec::verify_ds(black_box(&ds), black_box(&dk), black_box("example.com")))
});
}
criterion_group!(
dnssec_benches,
bench_key_tag,
bench_name_to_wire,
bench_build_signed_data,
bench_verify_signature,
bench_ds_verification,
);
criterion_main!(dnssec_benches);

View File

@@ -4,12 +4,39 @@ api_port = 5380
# api_bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN dashboard access
# [upstream]
# address = "" # auto-detect from system resolver (default)
# mode = "forward" # "forward" (default) — relay to upstream
# # "recursive" — resolve from root hints (no address needed)
# address = "https://dns.quad9.net/dns-query" # DNS-over-HTTPS (encrypted)
# address = "https://cloudflare-dns.com/dns-query" # Cloudflare DoH
# address = "9.9.9.9" # plain UDP
# port = 53 # only used for plain UDP
# port = 53 # only for forward mode, plain UDP
# timeout_ms = 3000
# root_hints = [ # only used in recursive mode
# "198.41.0.4", # a.root-servers.net (Verisign)
# "199.9.14.201", # b.root-servers.net (USC-ISI)
# "192.33.4.12", # c.root-servers.net (Cogent)
# "199.7.91.13", # d.root-servers.net (UMD)
# "192.203.230.10", # e.root-servers.net (NASA)
# "192.5.5.241", # f.root-servers.net (ISC)
# "192.112.36.4", # g.root-servers.net (US DoD)
# "198.97.190.53", # h.root-servers.net (US Army)
# "192.36.148.17", # i.root-servers.net (Netnod)
# "192.58.128.30", # j.root-servers.net (Verisign)
# "193.0.14.129", # k.root-servers.net (RIPE NCC)
# "199.7.83.42", # l.root-servers.net (ICANN)
# "202.12.27.33", # m.root-servers.net (WIDE)
# ]
# prime_tlds = [ # TLDs to pre-warm on startup (recursive mode)
# "com", "net", "org", "info", # gTLDs
# "io", "dev", "app", "xyz", "me",
# "eu", "uk", "de", "fr", "nl", # EU + European ccTLDs
# "it", "es", "pl", "se", "no",
# "dk", "fi", "at", "be", "ie",
# "pt", "cz", "ro", "gr", "hu",
# "bg", "hr", "sk", "si", "lt",
# "lv", "ee", "ch", "is",
# "co", "br", "au", "ca", "jp", # other major ccTLDs
# ]
# [blocking]
# enabled = true # set to false to disable ad blocking
@@ -51,6 +78,11 @@ tld = "numa"
# value = "127.0.0.1"
# ttl = 60
# DNSSEC signature validation (requires mode = "recursive")
# [dnssec]
# enabled = false # opt-in: verify chain of trust from root KSK
# strict = false # true = SERVFAIL on bogus signatures
# LAN service discovery via mDNS (disabled by default — no network traffic unless enabled)
# [lan]
# enabled = true # discover other Numa instances via mDNS (_numa._tcp.local)

View File

@@ -4,10 +4,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Numa — DNS you own. Everywhere you go.</title>
<meta name="description" content="DNS you own. Block ads, override DNS for development, name your local services with .numa domains, cache for speed. A single portable binary built from scratch in Rust.">
<meta name="description" content="DNS you own. Recursive resolver with full DNSSEC validation, ad blocking, .numa local domains, developer overrides. A single portable binary built from scratch in Rust.">
<link rel="canonical" href="https://numa.rs">
<meta property="og:title" content="Numa — DNS you own. Everywhere you go.">
<meta property="og:description" content="Portable DNS resolver with ad blocking, encrypted upstream, .numa local domains, and developer overrides. Built from scratch in Rust.">
<meta property="og:description" content="Recursive DNS resolver with full DNSSEC validation, ad blocking, .numa local domains, and developer overrides. Built from scratch in Rust.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://numa.rs">
<link rel="stylesheet" href="/fonts/fonts.css">
@@ -1232,18 +1232,19 @@ footer .closing {
<div class="reveal">
<div class="section-label">How It Works</div>
<h2>What it does today</h2>
<p class="lead">A portable DNS proxy with ad blocking, encrypted upstream, local service domains, and a REST API. Everything runs in a single binary.</p>
<p class="lead">A recursive DNS resolver with DNSSEC validation, ad blocking, local service domains, and a REST API. Everything runs in a single binary.</p>
</div>
<div class="layers-grid">
<div class="layer-card reveal reveal-delay-1">
<div class="layer-badge">Layer 1</div>
<h3>Block &amp; Protect</h3>
<h3>Resolve &amp; Protect</h3>
<ul>
<li>Recursive resolution &mdash; resolve from root nameservers, no upstream needed</li>
<li>DNSSEC validation &mdash; chain-of-trust + NSEC/NSEC3 denial proofs (RSA, ECDSA, Ed25519)</li>
<li>Ad &amp; tracker blocking &mdash; 385K+ domains, zero config</li>
<li>DNS-over-HTTPS &mdash; encrypted upstream (Quad9, Cloudflare, any provider)</li>
<li>DNS-over-HTTPS &mdash; encrypted upstream as alternative to recursive mode</li>
<li>TTL-aware caching (sub-ms lookups)</li>
<li>Single binary, portable &mdash; your DNS travels with you</li>
<li>macOS, Linux, and Windows</li>
<li>Single binary, portable &mdash; macOS, Linux, and Windows</li>
</ul>
</div>
<div class="layer-card reveal reveal-delay-2">
@@ -1331,6 +1332,14 @@ footer .closing {
</tr>
</thead>
<tbody>
<tr>
<td>Recursive resolver</td>
<td class="cross">No (needs Unbound)</td>
<td class="cross">Cloud only</td>
<td class="cross">Cloud only</td>
<td class="cross">No</td>
<td class="check">Root hints + full DNSSEC</td>
</tr>
<tr>
<td>Ad &amp; tracker blocking</td>
<td class="check">Yes</td>
@@ -1609,16 +1618,24 @@ footer .closing {
<span class="phase">Phase 5</span>
<span class="phase-desc">DNS-over-HTTPS &mdash; encrypted upstream, HTTP/2 connection pooling</span>
</div>
<div class="roadmap-item phase-teal">
<div class="roadmap-item done">
<span class="phase">Phase 6</span>
<span class="phase-desc">pkarr integration &mdash; self-sovereign DNS via Mainline DHT, no registrar needed</span>
<span class="phase-desc">Recursive resolution &mdash; resolve from root nameservers, no upstream dependency</span>
</div>
<div class="roadmap-item phase-teal">
<div class="roadmap-item done">
<span class="phase">Phase 7</span>
<span class="phase-desc">Global .numa names &mdash; self-publish, DHT-backed, first-come-first-served</span>
<span class="phase-desc">DNSSEC validation &mdash; chain-of-trust, NSEC/NSEC3 denial proofs, RSA + ECDSA + Ed25519</span>
</div>
<div class="roadmap-item phase-teal">
<span class="phase">Phase 8</span>
<span class="phase-desc">pkarr integration &mdash; self-sovereign DNS via Mainline DHT, no registrar needed</span>
</div>
<div class="roadmap-item phase-teal">
<span class="phase">Phase 9</span>
<span class="phase-desc">Global .numa names &mdash; self-publish, DHT-backed, first-come-first-served</span>
</div>
<div class="roadmap-item phase-teal">
<span class="phase">Phase 10</span>
<span class="phase-desc">.onion bridge &mdash; human-readable Tor naming via Ed25519 same-key binding</span>
</div>
</div>

View File

@@ -178,6 +178,7 @@ struct LanStatsResponse {
struct QueriesStats {
total: u64,
forwarded: u64,
recursive: u64,
cached: u64,
local: u64,
overridden: u64,
@@ -477,7 +478,11 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
let override_count = ctx.overrides.read().unwrap().active_count();
let bl_stats = ctx.blocklist.read().unwrap().stats();
let upstream = ctx.upstream.lock().unwrap().to_string();
let upstream = if ctx.upstream_mode == crate::config::UpstreamMode::Recursive {
"recursive (root hints)".to_string()
} else {
ctx.upstream.lock().unwrap().to_string()
};
Json(StatsResponse {
uptime_secs: snap.uptime_secs,
@@ -487,6 +492,7 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
queries: QueriesStats {
total: snap.total,
forwarded: snap.forwarded,
recursive: snap.recursive,
cached: snap.cached,
local: snap.local,
overridden: snap.overridden,

View File

@@ -164,8 +164,16 @@ impl BytePacketBuffer {
}
pub fn write_qname(&mut self, qname: &str) -> Result<()> {
if qname.is_empty() || qname == "." {
self.write_u8(0)?;
return Ok(());
}
for label in qname.split('.') {
let len = label.len();
if len == 0 {
continue; // skip empty labels from trailing dot
}
if len > 0x3f {
return Err("Single label exceeds 63 characters of length".into());
}
@@ -180,6 +188,16 @@ impl BytePacketBuffer {
Ok(())
}
pub fn write_bytes(&mut self, data: &[u8]) -> Result<()> {
let end = self.pos + data.len();
if end > BUF_SIZE {
return Err("End of buffer".into());
}
self.buf[self.pos..end].copy_from_slice(data);
self.pos = end;
Ok(())
}
pub fn set(&mut self, pos: usize, val: u8) -> Result<()> {
if pos >= BUF_SIZE {
return Err("End of buffer".into());

View File

@@ -5,10 +5,20 @@ use crate::packet::DnsPacket;
use crate::question::QueryType;
use crate::record::DnsRecord;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DnssecStatus {
Secure,
Insecure,
Bogus,
#[default]
Indeterminate,
}
struct CacheEntry {
packet: DnsPacket,
inserted_at: Instant,
ttl: Duration,
dnssec_status: DnssecStatus,
}
/// DNS cache using a two-level map (domain -> query_type -> entry) so that
@@ -34,6 +44,14 @@ impl DnsCache {
/// Read-only lookup — expired entries are left in place (cleaned up on insert).
pub fn lookup(&self, domain: &str, qtype: QueryType) -> Option<DnsPacket> {
self.lookup_with_status(domain, qtype).map(|(pkt, _)| pkt)
}
pub fn lookup_with_status(
&self,
domain: &str,
qtype: QueryType,
) -> Option<(DnsPacket, DnssecStatus)> {
let type_map = self.entries.get(domain)?;
let entry = type_map.get(&qtype)?;
@@ -50,10 +68,20 @@ impl DnsCache {
adjust_ttls(&mut packet.authorities, remaining);
adjust_ttls(&mut packet.resources, remaining);
Some(packet)
Some((packet, entry.dnssec_status))
}
pub fn insert(&mut self, domain: &str, qtype: QueryType, packet: &DnsPacket) {
self.insert_with_status(domain, qtype, packet, DnssecStatus::Indeterminate);
}
pub fn insert_with_status(
&mut self,
domain: &str,
qtype: QueryType,
packet: &DnsPacket,
dnssec_status: DnssecStatus,
) {
if self.entry_count >= self.max_entries {
self.evict_expired();
if self.entry_count >= self.max_entries {
@@ -81,6 +109,7 @@ impl DnsCache {
packet: packet.clone(),
inserted_at: Instant::now(),
ttl: Duration::from_secs(min_ttl as u64),
dnssec_status,
},
);
}

View File

@@ -27,6 +27,8 @@ pub struct Config {
pub services: Vec<ServiceConfig>,
#[serde(default)]
pub lan: LanConfig,
#[serde(default)]
pub dnssec: DnssecConfig,
}
#[derive(Deserialize)]
@@ -61,26 +63,112 @@ fn default_api_port() -> u16 {
5380
}
#[derive(Deserialize, Default, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub enum UpstreamMode {
#[default]
Forward,
Recursive,
}
#[derive(Deserialize)]
pub struct UpstreamConfig {
#[serde(default)]
pub mode: UpstreamMode,
#[serde(default = "default_upstream_addr")]
pub address: String,
#[serde(default = "default_upstream_port")]
pub port: u16,
#[serde(default = "default_timeout_ms")]
pub timeout_ms: u64,
#[serde(default = "default_root_hints")]
pub root_hints: Vec<String>,
#[serde(default = "default_prime_tlds")]
pub prime_tlds: Vec<String>,
}
impl Default for UpstreamConfig {
fn default() -> Self {
UpstreamConfig {
mode: UpstreamMode::default(),
address: default_upstream_addr(),
port: default_upstream_port(),
timeout_ms: default_timeout_ms(),
root_hints: default_root_hints(),
prime_tlds: default_prime_tlds(),
}
}
}
fn default_prime_tlds() -> Vec<String> {
vec![
// gTLDs
"com".into(),
"net".into(),
"org".into(),
"info".into(),
"io".into(),
"dev".into(),
"app".into(),
"xyz".into(),
"me".into(),
// EU + European ccTLDs
"eu".into(),
"uk".into(),
"de".into(),
"fr".into(),
"nl".into(),
"it".into(),
"es".into(),
"pl".into(),
"se".into(),
"no".into(),
"dk".into(),
"fi".into(),
"at".into(),
"be".into(),
"ie".into(),
"pt".into(),
"cz".into(),
"ro".into(),
"gr".into(),
"hu".into(),
"bg".into(),
"hr".into(),
"sk".into(),
"si".into(),
"lt".into(),
"lv".into(),
"ee".into(),
"ch".into(),
"is".into(),
// Other major ccTLDs
"co".into(),
"br".into(),
"au".into(),
"ca".into(),
"jp".into(),
]
}
fn default_root_hints() -> Vec<String> {
vec![
"198.41.0.4".into(), // a.root-servers.net
"199.9.14.201".into(), // b.root-servers.net
"192.33.4.12".into(), // c.root-servers.net
"199.7.91.13".into(), // d.root-servers.net
"192.203.230.10".into(), // e.root-servers.net
"192.5.5.241".into(), // f.root-servers.net
"192.112.36.4".into(), // g.root-servers.net
"198.97.190.53".into(), // h.root-servers.net
"192.36.148.17".into(), // i.root-servers.net
"192.58.128.30".into(), // j.root-servers.net
"193.0.14.129".into(), // k.root-servers.net
"199.7.83.42".into(), // l.root-servers.net
"202.12.27.33".into(), // m.root-servers.net
]
}
fn default_upstream_addr() -> String {
String::new() // empty = auto-detect from system resolver
}
@@ -250,6 +338,14 @@ fn default_lan_peer_timeout() -> u64 {
90
}
#[derive(Deserialize, Clone, Default)]
pub struct DnssecConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub strict: bool,
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -11,7 +11,7 @@ use tokio::net::UdpSocket;
use crate::blocklist::BlocklistStore;
use crate::buffer::BytePacketBuffer;
use crate::cache::DnsCache;
use crate::config::ZoneMap;
use crate::config::{UpstreamMode, ZoneMap};
use crate::forward::{forward_query, Upstream};
use crate::header::ResultCode;
use crate::lan::PeerStore;
@@ -27,6 +27,7 @@ use crate::system_dns::ForwardingRule;
pub struct ServerCtx {
pub socket: UdpSocket,
pub zone_map: ZoneMap,
/// std::sync::RwLock (not tokio) — locks must never be held across .await points.
pub cache: RwLock<DnsCache>,
pub stats: Mutex<ServerStats>,
pub overrides: RwLock<OverrideStore>,
@@ -48,6 +49,10 @@ pub struct ServerCtx {
pub config_dir: PathBuf,
pub data_dir: PathBuf,
pub tls_config: Option<ArcSwap<ServerConfig>>,
pub upstream_mode: UpstreamMode,
pub root_hints: Vec<SocketAddr>,
pub dnssec_enabled: bool,
pub dnssec_strict: bool,
}
pub async fn handle_query(
@@ -136,11 +141,51 @@ pub async fn handle_query(
resp.answers = records.clone();
(resp, QueryPath::Local)
} else {
let cached = ctx.cache.read().unwrap().lookup(&qname, qtype);
if let Some(cached) = cached {
let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype);
if let Some((cached, cached_dnssec)) = cached {
let mut resp = cached;
resp.header.id = query.header.id;
if cached_dnssec == crate::cache::DnssecStatus::Secure {
resp.header.authed_data = true;
}
(resp, QueryPath::Cached)
} else if ctx.upstream_mode == UpstreamMode::Recursive {
match crate::recursive::resolve_recursive(
&qname,
qtype,
&ctx.cache,
ctx.timeout,
&query,
&ctx.root_hints,
)
.await
{
Ok(resp) => (resp, QueryPath::Recursive),
Err(e) => {
// Auto-fallback: retry via forward upstream if configured
let upstream = ctx.upstream.lock().unwrap().clone();
match forward_query(&query, &upstream, ctx.timeout).await {
Ok(resp) => {
debug!(
"{} | {:?} {} | RECURSIVE FALLBACK → FORWARD | {}",
src_addr, qtype, qname, e
);
ctx.cache.write().unwrap().insert(&qname, qtype, &resp);
(resp, QueryPath::Forwarded)
}
Err(e2) => {
error!(
"{} | {:?} {} | RECURSIVE+FORWARD FAILED | recursive: {} | forward: {}",
src_addr, qtype, qname, e, e2
);
(
DnsPacket::response_from(&query, ResultCode::SERVFAIL),
QueryPath::UpstreamError,
)
}
}
}
}
} else {
let upstream =
match crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) {
@@ -167,6 +212,52 @@ pub async fn handle_query(
}
};
let client_do = query.edns.as_ref().is_some_and(|e| e.do_bit);
let mut response = response;
// DNSSEC validation (recursive/forwarded responses only)
if ctx.dnssec_enabled && path == QueryPath::Recursive {
let (status, vstats) =
crate::dnssec::validate_response(&response, &ctx.cache, &ctx.root_hints).await;
debug!(
"DNSSEC | {} | {:?} | {}ms | dnskey_hit={} dnskey_fetch={} ds_hit={} ds_fetch={}",
qname,
status,
vstats.elapsed_ms,
vstats.dnskey_cache_hits,
vstats.dnskey_fetches,
vstats.ds_cache_hits,
vstats.ds_fetches,
);
if status == crate::cache::DnssecStatus::Secure {
response.header.authed_data = true;
}
if status == crate::cache::DnssecStatus::Bogus && ctx.dnssec_strict {
response = DnsPacket::response_from(&query, ResultCode::SERVFAIL);
}
ctx.cache
.write()
.unwrap()
.insert_with_status(&qname, qtype, &response, status);
}
// Strip DNSSEC records if client didn't set DO bit
if !client_do {
strip_dnssec_records(&mut response);
}
// Echo EDNS back if client sent it
if query.edns.is_some() {
response.edns = Some(crate::packet::EdnsOpt {
do_bit: client_do,
..Default::default()
});
}
let elapsed = start.elapsed();
info!(
@@ -220,3 +311,16 @@ pub async fn handle_query(
Ok(())
}
fn is_dnssec_record(r: &DnsRecord) -> bool {
matches!(
r.query_type(),
QueryType::RRSIG | QueryType::DNSKEY | QueryType::DS | QueryType::NSEC | QueryType::NSEC3
)
}
fn strip_dnssec_records(pkt: &mut DnsPacket) {
pkt.answers.retain(|r| !is_dnssec_record(r));
pkt.authorities.retain(|r| !is_dnssec_record(r));
pkt.resources.retain(|r| !is_dnssec_record(r));
}

1675
src/dnssec.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -48,7 +48,7 @@ pub async fn forward_query(
}
}
async fn forward_udp(
pub(crate) async fn forward_udp(
query: &DnsPacket,
upstream: SocketAddr,
timeout_duration: Duration,

View File

@@ -4,6 +4,7 @@ pub mod buffer;
pub mod cache;
pub mod config;
pub mod ctx;
pub mod dnssec;
pub mod forward;
pub mod header;
pub mod lan;
@@ -13,6 +14,7 @@ pub mod proxy;
pub mod query_log;
pub mod question;
pub mod record;
pub mod recursive;
pub mod service_store;
pub mod stats;
pub mod system_dns;

View File

@@ -199,6 +199,10 @@ async fn main() -> numa::Result<()> {
config_dir: numa::config_dir(),
data_dir: numa::data_dir(),
tls_config: initial_tls,
upstream_mode: config.upstream.mode,
root_hints: numa::recursive::parse_root_hints(&config.upstream.root_hints),
dnssec_enabled: config.dnssec.enabled,
dnssec_strict: config.dnssec.strict,
});
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
@@ -276,7 +280,15 @@ async fn main() -> numa::Result<()> {
row("DNS", g, &config.server.bind_addr);
row("API", g, &api_url);
row("Dashboard", g, &api_url);
row("Upstream", g, &upstream_label);
row(
"Upstream",
g,
if ctx.upstream_mode == numa::config::UpstreamMode::Recursive {
"recursive (root hints)"
} else {
&upstream_label
},
);
row("Zones", g, &format!("{} records", zone_count));
row(
"Cache",
@@ -336,6 +348,16 @@ async fn main() -> numa::Result<()> {
});
}
// Prime TLD cache (recursive mode only)
if ctx.upstream_mode == numa::config::UpstreamMode::Recursive {
let prime_ctx = Arc::clone(&ctx);
let prime_tlds = config.upstream.prime_tlds;
tokio::spawn(async move {
numa::recursive::prime_tld_cache(&prime_ctx.cache, &prime_ctx.root_hints, &prime_tlds)
.await;
});
}
// Spawn HTTP API server
let api_ctx = Arc::clone(&ctx);
let api_addr: SocketAddr = format!("{}:{}", config.server.api_bind_addr, api_port).parse()?;

View File

@@ -4,6 +4,31 @@ use crate::question::{DnsQuestion, QueryType};
use crate::record::DnsRecord;
use crate::Result;
/// Recommended EDNS0 UDP payload size (DNS Flag Day 2020) — avoids IP fragmentation.
pub const DEFAULT_EDNS_PAYLOAD: u16 = 1232;
/// EDNS0 OPT pseudo-record (RFC 6891)
#[derive(Clone, Debug)]
pub struct EdnsOpt {
pub udp_payload_size: u16,
pub extended_rcode: u8,
pub version: u8,
pub do_bit: bool,
pub options: Vec<u8>,
}
impl Default for EdnsOpt {
fn default() -> Self {
EdnsOpt {
udp_payload_size: DEFAULT_EDNS_PAYLOAD,
extended_rcode: 0,
version: 0,
do_bit: false,
options: Vec::new(),
}
}
}
#[derive(Clone, Debug)]
pub struct DnsPacket {
pub header: DnsHeader,
@@ -11,6 +36,7 @@ pub struct DnsPacket {
pub answers: Vec<DnsRecord>,
pub authorities: Vec<DnsRecord>,
pub resources: Vec<DnsRecord>,
pub edns: Option<EdnsOpt>,
}
impl Default for DnsPacket {
@@ -27,6 +53,7 @@ impl DnsPacket {
answers: Vec::new(),
authorities: Vec::new(),
resources: Vec::new(),
edns: None,
}
}
@@ -60,24 +87,53 @@ impl DnsPacket {
result.authorities.push(rec);
}
for _ in 0..result.header.resource_entries {
// Peek at type field to detect OPT pseudo-records.
// OPT name is always root (0x00), so name byte + type field starts at pos+1.
let peek_pos = buffer.pos();
let name_byte = buffer.get(peek_pos)?;
let is_opt = if name_byte == 0 {
// Root name (single zero byte) — peek at type
let type_hi = buffer.get(peek_pos + 1)?;
let type_lo = buffer.get(peek_pos + 2)?;
u16::from_be_bytes([type_hi, type_lo]) == 41
} else {
false
};
if is_opt {
// Parse OPT manually to capture the class field (= UDP payload size)
buffer.step(1)?; // skip root name (0x00)
let _ = buffer.read_u16()?; // type (41)
let udp_payload_size = buffer.read_u16()?; // class = UDP payload size
let ttl_field = buffer.read_u32()?; // packed flags
let rdlength = buffer.read_u16()?;
let options = buffer.get_range(buffer.pos(), rdlength as usize)?.to_vec();
buffer.step(rdlength as usize)?;
result.edns = Some(EdnsOpt {
udp_payload_size,
extended_rcode: ((ttl_field >> 24) & 0xFF) as u8,
version: ((ttl_field >> 16) & 0xFF) as u8,
do_bit: (ttl_field >> 15) & 1 == 1,
options,
});
} else {
let rec = DnsRecord::read(buffer)?;
result.resources.push(rec);
}
}
Ok(result)
}
pub fn write(&self, buffer: &mut BytePacketBuffer) -> Result<()> {
// Count known records without allocating filter Vecs
let answer_count = self.answers.iter().filter(|r| !r.is_unknown()).count() as u16;
let auth_count = self.authorities.iter().filter(|r| !r.is_unknown()).count() as u16;
let res_count = self.resources.iter().filter(|r| !r.is_unknown()).count() as u16;
let edns_count = if self.edns.is_some() { 1u16 } else { 0 };
let mut header = self.header.clone();
header.questions = self.questions.len() as u16;
header.answers = answer_count;
header.authoritative_entries = auth_count;
header.resource_entries = res_count;
header.answers = self.answers.len() as u16;
header.authoritative_entries = self.authorities.len() as u16;
header.resource_entries = self.resources.len() as u16 + edns_count;
header.write(buffer)?;
@@ -85,19 +141,27 @@ impl DnsPacket {
question.write(buffer)?;
}
for rec in &self.answers {
if !rec.is_unknown() {
rec.write(buffer)?;
}
}
for rec in &self.authorities {
if !rec.is_unknown() {
rec.write(buffer)?;
}
}
for rec in &self.resources {
if !rec.is_unknown() {
rec.write(buffer)?;
}
// Write EDNS0 OPT pseudo-record
if let Some(ref edns) = self.edns {
buffer.write_u8(0)?; // root name
buffer.write_u16(QueryType::OPT.to_num())?; // type 41
buffer.write_u16(edns.udp_payload_size)?; // class = UDP payload size
// TTL = extended_rcode(8) | version(8) | DO(1) | Z(15)
let ttl_field = ((edns.extended_rcode as u32) << 24)
| ((edns.version as u32) << 16)
| (if edns.do_bit { 1u32 << 15 } else { 0 });
buffer.write_u32(ttl_field)?;
buffer.write_u16(edns.options.len() as u16)?; // RDLENGTH
buffer.write_bytes(&edns.options)?;
}
Ok(())
@@ -118,5 +182,404 @@ impl DnsPacket {
for rec in &self.resources {
println!("{:#?}", rec);
}
if let Some(ref edns) = self.edns {
println!("EDNS: {:?}", edns);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::header::ResultCode;
#[test]
fn edns_round_trip() {
let mut pkt = DnsPacket::new();
pkt.header.id = 0x1234;
pkt.header.response = true;
pkt.header.rescode = ResultCode::NOERROR;
pkt.edns = Some(EdnsOpt {
do_bit: true,
..Default::default()
});
let mut buf = BytePacketBuffer::new();
pkt.write(&mut buf).unwrap();
buf.seek(0).unwrap();
let parsed = DnsPacket::from_buffer(&mut buf).unwrap();
let edns = parsed.edns.expect("EDNS should be present");
assert_eq!(edns.udp_payload_size, DEFAULT_EDNS_PAYLOAD);
assert!(edns.do_bit);
assert_eq!(edns.version, 0);
}
#[test]
fn edns_do_bit_false() {
let mut pkt = DnsPacket::new();
pkt.header.id = 0x5678;
pkt.header.response = true;
pkt.edns = Some(EdnsOpt {
udp_payload_size: 1232,
do_bit: false,
..Default::default()
});
let mut buf = BytePacketBuffer::new();
pkt.write(&mut buf).unwrap();
buf.seek(0).unwrap();
let parsed = DnsPacket::from_buffer(&mut buf).unwrap();
let edns = parsed.edns.expect("EDNS should be present");
assert_eq!(edns.udp_payload_size, DEFAULT_EDNS_PAYLOAD);
assert!(!edns.do_bit);
}
#[test]
fn no_edns_by_default() {
let pkt = DnsPacket::new();
assert!(pkt.edns.is_none());
}
#[test]
fn packet_without_edns_round_trips() {
let mut pkt = DnsPacket::new();
pkt.header.id = 0xABCD;
pkt.header.response = true;
pkt.header.rescode = ResultCode::NOERROR;
pkt.answers.push(crate::record::DnsRecord::A {
domain: "example.com".into(),
addr: "1.2.3.4".parse().unwrap(),
ttl: 300,
});
let parsed = packet_round_trip(&pkt);
assert!(parsed.edns.is_none());
assert_eq!(parsed.answers.len(), 1);
}
fn packet_round_trip(pkt: &DnsPacket) -> DnsPacket {
let mut buf = BytePacketBuffer::new();
pkt.write(&mut buf).unwrap();
let wire_len = buf.pos();
buf.seek(0).unwrap();
let parsed = DnsPacket::from_buffer(&mut buf).unwrap();
// Verify we consumed exactly what was written
assert_eq!(
buf.pos(),
wire_len,
"parse did not consume all written bytes"
);
parsed
}
#[test]
fn nxdomain_with_nsec_authority_round_trips() {
use crate::question::DnsQuestion;
use crate::record::DnsRecord;
let mut pkt = DnsPacket::new();
pkt.header.id = 0x1111;
pkt.header.response = true;
pkt.header.rescode = ResultCode::NXDOMAIN;
pkt.questions.push(DnsQuestion::new(
"nonexistent.example.com".into(),
QueryType::A,
));
pkt.authorities.push(DnsRecord::NSEC {
domain: "alpha.example.com".into(),
next_domain: "gamma.example.com".into(),
type_bitmap: vec![0, 2, 0x40, 0x01], // A + MX
ttl: 3600,
});
pkt.authorities.push(DnsRecord::RRSIG {
domain: "alpha.example.com".into(),
type_covered: QueryType::NSEC.to_num(),
algorithm: 13,
labels: 3,
original_ttl: 3600,
expiration: 1700000000,
inception: 1690000000,
key_tag: 12345,
signer_name: "example.com".into(),
signature: vec![0xAA; 64],
ttl: 3600,
});
// Wildcard denial NSEC
pkt.authorities.push(DnsRecord::NSEC {
domain: "example.com".into(),
next_domain: "alpha.example.com".into(),
type_bitmap: vec![0, 3, 0x62, 0x01, 0x80], // A, NS, SOA, MX, RRSIG
ttl: 3600,
});
pkt.edns = Some(EdnsOpt {
do_bit: true,
..Default::default()
});
let parsed = packet_round_trip(&pkt);
assert_eq!(parsed.header.id, 0x1111);
assert_eq!(parsed.header.rescode, ResultCode::NXDOMAIN);
assert_eq!(parsed.questions.len(), 1);
assert_eq!(parsed.questions[0].name, "nonexistent.example.com");
assert_eq!(parsed.authorities.len(), 3);
// Verify NSEC records survived
if let DnsRecord::NSEC {
domain,
next_domain,
type_bitmap,
..
} = &parsed.authorities[0]
{
assert_eq!(domain, "alpha.example.com");
assert_eq!(next_domain, "gamma.example.com");
assert_eq!(type_bitmap, &[0, 2, 0x40, 0x01]);
} else {
panic!("expected NSEC, got {:?}", parsed.authorities[0]);
}
// Verify RRSIG survived
if let DnsRecord::RRSIG {
type_covered,
signer_name,
signature,
..
} = &parsed.authorities[1]
{
assert_eq!(*type_covered, QueryType::NSEC.to_num());
assert_eq!(signer_name, "example.com");
assert_eq!(signature.len(), 64);
} else {
panic!("expected RRSIG, got {:?}", parsed.authorities[1]);
}
// Verify EDNS survived
assert!(parsed.edns.as_ref().unwrap().do_bit);
}
#[test]
fn nxdomain_with_nsec3_authority_round_trips() {
use crate::question::DnsQuestion;
use crate::record::DnsRecord;
let mut pkt = DnsPacket::new();
pkt.header.id = 0x2222;
pkt.header.response = true;
pkt.header.rescode = ResultCode::NXDOMAIN;
pkt.questions
.push(DnsQuestion::new("no.example.com".into(), QueryType::AAAA));
// Three NSEC3 records (closest encloser, next closer, wildcard)
let salt = vec![0xAB, 0xCD];
pkt.authorities.push(DnsRecord::NSEC3 {
domain: "ABC123.example.com".into(),
hash_algorithm: 1,
flags: 0,
iterations: 5,
salt: salt.clone(),
next_hashed_owner: vec![
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
0x0F, 0x10, 0x11, 0x12, 0x13, 0x14,
],
type_bitmap: vec![0, 2, 0x60, 0x01], // NS, SOA, MX
ttl: 300,
});
pkt.authorities.push(DnsRecord::NSEC3 {
domain: "DEF456.example.com".into(),
hash_algorithm: 1,
flags: 0,
iterations: 5,
salt: salt.clone(),
next_hashed_owner: vec![0x20; 20],
type_bitmap: vec![0, 1, 0x40], // A
ttl: 300,
});
pkt.authorities.push(DnsRecord::RRSIG {
domain: "ABC123.example.com".into(),
type_covered: QueryType::NSEC3.to_num(),
algorithm: 8,
labels: 3,
original_ttl: 300,
expiration: 2000000000,
inception: 1600000000,
key_tag: 54321,
signer_name: "example.com".into(),
signature: vec![0xBB; 128],
ttl: 300,
});
pkt.edns = Some(EdnsOpt {
do_bit: true,
..Default::default()
});
let parsed = packet_round_trip(&pkt);
assert_eq!(parsed.header.rescode, ResultCode::NXDOMAIN);
assert_eq!(parsed.authorities.len(), 3);
// Verify first NSEC3 survived with all fields intact
if let DnsRecord::NSEC3 {
domain,
hash_algorithm,
flags,
iterations,
salt: parsed_salt,
next_hashed_owner,
type_bitmap,
..
} = &parsed.authorities[0]
{
assert_eq!(domain, "abc123.example.com");
assert_eq!(*hash_algorithm, 1);
assert_eq!(*flags, 0);
assert_eq!(*iterations, 5);
assert_eq!(parsed_salt, &salt);
assert_eq!(next_hashed_owner.len(), 20);
assert_eq!(type_bitmap, &[0, 2, 0x60, 0x01]);
} else {
panic!("expected NSEC3, got {:?}", parsed.authorities[0]);
}
// Verify RRSIG covering NSEC3
if let DnsRecord::RRSIG {
type_covered,
algorithm,
signature,
..
} = &parsed.authorities[2]
{
assert_eq!(*type_covered, QueryType::NSEC3.to_num());
assert_eq!(*algorithm, 8);
assert_eq!(signature.len(), 128);
} else {
panic!("expected RRSIG, got {:?}", parsed.authorities[2]);
}
}
#[test]
fn dnssec_answer_with_rrsig_round_trips() {
use crate::question::DnsQuestion;
use crate::record::DnsRecord;
let mut pkt = DnsPacket::new();
pkt.header.id = 0x3333;
pkt.header.response = true;
pkt.header.rescode = ResultCode::NOERROR;
pkt.header.authed_data = true;
pkt.questions
.push(DnsQuestion::new("example.com".into(), QueryType::A));
pkt.answers.push(DnsRecord::A {
domain: "example.com".into(),
addr: "93.184.216.34".parse().unwrap(),
ttl: 300,
});
pkt.answers.push(DnsRecord::RRSIG {
domain: "example.com".into(),
type_covered: QueryType::A.to_num(),
algorithm: 13,
labels: 2,
original_ttl: 300,
expiration: 1700000000,
inception: 1690000000,
key_tag: 11111,
signer_name: "example.com".into(),
signature: vec![0xCC; 64],
ttl: 300,
});
// Authority: NS + DS
pkt.authorities.push(DnsRecord::NS {
domain: "example.com".into(),
host: "ns1.example.com".into(),
ttl: 3600,
});
pkt.authorities.push(DnsRecord::DS {
domain: "example.com".into(),
key_tag: 22222,
algorithm: 8,
digest_type: 2,
digest: vec![0xDD; 32],
ttl: 86400,
});
// Additional: glue A + DNSKEY
pkt.resources.push(DnsRecord::A {
domain: "ns1.example.com".into(),
addr: "198.51.100.1".parse().unwrap(),
ttl: 3600,
});
pkt.resources.push(DnsRecord::DNSKEY {
domain: "example.com".into(),
flags: 257,
protocol: 3,
algorithm: 13,
public_key: vec![0xEE; 64],
ttl: 3600,
});
pkt.edns = Some(EdnsOpt {
do_bit: true,
..Default::default()
});
let parsed = packet_round_trip(&pkt);
assert_eq!(parsed.header.id, 0x3333);
assert!(parsed.header.authed_data);
assert_eq!(parsed.answers.len(), 2);
assert_eq!(parsed.authorities.len(), 2);
assert_eq!(parsed.resources.len(), 2);
// Verify A record
if let DnsRecord::A { addr, .. } = &parsed.answers[0] {
assert_eq!(addr.to_string(), "93.184.216.34");
} else {
panic!("expected A");
}
// Verify RRSIG in answers
if let DnsRecord::RRSIG {
type_covered,
key_tag,
signer_name,
..
} = &parsed.answers[1]
{
assert_eq!(*type_covered, 1); // A
assert_eq!(*key_tag, 11111);
assert_eq!(signer_name, "example.com");
} else {
panic!("expected RRSIG");
}
// Verify DS in authority
if let DnsRecord::DS {
key_tag, digest, ..
} = &parsed.authorities[1]
{
assert_eq!(*key_tag, 22222);
assert_eq!(digest.len(), 32);
} else {
panic!("expected DS");
}
// Verify DNSKEY in additional
if let DnsRecord::DNSKEY {
flags, public_key, ..
} = &parsed.resources[1]
{
assert_eq!(*flags, 257);
assert_eq!(public_key.len(), 64);
} else {
panic!("expected DNSKEY");
}
}
}

View File

@@ -13,6 +13,12 @@ pub enum QueryType {
TXT, // 16
AAAA, // 28
SRV, // 33
DS, // 43
RRSIG, // 46
NSEC, // 47
DNSKEY, // 48
NSEC3, // 50
OPT, // 41 (EDNS0 pseudo-type)
HTTPS, // 65
}
@@ -29,6 +35,12 @@ impl QueryType {
QueryType::TXT => 16,
QueryType::AAAA => 28,
QueryType::SRV => 33,
QueryType::OPT => 41,
QueryType::DS => 43,
QueryType::RRSIG => 46,
QueryType::NSEC => 47,
QueryType::DNSKEY => 48,
QueryType::NSEC3 => 50,
QueryType::HTTPS => 65,
}
}
@@ -44,6 +56,12 @@ impl QueryType {
16 => QueryType::TXT,
28 => QueryType::AAAA,
33 => QueryType::SRV,
41 => QueryType::OPT,
43 => QueryType::DS,
46 => QueryType::RRSIG,
47 => QueryType::NSEC,
48 => QueryType::DNSKEY,
50 => QueryType::NSEC3,
65 => QueryType::HTTPS,
_ => QueryType::UNKNOWN(num),
}
@@ -60,6 +78,12 @@ impl QueryType {
QueryType::TXT => "TXT",
QueryType::AAAA => "AAAA",
QueryType::SRV => "SRV",
QueryType::OPT => "OPT",
QueryType::DS => "DS",
QueryType::RRSIG => "RRSIG",
QueryType::NSEC => "NSEC",
QueryType::DNSKEY => "DNSKEY",
QueryType::NSEC3 => "NSEC3",
QueryType::HTTPS => "HTTPS",
QueryType::UNKNOWN(_) => "UNKNOWN",
}
@@ -76,6 +100,11 @@ impl QueryType {
"TXT" => Some(QueryType::TXT),
"AAAA" => Some(QueryType::AAAA),
"SRV" => Some(QueryType::SRV),
"DS" => Some(QueryType::DS),
"RRSIG" => Some(QueryType::RRSIG),
"DNSKEY" => Some(QueryType::DNSKEY),
"NSEC" => Some(QueryType::NSEC),
"NSEC3" => Some(QueryType::NSEC3),
"HTTPS" => Some(QueryType::HTTPS),
_ => None,
}

View File

@@ -11,7 +11,7 @@ pub enum DnsRecord {
UNKNOWN {
domain: String,
qtype: u16,
data_len: u16,
data: Vec<u8>,
ttl: u32,
},
A {
@@ -40,11 +40,84 @@ pub enum DnsRecord {
addr: Ipv6Addr,
ttl: u32,
},
DNSKEY {
domain: String,
flags: u16,
protocol: u8,
algorithm: u8,
public_key: Vec<u8>,
ttl: u32,
},
DS {
domain: String,
key_tag: u16,
algorithm: u8,
digest_type: u8,
digest: Vec<u8>,
ttl: u32,
},
RRSIG {
domain: String,
type_covered: u16,
algorithm: u8,
labels: u8,
original_ttl: u32,
expiration: u32,
inception: u32,
key_tag: u16,
signer_name: String,
signature: Vec<u8>,
ttl: u32,
},
NSEC {
domain: String,
next_domain: String,
type_bitmap: Vec<u8>,
ttl: u32,
},
NSEC3 {
domain: String,
hash_algorithm: u8,
flags: u8,
iterations: u16,
salt: Vec<u8>,
next_hashed_owner: Vec<u8>,
type_bitmap: Vec<u8>,
ttl: u32,
},
}
impl DnsRecord {
pub fn is_unknown(&self) -> bool {
matches!(self, DnsRecord::UNKNOWN { .. })
pub fn domain(&self) -> &str {
match self {
DnsRecord::A { domain, .. }
| DnsRecord::NS { domain, .. }
| DnsRecord::CNAME { domain, .. }
| DnsRecord::MX { domain, .. }
| DnsRecord::AAAA { domain, .. }
| DnsRecord::DNSKEY { domain, .. }
| DnsRecord::DS { domain, .. }
| DnsRecord::RRSIG { domain, .. }
| DnsRecord::NSEC { domain, .. }
| DnsRecord::NSEC3 { domain, .. }
| DnsRecord::UNKNOWN { domain, .. } => domain,
}
}
pub fn query_type(&self) -> QueryType {
match self {
DnsRecord::A { .. } => QueryType::A,
DnsRecord::AAAA { .. } => QueryType::AAAA,
DnsRecord::NS { .. } => QueryType::NS,
DnsRecord::CNAME { .. } => QueryType::CNAME,
DnsRecord::MX { .. } => QueryType::MX,
DnsRecord::DNSKEY { .. } => QueryType::DNSKEY,
DnsRecord::DS { .. } => QueryType::DS,
DnsRecord::RRSIG { .. } => QueryType::RRSIG,
DnsRecord::NSEC { .. } => QueryType::NSEC,
DnsRecord::NSEC3 { .. } => QueryType::NSEC3,
DnsRecord::UNKNOWN { qtype, .. } => QueryType::UNKNOWN(*qtype),
}
}
pub fn ttl(&self) -> u32 {
@@ -54,6 +127,11 @@ impl DnsRecord {
| DnsRecord::CNAME { ttl, .. }
| DnsRecord::MX { ttl, .. }
| DnsRecord::AAAA { ttl, .. }
| DnsRecord::DNSKEY { ttl, .. }
| DnsRecord::DS { ttl, .. }
| DnsRecord::RRSIG { ttl, .. }
| DnsRecord::NSEC { ttl, .. }
| DnsRecord::NSEC3 { ttl, .. }
| DnsRecord::UNKNOWN { ttl, .. } => *ttl,
}
}
@@ -65,6 +143,11 @@ impl DnsRecord {
| DnsRecord::CNAME { ttl, .. }
| DnsRecord::MX { ttl, .. }
| DnsRecord::AAAA { ttl, .. }
| DnsRecord::DNSKEY { ttl, .. }
| DnsRecord::DS { ttl, .. }
| DnsRecord::RRSIG { ttl, .. }
| DnsRecord::NSEC { ttl, .. }
| DnsRecord::NSEC3 { ttl, .. }
| DnsRecord::UNKNOWN { ttl, .. } => *ttl = new_ttl,
}
}
@@ -75,9 +158,10 @@ impl DnsRecord {
let qtype_num = buffer.read_u16()?;
let qtype = QueryType::from_num(qtype_num);
let _ = buffer.read_u16()?;
let _ = buffer.read_u16()?; // class
let ttl = buffer.read_u32()?;
let data_len = buffer.read_u16()?;
let rdata_start = buffer.pos();
match qtype {
QueryType::A => {
@@ -88,7 +172,6 @@ impl DnsRecord {
((raw_addr >> 8) & 0xFF) as u8,
(raw_addr & 0xFF) as u8,
);
Ok(DnsRecord::A { domain, addr, ttl })
}
QueryType::AAAA => {
@@ -106,13 +189,11 @@ impl DnsRecord {
((raw_addr4 >> 16) & 0xFFFF) as u16,
(raw_addr4 & 0xFFFF) as u16,
);
Ok(DnsRecord::AAAA { domain, addr, ttl })
}
QueryType::NS => {
let mut ns = String::with_capacity(64);
buffer.read_qname(&mut ns)?;
Ok(DnsRecord::NS {
domain,
host: ns,
@@ -122,7 +203,6 @@ impl DnsRecord {
QueryType::CNAME => {
let mut cname = String::with_capacity(64);
buffer.read_qname(&mut cname)?;
Ok(DnsRecord::CNAME {
domain,
host: cname,
@@ -133,7 +213,6 @@ impl DnsRecord {
let priority = buffer.read_u16()?;
let mut mx = String::with_capacity(64);
buffer.read_qname(&mut mx)?;
Ok(DnsRecord::MX {
domain,
priority,
@@ -141,13 +220,119 @@ impl DnsRecord {
ttl,
})
}
QueryType::DNSKEY => {
let flags = buffer.read_u16()?;
let protocol = buffer.read()?;
let algorithm = buffer.read()?;
let key_len = data_len as usize - 4; // flags(2) + protocol(1) + algorithm(1)
let public_key = buffer.get_range(buffer.pos(), key_len)?.to_vec();
buffer.step(key_len)?;
Ok(DnsRecord::DNSKEY {
domain,
flags,
protocol,
algorithm,
public_key,
ttl,
})
}
QueryType::DS => {
let key_tag = buffer.read_u16()?;
let algorithm = buffer.read()?;
let digest_type = buffer.read()?;
let digest_len = data_len as usize - 4; // key_tag(2) + algorithm(1) + digest_type(1)
let digest = buffer.get_range(buffer.pos(), digest_len)?.to_vec();
buffer.step(digest_len)?;
Ok(DnsRecord::DS {
domain,
key_tag,
algorithm,
digest_type,
digest,
ttl,
})
}
QueryType::RRSIG => {
let type_covered = buffer.read_u16()?;
let algorithm = buffer.read()?;
let labels = buffer.read()?;
let original_ttl = buffer.read_u32()?;
let expiration = buffer.read_u32()?;
let inception = buffer.read_u32()?;
let key_tag = buffer.read_u16()?;
let mut signer_name = String::with_capacity(64);
buffer.read_qname(&mut signer_name)?;
let rdata_end = rdata_start + data_len as usize;
let sig_len = rdata_end
.checked_sub(buffer.pos())
.ok_or("RRSIG data_len too short for fixed fields + signer_name")?;
let signature = buffer.get_range(buffer.pos(), sig_len)?.to_vec();
buffer.step(sig_len)?;
Ok(DnsRecord::RRSIG {
domain,
type_covered,
algorithm,
labels,
original_ttl,
expiration,
inception,
key_tag,
signer_name,
signature,
ttl,
})
}
QueryType::NSEC => {
let rdata_end = rdata_start + data_len as usize;
let mut next_domain = String::with_capacity(64);
buffer.read_qname(&mut next_domain)?;
let bitmap_len = rdata_end
.checked_sub(buffer.pos())
.ok_or("NSEC data_len too short for type bitmap")?;
let type_bitmap = buffer.get_range(buffer.pos(), bitmap_len)?.to_vec();
buffer.step(bitmap_len)?;
Ok(DnsRecord::NSEC {
domain,
next_domain,
type_bitmap,
ttl,
})
}
QueryType::NSEC3 => {
let rdata_end = rdata_start + data_len as usize;
let hash_algorithm = buffer.read()?;
let flags = buffer.read()?;
let iterations = buffer.read_u16()?;
let salt_length = buffer.read()? as usize;
let salt = buffer.get_range(buffer.pos(), salt_length)?.to_vec();
buffer.step(salt_length)?;
let hash_length = buffer.read()? as usize;
let next_hashed_owner = buffer.get_range(buffer.pos(), hash_length)?.to_vec();
buffer.step(hash_length)?;
let bitmap_len = rdata_end
.checked_sub(buffer.pos())
.ok_or("NSEC3 data_len too short for type bitmap")?;
let type_bitmap = buffer.get_range(buffer.pos(), bitmap_len)?.to_vec();
buffer.step(bitmap_len)?;
Ok(DnsRecord::NSEC3 {
domain,
hash_algorithm,
flags,
iterations,
salt,
next_hashed_owner,
type_bitmap,
ttl,
})
}
_ => {
// SOA, TXT, SRV, etc. — stored as opaque bytes until parsed natively
let data = buffer.get_range(buffer.pos(), data_len as usize)?.to_vec();
buffer.step(data_len as usize)?;
Ok(DnsRecord::UNKNOWN {
domain,
qtype: qtype_num,
data_len,
data,
ttl,
})
}
@@ -163,32 +348,19 @@ impl DnsRecord {
ref addr,
ttl,
} => {
buffer.write_qname(domain)?;
buffer.write_u16(QueryType::A.to_num())?;
buffer.write_u16(1)?;
buffer.write_u32(ttl)?;
write_header(buffer, domain, QueryType::A.to_num(), ttl)?;
buffer.write_u16(4)?;
let octets = addr.octets();
buffer.write_u8(octets[0])?;
buffer.write_u8(octets[1])?;
buffer.write_u8(octets[2])?;
buffer.write_u8(octets[3])?;
buffer.write_bytes(&addr.octets())?;
}
DnsRecord::NS {
ref domain,
ref host,
ttl,
} => {
buffer.write_qname(domain)?;
buffer.write_u16(QueryType::NS.to_num())?;
buffer.write_u16(1)?;
buffer.write_u32(ttl)?;
write_header(buffer, domain, QueryType::NS.to_num(), ttl)?;
let pos = buffer.pos();
buffer.write_u16(0)?;
buffer.write_qname(host)?;
let size = buffer.pos() - (pos + 2);
buffer.set_u16(pos, size as u16)?;
}
@@ -197,15 +369,10 @@ impl DnsRecord {
ref host,
ttl,
} => {
buffer.write_qname(domain)?;
buffer.write_u16(QueryType::CNAME.to_num())?;
buffer.write_u16(1)?;
buffer.write_u32(ttl)?;
write_header(buffer, domain, QueryType::CNAME.to_num(), ttl)?;
let pos = buffer.pos();
buffer.write_u16(0)?;
buffer.write_qname(host)?;
let size = buffer.pos() - (pos + 2);
buffer.set_u16(pos, size as u16)?;
}
@@ -215,16 +382,11 @@ impl DnsRecord {
ref host,
ttl,
} => {
buffer.write_qname(domain)?;
buffer.write_u16(QueryType::MX.to_num())?;
buffer.write_u16(1)?;
buffer.write_u32(ttl)?;
write_header(buffer, domain, QueryType::MX.to_num(), ttl)?;
let pos = buffer.pos();
buffer.write_u16(0)?;
buffer.write_u16(priority)?;
buffer.write_qname(host)?;
let size = buffer.pos() - (pos + 2);
buffer.set_u16(pos, size as u16)?;
}
@@ -233,21 +395,259 @@ impl DnsRecord {
ref addr,
ttl,
} => {
buffer.write_qname(domain)?;
buffer.write_u16(QueryType::AAAA.to_num())?;
buffer.write_u16(1)?;
buffer.write_u32(ttl)?;
write_header(buffer, domain, QueryType::AAAA.to_num(), ttl)?;
buffer.write_u16(16)?;
for octet in &addr.segments() {
buffer.write_u16(*octet)?;
}
}
DnsRecord::UNKNOWN { .. } => {
log::debug!("Skipping record: {:?}", self);
DnsRecord::DNSKEY {
ref domain,
flags,
protocol,
algorithm,
ref public_key,
ttl,
} => {
write_header(buffer, domain, QueryType::DNSKEY.to_num(), ttl)?;
buffer.write_u16((4 + public_key.len()) as u16)?;
buffer.write_u16(flags)?;
buffer.write_u8(protocol)?;
buffer.write_u8(algorithm)?;
buffer.write_bytes(public_key)?;
}
DnsRecord::DS {
ref domain,
key_tag,
algorithm,
digest_type,
ref digest,
ttl,
} => {
write_header(buffer, domain, QueryType::DS.to_num(), ttl)?;
buffer.write_u16((4 + digest.len()) as u16)?;
buffer.write_u16(key_tag)?;
buffer.write_u8(algorithm)?;
buffer.write_u8(digest_type)?;
buffer.write_bytes(digest)?;
}
DnsRecord::RRSIG {
ref domain,
type_covered,
algorithm,
labels,
original_ttl,
expiration,
inception,
key_tag,
ref signer_name,
ref signature,
ttl,
} => {
write_header(buffer, domain, QueryType::RRSIG.to_num(), ttl)?;
let rdlen_pos = buffer.pos();
buffer.write_u16(0)?; // RDLENGTH placeholder
buffer.write_u16(type_covered)?;
buffer.write_u8(algorithm)?;
buffer.write_u8(labels)?;
buffer.write_u32(original_ttl)?;
buffer.write_u32(expiration)?;
buffer.write_u32(inception)?;
buffer.write_u16(key_tag)?;
buffer.write_qname(signer_name)?;
buffer.write_bytes(signature)?;
let rdlen = buffer.pos() - (rdlen_pos + 2);
buffer.set_u16(rdlen_pos, rdlen as u16)?;
}
DnsRecord::NSEC {
ref domain,
ref next_domain,
ref type_bitmap,
ttl,
} => {
write_header(buffer, domain, QueryType::NSEC.to_num(), ttl)?;
let rdlen_pos = buffer.pos();
buffer.write_u16(0)?;
buffer.write_qname(next_domain)?;
buffer.write_bytes(type_bitmap)?;
let rdlen = buffer.pos() - (rdlen_pos + 2);
buffer.set_u16(rdlen_pos, rdlen as u16)?;
}
DnsRecord::NSEC3 {
ref domain,
hash_algorithm,
flags,
iterations,
ref salt,
ref next_hashed_owner,
ref type_bitmap,
ttl,
} => {
write_header(buffer, domain, QueryType::NSEC3.to_num(), ttl)?;
let rdlen =
1 + 1 + 2 + 1 + salt.len() + 1 + next_hashed_owner.len() + type_bitmap.len();
buffer.write_u16(rdlen as u16)?;
buffer.write_u8(hash_algorithm)?;
buffer.write_u8(flags)?;
buffer.write_u16(iterations)?;
buffer.write_u8(salt.len() as u8)?;
buffer.write_bytes(salt)?;
buffer.write_u8(next_hashed_owner.len() as u8)?;
buffer.write_bytes(next_hashed_owner)?;
buffer.write_bytes(type_bitmap)?;
}
DnsRecord::UNKNOWN {
ref domain,
qtype,
ref data,
ttl,
} => {
write_header(buffer, domain, qtype, ttl)?;
buffer.write_u16(data.len() as u16)?;
buffer.write_bytes(data)?;
}
}
Ok(buffer.pos() - start_pos)
}
}
fn write_header(buffer: &mut BytePacketBuffer, domain: &str, qtype: u16, ttl: u32) -> Result<()> {
buffer.write_qname(domain)?;
buffer.write_u16(qtype)?;
buffer.write_u16(1)?; // class IN
buffer.write_u32(ttl)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn round_trip(record: &DnsRecord) -> DnsRecord {
let mut buf = BytePacketBuffer::new();
record.write(&mut buf).unwrap();
buf.seek(0).unwrap();
DnsRecord::read(&mut buf).unwrap()
}
#[test]
fn unknown_preserves_raw_bytes() {
let rec = DnsRecord::UNKNOWN {
domain: "example.com".into(),
qtype: 99,
data: vec![0xDE, 0xAD, 0xBE, 0xEF],
ttl: 300,
};
let parsed = round_trip(&rec);
if let DnsRecord::UNKNOWN { data, .. } = &parsed {
assert_eq!(data.len(), 4);
assert_eq!(data, &[0xDE, 0xAD, 0xBE, 0xEF]);
} else {
panic!("expected UNKNOWN");
}
}
#[test]
fn dnskey_round_trip() {
let rec = DnsRecord::DNSKEY {
domain: "example.com".into(),
flags: 257, // KSK
protocol: 3,
algorithm: 13, // ECDSAP256SHA256
public_key: vec![1, 2, 3, 4, 5, 6, 7, 8],
ttl: 3600,
};
let parsed = round_trip(&rec);
assert_eq!(rec, parsed);
}
#[test]
fn ds_round_trip() {
let rec = DnsRecord::DS {
domain: "example.com".into(),
key_tag: 12345,
algorithm: 8,
digest_type: 2,
digest: vec![0xAA, 0xBB, 0xCC, 0xDD],
ttl: 86400,
};
let parsed = round_trip(&rec);
assert_eq!(rec, parsed);
}
#[test]
fn rrsig_round_trip() {
let rec = DnsRecord::RRSIG {
domain: "example.com".into(),
type_covered: 1, // A
algorithm: 13,
labels: 2,
original_ttl: 300,
expiration: 1700000000,
inception: 1690000000,
key_tag: 54321,
signer_name: "example.com".into(),
signature: vec![0x01, 0x02, 0x03, 0x04, 0x05],
ttl: 300,
};
let parsed = round_trip(&rec);
assert_eq!(rec, parsed);
}
#[test]
fn query_type_method() {
assert_eq!(
DnsRecord::DNSKEY {
domain: String::new(),
flags: 0,
protocol: 3,
algorithm: 8,
public_key: vec![],
ttl: 0,
}
.query_type(),
QueryType::DNSKEY
);
assert_eq!(
DnsRecord::DS {
domain: String::new(),
key_tag: 0,
algorithm: 0,
digest_type: 0,
digest: vec![],
ttl: 0,
}
.query_type(),
QueryType::DS
);
}
#[test]
fn nsec_round_trip() {
let rec = DnsRecord::NSEC {
domain: "alpha.example.com".into(),
next_domain: "gamma.example.com".into(),
type_bitmap: vec![0, 2, 0x40, 0x01], // A(1), MX(15)
ttl: 3600,
};
let parsed = round_trip(&rec);
assert_eq!(rec, parsed);
}
#[test]
fn nsec3_round_trip() {
let rec = DnsRecord::NSEC3 {
domain: "abc123.example.com".into(),
hash_algorithm: 1,
flags: 0,
iterations: 10,
salt: vec![0xAB, 0xCD],
next_hashed_owner: vec![0x01, 0x02, 0x03, 0x04, 0x05],
type_bitmap: vec![0, 1, 0x40], // A(1)
ttl: 3600,
};
let parsed = round_trip(&rec);
assert_eq!(rec, parsed);
}
}

601
src/recursive.rs Normal file
View File

@@ -0,0 +1,601 @@
use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::{AtomicU16, Ordering};
use std::sync::RwLock;
use std::time::Duration;
use log::{debug, info};
use tokio::time::timeout;
use crate::cache::DnsCache;
use crate::forward::forward_udp;
use crate::header::ResultCode;
use crate::packet::DnsPacket;
use crate::question::{DnsQuestion, QueryType};
use crate::record::DnsRecord;
const MAX_REFERRAL_DEPTH: u8 = 10;
const MAX_CNAME_DEPTH: u8 = 8;
const NS_QUERY_TIMEOUT: Duration = Duration::from_secs(2);
static QUERY_ID: AtomicU16 = AtomicU16::new(1);
fn next_id() -> u16 {
QUERY_ID.fetch_add(1, Ordering::Relaxed)
}
fn dns_addr(ip: impl Into<IpAddr>) -> SocketAddr {
SocketAddr::new(ip.into(), 53)
}
/// Query root servers for common TLDs and cache NS + glue + DNSKEY + DS records.
/// Pre-warms the DNSSEC trust chain so first queries skip chain-walking I/O.
pub async fn prime_tld_cache(cache: &RwLock<DnsCache>, root_hints: &[SocketAddr], tlds: &[String]) {
let root_addr = match root_hints.first() {
Some(addr) => *addr,
None => return,
};
if tlds.is_empty() {
return;
}
// Fetch root DNSKEY (needed for DNSSEC chain-of-trust terminus)
if let Ok(root_dnskey) = send_query(".", QueryType::DNSKEY, root_addr).await {
cache
.write()
.unwrap()
.insert(".", QueryType::DNSKEY, &root_dnskey);
debug!("prime: cached root DNSKEY");
}
let mut primed = 0u16;
for tld in tlds {
// Fetch NS referral (includes DS in authority section from root)
let response = match send_query(tld, QueryType::NS, root_addr).await {
Ok(r) => r,
Err(e) => {
debug!("prime: failed to query NS for .{}: {}", tld, e);
continue;
}
};
let ns_names = extract_ns_names(&response);
if ns_names.is_empty() {
continue;
}
{
let mut cache_w = cache.write().unwrap();
cache_w.insert(tld, QueryType::NS, &response);
cache_glue(&mut cache_w, &response, &ns_names);
// Cache DS records from referral authority section
cache_ds_from_authority(&mut cache_w, &response);
}
// Fetch DNSKEY for this TLD (needed for DNSSEC chain validation)
let first_ns_name = ns_names.first().map(|s| s.as_str()).unwrap_or("");
let first_ns = glue_addrs_for(&response, first_ns_name);
if let Some(ns_addr) = first_ns.first() {
if let Ok(dnskey_resp) = send_query(tld, QueryType::DNSKEY, *ns_addr).await {
cache
.write()
.unwrap()
.insert(tld, QueryType::DNSKEY, &dnskey_resp);
}
}
primed += 1;
}
info!(
"primed {}/{} TLD caches (NS + glue + DS + DNSKEY)",
primed,
tlds.len()
);
}
pub async fn resolve_recursive(
qname: &str,
qtype: QueryType,
cache: &RwLock<DnsCache>,
overall_timeout: Duration,
original_query: &DnsPacket,
root_hints: &[SocketAddr],
) -> crate::Result<DnsPacket> {
let mut resp = match timeout(
overall_timeout,
resolve_iterative(qname, qtype, cache, root_hints, 0, 0),
)
.await
{
Ok(result) => result?,
Err(_) => return Err(format!("recursive resolution timed out for {}", qname).into()),
};
resp.header.id = original_query.header.id;
resp.header.recursion_available = true;
resp.header.recursion_desired = original_query.header.recursion_desired;
resp.questions = original_query.questions.clone();
Ok(resp)
}
pub(crate) fn resolve_iterative<'a>(
qname: &'a str,
qtype: QueryType,
cache: &'a RwLock<DnsCache>,
root_hints: &'a [SocketAddr],
referral_depth: u8,
cname_depth: u8,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::Result<DnsPacket>> + Send + 'a>> {
Box::pin(async move {
if referral_depth > MAX_REFERRAL_DEPTH {
return Err("max referral depth exceeded".into());
}
if let Some(cached) = cache.read().unwrap().lookup(qname, qtype) {
return Ok(cached);
}
let mut ns_addrs = find_starting_ns(qname, cache, root_hints);
let mut ns_idx = 0;
for _ in 0..MAX_REFERRAL_DEPTH {
let ns_addr = match ns_addrs.get(ns_idx) {
Some(addr) => *addr,
None => return Err("no nameserver available".into()),
};
debug!(
"recursive: querying {} for {:?} {} (depth {})",
ns_addr, qtype, qname, referral_depth
);
let response = match send_query(qname, qtype, ns_addr).await {
Ok(r) => r,
Err(e) => {
debug!("recursive: NS {} failed: {}", ns_addr, e);
ns_idx += 1;
continue;
}
};
if !response.answers.is_empty() {
let has_target = response.answers.iter().any(|r| r.query_type() == qtype);
if has_target || qtype == QueryType::CNAME {
cache.write().unwrap().insert(qname, qtype, &response);
return Ok(response);
}
if let Some(cname_target) = extract_cname_target(&response, qname) {
if cname_depth >= MAX_CNAME_DEPTH {
return Err("max CNAME depth exceeded".into());
}
debug!("recursive: chasing CNAME {} -> {}", qname, cname_target);
let final_resp = resolve_iterative(
&cname_target,
qtype,
cache,
root_hints,
0,
cname_depth + 1,
)
.await?;
let mut combined = response;
combined.answers.extend(final_resp.answers);
combined.header.rescode = final_resp.header.rescode;
cache.write().unwrap().insert(qname, qtype, &combined);
return Ok(combined);
}
cache.write().unwrap().insert(qname, qtype, &response);
return Ok(response);
}
if response.header.rescode == ResultCode::NXDOMAIN
|| response.header.rescode == ResultCode::REFUSED
{
cache.write().unwrap().insert(qname, qtype, &response);
return Ok(response);
}
// Referral — extract NS + glue, cache glue, resolve NS addresses
let ns_names = extract_ns_names(&response);
if ns_names.is_empty() {
return Ok(response);
}
// Cache glue + DS from referral (avoids separate fetch during DNSSEC validation)
let mut new_ns_addrs = Vec::new();
{
let mut cache_w = cache.write().unwrap();
cache_glue(&mut cache_w, &response, &ns_names);
cache_ds_from_authority(&mut cache_w, &response);
}
for ns_name in &ns_names {
let glue = glue_addrs_for(&response, ns_name);
if !glue.is_empty() {
new_ns_addrs.extend_from_slice(&glue);
break;
}
}
// If no glue, try cache (A then AAAA) then recursive resolve
if new_ns_addrs.is_empty() {
for ns_name in &ns_names {
new_ns_addrs.extend(addrs_from_cache(cache, ns_name));
if new_ns_addrs.is_empty() && referral_depth < MAX_REFERRAL_DEPTH {
debug!("recursive: resolving glue-less NS {}", ns_name);
// Try A first, then AAAA
for qt in [QueryType::A, QueryType::AAAA] {
if let Ok(ns_resp) = resolve_iterative(
ns_name,
qt,
cache,
root_hints,
referral_depth + 1,
cname_depth,
)
.await
{
for rec in &ns_resp.answers {
match rec {
DnsRecord::A { addr, .. } => {
new_ns_addrs.push(dns_addr(*addr));
}
DnsRecord::AAAA { addr, .. } => {
new_ns_addrs.push(dns_addr(*addr));
}
_ => {}
}
}
}
if !new_ns_addrs.is_empty() {
break;
}
}
}
if !new_ns_addrs.is_empty() {
break;
}
}
}
if new_ns_addrs.is_empty() {
return Err(format!("could not resolve any NS for {}", qname).into());
}
ns_addrs = new_ns_addrs;
ns_idx = 0;
}
Err(format!("recursive resolution exhausted for {}", qname).into())
})
}
fn find_starting_ns(
qname: &str,
cache: &RwLock<DnsCache>,
root_hints: &[SocketAddr],
) -> Vec<SocketAddr> {
let guard = cache.read().unwrap();
let mut pos = 0;
loop {
let zone = &qname[pos..];
if let Some(cached) = guard.lookup(zone, QueryType::NS) {
let mut addrs = Vec::new();
for ns_rec in &cached.answers {
if let DnsRecord::NS { host, .. } = ns_rec {
for qt in [QueryType::A, QueryType::AAAA] {
if let Some(resp) = guard.lookup(host, qt) {
for rec in &resp.answers {
match rec {
DnsRecord::A { addr, .. } => {
addrs.push(dns_addr(*addr));
}
DnsRecord::AAAA { addr, .. } => {
addrs.push(dns_addr(*addr));
}
_ => {}
}
}
}
}
}
}
if !addrs.is_empty() {
debug!("recursive: starting from cached NS for zone '{}'", zone);
return addrs;
}
}
match qname[pos..].find('.') {
Some(dot) => pos += dot + 1,
None => break,
}
}
drop(guard);
debug!(
"recursive: starting from root hints ({} servers)",
root_hints.len()
);
root_hints.to_vec()
}
fn addrs_from_cache(cache: &RwLock<DnsCache>, name: &str) -> Vec<SocketAddr> {
let guard = cache.read().unwrap();
let mut addrs = Vec::new();
for qt in [QueryType::A, QueryType::AAAA] {
if let Some(pkt) = guard.lookup(name, qt) {
for rec in &pkt.answers {
match rec {
DnsRecord::A { addr, .. } => addrs.push(dns_addr(*addr)),
DnsRecord::AAAA { addr, .. } => addrs.push(dns_addr(*addr)),
_ => {}
}
}
}
}
addrs
}
fn glue_addrs_for(response: &DnsPacket, ns_name: &str) -> Vec<SocketAddr> {
response
.resources
.iter()
.filter_map(|r| match r {
DnsRecord::A { domain, addr, .. } if domain.eq_ignore_ascii_case(ns_name) => {
Some(dns_addr(*addr))
}
DnsRecord::AAAA { domain, addr, .. } if domain.eq_ignore_ascii_case(ns_name) => {
Some(dns_addr(*addr))
}
_ => None,
})
.collect()
}
fn cache_glue(cache: &mut DnsCache, response: &DnsPacket, ns_names: &[String]) {
for ns_name in ns_names {
let mut a_pkt: Option<DnsPacket> = None;
let mut aaaa_pkt: Option<DnsPacket> = None;
for r in &response.resources {
match r {
DnsRecord::A { domain, addr, ttl } if domain.eq_ignore_ascii_case(ns_name) => {
a_pkt
.get_or_insert_with(make_glue_packet)
.answers
.push(DnsRecord::A {
domain: ns_name.clone(),
addr: *addr,
ttl: *ttl,
});
}
DnsRecord::AAAA { domain, addr, ttl } if domain.eq_ignore_ascii_case(ns_name) => {
aaaa_pkt
.get_or_insert_with(make_glue_packet)
.answers
.push(DnsRecord::AAAA {
domain: ns_name.clone(),
addr: *addr,
ttl: *ttl,
});
}
_ => {}
}
}
if let Some(pkt) = a_pkt {
cache.insert(ns_name, QueryType::A, &pkt);
}
if let Some(pkt) = aaaa_pkt {
cache.insert(ns_name, QueryType::AAAA, &pkt);
}
}
}
/// Cache DS + DS-covering RRSIG records from referral authority sections.
fn cache_ds_from_authority(cache: &mut DnsCache, response: &DnsPacket) {
let mut ds_by_domain: Vec<(String, DnsPacket)> = Vec::new();
for r in &response.authorities {
match r {
DnsRecord::DS { domain, .. } => {
let key = domain.to_lowercase();
let pkt = match ds_by_domain.iter_mut().find(|(d, _)| *d == key) {
Some((_, pkt)) => pkt,
None => {
ds_by_domain.push((key, make_glue_packet()));
&mut ds_by_domain.last_mut().unwrap().1
}
};
pkt.answers.push(r.clone());
}
DnsRecord::RRSIG {
domain,
type_covered,
..
} if QueryType::from_num(*type_covered) == QueryType::DS => {
let key = domain.to_lowercase();
let pkt = match ds_by_domain.iter_mut().find(|(d, _)| *d == key) {
Some((_, pkt)) => pkt,
None => {
ds_by_domain.push((key, make_glue_packet()));
&mut ds_by_domain.last_mut().unwrap().1
}
};
pkt.answers.push(r.clone());
}
_ => {}
}
}
for (domain, pkt) in &ds_by_domain {
if !pkt.answers.is_empty() {
cache.insert(domain, QueryType::DS, pkt);
}
}
}
fn make_glue_packet() -> DnsPacket {
let mut pkt = DnsPacket::new();
pkt.header.response = true;
pkt.header.rescode = ResultCode::NOERROR;
pkt
}
async fn send_query(qname: &str, qtype: QueryType, server: SocketAddr) -> crate::Result<DnsPacket> {
let mut query = DnsPacket::new();
query.header.id = next_id();
query.header.recursion_desired = false;
query
.questions
.push(DnsQuestion::new(qname.to_string(), qtype));
query.edns = Some(crate::packet::EdnsOpt {
do_bit: true,
..Default::default()
});
forward_udp(&query, server, NS_QUERY_TIMEOUT).await
}
fn extract_cname_target(response: &DnsPacket, qname: &str) -> Option<String> {
response.answers.iter().find_map(|r| match r {
DnsRecord::CNAME { domain, host, .. } if domain.eq_ignore_ascii_case(qname) => {
Some(host.clone())
}
_ => None,
})
}
fn extract_ns_names(response: &DnsPacket) -> Vec<String> {
response
.authorities
.iter()
.filter_map(|r| match r {
DnsRecord::NS { host, .. } => Some(host.clone()),
_ => None,
})
.collect()
}
pub fn parse_root_hints(hints: &[String]) -> Vec<SocketAddr> {
hints
.iter()
.filter_map(|s| {
s.parse::<std::net::IpAddr>()
.map(|ip| SocketAddr::new(ip, 53))
.map_err(|e| log::warn!("invalid root hint '{}': {}", s, e))
.ok()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{Ipv4Addr, Ipv6Addr};
#[test]
fn extract_ns_from_authority() {
let mut pkt = DnsPacket::new();
pkt.authorities.push(DnsRecord::NS {
domain: "example.com".into(),
host: "ns1.example.com".into(),
ttl: 3600,
});
pkt.authorities.push(DnsRecord::NS {
domain: "example.com".into(),
host: "ns2.example.com".into(),
ttl: 3600,
});
let names = extract_ns_names(&pkt);
assert_eq!(names, vec!["ns1.example.com", "ns2.example.com"]);
}
#[test]
fn glue_extraction_a() {
let mut pkt = DnsPacket::new();
pkt.resources.push(DnsRecord::A {
domain: "ns1.example.com".into(),
addr: Ipv4Addr::new(1, 2, 3, 4),
ttl: 3600,
});
let addrs = glue_addrs_for(&pkt, "ns1.example.com");
assert_eq!(addrs, vec![dns_addr(Ipv4Addr::new(1, 2, 3, 4))]);
assert!(glue_addrs_for(&pkt, "ns3.example.com").is_empty());
}
#[test]
fn glue_extraction_aaaa() {
let mut pkt = DnsPacket::new();
pkt.resources.push(DnsRecord::AAAA {
domain: "ns1.example.com".into(),
addr: "2001:db8::1".parse().unwrap(),
ttl: 3600,
});
pkt.resources.push(DnsRecord::A {
domain: "ns1.example.com".into(),
addr: Ipv4Addr::new(1, 2, 3, 4),
ttl: 3600,
});
let addrs = glue_addrs_for(&pkt, "ns1.example.com");
assert_eq!(addrs.len(), 2);
// AAAA first (order matches resources), then A
assert_eq!(
addrs[0],
dns_addr("2001:db8::1".parse::<Ipv6Addr>().unwrap())
);
assert_eq!(addrs[1], dns_addr(Ipv4Addr::new(1, 2, 3, 4)));
}
#[test]
fn cname_extraction() {
let mut pkt = DnsPacket::new();
pkt.answers.push(DnsRecord::CNAME {
domain: "www.example.com".into(),
host: "example.com".into(),
ttl: 300,
});
assert_eq!(
extract_cname_target(&pkt, "www.example.com"),
Some("example.com".into())
);
assert_eq!(extract_cname_target(&pkt, "other.com"), None);
}
#[test]
fn parse_root_hints_valid() {
let hints = vec!["198.41.0.4".into(), "199.9.14.201".into()];
let addrs = parse_root_hints(&hints);
assert_eq!(addrs.len(), 2);
assert_eq!(addrs[0], dns_addr(Ipv4Addr::new(198, 41, 0, 4)));
}
#[test]
fn parse_root_hints_skips_invalid() {
let hints = vec![
"198.41.0.4".into(),
"not-an-ip".into(),
"192.33.4.12".into(),
];
let addrs = parse_root_hints(&hints);
assert_eq!(addrs.len(), 2);
}
#[test]
fn find_starting_ns_falls_back_to_hints() {
let cache = RwLock::new(DnsCache::new(100, 60, 86400));
let hints = vec![
dns_addr(Ipv4Addr::new(198, 41, 0, 4)),
dns_addr(Ipv4Addr::new(199, 9, 14, 201)),
];
let addrs = find_starting_ns("example.com", &cache, &hints);
assert_eq!(addrs, hints);
}
}

View File

@@ -3,6 +3,7 @@ use std::time::Instant;
pub struct ServerStats {
queries_total: u64,
queries_forwarded: u64,
queries_recursive: u64,
queries_cached: u64,
queries_blocked: u64,
queries_local: u64,
@@ -16,6 +17,7 @@ pub enum QueryPath {
Local,
Cached,
Forwarded,
Recursive,
Blocked,
Overridden,
UpstreamError,
@@ -27,6 +29,7 @@ impl QueryPath {
QueryPath::Local => "LOCAL",
QueryPath::Cached => "CACHED",
QueryPath::Forwarded => "FORWARD",
QueryPath::Recursive => "RECURSIVE",
QueryPath::Blocked => "BLOCKED",
QueryPath::Overridden => "OVERRIDE",
QueryPath::UpstreamError => "SERVFAIL",
@@ -40,6 +43,8 @@ impl QueryPath {
Some(QueryPath::Cached)
} else if s.eq_ignore_ascii_case("FORWARD") {
Some(QueryPath::Forwarded)
} else if s.eq_ignore_ascii_case("RECURSIVE") {
Some(QueryPath::Recursive)
} else if s.eq_ignore_ascii_case("BLOCKED") {
Some(QueryPath::Blocked)
} else if s.eq_ignore_ascii_case("OVERRIDE") {
@@ -63,6 +68,7 @@ impl ServerStats {
ServerStats {
queries_total: 0,
queries_forwarded: 0,
queries_recursive: 0,
queries_cached: 0,
queries_blocked: 0,
queries_local: 0,
@@ -78,6 +84,7 @@ impl ServerStats {
QueryPath::Local => self.queries_local += 1,
QueryPath::Cached => self.queries_cached += 1,
QueryPath::Forwarded => self.queries_forwarded += 1,
QueryPath::Recursive => self.queries_recursive += 1,
QueryPath::Blocked => self.queries_blocked += 1,
QueryPath::Overridden => self.queries_overridden += 1,
QueryPath::UpstreamError => self.upstream_errors += 1,
@@ -98,6 +105,7 @@ impl ServerStats {
uptime_secs: self.uptime_secs(),
total: self.queries_total,
forwarded: self.queries_forwarded,
recursive: self.queries_recursive,
cached: self.queries_cached,
local: self.queries_local,
overridden: self.queries_overridden,
@@ -113,10 +121,11 @@ impl ServerStats {
let secs = uptime.as_secs() % 60;
log::info!(
"STATS | uptime {}h{}m{}s | total {} | fwd {} | cached {} | local {} | override {} | blocked {} | errors {}",
"STATS | uptime {}h{}m{}s | total {} | fwd {} | recursive {} | cached {} | local {} | override {} | blocked {} | errors {}",
hours, mins, secs,
self.queries_total,
self.queries_forwarded,
self.queries_recursive,
self.queries_cached,
self.queries_local,
self.queries_overridden,
@@ -130,6 +139,7 @@ pub struct StatsSnapshot {
pub uptime_secs: u64,
pub total: u64,
pub forwarded: u64,
pub recursive: u64,
pub cached: u64,
pub local: u64,
pub overridden: u64,

401
tests/integration.sh Executable file
View File

@@ -0,0 +1,401 @@
#!/usr/bin/env bash
# Integration test suite for Numa
# Runs a test instance on port 5354, validates all features, exits with status.
# Usage: ./tests/integration.sh [release|debug]
set -euo pipefail
MODE="${1:-release}"
BINARY="./target/$MODE/numa"
PORT=5354
API_PORT=5381
CONFIG="/tmp/numa-integration-test.toml"
LOG="/tmp/numa-integration-test.log"
PASSED=0
FAILED=0
# Colors
GREEN="\033[32m"
RED="\033[31m"
DIM="\033[90m"
RESET="\033[0m"
check() {
local name="$1"
local expected="$2"
local actual="$3"
if echo "$actual" | grep -q "$expected"; then
PASSED=$((PASSED + 1))
printf " ${GREEN}${RESET} %s\n" "$name"
else
FAILED=$((FAILED + 1))
printf " ${RED}${RESET} %s\n" "$name"
printf " ${DIM}expected: %s${RESET}\n" "$expected"
printf " ${DIM} got: %s${RESET}\n" "$actual"
fi
}
# Build if needed
if [ ! -f "$BINARY" ]; then
echo "Building $MODE..."
cargo build --$MODE
fi
run_test_suite() {
local SUITE_NAME="$1"
local SUITE_CONFIG="$2"
cat > "$CONFIG" << CONF
$SUITE_CONFIG
CONF
echo "Starting Numa on :$PORT ($SUITE_NAME)..."
RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 &
NUMA_PID=$!
sleep 4
if ! kill -0 "$NUMA_PID" 2>/dev/null; then
echo "Failed to start Numa:"
tail -5 "$LOG"
return 1
fi
DIG="dig @127.0.0.1 -p $PORT +time=5 +tries=1"
echo ""
echo "=== Resolution ==="
check "A record (google.com)" \
"." \
"$($DIG google.com A +short)"
check "AAAA record (google.com)" \
":" \
"$($DIG google.com AAAA +short)"
check "CNAME chasing (www.github.com)" \
"github.com" \
"$($DIG www.github.com A +short)"
check "MX records (gmail.com)" \
"gmail-smtp-in" \
"$($DIG gmail.com MX +short)"
check "NS records (cloudflare.com)" \
"cloudflare.com" \
"$($DIG cloudflare.com NS +short)"
check "NXDOMAIN" \
"NXDOMAIN" \
"$($DIG nope12345678.com A 2>&1 | grep status:)"
echo ""
echo "=== Ad Blocking ==="
if echo "$SUITE_CONFIG" | grep -q 'enabled = true'; then
check "Blocked domain → 0.0.0.0" \
"0.0.0.0" \
"$($DIG ads.google.com A +short)"
else
local ADS=$($DIG ads.google.com A +short 2>/dev/null)
if echo "$ADS" | grep -q "0.0.0.0"; then
check "Blocking disabled but domain blocked" "should-resolve" "0.0.0.0"
else
check "Blocking disabled — domain resolves normally" "." "$ADS"
fi
fi
echo ""
echo "=== Cache ==="
$DIG example.com A +short > /dev/null 2>&1
sleep 1
check "Cache hit returns result" \
"." \
"$($DIG example.com A +short)"
echo ""
echo "=== Connectivity ==="
# Apple captive portal can be slow/flaky on some networks
local CAPTIVE
CAPTIVE=$($DIG captive.apple.com A +short 2>/dev/null || echo "timeout")
if echo "$CAPTIVE" | grep -q "apple\|17\.\|timeout"; then
check "Apple captive portal" "." "$CAPTIVE"
else
check "Apple captive portal" "apple" "$CAPTIVE"
fi
check "CDN (jsdelivr)" \
"." \
"$($DIG cdn.jsdelivr.net A +short)"
echo ""
echo "=== API ==="
check "Health endpoint" \
"ok" \
"$(curl -s http://127.0.0.1:$API_PORT/health)"
check "Stats endpoint" \
"uptime_secs" \
"$(curl -s http://127.0.0.1:$API_PORT/stats)"
echo ""
echo "=== Log Health ==="
ERRORS=$(grep -c 'RECURSIVE ERROR\|PARSE ERROR\|HANDLER ERROR\|panic' "$LOG" 2>/dev/null || echo 0)
check "No critical errors in log" \
"0" \
"$ERRORS"
kill "$NUMA_PID" 2>/dev/null || true
wait "$NUMA_PID" 2>/dev/null || true
sleep 1
}
# ---- Suite 1: Recursive mode + DNSSEC ----
echo ""
echo "╔══════════════════════════════════════════╗"
echo "║ Suite 1: Recursive + DNSSEC + Blocking ║"
echo "╚══════════════════════════════════════════╝"
run_test_suite "recursive + DNSSEC + blocking" "
[server]
bind_addr = \"127.0.0.1:5354\"
api_port = 5381
[upstream]
mode = \"recursive\"
[cache]
max_entries = 10000
min_ttl = 60
max_ttl = 86400
[blocking]
enabled = true
[proxy]
enabled = false
[dnssec]
enabled = true
"
DIG="dig @127.0.0.1 -p $PORT +time=5 +tries=1"
echo ""
echo "=== DNSSEC (recursive only) ==="
# Re-start for DNSSEC checks (suite 1 instance was killed)
RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 &
NUMA_PID=$!
sleep 4
check "AD bit set (cloudflare.com)" \
" ad" \
"$($DIG cloudflare.com A +dnssec 2>&1 | grep flags:)"
check "EDNS DO bit echoed" \
"flags: do" \
"$($DIG cloudflare.com A +dnssec 2>&1 | grep 'EDNS:')"
kill "$NUMA_PID" 2>/dev/null || true
wait "$NUMA_PID" 2>/dev/null || true
sleep 1
# ---- Suite 2: Forward mode (backward compat) ----
echo ""
echo "╔══════════════════════════════════════════╗"
echo "║ Suite 2: Forward (DoH) + Blocking ║"
echo "╚══════════════════════════════════════════╝"
run_test_suite "forward DoH + blocking" "
[server]
bind_addr = \"127.0.0.1:5354\"
api_port = 5381
[upstream]
mode = \"forward\"
address = \"https://9.9.9.9/dns-query\"
[cache]
max_entries = 10000
min_ttl = 60
max_ttl = 86400
[blocking]
enabled = true
[proxy]
enabled = false
"
# ---- Suite 3: Forward UDP (plain, no DoH) ----
echo ""
echo "╔══════════════════════════════════════════╗"
echo "║ Suite 3: Forward (UDP) + No Blocking ║"
echo "╚══════════════════════════════════════════╝"
run_test_suite "forward UDP, no blocking" "
[server]
bind_addr = \"127.0.0.1:5354\"
api_port = 5381
[upstream]
mode = \"forward\"
address = \"9.9.9.9\"
port = 53
[cache]
max_entries = 10000
min_ttl = 60
max_ttl = 86400
[blocking]
enabled = false
[proxy]
enabled = false
"
# Verify blocking is actually off
RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 &
NUMA_PID=$!
sleep 3
echo ""
echo "=== Blocking disabled ==="
ADS_RESULT=$($DIG ads.google.com A +short 2>/dev/null)
if echo "$ADS_RESULT" | grep -q "0.0.0.0"; then
check "ads.google.com NOT blocked (blocking disabled)" "not-0.0.0.0" "0.0.0.0"
else
check "ads.google.com NOT blocked (blocking disabled)" "." "$ADS_RESULT"
fi
kill "$NUMA_PID" 2>/dev/null || true
wait "$NUMA_PID" 2>/dev/null || true
sleep 1
# ---- Suite 4: Local zones + Overrides API ----
echo ""
echo "╔══════════════════════════════════════════╗"
echo "║ Suite 4: Local Zones + Overrides API ║"
echo "╚══════════════════════════════════════════╝"
cat > "$CONFIG" << 'CONF'
[server]
bind_addr = "127.0.0.1:5354"
api_port = 5381
[upstream]
mode = "forward"
address = "9.9.9.9"
port = 53
[cache]
max_entries = 10000
[blocking]
enabled = false
[proxy]
enabled = false
[[zones]]
domain = "test.local"
record_type = "A"
value = "10.0.0.1"
ttl = 60
[[zones]]
domain = "mail.local"
record_type = "MX"
value = "10 smtp.local"
ttl = 60
CONF
RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 &
NUMA_PID=$!
sleep 3
echo ""
echo "=== Local Zones ==="
check "Local A record (test.local)" \
"10.0.0.1" \
"$($DIG test.local A +short)"
check "Local MX record (mail.local)" \
"smtp.local" \
"$($DIG mail.local MX +short)"
check "Non-local domain still resolves" \
"." \
"$($DIG example.com A +short)"
echo ""
echo "=== Overrides API ==="
# Create override
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://127.0.0.1:$API_PORT/overrides \
-H 'Content-Type: application/json' \
-d '{"domain":"override.test","target":"192.168.1.100","duration_secs":60}')
check "Create override (HTTP 200/201)" \
"20" \
"$HTTP_CODE"
sleep 1
check "Override resolves" \
"192.168.1.100" \
"$($DIG override.test A +short)"
# List overrides
check "List overrides" \
"override.test" \
"$(curl -s http://127.0.0.1:$API_PORT/overrides)"
# Delete override
curl -s -X DELETE http://127.0.0.1:$API_PORT/overrides/override.test > /dev/null
sleep 1
# After delete, should not resolve to override
AFTER_DELETE=$($DIG override.test A +short 2>/dev/null)
if echo "$AFTER_DELETE" | grep -q "192.168.1.100"; then
check "Override deleted" "not-192.168.1.100" "$AFTER_DELETE"
else
check "Override deleted" "." "deleted"
fi
echo ""
echo "=== Cache API ==="
check "Cache list" \
"domain" \
"$(curl -s http://127.0.0.1:$API_PORT/cache)"
# Flush cache
curl -s -X DELETE http://127.0.0.1:$API_PORT/cache > /dev/null
check "Cache flushed" \
"0" \
"$(curl -s http://127.0.0.1:$API_PORT/stats | grep -o '"entries":[0-9]*' | grep -o '[0-9]*')"
kill "$NUMA_PID" 2>/dev/null || true
wait "$NUMA_PID" 2>/dev/null || true
# Summary
echo ""
TOTAL=$((PASSED + FAILED))
if [ "$FAILED" -eq 0 ]; then
printf "${GREEN}All %d tests passed.${RESET}\n" "$TOTAL"
exit 0
else
printf "${RED}%d/%d tests failed.${RESET}\n" "$FAILED" "$TOTAL"
echo ""
echo "Log: $LOG"
exit 1
fi

128
tests/network-probe.sh Executable file
View File

@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# Network probe: tests which DNS transports are available on the current network.
# Run on a problematic network to diagnose what's blocked.
# Usage: ./tests/network-probe.sh
set -euo pipefail
GREEN="\033[32m"
RED="\033[31m"
DIM="\033[90m"
RESET="\033[0m"
PASSED=0
FAILED=0
probe() {
local name="$1"
local cmd="$2"
local expect="$3"
local result
result=$(eval "$cmd" 2>&1) || true
if echo "$result" | grep -q "$expect"; then
PASSED=$((PASSED + 1))
printf " ${GREEN}${RESET} %-45s ${DIM}%s${RESET}\n" "$name" "$(echo "$result" | head -1 | cut -c1-60)"
else
FAILED=$((FAILED + 1))
printf " ${RED}${RESET} %-45s ${DIM}blocked/timeout${RESET}\n" "$name"
fi
}
echo ""
echo "Network DNS Transport Probe"
echo "==========================="
echo "Network: $(networksetup -getairportnetwork en0 2>/dev/null | sed 's/Current Wi-Fi Network: //' || echo 'unknown')"
echo "Local IP: $(ipconfig getifaddr en0 2>/dev/null || echo 'unknown')"
echo "Gateway: $(route -n get default 2>/dev/null | grep gateway | awk '{print $2}' || echo 'unknown')"
echo ""
echo "=== UDP port 53 (recursive resolution) ==="
probe "Root server a (198.41.0.4)" \
"dig @198.41.0.4 . NS +short +time=5 +tries=1" \
"root-servers"
probe "Root server k (193.0.14.129)" \
"dig @193.0.14.129 . NS +short +time=5 +tries=1" \
"root-servers"
probe "Google DNS (8.8.8.8)" \
"dig @8.8.8.8 google.com A +short +time=5 +tries=1" \
"\."
probe "Cloudflare (1.1.1.1)" \
"dig @1.1.1.1 cloudflare.com A +short +time=5 +tries=1" \
"\."
probe ".com TLD (192.5.6.30)" \
"dig @192.5.6.30 google.com NS +short +time=5 +tries=1" \
"google"
echo ""
echo "=== TCP port 53 ==="
probe "Google DNS TCP (8.8.8.8)" \
"dig @8.8.8.8 google.com A +short +tcp +time=5 +tries=1" \
"\."
probe "Root server TCP (198.41.0.4)" \
"dig @198.41.0.4 . NS +short +tcp +time=5 +tries=1" \
"root-servers"
echo ""
echo "=== DoT port 853 (DNS-over-TLS) ==="
probe "Quad9 DoT (9.9.9.9:853)" \
"echo Q | openssl s_client -connect 9.9.9.9:853 -servername dns.quad9.net 2>&1 | grep 'verify return'" \
"verify return"
probe "Cloudflare DoT (1.1.1.1:853)" \
"echo Q | openssl s_client -connect 1.1.1.1:853 -servername cloudflare-dns.com 2>&1 | grep 'verify return'" \
"verify return"
echo ""
echo "=== DoH port 443 (DNS-over-HTTPS) ==="
probe "Quad9 DoH (dns.quad9.net)" \
"curl -s -m 5 -H 'accept: application/dns-json' 'https://dns.quad9.net:443/dns-query?name=google.com&type=A'" \
"Answer"
probe "Cloudflare DoH (1.1.1.1)" \
"curl -s -m 5 -H 'accept: application/dns-json' 'https://1.1.1.1/dns-query?name=google.com&type=A'" \
"Answer"
probe "Google DoH (dns.google)" \
"curl -s -m 5 'https://dns.google/resolve?name=google.com&type=A'" \
"Answer"
echo ""
echo "=== ISP DNS ==="
# Detect system DNS
SYS_DNS=$(scutil --dns 2>/dev/null | grep "nameserver\[0\]" | head -1 | awk '{print $3}' || echo "unknown")
if [ "$SYS_DNS" != "unknown" ] && [ "$SYS_DNS" != "127.0.0.1" ]; then
probe "ISP DNS ($SYS_DNS)" \
"dig @$SYS_DNS google.com A +short +time=5 +tries=1" \
"\."
else
printf " ${DIM} System DNS is $SYS_DNS (skipped)${RESET}\n"
fi
echo ""
echo "==========================="
TOTAL=$((PASSED + FAILED))
printf "Results: ${GREEN}%d passed${RESET}, ${RED}%d blocked${RESET} / %d total\n" "$PASSED" "$FAILED" "$TOTAL"
echo ""
echo "Recommendation:"
if [ "$FAILED" -eq 0 ]; then
echo " All transports available. Recursive mode will work."
elif dig @198.41.0.4 . NS +short +time=5 +tries=1 2>&1 | grep -q "root-servers"; then
echo " UDP:53 works. Recursive mode will work."
else
echo " UDP:53 blocked — recursive mode will NOT work on this network."
if curl -s -m 5 'https://dns.quad9.net:443/dns-query?name=test.com&type=A' 2>&1 | grep -q "Answer"; then
echo " DoH (port 443) works — use mode = \"forward\" with DoH upstream."
elif echo Q | openssl s_client -connect 9.9.9.9:853 2>&1 | grep -q "verify return"; then
echo " DoT (port 853) works — DoT upstream would work (not yet implemented)."
else
echo " Only ISP DNS available. Use mode = \"forward\" with ISP auto-detect."
fi
fi