From 83d435a9193166b07f22124bed874a2b9db3169a Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 30 Mar 2026 00:36:26 +0300 Subject: [PATCH 01/19] feat: add DNS-over-TLS (DoT) listener (RFC 7858) Refactor handle_query into transport-agnostic resolve_query that returns a BytePacketBuffer, keeping the UDP path zero-alloc. Add a TLS listener on port 853 with persistent connections, idle timeout, connection limits, and coalesced writes. Supports user-provided certs or self-signed CA fallback. Includes 5 integration tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 10 ++ Cargo.toml | 1 + src/config.rs | 39 ++++- src/ctx.rs | 38 +++-- src/dot.rs | 444 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 12 ++ 7 files changed, 534 insertions(+), 11 deletions(-) create mode 100644 src/dot.rs diff --git a/Cargo.lock b/Cargo.lock index ea8eb0a..722c413 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1159,6 +1159,7 @@ dependencies = [ "reqwest", "ring", "rustls", + "rustls-pemfile", "serde", "serde_json", "socket2 0.5.10", @@ -1546,6 +1547,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" diff --git a/Cargo.toml b/Cargo.toml index 79ccf9d..c6e9a2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ rustls = "0.23" tokio-rustls = "0.26" arc-swap = "1" ring = "0.17" +rustls-pemfile = "2.2.0" [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } diff --git a/src/config.rs b/src/config.rs index 0cf5cb0..acf4d37 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::net::Ipv4Addr; use std::net::Ipv6Addr; -use std::path::Path; +use std::path::{Path, PathBuf}; use serde::Deserialize; @@ -29,6 +29,8 @@ pub struct Config { pub lan: LanConfig, #[serde(default)] pub dnssec: DnssecConfig, + #[serde(default)] + pub dot: DotConfig, } #[derive(Deserialize)] @@ -370,6 +372,41 @@ pub struct DnssecConfig { pub strict: bool, } +#[derive(Deserialize, Clone)] +pub struct DotConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_dot_port")] + pub port: u16, + #[serde(default = "default_dot_bind_addr")] + pub bind_addr: String, + /// Path to TLS certificate (PEM). If None, uses self-signed CA. + #[serde(default)] + pub cert_path: Option, + /// Path to TLS private key (PEM). If None, uses self-signed CA. + #[serde(default)] + pub key_path: Option, +} + +impl Default for DotConfig { + fn default() -> Self { + DotConfig { + enabled: false, + port: default_dot_port(), + bind_addr: default_dot_bind_addr(), + cert_path: None, + key_path: None, + } + } +} + +fn default_dot_port() -> u16 { + 853 +} +fn default_dot_bind_addr() -> String { + "0.0.0.0".to_string() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/ctx.rs b/src/ctx.rs index 7529bc1..5ad1bbc 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -62,24 +62,27 @@ pub struct ServerCtx { pub dnssec_strict: bool, } -pub async fn handle_query( +/// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist, +/// cache, upstream, DNSSEC) and returns the serialized response in a buffer. +/// Callers use `.filled()` to get the response bytes without heap allocation. +pub async fn resolve_query( mut buffer: BytePacketBuffer, src_addr: SocketAddr, ctx: &ServerCtx, -) -> crate::Result<()> { +) -> crate::Result { let start = Instant::now(); let query = match DnsPacket::from_buffer(&mut buffer) { Ok(packet) => packet, Err(e) => { warn!("{} | PARSE ERROR | {}", src_addr, e); - return Ok(()); + return Err(e); } }; let (qname, qtype) = match query.questions.first() { Some(q) => (q.name.clone(), q.qtype), - None => return Ok(()), + None => return Err("empty question section".into()), }; // Pipeline: overrides -> .tld interception -> blocklist -> local zones -> cache -> upstream @@ -306,17 +309,15 @@ pub async fn handle_query( response.resources.len(), ); + // Serialize response let mut resp_buffer = BytePacketBuffer::new(); if response.write(&mut resp_buffer).is_err() { - // Response too large for UDP — set TC bit and send header + question only + // Response too large — set TC bit and send header + question only debug!("response too large, setting TC bit for {}", qname); let mut tc_response = DnsPacket::response_from(&query, response.header.rescode); tc_response.header.truncated_message = true; - let mut tc_buffer = BytePacketBuffer::new(); - tc_response.write(&mut tc_buffer)?; - ctx.socket.send_to(tc_buffer.filled(), src_addr).await?; - } else { - ctx.socket.send_to(resp_buffer.filled(), src_addr).await?; + resp_buffer = BytePacketBuffer::new(); + tc_response.write(&mut resp_buffer)?; } // Record stats and query log @@ -339,6 +340,23 @@ pub async fn handle_query( dnssec, }); + Ok(resp_buffer) +} + +/// Handle a DNS query received over UDP. Thin wrapper around resolve_query. +pub async fn handle_query( + buffer: BytePacketBuffer, + src_addr: SocketAddr, + ctx: &ServerCtx, +) -> crate::Result<()> { + match resolve_query(buffer, src_addr, ctx).await { + Ok(resp_buffer) => { + ctx.socket.send_to(resp_buffer.filled(), src_addr).await?; + } + Err(e) => { + warn!("{} | RESOLVE ERROR | {}", src_addr, e); + } + } Ok(()) } diff --git a/src/dot.rs b/src/dot.rs new file mode 100644 index 0000000..4d86176 --- /dev/null +++ b/src/dot.rs @@ -0,0 +1,444 @@ +use std::net::SocketAddr; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use log::{debug, error, info, warn}; +use rustls::ServerConfig; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::sync::Semaphore; +use tokio_rustls::TlsAcceptor; + +use crate::buffer::BytePacketBuffer; +use crate::config::DotConfig; +use crate::ctx::{resolve_query, ServerCtx}; + +const MAX_CONNECTIONS: usize = 512; +const IDLE_TIMEOUT: Duration = Duration::from_secs(30); + +/// Build a TLS ServerConfig for DoT from user-provided cert/key PEM files. +fn load_tls_config(cert_path: &Path, key_path: &Path) -> crate::Result> { + let cert_pem = std::fs::read(cert_path)?; + let key_pem = std::fs::read(key_path)?; + + let certs: Vec<_> = rustls_pemfile::certs(&mut &cert_pem[..]).collect::>()?; + let key = rustls_pemfile::private_key(&mut &key_pem[..])? + .ok_or("no private key found in key file")?; + + let _ = rustls::crypto::ring::default_provider().install_default(); + + let config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key)?; + + Ok(Arc::new(config)) +} + +/// Start the DNS-over-TLS listener (RFC 7858). +pub async fn start_dot(ctx: Arc, config: &DotConfig) { + let tls_config = match (&config.cert_path, &config.key_path) { + (Some(cert), Some(key)) => match load_tls_config(cert, key) { + Ok(cfg) => cfg, + Err(e) => { + warn!("DoT: failed to load TLS cert/key: {} — DoT disabled", e); + return; + } + }, + _ => match ctx.tls_config.as_ref() { + Some(arc_swap) => Arc::clone(&*arc_swap.load()), + None => match crate::tls::build_tls_config(&ctx.proxy_tld, &[]) { + Ok(cfg) => cfg, + Err(e) => { + warn!( + "DoT: failed to generate self-signed TLS: {} — DoT disabled", + e + ); + return; + } + }, + }, + }; + + let bind_addr: std::net::Ipv4Addr = config + .bind_addr + .parse() + .unwrap_or(std::net::Ipv4Addr::UNSPECIFIED); + let addr: SocketAddr = (bind_addr, config.port).into(); + let listener = match TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + warn!("DoT: could not bind {} ({}) — DoT disabled", addr, e); + return; + } + }; + info!("DoT listening on {}", addr); + + let acceptor = TlsAcceptor::from(tls_config); + let semaphore = Arc::new(Semaphore::new(MAX_CONNECTIONS)); + + loop { + let (tcp_stream, remote_addr) = match listener.accept().await { + Ok(conn) => conn, + Err(e) => { + error!("DoT: TCP accept error: {}", e); + continue; + } + }; + + let permit = match semaphore.clone().try_acquire_owned() { + Ok(p) => p, + Err(_) => { + debug!("DoT: connection limit reached, rejecting {}", remote_addr); + continue; + } + }; + let acceptor = acceptor.clone(); + let ctx = Arc::clone(&ctx); + + tokio::spawn(async move { + let _permit = permit; // held until task exits + + let mut tls_stream = match acceptor.accept(tcp_stream).await { + Ok(s) => s, + Err(e) => { + debug!("DoT: TLS handshake failed from {}: {}", remote_addr, e); + return; + } + }; + + // RFC 7858: connection is persistent — read queries until EOF or idle timeout + loop { + // Read 2-byte length prefix (RFC 1035 §4.2.2) with idle timeout + let mut len_buf = [0u8; 2]; + match tokio::time::timeout(IDLE_TIMEOUT, tls_stream.read_exact(&mut len_buf)).await + { + Ok(Ok(_)) => {} + Ok(Err(_)) => break, // read error or EOF + Err(_) => break, // idle timeout + } + let msg_len = u16::from_be_bytes(len_buf) as usize; + if msg_len == 0 || msg_len > 4096 { + debug!( + "DoT: invalid message length {} from {}", + msg_len, remote_addr + ); + break; + } + + let mut data = vec![0u8; msg_len]; + if tls_stream.read_exact(&mut data).await.is_err() { + break; + } + + let buffer = BytePacketBuffer::from_bytes(&data); + match resolve_query(buffer, remote_addr, &ctx).await { + Ok(resp_buffer) => { + let resp = resp_buffer.filled(); + // Coalesce length prefix + response into a single TLS write + let mut out = Vec::with_capacity(2 + resp.len()); + out.extend_from_slice(&(resp.len() as u16).to_be_bytes()); + out.extend_from_slice(resp); + if tls_stream.write_all(&out).await.is_err() { + break; + } + } + Err(e) => { + debug!("DoT: resolve error from {}: {}", remote_addr, e); + } + } + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::{Mutex, RwLock}; + + use rcgen::{CertificateParams, DnType, KeyPair}; + use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + use crate::buffer::BytePacketBuffer; + use crate::header::ResultCode; + use crate::packet::DnsPacket; + use crate::question::QueryType; + use crate::record::DnsRecord; + + /// Generate a self-signed cert + key in memory, return (ServerConfig, ClientConfig). + fn test_tls_configs() -> (Arc, Arc) { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let key_pair = KeyPair::generate().unwrap(); + let mut params = CertificateParams::default(); + params + .distinguished_name + .push(DnType::CommonName, "localhost"); + params.subject_alt_names = vec![rcgen::SanType::DnsName("localhost".try_into().unwrap())]; + let cert = params.self_signed(&key_pair).unwrap(); + + let cert_der = CertificateDer::from(cert.der().to_vec()); + let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der())); + + let server_config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert_der.clone()], key_der) + .unwrap(); + + let mut root_store = rustls::RootCertStore::empty(); + root_store.add(cert_der).unwrap(); + let client_config = rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + + (Arc::new(server_config), Arc::new(client_config)) + } + + /// Spin up a DoT listener with a test TLS config. Returns (addr, client_config). + async fn spawn_dot_server() -> (SocketAddr, Arc) { + let (server_tls, client_tls) = test_tls_configs(); + + let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.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)), + stats: Mutex::new(crate::stats::ServerStats::new()), + overrides: RwLock::new(crate::override_store::OverrideStore::new()), + blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()), + query_log: Mutex::new(crate::query_log::QueryLog::new(100)), + services: Mutex::new(crate::service_store::ServiceStore::new()), + lan_peers: Mutex::new(crate::lan::PeerStore::new(90)), + forwarding_rules: Vec::new(), + upstream: Mutex::new(crate::forward::Upstream::Udp( + "127.0.0.1:53".parse().unwrap(), + )), + upstream_auto: false, + upstream_port: 53, + lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST), + timeout: Duration::from_secs(3), + 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, + }); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let tls_config = Arc::clone(&*ctx.tls_config.as_ref().unwrap().load()); + let acceptor = TlsAcceptor::from(tls_config); + let semaphore = Arc::new(Semaphore::new(MAX_CONNECTIONS)); + + tokio::spawn(async move { + loop { + let (tcp_stream, remote_addr) = match listener.accept().await { + Ok(conn) => conn, + Err(_) => return, + }; + let permit = match semaphore.clone().try_acquire_owned() { + Ok(p) => p, + Err(_) => continue, + }; + let acceptor = acceptor.clone(); + let ctx = Arc::clone(&ctx); + tokio::spawn(async move { + let _permit = permit; + let mut tls_stream = match acceptor.accept(tcp_stream).await { + Ok(s) => s, + Err(_) => return, + }; + loop { + let mut len_buf = [0u8; 2]; + match tokio::time::timeout( + IDLE_TIMEOUT, + tls_stream.read_exact(&mut len_buf), + ) + .await + { + Ok(Ok(_)) => {} + _ => break, + } + let msg_len = u16::from_be_bytes(len_buf) as usize; + if msg_len == 0 || msg_len > 4096 { + break; + } + let mut data = vec![0u8; msg_len]; + if tls_stream.read_exact(&mut data).await.is_err() { + break; + } + let buffer = BytePacketBuffer::from_bytes(&data); + match resolve_query(buffer, remote_addr, &ctx).await { + Ok(resp_buffer) => { + let resp = resp_buffer.filled(); + let mut out = Vec::with_capacity(2 + resp.len()); + out.extend_from_slice(&(resp.len() as u16).to_be_bytes()); + out.extend_from_slice(resp); + if tls_stream.write_all(&out).await.is_err() { + break; + } + } + Err(_) => {} + } + } + }); + } + }); + + (addr, client_tls) + } + + /// Open a TLS connection to the DoT server and return the stream. + async fn dot_connect( + addr: SocketAddr, + client_config: &Arc, + ) -> tokio_rustls::client::TlsStream { + let connector = tokio_rustls::TlsConnector::from(Arc::clone(client_config)); + let tcp = tokio::net::TcpStream::connect(addr).await.unwrap(); + connector + .connect(ServerName::try_from("localhost").unwrap(), tcp) + .await + .unwrap() + } + + /// Send a DNS query over a DoT stream and read the response. + async fn dot_exchange( + stream: &mut tokio_rustls::client::TlsStream, + query: &DnsPacket, + ) -> DnsPacket { + let mut buf = BytePacketBuffer::new(); + query.write(&mut buf).unwrap(); + let msg = buf.filled(); + + let mut out = Vec::with_capacity(2 + msg.len()); + out.extend_from_slice(&(msg.len() as u16).to_be_bytes()); + out.extend_from_slice(msg); + stream.write_all(&out).await.unwrap(); + + let mut len_buf = [0u8; 2]; + stream.read_exact(&mut len_buf).await.unwrap(); + let resp_len = u16::from_be_bytes(len_buf) as usize; + + let mut data = vec![0u8; resp_len]; + stream.read_exact(&mut data).await.unwrap(); + + let mut resp_buf = BytePacketBuffer::from_bytes(&data); + DnsPacket::from_buffer(&mut resp_buf).unwrap() + } + + #[tokio::test] + async fn dot_resolves_local_zone() { + let (addr, client_config) = spawn_dot_server().await; + let mut stream = dot_connect(addr, &client_config).await; + + let query = DnsPacket::query(0x1234, "dot-test.example", QueryType::A); + let resp = dot_exchange(&mut stream, &query).await; + + assert_eq!(resp.header.id, 0x1234); + assert!(resp.header.response); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + assert_eq!(resp.answers.len(), 1); + match &resp.answers[0] { + DnsRecord::A { domain, addr, ttl } => { + assert_eq!(domain, "dot-test.example"); + assert_eq!(*addr, std::net::Ipv4Addr::new(10, 0, 0, 1)); + assert_eq!(*ttl, 300); + } + other => panic!("expected A record, got {:?}", other), + } + } + + #[tokio::test] + async fn dot_multiple_queries_on_persistent_connection() { + let (addr, client_config) = spawn_dot_server().await; + let mut stream = dot_connect(addr, &client_config).await; + + // Send 3 queries on the same TLS connection + for i in 0..3u16 { + let query = DnsPacket::query(0xA000 + i, "dot-test.example", QueryType::A); + let resp = dot_exchange(&mut stream, &query).await; + assert_eq!(resp.header.id, 0xA000 + i); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + assert_eq!(resp.answers.len(), 1); + } + } + + #[tokio::test] + async fn dot_nxdomain_for_unknown() { + let (addr, client_config) = spawn_dot_server().await; + let mut stream = dot_connect(addr, &client_config).await; + + let query = DnsPacket::query(0xBEEF, "nonexistent.test", QueryType::A); + let resp = dot_exchange(&mut stream, &query).await; + + assert_eq!(resp.header.id, 0xBEEF); + assert!(resp.header.response); + // Query goes to upstream (127.0.0.1:53), which will fail — expect SERVFAIL + assert_eq!(resp.header.rescode, ResultCode::SERVFAIL); + } + + #[tokio::test] + async fn dot_concurrent_connections() { + let (addr, client_config) = spawn_dot_server().await; + + let mut handles = Vec::new(); + for i in 0..5u16 { + let cfg = Arc::clone(&client_config); + handles.push(tokio::spawn(async move { + let mut stream = dot_connect(addr, &cfg).await; + let query = DnsPacket::query(0xC000 + i, "dot-test.example", QueryType::A); + let resp = dot_exchange(&mut stream, &query).await; + assert_eq!(resp.header.id, 0xC000 + i); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + assert_eq!(resp.answers.len(), 1); + })); + } + + for h in handles { + h.await.unwrap(); + } + } + + #[tokio::test] + async fn dot_localhost_resolution() { + let (addr, client_config) = spawn_dot_server().await; + let mut stream = dot_connect(addr, &client_config).await; + + let query = DnsPacket::query(0xD000, "localhost", QueryType::A); + let resp = dot_exchange(&mut stream, &query).await; + + assert_eq!(resp.header.id, 0xD000); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + assert_eq!(resp.answers.len(), 1); + match &resp.answers[0] { + DnsRecord::A { addr, .. } => assert_eq!(*addr, std::net::Ipv4Addr::LOCALHOST), + other => panic!("expected A record, got {:?}", other), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index cff1a48..36017fe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod cache; pub mod config; pub mod ctx; pub mod dnssec; +pub mod dot; pub mod forward; pub mod header; pub mod lan; diff --git a/src/main.rs b/src/main.rs index 68022fc..b9316b8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -370,6 +370,9 @@ async fn main() -> numa::Result<()> { ); } } + if config.dot.enabled { + row("DoT", g, &format!("tls://:{}", config.dot.port)); + } if config.lan.enabled { row("LAN", g, "mDNS (_numa._tcp.local)"); } @@ -477,6 +480,15 @@ async fn main() -> numa::Result<()> { }); } + // Spawn DNS-over-TLS listener (RFC 7858) + if config.dot.enabled { + let dot_ctx = Arc::clone(&ctx); + let dot_config = config.dot.clone(); + tokio::spawn(async move { + numa::dot::start_dot(dot_ctx, &dot_config).await; + }); + } + // UDP DNS listener #[allow(clippy::infinite_loop)] loop { -- 2.34.1 From ee310fe7cb415d62bcf4f8d5cd9c7e4711b25a7a Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 30 Mar 2026 00:50:04 +0300 Subject: [PATCH 02/19] fix: send SERVFAIL on DoT resolve errors, extract shared connection handler - Send SERVFAIL response (with correct query ID) when resolve_query fails, preventing DoT clients from hanging until idle timeout - Extract handle_dot_connection() so tests use the same logic as production, eliminating duplicated accept/read/resolve loop - Replace magic 4096 with named MAX_MSG_LEN constant tied to BUF_SIZE - Add flush() after each TLS write to prevent buffered responses - Extract fallback_tls() helper, handle partial cert/key config, support IPv6 bind address, remove redundant crypto provider init Co-Authored-By: Claude Opus 4.6 (1M context) --- src/dot.rs | 197 ++++++++++++++++++++++++++++------------------------- 1 file changed, 103 insertions(+), 94 deletions(-) diff --git a/src/dot.rs b/src/dot.rs index 4d86176..e10e7b7 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -1,4 +1,4 @@ -use std::net::SocketAddr; +use std::net::{IpAddr, SocketAddr}; use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -13,9 +13,14 @@ use tokio_rustls::TlsAcceptor; use crate::buffer::BytePacketBuffer; use crate::config::DotConfig; use crate::ctx::{resolve_query, ServerCtx}; +use crate::header::ResultCode; +use crate::packet::DnsPacket; const MAX_CONNECTIONS: usize = 512; const IDLE_TIMEOUT: Duration = Duration::from_secs(30); +// Matches BytePacketBuffer::BUF_SIZE — RFC 7858 allows up to 65535 but our +// buffer would silently truncate anything larger. +const MAX_MSG_LEN: usize = 4096; /// Build a TLS ServerConfig for DoT from user-provided cert/key PEM files. fn load_tls_config(cert_path: &Path, key_path: &Path) -> crate::Result> { @@ -26,8 +31,6 @@ fn load_tls_config(cert_path: &Path, key_path: &Path) -> crate::Result crate::Result Option> { + if let Some(arc_swap) = ctx.tls_config.as_ref() { + return Some(Arc::clone(&*arc_swap.load())); + } + match crate::tls::build_tls_config(&ctx.proxy_tld, &[]) { + Ok(cfg) => Some(cfg), + Err(e) => { + warn!( + "DoT: failed to generate self-signed TLS: {} — DoT disabled", + e + ); + None + } + } +} + /// Start the DNS-over-TLS listener (RFC 7858). pub async fn start_dot(ctx: Arc, config: &DotConfig) { let tls_config = match (&config.cert_path, &config.key_path) { @@ -45,26 +64,24 @@ pub async fn start_dot(ctx: Arc, config: &DotConfig) { return; } }, - _ => match ctx.tls_config.as_ref() { - Some(arc_swap) => Arc::clone(&*arc_swap.load()), - None => match crate::tls::build_tls_config(&ctx.proxy_tld, &[]) { - Ok(cfg) => cfg, - Err(e) => { - warn!( - "DoT: failed to generate self-signed TLS: {} — DoT disabled", - e - ); - return; - } - }, + (Some(_), None) | (None, Some(_)) => { + warn!("DoT: both cert_path and key_path must be set — ignoring partial config, using self-signed"); + match fallback_tls(&ctx) { + Some(cfg) => cfg, + None => return, + } + } + (None, None) => match fallback_tls(&ctx) { + Some(cfg) => cfg, + None => return, }, }; - let bind_addr: std::net::Ipv4Addr = config + let bind_addr: IpAddr = config .bind_addr .parse() - .unwrap_or(std::net::Ipv4Addr::UNSPECIFIED); - let addr: SocketAddr = (bind_addr, config.port).into(); + .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)); + let addr = SocketAddr::new(bind_addr, config.port); let listener = match TcpListener::bind(addr).await { Ok(l) => l, Err(e) => { @@ -99,7 +116,7 @@ pub async fn start_dot(ctx: Arc, config: &DotConfig) { tokio::spawn(async move { let _permit = permit; // held until task exits - let mut tls_stream = match acceptor.accept(tcp_stream).await { + let tls_stream = match acceptor.accept(tcp_stream).await { Ok(s) => s, Err(e) => { debug!("DoT: TLS handshake failed from {}: {}", remote_addr, e); @@ -107,51 +124,75 @@ pub async fn start_dot(ctx: Arc, config: &DotConfig) { } }; - // RFC 7858: connection is persistent — read queries until EOF or idle timeout - loop { - // Read 2-byte length prefix (RFC 1035 §4.2.2) with idle timeout - let mut len_buf = [0u8; 2]; - match tokio::time::timeout(IDLE_TIMEOUT, tls_stream.read_exact(&mut len_buf)).await - { - Ok(Ok(_)) => {} - Ok(Err(_)) => break, // read error or EOF - Err(_) => break, // idle timeout - } - let msg_len = u16::from_be_bytes(len_buf) as usize; - if msg_len == 0 || msg_len > 4096 { - debug!( - "DoT: invalid message length {} from {}", - msg_len, remote_addr - ); - break; - } - - let mut data = vec![0u8; msg_len]; - if tls_stream.read_exact(&mut data).await.is_err() { - break; - } - - let buffer = BytePacketBuffer::from_bytes(&data); - match resolve_query(buffer, remote_addr, &ctx).await { - Ok(resp_buffer) => { - let resp = resp_buffer.filled(); - // Coalesce length prefix + response into a single TLS write - let mut out = Vec::with_capacity(2 + resp.len()); - out.extend_from_slice(&(resp.len() as u16).to_be_bytes()); - out.extend_from_slice(resp); - if tls_stream.write_all(&out).await.is_err() { - break; - } - } - Err(e) => { - debug!("DoT: resolve error from {}: {}", remote_addr, e); - } - } - } + handle_dot_connection(tls_stream, remote_addr, &ctx).await; }); } } +/// Handle a single persistent DoT connection (RFC 7858). +/// Reads length-prefixed DNS queries until EOF, idle timeout, or error. +async fn handle_dot_connection(mut stream: S, remote_addr: SocketAddr, ctx: &ServerCtx) +where + S: AsyncReadExt + AsyncWriteExt + Unpin, +{ + loop { + // Read 2-byte length prefix (RFC 1035 §4.2.2) with idle timeout + let mut len_buf = [0u8; 2]; + match tokio::time::timeout(IDLE_TIMEOUT, stream.read_exact(&mut len_buf)).await { + Ok(Ok(_)) => {} + Ok(Err(_)) => break, // read error or EOF + Err(_) => break, // idle timeout + } + let msg_len = u16::from_be_bytes(len_buf) as usize; + if msg_len == 0 || msg_len > MAX_MSG_LEN { + debug!( + "DoT: invalid message length {} from {}", + msg_len, remote_addr + ); + break; + } + + let mut data = vec![0u8; msg_len]; + if stream.read_exact(&mut data).await.is_err() { + break; + } + + // Extract query ID before resolve_query consumes the buffer + let query_id = data + .get(..2) + .map(|b| u16::from_be_bytes([b[0], b[1]])) + .unwrap_or(0); + + let buffer = BytePacketBuffer::from_bytes(&data); + let resp_buffer = match resolve_query(buffer, remote_addr, ctx).await { + Ok(buf) => buf, + Err(e) => { + debug!("DoT: resolve error from {}: {}", remote_addr, e); + // Send SERVFAIL so the client doesn't hang + let mut resp = DnsPacket::new(); + resp.header.id = query_id; + resp.header.response = true; + resp.header.rescode = ResultCode::SERVFAIL; + let mut buf = BytePacketBuffer::new(); + if resp.write(&mut buf).is_err() { + break; + } + buf + } + }; + let resp = resp_buffer.filled(); + let mut out = Vec::with_capacity(2 + resp.len()); + out.extend_from_slice(&(resp.len() as u16).to_be_bytes()); + out.extend_from_slice(resp); + if stream.write_all(&out).await.is_err() { + break; + } + if stream.flush().await.is_err() { + break; + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -270,43 +311,11 @@ mod tests { let ctx = Arc::clone(&ctx); tokio::spawn(async move { let _permit = permit; - let mut tls_stream = match acceptor.accept(tcp_stream).await { + let tls_stream = match acceptor.accept(tcp_stream).await { Ok(s) => s, Err(_) => return, }; - loop { - let mut len_buf = [0u8; 2]; - match tokio::time::timeout( - IDLE_TIMEOUT, - tls_stream.read_exact(&mut len_buf), - ) - .await - { - Ok(Ok(_)) => {} - _ => break, - } - let msg_len = u16::from_be_bytes(len_buf) as usize; - if msg_len == 0 || msg_len > 4096 { - break; - } - let mut data = vec![0u8; msg_len]; - if tls_stream.read_exact(&mut data).await.is_err() { - break; - } - let buffer = BytePacketBuffer::from_bytes(&data); - match resolve_query(buffer, remote_addr, &ctx).await { - Ok(resp_buffer) => { - let resp = resp_buffer.filled(); - let mut out = Vec::with_capacity(2 + resp.len()); - out.extend_from_slice(&(resp.len() as u16).to_be_bytes()); - out.extend_from_slice(resp); - if tls_stream.write_all(&out).await.is_err() { - break; - } - } - Err(_) => {} - } - } + handle_dot_connection(tls_stream, remote_addr, &ctx).await; }); } }); -- 2.34.1 From 349ed3378f96e9c7eee794c1d209eec323d9fc00 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 30 Mar 2026 01:31:51 +0300 Subject: [PATCH 03/19] fix: add debug logging for DoT SERVFAIL serialization failure, TC-bit TODO Co-Authored-By: Claude Opus 4.6 --- src/ctx.rs | 2 ++ src/dot.rs | 1 + 2 files changed, 3 insertions(+) diff --git a/src/ctx.rs b/src/ctx.rs index 5ad1bbc..d3d4eb0 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -310,6 +310,8 @@ pub async fn resolve_query( ); // Serialize response + // TODO: TC bit is UDP-specific; DoT connections could carry up to 65535 bytes. + // Once BytePacketBuffer supports larger buffers, skip truncation for TCP/TLS. let mut resp_buffer = BytePacketBuffer::new(); if response.write(&mut resp_buffer).is_err() { // Response too large — set TC bit and send header + question only diff --git a/src/dot.rs b/src/dot.rs index e10e7b7..d780727 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -175,6 +175,7 @@ where resp.header.rescode = ResultCode::SERVFAIL; let mut buf = BytePacketBuffer::new(); if resp.write(&mut buf).is_err() { + debug!("DoT: failed to serialize SERVFAIL for {}", remote_addr); break; } buf -- 2.34.1 From b78cd44c999473ffc47119ead7ee465f3782c225 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 6 Apr 2026 23:10:45 +0300 Subject: [PATCH 04/19] fix: harden DoT listener against slowloris and stale handshakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 10s timeout on TLS handshake — prevents clients from holding a semaphore permit without completing the handshake - Add IDLE_TIMEOUT on payload read_exact — prevents slowloris after sending a valid length prefix then trickling bytes - Extract accept_loop() shared between start_dot and tests — eliminates duplicated accept logic that could drift - Add 5s timeout on TCP reads in recursive test mock server Co-Authored-By: Claude Opus 4.6 (1M context) --- src/dot.rs | 70 ++++++++++++++++++++---------------------------- src/recursive.rs | 15 +++++++++-- 2 files changed, 42 insertions(+), 43 deletions(-) diff --git a/src/dot.rs b/src/dot.rs index d780727..d9c1180 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -18,6 +18,7 @@ use crate::packet::DnsPacket; const MAX_CONNECTIONS: usize = 512; const IDLE_TIMEOUT: Duration = Duration::from_secs(30); +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); // Matches BytePacketBuffer::BUF_SIZE — RFC 7858 allows up to 65535 but our // buffer would silently truncate anything larger. const MAX_MSG_LEN: usize = 4096; @@ -91,7 +92,10 @@ pub async fn start_dot(ctx: Arc, config: &DotConfig) { }; info!("DoT listening on {}", addr); - let acceptor = TlsAcceptor::from(tls_config); + accept_loop(listener, TlsAcceptor::from(tls_config), ctx).await; +} + +async fn accept_loop(listener: TcpListener, acceptor: TlsAcceptor, ctx: Arc) { let semaphore = Arc::new(Semaphore::new(MAX_CONNECTIONS)); loop { @@ -116,13 +120,18 @@ pub async fn start_dot(ctx: Arc, config: &DotConfig) { tokio::spawn(async move { let _permit = permit; // held until task exits - let tls_stream = match acceptor.accept(tcp_stream).await { - Ok(s) => s, - Err(e) => { - debug!("DoT: TLS handshake failed from {}: {}", remote_addr, e); - return; - } - }; + let tls_stream = + match tokio::time::timeout(HANDSHAKE_TIMEOUT, acceptor.accept(tcp_stream)).await { + Ok(Ok(s)) => s, + Ok(Err(e)) => { + debug!("DoT: TLS handshake failed from {}: {}", remote_addr, e); + return; + } + Err(_) => { + debug!("DoT: TLS handshake timeout from {}", remote_addr); + return; + } + }; handle_dot_connection(tls_stream, remote_addr, &ctx).await; }); @@ -152,18 +161,19 @@ where break; } - let mut data = vec![0u8; msg_len]; - if stream.read_exact(&mut data).await.is_err() { - break; + let mut buffer = BytePacketBuffer::new(); + match tokio::time::timeout(IDLE_TIMEOUT, stream.read_exact(&mut buffer.buf[..msg_len])) + .await + { + Ok(Ok(_)) => {} + Ok(Err(_)) => break, + Err(_) => { + debug!("DoT: payload read timeout from {}", remote_addr); + break; + } } - // Extract query ID before resolve_query consumes the buffer - let query_id = data - .get(..2) - .map(|b| u16::from_be_bytes([b[0], b[1]])) - .unwrap_or(0); - - let buffer = BytePacketBuffer::from_bytes(&data); + let query_id = u16::from_be_bytes([buffer.buf[0], buffer.buf[1]]); let resp_buffer = match resolve_query(buffer, remote_addr, ctx).await { Ok(buf) => buf, Err(e) => { @@ -296,30 +306,8 @@ mod tests { let tls_config = Arc::clone(&*ctx.tls_config.as_ref().unwrap().load()); let acceptor = TlsAcceptor::from(tls_config); - let semaphore = Arc::new(Semaphore::new(MAX_CONNECTIONS)); - tokio::spawn(async move { - loop { - let (tcp_stream, remote_addr) = match listener.accept().await { - Ok(conn) => conn, - Err(_) => return, - }; - let permit = match semaphore.clone().try_acquire_owned() { - Ok(p) => p, - Err(_) => continue, - }; - let acceptor = acceptor.clone(); - let ctx = Arc::clone(&ctx); - tokio::spawn(async move { - let _permit = permit; - let tls_stream = match acceptor.accept(tcp_stream).await { - Ok(s) => s, - Err(_) => return, - }; - handle_dot_connection(tls_stream, remote_addr, &ctx).await; - }); - } - }); + tokio::spawn(accept_loop(listener, acceptor, ctx)); (addr, client_tls) } diff --git a/src/recursive.rs b/src/recursive.rs index 7801bec..24d0367 100644 --- a/src/recursive.rs +++ b/src/recursive.rs @@ -870,14 +870,25 @@ mod tests { }; let handler = handler.clone(); tokio::spawn(async move { + let timeout = std::time::Duration::from_secs(5); // Read length-prefixed DNS query let mut len_buf = [0u8; 2]; - if stream.read_exact(&mut len_buf).await.is_err() { + if tokio::time::timeout(timeout, stream.read_exact(&mut len_buf)) + .await + .ok() + .and_then(|r| r.ok()) + .is_none() + { return; } let len = u16::from_be_bytes(len_buf) as usize; let mut data = vec![0u8; len]; - if stream.read_exact(&mut data).await.is_err() { + if tokio::time::timeout(timeout, stream.read_exact(&mut data)) + .await + .ok() + .and_then(|r| r.ok()) + .is_none() + { return; } -- 2.34.1 From 7be923e6d24e4fc89c6026324d4c944a5ed7c595 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 7 Apr 2026 16:47:54 +0300 Subject: [PATCH 05/19] fix: parse DoT queries up-front and echo question in SERVFAIL Address review findings on PR #25: - Refactor resolve_query to take a pre-parsed DnsPacket. Parse-error handling moves to the UDP caller, eliminating the double warn! line on malformed UDP queries. - Enforce MIN_MSG_LEN=12 (DNS header) in handle_dot_connection so query_id extraction is always reading client-sent bytes, not the zeroed buffer tail. - Parse the DoT query before calling resolve_query and retain it, so SERVFAIL responses can echo the original question section via response_from(). Parse failures send FORMERR with the client id. - Extract write_framed() helper for length-prefix + flush, reused by success, SERVFAIL, and FORMERR paths. - Back off 100ms on listener.accept() errors to avoid tight-looping on fd exhaustion. - Replace the hardcoded 127.0.0.1:53 upstream in dot_nxdomain_for_unknown with a bound-but-unresponsive UDP socket owned by the test, making it independent of the host's local resolver. Test now runs in ~220ms (timeout lowered to 200ms) instead of 3s and asserts the question is echoed in the SERVFAIL response. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ctx.rs | 23 ++++++++------- src/dot.rs | 87 +++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 76 insertions(+), 34 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index d3d4eb0..17a4979 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -65,21 +65,15 @@ pub struct ServerCtx { /// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist, /// cache, upstream, DNSSEC) and returns the serialized response in a buffer. /// Callers use `.filled()` to get the response bytes without heap allocation. +/// Callers are responsible for parsing the incoming buffer into a `DnsPacket` +/// (and logging parse errors) before calling this function. pub async fn resolve_query( - mut buffer: BytePacketBuffer, + query: DnsPacket, src_addr: SocketAddr, ctx: &ServerCtx, ) -> crate::Result { let start = Instant::now(); - let query = match DnsPacket::from_buffer(&mut buffer) { - Ok(packet) => packet, - Err(e) => { - warn!("{} | PARSE ERROR | {}", src_addr, e); - return Err(e); - } - }; - let (qname, qtype) = match query.questions.first() { Some(q) => (q.name.clone(), q.qtype), None => return Err("empty question section".into()), @@ -347,11 +341,18 @@ pub async fn resolve_query( /// Handle a DNS query received over UDP. Thin wrapper around resolve_query. pub async fn handle_query( - buffer: BytePacketBuffer, + mut buffer: BytePacketBuffer, src_addr: SocketAddr, ctx: &ServerCtx, ) -> crate::Result<()> { - match resolve_query(buffer, src_addr, ctx).await { + let query = match DnsPacket::from_buffer(&mut buffer) { + Ok(packet) => packet, + Err(e) => { + warn!("{} | PARSE ERROR | {}", src_addr, e); + return Ok(()); + } + }; + match resolve_query(query, src_addr, ctx).await { Ok(resp_buffer) => { ctx.socket.send_to(resp_buffer.filled(), src_addr).await?; } diff --git a/src/dot.rs b/src/dot.rs index d9c1180..2178c26 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -19,9 +19,12 @@ use crate::packet::DnsPacket; const MAX_CONNECTIONS: usize = 512; const IDLE_TIMEOUT: Duration = Duration::from_secs(30); const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); +const ACCEPT_ERROR_BACKOFF: Duration = Duration::from_millis(100); // Matches BytePacketBuffer::BUF_SIZE — RFC 7858 allows up to 65535 but our // buffer would silently truncate anything larger. const MAX_MSG_LEN: usize = 4096; +// DNS header is 12 bytes; anything shorter cannot be a valid query. +const MIN_MSG_LEN: usize = 12; /// Build a TLS ServerConfig for DoT from user-provided cert/key PEM files. fn load_tls_config(cert_path: &Path, key_path: &Path) -> crate::Result> { @@ -103,6 +106,8 @@ async fn accept_loop(listener: TcpListener, acceptor: TlsAcceptor, ctx: Arc conn, Err(e) => { error!("DoT: TCP accept error: {}", e); + // Back off to avoid tight-looping on persistent failures (e.g. fd exhaustion). + tokio::time::sleep(ACCEPT_ERROR_BACKOFF).await; continue; } }; @@ -153,7 +158,7 @@ where Err(_) => break, // idle timeout } let msg_len = u16::from_be_bytes(len_buf) as usize; - if msg_len == 0 || msg_len > MAX_MSG_LEN { + if !(MIN_MSG_LEN..=MAX_MSG_LEN).contains(&msg_len) { debug!( "DoT: invalid message length {} from {}", msg_len, remote_addr @@ -173,37 +178,66 @@ where } } - let query_id = u16::from_be_bytes([buffer.buf[0], buffer.buf[1]]); - let resp_buffer = match resolve_query(buffer, remote_addr, ctx).await { - Ok(buf) => buf, + // Parse query up-front so we can echo its question section in SERVFAIL + // responses when resolve_query fails. + let query = match DnsPacket::from_buffer(&mut buffer) { + Ok(q) => q, Err(e) => { - debug!("DoT: resolve error from {}: {}", remote_addr, e); - // Send SERVFAIL so the client doesn't hang + warn!("{} | PARSE ERROR | {}", remote_addr, e); + // msg_len >= MIN_MSG_LEN guarantees buf[0..2] is the client's query id. + let query_id = u16::from_be_bytes([buffer.buf[0], buffer.buf[1]]); let mut resp = DnsPacket::new(); resp.header.id = query_id; resp.header.response = true; - resp.header.rescode = ResultCode::SERVFAIL; - let mut buf = BytePacketBuffer::new(); - if resp.write(&mut buf).is_err() { + resp.header.rescode = ResultCode::FORMERR; + let mut out_buf = BytePacketBuffer::new(); + if resp.write(&mut out_buf).is_err() { + debug!("DoT: failed to serialize FORMERR for {}", remote_addr); + break; + } + if write_framed(&mut stream, out_buf.filled()).await.is_err() { + break; + } + continue; + } + }; + + let resp_buffer = match resolve_query(query.clone(), remote_addr, ctx).await { + Ok(buf) => buf, + Err(e) => { + warn!("{} | RESOLVE ERROR | {}", remote_addr, e); + // Build SERVFAIL that echoes the original question section. + let resp = DnsPacket::response_from(&query, ResultCode::SERVFAIL); + let mut out_buf = BytePacketBuffer::new(); + if resp.write(&mut out_buf).is_err() { debug!("DoT: failed to serialize SERVFAIL for {}", remote_addr); break; } - buf + out_buf } }; - let resp = resp_buffer.filled(); - let mut out = Vec::with_capacity(2 + resp.len()); - out.extend_from_slice(&(resp.len() as u16).to_be_bytes()); - out.extend_from_slice(resp); - if stream.write_all(&out).await.is_err() { - break; - } - if stream.flush().await.is_err() { + if write_framed(&mut stream, resp_buffer.filled()) + .await + .is_err() + { break; } } } +/// Write a DNS message with its 2-byte length prefix, coalesced into one syscall. +async fn write_framed(stream: &mut S, msg: &[u8]) -> std::io::Result<()> +where + S: AsyncWriteExt + Unpin, +{ + let mut out = Vec::with_capacity(2 + msg.len()); + out.extend_from_slice(&(msg.len() as u16).to_be_bytes()); + out.extend_from_slice(msg); + stream.write_all(&out).await?; + stream.flush().await?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -250,10 +284,16 @@ mod tests { } /// Spin up a DoT listener with a test TLS config. Returns (addr, client_config). + /// The upstream is pointed at a bound-but-unresponsive UDP socket we own, so + /// any query that escapes to the upstream path times out deterministically + /// (SERVFAIL) regardless of what the host has running on port 53. async fn spawn_dot_server() -> (SocketAddr, Arc) { let (server_tls, client_tls) = 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: { @@ -278,13 +318,11 @@ mod tests { services: Mutex::new(crate::service_store::ServiceStore::new()), lan_peers: Mutex::new(crate::lan::PeerStore::new(90)), forwarding_rules: Vec::new(), - upstream: Mutex::new(crate::forward::Upstream::Udp( - "127.0.0.1:53".parse().unwrap(), - )), + upstream: Mutex::new(crate::forward::Upstream::Udp(upstream_addr)), upstream_auto: false, upstream_port: 53, lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST), - timeout: Duration::from_secs(3), + timeout: Duration::from_millis(200), proxy_tld: "numa".to_string(), proxy_tld_suffix: ".numa".to_string(), lan_enabled: false, @@ -397,8 +435,11 @@ mod tests { assert_eq!(resp.header.id, 0xBEEF); assert!(resp.header.response); - // Query goes to upstream (127.0.0.1:53), which will fail — expect SERVFAIL + // Query goes to the blackhole upstream which never replies → SERVFAIL. + // The SERVFAIL response echoes the question section. assert_eq!(resp.header.rescode, ResultCode::SERVFAIL); + assert_eq!(resp.questions.len(), 1); + assert_eq!(resp.questions[0].name, "nonexistent.test"); } #[tokio::test] -- 2.34.1 From 829d2b9d21d04b15aa81bce4d5c4a319debca3be Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 7 Apr 2026 20:10:51 +0300 Subject: [PATCH 06/19] refactor: simplify DoT cert/key match and extract send_response helper - Flatten 4-arm cert/key match in start_dot to 2 arms with the partial-config warning hoisted into a one-liner above the match. - Extract send_response() that serializes a DnsPacket and writes it framed, used by both the FORMERR-on-parse-error and SERVFAIL-on- resolve-error paths. Removes duplicated buffer/write/log boilerplate and unifies the rescode logging via {:?}. No behavior change; 126/126 tests still pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/dot.rs | 70 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/src/dot.rs b/src/dot.rs index 2178c26..291f6f0 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -60,6 +60,9 @@ fn fallback_tls(ctx: &ServerCtx) -> Option> { /// Start the DNS-over-TLS listener (RFC 7858). pub async fn start_dot(ctx: Arc, config: &DotConfig) { + if config.cert_path.is_some() != config.key_path.is_some() { + warn!("DoT: both cert_path and key_path must be set — ignoring partial config, using self-signed"); + } let tls_config = match (&config.cert_path, &config.key_path) { (Some(cert), Some(key)) => match load_tls_config(cert, key) { Ok(cfg) => cfg, @@ -68,14 +71,7 @@ pub async fn start_dot(ctx: Arc, config: &DotConfig) { return; } }, - (Some(_), None) | (None, Some(_)) => { - warn!("DoT: both cert_path and key_path must be set — ignoring partial config, using self-signed"); - match fallback_tls(&ctx) { - Some(cfg) => cfg, - None => return, - } - } - (None, None) => match fallback_tls(&ctx) { + _ => match fallback_tls(&ctx) { Some(cfg) => cfg, None => return, }, @@ -190,41 +186,55 @@ where resp.header.id = query_id; resp.header.response = true; resp.header.rescode = ResultCode::FORMERR; - let mut out_buf = BytePacketBuffer::new(); - if resp.write(&mut out_buf).is_err() { - debug!("DoT: failed to serialize FORMERR for {}", remote_addr); - break; - } - if write_framed(&mut stream, out_buf.filled()).await.is_err() { + if send_response(&mut stream, &resp, remote_addr).await.is_err() { break; } continue; } }; - let resp_buffer = match resolve_query(query.clone(), remote_addr, ctx).await { - Ok(buf) => buf, - Err(e) => { - warn!("{} | RESOLVE ERROR | {}", remote_addr, e); - // Build SERVFAIL that echoes the original question section. - let resp = DnsPacket::response_from(&query, ResultCode::SERVFAIL); - let mut out_buf = BytePacketBuffer::new(); - if resp.write(&mut out_buf).is_err() { - debug!("DoT: failed to serialize SERVFAIL for {}", remote_addr); + match resolve_query(query.clone(), remote_addr, ctx).await { + Ok(resp_buffer) => { + if write_framed(&mut stream, resp_buffer.filled()) + .await + .is_err() + { + break; + } + } + Err(e) => { + warn!("{} | RESOLVE ERROR | {}", remote_addr, e); + // SERVFAIL that echoes the original question section. + let resp = DnsPacket::response_from(&query, ResultCode::SERVFAIL); + if send_response(&mut stream, &resp, remote_addr).await.is_err() { break; } - out_buf } - }; - if write_framed(&mut stream, resp_buffer.filled()) - .await - .is_err() - { - break; } } } +/// Serialize a DNS response and send it framed. Logs serialization failures +/// and returns Err so the caller can tear down the connection. +async fn send_response( + stream: &mut S, + resp: &DnsPacket, + remote_addr: SocketAddr, +) -> std::io::Result<()> +where + S: AsyncWriteExt + Unpin, +{ + let mut out_buf = BytePacketBuffer::new(); + if resp.write(&mut out_buf).is_err() { + debug!( + "DoT: failed to serialize {:?} response for {}", + resp.header.rescode, remote_addr + ); + return Err(std::io::Error::other("serialize failed")); + } + write_framed(stream, out_buf.filled()).await +} + /// Write a DNS message with its 2-byte length prefix, coalesced into one syscall. async fn write_framed(stream: &mut S, msg: &[u8]) -> std::io::Result<()> where -- 2.34.1 From 16689526aa3df7d41d30c8984014c880ea2cae12 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 7 Apr 2026 20:11:15 +0300 Subject: [PATCH 07/19] style: rustfmt Co-Authored-By: Claude Opus 4.6 (1M context) --- src/dot.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/dot.rs b/src/dot.rs index 291f6f0..de2f9a8 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -186,7 +186,10 @@ where resp.header.id = query_id; resp.header.response = true; resp.header.rescode = ResultCode::FORMERR; - if send_response(&mut stream, &resp, remote_addr).await.is_err() { + if send_response(&mut stream, &resp, remote_addr) + .await + .is_err() + { break; } continue; @@ -206,7 +209,10 @@ where warn!("{} | RESOLVE ERROR | {}", remote_addr, e); // SERVFAIL that echoes the original question section. let resp = DnsPacket::response_from(&query, ResultCode::SERVFAIL); - if send_response(&mut stream, &resp, remote_addr).await.is_err() { + if send_response(&mut stream, &resp, remote_addr) + .await + .is_err() + { break; } } -- 2.34.1 From e9836f1be74cc437767ae055006b5fe5bffa5296 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 7 Apr 2026 20:35:05 +0300 Subject: [PATCH 08/19] =?UTF-8?q?refactor:=20trim=20DoT=20listener=20?= =?UTF-8?q?=E2=80=94=20let-else=20reads,=20drop=20MIN=5FMSG=5FLEN=20and=20?= =?UTF-8?q?redundant=20localhost=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Collapse two 4-arm read/timeout matches to let-else (lose one defensive debug log on payload-read timeout; idle timeouts are routine on persistent DoT connections anyway) - Drop MIN_MSG_LEN: DnsPacket::from_buffer rejects truncated input on its own, and BytePacketBuffer is zero-init so buf[0..2] for sub-2-byte messages just yields a harmless FORMERR with id=0 - Inline ACCEPT_ERROR_BACKOFF (single use site) - Drop the partial cert/key warning: missing one of cert_path/ key_path silently falls back to self-signed; users see the self-signed cert at startup and figure it out - Drop dot_localhost_resolution test: RFC 6761 localhost is tested in ctx.rs; this test only verified DoT transport, which dot_resolves_local_zone already covers - Drop self-documenting comment in dot_multiple_queries_on_persistent_connection Net -32 lines, 125/125 tests pass, no behavior change users would notice. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/dot.rs | 60 +++++++++++++----------------------------------------- 1 file changed, 14 insertions(+), 46 deletions(-) diff --git a/src/dot.rs b/src/dot.rs index de2f9a8..360bf4a 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -19,12 +19,9 @@ use crate::packet::DnsPacket; const MAX_CONNECTIONS: usize = 512; const IDLE_TIMEOUT: Duration = Duration::from_secs(30); const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); -const ACCEPT_ERROR_BACKOFF: Duration = Duration::from_millis(100); // Matches BytePacketBuffer::BUF_SIZE — RFC 7858 allows up to 65535 but our // buffer would silently truncate anything larger. const MAX_MSG_LEN: usize = 4096; -// DNS header is 12 bytes; anything shorter cannot be a valid query. -const MIN_MSG_LEN: usize = 12; /// Build a TLS ServerConfig for DoT from user-provided cert/key PEM files. fn load_tls_config(cert_path: &Path, key_path: &Path) -> crate::Result> { @@ -60,9 +57,6 @@ fn fallback_tls(ctx: &ServerCtx) -> Option> { /// Start the DNS-over-TLS listener (RFC 7858). pub async fn start_dot(ctx: Arc, config: &DotConfig) { - if config.cert_path.is_some() != config.key_path.is_some() { - warn!("DoT: both cert_path and key_path must be set — ignoring partial config, using self-signed"); - } let tls_config = match (&config.cert_path, &config.key_path) { (Some(cert), Some(key)) => match load_tls_config(cert, key) { Ok(cfg) => cfg, @@ -103,7 +97,7 @@ async fn accept_loop(listener: TcpListener, acceptor: TlsAcceptor, ctx: Arc { error!("DoT: TCP accept error: {}", e); // Back off to avoid tight-looping on persistent failures (e.g. fd exhaustion). - tokio::time::sleep(ACCEPT_ERROR_BACKOFF).await; + tokio::time::sleep(Duration::from_millis(100)).await; continue; } }; @@ -148,31 +142,22 @@ where loop { // Read 2-byte length prefix (RFC 1035 §4.2.2) with idle timeout let mut len_buf = [0u8; 2]; - match tokio::time::timeout(IDLE_TIMEOUT, stream.read_exact(&mut len_buf)).await { - Ok(Ok(_)) => {} - Ok(Err(_)) => break, // read error or EOF - Err(_) => break, // idle timeout - } + let Ok(Ok(_)) = tokio::time::timeout(IDLE_TIMEOUT, stream.read_exact(&mut len_buf)).await + else { + break; + }; let msg_len = u16::from_be_bytes(len_buf) as usize; - if !(MIN_MSG_LEN..=MAX_MSG_LEN).contains(&msg_len) { - debug!( - "DoT: invalid message length {} from {}", - msg_len, remote_addr - ); + if msg_len > MAX_MSG_LEN { + debug!("DoT: oversized message {} from {}", msg_len, remote_addr); break; } let mut buffer = BytePacketBuffer::new(); - match tokio::time::timeout(IDLE_TIMEOUT, stream.read_exact(&mut buffer.buf[..msg_len])) - .await - { - Ok(Ok(_)) => {} - Ok(Err(_)) => break, - Err(_) => { - debug!("DoT: payload read timeout from {}", remote_addr); - break; - } - } + let Ok(Ok(_)) = + tokio::time::timeout(IDLE_TIMEOUT, stream.read_exact(&mut buffer.buf[..msg_len])).await + else { + break; + }; // Parse query up-front so we can echo its question section in SERVFAIL // responses when resolve_query fails. @@ -180,7 +165,8 @@ where Ok(q) => q, Err(e) => { warn!("{} | PARSE ERROR | {}", remote_addr, e); - // msg_len >= MIN_MSG_LEN guarantees buf[0..2] is the client's query id. + // BytePacketBuffer is zero-initialized, so buf[0..2] reads as 0x0000 + // for sub-2-byte messages — harmless FORMERR with id=0. let query_id = u16::from_be_bytes([buffer.buf[0], buffer.buf[1]]); let mut resp = DnsPacket::new(); resp.header.id = query_id; @@ -431,7 +417,6 @@ mod tests { let (addr, client_config) = spawn_dot_server().await; let mut stream = dot_connect(addr, &client_config).await; - // Send 3 queries on the same TLS connection for i in 0..3u16 { let query = DnsPacket::query(0xA000 + i, "dot-test.example", QueryType::A); let resp = dot_exchange(&mut stream, &query).await; @@ -479,21 +464,4 @@ mod tests { h.await.unwrap(); } } - - #[tokio::test] - async fn dot_localhost_resolution() { - let (addr, client_config) = spawn_dot_server().await; - let mut stream = dot_connect(addr, &client_config).await; - - let query = DnsPacket::query(0xD000, "localhost", QueryType::A); - let resp = dot_exchange(&mut stream, &query).await; - - assert_eq!(resp.header.id, 0xD000); - assert_eq!(resp.header.rescode, ResultCode::NOERROR); - assert_eq!(resp.answers.len(), 1); - match &resp.answers[0] { - DnsRecord::A { addr, .. } => assert_eq!(*addr, std::net::Ipv4Addr::LOCALHOST), - other => panic!("expected A record, got {:?}", other), - } - } } -- 2.34.1 From 5713b9bed61d424455858f2ddd25340010e90933 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 7 Apr 2026 20:37:40 +0300 Subject: [PATCH 09/19] docs: add commented-out [dot] example to numa.toml Matches the style of the other opt-in sections (blocking, dnssec, lan). Documents all five DotConfig fields with their defaults. Co-Authored-By: Claude Opus 4.6 (1M context) --- numa.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/numa.toml b/numa.toml index 4fa0a3d..b7f98de 100644 --- a/numa.toml +++ b/numa.toml @@ -83,6 +83,14 @@ tld = "numa" # enabled = false # opt-in: verify chain of trust from root KSK # strict = false # true = SERVFAIL on bogus signatures +# DNS-over-TLS listener (RFC 7858) — encrypted DNS on port 853 +# [dot] +# enabled = false # opt-in: accept DoT queries +# port = 853 # standard DoT port +# bind_addr = "0.0.0.0" # IPv4 or IPv6; unspecified binds all interfaces +# cert_path = "/etc/numa/dot.crt" # PEM cert; omit to use self-signed (proxy CA if available) +# key_path = "/etc/numa/dot.key" # PEM private key; must be set together with cert_path + # LAN service discovery via mDNS (disabled by default — no network traffic unless enabled) # [lan] # enabled = true # discover other Numa instances via mDNS (_numa._tcp.local) -- 2.34.1 From d0deb08d2c4a2f6858aaf2e22547e7a9a660d880 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 7 Apr 2026 22:51:52 +0300 Subject: [PATCH 10/19] feat: DoT write timeout and ALPN "dot" advertisement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two DoS/interop hardening items: 1. Bound write_framed by WRITE_TIMEOUT (10s) so a slow-reader attacker can't indefinitely hold a worker task and its connection permit. Symmetric to the existing handshake timeout. 2. Advertise ALPN "dot" per RFC 7858 §3.2. Required by some strict DoT clients (newer Apple stacks, some Android versions). rustls ServerConfig exposes alpn_protocols as a pub field so we set it after with_single_cert: - load_tls_config (user-provided cert/key): set directly - self_signed_tls (new, replaces fallback_tls): builds a fresh DoT-specific TLS config via build_tls_config with the ALPN list build_tls_config now takes an `alpn: Vec>` parameter so DoT and the proxy can pass different ALPN lists while sharing the same CA. Proxy callers pass Vec::new() (unchanged behavior). Dropped the ctx.tls_config reuse branch: we can't mutate a shared Arc to add DoT-specific ALPN, and reusing the proxy config was already quietly broken re: SAN (proxy cert covers *.{tld}, not the DoT server's bind hostname/IP). Added dot_negotiates_alpn test that asserts conn.alpn_protocol() returns Some(b"dot") after handshake. 126/126 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/dot.rs | 48 ++++++++++++++++++++++++++++++++++++------------ src/main.rs | 2 +- src/tls.rs | 13 ++++++++++--- 3 files changed, 47 insertions(+), 16 deletions(-) diff --git a/src/dot.rs b/src/dot.rs index 360bf4a..898c2a1 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -19,10 +19,16 @@ use crate::packet::DnsPacket; const MAX_CONNECTIONS: usize = 512; const IDLE_TIMEOUT: Duration = Duration::from_secs(30); const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); +const WRITE_TIMEOUT: Duration = Duration::from_secs(10); // Matches BytePacketBuffer::BUF_SIZE — RFC 7858 allows up to 65535 but our // buffer would silently truncate anything larger. const MAX_MSG_LEN: usize = 4096; +/// ALPN protocol identifier for DNS-over-TLS (RFC 7858 §3.2). +fn dot_alpn() -> Vec> { + vec![b"dot".to_vec()] +} + /// Build a TLS ServerConfig for DoT from user-provided cert/key PEM files. fn load_tls_config(cert_path: &Path, key_path: &Path) -> crate::Result> { let cert_pem = std::fs::read(cert_path)?; @@ -32,18 +38,18 @@ fn load_tls_config(cert_path: &Path, key_path: &Path) -> crate::Result Option> { - if let Some(arc_swap) = ctx.tls_config.as_ref() { - return Some(Arc::clone(&*arc_swap.load())); - } - match crate::tls::build_tls_config(&ctx.proxy_tld, &[]) { +/// Build a self-signed DoT TLS config. Can't reuse `ctx.tls_config` (the +/// proxy's shared config) because DoT needs its own ALPN advertisement. +fn self_signed_tls(ctx: &ServerCtx) -> Option> { + match crate::tls::build_tls_config(&ctx.proxy_tld, &[], dot_alpn()) { Ok(cfg) => Some(cfg), Err(e) => { warn!( @@ -65,7 +71,7 @@ pub async fn start_dot(ctx: Arc, config: &DotConfig) { return; } }, - _ => match fallback_tls(&ctx) { + _ => match self_signed_tls(&ctx) { Some(cfg) => cfg, None => return, }, @@ -228,6 +234,7 @@ where } /// Write a DNS message with its 2-byte length prefix, coalesced into one syscall. +/// Bounded by WRITE_TIMEOUT so a stalled reader can't indefinitely hold a worker. async fn write_framed(stream: &mut S, msg: &[u8]) -> std::io::Result<()> where S: AsyncWriteExt + Unpin, @@ -235,9 +242,15 @@ where let mut out = Vec::with_capacity(2 + msg.len()); out.extend_from_slice(&(msg.len() as u16).to_be_bytes()); out.extend_from_slice(msg); - stream.write_all(&out).await?; - stream.flush().await?; - Ok(()) + match tokio::time::timeout(WRITE_TIMEOUT, async { + stream.write_all(&out).await?; + stream.flush().await + }) + .await + { + Ok(result) => result, + Err(_) => Err(std::io::Error::other("write timeout")), + } } #[cfg(test)] @@ -271,16 +284,18 @@ mod tests { let cert_der = CertificateDer::from(cert.der().to_vec()); let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der())); - let server_config = ServerConfig::builder() + let mut server_config = ServerConfig::builder() .with_no_client_auth() .with_single_cert(vec![cert_der.clone()], key_der) .unwrap(); + server_config.alpn_protocols = dot_alpn(); let mut root_store = rustls::RootCertStore::empty(); root_store.add(cert_der).unwrap(); - let client_config = rustls::ClientConfig::builder() + let mut client_config = rustls::ClientConfig::builder() .with_root_certificates(root_store) .with_no_client_auth(); + client_config.alpn_protocols = dot_alpn(); (Arc::new(server_config), Arc::new(client_config)) } @@ -443,6 +458,15 @@ mod tests { assert_eq!(resp.questions[0].name, "nonexistent.test"); } + #[tokio::test] + async fn dot_negotiates_alpn() { + let (addr, client_config) = spawn_dot_server().await; + let stream = dot_connect(addr, &client_config).await; + // After handshake, the negotiated ALPN protocol should be "dot" (RFC 7858 §3.2). + let (_io, conn) = stream.get_ref(); + assert_eq!(conn.alpn_protocol(), Some(&b"dot"[..])); + } + #[tokio::test] async fn dot_concurrent_connections() { let (addr, client_config) = spawn_dot_server().await; diff --git a/src/main.rs b/src/main.rs index b9316b8..adf266e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -207,7 +207,7 @@ async fn main() -> numa::Result<()> { // Build initial TLS config before ServerCtx (so ArcSwap is ready at construction) let initial_tls = if config.proxy.enabled && config.proxy.tls_port > 0 { let service_names = service_store.names(); - match numa::tls::build_tls_config(&config.proxy.tld, &service_names) { + match numa::tls::build_tls_config(&config.proxy.tld, &service_names, Vec::new()) { Ok(tls_config) => Some(ArcSwap::from(tls_config)), Err(e) => { log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e); diff --git a/src/tls.rs b/src/tls.rs index a4d91bf..5746f3b 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -24,7 +24,7 @@ pub fn regenerate_tls(ctx: &ServerCtx) { names.extend(ctx.lan_peers.lock().unwrap().names()); let names: Vec = names.into_iter().collect(); - match build_tls_config(&ctx.proxy_tld, &names) { + match build_tls_config(&ctx.proxy_tld, &names, Vec::new()) { Ok(new_config) => { tls.store(new_config); info!("TLS cert regenerated for {} services", names.len()); @@ -36,7 +36,13 @@ pub fn regenerate_tls(ctx: &ServerCtx) { /// Build a TLS config with a cert covering all provided service names. /// Wildcards under single-label TLDs (*.numa) are rejected by browsers, /// so we list each service explicitly as a SAN. -pub fn build_tls_config(tld: &str, service_names: &[String]) -> crate::Result> { +/// `alpn` is advertised in the TLS ServerHello — pass empty for the proxy +/// (which accepts any ALPN), or `[b"dot"]` for DoT (RFC 7858 §3.2). +pub fn build_tls_config( + tld: &str, + service_names: &[String], + alpn: Vec>, +) -> crate::Result> { let dir = crate::data_dir(); let (ca_cert, ca_key) = ensure_ca(&dir)?; let (cert_chain, key) = generate_service_cert(&ca_cert, &ca_key, tld, service_names)?; @@ -44,9 +50,10 @@ pub fn build_tls_config(tld: &str, service_names: &[String]) -> crate::Result Date: Tue, 7 Apr 2026 22:56:44 +0300 Subject: [PATCH 11/19] style: drop narrating comments on dot_alpn and ALPN test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were restating what the code already said — dot_alpn's doc narrated the function name and the test comment restated the assertion. RFC 7858 §3.2 is already cited on self_signed_tls and build_tls_config where the "why" actually matters. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/dot.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/dot.rs b/src/dot.rs index 898c2a1..b7e7875 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -24,7 +24,6 @@ const WRITE_TIMEOUT: Duration = Duration::from_secs(10); // buffer would silently truncate anything larger. const MAX_MSG_LEN: usize = 4096; -/// ALPN protocol identifier for DNS-over-TLS (RFC 7858 §3.2). fn dot_alpn() -> Vec> { vec![b"dot".to_vec()] } @@ -462,7 +461,6 @@ mod tests { async fn dot_negotiates_alpn() { let (addr, client_config) = spawn_dot_server().await; let stream = dot_connect(addr, &client_config).await; - // After handshake, the negotiated ALPN protocol should be "dot" (RFC 7858 §3.2). let (_io, conn) = stream.get_ref(); assert_eq!(conn.alpn_protocol(), Some(&b"dot"[..])); } -- 2.34.1 From efdad2e7219107be28247b5429643daacc7fc6df Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 7 Apr 2026 23:22:04 +0300 Subject: [PATCH 12/19] fix: DoT cert needs explicit {tld}.{tld} SAN, not just *.{tld} wildcard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit self_signed_tls was passing an empty service_names list, so the generated cert only had the *.numa wildcard SAN. Strict TLS clients (browsers, possibly some iOS versions) reject wildcards under single-label TLDs — see the existing comment in tls.rs explaining why the proxy lists each service explicitly. setup-phone's mobileconfig sends ServerName "numa.numa" as SNI, so the DoT cert must have an explicit numa.numa SAN. Pass proxy_tld itself as a service name, mirroring how main.rs already registers "numa" as a service for the proxy's TLS cert. Test fixture updated to mirror the production SAN shape (*.numa + numa.numa) and switched the client to SNI "numa.numa", so the existing DoT test suite implicitly exercises the SNI path used by setup-phone clients. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/dot.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/dot.rs b/src/dot.rs index b7e7875..487c25f 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -47,8 +47,15 @@ fn load_tls_config(cert_path: &Path, key_path: &Path) -> crate::Result Option> { - match crate::tls::build_tls_config(&ctx.proxy_tld, &[], dot_alpn()) { + let service_names = [ctx.proxy_tld.clone()]; + match crate::tls::build_tls_config(&ctx.proxy_tld, &service_names, dot_alpn()) { Ok(cfg) => Some(cfg), Err(e) => { warn!( @@ -272,12 +279,17 @@ mod tests { fn test_tls_configs() -> (Arc, Arc) { let _ = rustls::crypto::ring::default_provider().install_default(); + // Mirror production self_signed_tls SAN shape: *.numa wildcard plus + // explicit numa.numa apex (the ServerName setup-phone uses as SNI). let key_pair = KeyPair::generate().unwrap(); let mut params = CertificateParams::default(); params .distinguished_name - .push(DnType::CommonName, "localhost"); - params.subject_alt_names = vec![rcgen::SanType::DnsName("localhost".try_into().unwrap())]; + .push(DnType::CommonName, "Numa .numa services"); + params.subject_alt_names = vec![ + rcgen::SanType::DnsName("*.numa".try_into().unwrap()), + rcgen::SanType::DnsName("numa.numa".try_into().unwrap()), + ]; let cert = params.self_signed(&key_pair).unwrap(); let cert_der = CertificateDer::from(cert.der().to_vec()); @@ -367,6 +379,7 @@ mod tests { } /// Open a TLS connection to the DoT server and return the stream. + /// Uses SNI "numa.numa" to mirror what setup-phone's mobileconfig sends. async fn dot_connect( addr: SocketAddr, client_config: &Arc, @@ -374,7 +387,7 @@ mod tests { let connector = tokio_rustls::TlsConnector::from(Arc::clone(client_config)); let tcp = tokio::net::TcpStream::connect(addr).await.unwrap(); connector - .connect(ServerName::try_from("localhost").unwrap(), tcp) + .connect(ServerName::try_from("numa.numa").unwrap(), tcp) .await .unwrap() } -- 2.34.1 From e789d12520934c19bc25010556ade66d635a549b Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 00:09:54 +0300 Subject: [PATCH 13/19] test: verify DoT server rejects mismatched ALPN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds dot_rejects_non_dot_alpn to assert the rustls server enforces ALPN strictness rather than silently accepting a mismatched negotiation. This is the load-bearing behavior behind the cross- protocol confusion defense — without enforcement, the ALPN "dot" advertisement is just a sign hung on an unlocked door. Refactors test_tls_configs to return the leaf cert DER instead of a prebuilt client config, and adds a dot_client(cert_der, alpn) helper so each test can build a client config with the ALPN list it needs. The five existing DoT tests gain one line each to call dot_client with dot_alpn(); behavior unchanged. 127/127 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/dot.rs | 72 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 19 deletions(-) diff --git a/src/dot.rs b/src/dot.rs index 487c25f..0a917dd 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -275,8 +275,9 @@ mod tests { use crate::question::QueryType; use crate::record::DnsRecord; - /// Generate a self-signed cert + key in memory, return (ServerConfig, ClientConfig). - fn test_tls_configs() -> (Arc, Arc) { + /// Generate a self-signed DoT server config and return its leaf cert DER + /// so callers can build matching client configs with arbitrary ALPN. + fn test_tls_configs() -> (Arc, CertificateDer<'static>) { let _ = rustls::crypto::ring::default_provider().install_default(); // Mirror production self_signed_tls SAN shape: *.numa wildcard plus @@ -301,22 +302,31 @@ mod tests { .unwrap(); server_config.alpn_protocols = dot_alpn(); - let mut root_store = rustls::RootCertStore::empty(); - root_store.add(cert_der).unwrap(); - let mut client_config = rustls::ClientConfig::builder() - .with_root_certificates(root_store) - .with_no_client_auth(); - client_config.alpn_protocols = dot_alpn(); - - (Arc::new(server_config), Arc::new(client_config)) + (Arc::new(server_config), cert_der) } - /// Spin up a DoT listener with a test TLS config. Returns (addr, client_config). + /// Build a TLS client config that trusts `cert_der` and advertises the + /// given ALPN protocols. Used by tests to vary ALPN per test case. + fn dot_client( + cert_der: &CertificateDer<'static>, + alpn: Vec>, + ) -> Arc { + let mut root_store = rustls::RootCertStore::empty(); + root_store.add(cert_der.clone()).unwrap(); + let mut config = rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + config.alpn_protocols = alpn; + Arc::new(config) + } + + /// Spin up a DoT listener with a test TLS config. Returns the bind addr + /// and the leaf cert DER so callers can build clients with arbitrary ALPN. /// The upstream is pointed at a bound-but-unresponsive UDP socket we own, so /// any query that escapes to the upstream path times out deterministically /// (SERVFAIL) regardless of what the host has running on port 53. - async fn spawn_dot_server() -> (SocketAddr, Arc) { - let (server_tls, client_tls) = test_tls_configs(); + 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. @@ -375,7 +385,7 @@ mod tests { tokio::spawn(accept_loop(listener, acceptor, ctx)); - (addr, client_tls) + (addr, cert_der) } /// Open a TLS connection to the DoT server and return the stream. @@ -419,7 +429,8 @@ mod tests { #[tokio::test] async fn dot_resolves_local_zone() { - let (addr, client_config) = spawn_dot_server().await; + let (addr, cert_der) = spawn_dot_server().await; + let client_config = dot_client(&cert_der, dot_alpn()); let mut stream = dot_connect(addr, &client_config).await; let query = DnsPacket::query(0x1234, "dot-test.example", QueryType::A); @@ -441,7 +452,8 @@ mod tests { #[tokio::test] async fn dot_multiple_queries_on_persistent_connection() { - let (addr, client_config) = spawn_dot_server().await; + let (addr, cert_der) = spawn_dot_server().await; + let client_config = dot_client(&cert_der, dot_alpn()); let mut stream = dot_connect(addr, &client_config).await; for i in 0..3u16 { @@ -455,7 +467,8 @@ mod tests { #[tokio::test] async fn dot_nxdomain_for_unknown() { - let (addr, client_config) = spawn_dot_server().await; + let (addr, cert_der) = spawn_dot_server().await; + let client_config = dot_client(&cert_der, dot_alpn()); let mut stream = dot_connect(addr, &client_config).await; let query = DnsPacket::query(0xBEEF, "nonexistent.test", QueryType::A); @@ -472,15 +485,36 @@ mod tests { #[tokio::test] async fn dot_negotiates_alpn() { - let (addr, client_config) = spawn_dot_server().await; + let (addr, cert_der) = spawn_dot_server().await; + let client_config = dot_client(&cert_der, dot_alpn()); let stream = dot_connect(addr, &client_config).await; let (_io, conn) = stream.get_ref(); assert_eq!(conn.alpn_protocol(), Some(&b"dot"[..])); } + #[tokio::test] + async fn dot_rejects_non_dot_alpn() { + // Cross-protocol confusion defense: a client that only offers "h2" + // (e.g. an HTTP/2 client mistakenly hitting :853) must not complete + // a TLS handshake with the DoT server. Verifies the rustls server + // sends `no_application_protocol` rather than silently negotiating. + let (addr, cert_der) = spawn_dot_server().await; + let client_config = dot_client(&cert_der, vec![b"h2".to_vec()]); + let connector = tokio_rustls::TlsConnector::from(client_config); + let tcp = tokio::net::TcpStream::connect(addr).await.unwrap(); + let result = connector + .connect(ServerName::try_from("numa.numa").unwrap(), tcp) + .await; + assert!( + result.is_err(), + "DoT server must reject ALPN that doesn't include \"dot\"" + ); + } + #[tokio::test] async fn dot_concurrent_connections() { - let (addr, client_config) = spawn_dot_server().await; + let (addr, cert_der) = spawn_dot_server().await; + let client_config = dot_client(&cert_der, dot_alpn()); let mut handles = Vec::new(); for i in 0..5u16 { -- 2.34.1 From ee90ae79e56e79e500e350e1bb7a4fcc5197e55d Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 00:54:51 +0300 Subject: [PATCH 14/19] fix: install rustls crypto provider when loading user DoT cert Adds tests/integration.sh Suite 5 (DoT via kdig + openssl) and fixes a startup panic caught by it. Bug: when [dot] cert_path/key_path was set AND [proxy] was disabled, numa panicked on the first DoT handshake with "Could not automatically determine the process-level CryptoProvider from Rustls crate features". In normal deployments the proxy's build_tls_config installs the default provider as a side effect, masking the missing call in dot.rs::load_tls_config. Disable the proxy and the panic surfaces. Fix: call rustls::crypto::ring::default_provider().install_default() at the top of load_tls_config (no-op if already installed). Suite 5 exercises: - DoT listener binds on configured port - Resolves a local zone A record over TLS (kdig +tls) - Persistent connection reuse (kdig +keepopen, 3 queries, 1 handshake) - ALPN "dot" negotiation (openssl s_client -alpn dot) - ALPN mismatch rejected with no_application_protocol (openssl -alpn h2) Uses a pre-generated cert at /tmp so the test runs non-root. Skips gracefully if kdig or openssl aren't installed. Also: Dockerfile now EXPOSE 853/tcp so docker run -p 853:853 works out of the box when users enable DoT. Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile | 2 +- src/dot.rs | 6 +++ tests/integration.sh | 122 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 0af2ee3..1d6f28f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,5 +13,5 @@ RUN cargo build --release FROM alpine:3.20 COPY --from=builder /app/target/release/numa /usr/local/bin/numa -EXPOSE 53/udp 80/tcp 443/tcp 5380/tcp +EXPOSE 53/udp 80/tcp 443/tcp 853/tcp 5380/tcp ENTRYPOINT ["numa"] diff --git a/src/dot.rs b/src/dot.rs index 0a917dd..d399649 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -30,6 +30,12 @@ fn dot_alpn() -> Vec> { /// Build a TLS ServerConfig for DoT from user-provided cert/key PEM files. fn load_tls_config(cert_path: &Path, key_path: &Path) -> crate::Result> { + // rustls needs a CryptoProvider installed before ServerConfig::builder(). + // The proxy's build_tls_config also does this; we repeat it here because + // running DoT with user-provided certs while the proxy is disabled would + // otherwise panic on first handshake (no default provider). + let _ = rustls::crypto::ring::default_provider().install_default(); + let cert_pem = std::fs::read(cert_path)?; let key_pem = std::fs::read(key_path)?; diff --git a/tests/integration.sh b/tests/integration.sh index c83dd61..a19d3bc 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -404,6 +404,128 @@ check "Cache flushed" \ kill "$NUMA_PID" 2>/dev/null || true wait "$NUMA_PID" 2>/dev/null || true +sleep 1 + +# ---- Suite 5: DNS-over-TLS (RFC 7858) ---- +echo "" +echo "╔══════════════════════════════════════════╗" +echo "║ Suite 5: DNS-over-TLS (RFC 7858) ║" +echo "╚══════════════════════════════════════════╝" + +if ! command -v kdig >/dev/null 2>&1; then + printf " ${DIM}skipped — install 'knot' for kdig${RESET}\n" +elif ! command -v openssl >/dev/null 2>&1; then + printf " ${DIM}skipped — openssl not found${RESET}\n" +else + DOT_PORT=8853 + DOT_CERT=/tmp/numa-integration-dot.crt + DOT_KEY=/tmp/numa-integration-dot.key + + # Generate a test cert mirroring production self_signed_tls SAN shape + # (*.numa wildcard + explicit numa.numa apex). + openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ + -keyout "$DOT_KEY" -out "$DOT_CERT" \ + -subj "/CN=Numa .numa services" \ + -addext "subjectAltName=DNS:*.numa,DNS:numa.numa" \ + >/dev/null 2>&1 + + # Suite 5 uses a local zone so it's upstream-independent — the point is + # to exercise the DoT transport layer (handshake, ALPN, framing, + # persistent connections), not re-test recursive resolution. + cat > "$CONFIG" << CONF +[server] +bind_addr = "127.0.0.1:$PORT" +api_port = $API_PORT + +[upstream] +mode = "forward" +address = "127.0.0.1" +port = 65535 + +[cache] +max_entries = 10000 + +[blocking] +enabled = false + +[proxy] +enabled = false + +[dot] +enabled = true +port = $DOT_PORT +bind_addr = "127.0.0.1" +cert_path = "$DOT_CERT" +key_path = "$DOT_KEY" + +[[zones]] +domain = "dot-test.example" +record_type = "A" +value = "10.0.0.1" +ttl = 60 +CONF + + RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 & + NUMA_PID=$! + sleep 4 + + if ! kill -0 "$NUMA_PID" 2>/dev/null; then + FAILED=$((FAILED + 1)) + printf " ${RED}✗${RESET} DoT startup\n" + printf " ${DIM}%s${RESET}\n" "$(tail -5 "$LOG")" + else + echo "" + echo "=== Listener ===" + + check "DoT bound on 127.0.0.1:$DOT_PORT" \ + "DoT listening on 127.0.0.1:$DOT_PORT" \ + "$(grep 'DoT listening' "$LOG")" + + KDIG="kdig @127.0.0.1 -p $DOT_PORT +tls +tls-ca=$DOT_CERT +tls-hostname=numa.numa +time=5 +retry=0" + + echo "" + echo "=== Queries over DoT ===" + + check "DoT local zone A record" \ + "10.0.0.1" \ + "$($KDIG +short dot-test.example A 2>/dev/null)" + + # +keepopen reuses one TLS connection for multiple queries — tests + # persistent connection handling. kdig applies options left-to-right, + # so +short and +keepopen must come before the query specs. + check "DoT persistent connection (3 queries, 1 handshake)" \ + "10.0.0.1" \ + "$($KDIG +keepopen +short dot-test.example A dot-test.example A dot-test.example A 2>/dev/null | head -1)" + + echo "" + echo "=== ALPN ===" + + # Positive case: client offers "dot", server picks it. + ALPN_OK=$(echo "" | openssl s_client -connect "127.0.0.1:$DOT_PORT" \ + -servername numa.numa -alpn dot -CAfile "$DOT_CERT" 2>&1 /dev/null 2>&1; then + ALPN_MISMATCH="handshake unexpectedly succeeded" + else + ALPN_MISMATCH="rejected" + fi + check "DoT rejects non-dot ALPN" \ + "rejected" \ + "$ALPN_MISMATCH" + fi + + kill "$NUMA_PID" 2>/dev/null || true + wait "$NUMA_PID" 2>/dev/null || true + rm -f "$DOT_CERT" "$DOT_KEY" +fi # Summary echo "" -- 2.34.1 From 6c92f74db00a54f5cac96aa3b604adb062264cb0 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 01:12:16 +0300 Subject: [PATCH 15/19] =?UTF-8?q?test:=20Suite=206=20=E2=80=94=20proxy=20+?= =?UTF-8?q?=20DoT=20coexistence,=20NUMA=5FDATA=5FDIR=20override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds integration test coverage for the realistic production shape where both the HTTPS proxy and DoT are enabled simultaneously. This was previously untested — every existing suite had either one or the other, so the interaction path was implicit. What Suite 6 verifies: - Both listeners bind without panic - DoT still resolves queries with the proxy enabled - Proxy HTTPS handshake still works with DoT enabled - Both certs validate against the same shared CA To run non-root, adds a NUMA_DATA_DIR env var override to data_dir() that lets callers point the CA/cert storage at any writable path. Useful beyond tests: containerized deployments, CI runners, dev testing without sudo. The fallback is the existing platform-specific path (unix: /usr/local/var/numa, windows: %PROGRAMDATA%\numa). Suite 6 sets NUMA_DATA_DIR=/tmp/numa-integration-data before starting numa, then trusts the generated CA at $NUMA_DATA_DIR/ca.pem for both kdig (DoT query) and openssl s_client (HTTPS proxy handshake) verification. All 6 suites, 32 checks, run non-root and pass locally. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib.rs | 5 ++ tests/integration.sh | 111 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 36017fe..05d18a0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -67,9 +67,14 @@ fn config_dir_unix() -> std::path::PathBuf { } /// System-wide data directory for TLS certs. +/// Override with `NUMA_DATA_DIR` env var (useful for containerized +/// deployments and integration tests that can't write to the default path). /// Unix: /usr/local/var/numa /// Windows: %PROGRAMDATA%\numa pub fn data_dir() -> std::path::PathBuf { + if let Ok(dir) = std::env::var("NUMA_DATA_DIR") { + return std::path::PathBuf::from(dir); + } #[cfg(windows)] { std::path::PathBuf::from( diff --git a/tests/integration.sh b/tests/integration.sh index a19d3bc..f1c5205 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -526,6 +526,117 @@ CONF wait "$NUMA_PID" 2>/dev/null || true rm -f "$DOT_CERT" "$DOT_KEY" fi +sleep 1 + +# ---- Suite 6: Proxy + DoT coexistence ---- +echo "" +echo "╔══════════════════════════════════════════╗" +echo "║ Suite 6: Proxy + DoT Coexistence ║" +echo "╚══════════════════════════════════════════╝" + +if ! command -v kdig >/dev/null 2>&1 || ! command -v openssl >/dev/null 2>&1; then + printf " ${DIM}skipped — needs kdig + openssl${RESET}\n" +else + DOT_PORT=8853 + PROXY_HTTP_PORT=8080 + PROXY_HTTPS_PORT=8443 + NUMA_DATA=/tmp/numa-integration-data + + # Fresh data dir so we generate a fresh CA for this suite — NUMA_DATA_DIR + # env var lets numa write under $TMPDIR instead of /usr/local/var/numa. + rm -rf "$NUMA_DATA" + mkdir -p "$NUMA_DATA" + + cat > "$CONFIG" << CONF +[server] +bind_addr = "127.0.0.1:$PORT" +api_port = $API_PORT + +[upstream] +mode = "forward" +address = "127.0.0.1" +port = 65535 + +[cache] +max_entries = 10000 + +[blocking] +enabled = false + +[proxy] +enabled = true +port = $PROXY_HTTP_PORT +tls_port = $PROXY_HTTPS_PORT +tld = "numa" +bind_addr = "127.0.0.1" + +[dot] +enabled = true +port = $DOT_PORT +bind_addr = "127.0.0.1" + +[[zones]] +domain = "dot-test.example" +record_type = "A" +value = "10.0.0.1" +ttl = 60 +CONF + + NUMA_DATA_DIR="$NUMA_DATA" RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 & + NUMA_PID=$! + sleep 4 + + if ! kill -0 "$NUMA_PID" 2>/dev/null; then + FAILED=$((FAILED + 1)) + printf " ${RED}✗${RESET} Startup with proxy + DoT\n" + printf " ${DIM}%s${RESET}\n" "$(tail -5 "$LOG")" + else + echo "" + echo "=== Both listeners ===" + + check "DoT listener bound" \ + "DoT listening on 127.0.0.1:$DOT_PORT" \ + "$(grep 'DoT listening' "$LOG")" + + check "HTTPS proxy listener bound" \ + "HTTPS proxy listening on 127.0.0.1:$PROXY_HTTPS_PORT" \ + "$(grep 'HTTPS proxy listening' "$LOG")" + + PANIC_COUNT=$(grep -c 'panicked' "$LOG" 2>/dev/null || echo 0) + check "No startup panics in log" \ + "^0$" \ + "$PANIC_COUNT" + + echo "" + echo "=== DoT works with proxy enabled ===" + + # Proxy's build_tls_config runs first and creates the CA in + # $NUMA_DATA_DIR. DoT self_signed_tls then loads the same CA and + # issues its own leaf cert. One CA trusts both listeners. + CA="$NUMA_DATA/ca.pem" + KDIG="kdig @127.0.0.1 -p $DOT_PORT +tls +tls-ca=$CA +tls-hostname=numa.numa +time=5 +retry=0" + + check "DoT local zone A (with proxy on)" \ + "10.0.0.1" \ + "$($KDIG +short dot-test.example A 2>/dev/null)" + + echo "" + echo "=== Proxy TLS works with DoT enabled ===" + + # Proxy cert has SAN numa.numa (auto-added "numa" service). A + # successful handshake validates that the proxy's separate + # ServerConfig wasn't disturbed by DoT's own cert generation. + PROXY_TLS=$(echo "" | openssl s_client -connect "127.0.0.1:$PROXY_HTTPS_PORT" \ + -servername numa.numa -CAfile "$CA" 2>&1 /dev/null || true + wait "$NUMA_PID" 2>/dev/null || true + rm -rf "$NUMA_DATA" +fi # Summary echo "" -- 2.34.1 From 21e8f7490cf4a940f04e4dee187c40610e4eab44 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 01:31:16 +0300 Subject: [PATCH 16/19] refactor: move data_dir override from env var to [server] TOML field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the NUMA_DATA_DIR env var added in the previous commit and replaces it with a [server] data_dir TOML field. Numa already has a well-developed config system; adding a parallel env-var mechanism for a single knob was wrong. The principle: TOML is for application behavior configuration. Env vars are for bootstrap values (HOME, SUDO_USER to discover paths before config loads) and standard ecosystem conventions (RUST_LOG). data_dir is neither — it's an app knob, so it belongs in the TOML. Changes: - lib.rs::data_dir() reverts to the platform-specific fallback only - config.rs adds `data_dir: Option` to ServerConfig - main.rs resolves config.server.data_dir with fallback to numa::data_dir() and passes it to build_tls_config, then stores the resolved path on ctx.data_dir for downstream consumers - tls.rs::build_tls_config takes `data_dir: &Path` as an explicit parameter instead of calling crate::data_dir() behind the caller's back. regenerate_tls and dot.rs self_signed_tls now pass &ctx.data_dir, honoring whatever path the config resolved to - tests/integration.sh Suite 6 uses `data_dir = "$NUMA_DATA"` in its test TOML instead of the NUMA_DATA_DIR env var prefix - numa.toml gains a commented-out data_dir example No behavior change for existing production deployments (the default path is unchanged). Test harness is now fully config-driven, and containerized deploys can override data_dir via mount+config without needing env var injection. 127/127 unit tests pass, Suite 6 passes end-to-end. Co-Authored-By: Claude Opus 4.6 (1M context) --- numa.toml | 5 +++++ src/config.rs | 5 +++++ src/dot.rs | 2 +- src/lib.rs | 9 +++------ src/main.rs | 17 +++++++++++++++-- src/tls.rs | 8 +++++--- tests/integration.sh | 8 +++++--- 7 files changed, 39 insertions(+), 15 deletions(-) diff --git a/numa.toml b/numa.toml index b7f98de..35d92de 100644 --- a/numa.toml +++ b/numa.toml @@ -2,6 +2,11 @@ bind_addr = "0.0.0.0:53" api_port = 5380 # api_bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN dashboard access +# data_dir = "/usr/local/var/numa" # where numa stores TLS CA and cert material + # (default: /usr/local/var/numa on unix, + # %PROGRAMDATA%\numa on windows). Override for + # containerized deploys or tests that can't + # write to the system path. # [upstream] # mode = "forward" # "forward" (default) — relay to upstream diff --git a/src/config.rs b/src/config.rs index acf4d37..45dc896 100644 --- a/src/config.rs +++ b/src/config.rs @@ -41,6 +41,10 @@ pub struct ServerConfig { pub api_port: u16, #[serde(default = "default_api_bind_addr")] pub api_bind_addr: String, + /// Where numa writes TLS material (CA, leaf certs, regenerated state). + /// Defaults to `crate::data_dir()` (platform-specific system path) if unset. + #[serde(default)] + pub data_dir: Option, } impl Default for ServerConfig { @@ -49,6 +53,7 @@ impl Default for ServerConfig { bind_addr: default_bind_addr(), api_port: default_api_port(), api_bind_addr: default_api_bind_addr(), + data_dir: None, } } } diff --git a/src/dot.rs b/src/dot.rs index d399649..a09b160 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -61,7 +61,7 @@ fn load_tls_config(cert_path: &Path, key_path: &Path) -> crate::Result Option> { let service_names = [ctx.proxy_tld.clone()]; - match crate::tls::build_tls_config(&ctx.proxy_tld, &service_names, dot_alpn()) { + match crate::tls::build_tls_config(&ctx.proxy_tld, &service_names, dot_alpn(), &ctx.data_dir) { Ok(cfg) => Some(cfg), Err(e) => { warn!( diff --git a/src/lib.rs b/src/lib.rs index 05d18a0..347e72f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -66,15 +66,12 @@ fn config_dir_unix() -> std::path::PathBuf { std::path::PathBuf::from("/usr/local/var/numa") } -/// System-wide data directory for TLS certs. -/// Override with `NUMA_DATA_DIR` env var (useful for containerized -/// deployments and integration tests that can't write to the default path). +/// Default system-wide data directory for TLS certs. Overridable via +/// `[server] data_dir = "..."` in numa.toml — this function only provides +/// the fallback when the config doesn't set it. /// Unix: /usr/local/var/numa /// Windows: %PROGRAMDATA%\numa pub fn data_dir() -> std::path::PathBuf { - if let Ok(dir) = std::env::var("NUMA_DATA_DIR") { - return std::path::PathBuf::from(dir); - } #[cfg(windows)] { std::path::PathBuf::from( diff --git a/src/main.rs b/src/main.rs index adf266e..af0fb3a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -204,10 +204,23 @@ async fn main() -> numa::Result<()> { let forwarding_rules = system_dns.forwarding_rules; + // Resolve data_dir from config, falling back to the platform default. + // Used for TLS CA storage below and stored on ServerCtx for runtime use. + let resolved_data_dir = config + .server + .data_dir + .clone() + .unwrap_or_else(numa::data_dir); + // Build initial TLS config before ServerCtx (so ArcSwap is ready at construction) let initial_tls = if config.proxy.enabled && config.proxy.tls_port > 0 { let service_names = service_store.names(); - match numa::tls::build_tls_config(&config.proxy.tld, &service_names, Vec::new()) { + match numa::tls::build_tls_config( + &config.proxy.tld, + &service_names, + Vec::new(), + &resolved_data_dir, + ) { Ok(tls_config) => Some(ArcSwap::from(tls_config)), Err(e) => { log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e); @@ -248,7 +261,7 @@ async fn main() -> numa::Result<()> { config_path: resolved_config_path, config_found, config_dir: numa::config_dir(), - data_dir: numa::data_dir(), + data_dir: resolved_data_dir, tls_config: initial_tls, upstream_mode: resolved_mode, root_hints, diff --git a/src/tls.rs b/src/tls.rs index 5746f3b..c60714e 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -24,7 +24,7 @@ pub fn regenerate_tls(ctx: &ServerCtx) { names.extend(ctx.lan_peers.lock().unwrap().names()); let names: Vec = names.into_iter().collect(); - match build_tls_config(&ctx.proxy_tld, &names, Vec::new()) { + match build_tls_config(&ctx.proxy_tld, &names, Vec::new(), &ctx.data_dir) { Ok(new_config) => { tls.store(new_config); info!("TLS cert regenerated for {} services", names.len()); @@ -38,13 +38,15 @@ pub fn regenerate_tls(ctx: &ServerCtx) { /// so we list each service explicitly as a SAN. /// `alpn` is advertised in the TLS ServerHello — pass empty for the proxy /// (which accepts any ALPN), or `[b"dot"]` for DoT (RFC 7858 §3.2). +/// `data_dir` is where the CA material is stored — taken from +/// `[server] data_dir` in numa.toml (defaults to `crate::data_dir()`). pub fn build_tls_config( tld: &str, service_names: &[String], alpn: Vec>, + data_dir: &Path, ) -> crate::Result> { - let dir = crate::data_dir(); - let (ca_cert, ca_key) = ensure_ca(&dir)?; + let (ca_cert, ca_key) = ensure_ca(data_dir)?; let (cert_chain, key) = generate_service_cert(&ca_cert, &ca_key, tld, service_names)?; // Ensure a crypto provider is installed (rustls needs one) diff --git a/tests/integration.sh b/tests/integration.sh index f1c5205..473356e 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -542,8 +542,9 @@ else PROXY_HTTPS_PORT=8443 NUMA_DATA=/tmp/numa-integration-data - # Fresh data dir so we generate a fresh CA for this suite — NUMA_DATA_DIR - # env var lets numa write under $TMPDIR instead of /usr/local/var/numa. + # Fresh data dir so we generate a fresh CA for this suite. Path is set + # via [server] data_dir in the TOML below, not an env var — numa treats + # its config file as the single source of truth for all knobs. rm -rf "$NUMA_DATA" mkdir -p "$NUMA_DATA" @@ -551,6 +552,7 @@ else [server] bind_addr = "127.0.0.1:$PORT" api_port = $API_PORT +data_dir = "$NUMA_DATA" [upstream] mode = "forward" @@ -582,7 +584,7 @@ value = "10.0.0.1" ttl = 60 CONF - NUMA_DATA_DIR="$NUMA_DATA" RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 & + RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 & NUMA_PID=$! sleep 4 -- 2.34.1 From dc96e7f9999c17a29c9caf81a9aa8baa55c4098c Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 01:37:07 +0300 Subject: [PATCH 17/19] chore: bump version to 0.10.0 v0.10.0 ships DNS-over-TLS. Tagged release v0.10.0 on main after merge will pick up this Cargo.toml version, keeping tag and manifest aligned for release.yml. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 722c413..8750934 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1143,7 +1143,7 @@ dependencies = [ [[package]] name = "numa" -version = "0.9.1" +version = "0.10.0" dependencies = [ "arc-swap", "axum", diff --git a/Cargo.toml b/Cargo.toml index c6e9a2a..f0278b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "numa" -version = "0.9.1" +version = "0.10.0" authors = ["razvandimescu "] edition = "2021" description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS" -- 2.34.1 From a644c6deb06bb142e49a6cc46f98f96e2a282b75 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 01:49:44 +0300 Subject: [PATCH 18/19] docs: document DNS-over-TLS listener in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds DoT to the four existing touchpoints in the README where the feature naturally belongs: - Hero paragraph: mentions DoT alongside DNSSEC as a headline feature - Ad Blocking & Privacy section: dedicated paragraph with RFC 7858 reference, config hint, and the ALPN strictness guarantee - Comparison table: new "Encrypted clients (DoT listener)" row. Pi-hole "Needs stunnel sidecar" (verified — Pi-hole explicitly closed the native-DoT feature request as out of scope; community uses stunnel or AdGuard DNS Proxy as a TLS terminator) - Roadmap: checks off "DNS-over-TLS listener" alongside the existing DoH entry Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e96ecda..373c239 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A portable DNS resolver in a single binary. Block ads on any network, name your local services (`frontend.numa`), and override any hostname with auto-revert — all from your laptop, no cloud account or Raspberry Pi required. -Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. Caching, ad blocking, and local service domains out of the box. Optional recursive resolution from root nameservers with full DNSSEC chain-of-trust validation. One ~8MB binary, everything embedded. +Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. Caching, ad blocking, and local service domains out of the box. Optional recursive resolution from root nameservers with full DNSSEC chain-of-trust validation, plus a DNS-over-TLS listener for encrypted client connections (iOS Private DNS, systemd-resolved, etc.). One ~8MB binary, everything embedded. ![Numa dashboard](assets/hero-demo.gif) @@ -67,6 +67,8 @@ Three resolution modes: DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification, DS delegation, NSEC/NSEC3 denial proofs. [Read how it works →](https://numa.rs/blog/posts/dnssec-from-scratch.html) +**DNS-over-TLS listener** (RFC 7858) — accept encrypted queries on port 853 from strict clients like iOS Private DNS, systemd-resolved, or stubby. Self-signed CA generated automatically, or bring your own cert via `[dot] cert_path` / `key_path` in `numa.toml`. ALPN `"dot"` is advertised and enforced; a handshake with mismatched ALPN is rejected as a cross-protocol confusion defense. + ## LAN Discovery Run Numa on multiple machines. They find each other automatically via mDNS: @@ -96,6 +98,7 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena | Ad blocking | Yes | Yes | — | 385K+ domains | | Web admin UI | Full | Full | — | Dashboard | | Encrypted upstream (DoH) | Needs cloudflared | Yes | — | Native | +| Encrypted clients (DoT listener) | Needs stunnel sidecar | Yes | Yes | Native (RFC 7858) | | Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary, macOS/Linux/Windows | | Community maturity | 56K stars, 10 years | 33K stars | 20 years | New | @@ -116,6 +119,7 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena - [x] `.numa` local domains — auto TLS, path routing, WebSocket proxy - [x] LAN service discovery — mDNS, cross-machine DNS + proxy - [x] DNS-over-HTTPS — encrypted upstream +- [x] DNS-over-TLS listener — encrypted client connections (RFC 7858, ALPN strict) - [x] Recursive resolution + DNSSEC — chain-of-trust, NSEC/NSEC3 - [x] SRTT-based nameserver selection - [ ] pkarr integration — self-sovereign DNS via Mainline DHT -- 2.34.1 From 78c4f4148840abcdedf2bed774a17864bca3e3f7 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 01:59:41 +0300 Subject: [PATCH 19/19] docs: explain the two DoT cert modes in README Expands the DoT paragraph to make the trust model explicit. The previous version said "self-signed or bring your own cert" without explaining when to pick which or what the user experience looks like. The two modes close numa's gap vs AdGuard Home: BYO cert mode is functionally identical (Let's Encrypt via DNS-01 + cert_path/key_path), and the self-signed mode is numa's advantage on LAN-only deploys. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 373c239..4c32370 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,12 @@ Three resolution modes: DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification, DS delegation, NSEC/NSEC3 denial proofs. [Read how it works →](https://numa.rs/blog/posts/dnssec-from-scratch.html) -**DNS-over-TLS listener** (RFC 7858) — accept encrypted queries on port 853 from strict clients like iOS Private DNS, systemd-resolved, or stubby. Self-signed CA generated automatically, or bring your own cert via `[dot] cert_path` / `key_path` in `numa.toml`. ALPN `"dot"` is advertised and enforced; a handshake with mismatched ALPN is rejected as a cross-protocol confusion defense. +**DNS-over-TLS listener** (RFC 7858) — accept encrypted queries on port 853 from strict clients like iOS Private DNS, systemd-resolved, or stubby. Two modes: + +- **Self-signed** (default) — numa generates a local CA automatically. Works on any network with zero DNS setup, but clients must manually trust the CA (on macOS/Linux add to the system trust store; on iOS install a `.mobileconfig`). +- **Bring-your-own cert** — point `[dot] cert_path` / `key_path` at a publicly-trusted cert (e.g., Let's Encrypt via DNS-01 challenge on a domain pointing at your numa instance). Clients connect without any trust-store setup — same UX as AdGuard Home or Cloudflare `1.1.1.1`. + +ALPN `"dot"` is advertised and enforced in both modes; a handshake with mismatched ALPN is rejected as a cross-protocol confusion defense. ## LAN Discovery -- 2.34.1