feat: recursive DNS + DNSSEC + TCP fallback (#17)
* 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> * feat: TCP fallback, query minimization, UDP auto-disable Transport resilience for restrictive networks (ISPs blocking UDP:53): - DNS-over-TCP fallback: UDP fail/truncation → automatic TCP retry - UDP auto-disable: after 3 consecutive failures, switch to TCP-first - IPv6 → TCP directly (UDP socket binds 0.0.0.0, can't reach IPv6) - Network change resets UDP detection for re-probing - Root hint rotation in TLD priming Privacy: - RFC 7816 query minimization: root servers see TLD only, not full name Code quality: - Merged find_starting_ns + find_starting_zone → find_closest_ns - Extracted resolve_ns_addrs_from_glue shared helper - Removed overall timeout wrapper (per-hop timeouts sufficient) - forward_tcp for DNS-over-TCP (RFC 1035 §4.2.2) Testing: - Mock TCP-only DNS server for fallback tests (no network needed) - tcp_fallback_resolves_when_udp_blocked - tcp_only_iterative_resolution - tcp_fallback_handles_nxdomain - udp_auto_disable_resets - Integration test suite (4 suites, 51 tests) - Network probe script (tests/network-probe.sh) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: DNSSEC verified badge in dashboard query log - Add dnssec field to QueryLogEntry, track validation status per query - DnssecStatus::as_str() for API serialization - Dashboard shows green checkmark next to DNSSEC-verified responses - Blog post: add "How keys get there" section, transport resilience section, trim code blocks, update What's Next Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use SVG shield for DNSSEC badge, update blog HTML Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 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> * fix: TCP single-write, mock server consistency, integration tests - TCP single-write fix: combine length prefix + message to avoid split segments that Microsoft/Azure DNS servers reject - Mock server (spawn_tcp_dns_server) updated to use single-write too - Tests: forward_tcp_wire_format, forward_tcp_single_segment_write - Integration: real-server checks for Microsoft/Office/Azure domains Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: recursive bar in dashboard, special-use domain interception Dashboard: - Add Recursive bar to resolution paths chart (cyan, distinct from Override) - Add RECURSIVE path tag style in query log Special-use domains (RFC 6761/6303/8880/9462): - .localhost → 127.0.0.1 (RFC 6761) - Private reverse PTR (10.x, 192.168.x, 172.16-31.x) → NXDOMAIN - _dns.resolver.arpa (DDR) → NXDOMAIN - ipv4only.arpa (NAT64) → 192.0.0.170/171 - mDNS service discovery for private ranges → NXDOMAIN Eliminates ~900ms SERVFAILs for macOS system queries that were hitting root servers unnecessarily. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: move generated blog HTML to site/blog/posts/, gitignore - Generated HTML now in site/blog/posts/ (gitignored) - CI workflow runs pandoc + make blog before deploy - Updated all internal blog links to /blog/posts/ path - blog/*.md remains the source of truth Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: review feedback — memory ordering, RRSIG time, NS resolution - Ordering::Relaxed → Acquire/Release for UDP_DISABLED/UDP_FAILURES (ARM correctness for cross-thread coordination) - RRSIG time validation: serial number arithmetic (RFC 4034 §3.1.5) + 300s clock skew fudge factor (matches BIND) - resolve_ns_addrs_from_glue collects addresses from ALL NS names, not just the first with glue (improves failover) - is_special_use_domain: eliminate 16 format! allocations per .in-addr.arpa query (parse octet instead) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: API endpoint tests, coverage target - 8 new axum handler tests: health, stats, query-log, overrides CRUD, cache, blocking stats, services CRUD, dashboard HTML - Tests use tower::oneshot — no network, no server startup - test_ctx() builds minimal ServerCtx for isolated testing - `make coverage` target (cargo-tarpaulin), separate from `make all` - 82 total tests (was 74) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #17.
This commit is contained in:
4
.github/workflows/static.yml
vendored
4
.github/workflows/static.yml
vendored
@@ -31,6 +31,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Install pandoc
|
||||
run: sudo apt-get install -y pandoc
|
||||
- name: Generate blog HTML
|
||||
run: make blog
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
- name: Upload artifact
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
/target
|
||||
CLAUDE.md
|
||||
docs/
|
||||
site/blog/posts/
|
||||
|
||||
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -1148,12 +1148,14 @@ dependencies = [
|
||||
"criterion",
|
||||
"env_logger",
|
||||
"futures",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"rcgen",
|
||||
"reqwest",
|
||||
"ring",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1162,6 +1164,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"toml",
|
||||
"tower",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -28,9 +28,12 @@ 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"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
http = "1"
|
||||
|
||||
[[bench]]
|
||||
name = "hot_path"
|
||||
@@ -39,3 +42,7 @@ harness = false
|
||||
[[bench]]
|
||||
name = "throughput"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "dnssec"
|
||||
harness = false
|
||||
|
||||
13
Makefile
13
Makefile
@@ -1,6 +1,6 @@
|
||||
.PHONY: all build lint fmt check audit test bench clean deploy blog
|
||||
.PHONY: all build lint fmt check audit test coverage bench clean deploy blog
|
||||
|
||||
all: lint build
|
||||
all: lint build test
|
||||
|
||||
build:
|
||||
cargo build
|
||||
@@ -19,15 +19,18 @@ audit:
|
||||
test:
|
||||
cargo test
|
||||
|
||||
coverage:
|
||||
cargo tarpaulin --skip-clean --out stdout
|
||||
|
||||
bench:
|
||||
cargo bench
|
||||
|
||||
blog:
|
||||
@mkdir -p site/blog
|
||||
@mkdir -p site/blog/posts
|
||||
@for f in blog/*.md; do \
|
||||
name=$$(basename "$$f" .md); \
|
||||
pandoc "$$f" --template=site/blog-template.html -o "site/blog/$$name.html"; \
|
||||
echo " $$f → site/blog/$$name.html"; \
|
||||
pandoc "$$f" --template=site/blog-template.html -o "site/blog/posts/$$name.html"; \
|
||||
echo " $$f → site/blog/posts/$$name.html"; \
|
||||
done
|
||||
|
||||
clean:
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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
183
benches/dnssec.rs
Normal 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);
|
||||
@@ -8,7 +8,7 @@ I wanted to understand how DNS actually works. Not the "it translates domain nam
|
||||
|
||||
So I built one from scratch in Rust. No `hickory-dns`, no `trust-dns`, no `simple-dns`. The entire RFC 1035 wire protocol — headers, labels, compression pointers, record types — parsed and serialized by hand. It started as a weekend learning project, became a side project I kept coming back to over 6 years, and eventually turned into [Numa](https://github.com/razvandimescu/numa) — which I now use as my actual system DNS.
|
||||
|
||||
A note on terminology before we go further: Numa is currently a *forwarding* resolver — it parses and caches DNS packets, but forwards queries to an upstream (Quad9, Cloudflare, or any DoH provider) rather than walking the delegation chain from root servers itself. Think of it as a smart proxy that does useful things with your DNS traffic locally (caching, ad blocking, overrides, local service domains) before forwarding what it can't answer. Full recursive resolution — where Numa talks directly to root and authoritative nameservers — is on the roadmap, along with DNSSEC validation.
|
||||
A note on terminology: Numa supports two resolution modes. *Forward* mode relays queries to an upstream (Quad9, Cloudflare, or any DoH provider). *Recursive* mode walks the delegation chain from root servers itself — iterative queries to root, TLD, and authoritative nameservers, with full DNSSEC validation. In both modes, Numa does useful things with your DNS traffic locally (caching, ad blocking, overrides, local service domains) before resolving what it can't answer. This post covers the wire protocol and forwarding path; [the next post](/blog/posts/dnssec-from-scratch.html) covers recursive resolution and DNSSEC.
|
||||
|
||||
Here's what surprised me along the way.
|
||||
|
||||
@@ -315,14 +315,13 @@ That creates the DNS entry, generates a TLS certificate, and starts proxying —
|
||||
|
||||
## What's next
|
||||
|
||||
Numa is at v0.5.0 with DNS forwarding, caching, ad blocking, DNS-over-HTTPS, .numa local domains with auto TLS, and LAN service discovery.
|
||||
**Update (March 2026):** Recursive resolution and DNSSEC validation are now shipped. Numa resolves from root nameservers with full chain-of-trust verification (RSA/SHA-256, ECDSA P-256, Ed25519) and NSEC/NSEC3 authenticated denial of existence.
|
||||
|
||||
On the roadmap:
|
||||
**[Read the follow-up: Implementing DNSSEC from Scratch in Rust →](/blog/posts/dnssec-from-scratch.html)**
|
||||
|
||||
Still on the roadmap:
|
||||
|
||||
- **DoT (DNS-over-TLS)** — DoH was first because it passes through captive portals and corporate firewalls (port 443 vs 853). DoT has less framing overhead, so it's faster. Both will be available.
|
||||
- **Recursive resolution** — walk the delegation chain from root servers instead of forwarding. Combined with DNSSEC validation, this removes the need to trust any upstream resolver.
|
||||
- **[pkarr](https://github.com/pubky/pkarr) integration** — self-sovereign DNS via the Mainline BitTorrent DHT. Publish DNS records signed with your Ed25519 key, no registrar needed.
|
||||
|
||||
But those are rabbit holes for future posts.
|
||||
|
||||
[github.com/razvandimescu/numa](https://github.com/razvandimescu/numa)
|
||||
|
||||
201
blog/dnssec-from-scratch.md
Normal file
201
blog/dnssec-from-scratch.md
Normal file
@@ -0,0 +1,201 @@
|
||||
---
|
||||
title: Implementing DNSSEC from Scratch in Rust
|
||||
description: Recursive resolution from root hints, chain-of-trust validation, NSEC/NSEC3 denial proofs, and what I learned implementing DNSSEC with zero DNS libraries.
|
||||
date: March 2026
|
||||
---
|
||||
|
||||
In the [previous post](/blog/posts/dns-from-scratch.html) I covered how DNS works at the wire level — packet format, label compression, TTL caching, DoH. Numa was a forwarding resolver: it parsed packets, did useful things locally, and relayed the rest to Cloudflare or Quad9.
|
||||
|
||||
That post ended with "recursive resolution and DNSSEC are on the roadmap." This post is about building both.
|
||||
|
||||
The short version: Numa now resolves from root nameservers with iterative queries, validates the full DNSSEC chain of trust, and cryptographically proves that non-existent domains don't exist. No upstream dependency. No DNS libraries. Just `ring` for the crypto primitives and a lot of RFC reading.
|
||||
|
||||
## Why recursive?
|
||||
|
||||
A forwarding resolver trusts its upstream. When you ask Quad9 for `cloudflare.com`, you trust that Quad9 returns the real answer. If Quad9 lies, gets compromised, or is legally compelled to redirect you — you have no way to know.
|
||||
|
||||
A recursive resolver doesn't trust anyone. It starts at the root nameservers (operated by 12 independent organizations) and follows the delegation chain: root → `.com` TLD → `cloudflare.com` authoritative servers. Each server only answers for its own zone. No single entity sees your full query pattern.
|
||||
|
||||
DNSSEC adds cryptographic proof to each step. The root signs `.com`'s key. `.com` signs `cloudflare.com`'s key. `cloudflare.com` signs its own records. If any step is tampered with, the chain breaks and Numa rejects the response.
|
||||
|
||||
## The iterative resolution loop
|
||||
|
||||
Recursive resolution is a misnomer — the resolver actually uses *iterative* queries. It asks root "where is `cloudflare.com`?", root says "I don't know, but here are the `.com` nameservers." It asks `.com`, which says "here are cloudflare's nameservers." It asks those, and gets the answer.
|
||||
|
||||
```
|
||||
resolve("cloudflare.com", A)
|
||||
→ ask 198.41.0.4 (a.root-servers.net)
|
||||
← "try .com: ns1.gtld-servers.net (192.5.6.30)" [referral + glue]
|
||||
→ ask 192.5.6.30 (ns1.gtld-servers.net)
|
||||
← "try cloudflare: ns1.cloudflare.com (173.245.58.51)" [referral + glue]
|
||||
→ ask 173.245.58.51 (ns1.cloudflare.com)
|
||||
← "104.16.132.229" [answer]
|
||||
```
|
||||
|
||||
The implementation (`src/recursive.rs`) is a loop with three possible outcomes per query:
|
||||
|
||||
1. **Answer** — the server knows the record. Cache it, return it.
|
||||
2. **Referral** — the server delegates to another zone. Extract NS records and glue (A/AAAA records for the nameservers, included in the additional section to avoid a chicken-and-egg problem), then query the next server.
|
||||
3. **NXDOMAIN/REFUSED** — the name doesn't exist or the server refuses. Cache the negative result.
|
||||
|
||||
CNAME chasing adds complexity: if you ask for `www.cloudflare.com` and get a CNAME to `cloudflare.com`, you need to restart resolution for the new name. I cap this at 8 levels.
|
||||
|
||||
### TLD priming
|
||||
|
||||
Cold-cache resolution is slow. Every query needs root → TLD → authoritative, each with its own network round-trip. For the first query to `example.com`, that's three serial UDP round-trips before you get an answer.
|
||||
|
||||
TLD priming solves this. On startup, Numa queries root for NS records of 34 common TLDs (`.com`, `.org`, `.net`, `.io`, `.dev`, plus EU ccTLDs), caching NS records, glue addresses, DS records, and DNSKEY records. After priming, the first query to any `.com` domain skips root entirely — it already knows where `.com`'s nameservers are, and already has the DNSSEC keys needed to validate the response.
|
||||
|
||||
## DNSSEC chain of trust
|
||||
|
||||
DNSSEC doesn't encrypt DNS traffic. It *signs* it. Every DNS record can have an accompanying RRSIG (signature) record. The resolver verifies the signature against the zone's DNSKEY, then verifies that DNSKEY against the parent zone's DS (delegation signer) record, walking up until it reaches the root trust anchor — a hardcoded public key that IANA publishes and the entire internet agrees on.
|
||||
|
||||
```
|
||||
cloudflare.com A 104.16.132.229
|
||||
signed by → RRSIG (key_tag=34505, algo=13, signer=cloudflare.com)
|
||||
verified with → DNSKEY (cloudflare.com, key_tag=34505, ECDSA P-256)
|
||||
vouched for by → DS (at .com, key_tag=2371, digest=SHA-256 of cloudflare's DNSKEY)
|
||||
signed by → RRSIG (key_tag=19718, signer=com)
|
||||
verified with → DNSKEY (com, key_tag=19718)
|
||||
vouched for by → DS (at root, key_tag=30909)
|
||||
signed by → RRSIG (signer=.)
|
||||
verified with → DNSKEY (., key_tag=20326) ← root trust anchor (hardcoded)
|
||||
```
|
||||
|
||||
### How keys get there
|
||||
|
||||
The domain owner generates the DNSKEY keypair — typically their DNS provider (Cloudflare, etc.) does this. The owner then submits the DS record (a hash of their DNSKEY) to their registrar (Namecheap, GoDaddy), who passes it to the registry (Verisign for `.com`). The registry signs it into the TLD zone, and IANA signs the TLD's DS into the root. Trust flows up; keys flow down.
|
||||
|
||||
The irony: you "own" your DNSSEC keys, but your registrar controls whether the DS record gets published. If they remove it — by mistake, by policy, or by court order — your DNSSEC chain breaks silently.
|
||||
|
||||
### The trust anchor
|
||||
|
||||
IANA's root KSK (Key Signing Key) has key tag 20326, algorithm 8 (RSA/SHA-256), and a 256-byte public key. It was last rolled in 2018. I hardcode it as a `const` array — this is the one thing in the entire system that requires out-of-band trust.
|
||||
|
||||
```rust
|
||||
const ROOT_KSK_PUBLIC_KEY: &[u8] = &[
|
||||
0x03, 0x01, 0x00, 0x01, 0xac, 0xff, 0xb4, 0x09,
|
||||
// ... 256 bytes total
|
||||
];
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Numa uses `ring` for all cryptographic operations. Three algorithms cover the vast majority of signed zones:
|
||||
|
||||
| Algorithm | ID | Usage | Verify time |
|
||||
|---|---|---|---|
|
||||
| RSA/SHA-256 | 8 | Root, most TLDs | 10.9 µs |
|
||||
| ECDSA P-256 | 13 | Cloudflare, many modern zones | 174 ns |
|
||||
| Ed25519 | 15 | Newer zones | ~200 ns |
|
||||
|
||||
### RSA key format conversion
|
||||
|
||||
DNS stores RSA public keys in RFC 3110 format (exponent length, exponent, modulus). `ring` expects PKCS#1 DER (ASN.1 encoded). Converting between them means writing a minimal ASN.1 encoder with leading-zero stripping and sign-bit padding. Getting this wrong produces keys that `ring` silently rejects — one of the harder bugs to track down.
|
||||
|
||||
### ECDSA is simpler
|
||||
|
||||
ECDSA P-256 keys in DNS are 64 bytes (x + y coordinates). `ring` expects uncompressed point format: `0x04` prefix + 64 bytes. One line:
|
||||
|
||||
```rust
|
||||
let mut uncompressed = Vec::with_capacity(65);
|
||||
uncompressed.push(0x04);
|
||||
uncompressed.extend_from_slice(public_key); // 64 bytes from DNS
|
||||
```
|
||||
|
||||
Signatures are also 64 bytes (r + s), used directly. No format conversion needed.
|
||||
|
||||
### Building the signed data
|
||||
|
||||
RRSIG verification doesn't sign the DNS packet — it signs a canonical form of the records. Building this correctly is the most detail-sensitive part of DNSSEC. The signed data is:
|
||||
|
||||
1. RRSIG RDATA fields (type covered, algorithm, labels, original TTL, expiration, inception, key tag, signer name) — *without* the signature itself
|
||||
2. For each record in the RRset: owner name (lowercased, uncompressed) + type + class + original TTL (from the RRSIG, not the record's current TTL) + RDATA length + canonical RDATA
|
||||
|
||||
The records must be sorted by their canonical wire-format representation. Owner names must be lowercased. The TTL must be the *original* TTL from the RRSIG, not the decremented TTL from caching.
|
||||
|
||||
Getting any of these details wrong — wrong TTL, wrong case, wrong sort order, wrong RDATA encoding — produces a valid-looking but incorrect signed data blob, and `ring` returns a signature mismatch with no diagnostic information. I spent more time debugging signed data construction than any other part of DNSSEC.
|
||||
|
||||
## Proving a name doesn't exist
|
||||
|
||||
Verifying that `cloudflare.com` has a valid A record is one thing. Proving that `doesnotexist.cloudflare.com` *doesn't* exist — cryptographically, in a way that can't be forged — is harder.
|
||||
|
||||
### NSEC
|
||||
|
||||
NSEC records form a chain. Each NSEC says "the next name in this zone after me is X, and at my name these record types exist." If you query `beta.example.com` and the zone has `alpha.example.com → NSEC → gamma.example.com`, the gap proves `beta` doesn't exist — there's nothing between `alpha` and `gamma`.
|
||||
|
||||
For NXDOMAIN proofs, RFC 4035 §5.4 requires two things:
|
||||
1. An NSEC record whose gap covers the queried name
|
||||
2. An NSEC record proving no wildcard exists at the closest encloser
|
||||
|
||||
The canonical DNS name ordering (RFC 4034 §6.1) compares labels right-to-left, case-insensitive. `a.example.com` < `b.example.com` because at the `example.com` level they're equal, then `a` < `b`. But `z.example.com` < `a.example.org` because `.com` < `.org` at the TLD level.
|
||||
|
||||
### NSEC3
|
||||
|
||||
NSEC3 solves NSEC's zone enumeration problem — with NSEC, you can walk the chain and discover every name in the zone. NSEC3 hashes the names first (iterated SHA-1 with a salt), so the NSEC3 chain reveals hashes, not names.
|
||||
|
||||
The proof is a 3-part closest encloser proof (RFC 5155 §8.4): find an ancestor whose hash matches an NSEC3 owner, prove the next-closer name falls within a hash range gap, and prove the wildcard at the closest encloser also falls within a gap. All three must hold, or the denial is rejected.
|
||||
|
||||
I cap NSEC3 iterations at 500 (RFC 9276 recommends 0). Higher iteration counts are a DoS vector — each verification requires `iterations + 1` SHA-1 hashes.
|
||||
|
||||
## Making it fast
|
||||
|
||||
Cold-cache DNSSEC validation initially required ~5 network fetches per query (DNSKEY for each zone in the chain, plus DS records). Three optimizations brought this down to ~1:
|
||||
|
||||
**TLD priming** (startup) — fetch root DNSKEY + each TLD's NS/DS/DNSKEY. After priming, the trust chain from root to any `.com` zone is fully cached.
|
||||
|
||||
**Referral DS piggybacking** — when a TLD server refers you to `cloudflare.com`'s nameservers, the authority section often includes DS records for the child zone. Cache them during resolution instead of fetching separately during validation.
|
||||
|
||||
**DNSKEY prefetch** — before the validation loop, scan all RRSIGs for signer zones and batch-fetch any missing DNSKEYs. This avoids serial DNSKEY fetches inside the per-RRset verification loop.
|
||||
|
||||
Result: a cold-cache query for `cloudflare.com` with full DNSSEC validation takes ~90ms. The TLD chain is already warm; only one DNSKEY fetch is needed (for `cloudflare.com` itself).
|
||||
|
||||
| Operation | Time |
|
||||
|---|---|
|
||||
| ECDSA P-256 verify | 174 ns |
|
||||
| Ed25519 verify | ~200 ns |
|
||||
| RSA/SHA-256 verify | 10.9 µs |
|
||||
| DS digest (SHA-256) | 257 ns |
|
||||
| Key tag computation | 20–63 ns |
|
||||
| Cold-cache validation (1 fetch) | ~90 ms |
|
||||
|
||||
The network fetch dominates. The crypto is noise.
|
||||
|
||||
## Surviving hostile networks
|
||||
|
||||
I deployed Numa as my system DNS and switched to a different network. Everything broke. Every query: SERVFAIL, 3-second timeout.
|
||||
|
||||
The network probe told the story: the ISP blocks outbound UDP port 53 to all servers except a handful of whitelisted public resolvers (Google, Cloudflare). Root servers, TLD servers, authoritative servers — all unreachable over UDP. The ISP forces you onto their DNS or a blessed upstream. Recursive resolution is impossible.
|
||||
|
||||
Except TCP port 53 worked fine. And every DNS server is required to support TCP (RFC 1035 section 4.2.2). The ISP apparently only filters UDP.
|
||||
|
||||
The fix has three parts:
|
||||
|
||||
**TCP fallback.** Every outbound query tries UDP first (800ms timeout). If UDP fails or the response is truncated, retry immediately over TCP. TCP uses a 2-byte length prefix before the DNS message — trivial to implement, and it handles DNSSEC responses that exceed the UDP payload limit.
|
||||
|
||||
**UDP auto-disable.** After 3 consecutive UDP failures, flip a global `AtomicBool` and skip UDP entirely — go TCP-first for all queries. This avoids burning 800ms per hop on a network where UDP will never work. The flag resets when the network changes (detected via LAN IP monitoring).
|
||||
|
||||
**Query minimization (RFC 7816).** When querying root servers, send only the TLD — `com` instead of `secret-project.example.com`. Root servers handle trillions of queries and are operated by 12 organizations. Minimization reduces what they learn from yours.
|
||||
|
||||
The result: on a network that blocks UDP:53, Numa detects the block within the first 3 queries, switches to TCP, and resolves normally at 300-500ms per cold query. Cached queries remain 0ms. No manual config change needed — switch networks and it adapts.
|
||||
|
||||
I wouldn't have found this without dogfooding. The code worked perfectly on my home network. It took a real hostile network to expose the assumption that UDP always works.
|
||||
|
||||
## What I learned
|
||||
|
||||
**DNSSEC is a verification system, not an encryption system.** It proves authenticity — this record was signed by the zone owner. It doesn't hide what you're querying. For privacy, you still need encrypted transport (DoH/DoT) or recursive resolution (no single upstream).
|
||||
|
||||
**The hardest bugs are in data serialization, not crypto.** `ring` either verifies or it doesn't — a binary answer. But getting the signed data blob exactly right (correct TTL, correct case, correct sort, correct RDATA encoding for each record type) requires extreme precision. A single wrong byte means verification fails with no hint about what's wrong.
|
||||
|
||||
**Negative proofs are harder than positive proofs.** Verifying a record exists: verify one RRSIG. Proving a record doesn't exist: find the right NSEC/NSEC3 records, verify their RRSIGs, check gap coverage, check wildcard denial, compute hashes. The NSEC3 closest encloser proof alone has three sub-proofs, each requiring hash computation and range checking.
|
||||
|
||||
**Performance optimization is about avoiding network, not avoiding CPU.** The crypto takes nanoseconds to microseconds. The network fetch takes tens of milliseconds. Every optimization that matters — TLD priming, DS piggybacking, DNSKEY prefetch — is about eliminating a round trip, not speeding up a hash.
|
||||
|
||||
## What's next
|
||||
|
||||
- **[pkarr](https://github.com/pubky/pkarr) integration** — self-sovereign DNS via the Mainline BitTorrent DHT. Your Ed25519 key is your domain. No registrar, no ICANN.
|
||||
- **DoT (DNS-over-TLS)** — the last encrypted transport we don't support
|
||||
|
||||
The code is at [github.com/razvandimescu/numa](https://github.com/razvandimescu/numa) — the DNSSEC validation is in [`src/dnssec.rs`](https://github.com/razvandimescu/numa/blob/main/src/dnssec.rs) and the recursive resolver in [`src/recursive.rs`](https://github.com/razvandimescu/numa/blob/main/src/recursive.rs). MIT license.
|
||||
36
numa.toml
36
numa.toml
@@ -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)
|
||||
|
||||
@@ -1,651 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>I Built a DNS Resolver from Scratch in Rust — Numa</title>
|
||||
<meta name="description" content="How DNS actually works at the wire
|
||||
level — label compression, TTL tricks, DoH, and what surprised me
|
||||
building a resolver with zero DNS libraries.">
|
||||
<link rel="stylesheet" href="/fonts/fonts.css">
|
||||
<style>
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--bg-deep: #f5f0e8;
|
||||
--bg-surface: #ece5da;
|
||||
--bg-elevated: #e3dbce;
|
||||
--bg-card: #faf7f2;
|
||||
--amber: #c0623a;
|
||||
--amber-dim: #9e4e2d;
|
||||
--teal: #6b7c4e;
|
||||
--teal-dim: #566540;
|
||||
--violet: #64748b;
|
||||
--text-primary: #2c2418;
|
||||
--text-secondary: #6b5e4f;
|
||||
--text-dim: #a39888;
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--border-amber: rgba(192, 98, 58, 0.22);
|
||||
--font-display: 'Instrument Serif', Georgia, serif;
|
||||
--font-body: 'DM Sans', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
background: var(--bg-deep);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-weight: 400;
|
||||
line-height: 1.7;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.025'/%3E%3C/svg%3E");
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* --- Blog nav --- */
|
||||
.blog-nav {
|
||||
padding: 1.5rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.blog-nav a {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.blog-nav a:hover { color: var(--amber); }
|
||||
|
||||
.blog-nav .wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.blog-nav .wordmark:hover { color: var(--amber); }
|
||||
|
||||
.blog-nav .sep {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* --- Article --- */
|
||||
.article {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 2rem 6rem;
|
||||
}
|
||||
|
||||
.article-header {
|
||||
margin-bottom: 3rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.article-header h1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-size: clamp(2rem, 5vw, 3rem);
|
||||
line-height: 1.15;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.article-meta a {
|
||||
color: var(--amber);
|
||||
text-decoration: none;
|
||||
}
|
||||
.article-meta a:hover { text-decoration: underline; }
|
||||
|
||||
/* --- Prose --- */
|
||||
.article h2 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 1.8rem;
|
||||
line-height: 1.2;
|
||||
margin: 3rem 0 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.article h3 {
|
||||
font-family: var(--font-body);
|
||||
font-weight: 600;
|
||||
font-size: 1.2rem;
|
||||
margin: 2rem 0 0.75rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.article p {
|
||||
margin-bottom: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.article a {
|
||||
color: var(--amber);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(192, 98, 58, 0.3);
|
||||
text-underline-offset: 2px;
|
||||
transition: text-decoration-color 0.2s;
|
||||
}
|
||||
.article a:hover {
|
||||
text-decoration-color: var(--amber);
|
||||
}
|
||||
|
||||
.article strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.article ul, .article ol {
|
||||
margin-bottom: 1.25rem;
|
||||
padding-left: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.article li {
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.article blockquote {
|
||||
border-left: 3px solid var(--amber);
|
||||
padding: 0.75rem 1.25rem;
|
||||
margin: 1.5rem 0;
|
||||
background: rgba(192, 98, 58, 0.04);
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.article blockquote p {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* --- Code --- */
|
||||
.article code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.88em;
|
||||
background: var(--bg-elevated);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 3px;
|
||||
color: var(--amber-dim);
|
||||
}
|
||||
|
||||
.article pre {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
overflow-x: auto;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.article pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* --- Images --- */
|
||||
.article img {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* --- Tables --- */
|
||||
.article table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.5rem 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.article th {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
text-align: left;
|
||||
padding: 0.6rem 1rem;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.article td {
|
||||
padding: 0.6rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* --- Footer --- */
|
||||
.blog-footer {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
border-top: 1px solid var(--border);
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.blog-footer a {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
margin: 0 1rem;
|
||||
}
|
||||
.blog-footer a:hover { color: var(--amber); }
|
||||
|
||||
/* --- Responsive --- */
|
||||
@media (max-width: 640px) {
|
||||
.article { padding: 2rem 1.25rem 4rem; }
|
||||
.article pre { padding: 1rem; margin-left: -0.5rem; margin-right: -0.5rem; border-radius: 0; border-left: none; border-right: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="blog-nav">
|
||||
<a href="/" class="wordmark">Numa</a>
|
||||
<span class="sep">/</span>
|
||||
<a href="/blog/">Blog</a>
|
||||
</nav>
|
||||
|
||||
<article class="article">
|
||||
<header class="article-header">
|
||||
<h1>I Built a DNS Resolver from Scratch in Rust</h1>
|
||||
<div class="article-meta">
|
||||
March 2026 · <a href="https://dimescu.ro">Razvan Dimescu</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p>I wanted to understand how DNS actually works. Not the “it translates
|
||||
domain names to IP addresses” explanation — the actual bytes on the
|
||||
wire. What does a DNS packet look like? How does label compression work?
|
||||
Why is everything crammed into 512 bytes?</p>
|
||||
<p>So I built one from scratch in Rust. No <code>hickory-dns</code>, no
|
||||
<code>trust-dns</code>, no <code>simple-dns</code>. The entire RFC 1035
|
||||
wire protocol — headers, labels, compression pointers, record types —
|
||||
parsed and serialized by hand. It started as a weekend learning project,
|
||||
became a side project I kept coming back to over 6 years, and eventually
|
||||
turned into <a href="https://github.com/razvandimescu/numa">Numa</a> —
|
||||
which I now use as my actual system DNS.</p>
|
||||
<p>A note on terminology before we go further: Numa is currently a
|
||||
<em>forwarding</em> resolver — it parses and caches DNS packets, but
|
||||
forwards queries to an upstream (Quad9, Cloudflare, or any DoH provider)
|
||||
rather than walking the delegation chain from root servers itself. Think
|
||||
of it as a smart proxy that does useful things with your DNS traffic
|
||||
locally (caching, ad blocking, overrides, local service domains) before
|
||||
forwarding what it can’t answer. Full recursive resolution — where Numa
|
||||
talks directly to root and authoritative nameservers — is on the
|
||||
roadmap, along with DNSSEC validation.</p>
|
||||
<p>Here’s what surprised me along the way.</p>
|
||||
<h2 id="what-does-a-dns-packet-actually-look-like">What does a DNS
|
||||
packet actually look like?</h2>
|
||||
<p>You can see a real one yourself. Run this:</p>
|
||||
<div class="sourceCode" id="cb1"><pre
|
||||
class="sourceCode bash"><code class="sourceCode bash"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="ex">dig</span> @127.0.0.1 example.com A +noedns</span></code></pre></div>
|
||||
<pre><code>;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 15242
|
||||
;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 0
|
||||
|
||||
;; QUESTION SECTION:
|
||||
;example.com. IN A
|
||||
|
||||
;; ANSWER SECTION:
|
||||
example.com. 53 IN A 104.18.27.120
|
||||
example.com. 53 IN A 104.18.26.120</code></pre>
|
||||
<p>That’s the human-readable version. But what’s actually on the wire? A
|
||||
DNS query for <code>example.com A</code> is just 29 bytes:</p>
|
||||
<pre><code> ID Flags QCount ACount NSCount ARCount
|
||||
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
|
||||
Header: AB CD 01 00 00 01 00 00 00 00 00 00
|
||||
└────┘ └────┘ └────┘ └────┘ └────┘ └────┘
|
||||
↑ ↑ ↑
|
||||
│ │ └─ 1 question, 0 answers, 0 authority, 0 additional
|
||||
│ └─ Standard query, recursion desired
|
||||
└─ Random ID (we'll match this in the response)
|
||||
|
||||
Question: 07 65 78 61 6D 70 6C 65 03 63 6F 6D 00 00 01 00 01
|
||||
── ───────────────────── ── ───────── ── ───── ─────
|
||||
7 e x a m p l e 3 c o m end A IN
|
||||
↑ ↑ ↑
|
||||
└─ length prefix └─ length └─ root label (end of name)</code></pre>
|
||||
<p>12 bytes of header + 17 bytes of question = 29 bytes to ask “what’s
|
||||
the IP for example.com?” Compare that to an HTTP request for the same
|
||||
information — you’d need hundreds of bytes just for headers.</p>
|
||||
<p>We can send exactly those bytes and capture what comes back:</p>
|
||||
<div class="sourceCode" id="cb4"><pre
|
||||
class="sourceCode python"><code class="sourceCode python"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a>python3 <span class="op">-</span>c <span class="st">"</span></span>
|
||||
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a><span class="er">import socket</span></span>
|
||||
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a><span class="co"># Hand-craft a DNS query: header (12 bytes) + question (17 bytes)</span></span>
|
||||
<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a>q <span class="op">=</span> <span class="st">b'</span><span class="ch">\xab\xcd\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00</span><span class="st">'</span> <span class="co"># header</span></span>
|
||||
<span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a>q <span class="op">+=</span> <span class="st">b'</span><span class="ch">\x07</span><span class="st">example</span><span class="ch">\x03</span><span class="st">com</span><span class="ch">\x00\x00\x01\x00\x01</span><span class="st">'</span> <span class="co"># question</span></span>
|
||||
<span id="cb4-6"><a href="#cb4-6" aria-hidden="true" tabindex="-1"></a>s <span class="op">=</span> socket.socket(socket.AF_INET, socket.SOCK_DGRAM)</span>
|
||||
<span id="cb4-7"><a href="#cb4-7" aria-hidden="true" tabindex="-1"></a>s.sendto(q, (<span class="st">'127.0.0.1'</span>, <span class="dv">53</span>))</span>
|
||||
<span id="cb4-8"><a href="#cb4-8" aria-hidden="true" tabindex="-1"></a>resp <span class="op">=</span> s.recv(<span class="dv">512</span>)</span>
|
||||
<span id="cb4-9"><a href="#cb4-9" aria-hidden="true" tabindex="-1"></a><span class="cf">for</span> i <span class="kw">in</span> <span class="bu">range</span>(<span class="dv">0</span>, <span class="bu">len</span>(resp), <span class="dv">16</span>):</span>
|
||||
<span id="cb4-10"><a href="#cb4-10" aria-hidden="true" tabindex="-1"></a> h <span class="op">=</span> <span class="st">' '</span>.join(<span class="ss">f'</span><span class="sc">{</span>b<span class="sc">:02x}</span><span class="ss">'</span> <span class="cf">for</span> b <span class="kw">in</span> resp[i:i<span class="op">+</span><span class="dv">16</span>])</span>
|
||||
<span id="cb4-11"><a href="#cb4-11" aria-hidden="true" tabindex="-1"></a> a <span class="op">=</span> <span class="st">''</span>.join(<span class="bu">chr</span>(b) <span class="cf">if</span> <span class="dv">32</span><span class="op"><=</span>b<span class="op"><</span><span class="dv">127</span> <span class="cf">else</span> <span class="st">'.'</span> <span class="cf">for</span> b <span class="kw">in</span> resp[i:i<span class="op">+</span><span class="dv">16</span>])</span>
|
||||
<span id="cb4-12"><a href="#cb4-12" aria-hidden="true" tabindex="-1"></a> <span class="bu">print</span>(<span class="ss">f'</span><span class="sc">{</span>i<span class="sc">:08x}</span><span class="ss"> </span><span class="sc">{</span>h<span class="sc">:<48s}</span><span class="ss"> </span><span class="sc">{</span>a<span class="sc">}</span><span class="ss">'</span>)</span>
|
||||
<span id="cb4-13"><a href="#cb4-13" aria-hidden="true" tabindex="-1"></a><span class="co">"</span></span></code></pre></div>
|
||||
<pre><code>00000000 ab cd 81 80 00 01 00 02 00 00 00 00 07 65 78 61 .............exa
|
||||
00000010 6d 70 6c 65 03 63 6f 6d 00 00 01 00 01 07 65 78 mple.com......ex
|
||||
00000020 61 6d 70 6c 65 03 63 6f 6d 00 00 01 00 01 00 00 ample.com.......
|
||||
00000030 00 19 00 04 68 12 1b 78 07 65 78 61 6d 70 6c 65 ....h..x.example
|
||||
00000040 03 63 6f 6d 00 00 01 00 01 00 00 00 19 00 04 68 .com...........h
|
||||
00000050 12 1a 78 ..x</code></pre>
|
||||
<p>83 bytes back. Let’s annotate the response:</p>
|
||||
<pre><code> ID Flags QCount ACount NSCount ARCount
|
||||
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
|
||||
Header: AB CD 81 80 00 01 00 02 00 00 00 00
|
||||
└────┘ └────┘ └────┘ └────┘ └────┘ └────┘
|
||||
↑ ↑ ↑ ↑
|
||||
│ │ │ └─ 2 answers
|
||||
│ │ └─ 1 question (echoed back)
|
||||
│ └─ Response flag set, recursion available
|
||||
└─ Same ID as our query
|
||||
|
||||
Question: 07 65 78 61 6D 70 6C 65 03 63 6F 6D 00 00 01 00 01
|
||||
(same as our query — echoed back)
|
||||
|
||||
Answer 1: 07 65 78 61 6D 70 6C 65 03 63 6F 6D 00 00 01 00 01
|
||||
───────────────────────────────────── ── ───── ─────
|
||||
e x a m p l e . c o m end A IN
|
||||
|
||||
00 00 00 19 00 04 68 12 1B 78
|
||||
─────────── ───── ───────────
|
||||
TTL: 25s len:4 104.18.27.120
|
||||
|
||||
Answer 2: (same domain repeated) 00 01 00 01 00 00 00 19 00 04 68 12 1A 78
|
||||
───────────
|
||||
104.18.26.120</code></pre>
|
||||
<p>Notice something wasteful? The domain <code>example.com</code>
|
||||
appears <em>three times</em> — once in the question, twice in the
|
||||
answers. That’s 39 bytes of repeated names in an 83-byte packet. DNS has
|
||||
a solution for this — but first, the overall structure.</p>
|
||||
<p>The whole thing fits in a single UDP datagram. The structure is:</p>
|
||||
<pre><code>+--+--+--+--+--+--+--+--+
|
||||
| Header | 12 bytes: ID, flags, counts
|
||||
+--+--+--+--+--+--+--+--+
|
||||
| Questions | What you're asking
|
||||
+--+--+--+--+--+--+--+--+
|
||||
| Answers | The response records
|
||||
+--+--+--+--+--+--+--+--+
|
||||
| Authorities | NS records for the zone
|
||||
+--+--+--+--+--+--+--+--+
|
||||
| Additional | Extra helpful records
|
||||
+--+--+--+--+--+--+--+--+</code></pre>
|
||||
<p>In Rust, parsing the header is just reading 12 bytes and unpacking
|
||||
the flags:</p>
|
||||
<div class="sourceCode" id="cb8"><pre
|
||||
class="sourceCode rust"><code class="sourceCode rust"><span id="cb8-1"><a href="#cb8-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">fn</span> read(buffer<span class="op">:</span> <span class="op">&</span><span class="kw">mut</span> BytePacketBuffer) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>DnsHeader<span class="op">></span> <span class="op">{</span></span>
|
||||
<span id="cb8-2"><a href="#cb8-2" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> id <span class="op">=</span> buffer<span class="op">.</span>read_u16()<span class="op">?;</span></span>
|
||||
<span id="cb8-3"><a href="#cb8-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> flags <span class="op">=</span> buffer<span class="op">.</span>read_u16()<span class="op">?;</span></span>
|
||||
<span id="cb8-4"><a href="#cb8-4" aria-hidden="true" tabindex="-1"></a> <span class="co">// Flags pack 9 fields into 16 bits</span></span>
|
||||
<span id="cb8-5"><a href="#cb8-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> recursion_desired <span class="op">=</span> (flags <span class="op">&</span> (<span class="dv">1</span> <span class="op"><<</span> <span class="dv">8</span>)) <span class="op">></span> <span class="dv">0</span><span class="op">;</span></span>
|
||||
<span id="cb8-6"><a href="#cb8-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> truncated_message <span class="op">=</span> (flags <span class="op">&</span> (<span class="dv">1</span> <span class="op"><<</span> <span class="dv">9</span>)) <span class="op">></span> <span class="dv">0</span><span class="op">;</span></span>
|
||||
<span id="cb8-7"><a href="#cb8-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> authoritative_answer <span class="op">=</span> (flags <span class="op">&</span> (<span class="dv">1</span> <span class="op"><<</span> <span class="dv">10</span>)) <span class="op">></span> <span class="dv">0</span><span class="op">;</span></span>
|
||||
<span id="cb8-8"><a href="#cb8-8" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> opcode <span class="op">=</span> (flags <span class="op">>></span> <span class="dv">11</span>) <span class="op">&</span> <span class="dv">0x0F</span><span class="op">;</span></span>
|
||||
<span id="cb8-9"><a href="#cb8-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> response <span class="op">=</span> (flags <span class="op">&</span> (<span class="dv">1</span> <span class="op"><<</span> <span class="dv">15</span>)) <span class="op">></span> <span class="dv">0</span><span class="op">;</span></span>
|
||||
<span id="cb8-10"><a href="#cb8-10" aria-hidden="true" tabindex="-1"></a> <span class="co">// ... and so on</span></span>
|
||||
<span id="cb8-11"><a href="#cb8-11" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code></pre></div>
|
||||
<p>No padding, no alignment, no JSON overhead. DNS was designed in 1987
|
||||
when every byte counted, and honestly? The wire format is kind of
|
||||
beautiful in its efficiency.</p>
|
||||
<h2 id="label-compression-is-the-clever-part">Label compression is the
|
||||
clever part</h2>
|
||||
<p>Remember how <code>example.com</code> appeared three times in that
|
||||
83-byte response? Domain names in DNS are stored as a sequence of
|
||||
<strong>labels</strong> — length-prefixed segments:</p>
|
||||
<pre><code>example.com → [7]example[3]com[0]</code></pre>
|
||||
<p>The <code>[7]</code> means “the next 7 bytes are a label.” The
|
||||
<code>[0]</code> is the root label (end of name). That’s 13 bytes per
|
||||
occurrence, 39 bytes for three repetitions. In a response with authority
|
||||
and additional records, domain names can account for half the
|
||||
packet.</p>
|
||||
<p>DNS solves this with <strong>compression pointers</strong> — if the
|
||||
top two bits of a length byte are <code>11</code>, the remaining 14 bits
|
||||
are an offset back into the packet where the rest of the name can be
|
||||
found. A well-compressed version of our response would replace the
|
||||
answer names with <code>C0 0C</code> — a 2-byte pointer to offset 12
|
||||
where <code>example.com</code> first appears in the question section.
|
||||
That turns 39 bytes of names into 15 (13 + 2 + 2). Our upstream didn’t
|
||||
bother compressing, but many do — especially when related domains
|
||||
appear:</p>
|
||||
<pre><code>Offset 0x20: [6]google[3]com[0] ← full name
|
||||
Offset 0x40: [4]mail[0xC0][0x20] ← "mail" + pointer to offset 0x20
|
||||
Offset 0x50: [3]www[0xC0][0x20] ← "www" + pointer to offset 0x20</code></pre>
|
||||
<p>Pointers can chain — a pointer can point to another pointer. Parsing
|
||||
this correctly requires tracking your position in the buffer and
|
||||
handling jumps:</p>
|
||||
<div class="sourceCode" id="cb11"><pre
|
||||
class="sourceCode rust"><code class="sourceCode rust"><span id="cb11-1"><a href="#cb11-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">fn</span> read_qname(<span class="op">&</span><span class="kw">mut</span> <span class="kw">self</span><span class="op">,</span> outstr<span class="op">:</span> <span class="op">&</span><span class="kw">mut</span> <span class="dt">String</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>()<span class="op">></span> <span class="op">{</span></span>
|
||||
<span id="cb11-2"><a href="#cb11-2" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> pos <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>pos()<span class="op">;</span></span>
|
||||
<span id="cb11-3"><a href="#cb11-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> jumped <span class="op">=</span> <span class="cn">false</span><span class="op">;</span></span>
|
||||
<span id="cb11-4"><a href="#cb11-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> delim <span class="op">=</span> <span class="st">""</span><span class="op">;</span></span>
|
||||
<span id="cb11-5"><a href="#cb11-5" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb11-6"><a href="#cb11-6" aria-hidden="true" tabindex="-1"></a> <span class="cf">loop</span> <span class="op">{</span></span>
|
||||
<span id="cb11-7"><a href="#cb11-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> len <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>get(pos)<span class="op">?;</span></span>
|
||||
<span id="cb11-8"><a href="#cb11-8" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb11-9"><a href="#cb11-9" aria-hidden="true" tabindex="-1"></a> <span class="co">// Top two bits set = compression pointer</span></span>
|
||||
<span id="cb11-10"><a href="#cb11-10" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (len <span class="op">&</span> <span class="dv">0xC0</span>) <span class="op">==</span> <span class="dv">0xC0</span> <span class="op">{</span></span>
|
||||
<span id="cb11-11"><a href="#cb11-11" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="op">!</span>jumped <span class="op">{</span></span>
|
||||
<span id="cb11-12"><a href="#cb11-12" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>seek(pos <span class="op">+</span> <span class="dv">2</span>)<span class="op">?;</span> <span class="co">// advance past the pointer</span></span>
|
||||
<span id="cb11-13"><a href="#cb11-13" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||
<span id="cb11-14"><a href="#cb11-14" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> offset <span class="op">=</span> (((len <span class="kw">as</span> <span class="dt">u16</span>) <span class="op">^</span> <span class="dv">0xC0</span>) <span class="op"><<</span> <span class="dv">8</span>) <span class="op">|</span> <span class="kw">self</span><span class="op">.</span>get(pos <span class="op">+</span> <span class="dv">1</span>)<span class="op">?</span> <span class="kw">as</span> <span class="dt">u16</span><span class="op">;</span></span>
|
||||
<span id="cb11-15"><a href="#cb11-15" aria-hidden="true" tabindex="-1"></a> pos <span class="op">=</span> offset <span class="kw">as</span> <span class="dt">usize</span><span class="op">;</span></span>
|
||||
<span id="cb11-16"><a href="#cb11-16" aria-hidden="true" tabindex="-1"></a> jumped <span class="op">=</span> <span class="cn">true</span><span class="op">;</span></span>
|
||||
<span id="cb11-17"><a href="#cb11-17" aria-hidden="true" tabindex="-1"></a> <span class="cf">continue</span><span class="op">;</span></span>
|
||||
<span id="cb11-18"><a href="#cb11-18" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||
<span id="cb11-19"><a href="#cb11-19" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb11-20"><a href="#cb11-20" aria-hidden="true" tabindex="-1"></a> pos <span class="op">+=</span> <span class="dv">1</span><span class="op">;</span></span>
|
||||
<span id="cb11-21"><a href="#cb11-21" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> len <span class="op">==</span> <span class="dv">0</span> <span class="op">{</span> <span class="cf">break</span><span class="op">;</span> <span class="op">}</span> <span class="co">// root label</span></span>
|
||||
<span id="cb11-22"><a href="#cb11-22" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb11-23"><a href="#cb11-23" aria-hidden="true" tabindex="-1"></a> outstr<span class="op">.</span>push_str(delim)<span class="op">;</span></span>
|
||||
<span id="cb11-24"><a href="#cb11-24" aria-hidden="true" tabindex="-1"></a> outstr<span class="op">.</span>push_str(<span class="op">&</span><span class="kw">self</span><span class="op">.</span>get_range(pos<span class="op">,</span> len <span class="kw">as</span> <span class="dt">usize</span>)<span class="op">?</span></span>
|
||||
<span id="cb11-25"><a href="#cb11-25" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>iter()<span class="op">.</span>map(<span class="op">|&</span>b<span class="op">|</span> b <span class="kw">as</span> <span class="dt">char</span>)<span class="op">.</span><span class="pp">collect::</span><span class="op"><</span><span class="dt">String</span><span class="op">></span>())<span class="op">;</span></span>
|
||||
<span id="cb11-26"><a href="#cb11-26" aria-hidden="true" tabindex="-1"></a> delim <span class="op">=</span> <span class="st">"."</span><span class="op">;</span></span>
|
||||
<span id="cb11-27"><a href="#cb11-27" aria-hidden="true" tabindex="-1"></a> pos <span class="op">+=</span> len <span class="kw">as</span> <span class="dt">usize</span><span class="op">;</span></span>
|
||||
<span id="cb11-28"><a href="#cb11-28" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||
<span id="cb11-29"><a href="#cb11-29" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb11-30"><a href="#cb11-30" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="op">!</span>jumped <span class="op">{</span></span>
|
||||
<span id="cb11-31"><a href="#cb11-31" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>seek(pos)<span class="op">?;</span></span>
|
||||
<span id="cb11-32"><a href="#cb11-32" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||
<span id="cb11-33"><a href="#cb11-33" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(())</span>
|
||||
<span id="cb11-34"><a href="#cb11-34" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code></pre></div>
|
||||
<p>This one bit me: when you follow a pointer, you must <em>not</em>
|
||||
advance the buffer’s read position past where you jumped from. The
|
||||
pointer is 2 bytes, so you advance by 2, but the actual label data lives
|
||||
elsewhere in the packet. If you follow the pointer and also advance past
|
||||
it, you’ll skip over the next record entirely. I spent a fun evening
|
||||
debugging that one.</p>
|
||||
<h2 id="ttl-adjustment-on-read-not-write">TTL adjustment on read, not
|
||||
write</h2>
|
||||
<p>This is my favorite trick in the whole codebase. I initially stored
|
||||
the remaining TTL and decremented it, which meant I needed a background
|
||||
thread to sweep expired entries. It worked, but it felt wrong — too much
|
||||
machinery for something simple.</p>
|
||||
<p>The cleaner approach: store the original TTL and the timestamp when
|
||||
the record was cached. On read, compute
|
||||
<code>remaining = original_ttl - elapsed</code>. If it’s zero or
|
||||
negative, the entry is stale — evict it lazily.</p>
|
||||
<div class="sourceCode" id="cb12"><pre
|
||||
class="sourceCode rust"><code class="sourceCode rust"><span id="cb12-1"><a href="#cb12-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">fn</span> lookup(<span class="op">&</span><span class="kw">mut</span> <span class="kw">self</span><span class="op">,</span> domain<span class="op">:</span> <span class="op">&</span><span class="dt">str</span><span class="op">,</span> qtype<span class="op">:</span> QueryType) <span class="op">-></span> <span class="dt">Option</span><span class="op"><</span>DnsPacket<span class="op">></span> <span class="op">{</span></span>
|
||||
<span id="cb12-2"><a href="#cb12-2" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> key <span class="op">=</span> (domain<span class="op">.</span>to_lowercase()<span class="op">,</span> qtype)<span class="op">;</span></span>
|
||||
<span id="cb12-3"><a href="#cb12-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> entry <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>entries<span class="op">.</span>get(<span class="op">&</span>key)<span class="op">?;</span></span>
|
||||
<span id="cb12-4"><a href="#cb12-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> elapsed <span class="op">=</span> entry<span class="op">.</span>cached_at<span class="op">.</span>elapsed()<span class="op">.</span>as_secs() <span class="kw">as</span> <span class="dt">u32</span><span class="op">;</span></span>
|
||||
<span id="cb12-5"><a href="#cb12-5" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb12-6"><a href="#cb12-6" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> elapsed <span class="op">>=</span> entry<span class="op">.</span>original_ttl <span class="op">{</span></span>
|
||||
<span id="cb12-7"><a href="#cb12-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>entries<span class="op">.</span>remove(<span class="op">&</span>key)<span class="op">;</span></span>
|
||||
<span id="cb12-8"><a href="#cb12-8" aria-hidden="true" tabindex="-1"></a> <span class="cf">return</span> <span class="cn">None</span><span class="op">;</span></span>
|
||||
<span id="cb12-9"><a href="#cb12-9" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||
<span id="cb12-10"><a href="#cb12-10" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb12-11"><a href="#cb12-11" aria-hidden="true" tabindex="-1"></a> <span class="co">// Adjust TTLs in the response to reflect remaining time</span></span>
|
||||
<span id="cb12-12"><a href="#cb12-12" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> packet <span class="op">=</span> entry<span class="op">.</span>packet<span class="op">.</span>clone()<span class="op">;</span></span>
|
||||
<span id="cb12-13"><a href="#cb12-13" aria-hidden="true" tabindex="-1"></a> <span class="cf">for</span> answer <span class="kw">in</span> <span class="op">&</span><span class="kw">mut</span> packet<span class="op">.</span>answers <span class="op">{</span></span>
|
||||
<span id="cb12-14"><a href="#cb12-14" aria-hidden="true" tabindex="-1"></a> answer<span class="op">.</span>set_ttl(entry<span class="op">.</span>original_ttl<span class="op">.</span>saturating_sub(elapsed))<span class="op">;</span></span>
|
||||
<span id="cb12-15"><a href="#cb12-15" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||
<span id="cb12-16"><a href="#cb12-16" aria-hidden="true" tabindex="-1"></a> <span class="cn">Some</span>(packet)</span>
|
||||
<span id="cb12-17"><a href="#cb12-17" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code></pre></div>
|
||||
<p>No background thread. No timer. Entries expire lazily. The cache
|
||||
stays consistent because every consumer sees the adjusted TTL.</p>
|
||||
<h2 id="the-resolution-pipeline">The resolution pipeline</h2>
|
||||
<p>Each incoming UDP packet spawns a tokio task. Each task walks a
|
||||
deterministic pipeline — every step either answers or passes to the
|
||||
next:</p>
|
||||
<pre><code> ┌─────────────────────────────────────────────────────┐
|
||||
│ Numa Resolution Pipeline │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
|
||||
Query ──→ Overrides ──→ .numa TLD ──→ Blocklist ──→ Zones ──→ Cache ──→ DoH
|
||||
│ │ │ │ │ │ │
|
||||
│ │ match? │ match? │ blocked? │ match? │ hit? │
|
||||
│ ↓ ↓ ↓ ↓ ↓ ↓
|
||||
│ respond respond 0.0.0.0 respond respond forward
|
||||
│ (auto-reverts (reverse (ad gone) (static (TTL to upstream
|
||||
│ after N min) proxy+TLS) records) adjusted) (encrypted)
|
||||
│
|
||||
└──→ Each step either answers or passes to the next.</code></pre>
|
||||
<p>This is where “from scratch” pays off. Want conditional forwarding
|
||||
for Tailscale? Insert a step before the upstream. Want to override
|
||||
<code>api.example.com</code> for 5 minutes while debugging? Add an entry
|
||||
in the overrides step — it auto-expires. A DNS library would have hidden
|
||||
this pipeline behind an opaque <code>resolve()</code> call.</p>
|
||||
<h2 id="dns-over-https-the-wait-thats-it-moment">DNS-over-HTTPS: the
|
||||
“wait, that’s it?” moment</h2>
|
||||
<p>The most recent addition, and honestly the one that surprised me with
|
||||
how little code it needed. DoH (RFC 8484) is conceptually simple: take
|
||||
the exact same DNS wire-format packet you’d send over UDP, POST it to an
|
||||
HTTPS endpoint with <code>Content-Type: application/dns-message</code>,
|
||||
and parse the response the same way. Same bytes, different
|
||||
transport.</p>
|
||||
<div class="sourceCode" id="cb14"><pre
|
||||
class="sourceCode rust"><code class="sourceCode rust"><span id="cb14-1"><a href="#cb14-1" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> forward_doh(</span>
|
||||
<span id="cb14-2"><a href="#cb14-2" aria-hidden="true" tabindex="-1"></a> query<span class="op">:</span> <span class="op">&</span>DnsPacket<span class="op">,</span></span>
|
||||
<span id="cb14-3"><a href="#cb14-3" aria-hidden="true" tabindex="-1"></a> url<span class="op">:</span> <span class="op">&</span><span class="dt">str</span><span class="op">,</span></span>
|
||||
<span id="cb14-4"><a href="#cb14-4" aria-hidden="true" tabindex="-1"></a> client<span class="op">:</span> <span class="op">&</span><span class="pp">reqwest::</span>Client<span class="op">,</span></span>
|
||||
<span id="cb14-5"><a href="#cb14-5" aria-hidden="true" tabindex="-1"></a> timeout_duration<span class="op">:</span> Duration<span class="op">,</span></span>
|
||||
<span id="cb14-6"><a href="#cb14-6" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>DnsPacket<span class="op">></span> <span class="op">{</span></span>
|
||||
<span id="cb14-7"><a href="#cb14-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> send_buffer <span class="op">=</span> <span class="pp">BytePacketBuffer::</span>new()<span class="op">;</span></span>
|
||||
<span id="cb14-8"><a href="#cb14-8" aria-hidden="true" tabindex="-1"></a> query<span class="op">.</span>write(<span class="op">&</span><span class="kw">mut</span> send_buffer)<span class="op">?;</span></span>
|
||||
<span id="cb14-9"><a href="#cb14-9" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb14-10"><a href="#cb14-10" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> resp <span class="op">=</span> timeout(timeout_duration<span class="op">,</span> client</span>
|
||||
<span id="cb14-11"><a href="#cb14-11" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>post(url)</span>
|
||||
<span id="cb14-12"><a href="#cb14-12" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>header(<span class="st">"content-type"</span><span class="op">,</span> <span class="st">"application/dns-message"</span>)</span>
|
||||
<span id="cb14-13"><a href="#cb14-13" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>header(<span class="st">"accept"</span><span class="op">,</span> <span class="st">"application/dns-message"</span>)</span>
|
||||
<span id="cb14-14"><a href="#cb14-14" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>body(send_buffer<span class="op">.</span>filled()<span class="op">.</span>to_vec())</span>
|
||||
<span id="cb14-15"><a href="#cb14-15" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>send())</span>
|
||||
<span id="cb14-16"><a href="#cb14-16" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span><span class="op">??.</span>error_for_status()<span class="op">?;</span></span>
|
||||
<span id="cb14-17"><a href="#cb14-17" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb14-18"><a href="#cb14-18" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> bytes <span class="op">=</span> resp<span class="op">.</span>bytes()<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||
<span id="cb14-19"><a href="#cb14-19" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> recv_buffer <span class="op">=</span> <span class="pp">BytePacketBuffer::</span>from_bytes(<span class="op">&</span>bytes)<span class="op">;</span></span>
|
||||
<span id="cb14-20"><a href="#cb14-20" aria-hidden="true" tabindex="-1"></a> <span class="pp">DnsPacket::</span>from_buffer(<span class="op">&</span><span class="kw">mut</span> recv_buffer)</span>
|
||||
<span id="cb14-21"><a href="#cb14-21" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code></pre></div>
|
||||
<p>The one gotcha that cost me an hour: Quad9 and other DoH providers
|
||||
require HTTP/2. My first attempt used HTTP/1.1 and got a cryptic 400 Bad
|
||||
Request. Adding the <code>http2</code> feature to reqwest fixed it. The
|
||||
upside of HTTP/2? Connection multiplexing means subsequent queries reuse
|
||||
the TLS session — ~16ms vs ~50ms for the first query. Free
|
||||
performance.</p>
|
||||
<p>The <code>Upstream</code> enum dispatches between UDP and DoH based
|
||||
on the URL scheme:</p>
|
||||
<div class="sourceCode" id="cb15"><pre
|
||||
class="sourceCode rust"><code class="sourceCode rust"><span id="cb15-1"><a href="#cb15-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">enum</span> Upstream <span class="op">{</span></span>
|
||||
<span id="cb15-2"><a href="#cb15-2" aria-hidden="true" tabindex="-1"></a> Udp(SocketAddr)<span class="op">,</span></span>
|
||||
<span id="cb15-3"><a href="#cb15-3" aria-hidden="true" tabindex="-1"></a> Doh <span class="op">{</span> url<span class="op">:</span> <span class="dt">String</span><span class="op">,</span> client<span class="op">:</span> <span class="pp">reqwest::</span>Client <span class="op">},</span></span>
|
||||
<span id="cb15-4"><a href="#cb15-4" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code></pre></div>
|
||||
<p>If the configured address starts with <code>https://</code>, it’s
|
||||
DoH. Otherwise, plain UDP. Simple, no toggles.</p>
|
||||
<h2 id="why-not-just-use-dnsmasq-nginx-mkcert">“Why not just use dnsmasq
|
||||
+ nginx + mkcert?”</h2>
|
||||
<p>You absolutely can — those are mature, battle-tested tools. The
|
||||
difference is integration: with dnsmasq + nginx + mkcert, you’re
|
||||
configuring three tools with three config formats. Numa puts the DNS
|
||||
record, reverse proxy, and TLS cert behind one API call:</p>
|
||||
<div class="sourceCode" id="cb16"><pre
|
||||
class="sourceCode bash"><code class="sourceCode bash"><span id="cb16-1"><a href="#cb16-1" aria-hidden="true" tabindex="-1"></a><span class="ex">curl</span> <span class="at">-X</span> POST localhost:5380/services <span class="at">-d</span> <span class="st">'{"name":"frontend","target_port":5173}'</span></span></code></pre></div>
|
||||
<p>That creates the DNS entry, generates a TLS certificate, and starts
|
||||
proxying — including WebSocket upgrade for Vite HMR. One command, no
|
||||
config files. Having full control over the resolution pipeline is what
|
||||
makes auto-revert overrides and LAN discovery possible.</p>
|
||||
<h2 id="what-i-learned">What I learned</h2>
|
||||
<p><strong>DNS is a 40-year-old protocol that works remarkably
|
||||
well.</strong> The wire format is tight, the caching model is elegant,
|
||||
and the hierarchical delegation system has scaled to billions of queries
|
||||
per day. The things people complain about (DNSSEC complexity, lack of
|
||||
encryption) are extensions bolted on decades later, not flaws in the
|
||||
original design.</p>
|
||||
<p><strong>The hard parts aren’t where you’d expect.</strong> Parsing
|
||||
the wire protocol was straightforward (RFC 1035 is well-written). The
|
||||
hard parts were: browsers rejecting wildcard certs under single-label
|
||||
TLDs, macOS resolver quirks (<code>scutil</code> vs
|
||||
<code>/etc/resolv.conf</code>), and getting multiple processes to bind
|
||||
the same multicast port (<code>SO_REUSEPORT</code> on macOS,
|
||||
<code>SO_REUSEADDR</code> on Linux).</p>
|
||||
<p><strong>Learn the vocabulary before you show up.</strong> I initially
|
||||
called Numa a “DNS resolver” and got corrected — it’s a forwarding
|
||||
resolver. The distinction matters to people who work with DNS
|
||||
professionally, and being sloppy about it cost me credibility in my
|
||||
first community posts.</p>
|
||||
<h2 id="whats-next">What’s next</h2>
|
||||
<p>Numa is at v0.5.0 with DNS forwarding, caching, ad blocking,
|
||||
DNS-over-HTTPS, .numa local domains with auto TLS, and LAN service
|
||||
discovery.</p>
|
||||
<p>On the roadmap:</p>
|
||||
<ul>
|
||||
<li><strong>DoT (DNS-over-TLS)</strong> — DoH was first because it
|
||||
passes through captive portals and corporate firewalls (port 443 vs
|
||||
853). DoT has less framing overhead, so it’s faster. Both will be
|
||||
available.</li>
|
||||
<li><strong>Recursive resolution</strong> — walk the delegation chain
|
||||
from root servers instead of forwarding. Combined with DNSSEC
|
||||
validation, this removes the need to trust any upstream resolver.</li>
|
||||
<li><strong><a href="https://github.com/pubky/pkarr">pkarr</a>
|
||||
integration</strong> — self-sovereign DNS via the Mainline BitTorrent
|
||||
DHT. Publish DNS records signed with your Ed25519 key, no registrar
|
||||
needed.</li>
|
||||
</ul>
|
||||
<p>But those are rabbit holes for future posts.</p>
|
||||
<p><a
|
||||
href="https://github.com/razvandimescu/numa">github.com/razvandimescu/numa</a></p>
|
||||
</article>
|
||||
|
||||
<footer class="blog-footer">
|
||||
<a href="https://github.com/razvandimescu/numa">GitHub</a>
|
||||
<a href="/">Home</a>
|
||||
<a href="/blog/">Blog</a>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -168,7 +168,14 @@ body::before {
|
||||
<h1>Blog</h1>
|
||||
<ul class="post-list">
|
||||
<li>
|
||||
<a href="/blog/dns-from-scratch.html">
|
||||
<a href="/blog/posts/dnssec-from-scratch.html">
|
||||
<div class="post-title">Implementing DNSSEC from Scratch in Rust</div>
|
||||
<div class="post-desc">Recursive resolution from root hints, chain-of-trust validation, NSEC/NSEC3 denial proofs, and what I learned implementing DNSSEC with zero DNS libraries.</div>
|
||||
<div class="post-date">March 2026</div>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/blog/posts/dns-from-scratch.html">
|
||||
<div class="post-title">I Built a DNS Resolver from Scratch in Rust</div>
|
||||
<div class="post-desc">How DNS actually works at the wire level — label compression, TTL tricks, DoH implementation, and what I learned building a resolver with zero DNS libraries.</div>
|
||||
<div class="post-date">March 2026</div>
|
||||
|
||||
@@ -215,6 +215,7 @@ body {
|
||||
min-width: 2px;
|
||||
}
|
||||
.path-bar-fill.forward { background: var(--amber); }
|
||||
.path-bar-fill.recursive { background: var(--cyan); }
|
||||
.path-bar-fill.cached { background: var(--teal); }
|
||||
.path-bar-fill.local { background: var(--violet); }
|
||||
.path-bar-fill.override { background: var(--emerald); }
|
||||
@@ -278,6 +279,7 @@ body {
|
||||
font-weight: 500;
|
||||
}
|
||||
.path-tag.FORWARD { background: rgba(192, 98, 58, 0.12); color: var(--amber-dim); }
|
||||
.path-tag.RECURSIVE { background: rgba(74, 124, 138, 0.12); color: var(--cyan); }
|
||||
.path-tag.CACHED { background: rgba(107, 124, 78, 0.12); color: var(--teal-dim); }
|
||||
.path-tag.LOCAL { background: rgba(100, 116, 139, 0.12); color: var(--violet-dim); }
|
||||
.path-tag.OVERRIDE { background: rgba(82, 122, 82, 0.12); color: var(--emerald); }
|
||||
@@ -709,6 +711,7 @@ function formatRemaining(secs) {
|
||||
|
||||
const PATH_DEFS = [
|
||||
{ key: 'forwarded', label: 'Forward', cls: 'forward' },
|
||||
{ key: 'recursive', label: 'Recursive', cls: 'recursive' },
|
||||
{ key: 'cached', label: 'Cached', cls: 'cached' },
|
||||
{ key: 'local', label: 'Local', cls: 'local' },
|
||||
{ key: 'overridden', label: 'Override', cls: 'override' },
|
||||
@@ -766,7 +769,7 @@ function applyLogFilter() {
|
||||
<td>${e.query_type}</td>
|
||||
<td class="domain-cell" title="${e.domain}">${e.domain}${allowBtn}</td>
|
||||
<td><span class="path-tag ${e.path}">${e.path}</span></td>
|
||||
<td>${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>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
@@ -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 & Protect</h3>
|
||||
<h3>Resolve & Protect</h3>
|
||||
<ul>
|
||||
<li>Recursive resolution — resolve from root nameservers, no upstream needed</li>
|
||||
<li>DNSSEC validation — chain-of-trust + NSEC/NSEC3 denial proofs (RSA, ECDSA, Ed25519)</li>
|
||||
<li>Ad & tracker blocking — 385K+ domains, zero config</li>
|
||||
<li>DNS-over-HTTPS — encrypted upstream (Quad9, Cloudflare, any provider)</li>
|
||||
<li>DNS-over-HTTPS — encrypted upstream as alternative to recursive mode</li>
|
||||
<li>TTL-aware caching (sub-ms lookups)</li>
|
||||
<li>Single binary, portable — your DNS travels with you</li>
|
||||
<li>macOS, Linux, and Windows</li>
|
||||
<li>Single binary, portable — macOS, Linux, and Windows</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="layer-card reveal reveal-delay-2">
|
||||
@@ -1299,7 +1300,9 @@ footer .closing {
|
||||
<span class="pipeline-arrow">→</span>
|
||||
<div class="pipeline-node"><div class="pipeline-box">Cache</div></div>
|
||||
<span class="pipeline-arrow">→</span>
|
||||
<div class="pipeline-node"><div class="pipeline-box hl-violet">DoH Upstream</div></div>
|
||||
<div class="pipeline-node"><div class="pipeline-box hl-violet">Recursive / Forward (DoH)</div></div>
|
||||
<span class="pipeline-arrow">→</span>
|
||||
<div class="pipeline-node"><div class="pipeline-box highlight">DNSSEC Validate</div></div>
|
||||
<span class="pipeline-arrow">→</span>
|
||||
<div class="pipeline-node"><div class="pipeline-box hl-emerald">Respond</div></div>
|
||||
</div>
|
||||
@@ -1331,6 +1334,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 & tracker blocking</td>
|
||||
<td class="check">Yes</td>
|
||||
@@ -1516,6 +1527,14 @@ footer .closing {
|
||||
<div class="perf-stat-value amber">0 allocations</div>
|
||||
<div class="perf-stat-label">Heap allocations in the I/O path — 4KB stack buffers, inline serialization</div>
|
||||
</div>
|
||||
<div class="perf-stat">
|
||||
<div class="perf-stat-value teal">174 ns</div>
|
||||
<div class="perf-stat-label">ECDSA P-256 signature verification (DNSSEC). RSA/SHA-256: 10.9µs. DS digest: 257ns.</div>
|
||||
</div>
|
||||
<div class="perf-stat">
|
||||
<div class="perf-stat-value emerald">~90 ms</div>
|
||||
<div class="perf-stat-label">Cold-cache DNSSEC validation — only 1 network fetch needed (TLD chain pre-warmed on startup)</div>
|
||||
</div>
|
||||
|
||||
<p class="perf-note">
|
||||
Cold queries match system resolver speed — the bottleneck is upstream RTT, not Numa. We don't claim to be faster when the network is the limit.
|
||||
@@ -1545,17 +1564,20 @@ footer .closing {
|
||||
<dt>DNS Libraries</dt>
|
||||
<dd>Zero — wire protocol parsed from scratch</dd>
|
||||
|
||||
<dt>Resolution Modes</dt>
|
||||
<dd>Recursive (iterative from root hints, CNAME chasing, glue extraction) or Forward (DoH / plain UDP)</dd>
|
||||
|
||||
<dt>DNSSEC</dt>
|
||||
<dd>Chain-of-trust via ring — RSA/SHA-256, ECDSA P-256, Ed25519. NSEC/NSEC3 denial proofs. EDNS0 DO bit, 1232-byte payload (DNS Flag Day 2020).</dd>
|
||||
|
||||
<dt>Dependencies</dt>
|
||||
<dd>18 runtime crates — tokio, axum, hyper, reqwest (DoH), rcgen + rustls (TLS), socket2 (multicast), serde, and more</dd>
|
||||
<dd>19 runtime crates — tokio, axum, hyper, ring (DNSSEC), reqwest (DoH), rcgen + rustls (TLS), socket2 (multicast), serde, and more</dd>
|
||||
|
||||
<dt>Packet Format</dt>
|
||||
<dd>RFC 1035 compliant, 4096-byte UDP (EDNS)</dd>
|
||||
<dd>RFC 1035 compliant. EDNS0 OPT pseudo-record. Parses A, AAAA, NS, CNAME, MX, SOA, SRV, HTTPS, DNSKEY, DS, RRSIG, NSEC, NSEC3.</dd>
|
||||
|
||||
<dt>Concurrency</dt>
|
||||
<dd>Arc<ServerCtx> + RwLock for reads, Mutex for writes (never across .await)</dd>
|
||||
|
||||
<dt>Upstream</dt>
|
||||
<dd>DNS-over-HTTPS (DoH) via reqwest + http2 + rustls</dd>
|
||||
</dl>
|
||||
<div class="code-block reveal reveal-delay-2">
|
||||
<span class="comment"># Install (pick one)</span>
|
||||
@@ -1609,16 +1631,24 @@ footer .closing {
|
||||
<span class="phase">Phase 5</span>
|
||||
<span class="phase-desc">DNS-over-HTTPS — 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 — self-sovereign DNS via Mainline DHT, no registrar needed</span>
|
||||
<span class="phase-desc">Recursive resolution — 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 — self-publish, DHT-backed, first-come-first-served</span>
|
||||
<span class="phase-desc">DNSSEC validation — 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 — 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 — 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 — human-readable Tor naming via Ed25519 same-key binding</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
259
src/api.rs
259
src/api.rs
@@ -153,6 +153,7 @@ struct QueryLogResponse {
|
||||
path: String,
|
||||
rescode: String,
|
||||
latency_ms: f64,
|
||||
dnssec: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -178,6 +179,7 @@ struct LanStatsResponse {
|
||||
struct QueriesStats {
|
||||
total: u64,
|
||||
forwarded: u64,
|
||||
recursive: u64,
|
||||
cached: u64,
|
||||
local: u64,
|
||||
overridden: u64,
|
||||
@@ -460,6 +462,7 @@ async fn query_log(
|
||||
path: e.path.as_str().to_string(),
|
||||
rescode: e.rescode.as_str().to_string(),
|
||||
latency_ms: e.latency_us as f64 / 1000.0,
|
||||
dnssec: e.dnssec.as_str().to_string(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -477,7 +480,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 +494,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,
|
||||
@@ -901,3 +909,252 @@ async fn check_tcp(addr: std::net::SocketAddr) -> bool {
|
||||
.map(|r| r.is_ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use http::Request;
|
||||
use std::sync::{Mutex, RwLock};
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn test_ctx() -> Arc<ServerCtx> {
|
||||
let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
Arc::new(ServerCtx {
|
||||
socket,
|
||||
zone_map: std::collections::HashMap::new(),
|
||||
cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)),
|
||||
stats: Mutex::new(crate::stats::ServerStats::new()),
|
||||
overrides: RwLock::new(crate::override_store::OverrideStore::new()),
|
||||
blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()),
|
||||
query_log: Mutex::new(crate::query_log::QueryLog::new(100)),
|
||||
services: Mutex::new(crate::service_store::ServiceStore::new()),
|
||||
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
|
||||
forwarding_rules: Vec::new(),
|
||||
upstream: Mutex::new(crate::forward::Upstream::Udp(
|
||||
"127.0.0.1:53".parse().unwrap(),
|
||||
)),
|
||||
upstream_auto: false,
|
||||
upstream_port: 53,
|
||||
lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST),
|
||||
timeout: std::time::Duration::from_secs(3),
|
||||
proxy_tld: "numa".to_string(),
|
||||
proxy_tld_suffix: ".numa".to_string(),
|
||||
lan_enabled: false,
|
||||
config_path: "/tmp/test-numa.toml".to_string(),
|
||||
config_found: false,
|
||||
config_dir: std::path::PathBuf::from("/tmp"),
|
||||
data_dir: std::path::PathBuf::from("/tmp"),
|
||||
tls_config: None,
|
||||
upstream_mode: crate::config::UpstreamMode::Forward,
|
||||
root_hints: Vec::new(),
|
||||
dnssec_enabled: false,
|
||||
dnssec_strict: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_returns_ok() {
|
||||
let ctx = test_ctx().await;
|
||||
let resp = router(ctx)
|
||||
.oneshot(Request::get("/health").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body = axum::body::to_bytes(resp.into_body(), 1000).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["status"], "ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stats_returns_json() {
|
||||
let ctx = test_ctx().await;
|
||||
let resp = router(ctx)
|
||||
.oneshot(Request::get("/stats").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(json["uptime_secs"].is_number());
|
||||
assert!(json["queries"]["total"].is_number());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_log_empty() {
|
||||
let ctx = test_ctx().await;
|
||||
let resp = router(ctx)
|
||||
.oneshot(
|
||||
Request::get("/query-log?limit=10")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(json.as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overrides_crud() {
|
||||
let ctx = test_ctx().await;
|
||||
let a = router(ctx.clone());
|
||||
|
||||
// Create
|
||||
let resp = a
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::post("/overrides")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"domain":"test.dev","target":"1.2.3.4","duration_secs":60}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.status().is_success());
|
||||
|
||||
// List
|
||||
let resp = a
|
||||
.clone()
|
||||
.oneshot(Request::get("/overrides").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
|
||||
assert!(String::from_utf8_lossy(&body).contains("test.dev"));
|
||||
|
||||
// Get
|
||||
let resp = a
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::get("/overrides/test.dev")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// Delete
|
||||
let resp = a
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::delete("/overrides/test.dev")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.status().is_success());
|
||||
|
||||
// Verify deleted
|
||||
let resp = a
|
||||
.oneshot(
|
||||
Request::get("/overrides/test.dev")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_list_and_flush() {
|
||||
let ctx = test_ctx().await;
|
||||
let a = router(ctx.clone());
|
||||
|
||||
// List (empty)
|
||||
let resp = a
|
||||
.clone()
|
||||
.oneshot(Request::get("/cache").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// Flush
|
||||
let resp = a
|
||||
.oneshot(Request::delete("/cache").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.status().is_success());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocking_stats_returns_json() {
|
||||
let ctx = test_ctx().await;
|
||||
let resp = router(ctx)
|
||||
.oneshot(Request::get("/blocking/stats").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(json["enabled"].is_boolean());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn services_crud() {
|
||||
let ctx = test_ctx().await;
|
||||
let a = router(ctx);
|
||||
|
||||
// Add service
|
||||
let resp = a
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::post("/services")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"name":"testapp","target_port":3000}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.status().is_success());
|
||||
|
||||
// List
|
||||
let resp = a
|
||||
.clone()
|
||||
.oneshot(Request::get("/services").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
|
||||
assert!(String::from_utf8_lossy(&body).contains("testapp"));
|
||||
|
||||
// Delete
|
||||
let resp = a
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::delete("/services/testapp")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.status().is_success());
|
||||
|
||||
// Verify deleted
|
||||
let resp = a
|
||||
.oneshot(Request::get("/services").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
|
||||
assert!(!String::from_utf8_lossy(&body).contains("testapp"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_returns_html() {
|
||||
let ctx = test_ctx().await;
|
||||
let resp = router(ctx)
|
||||
.oneshot(Request::get("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body = axum::body::to_bytes(resp.into_body(), 100000)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&body).contains("Numa"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
42
src/cache.rs
42
src/cache.rs
@@ -5,10 +5,31 @@ 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,
|
||||
}
|
||||
|
||||
impl DnssecStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
DnssecStatus::Secure => "secure",
|
||||
DnssecStatus::Insecure => "insecure",
|
||||
DnssecStatus::Bogus => "bogus",
|
||||
DnssecStatus::Indeterminate => "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 +55,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 +79,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 +120,7 @@ impl DnsCache {
|
||||
packet: packet.clone(),
|
||||
inserted_at: Instant::now(),
|
||||
ttl: Duration::from_secs(min_ttl as u64),
|
||||
dnssec_status,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -88,7 +176,7 @@ fn default_upstream_port() -> u16 {
|
||||
53
|
||||
}
|
||||
fn default_timeout_ms() -> u64 {
|
||||
3000
|
||||
5000
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -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::*;
|
||||
|
||||
204
src/ctx.rs
204
src/ctx.rs
@@ -10,8 +10,8 @@ use tokio::net::UdpSocket;
|
||||
|
||||
use crate::blocklist::BlocklistStore;
|
||||
use crate::buffer::BytePacketBuffer;
|
||||
use crate::cache::DnsCache;
|
||||
use crate::config::ZoneMap;
|
||||
use crate::cache::{DnsCache, DnssecStatus};
|
||||
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(
|
||||
@@ -72,12 +77,32 @@ pub async fn handle_query(
|
||||
|
||||
// Pipeline: overrides -> .tld interception -> blocklist -> local zones -> cache -> upstream
|
||||
// Each lock is scoped to avoid holding MutexGuard across await points.
|
||||
let (response, path) = {
|
||||
let (response, path, dnssec) = {
|
||||
let override_record = ctx.overrides.read().unwrap().lookup(&qname);
|
||||
if let Some(record) = override_record {
|
||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||
resp.answers.push(record);
|
||||
(resp, QueryPath::Overridden)
|
||||
(resp, QueryPath::Overridden, DnssecStatus::Indeterminate)
|
||||
} else if qname == "localhost" || qname.ends_with(".localhost") {
|
||||
// RFC 6761: .localhost always resolves to loopback
|
||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||
match qtype {
|
||||
QueryType::AAAA => resp.answers.push(DnsRecord::AAAA {
|
||||
domain: qname.clone(),
|
||||
addr: std::net::Ipv6Addr::LOCALHOST,
|
||||
ttl: 300,
|
||||
}),
|
||||
_ => resp.answers.push(DnsRecord::A {
|
||||
domain: qname.clone(),
|
||||
addr: std::net::Ipv4Addr::LOCALHOST,
|
||||
ttl: 300,
|
||||
}),
|
||||
}
|
||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||
} else if is_special_use_domain(&qname) {
|
||||
// RFC 6761/8880: private PTR, DDR, NAT64 — answer locally
|
||||
let resp = special_use_response(&query, &qname, qtype);
|
||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||
} else if !ctx.proxy_tld_suffix.is_empty()
|
||||
&& (qname.ends_with(&ctx.proxy_tld_suffix) || qname == ctx.proxy_tld)
|
||||
{
|
||||
@@ -115,7 +140,7 @@ pub async fn handle_query(
|
||||
ttl: 300,
|
||||
}),
|
||||
}
|
||||
(resp, QueryPath::Local)
|
||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||
} else if ctx.blocklist.read().unwrap().is_blocked(&qname) {
|
||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||
match qtype {
|
||||
@@ -130,17 +155,43 @@ pub async fn handle_query(
|
||||
ttl: 60,
|
||||
}),
|
||||
}
|
||||
(resp, QueryPath::Blocked)
|
||||
(resp, QueryPath::Blocked, DnssecStatus::Indeterminate)
|
||||
} else if let Some(records) = ctx.zone_map.get(qname.as_str()).and_then(|m| m.get(&qtype)) {
|
||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||
resp.answers = records.clone();
|
||||
(resp, QueryPath::Local)
|
||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||
} 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;
|
||||
(resp, QueryPath::Cached)
|
||||
if cached_dnssec == DnssecStatus::Secure {
|
||||
resp.header.authed_data = true;
|
||||
}
|
||||
(resp, QueryPath::Cached, cached_dnssec)
|
||||
} else if ctx.upstream_mode == UpstreamMode::Recursive {
|
||||
match crate::recursive::resolve_recursive(
|
||||
&qname,
|
||||
qtype,
|
||||
&ctx.cache,
|
||||
&query,
|
||||
&ctx.root_hints,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => (resp, QueryPath::Recursive, DnssecStatus::Indeterminate),
|
||||
Err(e) => {
|
||||
error!(
|
||||
"{} | {:?} {} | RECURSIVE ERROR | {}",
|
||||
src_addr, qtype, qname, e
|
||||
);
|
||||
(
|
||||
DnsPacket::response_from(&query, ResultCode::SERVFAIL),
|
||||
QueryPath::UpstreamError,
|
||||
DnssecStatus::Indeterminate,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let upstream =
|
||||
match crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) {
|
||||
@@ -150,7 +201,7 @@ pub async fn handle_query(
|
||||
match forward_query(&query, &upstream, ctx.timeout).await {
|
||||
Ok(resp) => {
|
||||
ctx.cache.write().unwrap().insert(&qname, qtype, &resp);
|
||||
(resp, QueryPath::Forwarded)
|
||||
(resp, QueryPath::Forwarded, DnssecStatus::Indeterminate)
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
@@ -160,6 +211,7 @@ pub async fn handle_query(
|
||||
(
|
||||
DnsPacket::response_from(&query, ResultCode::SERVFAIL),
|
||||
QueryPath::UpstreamError,
|
||||
DnssecStatus::Indeterminate,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -167,6 +219,55 @@ 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)
|
||||
let mut dnssec = dnssec;
|
||||
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,
|
||||
);
|
||||
|
||||
dnssec = status;
|
||||
|
||||
if status == DnssecStatus::Secure {
|
||||
response.header.authed_data = true;
|
||||
}
|
||||
|
||||
if status == 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!(
|
||||
@@ -216,7 +317,88 @@ pub async fn handle_query(
|
||||
path,
|
||||
rescode: response.header.rescode,
|
||||
latency_us: elapsed.as_micros() as u64,
|
||||
dnssec,
|
||||
});
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
fn is_special_use_domain(qname: &str) -> bool {
|
||||
if qname.ends_with(".in-addr.arpa") {
|
||||
// RFC 6303: private + loopback + link-local reverse DNS
|
||||
if qname.ends_with(".10.in-addr.arpa")
|
||||
|| qname.ends_with(".168.192.in-addr.arpa")
|
||||
|| qname.ends_with(".127.in-addr.arpa")
|
||||
|| qname.ends_with(".254.169.in-addr.arpa")
|
||||
|| qname.ends_with(".0.in-addr.arpa")
|
||||
|| qname.contains("_dns-sd._udp")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// 172.16-31.x.x (RFC 1918) — extract second octet from reverse name
|
||||
if qname.ends_with(".172.in-addr.arpa") {
|
||||
if let Some(octet_str) = qname
|
||||
.strip_suffix(".172.in-addr.arpa")
|
||||
.and_then(|s| s.rsplit('.').next())
|
||||
{
|
||||
if let Ok(octet) = octet_str.parse::<u8>() {
|
||||
return (16..=31).contains(&octet);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// DDR (RFC 9462)
|
||||
if qname == "_dns.resolver.arpa" || qname.ends_with("._dns.resolver.arpa") {
|
||||
return true;
|
||||
}
|
||||
// NAT64 (RFC 8880)
|
||||
qname == "ipv4only.arpa"
|
||||
}
|
||||
|
||||
fn special_use_response(query: &DnsPacket, qname: &str, qtype: QueryType) -> DnsPacket {
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
if qname == "ipv4only.arpa" {
|
||||
// RFC 8880: well-known NAT64 addresses
|
||||
let mut resp = DnsPacket::response_from(query, ResultCode::NOERROR);
|
||||
let domain = qname.to_string();
|
||||
match qtype {
|
||||
QueryType::A => {
|
||||
resp.answers.push(DnsRecord::A {
|
||||
domain: domain.clone(),
|
||||
addr: Ipv4Addr::new(192, 0, 0, 170),
|
||||
ttl: 300,
|
||||
});
|
||||
resp.answers.push(DnsRecord::A {
|
||||
domain,
|
||||
addr: Ipv4Addr::new(192, 0, 0, 171),
|
||||
ttl: 300,
|
||||
});
|
||||
}
|
||||
QueryType::AAAA => {
|
||||
resp.answers.push(DnsRecord::AAAA {
|
||||
domain,
|
||||
addr: Ipv6Addr::new(0x0064, 0xff9b, 0, 0, 0, 0, 0xc000, 0x00aa),
|
||||
ttl: 300,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
resp
|
||||
} else {
|
||||
DnsPacket::response_from(query, ResultCode::NXDOMAIN)
|
||||
}
|
||||
}
|
||||
|
||||
1679
src/dnssec.rs
Normal file
1679
src/dnssec.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
@@ -74,6 +74,39 @@ async fn forward_udp(
|
||||
DnsPacket::from_buffer(&mut recv_buffer)
|
||||
}
|
||||
|
||||
/// DNS over TCP (RFC 1035 §4.2.2): 2-byte length prefix, then the DNS message.
|
||||
pub(crate) async fn forward_tcp(
|
||||
query: &DnsPacket,
|
||||
upstream: SocketAddr,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<DnsPacket> {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
let mut send_buffer = BytePacketBuffer::new();
|
||||
query.write(&mut send_buffer)?;
|
||||
let msg = send_buffer.filled();
|
||||
|
||||
let mut stream = timeout(timeout_duration, TcpStream::connect(upstream)).await??;
|
||||
|
||||
// Single write: Microsoft/Azure DNS servers close TCP connections on split segments
|
||||
let mut outbuf = Vec::with_capacity(2 + msg.len());
|
||||
outbuf.extend_from_slice(&(msg.len() as u16).to_be_bytes());
|
||||
outbuf.extend_from_slice(msg);
|
||||
stream.write_all(&outbuf).await?;
|
||||
|
||||
// Read length-prefixed response
|
||||
let mut len_buf = [0u8; 2];
|
||||
timeout(timeout_duration, stream.read_exact(&mut len_buf)).await??;
|
||||
let resp_len = u16::from_be_bytes(len_buf) as usize;
|
||||
|
||||
let mut data = vec![0u8; resp_len];
|
||||
timeout(timeout_duration, stream.read_exact(&mut data)).await??;
|
||||
|
||||
let mut recv_buffer = BytePacketBuffer::from_bytes(&data);
|
||||
DnsPacket::from_buffer(&mut recv_buffer)
|
||||
}
|
||||
|
||||
async fn forward_doh(
|
||||
query: &DnsPacket,
|
||||
url: &str,
|
||||
|
||||
@@ -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;
|
||||
|
||||
30
src/main.rs
30
src/main.rs
@@ -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()?;
|
||||
@@ -425,6 +447,7 @@ async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
|
||||
info!("LAN IP changed: {} → {}", current_ip, new_ip);
|
||||
*current_ip = new_ip;
|
||||
changed = true;
|
||||
numa::recursive::reset_udp_state();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,6 +480,11 @@ async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
499
src/packet.rs
499
src/packet.rs
@@ -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 {
|
||||
let rec = DnsRecord::read(buffer)?;
|
||||
result.resources.push(rec);
|
||||
// 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)?;
|
||||
}
|
||||
rec.write(buffer)?;
|
||||
}
|
||||
for rec in &self.authorities {
|
||||
if !rec.is_unknown() {
|
||||
rec.write(buffer)?;
|
||||
}
|
||||
rec.write(buffer)?;
|
||||
}
|
||||
for rec in &self.resources {
|
||||
if !rec.is_unknown() {
|
||||
rec.write(buffer)?;
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::collections::VecDeque;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use crate::cache::DnssecStatus;
|
||||
use crate::header::ResultCode;
|
||||
use crate::question::QueryType;
|
||||
use crate::stats::QueryPath;
|
||||
@@ -14,6 +15,7 @@ pub struct QueryLogEntry {
|
||||
pub path: QueryPath,
|
||||
pub rescode: ResultCode,
|
||||
pub latency_us: u64,
|
||||
pub dnssec: DnssecStatus,
|
||||
}
|
||||
|
||||
pub struct QueryLog {
|
||||
|
||||
@@ -4,16 +4,22 @@ use crate::Result;
|
||||
#[derive(PartialEq, Eq, Debug, Clone, Hash, Copy)]
|
||||
pub enum QueryType {
|
||||
UNKNOWN(u16),
|
||||
A, // 1
|
||||
NS, // 2
|
||||
CNAME, // 5
|
||||
SOA, // 6
|
||||
PTR, // 12
|
||||
MX, // 15
|
||||
TXT, // 16
|
||||
AAAA, // 28
|
||||
SRV, // 33
|
||||
HTTPS, // 65
|
||||
A, // 1
|
||||
NS, // 2
|
||||
CNAME, // 5
|
||||
SOA, // 6
|
||||
PTR, // 12
|
||||
MX, // 15
|
||||
TXT, // 16
|
||||
AAAA, // 28
|
||||
SRV, // 33
|
||||
DS, // 43
|
||||
RRSIG, // 46
|
||||
NSEC, // 47
|
||||
DNSKEY, // 48
|
||||
NSEC3, // 50
|
||||
OPT, // 41 (EDNS0 pseudo-type)
|
||||
HTTPS, // 65
|
||||
}
|
||||
|
||||
impl QueryType {
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
492
src/record.rs
492
src/record.rs
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
1088
src/recursive.rs
Normal file
1088
src/recursive.rs
Normal file
File diff suppressed because it is too large
Load Diff
12
src/stats.rs
12
src/stats.rs
@@ -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,
|
||||
|
||||
419
tests/integration.sh
Executable file
419
tests/integration.sh
Executable file
@@ -0,0 +1,419 @@
|
||||
#!/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:')"
|
||||
|
||||
echo ""
|
||||
echo "=== TCP wire format (real servers) ==="
|
||||
|
||||
# Microsoft's Azure DNS servers require length+message in a single TCP segment.
|
||||
# This test catches the split-write bug that caused early-eof SERVFAILs.
|
||||
check "Microsoft domain (update.code.visualstudio.com)" \
|
||||
"NOERROR" \
|
||||
"$($DIG update.code.visualstudio.com A 2>&1 | grep status:)"
|
||||
|
||||
check "Office domain (ecs.office.com)" \
|
||||
"NOERROR" \
|
||||
"$($DIG ecs.office.com A 2>&1 | grep status:)"
|
||||
|
||||
# Azure Application Insights — another strict TCP server
|
||||
check "Azure telemetry (eastus2-3.in.applicationinsights.azure.com)" \
|
||||
"." \
|
||||
"$($DIG eastus2-3.in.applicationinsights.azure.com A +short 2>/dev/null || echo 'timeout')"
|
||||
|
||||
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
128
tests/network-probe.sh
Executable 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
|
||||
Reference in New Issue
Block a user