feat: recursive resolution + full DNSSEC validation
Numa becomes a true DNS resolver — resolves from root nameservers with complete DNSSEC chain-of-trust verification. Recursive resolution: - Iterative RFC 1034 from configurable root hints (13 default) - CNAME chasing (depth 8), referral following (depth 10) - A+AAAA glue extraction, IPv6 nameserver support - TLD priming: NS + DS + DNSKEY for 34 gTLDs + EU ccTLDs - Config: mode = "recursive" in [upstream], root_hints, prime_tlds DNSSEC (all 4 phases): - EDNS0 OPT pseudo-record (DO bit, 1232 payload per DNS Flag Day 2020) - DNSKEY, DS, RRSIG, NSEC, NSEC3 record types with wire read/write - Signature verification via ring: RSA/SHA-256, ECDSA P-256, Ed25519 - Chain-of-trust: zone DNSKEY → parent DS → root KSK (key tag 20326) - DNSKEY RRset self-signature verification (RRSIG(DNSKEY) by KSK) - RRSIG expiration/inception time validation - NSEC: NXDOMAIN gap proofs, NODATA type absence, wildcard denial - NSEC3: SHA-1 iterated hashing, closest encloser proof, hash range - Authority RRSIG verification for denial proofs - Config: [dnssec] enabled/strict (default false, opt-in) - AD bit on Secure, SERVFAIL on Bogus+strict - DnssecStatus cached per entry, ValidationStats logging Performance: - TLD chain pre-warmed on startup (root DNSKEY + TLD DS/DNSKEY) - Referral DS piggybacking from authority sections - DNSKEY prefetch before validation loop - Cold-cache validation: ~1 DNSKEY fetch (down from 5) - Benchmarks: RSA 10.9µs, ECDSA 174ns, DS verify 257ns Also: - write_qname fix for root domain "." (was producing malformed queries) - write_record_header() dedup, write_bytes() bulk writes - DnsRecord::domain() + query_type() accessors - UpstreamMode enum, DEFAULT_EDNS_PAYLOAD const - Real glue TTL (was hardcoded 3600) - DNSSEC restricted to recursive mode only Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1154,6 +1154,7 @@ dependencies = [
|
||||
"log",
|
||||
"rcgen",
|
||||
"reqwest",
|
||||
"ring",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -28,6 +28,7 @@ time = "0.3"
|
||||
rustls = "0.23"
|
||||
tokio-rustls = "0.26"
|
||||
arc-swap = "1"
|
||||
ring = "0.17"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
@@ -39,3 +40,7 @@ harness = false
|
||||
[[bench]]
|
||||
name = "throughput"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "dnssec"
|
||||
harness = false
|
||||
|
||||
2
Makefile
2
Makefile
@@ -1,6 +1,6 @@
|
||||
.PHONY: all build lint fmt check audit test bench clean deploy blog
|
||||
|
||||
all: lint build
|
||||
all: lint build test
|
||||
|
||||
build:
|
||||
cargo build
|
||||
|
||||
@@ -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);
|
||||
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)
|
||||
|
||||
@@ -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">
|
||||
@@ -1331,6 +1332,14 @@ footer .closing {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Recursive resolver</td>
|
||||
<td class="cross">No (needs Unbound)</td>
|
||||
<td class="cross">Cloud only</td>
|
||||
<td class="cross">Cloud only</td>
|
||||
<td class="cross">No</td>
|
||||
<td class="check">Root hints + full DNSSEC</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Ad & tracker blocking</td>
|
||||
<td class="check">Yes</td>
|
||||
@@ -1609,16 +1618,24 @@ footer .closing {
|
||||
<span class="phase">Phase 5</span>
|
||||
<span class="phase-desc">DNS-over-HTTPS — 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>
|
||||
|
||||
@@ -178,6 +178,7 @@ struct LanStatsResponse {
|
||||
struct QueriesStats {
|
||||
total: u64,
|
||||
forwarded: u64,
|
||||
recursive: u64,
|
||||
cached: u64,
|
||||
local: u64,
|
||||
overridden: u64,
|
||||
@@ -477,7 +478,11 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
||||
let override_count = ctx.overrides.read().unwrap().active_count();
|
||||
let bl_stats = ctx.blocklist.read().unwrap().stats();
|
||||
|
||||
let upstream = ctx.upstream.lock().unwrap().to_string();
|
||||
let upstream = if ctx.upstream_mode == crate::config::UpstreamMode::Recursive {
|
||||
"recursive (root hints)".to_string()
|
||||
} else {
|
||||
ctx.upstream.lock().unwrap().to_string()
|
||||
};
|
||||
|
||||
Json(StatsResponse {
|
||||
uptime_secs: snap.uptime_secs,
|
||||
@@ -487,6 +492,7 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
||||
queries: QueriesStats {
|
||||
total: snap.total,
|
||||
forwarded: snap.forwarded,
|
||||
recursive: snap.recursive,
|
||||
cached: snap.cached,
|
||||
local: snap.local,
|
||||
overridden: snap.overridden,
|
||||
|
||||
@@ -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());
|
||||
|
||||
31
src/cache.rs
31
src/cache.rs
@@ -5,10 +5,20 @@ use crate::packet::DnsPacket;
|
||||
use crate::question::QueryType;
|
||||
use crate::record::DnsRecord;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum DnssecStatus {
|
||||
Secure,
|
||||
Insecure,
|
||||
Bogus,
|
||||
#[default]
|
||||
Indeterminate,
|
||||
}
|
||||
|
||||
struct CacheEntry {
|
||||
packet: DnsPacket,
|
||||
inserted_at: Instant,
|
||||
ttl: Duration,
|
||||
dnssec_status: DnssecStatus,
|
||||
}
|
||||
|
||||
/// DNS cache using a two-level map (domain -> query_type -> entry) so that
|
||||
@@ -34,6 +44,14 @@ impl DnsCache {
|
||||
|
||||
/// Read-only lookup — expired entries are left in place (cleaned up on insert).
|
||||
pub fn lookup(&self, domain: &str, qtype: QueryType) -> Option<DnsPacket> {
|
||||
self.lookup_with_status(domain, qtype).map(|(pkt, _)| pkt)
|
||||
}
|
||||
|
||||
pub fn lookup_with_status(
|
||||
&self,
|
||||
domain: &str,
|
||||
qtype: QueryType,
|
||||
) -> Option<(DnsPacket, DnssecStatus)> {
|
||||
let type_map = self.entries.get(domain)?;
|
||||
let entry = type_map.get(&qtype)?;
|
||||
|
||||
@@ -50,10 +68,20 @@ impl DnsCache {
|
||||
adjust_ttls(&mut packet.authorities, remaining);
|
||||
adjust_ttls(&mut packet.resources, remaining);
|
||||
|
||||
Some(packet)
|
||||
Some((packet, entry.dnssec_status))
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, domain: &str, qtype: QueryType, packet: &DnsPacket) {
|
||||
self.insert_with_status(domain, qtype, packet, DnssecStatus::Indeterminate);
|
||||
}
|
||||
|
||||
pub fn insert_with_status(
|
||||
&mut self,
|
||||
domain: &str,
|
||||
qtype: QueryType,
|
||||
packet: &DnsPacket,
|
||||
dnssec_status: DnssecStatus,
|
||||
) {
|
||||
if self.entry_count >= self.max_entries {
|
||||
self.evict_expired();
|
||||
if self.entry_count >= self.max_entries {
|
||||
@@ -81,6 +109,7 @@ impl DnsCache {
|
||||
packet: packet.clone(),
|
||||
inserted_at: Instant::now(),
|
||||
ttl: Duration::from_secs(min_ttl as u64),
|
||||
dnssec_status,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ pub struct Config {
|
||||
pub services: Vec<ServiceConfig>,
|
||||
#[serde(default)]
|
||||
pub lan: LanConfig,
|
||||
#[serde(default)]
|
||||
pub dnssec: DnssecConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -61,26 +63,112 @@ fn default_api_port() -> u16 {
|
||||
5380
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, PartialEq, Eq, Clone, Copy)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum UpstreamMode {
|
||||
#[default]
|
||||
Forward,
|
||||
Recursive,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpstreamConfig {
|
||||
#[serde(default)]
|
||||
pub mode: UpstreamMode,
|
||||
#[serde(default = "default_upstream_addr")]
|
||||
pub address: String,
|
||||
#[serde(default = "default_upstream_port")]
|
||||
pub port: u16,
|
||||
#[serde(default = "default_timeout_ms")]
|
||||
pub timeout_ms: u64,
|
||||
#[serde(default = "default_root_hints")]
|
||||
pub root_hints: Vec<String>,
|
||||
#[serde(default = "default_prime_tlds")]
|
||||
pub prime_tlds: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for UpstreamConfig {
|
||||
fn default() -> Self {
|
||||
UpstreamConfig {
|
||||
mode: UpstreamMode::default(),
|
||||
address: default_upstream_addr(),
|
||||
port: default_upstream_port(),
|
||||
timeout_ms: default_timeout_ms(),
|
||||
root_hints: default_root_hints(),
|
||||
prime_tlds: default_prime_tlds(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_prime_tlds() -> Vec<String> {
|
||||
vec![
|
||||
// gTLDs
|
||||
"com".into(),
|
||||
"net".into(),
|
||||
"org".into(),
|
||||
"info".into(),
|
||||
"io".into(),
|
||||
"dev".into(),
|
||||
"app".into(),
|
||||
"xyz".into(),
|
||||
"me".into(),
|
||||
// EU + European ccTLDs
|
||||
"eu".into(),
|
||||
"uk".into(),
|
||||
"de".into(),
|
||||
"fr".into(),
|
||||
"nl".into(),
|
||||
"it".into(),
|
||||
"es".into(),
|
||||
"pl".into(),
|
||||
"se".into(),
|
||||
"no".into(),
|
||||
"dk".into(),
|
||||
"fi".into(),
|
||||
"at".into(),
|
||||
"be".into(),
|
||||
"ie".into(),
|
||||
"pt".into(),
|
||||
"cz".into(),
|
||||
"ro".into(),
|
||||
"gr".into(),
|
||||
"hu".into(),
|
||||
"bg".into(),
|
||||
"hr".into(),
|
||||
"sk".into(),
|
||||
"si".into(),
|
||||
"lt".into(),
|
||||
"lv".into(),
|
||||
"ee".into(),
|
||||
"ch".into(),
|
||||
"is".into(),
|
||||
// Other major ccTLDs
|
||||
"co".into(),
|
||||
"br".into(),
|
||||
"au".into(),
|
||||
"ca".into(),
|
||||
"jp".into(),
|
||||
]
|
||||
}
|
||||
|
||||
fn default_root_hints() -> Vec<String> {
|
||||
vec![
|
||||
"198.41.0.4".into(), // a.root-servers.net
|
||||
"199.9.14.201".into(), // b.root-servers.net
|
||||
"192.33.4.12".into(), // c.root-servers.net
|
||||
"199.7.91.13".into(), // d.root-servers.net
|
||||
"192.203.230.10".into(), // e.root-servers.net
|
||||
"192.5.5.241".into(), // f.root-servers.net
|
||||
"192.112.36.4".into(), // g.root-servers.net
|
||||
"198.97.190.53".into(), // h.root-servers.net
|
||||
"192.36.148.17".into(), // i.root-servers.net
|
||||
"192.58.128.30".into(), // j.root-servers.net
|
||||
"193.0.14.129".into(), // k.root-servers.net
|
||||
"199.7.83.42".into(), // l.root-servers.net
|
||||
"202.12.27.33".into(), // m.root-servers.net
|
||||
]
|
||||
}
|
||||
|
||||
fn default_upstream_addr() -> String {
|
||||
String::new() // empty = auto-detect from system resolver
|
||||
}
|
||||
@@ -250,6 +338,14 @@ fn default_lan_peer_timeout() -> u64 {
|
||||
90
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Default)]
|
||||
pub struct DnssecConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub strict: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
110
src/ctx.rs
110
src/ctx.rs
@@ -11,7 +11,7 @@ use tokio::net::UdpSocket;
|
||||
use crate::blocklist::BlocklistStore;
|
||||
use crate::buffer::BytePacketBuffer;
|
||||
use crate::cache::DnsCache;
|
||||
use crate::config::ZoneMap;
|
||||
use crate::config::{UpstreamMode, ZoneMap};
|
||||
use crate::forward::{forward_query, Upstream};
|
||||
use crate::header::ResultCode;
|
||||
use crate::lan::PeerStore;
|
||||
@@ -27,6 +27,7 @@ use crate::system_dns::ForwardingRule;
|
||||
pub struct ServerCtx {
|
||||
pub socket: UdpSocket,
|
||||
pub zone_map: ZoneMap,
|
||||
/// std::sync::RwLock (not tokio) — locks must never be held across .await points.
|
||||
pub cache: RwLock<DnsCache>,
|
||||
pub stats: Mutex<ServerStats>,
|
||||
pub overrides: RwLock<OverrideStore>,
|
||||
@@ -48,6 +49,10 @@ pub struct ServerCtx {
|
||||
pub config_dir: PathBuf,
|
||||
pub data_dir: PathBuf,
|
||||
pub tls_config: Option<ArcSwap<ServerConfig>>,
|
||||
pub upstream_mode: UpstreamMode,
|
||||
pub root_hints: Vec<SocketAddr>,
|
||||
pub dnssec_enabled: bool,
|
||||
pub dnssec_strict: bool,
|
||||
}
|
||||
|
||||
pub async fn handle_query(
|
||||
@@ -136,11 +141,51 @@ pub async fn handle_query(
|
||||
resp.answers = records.clone();
|
||||
(resp, QueryPath::Local)
|
||||
} else {
|
||||
let cached = ctx.cache.read().unwrap().lookup(&qname, qtype);
|
||||
if let Some(cached) = cached {
|
||||
let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype);
|
||||
if let Some((cached, cached_dnssec)) = cached {
|
||||
let mut resp = cached;
|
||||
resp.header.id = query.header.id;
|
||||
if cached_dnssec == crate::cache::DnssecStatus::Secure {
|
||||
resp.header.authed_data = true;
|
||||
}
|
||||
(resp, QueryPath::Cached)
|
||||
} else if ctx.upstream_mode == UpstreamMode::Recursive {
|
||||
match crate::recursive::resolve_recursive(
|
||||
&qname,
|
||||
qtype,
|
||||
&ctx.cache,
|
||||
ctx.timeout,
|
||||
&query,
|
||||
&ctx.root_hints,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => (resp, QueryPath::Recursive),
|
||||
Err(e) => {
|
||||
// Auto-fallback: retry via forward upstream if configured
|
||||
let upstream = ctx.upstream.lock().unwrap().clone();
|
||||
match forward_query(&query, &upstream, ctx.timeout).await {
|
||||
Ok(resp) => {
|
||||
debug!(
|
||||
"{} | {:?} {} | RECURSIVE FALLBACK → FORWARD | {}",
|
||||
src_addr, qtype, qname, e
|
||||
);
|
||||
ctx.cache.write().unwrap().insert(&qname, qtype, &resp);
|
||||
(resp, QueryPath::Forwarded)
|
||||
}
|
||||
Err(e2) => {
|
||||
error!(
|
||||
"{} | {:?} {} | RECURSIVE+FORWARD FAILED | recursive: {} | forward: {}",
|
||||
src_addr, qtype, qname, e, e2
|
||||
);
|
||||
(
|
||||
DnsPacket::response_from(&query, ResultCode::SERVFAIL),
|
||||
QueryPath::UpstreamError,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let upstream =
|
||||
match crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) {
|
||||
@@ -167,6 +212,52 @@ pub async fn handle_query(
|
||||
}
|
||||
};
|
||||
|
||||
let client_do = query.edns.as_ref().is_some_and(|e| e.do_bit);
|
||||
let mut response = response;
|
||||
|
||||
// DNSSEC validation (recursive/forwarded responses only)
|
||||
if ctx.dnssec_enabled && path == QueryPath::Recursive {
|
||||
let (status, vstats) =
|
||||
crate::dnssec::validate_response(&response, &ctx.cache, &ctx.root_hints).await;
|
||||
|
||||
debug!(
|
||||
"DNSSEC | {} | {:?} | {}ms | dnskey_hit={} dnskey_fetch={} ds_hit={} ds_fetch={}",
|
||||
qname,
|
||||
status,
|
||||
vstats.elapsed_ms,
|
||||
vstats.dnskey_cache_hits,
|
||||
vstats.dnskey_fetches,
|
||||
vstats.ds_cache_hits,
|
||||
vstats.ds_fetches,
|
||||
);
|
||||
|
||||
if status == crate::cache::DnssecStatus::Secure {
|
||||
response.header.authed_data = true;
|
||||
}
|
||||
|
||||
if status == crate::cache::DnssecStatus::Bogus && ctx.dnssec_strict {
|
||||
response = DnsPacket::response_from(&query, ResultCode::SERVFAIL);
|
||||
}
|
||||
|
||||
ctx.cache
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert_with_status(&qname, qtype, &response, status);
|
||||
}
|
||||
|
||||
// Strip DNSSEC records if client didn't set DO bit
|
||||
if !client_do {
|
||||
strip_dnssec_records(&mut response);
|
||||
}
|
||||
|
||||
// Echo EDNS back if client sent it
|
||||
if query.edns.is_some() {
|
||||
response.edns = Some(crate::packet::EdnsOpt {
|
||||
do_bit: client_do,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
info!(
|
||||
@@ -220,3 +311,16 @@ pub async fn handle_query(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_dnssec_record(r: &DnsRecord) -> bool {
|
||||
matches!(
|
||||
r.query_type(),
|
||||
QueryType::RRSIG | QueryType::DNSKEY | QueryType::DS | QueryType::NSEC | QueryType::NSEC3
|
||||
)
|
||||
}
|
||||
|
||||
fn strip_dnssec_records(pkt: &mut DnsPacket) {
|
||||
pkt.answers.retain(|r| !is_dnssec_record(r));
|
||||
pkt.authorities.retain(|r| !is_dnssec_record(r));
|
||||
pkt.resources.retain(|r| !is_dnssec_record(r));
|
||||
}
|
||||
|
||||
1675
src/dnssec.rs
Normal file
1675
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
24
src/main.rs
24
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()?;
|
||||
|
||||
487
src/packet.rs
487
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 {
|
||||
// Peek at type field to detect OPT pseudo-records.
|
||||
// OPT name is always root (0x00), so name byte + type field starts at pos+1.
|
||||
let peek_pos = buffer.pos();
|
||||
let name_byte = buffer.get(peek_pos)?;
|
||||
let is_opt = if name_byte == 0 {
|
||||
// Root name (single zero byte) — peek at type
|
||||
let type_hi = buffer.get(peek_pos + 1)?;
|
||||
let type_lo = buffer.get(peek_pos + 2)?;
|
||||
u16::from_be_bytes([type_hi, type_lo]) == 41
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if is_opt {
|
||||
// Parse OPT manually to capture the class field (= UDP payload size)
|
||||
buffer.step(1)?; // skip root name (0x00)
|
||||
let _ = buffer.read_u16()?; // type (41)
|
||||
let udp_payload_size = buffer.read_u16()?; // class = UDP payload size
|
||||
let ttl_field = buffer.read_u32()?; // packed flags
|
||||
let rdlength = buffer.read_u16()?;
|
||||
let options = buffer.get_range(buffer.pos(), rdlength as usize)?.to_vec();
|
||||
buffer.step(rdlength as usize)?;
|
||||
|
||||
result.edns = Some(EdnsOpt {
|
||||
udp_payload_size,
|
||||
extended_rcode: ((ttl_field >> 24) & 0xFF) as u8,
|
||||
version: ((ttl_field >> 16) & 0xFF) as u8,
|
||||
do_bit: (ttl_field >> 15) & 1 == 1,
|
||||
options,
|
||||
});
|
||||
} else {
|
||||
let rec = DnsRecord::read(buffer)?;
|
||||
result.resources.push(rec);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn write(&self, buffer: &mut BytePacketBuffer) -> Result<()> {
|
||||
// Count known records without allocating filter Vecs
|
||||
let answer_count = self.answers.iter().filter(|r| !r.is_unknown()).count() as u16;
|
||||
let auth_count = self.authorities.iter().filter(|r| !r.is_unknown()).count() as u16;
|
||||
let res_count = self.resources.iter().filter(|r| !r.is_unknown()).count() as u16;
|
||||
let edns_count = if self.edns.is_some() { 1u16 } else { 0 };
|
||||
|
||||
let mut header = self.header.clone();
|
||||
header.questions = self.questions.len() as u16;
|
||||
header.answers = answer_count;
|
||||
header.authoritative_entries = auth_count;
|
||||
header.resource_entries = res_count;
|
||||
header.answers = self.answers.len() as u16;
|
||||
header.authoritative_entries = self.authorities.len() as u16;
|
||||
header.resource_entries = self.resources.len() as u16 + edns_count;
|
||||
|
||||
header.write(buffer)?;
|
||||
|
||||
@@ -85,19 +141,27 @@ impl DnsPacket {
|
||||
question.write(buffer)?;
|
||||
}
|
||||
for rec in &self.answers {
|
||||
if !rec.is_unknown() {
|
||||
rec.write(buffer)?;
|
||||
}
|
||||
}
|
||||
for rec in &self.authorities {
|
||||
if !rec.is_unknown() {
|
||||
rec.write(buffer)?;
|
||||
}
|
||||
}
|
||||
for rec in &self.resources {
|
||||
if !rec.is_unknown() {
|
||||
rec.write(buffer)?;
|
||||
}
|
||||
|
||||
// Write EDNS0 OPT pseudo-record
|
||||
if let Some(ref edns) = self.edns {
|
||||
buffer.write_u8(0)?; // root name
|
||||
buffer.write_u16(QueryType::OPT.to_num())?; // type 41
|
||||
buffer.write_u16(edns.udp_payload_size)?; // class = UDP payload size
|
||||
// TTL = extended_rcode(8) | version(8) | DO(1) | Z(15)
|
||||
let ttl_field = ((edns.extended_rcode as u32) << 24)
|
||||
| ((edns.version as u32) << 16)
|
||||
| (if edns.do_bit { 1u32 << 15 } else { 0 });
|
||||
buffer.write_u32(ttl_field)?;
|
||||
buffer.write_u16(edns.options.len() as u16)?; // RDLENGTH
|
||||
buffer.write_bytes(&edns.options)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -118,5 +182,404 @@ impl DnsPacket {
|
||||
for rec in &self.resources {
|
||||
println!("{:#?}", rec);
|
||||
}
|
||||
if let Some(ref edns) = self.edns {
|
||||
println!("EDNS: {:?}", edns);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::header::ResultCode;
|
||||
|
||||
#[test]
|
||||
fn edns_round_trip() {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.id = 0x1234;
|
||||
pkt.header.response = true;
|
||||
pkt.header.rescode = ResultCode::NOERROR;
|
||||
pkt.edns = Some(EdnsOpt {
|
||||
do_bit: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
pkt.write(&mut buf).unwrap();
|
||||
buf.seek(0).unwrap();
|
||||
let parsed = DnsPacket::from_buffer(&mut buf).unwrap();
|
||||
|
||||
let edns = parsed.edns.expect("EDNS should be present");
|
||||
assert_eq!(edns.udp_payload_size, DEFAULT_EDNS_PAYLOAD);
|
||||
assert!(edns.do_bit);
|
||||
assert_eq!(edns.version, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edns_do_bit_false() {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.id = 0x5678;
|
||||
pkt.header.response = true;
|
||||
pkt.edns = Some(EdnsOpt {
|
||||
udp_payload_size: 1232,
|
||||
do_bit: false,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
pkt.write(&mut buf).unwrap();
|
||||
buf.seek(0).unwrap();
|
||||
let parsed = DnsPacket::from_buffer(&mut buf).unwrap();
|
||||
|
||||
let edns = parsed.edns.expect("EDNS should be present");
|
||||
assert_eq!(edns.udp_payload_size, DEFAULT_EDNS_PAYLOAD);
|
||||
assert!(!edns.do_bit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_edns_by_default() {
|
||||
let pkt = DnsPacket::new();
|
||||
assert!(pkt.edns.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packet_without_edns_round_trips() {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.id = 0xABCD;
|
||||
pkt.header.response = true;
|
||||
pkt.header.rescode = ResultCode::NOERROR;
|
||||
pkt.answers.push(crate::record::DnsRecord::A {
|
||||
domain: "example.com".into(),
|
||||
addr: "1.2.3.4".parse().unwrap(),
|
||||
ttl: 300,
|
||||
});
|
||||
|
||||
let parsed = packet_round_trip(&pkt);
|
||||
assert!(parsed.edns.is_none());
|
||||
assert_eq!(parsed.answers.len(), 1);
|
||||
}
|
||||
|
||||
fn packet_round_trip(pkt: &DnsPacket) -> DnsPacket {
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
pkt.write(&mut buf).unwrap();
|
||||
let wire_len = buf.pos();
|
||||
buf.seek(0).unwrap();
|
||||
let parsed = DnsPacket::from_buffer(&mut buf).unwrap();
|
||||
// Verify we consumed exactly what was written
|
||||
assert_eq!(
|
||||
buf.pos(),
|
||||
wire_len,
|
||||
"parse did not consume all written bytes"
|
||||
);
|
||||
parsed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nxdomain_with_nsec_authority_round_trips() {
|
||||
use crate::question::DnsQuestion;
|
||||
use crate::record::DnsRecord;
|
||||
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.id = 0x1111;
|
||||
pkt.header.response = true;
|
||||
pkt.header.rescode = ResultCode::NXDOMAIN;
|
||||
pkt.questions.push(DnsQuestion::new(
|
||||
"nonexistent.example.com".into(),
|
||||
QueryType::A,
|
||||
));
|
||||
|
||||
pkt.authorities.push(DnsRecord::NSEC {
|
||||
domain: "alpha.example.com".into(),
|
||||
next_domain: "gamma.example.com".into(),
|
||||
type_bitmap: vec![0, 2, 0x40, 0x01], // A + MX
|
||||
ttl: 3600,
|
||||
});
|
||||
pkt.authorities.push(DnsRecord::RRSIG {
|
||||
domain: "alpha.example.com".into(),
|
||||
type_covered: QueryType::NSEC.to_num(),
|
||||
algorithm: 13,
|
||||
labels: 3,
|
||||
original_ttl: 3600,
|
||||
expiration: 1700000000,
|
||||
inception: 1690000000,
|
||||
key_tag: 12345,
|
||||
signer_name: "example.com".into(),
|
||||
signature: vec![0xAA; 64],
|
||||
ttl: 3600,
|
||||
});
|
||||
|
||||
// Wildcard denial NSEC
|
||||
pkt.authorities.push(DnsRecord::NSEC {
|
||||
domain: "example.com".into(),
|
||||
next_domain: "alpha.example.com".into(),
|
||||
type_bitmap: vec![0, 3, 0x62, 0x01, 0x80], // A, NS, SOA, MX, RRSIG
|
||||
ttl: 3600,
|
||||
});
|
||||
|
||||
pkt.edns = Some(EdnsOpt {
|
||||
do_bit: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let parsed = packet_round_trip(&pkt);
|
||||
|
||||
assert_eq!(parsed.header.id, 0x1111);
|
||||
assert_eq!(parsed.header.rescode, ResultCode::NXDOMAIN);
|
||||
assert_eq!(parsed.questions.len(), 1);
|
||||
assert_eq!(parsed.questions[0].name, "nonexistent.example.com");
|
||||
assert_eq!(parsed.authorities.len(), 3);
|
||||
|
||||
// Verify NSEC records survived
|
||||
if let DnsRecord::NSEC {
|
||||
domain,
|
||||
next_domain,
|
||||
type_bitmap,
|
||||
..
|
||||
} = &parsed.authorities[0]
|
||||
{
|
||||
assert_eq!(domain, "alpha.example.com");
|
||||
assert_eq!(next_domain, "gamma.example.com");
|
||||
assert_eq!(type_bitmap, &[0, 2, 0x40, 0x01]);
|
||||
} else {
|
||||
panic!("expected NSEC, got {:?}", parsed.authorities[0]);
|
||||
}
|
||||
|
||||
// Verify RRSIG survived
|
||||
if let DnsRecord::RRSIG {
|
||||
type_covered,
|
||||
signer_name,
|
||||
signature,
|
||||
..
|
||||
} = &parsed.authorities[1]
|
||||
{
|
||||
assert_eq!(*type_covered, QueryType::NSEC.to_num());
|
||||
assert_eq!(signer_name, "example.com");
|
||||
assert_eq!(signature.len(), 64);
|
||||
} else {
|
||||
panic!("expected RRSIG, got {:?}", parsed.authorities[1]);
|
||||
}
|
||||
|
||||
// Verify EDNS survived
|
||||
assert!(parsed.edns.as_ref().unwrap().do_bit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nxdomain_with_nsec3_authority_round_trips() {
|
||||
use crate::question::DnsQuestion;
|
||||
use crate::record::DnsRecord;
|
||||
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.id = 0x2222;
|
||||
pkt.header.response = true;
|
||||
pkt.header.rescode = ResultCode::NXDOMAIN;
|
||||
pkt.questions
|
||||
.push(DnsQuestion::new("no.example.com".into(), QueryType::AAAA));
|
||||
|
||||
// Three NSEC3 records (closest encloser, next closer, wildcard)
|
||||
let salt = vec![0xAB, 0xCD];
|
||||
pkt.authorities.push(DnsRecord::NSEC3 {
|
||||
domain: "ABC123.example.com".into(),
|
||||
hash_algorithm: 1,
|
||||
flags: 0,
|
||||
iterations: 5,
|
||||
salt: salt.clone(),
|
||||
next_hashed_owner: vec![
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
|
||||
0x0F, 0x10, 0x11, 0x12, 0x13, 0x14,
|
||||
],
|
||||
type_bitmap: vec![0, 2, 0x60, 0x01], // NS, SOA, MX
|
||||
ttl: 300,
|
||||
});
|
||||
pkt.authorities.push(DnsRecord::NSEC3 {
|
||||
domain: "DEF456.example.com".into(),
|
||||
hash_algorithm: 1,
|
||||
flags: 0,
|
||||
iterations: 5,
|
||||
salt: salt.clone(),
|
||||
next_hashed_owner: vec![0x20; 20],
|
||||
type_bitmap: vec![0, 1, 0x40], // A
|
||||
ttl: 300,
|
||||
});
|
||||
pkt.authorities.push(DnsRecord::RRSIG {
|
||||
domain: "ABC123.example.com".into(),
|
||||
type_covered: QueryType::NSEC3.to_num(),
|
||||
algorithm: 8,
|
||||
labels: 3,
|
||||
original_ttl: 300,
|
||||
expiration: 2000000000,
|
||||
inception: 1600000000,
|
||||
key_tag: 54321,
|
||||
signer_name: "example.com".into(),
|
||||
signature: vec![0xBB; 128],
|
||||
ttl: 300,
|
||||
});
|
||||
|
||||
pkt.edns = Some(EdnsOpt {
|
||||
do_bit: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let parsed = packet_round_trip(&pkt);
|
||||
|
||||
assert_eq!(parsed.header.rescode, ResultCode::NXDOMAIN);
|
||||
assert_eq!(parsed.authorities.len(), 3);
|
||||
|
||||
// Verify first NSEC3 survived with all fields intact
|
||||
if let DnsRecord::NSEC3 {
|
||||
domain,
|
||||
hash_algorithm,
|
||||
flags,
|
||||
iterations,
|
||||
salt: parsed_salt,
|
||||
next_hashed_owner,
|
||||
type_bitmap,
|
||||
..
|
||||
} = &parsed.authorities[0]
|
||||
{
|
||||
assert_eq!(domain, "abc123.example.com");
|
||||
assert_eq!(*hash_algorithm, 1);
|
||||
assert_eq!(*flags, 0);
|
||||
assert_eq!(*iterations, 5);
|
||||
assert_eq!(parsed_salt, &salt);
|
||||
assert_eq!(next_hashed_owner.len(), 20);
|
||||
assert_eq!(type_bitmap, &[0, 2, 0x60, 0x01]);
|
||||
} else {
|
||||
panic!("expected NSEC3, got {:?}", parsed.authorities[0]);
|
||||
}
|
||||
|
||||
// Verify RRSIG covering NSEC3
|
||||
if let DnsRecord::RRSIG {
|
||||
type_covered,
|
||||
algorithm,
|
||||
signature,
|
||||
..
|
||||
} = &parsed.authorities[2]
|
||||
{
|
||||
assert_eq!(*type_covered, QueryType::NSEC3.to_num());
|
||||
assert_eq!(*algorithm, 8);
|
||||
assert_eq!(signature.len(), 128);
|
||||
} else {
|
||||
panic!("expected RRSIG, got {:?}", parsed.authorities[2]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dnssec_answer_with_rrsig_round_trips() {
|
||||
use crate::question::DnsQuestion;
|
||||
use crate::record::DnsRecord;
|
||||
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.id = 0x3333;
|
||||
pkt.header.response = true;
|
||||
pkt.header.rescode = ResultCode::NOERROR;
|
||||
pkt.header.authed_data = true;
|
||||
pkt.questions
|
||||
.push(DnsQuestion::new("example.com".into(), QueryType::A));
|
||||
|
||||
pkt.answers.push(DnsRecord::A {
|
||||
domain: "example.com".into(),
|
||||
addr: "93.184.216.34".parse().unwrap(),
|
||||
ttl: 300,
|
||||
});
|
||||
pkt.answers.push(DnsRecord::RRSIG {
|
||||
domain: "example.com".into(),
|
||||
type_covered: QueryType::A.to_num(),
|
||||
algorithm: 13,
|
||||
labels: 2,
|
||||
original_ttl: 300,
|
||||
expiration: 1700000000,
|
||||
inception: 1690000000,
|
||||
key_tag: 11111,
|
||||
signer_name: "example.com".into(),
|
||||
signature: vec![0xCC; 64],
|
||||
ttl: 300,
|
||||
});
|
||||
|
||||
// Authority: NS + DS
|
||||
pkt.authorities.push(DnsRecord::NS {
|
||||
domain: "example.com".into(),
|
||||
host: "ns1.example.com".into(),
|
||||
ttl: 3600,
|
||||
});
|
||||
pkt.authorities.push(DnsRecord::DS {
|
||||
domain: "example.com".into(),
|
||||
key_tag: 22222,
|
||||
algorithm: 8,
|
||||
digest_type: 2,
|
||||
digest: vec![0xDD; 32],
|
||||
ttl: 86400,
|
||||
});
|
||||
|
||||
// Additional: glue A + DNSKEY
|
||||
pkt.resources.push(DnsRecord::A {
|
||||
domain: "ns1.example.com".into(),
|
||||
addr: "198.51.100.1".parse().unwrap(),
|
||||
ttl: 3600,
|
||||
});
|
||||
pkt.resources.push(DnsRecord::DNSKEY {
|
||||
domain: "example.com".into(),
|
||||
flags: 257,
|
||||
protocol: 3,
|
||||
algorithm: 13,
|
||||
public_key: vec![0xEE; 64],
|
||||
ttl: 3600,
|
||||
});
|
||||
|
||||
pkt.edns = Some(EdnsOpt {
|
||||
do_bit: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let parsed = packet_round_trip(&pkt);
|
||||
|
||||
assert_eq!(parsed.header.id, 0x3333);
|
||||
assert!(parsed.header.authed_data);
|
||||
assert_eq!(parsed.answers.len(), 2);
|
||||
assert_eq!(parsed.authorities.len(), 2);
|
||||
assert_eq!(parsed.resources.len(), 2);
|
||||
|
||||
// Verify A record
|
||||
if let DnsRecord::A { addr, .. } = &parsed.answers[0] {
|
||||
assert_eq!(addr.to_string(), "93.184.216.34");
|
||||
} else {
|
||||
panic!("expected A");
|
||||
}
|
||||
|
||||
// Verify RRSIG in answers
|
||||
if let DnsRecord::RRSIG {
|
||||
type_covered,
|
||||
key_tag,
|
||||
signer_name,
|
||||
..
|
||||
} = &parsed.answers[1]
|
||||
{
|
||||
assert_eq!(*type_covered, 1); // A
|
||||
assert_eq!(*key_tag, 11111);
|
||||
assert_eq!(signer_name, "example.com");
|
||||
} else {
|
||||
panic!("expected RRSIG");
|
||||
}
|
||||
|
||||
// Verify DS in authority
|
||||
if let DnsRecord::DS {
|
||||
key_tag, digest, ..
|
||||
} = &parsed.authorities[1]
|
||||
{
|
||||
assert_eq!(*key_tag, 22222);
|
||||
assert_eq!(digest.len(), 32);
|
||||
} else {
|
||||
panic!("expected DS");
|
||||
}
|
||||
|
||||
// Verify DNSKEY in additional
|
||||
if let DnsRecord::DNSKEY {
|
||||
flags, public_key, ..
|
||||
} = &parsed.resources[1]
|
||||
{
|
||||
assert_eq!(*flags, 257);
|
||||
assert_eq!(public_key.len(), 64);
|
||||
} else {
|
||||
panic!("expected DNSKEY");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ pub enum QueryType {
|
||||
TXT, // 16
|
||||
AAAA, // 28
|
||||
SRV, // 33
|
||||
DS, // 43
|
||||
RRSIG, // 46
|
||||
NSEC, // 47
|
||||
DNSKEY, // 48
|
||||
NSEC3, // 50
|
||||
OPT, // 41 (EDNS0 pseudo-type)
|
||||
HTTPS, // 65
|
||||
}
|
||||
|
||||
@@ -29,6 +35,12 @@ impl QueryType {
|
||||
QueryType::TXT => 16,
|
||||
QueryType::AAAA => 28,
|
||||
QueryType::SRV => 33,
|
||||
QueryType::OPT => 41,
|
||||
QueryType::DS => 43,
|
||||
QueryType::RRSIG => 46,
|
||||
QueryType::NSEC => 47,
|
||||
QueryType::DNSKEY => 48,
|
||||
QueryType::NSEC3 => 50,
|
||||
QueryType::HTTPS => 65,
|
||||
}
|
||||
}
|
||||
@@ -44,6 +56,12 @@ impl QueryType {
|
||||
16 => QueryType::TXT,
|
||||
28 => QueryType::AAAA,
|
||||
33 => QueryType::SRV,
|
||||
41 => QueryType::OPT,
|
||||
43 => QueryType::DS,
|
||||
46 => QueryType::RRSIG,
|
||||
47 => QueryType::NSEC,
|
||||
48 => QueryType::DNSKEY,
|
||||
50 => QueryType::NSEC3,
|
||||
65 => QueryType::HTTPS,
|
||||
_ => QueryType::UNKNOWN(num),
|
||||
}
|
||||
@@ -60,6 +78,12 @@ impl QueryType {
|
||||
QueryType::TXT => "TXT",
|
||||
QueryType::AAAA => "AAAA",
|
||||
QueryType::SRV => "SRV",
|
||||
QueryType::OPT => "OPT",
|
||||
QueryType::DS => "DS",
|
||||
QueryType::RRSIG => "RRSIG",
|
||||
QueryType::NSEC => "NSEC",
|
||||
QueryType::DNSKEY => "DNSKEY",
|
||||
QueryType::NSEC3 => "NSEC3",
|
||||
QueryType::HTTPS => "HTTPS",
|
||||
QueryType::UNKNOWN(_) => "UNKNOWN",
|
||||
}
|
||||
@@ -76,6 +100,11 @@ impl QueryType {
|
||||
"TXT" => Some(QueryType::TXT),
|
||||
"AAAA" => Some(QueryType::AAAA),
|
||||
"SRV" => Some(QueryType::SRV),
|
||||
"DS" => Some(QueryType::DS),
|
||||
"RRSIG" => Some(QueryType::RRSIG),
|
||||
"DNSKEY" => Some(QueryType::DNSKEY),
|
||||
"NSEC" => Some(QueryType::NSEC),
|
||||
"NSEC3" => Some(QueryType::NSEC3),
|
||||
"HTTPS" => Some(QueryType::HTTPS),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
601
src/recursive.rs
Normal file
601
src/recursive.rs
Normal file
@@ -0,0 +1,601 @@
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::atomic::{AtomicU16, Ordering};
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use log::{debug, info};
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::cache::DnsCache;
|
||||
use crate::forward::forward_udp;
|
||||
use crate::header::ResultCode;
|
||||
use crate::packet::DnsPacket;
|
||||
use crate::question::{DnsQuestion, QueryType};
|
||||
use crate::record::DnsRecord;
|
||||
|
||||
const MAX_REFERRAL_DEPTH: u8 = 10;
|
||||
const MAX_CNAME_DEPTH: u8 = 8;
|
||||
const NS_QUERY_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
static QUERY_ID: AtomicU16 = AtomicU16::new(1);
|
||||
|
||||
fn next_id() -> u16 {
|
||||
QUERY_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn dns_addr(ip: impl Into<IpAddr>) -> SocketAddr {
|
||||
SocketAddr::new(ip.into(), 53)
|
||||
}
|
||||
|
||||
/// Query root servers for common TLDs and cache NS + glue + DNSKEY + DS records.
|
||||
/// Pre-warms the DNSSEC trust chain so first queries skip chain-walking I/O.
|
||||
pub async fn prime_tld_cache(cache: &RwLock<DnsCache>, root_hints: &[SocketAddr], tlds: &[String]) {
|
||||
let root_addr = match root_hints.first() {
|
||||
Some(addr) => *addr,
|
||||
None => return,
|
||||
};
|
||||
if tlds.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch root DNSKEY (needed for DNSSEC chain-of-trust terminus)
|
||||
if let Ok(root_dnskey) = send_query(".", QueryType::DNSKEY, root_addr).await {
|
||||
cache
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(".", QueryType::DNSKEY, &root_dnskey);
|
||||
debug!("prime: cached root DNSKEY");
|
||||
}
|
||||
|
||||
let mut primed = 0u16;
|
||||
|
||||
for tld in tlds {
|
||||
// Fetch NS referral (includes DS in authority section from root)
|
||||
let response = match send_query(tld, QueryType::NS, root_addr).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
debug!("prime: failed to query NS for .{}: {}", tld, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let ns_names = extract_ns_names(&response);
|
||||
if ns_names.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
{
|
||||
let mut cache_w = cache.write().unwrap();
|
||||
cache_w.insert(tld, QueryType::NS, &response);
|
||||
cache_glue(&mut cache_w, &response, &ns_names);
|
||||
// Cache DS records from referral authority section
|
||||
cache_ds_from_authority(&mut cache_w, &response);
|
||||
}
|
||||
|
||||
// Fetch DNSKEY for this TLD (needed for DNSSEC chain validation)
|
||||
let first_ns_name = ns_names.first().map(|s| s.as_str()).unwrap_or("");
|
||||
let first_ns = glue_addrs_for(&response, first_ns_name);
|
||||
if let Some(ns_addr) = first_ns.first() {
|
||||
if let Ok(dnskey_resp) = send_query(tld, QueryType::DNSKEY, *ns_addr).await {
|
||||
cache
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(tld, QueryType::DNSKEY, &dnskey_resp);
|
||||
}
|
||||
}
|
||||
|
||||
primed += 1;
|
||||
}
|
||||
|
||||
info!(
|
||||
"primed {}/{} TLD caches (NS + glue + DS + DNSKEY)",
|
||||
primed,
|
||||
tlds.len()
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn resolve_recursive(
|
||||
qname: &str,
|
||||
qtype: QueryType,
|
||||
cache: &RwLock<DnsCache>,
|
||||
overall_timeout: Duration,
|
||||
original_query: &DnsPacket,
|
||||
root_hints: &[SocketAddr],
|
||||
) -> crate::Result<DnsPacket> {
|
||||
let mut resp = match timeout(
|
||||
overall_timeout,
|
||||
resolve_iterative(qname, qtype, cache, root_hints, 0, 0),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result?,
|
||||
Err(_) => return Err(format!("recursive resolution timed out for {}", qname).into()),
|
||||
};
|
||||
|
||||
resp.header.id = original_query.header.id;
|
||||
resp.header.recursion_available = true;
|
||||
resp.header.recursion_desired = original_query.header.recursion_desired;
|
||||
resp.questions = original_query.questions.clone();
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_iterative<'a>(
|
||||
qname: &'a str,
|
||||
qtype: QueryType,
|
||||
cache: &'a RwLock<DnsCache>,
|
||||
root_hints: &'a [SocketAddr],
|
||||
referral_depth: u8,
|
||||
cname_depth: u8,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::Result<DnsPacket>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
if referral_depth > MAX_REFERRAL_DEPTH {
|
||||
return Err("max referral depth exceeded".into());
|
||||
}
|
||||
|
||||
if let Some(cached) = cache.read().unwrap().lookup(qname, qtype) {
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
let mut ns_addrs = find_starting_ns(qname, cache, root_hints);
|
||||
let mut ns_idx = 0;
|
||||
|
||||
for _ in 0..MAX_REFERRAL_DEPTH {
|
||||
let ns_addr = match ns_addrs.get(ns_idx) {
|
||||
Some(addr) => *addr,
|
||||
None => return Err("no nameserver available".into()),
|
||||
};
|
||||
|
||||
debug!(
|
||||
"recursive: querying {} for {:?} {} (depth {})",
|
||||
ns_addr, qtype, qname, referral_depth
|
||||
);
|
||||
|
||||
let response = match send_query(qname, qtype, ns_addr).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
debug!("recursive: NS {} failed: {}", ns_addr, e);
|
||||
ns_idx += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if !response.answers.is_empty() {
|
||||
let has_target = response.answers.iter().any(|r| r.query_type() == qtype);
|
||||
|
||||
if has_target || qtype == QueryType::CNAME {
|
||||
cache.write().unwrap().insert(qname, qtype, &response);
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
if let Some(cname_target) = extract_cname_target(&response, qname) {
|
||||
if cname_depth >= MAX_CNAME_DEPTH {
|
||||
return Err("max CNAME depth exceeded".into());
|
||||
}
|
||||
debug!("recursive: chasing CNAME {} -> {}", qname, cname_target);
|
||||
let final_resp = resolve_iterative(
|
||||
&cname_target,
|
||||
qtype,
|
||||
cache,
|
||||
root_hints,
|
||||
0,
|
||||
cname_depth + 1,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut combined = response;
|
||||
combined.answers.extend(final_resp.answers);
|
||||
combined.header.rescode = final_resp.header.rescode;
|
||||
cache.write().unwrap().insert(qname, qtype, &combined);
|
||||
return Ok(combined);
|
||||
}
|
||||
|
||||
cache.write().unwrap().insert(qname, qtype, &response);
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
if response.header.rescode == ResultCode::NXDOMAIN
|
||||
|| response.header.rescode == ResultCode::REFUSED
|
||||
{
|
||||
cache.write().unwrap().insert(qname, qtype, &response);
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
// Referral — extract NS + glue, cache glue, resolve NS addresses
|
||||
let ns_names = extract_ns_names(&response);
|
||||
if ns_names.is_empty() {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
// Cache glue + DS from referral (avoids separate fetch during DNSSEC validation)
|
||||
let mut new_ns_addrs = Vec::new();
|
||||
{
|
||||
let mut cache_w = cache.write().unwrap();
|
||||
cache_glue(&mut cache_w, &response, &ns_names);
|
||||
cache_ds_from_authority(&mut cache_w, &response);
|
||||
}
|
||||
for ns_name in &ns_names {
|
||||
let glue = glue_addrs_for(&response, ns_name);
|
||||
if !glue.is_empty() {
|
||||
new_ns_addrs.extend_from_slice(&glue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no glue, try cache (A then AAAA) then recursive resolve
|
||||
if new_ns_addrs.is_empty() {
|
||||
for ns_name in &ns_names {
|
||||
new_ns_addrs.extend(addrs_from_cache(cache, ns_name));
|
||||
|
||||
if new_ns_addrs.is_empty() && referral_depth < MAX_REFERRAL_DEPTH {
|
||||
debug!("recursive: resolving glue-less NS {}", ns_name);
|
||||
// Try A first, then AAAA
|
||||
for qt in [QueryType::A, QueryType::AAAA] {
|
||||
if let Ok(ns_resp) = resolve_iterative(
|
||||
ns_name,
|
||||
qt,
|
||||
cache,
|
||||
root_hints,
|
||||
referral_depth + 1,
|
||||
cname_depth,
|
||||
)
|
||||
.await
|
||||
{
|
||||
for rec in &ns_resp.answers {
|
||||
match rec {
|
||||
DnsRecord::A { addr, .. } => {
|
||||
new_ns_addrs.push(dns_addr(*addr));
|
||||
}
|
||||
DnsRecord::AAAA { addr, .. } => {
|
||||
new_ns_addrs.push(dns_addr(*addr));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !new_ns_addrs.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !new_ns_addrs.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if new_ns_addrs.is_empty() {
|
||||
return Err(format!("could not resolve any NS for {}", qname).into());
|
||||
}
|
||||
|
||||
ns_addrs = new_ns_addrs;
|
||||
ns_idx = 0;
|
||||
}
|
||||
|
||||
Err(format!("recursive resolution exhausted for {}", qname).into())
|
||||
})
|
||||
}
|
||||
|
||||
fn find_starting_ns(
|
||||
qname: &str,
|
||||
cache: &RwLock<DnsCache>,
|
||||
root_hints: &[SocketAddr],
|
||||
) -> Vec<SocketAddr> {
|
||||
let guard = cache.read().unwrap();
|
||||
|
||||
let mut pos = 0;
|
||||
loop {
|
||||
let zone = &qname[pos..];
|
||||
if let Some(cached) = guard.lookup(zone, QueryType::NS) {
|
||||
let mut addrs = Vec::new();
|
||||
for ns_rec in &cached.answers {
|
||||
if let DnsRecord::NS { host, .. } = ns_rec {
|
||||
for qt in [QueryType::A, QueryType::AAAA] {
|
||||
if let Some(resp) = guard.lookup(host, qt) {
|
||||
for rec in &resp.answers {
|
||||
match rec {
|
||||
DnsRecord::A { addr, .. } => {
|
||||
addrs.push(dns_addr(*addr));
|
||||
}
|
||||
DnsRecord::AAAA { addr, .. } => {
|
||||
addrs.push(dns_addr(*addr));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !addrs.is_empty() {
|
||||
debug!("recursive: starting from cached NS for zone '{}'", zone);
|
||||
return addrs;
|
||||
}
|
||||
}
|
||||
|
||||
match qname[pos..].find('.') {
|
||||
Some(dot) => pos += dot + 1,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
drop(guard);
|
||||
debug!(
|
||||
"recursive: starting from root hints ({} servers)",
|
||||
root_hints.len()
|
||||
);
|
||||
root_hints.to_vec()
|
||||
}
|
||||
|
||||
fn addrs_from_cache(cache: &RwLock<DnsCache>, name: &str) -> Vec<SocketAddr> {
|
||||
let guard = cache.read().unwrap();
|
||||
let mut addrs = Vec::new();
|
||||
for qt in [QueryType::A, QueryType::AAAA] {
|
||||
if let Some(pkt) = guard.lookup(name, qt) {
|
||||
for rec in &pkt.answers {
|
||||
match rec {
|
||||
DnsRecord::A { addr, .. } => addrs.push(dns_addr(*addr)),
|
||||
DnsRecord::AAAA { addr, .. } => addrs.push(dns_addr(*addr)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
addrs
|
||||
}
|
||||
|
||||
fn glue_addrs_for(response: &DnsPacket, ns_name: &str) -> Vec<SocketAddr> {
|
||||
response
|
||||
.resources
|
||||
.iter()
|
||||
.filter_map(|r| match r {
|
||||
DnsRecord::A { domain, addr, .. } if domain.eq_ignore_ascii_case(ns_name) => {
|
||||
Some(dns_addr(*addr))
|
||||
}
|
||||
DnsRecord::AAAA { domain, addr, .. } if domain.eq_ignore_ascii_case(ns_name) => {
|
||||
Some(dns_addr(*addr))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn cache_glue(cache: &mut DnsCache, response: &DnsPacket, ns_names: &[String]) {
|
||||
for ns_name in ns_names {
|
||||
let mut a_pkt: Option<DnsPacket> = None;
|
||||
let mut aaaa_pkt: Option<DnsPacket> = None;
|
||||
|
||||
for r in &response.resources {
|
||||
match r {
|
||||
DnsRecord::A { domain, addr, ttl } if domain.eq_ignore_ascii_case(ns_name) => {
|
||||
a_pkt
|
||||
.get_or_insert_with(make_glue_packet)
|
||||
.answers
|
||||
.push(DnsRecord::A {
|
||||
domain: ns_name.clone(),
|
||||
addr: *addr,
|
||||
ttl: *ttl,
|
||||
});
|
||||
}
|
||||
DnsRecord::AAAA { domain, addr, ttl } if domain.eq_ignore_ascii_case(ns_name) => {
|
||||
aaaa_pkt
|
||||
.get_or_insert_with(make_glue_packet)
|
||||
.answers
|
||||
.push(DnsRecord::AAAA {
|
||||
domain: ns_name.clone(),
|
||||
addr: *addr,
|
||||
ttl: *ttl,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pkt) = a_pkt {
|
||||
cache.insert(ns_name, QueryType::A, &pkt);
|
||||
}
|
||||
if let Some(pkt) = aaaa_pkt {
|
||||
cache.insert(ns_name, QueryType::AAAA, &pkt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache DS + DS-covering RRSIG records from referral authority sections.
|
||||
fn cache_ds_from_authority(cache: &mut DnsCache, response: &DnsPacket) {
|
||||
let mut ds_by_domain: Vec<(String, DnsPacket)> = Vec::new();
|
||||
|
||||
for r in &response.authorities {
|
||||
match r {
|
||||
DnsRecord::DS { domain, .. } => {
|
||||
let key = domain.to_lowercase();
|
||||
let pkt = match ds_by_domain.iter_mut().find(|(d, _)| *d == key) {
|
||||
Some((_, pkt)) => pkt,
|
||||
None => {
|
||||
ds_by_domain.push((key, make_glue_packet()));
|
||||
&mut ds_by_domain.last_mut().unwrap().1
|
||||
}
|
||||
};
|
||||
pkt.answers.push(r.clone());
|
||||
}
|
||||
DnsRecord::RRSIG {
|
||||
domain,
|
||||
type_covered,
|
||||
..
|
||||
} if QueryType::from_num(*type_covered) == QueryType::DS => {
|
||||
let key = domain.to_lowercase();
|
||||
let pkt = match ds_by_domain.iter_mut().find(|(d, _)| *d == key) {
|
||||
Some((_, pkt)) => pkt,
|
||||
None => {
|
||||
ds_by_domain.push((key, make_glue_packet()));
|
||||
&mut ds_by_domain.last_mut().unwrap().1
|
||||
}
|
||||
};
|
||||
pkt.answers.push(r.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
for (domain, pkt) in &ds_by_domain {
|
||||
if !pkt.answers.is_empty() {
|
||||
cache.insert(domain, QueryType::DS, pkt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_glue_packet() -> DnsPacket {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.response = true;
|
||||
pkt.header.rescode = ResultCode::NOERROR;
|
||||
pkt
|
||||
}
|
||||
|
||||
async fn send_query(qname: &str, qtype: QueryType, server: SocketAddr) -> crate::Result<DnsPacket> {
|
||||
let mut query = DnsPacket::new();
|
||||
query.header.id = next_id();
|
||||
query.header.recursion_desired = false;
|
||||
query
|
||||
.questions
|
||||
.push(DnsQuestion::new(qname.to_string(), qtype));
|
||||
query.edns = Some(crate::packet::EdnsOpt {
|
||||
do_bit: true,
|
||||
..Default::default()
|
||||
});
|
||||
forward_udp(&query, server, NS_QUERY_TIMEOUT).await
|
||||
}
|
||||
|
||||
fn extract_cname_target(response: &DnsPacket, qname: &str) -> Option<String> {
|
||||
response.answers.iter().find_map(|r| match r {
|
||||
DnsRecord::CNAME { domain, host, .. } if domain.eq_ignore_ascii_case(qname) => {
|
||||
Some(host.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_ns_names(response: &DnsPacket) -> Vec<String> {
|
||||
response
|
||||
.authorities
|
||||
.iter()
|
||||
.filter_map(|r| match r {
|
||||
DnsRecord::NS { host, .. } => Some(host.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn parse_root_hints(hints: &[String]) -> Vec<SocketAddr> {
|
||||
hints
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
s.parse::<std::net::IpAddr>()
|
||||
.map(|ip| SocketAddr::new(ip, 53))
|
||||
.map_err(|e| log::warn!("invalid root hint '{}': {}", s, e))
|
||||
.ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
#[test]
|
||||
fn extract_ns_from_authority() {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.authorities.push(DnsRecord::NS {
|
||||
domain: "example.com".into(),
|
||||
host: "ns1.example.com".into(),
|
||||
ttl: 3600,
|
||||
});
|
||||
pkt.authorities.push(DnsRecord::NS {
|
||||
domain: "example.com".into(),
|
||||
host: "ns2.example.com".into(),
|
||||
ttl: 3600,
|
||||
});
|
||||
let names = extract_ns_names(&pkt);
|
||||
assert_eq!(names, vec!["ns1.example.com", "ns2.example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glue_extraction_a() {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.resources.push(DnsRecord::A {
|
||||
domain: "ns1.example.com".into(),
|
||||
addr: Ipv4Addr::new(1, 2, 3, 4),
|
||||
ttl: 3600,
|
||||
});
|
||||
let addrs = glue_addrs_for(&pkt, "ns1.example.com");
|
||||
assert_eq!(addrs, vec![dns_addr(Ipv4Addr::new(1, 2, 3, 4))]);
|
||||
assert!(glue_addrs_for(&pkt, "ns3.example.com").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glue_extraction_aaaa() {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.resources.push(DnsRecord::AAAA {
|
||||
domain: "ns1.example.com".into(),
|
||||
addr: "2001:db8::1".parse().unwrap(),
|
||||
ttl: 3600,
|
||||
});
|
||||
pkt.resources.push(DnsRecord::A {
|
||||
domain: "ns1.example.com".into(),
|
||||
addr: Ipv4Addr::new(1, 2, 3, 4),
|
||||
ttl: 3600,
|
||||
});
|
||||
let addrs = glue_addrs_for(&pkt, "ns1.example.com");
|
||||
assert_eq!(addrs.len(), 2);
|
||||
// AAAA first (order matches resources), then A
|
||||
assert_eq!(
|
||||
addrs[0],
|
||||
dns_addr("2001:db8::1".parse::<Ipv6Addr>().unwrap())
|
||||
);
|
||||
assert_eq!(addrs[1], dns_addr(Ipv4Addr::new(1, 2, 3, 4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cname_extraction() {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.answers.push(DnsRecord::CNAME {
|
||||
domain: "www.example.com".into(),
|
||||
host: "example.com".into(),
|
||||
ttl: 300,
|
||||
});
|
||||
assert_eq!(
|
||||
extract_cname_target(&pkt, "www.example.com"),
|
||||
Some("example.com".into())
|
||||
);
|
||||
assert_eq!(extract_cname_target(&pkt, "other.com"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_root_hints_valid() {
|
||||
let hints = vec!["198.41.0.4".into(), "199.9.14.201".into()];
|
||||
let addrs = parse_root_hints(&hints);
|
||||
assert_eq!(addrs.len(), 2);
|
||||
assert_eq!(addrs[0], dns_addr(Ipv4Addr::new(198, 41, 0, 4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_root_hints_skips_invalid() {
|
||||
let hints = vec![
|
||||
"198.41.0.4".into(),
|
||||
"not-an-ip".into(),
|
||||
"192.33.4.12".into(),
|
||||
];
|
||||
let addrs = parse_root_hints(&hints);
|
||||
assert_eq!(addrs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_starting_ns_falls_back_to_hints() {
|
||||
let cache = RwLock::new(DnsCache::new(100, 60, 86400));
|
||||
let hints = vec![
|
||||
dns_addr(Ipv4Addr::new(198, 41, 0, 4)),
|
||||
dns_addr(Ipv4Addr::new(199, 9, 14, 201)),
|
||||
];
|
||||
let addrs = find_starting_ns("example.com", &cache, &hints);
|
||||
assert_eq!(addrs, hints);
|
||||
}
|
||||
}
|
||||
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,
|
||||
|
||||
401
tests/integration.sh
Executable file
401
tests/integration.sh
Executable file
@@ -0,0 +1,401 @@
|
||||
#!/usr/bin/env bash
|
||||
# Integration test suite for Numa
|
||||
# Runs a test instance on port 5354, validates all features, exits with status.
|
||||
# Usage: ./tests/integration.sh [release|debug]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MODE="${1:-release}"
|
||||
BINARY="./target/$MODE/numa"
|
||||
PORT=5354
|
||||
API_PORT=5381
|
||||
CONFIG="/tmp/numa-integration-test.toml"
|
||||
LOG="/tmp/numa-integration-test.log"
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
|
||||
# Colors
|
||||
GREEN="\033[32m"
|
||||
RED="\033[31m"
|
||||
DIM="\033[90m"
|
||||
RESET="\033[0m"
|
||||
|
||||
check() {
|
||||
local name="$1"
|
||||
local expected="$2"
|
||||
local actual="$3"
|
||||
|
||||
if echo "$actual" | grep -q "$expected"; then
|
||||
PASSED=$((PASSED + 1))
|
||||
printf " ${GREEN}✓${RESET} %s\n" "$name"
|
||||
else
|
||||
FAILED=$((FAILED + 1))
|
||||
printf " ${RED}✗${RESET} %s\n" "$name"
|
||||
printf " ${DIM}expected: %s${RESET}\n" "$expected"
|
||||
printf " ${DIM} got: %s${RESET}\n" "$actual"
|
||||
fi
|
||||
}
|
||||
|
||||
# Build if needed
|
||||
if [ ! -f "$BINARY" ]; then
|
||||
echo "Building $MODE..."
|
||||
cargo build --$MODE
|
||||
fi
|
||||
|
||||
run_test_suite() {
|
||||
local SUITE_NAME="$1"
|
||||
local SUITE_CONFIG="$2"
|
||||
|
||||
cat > "$CONFIG" << CONF
|
||||
$SUITE_CONFIG
|
||||
CONF
|
||||
|
||||
echo "Starting Numa on :$PORT ($SUITE_NAME)..."
|
||||
RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 &
|
||||
NUMA_PID=$!
|
||||
sleep 4
|
||||
|
||||
if ! kill -0 "$NUMA_PID" 2>/dev/null; then
|
||||
echo "Failed to start Numa:"
|
||||
tail -5 "$LOG"
|
||||
return 1
|
||||
fi
|
||||
|
||||
DIG="dig @127.0.0.1 -p $PORT +time=5 +tries=1"
|
||||
|
||||
echo ""
|
||||
echo "=== Resolution ==="
|
||||
|
||||
check "A record (google.com)" \
|
||||
"." \
|
||||
"$($DIG google.com A +short)"
|
||||
|
||||
check "AAAA record (google.com)" \
|
||||
":" \
|
||||
"$($DIG google.com AAAA +short)"
|
||||
|
||||
check "CNAME chasing (www.github.com)" \
|
||||
"github.com" \
|
||||
"$($DIG www.github.com A +short)"
|
||||
|
||||
check "MX records (gmail.com)" \
|
||||
"gmail-smtp-in" \
|
||||
"$($DIG gmail.com MX +short)"
|
||||
|
||||
check "NS records (cloudflare.com)" \
|
||||
"cloudflare.com" \
|
||||
"$($DIG cloudflare.com NS +short)"
|
||||
|
||||
check "NXDOMAIN" \
|
||||
"NXDOMAIN" \
|
||||
"$($DIG nope12345678.com A 2>&1 | grep status:)"
|
||||
|
||||
echo ""
|
||||
echo "=== Ad Blocking ==="
|
||||
|
||||
if echo "$SUITE_CONFIG" | grep -q 'enabled = true'; then
|
||||
check "Blocked domain → 0.0.0.0" \
|
||||
"0.0.0.0" \
|
||||
"$($DIG ads.google.com A +short)"
|
||||
else
|
||||
local ADS=$($DIG ads.google.com A +short 2>/dev/null)
|
||||
if echo "$ADS" | grep -q "0.0.0.0"; then
|
||||
check "Blocking disabled but domain blocked" "should-resolve" "0.0.0.0"
|
||||
else
|
||||
check "Blocking disabled — domain resolves normally" "." "$ADS"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Cache ==="
|
||||
|
||||
$DIG example.com A +short > /dev/null 2>&1
|
||||
sleep 1
|
||||
check "Cache hit returns result" \
|
||||
"." \
|
||||
"$($DIG example.com A +short)"
|
||||
|
||||
echo ""
|
||||
echo "=== Connectivity ==="
|
||||
|
||||
# Apple captive portal can be slow/flaky on some networks
|
||||
local CAPTIVE
|
||||
CAPTIVE=$($DIG captive.apple.com A +short 2>/dev/null || echo "timeout")
|
||||
if echo "$CAPTIVE" | grep -q "apple\|17\.\|timeout"; then
|
||||
check "Apple captive portal" "." "$CAPTIVE"
|
||||
else
|
||||
check "Apple captive portal" "apple" "$CAPTIVE"
|
||||
fi
|
||||
|
||||
check "CDN (jsdelivr)" \
|
||||
"." \
|
||||
"$($DIG cdn.jsdelivr.net A +short)"
|
||||
|
||||
echo ""
|
||||
echo "=== API ==="
|
||||
|
||||
check "Health endpoint" \
|
||||
"ok" \
|
||||
"$(curl -s http://127.0.0.1:$API_PORT/health)"
|
||||
|
||||
check "Stats endpoint" \
|
||||
"uptime_secs" \
|
||||
"$(curl -s http://127.0.0.1:$API_PORT/stats)"
|
||||
|
||||
echo ""
|
||||
echo "=== Log Health ==="
|
||||
|
||||
ERRORS=$(grep -c 'RECURSIVE ERROR\|PARSE ERROR\|HANDLER ERROR\|panic' "$LOG" 2>/dev/null || echo 0)
|
||||
check "No critical errors in log" \
|
||||
"0" \
|
||||
"$ERRORS"
|
||||
|
||||
kill "$NUMA_PID" 2>/dev/null || true
|
||||
wait "$NUMA_PID" 2>/dev/null || true
|
||||
sleep 1
|
||||
}
|
||||
|
||||
# ---- Suite 1: Recursive mode + DNSSEC ----
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════╗"
|
||||
echo "║ Suite 1: Recursive + DNSSEC + Blocking ║"
|
||||
echo "╚══════════════════════════════════════════╝"
|
||||
|
||||
run_test_suite "recursive + DNSSEC + blocking" "
|
||||
[server]
|
||||
bind_addr = \"127.0.0.1:5354\"
|
||||
api_port = 5381
|
||||
|
||||
[upstream]
|
||||
mode = \"recursive\"
|
||||
|
||||
[cache]
|
||||
max_entries = 10000
|
||||
min_ttl = 60
|
||||
max_ttl = 86400
|
||||
|
||||
[blocking]
|
||||
enabled = true
|
||||
|
||||
[proxy]
|
||||
enabled = false
|
||||
|
||||
[dnssec]
|
||||
enabled = true
|
||||
"
|
||||
|
||||
DIG="dig @127.0.0.1 -p $PORT +time=5 +tries=1"
|
||||
|
||||
echo ""
|
||||
echo "=== DNSSEC (recursive only) ==="
|
||||
|
||||
# Re-start for DNSSEC checks (suite 1 instance was killed)
|
||||
RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 &
|
||||
NUMA_PID=$!
|
||||
sleep 4
|
||||
|
||||
check "AD bit set (cloudflare.com)" \
|
||||
" ad" \
|
||||
"$($DIG cloudflare.com A +dnssec 2>&1 | grep flags:)"
|
||||
|
||||
check "EDNS DO bit echoed" \
|
||||
"flags: do" \
|
||||
"$($DIG cloudflare.com A +dnssec 2>&1 | grep 'EDNS:')"
|
||||
|
||||
kill "$NUMA_PID" 2>/dev/null || true
|
||||
wait "$NUMA_PID" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# ---- Suite 2: Forward mode (backward compat) ----
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════╗"
|
||||
echo "║ Suite 2: Forward (DoH) + Blocking ║"
|
||||
echo "╚══════════════════════════════════════════╝"
|
||||
|
||||
run_test_suite "forward DoH + blocking" "
|
||||
[server]
|
||||
bind_addr = \"127.0.0.1:5354\"
|
||||
api_port = 5381
|
||||
|
||||
[upstream]
|
||||
mode = \"forward\"
|
||||
address = \"https://9.9.9.9/dns-query\"
|
||||
|
||||
[cache]
|
||||
max_entries = 10000
|
||||
min_ttl = 60
|
||||
max_ttl = 86400
|
||||
|
||||
[blocking]
|
||||
enabled = true
|
||||
|
||||
[proxy]
|
||||
enabled = false
|
||||
"
|
||||
|
||||
# ---- Suite 3: Forward UDP (plain, no DoH) ----
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════╗"
|
||||
echo "║ Suite 3: Forward (UDP) + No Blocking ║"
|
||||
echo "╚══════════════════════════════════════════╝"
|
||||
|
||||
run_test_suite "forward UDP, no blocking" "
|
||||
[server]
|
||||
bind_addr = \"127.0.0.1:5354\"
|
||||
api_port = 5381
|
||||
|
||||
[upstream]
|
||||
mode = \"forward\"
|
||||
address = \"9.9.9.9\"
|
||||
port = 53
|
||||
|
||||
[cache]
|
||||
max_entries = 10000
|
||||
min_ttl = 60
|
||||
max_ttl = 86400
|
||||
|
||||
[blocking]
|
||||
enabled = false
|
||||
|
||||
[proxy]
|
||||
enabled = false
|
||||
"
|
||||
|
||||
# Verify blocking is actually off
|
||||
RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 &
|
||||
NUMA_PID=$!
|
||||
sleep 3
|
||||
|
||||
echo ""
|
||||
echo "=== Blocking disabled ==="
|
||||
ADS_RESULT=$($DIG ads.google.com A +short 2>/dev/null)
|
||||
if echo "$ADS_RESULT" | grep -q "0.0.0.0"; then
|
||||
check "ads.google.com NOT blocked (blocking disabled)" "not-0.0.0.0" "0.0.0.0"
|
||||
else
|
||||
check "ads.google.com NOT blocked (blocking disabled)" "." "$ADS_RESULT"
|
||||
fi
|
||||
|
||||
kill "$NUMA_PID" 2>/dev/null || true
|
||||
wait "$NUMA_PID" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# ---- Suite 4: Local zones + Overrides API ----
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════╗"
|
||||
echo "║ Suite 4: Local Zones + Overrides API ║"
|
||||
echo "╚══════════════════════════════════════════╝"
|
||||
|
||||
cat > "$CONFIG" << 'CONF'
|
||||
[server]
|
||||
bind_addr = "127.0.0.1:5354"
|
||||
api_port = 5381
|
||||
|
||||
[upstream]
|
||||
mode = "forward"
|
||||
address = "9.9.9.9"
|
||||
port = 53
|
||||
|
||||
[cache]
|
||||
max_entries = 10000
|
||||
|
||||
[blocking]
|
||||
enabled = false
|
||||
|
||||
[proxy]
|
||||
enabled = false
|
||||
|
||||
[[zones]]
|
||||
domain = "test.local"
|
||||
record_type = "A"
|
||||
value = "10.0.0.1"
|
||||
ttl = 60
|
||||
|
||||
[[zones]]
|
||||
domain = "mail.local"
|
||||
record_type = "MX"
|
||||
value = "10 smtp.local"
|
||||
ttl = 60
|
||||
CONF
|
||||
|
||||
RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 &
|
||||
NUMA_PID=$!
|
||||
sleep 3
|
||||
|
||||
echo ""
|
||||
echo "=== Local Zones ==="
|
||||
|
||||
check "Local A record (test.local)" \
|
||||
"10.0.0.1" \
|
||||
"$($DIG test.local A +short)"
|
||||
|
||||
check "Local MX record (mail.local)" \
|
||||
"smtp.local" \
|
||||
"$($DIG mail.local MX +short)"
|
||||
|
||||
check "Non-local domain still resolves" \
|
||||
"." \
|
||||
"$($DIG example.com A +short)"
|
||||
|
||||
echo ""
|
||||
echo "=== Overrides API ==="
|
||||
|
||||
# Create override
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://127.0.0.1:$API_PORT/overrides \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"domain":"override.test","target":"192.168.1.100","duration_secs":60}')
|
||||
check "Create override (HTTP 200/201)" \
|
||||
"20" \
|
||||
"$HTTP_CODE"
|
||||
|
||||
sleep 1
|
||||
|
||||
check "Override resolves" \
|
||||
"192.168.1.100" \
|
||||
"$($DIG override.test A +short)"
|
||||
|
||||
# List overrides
|
||||
check "List overrides" \
|
||||
"override.test" \
|
||||
"$(curl -s http://127.0.0.1:$API_PORT/overrides)"
|
||||
|
||||
# Delete override
|
||||
curl -s -X DELETE http://127.0.0.1:$API_PORT/overrides/override.test > /dev/null
|
||||
|
||||
sleep 1
|
||||
|
||||
# After delete, should not resolve to override
|
||||
AFTER_DELETE=$($DIG override.test A +short 2>/dev/null)
|
||||
if echo "$AFTER_DELETE" | grep -q "192.168.1.100"; then
|
||||
check "Override deleted" "not-192.168.1.100" "$AFTER_DELETE"
|
||||
else
|
||||
check "Override deleted" "." "deleted"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Cache API ==="
|
||||
|
||||
check "Cache list" \
|
||||
"domain" \
|
||||
"$(curl -s http://127.0.0.1:$API_PORT/cache)"
|
||||
|
||||
# Flush cache
|
||||
curl -s -X DELETE http://127.0.0.1:$API_PORT/cache > /dev/null
|
||||
check "Cache flushed" \
|
||||
"0" \
|
||||
"$(curl -s http://127.0.0.1:$API_PORT/stats | grep -o '"entries":[0-9]*' | grep -o '[0-9]*')"
|
||||
|
||||
kill "$NUMA_PID" 2>/dev/null || true
|
||||
wait "$NUMA_PID" 2>/dev/null || true
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
TOTAL=$((PASSED + FAILED))
|
||||
if [ "$FAILED" -eq 0 ]; then
|
||||
printf "${GREEN}All %d tests passed.${RESET}\n" "$TOTAL"
|
||||
exit 0
|
||||
else
|
||||
printf "${RED}%d/%d tests failed.${RESET}\n" "$FAILED" "$TOTAL"
|
||||
echo ""
|
||||
echo "Log: $LOG"
|
||||
exit 1
|
||||
fi
|
||||
128
tests/network-probe.sh
Executable file
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