From ca0084639337ad82dbc7997496763944c438e867 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 07:36:53 +0300 Subject: [PATCH 1/7] fix: forwarding rules override special-use NXDOMAIN for private PTR zones Explicit [[forwarding]] rules now take precedence over the RFC 6303 special-use domain intercept. Previously, PTR queries for private ranges (e.g. 168.192.in-addr.arpa) always returned local NXDOMAIN even when a forwarding rule pointed them at a corporate DNS server. Add full-pipeline resolve_query test harness (test_ctx + resolve_in_test) and two tests covering both the default behavior and the override. Closes #94 --- src/ctx.rs | 163 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 3 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index 65b76d3..ee88b78 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -96,7 +96,8 @@ pub async fn resolve_query( None => return Err("empty question section".into()), }; - // Pipeline: overrides -> .tld interception -> blocklist -> local zones -> cache -> upstream + // Pipeline: overrides -> .localhost -> local zones -> special-use (unless forwarded) + // -> .tld proxy -> blocklist -> cache -> forwarding -> recursive/upstream // Each lock is scoped to avoid holding MutexGuard across await points. let (response, path, dnssec) = { let override_record = ctx.overrides.read().unwrap().lookup(&qname); @@ -119,8 +120,11 @@ pub async fn resolve_query( let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); resp.answers = records.clone(); (resp, QueryPath::Local, DnssecStatus::Indeterminate) - } else if is_special_use_domain(&qname) { - // RFC 6761/8880: private PTR, DDR, NAT64 — answer locally + } else if is_special_use_domain(&qname) + && crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules).is_none() + { + // RFC 6761/8880: private PTR, DDR, NAT64 — answer locally, + // unless an explicit forwarding rule covers this zone. let resp = special_use_response(&query, &qname, qtype); (resp, QueryPath::Local, DnssecStatus::Indeterminate) } else if !ctx.proxy_tld_suffix.is_empty() @@ -655,6 +659,7 @@ mod tests { use super::*; use std::collections::HashMap; use std::net::Ipv4Addr; + use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tokio::sync::broadcast; @@ -1036,4 +1041,156 @@ mod tests { "error message must be preserved for logging" ); } + + // ---- Full-pipeline resolve_query tests ---- + + async fn test_ctx() -> Arc { + let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + Arc::new(ServerCtx { + socket, + zone_map: HashMap::new(), + cache: RwLock::new(DnsCache::new(100, 60, 86400)), + refreshing: Mutex::new(HashSet::new()), + stats: Mutex::new(ServerStats::new()), + overrides: RwLock::new(OverrideStore::new()), + blocklist: RwLock::new(BlocklistStore::new()), + query_log: Mutex::new(QueryLog::new(100)), + services: Mutex::new(ServiceStore::new()), + lan_peers: Mutex::new(PeerStore::new(90)), + forwarding_rules: Vec::new(), + upstream_pool: Mutex::new(UpstreamPool::new( + vec![Upstream::Udp("127.0.0.1:53".parse().unwrap())], + vec![], + )), + upstream_auto: false, + upstream_port: 53, + lan_ip: Mutex::new(Ipv4Addr::LOCALHOST), + timeout: Duration::from_secs(3), + hedge_delay: Duration::ZERO, + 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: PathBuf::from("/tmp"), + data_dir: PathBuf::from("/tmp"), + tls_config: None, + upstream_mode: UpstreamMode::Forward, + root_hints: Vec::new(), + srtt: RwLock::new(SrttCache::new(true)), + inflight: Mutex::new(HashMap::new()), + dnssec_enabled: false, + dnssec_strict: false, + health_meta: HealthMeta::test_fixture(), + ca_pem: None, + mobile_enabled: false, + mobile_port: 8765, + }) + } + + /// Helper: send a query through the full resolve_query pipeline and return + /// the parsed response + query path. + async fn resolve_in_test( + ctx: &Arc, + domain: &str, + qtype: QueryType, + ) -> (DnsPacket, QueryPath) { + let query = DnsPacket::query(0xBEEF, domain, qtype); + let mut buf = BytePacketBuffer::new(); + query.write(&mut buf).unwrap(); + let raw = &buf.buf[..buf.pos]; + let src: SocketAddr = "127.0.0.1:1234".parse().unwrap(); + + let resp_buf = resolve_query(query, raw, src, ctx, Transport::Udp) + .await + .unwrap(); + + let log = ctx.query_log.lock().unwrap(); + let entry = log.query(&crate::query_log::QueryLogFilter { + domain: None, + query_type: None, + path: None, + since: None, + limit: Some(1), + }); + let path = entry.first().unwrap().path; + drop(log); + + let mut resp_parse_buf = BytePacketBuffer::from_bytes(resp_buf.filled()); + let resp = DnsPacket::from_buffer(&mut resp_parse_buf).unwrap(); + (resp, path) + } + + #[tokio::test] + async fn special_use_private_ptr_returns_nxdomain() { + let ctx = test_ctx().await; + let (resp, path) = + resolve_in_test(&ctx, "153.188.168.192.in-addr.arpa", QueryType::PTR).await; + assert_eq!(path, QueryPath::Local); + assert_eq!(resp.header.rescode, ResultCode::NXDOMAIN); + } + + async fn test_ctx_with_forwarding(rules: Vec) -> Arc { + let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + Arc::new(ServerCtx { + socket, + zone_map: HashMap::new(), + cache: RwLock::new(DnsCache::new(100, 60, 86400)), + refreshing: Mutex::new(HashSet::new()), + stats: Mutex::new(ServerStats::new()), + overrides: RwLock::new(OverrideStore::new()), + blocklist: RwLock::new(BlocklistStore::new()), + query_log: Mutex::new(QueryLog::new(100)), + services: Mutex::new(ServiceStore::new()), + lan_peers: Mutex::new(PeerStore::new(90)), + forwarding_rules: rules, + upstream_pool: Mutex::new(UpstreamPool::new( + vec![Upstream::Udp("127.0.0.1:53".parse().unwrap())], + vec![], + )), + upstream_auto: false, + upstream_port: 53, + lan_ip: Mutex::new(Ipv4Addr::LOCALHOST), + timeout: Duration::from_millis(100), + hedge_delay: Duration::ZERO, + 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: PathBuf::from("/tmp"), + data_dir: PathBuf::from("/tmp"), + tls_config: None, + upstream_mode: UpstreamMode::Forward, + root_hints: Vec::new(), + srtt: RwLock::new(SrttCache::new(true)), + inflight: Mutex::new(HashMap::new()), + dnssec_enabled: false, + dnssec_strict: false, + health_meta: HealthMeta::test_fixture(), + ca_pem: None, + mobile_enabled: false, + mobile_port: 8765, + }) + } + + #[tokio::test] + async fn forwarding_rule_overrides_special_use_domain() { + let rules = vec![ForwardingRule::new( + "168.192.in-addr.arpa".to_string(), + "192.168.88.1:53".parse().unwrap(), + )]; + let ctx = test_ctx_with_forwarding(rules).await; + + let (_, path) = resolve_in_test(&ctx, "153.188.168.192.in-addr.arpa", QueryType::PTR).await; + + // Should attempt forwarding, not return local NXDOMAIN. + // The forwarding will fail (no real upstream at 192.168.88.1), so we + // expect UpstreamError — but critically NOT QueryPath::Local. + assert_ne!( + path, + QueryPath::Local, + "forwarding rule must take precedence over special-use NXDOMAIN" + ); + } } From 48f67be2f15903314e5a99da36bb2a62b457b2e7 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 07:39:55 +0300 Subject: [PATCH 2/7] refactor: deduplicate test_ctx by delegating to test_ctx_with_forwarding --- src/ctx.rs | 42 +----------------------------------------- 1 file changed, 1 insertion(+), 41 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index ee88b78..e440c2d 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1045,47 +1045,7 @@ mod tests { // ---- Full-pipeline resolve_query tests ---- async fn test_ctx() -> Arc { - let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap(); - Arc::new(ServerCtx { - socket, - zone_map: HashMap::new(), - cache: RwLock::new(DnsCache::new(100, 60, 86400)), - refreshing: Mutex::new(HashSet::new()), - stats: Mutex::new(ServerStats::new()), - overrides: RwLock::new(OverrideStore::new()), - blocklist: RwLock::new(BlocklistStore::new()), - query_log: Mutex::new(QueryLog::new(100)), - services: Mutex::new(ServiceStore::new()), - lan_peers: Mutex::new(PeerStore::new(90)), - forwarding_rules: Vec::new(), - upstream_pool: Mutex::new(UpstreamPool::new( - vec![Upstream::Udp("127.0.0.1:53".parse().unwrap())], - vec![], - )), - upstream_auto: false, - upstream_port: 53, - lan_ip: Mutex::new(Ipv4Addr::LOCALHOST), - timeout: Duration::from_secs(3), - hedge_delay: Duration::ZERO, - 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: PathBuf::from("/tmp"), - data_dir: PathBuf::from("/tmp"), - tls_config: None, - upstream_mode: UpstreamMode::Forward, - root_hints: Vec::new(), - srtt: RwLock::new(SrttCache::new(true)), - inflight: Mutex::new(HashMap::new()), - dnssec_enabled: false, - dnssec_strict: false, - health_meta: HealthMeta::test_fixture(), - ca_pem: None, - mobile_enabled: false, - mobile_port: 8765, - }) + test_ctx_with_forwarding(Vec::new()).await } /// Helper: send a query through the full resolve_query pipeline and return From b8ddc16027453beec62888e9ee062b105f362543 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 07:51:14 +0300 Subject: [PATCH 3/7] refactor: return QueryPath from resolve_query, add mock upstream to tests resolve_query now returns (BytePacketBuffer, QueryPath) so callers and tests can inspect the resolution path without reading the query log. Production call sites (UDP, DoT, DoH) destructure and ignore it. The forwarding test now uses a mock UDP upstream that replies with a canned response, asserting QueryPath::Forwarded instead of != Local. --- src/ctx.rs | 57 ++++++++++++++++++++++++++++++++---------------------- src/doh.rs | 2 +- src/dot.rs | 2 +- 3 files changed, 36 insertions(+), 25 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index e440c2d..3f1370a 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -88,7 +88,7 @@ pub async fn resolve_query( src_addr: SocketAddr, ctx: &Arc, transport: Transport, -) -> crate::Result { +) -> crate::Result<(BytePacketBuffer, QueryPath)> { let start = Instant::now(); let (qname, qtype) = match query.questions.first() { @@ -377,7 +377,7 @@ pub async fn resolve_query( dnssec, }); - Ok(resp_buffer) + Ok((resp_buffer, path)) } fn cache_and_parse( @@ -461,7 +461,7 @@ pub async fn handle_query( } }; match resolve_query(query, &buffer.buf[..raw_len], src_addr, ctx, transport).await { - Ok(resp_buffer) => { + Ok((resp_buffer, _)) => { ctx.socket.send_to(resp_buffer.filled(), src_addr).await?; } Err(e) => { @@ -1048,7 +1048,7 @@ mod tests { test_ctx_with_forwarding(Vec::new()).await } - /// Helper: send a query through the full resolve_query pipeline and return + /// Send a query through the full resolve_query pipeline and return /// the parsed response + query path. async fn resolve_in_test( ctx: &Arc, @@ -1061,21 +1061,10 @@ mod tests { let raw = &buf.buf[..buf.pos]; let src: SocketAddr = "127.0.0.1:1234".parse().unwrap(); - let resp_buf = resolve_query(query, raw, src, ctx, Transport::Udp) + let (resp_buf, path) = resolve_query(query, raw, src, ctx, Transport::Udp) .await .unwrap(); - let log = ctx.query_log.lock().unwrap(); - let entry = log.query(&crate::query_log::QueryLogFilter { - domain: None, - query_type: None, - path: None, - since: None, - limit: Some(1), - }); - let path = entry.first().unwrap().path; - drop(log); - let mut resp_parse_buf = BytePacketBuffer::from_bytes(resp_buf.filled()); let resp = DnsPacket::from_buffer(&mut resp_parse_buf).unwrap(); (resp, path) @@ -1134,23 +1123,45 @@ mod tests { }) } + /// Spawn a UDP socket that replies to the first DNS query with the given + /// response packet (patching the query ID). Returns the socket address. + async fn mock_upstream(response: DnsPacket) -> SocketAddr { + let sock = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let addr = sock.local_addr().unwrap(); + tokio::spawn(async move { + let mut buf = [0u8; 512]; + let (_, src) = sock.recv_from(&mut buf).await.unwrap(); + let query_id = u16::from_be_bytes([buf[0], buf[1]]); + let mut resp = response; + resp.header.id = query_id; + let mut out = BytePacketBuffer::new(); + resp.write(&mut out).unwrap(); + sock.send_to(out.filled(), src).await.unwrap(); + }); + addr + } + #[tokio::test] async fn forwarding_rule_overrides_special_use_domain() { + let mut resp = DnsPacket::new(); + resp.header.response = true; + resp.header.rescode = ResultCode::NOERROR; + let upstream_addr = mock_upstream(resp).await; + let rules = vec![ForwardingRule::new( "168.192.in-addr.arpa".to_string(), - "192.168.88.1:53".parse().unwrap(), + upstream_addr, )]; let ctx = test_ctx_with_forwarding(rules).await; - let (_, path) = resolve_in_test(&ctx, "153.188.168.192.in-addr.arpa", QueryType::PTR).await; + let (resp, path) = + resolve_in_test(&ctx, "153.188.168.192.in-addr.arpa", QueryType::PTR).await; - // Should attempt forwarding, not return local NXDOMAIN. - // The forwarding will fail (no real upstream at 192.168.88.1), so we - // expect UpstreamError — but critically NOT QueryPath::Local. - assert_ne!( + assert_eq!( path, - QueryPath::Local, + QueryPath::Forwarded, "forwarding rule must take precedence over special-use NXDOMAIN" ); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); } } diff --git a/src/doh.rs b/src/doh.rs index f90b919..900edb4 100644 --- a/src/doh.rs +++ b/src/doh.rs @@ -113,7 +113,7 @@ async fn resolve_doh( let questions = query.questions.clone(); match resolve_query(query, dns_bytes, src, ctx, Transport::Doh).await { - Ok(resp_buffer) => { + Ok((resp_buffer, _)) => { let min_ttl = extract_min_ttl(resp_buffer.filled()); dns_response(resp_buffer.filled(), min_ttl) } diff --git a/src/dot.rs b/src/dot.rs index e883e0b..db8257d 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -211,7 +211,7 @@ async fn handle_dot_connection( ) .await { - Ok(resp_buffer) => { + Ok((resp_buffer, _)) => { if write_framed(&mut stream, resp_buffer.filled()) .await .is_err() From b40004fe5e41dc7800d25cbec5e49347a6e68674 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 07:56:47 +0300 Subject: [PATCH 4/7] refactor: extract shared test infrastructure into testutil module - test_ctx(): single ServerCtx builder, replaces 3 copies (ctx/api/dot) - mock_upstream(): canned DNS response server for forwarding tests - blackhole_upstream(): unresponsive socket for timeout tests - Removes ~100 lines of duplicated 30-field struct literals --- src/api.rs | 45 +---------------------- src/ctx.rs | 76 +++------------------------------------ src/dot.rs | 82 +++++++++++++----------------------------- src/lib.rs | 3 ++ src/testutil.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 173 deletions(-) create mode 100644 src/testutil.rs diff --git a/src/api.rs b/src/api.rs index fcc0bd9..6ec3e48 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1020,53 +1020,10 @@ mod tests { use super::*; use axum::body::Body; use http::Request; - use std::sync::{Mutex, RwLock}; use tower::ServiceExt; async fn test_ctx() -> Arc { - 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)), - refreshing: Mutex::new(std::collections::HashSet::new()), - 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_pool: Mutex::new(crate::forward::UpstreamPool::new( - vec![crate::forward::Upstream::Udp( - "127.0.0.1:53".parse().unwrap(), - )], - vec![], - )), - upstream_auto: false, - upstream_port: 53, - lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST), - timeout: std::time::Duration::from_secs(3), - hedge_delay: std::time::Duration::ZERO, - 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(), - srtt: RwLock::new(crate::srtt::SrttCache::new(true)), - inflight: Mutex::new(std::collections::HashMap::new()), - dnssec_enabled: false, - dnssec_strict: false, - health_meta: crate::health::HealthMeta::test_fixture(), - ca_pem: None, - mobile_enabled: false, - mobile_port: 8765, - }) + Arc::new(crate::testutil::test_ctx().await) } #[tokio::test] diff --git a/src/ctx.rs b/src/ctx.rs index 3f1370a..475dfe7 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -659,7 +659,6 @@ mod tests { use super::*; use std::collections::HashMap; use std::net::Ipv4Addr; - use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tokio::sync::broadcast; @@ -1044,10 +1043,6 @@ mod tests { // ---- Full-pipeline resolve_query tests ---- - async fn test_ctx() -> Arc { - test_ctx_with_forwarding(Vec::new()).await - } - /// Send a query through the full resolve_query pipeline and return /// the parsed response + query path. async fn resolve_in_test( @@ -1072,87 +1067,26 @@ mod tests { #[tokio::test] async fn special_use_private_ptr_returns_nxdomain() { - let ctx = test_ctx().await; + let ctx = Arc::new(crate::testutil::test_ctx().await); let (resp, path) = resolve_in_test(&ctx, "153.188.168.192.in-addr.arpa", QueryType::PTR).await; assert_eq!(path, QueryPath::Local); assert_eq!(resp.header.rescode, ResultCode::NXDOMAIN); } - async fn test_ctx_with_forwarding(rules: Vec) -> Arc { - let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap(); - Arc::new(ServerCtx { - socket, - zone_map: HashMap::new(), - cache: RwLock::new(DnsCache::new(100, 60, 86400)), - refreshing: Mutex::new(HashSet::new()), - stats: Mutex::new(ServerStats::new()), - overrides: RwLock::new(OverrideStore::new()), - blocklist: RwLock::new(BlocklistStore::new()), - query_log: Mutex::new(QueryLog::new(100)), - services: Mutex::new(ServiceStore::new()), - lan_peers: Mutex::new(PeerStore::new(90)), - forwarding_rules: rules, - upstream_pool: Mutex::new(UpstreamPool::new( - vec![Upstream::Udp("127.0.0.1:53".parse().unwrap())], - vec![], - )), - upstream_auto: false, - upstream_port: 53, - lan_ip: Mutex::new(Ipv4Addr::LOCALHOST), - timeout: Duration::from_millis(100), - hedge_delay: Duration::ZERO, - 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: PathBuf::from("/tmp"), - data_dir: PathBuf::from("/tmp"), - tls_config: None, - upstream_mode: UpstreamMode::Forward, - root_hints: Vec::new(), - srtt: RwLock::new(SrttCache::new(true)), - inflight: Mutex::new(HashMap::new()), - dnssec_enabled: false, - dnssec_strict: false, - health_meta: HealthMeta::test_fixture(), - ca_pem: None, - mobile_enabled: false, - mobile_port: 8765, - }) - } - - /// Spawn a UDP socket that replies to the first DNS query with the given - /// response packet (patching the query ID). Returns the socket address. - async fn mock_upstream(response: DnsPacket) -> SocketAddr { - let sock = UdpSocket::bind("127.0.0.1:0").await.unwrap(); - let addr = sock.local_addr().unwrap(); - tokio::spawn(async move { - let mut buf = [0u8; 512]; - let (_, src) = sock.recv_from(&mut buf).await.unwrap(); - let query_id = u16::from_be_bytes([buf[0], buf[1]]); - let mut resp = response; - resp.header.id = query_id; - let mut out = BytePacketBuffer::new(); - resp.write(&mut out).unwrap(); - sock.send_to(out.filled(), src).await.unwrap(); - }); - addr - } - #[tokio::test] async fn forwarding_rule_overrides_special_use_domain() { let mut resp = DnsPacket::new(); resp.header.response = true; resp.header.rescode = ResultCode::NOERROR; - let upstream_addr = mock_upstream(resp).await; + let upstream_addr = crate::testutil::mock_upstream(resp).await; - let rules = vec![ForwardingRule::new( + let mut ctx = crate::testutil::test_ctx().await; + ctx.forwarding_rules = vec![ForwardingRule::new( "168.192.in-addr.arpa".to_string(), upstream_addr, )]; - let ctx = test_ctx_with_forwarding(rules).await; + let ctx = Arc::new(ctx); let (resp, path) = resolve_in_test(&ctx, "153.188.168.192.in-addr.arpa", QueryType::PTR).await; diff --git a/src/dot.rs b/src/dot.rs index db8257d..b39d7fe 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -279,7 +279,7 @@ where mod tests { use super::*; use std::collections::HashMap; - use std::sync::{Mutex, RwLock}; + use std::sync::Mutex; use rcgen::{CertificateParams, DnType, KeyPair}; use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName}; @@ -344,63 +344,29 @@ mod tests { async fn spawn_dot_server() -> (SocketAddr, CertificateDer<'static>) { let (server_tls, cert_der) = test_tls_configs(); - let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); - // Bind an unresponsive upstream and leak it so it lives for the test duration. - let blackhole = Box::leak(Box::new(std::net::UdpSocket::bind("127.0.0.1:0").unwrap())); - let upstream_addr = blackhole.local_addr().unwrap(); - let ctx = Arc::new(ServerCtx { - socket, - zone_map: { - let mut m = HashMap::new(); - let mut inner = HashMap::new(); - inner.insert( - QueryType::A, - vec![DnsRecord::A { - domain: "dot-test.example".to_string(), - addr: std::net::Ipv4Addr::new(10, 0, 0, 1), - ttl: 300, - }], - ); - m.insert("dot-test.example".to_string(), inner); - m - }, - cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)), - refreshing: Mutex::new(std::collections::HashSet::new()), - 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_pool: Mutex::new(crate::forward::UpstreamPool::new( - vec![crate::forward::Upstream::Udp(upstream_addr)], - vec![], - )), - upstream_auto: false, - upstream_port: 53, - lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST), - timeout: Duration::from_millis(200), - hedge_delay: Duration::ZERO, - proxy_tld: "numa".to_string(), - proxy_tld_suffix: ".numa".to_string(), - lan_enabled: false, - config_path: String::new(), - config_found: false, - config_dir: std::path::PathBuf::from("/tmp"), - data_dir: std::path::PathBuf::from("/tmp"), - tls_config: Some(arc_swap::ArcSwap::from(server_tls)), - upstream_mode: crate::config::UpstreamMode::Forward, - root_hints: Vec::new(), - srtt: RwLock::new(crate::srtt::SrttCache::new(true)), - inflight: Mutex::new(HashMap::new()), - dnssec_enabled: false, - dnssec_strict: false, - health_meta: crate::health::HealthMeta::test_fixture(), - ca_pem: None, - mobile_enabled: false, - mobile_port: 8765, - }); + let upstream_addr = crate::testutil::blackhole_upstream(); + + let mut ctx = crate::testutil::test_ctx().await; + ctx.zone_map = { + let mut m = HashMap::new(); + let mut inner = HashMap::new(); + inner.insert( + QueryType::A, + vec![DnsRecord::A { + domain: "dot-test.example".to_string(), + addr: std::net::Ipv4Addr::new(10, 0, 0, 1), + ttl: 300, + }], + ); + m.insert("dot-test.example".to_string(), inner); + m + }; + ctx.upstream_pool = Mutex::new(crate::forward::UpstreamPool::new( + vec![crate::forward::Upstream::Udp(upstream_addr)], + vec![], + )); + ctx.tls_config = Some(arc_swap::ArcSwap::from(server_tls)); + let ctx = Arc::new(ctx); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 92a0b00..8933e2a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,9 @@ pub mod system_dns; pub mod tls; pub mod wire; +#[cfg(test)] +pub(crate) mod testutil; + pub type Error = Box; pub type Result = std::result::Result; diff --git a/src/testutil.rs b/src/testutil.rs new file mode 100644 index 0000000..8687625 --- /dev/null +++ b/src/testutil.rs @@ -0,0 +1,95 @@ +use std::collections::{HashMap, HashSet}; +use std::net::{Ipv4Addr, SocketAddr}; +use std::path::PathBuf; +use std::sync::{Mutex, RwLock}; +use std::time::Duration; + +use tokio::net::UdpSocket; + +use crate::blocklist::BlocklistStore; +use crate::buffer::BytePacketBuffer; +use crate::cache::DnsCache; +use crate::config::UpstreamMode; +use crate::ctx::ServerCtx; +use crate::forward::{Upstream, UpstreamPool}; +use crate::health::HealthMeta; +use crate::lan::PeerStore; +use crate::override_store::OverrideStore; +use crate::packet::DnsPacket; +use crate::query_log::QueryLog; +use crate::service_store::ServiceStore; +use crate::srtt::SrttCache; +use crate::stats::ServerStats; +/// Minimal `ServerCtx` for tests. Override fields after construction +/// (all fields are `pub`), then wrap in `Arc`. +pub async fn test_ctx() -> ServerCtx { + let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + ServerCtx { + socket, + zone_map: HashMap::new(), + cache: RwLock::new(DnsCache::new(100, 60, 86400)), + refreshing: Mutex::new(HashSet::new()), + stats: Mutex::new(ServerStats::new()), + overrides: RwLock::new(OverrideStore::new()), + blocklist: RwLock::new(BlocklistStore::new()), + query_log: Mutex::new(QueryLog::new(100)), + services: Mutex::new(ServiceStore::new()), + lan_peers: Mutex::new(PeerStore::new(90)), + forwarding_rules: Vec::new(), + upstream_pool: Mutex::new(UpstreamPool::new( + vec![Upstream::Udp("127.0.0.1:53".parse().unwrap())], + vec![], + )), + upstream_auto: false, + upstream_port: 53, + lan_ip: Mutex::new(Ipv4Addr::LOCALHOST), + timeout: Duration::from_millis(200), + hedge_delay: Duration::ZERO, + 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: PathBuf::from("/tmp"), + data_dir: PathBuf::from("/tmp"), + tls_config: None, + upstream_mode: UpstreamMode::Forward, + root_hints: Vec::new(), + srtt: RwLock::new(SrttCache::new(true)), + inflight: Mutex::new(HashMap::new()), + dnssec_enabled: false, + dnssec_strict: false, + health_meta: HealthMeta::test_fixture(), + ca_pem: None, + mobile_enabled: false, + mobile_port: 8765, + } +} + +/// Spawn a UDP socket that replies to the first DNS query with the given +/// response packet (patching the query ID to match). Returns the socket address. +pub async fn mock_upstream(response: DnsPacket) -> SocketAddr { + let sock = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let addr = sock.local_addr().unwrap(); + tokio::spawn(async move { + let mut buf = [0u8; 512]; + let (_, src) = sock.recv_from(&mut buf).await.unwrap(); + let query_id = u16::from_be_bytes([buf[0], buf[1]]); + let mut resp = response; + resp.header.id = query_id; + let mut out = BytePacketBuffer::new(); + resp.write(&mut out).unwrap(); + sock.send_to(out.filled(), src).await.unwrap(); + }); + addr +} + +/// UDP socket that accepts connections but never replies. +/// Useful as an upstream that triggers timeouts. +pub fn blackhole_upstream() -> SocketAddr { + let sock = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + let addr = sock.local_addr().unwrap(); + // Leak so it stays bound for the duration of the test process. + Box::leak(Box::new(sock)); + addr +} From 155c1c4da0f1fcd7f27c835939967a87ecebcae5 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 08:04:59 +0300 Subject: [PATCH 5/7] test: full-pipeline coverage for every resolve_query step Test each pipeline stage in isolation through resolve_query: - override takes precedence over all other paths - localhost and *.localhost resolve to loopback - local zone returns configured records - .tld proxy resolves registered services to loopback - blocklist sinkholes to 0.0.0.0 - cache hit returns stored response without upstream --- src/ctx.rs | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/src/ctx.rs b/src/ctx.rs index 475dfe7..460b0eb 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1098,4 +1098,125 @@ mod tests { ); assert_eq!(resp.header.rescode, ResultCode::NOERROR); } + + #[tokio::test] + async fn pipeline_override_takes_precedence() { + let ctx = crate::testutil::test_ctx().await; + ctx.overrides + .write() + .unwrap() + .insert("override.test", "1.2.3.4", 60, None) + .unwrap(); + let ctx = Arc::new(ctx); + + let (resp, path) = resolve_in_test(&ctx, "override.test", QueryType::A).await; + assert_eq!(path, QueryPath::Overridden); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + assert_eq!(resp.answers.len(), 1); + } + + #[tokio::test] + async fn pipeline_localhost_resolves_to_loopback() { + let ctx = Arc::new(crate::testutil::test_ctx().await); + + let (resp, path) = resolve_in_test(&ctx, "localhost", QueryType::A).await; + assert_eq!(path, QueryPath::Local); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + match &resp.answers[0] { + DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::LOCALHOST), + other => panic!("expected A record, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_localhost_subdomain_resolves_to_loopback() { + let ctx = Arc::new(crate::testutil::test_ctx().await); + + let (resp, path) = resolve_in_test(&ctx, "app.localhost", QueryType::A).await; + assert_eq!(path, QueryPath::Local); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + } + + #[tokio::test] + async fn pipeline_local_zone_returns_configured_record() { + let mut ctx = crate::testutil::test_ctx().await; + let mut inner = HashMap::new(); + inner.insert( + QueryType::A, + vec![DnsRecord::A { + domain: "myapp.test".to_string(), + addr: Ipv4Addr::new(10, 0, 0, 42), + ttl: 300, + }], + ); + ctx.zone_map.insert("myapp.test".to_string(), inner); + let ctx = Arc::new(ctx); + + let (resp, path) = resolve_in_test(&ctx, "myapp.test", QueryType::A).await; + assert_eq!(path, QueryPath::Local); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + match &resp.answers[0] { + DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::new(10, 0, 0, 42)), + other => panic!("expected A record, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_tld_proxy_resolves_service() { + let ctx = crate::testutil::test_ctx().await; + ctx.services.lock().unwrap().insert("grafana", 3000); + let ctx = Arc::new(ctx); + + let (resp, path) = resolve_in_test(&ctx, "grafana.numa", QueryType::A).await; + assert_eq!(path, QueryPath::Local); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + match &resp.answers[0] { + DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::LOCALHOST), + other => panic!("expected A record, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_blocklist_sinkhole() { + let ctx = crate::testutil::test_ctx().await; + let mut domains = std::collections::HashSet::new(); + domains.insert("ads.tracker.test".to_string()); + ctx.blocklist.write().unwrap().swap_domains(domains, vec![]); + let ctx = Arc::new(ctx); + + let (resp, path) = resolve_in_test(&ctx, "ads.tracker.test", QueryType::A).await; + assert_eq!(path, QueryPath::Blocked); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + match &resp.answers[0] { + DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::UNSPECIFIED), + other => panic!("expected sinkhole A record, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_cache_hit() { + let ctx = Arc::new(crate::testutil::test_ctx().await); + + // Pre-populate cache with a response + let mut pkt = DnsPacket::new(); + pkt.header.response = true; + pkt.header.rescode = ResultCode::NOERROR; + pkt.questions.push(crate::question::DnsQuestion { + name: "cached.test".to_string(), + qtype: QueryType::A, + }); + pkt.answers.push(DnsRecord::A { + domain: "cached.test".to_string(), + addr: Ipv4Addr::new(5, 5, 5, 5), + ttl: 3600, + }); + ctx.cache + .write() + .unwrap() + .insert("cached.test", QueryType::A, &pkt); + + let (resp, path) = resolve_in_test(&ctx, "cached.test", QueryType::A).await; + assert_eq!(path, QueryPath::Cached); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + } } From 0bdde40f4094fd298b2a2db7bcf74064451982b6 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 08:07:58 +0300 Subject: [PATCH 6/7] test: verify forwarded response content from mock upstream --- src/ctx.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/ctx.rs b/src/ctx.rs index 460b0eb..4e5d938 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1219,4 +1219,33 @@ mod tests { assert_eq!(path, QueryPath::Cached); assert_eq!(resp.header.rescode, ResultCode::NOERROR); } + + #[tokio::test] + async fn pipeline_forwarding_returns_upstream_answer() { + let mut upstream_resp = DnsPacket::new(); + upstream_resp.header.response = true; + upstream_resp.header.rescode = ResultCode::NOERROR; + upstream_resp.answers.push(DnsRecord::A { + domain: "internal.corp".to_string(), + addr: Ipv4Addr::new(10, 1, 2, 3), + ttl: 600, + }); + let upstream_addr = crate::testutil::mock_upstream(upstream_resp).await; + + let mut ctx = crate::testutil::test_ctx().await; + ctx.forwarding_rules = vec![ForwardingRule::new("corp".to_string(), upstream_addr)]; + let ctx = Arc::new(ctx); + + let (resp, path) = resolve_in_test(&ctx, "internal.corp", QueryType::A).await; + assert_eq!(path, QueryPath::Forwarded); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + assert_eq!(resp.answers.len(), 1); + match &resp.answers[0] { + DnsRecord::A { domain, addr, .. } => { + assert_eq!(domain, "internal.corp"); + assert_eq!(*addr, Ipv4Addr::new(10, 1, 2, 3)); + } + other => panic!("expected A record, got {:?}", other), + } + } } From d3f046da4cab44e8c6201003344336b46d81eb06 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 08:10:26 +0300 Subject: [PATCH 7/7] style: assert loopback addr in subdomain test, trim verbose comment --- src/ctx.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index 4e5d938..2812bed 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -123,8 +123,7 @@ pub async fn resolve_query( } else if is_special_use_domain(&qname) && crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules).is_none() { - // RFC 6761/8880: private PTR, DDR, NAT64 — answer locally, - // unless an explicit forwarding rule covers this zone. + // RFC 6761/8880: answer locally unless a forwarding rule covers this zone. let resp = special_use_response(&query, &qname, qtype); (resp, QueryPath::Local, DnssecStatus::Indeterminate) } else if !ctx.proxy_tld_suffix.is_empty() @@ -1135,6 +1134,10 @@ mod tests { let (resp, path) = resolve_in_test(&ctx, "app.localhost", QueryType::A).await; assert_eq!(path, QueryPath::Local); assert_eq!(resp.header.rescode, ResultCode::NOERROR); + match &resp.answers[0] { + DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::LOCALHOST), + other => panic!("expected A record, got {:?}", other), + } } #[tokio::test]