feat: recursive resolution + full DNSSEC validation

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

1675
src/dnssec.rs Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,31 @@ use crate::question::{DnsQuestion, QueryType};
use crate::record::DnsRecord;
use crate::Result;
/// Recommended EDNS0 UDP payload size (DNS Flag Day 2020) — avoids IP fragmentation.
pub const DEFAULT_EDNS_PAYLOAD: u16 = 1232;
/// EDNS0 OPT pseudo-record (RFC 6891)
#[derive(Clone, Debug)]
pub struct EdnsOpt {
pub udp_payload_size: u16,
pub extended_rcode: u8,
pub version: u8,
pub do_bit: bool,
pub options: Vec<u8>,
}
impl Default for EdnsOpt {
fn default() -> Self {
EdnsOpt {
udp_payload_size: DEFAULT_EDNS_PAYLOAD,
extended_rcode: 0,
version: 0,
do_bit: false,
options: Vec::new(),
}
}
}
#[derive(Clone, Debug)]
pub struct DnsPacket {
pub header: DnsHeader,
@@ -11,6 +36,7 @@ pub struct DnsPacket {
pub answers: Vec<DnsRecord>,
pub authorities: Vec<DnsRecord>,
pub resources: Vec<DnsRecord>,
pub edns: Option<EdnsOpt>,
}
impl Default for DnsPacket {
@@ -27,6 +53,7 @@ impl DnsPacket {
answers: Vec::new(),
authorities: Vec::new(),
resources: Vec::new(),
edns: None,
}
}
@@ -60,24 +87,53 @@ impl DnsPacket {
result.authorities.push(rec);
}
for _ in 0..result.header.resource_entries {
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");
}
}
}

View File

@@ -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,
}

View File

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

601
src/recursive.rs Normal file
View File

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

View File

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