feat: recursive DNS + DNSSEC + TCP fallback (#17)
* feat: recursive resolution + full DNSSEC validation Numa becomes a true DNS resolver — resolves from root nameservers with complete DNSSEC chain-of-trust verification. Recursive resolution: - Iterative RFC 1034 from configurable root hints (13 default) - CNAME chasing (depth 8), referral following (depth 10) - A+AAAA glue extraction, IPv6 nameserver support - TLD priming: NS + DS + DNSKEY for 34 gTLDs + EU ccTLDs - Config: mode = "recursive" in [upstream], root_hints, prime_tlds DNSSEC (all 4 phases): - EDNS0 OPT pseudo-record (DO bit, 1232 payload per DNS Flag Day 2020) - DNSKEY, DS, RRSIG, NSEC, NSEC3 record types with wire read/write - Signature verification via ring: RSA/SHA-256, ECDSA P-256, Ed25519 - Chain-of-trust: zone DNSKEY → parent DS → root KSK (key tag 20326) - DNSKEY RRset self-signature verification (RRSIG(DNSKEY) by KSK) - RRSIG expiration/inception time validation - NSEC: NXDOMAIN gap proofs, NODATA type absence, wildcard denial - NSEC3: SHA-1 iterated hashing, closest encloser proof, hash range - Authority RRSIG verification for denial proofs - Config: [dnssec] enabled/strict (default false, opt-in) - AD bit on Secure, SERVFAIL on Bogus+strict - DnssecStatus cached per entry, ValidationStats logging Performance: - TLD chain pre-warmed on startup (root DNSKEY + TLD DS/DNSKEY) - Referral DS piggybacking from authority sections - DNSKEY prefetch before validation loop - Cold-cache validation: ~1 DNSKEY fetch (down from 5) - Benchmarks: RSA 10.9µs, ECDSA 174ns, DS verify 257ns Also: - write_qname fix for root domain "." (was producing malformed queries) - write_record_header() dedup, write_bytes() bulk writes - DnsRecord::domain() + query_type() accessors - UpstreamMode enum, DEFAULT_EDNS_PAYLOAD const - Real glue TTL (was hardcoded 3600) - DNSSEC restricted to recursive mode only Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: TCP fallback, query minimization, UDP auto-disable Transport resilience for restrictive networks (ISPs blocking UDP:53): - DNS-over-TCP fallback: UDP fail/truncation → automatic TCP retry - UDP auto-disable: after 3 consecutive failures, switch to TCP-first - IPv6 → TCP directly (UDP socket binds 0.0.0.0, can't reach IPv6) - Network change resets UDP detection for re-probing - Root hint rotation in TLD priming Privacy: - RFC 7816 query minimization: root servers see TLD only, not full name Code quality: - Merged find_starting_ns + find_starting_zone → find_closest_ns - Extracted resolve_ns_addrs_from_glue shared helper - Removed overall timeout wrapper (per-hop timeouts sufficient) - forward_tcp for DNS-over-TCP (RFC 1035 §4.2.2) Testing: - Mock TCP-only DNS server for fallback tests (no network needed) - tcp_fallback_resolves_when_udp_blocked - tcp_only_iterative_resolution - tcp_fallback_handles_nxdomain - udp_auto_disable_resets - Integration test suite (4 suites, 51 tests) - Network probe script (tests/network-probe.sh) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: DNSSEC verified badge in dashboard query log - Add dnssec field to QueryLogEntry, track validation status per query - DnssecStatus::as_str() for API serialization - Dashboard shows green checkmark next to DNSSEC-verified responses - Blog post: add "How keys get there" section, transport resilience section, trim code blocks, update What's Next Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use SVG shield for DNSSEC badge, update blog HTML Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: NS cache lookup from authorities, UDP re-probe, shield alignment - find_closest_ns checks authorities (not just answers) for NS records, fixing TLD priming cache misses that caused redundant root queries - Periodic UDP re-probe every 5min when disabled — re-enables UDP after switching from a restrictive network to an open one - Dashboard DNSSEC shield uses fixed-width container for alignment - Blog post: tuck key-tag into trust anchor paragraph Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: TCP single-write, mock server consistency, integration tests - TCP single-write fix: combine length prefix + message to avoid split segments that Microsoft/Azure DNS servers reject - Mock server (spawn_tcp_dns_server) updated to use single-write too - Tests: forward_tcp_wire_format, forward_tcp_single_segment_write - Integration: real-server checks for Microsoft/Office/Azure domains Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: recursive bar in dashboard, special-use domain interception Dashboard: - Add Recursive bar to resolution paths chart (cyan, distinct from Override) - Add RECURSIVE path tag style in query log Special-use domains (RFC 6761/6303/8880/9462): - .localhost → 127.0.0.1 (RFC 6761) - Private reverse PTR (10.x, 192.168.x, 172.16-31.x) → NXDOMAIN - _dns.resolver.arpa (DDR) → NXDOMAIN - ipv4only.arpa (NAT64) → 192.0.0.170/171 - mDNS service discovery for private ranges → NXDOMAIN Eliminates ~900ms SERVFAILs for macOS system queries that were hitting root servers unnecessarily. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: move generated blog HTML to site/blog/posts/, gitignore - Generated HTML now in site/blog/posts/ (gitignored) - CI workflow runs pandoc + make blog before deploy - Updated all internal blog links to /blog/posts/ path - blog/*.md remains the source of truth Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: review feedback — memory ordering, RRSIG time, NS resolution - Ordering::Relaxed → Acquire/Release for UDP_DISABLED/UDP_FAILURES (ARM correctness for cross-thread coordination) - RRSIG time validation: serial number arithmetic (RFC 4034 §3.1.5) + 300s clock skew fudge factor (matches BIND) - resolve_ns_addrs_from_glue collects addresses from ALL NS names, not just the first with glue (improves failover) - is_special_use_domain: eliminate 16 format! allocations per .in-addr.arpa query (parse octet instead) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: API endpoint tests, coverage target - 8 new axum handler tests: health, stats, query-log, overrides CRUD, cache, blocking stats, services CRUD, dashboard HTML - Tests use tower::oneshot — no network, no server startup - test_ctx() builds minimal ServerCtx for isolated testing - `make coverage` target (cargo-tarpaulin), separate from `make all` - 82 total tests (was 74) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
259
src/api.rs
259
src/api.rs
@@ -153,6 +153,7 @@ struct QueryLogResponse {
|
||||
path: String,
|
||||
rescode: String,
|
||||
latency_ms: f64,
|
||||
dnssec: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -178,6 +179,7 @@ struct LanStatsResponse {
|
||||
struct QueriesStats {
|
||||
total: u64,
|
||||
forwarded: u64,
|
||||
recursive: u64,
|
||||
cached: u64,
|
||||
local: u64,
|
||||
overridden: u64,
|
||||
@@ -460,6 +462,7 @@ async fn query_log(
|
||||
path: e.path.as_str().to_string(),
|
||||
rescode: e.rescode.as_str().to_string(),
|
||||
latency_ms: e.latency_us as f64 / 1000.0,
|
||||
dnssec: e.dnssec.as_str().to_string(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -477,7 +480,11 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
||||
let override_count = ctx.overrides.read().unwrap().active_count();
|
||||
let bl_stats = ctx.blocklist.read().unwrap().stats();
|
||||
|
||||
let upstream = ctx.upstream.lock().unwrap().to_string();
|
||||
let upstream = if ctx.upstream_mode == crate::config::UpstreamMode::Recursive {
|
||||
"recursive (root hints)".to_string()
|
||||
} else {
|
||||
ctx.upstream.lock().unwrap().to_string()
|
||||
};
|
||||
|
||||
Json(StatsResponse {
|
||||
uptime_secs: snap.uptime_secs,
|
||||
@@ -487,6 +494,7 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
||||
queries: QueriesStats {
|
||||
total: snap.total,
|
||||
forwarded: snap.forwarded,
|
||||
recursive: snap.recursive,
|
||||
cached: snap.cached,
|
||||
local: snap.local,
|
||||
overridden: snap.overridden,
|
||||
@@ -901,3 +909,252 @@ async fn check_tcp(addr: std::net::SocketAddr) -> bool {
|
||||
.map(|r| r.is_ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use http::Request;
|
||||
use std::sync::{Mutex, RwLock};
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn test_ctx() -> Arc<ServerCtx> {
|
||||
let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
Arc::new(ServerCtx {
|
||||
socket,
|
||||
zone_map: std::collections::HashMap::new(),
|
||||
cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)),
|
||||
stats: Mutex::new(crate::stats::ServerStats::new()),
|
||||
overrides: RwLock::new(crate::override_store::OverrideStore::new()),
|
||||
blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()),
|
||||
query_log: Mutex::new(crate::query_log::QueryLog::new(100)),
|
||||
services: Mutex::new(crate::service_store::ServiceStore::new()),
|
||||
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
|
||||
forwarding_rules: Vec::new(),
|
||||
upstream: Mutex::new(crate::forward::Upstream::Udp(
|
||||
"127.0.0.1:53".parse().unwrap(),
|
||||
)),
|
||||
upstream_auto: false,
|
||||
upstream_port: 53,
|
||||
lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST),
|
||||
timeout: std::time::Duration::from_secs(3),
|
||||
proxy_tld: "numa".to_string(),
|
||||
proxy_tld_suffix: ".numa".to_string(),
|
||||
lan_enabled: false,
|
||||
config_path: "/tmp/test-numa.toml".to_string(),
|
||||
config_found: false,
|
||||
config_dir: std::path::PathBuf::from("/tmp"),
|
||||
data_dir: std::path::PathBuf::from("/tmp"),
|
||||
tls_config: None,
|
||||
upstream_mode: crate::config::UpstreamMode::Forward,
|
||||
root_hints: Vec::new(),
|
||||
dnssec_enabled: false,
|
||||
dnssec_strict: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_returns_ok() {
|
||||
let ctx = test_ctx().await;
|
||||
let resp = router(ctx)
|
||||
.oneshot(Request::get("/health").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body = axum::body::to_bytes(resp.into_body(), 1000).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["status"], "ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stats_returns_json() {
|
||||
let ctx = test_ctx().await;
|
||||
let resp = router(ctx)
|
||||
.oneshot(Request::get("/stats").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(json["uptime_secs"].is_number());
|
||||
assert!(json["queries"]["total"].is_number());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_log_empty() {
|
||||
let ctx = test_ctx().await;
|
||||
let resp = router(ctx)
|
||||
.oneshot(
|
||||
Request::get("/query-log?limit=10")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(json.as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overrides_crud() {
|
||||
let ctx = test_ctx().await;
|
||||
let a = router(ctx.clone());
|
||||
|
||||
// Create
|
||||
let resp = a
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::post("/overrides")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"domain":"test.dev","target":"1.2.3.4","duration_secs":60}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.status().is_success());
|
||||
|
||||
// List
|
||||
let resp = a
|
||||
.clone()
|
||||
.oneshot(Request::get("/overrides").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
|
||||
assert!(String::from_utf8_lossy(&body).contains("test.dev"));
|
||||
|
||||
// Get
|
||||
let resp = a
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::get("/overrides/test.dev")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// Delete
|
||||
let resp = a
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::delete("/overrides/test.dev")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.status().is_success());
|
||||
|
||||
// Verify deleted
|
||||
let resp = a
|
||||
.oneshot(
|
||||
Request::get("/overrides/test.dev")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_list_and_flush() {
|
||||
let ctx = test_ctx().await;
|
||||
let a = router(ctx.clone());
|
||||
|
||||
// List (empty)
|
||||
let resp = a
|
||||
.clone()
|
||||
.oneshot(Request::get("/cache").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// Flush
|
||||
let resp = a
|
||||
.oneshot(Request::delete("/cache").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.status().is_success());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocking_stats_returns_json() {
|
||||
let ctx = test_ctx().await;
|
||||
let resp = router(ctx)
|
||||
.oneshot(Request::get("/blocking/stats").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(json["enabled"].is_boolean());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn services_crud() {
|
||||
let ctx = test_ctx().await;
|
||||
let a = router(ctx);
|
||||
|
||||
// Add service
|
||||
let resp = a
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::post("/services")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"name":"testapp","target_port":3000}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.status().is_success());
|
||||
|
||||
// List
|
||||
let resp = a
|
||||
.clone()
|
||||
.oneshot(Request::get("/services").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
|
||||
assert!(String::from_utf8_lossy(&body).contains("testapp"));
|
||||
|
||||
// Delete
|
||||
let resp = a
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::delete("/services/testapp")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.status().is_success());
|
||||
|
||||
// Verify deleted
|
||||
let resp = a
|
||||
.oneshot(Request::get("/services").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
|
||||
assert!(!String::from_utf8_lossy(&body).contains("testapp"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_returns_html() {
|
||||
let ctx = test_ctx().await;
|
||||
let resp = router(ctx)
|
||||
.oneshot(Request::get("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body = axum::body::to_bytes(resp.into_body(), 100000)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&body).contains("Numa"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,8 +164,16 @@ impl BytePacketBuffer {
|
||||
}
|
||||
|
||||
pub fn write_qname(&mut self, qname: &str) -> Result<()> {
|
||||
if qname.is_empty() || qname == "." {
|
||||
self.write_u8(0)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for label in qname.split('.') {
|
||||
let len = label.len();
|
||||
if len == 0 {
|
||||
continue; // skip empty labels from trailing dot
|
||||
}
|
||||
if len > 0x3f {
|
||||
return Err("Single label exceeds 63 characters of length".into());
|
||||
}
|
||||
@@ -180,6 +188,16 @@ impl BytePacketBuffer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_bytes(&mut self, data: &[u8]) -> Result<()> {
|
||||
let end = self.pos + data.len();
|
||||
if end > BUF_SIZE {
|
||||
return Err("End of buffer".into());
|
||||
}
|
||||
self.buf[self.pos..end].copy_from_slice(data);
|
||||
self.pos = end;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set(&mut self, pos: usize, val: u8) -> Result<()> {
|
||||
if pos >= BUF_SIZE {
|
||||
return Err("End of buffer".into());
|
||||
|
||||
42
src/cache.rs
42
src/cache.rs
@@ -5,10 +5,31 @@ use crate::packet::DnsPacket;
|
||||
use crate::question::QueryType;
|
||||
use crate::record::DnsRecord;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum DnssecStatus {
|
||||
Secure,
|
||||
Insecure,
|
||||
Bogus,
|
||||
#[default]
|
||||
Indeterminate,
|
||||
}
|
||||
|
||||
impl DnssecStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
DnssecStatus::Secure => "secure",
|
||||
DnssecStatus::Insecure => "insecure",
|
||||
DnssecStatus::Bogus => "bogus",
|
||||
DnssecStatus::Indeterminate => "indeterminate",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CacheEntry {
|
||||
packet: DnsPacket,
|
||||
inserted_at: Instant,
|
||||
ttl: Duration,
|
||||
dnssec_status: DnssecStatus,
|
||||
}
|
||||
|
||||
/// DNS cache using a two-level map (domain -> query_type -> entry) so that
|
||||
@@ -34,6 +55,14 @@ impl DnsCache {
|
||||
|
||||
/// Read-only lookup — expired entries are left in place (cleaned up on insert).
|
||||
pub fn lookup(&self, domain: &str, qtype: QueryType) -> Option<DnsPacket> {
|
||||
self.lookup_with_status(domain, qtype).map(|(pkt, _)| pkt)
|
||||
}
|
||||
|
||||
pub fn lookup_with_status(
|
||||
&self,
|
||||
domain: &str,
|
||||
qtype: QueryType,
|
||||
) -> Option<(DnsPacket, DnssecStatus)> {
|
||||
let type_map = self.entries.get(domain)?;
|
||||
let entry = type_map.get(&qtype)?;
|
||||
|
||||
@@ -50,10 +79,20 @@ impl DnsCache {
|
||||
adjust_ttls(&mut packet.authorities, remaining);
|
||||
adjust_ttls(&mut packet.resources, remaining);
|
||||
|
||||
Some(packet)
|
||||
Some((packet, entry.dnssec_status))
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, domain: &str, qtype: QueryType, packet: &DnsPacket) {
|
||||
self.insert_with_status(domain, qtype, packet, DnssecStatus::Indeterminate);
|
||||
}
|
||||
|
||||
pub fn insert_with_status(
|
||||
&mut self,
|
||||
domain: &str,
|
||||
qtype: QueryType,
|
||||
packet: &DnsPacket,
|
||||
dnssec_status: DnssecStatus,
|
||||
) {
|
||||
if self.entry_count >= self.max_entries {
|
||||
self.evict_expired();
|
||||
if self.entry_count >= self.max_entries {
|
||||
@@ -81,6 +120,7 @@ impl DnsCache {
|
||||
packet: packet.clone(),
|
||||
inserted_at: Instant::now(),
|
||||
ttl: Duration::from_secs(min_ttl as u64),
|
||||
dnssec_status,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ pub struct Config {
|
||||
pub services: Vec<ServiceConfig>,
|
||||
#[serde(default)]
|
||||
pub lan: LanConfig,
|
||||
#[serde(default)]
|
||||
pub dnssec: DnssecConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -61,26 +63,112 @@ fn default_api_port() -> u16 {
|
||||
5380
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, PartialEq, Eq, Clone, Copy)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum UpstreamMode {
|
||||
#[default]
|
||||
Forward,
|
||||
Recursive,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpstreamConfig {
|
||||
#[serde(default)]
|
||||
pub mode: UpstreamMode,
|
||||
#[serde(default = "default_upstream_addr")]
|
||||
pub address: String,
|
||||
#[serde(default = "default_upstream_port")]
|
||||
pub port: u16,
|
||||
#[serde(default = "default_timeout_ms")]
|
||||
pub timeout_ms: u64,
|
||||
#[serde(default = "default_root_hints")]
|
||||
pub root_hints: Vec<String>,
|
||||
#[serde(default = "default_prime_tlds")]
|
||||
pub prime_tlds: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for UpstreamConfig {
|
||||
fn default() -> Self {
|
||||
UpstreamConfig {
|
||||
mode: UpstreamMode::default(),
|
||||
address: default_upstream_addr(),
|
||||
port: default_upstream_port(),
|
||||
timeout_ms: default_timeout_ms(),
|
||||
root_hints: default_root_hints(),
|
||||
prime_tlds: default_prime_tlds(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_prime_tlds() -> Vec<String> {
|
||||
vec![
|
||||
// gTLDs
|
||||
"com".into(),
|
||||
"net".into(),
|
||||
"org".into(),
|
||||
"info".into(),
|
||||
"io".into(),
|
||||
"dev".into(),
|
||||
"app".into(),
|
||||
"xyz".into(),
|
||||
"me".into(),
|
||||
// EU + European ccTLDs
|
||||
"eu".into(),
|
||||
"uk".into(),
|
||||
"de".into(),
|
||||
"fr".into(),
|
||||
"nl".into(),
|
||||
"it".into(),
|
||||
"es".into(),
|
||||
"pl".into(),
|
||||
"se".into(),
|
||||
"no".into(),
|
||||
"dk".into(),
|
||||
"fi".into(),
|
||||
"at".into(),
|
||||
"be".into(),
|
||||
"ie".into(),
|
||||
"pt".into(),
|
||||
"cz".into(),
|
||||
"ro".into(),
|
||||
"gr".into(),
|
||||
"hu".into(),
|
||||
"bg".into(),
|
||||
"hr".into(),
|
||||
"sk".into(),
|
||||
"si".into(),
|
||||
"lt".into(),
|
||||
"lv".into(),
|
||||
"ee".into(),
|
||||
"ch".into(),
|
||||
"is".into(),
|
||||
// Other major ccTLDs
|
||||
"co".into(),
|
||||
"br".into(),
|
||||
"au".into(),
|
||||
"ca".into(),
|
||||
"jp".into(),
|
||||
]
|
||||
}
|
||||
|
||||
fn default_root_hints() -> Vec<String> {
|
||||
vec![
|
||||
"198.41.0.4".into(), // a.root-servers.net
|
||||
"199.9.14.201".into(), // b.root-servers.net
|
||||
"192.33.4.12".into(), // c.root-servers.net
|
||||
"199.7.91.13".into(), // d.root-servers.net
|
||||
"192.203.230.10".into(), // e.root-servers.net
|
||||
"192.5.5.241".into(), // f.root-servers.net
|
||||
"192.112.36.4".into(), // g.root-servers.net
|
||||
"198.97.190.53".into(), // h.root-servers.net
|
||||
"192.36.148.17".into(), // i.root-servers.net
|
||||
"192.58.128.30".into(), // j.root-servers.net
|
||||
"193.0.14.129".into(), // k.root-servers.net
|
||||
"199.7.83.42".into(), // l.root-servers.net
|
||||
"202.12.27.33".into(), // m.root-servers.net
|
||||
]
|
||||
}
|
||||
|
||||
fn default_upstream_addr() -> String {
|
||||
String::new() // empty = auto-detect from system resolver
|
||||
}
|
||||
@@ -88,7 +176,7 @@ fn default_upstream_port() -> u16 {
|
||||
53
|
||||
}
|
||||
fn default_timeout_ms() -> u64 {
|
||||
3000
|
||||
5000
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -250,6 +338,14 @@ fn default_lan_peer_timeout() -> u64 {
|
||||
90
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Default)]
|
||||
pub struct DnssecConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub strict: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
204
src/ctx.rs
204
src/ctx.rs
@@ -10,8 +10,8 @@ use tokio::net::UdpSocket;
|
||||
|
||||
use crate::blocklist::BlocklistStore;
|
||||
use crate::buffer::BytePacketBuffer;
|
||||
use crate::cache::DnsCache;
|
||||
use crate::config::ZoneMap;
|
||||
use crate::cache::{DnsCache, DnssecStatus};
|
||||
use crate::config::{UpstreamMode, ZoneMap};
|
||||
use crate::forward::{forward_query, Upstream};
|
||||
use crate::header::ResultCode;
|
||||
use crate::lan::PeerStore;
|
||||
@@ -27,6 +27,7 @@ use crate::system_dns::ForwardingRule;
|
||||
pub struct ServerCtx {
|
||||
pub socket: UdpSocket,
|
||||
pub zone_map: ZoneMap,
|
||||
/// std::sync::RwLock (not tokio) — locks must never be held across .await points.
|
||||
pub cache: RwLock<DnsCache>,
|
||||
pub stats: Mutex<ServerStats>,
|
||||
pub overrides: RwLock<OverrideStore>,
|
||||
@@ -48,6 +49,10 @@ pub struct ServerCtx {
|
||||
pub config_dir: PathBuf,
|
||||
pub data_dir: PathBuf,
|
||||
pub tls_config: Option<ArcSwap<ServerConfig>>,
|
||||
pub upstream_mode: UpstreamMode,
|
||||
pub root_hints: Vec<SocketAddr>,
|
||||
pub dnssec_enabled: bool,
|
||||
pub dnssec_strict: bool,
|
||||
}
|
||||
|
||||
pub async fn handle_query(
|
||||
@@ -72,12 +77,32 @@ pub async fn handle_query(
|
||||
|
||||
// Pipeline: overrides -> .tld interception -> blocklist -> local zones -> cache -> upstream
|
||||
// Each lock is scoped to avoid holding MutexGuard across await points.
|
||||
let (response, path) = {
|
||||
let (response, path, dnssec) = {
|
||||
let override_record = ctx.overrides.read().unwrap().lookup(&qname);
|
||||
if let Some(record) = override_record {
|
||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||
resp.answers.push(record);
|
||||
(resp, QueryPath::Overridden)
|
||||
(resp, QueryPath::Overridden, DnssecStatus::Indeterminate)
|
||||
} else if qname == "localhost" || qname.ends_with(".localhost") {
|
||||
// RFC 6761: .localhost always resolves to loopback
|
||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||
match qtype {
|
||||
QueryType::AAAA => resp.answers.push(DnsRecord::AAAA {
|
||||
domain: qname.clone(),
|
||||
addr: std::net::Ipv6Addr::LOCALHOST,
|
||||
ttl: 300,
|
||||
}),
|
||||
_ => resp.answers.push(DnsRecord::A {
|
||||
domain: qname.clone(),
|
||||
addr: std::net::Ipv4Addr::LOCALHOST,
|
||||
ttl: 300,
|
||||
}),
|
||||
}
|
||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||
} else if is_special_use_domain(&qname) {
|
||||
// RFC 6761/8880: private PTR, DDR, NAT64 — answer locally
|
||||
let resp = special_use_response(&query, &qname, qtype);
|
||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||
} else if !ctx.proxy_tld_suffix.is_empty()
|
||||
&& (qname.ends_with(&ctx.proxy_tld_suffix) || qname == ctx.proxy_tld)
|
||||
{
|
||||
@@ -115,7 +140,7 @@ pub async fn handle_query(
|
||||
ttl: 300,
|
||||
}),
|
||||
}
|
||||
(resp, QueryPath::Local)
|
||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||
} else if ctx.blocklist.read().unwrap().is_blocked(&qname) {
|
||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||
match qtype {
|
||||
@@ -130,17 +155,43 @@ pub async fn handle_query(
|
||||
ttl: 60,
|
||||
}),
|
||||
}
|
||||
(resp, QueryPath::Blocked)
|
||||
(resp, QueryPath::Blocked, DnssecStatus::Indeterminate)
|
||||
} else if let Some(records) = ctx.zone_map.get(qname.as_str()).and_then(|m| m.get(&qtype)) {
|
||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||
resp.answers = records.clone();
|
||||
(resp, QueryPath::Local)
|
||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||
} else {
|
||||
let cached = ctx.cache.read().unwrap().lookup(&qname, qtype);
|
||||
if let Some(cached) = cached {
|
||||
let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype);
|
||||
if let Some((cached, cached_dnssec)) = cached {
|
||||
let mut resp = cached;
|
||||
resp.header.id = query.header.id;
|
||||
(resp, QueryPath::Cached)
|
||||
if cached_dnssec == DnssecStatus::Secure {
|
||||
resp.header.authed_data = true;
|
||||
}
|
||||
(resp, QueryPath::Cached, cached_dnssec)
|
||||
} else if ctx.upstream_mode == UpstreamMode::Recursive {
|
||||
match crate::recursive::resolve_recursive(
|
||||
&qname,
|
||||
qtype,
|
||||
&ctx.cache,
|
||||
&query,
|
||||
&ctx.root_hints,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => (resp, QueryPath::Recursive, DnssecStatus::Indeterminate),
|
||||
Err(e) => {
|
||||
error!(
|
||||
"{} | {:?} {} | RECURSIVE ERROR | {}",
|
||||
src_addr, qtype, qname, e
|
||||
);
|
||||
(
|
||||
DnsPacket::response_from(&query, ResultCode::SERVFAIL),
|
||||
QueryPath::UpstreamError,
|
||||
DnssecStatus::Indeterminate,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let upstream =
|
||||
match crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) {
|
||||
@@ -150,7 +201,7 @@ pub async fn handle_query(
|
||||
match forward_query(&query, &upstream, ctx.timeout).await {
|
||||
Ok(resp) => {
|
||||
ctx.cache.write().unwrap().insert(&qname, qtype, &resp);
|
||||
(resp, QueryPath::Forwarded)
|
||||
(resp, QueryPath::Forwarded, DnssecStatus::Indeterminate)
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
@@ -160,6 +211,7 @@ pub async fn handle_query(
|
||||
(
|
||||
DnsPacket::response_from(&query, ResultCode::SERVFAIL),
|
||||
QueryPath::UpstreamError,
|
||||
DnssecStatus::Indeterminate,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -167,6 +219,55 @@ pub async fn handle_query(
|
||||
}
|
||||
};
|
||||
|
||||
let client_do = query.edns.as_ref().is_some_and(|e| e.do_bit);
|
||||
let mut response = response;
|
||||
|
||||
// DNSSEC validation (recursive/forwarded responses only)
|
||||
let mut dnssec = dnssec;
|
||||
if ctx.dnssec_enabled && path == QueryPath::Recursive {
|
||||
let (status, vstats) =
|
||||
crate::dnssec::validate_response(&response, &ctx.cache, &ctx.root_hints).await;
|
||||
|
||||
debug!(
|
||||
"DNSSEC | {} | {:?} | {}ms | dnskey_hit={} dnskey_fetch={} ds_hit={} ds_fetch={}",
|
||||
qname,
|
||||
status,
|
||||
vstats.elapsed_ms,
|
||||
vstats.dnskey_cache_hits,
|
||||
vstats.dnskey_fetches,
|
||||
vstats.ds_cache_hits,
|
||||
vstats.ds_fetches,
|
||||
);
|
||||
|
||||
dnssec = status;
|
||||
|
||||
if status == DnssecStatus::Secure {
|
||||
response.header.authed_data = true;
|
||||
}
|
||||
|
||||
if status == DnssecStatus::Bogus && ctx.dnssec_strict {
|
||||
response = DnsPacket::response_from(&query, ResultCode::SERVFAIL);
|
||||
}
|
||||
|
||||
ctx.cache
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert_with_status(&qname, qtype, &response, status);
|
||||
}
|
||||
|
||||
// Strip DNSSEC records if client didn't set DO bit
|
||||
if !client_do {
|
||||
strip_dnssec_records(&mut response);
|
||||
}
|
||||
|
||||
// Echo EDNS back if client sent it
|
||||
if query.edns.is_some() {
|
||||
response.edns = Some(crate::packet::EdnsOpt {
|
||||
do_bit: client_do,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
info!(
|
||||
@@ -216,7 +317,88 @@ pub async fn handle_query(
|
||||
path,
|
||||
rescode: response.header.rescode,
|
||||
latency_us: elapsed.as_micros() as u64,
|
||||
dnssec,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_dnssec_record(r: &DnsRecord) -> bool {
|
||||
matches!(
|
||||
r.query_type(),
|
||||
QueryType::RRSIG | QueryType::DNSKEY | QueryType::DS | QueryType::NSEC | QueryType::NSEC3
|
||||
)
|
||||
}
|
||||
|
||||
fn strip_dnssec_records(pkt: &mut DnsPacket) {
|
||||
pkt.answers.retain(|r| !is_dnssec_record(r));
|
||||
pkt.authorities.retain(|r| !is_dnssec_record(r));
|
||||
pkt.resources.retain(|r| !is_dnssec_record(r));
|
||||
}
|
||||
|
||||
fn is_special_use_domain(qname: &str) -> bool {
|
||||
if qname.ends_with(".in-addr.arpa") {
|
||||
// RFC 6303: private + loopback + link-local reverse DNS
|
||||
if qname.ends_with(".10.in-addr.arpa")
|
||||
|| qname.ends_with(".168.192.in-addr.arpa")
|
||||
|| qname.ends_with(".127.in-addr.arpa")
|
||||
|| qname.ends_with(".254.169.in-addr.arpa")
|
||||
|| qname.ends_with(".0.in-addr.arpa")
|
||||
|| qname.contains("_dns-sd._udp")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// 172.16-31.x.x (RFC 1918) — extract second octet from reverse name
|
||||
if qname.ends_with(".172.in-addr.arpa") {
|
||||
if let Some(octet_str) = qname
|
||||
.strip_suffix(".172.in-addr.arpa")
|
||||
.and_then(|s| s.rsplit('.').next())
|
||||
{
|
||||
if let Ok(octet) = octet_str.parse::<u8>() {
|
||||
return (16..=31).contains(&octet);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// DDR (RFC 9462)
|
||||
if qname == "_dns.resolver.arpa" || qname.ends_with("._dns.resolver.arpa") {
|
||||
return true;
|
||||
}
|
||||
// NAT64 (RFC 8880)
|
||||
qname == "ipv4only.arpa"
|
||||
}
|
||||
|
||||
fn special_use_response(query: &DnsPacket, qname: &str, qtype: QueryType) -> DnsPacket {
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
if qname == "ipv4only.arpa" {
|
||||
// RFC 8880: well-known NAT64 addresses
|
||||
let mut resp = DnsPacket::response_from(query, ResultCode::NOERROR);
|
||||
let domain = qname.to_string();
|
||||
match qtype {
|
||||
QueryType::A => {
|
||||
resp.answers.push(DnsRecord::A {
|
||||
domain: domain.clone(),
|
||||
addr: Ipv4Addr::new(192, 0, 0, 170),
|
||||
ttl: 300,
|
||||
});
|
||||
resp.answers.push(DnsRecord::A {
|
||||
domain,
|
||||
addr: Ipv4Addr::new(192, 0, 0, 171),
|
||||
ttl: 300,
|
||||
});
|
||||
}
|
||||
QueryType::AAAA => {
|
||||
resp.answers.push(DnsRecord::AAAA {
|
||||
domain,
|
||||
addr: Ipv6Addr::new(0x0064, 0xff9b, 0, 0, 0, 0, 0xc000, 0x00aa),
|
||||
ttl: 300,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
resp
|
||||
} else {
|
||||
DnsPacket::response_from(query, ResultCode::NXDOMAIN)
|
||||
}
|
||||
}
|
||||
|
||||
1679
src/dnssec.rs
Normal file
1679
src/dnssec.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,7 @@ pub async fn forward_query(
|
||||
}
|
||||
}
|
||||
|
||||
async fn forward_udp(
|
||||
pub(crate) async fn forward_udp(
|
||||
query: &DnsPacket,
|
||||
upstream: SocketAddr,
|
||||
timeout_duration: Duration,
|
||||
@@ -74,6 +74,39 @@ async fn forward_udp(
|
||||
DnsPacket::from_buffer(&mut recv_buffer)
|
||||
}
|
||||
|
||||
/// DNS over TCP (RFC 1035 §4.2.2): 2-byte length prefix, then the DNS message.
|
||||
pub(crate) async fn forward_tcp(
|
||||
query: &DnsPacket,
|
||||
upstream: SocketAddr,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<DnsPacket> {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
let mut send_buffer = BytePacketBuffer::new();
|
||||
query.write(&mut send_buffer)?;
|
||||
let msg = send_buffer.filled();
|
||||
|
||||
let mut stream = timeout(timeout_duration, TcpStream::connect(upstream)).await??;
|
||||
|
||||
// Single write: Microsoft/Azure DNS servers close TCP connections on split segments
|
||||
let mut outbuf = Vec::with_capacity(2 + msg.len());
|
||||
outbuf.extend_from_slice(&(msg.len() as u16).to_be_bytes());
|
||||
outbuf.extend_from_slice(msg);
|
||||
stream.write_all(&outbuf).await?;
|
||||
|
||||
// Read length-prefixed response
|
||||
let mut len_buf = [0u8; 2];
|
||||
timeout(timeout_duration, stream.read_exact(&mut len_buf)).await??;
|
||||
let resp_len = u16::from_be_bytes(len_buf) as usize;
|
||||
|
||||
let mut data = vec![0u8; resp_len];
|
||||
timeout(timeout_duration, stream.read_exact(&mut data)).await??;
|
||||
|
||||
let mut recv_buffer = BytePacketBuffer::from_bytes(&data);
|
||||
DnsPacket::from_buffer(&mut recv_buffer)
|
||||
}
|
||||
|
||||
async fn forward_doh(
|
||||
query: &DnsPacket,
|
||||
url: &str,
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod buffer;
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
pub mod ctx;
|
||||
pub mod dnssec;
|
||||
pub mod forward;
|
||||
pub mod header;
|
||||
pub mod lan;
|
||||
@@ -13,6 +14,7 @@ pub mod proxy;
|
||||
pub mod query_log;
|
||||
pub mod question;
|
||||
pub mod record;
|
||||
pub mod recursive;
|
||||
pub mod service_store;
|
||||
pub mod stats;
|
||||
pub mod system_dns;
|
||||
|
||||
30
src/main.rs
30
src/main.rs
@@ -199,6 +199,10 @@ async fn main() -> numa::Result<()> {
|
||||
config_dir: numa::config_dir(),
|
||||
data_dir: numa::data_dir(),
|
||||
tls_config: initial_tls,
|
||||
upstream_mode: config.upstream.mode,
|
||||
root_hints: numa::recursive::parse_root_hints(&config.upstream.root_hints),
|
||||
dnssec_enabled: config.dnssec.enabled,
|
||||
dnssec_strict: config.dnssec.strict,
|
||||
});
|
||||
|
||||
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
|
||||
@@ -276,7 +280,15 @@ async fn main() -> numa::Result<()> {
|
||||
row("DNS", g, &config.server.bind_addr);
|
||||
row("API", g, &api_url);
|
||||
row("Dashboard", g, &api_url);
|
||||
row("Upstream", g, &upstream_label);
|
||||
row(
|
||||
"Upstream",
|
||||
g,
|
||||
if ctx.upstream_mode == numa::config::UpstreamMode::Recursive {
|
||||
"recursive (root hints)"
|
||||
} else {
|
||||
&upstream_label
|
||||
},
|
||||
);
|
||||
row("Zones", g, &format!("{} records", zone_count));
|
||||
row(
|
||||
"Cache",
|
||||
@@ -336,6 +348,16 @@ async fn main() -> numa::Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// Prime TLD cache (recursive mode only)
|
||||
if ctx.upstream_mode == numa::config::UpstreamMode::Recursive {
|
||||
let prime_ctx = Arc::clone(&ctx);
|
||||
let prime_tlds = config.upstream.prime_tlds;
|
||||
tokio::spawn(async move {
|
||||
numa::recursive::prime_tld_cache(&prime_ctx.cache, &prime_ctx.root_hints, &prime_tlds)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn HTTP API server
|
||||
let api_ctx = Arc::clone(&ctx);
|
||||
let api_addr: SocketAddr = format!("{}:{}", config.server.api_bind_addr, api_port).parse()?;
|
||||
@@ -425,6 +447,7 @@ async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
|
||||
info!("LAN IP changed: {} → {}", current_ip, new_ip);
|
||||
*current_ip = new_ip;
|
||||
changed = true;
|
||||
numa::recursive::reset_udp_state();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,6 +480,11 @@ async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
|
||||
ctx.lan_peers.lock().unwrap().clear();
|
||||
info!("flushed LAN peers after network change");
|
||||
}
|
||||
|
||||
// Re-probe UDP every 5 minutes when disabled
|
||||
if tick.is_multiple_of(60) {
|
||||
numa::recursive::probe_udp(&ctx.root_hints).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
499
src/packet.rs
499
src/packet.rs
@@ -4,6 +4,31 @@ use crate::question::{DnsQuestion, QueryType};
|
||||
use crate::record::DnsRecord;
|
||||
use crate::Result;
|
||||
|
||||
/// Recommended EDNS0 UDP payload size (DNS Flag Day 2020) — avoids IP fragmentation.
|
||||
pub const DEFAULT_EDNS_PAYLOAD: u16 = 1232;
|
||||
|
||||
/// EDNS0 OPT pseudo-record (RFC 6891)
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EdnsOpt {
|
||||
pub udp_payload_size: u16,
|
||||
pub extended_rcode: u8,
|
||||
pub version: u8,
|
||||
pub do_bit: bool,
|
||||
pub options: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Default for EdnsOpt {
|
||||
fn default() -> Self {
|
||||
EdnsOpt {
|
||||
udp_payload_size: DEFAULT_EDNS_PAYLOAD,
|
||||
extended_rcode: 0,
|
||||
version: 0,
|
||||
do_bit: false,
|
||||
options: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DnsPacket {
|
||||
pub header: DnsHeader,
|
||||
@@ -11,6 +36,7 @@ pub struct DnsPacket {
|
||||
pub answers: Vec<DnsRecord>,
|
||||
pub authorities: Vec<DnsRecord>,
|
||||
pub resources: Vec<DnsRecord>,
|
||||
pub edns: Option<EdnsOpt>,
|
||||
}
|
||||
|
||||
impl Default for DnsPacket {
|
||||
@@ -27,6 +53,7 @@ impl DnsPacket {
|
||||
answers: Vec::new(),
|
||||
authorities: Vec::new(),
|
||||
resources: Vec::new(),
|
||||
edns: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,24 +87,53 @@ impl DnsPacket {
|
||||
result.authorities.push(rec);
|
||||
}
|
||||
for _ in 0..result.header.resource_entries {
|
||||
let rec = DnsRecord::read(buffer)?;
|
||||
result.resources.push(rec);
|
||||
// Peek at type field to detect OPT pseudo-records.
|
||||
// OPT name is always root (0x00), so name byte + type field starts at pos+1.
|
||||
let peek_pos = buffer.pos();
|
||||
let name_byte = buffer.get(peek_pos)?;
|
||||
let is_opt = if name_byte == 0 {
|
||||
// Root name (single zero byte) — peek at type
|
||||
let type_hi = buffer.get(peek_pos + 1)?;
|
||||
let type_lo = buffer.get(peek_pos + 2)?;
|
||||
u16::from_be_bytes([type_hi, type_lo]) == 41
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if is_opt {
|
||||
// Parse OPT manually to capture the class field (= UDP payload size)
|
||||
buffer.step(1)?; // skip root name (0x00)
|
||||
let _ = buffer.read_u16()?; // type (41)
|
||||
let udp_payload_size = buffer.read_u16()?; // class = UDP payload size
|
||||
let ttl_field = buffer.read_u32()?; // packed flags
|
||||
let rdlength = buffer.read_u16()?;
|
||||
let options = buffer.get_range(buffer.pos(), rdlength as usize)?.to_vec();
|
||||
buffer.step(rdlength as usize)?;
|
||||
|
||||
result.edns = Some(EdnsOpt {
|
||||
udp_payload_size,
|
||||
extended_rcode: ((ttl_field >> 24) & 0xFF) as u8,
|
||||
version: ((ttl_field >> 16) & 0xFF) as u8,
|
||||
do_bit: (ttl_field >> 15) & 1 == 1,
|
||||
options,
|
||||
});
|
||||
} else {
|
||||
let rec = DnsRecord::read(buffer)?;
|
||||
result.resources.push(rec);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn write(&self, buffer: &mut BytePacketBuffer) -> Result<()> {
|
||||
// Count known records without allocating filter Vecs
|
||||
let answer_count = self.answers.iter().filter(|r| !r.is_unknown()).count() as u16;
|
||||
let auth_count = self.authorities.iter().filter(|r| !r.is_unknown()).count() as u16;
|
||||
let res_count = self.resources.iter().filter(|r| !r.is_unknown()).count() as u16;
|
||||
let edns_count = if self.edns.is_some() { 1u16 } else { 0 };
|
||||
|
||||
let mut header = self.header.clone();
|
||||
header.questions = self.questions.len() as u16;
|
||||
header.answers = answer_count;
|
||||
header.authoritative_entries = auth_count;
|
||||
header.resource_entries = res_count;
|
||||
header.answers = self.answers.len() as u16;
|
||||
header.authoritative_entries = self.authorities.len() as u16;
|
||||
header.resource_entries = self.resources.len() as u16 + edns_count;
|
||||
|
||||
header.write(buffer)?;
|
||||
|
||||
@@ -85,19 +141,27 @@ impl DnsPacket {
|
||||
question.write(buffer)?;
|
||||
}
|
||||
for rec in &self.answers {
|
||||
if !rec.is_unknown() {
|
||||
rec.write(buffer)?;
|
||||
}
|
||||
rec.write(buffer)?;
|
||||
}
|
||||
for rec in &self.authorities {
|
||||
if !rec.is_unknown() {
|
||||
rec.write(buffer)?;
|
||||
}
|
||||
rec.write(buffer)?;
|
||||
}
|
||||
for rec in &self.resources {
|
||||
if !rec.is_unknown() {
|
||||
rec.write(buffer)?;
|
||||
}
|
||||
rec.write(buffer)?;
|
||||
}
|
||||
|
||||
// Write EDNS0 OPT pseudo-record
|
||||
if let Some(ref edns) = self.edns {
|
||||
buffer.write_u8(0)?; // root name
|
||||
buffer.write_u16(QueryType::OPT.to_num())?; // type 41
|
||||
buffer.write_u16(edns.udp_payload_size)?; // class = UDP payload size
|
||||
// TTL = extended_rcode(8) | version(8) | DO(1) | Z(15)
|
||||
let ttl_field = ((edns.extended_rcode as u32) << 24)
|
||||
| ((edns.version as u32) << 16)
|
||||
| (if edns.do_bit { 1u32 << 15 } else { 0 });
|
||||
buffer.write_u32(ttl_field)?;
|
||||
buffer.write_u16(edns.options.len() as u16)?; // RDLENGTH
|
||||
buffer.write_bytes(&edns.options)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -118,5 +182,404 @@ impl DnsPacket {
|
||||
for rec in &self.resources {
|
||||
println!("{:#?}", rec);
|
||||
}
|
||||
if let Some(ref edns) = self.edns {
|
||||
println!("EDNS: {:?}", edns);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::header::ResultCode;
|
||||
|
||||
#[test]
|
||||
fn edns_round_trip() {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.id = 0x1234;
|
||||
pkt.header.response = true;
|
||||
pkt.header.rescode = ResultCode::NOERROR;
|
||||
pkt.edns = Some(EdnsOpt {
|
||||
do_bit: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
pkt.write(&mut buf).unwrap();
|
||||
buf.seek(0).unwrap();
|
||||
let parsed = DnsPacket::from_buffer(&mut buf).unwrap();
|
||||
|
||||
let edns = parsed.edns.expect("EDNS should be present");
|
||||
assert_eq!(edns.udp_payload_size, DEFAULT_EDNS_PAYLOAD);
|
||||
assert!(edns.do_bit);
|
||||
assert_eq!(edns.version, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edns_do_bit_false() {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.id = 0x5678;
|
||||
pkt.header.response = true;
|
||||
pkt.edns = Some(EdnsOpt {
|
||||
udp_payload_size: 1232,
|
||||
do_bit: false,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
pkt.write(&mut buf).unwrap();
|
||||
buf.seek(0).unwrap();
|
||||
let parsed = DnsPacket::from_buffer(&mut buf).unwrap();
|
||||
|
||||
let edns = parsed.edns.expect("EDNS should be present");
|
||||
assert_eq!(edns.udp_payload_size, DEFAULT_EDNS_PAYLOAD);
|
||||
assert!(!edns.do_bit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_edns_by_default() {
|
||||
let pkt = DnsPacket::new();
|
||||
assert!(pkt.edns.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packet_without_edns_round_trips() {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.id = 0xABCD;
|
||||
pkt.header.response = true;
|
||||
pkt.header.rescode = ResultCode::NOERROR;
|
||||
pkt.answers.push(crate::record::DnsRecord::A {
|
||||
domain: "example.com".into(),
|
||||
addr: "1.2.3.4".parse().unwrap(),
|
||||
ttl: 300,
|
||||
});
|
||||
|
||||
let parsed = packet_round_trip(&pkt);
|
||||
assert!(parsed.edns.is_none());
|
||||
assert_eq!(parsed.answers.len(), 1);
|
||||
}
|
||||
|
||||
fn packet_round_trip(pkt: &DnsPacket) -> DnsPacket {
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
pkt.write(&mut buf).unwrap();
|
||||
let wire_len = buf.pos();
|
||||
buf.seek(0).unwrap();
|
||||
let parsed = DnsPacket::from_buffer(&mut buf).unwrap();
|
||||
// Verify we consumed exactly what was written
|
||||
assert_eq!(
|
||||
buf.pos(),
|
||||
wire_len,
|
||||
"parse did not consume all written bytes"
|
||||
);
|
||||
parsed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nxdomain_with_nsec_authority_round_trips() {
|
||||
use crate::question::DnsQuestion;
|
||||
use crate::record::DnsRecord;
|
||||
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.id = 0x1111;
|
||||
pkt.header.response = true;
|
||||
pkt.header.rescode = ResultCode::NXDOMAIN;
|
||||
pkt.questions.push(DnsQuestion::new(
|
||||
"nonexistent.example.com".into(),
|
||||
QueryType::A,
|
||||
));
|
||||
|
||||
pkt.authorities.push(DnsRecord::NSEC {
|
||||
domain: "alpha.example.com".into(),
|
||||
next_domain: "gamma.example.com".into(),
|
||||
type_bitmap: vec![0, 2, 0x40, 0x01], // A + MX
|
||||
ttl: 3600,
|
||||
});
|
||||
pkt.authorities.push(DnsRecord::RRSIG {
|
||||
domain: "alpha.example.com".into(),
|
||||
type_covered: QueryType::NSEC.to_num(),
|
||||
algorithm: 13,
|
||||
labels: 3,
|
||||
original_ttl: 3600,
|
||||
expiration: 1700000000,
|
||||
inception: 1690000000,
|
||||
key_tag: 12345,
|
||||
signer_name: "example.com".into(),
|
||||
signature: vec![0xAA; 64],
|
||||
ttl: 3600,
|
||||
});
|
||||
|
||||
// Wildcard denial NSEC
|
||||
pkt.authorities.push(DnsRecord::NSEC {
|
||||
domain: "example.com".into(),
|
||||
next_domain: "alpha.example.com".into(),
|
||||
type_bitmap: vec![0, 3, 0x62, 0x01, 0x80], // A, NS, SOA, MX, RRSIG
|
||||
ttl: 3600,
|
||||
});
|
||||
|
||||
pkt.edns = Some(EdnsOpt {
|
||||
do_bit: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let parsed = packet_round_trip(&pkt);
|
||||
|
||||
assert_eq!(parsed.header.id, 0x1111);
|
||||
assert_eq!(parsed.header.rescode, ResultCode::NXDOMAIN);
|
||||
assert_eq!(parsed.questions.len(), 1);
|
||||
assert_eq!(parsed.questions[0].name, "nonexistent.example.com");
|
||||
assert_eq!(parsed.authorities.len(), 3);
|
||||
|
||||
// Verify NSEC records survived
|
||||
if let DnsRecord::NSEC {
|
||||
domain,
|
||||
next_domain,
|
||||
type_bitmap,
|
||||
..
|
||||
} = &parsed.authorities[0]
|
||||
{
|
||||
assert_eq!(domain, "alpha.example.com");
|
||||
assert_eq!(next_domain, "gamma.example.com");
|
||||
assert_eq!(type_bitmap, &[0, 2, 0x40, 0x01]);
|
||||
} else {
|
||||
panic!("expected NSEC, got {:?}", parsed.authorities[0]);
|
||||
}
|
||||
|
||||
// Verify RRSIG survived
|
||||
if let DnsRecord::RRSIG {
|
||||
type_covered,
|
||||
signer_name,
|
||||
signature,
|
||||
..
|
||||
} = &parsed.authorities[1]
|
||||
{
|
||||
assert_eq!(*type_covered, QueryType::NSEC.to_num());
|
||||
assert_eq!(signer_name, "example.com");
|
||||
assert_eq!(signature.len(), 64);
|
||||
} else {
|
||||
panic!("expected RRSIG, got {:?}", parsed.authorities[1]);
|
||||
}
|
||||
|
||||
// Verify EDNS survived
|
||||
assert!(parsed.edns.as_ref().unwrap().do_bit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nxdomain_with_nsec3_authority_round_trips() {
|
||||
use crate::question::DnsQuestion;
|
||||
use crate::record::DnsRecord;
|
||||
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.id = 0x2222;
|
||||
pkt.header.response = true;
|
||||
pkt.header.rescode = ResultCode::NXDOMAIN;
|
||||
pkt.questions
|
||||
.push(DnsQuestion::new("no.example.com".into(), QueryType::AAAA));
|
||||
|
||||
// Three NSEC3 records (closest encloser, next closer, wildcard)
|
||||
let salt = vec![0xAB, 0xCD];
|
||||
pkt.authorities.push(DnsRecord::NSEC3 {
|
||||
domain: "ABC123.example.com".into(),
|
||||
hash_algorithm: 1,
|
||||
flags: 0,
|
||||
iterations: 5,
|
||||
salt: salt.clone(),
|
||||
next_hashed_owner: vec![
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
|
||||
0x0F, 0x10, 0x11, 0x12, 0x13, 0x14,
|
||||
],
|
||||
type_bitmap: vec![0, 2, 0x60, 0x01], // NS, SOA, MX
|
||||
ttl: 300,
|
||||
});
|
||||
pkt.authorities.push(DnsRecord::NSEC3 {
|
||||
domain: "DEF456.example.com".into(),
|
||||
hash_algorithm: 1,
|
||||
flags: 0,
|
||||
iterations: 5,
|
||||
salt: salt.clone(),
|
||||
next_hashed_owner: vec![0x20; 20],
|
||||
type_bitmap: vec![0, 1, 0x40], // A
|
||||
ttl: 300,
|
||||
});
|
||||
pkt.authorities.push(DnsRecord::RRSIG {
|
||||
domain: "ABC123.example.com".into(),
|
||||
type_covered: QueryType::NSEC3.to_num(),
|
||||
algorithm: 8,
|
||||
labels: 3,
|
||||
original_ttl: 300,
|
||||
expiration: 2000000000,
|
||||
inception: 1600000000,
|
||||
key_tag: 54321,
|
||||
signer_name: "example.com".into(),
|
||||
signature: vec![0xBB; 128],
|
||||
ttl: 300,
|
||||
});
|
||||
|
||||
pkt.edns = Some(EdnsOpt {
|
||||
do_bit: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let parsed = packet_round_trip(&pkt);
|
||||
|
||||
assert_eq!(parsed.header.rescode, ResultCode::NXDOMAIN);
|
||||
assert_eq!(parsed.authorities.len(), 3);
|
||||
|
||||
// Verify first NSEC3 survived with all fields intact
|
||||
if let DnsRecord::NSEC3 {
|
||||
domain,
|
||||
hash_algorithm,
|
||||
flags,
|
||||
iterations,
|
||||
salt: parsed_salt,
|
||||
next_hashed_owner,
|
||||
type_bitmap,
|
||||
..
|
||||
} = &parsed.authorities[0]
|
||||
{
|
||||
assert_eq!(domain, "abc123.example.com");
|
||||
assert_eq!(*hash_algorithm, 1);
|
||||
assert_eq!(*flags, 0);
|
||||
assert_eq!(*iterations, 5);
|
||||
assert_eq!(parsed_salt, &salt);
|
||||
assert_eq!(next_hashed_owner.len(), 20);
|
||||
assert_eq!(type_bitmap, &[0, 2, 0x60, 0x01]);
|
||||
} else {
|
||||
panic!("expected NSEC3, got {:?}", parsed.authorities[0]);
|
||||
}
|
||||
|
||||
// Verify RRSIG covering NSEC3
|
||||
if let DnsRecord::RRSIG {
|
||||
type_covered,
|
||||
algorithm,
|
||||
signature,
|
||||
..
|
||||
} = &parsed.authorities[2]
|
||||
{
|
||||
assert_eq!(*type_covered, QueryType::NSEC3.to_num());
|
||||
assert_eq!(*algorithm, 8);
|
||||
assert_eq!(signature.len(), 128);
|
||||
} else {
|
||||
panic!("expected RRSIG, got {:?}", parsed.authorities[2]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dnssec_answer_with_rrsig_round_trips() {
|
||||
use crate::question::DnsQuestion;
|
||||
use crate::record::DnsRecord;
|
||||
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.id = 0x3333;
|
||||
pkt.header.response = true;
|
||||
pkt.header.rescode = ResultCode::NOERROR;
|
||||
pkt.header.authed_data = true;
|
||||
pkt.questions
|
||||
.push(DnsQuestion::new("example.com".into(), QueryType::A));
|
||||
|
||||
pkt.answers.push(DnsRecord::A {
|
||||
domain: "example.com".into(),
|
||||
addr: "93.184.216.34".parse().unwrap(),
|
||||
ttl: 300,
|
||||
});
|
||||
pkt.answers.push(DnsRecord::RRSIG {
|
||||
domain: "example.com".into(),
|
||||
type_covered: QueryType::A.to_num(),
|
||||
algorithm: 13,
|
||||
labels: 2,
|
||||
original_ttl: 300,
|
||||
expiration: 1700000000,
|
||||
inception: 1690000000,
|
||||
key_tag: 11111,
|
||||
signer_name: "example.com".into(),
|
||||
signature: vec![0xCC; 64],
|
||||
ttl: 300,
|
||||
});
|
||||
|
||||
// Authority: NS + DS
|
||||
pkt.authorities.push(DnsRecord::NS {
|
||||
domain: "example.com".into(),
|
||||
host: "ns1.example.com".into(),
|
||||
ttl: 3600,
|
||||
});
|
||||
pkt.authorities.push(DnsRecord::DS {
|
||||
domain: "example.com".into(),
|
||||
key_tag: 22222,
|
||||
algorithm: 8,
|
||||
digest_type: 2,
|
||||
digest: vec![0xDD; 32],
|
||||
ttl: 86400,
|
||||
});
|
||||
|
||||
// Additional: glue A + DNSKEY
|
||||
pkt.resources.push(DnsRecord::A {
|
||||
domain: "ns1.example.com".into(),
|
||||
addr: "198.51.100.1".parse().unwrap(),
|
||||
ttl: 3600,
|
||||
});
|
||||
pkt.resources.push(DnsRecord::DNSKEY {
|
||||
domain: "example.com".into(),
|
||||
flags: 257,
|
||||
protocol: 3,
|
||||
algorithm: 13,
|
||||
public_key: vec![0xEE; 64],
|
||||
ttl: 3600,
|
||||
});
|
||||
|
||||
pkt.edns = Some(EdnsOpt {
|
||||
do_bit: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let parsed = packet_round_trip(&pkt);
|
||||
|
||||
assert_eq!(parsed.header.id, 0x3333);
|
||||
assert!(parsed.header.authed_data);
|
||||
assert_eq!(parsed.answers.len(), 2);
|
||||
assert_eq!(parsed.authorities.len(), 2);
|
||||
assert_eq!(parsed.resources.len(), 2);
|
||||
|
||||
// Verify A record
|
||||
if let DnsRecord::A { addr, .. } = &parsed.answers[0] {
|
||||
assert_eq!(addr.to_string(), "93.184.216.34");
|
||||
} else {
|
||||
panic!("expected A");
|
||||
}
|
||||
|
||||
// Verify RRSIG in answers
|
||||
if let DnsRecord::RRSIG {
|
||||
type_covered,
|
||||
key_tag,
|
||||
signer_name,
|
||||
..
|
||||
} = &parsed.answers[1]
|
||||
{
|
||||
assert_eq!(*type_covered, 1); // A
|
||||
assert_eq!(*key_tag, 11111);
|
||||
assert_eq!(signer_name, "example.com");
|
||||
} else {
|
||||
panic!("expected RRSIG");
|
||||
}
|
||||
|
||||
// Verify DS in authority
|
||||
if let DnsRecord::DS {
|
||||
key_tag, digest, ..
|
||||
} = &parsed.authorities[1]
|
||||
{
|
||||
assert_eq!(*key_tag, 22222);
|
||||
assert_eq!(digest.len(), 32);
|
||||
} else {
|
||||
panic!("expected DS");
|
||||
}
|
||||
|
||||
// Verify DNSKEY in additional
|
||||
if let DnsRecord::DNSKEY {
|
||||
flags, public_key, ..
|
||||
} = &parsed.resources[1]
|
||||
{
|
||||
assert_eq!(*flags, 257);
|
||||
assert_eq!(public_key.len(), 64);
|
||||
} else {
|
||||
panic!("expected DNSKEY");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::collections::VecDeque;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use crate::cache::DnssecStatus;
|
||||
use crate::header::ResultCode;
|
||||
use crate::question::QueryType;
|
||||
use crate::stats::QueryPath;
|
||||
@@ -14,6 +15,7 @@ pub struct QueryLogEntry {
|
||||
pub path: QueryPath,
|
||||
pub rescode: ResultCode,
|
||||
pub latency_us: u64,
|
||||
pub dnssec: DnssecStatus,
|
||||
}
|
||||
|
||||
pub struct QueryLog {
|
||||
|
||||
@@ -4,16 +4,22 @@ use crate::Result;
|
||||
#[derive(PartialEq, Eq, Debug, Clone, Hash, Copy)]
|
||||
pub enum QueryType {
|
||||
UNKNOWN(u16),
|
||||
A, // 1
|
||||
NS, // 2
|
||||
CNAME, // 5
|
||||
SOA, // 6
|
||||
PTR, // 12
|
||||
MX, // 15
|
||||
TXT, // 16
|
||||
AAAA, // 28
|
||||
SRV, // 33
|
||||
HTTPS, // 65
|
||||
A, // 1
|
||||
NS, // 2
|
||||
CNAME, // 5
|
||||
SOA, // 6
|
||||
PTR, // 12
|
||||
MX, // 15
|
||||
TXT, // 16
|
||||
AAAA, // 28
|
||||
SRV, // 33
|
||||
DS, // 43
|
||||
RRSIG, // 46
|
||||
NSEC, // 47
|
||||
DNSKEY, // 48
|
||||
NSEC3, // 50
|
||||
OPT, // 41 (EDNS0 pseudo-type)
|
||||
HTTPS, // 65
|
||||
}
|
||||
|
||||
impl QueryType {
|
||||
@@ -29,6 +35,12 @@ impl QueryType {
|
||||
QueryType::TXT => 16,
|
||||
QueryType::AAAA => 28,
|
||||
QueryType::SRV => 33,
|
||||
QueryType::OPT => 41,
|
||||
QueryType::DS => 43,
|
||||
QueryType::RRSIG => 46,
|
||||
QueryType::NSEC => 47,
|
||||
QueryType::DNSKEY => 48,
|
||||
QueryType::NSEC3 => 50,
|
||||
QueryType::HTTPS => 65,
|
||||
}
|
||||
}
|
||||
@@ -44,6 +56,12 @@ impl QueryType {
|
||||
16 => QueryType::TXT,
|
||||
28 => QueryType::AAAA,
|
||||
33 => QueryType::SRV,
|
||||
41 => QueryType::OPT,
|
||||
43 => QueryType::DS,
|
||||
46 => QueryType::RRSIG,
|
||||
47 => QueryType::NSEC,
|
||||
48 => QueryType::DNSKEY,
|
||||
50 => QueryType::NSEC3,
|
||||
65 => QueryType::HTTPS,
|
||||
_ => QueryType::UNKNOWN(num),
|
||||
}
|
||||
@@ -60,6 +78,12 @@ impl QueryType {
|
||||
QueryType::TXT => "TXT",
|
||||
QueryType::AAAA => "AAAA",
|
||||
QueryType::SRV => "SRV",
|
||||
QueryType::OPT => "OPT",
|
||||
QueryType::DS => "DS",
|
||||
QueryType::RRSIG => "RRSIG",
|
||||
QueryType::NSEC => "NSEC",
|
||||
QueryType::DNSKEY => "DNSKEY",
|
||||
QueryType::NSEC3 => "NSEC3",
|
||||
QueryType::HTTPS => "HTTPS",
|
||||
QueryType::UNKNOWN(_) => "UNKNOWN",
|
||||
}
|
||||
@@ -76,6 +100,11 @@ impl QueryType {
|
||||
"TXT" => Some(QueryType::TXT),
|
||||
"AAAA" => Some(QueryType::AAAA),
|
||||
"SRV" => Some(QueryType::SRV),
|
||||
"DS" => Some(QueryType::DS),
|
||||
"RRSIG" => Some(QueryType::RRSIG),
|
||||
"DNSKEY" => Some(QueryType::DNSKEY),
|
||||
"NSEC" => Some(QueryType::NSEC),
|
||||
"NSEC3" => Some(QueryType::NSEC3),
|
||||
"HTTPS" => Some(QueryType::HTTPS),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
492
src/record.rs
492
src/record.rs
@@ -11,7 +11,7 @@ pub enum DnsRecord {
|
||||
UNKNOWN {
|
||||
domain: String,
|
||||
qtype: u16,
|
||||
data_len: u16,
|
||||
data: Vec<u8>,
|
||||
ttl: u32,
|
||||
},
|
||||
A {
|
||||
@@ -40,11 +40,84 @@ pub enum DnsRecord {
|
||||
addr: Ipv6Addr,
|
||||
ttl: u32,
|
||||
},
|
||||
DNSKEY {
|
||||
domain: String,
|
||||
flags: u16,
|
||||
protocol: u8,
|
||||
algorithm: u8,
|
||||
public_key: Vec<u8>,
|
||||
ttl: u32,
|
||||
},
|
||||
DS {
|
||||
domain: String,
|
||||
key_tag: u16,
|
||||
algorithm: u8,
|
||||
digest_type: u8,
|
||||
digest: Vec<u8>,
|
||||
ttl: u32,
|
||||
},
|
||||
RRSIG {
|
||||
domain: String,
|
||||
type_covered: u16,
|
||||
algorithm: u8,
|
||||
labels: u8,
|
||||
original_ttl: u32,
|
||||
expiration: u32,
|
||||
inception: u32,
|
||||
key_tag: u16,
|
||||
signer_name: String,
|
||||
signature: Vec<u8>,
|
||||
ttl: u32,
|
||||
},
|
||||
NSEC {
|
||||
domain: String,
|
||||
next_domain: String,
|
||||
type_bitmap: Vec<u8>,
|
||||
ttl: u32,
|
||||
},
|
||||
NSEC3 {
|
||||
domain: String,
|
||||
hash_algorithm: u8,
|
||||
flags: u8,
|
||||
iterations: u16,
|
||||
salt: Vec<u8>,
|
||||
next_hashed_owner: Vec<u8>,
|
||||
type_bitmap: Vec<u8>,
|
||||
ttl: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl DnsRecord {
|
||||
pub fn is_unknown(&self) -> bool {
|
||||
matches!(self, DnsRecord::UNKNOWN { .. })
|
||||
pub fn domain(&self) -> &str {
|
||||
match self {
|
||||
DnsRecord::A { domain, .. }
|
||||
| DnsRecord::NS { domain, .. }
|
||||
| DnsRecord::CNAME { domain, .. }
|
||||
| DnsRecord::MX { domain, .. }
|
||||
| DnsRecord::AAAA { domain, .. }
|
||||
| DnsRecord::DNSKEY { domain, .. }
|
||||
| DnsRecord::DS { domain, .. }
|
||||
| DnsRecord::RRSIG { domain, .. }
|
||||
| DnsRecord::NSEC { domain, .. }
|
||||
| DnsRecord::NSEC3 { domain, .. }
|
||||
| DnsRecord::UNKNOWN { domain, .. } => domain,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query_type(&self) -> QueryType {
|
||||
match self {
|
||||
DnsRecord::A { .. } => QueryType::A,
|
||||
DnsRecord::AAAA { .. } => QueryType::AAAA,
|
||||
DnsRecord::NS { .. } => QueryType::NS,
|
||||
DnsRecord::CNAME { .. } => QueryType::CNAME,
|
||||
DnsRecord::MX { .. } => QueryType::MX,
|
||||
DnsRecord::DNSKEY { .. } => QueryType::DNSKEY,
|
||||
DnsRecord::DS { .. } => QueryType::DS,
|
||||
DnsRecord::RRSIG { .. } => QueryType::RRSIG,
|
||||
DnsRecord::NSEC { .. } => QueryType::NSEC,
|
||||
DnsRecord::NSEC3 { .. } => QueryType::NSEC3,
|
||||
DnsRecord::UNKNOWN { qtype, .. } => QueryType::UNKNOWN(*qtype),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ttl(&self) -> u32 {
|
||||
@@ -54,6 +127,11 @@ impl DnsRecord {
|
||||
| DnsRecord::CNAME { ttl, .. }
|
||||
| DnsRecord::MX { ttl, .. }
|
||||
| DnsRecord::AAAA { ttl, .. }
|
||||
| DnsRecord::DNSKEY { ttl, .. }
|
||||
| DnsRecord::DS { ttl, .. }
|
||||
| DnsRecord::RRSIG { ttl, .. }
|
||||
| DnsRecord::NSEC { ttl, .. }
|
||||
| DnsRecord::NSEC3 { ttl, .. }
|
||||
| DnsRecord::UNKNOWN { ttl, .. } => *ttl,
|
||||
}
|
||||
}
|
||||
@@ -65,6 +143,11 @@ impl DnsRecord {
|
||||
| DnsRecord::CNAME { ttl, .. }
|
||||
| DnsRecord::MX { ttl, .. }
|
||||
| DnsRecord::AAAA { ttl, .. }
|
||||
| DnsRecord::DNSKEY { ttl, .. }
|
||||
| DnsRecord::DS { ttl, .. }
|
||||
| DnsRecord::RRSIG { ttl, .. }
|
||||
| DnsRecord::NSEC { ttl, .. }
|
||||
| DnsRecord::NSEC3 { ttl, .. }
|
||||
| DnsRecord::UNKNOWN { ttl, .. } => *ttl = new_ttl,
|
||||
}
|
||||
}
|
||||
@@ -75,9 +158,10 @@ impl DnsRecord {
|
||||
|
||||
let qtype_num = buffer.read_u16()?;
|
||||
let qtype = QueryType::from_num(qtype_num);
|
||||
let _ = buffer.read_u16()?;
|
||||
let _ = buffer.read_u16()?; // class
|
||||
let ttl = buffer.read_u32()?;
|
||||
let data_len = buffer.read_u16()?;
|
||||
let rdata_start = buffer.pos();
|
||||
|
||||
match qtype {
|
||||
QueryType::A => {
|
||||
@@ -88,7 +172,6 @@ impl DnsRecord {
|
||||
((raw_addr >> 8) & 0xFF) as u8,
|
||||
(raw_addr & 0xFF) as u8,
|
||||
);
|
||||
|
||||
Ok(DnsRecord::A { domain, addr, ttl })
|
||||
}
|
||||
QueryType::AAAA => {
|
||||
@@ -106,13 +189,11 @@ impl DnsRecord {
|
||||
((raw_addr4 >> 16) & 0xFFFF) as u16,
|
||||
(raw_addr4 & 0xFFFF) as u16,
|
||||
);
|
||||
|
||||
Ok(DnsRecord::AAAA { domain, addr, ttl })
|
||||
}
|
||||
QueryType::NS => {
|
||||
let mut ns = String::with_capacity(64);
|
||||
buffer.read_qname(&mut ns)?;
|
||||
|
||||
Ok(DnsRecord::NS {
|
||||
domain,
|
||||
host: ns,
|
||||
@@ -122,7 +203,6 @@ impl DnsRecord {
|
||||
QueryType::CNAME => {
|
||||
let mut cname = String::with_capacity(64);
|
||||
buffer.read_qname(&mut cname)?;
|
||||
|
||||
Ok(DnsRecord::CNAME {
|
||||
domain,
|
||||
host: cname,
|
||||
@@ -133,7 +213,6 @@ impl DnsRecord {
|
||||
let priority = buffer.read_u16()?;
|
||||
let mut mx = String::with_capacity(64);
|
||||
buffer.read_qname(&mut mx)?;
|
||||
|
||||
Ok(DnsRecord::MX {
|
||||
domain,
|
||||
priority,
|
||||
@@ -141,13 +220,119 @@ impl DnsRecord {
|
||||
ttl,
|
||||
})
|
||||
}
|
||||
QueryType::DNSKEY => {
|
||||
let flags = buffer.read_u16()?;
|
||||
let protocol = buffer.read()?;
|
||||
let algorithm = buffer.read()?;
|
||||
let key_len = data_len as usize - 4; // flags(2) + protocol(1) + algorithm(1)
|
||||
let public_key = buffer.get_range(buffer.pos(), key_len)?.to_vec();
|
||||
buffer.step(key_len)?;
|
||||
Ok(DnsRecord::DNSKEY {
|
||||
domain,
|
||||
flags,
|
||||
protocol,
|
||||
algorithm,
|
||||
public_key,
|
||||
ttl,
|
||||
})
|
||||
}
|
||||
QueryType::DS => {
|
||||
let key_tag = buffer.read_u16()?;
|
||||
let algorithm = buffer.read()?;
|
||||
let digest_type = buffer.read()?;
|
||||
let digest_len = data_len as usize - 4; // key_tag(2) + algorithm(1) + digest_type(1)
|
||||
let digest = buffer.get_range(buffer.pos(), digest_len)?.to_vec();
|
||||
buffer.step(digest_len)?;
|
||||
Ok(DnsRecord::DS {
|
||||
domain,
|
||||
key_tag,
|
||||
algorithm,
|
||||
digest_type,
|
||||
digest,
|
||||
ttl,
|
||||
})
|
||||
}
|
||||
QueryType::RRSIG => {
|
||||
let type_covered = buffer.read_u16()?;
|
||||
let algorithm = buffer.read()?;
|
||||
let labels = buffer.read()?;
|
||||
let original_ttl = buffer.read_u32()?;
|
||||
let expiration = buffer.read_u32()?;
|
||||
let inception = buffer.read_u32()?;
|
||||
let key_tag = buffer.read_u16()?;
|
||||
let mut signer_name = String::with_capacity(64);
|
||||
buffer.read_qname(&mut signer_name)?;
|
||||
let rdata_end = rdata_start + data_len as usize;
|
||||
let sig_len = rdata_end
|
||||
.checked_sub(buffer.pos())
|
||||
.ok_or("RRSIG data_len too short for fixed fields + signer_name")?;
|
||||
let signature = buffer.get_range(buffer.pos(), sig_len)?.to_vec();
|
||||
buffer.step(sig_len)?;
|
||||
Ok(DnsRecord::RRSIG {
|
||||
domain,
|
||||
type_covered,
|
||||
algorithm,
|
||||
labels,
|
||||
original_ttl,
|
||||
expiration,
|
||||
inception,
|
||||
key_tag,
|
||||
signer_name,
|
||||
signature,
|
||||
ttl,
|
||||
})
|
||||
}
|
||||
QueryType::NSEC => {
|
||||
let rdata_end = rdata_start + data_len as usize;
|
||||
let mut next_domain = String::with_capacity(64);
|
||||
buffer.read_qname(&mut next_domain)?;
|
||||
let bitmap_len = rdata_end
|
||||
.checked_sub(buffer.pos())
|
||||
.ok_or("NSEC data_len too short for type bitmap")?;
|
||||
let type_bitmap = buffer.get_range(buffer.pos(), bitmap_len)?.to_vec();
|
||||
buffer.step(bitmap_len)?;
|
||||
Ok(DnsRecord::NSEC {
|
||||
domain,
|
||||
next_domain,
|
||||
type_bitmap,
|
||||
ttl,
|
||||
})
|
||||
}
|
||||
QueryType::NSEC3 => {
|
||||
let rdata_end = rdata_start + data_len as usize;
|
||||
let hash_algorithm = buffer.read()?;
|
||||
let flags = buffer.read()?;
|
||||
let iterations = buffer.read_u16()?;
|
||||
let salt_length = buffer.read()? as usize;
|
||||
let salt = buffer.get_range(buffer.pos(), salt_length)?.to_vec();
|
||||
buffer.step(salt_length)?;
|
||||
let hash_length = buffer.read()? as usize;
|
||||
let next_hashed_owner = buffer.get_range(buffer.pos(), hash_length)?.to_vec();
|
||||
buffer.step(hash_length)?;
|
||||
let bitmap_len = rdata_end
|
||||
.checked_sub(buffer.pos())
|
||||
.ok_or("NSEC3 data_len too short for type bitmap")?;
|
||||
let type_bitmap = buffer.get_range(buffer.pos(), bitmap_len)?.to_vec();
|
||||
buffer.step(bitmap_len)?;
|
||||
Ok(DnsRecord::NSEC3 {
|
||||
domain,
|
||||
hash_algorithm,
|
||||
flags,
|
||||
iterations,
|
||||
salt,
|
||||
next_hashed_owner,
|
||||
type_bitmap,
|
||||
ttl,
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
// SOA, TXT, SRV, etc. — stored as opaque bytes until parsed natively
|
||||
let data = buffer.get_range(buffer.pos(), data_len as usize)?.to_vec();
|
||||
buffer.step(data_len as usize)?;
|
||||
|
||||
Ok(DnsRecord::UNKNOWN {
|
||||
domain,
|
||||
qtype: qtype_num,
|
||||
data_len,
|
||||
data,
|
||||
ttl,
|
||||
})
|
||||
}
|
||||
@@ -163,32 +348,19 @@ impl DnsRecord {
|
||||
ref addr,
|
||||
ttl,
|
||||
} => {
|
||||
buffer.write_qname(domain)?;
|
||||
buffer.write_u16(QueryType::A.to_num())?;
|
||||
buffer.write_u16(1)?;
|
||||
buffer.write_u32(ttl)?;
|
||||
write_header(buffer, domain, QueryType::A.to_num(), ttl)?;
|
||||
buffer.write_u16(4)?;
|
||||
|
||||
let octets = addr.octets();
|
||||
buffer.write_u8(octets[0])?;
|
||||
buffer.write_u8(octets[1])?;
|
||||
buffer.write_u8(octets[2])?;
|
||||
buffer.write_u8(octets[3])?;
|
||||
buffer.write_bytes(&addr.octets())?;
|
||||
}
|
||||
DnsRecord::NS {
|
||||
ref domain,
|
||||
ref host,
|
||||
ttl,
|
||||
} => {
|
||||
buffer.write_qname(domain)?;
|
||||
buffer.write_u16(QueryType::NS.to_num())?;
|
||||
buffer.write_u16(1)?;
|
||||
buffer.write_u32(ttl)?;
|
||||
|
||||
write_header(buffer, domain, QueryType::NS.to_num(), ttl)?;
|
||||
let pos = buffer.pos();
|
||||
buffer.write_u16(0)?;
|
||||
buffer.write_qname(host)?;
|
||||
|
||||
let size = buffer.pos() - (pos + 2);
|
||||
buffer.set_u16(pos, size as u16)?;
|
||||
}
|
||||
@@ -197,15 +369,10 @@ impl DnsRecord {
|
||||
ref host,
|
||||
ttl,
|
||||
} => {
|
||||
buffer.write_qname(domain)?;
|
||||
buffer.write_u16(QueryType::CNAME.to_num())?;
|
||||
buffer.write_u16(1)?;
|
||||
buffer.write_u32(ttl)?;
|
||||
|
||||
write_header(buffer, domain, QueryType::CNAME.to_num(), ttl)?;
|
||||
let pos = buffer.pos();
|
||||
buffer.write_u16(0)?;
|
||||
buffer.write_qname(host)?;
|
||||
|
||||
let size = buffer.pos() - (pos + 2);
|
||||
buffer.set_u16(pos, size as u16)?;
|
||||
}
|
||||
@@ -215,16 +382,11 @@ impl DnsRecord {
|
||||
ref host,
|
||||
ttl,
|
||||
} => {
|
||||
buffer.write_qname(domain)?;
|
||||
buffer.write_u16(QueryType::MX.to_num())?;
|
||||
buffer.write_u16(1)?;
|
||||
buffer.write_u32(ttl)?;
|
||||
|
||||
write_header(buffer, domain, QueryType::MX.to_num(), ttl)?;
|
||||
let pos = buffer.pos();
|
||||
buffer.write_u16(0)?;
|
||||
buffer.write_u16(priority)?;
|
||||
buffer.write_qname(host)?;
|
||||
|
||||
let size = buffer.pos() - (pos + 2);
|
||||
buffer.set_u16(pos, size as u16)?;
|
||||
}
|
||||
@@ -233,21 +395,259 @@ impl DnsRecord {
|
||||
ref addr,
|
||||
ttl,
|
||||
} => {
|
||||
buffer.write_qname(domain)?;
|
||||
buffer.write_u16(QueryType::AAAA.to_num())?;
|
||||
buffer.write_u16(1)?;
|
||||
buffer.write_u32(ttl)?;
|
||||
write_header(buffer, domain, QueryType::AAAA.to_num(), ttl)?;
|
||||
buffer.write_u16(16)?;
|
||||
|
||||
for octet in &addr.segments() {
|
||||
buffer.write_u16(*octet)?;
|
||||
}
|
||||
}
|
||||
DnsRecord::UNKNOWN { .. } => {
|
||||
log::debug!("Skipping record: {:?}", self);
|
||||
DnsRecord::DNSKEY {
|
||||
ref domain,
|
||||
flags,
|
||||
protocol,
|
||||
algorithm,
|
||||
ref public_key,
|
||||
ttl,
|
||||
} => {
|
||||
write_header(buffer, domain, QueryType::DNSKEY.to_num(), ttl)?;
|
||||
buffer.write_u16((4 + public_key.len()) as u16)?;
|
||||
buffer.write_u16(flags)?;
|
||||
buffer.write_u8(protocol)?;
|
||||
buffer.write_u8(algorithm)?;
|
||||
buffer.write_bytes(public_key)?;
|
||||
}
|
||||
DnsRecord::DS {
|
||||
ref domain,
|
||||
key_tag,
|
||||
algorithm,
|
||||
digest_type,
|
||||
ref digest,
|
||||
ttl,
|
||||
} => {
|
||||
write_header(buffer, domain, QueryType::DS.to_num(), ttl)?;
|
||||
buffer.write_u16((4 + digest.len()) as u16)?;
|
||||
buffer.write_u16(key_tag)?;
|
||||
buffer.write_u8(algorithm)?;
|
||||
buffer.write_u8(digest_type)?;
|
||||
buffer.write_bytes(digest)?;
|
||||
}
|
||||
DnsRecord::RRSIG {
|
||||
ref domain,
|
||||
type_covered,
|
||||
algorithm,
|
||||
labels,
|
||||
original_ttl,
|
||||
expiration,
|
||||
inception,
|
||||
key_tag,
|
||||
ref signer_name,
|
||||
ref signature,
|
||||
ttl,
|
||||
} => {
|
||||
write_header(buffer, domain, QueryType::RRSIG.to_num(), ttl)?;
|
||||
let rdlen_pos = buffer.pos();
|
||||
buffer.write_u16(0)?; // RDLENGTH placeholder
|
||||
buffer.write_u16(type_covered)?;
|
||||
buffer.write_u8(algorithm)?;
|
||||
buffer.write_u8(labels)?;
|
||||
buffer.write_u32(original_ttl)?;
|
||||
buffer.write_u32(expiration)?;
|
||||
buffer.write_u32(inception)?;
|
||||
buffer.write_u16(key_tag)?;
|
||||
buffer.write_qname(signer_name)?;
|
||||
buffer.write_bytes(signature)?;
|
||||
let rdlen = buffer.pos() - (rdlen_pos + 2);
|
||||
buffer.set_u16(rdlen_pos, rdlen as u16)?;
|
||||
}
|
||||
DnsRecord::NSEC {
|
||||
ref domain,
|
||||
ref next_domain,
|
||||
ref type_bitmap,
|
||||
ttl,
|
||||
} => {
|
||||
write_header(buffer, domain, QueryType::NSEC.to_num(), ttl)?;
|
||||
let rdlen_pos = buffer.pos();
|
||||
buffer.write_u16(0)?;
|
||||
buffer.write_qname(next_domain)?;
|
||||
buffer.write_bytes(type_bitmap)?;
|
||||
let rdlen = buffer.pos() - (rdlen_pos + 2);
|
||||
buffer.set_u16(rdlen_pos, rdlen as u16)?;
|
||||
}
|
||||
DnsRecord::NSEC3 {
|
||||
ref domain,
|
||||
hash_algorithm,
|
||||
flags,
|
||||
iterations,
|
||||
ref salt,
|
||||
ref next_hashed_owner,
|
||||
ref type_bitmap,
|
||||
ttl,
|
||||
} => {
|
||||
write_header(buffer, domain, QueryType::NSEC3.to_num(), ttl)?;
|
||||
let rdlen =
|
||||
1 + 1 + 2 + 1 + salt.len() + 1 + next_hashed_owner.len() + type_bitmap.len();
|
||||
buffer.write_u16(rdlen as u16)?;
|
||||
buffer.write_u8(hash_algorithm)?;
|
||||
buffer.write_u8(flags)?;
|
||||
buffer.write_u16(iterations)?;
|
||||
buffer.write_u8(salt.len() as u8)?;
|
||||
buffer.write_bytes(salt)?;
|
||||
buffer.write_u8(next_hashed_owner.len() as u8)?;
|
||||
buffer.write_bytes(next_hashed_owner)?;
|
||||
buffer.write_bytes(type_bitmap)?;
|
||||
}
|
||||
DnsRecord::UNKNOWN {
|
||||
ref domain,
|
||||
qtype,
|
||||
ref data,
|
||||
ttl,
|
||||
} => {
|
||||
write_header(buffer, domain, qtype, ttl)?;
|
||||
buffer.write_u16(data.len() as u16)?;
|
||||
buffer.write_bytes(data)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(buffer.pos() - start_pos)
|
||||
}
|
||||
}
|
||||
|
||||
fn write_header(buffer: &mut BytePacketBuffer, domain: &str, qtype: u16, ttl: u32) -> Result<()> {
|
||||
buffer.write_qname(domain)?;
|
||||
buffer.write_u16(qtype)?;
|
||||
buffer.write_u16(1)?; // class IN
|
||||
buffer.write_u32(ttl)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn round_trip(record: &DnsRecord) -> DnsRecord {
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
record.write(&mut buf).unwrap();
|
||||
buf.seek(0).unwrap();
|
||||
DnsRecord::read(&mut buf).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_preserves_raw_bytes() {
|
||||
let rec = DnsRecord::UNKNOWN {
|
||||
domain: "example.com".into(),
|
||||
qtype: 99,
|
||||
data: vec![0xDE, 0xAD, 0xBE, 0xEF],
|
||||
ttl: 300,
|
||||
};
|
||||
let parsed = round_trip(&rec);
|
||||
if let DnsRecord::UNKNOWN { data, .. } = &parsed {
|
||||
assert_eq!(data.len(), 4);
|
||||
assert_eq!(data, &[0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
} else {
|
||||
panic!("expected UNKNOWN");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dnskey_round_trip() {
|
||||
let rec = DnsRecord::DNSKEY {
|
||||
domain: "example.com".into(),
|
||||
flags: 257, // KSK
|
||||
protocol: 3,
|
||||
algorithm: 13, // ECDSAP256SHA256
|
||||
public_key: vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
ttl: 3600,
|
||||
};
|
||||
let parsed = round_trip(&rec);
|
||||
assert_eq!(rec, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ds_round_trip() {
|
||||
let rec = DnsRecord::DS {
|
||||
domain: "example.com".into(),
|
||||
key_tag: 12345,
|
||||
algorithm: 8,
|
||||
digest_type: 2,
|
||||
digest: vec![0xAA, 0xBB, 0xCC, 0xDD],
|
||||
ttl: 86400,
|
||||
};
|
||||
let parsed = round_trip(&rec);
|
||||
assert_eq!(rec, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rrsig_round_trip() {
|
||||
let rec = DnsRecord::RRSIG {
|
||||
domain: "example.com".into(),
|
||||
type_covered: 1, // A
|
||||
algorithm: 13,
|
||||
labels: 2,
|
||||
original_ttl: 300,
|
||||
expiration: 1700000000,
|
||||
inception: 1690000000,
|
||||
key_tag: 54321,
|
||||
signer_name: "example.com".into(),
|
||||
signature: vec![0x01, 0x02, 0x03, 0x04, 0x05],
|
||||
ttl: 300,
|
||||
};
|
||||
let parsed = round_trip(&rec);
|
||||
assert_eq!(rec, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_type_method() {
|
||||
assert_eq!(
|
||||
DnsRecord::DNSKEY {
|
||||
domain: String::new(),
|
||||
flags: 0,
|
||||
protocol: 3,
|
||||
algorithm: 8,
|
||||
public_key: vec![],
|
||||
ttl: 0,
|
||||
}
|
||||
.query_type(),
|
||||
QueryType::DNSKEY
|
||||
);
|
||||
assert_eq!(
|
||||
DnsRecord::DS {
|
||||
domain: String::new(),
|
||||
key_tag: 0,
|
||||
algorithm: 0,
|
||||
digest_type: 0,
|
||||
digest: vec![],
|
||||
ttl: 0,
|
||||
}
|
||||
.query_type(),
|
||||
QueryType::DS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nsec_round_trip() {
|
||||
let rec = DnsRecord::NSEC {
|
||||
domain: "alpha.example.com".into(),
|
||||
next_domain: "gamma.example.com".into(),
|
||||
type_bitmap: vec![0, 2, 0x40, 0x01], // A(1), MX(15)
|
||||
ttl: 3600,
|
||||
};
|
||||
let parsed = round_trip(&rec);
|
||||
assert_eq!(rec, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nsec3_round_trip() {
|
||||
let rec = DnsRecord::NSEC3 {
|
||||
domain: "abc123.example.com".into(),
|
||||
hash_algorithm: 1,
|
||||
flags: 0,
|
||||
iterations: 10,
|
||||
salt: vec![0xAB, 0xCD],
|
||||
next_hashed_owner: vec![0x01, 0x02, 0x03, 0x04, 0x05],
|
||||
type_bitmap: vec![0, 1, 0x40], // A(1)
|
||||
ttl: 3600,
|
||||
};
|
||||
let parsed = round_trip(&rec);
|
||||
assert_eq!(rec, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
1088
src/recursive.rs
Normal file
1088
src/recursive.rs
Normal file
File diff suppressed because it is too large
Load Diff
12
src/stats.rs
12
src/stats.rs
@@ -3,6 +3,7 @@ use std::time::Instant;
|
||||
pub struct ServerStats {
|
||||
queries_total: u64,
|
||||
queries_forwarded: u64,
|
||||
queries_recursive: u64,
|
||||
queries_cached: u64,
|
||||
queries_blocked: u64,
|
||||
queries_local: u64,
|
||||
@@ -16,6 +17,7 @@ pub enum QueryPath {
|
||||
Local,
|
||||
Cached,
|
||||
Forwarded,
|
||||
Recursive,
|
||||
Blocked,
|
||||
Overridden,
|
||||
UpstreamError,
|
||||
@@ -27,6 +29,7 @@ impl QueryPath {
|
||||
QueryPath::Local => "LOCAL",
|
||||
QueryPath::Cached => "CACHED",
|
||||
QueryPath::Forwarded => "FORWARD",
|
||||
QueryPath::Recursive => "RECURSIVE",
|
||||
QueryPath::Blocked => "BLOCKED",
|
||||
QueryPath::Overridden => "OVERRIDE",
|
||||
QueryPath::UpstreamError => "SERVFAIL",
|
||||
@@ -40,6 +43,8 @@ impl QueryPath {
|
||||
Some(QueryPath::Cached)
|
||||
} else if s.eq_ignore_ascii_case("FORWARD") {
|
||||
Some(QueryPath::Forwarded)
|
||||
} else if s.eq_ignore_ascii_case("RECURSIVE") {
|
||||
Some(QueryPath::Recursive)
|
||||
} else if s.eq_ignore_ascii_case("BLOCKED") {
|
||||
Some(QueryPath::Blocked)
|
||||
} else if s.eq_ignore_ascii_case("OVERRIDE") {
|
||||
@@ -63,6 +68,7 @@ impl ServerStats {
|
||||
ServerStats {
|
||||
queries_total: 0,
|
||||
queries_forwarded: 0,
|
||||
queries_recursive: 0,
|
||||
queries_cached: 0,
|
||||
queries_blocked: 0,
|
||||
queries_local: 0,
|
||||
@@ -78,6 +84,7 @@ impl ServerStats {
|
||||
QueryPath::Local => self.queries_local += 1,
|
||||
QueryPath::Cached => self.queries_cached += 1,
|
||||
QueryPath::Forwarded => self.queries_forwarded += 1,
|
||||
QueryPath::Recursive => self.queries_recursive += 1,
|
||||
QueryPath::Blocked => self.queries_blocked += 1,
|
||||
QueryPath::Overridden => self.queries_overridden += 1,
|
||||
QueryPath::UpstreamError => self.upstream_errors += 1,
|
||||
@@ -98,6 +105,7 @@ impl ServerStats {
|
||||
uptime_secs: self.uptime_secs(),
|
||||
total: self.queries_total,
|
||||
forwarded: self.queries_forwarded,
|
||||
recursive: self.queries_recursive,
|
||||
cached: self.queries_cached,
|
||||
local: self.queries_local,
|
||||
overridden: self.queries_overridden,
|
||||
@@ -113,10 +121,11 @@ impl ServerStats {
|
||||
let secs = uptime.as_secs() % 60;
|
||||
|
||||
log::info!(
|
||||
"STATS | uptime {}h{}m{}s | total {} | fwd {} | cached {} | local {} | override {} | blocked {} | errors {}",
|
||||
"STATS | uptime {}h{}m{}s | total {} | fwd {} | recursive {} | cached {} | local {} | override {} | blocked {} | errors {}",
|
||||
hours, mins, secs,
|
||||
self.queries_total,
|
||||
self.queries_forwarded,
|
||||
self.queries_recursive,
|
||||
self.queries_cached,
|
||||
self.queries_local,
|
||||
self.queries_overridden,
|
||||
@@ -130,6 +139,7 @@ pub struct StatsSnapshot {
|
||||
pub uptime_secs: u64,
|
||||
pub total: u64,
|
||||
pub forwarded: u64,
|
||||
pub recursive: u64,
|
||||
pub cached: u64,
|
||||
pub local: u64,
|
||||
pub overridden: u64,
|
||||
|
||||
Reference in New Issue
Block a user