From 8c3395e74b890313d1f704fbb16b674ea05eb1a5 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 3 Apr 2026 00:02:12 +0300 Subject: [PATCH 001/204] fix: check forwarding rules before recursive resolution Conditional forwarding (Tailscale .ts.net, VPC private zones) was only checked in the forward mode branch. In recursive mode, queries for forwarding-rule domains went to root servers instead of the configured upstream, returning NXDOMAIN for private domains. Move the forwarding rule check before the recursive/forward branch so it takes priority regardless of mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ctx.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/ctx.rs b/src/ctx.rs index 4e80b16..7529bc1 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -162,6 +162,29 @@ pub async fn handle_query( resp.header.authed_data = true; } (resp, QueryPath::Cached, cached_dnssec) + } else if let Some(fwd_addr) = + crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) + { + // Conditional forwarding takes priority over recursive mode + // (e.g. Tailscale .ts.net, VPC private zones) + let upstream = Upstream::Udp(fwd_addr); + match forward_query(&query, &upstream, ctx.timeout).await { + Ok(resp) => { + ctx.cache.write().unwrap().insert(&qname, qtype, &resp); + (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate) + } + Err(e) => { + error!( + "{} | {:?} {} | FORWARD ERROR | {}", + src_addr, qtype, qname, e + ); + ( + DnsPacket::response_from(&query, ResultCode::SERVFAIL), + QueryPath::UpstreamError, + DnssecStatus::Indeterminate, + ) + } + } } else if ctx.upstream_mode == UpstreamMode::Recursive { let key = (qname.clone(), qtype); let (resp, path, err) = resolve_coalesced(&ctx.inflight, key, &query, || { -- 2.34.1 From 8c421b9fa34edf50ba898351ead4536a08df50dd Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 3 Apr 2026 00:07:11 +0300 Subject: [PATCH 002/204] fix: check forwarding rules before recursive resolution (#29) Conditional forwarding (Tailscale .ts.net, VPC private zones) was only checked in the forward mode branch. In recursive mode, queries for forwarding-rule domains went to root servers instead of the configured upstream, returning NXDOMAIN for private domains. Move the forwarding rule check before the recursive/forward branch so it takes priority regardless of mode. Co-authored-by: Claude Opus 4.6 (1M context) --- src/ctx.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/ctx.rs b/src/ctx.rs index 4e80b16..7529bc1 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -162,6 +162,29 @@ pub async fn handle_query( resp.header.authed_data = true; } (resp, QueryPath::Cached, cached_dnssec) + } else if let Some(fwd_addr) = + crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) + { + // Conditional forwarding takes priority over recursive mode + // (e.g. Tailscale .ts.net, VPC private zones) + let upstream = Upstream::Udp(fwd_addr); + match forward_query(&query, &upstream, ctx.timeout).await { + Ok(resp) => { + ctx.cache.write().unwrap().insert(&qname, qtype, &resp); + (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate) + } + Err(e) => { + error!( + "{} | {:?} {} | FORWARD ERROR | {}", + src_addr, qtype, qname, e + ); + ( + DnsPacket::response_from(&query, ResultCode::SERVFAIL), + QueryPath::UpstreamError, + DnssecStatus::Indeterminate, + ) + } + } } else if ctx.upstream_mode == UpstreamMode::Recursive { let key = (qname.clone(), qtype); let (resp, path, err) = resolve_coalesced(&ctx.inflight, key, &query, || { -- 2.34.1 From d979cd9505e1f4001c08c1af685d1b0360f56c5f Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 3 Apr 2026 00:08:36 +0300 Subject: [PATCH 003/204] chore: bump version to 0.9.1 Fix: forwarding rules ignored in recursive mode (Tailscale/VPN). Fix: browsers treating .numa as search query (add search domain). 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 7d534b9..ea8eb0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1143,7 +1143,7 @@ dependencies = [ [[package]] name = "numa" -version = "0.9.0" +version = "0.9.1" dependencies = [ "arc-swap", "axum", diff --git a/Cargo.toml b/Cargo.toml index cb7b592..79ccf9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "numa" -version = "0.9.0" +version = "0.9.1" authors = ["razvandimescu "] edition = "2021" description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS" -- 2.34.1 From 80fcfd10ae73b7778245693204ea78714bd2a834 Mon Sep 17 00:00:00 2001 From: Laurin Brandner Date: Fri, 3 Apr 2026 10:27:47 +0200 Subject: [PATCH 004/204] flexible installation path --- com.numa.dns.plist | 2 +- numa.service | 2 +- src/system_dns.rs | 34 ++++++++++++++++------------------ 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/com.numa.dns.plist b/com.numa.dns.plist index 67c90fa..ad59f43 100644 --- a/com.numa.dns.plist +++ b/com.numa.dns.plist @@ -6,7 +6,7 @@ com.numa.dns ProgramArguments - /usr/local/bin/numa + {{exe_path}} RunAtLoad diff --git a/numa.service b/numa.service index 50b0909..7e67296 100644 --- a/numa.service +++ b/numa.service @@ -5,7 +5,7 @@ Wants=network-online.target [Service] Type=simple -ExecStart=/usr/local/bin/numa +ExecStart={{exe_path}} Restart=always RestartSec=2 StandardOutput=journal diff --git a/src/system_dns.rs b/src/system_dns.rs index a172608..89ef441 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -903,9 +903,12 @@ pub fn uninstall_service() -> Result<(), String> { /// Restart the service (kill process, launchd/systemd auto-restarts with new binary). pub fn restart_service() -> Result<(), String> { + let exe_path = + std::env::current_exe().map_err(|e| format!("failed to get current exe: {}", e))?; + #[cfg(any(target_os = "macos", target_os = "linux"))] let version = { - match std::process::Command::new("/usr/local/bin/numa") + match std::process::Command::new(&exe_path) .arg("--version") .output() { @@ -916,6 +919,7 @@ pub fn restart_service() -> Result<(), String> { #[cfg(target_os = "macos")] { + let exe_path = exe_path.to_string_lossy(); let output = std::process::Command::new("launchctl") .args(["list", PLIST_LABEL]) .output(); @@ -926,11 +930,11 @@ pub fn restart_service() -> Result<(), String> { // This will kill us too (we ARE /usr/local/bin/numa), so // codesign and print output first. let _ = std::process::Command::new("codesign") - .args(["-f", "-s", "-", "/usr/local/bin/numa"]) + .args(["-f", "-s", "-", &exe_path]) .output(); // use output() to suppress codesign stderr eprintln!(" Service restarting → {}\n", version); let _ = std::process::Command::new("pkill") - .args(["-f", "/usr/local/bin/numa"]) + .args(["-f", &exe_path]) .status(); Ok(()) } @@ -965,19 +969,22 @@ pub fn service_status() -> Result<(), String> { } } +fn replace_exe_path(service: &str) -> Result { + let exe_path = + std::env::current_exe().map_err(|e| format!("failed to get current exe: {}", e))?; + Ok(service.replace("{{exe_path}}", &exe_path.to_string_lossy())) +} + #[cfg(target_os = "macos")] fn install_service_macos() -> Result<(), String> { - // Check binary exists - if !std::path::Path::new("/usr/local/bin/numa").exists() { - return Err("numa binary not found at /usr/local/bin/numa. Run: sudo cp target/release/numa /usr/local/bin/numa".to_string()); - } - // Create log directory std::fs::create_dir_all("/usr/local/var/log") .map_err(|e| format!("failed to create log dir: {}", e))?; // Write plist let plist = include_str!("../com.numa.dns.plist"); + let plist = replace_exe_path(plist)?; + std::fs::write(PLIST_DEST, plist) .map_err(|e| format!("failed to write {}: {}", PLIST_DEST, e))?; @@ -1179,19 +1186,10 @@ fn uninstall_linux() -> Result<(), String> { Ok(()) } -#[cfg(target_os = "linux")] -fn ensure_binary_installed() -> Result<(), String> { - if !std::path::Path::new("/usr/local/bin/numa").exists() { - return Err("numa binary not found at /usr/local/bin/numa. Run: sudo cp target/release/numa /usr/local/bin/numa".to_string()); - } - Ok(()) -} - #[cfg(target_os = "linux")] fn install_service_linux() -> Result<(), String> { - ensure_binary_installed()?; - let unit = include_str!("../numa.service"); + let unit = replace_exe_path(plist)?; std::fs::write(SYSTEMD_UNIT, unit) .map_err(|e| format!("failed to write {}: {}", SYSTEMD_UNIT, e))?; -- 2.34.1 From ad34fe2d9eb1a3f6a7ca88bbfb2a73f8d4b10720 Mon Sep 17 00:00:00 2001 From: Laurin Brandner Date: Sat, 4 Apr 2026 11:25:29 +0200 Subject: [PATCH 005/204] Fix unit replacement for linux --- src/system_dns.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index 89ef441..25ab11e 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -1189,7 +1189,7 @@ fn uninstall_linux() -> Result<(), String> { #[cfg(target_os = "linux")] fn install_service_linux() -> Result<(), String> { let unit = include_str!("../numa.service"); - let unit = replace_exe_path(plist)?; + let unit = replace_exe_path(unit)?; std::fs::write(SYSTEMD_UNIT, unit) .map_err(|e| format!("failed to write {}: {}", SYSTEMD_UNIT, e))?; -- 2.34.1 From efe36695407d8b01add433a24ce549c886600359 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 6 Apr 2026 22:38:22 +0300 Subject: [PATCH 006/204] fix: gate exe_path and replace_exe_path for Windows clippy, add macOS CI - Gate exe_path in restart_service() and replace_exe_path() behind #[cfg(any(target_os = "macos", target_os = "linux"))] to fix unused variable and dead code warnings on Windows - Add macOS CI job (clippy + tests) - Add test for template substitution in plist and systemd unit files Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 11 +++++++++++ src/system_dns.rs | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f59e274..7884549 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,17 @@ jobs: - name: audit run: cargo install cargo-audit && cargo audit + check-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: clippy + run: cargo clippy -- -D warnings + - name: test + run: cargo test + check-windows: runs-on: windows-latest steps: diff --git a/src/system_dns.rs b/src/system_dns.rs index 25ab11e..ea5d05f 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -903,6 +903,7 @@ pub fn uninstall_service() -> Result<(), String> { /// Restart the service (kill process, launchd/systemd auto-restarts with new binary). pub fn restart_service() -> Result<(), String> { + #[cfg(any(target_os = "macos", target_os = "linux"))] let exe_path = std::env::current_exe().map_err(|e| format!("failed to get current exe: {}", e))?; @@ -969,6 +970,7 @@ pub fn service_status() -> Result<(), String> { } } +#[cfg(any(target_os = "macos", target_os = "linux"))] fn replace_exe_path(service: &str) -> Result { let exe_path = std::env::current_exe().map_err(|e| format!("failed to get current exe: {}", e))?; @@ -1411,6 +1413,22 @@ Wireless LAN adapter Wi-Fi: ); } + #[test] + #[cfg(any(target_os = "macos", target_os = "linux"))] + fn replace_exe_path_substitutes_template() { + let plist = include_str!("../com.numa.dns.plist"); + let unit = include_str!("../numa.service"); + + assert!(plist.contains("{{exe_path}}"), "plist missing placeholder"); + assert!(unit.contains("{{exe_path}}"), "unit file missing placeholder"); + + let result = replace_exe_path(plist).expect("replace_exe_path failed for plist"); + assert!(!result.contains("{{exe_path}}")); + + let result = replace_exe_path(unit).expect("replace_exe_path failed for unit"); + assert!(!result.contains("{{exe_path}}")); + } + #[test] fn parse_ipconfig_skips_disconnected() { let sample = "\ -- 2.34.1 From 766935ec97b589e956f90804e7c9d43cc1ec42bc Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 6 Apr 2026 22:42:43 +0300 Subject: [PATCH 007/204] style: fix rustfmt formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- src/system_dns.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index ea5d05f..8709e0d 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -1420,7 +1420,10 @@ Wireless LAN adapter Wi-Fi: let unit = include_str!("../numa.service"); assert!(plist.contains("{{exe_path}}"), "plist missing placeholder"); - assert!(unit.contains("{{exe_path}}"), "unit file missing placeholder"); + assert!( + unit.contains("{{exe_path}}"), + "unit file missing placeholder" + ); let result = replace_exe_path(plist).expect("replace_exe_path failed for plist"); assert!(!result.contains("{{exe_path}}")); -- 2.34.1 From e4350ae81cd90f367d1d3197b0b07c89547700e5 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 30 Mar 2026 00:36:26 +0300 Subject: [PATCH 008/204] 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 14efc51340fe419eb9d66166544b0478ff491bee Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 30 Mar 2026 00:50:04 +0300 Subject: [PATCH 009/204] 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 aa8923b2c63b8b915762adb60566affb4c77a548 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 30 Mar 2026 01:31:51 +0300 Subject: [PATCH 010/204] 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 cb54ab3dfce794269aab155f55e8f843722ab9a4 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 6 Apr 2026 23:10:45 +0300 Subject: [PATCH 011/204] 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 1239ed0e729882383d050fbc2c62adf13f287c58 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 7 Apr 2026 16:47:54 +0300 Subject: [PATCH 012/204] 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 7742858b7bc6ca7b0091475ad4d62e1ab9884a9a Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 7 Apr 2026 20:10:51 +0300 Subject: [PATCH 013/204] 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 357c710ec43b57a6809e9fa0dfef452d0b682774 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 7 Apr 2026 20:11:15 +0300 Subject: [PATCH 014/204] 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 2b0c4e3d5e2e04e695fbc24ac09cb63e659c4a2e Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 7 Apr 2026 20:35:05 +0300 Subject: [PATCH 015/204] =?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 0a73cdf4db3f6094cf33b17b7d0013116bef9bc5 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 7 Apr 2026 20:37:40 +0300 Subject: [PATCH 016/204] 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 1632fc36f2bd6fb48be48ef1581b23aa5aead153 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 7 Apr 2026 22:51:52 +0300 Subject: [PATCH 017/204] 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 018/204] 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 bacc49667ab5e28870765d9afbb6478ac8b4b073 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 7 Apr 2026 23:22:04 +0300 Subject: [PATCH 019/204] 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 186e70937381b66dfa4236f968acb1d1def44683 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 00:09:54 +0300 Subject: [PATCH 020/204] 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 c98e6c3ea9bea6a8e32ce1ad53b6f542e3e1dbf9 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 00:54:51 +0300 Subject: [PATCH 021/204] 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 7f52bd8a324c689dbb8328229516aec4bc9d5e60 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 01:12:16 +0300 Subject: [PATCH 022/204] =?UTF-8?q?test:=20Suite=206=20=E2=80=94=20proxy?= =?UTF-8?q?=20+=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 6887c8e02e6eec69dc21da8f19e9e406c8bd16aa Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 01:31:16 +0300 Subject: [PATCH 023/204] 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 7001ba2e517a69909d83f9f2fdbf6f45bad8c9d2 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 01:37:07 +0300 Subject: [PATCH 024/204] 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 bc54ea930f3928f5436fa6ddb6ea59c2fc695798 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 01:49:44 +0300 Subject: [PATCH 025/204] 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 82cc588c67548103df8fdd6483f2fe31a4da55a4 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 01:59:41 +0300 Subject: [PATCH 026/204] 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 From 1b2f68202696e73fa94c5e4434a3dbc534018f5d Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 03:47:43 +0300 Subject: [PATCH 027/204] ci: auto-bump homebrew formula on release (#39) Add a workflow that runs on release:published (and via manual workflow_dispatch), fetches sha256 checksums from the published release assets, and rewrites razvandimescu/homebrew-tap/numa.rb in place: version, URL paths, and sha256 lines after each url. The formula's existing on_macos/on_linux structure is preserved. Uses HOMEBREW_TAP_GITHUB_TOKEN (already set as a repo secret) to push directly to the tap's main branch. Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/homebrew-bump.yml | 76 +++++++++++++++++++++++++++++ scripts/update-homebrew-formula.py | 57 ++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 .github/workflows/homebrew-bump.yml create mode 100755 scripts/update-homebrew-formula.py diff --git a/.github/workflows/homebrew-bump.yml b/.github/workflows/homebrew-bump.yml new file mode 100644 index 0000000..5bcac57 --- /dev/null +++ b/.github/workflows/homebrew-bump.yml @@ -0,0 +1,76 @@ +name: Bump Homebrew Tap + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: 'Version to bump (e.g. 0.10.0 or v0.10.0)' + required: true + +permissions: + contents: read + +jobs: + bump: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Determine version + id: ver + run: | + if [ "${{ github.event_name }}" = "release" ]; then + V="${{ github.event.release.tag_name }}" + else + V="${{ github.event.inputs.version }}" + fi + V="${V#v}" + echo "version=$V" >> "$GITHUB_OUTPUT" + + - name: Fetch sha256 checksums from release assets + id: shas + env: + V: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + base="https://github.com/razvandimescu/numa/releases/download/v${V}" + for t in macos-aarch64 macos-x86_64 linux-aarch64 linux-x86_64; do + sha=$(curl -fsSL "${base}/numa-${t}.tar.gz.sha256" | awk '{print $1}') + if [ -z "$sha" ]; then + echo "ERROR: failed to fetch sha256 for $t" >&2 + exit 1 + fi + key=$(echo "$t" | tr '[:lower:]-' '[:upper:]_') + echo "SHA_${key}=${sha}" >> "$GITHUB_ENV" + done + + - name: Clone homebrew-tap + env: + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + run: | + git clone "https://x-access-token:${HOMEBREW_TAP_GITHUB_TOKEN}@github.com/razvandimescu/homebrew-tap.git" tap + + - name: Update formula + env: + VERSION: ${{ steps.ver.outputs.version }} + run: | + python3 scripts/update-homebrew-formula.py tap/numa.rb + echo "--- updated numa.rb ---" + cat tap/numa.rb + + - name: Commit and push + working-directory: tap + env: + V: ${{ steps.ver.outputs.version }} + run: | + if git diff --quiet; then + echo "numa.rb already at v${V}, nothing to commit" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add numa.rb + git commit -m "chore: bump numa to v${V}" + git push origin main diff --git a/scripts/update-homebrew-formula.py b/scripts/update-homebrew-formula.py new file mode 100755 index 0000000..c114784 --- /dev/null +++ b/scripts/update-homebrew-formula.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Rewrite a Homebrew formula in place: bump version, URL paths, and sha256 lines. + +Reads the formula path from argv[1], and the following env vars: + VERSION e.g. "0.10.0" (no leading v) + SHA_MACOS_AARCH64 + SHA_MACOS_X86_64 + SHA_LINUX_AARCH64 + SHA_LINUX_X86_64 + +Assumptions about the formula: + - Has `version "X.Y.Z"` somewhere + - Has `url "...releases/download/vX.Y.Z/numa-.tar.gz"` lines + - May or may not already have `sha256 "..."` lines immediately after each url +""" +import os +import re +import sys + +formula_path = sys.argv[1] +version = os.environ["VERSION"].lstrip("v") +shas = { + "macos-aarch64": os.environ["SHA_MACOS_AARCH64"], + "macos-x86_64": os.environ["SHA_MACOS_X86_64"], + "linux-aarch64": os.environ["SHA_LINUX_AARCH64"], + "linux-x86_64": os.environ["SHA_LINUX_X86_64"], +} + +with open(formula_path) as f: + content = f.read() + +content = re.sub(r'version "[^"]*"', f'version "{version}"', content) +content = re.sub( + r"releases/download/v[\d.]+/numa-", + f"releases/download/v{version}/numa-", + content, +) +content = re.sub(r'\n[ \t]*sha256 "[^"]*"', "", content) + + +def add_sha(match: re.Match) -> str: + indent = match.group(1) + target = match.group(2) + if target not in shas: + return match.group(0) + return f'{match.group(0)}\n{indent}sha256 "{shas[target]}"' + + +content = re.sub( + r'^([ \t]+)url "[^"]*numa-([\w-]+)\.tar\.gz"', + add_sha, + content, + flags=re.MULTILINE, +) + +with open(formula_path, "w") as f: + f.write(content) -- 2.34.1 From 039254280b49655e561a92c54ade70d94d30a1e0 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 15:18:01 +0300 Subject: [PATCH 028/204] fix: cross-platform CA trust (Arch/Fedora + Windows) (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: cross-platform CA trust (Arch/Fedora + Windows) Closes #35. trust_ca_linux now detects which trust store the distro ships and runs the matching refresh command, instead of hardcoding Debian's update-ca-certificates. Detection walks a const table in priority order, picking the first whose anchor dir exists: - debian: /usr/local/share/ca-certificates (update-ca-certificates) - pki: /etc/pki/ca-trust/source/anchors (update-ca-trust extract) - p11kit: /etc/ca-certificates/trust-source/anchors (trust extract-compat) Falls back with a clear error listing every backend tried. Adds Windows support via certutil -addstore Root / -delstore Root, removing the silent CA-trust gap on numa install (previously the service installed but the trust step quietly errored, leaving every HTTPS .numa request throwing browser warnings). Refactor: trust_ca and untrust_ca are now thin dispatchers calling per-platform helpers. CA_COMMON_NAME and CA_FILE_NAME are centralized in tls.rs and reused from system_dns.rs and api.rs. untrust_ca_linux no longer pre-checks file existence (TOCTOU) and skips the refresh when no file was actually removed. Test: tests/docker/install-trust.sh runs the install/uninstall contract against debian:stable, fedora:latest, and archlinux:latest in containers, asserting the cert lands in (and is removed from) the system bundle. All three pass locally. README notes the Firefox/NSS limitation (separate trust store). Co-Authored-By: Claude Opus 4.6 (1M context) * style: rustfmt fixes for trust_ca_linux helpers Co-Authored-By: Claude Opus 4.6 (1M context) * test: macOS CA trust contract test (manual) Adds tests/manual/install-trust-macos.sh — a sudo bash script that mirrors trust_ca_macos / untrust_ca_macos against a fixture cert with a unique CN. Designed to coexist with a running production numa: - Refuses to run if a real "Numa Local CA" is already in System.keychain (fail-closed protection for dogfood installs) - Uses a unique CN ("Numa Local CA Test ") so the test cert can never collide with production - Mirrors the by-hash deletion loop from untrust_ca_macos - Trap-cleanup on success or interrupt Lives under tests/manual/ to signal "host-mutating, dev-only" — distinct from tests/docker/install-trust.sh which is hermetic. Co-Authored-By: Claude Opus 4.6 (1M context) * test: relax bail-out in macOS trust test (safe alongside production) The bail-out was overly defensive. The test cert uses a unique CN ("Numa Local CA Test ") that is strictly longer than the production CN, so `security find-certificate -c $TEST_CN` cannot substring-match the production cert. All deletes are by-hash, which can only target the test cert's specific hash. Coexistence is provably safe; document the reasoning in the header comment block and replace the refusal with an informational notice. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- README.md | 2 +- src/api.rs | 2 +- src/system_dns.rs | 259 ++++++++++++++++++++-------- src/tls.rs | 11 +- tests/docker/install-trust.sh | 123 +++++++++++++ tests/manual/install-trust-macos.sh | 94 ++++++++++ 6 files changed, 411 insertions(+), 80 deletions(-) create mode 100755 tests/docker/install-trust.sh create mode 100755 tests/manual/install-trust-macos.sh diff --git a/README.md b/README.md index 4c32370..5794268 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification, **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`). +- **Self-signed** (default) — numa generates a local CA automatically. `numa install` adds it to the system trust store on macOS, Linux (Debian/Ubuntu, Fedora/RHEL/SUSE, Arch), and Windows. On iOS, install the `.mobileconfig` from `numa setup-phone`. Firefox keeps its own NSS store and ignores the system one — trust the CA there manually if you need HTTPS for `.numa` services in Firefox. - **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. diff --git a/src/api.rs b/src/api.rs index 1a6b7ef..59938b4 100644 --- a/src/api.rs +++ b/src/api.rs @@ -906,7 +906,7 @@ async fn remove_route( } async fn serve_ca(State(ctx): State>) -> Result { - let ca_path = ctx.data_dir.join("ca.pem"); + let ca_path = ctx.data_dir.join(crate::tls::CA_FILE_NAME); let bytes = tokio::task::spawn_blocking(move || std::fs::read(ca_path)) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? diff --git a/src/system_dns.rs b/src/system_dns.rs index 8709e0d..fc02393 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -1278,102 +1278,209 @@ fn run_systemctl(args: &[&str]) -> Result<(), String> { // --- CA trust management --- +/// One Linux trust-store backend (Debian, Fedora pki, Arch p11-kit). +#[cfg(target_os = "linux")] +struct LinuxTrustStore { + name: &'static str, + anchor_dir: &'static str, + anchor_file: &'static str, + refresh_install: &'static [&'static str], + refresh_uninstall: &'static [&'static str], +} + +// If you change this table, update tests/docker/install-trust.sh to match — +// it asserts the same paths/commands against real distro images. +#[cfg(target_os = "linux")] +const LINUX_TRUST_STORES: &[LinuxTrustStore] = &[ + // Debian / Ubuntu / Mint + LinuxTrustStore { + name: "debian", + anchor_dir: "/usr/local/share/ca-certificates", + anchor_file: "numa-local-ca.crt", + refresh_install: &["update-ca-certificates"], + refresh_uninstall: &["update-ca-certificates", "--fresh"], + }, + // Fedora / RHEL / CentOS / SUSE (p11-kit via update-ca-trust wrapper) + LinuxTrustStore { + name: "pki", + anchor_dir: "/etc/pki/ca-trust/source/anchors", + anchor_file: "numa-local-ca.pem", + refresh_install: &["update-ca-trust", "extract"], + refresh_uninstall: &["update-ca-trust", "extract"], + }, + // Arch / Manjaro (raw p11-kit) + LinuxTrustStore { + name: "p11kit", + anchor_dir: "/etc/ca-certificates/trust-source/anchors", + anchor_file: "numa-local-ca.pem", + refresh_install: &["trust", "extract-compat"], + refresh_uninstall: &["trust", "extract-compat"], + }, +]; + +#[cfg(target_os = "linux")] +fn detect_linux_trust_store() -> Option<&'static LinuxTrustStore> { + LINUX_TRUST_STORES + .iter() + .find(|s| std::path::Path::new(s.anchor_dir).is_dir()) +} + fn trust_ca() -> Result<(), String> { - let ca_path = crate::data_dir().join("ca.pem"); + let ca_path = crate::data_dir().join(crate::tls::CA_FILE_NAME); if !ca_path.exists() { return Err("CA not generated yet — start numa first to create certificates".into()); } #[cfg(target_os = "macos")] - { - let status = std::process::Command::new("security") - .args([ - "add-trusted-cert", - "-d", - "-r", - "trustRoot", - "-k", - "/Library/Keychains/System.keychain", - ]) - .arg(&ca_path) - .status() - .map_err(|e| format!("security: {}", e))?; - if !status.success() { - return Err("security add-trusted-cert failed".into()); - } - eprintln!(" Trusted Numa CA in system keychain"); - } - + let result = trust_ca_macos(&ca_path); #[cfg(target_os = "linux")] - { - let dest = std::path::Path::new("/usr/local/share/ca-certificates/numa-local-ca.crt"); - std::fs::copy(&ca_path, dest).map_err(|e| format!("copy CA: {}", e))?; - let status = std::process::Command::new("update-ca-certificates") - .status() - .map_err(|e| format!("update-ca-certificates: {}", e))?; - if !status.success() { - return Err("update-ca-certificates failed".into()); - } - eprintln!(" Trusted Numa CA system-wide"); - } + let result = trust_ca_linux(&ca_path); + #[cfg(windows)] + let result = trust_ca_windows(&ca_path); + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] + let result = Err::<(), String>("CA trust not supported on this OS".to_string()); - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - { - Err("CA trust not supported on this OS".into()) - } - - #[cfg(any(target_os = "macos", target_os = "linux"))] - Ok(()) + result } fn untrust_ca() -> Result<(), String> { - let ca_path = crate::data_dir().join("ca.pem"); - #[cfg(target_os = "macos")] + let result = untrust_ca_macos(); + #[cfg(target_os = "linux")] + let result = untrust_ca_linux(); + #[cfg(windows)] + let result = untrust_ca_windows(); + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] + let result = Ok::<(), String>(()); + + result +} + +#[cfg(target_os = "macos")] +fn trust_ca_macos(ca_path: &std::path::Path) -> Result<(), String> { + let status = std::process::Command::new("security") + .args([ + "add-trusted-cert", + "-d", + "-r", + "trustRoot", + "-k", + "/Library/Keychains/System.keychain", + ]) + .arg(ca_path) + .status() + .map_err(|e| format!("security: {}", e))?; + if !status.success() { + return Err("security add-trusted-cert failed".into()); + } + eprintln!(" Trusted Numa CA in system keychain"); + Ok(()) +} + +#[cfg(target_os = "macos")] +fn untrust_ca_macos() -> Result<(), String> { + if let Ok(out) = std::process::Command::new("security") + .args([ + "find-certificate", + "-c", + crate::tls::CA_COMMON_NAME, + "-a", + "-Z", + "/Library/Keychains/System.keychain", + ]) + .output() { - // Find all Numa CA certs by hash and delete each one - if let Ok(out) = std::process::Command::new("security") - .args([ - "find-certificate", - "-c", - "Numa Local CA", - "-a", - "-Z", - "/Library/Keychains/System.keychain", - ]) - .output() - { - let stdout = String::from_utf8_lossy(&out.stdout); - for line in stdout.lines() { - if let Some(hash) = line.strip_prefix("SHA-1 hash: ") { - let hash = hash.trim(); - let _ = std::process::Command::new("security") - .args([ - "delete-certificate", - "-Z", - hash, - "/Library/Keychains/System.keychain", - ]) - .output(); - } + let stdout = String::from_utf8_lossy(&out.stdout); + for line in stdout.lines() { + if let Some(hash) = line.strip_prefix("SHA-1 hash: ") { + let hash = hash.trim(); + let _ = std::process::Command::new("security") + .args([ + "delete-certificate", + "-Z", + hash, + "/Library/Keychains/System.keychain", + ]) + .output(); } } - eprintln!(" Removed Numa CA from system keychain"); } + eprintln!(" Removed Numa CA from system keychain"); + Ok(()) +} - #[cfg(target_os = "linux")] - { - let dest = std::path::Path::new("/usr/local/share/ca-certificates/numa-local-ca.crt"); - if dest.exists() { - let _ = std::fs::remove_file(dest); - let _ = std::process::Command::new("update-ca-certificates") - .arg("--fresh") - .status(); - eprintln!(" Removed Numa CA from system trust store"); +#[cfg(target_os = "linux")] +fn trust_ca_linux(ca_path: &std::path::Path) -> Result<(), String> { + let store = detect_linux_trust_store().ok_or_else(|| { + let names: Vec<&str> = LINUX_TRUST_STORES.iter().map(|s| s.name).collect(); + format!( + "no supported CA trust store found (tried: {}). \ + Please report at https://github.com/razvandimescu/numa/issues", + names.join(", ") + ) + })?; + + let dest = std::path::Path::new(store.anchor_dir).join(store.anchor_file); + std::fs::copy(ca_path, &dest).map_err(|e| format!("copy CA to {}: {}", dest.display(), e))?; + + run_refresh(store.name, store.refresh_install)?; + eprintln!(" Trusted Numa CA system-wide ({})", store.name); + Ok(()) +} + +#[cfg(target_os = "linux")] +fn untrust_ca_linux() -> Result<(), String> { + let Some(store) = detect_linux_trust_store() else { + return Ok(()); + }; + + let dest = std::path::Path::new(store.anchor_dir).join(store.anchor_file); + match std::fs::remove_file(&dest) { + Ok(()) => { + let _ = run_refresh(store.name, store.refresh_uninstall); + eprintln!(" Removed Numa CA from system trust store ({})", store.name); } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => {} // best-effort uninstall } + Ok(()) +} - let _ = ca_path; // suppress unused warning on other platforms +#[cfg(target_os = "linux")] +fn run_refresh(store_name: &str, argv: &[&str]) -> Result<(), String> { + let (cmd, args) = argv + .split_first() + .expect("refresh command must be non-empty"); + let status = std::process::Command::new(cmd) + .args(args) + .status() + .map_err(|e| format!("{} ({}): {}", cmd, store_name, e))?; + if !status.success() { + return Err(format!("{} ({}) failed", cmd, store_name)); + } + Ok(()) +} + +#[cfg(windows)] +fn trust_ca_windows(ca_path: &std::path::Path) -> Result<(), String> { + let status = std::process::Command::new("certutil") + .args(["-addstore", "-f", "Root"]) + .arg(ca_path) + .status() + .map_err(|e| format!("certutil: {}", e))?; + if !status.success() { + return Err("certutil -addstore Root failed (run as Administrator?)".into()); + } + eprintln!(" Trusted Numa CA in Windows Root store"); + Ok(()) +} + +#[cfg(windows)] +fn untrust_ca_windows() -> Result<(), String> { + let _ = std::process::Command::new("certutil") + .args(["-delstore", "Root", crate::tls::CA_COMMON_NAME]) + .status(); + eprintln!(" Removed Numa CA from Windows Root store"); Ok(()) } diff --git a/src/tls.rs b/src/tls.rs index c60714e..7c7620a 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -13,6 +13,13 @@ use time::{Duration, OffsetDateTime}; const CA_VALIDITY_DAYS: i64 = 3650; // 10 years const CERT_VALIDITY_DAYS: i64 = 365; // 1 year +/// Common Name on Numa's local CA. Referenced by trust-store helpers +/// (`security`, `certutil`) when locating the cert for removal. +pub const CA_COMMON_NAME: &str = "Numa Local CA"; + +/// Filename of the CA certificate inside the data dir. +pub const CA_FILE_NAME: &str = "ca.pem"; + /// Collect all service + LAN peer names and regenerate the TLS cert. pub fn regenerate_tls(ctx: &ServerCtx) { let tls = match &ctx.tls_config { @@ -67,7 +74,7 @@ pub fn build_tls_config( fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> { let ca_key_path = dir.join("ca.key"); - let ca_cert_path = dir.join("ca.pem"); + let ca_cert_path = dir.join(CA_FILE_NAME); if ca_key_path.exists() && ca_cert_path.exists() { let key_pem = std::fs::read_to_string(&ca_key_path)?; @@ -86,7 +93,7 @@ fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> { let mut params = CertificateParams::default(); params .distinguished_name - .push(DnType::CommonName, "Numa Local CA"); + .push(DnType::CommonName, CA_COMMON_NAME); params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; params.not_before = OffsetDateTime::now_utc(); diff --git a/tests/docker/install-trust.sh b/tests/docker/install-trust.sh new file mode 100755 index 0000000..ec6d55c --- /dev/null +++ b/tests/docker/install-trust.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# +# Cross-distro CA trust contract test for issue #35. +# +# Runs the exact shell commands `src/system_dns.rs::trust_ca_linux` would run +# on each Linux trust-store family (Debian, Fedora pki, Arch p11-kit), and +# asserts the certificate ends up in (and is removed from) the system bundle. +# +# This is a contract test, not an integration test: it doesn't drive the Rust +# code (that would need systemd-in-container). It verifies the assumptions in +# `LINUX_TRUST_STORES` against the real distro behavior. If you change that +# table in src/system_dns.rs, update the per-distro cases below to match. +# +# Requirements: docker, openssl (host). +# Usage: ./tests/docker/install-trust.sh + +set -euo pipefail + +cd "$(dirname "$0")/../.." + +GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m" + +# Self-signed CA fixture, mounted into each container as ca.pem. +# basicConstraints=CA:TRUE is required — without it, Debian's +# update-ca-certificates silently skips the cert during bundle build. +FIXTURE_DIR=$(mktemp -d) +trap 'rm -rf "$FIXTURE_DIR"' EXIT +openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ + -keyout "$FIXTURE_DIR/ca.key" \ + -out "$FIXTURE_DIR/ca.pem" \ + -subj "/CN=Numa Local CA Test $(date +%s)" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" >/dev/null 2>&1 + +# Distro bundles store certs differently — Debian writes raw PEM only, +# Fedora prepends "# CN" comment headers, Arch via extract-compat is +# raw PEM. To detect cert presence uniformly we grep for a deterministic +# substring of the base64 body (first base64 line is unique per cert). +CERT_TAG=$(sed -n '2p' "$FIXTURE_DIR/ca.pem") + +PASSED=0; FAILED=0 + +run_case() { + local distro="$1"; shift + local image="$1"; shift + local platform="$1"; shift + local script="$1" + + printf "── %s (%s) ──\n" "$distro" "$image" + if docker run --rm \ + --platform "$platform" \ + --security-opt seccomp=unconfined \ + -e CERT_TAG="$CERT_TAG" \ + -e DEBIAN_FRONTEND=noninteractive \ + -v "$FIXTURE_DIR/ca.pem:/fixture/ca.pem:ro" \ + "$image" bash -c "$script"; then + printf "${GREEN}✓${RESET} %s\n\n" "$distro" + PASSED=$((PASSED + 1)) + else + printf "${RED}✗${RESET} %s\n\n" "$distro" + FAILED=$((FAILED + 1)) + fi +} + +# Debian / Ubuntu / Mint — anchor: /usr/local/share/ca-certificates/*.crt +run_case "debian" "debian:stable" "linux/amd64" ' + set -e + apt-get update -qq + apt-get install -qq -y ca-certificates >/dev/null + install -m 0644 /fixture/ca.pem /usr/local/share/ca-certificates/numa-local-ca.crt + update-ca-certificates >/dev/null 2>&1 + grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt + echo " install: cert present in bundle" + rm /usr/local/share/ca-certificates/numa-local-ca.crt + update-ca-certificates --fresh >/dev/null 2>&1 + if grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt; then + echo " uninstall: cert STILL present (regression)" >&2 + exit 1 + fi + echo " uninstall: cert removed from bundle" +' + +# Fedora / RHEL / CentOS / SUSE — anchor: /etc/pki/ca-trust/source/anchors/*.pem +run_case "fedora" "fedora:latest" "linux/amd64" ' + set -e + dnf install -q -y ca-certificates >/dev/null + install -m 0644 /fixture/ca.pem /etc/pki/ca-trust/source/anchors/numa-local-ca.pem + update-ca-trust extract + grep -q "$CERT_TAG" /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem + echo " install: cert present in bundle" + rm /etc/pki/ca-trust/source/anchors/numa-local-ca.pem + update-ca-trust extract + if grep -q "$CERT_TAG" /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem; then + echo " uninstall: cert STILL present (regression)" >&2 + exit 1 + fi + echo " uninstall: cert removed from bundle" +' + +# Arch / Manjaro — anchor: /etc/ca-certificates/trust-source/anchors/*.pem +# archlinux:latest is x86_64-only; --platform forces emulation on Apple Silicon. +run_case "arch" "archlinux:latest" "linux/amd64" ' + set -e + # pacman 7+ filters syscalls in its own sandbox; disable for Rosetta/qemu emulation. + sed -i "s/^#DisableSandboxSyscalls/DisableSandboxSyscalls/" /etc/pacman.conf + pacman -Sy --noconfirm --needed ca-certificates p11-kit >/dev/null 2>&1 + install -m 0644 /fixture/ca.pem /etc/ca-certificates/trust-source/anchors/numa-local-ca.pem + trust extract-compat + grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt + echo " install: cert present in bundle" + rm /etc/ca-certificates/trust-source/anchors/numa-local-ca.pem + trust extract-compat + if grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt; then + echo " uninstall: cert STILL present (regression)" >&2 + exit 1 + fi + echo " uninstall: cert removed from bundle" +' + +printf "── summary ──\n" +printf " ${GREEN}passed${RESET}: %d\n" "$PASSED" +printf " ${RED}failed${RESET}: %d\n" "$FAILED" +[ "$FAILED" -eq 0 ] diff --git a/tests/manual/install-trust-macos.sh b/tests/manual/install-trust-macos.sh new file mode 100755 index 0000000..5ad29d0 --- /dev/null +++ b/tests/manual/install-trust-macos.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# +# Manual macOS CA trust contract test. +# +# Mirrors src/system_dns.rs::trust_ca_macos / untrust_ca_macos by running +# the same `security` shell commands against a fixture cert with a unique +# CN. Safe to run alongside a production numa install: +# +# - Test cert CN = "Numa Local CA Test ", always strictly longer +# than the production CN "Numa Local CA". `security find-certificate -c` +# does substring matching, so the test's search for $TEST_CN can never +# match the production cert (the search term is longer than the prod CN). +# - All deletes use `delete-certificate -Z `, which only touches the +# cert with that exact hash. Production and test certs have different +# hashes by construction (different key material), so the delete cannot +# reach the production cert even if a CN search somehow returned both. +# +# Mutates the System keychain (briefly). Cleans up on success or interrupt. +# Requires sudo for `security add-trusted-cert` and `delete-certificate`. +# +# Usage: ./tests/manual/install-trust-macos.sh + +set -euo pipefail + +if [[ "$OSTYPE" != darwin* ]]; then + echo "This test is macOS-only." >&2 + exit 1 +fi + +GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m" + +# Production constant from src/tls.rs::CA_COMMON_NAME — keep in sync. +PROD_CN="Numa Local CA" +KEYCHAIN="/Library/Keychains/System.keychain" + +# Notice if production numa is already installed. We proceed regardless — +# see header for why coexistence is safe (unique CN + by-hash deletion). +if security find-certificate -c "$PROD_CN" "$KEYCHAIN" >/dev/null 2>&1; then + echo " note: production '$PROD_CN' detected — proceeding alongside (test cert can't touch it)" + echo +fi + +# Unique CN ensures the test cert can never collide with production. +TEST_CN="Numa Local CA Test $$-$(date +%s)" +FIXTURE_DIR=$(mktemp -d) + +cleanup() { + # Best-effort: remove any test certs by hash if still present. + if security find-certificate -c "$TEST_CN" "$KEYCHAIN" >/dev/null 2>&1; then + echo " cleanup: removing leftover test cert" + security find-certificate -c "$TEST_CN" -a -Z "$KEYCHAIN" 2>/dev/null \ + | awk '/^SHA-1 hash:/ {print $NF}' \ + | while read -r hash; do + sudo security delete-certificate -Z "$hash" "$KEYCHAIN" >/dev/null 2>&1 || true + done + fi + rm -rf "$FIXTURE_DIR" +} +trap cleanup EXIT + +echo "── generating fixture CA ──" +openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ + -keyout "$FIXTURE_DIR/ca.key" \ + -out "$FIXTURE_DIR/ca.pem" \ + -subj "/CN=$TEST_CN" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" >/dev/null 2>&1 +echo " CN: $TEST_CN" +echo + +echo "── trust step (mirrors trust_ca_macos) ──" +sudo security add-trusted-cert -d -r trustRoot -k "$KEYCHAIN" "$FIXTURE_DIR/ca.pem" +if security find-certificate -c "$TEST_CN" "$KEYCHAIN" >/dev/null 2>&1; then + printf " ${GREEN}✓${RESET} test cert found in keychain\n" +else + printf " ${RED}✗${RESET} test cert NOT found after add-trusted-cert\n" + exit 1 +fi +echo + +echo "── untrust step (mirrors untrust_ca_macos) ──" +security find-certificate -c "$TEST_CN" -a -Z "$KEYCHAIN" 2>/dev/null \ + | awk '/^SHA-1 hash:/ {print $NF}' \ + | while read -r hash; do + sudo security delete-certificate -Z "$hash" "$KEYCHAIN" >/dev/null + done +if security find-certificate -c "$TEST_CN" "$KEYCHAIN" >/dev/null 2>&1; then + printf " ${RED}✗${RESET} test cert STILL present after delete (regression)\n" + exit 1 +fi +printf " ${GREEN}✓${RESET} test cert removed from keychain\n" +echo + +printf "${GREEN}all checks passed${RESET}\n" -- 2.34.1 From 679b346246c38bbc51ddd132a99b43efe82e3f81 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 16:38:37 +0300 Subject: [PATCH 029/204] fix: prevent self-referential DNS backup on re-install (#40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: prevent self-referential DNS backup on re-install The install flow previously captured current system DNS servers verbatim into the backup file. If numa was already installed, current DNS was 127.0.0.1, so the "backup" recorded 127.0.0.1 as the "original" — making a subsequent uninstall a no-op self-reference. Reproduced 2026-04-08 during v0.10.0 brew dogfood: after `sudo numa uninstall; sudo /opt/homebrew/bin/numa install`, `sudo numa uninstall` printed `restored DNS for "Wi-Fi" -> 127.0.0.1` because the brew binary's install step had overwritten the backup with the already-stub state. Fix (all three platforms): - macOS/Windows: if the existing backup already contains at least one non-loopback/non-stub upstream, preserve it as-is. If writing a fresh backup, filter loopback/stub addresses first so a capture from already-numa-managed state isn't self-referential. - Linux (resolv.conf fallback path): detect numa-managed or all-loopback resolv.conf content and skip the file copy in that case; preserve an existing useful backup rather than overwriting it. systemd-resolved path is unaffected (uses a drop-in, no backup file). Adds three unit tests for the predicates: macOS HashMap detection, Windows interface filter, and resolv.conf parsing (real upstream, self-referential, numa-marker, systemd stub, mixed). Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: share iter_nameservers helper and reuse resolv.conf content Post-review simplifications on the stale-backup fix: - Extract iter_nameservers(&str) helper used by both parse_resolv_conf and resolv_conf_has_real_upstream. Eliminates the duplicated line-by-line nameserver parsing (findings from reuse review). - install_linux: reuse the already-read resolv.conf content via std::fs::write instead of a second read via std::fs::copy. - install_macos / install_windows: flatten the conditional eprintln pattern — always print a blank line, conditionally print the save message. Equivalent output, less branching. Net −12 lines. All 130 tests still pass, clippy clean. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: drop redundant trim before split_whitespace CI caught `clippy::trim_split_whitespace` on Rust 1.94: `split_whitespace()` already skips leading/trailing whitespace, so `.trim()` first is redundant. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: extract load_backup helper Remove duplicated read+deserialize boilerplate shared by install_macos and install_windows. The two call sites each had an identical 4-line chain of read_to_string().ok().and_then(serde_json::from_str).ok() — collapse into a single generic helper load_backup(). Co-Authored-By: Claude Opus 4.6 (1M context) * Revert "refactor: extract load_backup helper" This reverts commit a54fb99428fb29da6f6ee2cc365bbb97e31cfbb1. * test: drop windows_backup_filters_loopback The test inlined the 3-line filter block from install_windows rather than calling a production helper, so it was testing stdlib Vec::retain + is_loopback_or_stub — both already covered elsewhere. Deleting it removes a test that would silently pass even if install_windows stopped filtering altogether. The predicate logic for macOS-shaped backups stays covered by macos_backup_real_upstream_detection (same inner Vec type). Co-Authored-By: Claude Opus 4.6 (1M context) * test: add windows_backup_filters_loopback unit test The PR description mentioned this test but it was missing from the diff, leaving backup_has_real_upstream_windows untested. Mirrors the shape of macos_backup_real_upstream_detection: empty map → false, all-loopback (127.0.0.1, ::1, 0.0.0.0) → false, one real entry alongside loopback → true. Also relax the cfg gate on backup_has_real_upstream_windows from cfg(windows) to cfg(any(windows, test)) so the test compiles cross-platform, matching how backup_has_real_upstream_macos and the resolv_conf helpers are gated. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/system_dns.rs | 254 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 220 insertions(+), 34 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index fc02393..643b9d0 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -214,7 +214,18 @@ fn discover_linux() -> SystemDnsInfo { } } -/// Parse resolv.conf in a single pass, extracting both the first non-loopback +/// Yield each `nameserver` address from resolv.conf content. No filtering — +/// callers decide what counts as a real upstream. +#[cfg(any(target_os = "linux", test))] +fn iter_nameservers(content: &str) -> impl Iterator { + content.lines().filter_map(|line| { + let mut parts = line.split_whitespace(); + (parts.next() == Some("nameserver")).then_some(())?; + parts.next() + }) +} + +/// Parse resolv.conf in a single pass, extracting the first non-loopback /// nameserver and all search domains. #[cfg(target_os = "linux")] fn parse_resolv_conf(path: &str) -> (Option, Vec) { @@ -222,19 +233,13 @@ fn parse_resolv_conf(path: &str) -> (Option, Vec) { Ok(t) => t, Err(_) => return (None, Vec::new()), }; - let mut upstream = None; + let upstream = iter_nameservers(&text) + .find(|ns| !is_loopback_or_stub(ns)) + .map(str::to_string); let mut search_domains = Vec::new(); for line in text.lines() { let line = line.trim(); - if line.starts_with("nameserver") { - if upstream.is_none() { - if let Some(ns) = line.split_whitespace().nth(1) { - if !is_loopback_or_stub(ns) { - upstream = Some(ns.to_string()); - } - } - } - } else if line.starts_with("search") || line.starts_with("domain") { + if line.starts_with("search") || line.starts_with("domain") { for domain in line.split_whitespace().skip(1) { search_domains.push(domain.to_string()); } @@ -243,6 +248,21 @@ fn parse_resolv_conf(path: &str) -> (Option, Vec) { (upstream, search_domains) } +/// True if the resolv.conf *content* appears to be written by numa itself, +/// or has no real upstream — either way, it's not a safe source of truth +/// for a backup. +#[cfg(any(target_os = "linux", test))] +fn resolv_conf_is_numa_managed(content: &str) -> bool { + content.contains("Generated by Numa") || !resolv_conf_has_real_upstream(content) +} + +/// True if the resolv.conf content has at least one non-loopback, non-stub +/// nameserver. An all-loopback resolv.conf is self-referential. +#[cfg(any(target_os = "linux", test))] +fn resolv_conf_has_real_upstream(content: &str) -> bool { + iter_nameservers(content).any(|ns| !is_loopback_or_stub(ns)) +} + /// Query resolvectl for the real upstream DNS server (e.g. VPC resolver on AWS). #[cfg(target_os = "linux")] fn resolvectl_dns_server() -> Option { @@ -526,9 +546,19 @@ fn enable_dnscache() { .status(); } +/// True if the backup map has at least one real upstream (non-loopback, non-stub). +#[cfg(any(windows, test))] +fn backup_has_real_upstream_windows( + interfaces: &std::collections::HashMap, +) -> bool { + interfaces + .values() + .any(|iface| iface.servers.iter().any(|s| !is_loopback_or_stub(s))) +} + #[cfg(windows)] fn install_windows() -> Result<(), String> { - let interfaces = get_windows_interfaces()?; + let mut interfaces = get_windows_interfaces()?; if interfaces.is_empty() { return Err("no active network interfaces found".to_string()); } @@ -538,9 +568,30 @@ fn install_windows() -> Result<(), String> { std::fs::create_dir_all(parent) .map_err(|e| format!("failed to create {}: {}", parent.display(), e))?; } - let json = serde_json::to_string_pretty(&interfaces) - .map_err(|e| format!("failed to serialize backup: {}", e))?; - std::fs::write(&path, json).map_err(|e| format!("failed to write backup: {}", e))?; + + // Preserve an existing useful backup rather than overwriting it with + // numa-managed state (which would be self-referential after uninstall). + let existing: Option> = + std::fs::read_to_string(&path) + .ok() + .and_then(|json| serde_json::from_str(&json).ok()); + let has_useful_existing = existing + .as_ref() + .map(backup_has_real_upstream_windows) + .unwrap_or(false); + + if has_useful_existing { + eprintln!(" Existing DNS backup preserved at {}", path.display()); + } else { + // Filter loopback/stub addresses before saving so a fresh backup + // captured from already-numa-managed state isn't self-referential. + for iface in interfaces.values_mut() { + iface.servers.retain(|s| !is_loopback_or_stub(s)); + } + let json = serde_json::to_string_pretty(&interfaces) + .map_err(|e| format!("failed to serialize backup: {}", e))?; + std::fs::write(&path, json).map_err(|e| format!("failed to write backup: {}", e))?; + } for name in interfaces.keys() { let status = std::process::Command::new("netsh") @@ -570,7 +621,10 @@ fn install_windows() -> Result<(), String> { let needs_reboot = disable_dnscache()?; register_autostart(); - eprintln!("\n Original DNS saved to {}", path.display()); + eprintln!(); + if !has_useful_existing { + eprintln!(" Original DNS saved to {}", path.display()); + } eprintln!(" Run 'numa uninstall' to restore.\n"); if needs_reboot { eprintln!(" *** Reboot required. Numa will start automatically. ***\n"); @@ -754,27 +808,60 @@ fn get_dns_servers(service: &str) -> Result, String> { } } +/// True if the backup map has at least one real upstream (non-loopback, non-stub). +/// An all-loopback backup is self-referential — restoring it is a no-op. +#[cfg(any(target_os = "macos", test))] +fn backup_has_real_upstream_macos( + servers: &std::collections::HashMap>, +) -> bool { + servers + .values() + .any(|list| list.iter().any(|s| !is_loopback_or_stub(s))) +} + #[cfg(target_os = "macos")] fn install_macos() -> Result<(), String> { use std::collections::HashMap; let services = get_network_services()?; - let mut original: HashMap> = HashMap::new(); - - // Save current DNS for each service - for service in &services { - let servers = get_dns_servers(service)?; - original.insert(service.clone(), servers); - } - - // Save backup let dir = numa_data_dir(); std::fs::create_dir_all(&dir) .map_err(|e| format!("failed to create {}: {}", dir.display(), e))?; - let json = serde_json::to_string_pretty(&original) - .map_err(|e| format!("failed to serialize backup: {}", e))?; - std::fs::write(backup_path(), json).map_err(|e| format!("failed to write backup: {}", e))?; + // If a useful backup already exists (at least one non-loopback upstream), + // preserve it — overwriting would destroy the original DNS state when + // re-installing on top of a numa-managed configuration. + let existing_backup: Option>> = + std::fs::read_to_string(backup_path()) + .ok() + .and_then(|json| serde_json::from_str(&json).ok()); + let has_useful_existing = existing_backup + .as_ref() + .map(backup_has_real_upstream_macos) + .unwrap_or(false); + + if has_useful_existing { + eprintln!( + " Existing DNS backup preserved at {}", + backup_path().display() + ); + } else { + // Capture fresh, filtering out loopback and stub addresses so we + // never record a self-referential backup. + let mut original: HashMap> = HashMap::new(); + for service in &services { + let servers: Vec = get_dns_servers(service)? + .into_iter() + .filter(|s| !is_loopback_or_stub(s)) + .collect(); + original.insert(service.clone(), servers); + } + + let json = serde_json::to_string_pretty(&original) + .map_err(|e| format!("failed to serialize backup: {}", e))?; + std::fs::write(backup_path(), json) + .map_err(|e| format!("failed to write backup: {}", e))?; + } // Set DNS to 127.0.0.1 and add "numa" search domain for each service for service in &services { @@ -795,7 +882,10 @@ fn install_macos() -> Result<(), String> { .status(); } - eprintln!("\n Original DNS saved to {}", backup_path().display()); + eprintln!(); + if !has_useful_existing { + eprintln!(" Original DNS saved to {}", backup_path().display()); + } eprintln!(" Run 'sudo numa uninstall' to restore.\n"); Ok(()) @@ -1132,11 +1222,31 @@ fn install_linux() -> Result<(), String> { .map_err(|e| format!("failed to create {}: {}", parent.display(), e))?; } - // Back up current resolv.conf (ignore NotFound) - match std::fs::copy(resolv, &backup) { - Ok(_) => eprintln!(" Saved /etc/resolv.conf to {}", backup.display()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(format!("failed to backup /etc/resolv.conf: {}", e)), + // Back up current resolv.conf, but never overwrite a useful existing + // backup with a numa-managed file — that would leave uninstall with + // nothing to restore to. + let current = std::fs::read_to_string(resolv).ok(); + let current_is_numa_managed = current + .as_deref() + .map(resolv_conf_is_numa_managed) + .unwrap_or(false); + let existing_backup_is_useful = std::fs::read_to_string(&backup) + .ok() + .as_deref() + .map(resolv_conf_has_real_upstream) + .unwrap_or(false); + + if existing_backup_is_useful { + eprintln!( + " Existing resolv.conf backup preserved at {}", + backup.display() + ); + } else if current_is_numa_managed { + eprintln!(" warning: /etc/resolv.conf is already numa-managed; no fresh backup written"); + } else if let Some(content) = current.as_deref() { + std::fs::write(&backup, content) + .map_err(|e| format!("failed to backup /etc/resolv.conf: {}", e))?; + eprintln!(" Saved /etc/resolv.conf to {}", backup.display()); } if resolv @@ -1539,6 +1649,82 @@ Wireless LAN adapter Wi-Fi: assert!(!result.contains("{{exe_path}}")); } + #[test] + fn macos_backup_real_upstream_detection() { + use std::collections::HashMap; + let mut map: HashMap> = HashMap::new(); + + // Empty backup → no real upstream + assert!(!backup_has_real_upstream_macos(&map)); + + // All-loopback backup → still no real upstream (the bug case) + map.insert("Wi-Fi".into(), vec!["127.0.0.1".into()]); + map.insert("Ethernet".into(), vec!["::1".into()]); + assert!(!backup_has_real_upstream_macos(&map)); + + // One real entry → useful + map.insert("Tailscale".into(), vec!["192.168.1.1".into()]); + assert!(backup_has_real_upstream_macos(&map)); + } + + #[test] + fn windows_backup_filters_loopback() { + use std::collections::HashMap; + let mut map: HashMap = HashMap::new(); + + // Empty backup → no real upstream + assert!(!backup_has_real_upstream_windows(&map)); + + // All-loopback backup → still no real upstream (the bug case) + map.insert( + "Wi-Fi".into(), + WindowsInterfaceDns { + dhcp: false, + servers: vec!["127.0.0.1".into()], + }, + ); + map.insert( + "Ethernet".into(), + WindowsInterfaceDns { + dhcp: false, + servers: vec!["::1".into(), "0.0.0.0".into()], + }, + ); + assert!(!backup_has_real_upstream_windows(&map)); + + // One real entry alongside loopback → useful + map.insert( + "Ethernet 2".into(), + WindowsInterfaceDns { + dhcp: false, + servers: vec!["192.168.1.1".into()], + }, + ); + assert!(backup_has_real_upstream_windows(&map)); + } + + #[test] + fn resolv_conf_real_upstream_detection() { + let real = "nameserver 192.168.1.1\nsearch lan\n"; + assert!(resolv_conf_has_real_upstream(real)); + assert!(!resolv_conf_is_numa_managed(real)); + + let self_ref = "nameserver 127.0.0.1\nsearch numa\n"; + assert!(!resolv_conf_has_real_upstream(self_ref)); + assert!(resolv_conf_is_numa_managed(self_ref)); + + let numa_marker = + "# Generated by Numa — run 'sudo numa uninstall' to restore\nnameserver 127.0.0.1\nsearch numa\n"; + assert!(resolv_conf_is_numa_managed(numa_marker)); + + let systemd_stub = "nameserver 127.0.0.53\noptions edns0\n"; + assert!(!resolv_conf_has_real_upstream(systemd_stub)); + + let mixed = "nameserver 127.0.0.1\nnameserver 1.1.1.1\n"; + assert!(resolv_conf_has_real_upstream(mixed)); + assert!(!resolv_conf_is_numa_managed(mixed)); + } + #[test] fn parse_ipconfig_skips_disconnected() { let sample = "\ -- 2.34.1 From bf5565ac262e65322fc0eafa66b510cf7d8110ad Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 16:54:21 +0300 Subject: [PATCH 030/204] fix: macOS use launchctl bootout/bootstrap instead of deprecated load (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deprecated `launchctl load -w` returns exit code 0 even when it cannot actually reload a service whose label is already in launchd's in-memory state. It prints `Load failed: 5: Input/output error` to stderr but exits 0, so the install path interprets it as success and continues — silently leaving the running daemon on whatever binary was first loaded, even though the on-disk plist now points elsewhere. The consequence: every macOS user running `brew upgrade numa` rewrites the plist to point at the new binary, but launchctl never actually loads it. They think they upgraded; they're still running the old version. Neither #41 (cross-platform CA trust) nor #40 (self-referential backup) would actually take effect for them until they manually run: sudo launchctl bootout system /Library/LaunchDaemons/com.numa.dns.plist sudo launchctl bootstrap system /Library/LaunchDaemons/com.numa.dns.plist The fix uses the modern API symmetrically across all three call sites: - install_service_macos: bootout (best-effort cleanup, no-op on first install) → bootstrap → wait for readiness → configure DNS - install_service_macos rollback path: bootout instead of `unload` - uninstall_service_macos: bootout BEFORE remove_file (the modern API needs the plist file path as the specifier; doing it after remove would leave the service in memory until reboot) No new tests — this is a shell-call substitution with no logic to unit-test. Verified manually on macOS: `sudo numa install` no longer prints `Load failed`, and the daemon is correctly running the binary the plist points at. Co-authored-by: Claude Opus 4.6 (1M context) --- src/system_dns.rs | 46 +++++++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index 643b9d0..b24b3ad 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -1080,14 +1080,23 @@ fn install_service_macos() -> Result<(), String> { std::fs::write(PLIST_DEST, plist) .map_err(|e| format!("failed to write {}: {}", PLIST_DEST, e))?; - // Load the service first so numa is listening before DNS redirect + // Modern launchctl API: explicitly tear down any existing in-memory + // state, then bootstrap fresh from the on-disk plist. The deprecated + // `load -w` returns exit 0 even when it cannot actually reload (label + // already in launchd state), silently leaving the daemon running a + // stale binary path after `numa install` rewrites the plist on disk — + // which is exactly what `brew upgrade numa` does. + let _ = std::process::Command::new("launchctl") + .args(["bootout", "system", PLIST_DEST]) + .status(); + let status = std::process::Command::new("launchctl") - .args(["load", "-w", PLIST_DEST]) + .args(["bootstrap", "system", PLIST_DEST]) .status() .map_err(|e| format!("failed to run launchctl: {}", e))?; if !status.success() { - return Err("launchctl load failed".to_string()); + return Err("launchctl bootstrap failed".to_string()); } // Wait for numa to be ready before redirecting DNS @@ -1100,7 +1109,7 @@ fn install_service_macos() -> Result<(), String> { if !api_up { // Service failed to start — don't redirect DNS to a dead endpoint let _ = std::process::Command::new("launchctl") - .args(["unload", PLIST_DEST]) + .args(["bootout", "system", PLIST_DEST]) .status(); return Err( "numa service did not start (port 53 may be in use). Service unloaded.".to_string(), @@ -1128,22 +1137,25 @@ fn uninstall_service_macos() -> Result<(), String> { eprintln!(" warning: failed to restore system DNS: {}", e); } - // Remove plist first so service won't restart on boot even if unload fails - if let Err(e) = std::fs::remove_file(PLIST_DEST) { - if e.kind() != std::io::ErrorKind::NotFound { - return Err(format!("failed to remove {}: {}", PLIST_DEST, e)); + // Bootout the service from launchd's in-memory state BEFORE removing + // the plist. The modern API needs the file path as the specifier; + // doing this in the wrong order would leave the service loaded in + // memory until reboot. (Deprecated `unload -w` had the same issue.) + let bootout_status = std::process::Command::new("launchctl") + .args(["bootout", "system", PLIST_DEST]) + .status(); + if let Ok(s) = bootout_status { + if !s.success() { + eprintln!( + " warning: launchctl bootout returned non-zero (service may not have been loaded)" + ); } } - // Unload the service - let status = std::process::Command::new("launchctl") - .args(["unload", "-w", PLIST_DEST]) - .status(); - if let Ok(s) = status { - if !s.success() { - eprintln!( - " warning: launchctl unload returned non-zero (service may still be running)" - ); + // Remove plist so the service won't restart on boot + if let Err(e) = std::fs::remove_file(PLIST_DEST) { + if e.kind() != std::io::ErrorKind::NotFound { + return Err(format!("failed to remove {}: {}", PLIST_DEST, e)); } } -- 2.34.1 From 79ecb73d8793a23a5fd15425d5769eaebc1fc159 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 18:00:27 +0300 Subject: [PATCH 031/204] fix: use FHS-compliant /var/lib/numa as Linux data dir default (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: use FHS-compliant /var/lib/numa as Linux data dir default numa's default system-wide data directory was hardcoded to /usr/local/var/numa for all Unix platforms. This is the right path on macOS (Homebrew prefix convention) but non-FHS on Linux, where Arch / Fedora / Debian / etc. expect persistent state under /var/lib/. The mismatch was invisible to existing users (numa creates the dir silently on first run) but immediately surfaces when packaging for a distro — see PR #33 (community contribution to add an Arch AUR package) which had to add fragile sed-based path patching at PKGBUILD build time. The fix moves the path decision into a small helper: - daemon_data_dir() — cfg-gated platform dispatch (linux/macos) - resolve_linux_data_dir() — pure function, takes "does X exist?" as parameters, returns the right path Linux behavior: - Fresh install → /var/lib/numa (FHS) - Upgrading from pre-v0.10.1 install → /usr/local/var/numa (legacy) - Both paths exist → /var/lib/numa (FHS wins) The legacy fallback is critical: existing v0.10.0 Linux users have their CA cert + services.json under /usr/local/var/numa. Returning the new path unconditionally would cause CA regeneration on upgrade, breaking every browser that had trusted the previous CA. The fallback is checked at startup via std::path::Path::exists, so the upgrade is seamless and zero-config. macOS behavior is unchanged — /usr/local/var/numa is still correct because Homebrew's prefix is /usr/local. Test coverage: - resolve_linux_data_dir is a pure function gated cfg(any(linux,test)) so the same code path is unit-tested on every platform's CI run. - Four tests cover all combinations of (legacy_exists, fhs_exists), asserting the migration logic stays correct under future edits. The default config in numa.toml is also updated to document the new per-platform default paths. Co-Authored-By: Claude Opus 4.6 (1M context) * test: end-to-end FHS path verification + simplify cleanup Two related changes from a /simplify pass and a follow-up testing finalization: 1. lib.rs cleanup (no behavior change): - Drop FHS_LINUX_DATA_DIR and LEGACY_LINUX_DATA_DIR consts. Both were used in only 4 places total and the unit tests already bypassed them with string literals, so they were over-engineering. Inline the strings in daemon_data_dir() and resolve_linux_data_dir(). - Trim narrating doc/comments on the helper and the test bodies. Keep only the non-obvious WHY (the macOS Homebrew note and the migration-keeps-legacy rationale). 2. tests/docker/smoke-arch.sh: - Cherry-picked the previously-uncommitted Arch compatibility smoke test from feat/smoke-arch. - Removed the [server] data_dir = "/tmp/numa-smoke" override from the test config so the script now exercises the DEFAULT data dir code path — which is exactly what the FHS fix touches. - Added a path assertion after the dig succeeds: verify that /var/lib/numa/ca.pem exists (FHS) and /usr/local/var/numa is absent (no accidental dual-creation on a fresh install). Verified end-to-end on archlinux:latest (Apple Silicon, Rosetta): ── building + running numa on archlinux:latest ── ── cargo build --release --locked ── Finished `release` profile [optimized] target(s) in 24.02s ── dig @127.0.0.1 -p 5354 google.com A ── 142.251.38.206 ── FHS path check ── ✓ CA cert at /var/lib/numa/ca.pem (FHS path) ✓ legacy path /usr/local/var/numa absent (fresh install used FHS) ── smoke-arch passed ── This closes the testing gap where the unit tests covered the path-decision LOGIC in isolation but nothing exercised the live wiring on a real Linux filesystem. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- numa.toml | 11 +-- src/lib.rs | 67 ++++++++++++++++- tests/docker/smoke-arch.sh | 147 +++++++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 8 deletions(-) create mode 100755 tests/docker/smoke-arch.sh diff --git a/numa.toml b/numa.toml index 35d92de..77ba231 100644 --- a/numa.toml +++ b/numa.toml @@ -2,11 +2,12 @@ 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. +# data_dir = "/var/lib/numa" # where numa stores TLS CA and cert material + # Defaults: /var/lib/numa on linux (FHS), + # /usr/local/var/numa on macos (homebrew prefix), + # %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/lib.rs b/src/lib.rs index 347e72f..6455506 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,7 +26,10 @@ pub type Error = Box; pub type Result = std::result::Result; /// Shared config directory for persistent data (services.json, etc). -/// Unix: ~/.config/numa/ (or /usr/local/var/numa/ when running as root daemon) +/// Unix users: ~/.config/numa/ +/// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa +/// if a pre-v0.10.1 install already lives there. +/// macOS root daemon: /usr/local/var/numa (Homebrew prefix) /// Windows: %APPDATA%\numa pub fn config_dir() -> std::path::PathBuf { #[cfg(windows)] @@ -63,13 +66,15 @@ fn config_dir_unix() -> std::path::PathBuf { } // Running as root daemon (launchd/systemd) — use system-wide path - std::path::PathBuf::from("/usr/local/var/numa") + daemon_data_dir() } /// 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 +/// Linux: /var/lib/numa (FHS) — falls back to /usr/local/var/numa if a +/// pre-v0.10.1 install already has data there. +/// macOS: /usr/local/var/numa (Homebrew prefix) /// Windows: %PROGRAMDATA%\numa pub fn data_dir() -> std::path::PathBuf { #[cfg(windows)] @@ -81,6 +86,62 @@ pub fn data_dir() -> std::path::PathBuf { } #[cfg(not(windows))] { + daemon_data_dir() + } +} + +/// Resolve the system-wide data directory for the running platform. +/// Honors backwards compatibility with pre-v0.10.1 installs that still +/// have their CA cert + services.json under `/usr/local/var/numa`. +#[cfg(not(windows))] +fn daemon_data_dir() -> std::path::PathBuf { + #[cfg(target_os = "linux")] + { + std::path::PathBuf::from(resolve_linux_data_dir( + std::path::Path::new("/usr/local/var/numa").exists(), + std::path::Path::new("/var/lib/numa").exists(), + )) + } + #[cfg(target_os = "macos")] + { + // macOS uses the Homebrew prefix convention; no FHS migration needed. std::path::PathBuf::from("/usr/local/var/numa") } } + +/// Extracted as a pure function so the migration logic is unit-testable +/// without touching the real filesystem. +#[cfg(any(target_os = "linux", test))] +fn resolve_linux_data_dir(legacy_exists: bool, fhs_exists: bool) -> &'static str { + if legacy_exists && !fhs_exists { + "/usr/local/var/numa" + } else { + "/var/lib/numa" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn linux_data_dir_fresh_install_uses_fhs() { + assert_eq!(resolve_linux_data_dir(false, false), "/var/lib/numa"); + } + + #[test] + fn linux_data_dir_upgrading_install_keeps_legacy() { + // Migration must keep legacy so the user doesn't lose their CA on upgrade. + assert_eq!(resolve_linux_data_dir(true, false), "/usr/local/var/numa"); + } + + #[test] + fn linux_data_dir_after_migration_uses_fhs() { + assert_eq!(resolve_linux_data_dir(true, true), "/var/lib/numa"); + } + + #[test] + fn linux_data_dir_only_fhs_uses_fhs() { + assert_eq!(resolve_linux_data_dir(false, true), "/var/lib/numa"); + } +} diff --git a/tests/docker/smoke-arch.sh b/tests/docker/smoke-arch.sh new file mode 100755 index 0000000..12e779e --- /dev/null +++ b/tests/docker/smoke-arch.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# +# Arch Linux compatibility smoke test. +# +# Builds numa from source inside an archlinux:latest container, runs it +# in forward mode on port 5354, and verifies a single DNS query returns +# an A record. Validates the "Arch compatible" claim end-to-end before +# release announcements. +# +# Dogfooding: the test numa forwards to the host's running numa via +# host.docker.internal (Docker Desktop's host gateway). This avoids the +# Docker NAT/UDP issues with public resolvers and exercises the realistic +# numa-on-numa shape. Requires the host to be running numa on port 53. +# +# First run is slow (~8-12 min): image pull + pacman + cold cargo build. +# No caching across runs. +# +# Requirements: docker, host running numa on 0.0.0.0:53 +# Usage: ./tests/docker/smoke-arch.sh + +set -euo pipefail + +cd "$(dirname "$0")/../.." + +GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m" + +# Precondition: the test numa-on-arch forwards to the host numa as its +# upstream (dogfood pattern). Fail fast with a clear error if there is +# no working DNS on the host, rather than letting the dig inside the +# container time out with "deadline has elapsed". +if ! dig @127.0.0.1 google.com A +short +time=1 +tries=1 >/dev/null 2>&1; then + printf "${RED}error:${RESET} host numa is not answering on 127.0.0.1:53\n" >&2 + echo " This test forwards to the host numa via host.docker.internal." >&2 + echo " Start numa on the host first (sudo numa install), then rerun." >&2 + exit 1 +fi + +echo "── building + running numa on archlinux:latest ──" +echo " (first run is slow: image pull + pacman + cold cargo build, ~8-12 min)" +echo + +docker run --rm \ + --platform linux/amd64 \ + --security-opt seccomp=unconfined \ + -v "$PWD:/src:ro" \ + -v numa-arch-cargo:/root/.cargo \ + -v numa-arch-target:/work/target \ + archlinux:latest bash -c ' + set -e + + # pacman 7+ filters syscalls in its own sandbox; disable for Rosetta/qemu + sed -i "s/^#DisableSandboxSyscalls/DisableSandboxSyscalls/" /etc/pacman.conf + + echo "── pacman: installing build + runtime deps ──" + pacman -Sy --noconfirm --needed rust gcc pkgconf cmake make perl bind 2>&1 | tail -3 + echo + + # Copy source to a writable workdir, skipping target/ + .git so we + # do not pull in the host (macOS) build artifacts. + mkdir -p /work + tar -C /src --exclude=./target --exclude=./.git -cf - . | tar -C /work -xf - + cd /work + + echo "── cargo build --release --locked ──" + cargo build --release --locked 2>&1 | tail -5 + echo + + # Dogfood: forward to the host numa via host.docker.internal. + # numa parses upstream.address as a literal SocketAddr, so we resolve + # the hostname to an IPv4 address first (force v4 — getent hosts may + # return IPv6 first, and IPv6 addresses need bracketed addr:port form). + HOST_IP=$(getent ahostsv4 host.docker.internal | awk "/STREAM/ {print \$1; exit}") + if [ -z "$HOST_IP" ]; then + echo " ✗ could not resolve host.docker.internal to IPv4 (not on Docker Desktop?)" + exit 1 + fi + echo "── starting numa on :5354 (forward to host numa at $HOST_IP:53) ──" + # Intentionally NOT setting [server] data_dir — we want to exercise the + # default code path (data_dir() → daemon_data_dir() → /var/lib/numa) so + # the FHS-path assertion below verifies the live wiring, not just the + # unit-tested helper. + cat > /tmp/numa.toml < /tmp/numa.log 2>&1 & + NUMA_PID=$! + + # Poll for readiness — numa is ready when it answers a query + READY=0 + for i in 1 2 3 4 5 6 7 8; do + sleep 1 + if dig @127.0.0.1 -p 5354 google.com A +short +time=1 +tries=1 2>/dev/null \ + | grep -qE "^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$"; then + READY=1 + break + fi + done + + if [ "$READY" -ne 1 ]; then + echo " ✗ numa did not return an A record after 8s" + echo " numa log:" + cat /tmp/numa.log + kill $NUMA_PID 2>/dev/null || true + exit 1 + fi + + echo "── dig @127.0.0.1 -p 5354 google.com A ──" + ANSWER=$(dig @127.0.0.1 -p 5354 google.com A +short +time=2 +tries=1) + echo "$ANSWER" | sed "s/^/ /" + + kill $NUMA_PID 2>/dev/null || true + + # FHS path assertion: the default data dir on Linux must be /var/lib/numa + # (not the legacy /usr/local/var/numa). The CA cert generated at startup + # is the canonical proof that numa wrote to the right place. + echo + echo "── FHS path check ──" + if [ -f /var/lib/numa/ca.pem ]; then + echo " ✓ CA cert at /var/lib/numa/ca.pem (FHS path)" + else + echo " ✗ CA cert NOT at /var/lib/numa/ca.pem" + echo " ls /var/lib/numa/:" + ls -la /var/lib/numa/ 2>&1 | sed "s/^/ /" + echo " ls /usr/local/var/numa/:" + ls -la /usr/local/var/numa/ 2>&1 | sed "s/^/ /" + exit 1 + fi + if [ -e /usr/local/var/numa ]; then + echo " ✗ legacy path /usr/local/var/numa unexpectedly exists on a fresh container" + exit 1 + fi + echo " ✓ legacy path /usr/local/var/numa absent (fresh install used FHS)" + + echo + echo " ✓ numa built, ran, answered a forward query, and used the FHS data dir on Arch" +' + +echo +printf "${GREEN}── smoke-arch passed ──${RESET}\n" -- 2.34.1 From b2ed2e6aec43b30ec26c477d63d32c0170e58fb3 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 18:05:00 +0300 Subject: [PATCH 032/204] chore: bump version to 0.10.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8750934..c36808c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1143,7 +1143,7 @@ dependencies = [ [[package]] name = "numa" -version = "0.10.0" +version = "0.10.1" dependencies = [ "arc-swap", "axum", diff --git a/Cargo.toml b/Cargo.toml index f0278b9..8ca29d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "numa" -version = "0.10.0" +version = "0.10.1" authors = ["razvandimescu "] edition = "2021" description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS" -- 2.34.1 From 27dfaab36019d30f452332bbc56494cfb7a40c4b Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 8 Apr 2026 18:26:21 +0300 Subject: [PATCH 033/204] ci: pass PAT to action-gh-release so release events propagate (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub Actions deliberately does not propagate workflow events triggered by the default GITHUB_TOKEN — a safety feature against infinite loops. softprops/action-gh-release falls back to GITHUB_TOKEN when no `token` is supplied, so the resulting `release: published` event was silently swallowed and never reached homebrew-bump.yml. Discovered shipping v0.10.1: tag pushed cleanly, crates.io published cleanly, GitHub release page created cleanly, but the brew tap never auto-bumped. Had to trigger homebrew-bump.yml manually via workflow_dispatch. Fix: pass HOMEBREW_TAP_GITHUB_TOKEN explicitly. This is already a PAT (used by homebrew-bump.yml to push cross-repo to razvandimescu/ homebrew-tap), so reusing it keeps the secret surface flat. PAT-authored release events are the documented escape hatch from the GITHUB_TOKEN no-propagation rule. Applies to v0.10.2+. v0.10.1 was bumped manually. Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/release.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 057a8d0..3396667 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,6 +103,14 @@ jobs: - name: Create Release uses: softprops/action-gh-release@v2 with: + # Use a PAT (not the default GITHUB_TOKEN) so the resulting + # `release: published` event propagates to downstream workflows + # like homebrew-bump.yml. Events triggered by GITHUB_TOKEN are + # deliberately not propagated by GitHub Actions to prevent + # infinite loops; PAT-authored events are the documented escape + # hatch. Reusing HOMEBREW_TAP_GITHUB_TOKEN (already a PAT used + # by homebrew-bump.yml itself) keeps the secret surface flat. + token: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} generate_release_notes: true files: | *.tar.gz -- 2.34.1 From a6f23a5ddbeecc5da0d003caa6e33114572f12cd Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 9 Apr 2026 15:03:58 +0300 Subject: [PATCH 034/204] fix: advisory + exit(1) when port 53 is already in use (#45) (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: advisory + exit(1) when port 53 is already in use (#45) Detect AddrInUse on bind, print a human-readable diagnostic explaining systemd-resolved / Dnscache as the likely cause and offer two concrete fixes (sudo numa install, or bind_addr on a non-privileged port), then exit(1) instead of surfacing a raw OS error. Adds tests/docker/smoke-port53.sh: end-to-end Docker test that pre-binds port 53 with a Python UDP socket and asserts the advisory + exit code. Co-Authored-By: Claude Opus 4.6 * refactor: collapse port53 advisory to single flat path The per-platform cause sentences were cosmetic — they didn't change the user's actions (install, or bind_addr on a non-privileged port), but they introduced duplicated "another process..." strings, a dead-from-CI branch (is_systemd_resolved_active() == true is never reached by any test), and a pub visibility bump on is_systemd_resolved_active for a single caller. Replace with one flat format! whose cause line mentions both systemd-resolved and the Windows DNS Client inline. The existing smoke test now exercises 100% of the function. is_systemd_resolved_active reverts to private. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- src/main.rs | 17 ++++- src/system_dns.rs | 43 +++++++++++ tests/docker/smoke-port53.sh | 138 +++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100755 tests/docker/smoke-port53.sh diff --git a/src/main.rs b/src/main.rs index af0fb3a..20f0dba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -231,8 +231,23 @@ async fn main() -> numa::Result<()> { None }; + let socket = match UdpSocket::bind(&config.server.bind_addr).await { + Ok(s) => s, + Err(e) + if e.kind() == std::io::ErrorKind::AddrInUse + && numa::system_dns::is_port_53(&config.server.bind_addr) => + { + eprint!( + "{}", + numa::system_dns::port53_conflict_advisory(&config.server.bind_addr) + ); + std::process::exit(1); + } + Err(e) => return Err(e.into()), + }; + let ctx = Arc::new(ServerCtx { - socket: UdpSocket::bind(&config.server.bind_addr).await?, + socket, zone_map: build_zone_map(&config.zones)?, cache: RwLock::new(DnsCache::new( config.cache.max_entries, diff --git a/src/system_dns.rs b/src/system_dns.rs index b24b3ad..fcb17fa 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -46,6 +46,49 @@ pub fn discover_system_dns() -> SystemDnsInfo { } } +/// True if `bind_addr` targets DNS port 53. Used to scope the port-53 +/// conflict advisory — we only want to print the systemd-resolved / +/// Dnscache hint when the user is actually trying to bind the DNS port. +pub fn is_port_53(bind_addr: &str) -> bool { + bind_addr + .parse::() + .map(|s| s.port() == 53) + .unwrap_or(false) +} + +/// Human-readable diagnostic for port-53 bind conflicts. Offers two +/// concrete fixes: install Numa as the system resolver, or bind to a +/// non-privileged port. +pub fn port53_conflict_advisory(bind_addr: &str) -> String { + let o = "\x1b[1;38;2;192;98;58m"; // bold orange + let r = "\x1b[0m"; + format!( + " +{o}Numa{r} — cannot bind to {bind_addr}: port 53 is already in use. + + Another process is already bound to port 53. On Linux this is + typically systemd-resolved; on Windows, the DNS Client service. + + Fix — pick one: + + 1. Install Numa as the system resolver (frees port 53): + + sudo numa install (on Windows, run as Administrator) + + 2. Run on a non-privileged port for testing. + Create ~/.config/numa/numa.toml with: + + [server] + bind_addr = \"127.0.0.1:5354\" + api_port = 5380 + + Then run: numa + Test with: dig @127.0.0.1 -p 5354 example.com + +" + ) +} + #[cfg(target_os = "macos")] fn discover_macos() -> SystemDnsInfo { use log::{debug, warn}; diff --git a/tests/docker/smoke-port53.sh b/tests/docker/smoke-port53.sh new file mode 100755 index 0000000..d5c67ae --- /dev/null +++ b/tests/docker/smoke-port53.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# +# Port-53 conflict advisory integration test. +# +# Builds numa from source inside a debian:bookworm container, pre-binds +# port 53 with a UDP socket, then runs numa bare (default bind_addr +# 0.0.0.0:53). Verifies: +# - process exits with code 1 +# - stderr contains the advisory ("cannot bind to") +# - stderr contains both fix suggestions ("numa install", "bind_addr") +# +# This is the end-to-end test for the fix in: +# src/main.rs — AddrInUse match arm → eprint advisory + process::exit(1) +# +# No systemd-resolved needed — the conflict is simulated by a Python +# UDP socket held open before numa starts. +# +# Requirements: docker +# Usage: ./tests/docker/smoke-port53.sh + +set -euo pipefail + +cd "$(dirname "$0")/../.." + +GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m" + +pass() { printf " ${GREEN}✓${RESET} %s\n" "$1"; } +fail() { printf " ${RED}✗${RESET} %s\n" "$1"; printf " %s\n" "$2"; FAILED=$((FAILED+1)); } +FAILED=0 + +echo "── smoke-port53: building + testing numa on debian:bookworm ──" +echo " (first run is slow: image pull + cold cargo build, ~5-8 min)" +echo + +OUTPUT=$(docker run --rm \ + --platform linux/amd64 \ + -v "$PWD:/src:ro" \ + -v numa-port53-cargo:/root/.cargo \ + -v numa-port53-target:/work/target \ + debian:bookworm bash -c ' +set -e + +apt-get update -qq && apt-get install -y -qq curl build-essential python3 2>&1 | tail -3 + +# Install rustup if not already in the cargo cache volume +if ! command -v cargo &>/dev/null; then + curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --quiet +fi +. "$HOME/.cargo/env" + +# Copy source to a writable workdir +mkdir -p /work +tar -C /src --exclude=./target --exclude=./.git -cf - . | tar -C /work -xf - +cd /work + +echo "── cargo build --release --locked ──" +cargo build --release --locked 2>&1 | tail -5 +echo + +# Write the holder script to a file to avoid quoting hell. +# Holds port 53 until killed — no sleep race. +cat > /tmp/hold53.py << '"'"'PYEOF'"'"' +import socket, signal +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0) +s.bind(("", 53)) +signal.pause() +PYEOF + +python3 /tmp/hold53.py & +HOLDER_PID=$! + +# Verify the holder is actually up before proceeding +sleep 0.3 +if ! kill -0 $HOLDER_PID 2>/dev/null; then + echo "holder_failed=1" + exit 1 +fi + +echo "── running numa with port 53 already bound ──" +# timeout 5: guards against numa not exiting (advisory not fired, bug present) +# Capture stderr to a file so the exit code is not clobbered by || or $() +set +e +timeout 5 ./target/release/numa > /tmp/numa-stderr.txt 2>&1 +EXIT_CODE=$? +set -e +STDERR=$(cat /tmp/numa-stderr.txt) + +kill $HOLDER_PID 2>/dev/null || true + +echo "exit_code=$EXIT_CODE" +printf "%s" "$STDERR" | sed "s/^/ numa: /" +' 2>&1) + +echo "$OUTPUT" + +echo +echo "── assertions ──" + +if echo "$OUTPUT" | grep -q "holder_failed=1"; then + echo " SETUP FAILED: could not pre-bind port 53 inside container" + exit 1 +fi + +EXIT_CODE=$(echo "$OUTPUT" | grep '^exit_code=' | cut -d= -f2) + +if [ "${EXIT_CODE:-}" = "1" ]; then + pass "exits with code 1" +else + fail "exits with code 1" "got: exit_code=${EXIT_CODE:-}" +fi + +if echo "$OUTPUT" | grep -q "cannot bind to"; then + pass "advisory printed to stderr" +else + fail "advisory printed to stderr" "stderr did not contain 'cannot bind to'" +fi + +if echo "$OUTPUT" | grep -q "numa install"; then + pass "advisory offers 'sudo numa install'" +else + fail "advisory offers 'sudo numa install'" "not found in output" +fi + +if echo "$OUTPUT" | grep -q "bind_addr"; then + pass "advisory offers non-privileged port alternative" +else + fail "advisory offers non-privileged port alternative" "'bind_addr' not found in output" +fi + +echo +if [ "$FAILED" -eq 0 ]; then + printf "${GREEN}── smoke-port53 passed ──${RESET}\n" + exit 0 +else + printf "${RED}── smoke-port53 failed ($FAILED assertion(s)) ──${RESET}\n" + exit 1 +fi -- 2.34.1 From fab8b698d812ab78f63a9448f1dd02c687fdc416 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 9 Apr 2026 16:27:08 +0300 Subject: [PATCH 035/204] fix: human-readable advisories for TLS data_dir + port-53 EACCES (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: human-readable advisory when TLS data_dir is not writable When numa runs as non-root on a system with a privileged default data_dir (e.g. /usr/local/var/numa on macOS), TLS CA setup fails with a raw "Permission denied (os error 13)" and HTTPS proxy is silently disabled. The user sees a cryptic warning with no path forward. Detect std::io::ErrorKind::PermissionDenied on the tls error, print a diagnostic naming the data_dir and offering two fixes (install as system resolver, or point data_dir at a writable path), and keep the graceful-degradation behavior — DNS resolution and plain-HTTP proxy continue to work without HTTPS. All other TLS setup errors fall through to the existing log::warn!. Co-Authored-By: Claude Opus 4.6 * fix: port-53 advisory also handles EACCES (non-root privileged bind) The original port-53 match arm only caught EADDRINUSE, so a fresh non-root user on macOS/Linux hitting EACCES when trying to bind a privileged port saw the raw OS error instead of the advisory. Collapse the scoping helper and the advisory into a single `try_port53_advisory(bind_addr, &io::Error) -> Option` that returns the formatted diagnostic when both the port is 53 and the error kind is one we can speak to (AddrInUse or PermissionDenied), and `None` otherwise. The two failure modes share one body with a cause-sentence variant — no duplicated fix text. Caller becomes a plain if-let: no match guard, no separate is_port_53 helper exposed on the public API. is_port_53 goes back to private. Unit tests cover all branches: AddrInUse, PermissionDenied, non-53 bind_addr, unrelated ErrorKind, and malformed bind_addr. Co-Authored-By: Claude Opus 4.6 * refactor: move TLS error classification into tls module main.rs no longer downcasts a boxed error to figure out whether it's a permission-denied case. tls::try_data_dir_advisory(&err, &dir) encapsulates the downcast + kind match and returns Some(advisory) or None, mirroring system_dns::try_port53_advisory. main.rs becomes a plain if-let, symmetric with the port-53 path. Trim the docstrings on both advisory functions: they were narrating the implementation (errno mapping) instead of stating the contract. Add unit tests for try_data_dir_advisory covering PermissionDenied, other io::ErrorKind, and non-io errors. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- src/main.rs | 24 +++++++------ src/system_dns.rs | 88 +++++++++++++++++++++++++++++++++++++---------- src/tls.rs | 64 ++++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 30 deletions(-) diff --git a/src/main.rs b/src/main.rs index 20f0dba..b335016 100644 --- a/src/main.rs +++ b/src/main.rs @@ -223,7 +223,11 @@ async fn main() -> numa::Result<()> { ) { Ok(tls_config) => Some(ArcSwap::from(tls_config)), Err(e) => { - log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e); + if let Some(advisory) = numa::tls::try_data_dir_advisory(&e, &resolved_data_dir) { + eprint!("{}", advisory); + } else { + log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e); + } None } } @@ -233,17 +237,15 @@ async fn main() -> numa::Result<()> { let socket = match UdpSocket::bind(&config.server.bind_addr).await { Ok(s) => s, - Err(e) - if e.kind() == std::io::ErrorKind::AddrInUse - && numa::system_dns::is_port_53(&config.server.bind_addr) => - { - eprint!( - "{}", - numa::system_dns::port53_conflict_advisory(&config.server.bind_addr) - ); - std::process::exit(1); + Err(e) => { + if let Some(advisory) = + numa::system_dns::try_port53_advisory(&config.server.bind_addr, &e) + { + eprint!("{}", advisory); + std::process::exit(1); + } + return Err(e.into()); } - Err(e) => return Err(e.into()), }; let ctx = Arc::new(ServerCtx { diff --git a/src/system_dns.rs b/src/system_dns.rs index fcb17fa..f77d820 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -46,28 +46,32 @@ pub fn discover_system_dns() -> SystemDnsInfo { } } -/// True if `bind_addr` targets DNS port 53. Used to scope the port-53 -/// conflict advisory — we only want to print the systemd-resolved / -/// Dnscache hint when the user is actually trying to bind the DNS port. -pub fn is_port_53(bind_addr: &str) -> bool { - bind_addr - .parse::() - .map(|s| s.port() == 53) - .unwrap_or(false) -} - -/// Human-readable diagnostic for port-53 bind conflicts. Offers two -/// concrete fixes: install Numa as the system resolver, or bind to a -/// non-privileged port. -pub fn port53_conflict_advisory(bind_addr: &str) -> String { +/// Advisory for port-53 bind failures (EADDRINUSE or EACCES); `None` +/// if not applicable so the caller can fall back to the raw error. +pub fn try_port53_advisory(bind_addr: &str, err: &std::io::Error) -> Option { + if !is_port_53(bind_addr) { + return None; + } + let (title, cause) = match err.kind() { + std::io::ErrorKind::AddrInUse => ( + "port 53 is already in use", + "Another process is already bound to port 53. On Linux this is\n \ + typically systemd-resolved; on Windows, the DNS Client service.", + ), + std::io::ErrorKind::PermissionDenied => ( + "permission denied", + "Port 53 is privileged — binding it requires root on Linux/macOS\n \ + or Administrator on Windows.", + ), + _ => return None, + }; let o = "\x1b[1;38;2;192;98;58m"; // bold orange let r = "\x1b[0m"; - format!( + Some(format!( " -{o}Numa{r} — cannot bind to {bind_addr}: port 53 is already in use. +{o}Numa{r} — cannot bind to {bind_addr}: {title}. - Another process is already bound to port 53. On Linux this is - typically systemd-resolved; on Windows, the DNS Client service. + {cause} Fix — pick one: @@ -86,7 +90,14 @@ pub fn port53_conflict_advisory(bind_addr: &str) -> String { Test with: dig @127.0.0.1 -p 5354 example.com " - ) + )) +} + +fn is_port_53(bind_addr: &str) -> bool { + bind_addr + .parse::() + .map(|s| s.port() == 53) + .unwrap_or(false) } #[cfg(target_os = "macos")] @@ -1796,4 +1807,43 @@ Wireless LAN adapter Wi-Fi: assert_eq!(result.len(), 1); assert!(result.contains_key("Wi-Fi")); } + + #[test] + fn try_port53_advisory_addr_in_use() { + let err = std::io::Error::from(std::io::ErrorKind::AddrInUse); + let msg = try_port53_advisory("0.0.0.0:53", &err).expect("should advise on port 53"); + assert!(msg.contains("cannot bind to")); + assert!(msg.contains("already in use")); + assert!(msg.contains("numa install")); + assert!(msg.contains("bind_addr")); + } + + #[test] + fn try_port53_advisory_permission_denied() { + let err = std::io::Error::from(std::io::ErrorKind::PermissionDenied); + let msg = try_port53_advisory("0.0.0.0:53", &err).expect("should advise on port 53"); + assert!(msg.contains("cannot bind to")); + assert!(msg.contains("permission denied")); + assert!(msg.contains("numa install")); + assert!(msg.contains("bind_addr")); + } + + #[test] + fn try_port53_advisory_skips_non_53_ports() { + let err = std::io::Error::from(std::io::ErrorKind::AddrInUse); + assert!(try_port53_advisory("127.0.0.1:5354", &err).is_none()); + assert!(try_port53_advisory("[::]:853", &err).is_none()); + } + + #[test] + fn try_port53_advisory_skips_unrelated_error_kinds() { + let err = std::io::Error::from(std::io::ErrorKind::NotFound); + assert!(try_port53_advisory("0.0.0.0:53", &err).is_none()); + } + + #[test] + fn try_port53_advisory_skips_malformed_bind_addr() { + let err = std::io::Error::from(std::io::ErrorKind::AddrInUse); + assert!(try_port53_advisory("not-an-address", &err).is_none()); + } } diff --git a/src/tls.rs b/src/tls.rs index 7c7620a..7ba96b6 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -40,6 +40,40 @@ pub fn regenerate_tls(ctx: &ServerCtx) { } } +/// Advisory for TLS-setup failures caused by a non-writable data dir; +/// `None` if not applicable so the caller can fall back to the raw error. +pub fn try_data_dir_advisory(err: &crate::Error, data_dir: &Path) -> Option { + let io_err = err.downcast_ref::()?; + if io_err.kind() != std::io::ErrorKind::PermissionDenied { + return None; + } + let o = "\x1b[1;38;2;192;98;58m"; + let r = "\x1b[0m"; + Some(format!( + " +{o}Numa{r} — HTTPS proxy disabled: cannot write TLS CA to {}. + + The data directory is not writable by the current user. Numa needs + to persist a local Certificate Authority there to serve .numa over + HTTPS. DNS resolution and plain-HTTP proxy continue to work. + + Fix — pick one: + + 1. Install Numa as the system resolver (sets up a writable data dir): + + sudo numa install (on Windows, run as Administrator) + + 2. Point data_dir at a path you can write. + Create ~/.config/numa/numa.toml with: + + [server] + data_dir = \"/path/you/can/write\" + +", + data_dir.display() + )) +} + /// 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. @@ -170,3 +204,33 @@ fn generate_service_cert( Ok((vec![cert_der, ca_der], key_der)) } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn try_data_dir_advisory_permission_denied() { + let err: crate::Error = + Box::new(std::io::Error::from(std::io::ErrorKind::PermissionDenied)); + let path = PathBuf::from("/usr/local/var/numa"); + let msg = try_data_dir_advisory(&err, &path).expect("should advise"); + assert!(msg.contains("HTTPS proxy disabled")); + assert!(msg.contains("/usr/local/var/numa")); + assert!(msg.contains("numa install")); + assert!(msg.contains("data_dir")); + } + + #[test] + fn try_data_dir_advisory_skips_other_io_kinds() { + let err: crate::Error = Box::new(std::io::Error::from(std::io::ErrorKind::NotFound)); + assert!(try_data_dir_advisory(&err, &PathBuf::from("/x")).is_none()); + } + + #[test] + fn try_data_dir_advisory_skips_non_io_errors() { + let err: crate::Error = "rcgen failure".into(); + assert!(try_data_dir_advisory(&err, &PathBuf::from("/x")).is_none()); + } +} -- 2.34.1 From 819614fa7dba39c207999225def38b4f3387dc81 Mon Sep 17 00:00:00 2001 From: Casey Labs <4674433+CaseyLabs@users.noreply.github.com> Date: Thu, 9 Apr 2026 07:22:38 -0700 Subject: [PATCH 036/204] [Feature] Add GitHub Action Workflow for Arch Linux AUR Package publishing (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feature: add GitHub Actions workflow for publishing Arch Linux AUR package * Fix issues in Arch Linux AUR publishing process * Add patch to fix default Arch Linux binary path location issues * fix: PKGBUILD compatibility with numa v0.10.1, fix QEMU action SHA pin Three small bug fixes that make this PR mergeable end-to-end against current main, without changing the package design (still numa-git, still pushed on every main commit, still tracking HEAD via pkgver()): 1. Simplified prepare() — drop the obsolete sed patching for /usr/local/bin/numa. That literal only appears in a comment in current main; the actual binary path is determined at runtime via std::env::current_exe(). Additionally, numa v0.10.1 ships PR #43 which makes numa FHS-compliant on Linux out of the box (/var/lib/numa for data dir), so no source patching is needed at all on Arch. 2. Fixed package() sed for the systemd unit. The previous sed targeted "ExecStart=/usr/local/bin/numa" but numa.service actually uses "{{exe_path}}" as a templating placeholder that's substituted at runtime by replace_exe_path() when `numa install` runs. The sed silently did nothing, and the AUR-installed unit file would have a literal "{{exe_path}}" that systemd cannot start. Fixed sed: sed 's|{{exe_path}}|/usr/bin/numa /etc/numa.toml|g' \ numa.service > numa.service.patched 3. Fixed broken docker/setup-qemu-action SHA pin in publish-aur.yml. The pinned SHA 6882732593b27c7f95a044d559b586a46371a68e doesn't exist as a commit in upstream docker/setup-qemu-action. Verified v3.0.0 SHA is 68827325e0b33c7199eb31dd4e31fbe9023e06e3. Without this fix the aarch64 validate job would fail to load the action at workflow start. Also refreshed the stale pkgver placeholder in PKGBUILD and .SRCINFO from 0.9.1.r0.g1234abc to 0.10.1.r0.g0000000 — purely cosmetic since pkgver() auto-overrides on every makepkg run, but at least the in-VC value reflects the current era. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: make AUR packaging x86_64-only and stabilize local validation Turns out Arch Linux doesn't officially support aarch64 architecture, so we will drop if from this AUR build process. Changes: - drop aarch64 from PKGBUILD, .SRCINFO, and AUR validation workflow - keep AUR process aligned with official Arch Linux x86_64 support - install rust directly in CI to avoid Arch cargo provider prompts - fetch sources before running cargo audit and audit inside the fetched repo - disable makepkg LTO for this package to avoid Arch packaging link failures - mark /etc/numa.toml as a backup file - Add local AUR build scratch directory exclusion to .gitignore * Add temporary AUR test workflow * Update github actions checkout workflow version * remove temporary AUR test workflow * fix: correct AUR SSH host key fingerprint The previously pinned ed25519 key was truncated (52 chars) and did not match the actual aur.archlinux.org host key. Verified via ssh-keyscan. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Razvan Dimescu Co-authored-by: Claude Opus 4.6 (1M context) --- .SRCINFO | 19 ++++ .github/workflows/publish-aur.yml | 150 ++++++++++++++++++++++++++++++ .gitignore | 1 + PKGBUILD | 62 ++++++++++++ README.md | 3 + 5 files changed, 235 insertions(+) create mode 100644 .SRCINFO create mode 100644 .github/workflows/publish-aur.yml create mode 100644 PKGBUILD diff --git a/.SRCINFO b/.SRCINFO new file mode 100644 index 0000000..66d3218 --- /dev/null +++ b/.SRCINFO @@ -0,0 +1,19 @@ +pkgbase = numa-git + pkgdesc = Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS + pkgver = 0.10.1.r0.g0000000 + pkgrel = 1 + url = https://github.com/razvandimescu/numa + arch = x86_64 + license = MIT + options = !lto + makedepends = cargo + makedepends = git + depends = gcc-libs + depends = glibc + provides = numa + conflicts = numa + backup = etc/numa.toml + source = numa::git+https://github.com/razvandimescu/numa.git + sha256sums = SKIP + +pkgname = numa-git diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml new file mode 100644 index 0000000..03831c9 --- /dev/null +++ b/.github/workflows/publish-aur.yml @@ -0,0 +1,150 @@ +# `publish-aur.yml` - Arch Linux AUR Package Workflow +# -------------------- +# This workflow automates the validation and publishing of the 'numa-git' package to the +# Arch User Repository (AUR). The AUR is a community-driven repository for Arch Linux users. +# +# Workflow Overview: +# 1. Validate: Builds and tests the package for Arch Linux x86_64 using a clean +# Arch Linux container. +# 2. Audit: Checks Rust dependencies for known security vulnerabilities using +# 'cargo-audit'. +# 3. Publish: If on the 'main' branch, it pushes the updated PKGBUILD and +# .SRCINFO to the AUR. +# +# Security Best Practices: +# - SHA Pinning: All GitHub Actions are pinned to a full-length commit SHA (e.g., v6.0.2 @ SHA) +# to ensure the code is immutable and protects against supply-chain attacks where a tag +# might be maliciously moved to a compromised commit. +# - SSH Hygiene: Uses ssh-agent to keep the private key in memory rather than on disk. +# - Audit: Runs 'cargo audit' to prevent publishing known vulnerable dependencies. + +name: Publish - Arch Linux AUR Package + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + # The 'validate' job ensures that the PKGBUILD is correct and the software builds/tests + # successfully on Arch Linux before we attempt to publish it. + validate: + name: Validate PKGBUILD (${{ matrix.arch }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + arch: [x86_64] + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Build and Test Package + timeout-minutes: 60 + env: + AUR_PKGNAME: ${{ secrets.AUR_PACKAGE_NAME }} + run: | + # We use a temporary directory to avoid Docker permission issues with the workspace. + mkdir -p build-dir + cp PKGBUILD build-dir/ + + docker run --rm -v $PWD/build-dir:/pkg -w /pkg archlinux:latest /bin/bash -c " + # ARCH LINUX SECURITY REQUIREMENT: + # 'makepkg' (the tool that builds Arch packages) refuses to run as root for safety. + # We must create a standard user and give them sudo access. + + # Install build-time dependencies. + # 'base-devel' includes essential tools like gcc, make, and binutils. + # Install 'rust' directly to avoid the interactive virtual-package + # prompt for 'cargo' on current Arch images. + pacman -Syu --noconfirm --needed base-devel rust git sudo cargo-audit + + useradd -m builduser + chown -R builduser:builduser /pkg + + # Allow the build user to install dependencies during the build process. + echo 'builduser ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builduser + + # Fetch the source tree first so pkgver() and cargo-audit have a + # real Cargo.lock to inspect. + sudo -u builduser makepkg -o --nobuild --nocheck --nodeps --noprepare + + # SECURITY AUDIT: + # Fail early if any dependencies have known security vulnerabilities. + sudo -u builduser sh -lc 'cd /pkg/src/numa && cargo audit' + + # BUILD & TEST: + # 'makepkg -s' will: + # 1. Download source files (cloning this repo) + # 2. Run prepare(), build(), and check() (running cargo test) + # 3. Create the final .pkg.tar.zst package + sudo -u builduser makepkg -s --noconfirm + " + + # The 'publish' job updates the AUR repository with our latest PKGBUILD and .SRCINFO. + publish: + name: Publish to AUR + needs: validate + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # Securely configure SSH for AUR access. + - name: Configure SSH + run: | + mkdir -p ~/.ssh + # Official AUR Ed25519 fingerprint (prevents Man-in-the-Middle attacks). + echo "aur.archlinux.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEuBKrPzbawxA/k2g6NcyV5jmqwJ2s+zpgZGZ7tpLIcN" >> ~/.ssh/known_hosts + + # Use ssh-agent to keep the private key in memory rather than writing it to disk. + eval $(ssh-agent -s) + echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" | tr -d '\r' | ssh-add - + + # Export the agent socket so subsequent 'git' commands can use it. + echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> $GITHUB_ENV + echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> $GITHUB_ENV + + - name: Push to AUR + env: + AUR_PKGNAME: ${{ secrets.AUR_PACKAGE_NAME }} + AUR_EMAIL: ${{ secrets.AUR_EMAIL }} + AUR_USER: ${{ secrets.AUR_USERNAME }} + run: | + # AUR repos are managed via Git. Each package has its own repo at: + # ssh://aur@aur.archlinux.org/.git + git clone ssh://aur@aur.archlinux.org/$AUR_PKGNAME.git aur-repo + + cp PKGBUILD aur-repo/ + cd aur-repo + + # METADATA GENERATION: + # '.SRCINFO' is a machine-readable version of the PKGBUILD. + # We must run this as a non-root user ('builduser') inside the container. + docker run --rm -v $(pwd):/pkg archlinux:latest /bin/bash -c " + pacman -Syu --noconfirm --needed binutils git sudo + useradd -m builduser + chown -R builduser:builduser /pkg + cd /pkg + sudo -u builduser git config --global --add safe.directory '*' + # "makepkg -od" fetches the source first so pkgver() can calculate the version. + sudo -u builduser makepkg -od && sudo -u builduser makepkg --printsrcinfo > .SRCINFO + " + + # Set the commit identity using secrets for security and auditability. + git config user.name "$AUR_USER" + git config user.email "$AUR_EMAIL" + + # Stage and commit both the human-readable PKGBUILD and machine-readable .SRCINFO. + git add PKGBUILD .SRCINFO + + if ! git diff --cached --quiet; then + git commit -m "chore: update PKGBUILD to ${{ github.sha }}" + git push origin master + else + echo "No changes to commit (metadata and PKGBUILD are already up-to-date)." + fi diff --git a/.gitignore b/.gitignore index 9dcba3d..1c510fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +/build-dir CLAUDE.md docs/ site/blog/posts/ diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 0000000..b3e3f6b --- /dev/null +++ b/PKGBUILD @@ -0,0 +1,62 @@ +# Maintainer: razvandimescu +pkgname=numa-git +_pkgname=numa +pkgver=0.10.1.r0.g0000000 # Placeholder — pkgver() rewrites this on each makepkg run +pkgrel=1 +pkgdesc="Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS" +arch=('x86_64') +url="https://github.com/razvandimescu/numa" +license=('MIT') +options=('!lto') +depends=('gcc-libs' 'glibc') +makedepends=('cargo' 'git') +provides=("$_pkgname") +conflicts=("$_pkgname") +backup=('etc/numa.toml') +source=("$_pkgname::git+$url.git") +sha256sums=('SKIP') + +pkgver() { + cd "$srcdir/$_pkgname" + ( set -o pipefail + git describe --long --tags 2>/dev/null | sed 's/\([^-]*-g\)/r\1/;s/-/./g' || + printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" + ) | sed 's/^v//' +} + +prepare() { + cd "$srcdir/$_pkgname" + # numa v0.10.1+ uses FHS-compliant paths on Linux by default + # (/var/lib/numa for data, journalctl for logs), so no source + # patching is needed. The earlier sed targeted /usr/local/bin/numa, + # which only appears in a comment in current main. + export RUSTUP_TOOLCHAIN=stable + cargo fetch --locked +} + +build() { + cd "$srcdir/$_pkgname" + export RUSTUP_TOOLCHAIN=stable + cargo build --frozen --release +} + +check() { + cd "$srcdir/$_pkgname" + export RUSTUP_TOOLCHAIN=stable + cargo test --frozen +} + +package() { + cd "$srcdir/$_pkgname" + install -Dm755 "target/release/$_pkgname" "$pkgdir/usr/bin/$_pkgname" + + # numa.service uses {{exe_path}} as a placeholder substituted by + # `numa install` at runtime via replace_exe_path(). For an AUR + # package install (no `numa install` step), we substitute it + # statically here so systemd gets a real ExecStart path. + sed 's|{{exe_path}}|/usr/bin/numa /etc/numa.toml|g' numa.service > numa.service.patched + install -Dm644 "numa.service.patched" "$pkgdir/usr/lib/systemd/system/numa.service" + + install -Dm644 "numa.toml" "$pkgdir/etc/numa.toml" + install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE" +} diff --git a/README.md b/README.md index 5794268..79dcff8 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,9 @@ brew install razvandimescu/tap/numa # Linux curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh +# Arch Linux (AUR) +yay -S numa-git + # Windows — download from GitHub Releases # All platforms cargo install numa -- 2.34.1 From 5308e9648cca820b5cbf92ce5142f3e5df980840 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 9 Apr 2026 18:24:30 +0300 Subject: [PATCH 037/204] fix(ci): reclaim aur-repo ownership after docker chown (#49) The 'Push to AUR' step failed on run 24195384571 with: error: could not lock config file .git/config: Permission denied Inside the docker block we 'chown -R builduser:builduser /pkg', which propagates through the bind mount and transfers ownership of aur-repo/ (including .git/) to the container's builduser UID. When control returns to the runner user, 'git config user.name' can no longer write .git/config and the step exits 255. Chown the directory back to the runner's UID/GID before resuming host-side git operations. Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/publish-aur.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml index 03831c9..f36783d 100644 --- a/.github/workflows/publish-aur.yml +++ b/.github/workflows/publish-aur.yml @@ -135,6 +135,12 @@ jobs: sudo -u builduser makepkg -od && sudo -u builduser makepkg --printsrcinfo > .SRCINFO " + # Reclaim ownership: the in-container 'chown -R builduser:builduser /pkg' + # propagates through the bind mount, leaving .git/ owned by the container's + # builduser UID. Without this, subsequent 'git config' on the host fails with + # "could not lock config file .git/config: Permission denied". + sudo chown -R "$(id -u):$(id -g)" . + # Set the commit identity using secrets for security and auditability. git config user.name "$AUR_USER" git config user.email "$AUR_EMAIL" -- 2.34.1 From 389ac099072de9206716cdb2c894ccb3271bcd4b Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 9 Apr 2026 18:55:03 +0300 Subject: [PATCH 038/204] fix(ci): repair broken quoting in publish-aur docker heredoc (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docker block runs as '/bin/bash -c ""'. A comment inside the script contained embedded double quotes: # "makepkg -od" fetches the source first so pkgver() can calculate the version. The first embedded '"' prematurely closes the outer string. Bash then parses the remainder into a second argument to 'bash -c' which becomes $0 inside the container and is silently discarded. Net effect: the in-container script stops at 'git config --add safe.directory', neither 'makepkg -od' nor 'makepkg --printsrcinfo > .SRCINFO' ever run, and the host-side 'git add PKGBUILD .SRCINFO' fails with: fatal: pathspec '.SRCINFO' did not match any files This bug was masked by the earlier ownership bug fixed in #49 — once that permission error was removed, this one surfaced. Fix: drop the embedded double quotes from the comment. Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/publish-aur.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml index f36783d..ea5dc7f 100644 --- a/.github/workflows/publish-aur.yml +++ b/.github/workflows/publish-aur.yml @@ -131,7 +131,7 @@ jobs: chown -R builduser:builduser /pkg cd /pkg sudo -u builduser git config --global --add safe.directory '*' - # "makepkg -od" fetches the source first so pkgver() can calculate the version. + # makepkg -od fetches the source first so pkgver() can calculate the version. sudo -u builduser makepkg -od && sudo -u builduser makepkg --printsrcinfo > .SRCINFO " -- 2.34.1 From 17c8e70aa3624dac55b93e24d36be94287b08490 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 9 Apr 2026 19:39:28 +0300 Subject: [PATCH 039/204] fix(ci): skip prepare() in publish-aur metadata container (#51) Follow-up to #49 and #50. With ownership and quoting fixed, the next run ([24199871832](https://github.com/razvandimescu/numa/actions/runs/24199871832)) reached makepkg and failed with: /pkg/PKGBUILD: line 34: cargo: command not found ==> ERROR: A failure occurred in prepare(). The publish job only installs 'binutils git sudo' since its sole purpose is to regenerate .SRCINFO. 'makepkg -od' still runs prepare(), which calls cargo. The sibling validate job avoids this by passing --noprepare (and installs rust anyway). Mirror that pattern: add --noprepare to the metadata-generation invocation. pkgver() runs before prepare() in makepkg's pipeline, so .SRCINFO still captures the computed version. Keeps the container minimal (no rust toolchain). Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/publish-aur.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml index ea5dc7f..49275a0 100644 --- a/.github/workflows/publish-aur.yml +++ b/.github/workflows/publish-aur.yml @@ -132,7 +132,10 @@ jobs: cd /pkg sudo -u builduser git config --global --add safe.directory '*' # makepkg -od fetches the source first so pkgver() can calculate the version. - sudo -u builduser makepkg -od && sudo -u builduser makepkg --printsrcinfo > .SRCINFO + # --noprepare skips the prepare() function, which invokes cargo and would + # otherwise require a full rust toolchain in this metadata-only container. + # pkgver() runs before prepare(), so .SRCINFO still gets the correct version. + sudo -u builduser makepkg -od --noprepare && sudo -u builduser makepkg --printsrcinfo > .SRCINFO " # Reclaim ownership: the in-container 'chown -R builduser:builduser /pkg' -- 2.34.1 From 643d6b01e10665f6dab381c4d17ec58fe2bbc381 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 9 Apr 2026 22:32:57 +0300 Subject: [PATCH 040/204] fix(linux): consult resolvectl when resolv.conf only shows the stub (#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On modern Arch / Ubuntu 22.04+ / Fedora desktops, NetworkManager + systemd-resolved symlink /etc/resolv.conf to stub-resolv.conf, which contains only: nameserver 127.0.0.53 The real upstream servers (router, ISP, configured DoT providers) live inside systemd-resolved's per-link state, exposed via 'resolvectl status'. discover_linux() was parsing /etc/resolv.conf, correctly filtering the stub address, and then falling through to the Quad9 DoH fallback because detect_dhcp_dns() is macOS-only on Linux. Net effect: on a large chunk of Linux installs, numa silently defaulted to Quad9 instead of the user's actual DNS — visible in Casey's AUR test banner (#33) as 'Upstream https://9.9.9.9/dns-query' despite his machine having working router DNS the entire time. resolvectl_dns_server() already exists — it was introduced for cloud VPC forwarding-rule discovery and knows how to ask systemd-resolved for the real active DNS server. This commit wires it into the default-upstream fallback chain, between the primary resolv.conf parse and the ~/.numa/original-resolv.conf backup. Co-authored-by: Claude Opus 4.6 (1M context) --- src/system_dns.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/system_dns.rs b/src/system_dns.rs index f77d820..36c13d7 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -228,6 +228,9 @@ fn discover_linux() -> SystemDnsInfo { let default_upstream = if let Some(ns) = upstream { info!("detected system upstream: {}", ns); Some(ns) + } else if let Some(ns) = resolvectl_dns_server() { + info!("detected system upstream via resolvectl: {}", ns); + Some(ns) } else { // Fallback to backup from a previous `numa install` let backup = { -- 2.34.1 From 1f6bdff8f811426e3d255de0704eb636501052d1 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 9 Apr 2026 22:59:10 +0300 Subject: [PATCH 041/204] chore: bump version to 0.10.2 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c36808c..7c2bcba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1143,7 +1143,7 @@ dependencies = [ [[package]] name = "numa" -version = "0.10.1" +version = "0.10.2" dependencies = [ "arc-swap", "axum", diff --git a/Cargo.toml b/Cargo.toml index 8ca29d0..785fe4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "numa" -version = "0.10.1" +version = "0.10.2" authors = ["razvandimescu "] edition = "2021" description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS" -- 2.34.1 From 63ac69a222b47c53d2b8a84dfdc0c36cb554a5df Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 9 Apr 2026 23:33:48 +0300 Subject: [PATCH 042/204] ci: call homebrew-bump as reusable workflow instead of PAT event propagation (#53) Reverts PR #44's approach of swapping GITHUB_TOKEN for a PAT on action-gh-release. That approach worked in principle but failed in practice during the v0.10.2 cut: HOMEBREW_TAP_GITHUB_TOKEN is a fine-grained PAT scoped only to razvandimescu/homebrew-tap, so when action-gh-release tried to create a release on razvandimescu/numa it got 403 Resource not accessible. v0.10.2 had to be recovered manually via `gh release create` from a user PAT. Root cause of the original bug (from #44): GitHub Actions deliberately does not propagate workflow events triggered by GITHUB_TOKEN, so a release created by GITHUB_TOKEN silently failed to fire homebrew-bump's `release: published` trigger. Fix: sidestep the event-propagation rule entirely by invoking homebrew-bump.yml directly as a reusable workflow via `workflow_call`. - release.yml: drop the `token:` override on action-gh-release (reverts to GITHUB_TOKEN default, which v0.10.0 and v0.10.1 used successfully) and add a new `bump-homebrew` job that `needs: release` and `uses:` homebrew-bump.yml with `secrets: inherit`. - homebrew-bump.yml: add `workflow_call` trigger with a `version` input, remove the `release: published` trigger (no longer needed), keep `workflow_dispatch` for manual recovery, and collapse the version determination step to a single `inputs.version` read. Each token now does exactly what its scope permits: - GITHUB_TOKEN creates the release on numa (contents: write, default) - HOMEBREW_TAP_GITHUB_TOKEN pushes to homebrew-tap (unchanged) The tap update becomes a child job in the release run, so failures are visible in one place instead of "why didn't the release event fire?" mysteries. Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/homebrew-bump.yml | 17 +++++++++-------- .github/workflows/release.yml | 15 +++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/homebrew-bump.yml b/.github/workflows/homebrew-bump.yml index 5bcac57..ad54e09 100644 --- a/.github/workflows/homebrew-bump.yml +++ b/.github/workflows/homebrew-bump.yml @@ -1,8 +1,12 @@ name: Bump Homebrew Tap on: - release: - types: [published] + workflow_call: + inputs: + version: + description: 'Version to bump (e.g. 0.10.0 or v0.10.0)' + type: string + required: true workflow_dispatch: inputs: version: @@ -20,13 +24,10 @@ jobs: - name: Determine version id: ver + env: + INPUT_VERSION: ${{ inputs.version }} run: | - if [ "${{ github.event_name }}" = "release" ]; then - V="${{ github.event.release.tag_name }}" - else - V="${{ github.event.inputs.version }}" - fi - V="${V#v}" + V="${INPUT_VERSION#v}" echo "version=$V" >> "$GITHUB_OUTPUT" - name: Fetch sha256 checksums from release assets diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3396667..00cc427 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,16 +103,15 @@ jobs: - name: Create Release uses: softprops/action-gh-release@v2 with: - # Use a PAT (not the default GITHUB_TOKEN) so the resulting - # `release: published` event propagates to downstream workflows - # like homebrew-bump.yml. Events triggered by GITHUB_TOKEN are - # deliberately not propagated by GitHub Actions to prevent - # infinite loops; PAT-authored events are the documented escape - # hatch. Reusing HOMEBREW_TAP_GITHUB_TOKEN (already a PAT used - # by homebrew-bump.yml itself) keeps the secret surface flat. - token: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} generate_release_notes: true files: | *.tar.gz *.zip *.sha256 + + bump-homebrew: + needs: release + uses: ./.github/workflows/homebrew-bump.yml + with: + version: ${{ github.ref_name }} + secrets: inherit -- 2.34.1 From 9001b14fed1d6cf6bd7cea98d628743dcbd0e763 Mon Sep 17 00:00:00 2001 From: Casey Labs <4674433+CaseyLabs@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:40:49 -0700 Subject: [PATCH 043/204] [Feature] Add GitHub Dependabot scanning (runs once a month) (#46) * Add GitHub Dependabot scanning (runs once a month) * chore: group dependabot updates and use conventional commit prefix Bundle all minor/patch bumps per ecosystem into a single PR to keep noise manageable (~3 PRs/month instead of 10+). Major bumps still get individual PRs since they may break APIs. Commit messages now use the `chore(deps)` conventional-commit prefix to match the repo's existing style. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Razvan Dimescu Co-authored-by: Claude Opus 4.6 (1M context) --- .github/dependabot.yml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4e5bd88 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,37 @@ +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "monthly" + commit-message: + prefix: "chore(deps)" + include: "scope" + groups: + minor-and-patch: + patterns: ["*"] + update-types: ["minor", "patch"] + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + commit-message: + prefix: "chore(deps)" + include: "scope" + groups: + minor-and-patch: + patterns: ["*"] + update-types: ["minor", "patch"] + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "monthly" + commit-message: + prefix: "chore(deps)" + include: "scope" + groups: + minor-and-patch: + patterns: ["*"] + update-types: ["minor", "patch"] -- 2.34.1 From a31ac36957d72497d40c93cf02e4ee9cef3466ec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:48:46 +0300 Subject: [PATCH 044/204] chore(deps)(deps): bump the minor-and-patch group with 2 updates (#57) Bumps the minor-and-patch group with 2 updates: rust and alpine. Updates `rust` from 1.88-alpine to 1.94-alpine Updates `alpine` from 3.20 to 3.23 --- updated-dependencies: - dependency-name: rust dependency-version: 1.94-alpine dependency-type: direct:production dependency-group: minor-and-patch - dependency-name: alpine dependency-version: '3.23' dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1d6f28f..e4ab8f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.88-alpine AS builder +FROM rust:1.94-alpine AS builder RUN apk add --no-cache musl-dev cmake make perl WORKDIR /app COPY Cargo.toml Cargo.lock ./ @@ -11,7 +11,7 @@ COPY numa.toml com.numa.dns.plist numa.service ./ RUN touch src/main.rs src/lib.rs RUN cargo build --release -FROM alpine:3.20 +FROM alpine:3.23 COPY --from=builder /app/target/release/numa /usr/local/bin/numa EXPOSE 53/udp 80/tcp 443/tcp 853/tcp 5380/tcp ENTRYPOINT ["numa"] -- 2.34.1 From 9a3fae9a0c20885fc7e0bc39c7259c5bd237c9bf Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 07:49:46 +0300 Subject: [PATCH 045/204] fix: drop include:scope from dependabot commit-message config (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The combination of `prefix: "chore(deps)"` and `include: "scope"` produced `chore(deps)(deps):` — double scope. Removing `include` keeps the prefix as-is. Co-authored-by: Claude Opus 4.6 (1M context) --- .github/dependabot.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4e5bd88..32994df 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,7 +6,6 @@ updates: interval: "monthly" commit-message: prefix: "chore(deps)" - include: "scope" groups: minor-and-patch: patterns: ["*"] @@ -18,7 +17,6 @@ updates: interval: "monthly" commit-message: prefix: "chore(deps)" - include: "scope" groups: minor-and-patch: patterns: ["*"] @@ -30,7 +28,6 @@ updates: interval: "monthly" commit-message: prefix: "chore(deps)" - include: "scope" groups: minor-and-patch: patterns: ["*"] -- 2.34.1 From b8b0fda1e0cfc05f46e85d30edce5be7abb40552 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:50:08 +0300 Subject: [PATCH 046/204] chore(deps)(deps): bump actions/deploy-pages from 4 to 5 (#62) Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5. - [Release notes](https://github.com/actions/deploy-pages/releases) - [Commits](https://github.com/actions/deploy-pages/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/deploy-pages dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/static.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 9db4306..8e6dfa0 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -44,4 +44,4 @@ jobs: path: './site' - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 -- 2.34.1 From f602687d93189aec97b8d1ace3f1893b656a405a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:50:22 +0300 Subject: [PATCH 047/204] chore(deps)(deps): bump actions/configure-pages from 5 to 6 (#61) Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 5 to 6. - [Release notes](https://github.com/actions/configure-pages/releases) - [Commits](https://github.com/actions/configure-pages/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/configure-pages dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/static.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 8e6dfa0..fe9221e 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -36,7 +36,7 @@ jobs: - name: Generate blog HTML run: make blog - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: -- 2.34.1 From 636c45b3d71d634d72d4445a2de7dddba8e7f453 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:50:38 +0300 Subject: [PATCH 048/204] chore(deps)(deps): bump actions/upload-pages-artifact from 3 to 4 (#59) Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 4. - [Release notes](https://github.com/actions/upload-pages-artifact/releases) - [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/upload-pages-artifact dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/static.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index fe9221e..1025b97 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -38,7 +38,7 @@ jobs: - name: Setup Pages uses: actions/configure-pages@v6 - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v4 with: # Upload entire repository path: './site' -- 2.34.1 From 11e3fdeae69958e2fe54aace5e251fb5b5ddad66 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:50:51 +0300 Subject: [PATCH 049/204] chore(deps)(deps): bump actions/upload-artifact from 4 to 7 (#58) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7884549..81ad913 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: - name: test run: cargo test - name: Upload binary - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: numa-windows-x86_64 path: target/debug/numa.exe diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00cc427..99f2f39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,7 +70,7 @@ jobs: (Get-FileHash "${{ matrix.name }}.zip" -Algorithm SHA256).Hash.ToLower() + " ${{ matrix.name }}.zip" | Out-File "${{ matrix.name }}.zip.sha256" -Encoding ascii - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.name }} path: | -- 2.34.1 From 524aed7fa166175a75978b952e36676e4ed011c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:51:33 +0300 Subject: [PATCH 050/204] chore(deps)(deps): bump actions/checkout from 4 to 6 (#60) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- .github/workflows/homebrew-bump.yml | 2 +- .github/workflows/release.yml | 4 ++-- .github/workflows/static.yml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81ad913..e0d06f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy @@ -30,7 +30,7 @@ jobs: check-macos: runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: clippy @@ -41,7 +41,7 @@ jobs: check-windows: runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: build diff --git a/.github/workflows/homebrew-bump.yml b/.github/workflows/homebrew-bump.yml index ad54e09..a0dd798 100644 --- a/.github/workflows/homebrew-bump.yml +++ b/.github/workflows/homebrew-bump.yml @@ -20,7 +20,7 @@ jobs: bump: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Determine version id: ver diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 99f2f39..127b1ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -82,7 +82,7 @@ jobs: publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 1025b97..90e7f35 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install pandoc run: sudo apt-get install -y pandoc - name: Generate blog HTML -- 2.34.1 From 66b937f7102d0200dcb2c47e956c671aeaed3d73 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:54:58 +0300 Subject: [PATCH 051/204] chore(deps): bump actions/download-artifact from 4 to 8 (#69) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4...v8) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 127b1ab..d168bba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: needs: [build, publish] runs-on: ubuntu-latest steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: merge-multiple: true -- 2.34.1 From fb0a21e5e614a1cd80ca2740d9eeae02d2cfea85 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:55:24 +0300 Subject: [PATCH 052/204] chore(deps)(deps): bump the minor-and-patch group with 3 updates (#63) Bumps the minor-and-patch group with 3 updates: [tokio](https://github.com/tokio-rs/tokio), [hyper](https://github.com/hyperium/hyper) and [arc-swap](https://github.com/vorner/arc-swap). Updates `tokio` from 1.50.0 to 1.51.1 - [Release notes](https://github.com/tokio-rs/tokio/releases) - [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.50.0...tokio-1.51.1) Updates `hyper` from 1.8.1 to 1.9.0 - [Release notes](https://github.com/hyperium/hyper/releases) - [Changelog](https://github.com/hyperium/hyper/blob/master/CHANGELOG.md) - [Commits](https://github.com/hyperium/hyper/compare/v1.8.1...v1.9.0) Updates `arc-swap` from 1.9.0 to 1.9.1 - [Changelog](https://github.com/vorner/arc-swap/blob/master/CHANGELOG.md) - [Commits](https://github.com/vorner/arc-swap/compare/v1.9.0...v1.9.1) --- updated-dependencies: - dependency-name: tokio dependency-version: 1.51.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: hyper dependency-version: 1.9.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: arc-swap dependency-version: 1.9.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7c2bcba..c721f11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,9 +75,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ "rustversion", ] @@ -755,9 +755,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -770,7 +770,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -1219,12 +1218,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "plotters" version = "0.3.7" @@ -1869,9 +1862,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "bytes", "libc", @@ -1884,9 +1877,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", -- 2.34.1 From 44cd17cf842a1e8d22fb65055b2c742d7ca45fd7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:56:50 +0300 Subject: [PATCH 053/204] chore(deps)(deps): bump criterion from 0.5.1 to 0.8.2 (#64) Bumps [criterion](https://github.com/criterion-rs/criterion.rs) from 0.5.1 to 0.8.2. - [Release notes](https://github.com/criterion-rs/criterion.rs/releases) - [Changelog](https://github.com/criterion-rs/criterion.rs/blob/master/CHANGELOG.md) - [Commits](https://github.com/criterion-rs/criterion.rs/compare/0.5.1...criterion-v0.8.2) --- updated-dependencies: - dependency-name: criterion dependency-version: 0.8.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 75 +++++++++++++++++++++++++++++++++++------------------- Cargo.toml | 2 +- 2 files changed, 50 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c721f11..358f9a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "anes" version = "0.1.6" @@ -368,25 +377,24 @@ dependencies = [ [[package]] name = "criterion" -version = "0.5.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" dependencies = [ + "alloca", "anes", "cast", "ciborium", "clap", "criterion-plot", - "is-terminal", "itertools", "num-traits", - "once_cell", "oorandom", + "page_size", "plotters", "rayon", "regex", "serde", - "serde_derive", "serde_json", "tinytemplate", "walkdir", @@ -394,9 +402,9 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.5.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", "itertools", @@ -702,12 +710,6 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - [[package]] name = "http" version = "1.4.0" @@ -943,17 +945,6 @@ dependencies = [ "serde", ] -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -962,9 +953,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.10.5" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -1196,6 +1187,16 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "pem" version = "3.0.6" @@ -2181,6 +2182,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2190,6 +2207,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 785fe4c..52f59bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ ring = "0.17" rustls-pemfile = "2.2.0" [dev-dependencies] -criterion = { version = "0.5", features = ["html_reports"] } +criterion = { version = "0.8", features = ["html_reports"] } tower = { version = "0.5", features = ["util"] } http = "1" -- 2.34.1 From f20c72a829ef0b9cc2f701d58f18a13bc6a01512 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:57:55 +0300 Subject: [PATCH 054/204] chore(deps)(deps): bump toml from 0.8.23 to 1.1.2+spec-1.1.0 (#65) Bumps [toml](https://github.com/toml-rs/toml) from 0.8.23 to 1.1.2+spec-1.1.0. - [Commits](https://github.com/toml-rs/toml/compare/toml-v0.8.23...toml-v1.1.2) --- updated-dependencies: - dependency-name: toml dependency-version: 1.1.2+spec-1.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 67 +++++++++++++++++++++++++----------------------------- Cargo.toml | 2 +- 2 files changed, 32 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 358f9a3..792cd06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1649,11 +1649,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -1912,44 +1912,42 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", - "serde", + "serde_core", "serde_spanned", "toml_datetime", - "toml_write", + "toml_parser", + "toml_writer", "winnow", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower" @@ -2377,12 +2375,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" [[package]] name = "wit-bindgen" diff --git a/Cargo.toml b/Cargo.toml index 52f59bb..995895b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", axum = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" -toml = "0.8" +toml = "1.1" log = "0.4" env_logger = "0.11" reqwest = { version = "0.12", features = ["rustls-tls", "gzip", "http2"], default-features = false } -- 2.34.1 From dd021d8642018f6d676dd0a631799026ff4e3188 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:58:07 +0300 Subject: [PATCH 055/204] chore(deps)(deps): bump socket2 from 0.5.10 to 0.6.3 (#67) Bumps [socket2](https://github.com/rust-lang/socket2) from 0.5.10 to 0.6.3. - [Release notes](https://github.com/rust-lang/socket2/releases) - [Changelog](https://github.com/rust-lang/socket2/blob/master/CHANGELOG.md) - [Commits](https://github.com/rust-lang/socket2/commits/v0.6.3) --- updated-dependencies: - dependency-name: socket2 dependency-version: 0.6.3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 20 +++++--------------- Cargo.toml | 2 +- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 792cd06..5f6fcc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -811,7 +811,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2", "tokio", "tower-service", "tracing", @@ -1152,7 +1152,7 @@ dependencies = [ "rustls-pemfile", "serde", "serde_json", - "socket2 0.5.10", + "socket2", "time", "tokio", "tokio-rustls", @@ -1308,7 +1308,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2", "thiserror 2.0.18", "tokio", "tracing", @@ -1345,7 +1345,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2", "tracing", "windows-sys 0.60.2", ] @@ -1692,16 +1692,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.3" @@ -1871,7 +1861,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2 0.6.3", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] diff --git a/Cargo.toml b/Cargo.toml index 995895b..7044d10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ hyper = { version = "1", features = ["client", "http1", "server"] } hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] } http-body-util = "0.1" futures = "0.3" -socket2 = { version = "0.5", features = ["all"] } +socket2 = { version = "0.6", features = ["all"] } rcgen = { version = "0.13", features = ["pem", "x509-parser"] } time = "0.3" rustls = "0.23" -- 2.34.1 From 422726f1c80c283e4698689e7128c744ab5133c4 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 08:28:07 +0300 Subject: [PATCH 056/204] chore(deps): bump rcgen from 0.13 to 0.14 (#70) rcgen 0.14 replaced the separate Certificate + KeyPair args with a unified Issuer type. Migrates ensure_ca and generate_service_cert: - Load path: Issuer::from_ca_cert_der replaces the old CertificateParams::from_ca_cert_pem + self_signed round-trip. - Generate path: Issuer::new(params, key_pair) constructs directly from the params used for self_signed (no DER re-parse). - signed_by takes (&key_pair, &issuer) instead of (&key_pair, &cert, &key). Also drops thiserror v1 from the dep tree (rcgen 0.14 uses v2). Co-authored-by: Claude Opus 4.6 --- Cargo.lock | 54 +++++++++++++++++------------------------------------- Cargo.toml | 2 +- src/tls.rs | 34 ++++++++++++++++++++-------------- 3 files changed, 38 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5f6fcc6..1bda29d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,9 +93,9 @@ dependencies = [ [[package]] name = "asn1-rs" -version = "0.6.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" dependencies = [ "asn1-rs-derive", "asn1-rs-impl", @@ -103,15 +103,15 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 1.0.69", + "thiserror", "time", ] [[package]] name = "asn1-rs-derive" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", @@ -449,9 +449,9 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "der-parser" -version = "9.0.0" +version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ "asn1-rs", "displaydoc", @@ -1162,9 +1162,9 @@ dependencies = [ [[package]] name = "oid-registry" -version = "0.7.1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" dependencies = [ "asn1-rs", ] @@ -1309,7 +1309,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "web-time", @@ -1330,7 +1330,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror", "tinyvec", "tracing", "web-time", @@ -1416,9 +1416,9 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.13.2" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" dependencies = [ "pem", "ring", @@ -1745,33 +1745,13 @@ dependencies = [ "syn", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -2383,9 +2363,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "x509-parser" -version = "0.16.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ "asn1-rs", "data-encoding", @@ -2395,7 +2375,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror 1.0.69", + "thiserror", "time", ] diff --git a/Cargo.toml b/Cargo.toml index 7044d10..961c280 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] } http-body-util = "0.1" futures = "0.3" socket2 = { version = "0.6", features = ["all"] } -rcgen = { version = "0.13", features = ["pem", "x509-parser"] } +rcgen = { version = "0.14", features = ["pem", "x509-parser"] } time = "0.3" rustls = "0.23" tokio-rustls = "0.26" diff --git a/src/tls.rs b/src/tls.rs index 7ba96b6..92947a0 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -5,7 +5,9 @@ use std::sync::Arc; use log::{info, warn}; use crate::ctx::ServerCtx; -use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose, SanType}; +use rcgen::{ + BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType, +}; use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; use rustls::ServerConfig; use time::{Duration, OffsetDateTime}; @@ -87,8 +89,8 @@ pub fn build_tls_config( alpn: Vec>, data_dir: &Path, ) -> crate::Result> { - let (ca_cert, ca_key) = ensure_ca(data_dir)?; - let (cert_chain, key) = generate_service_cert(&ca_cert, &ca_key, tld, service_names)?; + let (ca_der, issuer) = ensure_ca(data_dir)?; + let (cert_chain, key) = generate_service_cert(&ca_der, &issuer, tld, service_names)?; // Ensure a crypto provider is installed (rustls needs one) let _ = rustls::crypto::ring::default_provider().install_default(); @@ -106,7 +108,7 @@ pub fn build_tls_config( Ok(Arc::new(config)) } -fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> { +fn ensure_ca(dir: &Path) -> crate::Result<(CertificateDer<'static>, Issuer<'static, KeyPair>)> { let ca_key_path = dir.join("ca.key"); let ca_cert_path = dir.join(CA_FILE_NAME); @@ -114,10 +116,12 @@ fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> { let key_pem = std::fs::read_to_string(&ca_key_path)?; let cert_pem = std::fs::read_to_string(&ca_cert_path)?; let key_pair = KeyPair::from_pem(&key_pem)?; - let params = CertificateParams::from_ca_cert_pem(&cert_pem)?; - let cert = params.self_signed(&key_pair)?; + let ca_der = rustls_pemfile::certs(&mut cert_pem.as_bytes()) + .next() + .ok_or("empty CA PEM file")??; + let issuer = Issuer::from_ca_cert_der(&ca_der, key_pair)?; info!("loaded CA from {:?}", ca_cert_path); - return Ok((cert, key_pair)); + return Ok((ca_der, issuer)); } // Generate new CA @@ -145,14 +149,16 @@ fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> { } info!("generated CA at {:?}", ca_cert_path); - Ok((cert, key_pair)) + let ca_der = cert.der().clone(); + let issuer = Issuer::new(params, key_pair); + Ok((ca_der, issuer)) } /// Generate a cert with explicit SANs for each service name. /// Always regenerated at startup (~5ms) — no disk caching needed. fn generate_service_cert( - ca_cert: &rcgen::Certificate, - ca_key: &KeyPair, + ca_der: &CertificateDer<'static>, + issuer: &Issuer<'_, KeyPair>, tld: &str, service_names: &[String], ) -> crate::Result<(Vec>, PrivateKeyDer<'static>)> { @@ -187,7 +193,7 @@ fn generate_service_cert( params.not_before = OffsetDateTime::now_utc(); params.not_after = OffsetDateTime::now_utc() + Duration::days(CERT_VALIDITY_DAYS); - let cert = params.signed_by(&key_pair, ca_cert, ca_key)?; + let cert = params.signed_by(&key_pair, issuer)?; info!( "generated TLS cert for: {}", @@ -198,11 +204,11 @@ fn generate_service_cert( .join(", ") ); - let cert_der = CertificateDer::from(cert.der().to_vec()); - let ca_der = CertificateDer::from(ca_cert.der().to_vec()); + let cert_der = cert.der().clone(); + let ca_cert_der = ca_der.clone(); let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der())); - Ok((vec![cert_der, ca_der], key_der)) + Ok((vec![cert_der, ca_cert_der], key_der)) } #[cfg(test)] -- 2.34.1 From f556b60ce4a72b8a2ba8d30f9310220cc52b8974 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 08:32:51 +0300 Subject: [PATCH 057/204] fix: suppress recursive hint in install when already configured (#71) `sudo numa install` unconditionally printed the "Want full DNS sovereignty?" hint even when numa.toml already has mode = "recursive". Now loads the config first and skips the message if recursive is already set. Co-authored-by: Claude Opus 4.6 --- src/system_dns.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index 36c13d7..115ce2d 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -2,6 +2,17 @@ use std::net::SocketAddr; use log::info; +fn print_recursive_hint() { + let is_recursive = crate::config::load_config("numa.toml") + .map(|c| c.config.upstream.mode == crate::config::UpstreamMode::Recursive) + .unwrap_or(false); + if !is_recursive { + eprintln!(" Want full DNS sovereignty? Add to numa.toml:"); + eprintln!(" [upstream]"); + eprintln!(" mode = \"recursive\"\n"); + } +} + fn is_loopback_or_stub(addr: &str) -> bool { matches!(addr, "127.0.0.1" | "127.0.0.53" | "0.0.0.0" | "::1" | "") } @@ -688,9 +699,7 @@ fn install_windows() -> Result<(), String> { } else { eprintln!(" Numa will start automatically on next boot.\n"); } - eprintln!(" Want full DNS sovereignty? Add to numa.toml:"); - eprintln!(" [upstream]"); - eprintln!(" mode = \"recursive\"\n"); + print_recursive_hint(); Ok(()) } @@ -1181,9 +1190,7 @@ fn install_service_macos() -> Result<(), String> { eprintln!(" Numa will auto-start on boot and restart if killed."); eprintln!(" Logs: /usr/local/var/log/numa.log"); eprintln!(" Run 'sudo numa uninstall' to restore original DNS.\n"); - eprintln!(" Want full DNS sovereignty? Add to numa.toml:"); - eprintln!(" [upstream]"); - eprintln!(" mode = \"recursive\"\n"); + print_recursive_hint(); Ok(()) } @@ -1388,9 +1395,7 @@ fn install_service_linux() -> Result<(), String> { eprintln!(" Numa will auto-start on boot and restart if killed."); eprintln!(" Logs: journalctl -u numa -f"); eprintln!(" Run 'sudo numa uninstall' to restore original DNS.\n"); - eprintln!(" Want full DNS sovereignty? Add to numa.toml:"); - eprintln!(" [upstream]"); - eprintln!(" mode = \"recursive\"\n"); + print_recursive_hint(); Ok(()) } -- 2.34.1 From e860731c01bd73706120884adcdd7f91908c5283 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 08:53:46 +0300 Subject: [PATCH 058/204] =?UTF-8?q?fix:=20escape=20DNS=20label=20text=20pe?= =?UTF-8?q?r=20RFC=201035=20=C2=A75.1=20(closes=20#36)=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: escape dots and special characters in DNS label text per RFC 1035 §5.1 Closes #36 read_qname was pushing raw label bytes directly into the output string, producing ambiguous text for labels containing dots, backslashes, or non-printable bytes. fanf2 spotted this on HN: wire format `[8]exa.mple[3]com[0]` (two labels, first containing a literal 0x2E) was rendered as `exa.mple.com`, indistinguishable from three labels. Fix both sides of the text representation per RFC 1035 §5.1: read_qname — when rendering wire bytes to text: - literal `.` within a label → `\.` - literal `\` → `\\` - bytes outside 0x21..=0x7E → `\DDD` (3-digit decimal) - printable ASCII passes through unchanged write_qname — when parsing text back to wire: - `\.` produces a literal 0x2E inside the current label (not a separator) - `\\` produces a literal 0x5C - `\DDD` produces the byte with that decimal value (0..=255) - unescaped `.` still separates labels, empty labels still skipped - rejects trailing `\`, short `\DD`, and `\DDD` > 255 Impact in practice is low — real-world domains don't contain dots in labels — but it's a correctness bug in the wire format parser that could cause round-trip failures with adversarial input. The parser is the core of the project, so correctness bugs take priority over practical impact. Adds 16 unit tests in a new `#[cfg(test)] mod tests` block covering: plain domain read/write, literal-dot escaping on both sides, backslash escaping, non-printable + space decimal escapes, full round-trip preservation, and the three rejection cases for malformed escapes. Credit: fanf2 (https://news.ycombinator.com/item?id=47612321) Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: stream label writes directly into buffer (review feedback) The first cut of this fix delegated write_qname to a helper (parse_escaped_labels) that built Vec> up-front, then iterated to emit the wire bytes. On a plain-ASCII domain like "www.google.com" that's ~4 heap allocations per write_qname call, and record.rs calls write_qname ~6 times per response — so this PR would regress bench_buffer_serialize by roughly 24 extra allocations per response vs. main, where the old non-escaping code had zero. Rewrite write_qname as a streaming byte-level loop that reserves the length byte up-front, writes the label body directly into the buffer, then backpatches the length via set(). Zero intermediate allocations on the common path, and the 63-byte label cap is now checked incrementally so oversized labels fail fast. Byte-level scanning is safe for UTF-8 input: continuation bytes are always in 0x80..=0xBF, so they can never collide with the ASCII `.` (0x2E) or `\` (0x5C) that drive label splitting and escape parsing. Also inline the \DDD rendering in read_qname to avoid the per-byte format!() allocation on non-printable input. Plain-ASCII reads hit the unchanged push(c as char) fast path, so the common case has zero regression. The parse_escaped_labels helper is deleted — no remaining callers. All 158 tests pass, clippy + fmt clean. Collapses three review findings (HIGH allocation regression, MEDIUM format! allocation, MEDIUM .unwrap() after digit guard) in one pass. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: route dnssec::name_to_wire through write_qname for escape handling Closes #55. dnssec::name_to_wire was a parallel implementation of the old write_qname's splitting loop — it iterated qname.split('.') and pushed raw bytes. It predated and duplicated the buffer.rs logic, and it did not understand RFC 1035 §5.1 text escapes. After the read_qname fix in this PR, names that come out of read_qname can contain \., \\, or \DDD sequences; feeding those back into the old name_to_wire would split on the literal '.' inside a \. sequence and produce corrupt RRSIG signed-data blobs. The underlying bug predates this PR — the old read_qname was broken too, so both sides of the DNSSEC canonical form pipeline were silently wrong in the same way. Making read_qname correct exposed the divergence, so it's fixed here in the same PR that introduced the exposure. Reimplement name_to_wire on top of BytePacketBuffer::write_qname: reserve a scratch buffer, let write_qname handle the escape parsing and length-byte framing, copy the emitted bytes into a Vec, then walk the wire once more to lowercase label bodies (length bytes stay untouched). Canonical form per RFC 4034 §6.2 requires the lowercasing, and it has to happen post-escape-resolution — a decimal escape like \065 produces 0x41 ('A'), which must be lowercased to 'a' in the final wire bytes. Call sites in build_signed_data, record_to_canonical_wire, record_rdata_canonical, and nsec3_hash are unchanged — the public signature stays the same, infallible Vec return. Tests: - name_to_wire_escaped_dot_in_label_is_not_a_separator — verifies the fanf2 example round-trips correctly through canonical form - name_to_wire_decimal_escape_is_lowercased — verifies post-escape lowercasing (the subtle correctness requirement) - existing name_to_wire_root, name_to_wire_domain, ds_verification tests still pass unchanged Test count: 158 → 160. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: tighten name_to_wire per review feedback - Replace hand-rolled per-byte lowercase loop with stdlib [u8]::make_ascii_lowercase(). Shorter and idiomatic. - Tighten the .expect() message to state the actual invariant (parseable DNS name) instead of vague "well-formed" language. - Replace the doc comment's "see #55" with the real invariant — issue numbers rot, and by merge time #55 is closed anyway. The comment now explains WHY the lowercase pass has to happen post-escape-resolution (\065 → 'A' → 'a') instead of during write_qname. - Drop the redundant `\065` test comment (the one-liner version is enough with the assertion showing the transform). No behavior change; 160 tests still pass, clippy + fmt clean. Co-Authored-By: Claude Opus 4.6 (1M context) * test: cover label cap and empty-label rollback; trim doc comments Closes coverage gaps left by PR #54: - write_rejects_label_over_63_bytes: pins the incremental 63-byte cap inside write_qname's inner loop (boundary at 63 vs 64). - write_skips_empty_labels: pins the rollback branch (pos = len_pos) triggered by leading or consecutive dots. Doc comments tightened: - write_qname: drop the streaming-impl walkthrough and the escape-grammar restatement (already documented on read_qname). - name_to_wire: drop the implementation explanation; keep the post-escape lowercasing rationale, which pins behavior a future refactor could silently break. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/buffer.rs | 239 +++++++++++++++++++++++++++++++++++++++++++++++--- src/dnssec.rs | 51 ++++++++--- 2 files changed, 266 insertions(+), 24 deletions(-) diff --git a/src/buffer.rs b/src/buffer.rs index 5db470c..4e954c9 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -84,6 +84,11 @@ impl BytePacketBuffer { /// Read a qname, handling label compression (pointer jumps). /// Converts wire format like [3]www[6]google[3]com[0] into "www.google.com". + /// + /// Label bytes are escaped per RFC 1035 §5.1: + /// - literal `.` within a label → `\.` + /// - literal `\` → `\\` + /// - bytes outside `0x21..=0x7E` (excluding `.` and `\`) → `\DDD` (3-digit decimal) pub fn read_qname(&mut self, outstr: &mut String) -> Result<()> { let mut pos = self.pos(); let mut jumped = false; @@ -121,7 +126,18 @@ impl BytePacketBuffer { let str_buffer = self.get_range(pos, len as usize)?; for &b in str_buffer { - outstr.push(b.to_ascii_lowercase() as char); + let c = b.to_ascii_lowercase(); + match c { + b'.' => outstr.push_str("\\."), + b'\\' => outstr.push_str("\\\\"), + 0x21..=0x7E => outstr.push(c as char), + _ => { + outstr.push('\\'); + outstr.push((b'0' + c / 100) as char); + outstr.push((b'0' + (c / 10) % 10) as char); + outstr.push((b'0' + c % 10) as char); + } + } } delim = "."; @@ -163,24 +179,68 @@ impl BytePacketBuffer { Ok(()) } + /// Write a qname in wire format, parsing RFC 1035 §5.1 text escapes. + /// See `read_qname` for the escape grammar. pub fn write_qname(&mut self, qname: &str) -> Result<()> { if qname.is_empty() || qname == "." { self.write_u8(0)?; return Ok(()); } - for label in qname.split('.') { - let len = label.len(); - if len == 0 { - continue; // skip empty labels from trailing dot - } - if len > 0x3f { - return Err("Single label exceeds 63 characters of length".into()); + let bytes = qname.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let len_pos = self.pos; + self.write_u8(0)?; // placeholder length byte, backpatched below + let body_start = self.pos; + + while i < bytes.len() && bytes[i] != b'.' { + let b = bytes[i]; + if b == b'\\' { + i += 1; + let c1 = *bytes.get(i).ok_or("trailing backslash in qname")?; + if c1.is_ascii_digit() { + let c2 = *bytes + .get(i + 1) + .ok_or("invalid \\DDD escape: expected 3 digits")?; + let c3 = *bytes + .get(i + 2) + .ok_or("invalid \\DDD escape: expected 3 digits")?; + if !c2.is_ascii_digit() || !c3.is_ascii_digit() { + return Err("invalid \\DDD escape: expected 3 digits".into()); + } + let val = + (c1 - b'0') as u16 * 100 + (c2 - b'0') as u16 * 10 + (c3 - b'0') as u16; + if val > 255 { + return Err(format!("\\DDD escape out of range: {}", val).into()); + } + self.write_u8(val as u8)?; + i += 3; + } else { + // \. \\ and any other \X → literal next byte + self.write_u8(c1)?; + i += 1; + } + } else { + self.write_u8(b)?; + i += 1; + } + + if self.pos - body_start > 0x3f { + return Err("Single label exceeds 63 characters of length".into()); + } } - self.write_u8(len as u8)?; - for b in label.as_bytes() { - self.write_u8(*b)?; + let label_len = self.pos - body_start; + if label_len == 0 && i < bytes.len() { + // Empty label from leading/consecutive dots — roll back the placeholder. + self.pos = len_pos; + } else { + self.set(len_pos, label_len as u8)?; + } + + if i < bytes.len() && bytes[i] == b'.' { + i += 1; } } @@ -212,3 +272,160 @@ impl BytePacketBuffer { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn roundtrip(wire: &[u8]) -> String { + let mut buf = BytePacketBuffer::from_bytes(wire); + let mut out = String::new(); + buf.read_qname(&mut out).unwrap(); + out + } + + fn write_then_read(text: &str) -> String { + let mut buf = BytePacketBuffer::new(); + buf.write_qname(text).unwrap(); + let wire_end = buf.pos(); + buf.seek(0).unwrap(); + let mut out = String::new(); + buf.read_qname(&mut out).unwrap(); + assert_eq!( + buf.pos(), + wire_end, + "reader should consume exactly what writer wrote" + ); + out + } + + #[test] + fn read_plain_domain() { + // [3]www[6]google[3]com[0] + let wire = b"\x03www\x06google\x03com\x00"; + assert_eq!(roundtrip(wire), "www.google.com"); + } + + #[test] + fn read_label_with_literal_dot_is_escaped() { + // fanf2's example: [8]exa.mple[3]com[0] — two labels, first contains 0x2E + let wire = b"\x08exa.mple\x03com\x00"; + assert_eq!(roundtrip(wire), "exa\\.mple.com"); + } + + #[test] + fn read_label_with_backslash_is_escaped() { + // [4]a\bc[3]com[0] + let wire = b"\x04a\\bc\x03com\x00"; + assert_eq!(roundtrip(wire), "a\\\\bc.com"); + } + + #[test] + fn read_label_with_nonprintable_byte_uses_decimal_escape() { + // [4]\x00foo[3]com[0] — null byte at label start + let wire = b"\x04\x00foo\x03com\x00"; + assert_eq!(roundtrip(wire), "\\000foo.com"); + } + + #[test] + fn read_label_with_space_uses_decimal_escape() { + // Space (0x20) is outside 0x21..=0x7E, so it must be decimal-escaped. + let wire = b"\x05a b c\x00"; + assert_eq!(roundtrip(wire), "a\\032b\\032c"); + } + + #[test] + fn write_plain_domain() { + let mut buf = BytePacketBuffer::new(); + buf.write_qname("www.google.com").unwrap(); + assert_eq!(&buf.buf[..buf.pos], b"\x03www\x06google\x03com\x00"); + } + + #[test] + fn write_escaped_dot_does_not_split_label() { + let mut buf = BytePacketBuffer::new(); + buf.write_qname("exa\\.mple.com").unwrap(); + assert_eq!(&buf.buf[..buf.pos], b"\x08exa.mple\x03com\x00"); + } + + #[test] + fn write_escaped_backslash() { + let mut buf = BytePacketBuffer::new(); + buf.write_qname("a\\\\bc.com").unwrap(); + assert_eq!(&buf.buf[..buf.pos], b"\x04a\\bc\x03com\x00"); + } + + #[test] + fn write_decimal_escape_yields_raw_byte() { + let mut buf = BytePacketBuffer::new(); + buf.write_qname("\\000foo.com").unwrap(); + assert_eq!(&buf.buf[..buf.pos], b"\x04\x00foo\x03com\x00"); + } + + #[test] + fn write_skips_empty_labels() { + // Leading dot — first (empty) label is rolled back. + let mut buf = BytePacketBuffer::new(); + buf.write_qname(".foo.com").unwrap(); + assert_eq!(&buf.buf[..buf.pos], b"\x03foo\x03com\x00"); + + // Consecutive dots — middle empty label is rolled back. + let mut buf = BytePacketBuffer::new(); + buf.write_qname("foo..com").unwrap(); + assert_eq!(&buf.buf[..buf.pos], b"\x03foo\x03com\x00"); + } + + #[test] + fn write_rejects_out_of_range_decimal_escape() { + let mut buf = BytePacketBuffer::new(); + assert!(buf.write_qname("\\999foo.com").is_err()); + } + + #[test] + fn write_rejects_trailing_backslash() { + let mut buf = BytePacketBuffer::new(); + assert!(buf.write_qname("foo\\").is_err()); + } + + #[test] + fn write_rejects_short_decimal_escape() { + let mut buf = BytePacketBuffer::new(); + assert!(buf.write_qname("\\1").is_err()); + } + + #[test] + fn write_rejects_label_over_63_bytes() { + // 64 bytes exceeds the wire-format label cap. + let mut buf = BytePacketBuffer::new(); + assert!(buf.write_qname(&"a".repeat(64)).is_err()); + + // 63 bytes is the maximum permitted label length. + let mut buf = BytePacketBuffer::new(); + assert!(buf.write_qname(&"a".repeat(63)).is_ok()); + } + + #[test] + fn roundtrip_preserves_dot_in_label() { + assert_eq!(write_then_read("exa\\.mple.com"), "exa\\.mple.com"); + } + + #[test] + fn roundtrip_preserves_backslash_in_label() { + assert_eq!(write_then_read("a\\\\b.com"), "a\\\\b.com"); + } + + #[test] + fn roundtrip_preserves_nonprintable_byte() { + assert_eq!(write_then_read("\\000foo.com"), "\\000foo.com"); + } + + #[test] + fn root_name_empty_and_dot_both_produce_single_zero() { + let mut a = BytePacketBuffer::new(); + a.write_qname("").unwrap(); + let mut b = BytePacketBuffer::new(); + b.write_qname(".").unwrap(); + assert_eq!(&a.buf[..a.pos], b"\x00"); + assert_eq!(&b.buf[..b.pos], b"\x00"); + } +} diff --git a/src/dnssec.rs b/src/dnssec.rs index b336284..8614810 100644 --- a/src/dnssec.rs +++ b/src/dnssec.rs @@ -5,6 +5,7 @@ use log::{debug, trace}; use ring::digest; use ring::signature; +use crate::buffer::BytePacketBuffer; use crate::cache::{DnsCache, DnssecStatus}; use crate::packet::DnsPacket; use crate::question::QueryType; @@ -720,22 +721,29 @@ pub fn verify_ds(ds: &DnsRecord, dnskey: &DnsRecord, owner: &str) -> bool { // -- Canonical wire format -- +/// Encode a DNS name in canonical wire form per RFC 4034 §6.2: +/// uncompressed, with ASCII letters lowercased. +/// +/// Lowercasing happens *after* escape resolution because `\065` yields +/// `'A'`, which canonical form must convert to `'a'`. pub fn name_to_wire(name: &str) -> Vec { - let mut wire = Vec::with_capacity(name.len() + 2); - if name == "." || name.is_empty() { - wire.push(0); - return wire; - } - for label in name.split('.') { - if label.is_empty() { - continue; - } - wire.push(label.len() as u8); - for &b in label.as_bytes() { - wire.push(b.to_ascii_lowercase()); + let mut buf = BytePacketBuffer::new(); + buf.write_qname(name) + .expect("name_to_wire: input must parse as a valid DNS name"); + let mut wire = buf.filled().to_vec(); + + let mut i = 0; + while i < wire.len() { + let label_len = wire[i] as usize; + if label_len == 0 { + break; } + i += 1; + let end = i + label_len; + wire[i..end].make_ascii_lowercase(); + i = end; } - wire.push(0); + wire } @@ -1475,6 +1483,23 @@ mod tests { ); } + #[test] + fn name_to_wire_escaped_dot_in_label_is_not_a_separator() { + // `exa\.mple.com` is two labels: `exa.mple` (8 bytes including the 0x2E) and `com`. + let wire = name_to_wire("exa\\.mple.com"); + assert_eq!( + wire, + vec![8, b'e', b'x', b'a', b'.', b'm', b'p', b'l', b'e', 3, b'c', b'o', b'm', 0] + ); + } + + #[test] + fn name_to_wire_decimal_escape_is_lowercased() { + // \065 = 'A', must become 'a' in canonical form. + let wire = name_to_wire("\\065bc.com"); + assert_eq!(wire, vec![3, b'a', b'b', b'c', 3, b'c', b'o', b'm', 0]); + } + #[test] fn parent_zone_cases() { assert_eq!(parent_zone("example.com"), "com"); -- 2.34.1 From 20bf14e91c2267907db27cd9078a714326c5fbea Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 08:59:46 +0300 Subject: [PATCH 059/204] chore: bump version to 0.10.3 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1bda29d..01b6abf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1133,7 +1133,7 @@ dependencies = [ [[package]] name = "numa" -version = "0.10.2" +version = "0.10.3" dependencies = [ "arc-swap", "axum", diff --git a/Cargo.toml b/Cargo.toml index 961c280..9aaeb27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "numa" -version = "0.10.2" +version = "0.10.3" authors = ["razvandimescu "] edition = "2021" description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS" -- 2.34.1 From 6f961c5ec2873fc307224a0e8ace52e7c24eff52 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 09:03:03 +0300 Subject: [PATCH 060/204] fix: push only specific tag when releaseing a new version --- scripts/release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release.sh b/scripts/release.sh index d4ee882..b4165ea 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -37,7 +37,7 @@ cargo update --workspace git add Cargo.toml Cargo.lock git commit -m "chore: bump version to $VERSION" git tag "$TAG" -git push origin main --tags +git push origin main "$TAG" echo echo "Released $TAG — GitHub Actions will build, publish to crates.io, and create the release." -- 2.34.1 From de15b32325884a8071d6fd8a6dd612405c118508 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 19:08:56 +0300 Subject: [PATCH 061/204] =?UTF-8?q?feat:=20numa=20setup-phone=20=E2=80=94?= =?UTF-8?q?=20QR-based=20mobile=20DoT=20onboarding=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: numa setup-phone — QR-based mobile DoT onboarding Adds a CLI subcommand that generates a one-time mobileconfig profile containing both the Numa local CA (as a com.apple.security.root payload) and the DoT DNS settings, then serves it via a temporary HTTP server and prints a scannable QR code in the terminal. Flow: 1. User runs `numa setup-phone` (no sudo needed) 2. Detects current LAN IP, reads CA from /usr/local/var/numa/ca.pem 3. Builds combined mobileconfig (CA trust + DoT) 4. Renders QR code with qrcode crate (Unicode block characters) 5. Serves the profile on port 8765, stays open until Ctrl+C 6. Counts successful downloads (multi-device households) Important caveat documented in instructions: even with the CA bundled in the profile, iOS still requires the user to manually enable trust in Settings → General → About → Certificate Trust Settings. Verified on a real iPhone. Stable PayloadIdentifiers/UUIDs ensure re-running replaces the existing profile on iOS rather than accumulating duplicates. - New module: src/setup_phone.rs (~270 lines) - New CLI subcommand: `numa setup-phone` - New dependency: qrcode = "0.14" (default-features = false) - tokio "signal" feature added for Ctrl+C handling - 3 unit tests: PEM stripping, mobileconfig generation, QR rendering Co-Authored-By: Claude Opus 4.6 (1M context) * feat: mobile API, enriched /health, mobileconfig module Adds a persistent read-only HTTP listener (default port 8765, LAN-bound) serving a dedicated subset of Numa's API for iOS/Android companion apps and as a replacement for the one-shot server setup_phone used to spin up: GET /health — enriched JSON with version, hostname, LAN IP, SNI, DoT config, mobile API port, CA fingerprint, features (shared handler with the main API on port 5380) GET /ca.pem — public CA certificate (shared handler) GET /mobileconfig — full iOS profile (CA trust + DNS settings pinned to current LAN IP) GET /ca.mobileconfig — CA-only iOS profile (trust anchor without DNS override — for the iOS companion app's programmatic DNS flow via NEDNSSettingsManager) All routes are idempotent GETs. The mobile API never serves the state-mutating routes that live on the main API (overrides, blocking toggle, service CRUD, cache flush), so it is safe to expose on the LAN regardless of the main API's bind address. The CA private key is never served by any route. Opt-in via `[mobile] enabled = true`. Default is false so new installs do not silently expose a LAN listener after upgrading; our committed numa.toml template enables it explicitly for spike testing. New modules: - src/mobileconfig.rs — ProfileMode::{Full, CaOnly} enum with plist builder lifted from setup_phone.rs. Full and CaOnly share the CA payload UUID (same trust anchor) but have distinct top-level UUIDs so they coexist as separate installable profiles on iOS. - src/health.rs — HealthMeta cached metadata built once at startup from config + CA fingerprint (SHA-256 of the PEM via ring), and the HealthResponse JSON shape shared between the main and mobile APIs. - src/mobile_api.rs — axum Router for the persistent listener. Reuses api::health and api::serve_ca from the main API; owns the two mobileconfig handlers. Modified: - src/api.rs — health() returns the enriched HealthResponse, now pub. serve_ca is now pub so mobile_api can reuse it. - src/config.rs — MobileConfig section (enabled, port, bind_addr). - src/ctx.rs — health_meta: HealthMeta field on ServerCtx. - src/main.rs — builds HealthMeta at startup, spawns mobile API listener if enabled. - src/lan.rs — build_announcement takes &HealthMeta and writes enriched TXT records (version, api_port, proto, dot_port, ca_fp). SRV port now reports the mobile API port; peer discovery still reads TXT `services=` so this is backwards compatible. Always announces even when no .numa services are registered, so the iOS companion app can discover Numa via mDNS regardless of service state. - src/setup_phone.rs — reduced from 267 to 100 lines. The CLI is now a thin QR wrapper over the persistent /mobileconfig endpoint; the hand-rolled one-shot HTTP server (accept_loop, RUST_OK_HEADERS, RUST_NOT_FOUND, download counter) is gone. - src/dot.rs — test fixture updated with HealthMeta::test_fixture(). - numa.toml — commented [mobile] section, enabled = true for spike. Tests: 136 unit tests passing (5 new in mobileconfig, 3 new in health). cargo clippy clean. Integration sanity check: curl'd /health, /ca.pem, /mobileconfig, /ca.mobileconfig against a running numa — all return 200 with correct content types and valid response bodies. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: setup-phone probe, unknown command error, query source in dashboard - setup-phone now probes the mobile API before printing the QR code and shows an actionable error if [mobile] is not enabled - Unknown CLI subcommands print an error instead of silently attempting to start a full server - Dashboard query log shows source IP under timestamp (localhost for loopback, full IP for LAN devices) with full addr on hover Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- Cargo.lock | 51 ++++++-- Cargo.toml | 3 +- numa.toml | 19 +++ site/dashboard.html | 12 +- src/api.rs | 27 ++-- src/config.rs | 49 ++++++++ src/ctx.rs | 10 ++ src/dot.rs | 2 + src/health.rs | 254 ++++++++++++++++++++++++++++++++++++++ src/lan.rs | 84 ++++++++++--- src/lib.rs | 18 +++ src/main.rs | 50 +++++++- src/mobile_api.rs | 107 ++++++++++++++++ src/mobileconfig.rs | 294 ++++++++++++++++++++++++++++++++++++++++++++ src/setup_phone.rs | 126 +++++++++++++++++++ 15 files changed, 1065 insertions(+), 41 deletions(-) create mode 100644 src/health.rs create mode 100644 src/mobile_api.rs create mode 100644 src/mobileconfig.rs create mode 100644 src/setup_phone.rs diff --git a/Cargo.lock b/Cargo.lock index 01b6abf..72223f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,9 +84,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" dependencies = [ "rustversion", ] @@ -522,6 +522,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -757,9 +767,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", @@ -772,6 +782,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -1145,6 +1156,7 @@ dependencies = [ "hyper", "hyper-util", "log", + "qrcode", "rcgen", "reqwest", "ring", @@ -1219,6 +1231,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "plotters" version = "0.3.7" @@ -1295,6 +1313,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quinn" version = "0.11.9" @@ -1674,6 +1698,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -1833,14 +1867,15 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.1" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -1848,9 +1883,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 9aaeb27..79a42a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ keywords = ["dns", "dns-server", "ad-blocking", "reverse-proxy", "developer-tool categories = ["network-programming", "development-tools"] [dependencies] -tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "signal"] } axum = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -30,6 +30,7 @@ tokio-rustls = "0.26" arc-swap = "1" ring = "0.17" rustls-pemfile = "2.2.0" +qrcode = { version = "0.14", default-features = false } [dev-dependencies] criterion = { version = "0.8", features = ["html_reports"] } diff --git a/numa.toml b/numa.toml index 77ba231..4389fdb 100644 --- a/numa.toml +++ b/numa.toml @@ -102,3 +102,22 @@ tld = "numa" # enabled = true # discover other Numa instances via mDNS (_numa._tcp.local) # broadcast_interval_secs = 30 # peer_timeout_secs = 90 + +# Mobile API — persistent HTTP listener serving read-only routes +# (/health, /ca.pem, /mobileconfig, /ca.mobileconfig) on a LAN-reachable +# port. Consumed by the iOS/Android companion apps for discovery and +# profile fetching, and by `numa setup-phone` for QR-based onboarding. +# +# Opt-in because the listener binds to the LAN by default. None of the +# exposed routes are cryptographically sensitive (no private keys, no +# state mutations, all idempotent GETs), but enabling it does add a new +# listener to any device on the LAN that scans port 8765. +# +# Safe for home LANs. Think twice before enabling on untrusted LANs +# (office Wi-Fi, coffee shops, etc.) — an attacker on the same network +# could run a competing Numa instance that shadows yours via mDNS and +# trick companion apps into installing their profile instead of yours. +[mobile] +enabled = true # opt-in to the mobile API listener +# port = 8765 # default; matches Discovery.swift defaultAPIPort +# bind_addr = "0.0.0.0" # default; set to "127.0.0.1" for localhost-only diff --git a/site/dashboard.html b/site/dashboard.html index ffc6e0d..c78c48f 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -288,6 +288,7 @@ body { .path-tag.SERVFAIL { background: rgba(181, 68, 58, 0.12); color: var(--rose); } .path-tag.BLOCKED { background: rgba(163, 152, 136, 0.15); color: var(--text-dim); } .path-tag.COALESCED { background: rgba(138, 104, 158, 0.12); color: var(--violet-dim); } +.src-tag { font-size: 0.6rem; color: var(--text-dim); letter-spacing: 0.02em; } /* Sidebar panels */ .sidebar { @@ -787,6 +788,13 @@ function formatTime(epoch) { return d.toLocaleTimeString([], { hour12: false }); } +function shortSrc(addr) { + if (!addr) return ''; + const ip = addr.replace(/:\d+$/, ''); + if (ip === '127.0.0.1' || ip === '::1') return 'localhost'; + return ip; +} + function formatRemaining(secs) { if (secs == null) return 'permanent'; if (secs < 60) return `${secs}s left`; @@ -912,8 +920,8 @@ function applyLogFilter() { ? ` ` : ''; return ` - - ${formatTime(e.timestamp_epoch)} + + ${formatTime(e.timestamp_epoch)}
${shortSrc(e.src)} ${e.query_type} ${e.domain}${allowBtn} ${e.path} diff --git a/src/api.rs b/src/api.rs index 59938b4..fed7d5b 100644 --- a/src/api.rs +++ b/src/api.rs @@ -592,8 +592,19 @@ async fn flush_cache_domain( StatusCode::NO_CONTENT } -async fn health() -> Json { - Json(serde_json::json!({ "status": "ok" })) +/// Enriched `/health` handler shared between the main API and the mobile API. +/// +/// Returns the cached `HealthMeta` assembled with live fields (LAN IP, +/// uptime). Backward compatible with the previous minimal response in +/// that `status` is still the first field and `"ok"` is still the value. +/// The iOS companion app's `HealthInfo` Swift struct decodes the full +/// response; any HTTP client asserting only on `"status"` keeps working. +pub async fn health(State(ctx): State>) -> Json { + let lan_ip = Some(*ctx.lan_ip.lock().unwrap()); + Json(crate::health::HealthResponse::build( + &ctx.health_meta, + lan_ip, + )) } // --- Blocking handlers --- @@ -905,12 +916,8 @@ async fn remove_route( } } -async fn serve_ca(State(ctx): State>) -> Result { - let ca_path = ctx.data_dir.join(crate::tls::CA_FILE_NAME); - let bytes = tokio::task::spawn_blocking(move || std::fs::read(ca_path)) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .map_err(|_| StatusCode::NOT_FOUND)?; +pub async fn serve_ca(State(ctx): State>) -> Result { + let pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?; Ok(( [ (header::CONTENT_TYPE, "application/x-pem-file"), @@ -920,7 +927,7 @@ async fn serve_ca(State(ctx): State>) -> Result String { "0.0.0.0".to_string() } +/// Configuration for the mobile API — a persistent HTTP listener that +/// serves a read-only subset of routes (`/health`, `/ca.pem`, +/// `/mobileconfig`, `/ca.mobileconfig`) on a LAN-reachable port, for +/// consumption by the iOS/Android companion apps. +/// +/// Unlike the main API (port 5380, localhost-only by default, supports +/// state-mutating routes), the mobile API is safe to expose on the LAN +/// because every route is idempotent and read-only. +#[derive(Deserialize, Clone)] +pub struct MobileConfig { + /// If true, spawn the mobile API listener at startup. **Default false.** + /// Opt-in because the listener binds to the LAN by default and exposes + /// a few read-only endpoints to any device on the same network (`/health`, + /// `/ca.pem`, `/mobileconfig`, `/ca.mobileconfig`). None of those are + /// cryptographically sensitive (the CA private key is never served), + /// but users should enable this explicitly rather than have a new + /// LAN-reachable port appear after an upgrade. + #[serde(default)] + pub enabled: bool, + /// Port for the mobile API. Default 8765. + #[serde(default = "default_mobile_port")] + pub port: u16, + /// Bind address for the mobile API. Default "0.0.0.0" (all interfaces) + /// so phones on the LAN can reach it. Set to "127.0.0.1" to restrict + /// to localhost — useful if you're running behind another front-end. + #[serde(default = "default_mobile_bind_addr")] + pub bind_addr: String, +} + +impl Default for MobileConfig { + fn default() -> Self { + MobileConfig { + enabled: false, + port: default_mobile_port(), + bind_addr: default_mobile_bind_addr(), + } + } +} + +fn default_mobile_port() -> u16 { + 8765 +} + +fn default_mobile_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 17a4979..cf3522d 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -18,6 +18,7 @@ use crate::cache::{DnsCache, DnssecStatus}; use crate::config::{UpstreamMode, ZoneMap}; use crate::forward::{forward_query, Upstream}; use crate::header::ResultCode; +use crate::health::HealthMeta; use crate::lan::PeerStore; use crate::override_store::OverrideStore; use crate::packet::DnsPacket; @@ -60,6 +61,15 @@ pub struct ServerCtx { pub inflight: Mutex, pub dnssec_enabled: bool, pub dnssec_strict: bool, + /// Cached health metadata (version, hostname, DoT config, CA + /// fingerprint, features). Shared between the main and mobile + /// API `/health` handlers. Built once at startup in `main.rs`. + pub health_meta: HealthMeta, + /// CA certificate in PEM form, cached at startup. `None` if no + /// TLS-using feature is enabled and the CA hasn't been generated. + /// Used by `/ca.pem`, `/mobileconfig`, and `/ca.mobileconfig` + /// handlers to avoid per-request disk I/O on the hot path. + pub ca_pem: Option, } /// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist, diff --git a/src/dot.rs b/src/dot.rs index a09b160..32d32ba 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -381,6 +381,8 @@ mod tests { inflight: Mutex::new(HashMap::new()), dnssec_enabled: false, dnssec_strict: false, + health_meta: crate::health::HealthMeta::test_fixture(), + ca_pem: None, }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/src/health.rs b/src/health.rs new file mode 100644 index 0000000..b2359c4 --- /dev/null +++ b/src/health.rs @@ -0,0 +1,254 @@ +//! Health metadata and `/health` response shape, shared between the main +//! HTTP API and the mobile API. +//! +//! The static fields (version, hostname, DoT config, CA fingerprint, +//! feature list) are computed once at startup and stored in [`HealthMeta`] +//! on `ServerCtx`. Per-request fields (uptime, LAN IP) are computed live. +//! Both handlers call [`HealthResponse::build`] to assemble the JSON +//! response from `HealthMeta` + live inputs. +//! +//! JSON schema is documented in `docs/implementation/ios-companion-app.md` +//! §4.2. The iOS companion app's `HealthInfo` struct is the canonical +//! consumer; any change to this response must keep that struct decoding +//! cleanly (all consumed fields are optional on the Swift side, but +//! `lan_ip` is load-bearing for the pipeline). + +use std::net::Ipv4Addr; +use std::path::Path; +use std::time::Instant; + +use ring::digest::{digest, SHA256}; +use serde::Serialize; + +/// Immutable health metadata cached on `ServerCtx`. Built once at startup +/// from config + file-system state (CA cert). +#[derive(Clone)] +pub struct HealthMeta { + pub version: &'static str, + pub hostname: String, + pub sni: String, + pub dot_enabled: bool, + pub dot_port: u16, + pub api_port: u16, + pub ca_fingerprint_sha256: Option, + pub features: Vec, + pub started_at: Instant, +} + +impl HealthMeta { + /// Minimal `HealthMeta` for unit tests that construct a `ServerCtx` + /// without needing the real startup flow (CA file reads, hostname + /// detection, etc.). Deterministic values so test JSON assertions + /// stay stable. + #[cfg(test)] + pub fn test_fixture() -> Self { + HealthMeta { + version: env!("CARGO_PKG_VERSION"), + hostname: "test-host".to_string(), + sni: "numa.numa".to_string(), + dot_enabled: false, + dot_port: 853, + api_port: 8765, + ca_fingerprint_sha256: None, + features: vec![], + started_at: Instant::now(), + } + } + + /// Build a new HealthMeta from config + startup-time environment. + /// Call once at server boot; the returned value is cheap to clone + /// (small number of short strings) and lives on `ServerCtx`. + /// + /// The argument count is deliberate — each flag corresponds to a + /// specific config value and is clearly named at the call site. + /// Collapsing into a struct hides nothing meaningful for a one-call + /// initializer. + #[allow(clippy::too_many_arguments)] + pub fn build( + data_dir: &Path, + dot_enabled: bool, + dot_port: u16, + api_port: u16, + dnssec_enabled: bool, + recursive_enabled: bool, + mdns_enabled: bool, + blocking_enabled: bool, + ) -> Self { + let ca_path = data_dir.join("ca.pem"); + let ca_fingerprint_sha256 = compute_ca_fingerprint(&ca_path); + + let mut features = Vec::new(); + if dot_enabled { + features.push("dot".to_string()); + } + if recursive_enabled { + features.push("recursive".to_string()); + } + if blocking_enabled { + features.push("blocking".to_string()); + } + if mdns_enabled { + features.push("mdns".to_string()); + } + if dnssec_enabled { + features.push("dnssec".to_string()); + } + + HealthMeta { + version: env!("CARGO_PKG_VERSION"), + hostname: crate::hostname(), + sni: "numa.numa".to_string(), + dot_enabled, + dot_port, + api_port, + ca_fingerprint_sha256, + features, + started_at: Instant::now(), + } + } +} + +/// JSON response shape returned by `GET /health` on both main and mobile APIs. +/// +/// Fields are organized to match the iOS companion app's +/// `HealthInfo` Swift struct — see `ios-companion-app.md` §4.2. +#[derive(Serialize)] +pub struct HealthResponse { + pub status: &'static str, + pub version: &'static str, + pub uptime_secs: u64, + pub hostname: String, + pub lan_ip: Option, + pub sni: String, + pub dot: DotBlock, + pub api: ApiBlock, + pub ca: CaBlock, + pub features: Vec, +} + +#[derive(Serialize)] +pub struct DotBlock { + pub enabled: bool, + pub port: Option, +} + +#[derive(Serialize)] +pub struct ApiBlock { + pub port: u16, +} + +#[derive(Serialize)] +pub struct CaBlock { + pub present: bool, + pub fingerprint_sha256: Option, +} + +impl HealthResponse { + /// Assemble a fresh `HealthResponse` from the cached metadata and + /// the current LAN IP (which may change across network transitions). + /// Pass `None` for `lan_ip` if detection fails — the response still + /// returns 200 OK, just without the LAN address. + pub fn build(meta: &HealthMeta, lan_ip: Option) -> Self { + HealthResponse { + status: "ok", + version: meta.version, + uptime_secs: meta.started_at.elapsed().as_secs(), + hostname: meta.hostname.clone(), + lan_ip: lan_ip.map(|ip| ip.to_string()), + sni: meta.sni.clone(), + dot: DotBlock { + enabled: meta.dot_enabled, + port: if meta.dot_enabled { + Some(meta.dot_port) + } else { + None + }, + }, + api: ApiBlock { + port: meta.api_port, + }, + ca: CaBlock { + present: meta.ca_fingerprint_sha256.is_some(), + fingerprint_sha256: meta.ca_fingerprint_sha256.clone(), + }, + features: meta.features.clone(), + } + } +} + +/// Read the CA cert at `ca_path` and return its SHA-256 fingerprint as a +/// lowercase hex string, or None if the file doesn't exist or can't be read. +/// +/// Hashes the raw PEM bytes for simplicity. A more canonical SPKI-based +/// fingerprint would require parsing the PEM → DER → extracting +/// SubjectPublicKeyInfo, which adds complexity without meaningful benefit +/// for our use case (the iOS app uses the fingerprint only for display +/// and to detect rotation). +fn compute_ca_fingerprint(ca_path: &Path) -> Option { + let pem = std::fs::read(ca_path).ok()?; + let hash = digest(&SHA256, &pem); + let hex: String = hash.as_ref().iter().map(|b| format!("{:02x}", b)).collect(); + Some(hex) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn health_response_contains_required_fields() { + let meta = HealthMeta { + version: "0.10.0", + hostname: "test-host".to_string(), + sni: "numa.numa".to_string(), + dot_enabled: true, + dot_port: 853, + api_port: 8765, + ca_fingerprint_sha256: Some("abcd1234".to_string()), + features: vec!["dot".to_string(), "dnssec".to_string()], + started_at: Instant::now(), + }; + + let response = HealthResponse::build(&meta, Some(Ipv4Addr::new(192, 168, 1, 50))); + let json = serde_json::to_string(&response).unwrap(); + + assert!(json.contains("\"status\":\"ok\"")); + assert!(json.contains("\"version\":\"0.10.0\"")); + assert!(json.contains("\"hostname\":\"test-host\"")); + assert!(json.contains("\"lan_ip\":\"192.168.1.50\"")); + assert!(json.contains("\"sni\":\"numa.numa\"")); + assert!(json.contains("\"port\":853")); + assert!(json.contains("\"port\":8765")); + assert!(json.contains("\"fingerprint_sha256\":\"abcd1234\"")); + assert!(json.contains("\"features\":[\"dot\",\"dnssec\"]")); + } + + #[test] + fn health_response_omits_dot_port_when_disabled() { + let meta = HealthMeta { + version: "0.10.0", + hostname: "t".to_string(), + sni: "numa.numa".to_string(), + dot_enabled: false, + dot_port: 853, + api_port: 8765, + ca_fingerprint_sha256: None, + features: vec![], + started_at: Instant::now(), + }; + + let response = HealthResponse::build(&meta, None); + let json = serde_json::to_string(&response).unwrap(); + + assert!(json.contains("\"enabled\":false")); + assert!(json.contains("\"dot\":{\"enabled\":false,\"port\":null}")); + assert!(json.contains("\"present\":false")); + assert!(json.contains("\"lan_ip\":null")); + } + + #[test] + fn ca_fingerprint_returns_none_for_missing_file() { + let fp = compute_ca_fingerprint(Path::new("/nonexistent/ca.pem")); + assert!(fp.is_none()); + } +} diff --git a/src/lan.rs b/src/lan.rs index db210e9..8d0b9cf 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -9,6 +9,7 @@ use crate::buffer::BytePacketBuffer; use crate::config::LanConfig; use crate::ctx::ServerCtx; use crate::header::DnsHeader; +use crate::health::HealthMeta; use crate::question::{DnsQuestion, QueryType}; // --- Constants --- @@ -18,6 +19,18 @@ const MDNS_PORT: u16 = 5353; const SERVICE_TYPE: &str = "_numa._tcp.local"; const MDNS_TTL: u32 = 120; +// TXT record key prefixes (including the trailing `=`). Shared between +// the sender (`build_announcement`) and the receiver (`parse_mdns_response`) +// to prevent drift — both sides match on the same literal, not on two +// independent string constants that could diverge. +const TXT_SERVICES: &str = "services="; +const TXT_ID: &str = "id="; +const TXT_VERSION: &str = "version="; +const TXT_API_PORT: &str = "api_port="; +const TXT_PROTO: &str = "proto="; +const TXT_DOT_PORT: &str = "dot_port="; +const TXT_CA_FP: &str = "ca_fp="; + // --- Peer Store --- pub struct PeerStore { @@ -97,14 +110,16 @@ pub fn detect_lan_ip() -> Option { } } +/// Short hostname for mDNS instance names (`._numa._tcp.local`). +/// Truncates at the first `.` so `macbook-pro.local` becomes `macbook-pro`. +/// Uses the shared `crate::hostname()` helper as the source. fn get_hostname() -> String { - std::process::Command::new("hostname") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|h| h.trim().split('.').next().unwrap_or("numa").to_string()) - .filter(|h| !h.is_empty()) - .unwrap_or_else(|| "numa".to_string()) + crate::hostname() + .split('.') + .next() + .filter(|s| !s.is_empty()) + .unwrap_or("numa") + .to_string() } /// Generate a per-process instance ID for self-filtering on multi-instance hosts @@ -168,13 +183,22 @@ pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { .map(|e| (e.name.clone(), e.target_port)) .collect() }; - if services.is_empty() { - continue; - } + // Note: we always announce ourselves, even when the + // services list is empty. The announcement still carries + // the mobile API port + version + CA fingerprint in TXT, + // which is what the iOS companion app browses for via + // NWBrowser on `_numa._tcp.local`. Other Numa peers + // receive these empty-services announcements too and + // correctly ignore them in parse_mdns_response (the + // receiver only processes when services is non-empty). let current_ip = *sender_ctx.lan_ip.lock().unwrap(); - if let Ok(pkt) = - build_announcement(&sender_hostname, current_ip, &services, &sender_instance_id) - { + if let Ok(pkt) = build_announcement( + &sender_hostname, + current_ip, + &services, + &sender_instance_id, + &sender_ctx.health_meta, + ) { let _ = sender_socket.send_to(pkt.filled(), dest).await; } } @@ -240,6 +264,7 @@ fn build_announcement( ip: Ipv4Addr, services: &[(String, u16)], inst_id: &str, + meta: &HealthMeta, ) -> crate::Result { let mut buf = BytePacketBuffer::new(); let instance_name = format!("{}._numa._tcp.local", hostname); @@ -260,7 +285,11 @@ fn build_announcement( patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; // SRV: ._numa._tcp.local → .local - // Port in SRV is informational; actual service ports are in TXT + // Port = mobile API port, which is what the iOS companion app resolves + // the SRV record for. Legacy Numa peers don't read the SRV port (see + // parse_mdns_response — it only uses TXT services= for peer discovery), + // so changing the SRV port from "first service's port" to the mobile + // API port is backwards compatible. write_record_header( &mut buf, &instance_name, @@ -273,11 +302,13 @@ fn build_announcement( let rdata_start = buf.pos(); buf.write_u16(0)?; // priority buf.write_u16(0)?; // weight - buf.write_u16(services.first().map(|(_, p)| *p).unwrap_or(0))?; // first service port for SRV display + buf.write_u16(meta.api_port)?; // mobile API port, for iOS companion app buf.write_qname(&host_local)?; patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; - // TXT: services + instance ID for self-filtering + // TXT: legacy peer-discovery entries (services, id) + enriched entries + // for the iOS companion app (version, api_port, proto, dot_port, ca_fp). + // All in one TXT RRset per mDNS convention. write_record_header( &mut buf, &instance_name, @@ -293,8 +324,21 @@ fn build_announcement( .map(|(name, port)| format!("{}:{}", name, port)) .collect::>() .join(","); - write_txt_string(&mut buf, &format!("services={}", svc_str))?; - write_txt_string(&mut buf, &format!("id={}", inst_id))?; + // Legacy peer-discovery entries (consumed by parse_mdns_response) + write_txt_string(&mut buf, &format!("{}{}", TXT_SERVICES, svc_str))?; + write_txt_string(&mut buf, &format!("{}{}", TXT_ID, inst_id))?; + // Enriched entries (consumed by the iOS/Android companion apps) + write_txt_string(&mut buf, &format!("{}{}", TXT_VERSION, meta.version))?; + write_txt_string(&mut buf, &format!("{}{}", TXT_API_PORT, meta.api_port))?; + if meta.dot_enabled { + write_txt_string(&mut buf, &format!("{}dot", TXT_PROTO))?; + write_txt_string(&mut buf, &format!("{}{}", TXT_DOT_PORT, meta.dot_port))?; + } else { + write_txt_string(&mut buf, &format!("{}plain", TXT_PROTO))?; + } + if let Some(fp) = &meta.ca_fingerprint_sha256 { + write_txt_string(&mut buf, &format!("{}{}", TXT_CA_FP, fp))?; + } patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; // A: .local → IP @@ -408,7 +452,7 @@ fn parse_mdns_response(data: &[u8]) -> Option { break; } if let Ok(txt) = std::str::from_utf8(&data[pos..pos + txt_len]) { - if let Some(val) = txt.strip_prefix("services=") { + if let Some(val) = txt.strip_prefix(TXT_SERVICES) { let svcs: Vec<(String, u16)> = val .split(',') .filter_map(|s| { @@ -421,7 +465,7 @@ fn parse_mdns_response(data: &[u8]) -> Option { if !svcs.is_empty() { txt_services = Some(svcs); } - } else if let Some(id) = txt.strip_prefix("id=") { + } else if let Some(id) = txt.strip_prefix(TXT_ID) { peer_instance_id = Some(id.to_string()); } } diff --git a/src/lib.rs b/src/lib.rs index 6455506..066c7ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,10 @@ pub mod dnssec; pub mod dot; pub mod forward; pub mod header; +pub mod health; pub mod lan; +pub mod mobile_api; +pub mod mobileconfig; pub mod override_store; pub mod packet; pub mod proxy; @@ -17,6 +20,7 @@ pub mod question; pub mod record; pub mod recursive; pub mod service_store; +pub mod setup_phone; pub mod srtt; pub mod stats; pub mod system_dns; @@ -25,6 +29,20 @@ pub mod tls; pub type Error = Box; pub type Result = std::result::Result; +/// Detect the machine hostname via the `hostname` command. Returns the +/// full hostname (e.g., `macbook-pro.local`), or `"numa"` if the command +/// fails. Call sites that need the short form (e.g., mDNS instance +/// names) should truncate at the first `.`. +pub fn hostname() -> String { + std::process::Command::new("hostname") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|h| h.trim().to_string()) + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| "numa".to_string()) +} + /// Shared config directory for persistent data (services.json, etc). /// Unix users: ~/.config/numa/ /// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa diff --git a/src/main.rs b/src/main.rs index b335016..70bc3f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,6 +54,9 @@ async fn main() -> numa::Result<()> { } }; } + "setup-phone" => { + return numa::setup_phone::run().await.map_err(|e| e.into()); + } "lan" => { let sub = std::env::args().nth(2).unwrap_or_default(); let config_path = std::env::args() @@ -85,12 +88,27 @@ async fn main() -> numa::Result<()> { eprintln!(" service status Check if the service is running"); eprintln!(" lan on Enable LAN service discovery (mDNS)"); eprintln!(" lan off Disable LAN service discovery"); + eprintln!(" setup-phone Generate a QR code to install Numa DoT on a phone"); eprintln!(" help Show this help"); eprintln!(); eprintln!("Config path defaults to numa.toml"); return Ok(()); } - _ => {} + _ => { + if !arg1.is_empty() + && arg1 != "run" + && !arg1.contains('/') + && !arg1.contains('\\') + && !arg1.ends_with(".toml") + { + eprintln!( + "\x1b[1;38;2;192;98;58mNuma\x1b[0m — unknown command: \x1b[1m{}\x1b[0m\n", + arg1 + ); + eprintln!("Run \x1b[1mnuma help\x1b[0m for a list of commands."); + std::process::exit(1); + } + } } let config_path = if arg1.is_empty() || arg1 == "run" { @@ -235,6 +253,19 @@ async fn main() -> numa::Result<()> { None }; + let health_meta = numa::health::HealthMeta::build( + &resolved_data_dir, + config.dot.enabled, + config.dot.port, + config.mobile.port, + config.dnssec.enabled, + resolved_mode == numa::config::UpstreamMode::Recursive, + config.lan.enabled, + config.blocking.enabled, + ); + + let ca_pem = std::fs::read_to_string(resolved_data_dir.join("ca.pem")).ok(); + let socket = match UdpSocket::bind(&config.server.bind_addr).await { Ok(s) => s, Err(e) => { @@ -286,6 +317,8 @@ async fn main() -> numa::Result<()> { inflight: std::sync::Mutex::new(std::collections::HashMap::new()), dnssec_enabled: config.dnssec.enabled, dnssec_strict: config.dnssec.strict, + health_meta, + ca_pem, }); let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); @@ -469,6 +502,21 @@ async fn main() -> numa::Result<()> { axum::serve(listener, app).await.unwrap(); }); + // Spawn Mobile API listener (read-only subset for iOS/Android companion + // apps, LAN-bound by default so phones can reach it). Only idempotent + // GETs; no state-mutating routes are exposed here regardless of + // the main API's bind address. + if config.mobile.enabled { + let mobile_ctx = Arc::clone(&ctx); + let mobile_bind = config.mobile.bind_addr.clone(); + let mobile_port = config.mobile.port; + tokio::spawn(async move { + if let Err(e) = numa::mobile_api::start(mobile_ctx, mobile_bind, mobile_port).await { + log::warn!("Mobile API listener failed: {}", e); + } + }); + } + let proxy_bind: std::net::Ipv4Addr = config .proxy .bind_addr diff --git a/src/mobile_api.rs b/src/mobile_api.rs new file mode 100644 index 0000000..8925846 --- /dev/null +++ b/src/mobile_api.rs @@ -0,0 +1,107 @@ +//! Mobile API — persistent HTTP listener for iOS/Android companion apps. +//! +//! Read-only subset of Numa's HTTP surface served on a separate port +//! (default 8765) bound to the LAN. Unlike the main API on port 5380 +//! (which defaults to `127.0.0.1` and serves mutating routes like +//! `DELETE /services/{name}` or `PUT /blocking/toggle`), this listener +//! is safe to expose on the LAN because every route is idempotent and +//! read-only. +//! +//! Routes (all GET): +//! +//! - `/health` — enriched status + metadata, shares the handler with the +//! main API via `crate::api::health` +//! - `/ca.pem` — Numa local CA in PEM form, shares the handler with the +//! main API via `crate::api::serve_ca` +//! - `/mobileconfig` — combined CA + DNS settings profile (Full mode) +//! - `/ca.mobileconfig` — CA-only trust profile (no DNS override) +//! +//! The mobile API does NOT include the mutating routes (overrides, cache +//! flush, blocking toggle, service CRUD, etc.). Even if a user sets +//! `api_bind_addr` to `0.0.0.0` for the main API, those routes stay on +//! port 5380; the mobile API on port 8765 never serves them. This is the +//! primary security boundary: anything exposed to the LAN is read-only. + +use std::net::Ipv4Addr; +use std::sync::Arc; + +use axum::extract::State; +use axum::http::{header, StatusCode}; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::Router; +use log::info; + +use crate::ctx::ServerCtx; +use crate::mobileconfig::{build_mobileconfig, ProfileMode}; + +/// Content-Disposition for the full CA + DNS profile download. +const FULL_PROFILE_DISPOSITION: &str = "attachment; filename=\"numa.mobileconfig\""; + +/// Content-Disposition for the CA-only profile download. +const CA_ONLY_PROFILE_DISPOSITION: &str = "attachment; filename=\"numa-ca.mobileconfig\""; + +/// Build the axum router for the mobile API. +/// +/// Shares handler functions with the main API where possible (`health`, +/// `serve_ca`) so the response shapes are identical across both ports. +pub fn router(ctx: Arc) -> Router { + Router::new() + .route("/health", get(crate::api::health)) + .route("/ca.pem", get(crate::api::serve_ca)) + .route("/mobileconfig", get(serve_full_mobileconfig)) + .route("/ca.mobileconfig", get(serve_ca_only_mobileconfig)) + .with_state(ctx) +} + +/// Start the mobile API listener on `bind_addr:port`. Runs until the +/// caller cancels the spawned task. Logs the URL on successful bind. +pub async fn start(ctx: Arc, bind_addr: String, port: u16) -> crate::Result<()> { + let addr: std::net::SocketAddr = format!("{}:{}", bind_addr, port).parse()?; + let listener = tokio::net::TcpListener::bind(addr).await?; + + info!("Mobile API listening on http://{}", addr); + + let app = router(ctx); + axum::serve(listener, app).await?; + + Ok(()) +} + +/// Serve the full mobileconfig profile (CA + DNS settings), with the +/// DNS payload pointing at the current LAN IP. Each request reads the +/// fresh LAN IP from `ctx.lan_ip` so the profile always reflects the +/// laptop's current network state. +async fn serve_full_mobileconfig( + State(ctx): State>, +) -> Result { + let ca_pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?; + let lan_ip: Ipv4Addr = *ctx.lan_ip.lock().unwrap(); + let profile = build_mobileconfig(ProfileMode::Full { lan_ip }, ca_pem); + Ok(profile_response(profile, FULL_PROFILE_DISPOSITION)) +} + +/// Serve the CA-only mobileconfig profile. Trusts the Numa local CA but +/// does NOT change the device's DNS settings. Used by the iOS companion +/// app's DoT mode, where the app configures DNS via `NEDNSSettingsManager` +/// and only needs the system trust store to accept Numa's self-signed cert. +async fn serve_ca_only_mobileconfig( + State(ctx): State>, +) -> Result { + let ca_pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?; + let profile = build_mobileconfig(ProfileMode::CaOnly, ca_pem); + Ok(profile_response(profile, CA_ONLY_PROFILE_DISPOSITION)) +} + +/// Shared response constructor for both mobileconfig variants. +/// Identical headers; only the Content-Disposition filename differs. +fn profile_response(profile: String, disposition: &'static str) -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/x-apple-aspen-config"), + (header::CONTENT_DISPOSITION, disposition), + (header::CACHE_CONTROL, "no-store"), + ], + profile, + ) +} diff --git a/src/mobileconfig.rs b/src/mobileconfig.rs new file mode 100644 index 0000000..513d198 --- /dev/null +++ b/src/mobileconfig.rs @@ -0,0 +1,294 @@ +//! Apple `.mobileconfig` profile generator. +//! +//! Builds iOS Configuration Profiles that Numa serves to phones for one-tap +//! CA trust and DNS-over-TLS setup. The plist structure is hand-rendered +//! via `format!` — no plist crate dependency, deterministic output, small +//! binary footprint. +//! +//! Two modes: +//! +//! - [`ProfileMode::Full`]: CA trust payload + DNS settings payload pointing +//! at a specific LAN IP over DoT. This is what `numa setup-phone` has +//! always produced — the user scans a QR, installs this profile, and the +//! phone is configured for DoT through Numa in a single step (after the +//! iOS Certificate Trust Settings toggle, which is a separate system +//! gate we can't bypass). +//! +//! - [`ProfileMode::CaOnly`]: CA trust payload only, no DNS settings. Used +//! by the future iOS companion app flow where `NEDNSSettingsManager` +//! configures DNS programmatically and we only need the system trust +//! store to accept Numa's DoT cert. Installing this profile does NOT +//! change the user's DNS at all. +//! +//! Payload identifiers and UUIDs are fixed (not randomized) so iOS replaces +//! the existing profile on re-install rather than accumulating duplicates. +//! The `Full` and `CaOnly` profiles have distinct top-level UUIDs so they +//! can coexist as separate installed profiles, but they share the same CA +//! payload UUID since the CA itself is the same trust anchor in both. + +use std::net::Ipv4Addr; + +/// Top-level UUID and PayloadIdentifier for the full profile (CA + DNS). +/// Changing this breaks in-place replacement on existing iOS installs. +const FULL_PROFILE_UUID: &str = "F1E2D3C4-B5A6-7890-1234-567890ABCDEF"; +const FULL_PROFILE_ID: &str = "com.numa.dns.profile"; + +/// Top-level UUID and PayloadIdentifier for the CA-only profile. +/// Distinct from `FULL_PROFILE_UUID` so a user can install one, the other, +/// or both without the latest install silently replacing a different mode. +const CA_ONLY_PROFILE_UUID: &str = "F2E3D4C5-B6A7-8901-2345-67890ABCDEF0"; +const CA_ONLY_PROFILE_ID: &str = "com.numa.dns.ca.profile"; + +/// CA trust payload UUID. Same in both modes — iOS will see "the same CA +/// trust anchor" regardless of which wrapping profile contains it. +const CA_PAYLOAD_UUID: &str = "B2C3D4E5-F6A7-8901-BCDE-F12345678901"; +const CA_PAYLOAD_ID: &str = "com.numa.dns.ca"; + +/// DNS settings payload UUID (Full mode only). +const DNS_PAYLOAD_UUID: &str = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890"; +const DNS_PAYLOAD_ID: &str = "com.numa.dns.dot"; + +/// Profile mode determines which payloads are included in the generated +/// `.mobileconfig`. +#[derive(Debug, Clone)] +pub enum ProfileMode { + /// Full profile: CA trust anchor + managed DNS settings payload + /// pointing at the given LAN IP over DoT. This is what the classic + /// `numa setup-phone` QR flow serves. + Full { lan_ip: Ipv4Addr }, + + /// CA-only profile: just the trust anchor, no DNS settings. For use + /// with the iOS companion app which manages DNS programmatically via + /// `NEDNSSettingsManager` and only needs the system trust store to + /// accept Numa's self-signed DoT cert. + CaOnly, +} + +/// Build a full `.mobileconfig` profile as an XML plist string. +pub fn build_mobileconfig(mode: ProfileMode, ca_pem: &str) -> String { + let ca_payload = build_ca_payload(ca_pem); + + match mode { + ProfileMode::Full { lan_ip } => { + let dns_payload = build_dns_payload(lan_ip); + let payloads = format!("{}\n{}", ca_payload, dns_payload); + let description = format!( + "Trusts the Numa local CA and routes DNS queries to Numa over DoT on your local network ({lan_ip})" + ); + wrap_plist( + &payloads, + FULL_PROFILE_UUID, + FULL_PROFILE_ID, + &description, + "Numa DNS", + ) + } + ProfileMode::CaOnly => wrap_plist( + &ca_payload, + CA_ONLY_PROFILE_UUID, + CA_ONLY_PROFILE_ID, + "Trusts the Numa local Certificate Authority. Does not change your DNS settings.", + "Numa CA", + ), + } +} + +/// Strip the PEM header/footer and newlines from a CA cert, leaving raw +/// base64 for embedding in a plist `` block. +fn pem_to_base64(pem: &str) -> String { + pem.lines() + .filter(|line| !line.starts_with("-----")) + .collect::() +} + +/// Wrap the base64 CA cert at 52 chars per line for plist readability +/// (matches Apple convention in hand-written profiles). +fn chunk_base64(base64: &str) -> String { + base64 + .chars() + .collect::>() + .chunks(52) + .map(|chunk| format!("\t\t\t{}", chunk.iter().collect::())) + .collect::>() + .join("\n") +} + +/// Render the `com.apple.security.root` payload dict containing the CA cert. +fn build_ca_payload(ca_pem: &str) -> String { + let ca_wrapped = chunk_base64(&pem_to_base64(ca_pem)); + format!( + r#" + PayloadCertificateFileName + numa-ca.pem + PayloadContent + +{ca} + + PayloadDescription + Numa local Certificate Authority — required for DoT trust + PayloadDisplayName + Numa Local CA + PayloadIdentifier + {ca_id} + PayloadType + com.apple.security.root + PayloadUUID + {ca_uuid} + PayloadVersion + 1 + "#, + ca = ca_wrapped, + ca_id = CA_PAYLOAD_ID, + ca_uuid = CA_PAYLOAD_UUID, + ) +} + +/// Render the `com.apple.dnsSettings.managed` payload dict for Full mode. +/// Pins the device to Numa as its system resolver over DoT with +/// `ServerName = "numa.numa"` (must match the DoT cert SAN). +fn build_dns_payload(lan_ip: Ipv4Addr) -> String { + format!( + r#" + DNSSettings + + DNSProtocol + TLS + ServerAddresses + + {ip} + + ServerName + numa.numa + + PayloadDescription + Routes all DNS queries through Numa over DNS-over-TLS + PayloadDisplayName + Numa DNS-over-TLS + PayloadIdentifier + {dns_id} + PayloadType + com.apple.dnsSettings.managed + PayloadUUID + {dns_uuid} + PayloadVersion + 1 + "#, + ip = lan_ip, + dns_id = DNS_PAYLOAD_ID, + dns_uuid = DNS_PAYLOAD_UUID, + ) +} + +/// Wrap one or more payload dicts in the top-level plist structure +/// with Configuration type, PayloadContent array, and profile metadata. +fn wrap_plist( + payloads: &str, + top_uuid: &str, + top_id: &str, + description: &str, + display_name: &str, +) -> String { + format!( + r#" + + + + PayloadContent + +{payloads} + + PayloadDescription + {description} + PayloadDisplayName + {display_name} + PayloadIdentifier + {top_id} + PayloadRemovalDisallowed + + PayloadType + Configuration + PayloadUUID + {top_uuid} + PayloadVersion + 1 + + +"#, + payloads = payloads, + description = description, + display_name = display_name, + top_id = top_id, + top_uuid = top_uuid, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_PEM: &str = + "-----BEGIN CERTIFICATE-----\nMIIBkDCCATagAwIBAgIUTEST\n-----END CERTIFICATE-----\n"; + + #[test] + fn pem_to_base64_strips_headers() { + let pem = "-----BEGIN CERTIFICATE-----\nABCDEF\nGHIJKL\n-----END CERTIFICATE-----\n"; + assert_eq!(pem_to_base64(pem), "ABCDEFGHIJKL"); + } + + #[test] + fn full_profile_contains_ip_and_ca() { + let config = build_mobileconfig( + ProfileMode::Full { + lan_ip: Ipv4Addr::new(192, 168, 1, 100), + }, + SAMPLE_PEM, + ); + assert!(config.contains("192.168.1.100")); + assert!(config.contains("MIIBkDCCATagAwIBAgIUTEST")); + assert!(config.contains("com.apple.security.root")); + assert!(config.contains("com.apple.dnsSettings.managed")); + assert!(config.contains("DNSProtocol")); + assert!(config.contains(FULL_PROFILE_UUID)); + assert!(config.contains(FULL_PROFILE_ID)); + } + + #[test] + fn ca_only_profile_contains_ca_but_not_dns() { + let config = build_mobileconfig(ProfileMode::CaOnly, SAMPLE_PEM); + assert!(config.contains("MIIBkDCCATagAwIBAgIUTEST")); + assert!(config.contains("com.apple.security.root")); + assert!(!config.contains("com.apple.dnsSettings.managed")); + assert!(!config.contains("DNSProtocol")); + assert!(!config.contains("ServerAddresses")); + assert!(config.contains(CA_ONLY_PROFILE_UUID)); + assert!(config.contains(CA_ONLY_PROFILE_ID)); + } + + #[test] + fn full_and_ca_only_have_distinct_top_uuids() { + let full = build_mobileconfig( + ProfileMode::Full { + lan_ip: Ipv4Addr::new(10, 0, 0, 1), + }, + SAMPLE_PEM, + ); + let ca_only = build_mobileconfig(ProfileMode::CaOnly, SAMPLE_PEM); + assert!(full.contains(FULL_PROFILE_UUID)); + assert!(!full.contains(CA_ONLY_PROFILE_UUID)); + assert!(ca_only.contains(CA_ONLY_PROFILE_UUID)); + assert!(!ca_only.contains(FULL_PROFILE_UUID)); + } + + #[test] + fn both_modes_share_ca_payload_uuid() { + let full = build_mobileconfig( + ProfileMode::Full { + lan_ip: Ipv4Addr::new(10, 0, 0, 1), + }, + SAMPLE_PEM, + ); + let ca_only = build_mobileconfig(ProfileMode::CaOnly, SAMPLE_PEM); + assert!(full.contains(CA_PAYLOAD_UUID)); + assert!(ca_only.contains(CA_PAYLOAD_UUID)); + } +} diff --git a/src/setup_phone.rs b/src/setup_phone.rs new file mode 100644 index 0000000..fd37c84 --- /dev/null +++ b/src/setup_phone.rs @@ -0,0 +1,126 @@ +//! `numa setup-phone` CLI — thin QR wrapper over the persistent mobile API. +//! +//! Before the mobile API existed, this command spawned its own one-shot +//! HTTP server on port 8765 to serve a freshly-generated mobileconfig +//! for a single download. That role now belongs to +//! [`crate::mobile_api`], which runs persistently alongside the main +//! API and serves `/mobileconfig` at the same port whenever Numa is +//! running. +//! +//! This command is now a thin terminal-side wrapper: +//! +//! 1. Detect the current LAN IP +//! 2. Render a terminal QR code pointing at +//! `http://:8765/mobileconfig` +//! 3. Print install instructions and exit +//! +//! The user scans the QR, iOS fetches the profile from the mobile API +//! (which is always up as long as `numa` is running), installs, and the +//! user walks through Settings → Certificate Trust Settings to enable +//! trust. +//! +//! Numa must be running for the profile download to succeed; if the +//! mobile API is not listening on port 8765, the download will fail +//! and the user will see Safari's "Cannot Connect to Server" error. +//! The CLI prints a reminder about this at the bottom of the output. + +use qrcode::render::unicode; +use qrcode::QrCode; + +/// Default port where the persistent mobile API serves `/mobileconfig`. +/// Matches `MobileConfig::default().port` in `config.rs`. If the user +/// has overridden `[mobile] port = N` in `numa.toml`, they'll need to +/// adjust the URL manually — this CLI uses the default without parsing +/// `numa.toml`. +const SETUP_PORT: u16 = 8765; + +fn render_qr(url: &str) -> Result { + let code = QrCode::new(url).map_err(|e| format!("failed to encode QR: {}", e))?; + Ok(code + .render::() + .dark_color(unicode::Dense1x2::Light) + .light_color(unicode::Dense1x2::Dark) + .build()) +} + +/// Run the `numa setup-phone` flow. +pub async fn run() -> Result<(), String> { + let lan_ip = crate::lan::detect_lan_ip() + .ok_or("could not detect LAN IP — are you connected to a network?")?; + + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], SETUP_PORT)); + let api_reachable = tokio::time::timeout( + std::time::Duration::from_millis(500), + tokio::net::TcpStream::connect(addr), + ) + .await + .map(|r| r.is_ok()) + .unwrap_or(false); + + if !api_reachable { + eprintln!(); + eprintln!( + " \x1b[1;38;2;192;98;58mNuma\x1b[0m — mobile API is not reachable on port {}.", + SETUP_PORT + ); + eprintln!(); + eprintln!(" The phone won't be able to download the profile until the mobile"); + eprintln!(" API is running. Add this to your numa.toml and restart Numa:"); + eprintln!(); + eprintln!(" [mobile]"); + eprintln!(" enabled = true"); + eprintln!(); + return Err("mobile API not running".into()); + } + + let url = format!("http://{}:{}/mobileconfig", lan_ip, SETUP_PORT); + let qr = render_qr(&url)?; + + eprintln!(); + eprintln!(" \x1b[1;38;2;192;98;58mNuma Phone Setup\x1b[0m"); + eprintln!(); + eprintln!(" Profile URL: \x1b[36m{}\x1b[0m", url); + eprintln!(); + for line in qr.lines() { + eprintln!(" {}", line); + } + eprintln!(); + eprintln!(" \x1b[1mOn your iPhone:\x1b[0m"); + eprintln!(" 1. Open Camera, point at the QR code, tap the yellow banner"); + eprintln!(" 2. Allow the download when Safari asks"); + eprintln!(" 3. Open Settings — tap \"Profile Downloaded\" near the top"); + eprintln!(" (or: Settings → General → VPN & Device Management → Numa DNS)"); + eprintln!(" 4. Tap Install (top right), enter passcode, Install again"); + eprintln!(" 5. \x1b[1mSettings → General → About → Certificate Trust Settings\x1b[0m"); + eprintln!(" Toggle ON \"Numa Local CA\" — required for DoT to work"); + eprintln!(); + eprintln!( + " \x1b[33mNote:\x1b[0m profile uses your laptop's current IP ({}). If your", + lan_ip + ); + eprintln!(" laptop changes networks, re-scan this QR — iOS will replace the"); + eprintln!(" existing profile automatically (fixed UUID)."); + eprintln!(); + eprintln!( + " \x1b[90mThe profile is served by Numa's persistent mobile API on port {}.\x1b[0m", + SETUP_PORT + ); + eprintln!(" \x1b[90mMake sure `numa` is running before scanning. If it's not,\x1b[0m"); + eprintln!(" \x1b[90mstart it with `sudo numa install` or run it interactively.\x1b[0m"); + eprintln!(); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_qr_produces_unicode() { + let qr = render_qr("http://192.168.1.9:8765/mobileconfig").unwrap(); + assert!(!qr.is_empty()); + // Dense1x2 uses these block characters + assert!(qr.chars().any(|c| matches!(c, '█' | '▀' | '▄' | ' '))); + } +} -- 2.34.1 From 652fca5b80a79e110778fa380bc13382a1526dd3 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 19:10:58 +0300 Subject: [PATCH 062/204] chore: bump version to 0.11.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 72223f7..f64e765 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1144,7 +1144,7 @@ dependencies = [ [[package]] name = "numa" -version = "0.10.3" +version = "0.11.0" dependencies = [ "arc-swap", "axum", diff --git a/Cargo.toml b/Cargo.toml index 79a42a8..4b881c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "numa" -version = "0.10.3" +version = "0.11.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 8da03b1b8c57ed9f1a8c860166ef1ec4844836cb Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 19:23:11 +0300 Subject: [PATCH 063/204] chore: GoatCounter analytics, README v0.11.0, DoT blog post (#72) * chore: GoatCounter analytics, README for v0.11.0, DoT blog post - Add GoatCounter script to site pages (cookie-free, no consent needed) - Update README: setup-phone section, DoT blog link, roadmap checkbox - Add DoT blog post source and SVG assets Co-Authored-By: Claude Opus 4.6 (1M context) * chore: site navbar, updated roadmap, DoT blog listing, spec updates - Add top nav bar to landing page (wordmark + links, responsive) - Add DoT blog post entry to blog index - Update roadmap: phases 8-13 (hostile-network, Windows, DoT shipped) - Update specs: listeners section, dependency description, port list - Blog: hostile-network SVG in DNSSEC post, text-transform fix - Blog template: wordmark text-transform fix Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- README.md | 10 ++ blog/dnssec-from-scratch.md | 6 +- blog/dot-from-scratch.md | 167 ++++++++++++++++++++++++++++++++++ site/blog-template.html | 3 + site/blog/dot-handshake.svg | 129 ++++++++++++++++++++++++++ site/blog/hostile-network.svg | 92 +++++++++++++++++++ site/blog/index.html | 10 ++ site/index.html | 99 ++++++++++++++++++-- 8 files changed, 506 insertions(+), 10 deletions(-) create mode 100644 blog/dot-from-scratch.md create mode 100644 site/blog/dot-handshake.svg create mode 100644 site/blog/hostile-network.svg diff --git a/README.md b/README.md index 79dcff8..69ecd80 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,14 @@ DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification, ALPN `"dot"` is advertised and enforced in both modes; a handshake with mismatched ALPN is rejected as a cross-protocol confusion defense. +**Phone setup** — point your iPhone or Android at Numa in one step: + +```bash +numa setup-phone +``` + +Prints a QR code. Scan it, install the profile, toggle certificate trust — your phone's DNS now routes through Numa over TLS. Requires `[mobile] enabled = true` in `numa.toml`. + ## LAN Discovery Run Numa on multiple machines. They find each other automatically via mDNS: @@ -116,6 +124,7 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena ## Learn More +- [Blog: DNS-over-TLS from Scratch in Rust](https://numa.rs/blog/posts/dot-from-scratch.html) - [Blog: Implementing DNSSEC from Scratch in Rust](https://numa.rs/blog/posts/dnssec-from-scratch.html) - [Blog: I Built a DNS Resolver from Scratch](https://numa.rs/blog/posts/dns-from-scratch.html) - [Configuration reference](numa.toml) — all options documented inline @@ -130,6 +139,7 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena - [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 +- [x] Mobile onboarding — `setup-phone` QR flow, mobile API, mobileconfig profiles - [ ] pkarr integration — self-sovereign DNS via Mainline DHT - [ ] Global `.numa` names — DHT-backed, no registrar diff --git a/blog/dnssec-from-scratch.md b/blog/dnssec-from-scratch.md index 208bc53..01bc5c5 100644 --- a/blog/dnssec-from-scratch.md +++ b/blog/dnssec-from-scratch.md @@ -163,12 +163,12 @@ The fix has three parts: **TCP fallback.** Every outbound query tries UDP first (800ms timeout). If UDP fails or the response is truncated, retry immediately over TCP. TCP uses a 2-byte length prefix before the DNS message — trivial to implement, and it handles DNSSEC responses that exceed the UDP payload limit. -**UDP auto-disable.** After 3 consecutive UDP failures, flip a global `AtomicBool` and skip UDP entirely — go TCP-first for all queries. This avoids burning 800ms per hop on a network where UDP will never work. The flag resets when the network changes (detected via LAN IP monitoring). +**UDP auto-disable.** After 3 consecutive UDP failures, flip a global `AtomicBool` and skip UDP entirely — go TCP-first for all queries. The flag resets when the network changes (detected via LAN IP monitoring). + +Latency profile on a hostile network: queries 1-3 each spend 800ms waiting for a UDP timeout before retrying over TCP, taking 1,100ms total per query. After 3 consecutive failures the UDP auto-disable flag flips, and queries 4+ go TCP-first and complete in 300ms each — 3.7× faster. **Query minimization (RFC 7816).** When querying root servers, send only the TLD — `com` instead of `secret-project.example.com`. Root servers handle trillions of queries and are operated by 12 organizations. Minimization reduces what they learn from yours. -The result: on a network that blocks UDP:53, Numa detects the block within the first 3 queries, switches to TCP, and resolves normally at 300-500ms per cold query. Cached queries remain 0ms. No manual config change needed — switch networks and it adapts. - I wouldn't have found this without dogfooding. The code worked perfectly on my home network. It took a real hostile network to expose the assumption that UDP always works. ## What I learned diff --git a/blog/dot-from-scratch.md b/blog/dot-from-scratch.md new file mode 100644 index 0000000..9f04cdf --- /dev/null +++ b/blog/dot-from-scratch.md @@ -0,0 +1,167 @@ +--- +title: DNS-over-TLS from Scratch in Rust +description: Building RFC 7858 on top of rustls — length-prefix framing, ALPN cross-protocol defense, and two bugs that only the strict clients caught. +date: April 2026 +--- + +The [previous post](/blog/posts/dnssec-from-scratch.html) ended with "DoT — the last encrypted transport we don't support." This post is about building it. + +Numa now runs a DoT listener on port 853. My iPhone uses it as its system resolver, so ad blocking, DNSSEC validation, and recursive resolution follow my phone through the day. No cloud, no account, no companion app — a self-signed cert, a `.mobileconfig` profile, and a QR code in the terminal. + +RFC 7858 is ten pages. The hard parts weren't in the RFC. They were in cross-protocol confusion defenses, a crypto-provider init gotcha that only triggered in one specific config combination, and a certificate SAN bug iOS was happy to accept and `kdig` immediately rejected. This post is about those parts. + +## Why DoT when you already have DoH? + +Numa has shipped DoH since v0.1. Both protocols tunnel DNS over TLS; DoH wraps queries in HTTP/2, DoT is DNS-over-TCP with TLS in front. Same privacy guarantees, different wrapper. + +The answer to "why both" is that **phones ask for DoT by name.** iOS system DNS configures it with two fields (IP + server name) instead of a URL template. Android 9+ "Private DNS" speaks DoT natively. Linux stubs default to DoT. I wanted my phone on Numa without installing anything on the phone itself, and DoT is the protocol iOS and Android already speak for that. + +## The wire format is refreshingly small + +RFC 7858 is one sentence of wire protocol: *DNS-over-TCP (RFC 1035 §4.2.2) with TLS in front, on port 853.* DNS-over-TCP has existed since 1987 — a 2-byte length prefix followed by the DNS message. DoT is that, wrapped in a TLS session. The entire framing code is seven lines: + +```rust +async fn write_framed(stream: &mut S, msg: &[u8]) -> 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 +} +``` + +Reads are symmetric: `read_exact` two bytes, convert to `u16`, `read_exact` that many bytes. No HTTP headers, no chunked encoding, no framing layer. + +## Persistent connections + +A fresh TCP+TLS handshake is at least 3 RTTs — about 300ms on a 100ms connection, 60× the cost of a UDP query. RFC 7858 §3.4 says clients SHOULD reuse the TCP connection for multiple queries, and every real DoT client does: iOS, Android, systemd, stubby. A single connection often carries hundreds of queries. + +Timing diagram comparing a DNS lookup over plain UDP (1 RTT), over DoT on a fresh connection (3 RTTs — TCP handshake, TLS 1.3 handshake, then the query), and over a reused DoT session (1 RTT, same as UDP). + +The amortization point is the whole game. If you only ever do one query per connection, DoT is roughly 3× slower than UDP and you should not use it. If you reuse the same TLS session for a browsing session's worth of queries, the handshake is paid once and every subsequent query is effectively free. + +The server is a loop that reads a length-prefixed message, resolves it, writes the response framed the same way, waits for the next one. Three timeouts keep it honest: + +- **Handshake timeout (10s)** — a slowloris that opens TCP but never sends a ClientHello can't pin a worker. +- **Idle timeout (30s)** — a connected client with nothing to say gets dropped. +- **Write timeout (10s)** — a stalled reader can't hold a response buffer indefinitely. + +A semaphore caps concurrent connections at 512 so a burst of handshakes can't exhaust the tokio runtime. + +## ALPN, the cross-protocol defense that matters + +If DoT lives on port 853 and HTTPS on 443, what stops an HTTP/2 client from hitting 853 and getting confused replies? [Cross-protocol attacks](https://alpaca-attack.com/) exist and have had real CVEs. The defense is ALPN: during the TLS handshake the client advertises protocols, the server picks one it supports or fails. A DoT server advertises `"dot"`; a client offering only `"h2"` gets a `no_application_protocol` fatal alert before any frames are exchanged. + +rustls enforces this by default when you set `alpn_protocols`: + +```rust +let mut config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key)?; +config.alpn_protocols = vec![b"dot".to_vec()]; +``` + +"The library enforces it by default" has a latent risk: a future rustls upgrade could change the default, and the defense would quietly evaporate. I wrote a test that pins the behavior so any regression in a dependency update fails loudly: + +```rust +#[tokio::test] +async fn dot_rejects_non_dot_alpn() { + 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\""); +} +``` + +When you're leaning on a library's default for a security-critical invariant, the test is the contract. + +## Two bugs that hid for days + +Both were fixed before v0.10 shipped. Both stayed hidden because my initial tests used *permissive* clients. + +### The rustls crypto provider panic + +rustls 0.23 requires a `CryptoProvider` installed before you can build a `ServerConfig`. Numa's HTTPS proxy calls `install_default` as a side effect when it builds its own config, so DoT "just worked" for users who enabled both — the proxy had already initialized the provider before DoT's first handshake. + +Then I added support for user-provided DoT certificates. Someone running DoT with their own Let's Encrypt cert, with the HTTPS proxy disabled, would hit: + +``` +thread 'dot' panicked at rustls-0.23.25/src/crypto/mod.rs:185:14: +no process-level CryptoProvider available -- call +CryptoProvider::install_default() before this point +``` + +The panic happened on the first client connection, not at startup. While writing the integration suite for "DoT with BYO cert, proxy disabled" — the one combination nobody had ever actually exercised — the first run panicked. Fix is two lines: call `install_default` inside `load_tls_config` so DoT can stand alone. If a side effect initializes something and you have a path that skips that side effect, you have a bug waiting for a specific deployment. + +### The SAN bug iOS was happy to accept + +Numa's self-signed DoT cert is generated on first run from a local CA alongside the data directory. It needs to match whatever `ServerName` the client sends as SNI. For the HTTPS proxy, that's the wildcard domain pattern `*.numa` (matching `frontend.numa`, `api.numa`, etc.). I initially reused the same SAN list for DoT: a wildcard `*.numa` and nothing else. + +On an iPhone this worked perfectly. Full browsing session, persistent connections in the log, ad blocking active. I was about to merge when I ran one last smoke test with `kdig` (GnuTLS-backed, from [Knot DNS](https://www.knot-dns.cz/)): + +``` +$ kdig @192.168.1.16 -p 853 +tls \ + +tls-ca=/usr/local/var/numa/ca.pem \ + +tls-hostname=numa.numa example.com A + +;; TLS, handshake failed (Error in the certificate.) +``` + +Huh. + +[RFC 6125 §6.4.3](https://datatracker.ietf.org/doc/html/rfc6125#section-6.4.3): a wildcard in a certificate's DNS-ID matches exactly one label. `*.numa` matches `frontend.numa`, but not `numa.numa`, because the wildcard wants at least one label to substitute and strict clients reject wildcards in the leftmost label under single-label TLDs as ambiguous. + +iOS's TLS stack is lenient and accepts it. GnuTLS, NSS (Firefox), and most non-Apple validators don't. The fix is five lines — add an explicit `numa.numa` SAN alongside the wildcard. But the lesson is the one that stuck: I wrote a commit message saying "fix an iOS bug" and had to rewrite it, because iOS was fine. The real bug was that every GnuTLS/NSS-based client on the planet would have rejected the cert, and I only found it by running one more test with a stricter tool. + +> Test with the strict client. The permissive client hides your bugs. + +## Getting your phone onto it + +A DoT server is useless without a way to point a phone at it. iOS won't let you type an IP and a server name into Settings directly — you install a `.mobileconfig` profile that bundles the CA as a trust anchor and the DNS settings in a single payload. + +Numa ships a subcommand that builds one on the fly and serves it over a QR code in the terminal: + +``` +$ numa setup-phone + + Numa Phone Setup + + Profile URL: http://192.168.1.16:8765/mobileconfig + + █▀▀▀▀▀▀▀█▀▀██ ██ ▀█▀▀▀▀▀▀▀█ + █ █▀▀▀█ █▀▄▀▀▀▀▄▄█ █▀▀▀█ █ + ... + + On your iPhone: + 1. Open Camera, point at the QR code, tap the yellow banner + 2. Allow the download when Safari asks + 3. Settings → "Profile Downloaded" → Install + 4. Settings → General → About → Certificate Trust Settings + Toggle ON "Numa Local CA" — required for DoT to work +``` + +Step 4 is non-negotiable. Even though the CA is bundled in the same profile that installs the DNS settings, iOS still requires the user to explicitly toggle trust in Certificate Trust Settings. It's a deliberate iOS policy to prevent profile-based trust injection — annoying, and correct. + +I've been dogfooding this since v0.10 shipped in early April. The phone resolves through Numa over DoT whenever I'm home; persistent connections are visible in the log as a single source port living through dozens of queries. The one real caveat: if the laptop's LAN IP changes, the profile breaks. [RFC 9462 DDR](https://datatracker.ietf.org/doc/html/rfc9462) fixes that — Numa can respond to `_dns.resolver.arpa IN SVCB` with its current IP and iOS picks it up on each network join. Next piece of work. + +## What I learned + +**RFC-level small, API-level hard.** RFC 7858 is ten pages. The framing is trivial. But the subtle stuff — ALPN, timeouts, connection caps, handshake vs idle vs write deadlines, backoff on accept errors — isn't in the RFC. Miss any of it and you leak a DoS vector or a protocol confusion hole. + +**Your test matrix is your security matrix.** Both bugs in this post were hidden by lenient clients. In both cases the strict client — kdig, or a specific config combination — surfaced the bug instantly. Pick test tools for strictness, not convenience. The moment you find yourself thinking "but iOS accepts it," stop and run kdig. + +**Don't initialize global state via side effects.** "Module A installs a global, module B silently depends on it, disabling A breaks B" is a bug pattern that keeps coming back. Fix: have module B initialize its dependency explicitly, even if it means calling an idempotent `install_default` twice. The dependency graph should be local and obvious. + +## What's next + +- **DoH server** — Numa already has a DoH client; the other half unlocks Firefox's built-in DoH setting pointing at Numa. +- **DoQ server (RFC 9250)** — DNS over QUIC. Android 14+ supports it natively. +- **DDR (RFC 9462)** — auto-discovery via `_dns.resolver.arpa IN SVCB`, so phones pick up a moved Numa instance without the installed profile going stale. + +The code is at [github.com/razvandimescu/numa](https://github.com/razvandimescu/numa) — the DoT listener is in [`src/dot.rs`](https://github.com/razvandimescu/numa/blob/main/src/dot.rs) and the phone onboarding flow is in [`src/setup_phone.rs`](https://github.com/razvandimescu/numa/blob/main/src/setup_phone.rs) and [`src/mobileconfig.rs`](https://github.com/razvandimescu/numa/blob/main/src/mobileconfig.rs). MIT license. diff --git a/site/blog-template.html b/site/blog-template.html index 0275c1f..85e854b 100644 --- a/site/blog-template.html +++ b/site/blog-template.html @@ -74,6 +74,7 @@ body::before { font-weight: 400; color: var(--text-primary); text-decoration: none; + text-transform: none; letter-spacing: -0.02em; } .blog-nav .wordmark:hover { color: var(--amber); } @@ -297,5 +298,7 @@ $body$ Blog + diff --git a/site/blog/dot-handshake.svg b/site/blog/dot-handshake.svg new file mode 100644 index 0000000..16111eb --- /dev/null +++ b/site/blog/dot-handshake.svg @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + UDP vs DoT — one lookup, three scenarios + Time flows downward. Amber = DNS work. Gray = TCP/TLS handshake overhead. + + + + + Plain UDP DNS + PORT 53 · CLEARTEXT + + + client + server + + + + + + + + query + + + + response + + + + TOTAL LATENCY + 1 × RTT + + + + + + DoT — first query + PORT 853 · NEW CONNECTION + + + client + server + + + + + + + + + + + + + + 1 rtt + TCP handshake + + + + + + + + 2 rtt + TLS 1.3 handshake + + + + + + + + 3 rtt + query + response + + + + TOTAL LATENCY + 3 × RTT + + + + + + DoT — reused session + PORT 853 · PERSISTENT TCP/TLS + + + client + server + + + + + + + + query + + + + response + + + + TOTAL LATENCY + 1 × RTT + + + (handshake amortized + across the session) + + diff --git a/site/blog/hostile-network.svg b/site/blog/hostile-network.svg new file mode 100644 index 0000000..1528310 --- /dev/null +++ b/site/blog/hostile-network.svg @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + TCP fallback with UDP auto-disable + Latency profile on an ISP that blocks outbound UDP:53 + + + + + UDP timeout — 800 ms wasted + + TCP — successful exchange + + + + + + + + + + + + + 0 + 300 + 600 + 800 + 1100 ms + + + + + query 1 + + + 1,100 ms + + + query 2 + + + 1,100 ms + + + query 3 + + + 1,100 ms + + + + + 3 consecutive failures → UDP auto-disabled + + + + + query 4 + + 300 ms + + + query 5 + + 300 ms + + + + + 3.7× faster — no more UDP wait + + + + The flag resets on network change (LAN IP delta). Switch back to a clean network and UDP is tried again. + diff --git a/site/blog/index.html b/site/blog/index.html index f19149c..10d62a7 100644 --- a/site/blog/index.html +++ b/site/blog/index.html @@ -67,6 +67,7 @@ body::before { font-weight: 400; color: var(--text-primary); text-decoration: none; + text-transform: none; letter-spacing: -0.02em; } .blog-nav .wordmark:hover { color: var(--amber); } @@ -167,6 +168,13 @@ body::before {

Blog

    +
  • + +
    DNS-over-TLS from Scratch in Rust
    +
    Building RFC 7858 on top of rustls — length-prefix framing, ALPN cross-protocol defense, iPhone dogfooding, and two bugs that only the strict clients caught.
    + +
    +
  • Implementing DNSSEC from Scratch in Rust
    @@ -189,5 +197,7 @@ body::before {
    Home + diff --git a/site/index.html b/site/index.html index 7f22686..27ea8fb 100644 --- a/site/index.html +++ b/site/index.html @@ -188,11 +188,50 @@ p.lead { line-height: 1.8; } +/* =========================== + TOP NAV + =========================== */ +.site-nav { + padding: 1.5rem 2rem; + display: flex; + align-items: center; + gap: 1.5rem; + position: relative; + z-index: 10; +} + +.site-nav a { + font-family: var(--font-mono); + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-dim); + text-decoration: none; + transition: color 0.2s ease; +} +.site-nav a:hover { color: var(--amber); } + +.site-nav .wordmark { + font-family: var(--font-display); + font-size: 1.4rem; + font-weight: 400; + color: var(--text-primary); + text-transform: none; + letter-spacing: -0.02em; +} +.site-nav .wordmark:hover { color: var(--amber); } + +.site-nav .sep { + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 0.75rem; +} + /* =========================== HERO =========================== */ .hero { - min-height: 100vh; + min-height: calc(100vh - 5rem); display: flex; align-items: center; position: relative; @@ -1158,6 +1197,9 @@ footer .closing { @media (max-width: 600px) { section { padding: 4rem 0; } .container { padding: 0 1.25rem; } + .site-nav { padding: 1rem 1.25rem; gap: 1rem; } + .site-nav .wordmark { font-size: 1.2rem; } + .hero { min-height: calc(100vh - 4rem); } .network-grid { grid-template-columns: 1fr; } .pipeline { flex-direction: column; align-items: stretch; gap: 0; } .pipeline-arrow { transform: rotate(90deg); padding: 0.15rem 0; align-self: center; } @@ -1171,6 +1213,14 @@ footer .closing { + +
    @@ -1243,6 +1293,8 @@ footer .closing {
  • Ad & tracker blocking — 385K+ domains, zero config
  • Recursive resolution — opt-in, resolve from root nameservers, no upstream needed
  • DNSSEC validation — chain-of-trust + NSEC/NSEC3 denial proofs (RSA, ECDSA, Ed25519)
  • +
  • DNS-over-TLS listener — encrypted DNS for phones and strict clients (RFC 7858 with ALPN defense)
  • +
  • Hostile-network resilience — TCP fallback with UDP auto-disable when ISPs block port 53
  • TTL-aware caching (sub-ms lookups)
  • Single binary, portable — macOS, Linux, and Windows
@@ -1261,7 +1313,7 @@ footer .closing {
-
Coming Next
+
The Vision

Self-Sovereign DNS

  • pkarr integration — DNS via Mainline DHT, no registrar needed
  • @@ -1342,6 +1394,14 @@ footer .closing { No Root hints + full DNSSEC + + DNSSEC validation + Passthrough + Cloud only + Cloud only + Passthrough + Full chain-of-trust + Ad & tracker blocking Yes @@ -1398,6 +1458,14 @@ footer .closing { No Built in (HTTP/2 + rustls) + + DNS-over-TLS listener + No + Cloud only + Cloud only + Yes (cert required) + Self-signed or BYO + Conditional forwarding No @@ -1567,11 +1635,14 @@ footer .closing {
    Resolution Modes
    Recursive (iterative from root hints, CNAME chasing, glue extraction) or Forward (DoH / plain UDP)
    +
    Listeners
    +
    UDP:53 + TCP:53 (plain DNS), DoT:853 (RFC 7858 + ALPN), HTTP proxy :80 / HTTPS proxy :443, dashboard :5380
    +
    DNSSEC
    Chain-of-trust via ring — RSA/SHA-256, ECDSA P-256, Ed25519. NSEC/NSEC3 denial proofs. EDNS0 DO bit, 1232-byte payload (DNS Flag Day 2020).
    Dependencies
    -
    19 runtime crates — tokio, axum, hyper, ring (DNSSEC), reqwest (DoH), rcgen + rustls (TLS), socket2 (multicast), serde, and more
    +
    A focused set — tokio, axum, hyper, ring (DNSSEC), reqwest (DoH), rcgen + rustls + tokio-rustls (TLS/DoT), socket2 (multicast), serde. No transitive DNS library.
    Packet Format
    RFC 1035 compliant. EDNS0 OPT pseudo-record. Parses A, AAAA, NS, CNAME, MX, SOA, SRV, HTTPS, DNSKEY, DS, RRSIG, NSEC, NSEC3.
    @@ -1586,7 +1657,7 @@ footer .closing { $ curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh # Run -$ sudo numa # bind to :53, :80, :5380 +$ sudo numa # bind :53, :80, :443, :853, :5380 $ dig @127.0.0.1 google.com # test resolution $ open http://localhost:5380 # dashboard $ curl -X POST localhost:5380/services \ @@ -1639,16 +1710,28 @@ footer .closing { Phase 7 DNSSEC validation — chain-of-trust, NSEC/NSEC3 denial proofs, RSA + ECDSA + Ed25519
-
+
Phase 8 + Hostile-network resilience — TCP fallback with UDP auto-disable when ISPs block :53, RFC 7816 query minimization +
+
+ Phase 9 + Windows support — cross-platform install/uninstall, netsh DNS config, service integration +
+
+ Phase 10 + DNS-over-TLS listener (RFC 7858) — ALPN enforcement, persistent connections, self-signed or BYO cert +
+
+ Phase 11 pkarr integration — self-sovereign DNS via Mainline DHT, no registrar needed
- Phase 9 + Phase 12 Global .numa names — self-publish, DHT-backed, first-come-first-served
- Phase 10 + Phase 13 .onion bridge — human-readable Tor naming via Ed25519 same-key binding
@@ -1686,5 +1769,7 @@ const observer = new IntersectionObserver((entries) => { document.querySelectorAll('.reveal').forEach(el => observer.observe(el)); + -- 2.34.1 From 921ed68d546295a127ad6acfc3c5df84c6cebbfd Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 21:43:40 +0300 Subject: [PATCH 064/204] fix: allowlist parent domain unblocks subdomains (#74) * fix: allowlist parent domain unblocks subdomains in blocklist The allowlist walk-up was interleaved with the blocklist walk-up, so an exact blocklist match on www.example.com short-circuited before reaching example.com in the allowlist. Now allowlist is checked at all parent levels before consulting the blocklist. Deduplicate is_blocked/check via find_in_set helper; is_blocked delegates to check. Adds 7 new blocklist tests. Co-Authored-By: Claude Opus 4.6 (1M context) * style: rustfmt blocklist tests Co-Authored-By: Claude Opus 4.6 (1M context) * perf: zero-alloc is_blocked hot path, normalize trailing dots Co-Authored-By: Claude Opus 4.6 * style: rustfmt add_to_allowlist Co-Authored-By: Claude Opus 4.6 * refactor: extract normalize() for domain lowering + dot stripping Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/blocklist.rs | 173 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 134 insertions(+), 39 deletions(-) diff --git a/src/blocklist.rs b/src/blocklist.rs index e5caa99..ef865c4 100644 --- a/src/blocklist.rs +++ b/src/blocklist.rs @@ -81,66 +81,70 @@ impl BlocklistStore { if !self.enabled { return false; } - if let Some(until) = self.paused_until { if Instant::now() < until { return false; } } - - if self.allowlist.contains(domain) { + let domain = Self::normalize(domain); + if Self::find_in_set(&domain, &self.allowlist).is_some() { return false; } - - if self.domains.contains(domain) { - return true; - } - - // Walk up: ads.tracker.example.com → tracker.example.com → example.com - let mut d = domain; - while let Some(dot) = d.find('.') { - d = &d[dot + 1..]; - if self.allowlist.contains(d) { - return false; - } - if self.domains.contains(d) { - return true; - } - } - - false + Self::find_in_set(&domain, &self.domains).is_some() } - /// Check if a domain is blocked and return the reason. pub fn check(&self, domain: &str) -> BlockCheckResult { - let domain = domain.to_lowercase(); - if !self.enabled { return BlockCheckResult::disabled(); } - if self.allowlist.contains(&domain) { - return BlockCheckResult::allowed(&domain, "exact match in allowlist"); + if let Some(until) = self.paused_until { + if Instant::now() < until { + return BlockCheckResult::disabled(); + } } - if self.domains.contains(&domain) { - return BlockCheckResult::blocked(&domain, "exact match in blocklist"); + let domain = Self::normalize(domain); + + if let Some(matched) = Self::find_in_set(&domain, &self.allowlist) { + let reason = if matched == domain { + "exact match in allowlist" + } else { + "parent domain in allowlist" + }; + return BlockCheckResult::allowed(matched, reason); } - let mut d = domain.as_str(); - while let Some(dot) = d.find('.') { - d = &d[dot + 1..]; - if self.allowlist.contains(d) { - return BlockCheckResult::allowed(d, "parent domain in allowlist"); - } - if self.domains.contains(d) { - return BlockCheckResult::blocked(d, "parent domain in blocklist"); - } + if let Some(matched) = Self::find_in_set(&domain, &self.domains) { + let reason = if matched == domain { + "exact match in blocklist" + } else { + "parent domain in blocklist" + }; + return BlockCheckResult::blocked(matched, reason); } BlockCheckResult::not_blocked() } + fn normalize(domain: &str) -> String { + domain.to_lowercase().trim_end_matches('.').to_string() + } + + fn find_in_set<'a>(domain: &'a str, set: &HashSet) -> Option<&'a str> { + if set.contains(domain) { + return Some(domain); + } + let mut d = domain; + while let Some(dot) = d.find('.') { + d = &d[dot + 1..]; + if set.contains(d) { + return Some(d); + } + } + None + } + /// Atomically swap in a new domain set. Build the set outside the lock, /// then call this to swap — keeps lock hold time sub-microsecond. pub fn swap_domains(&mut self, domains: HashSet, sources: Vec) { @@ -172,11 +176,11 @@ impl BlocklistStore { } pub fn add_to_allowlist(&mut self, domain: &str) { - self.allowlist.insert(domain.to_lowercase()); + self.allowlist.insert(Self::normalize(domain)); } pub fn remove_from_allowlist(&mut self, domain: &str) -> bool { - self.allowlist.remove(&domain.to_lowercase()) + self.allowlist.remove(&Self::normalize(domain)) } pub fn allowlist(&self) -> Vec { @@ -247,6 +251,97 @@ pub fn parse_blocklist(text: &str) -> HashSet { mod tests { use super::*; + fn store_with(domains: &[&str], allowlist: &[&str]) -> BlocklistStore { + let mut store = BlocklistStore::new(); + store.swap_domains(domains.iter().map(|s| s.to_string()).collect(), vec![]); + for d in allowlist { + store.add_to_allowlist(d); + } + store + } + + #[test] + fn exact_block() { + let store = store_with(&["ads.example.com"], &[]); + assert!(store.is_blocked("ads.example.com")); + assert!(!store.is_blocked("example.com")); + } + + #[test] + fn parent_block_covers_subdomain() { + let store = store_with(&["tracker.com"], &[]); + assert!(store.is_blocked("tracker.com")); + assert!(store.is_blocked("www.tracker.com")); + assert!(store.is_blocked("deep.sub.tracker.com")); + } + + #[test] + fn exact_allowlist_unblocks() { + let store = store_with(&["ads.example.com"], &["ads.example.com"]); + assert!(!store.is_blocked("ads.example.com")); + } + + #[test] + fn parent_allowlist_unblocks_subdomain() { + let store = store_with(&["example.com", "www.example.com"], &["example.com"]); + assert!(!store.is_blocked("example.com")); + assert!(!store.is_blocked("www.example.com")); + assert!(!store.is_blocked("sub.deep.example.com")); + } + + #[test] + fn allowlist_does_not_unblock_sibling() { + let store = store_with( + &["www.example.com", "ads.example.com"], + &["www.example.com"], + ); + assert!(!store.is_blocked("www.example.com")); + assert!(store.is_blocked("ads.example.com")); + } + + #[test] + fn check_reports_parent_allowlist() { + let store = store_with( + &["goatcounter.com", "www.goatcounter.com"], + &["goatcounter.com"], + ); + let result = store.check("www.goatcounter.com"); + assert!(!result.blocked); + assert_eq!(result.matched_rule.as_deref(), Some("goatcounter.com")); + } + + #[test] + fn disabled_never_blocks() { + let mut store = store_with(&["ads.example.com"], &[]); + store.set_enabled(false); + assert!(!store.is_blocked("ads.example.com")); + } + + #[test] + fn trailing_dot_normalized() { + let store = store_with(&["ads.example.com"], &["safe.example.com"]); + assert!(store.is_blocked("ads.example.com.")); + assert!(!store.is_blocked("safe.example.com.")); + let result = store.check("ads.example.com."); + assert!(result.blocked); + } + + #[test] + fn case_insensitive() { + let store = store_with(&["ads.example.com"], &["safe.example.com"]); + assert!(store.is_blocked("ADS.Example.COM")); + assert!(!store.is_blocked("Safe.Example.COM")); + } + + #[test] + fn domain_in_neither_list() { + let store = store_with(&["ads.example.com"], &[]); + let result = store.check("clean.example.org"); + assert!(!result.blocked); + assert_eq!(result.reason, "not in blocklist"); + assert!(result.matched_rule.is_none()); + } + #[test] fn heap_bytes_grows_with_domains() { let mut store = BlocklistStore::new(); -- 2.34.1 From 2c20c56421b482af446e812b8b3b7af2ab92f869 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 22:21:51 +0300 Subject: [PATCH 065/204] =?UTF-8?q?feat:=20mobile=20setup=20=E2=80=94=20QR?= =?UTF-8?q?=20onboarding,=20Wi-Fi=20scoped=20mobileconfig=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: scope mobileconfig DNS to Wi-Fi only via OnDemandRules Without OnDemandRules, iOS applies the DoT profile globally — cellular DNS breaks when the phone leaves the LAN. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: phone setup QR code in dashboard header - Add /qr endpoint serving SVG (uses existing qrcode crate, svg feature) - Header popover: QR on desktop, direct download link on mobile viewports - Only visible when [mobile] enabled = true in config - Expose mobile.enabled and mobile.port in /stats response - Lazy-load QR on first click, dismiss on outside click Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add Cache-Control to /qr, re-fetch QR on each popover open Cache-Control: no-store prevents stale QR after LAN IP change. Remove qrLoaded flag so the QR always reflects the current IP. Co-Authored-By: Claude Opus 4.6 (1M context) * style: rustfmt serve_qr response tuple Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add iOS install steps to phone setup popover iOS shows "Profile Downloaded" with no guidance. The popover now includes the 3-step install flow including the buried Certificate Trust Settings toggle. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .gitignore | 1 + Cargo.toml | 2 +- site/dashboard.html | 50 +++++++++++++++++++++++++++++++++++++++++++++ src/api.rs | 36 ++++++++++++++++++++++++++++++++ src/ctx.rs | 2 ++ src/dot.rs | 2 ++ src/main.rs | 2 ++ src/mobileconfig.rs | 17 ++++++++++++--- 8 files changed, 108 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 1c510fd..649d86b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ CLAUDE.md docs/ site/blog/posts/ +ios/ diff --git a/Cargo.toml b/Cargo.toml index 4b881c5..95e094b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ tokio-rustls = "0.26" arc-swap = "1" ring = "0.17" rustls-pemfile = "2.2.0" -qrcode = { version = "0.14", default-features = false } +qrcode = { version = "0.14", default-features = false, features = ["svg"] } [dev-dependencies] criterion = { version = "0.8", features = ["html_reports"] } diff --git a/site/dashboard.html b/site/dashboard.html index c78c48f..5fa9777 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -554,6 +554,20 @@ body {
DNS that governs itself
+
@@ -788,6 +802,34 @@ function formatTime(epoch) { return d.toLocaleTimeString([], { hour12: false }); } +let mobilePort = 8765; +function togglePhoneSetup() { + const pop = document.getElementById('phoneSetupPopover'); + const isOpen = pop.style.display !== 'none'; + pop.style.display = isOpen ? 'none' : 'block'; + if (!isOpen) { + if (window.innerWidth <= 700) { + document.getElementById('qrContainer').style.display = 'none'; + const linkEl = document.getElementById('phoneSetupLink'); + const host = window.location.hostname; + linkEl.style.display = 'block'; + linkEl.innerHTML = `Install Profile`; + } else { + fetch(API + '/qr').then(r => r.text()).then(svg => { + document.getElementById('qrContainer').innerHTML = svg; + }).catch(() => { + document.getElementById('qrContainer').innerHTML = '
Could not load QR
'; + }); + } + } +} +document.addEventListener('click', (e) => { + const setup = document.getElementById('phoneSetup'); + if (setup && !setup.contains(e.target)) { + document.getElementById('phoneSetupPopover').style.display = 'none'; + } +}); + function shortSrc(addr) { if (!addr) return ''; const ip = addr.replace(/:\d+$/, ''); @@ -1058,6 +1100,14 @@ async function refresh() { } } + const phoneSetupEl = document.getElementById('phoneSetup'); + if (stats.mobile && stats.mobile.enabled) { + phoneSetupEl.style.display = ''; + mobilePort = stats.mobile.port; + } else { + phoneSetupEl.style.display = 'none'; + } + document.getElementById('overrideCount').textContent = stats.overrides.active; document.getElementById('blockedCount').textContent = formatNumber(q.blocked); const bl = stats.blocking; diff --git a/src/api.rs b/src/api.rs index fed7d5b..2e66931 100644 --- a/src/api.rs +++ b/src/api.rs @@ -57,6 +57,7 @@ pub fn router(ctx: Arc) -> Router { .route("/services/{name}/routes", post(add_route)) .route("/services/{name}/routes", delete(remove_route)) .route("/ca.pem", get(serve_ca)) + .route("/qr", get(serve_qr)) .route("/fonts/fonts.css", get(serve_fonts_css)) .route( "/fonts/dm-sans-latin.woff2", @@ -170,9 +171,16 @@ struct StatsResponse { overrides: OverrideStats, blocking: BlockingStatsResponse, lan: LanStatsResponse, + mobile: MobileStatsResponse, memory: MemoryStats, } +#[derive(Serialize)] +struct MobileStatsResponse { + enabled: bool, + port: u16, +} + #[derive(Serialize)] struct LanStatsResponse { enabled: bool, @@ -551,6 +559,10 @@ async fn stats(State(ctx): State>) -> Json { enabled: ctx.lan_enabled, peers: ctx.lan_peers.lock().unwrap().list().len(), }, + mobile: MobileStatsResponse { + enabled: ctx.mobile_enabled, + port: ctx.mobile_port, + }, memory: MemoryStats { cache_bytes, blocklist_bytes, @@ -931,6 +943,28 @@ pub async fn serve_ca(State(ctx): State>) -> Result>) -> Result { + if !ctx.mobile_enabled { + return Err(StatusCode::NOT_FOUND); + } + let lan_ip = *ctx.lan_ip.lock().unwrap(); + let url = format!("http://{}:{}/mobileconfig", lan_ip, ctx.mobile_port); + let code = qrcode::QrCode::new(&url).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let svg = code + .render::() + .min_dimensions(180, 180) + .dark_color(qrcode::render::svg::Color("#2c2418")) + .light_color(qrcode::render::svg::Color("#faf7f2")) + .build(); + Ok(( + [ + (header::CONTENT_TYPE, "image/svg+xml"), + (header::CACHE_CONTROL, "no-store"), + ], + svg, + )) +} + async fn serve_fonts_css() -> impl IntoResponse { ( [ @@ -1005,6 +1039,8 @@ mod tests { dnssec_strict: false, health_meta: crate::health::HealthMeta::test_fixture(), ca_pem: None, + mobile_enabled: false, + mobile_port: 8765, }) } diff --git a/src/ctx.rs b/src/ctx.rs index cf3522d..6b774eb 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -70,6 +70,8 @@ pub struct ServerCtx { /// Used by `/ca.pem`, `/mobileconfig`, and `/ca.mobileconfig` /// handlers to avoid per-request disk I/O on the hot path. pub ca_pem: Option, + pub mobile_enabled: bool, + pub mobile_port: u16, } /// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist, diff --git a/src/dot.rs b/src/dot.rs index 32d32ba..3ed47ba 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -383,6 +383,8 @@ mod tests { dnssec_strict: false, health_meta: crate::health::HealthMeta::test_fixture(), ca_pem: None, + mobile_enabled: false, + mobile_port: 8765, }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/src/main.rs b/src/main.rs index 70bc3f9..62acb69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -319,6 +319,8 @@ async fn main() -> numa::Result<()> { dnssec_strict: config.dnssec.strict, health_meta, ca_pem, + mobile_enabled: config.mobile.enabled, + mobile_port: config.mobile.port, }); let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); diff --git a/src/mobileconfig.rs b/src/mobileconfig.rs index 513d198..4ef1740 100644 --- a/src/mobileconfig.rs +++ b/src/mobileconfig.rs @@ -144,8 +144,6 @@ fn build_ca_payload(ca_pem: &str) -> String { } /// Render the `com.apple.dnsSettings.managed` payload dict for Full mode. -/// Pins the device to Numa as its system resolver over DoT with -/// `ServerName = "numa.numa"` (must match the DoT cert SAN). fn build_dns_payload(lan_ip: Ipv4Addr) -> String { format!( r#" @@ -160,8 +158,21 @@ fn build_dns_payload(lan_ip: Ipv4Addr) -> String { ServerName numa.numa + OnDemandRules + + + Action + Connect + InterfaceTypeMatch + WiFi + + + Action + Disconnect + + PayloadDescription - Routes all DNS queries through Numa over DNS-over-TLS + Routes DNS queries through Numa over DoT when on Wi-Fi PayloadDisplayName Numa DNS-over-TLS PayloadIdentifier -- 2.34.1 From 23ff3ce4551589ec8fe67356f21e708583f9ea9a Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 22:34:41 +0300 Subject: [PATCH 066/204] chore: blog full QR output + dashboard screenshot, hero script phone setup scene (#75) Co-authored-by: Claude Opus 4.6 (1M context) --- blog/dot-from-scratch.md | 33 ++++++++++++++++++---- scripts/record-demo.sh | 41 +++++++++++++++++----------- site/blog/phone-setup-dashboard.png | Bin 0 -> 317176 bytes 3 files changed, 52 insertions(+), 22 deletions(-) create mode 100644 site/blog/phone-setup-dashboard.png diff --git a/blog/dot-from-scratch.md b/blog/dot-from-scratch.md index 9f04cdf..b4bb70b 100644 --- a/blog/dot-from-scratch.md +++ b/blog/dot-from-scratch.md @@ -132,20 +132,41 @@ $ numa setup-phone Numa Phone Setup - Profile URL: http://192.168.1.16:8765/mobileconfig + Profile URL: http://192.168.1.10:8765/mobileconfig - █▀▀▀▀▀▀▀█▀▀██ ██ ▀█▀▀▀▀▀▀▀█ - █ █▀▀▀█ █▀▄▀▀▀▀▄▄█ █▀▀▀█ █ - ... + █████████████████████████████████████ + █████████████████████████████████████ + ████ ▄▄▄▄▄ ██ ▀█ ▀▀▀▄▀ ▀▀█ ▄▄▄▄▄ ████ + ████ █ █ █ ▄▀ ▄█▀▄▀█▄▀█ █ █ ████ + ████ █▄▄▄█ █ ▀▄▄ ▀ █▄▀▀█▀█ █▄▄▄█ ████ + ████▄▄▄▄▄▄▄█ ▀▄▀▄█▄█ █▄█▄█▄▄▄▄▄▄▄████ + ████ ▀▄▄▄▄▄█▀ ▀██▄ ▄ ▄▀█▀█ ▄ ▄▄█▀████ + █████▄▄▀▄▀▄▄█▄ ▀████▀▄▄▀█▀▀▄ ██▀█████ + ████▄██▄ ▀▄ █ █ █▀█▄▄██ ▄▄▀▄▀▄ █▀████ + █████ ▀ ▄▀ ▄▀▄ ▄▄▀ ██ ▄▀██▄▀█████ + ████ ▀▀ █▄█▄▀ ▄ █▄ ▄█▀▄ ▀█▀▀ █▀████ + ████ ██▀█ ▄▄▀█▄▄██▀▄▀ ▀█▄▀ █▀▄▄▀█████ + ████▄█▄▄▄▄▄█▀▄█▄█▀▀ ▀██▀ ▄▄▄ ▀ ████ + ████ ▄▄▄▄▄ █▀▀▀▀ ▄█▀ ▀▄ █▄█ ▄▄▀█████ + ████ █ █ █ ▄ ██▀▄ ▄▄██ ▄ ▄▄▄██████ + ████ █▄▄▄█ █▄ ▄▀▀▄▄█▀▄▀▄ ▀▄▀ ▄█ █████ + ████▄▄▄▄▄▄▄█▄▄█▄▄▄█▄█▄▄██████▄▄██████ + █████████████████████████████████████ On your iPhone: 1. Open Camera, point at the QR code, tap the yellow banner 2. Allow the download when Safari asks - 3. Settings → "Profile Downloaded" → Install - 4. Settings → General → About → Certificate Trust Settings + 3. Open Settings — tap "Profile Downloaded" near the top + (or: Settings → General → VPN & Device Management → Numa DNS) + 4. Tap Install (top right), enter passcode, Install again + 5. Settings → General → About → Certificate Trust Settings Toggle ON "Numa Local CA" — required for DoT to work ``` +The same QR is available in the dashboard — click "Phone Setup" in the header and the popover renders an SVG QR code pointing at the mobileconfig URL. On mobile viewports it shows a direct download link instead. + +Numa dashboard with Phone Setup popover showing QR code and install instructions + Step 4 is non-negotiable. Even though the CA is bundled in the same profile that installs the DNS settings, iOS still requires the user to explicitly toggle trust in Certificate Trust Settings. It's a deliberate iOS policy to prevent profile-based trust injection — annoying, and correct. I've been dogfooding this since v0.10 shipped in early April. The phone resolves through Numa over DoT whenever I'm home; persistent connections are visible in the log as a single source port living through dozens of queries. The one real caveat: if the laptop's LAN IP changes, the profile breaks. [RFC 9462 DDR](https://datatracker.ietf.org/doc/html/rfc9462) fixes that — Numa can respond to `_dns.resolver.arpa IN SVCB` with its current IP and iOS picks it up on each network join. Next piece of work. diff --git a/scripts/record-demo.sh b/scripts/record-demo.sh index bb84ead..30c44fb 100755 --- a/scripts/record-demo.sh +++ b/scripts/record-demo.sh @@ -7,18 +7,19 @@ # The script: # 1. Opens the dashboard in Chrome --app mode (clean, no address bar) # 2. Generates DNS traffic (forward, cache hit, blocked) -# 3. Types "peekm" / "6419" into the Local Services form on camera -# 4. Shows LAN accessibility badge ("local only" / "LAN") -# 5. Checks a blocked domain -# 6. Opens peekm.numa to show the proxy working -# 7. Records via ffmpeg and converts to optimized GIF +# 3. Opens Phone Setup QR popover +# 4. Types "peekm" / "6419" into the Local Services form on camera +# 5. Shows LAN accessibility badge ("local only" / "LAN") +# 6. Checks a blocked domain +# 7. Opens peekm.numa to show the proxy working +# 8. Records via ffmpeg and converts to optimized GIF set -euo pipefail # --------------- Configuration --------------- OUTPUT="${1:-assets/hero-demo.gif}" PORT=5380 -RECORD_SECONDS=20 +RECORD_SECONDS=24 VIEWPORT_W=1800 VIEWPORT_H=1100 FPS=12 @@ -230,8 +231,16 @@ dig @127.0.0.1 github.com +short > /dev/null 2>&1 dig @127.0.0.1 ad.doubleclick.net +short > /dev/null 2>&1 sleep 3 -# --------------- Scene 2: Add peekm service via UI (3-7s) --------------- -log "Scene 2: Adding peekm.numa service..." +# --------------- Scene 2: Phone Setup popover (3-7s) --------------- +log "Scene 2: Phone Setup QR popover..." +run_js "document.querySelector('#phoneSetup button').click();" +sleep 3 +# Dismiss popover +run_js "document.getElementById('phoneSetupPopover').style.display = 'none';" +sleep 1 + +# --------------- Scene 3: Add peekm service via UI (7-11s) --------------- +log "Scene 3: Adding peekm.numa service..." # Services panel is now first — scroll to it run_js " @@ -249,18 +258,18 @@ sleep 0.3 run_js "document.querySelector('#serviceForm .btn-add').click();" sleep 2 -# --------------- Scene 3: Open peekm.numa (7-11s) --------------- -log "Scene 3: Opening peekm.numa in browser..." +# --------------- Scene 4: Open peekm.numa (11-15s) --------------- +log "Scene 4: Opening peekm.numa in browser..." open "http://peekm.numa/view/peekm/README.md" 2>/dev/null || true sleep 4 -# --------------- Scene 4: Back to dashboard (11-14s) --------------- -log "Scene 4: Back to dashboard — LAN badges + LOCAL queries visible..." +# --------------- Scene 5: Back to dashboard (15-18s) --------------- +log "Scene 5: Back to dashboard — LAN badges + LOCAL queries visible..." osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $CHROME_PID) to true" 2>/dev/null || true sleep 3 -# --------------- Scene 5: Check Domain blocker (14-17s) --------------- -log "Scene 5: Check Domain — blocked tracker..." +# --------------- Scene 6: Check Domain blocker (18-21s) --------------- +log "Scene 6: Check Domain — blocked tracker..." # Scroll down to blocking panel run_js " var blockPanel = document.getElementById('blockingPanel'); @@ -273,8 +282,8 @@ sleep 0.3 run_js "document.querySelector('#checkDomainInput').closest('form').querySelector('.btn').click();" sleep 2 -# --------------- Scene 6: Terminal-style dig overlay (17-20s) --------------- -log "Scene 6: dig proof overlay..." +# --------------- Scene 7: Terminal-style dig overlay (21-24s) --------------- +log "Scene 7: dig proof overlay..." DIG_RESULT=$(dig @127.0.0.1 peekm.numa +short 2>/dev/null | head -1) run_js " var overlay = document.createElement('div'); diff --git a/site/blog/phone-setup-dashboard.png b/site/blog/phone-setup-dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..bdacb62f99aecc4386ef1957037f9ca28417ff1e GIT binary patch literal 317176 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

6%wb&^j|SyT4L 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�-B_5q1J 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 z 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 UBzXI<01x;mBGll;GNwWQ133Cmp#T5? literal 0 HcmV?d00001 -- 2.34.1 From a96b84fdebd758874836d9e5f3de60cad84c083b Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 23:16:59 +0300 Subject: [PATCH 067/204] ci: use pandoc/actions/setup instead of apt-get (#76) apt-get install pandoc took ~27 minutes due to apt index refresh. The prebuilt binary action completes in seconds. Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/static.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 90e7f35..84a76db 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -32,7 +32,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 - name: Install pandoc - run: sudo apt-get install -y pandoc + uses: pandoc/actions/setup@v1 - name: Generate blog HTML run: make blog - name: Setup Pages -- 2.34.1 From 8abcd91f95d805eb36782b2837851579d8ec2c4f Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 11 Apr 2026 00:26:58 +0300 Subject: [PATCH 068/204] feat: multi-forwarder with SRTT-based failover (#77) * feat: multi-forwarder with SRTT-based failover address accepts string or array, with optional per-server port override. New fallback pool tried only when all primaries fail. Sequential failover with SRTT ranking ensures fastest upstream is tried first. Closes #34 (items 1, 2, 3) Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: simplify failover candidate list and deduplicate recursive pool Co-Authored-By: Claude Opus 4.6 * refactor: extract maybe_update_primary for testable upstream re-detection Co-Authored-By: Claude Opus 4.6 * style: rustfmt Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/api.rs | 18 ++-- src/config.rs | 66 ++++++++++++-- src/ctx.rs | 12 +-- src/dot.rs | 5 +- src/forward.rs | 241 ++++++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 96 ++++++++------------ 6 files changed, 357 insertions(+), 81 deletions(-) diff --git a/src/api.rs b/src/api.rs index 2e66931..a0bae58 100644 --- a/src/api.rs +++ b/src/api.rs @@ -411,9 +411,12 @@ async fn diagnose( } // Check upstream (async, no locks held) - let upstream = ctx.upstream.lock().unwrap().clone(); - let (upstream_matched, upstream_detail) = - forward_query_for_diagnose(&domain_lower, &upstream, ctx.timeout).await; + let upstream = ctx.upstream_pool.lock().unwrap().preferred().cloned(); + let (upstream_matched, upstream_detail) = if let Some(ref u) = upstream { + forward_query_for_diagnose(&domain_lower, u, ctx.timeout).await + } else { + (false, "no upstream configured".to_string()) + }; steps.push(DiagnoseStep { source: "upstream".to_string(), matched: upstream_matched, @@ -520,7 +523,7 @@ async fn stats(State(ctx): State>) -> Json { let upstream = if ctx.upstream_mode == crate::config::UpstreamMode::Recursive { "recursive (root hints)".to_string() } else { - ctx.upstream.lock().unwrap().to_string() + ctx.upstream_pool.lock().unwrap().label() }; Json(StatsResponse { @@ -1016,8 +1019,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_pool: Mutex::new(crate::forward::UpstreamPool::new( + vec![crate::forward::Upstream::Udp( + "127.0.0.1:53".parse().unwrap(), + )], + vec![], )), upstream_auto: false, upstream_port: 53, diff --git a/src/config.rs b/src/config.rs index 9373d33..fa794d7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -97,10 +97,12 @@ impl UpstreamMode { pub struct UpstreamConfig { #[serde(default)] pub mode: UpstreamMode, - #[serde(default = "default_upstream_addr")] - pub address: String, + #[serde(default, deserialize_with = "string_or_vec")] + pub address: Vec, #[serde(default = "default_upstream_port")] pub port: u16, + #[serde(default)] + pub fallback: Vec, #[serde(default = "default_timeout_ms")] pub timeout_ms: u64, #[serde(default = "default_root_hints")] @@ -115,8 +117,9 @@ impl Default for UpstreamConfig { fn default() -> Self { UpstreamConfig { mode: UpstreamMode::default(), - address: default_upstream_addr(), + address: Vec::new(), port: default_upstream_port(), + fallback: Vec::new(), timeout_ms: default_timeout_ms(), root_hints: default_root_hints(), prime_tlds: default_prime_tlds(), @@ -125,6 +128,33 @@ impl Default for UpstreamConfig { } } +fn string_or_vec<'de, D>(deserializer: D) -> std::result::Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + struct Visitor; + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = Vec; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("string or array of strings") + } + fn visit_str(self, v: &str) -> std::result::Result { + Ok(vec![v.to_string()]) + } + fn visit_seq>( + self, + mut seq: A, + ) -> std::result::Result { + let mut v = Vec::new(); + while let Some(s) = seq.next_element::()? { + v.push(s); + } + Ok(v) + } + } + deserializer.deserialize_any(Visitor) +} + fn default_true() -> bool { true } @@ -202,9 +232,6 @@ fn default_root_hints() -> Vec { ] } -fn default_upstream_addr() -> String { - String::new() // empty = auto-detect from system resolver -} fn default_upstream_port() -> u16 { 53 } @@ -525,6 +552,33 @@ mod tests { assert!(config.services[0].routes[0].strip); assert!(!config.services[0].routes[1].strip); // default false } + + #[test] + fn address_string_parses_to_vec() { + let config: Config = toml::from_str("[upstream]\naddress = \"1.2.3.4\"").unwrap(); + assert_eq!(config.upstream.address, vec!["1.2.3.4"]); + } + + #[test] + fn address_array_parses() { + let config: Config = + toml::from_str("[upstream]\naddress = [\"1.2.3.4\", \"5.6.7.8:5353\"]").unwrap(); + assert_eq!(config.upstream.address, vec!["1.2.3.4", "5.6.7.8:5353"]); + } + + #[test] + fn fallback_parses() { + let config: Config = + toml::from_str("[upstream]\nfallback = [\"8.8.8.8\", \"1.1.1.1\"]").unwrap(); + assert_eq!(config.upstream.fallback, vec!["8.8.8.8", "1.1.1.1"]); + } + + #[test] + fn empty_address_gives_empty_vec() { + let config: Config = toml::from_str("").unwrap(); + assert!(config.upstream.address.is_empty()); + assert!(config.upstream.fallback.is_empty()); + } } pub struct ConfigLoad { diff --git a/src/ctx.rs b/src/ctx.rs index 6b774eb..b4e0777 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -16,7 +16,7 @@ use crate::blocklist::BlocklistStore; use crate::buffer::BytePacketBuffer; use crate::cache::{DnsCache, DnssecStatus}; use crate::config::{UpstreamMode, ZoneMap}; -use crate::forward::{forward_query, Upstream}; +use crate::forward::{forward_query, forward_with_failover, Upstream, UpstreamPool}; use crate::header::ResultCode; use crate::health::HealthMeta; use crate::lan::PeerStore; @@ -42,7 +42,7 @@ pub struct ServerCtx { pub services: Mutex, pub lan_peers: Mutex, pub forwarding_rules: Vec, - pub upstream: Mutex, + pub upstream_pool: Mutex, pub upstream_auto: bool, pub upstream_port: u16, pub lan_ip: Mutex, @@ -220,12 +220,8 @@ pub async fn resolve_query( } (resp, path, DnssecStatus::Indeterminate) } else { - let upstream = - match crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) { - Some(addr) => Upstream::Udp(addr), - None => ctx.upstream.lock().unwrap().clone(), - }; - match forward_query(&query, &upstream, ctx.timeout).await { + let pool = ctx.upstream_pool.lock().unwrap().clone(); + match forward_with_failover(&query, &pool, &ctx.srtt, ctx.timeout).await { Ok(resp) => { ctx.cache.write().unwrap().insert(&qname, qtype, &resp); (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate) diff --git a/src/dot.rs b/src/dot.rs index 3ed47ba..0d48fa2 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -362,7 +362,10 @@ 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(upstream_addr)), + upstream_pool: Mutex::new(crate::forward::UpstreamPool::new( + vec![crate::forward::Upstream::Udp(upstream_addr)], + vec![], + )), upstream_auto: false, upstream_port: 53, lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST), diff --git a/src/forward.rs b/src/forward.rs index ea2b03e..78efcb9 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -1,12 +1,14 @@ use std::fmt; -use std::net::SocketAddr; -use std::time::Duration; +use std::net::{IpAddr, SocketAddr}; +use std::sync::RwLock; +use std::time::{Duration, Instant}; use tokio::net::UdpSocket; use tokio::time::timeout; use crate::buffer::BytePacketBuffer; use crate::packet::DnsPacket; +use crate::srtt::SrttCache; use crate::Result; #[derive(Clone)] @@ -37,6 +39,133 @@ impl fmt::Display for Upstream { } } +pub fn parse_upstream_addr(s: &str, default_port: u16) -> std::result::Result { + // Try full socket addr first: "1.2.3.4:5353" or "[::1]:5353" + if let Ok(addr) = s.parse::() { + return Ok(addr); + } + // Bare IP: "1.2.3.4" or "::1" + if let Ok(ip) = s.parse::() { + return Ok(SocketAddr::new(ip, default_port)); + } + Err(format!("invalid upstream address: {}", s)) +} + +pub fn parse_upstream(s: &str, default_port: u16) -> Result { + if s.starts_with("https://") { + let client = reqwest::Client::builder() + .use_rustls_tls() + .build() + .unwrap_or_default(); + return Ok(Upstream::Doh { + url: s.to_string(), + client, + }); + } + let addr = parse_upstream_addr(s, default_port)?; + Ok(Upstream::Udp(addr)) +} + +#[derive(Clone)] +pub struct UpstreamPool { + primary: Vec, + fallback: Vec, +} + +impl UpstreamPool { + pub fn new(primary: Vec, fallback: Vec) -> Self { + Self { primary, fallback } + } + + pub fn preferred(&self) -> Option<&Upstream> { + self.primary.first().or(self.fallback.first()) + } + + pub fn set_primary(&mut self, primary: Vec) { + self.primary = primary; + } + + /// Update the primary upstream if `new_addr` (parsed with `port`) differs + /// from the current preferred upstream. Returns `true` if the pool changed. + pub fn maybe_update_primary(&mut self, new_addr: &str, port: u16) -> bool { + let Ok(new_sock) = format!("{}:{}", new_addr, port).parse::() else { + return false; + }; + let new_upstream = Upstream::Udp(new_sock); + if self.preferred() == Some(&new_upstream) { + return false; + } + self.primary = vec![new_upstream]; + true + } + + pub fn label(&self) -> String { + match self.preferred() { + Some(u) => { + let total = self.primary.len() + self.fallback.len(); + if total > 1 { + format!("{} (+{} more)", u, total - 1) + } else { + u.to_string() + } + } + None => "none".to_string(), + } + } +} + +pub async fn forward_with_failover( + query: &DnsPacket, + pool: &UpstreamPool, + srtt: &RwLock, + timeout_duration: Duration, +) -> Result { + // Build candidate list: primary (sorted by SRTT for UDP) then fallback + let mut candidates: Vec<(usize, u64)> = pool + .primary + .iter() + .enumerate() + .map(|(i, u)| { + let rtt = match u { + Upstream::Udp(addr) => srtt.read().unwrap().get(addr.ip()), + _ => 0, // DoH: keep config order (stable sort preserves it) + }; + (i, rtt) + }) + .collect(); + candidates.sort_by_key(|&(_, rtt)| rtt); + + let all_upstreams: Vec<&Upstream> = candidates + .iter() + .map(|&(i, _)| &pool.primary[i]) + .chain(pool.fallback.iter()) + .collect(); + + let mut last_err: Option> = None; + + for upstream in &all_upstreams { + let start = Instant::now(); + match forward_query(query, upstream, timeout_duration).await { + Ok(resp) => { + if let Upstream::Udp(addr) = upstream { + let rtt_ms = start.elapsed().as_millis() as u64; + srtt.write().unwrap().record_rtt(addr.ip(), rtt_ms, false); + } + return Ok(resp); + } + Err(e) => { + if let Upstream::Udp(addr) = upstream { + srtt.write().unwrap().record_failure(addr.ip()); + } + log::debug!("upstream {} failed: {}", upstream, e); + last_err = Some(e); + } + } + } + + Err(last_err.unwrap_or_else(|| "no upstream configured".into())) +} + pub async fn forward_query( query: &DnsPacket, upstream: &Upstream, @@ -271,4 +400,112 @@ mod tests { let result = forward_query(&make_query(), &upstream, Duration::from_millis(100)).await; assert!(result.is_err()); } + + #[test] + fn parse_addr_ip_only() { + let addr = parse_upstream_addr("1.2.3.4", 53).unwrap(); + assert_eq!(addr, "1.2.3.4:53".parse::().unwrap()); + } + + #[test] + fn parse_addr_ip_port() { + let addr = parse_upstream_addr("1.2.3.4:5353", 53).unwrap(); + assert_eq!(addr, "1.2.3.4:5353".parse::().unwrap()); + } + + #[test] + fn parse_addr_ipv6_bracketed() { + let addr = parse_upstream_addr("[::1]:5553", 53).unwrap(); + assert_eq!(addr, "[::1]:5553".parse::().unwrap()); + } + + #[test] + fn parse_addr_ipv6_bare() { + let addr = parse_upstream_addr("::1", 53).unwrap(); + assert_eq!(addr, "[::1]:53".parse::().unwrap()); + } + + #[test] + fn pool_label_single() { + let pool = UpstreamPool::new(vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())], vec![]); + assert_eq!(pool.label(), "1.2.3.4:53"); + } + + #[test] + fn pool_label_multi() { + let pool = UpstreamPool::new( + vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())], + vec![Upstream::Udp("8.8.8.8:53".parse().unwrap())], + ); + assert_eq!(pool.label(), "1.2.3.4:53 (+1 more)"); + } + + #[tokio::test] + async fn failover_tries_next_on_failure() { + // First upstream is unreachable, second responds + let query = make_query(); + let response_bytes = to_wire(&make_response(&query)); + + let app = axum::Router::new().route( + "/dns-query", + axum::routing::post(move || { + let body = response_bytes.clone(); + async move { + ( + [(axum::http::header::CONTENT_TYPE, "application/dns-message")], + body, + ) + } + }), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let good_addr = listener.local_addr().unwrap(); + tokio::spawn(axum::serve(listener, app).into_future()); + + // Unreachable UDP upstream + working DoH upstream + let pool = UpstreamPool::new( + vec![ + Upstream::Udp("127.0.0.1:1".parse().unwrap()), // will fail + Upstream::Doh { + url: format!("http://{}/dns-query", good_addr), + client: reqwest::Client::new(), + }, + ], + vec![], + ); + + let srtt = RwLock::new(SrttCache::new(true)); + let result = forward_with_failover(&query, &pool, &srtt, Duration::from_millis(500)) + .await + .expect("should fail over to second upstream"); + + assert_eq!(result.header.id, 0xABCD); + assert_eq!(result.answers.len(), 1); + } + + #[test] + fn maybe_update_primary_swaps_when_different() { + let mut pool = UpstreamPool::new( + vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())], + vec![Upstream::Udp("8.8.8.8:53".parse().unwrap())], + ); + assert!(pool.maybe_update_primary("5.6.7.8", 53)); + assert_eq!(pool.preferred().unwrap().to_string(), "5.6.7.8:53"); + } + + #[test] + fn maybe_update_primary_noop_when_same() { + let mut pool = + UpstreamPool::new(vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())], vec![]); + assert!(!pool.maybe_update_primary("1.2.3.4", 53)); + } + + #[test] + fn maybe_update_primary_rejects_invalid_addr() { + let mut pool = + UpstreamPool::new(vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())], vec![]); + assert!(!pool.maybe_update_primary("not-an-ip", 53)); + assert_eq!(pool.preferred().unwrap().to_string(), "1.2.3.4:53"); + } } diff --git a/src/main.rs b/src/main.rs index 62acb69..9e2d2f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,7 @@ use numa::buffer::BytePacketBuffer; use numa::cache::DnsCache; use numa::config::{build_zone_map, load_config, ConfigLoad}; use numa::ctx::{handle_query, ServerCtx}; -use numa::forward::Upstream; +use numa::forward::{parse_upstream, Upstream, UpstreamPool}; use numa::override_store::OverrideStore; use numa::query_log::QueryLog; use numa::service_store::ServiceStore; @@ -129,18 +129,18 @@ async fn main() -> numa::Result<()> { let root_hints = numa::recursive::parse_root_hints(&config.upstream.root_hints); - let (resolved_mode, upstream_auto, upstream, upstream_label) = match config.upstream.mode { + let recursive_pool = || { + let dummy = UpstreamPool::new(vec![Upstream::Udp("0.0.0.0:0".parse().unwrap())], vec![]); + (dummy, "recursive (root hints)".to_string()) + }; + + let (resolved_mode, upstream_auto, pool, upstream_label) = match config.upstream.mode { numa::config::UpstreamMode::Auto => { info!("auto mode: probing recursive resolution..."); if numa::recursive::probe_recursive(&root_hints).await { info!("recursive probe succeeded — self-sovereign mode"); - let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap()); - ( - numa::config::UpstreamMode::Recursive, - false, - dummy, - "recursive (root hints)".to_string(), - ) + let (pool, label) = recursive_pool(); + (numa::config::UpstreamMode::Recursive, false, pool, label) } else { log::warn!("recursive probe failed — falling back to Quad9 DoH"); let client = reqwest::Client::builder() @@ -149,55 +149,45 @@ async fn main() -> numa::Result<()> { .unwrap_or_default(); let url = DOH_FALLBACK.to_string(); let label = url.clone(); - ( - numa::config::UpstreamMode::Forward, - false, - Upstream::Doh { url, client }, - label, - ) + let pool = UpstreamPool::new(vec![Upstream::Doh { url, client }], vec![]); + (numa::config::UpstreamMode::Forward, false, pool, label) } } numa::config::UpstreamMode::Recursive => { - let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap()); - ( - numa::config::UpstreamMode::Recursive, - false, - dummy, - "recursive (root hints)".to_string(), - ) + let (pool, label) = recursive_pool(); + (numa::config::UpstreamMode::Recursive, false, pool, label) } numa::config::UpstreamMode::Forward => { - let upstream_addr = if config.upstream.address.is_empty() { - system_dns + let addrs = if config.upstream.address.is_empty() { + let detected = system_dns .default_upstream .or_else(numa::system_dns::detect_dhcp_dns) .unwrap_or_else(|| { info!("could not detect system DNS, falling back to Quad9 DoH"); DOH_FALLBACK.to_string() - }) + }); + vec![detected] } else { config.upstream.address.clone() }; - let upstream: Upstream = if upstream_addr.starts_with("https://") { - let client = reqwest::Client::builder() - .use_rustls_tls() - .build() - .unwrap_or_default(); - Upstream::Doh { - url: upstream_addr, - client, - } - } else { - let addr: SocketAddr = - format!("{}:{}", upstream_addr, config.upstream.port).parse()?; - Upstream::Udp(addr) - }; - let label = upstream.to_string(); + let primary: Vec = addrs + .iter() + .map(|s| parse_upstream(s, config.upstream.port)) + .collect::>>()?; + let fallback: Vec = config + .upstream + .fallback + .iter() + .map(|s| parse_upstream(s, config.upstream.port)) + .collect::>>()?; + + let pool = UpstreamPool::new(primary, fallback); + let label = pool.label(); ( numa::config::UpstreamMode::Forward, config.upstream.address.is_empty(), - upstream, + pool, label, ) } @@ -294,7 +284,7 @@ async fn main() -> numa::Result<()> { services: Mutex::new(service_store), lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)), forwarding_rules, - upstream: Mutex::new(upstream), + upstream_pool: Mutex::new(pool), upstream_auto, upstream_port: config.upstream.port, lan_ip: Mutex::new(numa::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)), @@ -613,27 +603,17 @@ async fn network_watch_loop(ctx: Arc) { } } - // Re-detect upstream every 30s or on LAN IP change (UDP only — - // DoH upstreams are explicitly configured via URL, not auto-detected) - if ctx.upstream_auto - && matches!(*ctx.upstream.lock().unwrap(), Upstream::Udp(_)) - && (changed || tick.is_multiple_of(6)) - { + // Re-detect upstream every 30s or on LAN IP change (auto-detect only) + if ctx.upstream_auto && (changed || tick.is_multiple_of(6)) { let dns_info = numa::system_dns::discover_system_dns(); let new_addr = dns_info .default_upstream .or_else(numa::system_dns::detect_dhcp_dns) .unwrap_or_else(|| QUAD9_IP.to_string()); - if let Ok(new_sock) = - format!("{}:{}", new_addr, ctx.upstream_port).parse::() - { - let new_upstream = Upstream::Udp(new_sock); - let mut upstream = ctx.upstream.lock().unwrap(); - if *upstream != new_upstream { - info!("upstream changed: {} → {}", upstream, new_upstream); - *upstream = new_upstream; - changed = true; - } + let mut pool = ctx.upstream_pool.lock().unwrap(); + if pool.maybe_update_primary(&new_addr, ctx.upstream_port) { + info!("upstream changed → {}", pool.label()); + changed = true; } } -- 2.34.1 From 777012958917d454a3323f177d272b660ff4972a Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 11 Apr 2026 01:14:04 +0300 Subject: [PATCH 069/204] =?UTF-8?q?feat:=20cache=20warming=20=E2=80=94=20p?= =?UTF-8?q?roactive=20DNS=20resolution=20for=20configured=20domains=20(#78?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves A + AAAA at startup for domains listed in [cache] warm, then re-resolves before TTL expiry (at 75% elapsed). Keeps critical domains always hot in cache with zero client-visible latency. Closes #34 (item 4) Co-authored-by: Claude Opus 4.6 --- src/cache.rs | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 3 ++ src/main.rs | 62 +++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) diff --git a/src/cache.rs b/src/cache.rs index d9a2a76..5bdde85 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -82,6 +82,29 @@ impl DnsCache { Some((packet, entry.dnssec_status)) } + pub fn ttl_remaining(&self, domain: &str, qtype: QueryType) -> Option<(u32, u32)> { + let type_map = self.entries.get(domain)?; + let entry = type_map.get(&qtype)?; + let elapsed = entry.inserted_at.elapsed(); + if elapsed >= entry.ttl { + return None; + } + let total = entry.ttl.as_secs() as u32; + let remaining = (entry.ttl - elapsed).as_secs() as u32; + Some((remaining, total)) + } + + pub fn needs_warm(&self, domain: &str) -> bool { + for qtype in [QueryType::A, QueryType::AAAA] { + match self.ttl_remaining(domain, qtype) { + None => return true, + Some((remaining, total)) if remaining < total / 4 => return true, + _ => {} + } + } + false + } + pub fn insert(&mut self, domain: &str, qtype: QueryType, packet: &DnsPacket) { self.insert_with_status(domain, qtype, packet, DnssecStatus::Indeterminate); } @@ -233,4 +256,66 @@ mod tests { cache.insert("example.com", QueryType::A, &pkt); assert!(cache.heap_bytes() > empty); } + + #[test] + fn ttl_remaining_returns_values_for_fresh_entry() { + let mut cache = DnsCache::new(100, 60, 3600); + let mut pkt = DnsPacket::new(); + pkt.answers.push(DnsRecord::A { + domain: "example.com".into(), + addr: "1.2.3.4".parse().unwrap(), + ttl: 300, + }); + cache.insert("example.com", QueryType::A, &pkt); + let (remaining, total) = cache.ttl_remaining("example.com", QueryType::A).unwrap(); + assert_eq!(total, 300); + assert!(remaining <= 300); + assert!(remaining > 0); + } + + #[test] + fn ttl_remaining_none_for_missing() { + let cache = DnsCache::new(100, 1, 3600); + assert!(cache.ttl_remaining("missing.com", QueryType::A).is_none()); + } + + #[test] + fn needs_warm_true_when_missing() { + let cache = DnsCache::new(100, 1, 3600); + assert!(cache.needs_warm("missing.com")); + } + + #[test] + fn needs_warm_false_when_fresh() { + let mut cache = DnsCache::new(100, 1, 3600); + let mut pkt_a = DnsPacket::new(); + pkt_a.answers.push(DnsRecord::A { + domain: "example.com".into(), + addr: "1.2.3.4".parse().unwrap(), + ttl: 300, + }); + let mut pkt_aaaa = DnsPacket::new(); + pkt_aaaa.answers.push(DnsRecord::AAAA { + domain: "example.com".into(), + addr: "::1".parse().unwrap(), + ttl: 300, + }); + cache.insert("example.com", QueryType::A, &pkt_a); + cache.insert("example.com", QueryType::AAAA, &pkt_aaaa); + assert!(!cache.needs_warm("example.com")); + } + + #[test] + fn needs_warm_true_when_only_a_cached() { + let mut cache = DnsCache::new(100, 1, 3600); + let mut pkt = DnsPacket::new(); + pkt.answers.push(DnsRecord::A { + domain: "example.com".into(), + addr: "1.2.3.4".parse().unwrap(), + ttl: 300, + }); + cache.insert("example.com", QueryType::A, &pkt); + // AAAA missing → needs warm + assert!(cache.needs_warm("example.com")); + } } diff --git a/src/config.rs b/src/config.rs index fa794d7..708ed4f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -247,6 +247,8 @@ pub struct CacheConfig { pub min_ttl: u32, #[serde(default = "default_max_ttl")] pub max_ttl: u32, + #[serde(default)] + pub warm: Vec, } impl Default for CacheConfig { @@ -255,6 +257,7 @@ impl Default for CacheConfig { max_entries: default_max_entries(), min_ttl: default_min_ttl(), max_ttl: default_max_ttl(), + warm: Vec::new(), } } } diff --git a/src/main.rs b/src/main.rs index 9e2d2f8..cee680a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -402,6 +402,9 @@ async fn main() -> numa::Result<()> { g, &format!("max {} entries", config.cache.max_entries), ); + if !config.cache.warm.is_empty() { + row("Warm", g, &format!("{} domains", config.cache.warm.len())); + } row( "Blocking", g, @@ -484,6 +487,15 @@ async fn main() -> numa::Result<()> { }); } + // Spawn cache warming for user-configured domains + if !config.cache.warm.is_empty() { + let warm_ctx = Arc::clone(&ctx); + let warm_domains = config.cache.warm.clone(); + tokio::spawn(async move { + cache_warm_loop(warm_ctx, warm_domains).await; + }); + } + // Spawn HTTP API server let api_ctx = Arc::clone(&ctx); let api_addr: SocketAddr = format!("{}:{}", config.server.api_bind_addr, api_port).parse()?; @@ -720,3 +732,53 @@ async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) { downloaded.len() ); } + +async fn warm_domain(ctx: &ServerCtx, domain: &str) { + use numa::question::QueryType; + + for qtype in [QueryType::A, QueryType::AAAA] { + let query = numa::packet::DnsPacket::query(0, domain, qtype); + let result = if ctx.upstream_mode == numa::config::UpstreamMode::Recursive { + numa::recursive::resolve_recursive( + domain, + qtype, + &ctx.cache, + &query, + &ctx.root_hints, + &ctx.srtt, + ) + .await + } else { + let pool = ctx.upstream_pool.lock().unwrap().clone(); + numa::forward::forward_with_failover(&query, &pool, &ctx.srtt, ctx.timeout).await + }; + match result { + Ok(resp) => { + ctx.cache.write().unwrap().insert(domain, qtype, &resp); + log::debug!("cache warm: {} {:?}", domain, qtype); + } + Err(e) => log::warn!("cache warm: {} {:?} failed: {}", domain, qtype, e), + } + } +} + +async fn cache_warm_loop(ctx: Arc, domains: Vec) { + tokio::time::sleep(Duration::from_secs(2)).await; + + for domain in &domains { + warm_domain(&ctx, domain).await; + } + info!("cache warm: {} domains resolved at startup", domains.len()); + + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.tick().await; + loop { + interval.tick().await; + for domain in &domains { + let refresh = ctx.cache.read().unwrap().needs_warm(domain); + if refresh { + warm_domain(&ctx, domain).await; + } + } + } +} -- 2.34.1 From 7d6b0ed568e6c8758e93c1ded1cb420683f8e7f5 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 11 Apr 2026 04:06:17 +0300 Subject: [PATCH 070/204] feat: DoH server endpoint + DoT enabled by default (#79) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: document multi-forwarder and cache warming in config and README Co-Authored-By: Claude Opus 4.6 * feat: DNS-over-HTTPS server endpoint (RFC 8484) Serve DoH at POST /dns-query on the existing HTTPS proxy (port 443). Automatically enabled when proxy TLS is active — no config needed. Also fix zone map priority so local zones override RFC 6762 .local special-use handling. Co-Authored-By: Claude Opus 4.6 (1M context) * style: cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: remove GoatCounter analytics from site GoatCounter domains (goatcounter.com, gc.zgo.at) are blocked by Hagezi Pro, which is Numa's default blocklist. A DNS privacy tool should not embed analytics that its own resolver blocks. Co-Authored-By: Claude Opus 4.6 * feat: enable DoT listener by default DoT now starts automatically with `sudo numa`, matching the proxy and DoH which are already on by default. The self-signed CA infrastructure is shared with the proxy, so there is no additional setup. This makes `numa setup-phone` work out of the box. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 --- README.md | 2 + numa.toml | 10 ++- site/blog-template.html | 2 - site/blog/index.html | 2 - site/index.html | 2 - src/config.rs | 7 +- src/ctx.rs | 8 +- src/doh.rs | 188 ++++++++++++++++++++++++++++++++++++++++ src/health.rs | 4 + src/lib.rs | 1 + src/main.rs | 9 ++ src/proxy.rs | 36 ++++++-- tests/integration.sh | 48 ++++++++++ 13 files changed, 298 insertions(+), 21 deletions(-) create mode 100644 src/doh.rs diff --git a/README.md b/README.md index 69ecd80..44b8aa4 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,8 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena - [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 +- [x] Multi-forwarder failover — multiple upstreams with SRTT ranking, fallback pool +- [x] Cache warming — proactive resolution for configured domains - [x] Mobile onboarding — `setup-phone` QR flow, mobile API, mobileconfig profiles - [ ] pkarr integration — self-sovereign DNS via Mainline DHT - [ ] Global `.numa` names — DHT-backed, no registrar diff --git a/numa.toml b/numa.toml index 4389fdb..92b5411 100644 --- a/numa.toml +++ b/numa.toml @@ -12,10 +12,11 @@ api_port = 5380 # [upstream] # mode = "forward" # "forward" (default) — relay to upstream # # "recursive" — resolve from root hints (no address needed) +# address = "9.9.9.9" # single upstream (plain UDP) +# address = ["192.168.1.1", "9.9.9.9:5353"] # multiple upstreams — SRTT picks fastest # address = "https://dns.quad9.net/dns-query" # DNS-over-HTTPS (encrypted) -# address = "https://cloudflare-dns.com/dns-query" # Cloudflare DoH -# address = "9.9.9.9" # plain UDP -# port = 53 # only for forward mode, plain UDP +# fallback = ["8.8.8.8", "1.1.1.1"] # tried only when all primaries fail +# port = 53 # default port for addresses without :port # timeout_ms = 3000 # root_hints = [ # only used in recursive mode # "198.41.0.4", # a.root-servers.net (Verisign) @@ -54,6 +55,7 @@ api_port = 5380 max_entries = 10000 min_ttl = 60 max_ttl = 86400 +# warm = ["google.com", "github.com"] # resolve at startup, refresh before TTL expiry [proxy] enabled = true @@ -91,7 +93,7 @@ tld = "numa" # DNS-over-TLS listener (RFC 7858) — encrypted DNS on port 853 # [dot] -# enabled = false # opt-in: accept DoT queries +# enabled = true # on by default; set false to disable # 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) diff --git a/site/blog-template.html b/site/blog-template.html index 85e854b..54f0eae 100644 --- a/site/blog-template.html +++ b/site/blog-template.html @@ -298,7 +298,5 @@ $body$ Blog - diff --git a/site/blog/index.html b/site/blog/index.html index 10d62a7..993c166 100644 --- a/site/blog/index.html +++ b/site/blog/index.html @@ -197,7 +197,5 @@ body::before { Home - diff --git a/site/index.html b/site/index.html index 27ea8fb..0231e0a 100644 --- a/site/index.html +++ b/site/index.html @@ -1769,7 +1769,5 @@ const observer = new IntersectionObserver((entries) => { document.querySelectorAll('.reveal').forEach(el => observer.observe(el)); - diff --git a/src/config.rs b/src/config.rs index 708ed4f..6480883 100644 --- a/src/config.rs +++ b/src/config.rs @@ -411,7 +411,7 @@ pub struct DnssecConfig { #[derive(Deserialize, Clone)] pub struct DotConfig { - #[serde(default)] + #[serde(default = "default_dot_enabled")] pub enabled: bool, #[serde(default = "default_dot_port")] pub port: u16, @@ -428,7 +428,7 @@ pub struct DotConfig { impl Default for DotConfig { fn default() -> Self { DotConfig { - enabled: false, + enabled: default_dot_enabled(), port: default_dot_port(), bind_addr: default_dot_bind_addr(), cert_path: None, @@ -437,6 +437,9 @@ impl Default for DotConfig { } } +fn default_dot_enabled() -> bool { + true +} fn default_dot_port() -> u16 { 853 } diff --git a/src/ctx.rs b/src/ctx.rs index b4e0777..3ef6a0a 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -110,6 +110,10 @@ pub async fn resolve_query( 300, )); (resp, QueryPath::Local, DnssecStatus::Indeterminate) + } else if let Some(records) = ctx.zone_map.get(qname.as_str()).and_then(|m| m.get(&qtype)) { + let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); + resp.answers = records.clone(); + (resp, QueryPath::Local, DnssecStatus::Indeterminate) } else if is_special_use_domain(&qname) { // RFC 6761/8880: private PTR, DDR, NAT64 — answer locally let resp = special_use_response(&query, &qname, qtype); @@ -158,10 +162,6 @@ pub async fn resolve_query( 60, )); (resp, QueryPath::Blocked, DnssecStatus::Indeterminate) - } else if let Some(records) = ctx.zone_map.get(qname.as_str()).and_then(|m| m.get(&qtype)) { - let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); - resp.answers = records.clone(); - (resp, QueryPath::Local, DnssecStatus::Indeterminate) } else { let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype); if let Some((cached, cached_dnssec)) = cached { diff --git a/src/doh.rs b/src/doh.rs new file mode 100644 index 0000000..cf50b31 --- /dev/null +++ b/src/doh.rs @@ -0,0 +1,188 @@ +use std::net::SocketAddr; + +use axum::body::Bytes; +use axum::extract::{Request, State}; +use axum::response::{IntoResponse, Response}; +use hyper::StatusCode; +use log::warn; + +use crate::buffer::BytePacketBuffer; +use crate::ctx::{resolve_query, ServerCtx}; +use crate::header::ResultCode; +use crate::packet::DnsPacket; + +const MAX_DNS_MSG: usize = 4096; +const DOH_CONTENT_TYPE: &str = "application/dns-message"; + +pub async fn doh_post(State(state): State, req: Request) -> Response { + let host = super::proxy::extract_host(&req); + if !is_doh_host(host.as_deref(), &state.ctx.proxy_tld) { + return StatusCode::NOT_FOUND.into_response(); + } + + let content_type = req + .headers() + .get(hyper::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if !content_type.starts_with(DOH_CONTENT_TYPE) { + return StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response(); + } + + let body = match axum::body::to_bytes(req.into_body(), MAX_DNS_MSG).await { + Ok(b) => b, + Err(_) => { + return (StatusCode::PAYLOAD_TOO_LARGE, "body exceeds 4096 bytes").into_response() + } + }; + + if body.is_empty() { + return (StatusCode::BAD_REQUEST, "empty body").into_response(); + } + + let src = state + .remote_addr + .unwrap_or_else(|| SocketAddr::from(([127, 0, 0, 1], 0))); + + resolve_doh(&body, src, &state.ctx).await +} + +fn is_doh_host(host: Option<&str>, tld: &str) -> bool { + match host { + Some(h) if h == tld => true, + Some(h) => { + h.len() == 2 * tld.len() + 1 + && h.starts_with(tld) + && h.as_bytes().get(tld.len()) == Some(&b'.') + && h.ends_with(tld) + } + None => false, + } +} + +async fn resolve_doh(dns_bytes: &[u8], src: SocketAddr, ctx: &ServerCtx) -> Response { + let mut buffer = BytePacketBuffer::from_bytes(dns_bytes); + let query = match DnsPacket::from_buffer(&mut buffer) { + Ok(q) => q, + Err(e) => { + warn!("DoH: parse error from {}: {}", src, e); + let query_id = u16::from_be_bytes([ + dns_bytes.first().copied().unwrap_or(0), + dns_bytes.get(1).copied().unwrap_or(0), + ]); + let mut resp = DnsPacket::new(); + resp.header.id = query_id; + resp.header.response = true; + resp.header.rescode = ResultCode::FORMERR; + return serialize_response(&resp); + } + }; + + let query_id = query.header.id; + let query_rd = query.header.recursion_desired; + let questions = query.questions.clone(); + + match resolve_query(query, src, ctx).await { + Ok(resp_buffer) => { + let min_ttl = extract_min_ttl(resp_buffer.filled()); + dns_response(resp_buffer.filled(), min_ttl) + } + Err(e) => { + warn!("DoH: resolve error for {}: {}", src, e); + let mut resp = DnsPacket::new(); + resp.header.id = query_id; + resp.header.response = true; + resp.header.recursion_desired = query_rd; + resp.header.recursion_available = true; + resp.header.rescode = ResultCode::SERVFAIL; + resp.questions = questions; + serialize_response(&resp) + } + } +} + +fn extract_min_ttl(wire: &[u8]) -> u32 { + let mut buf = BytePacketBuffer::from_bytes(wire); + match DnsPacket::from_buffer(&mut buf) { + Ok(pkt) => pkt.answers.iter().map(|r| r.ttl()).min().unwrap_or(0), + Err(_) => 0, + } +} + +fn dns_response(wire: &[u8], min_ttl: u32) -> Response { + ( + StatusCode::OK, + [ + (hyper::header::CONTENT_TYPE, DOH_CONTENT_TYPE), + ( + hyper::header::CACHE_CONTROL, + &format!("max-age={}", min_ttl), + ), + ], + Bytes::copy_from_slice(wire), + ) + .into_response() +} + +fn serialize_response(pkt: &DnsPacket) -> Response { + let mut buf = BytePacketBuffer::new(); + match pkt.write(&mut buf) { + Ok(_) => dns_response(buf.filled(), 0), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::buffer::BytePacketBuffer; + use crate::header::ResultCode; + use crate::packet::DnsPacket; + use crate::record::DnsRecord; + + #[test] + fn is_doh_host_matches_tld() { + assert!(is_doh_host(Some("numa"), "numa")); + assert!(is_doh_host(Some("numa.numa"), "numa")); + assert!(!is_doh_host(Some("foo.numa"), "numa")); + assert!(!is_doh_host(None, "numa")); + } + + #[test] + fn extract_min_ttl_from_response() { + let mut pkt = DnsPacket::new(); + pkt.header.response = true; + pkt.answers.push(DnsRecord::A { + domain: "example.com".to_string(), + addr: std::net::Ipv4Addr::new(1, 2, 3, 4), + ttl: 300, + }); + pkt.answers.push(DnsRecord::A { + domain: "example.com".to_string(), + addr: std::net::Ipv4Addr::new(5, 6, 7, 8), + ttl: 60, + }); + let mut buf = BytePacketBuffer::new(); + pkt.write(&mut buf).unwrap(); + assert_eq!(extract_min_ttl(buf.filled()), 60); + } + + #[test] + fn extract_min_ttl_no_answers() { + let mut pkt = DnsPacket::new(); + pkt.header.response = true; + let mut buf = BytePacketBuffer::new(); + pkt.write(&mut buf).unwrap(); + assert_eq!(extract_min_ttl(buf.filled()), 0); + } + + #[test] + fn serialize_formerr_response() { + let mut pkt = DnsPacket::new(); + pkt.header.id = 0xABCD; + pkt.header.response = true; + pkt.header.rescode = ResultCode::FORMERR; + let resp = serialize_response(&pkt); + assert_eq!(resp.status(), StatusCode::OK); + } +} diff --git a/src/health.rs b/src/health.rs index b2359c4..e55c569 100644 --- a/src/health.rs +++ b/src/health.rs @@ -73,11 +73,15 @@ impl HealthMeta { recursive_enabled: bool, mdns_enabled: bool, blocking_enabled: bool, + doh_enabled: bool, ) -> Self { let ca_path = data_dir.join("ca.pem"); let ca_fingerprint_sha256 = compute_ca_fingerprint(&ca_path); let mut features = Vec::new(); + if doh_enabled { + features.push("doh".to_string()); + } if dot_enabled { features.push("dot".to_string()); } diff --git a/src/lib.rs b/src/lib.rs index 066c7ca..be71125 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 doh; pub mod dot; pub mod forward; pub mod header; diff --git a/src/main.rs b/src/main.rs index cee680a..903be9a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -243,6 +243,7 @@ async fn main() -> numa::Result<()> { None }; + let doh_enabled = initial_tls.is_some(); let health_meta = numa::health::HealthMeta::build( &resolved_data_dir, config.dot.enabled, @@ -252,6 +253,7 @@ async fn main() -> numa::Result<()> { resolved_mode == numa::config::UpstreamMode::Recursive, config.lan.enabled, config.blocking.enabled, + doh_enabled, ); let ca_pem = std::fs::read_to_string(resolved_data_dir.join("ca.pem")).ok(); @@ -431,6 +433,13 @@ async fn main() -> numa::Result<()> { if config.dot.enabled { row("DoT", g, &format!("tls://:{}", config.dot.port)); } + if doh_enabled { + row( + "DoH", + g, + &format!("https://:{}/dns-query", config.proxy.tls_port), + ); + } if config.lan.enabled { row("LAN", g, "mDNS (_numa._tcp.local)"); } diff --git a/src/proxy.rs b/src/proxy.rs index 244e597..b158d9b 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use axum::body::Body; use axum::extract::{Request, State}; use axum::response::IntoResponse; -use axum::routing::any; +use axum::routing::{any, post}; use axum::Router; use http_body_util::BodyExt; use hyper::StatusCode; @@ -18,6 +18,14 @@ use crate::ctx::ServerCtx; type HttpClient = Client; +/// State passed to the DoH handler. Includes the remote address so +/// `resolve_query` can log the client IP. +#[derive(Clone)] +pub struct DohState { + pub ctx: Arc, + pub remote_addr: Option, +} + #[derive(Clone)] struct ProxyState { ctx: Arc, @@ -74,9 +82,17 @@ pub async fn start_proxy_tls(ctx: Arc, port: u16, bind_addr: Ipv4Addr // Hold a separate Arc so we can access tls_config after ctx moves into ProxyState let tls_holder = Arc::clone(&ctx); - let state = ProxyState { ctx, client }; + let proxy_state = ProxyState { + ctx: Arc::clone(&ctx), + client, + }; - let app = Router::new().fallback(any(proxy_handler)).with_state(state); + // DoH route (RFC 8484) served only on the TLS listener. + // DohState.remote_addr is set per-connection below. + let doh_state = DohState { + ctx, + remote_addr: None, + }; loop { let (tcp_stream, remote_addr) = match listener.accept().await { @@ -91,7 +107,17 @@ pub async fn start_proxy_tls(ctx: Arc, port: u16, bind_addr: Ipv4Addr // unwrap safe: guarded by is_none() check above let acceptor = TlsAcceptor::from(Arc::clone(&*tls_holder.tls_config.as_ref().unwrap().load())); - let app = app.clone(); + + let mut conn_doh_state = doh_state.clone(); + conn_doh_state.remote_addr = Some(remote_addr); + + let app = Router::new() + .route( + "/dns-query", + post(crate::doh::doh_post).with_state(conn_doh_state), + ) + .fallback(any(proxy_handler)) + .with_state(proxy_state.clone()); tokio::spawn(async move { let tls_stream = match acceptor.accept(tcp_stream).await { @@ -232,7 +258,7 @@ pre .str {{ color: #d48a5a }} ) } -fn extract_host(req: &Request) -> Option { +pub fn extract_host(req: &Request) -> Option { req.headers() .get(hyper::header::HOST) .and_then(|v| v.to_str().ok()) diff --git a/tests/integration.sh b/tests/integration.sh index 473356e..92da878 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -622,6 +622,54 @@ CONF "10.0.0.1" \ "$($KDIG +short dot-test.example A 2>/dev/null)" + echo "" + echo "=== DNS-over-HTTPS (RFC 8484) ===" + + DOH_QUERY_FILE=/tmp/numa-doh-query.bin + DOH_RESP_FILE=/tmp/numa-doh-resp.bin + + # Build DNS wire-format query for dot-test.example A + printf '\x00\x01\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x08dot-test\x07example\x00\x00\x01\x00\x01' > "$DOH_QUERY_FILE" + + # POST valid DoH query + DOH_CODE=$(curl -sk -X POST \ + --resolve "numa.numa:$PROXY_HTTPS_PORT:127.0.0.1" \ + -H "Content-Type: application/dns-message" \ + --data-binary @"$DOH_QUERY_FILE" \ + --cacert "$CA" \ + -o "$DOH_RESP_FILE" \ + -w "%{http_code}" \ + "https://numa.numa:$PROXY_HTTPS_PORT/dns-query") + check "DoH POST returns HTTP 200" "200" "$DOH_CODE" + + # Check response contains IP 10.0.0.1 (hex: 0a000001) + DOH_HEX=$(xxd -p "$DOH_RESP_FILE" | tr -d '\n') + if echo "$DOH_HEX" | grep -q "0a000001"; then + check "DoH response resolves dot-test.example → 10.0.0.1" "found" "found" + else + check "DoH response resolves dot-test.example → 10.0.0.1" "0a000001" "$DOH_HEX" + fi + + # Wrong Content-Type → 415 + DOH_CT_CODE=$(curl -sk -X POST \ + -H "Host: numa.numa" \ + -H "Content-Type: text/plain" \ + --data-binary @"$DOH_QUERY_FILE" \ + -o /dev/null -w "%{http_code}" \ + "https://127.0.0.1:$PROXY_HTTPS_PORT/dns-query") + check "DoH wrong Content-Type → 415" "415" "$DOH_CT_CODE" + + # Wrong host → 404 (DoH only serves numa.numa) + DOH_HOST_CODE=$(curl -sk -X POST \ + -H "Host: foo.numa" \ + -H "Content-Type: application/dns-message" \ + --data-binary @"$DOH_QUERY_FILE" \ + -o /dev/null -w "%{http_code}" \ + "https://127.0.0.1:$PROXY_HTTPS_PORT/dns-query") + check "DoH wrong host → 404" "404" "$DOH_HOST_CODE" + + rm -f "$DOH_QUERY_FILE" "$DOH_RESP_FILE" + echo "" echo "=== Proxy TLS works with DoT enabled ===" -- 2.34.1 From 156b68de87c31e9f877289ad9582a46087fd8435 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 11 Apr 2026 04:17:46 +0300 Subject: [PATCH 071/204] fix: replace unscannable QR art with placeholder in blog post (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Unicode block-character QR code in the DoT blog post can't be scanned by phone cameras due to HTML font metrics distorting the grid. Replace with a bordered placeholder box — the dashboard screenshot already shows a working QR. Co-authored-by: Claude Opus 4.6 (1M context) --- blog/dot-from-scratch.md | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/blog/dot-from-scratch.md b/blog/dot-from-scratch.md index b4bb70b..1775943 100644 --- a/blog/dot-from-scratch.md +++ b/blog/dot-from-scratch.md @@ -134,24 +134,12 @@ $ numa setup-phone Profile URL: http://192.168.1.10:8765/mobileconfig - █████████████████████████████████████ - █████████████████████████████████████ - ████ ▄▄▄▄▄ ██ ▀█ ▀▀▀▄▀ ▀▀█ ▄▄▄▄▄ ████ - ████ █ █ █ ▄▀ ▄█▀▄▀█▄▀█ █ █ ████ - ████ █▄▄▄█ █ ▀▄▄ ▀ █▄▀▀█▀█ █▄▄▄█ ████ - ████▄▄▄▄▄▄▄█ ▀▄▀▄█▄█ █▄█▄█▄▄▄▄▄▄▄████ - ████ ▀▄▄▄▄▄█▀ ▀██▄ ▄ ▄▀█▀█ ▄ ▄▄█▀████ - █████▄▄▀▄▀▄▄█▄ ▀████▀▄▄▀█▀▀▄ ██▀█████ - ████▄██▄ ▀▄ █ █ █▀█▄▄██ ▄▄▀▄▀▄ █▀████ - █████ ▀ ▄▀ ▄▀▄ ▄▄▀ ██ ▄▀██▄▀█████ - ████ ▀▀ █▄█▄▀ ▄ █▄ ▄█▀▄ ▀█▀▀ █▀████ - ████ ██▀█ ▄▄▀█▄▄██▀▄▀ ▀█▄▀ █▀▄▄▀█████ - ████▄█▄▄▄▄▄█▀▄█▄█▀▀ ▀██▀ ▄▄▄ ▀ ████ - ████ ▄▄▄▄▄ █▀▀▀▀ ▄█▀ ▀▄ █▄█ ▄▄▀█████ - ████ █ █ █ ▄ ██▀▄ ▄▄██ ▄ ▄▄▄██████ - ████ █▄▄▄█ █▄ ▄▀▀▄▄█▀▄▀▄ ▀▄▀ ▄█ █████ - ████▄▄▄▄▄▄▄█▄▄█▄▄▄█▄█▄▄██████▄▄██████ - █████████████████████████████████████ + ██████████████████████████████ + ██ ██ + ██ [QR code rendered in ██ + ██ your terminal] ██ + ██ ██ + ██████████████████████████████ On your iPhone: 1. Open Camera, point at the QR code, tap the yellow banner -- 2.34.1 From 2de1bc2efc53e5179692528c1a1c034427e15df3 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 11 Apr 2026 12:15:40 +0300 Subject: [PATCH 072/204] chore: bump version to 0.12.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f64e765..86f96da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1144,7 +1144,7 @@ dependencies = [ [[package]] name = "numa" -version = "0.11.0" +version = "0.12.0" dependencies = [ "arc-swap", "axum", diff --git a/Cargo.toml b/Cargo.toml index 95e094b..aa67dd4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "numa" -version = "0.11.0" +version = "0.12.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 fb4cbe0b2a60799e30df2582f7cba1b072217d7a Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 11 Apr 2026 14:08:09 +0300 Subject: [PATCH 073/204] =?UTF-8?q?chore:=20update=20DoT=20blog=20post=20?= =?UTF-8?q?=E2=80=94=20mark=20DoH=20server=20as=20shipped=20in=20v0.12.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- blog/dot-from-scratch.md | 2 +- site/blog/posts/dot-from-scratch.html | 553 ++++++++++++++++++++++++++ 2 files changed, 554 insertions(+), 1 deletion(-) create mode 100644 site/blog/posts/dot-from-scratch.html diff --git a/blog/dot-from-scratch.md b/blog/dot-from-scratch.md index 1775943..448f185 100644 --- a/blog/dot-from-scratch.md +++ b/blog/dot-from-scratch.md @@ -169,7 +169,7 @@ I've been dogfooding this since v0.10 shipped in early April. The phone resolves ## What's next -- **DoH server** — Numa already has a DoH client; the other half unlocks Firefox's built-in DoH setting pointing at Numa. +- ~~**DoH server**~~ — shipped in v0.12.0. `POST /dns-query` accepts [RFC 8484](https://datatracker.ietf.org/doc/html/rfc8484) wire-format queries, so Firefox/Chrome can point their built-in DoH at Numa. - **DoQ server (RFC 9250)** — DNS over QUIC. Android 14+ supports it natively. - **DDR (RFC 9462)** — auto-discovery via `_dns.resolver.arpa IN SVCB`, so phones pick up a moved Numa instance without the installed profile going stale. diff --git a/site/blog/posts/dot-from-scratch.html b/site/blog/posts/dot-from-scratch.html new file mode 100644 index 0000000..a620f3b --- /dev/null +++ b/site/blog/posts/dot-from-scratch.html @@ -0,0 +1,553 @@ + + + + + +DNS-over-TLS from Scratch in Rust — Numa + + + + + + +

+ +
+
+

DNS-over-TLS from Scratch in Rust

+ +
+ +

The previous post +ended with “DoT — the last encrypted transport we don’t support.” This +post is about building it.

+

Numa now runs a DoT listener on port 853. My iPhone uses it as its +system resolver, so ad blocking, DNSSEC validation, and recursive +resolution follow my phone through the day. No cloud, no account, no +companion app — a self-signed cert, a .mobileconfig +profile, and a QR code in the terminal.

+

RFC 7858 is ten pages. The hard parts weren’t in the RFC. They were +in cross-protocol confusion defenses, a crypto-provider init gotcha that +only triggered in one specific config combination, and a certificate SAN +bug iOS was happy to accept and kdig immediately rejected. +This post is about those parts.

+

Why DoT when you already have +DoH?

+

Numa has shipped DoH since v0.1. Both protocols tunnel DNS over TLS; +DoH wraps queries in HTTP/2, DoT is DNS-over-TCP with TLS in front. Same +privacy guarantees, different wrapper.

+

The answer to “why both” is that phones ask for DoT by +name. iOS system DNS configures it with two fields (IP + server +name) instead of a URL template. Android 9+ “Private DNS” speaks DoT +natively. Linux stubs default to DoT. I wanted my phone on Numa without +installing anything on the phone itself, and DoT is the protocol iOS and +Android already speak for that.

+

The wire format is +refreshingly small

+

RFC 7858 is one sentence of wire protocol: DNS-over-TCP (RFC 1035 +§4.2.2) with TLS in front, on port 853. DNS-over-TCP has existed +since 1987 — a 2-byte length prefix followed by the DNS message. DoT is +that, wrapped in a TLS session. The entire framing code is seven +lines:

+
async fn write_framed<S>(stream: &mut S, msg: &[u8]) -> 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
+}
+

Reads are symmetric: read_exact two bytes, convert to +u16, read_exact that many bytes. No HTTP +headers, no chunked encoding, no framing layer.

+

Persistent connections

+

A fresh TCP+TLS handshake is at least 3 RTTs — about 300ms on a 100ms +connection, 60× the cost of a UDP query. RFC 7858 §3.4 says clients +SHOULD reuse the TCP connection for multiple queries, and every real DoT +client does: iOS, Android, systemd, stubby. A single connection often +carries hundreds of queries.

+

Timing diagram comparing a DNS lookup over plain UDP (1 RTT), over DoT on a fresh connection (3 RTTs — TCP handshake, TLS 1.3 handshake, then the query), and over a reused DoT session (1 RTT, same as UDP).

+

The amortization point is the whole game. If you only ever do one +query per connection, DoT is roughly 3× slower than UDP and you should +not use it. If you reuse the same TLS session for a browsing session’s +worth of queries, the handshake is paid once and every subsequent query +is effectively free.

+

The server is a loop that reads a length-prefixed message, resolves +it, writes the response framed the same way, waits for the next one. +Three timeouts keep it honest:

+
    +
  • Handshake timeout (10s) — a slowloris that opens +TCP but never sends a ClientHello can’t pin a worker.
  • +
  • Idle timeout (30s) — a connected client with +nothing to say gets dropped.
  • +
  • Write timeout (10s) — a stalled reader can’t hold a +response buffer indefinitely.
  • +
+

A semaphore caps concurrent connections at 512 so a burst of +handshakes can’t exhaust the tokio runtime.

+

ALPN, the +cross-protocol defense that matters

+

If DoT lives on port 853 and HTTPS on 443, what stops an HTTP/2 +client from hitting 853 and getting confused replies? Cross-protocol attacks exist and +have had real CVEs. The defense is ALPN: during the TLS handshake the +client advertises protocols, the server picks one it supports or fails. +A DoT server advertises "dot"; a client offering only +"h2" gets a no_application_protocol fatal +alert before any frames are exchanged.

+

rustls enforces this by default when you set +alpn_protocols:

+
let mut config = ServerConfig::builder()
+    .with_no_client_auth()
+    .with_single_cert(certs, key)?;
+config.alpn_protocols = vec![b"dot".to_vec()];
+

“The library enforces it by default” has a latent risk: a future +rustls upgrade could change the default, and the defense would quietly +evaporate. I wrote a test that pins the behavior so any regression in a +dependency update fails loudly:

+
#[tokio::test]
+async fn dot_rejects_non_dot_alpn() {
+    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\"");
+}
+

When you’re leaning on a library’s default for a security-critical +invariant, the test is the contract.

+

Two bugs that hid for days

+

Both were fixed before v0.10 shipped. Both stayed hidden because my +initial tests used permissive clients.

+

The rustls crypto provider +panic

+

rustls 0.23 requires a CryptoProvider installed before +you can build a ServerConfig. Numa’s HTTPS proxy calls +install_default as a side effect when it builds its own +config, so DoT “just worked” for users who enabled both — the proxy had +already initialized the provider before DoT’s first handshake.

+

Then I added support for user-provided DoT certificates. Someone +running DoT with their own Let’s Encrypt cert, with the HTTPS proxy +disabled, would hit:

+
thread 'dot' panicked at rustls-0.23.25/src/crypto/mod.rs:185:14:
+no process-level CryptoProvider available -- call
+CryptoProvider::install_default() before this point
+

The panic happened on the first client connection, not at startup. +While writing the integration suite for “DoT with BYO cert, proxy +disabled” — the one combination nobody had ever actually exercised — the +first run panicked. Fix is two lines: call install_default +inside load_tls_config so DoT can stand alone. If a side +effect initializes something and you have a path that skips that side +effect, you have a bug waiting for a specific deployment.

+

The SAN bug iOS was happy +to accept

+

Numa’s self-signed DoT cert is generated on first run from a local CA +alongside the data directory. It needs to match whatever +ServerName the client sends as SNI. For the HTTPS proxy, +that’s the wildcard domain pattern *.numa (matching +frontend.numa, api.numa, etc.). I initially +reused the same SAN list for DoT: a wildcard *.numa and +nothing else.

+

On an iPhone this worked perfectly. Full browsing session, persistent +connections in the log, ad blocking active. I was about to merge when I +ran one last smoke test with kdig (GnuTLS-backed, from Knot DNS):

+
$ kdig @192.168.1.16 -p 853 +tls \
+    +tls-ca=/usr/local/var/numa/ca.pem \
+    +tls-hostname=numa.numa example.com A
+
+;; TLS, handshake failed (Error in the certificate.)
+

Huh.

+

RFC +6125 §6.4.3: a wildcard in a certificate’s DNS-ID matches exactly +one label. *.numa matches frontend.numa, but +not numa.numa, because the wildcard wants at least one +label to substitute and strict clients reject wildcards in the leftmost +label under single-label TLDs as ambiguous.

+

iOS’s TLS stack is lenient and accepts it. GnuTLS, NSS (Firefox), and +most non-Apple validators don’t. The fix is five lines — add an explicit +numa.numa SAN alongside the wildcard. But the lesson is the +one that stuck: I wrote a commit message saying “fix an iOS bug” and had +to rewrite it, because iOS was fine. The real bug was that every +GnuTLS/NSS-based client on the planet would have rejected the cert, and +I only found it by running one more test with a stricter tool.

+
+

Test with the strict client. The permissive client hides your +bugs.

+
+

Getting your phone onto it

+

A DoT server is useless without a way to point a phone at it. iOS +won’t let you type an IP and a server name into Settings directly — you +install a .mobileconfig profile that bundles the CA as a +trust anchor and the DNS settings in a single payload.

+

Numa ships a subcommand that builds one on the fly and serves it over +a QR code in the terminal:

+
$ numa setup-phone
+
+  Numa Phone Setup
+
+  Profile URL: http://192.168.1.10:8765/mobileconfig
+
+  ██████████████████████████████
+  ██                          ██
+  ██   [QR code rendered in   ██
+  ██    your terminal]        ██
+  ██                          ██
+  ██████████████████████████████
+
+  On your iPhone:
+    1. Open Camera, point at the QR code, tap the yellow banner
+    2. Allow the download when Safari asks
+    3. Open Settings — tap "Profile Downloaded" near the top
+       (or: Settings → General → VPN & Device Management → Numa DNS)
+    4. Tap Install (top right), enter passcode, Install again
+    5. Settings → General → About → Certificate Trust Settings
+       Toggle ON "Numa Local CA" — required for DoT to work
+

The same QR is available in the dashboard — click “Phone Setup” in +the header and the popover renders an SVG QR code pointing at the +mobileconfig URL. On mobile viewports it shows a direct download link +instead.

+

Numa dashboard with Phone Setup popover showing QR code and install instructions

+

Step 4 is non-negotiable. Even though the CA is bundled in the same +profile that installs the DNS settings, iOS still requires the user to +explicitly toggle trust in Certificate Trust Settings. It’s a deliberate +iOS policy to prevent profile-based trust injection — annoying, and +correct.

+

I’ve been dogfooding this since v0.10 shipped in early April. The +phone resolves through Numa over DoT whenever I’m home; persistent +connections are visible in the log as a single source port living +through dozens of queries. The one real caveat: if the laptop’s LAN IP +changes, the profile breaks. RFC 9462 DDR +fixes that — Numa can respond to _dns.resolver.arpa IN SVCB +with its current IP and iOS picks it up on each network join. Next piece +of work.

+

What I learned

+

RFC-level small, API-level hard. RFC 7858 is ten +pages. The framing is trivial. But the subtle stuff — ALPN, timeouts, +connection caps, handshake vs idle vs write deadlines, backoff on accept +errors — isn’t in the RFC. Miss any of it and you leak a DoS vector or a +protocol confusion hole.

+

Your test matrix is your security matrix. Both bugs +in this post were hidden by lenient clients. In both cases the strict +client — kdig, or a specific config combination — surfaced the bug +instantly. Pick test tools for strictness, not convenience. The moment +you find yourself thinking “but iOS accepts it,” stop and run kdig.

+

Don’t initialize global state via side effects. +“Module A installs a global, module B silently depends on it, disabling +A breaks B” is a bug pattern that keeps coming back. Fix: have module B +initialize its dependency explicitly, even if it means calling an +idempotent install_default twice. The dependency graph +should be local and obvious.

+

What’s next

+
    +
  • DoH server — shipped in v0.12.0. +POST /dns-query accepts RFC 8484 +wire-format queries, so Firefox/Chrome can point their built-in DoH at +Numa.
  • +
  • DoQ server (RFC 9250) — DNS over QUIC. Android 14+ +supports it natively.
  • +
  • DDR (RFC 9462) — auto-discovery via +_dns.resolver.arpa IN SVCB, so phones pick up a moved Numa +instance without the installed profile going stale.
  • +
+

The code is at github.com/razvandimescu/numa +— the DoT listener is in src/dot.rs +and the phone onboarding flow is in src/setup_phone.rs +and src/mobileconfig.rs. +MIT license.

+
+ + + + + -- 2.34.1 From 289f2b973b146bad2f30b4a7846c6ae91a7cb363 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 11 Apr 2026 14:10:13 +0300 Subject: [PATCH 074/204] chore: remove built blog HTML from tracking (built by CI) Co-Authored-By: Claude Opus 4.6 (1M context) --- site/blog/posts/dot-from-scratch.html | 553 -------------------------- 1 file changed, 553 deletions(-) delete mode 100644 site/blog/posts/dot-from-scratch.html diff --git a/site/blog/posts/dot-from-scratch.html b/site/blog/posts/dot-from-scratch.html deleted file mode 100644 index a620f3b..0000000 --- a/site/blog/posts/dot-from-scratch.html +++ /dev/null @@ -1,553 +0,0 @@ - - - - - -DNS-over-TLS from Scratch in Rust — Numa - - - - - - - - -
-
-

DNS-over-TLS from Scratch in Rust

- -
- -

The previous post -ended with “DoT — the last encrypted transport we don’t support.” This -post is about building it.

-

Numa now runs a DoT listener on port 853. My iPhone uses it as its -system resolver, so ad blocking, DNSSEC validation, and recursive -resolution follow my phone through the day. No cloud, no account, no -companion app — a self-signed cert, a .mobileconfig -profile, and a QR code in the terminal.

-

RFC 7858 is ten pages. The hard parts weren’t in the RFC. They were -in cross-protocol confusion defenses, a crypto-provider init gotcha that -only triggered in one specific config combination, and a certificate SAN -bug iOS was happy to accept and kdig immediately rejected. -This post is about those parts.

-

Why DoT when you already have -DoH?

-

Numa has shipped DoH since v0.1. Both protocols tunnel DNS over TLS; -DoH wraps queries in HTTP/2, DoT is DNS-over-TCP with TLS in front. Same -privacy guarantees, different wrapper.

-

The answer to “why both” is that phones ask for DoT by -name. iOS system DNS configures it with two fields (IP + server -name) instead of a URL template. Android 9+ “Private DNS” speaks DoT -natively. Linux stubs default to DoT. I wanted my phone on Numa without -installing anything on the phone itself, and DoT is the protocol iOS and -Android already speak for that.

-

The wire format is -refreshingly small

-

RFC 7858 is one sentence of wire protocol: DNS-over-TCP (RFC 1035 -§4.2.2) with TLS in front, on port 853. DNS-over-TCP has existed -since 1987 — a 2-byte length prefix followed by the DNS message. DoT is -that, wrapped in a TLS session. The entire framing code is seven -lines:

-
async fn write_framed<S>(stream: &mut S, msg: &[u8]) -> 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
-}
-

Reads are symmetric: read_exact two bytes, convert to -u16, read_exact that many bytes. No HTTP -headers, no chunked encoding, no framing layer.

-

Persistent connections

-

A fresh TCP+TLS handshake is at least 3 RTTs — about 300ms on a 100ms -connection, 60× the cost of a UDP query. RFC 7858 §3.4 says clients -SHOULD reuse the TCP connection for multiple queries, and every real DoT -client does: iOS, Android, systemd, stubby. A single connection often -carries hundreds of queries.

-

Timing diagram comparing a DNS lookup over plain UDP (1 RTT), over DoT on a fresh connection (3 RTTs — TCP handshake, TLS 1.3 handshake, then the query), and over a reused DoT session (1 RTT, same as UDP).

-

The amortization point is the whole game. If you only ever do one -query per connection, DoT is roughly 3× slower than UDP and you should -not use it. If you reuse the same TLS session for a browsing session’s -worth of queries, the handshake is paid once and every subsequent query -is effectively free.

-

The server is a loop that reads a length-prefixed message, resolves -it, writes the response framed the same way, waits for the next one. -Three timeouts keep it honest:

-
    -
  • Handshake timeout (10s) — a slowloris that opens -TCP but never sends a ClientHello can’t pin a worker.
  • -
  • Idle timeout (30s) — a connected client with -nothing to say gets dropped.
  • -
  • Write timeout (10s) — a stalled reader can’t hold a -response buffer indefinitely.
  • -
-

A semaphore caps concurrent connections at 512 so a burst of -handshakes can’t exhaust the tokio runtime.

-

ALPN, the -cross-protocol defense that matters

-

If DoT lives on port 853 and HTTPS on 443, what stops an HTTP/2 -client from hitting 853 and getting confused replies? Cross-protocol attacks exist and -have had real CVEs. The defense is ALPN: during the TLS handshake the -client advertises protocols, the server picks one it supports or fails. -A DoT server advertises "dot"; a client offering only -"h2" gets a no_application_protocol fatal -alert before any frames are exchanged.

-

rustls enforces this by default when you set -alpn_protocols:

-
let mut config = ServerConfig::builder()
-    .with_no_client_auth()
-    .with_single_cert(certs, key)?;
-config.alpn_protocols = vec![b"dot".to_vec()];
-

“The library enforces it by default” has a latent risk: a future -rustls upgrade could change the default, and the defense would quietly -evaporate. I wrote a test that pins the behavior so any regression in a -dependency update fails loudly:

-
#[tokio::test]
-async fn dot_rejects_non_dot_alpn() {
-    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\"");
-}
-

When you’re leaning on a library’s default for a security-critical -invariant, the test is the contract.

-

Two bugs that hid for days

-

Both were fixed before v0.10 shipped. Both stayed hidden because my -initial tests used permissive clients.

-

The rustls crypto provider -panic

-

rustls 0.23 requires a CryptoProvider installed before -you can build a ServerConfig. Numa’s HTTPS proxy calls -install_default as a side effect when it builds its own -config, so DoT “just worked” for users who enabled both — the proxy had -already initialized the provider before DoT’s first handshake.

-

Then I added support for user-provided DoT certificates. Someone -running DoT with their own Let’s Encrypt cert, with the HTTPS proxy -disabled, would hit:

-
thread 'dot' panicked at rustls-0.23.25/src/crypto/mod.rs:185:14:
-no process-level CryptoProvider available -- call
-CryptoProvider::install_default() before this point
-

The panic happened on the first client connection, not at startup. -While writing the integration suite for “DoT with BYO cert, proxy -disabled” — the one combination nobody had ever actually exercised — the -first run panicked. Fix is two lines: call install_default -inside load_tls_config so DoT can stand alone. If a side -effect initializes something and you have a path that skips that side -effect, you have a bug waiting for a specific deployment.

-

The SAN bug iOS was happy -to accept

-

Numa’s self-signed DoT cert is generated on first run from a local CA -alongside the data directory. It needs to match whatever -ServerName the client sends as SNI. For the HTTPS proxy, -that’s the wildcard domain pattern *.numa (matching -frontend.numa, api.numa, etc.). I initially -reused the same SAN list for DoT: a wildcard *.numa and -nothing else.

-

On an iPhone this worked perfectly. Full browsing session, persistent -connections in the log, ad blocking active. I was about to merge when I -ran one last smoke test with kdig (GnuTLS-backed, from Knot DNS):

-
$ kdig @192.168.1.16 -p 853 +tls \
-    +tls-ca=/usr/local/var/numa/ca.pem \
-    +tls-hostname=numa.numa example.com A
-
-;; TLS, handshake failed (Error in the certificate.)
-

Huh.

-

RFC -6125 §6.4.3: a wildcard in a certificate’s DNS-ID matches exactly -one label. *.numa matches frontend.numa, but -not numa.numa, because the wildcard wants at least one -label to substitute and strict clients reject wildcards in the leftmost -label under single-label TLDs as ambiguous.

-

iOS’s TLS stack is lenient and accepts it. GnuTLS, NSS (Firefox), and -most non-Apple validators don’t. The fix is five lines — add an explicit -numa.numa SAN alongside the wildcard. But the lesson is the -one that stuck: I wrote a commit message saying “fix an iOS bug” and had -to rewrite it, because iOS was fine. The real bug was that every -GnuTLS/NSS-based client on the planet would have rejected the cert, and -I only found it by running one more test with a stricter tool.

-
-

Test with the strict client. The permissive client hides your -bugs.

-
-

Getting your phone onto it

-

A DoT server is useless without a way to point a phone at it. iOS -won’t let you type an IP and a server name into Settings directly — you -install a .mobileconfig profile that bundles the CA as a -trust anchor and the DNS settings in a single payload.

-

Numa ships a subcommand that builds one on the fly and serves it over -a QR code in the terminal:

-
$ numa setup-phone
-
-  Numa Phone Setup
-
-  Profile URL: http://192.168.1.10:8765/mobileconfig
-
-  ██████████████████████████████
-  ██                          ██
-  ██   [QR code rendered in   ██
-  ██    your terminal]        ██
-  ██                          ██
-  ██████████████████████████████
-
-  On your iPhone:
-    1. Open Camera, point at the QR code, tap the yellow banner
-    2. Allow the download when Safari asks
-    3. Open Settings — tap "Profile Downloaded" near the top
-       (or: Settings → General → VPN & Device Management → Numa DNS)
-    4. Tap Install (top right), enter passcode, Install again
-    5. Settings → General → About → Certificate Trust Settings
-       Toggle ON "Numa Local CA" — required for DoT to work
-

The same QR is available in the dashboard — click “Phone Setup” in -the header and the popover renders an SVG QR code pointing at the -mobileconfig URL. On mobile viewports it shows a direct download link -instead.

-

Numa dashboard with Phone Setup popover showing QR code and install instructions

-

Step 4 is non-negotiable. Even though the CA is bundled in the same -profile that installs the DNS settings, iOS still requires the user to -explicitly toggle trust in Certificate Trust Settings. It’s a deliberate -iOS policy to prevent profile-based trust injection — annoying, and -correct.

-

I’ve been dogfooding this since v0.10 shipped in early April. The -phone resolves through Numa over DoT whenever I’m home; persistent -connections are visible in the log as a single source port living -through dozens of queries. The one real caveat: if the laptop’s LAN IP -changes, the profile breaks. RFC 9462 DDR -fixes that — Numa can respond to _dns.resolver.arpa IN SVCB -with its current IP and iOS picks it up on each network join. Next piece -of work.

-

What I learned

-

RFC-level small, API-level hard. RFC 7858 is ten -pages. The framing is trivial. But the subtle stuff — ALPN, timeouts, -connection caps, handshake vs idle vs write deadlines, backoff on accept -errors — isn’t in the RFC. Miss any of it and you leak a DoS vector or a -protocol confusion hole.

-

Your test matrix is your security matrix. Both bugs -in this post were hidden by lenient clients. In both cases the strict -client — kdig, or a specific config combination — surfaced the bug -instantly. Pick test tools for strictness, not convenience. The moment -you find yourself thinking “but iOS accepts it,” stop and run kdig.

-

Don’t initialize global state via side effects. -“Module A installs a global, module B silently depends on it, disabling -A breaks B” is a bug pattern that keeps coming back. Fix: have module B -initialize its dependency explicitly, even if it means calling an -idempotent install_default twice. The dependency graph -should be local and obvious.

-

What’s next

-
    -
  • DoH server — shipped in v0.12.0. -POST /dns-query accepts RFC 8484 -wire-format queries, so Firefox/Chrome can point their built-in DoH at -Numa.
  • -
  • DoQ server (RFC 9250) — DNS over QUIC. Android 14+ -supports it natively.
  • -
  • DDR (RFC 9462) — auto-discovery via -_dns.resolver.arpa IN SVCB, so phones pick up a moved Numa -instance without the installed profile going stale.
  • -
-

The code is at github.com/razvandimescu/numa -— the DoT listener is in src/dot.rs -and the phone onboarding flow is in src/setup_phone.rs -and src/mobileconfig.rs. -MIT license.

-
- - - - - -- 2.34.1 From 22bebb85a0ce0a49d897770c74b89db6a94f610b Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 02:17:33 +0300 Subject: [PATCH 075/204] fix: config path advisory ignores XDG file on interactive root (#81) (#83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port-53 and TLS-data-dir advisories told users to create ~/.config/numa/numa.toml, but config_dir() routed root to /var/lib/numa/ and load_config never consulted the XDG path, so the file the user created was silently ignored. New suggested_config_path() helper prefers $HOME/.config/numa/ when HOME is set (and isn't "/" or empty), with config_dir() as lazy fallback. Used by both advisories and by load_config as an additional candidate, so the advised path is the path numa actually reads. Runtime state (services.json, TLS CA) stays in FHS — config_dir()/data_dir() are intentionally unchanged to keep continuity with the installed daemon. End-to-end replication + regression check in tests/docker/issue-81.sh: four scenarios (replication and existing-install, each against main and fix), all matching expectations. --- src/config.rs | 13 ++-- src/lib.rs | 105 +++++++++++++++++++++++++ src/system_dns.rs | 5 +- src/tls.rs | 5 +- tests/docker/hold53.py | 5 ++ tests/docker/issue-81.sh | 164 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 288 insertions(+), 9 deletions(-) create mode 100644 tests/docker/hold53.py create mode 100755 tests/docker/issue-81.sh diff --git a/src/config.rs b/src/config.rs index 6480883..60b505e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -612,6 +612,13 @@ pub fn load_config(path: &str) -> Result { let filename = p.file_name().unwrap_or(p.as_os_str()); v.push(crate::config_dir().join(filename)); v.push(crate::data_dir().join(filename)); + // Interactive root and sudo'd users: always consult the XDG path + // so `touch ~/.config/numa/numa.toml` works regardless of whether + // config_dir() routed to FHS (issue #81). + let suggested = crate::suggested_config_path(); + if !v.contains(&suggested) { + v.push(suggested); + } } v }; @@ -632,11 +639,7 @@ pub fn load_config(path: &str) -> Result { } } - // Show config_dir candidate as the "expected" path — it's actionable - let display_path = candidates - .get(1) - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| resolve_path(path)); + let display_path = crate::suggested_config_path().to_string_lossy().to_string(); log::info!("config not found, using defaults (create {})", display_path); Ok(ConfigLoad { config: Config::default(), diff --git a/src/lib.rs b/src/lib.rs index be71125..4074020 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,6 +44,42 @@ pub fn hostname() -> String { .unwrap_or_else(|| "numa".to_string()) } +/// Path to suggest to an interactive user when asking them to create +/// `numa.toml`. Prefers `$HOME/.config/numa/numa.toml` when HOME is set +/// (actionable without sudo); falls back to `config_dir()` otherwise. +/// +/// Note: `config_dir()` routes interactive root to FHS (`/var/lib/numa`) +/// so that runtime state like `services.json` stays continuous with the +/// installed daemon. This helper exists specifically to give advisories +/// and `load_config` an XDG-aware path for user-authored config, without +/// moving runtime state out of FHS — see issue #81. +pub(crate) fn suggested_config_path() -> std::path::PathBuf { + #[cfg(not(windows))] + { + resolve_suggested_config_path(std::env::var("HOME").ok().as_deref(), config_dir) + } + #[cfg(windows)] + { + config_dir().join("numa.toml") + } +} + +#[cfg(not(windows))] +fn resolve_suggested_config_path(home: Option<&str>, fallback_dir: F) -> std::path::PathBuf +where + F: FnOnce() -> std::path::PathBuf, +{ + if let Some(home) = home { + if !home.is_empty() && home != "/" { + return std::path::PathBuf::from(home) + .join(".config") + .join("numa") + .join("numa.toml"); + } + } + fallback_dir().join("numa.toml") +} + /// Shared config directory for persistent data (services.json, etc). /// Unix users: ~/.config/numa/ /// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa @@ -163,4 +199,73 @@ mod tests { fn linux_data_dir_only_fhs_uses_fhs() { assert_eq!(resolve_linux_data_dir(false, true), "/var/lib/numa"); } + + #[cfg(not(windows))] + fn fhs() -> std::path::PathBuf { + std::path::PathBuf::from("/var/lib/numa") + } + + #[cfg(not(windows))] + #[test] + fn suggested_config_path_prefers_home() { + assert_eq!( + resolve_suggested_config_path(Some("/home/alice"), fhs), + std::path::PathBuf::from("/home/alice/.config/numa/numa.toml"), + ); + } + + #[cfg(not(windows))] + #[test] + fn suggested_config_path_prefers_root_home_over_fhs() { + // Interactive root: HOME=/root is a real user context, not a daemon signal. + // Advisory must point where load_config will actually look — issue #81. + assert_eq!( + resolve_suggested_config_path(Some("/root"), fhs), + std::path::PathBuf::from("/root/.config/numa/numa.toml"), + ); + } + + #[cfg(not(windows))] + #[test] + fn suggested_config_path_falls_back_when_home_unset() { + assert_eq!( + resolve_suggested_config_path(None, fhs), + std::path::PathBuf::from("/var/lib/numa/numa.toml"), + ); + } + + #[cfg(not(windows))] + #[test] + fn suggested_config_path_falls_back_when_home_is_root() { + // systemd services sometimes have HOME=/ — don't treat that as a real home. + assert_eq!( + resolve_suggested_config_path(Some("/"), fhs), + std::path::PathBuf::from("/var/lib/numa/numa.toml"), + ); + } + + #[cfg(not(windows))] + #[test] + fn suggested_config_path_falls_back_when_home_is_empty() { + assert_eq!( + resolve_suggested_config_path(Some(""), fhs), + std::path::PathBuf::from("/var/lib/numa/numa.toml"), + ); + } + + #[cfg(not(windows))] + #[test] + fn suggested_config_path_skips_fallback_when_home_valid() { + // Happy path shouldn't probe the filesystem via config_dir(). + let called = std::cell::Cell::new(false); + let fallback = || { + called.set(true); + std::path::PathBuf::from("/should/not/be/used") + }; + let _ = resolve_suggested_config_path(Some("/home/alice"), fallback); + assert!( + !called.get(), + "fallback must not be invoked when HOME is valid" + ); + } } diff --git a/src/system_dns.rs b/src/system_dns.rs index 115ce2d..539f0a1 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -91,7 +91,7 @@ pub fn try_port53_advisory(bind_addr: &str, err: &std::io::Error) -> Option Option Option&1 | tail -1 +if ! command -v cargo &>/dev/null; then + curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --quiet +fi +. "$HOME/.cargo/env" + +build_from() { + local label="$1"; local src="$2" + mkdir -p "/work/$label" + tar -C "$src" --exclude=./target --exclude=./.git -cf - . | tar -C "/work/$label" -xf - + (cd "/work/$label" && cargo build --release --locked 2>&1 | tail -1) + cp "/work/$label/target/release/numa" "/work/numa-$label" +} + +build_from main /main +build_from fix /fix + +holder=0 +stop_holder() { + if [ "$holder" -ne 0 ]; then + kill "$holder" 2>/dev/null || true + wait "$holder" 2>/dev/null || true + holder=0 + fi +} +trap stop_holder EXIT + +start_holder() { + python3 /tmp/hold53.py & + holder=$! + sleep 0.3 +} + +write_test_config() { + local path="$1" + mkdir -p "$(dirname "$path")" + cat > "$path" < /tmp/run1.txt 2>&1 + set -e + echo "── step 1: advisory printed by $label ──" + grep -E "Create .* with:" /tmp/run1.txt | sed "s/^/ /" || echo " " + + write_test_config "$XDG_CONFIG" + echo "── step 2: wrote config at $XDG_CONFIG ──" + + set +e + timeout 3 "$bin" > /tmp/run2.txt 2>&1 + set -e + stop_holder + + verdict "$label" "$expected" /tmp/run2.txt +} + +scenario_existing_install() { + local label="$1"; local bin="/work/numa-$label" + echo + echo "════════ EXISTING INSTALL / $label ════════" + rm -rf /root/.config/numa /var/lib/numa + write_test_config "$FHS_CONFIG" + + start_holder + set +e + timeout 3 "$bin" > /tmp/run.txt 2>&1 + set -e + stop_holder + + verdict "$label" "bound" /tmp/run.txt +} + +RC=0 +scenario_replication main ignored || RC=1 +scenario_replication fix bound || RC=1 +scenario_existing_install main || RC=1 +scenario_existing_install fix || RC=1 + +echo +if [ "$RC" -eq 0 ]; then + echo "── all scenarios matched expectations ──" +else + echo "── FAILURE: one or more scenarios diverged ──" +fi +exit $RC +' -- 2.34.1 From 7047767dc225ca56216eb677f4f312582027106b Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 06:12:08 +0300 Subject: [PATCH 076/204] feat: per-suffix conditional forwarding rules (#82) (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: per-suffix conditional forwarding rules in numa.toml (#82) Adds a `[[forwarding]]` config section so users can explicitly route domain suffixes to specific upstreams. Config-declared rules take precedence over auto-discovered rules (macOS scutil, Linux search domains) via first-match semantics. Example — the reporter's reverse-DNS case: [[forwarding]] suffix = "168.192.in-addr.arpa" upstream = "100.90.1.63:5361" Bare IPs default to port 53. IPv6 is supported via parse_upstream_addr. ForwardingRule::new() constructor replaces direct struct-literal construction, and make_rule() now delegates to parse_upstream_addr to fix a latent IPv6 parsing bug. * feat: accept suffix as string or array in [[forwarding]] rules Reuses existing string_or_vec deserializer so users can write: suffix = ["168.192.in-addr.arpa", "onsite"] instead of repeating [[forwarding]] blocks per suffix. * style: rustfmt * refactor: drop config_count from merge_forwarding_rules return Log config rules directly from config.forwarding before merging, keeping the merge API clean of logging concerns. --- numa.toml | 8 ++ src/config.rs | 184 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 8 +- src/system_dns.rs | 19 +++-- 4 files changed, 212 insertions(+), 7 deletions(-) diff --git a/numa.toml b/numa.toml index 92b5411..3b716e8 100644 --- a/numa.toml +++ b/numa.toml @@ -45,6 +45,14 @@ api_port = 5380 # "co", "br", "au", "ca", "jp", # other major ccTLDs # ] +# [[forwarding]] # per-suffix conditional forwarding rules +# suffix = "168.192.in-addr.arpa" # single suffix → one upstream +# upstream = "100.90.1.63:5361" +# +# [[forwarding]] +# suffix = ["home.local", "home.arpa"] # multiple suffixes → same upstream +# upstream = "10.0.0.1" # port 53 default + # [blocking] # enabled = true # set to false to disable ad blocking # refresh_hours = 24 diff --git a/src/config.rs b/src/config.rs index 60b505e..ae9f685 100644 --- a/src/config.rs +++ b/src/config.rs @@ -33,6 +33,39 @@ pub struct Config { pub dot: DotConfig, #[serde(default)] pub mobile: MobileConfig, + #[serde(default)] + pub forwarding: Vec, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct ForwardingRuleConfig { + #[serde(deserialize_with = "string_or_vec")] + pub suffix: Vec, + pub upstream: String, +} + +impl ForwardingRuleConfig { + fn to_runtime_rules(&self) -> Result> { + let addr = crate::forward::parse_upstream_addr(&self.upstream, 53) + .map_err(|e| format!("forwarding rule for upstream '{}': {}", self.upstream, e))?; + Ok(self + .suffix + .iter() + .map(|s| crate::system_dns::ForwardingRule::new(s.clone(), addr)) + .collect()) + } +} + +pub fn merge_forwarding_rules( + config_rules: &[ForwardingRuleConfig], + discovered: Vec, +) -> Result> { + let mut merged: Vec = Vec::new(); + for rule in config_rules { + merged.extend(rule.to_runtime_rules()?); + } + merged.extend(discovered); + Ok(merged) } #[derive(Deserialize)] @@ -585,6 +618,157 @@ mod tests { assert!(config.upstream.address.is_empty()); assert!(config.upstream.fallback.is_empty()); } + + // ── issue #82: [[forwarding]] config section ──────────────────────── + + #[test] + fn forwarding_empty_by_default() { + let config: Config = toml::from_str("").unwrap(); + assert!(config.forwarding.is_empty()); + } + + #[test] + fn forwarding_parses_single_rule() { + let toml = r#" + [[forwarding]] + suffix = "home.local" + upstream = "100.90.1.63:5361" + "#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.forwarding.len(), 1); + assert_eq!(config.forwarding[0].suffix, &["home.local"]); + assert_eq!(config.forwarding[0].upstream, "100.90.1.63:5361"); + } + + #[test] + fn forwarding_parses_reverse_dns_zone() { + let toml = r#" + [[forwarding]] + suffix = "168.192.in-addr.arpa" + upstream = "100.90.1.63:5361" + "#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.forwarding.len(), 1); + assert_eq!(config.forwarding[0].suffix, &["168.192.in-addr.arpa"]); + } + + #[test] + fn forwarding_parses_multiple_rules() { + let toml = r#" + [[forwarding]] + suffix = "168.192.in-addr.arpa" + upstream = "100.90.1.63:5361" + + [[forwarding]] + suffix = "home.local" + upstream = "10.0.0.1" + "#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.forwarding.len(), 2); + assert_eq!(config.forwarding[1].upstream, "10.0.0.1"); + } + + #[test] + fn forwarding_parses_suffix_array() { + let toml = r#" + [[forwarding]] + suffix = ["168.192.in-addr.arpa", "onsite"] + upstream = "192.168.88.1" + "#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.forwarding.len(), 1); + assert_eq!( + config.forwarding[0].suffix, + &["168.192.in-addr.arpa", "onsite"] + ); + } + + #[test] + fn forwarding_suffix_array_expands_to_multiple_runtime_rules() { + let rule = ForwardingRuleConfig { + suffix: vec!["168.192.in-addr.arpa".to_string(), "onsite".to_string()], + upstream: "192.168.88.1".to_string(), + }; + let runtime = rule.to_runtime_rules().unwrap(); + assert_eq!(runtime.len(), 2); + assert_eq!(runtime[0].suffix, "168.192.in-addr.arpa"); + assert_eq!(runtime[1].suffix, "onsite"); + assert_eq!(runtime[0].upstream, runtime[1].upstream); + } + + #[test] + fn forwarding_upstream_with_explicit_port() { + let rule = ForwardingRuleConfig { + suffix: vec!["home.local".to_string()], + upstream: "100.90.1.63:5361".to_string(), + }; + let runtime = rule.to_runtime_rules().unwrap(); + assert_eq!(runtime.len(), 1); + assert_eq!(runtime[0].upstream.to_string(), "100.90.1.63:5361"); + assert_eq!(runtime[0].suffix, "home.local"); + } + + #[test] + fn forwarding_upstream_defaults_to_port_53() { + let rule = ForwardingRuleConfig { + suffix: vec!["home.local".to_string()], + upstream: "100.90.1.63".to_string(), + }; + let runtime = rule.to_runtime_rules().unwrap(); + assert_eq!(runtime[0].upstream.to_string(), "100.90.1.63:53"); + } + + #[test] + fn forwarding_invalid_upstream_returns_error() { + let rule = ForwardingRuleConfig { + suffix: vec!["home.local".to_string()], + upstream: "not-a-valid-host".to_string(), + }; + assert!(rule.to_runtime_rules().is_err()); + } + + #[test] + fn forwarding_config_rules_take_precedence_over_discovered() { + let config_rules = vec![ForwardingRuleConfig { + suffix: vec!["home.local".to_string()], + upstream: "10.0.0.1:53".to_string(), + }]; + let discovered = vec![crate::system_dns::ForwardingRule::new( + "home.local".to_string(), + "192.168.1.1:53".parse().unwrap(), + )]; + let merged = merge_forwarding_rules(&config_rules, discovered).unwrap(); + let picked = crate::system_dns::match_forwarding_rule("host.home.local", &merged) + .expect("rule should match"); + assert_eq!(picked.to_string(), "10.0.0.1:53"); + } + + #[test] + fn forwarding_merge_preserves_non_overlapping_discovered() { + let config_rules = vec![ForwardingRuleConfig { + suffix: vec!["home.local".to_string()], + upstream: "10.0.0.1:53".to_string(), + }]; + let discovered = vec![crate::system_dns::ForwardingRule::new( + "corp.example".to_string(), + "192.168.1.1:53".parse().unwrap(), + )]; + let merged = merge_forwarding_rules(&config_rules, discovered).unwrap(); + assert_eq!(merged.len(), 2); + let picked = crate::system_dns::match_forwarding_rule("host.corp.example", &merged) + .expect("discovered rule should still match"); + assert_eq!(picked.to_string(), "192.168.1.1:53"); + } + + #[test] + fn forwarding_merge_suffix_array_expands_to_multiple_rules() { + let config_rules = vec![ForwardingRuleConfig { + suffix: vec!["a.local".to_string(), "b.local".to_string()], + upstream: "10.0.0.1:53".to_string(), + }]; + let merged = merge_forwarding_rules(&config_rules, vec![]).unwrap(); + assert_eq!(merged.len(), 2); + } } pub struct ConfigLoad { diff --git a/src/main.rs b/src/main.rs index 903be9a..7592186 100644 --- a/src/main.rs +++ b/src/main.rs @@ -210,7 +210,13 @@ async fn main() -> numa::Result<()> { } service_store.load_persisted(); - let forwarding_rules = system_dns.forwarding_rules; + for fwd in &config.forwarding { + for suffix in &fwd.suffix { + info!("forwarding .{} to {} (config rule)", suffix, fwd.upstream); + } + } + let forwarding_rules = + numa::config::merge_forwarding_rules(&config.forwarding, 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. diff --git a/src/system_dns.rs b/src/system_dns.rs index 539f0a1..d560a6e 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -25,6 +25,17 @@ pub struct ForwardingRule { pub upstream: SocketAddr, } +impl ForwardingRule { + pub fn new(suffix: String, upstream: SocketAddr) -> Self { + let dot_suffix = format!(".{}", suffix); + Self { + suffix, + dot_suffix, + upstream, + } + } +} + /// Result of system DNS discovery — default upstream + conditional forwarding rules. pub struct SystemDnsInfo { pub default_upstream: Option, @@ -221,12 +232,8 @@ fn discover_macos() -> SystemDnsInfo { #[cfg(any(target_os = "macos", target_os = "linux"))] fn make_rule(domain: &str, nameserver: &str) -> Option { - let addr: SocketAddr = format!("{}:53", nameserver).parse().ok()?; - Some(ForwardingRule { - dot_suffix: format!(".{}", domain), - suffix: domain.to_string(), - upstream: addr, - }) + let addr = crate::forward::parse_upstream_addr(nameserver, 53).ok()?; + Some(ForwardingRule::new(domain.to_string(), addr)) } #[cfg(target_os = "linux")] -- 2.34.1 From 05baad0cc0b8c41df9a1bc6d1d8395733dc970f0 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 18:35:06 +0300 Subject: [PATCH 077/204] feat: DoT (DNS over TLS) client upstream Adds tls:// upstream support for forwarding queries over DNS-over-TLS (RFC 7858). Parses tls://IP:PORT#hostname format, with default port 853. - New Upstream::Dot variant with TLS connector - forward_dot: length-prefixed DNS over TLS stream - build_dot_connector: system root CAs via webpki-roots - parse_upstream handles tls:// prefix Example config: address = ["tls://9.9.9.9#dns.quad9.net"] --- Cargo.lock | 1 + Cargo.toml | 1 + src/forward.rs | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 86f96da..c7cd38b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1170,6 +1170,7 @@ dependencies = [ "tokio-rustls", "toml", "tower", + "webpki-roots", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index aa67dd4..c5d5e1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ arc-swap = "1" ring = "0.17" rustls-pemfile = "2.2.0" qrcode = { version = "0.14", default-features = false, features = ["svg"] } +webpki-roots = "1" [dev-dependencies] criterion = { version = "0.8", features = ["html_reports"] } diff --git a/src/forward.rs b/src/forward.rs index 78efcb9..ea2f1e2 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -18,6 +18,11 @@ pub enum Upstream { url: String, client: reqwest::Client, }, + Dot { + addr: SocketAddr, + tls_name: Option, + connector: tokio_rustls::TlsConnector, + }, } impl PartialEq for Upstream { @@ -25,6 +30,7 @@ impl PartialEq for Upstream { match (self, other) { (Self::Udp(a), Self::Udp(b)) => a == b, (Self::Doh { url: a, .. }, Self::Doh { url: b, .. }) => a == b, + (Self::Dot { addr: a, .. }, Self::Dot { addr: b, .. }) => a == b, _ => false, } } @@ -35,6 +41,10 @@ impl fmt::Display for Upstream { match self { Upstream::Udp(addr) => write!(f, "{}", addr), Upstream::Doh { url, .. } => f.write_str(url), + Upstream::Dot { addr, tls_name, .. } => match tls_name { + Some(name) => write!(f, "tls://{}#{}", addr, name), + None => write!(f, "tls://{}", addr), + }, } } } @@ -62,10 +72,36 @@ pub fn parse_upstream(s: &str, default_port: u16) -> Result { client, }); } + // tls://IP:PORT#hostname or tls://IP#hostname (default port 853) + if let Some(rest) = s.strip_prefix("tls://") { + let (addr_part, tls_name) = match rest.find('#') { + Some(i) => (&rest[..i], Some(rest[i + 1..].to_string())), + None => (rest, None), + }; + let addr = parse_upstream_addr(addr_part, 853)?; + let connector = build_dot_connector()?; + return Ok(Upstream::Dot { + addr, + tls_name, + connector, + }); + } let addr = parse_upstream_addr(s, default_port)?; Ok(Upstream::Udp(addr)) } +fn build_dot_connector() -> Result { + let _ = rustls::crypto::ring::default_provider().install_default(); + let mut root_store = rustls::RootCertStore::empty(); + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let config = rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + Ok(tokio_rustls::TlsConnector::from(std::sync::Arc::new( + config, + ))) +} + #[derive(Clone)] pub struct UpstreamPool { primary: Vec, @@ -174,6 +210,11 @@ pub async fn forward_query( match upstream { Upstream::Udp(addr) => forward_udp(query, *addr, timeout_duration).await, Upstream::Doh { url, client } => forward_doh(query, url, client, timeout_duration).await, + Upstream::Dot { + addr, + tls_name, + connector, + } => forward_dot(query, *addr, tls_name, connector, timeout_duration).await, } } @@ -236,6 +277,45 @@ pub(crate) async fn forward_tcp( DnsPacket::from_buffer(&mut recv_buffer) } +async fn forward_dot( + query: &DnsPacket, + addr: SocketAddr, + tls_name: &Option, + connector: &tokio_rustls::TlsConnector, + timeout_duration: Duration, +) -> Result { + use rustls::pki_types::ServerName; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpStream; + + let server_name = match tls_name { + Some(name) => ServerName::try_from(name.clone())?, + None => ServerName::try_from(addr.ip().to_string())?, + }; + + let tcp = timeout(timeout_duration, TcpStream::connect(addr)).await??; + let mut tls = timeout(timeout_duration, connector.connect(server_name, tcp)).await??; + + let mut send_buffer = BytePacketBuffer::new(); + query.write(&mut send_buffer)?; + let wire = send_buffer.filled(); + + let mut outbuf = Vec::with_capacity(2 + wire.len()); + outbuf.extend_from_slice(&(wire.len() as u16).to_be_bytes()); + outbuf.extend_from_slice(wire); + timeout(timeout_duration, tls.write_all(&outbuf)).await??; + + let mut len_buf = [0u8; 2]; + timeout(timeout_duration, tls.read_exact(&mut len_buf)).await??; + let resp_len = u16::from_be_bytes(len_buf) as usize; + + let mut data = vec![0u8; resp_len]; + timeout(timeout_duration, tls.read_exact(&mut data)).await??; + + let mut recv_buffer = BytePacketBuffer::from_bytes(&data); + DnsPacket::from_buffer(&mut recv_buffer) +} + async fn forward_doh( query: &DnsPacket, url: &str, -- 2.34.1 From 7efac85836bacd483e174e5509fad03bac3f548f Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 04:20:18 +0300 Subject: [PATCH 078/204] feat: wire-level forwarding, cache, request hedging, and DoH keepalive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire-level forwarding path skips DnsPacket parse/serialize on the hot path. Cache stores raw wire bytes with pre-scanned TTL offsets — patches ID + TTLs in-place on lookup instead of cloning parsed packets. Request hedging (Dean & Barroso "Tail at Scale") fires a second parallel request after a configurable delay (default 10ms) when the primary upstream stalls. DoH keepalive loop prevents idle HTTP/2 + TLS connection teardown. Recursive resolver now hedges across multiple NS addresses and caches NS delegation records to skip TLD re-queries. Integration test harness polls /blocking/stats instead of fixed sleep, eliminating the blocklist-download race condition. --- Cargo.lock | 458 +++++++++- Cargo.toml | 6 + benches/numa-bench.toml | 25 + benches/recursive_compare.rs | 1649 ++++++++++++++++++++++++++++++++++ scripts/bench-recursive.sh | 115 +++ src/api.rs | 1 + src/cache.rs | 177 ++-- src/config.rs | 6 + src/ctx.rs | 47 +- src/doh.rs | 11 +- src/dot.rs | 6 +- src/forward.rs | 186 +++- src/lib.rs | 1 + src/main.rs | 26 +- src/recursive.rs | 123 ++- src/srtt.rs | 5 + src/wire.rs | 1347 +++++++++++++++++++++++++++ tests/integration.sh | 12 +- 18 files changed, 4091 insertions(+), 110 deletions(-) create mode 100644 benches/numa-bench.toml create mode 100644 benches/recursive_compare.rs create mode 100755 scripts/bench-recursive.sh create mode 100644 src/wire.rs diff --git a/Cargo.lock b/Cargo.lock index c7cd38b..eaba214 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "arc-swap" version = "1.9.0" @@ -142,6 +148,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -410,6 +427,21 @@ dependencies = [ "itertools", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -493,6 +525,18 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "env_filter" version = "1.0.1" @@ -554,6 +598,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -679,11 +729,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "h2" version = "0.4.13" @@ -714,12 +777,82 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "http", + "idna", + "ipnet", + "once_cell", + "rand", + "ring", + "rustls", + "thiserror", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand", + "resolv-conf", + "rustls", + "smallvec", + "thiserror", + "tokio", + "tokio-rustls", + "tracing", + "webpki-roots 0.26.11", +] + [[package]] name = "http" version = "1.4.0" @@ -802,7 +935,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.6", ] [[package]] @@ -909,6 +1042,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -937,7 +1076,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", ] [[package]] @@ -1029,6 +1183,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.183" @@ -1041,6 +1201,15 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -1098,6 +1267,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "nom" version = "7.1.3" @@ -1151,6 +1337,8 @@ dependencies = [ "criterion", "env_logger", "futures", + "hickory-proto", + "hickory-resolver", "http", "http-body-util", "hyper", @@ -1187,6 +1375,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -1210,6 +1402,29 @@ dependencies = [ "winapi", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "pem" version = "3.0.6" @@ -1305,6 +1520,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1390,6 +1615,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.9.2" @@ -1453,6 +1684,15 @@ dependencies = [ "yasna", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -1518,9 +1758,15 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.6", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "ring" version = "0.17.14" @@ -1618,6 +1864,18 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1780,6 +2038,12 @@ dependencies = [ "syn", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "thiserror" version = "2.0.18" @@ -2038,6 +2302,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -2068,6 +2338,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -2102,6 +2383,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.115" @@ -2157,6 +2447,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.92" @@ -2177,6 +2501,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + [[package]] name = "webpki-roots" version = "1.0.6" @@ -2186,6 +2519,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -2223,6 +2562,35 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2390,6 +2758,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" diff --git a/Cargo.toml b/Cargo.toml index c5d5e1d..d7f6f9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,8 @@ webpki-roots = "1" criterion = { version = "0.8", features = ["html_reports"] } tower = { version = "0.5", features = ["util"] } http = "1" +hickory-resolver = { version = "0.25", features = ["https-ring", "webpki-roots"] } +hickory-proto = "0.25" [[bench]] name = "hot_path" @@ -49,3 +51,7 @@ harness = false [[bench]] name = "dnssec" harness = false + +[[bench]] +name = "recursive_compare" +harness = false diff --git a/benches/numa-bench.toml b/benches/numa-bench.toml new file mode 100644 index 0000000..0e058af --- /dev/null +++ b/benches/numa-bench.toml @@ -0,0 +1,25 @@ +[server] +bind_addr = "127.0.0.1:5454" +api_port = 5381 +api_bind_addr = "127.0.0.1" +data_dir = "/tmp/numa-bench" + +[upstream] +mode = "recursive" +timeout_ms = 10000 + +[cache] +min_ttl = 60 +max_ttl = 3600 + +[blocking] +enabled = false + +[dot] +enabled = false + +[mobile] +enabled = false + +[lan] +enabled = false diff --git a/benches/recursive_compare.rs b/benches/recursive_compare.rs new file mode 100644 index 0000000..e35768c --- /dev/null +++ b/benches/recursive_compare.rs @@ -0,0 +1,1649 @@ +//! DoH forwarding benchmark: Numa vs hickory-resolver. +//! +//! Both forward to the same DoH upstream (Quad9). +//! Measures end-to-end resolution time through each implementation. +//! +//! Fairness: +//! - Both reuse a single TLS connection (Numa via persistent server, +//! Hickory via a shared resolver instance with cache_size=0). +//! - Measurement order is alternated each round to cancel order bias. +//! - Numa cache is flushed before each query. +//! - 100 domains × 10 rounds for statistical confidence. +//! +//! Setup: +//! 1. Start a bench Numa instance: +//! cargo run -- benches/numa-bench.toml +//! 2. Run: +//! cargo bench --bench recursive_compare + +use std::net::SocketAddr; +use std::time::{Duration, Instant}; + +const DOH_UPSTREAM: &str = "https://9.9.9.9/dns-query"; +const NUMA_BENCH: &str = "127.0.0.1:5454"; +const NUMA_API: u16 = 5381; + +const DOMAINS: &[&str] = &[ + "example.com", + "rust-lang.org", + "kernel.org", + "signal.org", + "archlinux.org", + "openbsd.org", + "git-scm.com", + "sqlite.org", + "wireguard.com", + "mozilla.org", + "cloudflare.com", + "google.com", + "github.com", + "stackoverflow.com", + "wikipedia.org", + "reddit.com", + "amazon.com", + "apple.com", + "microsoft.com", + "facebook.com", + "twitter.com", + "linkedin.com", + "netflix.com", + "spotify.com", + "discord.com", + "twitch.tv", + "youtube.com", + "instagram.com", + "whatsapp.com", + "telegram.org", + "debian.org", + "ubuntu.com", + "fedoraproject.org", + "nixos.org", + "gentoo.org", + "freebsd.org", + "netbsd.org", + "dragonflybsd.org", + "illumos.org", + "haiku-os.org", + "python.org", + "golang.org", + "nodejs.org", + "ruby-lang.org", + "php.net", + "swift.org", + "kotlinlang.org", + "scala-lang.org", + "haskell.org", + "elixir-lang.org", + "erlang.org", + "clojure.org", + "julialang.org", + "ziglang.org", + "nim-lang.org", + "dlang.org", + "vlang.io", + "crystal-lang.org", + "racket-lang.org", + "ocaml.org", + "crates.io", + "npmjs.com", + "pypi.org", + "rubygems.org", + "packagist.org", + "nuget.org", + "maven.apache.org", + "hex.pm", + "hackage.haskell.org", + "pkg.go.dev", + "docker.com", + "kubernetes.io", + "prometheus.io", + "grafana.com", + "elastic.co", + "datadog.com", + "sentry.io", + "pagerduty.com", + "atlassian.com", + "jetbrains.com", + "gitlab.com", + "bitbucket.org", + "sourcehut.org", + "codeberg.org", + "launchpad.net", + "savannah.gnu.org", + "letsencrypt.org", + "eff.org", + "torproject.org", + "privacyguides.org", + "matrix.org", + "element.io", + "jitsi.org", + "nextcloud.com", + "syncthing.net", + "tailscale.com", + "mullvad.net", + "proton.me", + "duckduckgo.com", + "brave.com", + "vivaldi.com", +]; + +const ROUNDS: usize = 10; + +fn main() { + let diag = std::env::args().any(|a| a == "--diag"); + let direct = std::env::args().any(|a| a == "--direct"); + + let rt = tokio::runtime::Runtime::new().unwrap(); + + if diag { + run_diag(&rt); + return; + } + + if direct { + run_direct(&rt); + return; + } + + if std::env::args().any(|a| a == "--diag-clients") { + run_diag_clients(&rt); + return; + } + + if std::env::args().any(|a| a == "--spike-trace") { + run_spike_trace(&rt); + return; + } + + if std::env::args().any(|a| a == "--spike-phases") { + run_spike_phases(&rt); + return; + } + + if std::env::args().any(|a| a == "--spike-heartbeat") { + run_spike_heartbeat(&rt); + return; + } + + if std::env::args().any(|a| a == "--hedge") { + run_hedge(&rt); + return; + } + + if std::env::args().any(|a| a == "--hedge-5x") { + run_hedge_multi(&rt, 5); + return; + } + + if std::env::args().any(|a| a == "--vs-dnscrypt") { + run_vs_dnscrypt(&rt, 5); + return; + } + + if std::env::args().any(|a| a == "--vs-unbound") { + run_vs_unbound(&rt, 5); + return; + } + + let numa_addr: SocketAddr = NUMA_BENCH.parse().unwrap(); + + println!("DoH Forwarding Benchmark: Numa vs hickory-resolver"); + println!("Both forwarding to {DOH_UPSTREAM}"); + println!("{} domains × {ROUNDS} rounds", DOMAINS.len()); + println!(); + + // Verify bench Numa is reachable + if rt.block_on(query_udp(numa_addr, "example.com")).is_none() { + eprintln!("Bench Numa not responding on {numa_addr}"); + eprintln!(); + eprintln!("Start it with:"); + eprintln!(" cargo run -- benches/numa-bench.toml"); + std::process::exit(1); + } + + // Build a shared Hickory resolver (reuses TLS connection, like Numa does) + let resolver = rt.block_on(build_hickory_resolver()); + + // Warm up both paths (TLS handshake, connection establishment) + println!("Warming up connections..."); + for _ in 0..3 { + rt.block_on(query_udp(numa_addr, "example.com")); + rt.block_on(query_hickory_doh(&resolver, "example.com")); + } + flush_cache(); + + println!( + "{:<30} {:>10} {:>10} {:>10} {:>8} {:>8}", + "Domain", "Numa (ms)", "Hickory", "Delta", "σ Numa", "σ Hick" + ); + println!("{}", "-".repeat(92)); + + let mut numa_all = Vec::new(); + let mut hickory_all = Vec::new(); + let mut per_domain: Vec<(&str, f64, f64, f64, f64, f64)> = Vec::new(); + + for domain in DOMAINS { + let mut numa_times = Vec::with_capacity(ROUNDS); + let mut hickory_times = Vec::with_capacity(ROUNDS); + + for round in 0..ROUNDS { + flush_cache(); + std::thread::sleep(Duration::from_millis(10)); + + // Alternate measurement order each round to cancel systematic bias + if round % 2 == 0 { + // Numa first + let t = measure(&rt, || rt.block_on(query_udp(numa_addr, domain))); + numa_times.push(t); + let t = measure(&rt, || rt.block_on(query_hickory_doh(&resolver, domain))); + hickory_times.push(t); + } else { + // Hickory first + let t = measure(&rt, || rt.block_on(query_hickory_doh(&resolver, domain))); + hickory_times.push(t); + flush_cache(); + std::thread::sleep(Duration::from_millis(10)); + let t = measure(&rt, || rt.block_on(query_udp(numa_addr, domain))); + numa_times.push(t); + } + } + + let numa_avg = mean(&numa_times); + let hickory_avg = mean(&hickory_times); + let numa_sd = stddev(&numa_times); + let hickory_sd = stddev(&hickory_times); + let delta = numa_avg - hickory_avg; + + numa_all.extend_from_slice(&numa_times); + hickory_all.extend_from_slice(&hickory_times); + per_domain.push((domain, numa_avg, hickory_avg, delta, numa_sd, hickory_sd)); + + let delta_str = format_delta(delta); + println!( + "{:<30} {:>7.1} ms {:>7.1} ms {:>7} ms {:>5.1}ms {:>5.1}ms", + domain, numa_avg, hickory_avg, delta_str, numa_sd, hickory_sd + ); + } + + println!("{}", "-".repeat(92)); + + let numa_mean = mean(&numa_all); + let hickory_mean = mean(&hickory_all); + let delta_mean = numa_mean - hickory_mean; + + println!( + "{:<30} {:>7.1} ms {:>7.1} ms {:>7} ms {:>5.1}ms {:>5.1}ms", + "OVERALL MEAN", + numa_mean, + hickory_mean, + format_delta(delta_mean), + stddev(&numa_all), + stddev(&hickory_all), + ); + + // Median + let numa_med = median(&mut numa_all); + let hickory_med = median(&mut hickory_all); + println!( + "{:<30} {:>7.1} ms {:>7.1} ms {:>7} ms", + "MEDIAN", + numa_med, + hickory_med, + format_delta(numa_med - hickory_med), + ); + + // P95 + let numa_p95 = percentile(&numa_all, 95.0); + let hickory_p95 = percentile(&hickory_all, 95.0); + println!( + "{:<30} {:>7.1} ms {:>7.1} ms {:>7} ms", + "P95", + numa_p95, + hickory_p95, + format_delta(numa_p95 - hickory_p95), + ); + + println!(); + let total_queries = DOMAINS.len() * ROUNDS; + if numa_mean < hickory_mean { + let pct = ((hickory_mean - numa_mean) / hickory_mean * 100.0).round(); + println!("Numa is ~{pct}% faster (mean over {total_queries} queries)."); + } else if hickory_mean < numa_mean { + let pct = ((numa_mean - hickory_mean) / numa_mean * 100.0).round(); + println!("Hickory is ~{pct}% faster (mean over {total_queries} queries)."); + } else { + println!("Both are equal (mean over {total_queries} queries)."); + } + + println!(); + println!("Methodology:"); + println!(" - Both forward to {DOH_UPSTREAM} over a reused TLS connection."); + println!(" - Numa cache flushed before each query. Hickory cache disabled."); + println!(" - Measurement order alternates each round to cancel order bias."); + println!(" - {} domains × {ROUNDS} rounds = {total_queries} queries per resolver.", DOMAINS.len()); +} + +fn run_diag(rt: &tokio::runtime::Runtime) { + println!("Hickory connection reuse diagnostic"); + println!("20 sequential queries to {DOH_UPSTREAM} via one shared resolver"); + println!("If conn is reused: query 1 slow (TLS handshake), rest fast.\n"); + + let resolver = rt.block_on(build_hickory_resolver()); + + let domains = [ + "example.com", "rust-lang.org", "kernel.org", "google.com", "github.com", + "example.com", "rust-lang.org", "kernel.org", "google.com", "github.com", + "example.com", "rust-lang.org", "kernel.org", "google.com", "github.com", + "example.com", "rust-lang.org", "kernel.org", "google.com", "github.com", + ]; + + println!("{:>3} {:<20} {:>10}", "#", "Domain", "Time (ms)"); + println!("{}", "-".repeat(40)); + + for (i, domain) in domains.iter().enumerate() { + use hickory_resolver::proto::rr::RecordType; + let start = Instant::now(); + let result = rt.block_on(resolver.lookup(*domain, RecordType::A)); + let ms = start.elapsed().as_secs_f64() * 1000.0; + match &result { + Ok(lookup) => { + let first = lookup.iter().next().map(|r| format!("{r}")).unwrap_or_default(); + println!("{:>3} {:<20} {:>7.1} ms OK {}", i + 1, domain, ms, first); + } + Err(e) => { + println!("{:>3} {:<20} {:>7.1} ms ERR {}", i + 1, domain, ms, e); + } + } + } +} + +/// Library-to-library comparison: Numa's forward_query_raw vs Hickory's resolver.lookup(). +/// No UDP, no server pipeline — just the DoH forwarding call. +fn run_direct(rt: &tokio::runtime::Runtime) { + println!("Direct DoH Forwarding: Numa forward_query_raw vs Hickory resolver.lookup()"); + println!("Both forwarding to {DOH_UPSTREAM} — no UDP, no server pipeline"); + println!("{} domains × {ROUNDS} rounds", DOMAINS.len()); + println!(); + + // Build Numa's upstream (shared reqwest client, reuses HTTP/2 connection) + let numa_upstream = + numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse upstream"); + let timeout = Duration::from_secs(10); + + // Build Hickory's resolver (shared, reuses HTTP/2 connection) + let resolver = rt.block_on(build_hickory_resolver()); + + // Warm up both + println!("Warming up connections..."); + for _ in 0..3 { + let wire = build_query_vec("example.com"); + let _ = rt.block_on(numa::forward::forward_query_raw(&wire, &numa_upstream, timeout)); + let _ = rt.block_on(query_hickory_doh(&resolver, "example.com")); + } + + println!( + "{:<30} {:>10} {:>10} {:>10} {:>8} {:>8}", + "Domain", "Numa (ms)", "Hickory", "Delta", "σ Numa", "σ Hick" + ); + println!("{}", "-".repeat(92)); + + let mut numa_all = Vec::new(); + let mut hickory_all = Vec::new(); + + for domain in DOMAINS { + let mut numa_times = Vec::with_capacity(ROUNDS); + let mut hickory_times = Vec::with_capacity(ROUNDS); + + for round in 0..ROUNDS { + let wire = build_query_vec(domain); + + if round % 2 == 0 { + let w = wire.clone(); + let t = measure(rt, || { + rt.block_on(numa::forward::forward_query_raw(&w, &numa_upstream, timeout)) + }); + numa_times.push(t); + let t = measure(rt, || rt.block_on(query_hickory_doh(&resolver, domain))); + hickory_times.push(t); + } else { + let t = measure(rt, || rt.block_on(query_hickory_doh(&resolver, domain))); + hickory_times.push(t); + let w = wire.clone(); + let t = measure(rt, || { + rt.block_on(numa::forward::forward_query_raw(&w, &numa_upstream, timeout)) + }); + numa_times.push(t); + } + } + + let numa_avg = mean(&numa_times); + let hickory_avg = mean(&hickory_times); + let numa_sd = stddev(&numa_times); + let hickory_sd = stddev(&hickory_times); + let delta = numa_avg - hickory_avg; + + numa_all.extend_from_slice(&numa_times); + hickory_all.extend_from_slice(&hickory_times); + + println!( + "{:<30} {:>7.1} ms {:>7.1} ms {:>7} ms {:>5.1}ms {:>5.1}ms", + domain, numa_avg, hickory_avg, format_delta(delta), numa_sd, hickory_sd + ); + } + + println!("{}", "-".repeat(92)); + let numa_mean = mean(&numa_all); + let hickory_mean = mean(&hickory_all); + println!( + "{:<30} {:>7.1} ms {:>7.1} ms {:>7} ms {:>5.1}ms {:>5.1}ms", + "OVERALL MEAN", numa_mean, hickory_mean, format_delta(numa_mean - hickory_mean), + stddev(&numa_all), stddev(&hickory_all), + ); + let numa_med = median(&mut numa_all); + let hickory_med = median(&mut hickory_all); + println!( + "{:<30} {:>7.1} ms {:>7.1} ms {:>7} ms", + "MEDIAN", numa_med, hickory_med, format_delta(numa_med - hickory_med), + ); + let numa_p95 = percentile(&numa_all, 95.0); + let hickory_p95 = percentile(&hickory_all, 95.0); + println!( + "{:<30} {:>7.1} ms {:>7.1} ms {:>7} ms", + "P95", numa_p95, hickory_p95, format_delta(numa_p95 - hickory_p95), + ); + + println!(); + let total_queries = DOMAINS.len() * ROUNDS; + if numa_mean < hickory_mean { + let pct = ((hickory_mean - numa_mean) / hickory_mean * 100.0).round(); + println!("Numa is ~{pct}% faster (mean over {total_queries} queries)."); + } else if hickory_mean < numa_mean { + let pct = ((numa_mean - hickory_mean) / numa_mean * 100.0).round(); + println!("Hickory is ~{pct}% faster (mean over {total_queries} queries)."); + } else { + println!("Both are equal (mean over {total_queries} queries)."); + } + + println!(); + println!("Methodology:"); + println!(" - Both forward to {DOH_UPSTREAM} over a reused TLS/HTTP2 connection."); + println!(" - No UDP, no server pipeline, no cache — pure DoH forwarding."); + println!(" - Numa: forward_query_raw (reqwest). Hickory: resolver.lookup (h2)."); + println!(" - {} domains × {ROUNDS} rounds = {total_queries} queries per implementation.", DOMAINS.len()); +} + +/// Per-query timing diagnostic: 20 queries each through reqwest and Hickory. +/// Shows whether reqwest has connection reuse issues or per-request overhead. +fn run_diag_clients(rt: &tokio::runtime::Runtime) { + println!("Client diagnostic: reqwest vs Hickory per-query timing"); + println!("20 queries each to {DOH_UPSTREAM}\n"); + + let upstream = + numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse upstream"); + let resolver = rt.block_on(build_hickory_resolver()); + let timeout = Duration::from_secs(10); + + // Warm both + for _ in 0..3 { + let w = build_query_vec("example.com"); + let _ = rt.block_on(numa::forward::forward_query_raw(&w, &upstream, timeout)); + let _ = rt.block_on(query_hickory_doh(&resolver, "example.com")); + } + + let domains = [ + "example.com", "google.com", "github.com", "rust-lang.org", "cloudflare.com", + "example.com", "google.com", "github.com", "rust-lang.org", "cloudflare.com", + "example.com", "google.com", "github.com", "rust-lang.org", "cloudflare.com", + "example.com", "google.com", "github.com", "rust-lang.org", "cloudflare.com", + ]; + + println!("{:>3} {:<20} {:>12} {:>12}", "#", "Domain", "reqwest", "Hickory"); + println!("{}", "-".repeat(55)); + + for (i, domain) in domains.iter().enumerate() { + let wire = build_query_vec(domain); + + let start = Instant::now(); + let r_result = rt.block_on(numa::forward::forward_query_raw(&wire, &upstream, timeout)); + let r_ms = start.elapsed().as_secs_f64() * 1000.0; + let r_ok = if r_result.is_ok() { "OK" } else { "FAIL" }; + + let start = Instant::now(); + let h_result = rt.block_on(query_hickory_doh(&resolver, domain)); + let h_ms = start.elapsed().as_secs_f64() * 1000.0; + let h_ok = if h_result.is_some() { "OK" } else { "FAIL" }; + + println!( + "{:>3} {:<20} {:>7.1} ms {} {:>7.1} ms {}", + i + 1, domain, r_ms, r_ok, h_ms, h_ok + ); + } +} + +/// Spike trace: fire 200 sequential queries through reqwest and log every one +/// with a timestamp. Analyze the distribution and find spike clusters. +fn run_spike_trace(rt: &tokio::runtime::Runtime) { + println!("Spike trace: 200 sequential reqwest DoH queries"); + println!("Target: {DOH_UPSTREAM}\n"); + + let upstream = + numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse upstream"); + let timeout = Duration::from_secs(10); + + // Warm + for _ in 0..5 { + let w = build_query_vec("example.com"); + let _ = rt.block_on(numa::forward::forward_query_raw(&w, &upstream, timeout)); + } + + // Run the entire 200-query loop inside ONE block_on to eliminate + // per-query runtime re-entry overhead. + let samples: Vec<(u128, f64)> = rt.block_on(async { + let test_start = Instant::now(); + let mut s = Vec::with_capacity(200); + for i in 0..200 { + let domain = match i % 5 { + 0 => "example.com", + 1 => "google.com", + 2 => "github.com", + 3 => "rust-lang.org", + _ => "cloudflare.com", + }; + let wire = build_query_vec(domain); + let req_start = Instant::now(); + let t_from_start_us = test_start.elapsed().as_micros(); + let _ = numa::forward::forward_query_raw(&wire, &upstream, timeout).await; + let ms = req_start.elapsed().as_secs_f64() * 1000.0; + s.push((t_from_start_us, ms)); + } + s + }); + + // Compute stats + let mut sorted_times: Vec = samples.iter().map(|(_, t)| *t).collect(); + sorted_times.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let n = sorted_times.len(); + let median = sorted_times[n / 2]; + let p90 = sorted_times[(n * 90) / 100]; + let p95 = sorted_times[(n * 95) / 100]; + let p99 = sorted_times[(n * 99) / 100]; + let max = sorted_times[n - 1]; + let mean: f64 = sorted_times.iter().sum::() / n as f64; + + println!("Distribution (n={}):", n); + println!(" mean: {:.1} ms", mean); + println!(" median: {:.1} ms", median); + println!(" p90: {:.1} ms", p90); + println!(" p95: {:.1} ms", p95); + println!(" p99: {:.1} ms", p99); + println!(" max: {:.1} ms", max); + println!(); + + // Define spike threshold as 3x median + let spike_threshold = median * 3.0; + let spikes: Vec<(usize, u128, f64)> = samples + .iter() + .enumerate() + .filter(|(_, (_, t))| *t > spike_threshold) + .map(|(i, (ts, t))| (i, *ts, *t)) + .collect(); + + println!("Spikes (> {:.1}ms, which is 3x median):", spike_threshold); + println!(" count: {}", spikes.len()); + if spikes.is_empty() { + return; + } + + // Inter-spike gaps (time between spikes) + let mut gaps_ms: Vec = Vec::new(); + for w in spikes.windows(2) { + let gap_us = w[1].1 - w[0].1; + gaps_ms.push(gap_us as f64 / 1000.0); + } + + println!(); + println!(" {:>4} {:>12} {:>10} {:>12}", "idx", "at (ms)", "latency", "gap from prev"); + for (i, ((idx, ts, latency), gap)) in spikes.iter().zip( + std::iter::once(&0.0).chain(gaps_ms.iter()) + ).enumerate() { + let _ = i; + let gap_str = if *gap > 0.0 { + format!("{:.0} ms", gap) + } else { + "-".to_string() + }; + println!(" {:>4} {:>9.1} {:>6.1} ms {:>12}", idx, *ts as f64 / 1000.0, latency, gap_str); + } + + if !gaps_ms.is_empty() { + let gap_mean: f64 = gaps_ms.iter().sum::() / gaps_ms.len() as f64; + let mut gap_sorted = gaps_ms.clone(); + gap_sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let gap_median = gap_sorted[gap_sorted.len() / 2]; + println!(); + println!(" Inter-spike gap: mean={:.0}ms, median={:.0}ms", gap_mean, gap_median); + } +} + +/// Spike phases: time each step of the reqwest DoH call to find which phase +/// is slow during a spike. Reports (build+send, send->resp headers, body read). +fn run_spike_phases(rt: &tokio::runtime::Runtime) { + println!("Spike phases: timing each phase of reqwest DoH call"); + println!("Target: {DOH_UPSTREAM}\n"); + + // Build the same tuned client our forward_doh uses + let client = reqwest::Client::builder() + .use_rustls_tls() + .http2_initial_stream_window_size(65_535) + .http2_initial_connection_window_size(65_535) + .http2_keep_alive_interval(Duration::from_secs(15)) + .http2_keep_alive_while_idle(true) + .http2_keep_alive_timeout(Duration::from_secs(10)) + .pool_idle_timeout(Duration::from_secs(300)) + .pool_max_idle_per_host(1) + .build() + .unwrap(); + + // Warm up + for _ in 0..5 { + let wire = build_query_vec("example.com"); + let _ = rt.block_on(async { + client + .post(DOH_UPSTREAM) + .header("content-type", "application/dns-message") + .header("accept", "application/dns-message") + .body(wire) + .send() + .await + .ok()? + .bytes() + .await + .ok() + }); + } + + println!("{:>4} {:>8} {:>8} {:>8} {:>8}", "idx", "total", "build", "send", "body"); + println!("{}", "-".repeat(50)); + + let samples: Vec<(f64, f64, f64, f64)> = rt.block_on(async { + let mut s = Vec::with_capacity(200); + for i in 0..200 { + let domain = match i % 5 { + 0 => "example.com", + 1 => "google.com", + 2 => "github.com", + 3 => "rust-lang.org", + _ => "cloudflare.com", + }; + let wire = build_query_vec(domain); + + let t0 = Instant::now(); + // Phase 1: build the request + let req = client + .post(DOH_UPSTREAM) + .header("content-type", "application/dns-message") + .header("accept", "application/dns-message") + .body(wire); + let t1 = Instant::now(); + // Phase 2: send() — this is the dispatch channel + round trip to headers + let resp_result = req.send().await; + let t2 = Instant::now(); + // Phase 3: read body + let body_result = match resp_result { + Ok(r) => r.bytes().await.ok().map(|b| b.len()), + Err(_) => None, + }; + let t3 = Instant::now(); + + let build_ms = (t1 - t0).as_secs_f64() * 1000.0; + let send_ms = (t2 - t1).as_secs_f64() * 1000.0; + let body_ms = (t3 - t2).as_secs_f64() * 1000.0; + let total_ms = (t3 - t0).as_secs_f64() * 1000.0; + + s.push((total_ms, build_ms, send_ms, body_ms)); + let _ = body_result; + } + s + }); + + // Compute distribution on total + let mut totals: Vec = samples.iter().map(|s| s.0).collect(); + totals.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let median = totals[100]; + + // Print spikes (> 3x median) with phase breakdown + for (i, (total, build, send, body)) in samples.iter().enumerate() { + if *total > median * 3.0 { + println!( + "{:>4} {:>5.1} ms {:>5.1} ms {:>5.1} ms {:>5.1} ms", + i, total, build, send, body + ); + } + } + + // Summary: mean of each phase for spikes vs non-spikes + let (spike_samples, normal_samples): (Vec<_>, Vec<_>) = samples + .iter() + .partition(|(t, _, _, _)| *t > median * 3.0); + + let phase_means = |samples: &[&(f64, f64, f64, f64)]| -> (f64, f64, f64, f64) { + let n = samples.len() as f64; + if n == 0.0 { return (0.0, 0.0, 0.0, 0.0); } + let total: f64 = samples.iter().map(|s| s.0).sum::() / n; + let build: f64 = samples.iter().map(|s| s.1).sum::() / n; + let send: f64 = samples.iter().map(|s| s.2).sum::() / n; + let body: f64 = samples.iter().map(|s| s.3).sum::() / n; + (total, build, send, body) + }; + + let spike_refs: Vec<&(f64, f64, f64, f64)> = spike_samples.iter().copied().collect(); + let normal_refs: Vec<&(f64, f64, f64, f64)> = normal_samples.iter().copied().collect(); + let (s_total, s_build, s_send, s_body) = phase_means(&spike_refs); + let (n_total, n_build, n_send, n_body) = phase_means(&normal_refs); + + println!(); + println!("Summary (mean ms):"); + println!( + " {:<8} {:>8} {:>8} {:>8} {:>8}", + "", "total", "build", "send", "body" + ); + println!( + " {:<8} {:>5.1} ms {:>5.1} ms {:>5.1} ms {:>5.1} ms (n={})", + "normal", n_total, n_build, n_send, n_body, normal_refs.len() + ); + println!( + " {:<8} {:>5.1} ms {:>5.1} ms {:>5.1} ms {:>5.1} ms (n={})", + "spike", s_total, s_build, s_send, s_body, spike_refs.len() + ); + println!(); + println!("Delta (spike - normal):"); + println!( + " build: {:+.1} ms, send: {:+.1} ms, body: {:+.1} ms", + s_build - n_build, + s_send - n_send, + s_body - n_body + ); +} + +/// Heartbeat probe: run a parallel task that ticks every 5ms and records +/// how long each tick actually takes. If the heartbeat stalls during a DoH +/// spike, it's a tokio scheduling issue (runtime can't poll tasks). If +/// heartbeat is fine while send() is stuck, it's internal to hyper/h2. +fn run_spike_heartbeat(rt: &tokio::runtime::Runtime) { + use std::sync::{Arc, Mutex}; + + println!("Spike heartbeat probe"); + println!("Running DoH queries + parallel 5ms heartbeat task\n"); + + let upstream = + numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse upstream"); + let timeout = Duration::from_secs(10); + + // Warm up + for _ in 0..5 { + let w = build_query_vec("example.com"); + let _ = rt.block_on(numa::forward::forward_query_raw(&w, &upstream, timeout)); + } + + // Shared vecs: (relative_ms_from_start, event_kind, latency_ms) + // event_kind: 0 = heartbeat, 1 = doh query + type EventLog = Vec<(f64, u8, f64)>; + let events: Arc> = Arc::new(Mutex::new(Vec::with_capacity(2000))); + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let test_start = Instant::now(); + + rt.block_on(async { + // Spawn heartbeat task + let hb_events = Arc::clone(&events); + let hb_stop = Arc::clone(&stop); + let hb_start = test_start; + let heartbeat = tokio::spawn(async move { + let mut next_tick = Instant::now(); + let target = Duration::from_millis(5); + while !hb_stop.load(std::sync::atomic::Ordering::Relaxed) { + next_tick += target; + // Sleep until the next scheduled tick + let now = Instant::now(); + if next_tick > now { + tokio::time::sleep(next_tick - now).await; + } + // Measure how much we overshot the scheduled tick + let actual = Instant::now(); + let lag_ms = if actual > next_tick { + (actual - next_tick).as_secs_f64() * 1000.0 + } else { + 0.0 + }; + let t = (actual - hb_start).as_secs_f64() * 1000.0; + if let Ok(mut e) = hb_events.lock() { + e.push((t, 0, lag_ms)); + } + } + }); + + // Run 200 DoH queries and record their timings + for i in 0..200 { + let domain = match i % 5 { + 0 => "example.com", + 1 => "google.com", + 2 => "github.com", + 3 => "rust-lang.org", + _ => "cloudflare.com", + }; + let wire = build_query_vec(domain); + let req_start = Instant::now(); + let _ = numa::forward::forward_query_raw(&wire, &upstream, timeout).await; + let elapsed = req_start.elapsed().as_secs_f64() * 1000.0; + let t = (req_start - test_start).as_secs_f64() * 1000.0; + if let Ok(mut e) = events.lock() { + e.push((t, 1, elapsed)); + } + } + + stop.store(true, std::sync::atomic::Ordering::Relaxed); + let _ = heartbeat.await; + }); + + let events = events.lock().unwrap(); + + // Separate heartbeats and doh events + let hb: Vec<(f64, f64)> = events + .iter() + .filter(|(_, k, _)| *k == 0) + .map(|(t, _, l)| (*t, *l)) + .collect(); + let doh: Vec<(f64, f64)> = events + .iter() + .filter(|(_, k, _)| *k == 1) + .map(|(t, _, l)| (*t, *l)) + .collect(); + + // Heartbeat stats + let mut hb_lags: Vec = hb.iter().map(|(_, l)| *l).collect(); + hb_lags.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let hb_n = hb_lags.len(); + let hb_median = hb_lags[hb_n / 2]; + let hb_p95 = hb_lags[(hb_n * 95) / 100]; + let hb_p99 = hb_lags[(hb_n * 99) / 100]; + let hb_max = hb_lags[hb_n - 1]; + + // DoH stats + let mut doh_latencies: Vec = doh.iter().map(|(_, l)| *l).collect(); + doh_latencies.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let doh_n = doh_latencies.len(); + let doh_median = doh_latencies[doh_n / 2]; + let doh_p95 = doh_latencies[(doh_n * 95) / 100]; + let doh_max = doh_latencies[doh_n - 1]; + + println!("Heartbeat lag (tick overshoot, {}ms target):", 5); + println!(" n: {}", hb_n); + println!(" median: {:.2} ms", hb_median); + println!(" p95: {:.2} ms", hb_p95); + println!(" p99: {:.2} ms", hb_p99); + println!(" max: {:.2} ms", hb_max); + println!(); + println!("DoH latency:"); + println!(" n: {}", doh_n); + println!(" median: {:.1} ms", doh_median); + println!(" p95: {:.1} ms", doh_p95); + println!(" max: {:.1} ms", doh_max); + println!(); + + // Find DoH spikes and check heartbeat activity DURING each spike + let doh_spike_threshold = doh_median * 3.0; + let mut spikes_with_hb_lag = 0; + let mut spikes_total = 0; + let mut max_hb_during_any_spike = 0.0_f64; + + println!( + "Correlation: during each DoH spike (>{:.1}ms), max heartbeat lag:", + doh_spike_threshold + ); + println!(" {:>6} {:>10} {:>18}", "doh_at", "doh_ms", "max_hb_lag_during"); + + for (doh_t, doh_ms) in &doh { + if *doh_ms > doh_spike_threshold { + spikes_total += 1; + // Find heartbeats that happened during this DoH query + let spike_start = *doh_t; + let spike_end = spike_start + *doh_ms; + let mut max_hb = 0.0_f64; + for (hb_t, hb_lag) in &hb { + if *hb_t >= spike_start && *hb_t <= spike_end + 20.0 { + if *hb_lag > max_hb { + max_hb = *hb_lag; + } + } + } + if max_hb > 5.0 { + spikes_with_hb_lag += 1; + } + max_hb_during_any_spike = max_hb_during_any_spike.max(max_hb); + println!( + " {:>5.0} ms {:>7.1} ms {:>14.2} ms", + doh_t, doh_ms, max_hb + ); + } + } + + println!(); + println!("Conclusion:"); + if spikes_total == 0 { + println!(" No DoH spikes in this run."); + } else { + let pct = (spikes_with_hb_lag as f64 / spikes_total as f64 * 100.0).round(); + println!( + " {}/{} spikes ({:.0}%) had concurrent heartbeat lag >5ms.", + spikes_with_hb_lag, spikes_total, pct + ); + println!(" Max heartbeat lag during any spike: {:.2}ms", max_hb_during_any_spike); + println!(); + if max_hb_during_any_spike > 20.0 { + println!(" → Heartbeat stalls during DoH spikes: tokio scheduling / OS thread issue."); + println!(" The runtime can't poll ANY task — likely QoS demotion, GC pause,"); + println!(" or the worker thread is blocked somewhere."); + } else { + println!(" → Heartbeat runs normally during DoH spikes: internal to hyper/h2."); + println!(" The runtime is fine, but send()'s await is stuck waiting for"); + println!(" the ClientTask to poll the dispatch channel."); + } + } +} + +/// Hedging benchmark: tests four configurations against Hickory. +/// Single: 1 client → Quad9 (baseline) +/// Hedge-same: hedge against same client/connection → Quad9 +/// Hedge-dual: hedge against 2 separate clients, both → Quad9 (same upstream, 2 HTTP/2 conns) +/// Hickory: Hickory resolver → Quad9 (reference) +fn run_hedge(rt: &tokio::runtime::Runtime) { + let hedge_delay = Duration::from_millis(10); + + println!("Hedging Benchmark (all paths → Quad9 only)"); + println!("Upstream: {}", DOH_UPSTREAM); + println!("Hedge delay: {:?}", hedge_delay); + println!("{} domains × {} rounds\n", DOMAINS.len(), ROUNDS); + + // Primary and secondary: two separate reqwest clients → same Quad9 URL. + // This gives two independent HTTP/2 connections, so dispatch spikes + // are uncorrelated (at most one stalls at a time). + let primary_same = + numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse primary"); + let primary_dual = + numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse primary_dual"); + let secondary_dual = + numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse secondary_dual"); + let timeout = Duration::from_secs(10); + + let resolver = rt.block_on(build_hickory_resolver()); + + // Warm up all paths (separate connections need their own TLS handshake) + println!("Warming up connections..."); + for _ in 0..5 { + let w = build_query_vec("example.com"); + let _ = rt.block_on(numa::forward::forward_query_raw(&w, &primary_same, timeout)); + let _ = rt.block_on(numa::forward::forward_query_raw(&w, &primary_dual, timeout)); + let _ = rt.block_on(numa::forward::forward_query_raw(&w, &secondary_dual, timeout)); + let _ = rt.block_on(query_hickory_doh(&resolver, "example.com")); + } + + let mut single_all = Vec::new(); + let mut hedge_same_all = Vec::new(); + let mut hedge_dual_all = Vec::new(); + let mut hickory_all = Vec::new(); + + println!( + "{:<24} {:>10} {:>10} {:>10} {:>10}", + "Domain", "Single", "Hedge-same", "Hedge-dual", "Hickory" + ); + println!("{}", "-".repeat(78)); + + for domain in DOMAINS { + let mut single_times = Vec::with_capacity(ROUNDS); + let mut hedge_same_times = Vec::with_capacity(ROUNDS); + let mut hedge_dual_times = Vec::with_capacity(ROUNDS); + let mut hickory_times = Vec::with_capacity(ROUNDS); + + for _ in 0..ROUNDS { + let wire = build_query_vec(domain); + + let t = Instant::now(); + let _ = rt.block_on(numa::forward::forward_query_raw(&wire, &primary_same, timeout)); + single_times.push(t.elapsed().as_secs_f64() * 1000.0); + + let t = Instant::now(); + let _ = rt.block_on(numa::forward::forward_with_hedging_raw( + &wire, &primary_same, &primary_same, hedge_delay, timeout, + )); + hedge_same_times.push(t.elapsed().as_secs_f64() * 1000.0); + + let t = Instant::now(); + let _ = rt.block_on(numa::forward::forward_with_hedging_raw( + &wire, &primary_dual, &secondary_dual, hedge_delay, timeout, + )); + hedge_dual_times.push(t.elapsed().as_secs_f64() * 1000.0); + + let t = Instant::now(); + let _ = rt.block_on(query_hickory_doh(&resolver, domain)); + hickory_times.push(t.elapsed().as_secs_f64() * 1000.0); + } + + single_all.extend_from_slice(&single_times); + hedge_same_all.extend_from_slice(&hedge_same_times); + hedge_dual_all.extend_from_slice(&hedge_dual_times); + hickory_all.extend_from_slice(&hickory_times); + + println!( + "{:<24} {:>7.1} ms {:>7.1} ms {:>7.1} ms {:>7.1} ms", + domain, + mean(&single_times), + mean(&hedge_same_times), + mean(&hedge_dual_times), + mean(&hickory_times) + ); + } + + println!("{}", "-".repeat(78)); + + let stats = |all: &mut Vec| -> (f64, f64, f64, f64, f64) { + let m = mean(all); + let med = median(all); + let p95 = percentile(all, 95.0); + let p99 = percentile(all, 99.0); + let sd = stddev(all); + (m, med, p95, p99, sd) + }; + + let (s_m, s_med, s_p95, s_p99, s_sd) = stats(&mut single_all); + let (hs_m, hs_med, hs_p95, hs_p99, hs_sd) = stats(&mut hedge_same_all); + let (hd_m, hd_med, hd_p95, hd_p99, hd_sd) = stats(&mut hedge_dual_all); + let (k_m, k_med, k_p95, k_p99, k_sd) = stats(&mut hickory_all); + + println!(); + println!( + "{:<10} {:>10} {:>10} {:>10} {:>10}", + "", "Single", "Hedge-same", "Hedge-dual", "Hickory" + ); + println!( + "{:<10} {:>7.1} ms {:>7.1} ms {:>7.1} ms {:>7.1} ms", + "mean", s_m, hs_m, hd_m, k_m + ); + println!( + "{:<10} {:>7.1} ms {:>7.1} ms {:>7.1} ms {:>7.1} ms", + "median", s_med, hs_med, hd_med, k_med + ); + println!( + "{:<10} {:>7.1} ms {:>7.1} ms {:>7.1} ms {:>7.1} ms", + "p95", s_p95, hs_p95, hd_p95, k_p95 + ); + println!( + "{:<10} {:>7.1} ms {:>7.1} ms {:>7.1} ms {:>7.1} ms", + "p99", s_p99, hs_p99, hd_p99, k_p99 + ); + println!( + "{:<10} {:>7.1} ms {:>7.1} ms {:>7.1} ms {:>7.1} ms", + "σ", s_sd, hs_sd, hd_sd, k_sd + ); + + println!(); + println!("Hedge-same improvement over single:"); + println!(" mean: {:+.0}%, p95: {:+.0}%, p99: {:+.0}%", + (hs_m - s_m) / s_m * 100.0, + (hs_p95 - s_p95) / s_p95 * 100.0, + (hs_p99 - s_p99) / s_p99 * 100.0); + println!("Hedge-dual improvement over single:"); + println!(" mean: {:+.0}%, p95: {:+.0}%, p99: {:+.0}%", + (hd_m - s_m) / s_m * 100.0, + (hd_p95 - s_p95) / s_p95 * 100.0, + (hd_p99 - s_p99) / s_p99 * 100.0); +} + +/// Run the hedging benchmark N times and aggregate samples across all runs. +/// Also reports per-run stats to show drift. +fn run_hedge_multi(rt: &tokio::runtime::Runtime, iterations: usize) { + let hedge_delay = Duration::from_millis(10); + + println!("Hedging Benchmark × {} iterations", iterations); + println!("Upstream: {}", DOH_UPSTREAM); + println!("Hedge delay: {:?}", hedge_delay); + println!("{} domains × {} rounds per iteration\n", DOMAINS.len(), ROUNDS); + + let primary_same = + numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse"); + let primary_dual = + numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse"); + let secondary_dual = + numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse"); + let timeout = Duration::from_secs(10); + + let resolver = rt.block_on(build_hickory_resolver()); + + // Warm up + println!("Warming up..."); + for _ in 0..5 { + let w = build_query_vec("example.com"); + let _ = rt.block_on(numa::forward::forward_query_raw(&w, &primary_same, timeout)); + let _ = rt.block_on(numa::forward::forward_query_raw(&w, &primary_dual, timeout)); + let _ = rt.block_on(numa::forward::forward_query_raw(&w, &secondary_dual, timeout)); + let _ = rt.block_on(query_hickory_doh(&resolver, "example.com")); + } + + // Accumulated samples across all iterations + let mut all_single = Vec::new(); + let mut all_hedge_same = Vec::new(); + let mut all_hedge_dual = Vec::new(); + let mut all_hickory = Vec::new(); + + // Per-iteration summary stats + let mut iter_stats: Vec<[(f64, f64, f64, f64, f64); 4]> = Vec::new(); + + for iter in 1..=iterations { + println!(" iteration {}/{}...", iter, iterations); + + let mut single = Vec::new(); + let mut hedge_same = Vec::new(); + let mut hedge_dual = Vec::new(); + let mut hickory = Vec::new(); + + for domain in DOMAINS { + for _ in 0..ROUNDS { + let wire = build_query_vec(domain); + + let t = Instant::now(); + let _ = rt.block_on(numa::forward::forward_query_raw(&wire, &primary_same, timeout)); + single.push(t.elapsed().as_secs_f64() * 1000.0); + + let t = Instant::now(); + let _ = rt.block_on(numa::forward::forward_with_hedging_raw( + &wire, &primary_same, &primary_same, hedge_delay, timeout, + )); + hedge_same.push(t.elapsed().as_secs_f64() * 1000.0); + + let t = Instant::now(); + let _ = rt.block_on(numa::forward::forward_with_hedging_raw( + &wire, &primary_dual, &secondary_dual, hedge_delay, timeout, + )); + hedge_dual.push(t.elapsed().as_secs_f64() * 1000.0); + + let t = Instant::now(); + let _ = rt.block_on(query_hickory_doh(&resolver, domain)); + hickory.push(t.elapsed().as_secs_f64() * 1000.0); + } + } + + let stats = |v: &mut Vec| -> (f64, f64, f64, f64, f64) { + (mean(v), median(v), percentile(v, 95.0), percentile(v, 99.0), stddev(v)) + }; + iter_stats.push([ + stats(&mut single), + stats(&mut hedge_same), + stats(&mut hedge_dual), + stats(&mut hickory), + ]); + + all_single.extend_from_slice(&single); + all_hedge_same.extend_from_slice(&hedge_same); + all_hedge_dual.extend_from_slice(&hedge_dual); + all_hickory.extend_from_slice(&hickory); + } + + println!(); + println!("=== Per-iteration medians (drift check) ==="); + println!( + "{:<8} {:>10} {:>12} {:>12} {:>10}", + "iter", "Single", "Hedge-same", "Hedge-dual", "Hickory" + ); + for (i, s) in iter_stats.iter().enumerate() { + println!( + "{:<8} {:>7.1} ms {:>9.1} ms {:>9.1} ms {:>7.1} ms", + i + 1, + s[0].1, + s[1].1, + s[2].1, + s[3].1 + ); + } + + println!(); + println!("=== Per-iteration p99 (drift check) ==="); + println!( + "{:<8} {:>10} {:>12} {:>12} {:>10}", + "iter", "Single", "Hedge-same", "Hedge-dual", "Hickory" + ); + for (i, s) in iter_stats.iter().enumerate() { + println!( + "{:<8} {:>7.1} ms {:>9.1} ms {:>9.1} ms {:>7.1} ms", + i + 1, + s[0].3, + s[1].3, + s[2].3, + s[3].3 + ); + } + + let final_stats = |v: &mut Vec| -> (f64, f64, f64, f64, f64) { + (mean(v), median(v), percentile(v, 95.0), percentile(v, 99.0), stddev(v)) + }; + let (s_m, s_med, s_p95, s_p99, s_sd) = final_stats(&mut all_single); + let (hs_m, hs_med, hs_p95, hs_p99, hs_sd) = final_stats(&mut all_hedge_same); + let (hd_m, hd_med, hd_p95, hd_p99, hd_sd) = final_stats(&mut all_hedge_dual); + let (k_m, k_med, k_p95, k_p99, k_sd) = final_stats(&mut all_hickory); + + println!(); + let total = iterations * DOMAINS.len() * ROUNDS; + println!("=== Aggregated across all {} samples per method ===", total); + println!(); + println!( + "{:<10} {:>10} {:>12} {:>12} {:>10}", + "", "Single", "Hedge-same", "Hedge-dual", "Hickory" + ); + println!( + "{:<10} {:>7.1} ms {:>9.1} ms {:>9.1} ms {:>7.1} ms", + "mean", s_m, hs_m, hd_m, k_m + ); + println!( + "{:<10} {:>7.1} ms {:>9.1} ms {:>9.1} ms {:>7.1} ms", + "median", s_med, hs_med, hd_med, k_med + ); + println!( + "{:<10} {:>7.1} ms {:>9.1} ms {:>9.1} ms {:>7.1} ms", + "p95", s_p95, hs_p95, hd_p95, k_p95 + ); + println!( + "{:<10} {:>7.1} ms {:>9.1} ms {:>9.1} ms {:>7.1} ms", + "p99", s_p99, hs_p99, hd_p99, k_p99 + ); + println!( + "{:<10} {:>7.1} ms {:>9.1} ms {:>9.1} ms {:>7.1} ms", + "σ", s_sd, hs_sd, hd_sd, k_sd + ); + + println!(); + println!("Hedge-same vs Single: mean {:+.0}%, p95 {:+.0}%, p99 {:+.0}%", + (hs_m - s_m) / s_m * 100.0, + (hs_p95 - s_p95) / s_p95 * 100.0, + (hs_p99 - s_p99) / s_p99 * 100.0); + println!("Hedge-dual vs Single: mean {:+.0}%, p95 {:+.0}%, p99 {:+.0}%", + (hd_m - s_m) / s_m * 100.0, + (hd_p95 - s_p95) / s_p95 * 100.0, + (hd_p99 - s_p99) / s_p99 * 100.0); + println!("Hedge-same vs Hickory: mean {:+.0}%, p95 {:+.0}%, p99 {:+.0}%", + (hs_m - k_m) / k_m * 100.0, + (hs_p95 - k_p95) / k_p95 * 100.0, + (hs_p99 - k_p99) / k_p99 * 100.0); +} + +/// Server-to-server benchmark: Numa vs dnscrypt-proxy vs Unbound. +/// All are full servers: UDP in, encrypted forwarding to Quad9. +/// Numa + dnscrypt: DoH (HTTPS). Unbound: DoT (TLS port 853). +fn run_vs_dnscrypt(rt: &tokio::runtime::Runtime, iterations: usize) { + const DNSCRYPT_ADDR: &str = "127.0.0.1:5455"; + const UNBOUND_ADDR: &str = "127.0.0.1:5456"; + let numa_addr: SocketAddr = NUMA_BENCH.parse().unwrap(); + let dnscrypt_addr: SocketAddr = DNSCRYPT_ADDR.parse().unwrap(); + let unbound_addr: SocketAddr = UNBOUND_ADDR.parse().unwrap(); + + println!("Server-to-Server: Numa vs dnscrypt-proxy vs Unbound"); + println!("Numa (DoH): {}", NUMA_BENCH); + println!("dnscrypt-proxy (DoH): {}", DNSCRYPT_ADDR); + println!("Unbound (DoT): {}", UNBOUND_ADDR); + println!("All forwarding to Quad9 over encrypted transport"); + println!("{} domains × {} rounds × {} iterations\n", + DOMAINS.len(), ROUNDS, iterations); + + // Verify all are up + let servers: Vec<(&str, SocketAddr)> = vec![ + ("Numa", numa_addr), + ("dnscrypt-proxy", dnscrypt_addr), + ("Unbound", unbound_addr), + ]; + for (name, addr) in &servers { + if rt.block_on(query_udp(*addr, "example.com")).is_none() { + eprintln!("{} not responding on {}", name, addr); + std::process::exit(1); + } + } + println!("All servers reachable.\n"); + + // Warm up + println!("Warming up..."); + for _ in 0..5 { + for (_, addr) in &servers { + let _ = rt.block_on(query_udp(*addr, "example.com")); + } + } + + let mut all_numa = Vec::new(); + let mut all_dnscrypt = Vec::new(); + let mut all_unbound = Vec::new(); + let mut iter_stats: Vec<[(f64, f64, f64, f64, f64); 3]> = Vec::new(); + + for iter in 1..=iterations { + println!(" iteration {}/{}...", iter, iterations); + + let mut numa = Vec::new(); + let mut dnscrypt = Vec::new(); + let mut unbound = Vec::new(); + + for domain in DOMAINS { + for round in 0..ROUNDS { + flush_cache(); + std::thread::sleep(Duration::from_millis(5)); + + // Rotate order: 3 servers, 3 possible orderings + let order = round % 3; + let mut measure = |addr: SocketAddr| -> f64 { + let t = Instant::now(); + let _ = rt.block_on(query_udp(addr, domain)); + t.elapsed().as_secs_f64() * 1000.0 + }; + + match order { + 0 => { + numa.push(measure(numa_addr)); + dnscrypt.push(measure(dnscrypt_addr)); + unbound.push(measure(unbound_addr)); + } + 1 => { + dnscrypt.push(measure(dnscrypt_addr)); + unbound.push(measure(unbound_addr)); + numa.push(measure(numa_addr)); + } + _ => { + unbound.push(measure(unbound_addr)); + numa.push(measure(numa_addr)); + dnscrypt.push(measure(dnscrypt_addr)); + } + } + } + } + + let stats = |v: &mut Vec| -> (f64, f64, f64, f64, f64) { + (mean(v), median(v), percentile(v, 95.0), percentile(v, 99.0), stddev(v)) + }; + iter_stats.push([stats(&mut numa), stats(&mut dnscrypt), stats(&mut unbound)]); + + all_numa.extend_from_slice(&numa); + all_dnscrypt.extend_from_slice(&dnscrypt); + all_unbound.extend_from_slice(&unbound); + } + + println!(); + println!("=== Per-iteration medians ==="); + println!("{:<8} {:>10} {:>14} {:>10}", "iter", "Numa", "dnscrypt-proxy", "Unbound"); + for (i, s) in iter_stats.iter().enumerate() { + println!("{:<8} {:>7.1} ms {:>11.1} ms {:>7.1} ms", + i + 1, s[0].1, s[1].1, s[2].1); + } + + println!(); + println!("=== Per-iteration p99 ==="); + println!("{:<8} {:>10} {:>14} {:>10}", "iter", "Numa", "dnscrypt-proxy", "Unbound"); + for (i, s) in iter_stats.iter().enumerate() { + println!("{:<8} {:>7.1} ms {:>11.1} ms {:>7.1} ms", + i + 1, s[0].3, s[1].3, s[2].3); + } + + let stats = |v: &mut Vec| -> (f64, f64, f64, f64, f64) { + (mean(v), median(v), percentile(v, 95.0), percentile(v, 99.0), stddev(v)) + }; + let (n_m, n_med, n_p95, n_p99, n_sd) = stats(&mut all_numa); + let (d_m, d_med, d_p95, d_p99, d_sd) = stats(&mut all_dnscrypt); + let (u_m, u_med, u_p95, u_p99, u_sd) = stats(&mut all_unbound); + + println!(); + let total = iterations * DOMAINS.len() * ROUNDS; + println!("=== Aggregated ({} samples per method) ===", total); + println!(); + println!("{:<10} {:>10} {:>14} {:>10}", "", "Numa", "dnscrypt-proxy", "Unbound"); + println!("{:<10} {:>7.1} ms {:>11.1} ms {:>7.1} ms", "mean", n_m, d_m, u_m); + println!("{:<10} {:>7.1} ms {:>11.1} ms {:>7.1} ms", "median", n_med, d_med, u_med); + println!("{:<10} {:>7.1} ms {:>11.1} ms {:>7.1} ms", "p95", n_p95, d_p95, u_p95); + println!("{:<10} {:>7.1} ms {:>11.1} ms {:>7.1} ms", "p99", n_p99, d_p99, u_p99); + println!("{:<10} {:>7.1} ms {:>11.1} ms {:>7.1} ms", "σ", n_sd, d_sd, u_sd); + println!(); + + println!("Numa vs dnscrypt-proxy:"); + println!(" mean: {:+.0}%, median: {:+.0}%, p99: {:+.0}%", + (n_m - d_m) / d_m * 100.0, (n_med - d_med) / d_med * 100.0, (n_p99 - d_p99) / d_p99 * 100.0); + println!("Numa vs Unbound:"); + println!(" mean: {:+.0}%, median: {:+.0}%, p99: {:+.0}%", + (n_m - u_m) / u_m * 100.0, (n_med - u_med) / u_med * 100.0, (n_p99 - u_p99) / u_p99 * 100.0); +} + +/// Numa vs Unbound: both forward over plain UDP to Quad9, caching enabled. +/// Truly equal transport — no TLS, no HTTP/2, pure forwarding + cache. +fn run_vs_unbound(rt: &tokio::runtime::Runtime, iterations: usize) { + const UNBOUND_ADDR: &str = "127.0.0.1:5456"; + let numa_addr: SocketAddr = NUMA_BENCH.parse().unwrap(); + let unbound_addr: SocketAddr = UNBOUND_ADDR.parse().unwrap(); + + println!("Numa vs Unbound (both plain UDP forwarding to Quad9, caching enabled)"); + println!("Numa: {} → 9.9.9.9:53 UDP", NUMA_BENCH); + println!("Unbound: {} → 9.9.9.9:53 UDP", UNBOUND_ADDR); + println!("{} domains × {} rounds × {} iterations\n", + DOMAINS.len(), ROUNDS, iterations); + + if rt.block_on(query_udp(numa_addr, "example.com")).is_none() { + eprintln!("Numa not responding"); std::process::exit(1); + } + if rt.block_on(query_udp(unbound_addr, "example.com")).is_none() { + eprintln!("Unbound not responding"); std::process::exit(1); + } + println!("Both servers reachable.\n"); + + println!("Warming up..."); + for _ in 0..5 { + let _ = rt.block_on(query_udp(numa_addr, "example.com")); + let _ = rt.block_on(query_udp(unbound_addr, "example.com")); + } + + let mut all_numa = Vec::new(); + let mut all_unbound = Vec::new(); + let mut iter_stats: Vec<[(f64, f64, f64, f64, f64); 2]> = Vec::new(); + + for iter in 1..=iterations { + println!(" iteration {}/{}...", iter, iterations); + + let mut numa = Vec::new(); + let mut unbound = Vec::new(); + + for domain in DOMAINS { + for round in 0..ROUNDS { + // No cache flushing — both serve from cache after first hit + let mut measure = |addr: SocketAddr| -> f64 { + let t = Instant::now(); + let _ = rt.block_on(query_udp(addr, domain)); + t.elapsed().as_secs_f64() * 1000.0 + }; + + if round % 2 == 0 { + numa.push(measure(numa_addr)); + unbound.push(measure(unbound_addr)); + } else { + unbound.push(measure(unbound_addr)); + numa.push(measure(numa_addr)); + } + } + } + + let stats = |v: &mut Vec| -> (f64, f64, f64, f64, f64) { + (mean(v), median(v), percentile(v, 95.0), percentile(v, 99.0), stddev(v)) + }; + iter_stats.push([stats(&mut numa), stats(&mut unbound)]); + + all_numa.extend_from_slice(&numa); + all_unbound.extend_from_slice(&unbound); + } + + println!(); + println!("=== Per-iteration medians ==="); + println!("{:<8} {:>10} {:>10}", "iter", "Numa", "Unbound"); + for (i, s) in iter_stats.iter().enumerate() { + println!("{:<8} {:>7.1} ms {:>7.1} ms", i + 1, s[0].1, s[1].1); + } + + println!(); + println!("=== Per-iteration p99 ==="); + println!("{:<8} {:>10} {:>10}", "iter", "Numa", "Unbound"); + for (i, s) in iter_stats.iter().enumerate() { + println!("{:<8} {:>7.1} ms {:>7.1} ms", i + 1, s[0].3, s[1].3); + } + + let stats = |v: &mut Vec| -> (f64, f64, f64, f64, f64) { + (mean(v), median(v), percentile(v, 95.0), percentile(v, 99.0), stddev(v)) + }; + let (n_m, n_med, n_p95, n_p99, n_sd) = stats(&mut all_numa); + let (u_m, u_med, u_p95, u_p99, u_sd) = stats(&mut all_unbound); + + println!(); + let total = iterations * DOMAINS.len() * ROUNDS; + println!("=== Aggregated ({} samples per method) ===", total); + println!(); + println!("{:<10} {:>10} {:>10}", "", "Numa", "Unbound"); + println!("{:<10} {:>7.1} ms {:>7.1} ms", "mean", n_m, u_m); + println!("{:<10} {:>7.1} ms {:>7.1} ms", "median", n_med, u_med); + println!("{:<10} {:>7.1} ms {:>7.1} ms", "p95", n_p95, u_p95); + println!("{:<10} {:>7.1} ms {:>7.1} ms", "p99", n_p99, u_p99); + println!("{:<10} {:>7.1} ms {:>7.1} ms", "σ", n_sd, u_sd); + println!(); + + println!("Numa vs Unbound:"); + println!(" mean: {:+.1} ms ({:+.0}%)", n_m - u_m, (n_m - u_m) / u_m * 100.0); + println!(" median: {:+.1} ms ({:+.0}%)", n_med - u_med, (n_med - u_med) / u_med * 100.0); + println!(" p95: {:+.1} ms ({:+.0}%)", n_p95 - u_p95, (n_p95 - u_p95) / u_p95 * 100.0); + println!(" p99: {:+.1} ms ({:+.0}%)", n_p99 - u_p99, (n_p99 - u_p99) / u_p99 * 100.0); +} + +/// Build a DNS query as a Vec for use with forward_query_raw. +fn build_query_vec(domain: &str) -> Vec { + let mut buf = vec![0u8; 512]; + let len = build_query(&mut buf, domain); + buf.truncate(len); + buf +} + +fn measure R, R>(_rt: &tokio::runtime::Runtime, f: F) -> f64 { + let start = Instant::now(); + f(); + start.elapsed().as_secs_f64() * 1000.0 +} + +fn mean(v: &[f64]) -> f64 { + v.iter().sum::() / v.len() as f64 +} + +fn stddev(v: &[f64]) -> f64 { + let m = mean(v); + let var = v.iter().map(|x| (x - m).powi(2)).sum::() / v.len() as f64; + var.sqrt() +} + +fn median(v: &mut [f64]) -> f64 { + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let n = v.len(); + if n % 2 == 0 { + (v[n / 2 - 1] + v[n / 2]) / 2.0 + } else { + v[n / 2] + } +} + +fn percentile(sorted: &[f64], p: f64) -> f64 { + let idx = (p / 100.0 * (sorted.len() - 1) as f64).round() as usize; + sorted[idx.min(sorted.len() - 1)] +} + +fn format_delta(delta: f64) -> String { + if delta > 0.0 { + format!("+{:.1}", delta) + } else { + format!("{:.1}", delta) + } +} + +/// Query a DNS server over UDP. +async fn query_udp(addr: SocketAddr, domain: &str) -> Option<()> { + use tokio::net::UdpSocket; + + let sock = UdpSocket::bind("0.0.0.0:0").await.ok()?; + let mut buf = vec![0u8; 512]; + let len = build_query(&mut buf, domain); + + sock.send_to(&buf[..len], addr).await.ok()?; + + let mut resp = vec![0u8; 4096]; + tokio::time::timeout(Duration::from_secs(10), sock.recv_from(&mut resp)) + .await + .ok()? + .ok()?; + + Some(()) +} + +/// Build a shared Hickory DoH resolver (reuses TLS connection across queries). +async fn build_hickory_resolver() -> hickory_resolver::TokioResolver { + use hickory_resolver::config::*; + + let ns = NameServerConfig { + socket_addr: "9.9.9.9:443".parse().unwrap(), + protocol: hickory_proto::xfer::Protocol::Https, + tls_dns_name: Some("dns.quad9.net".to_string()), + trust_negative_responses: true, + bind_addr: None, + http_endpoint: Some("/dns-query".to_string()), + }; + + let config = ResolverConfig::from_parts(None, vec![], NameServerConfigGroup::from(vec![ns])); + + let mut opts = ResolverOpts::default(); + opts.cache_size = 0; + opts.num_concurrent_reqs = 1; + opts.timeout = Duration::from_secs(10); + + hickory_resolver::TokioResolver::builder_with_config(config, Default::default()) + .with_options(opts) + .build() +} + +/// Query using the shared Hickory resolver. +async fn query_hickory_doh( + resolver: &hickory_resolver::TokioResolver, + domain: &str, +) -> Option<()> { + use hickory_resolver::proto::rr::RecordType; + let _ = resolver.lookup(domain, RecordType::A).await.ok()?; + Some(()) +} + +fn build_query(buf: &mut [u8], domain: &str) -> usize { + let mut pos = 0; + buf[pos..pos + 2].copy_from_slice(&0x1234u16.to_be_bytes()); + pos += 2; + buf[pos..pos + 2].copy_from_slice(&0x0100u16.to_be_bytes()); + pos += 2; + buf[pos..pos + 2].copy_from_slice(&1u16.to_be_bytes()); + pos += 2; + buf[pos..pos + 6].fill(0); + pos += 6; + + for label in domain.split('.') { + buf[pos] = label.len() as u8; + pos += 1; + buf[pos..pos + label.len()].copy_from_slice(label.as_bytes()); + pos += label.len(); + } + buf[pos] = 0; + pos += 1; + buf[pos..pos + 2].copy_from_slice(&1u16.to_be_bytes()); + pos += 2; + buf[pos..pos + 2].copy_from_slice(&1u16.to_be_bytes()); + pos += 2; + pos +} + +fn flush_cache() { + let _ = std::process::Command::new("curl") + .args(["-s", "-X", "DELETE", &format!("http://127.0.0.1:{NUMA_API}/cache")]) + .output(); +} diff --git a/scripts/bench-recursive.sh b/scripts/bench-recursive.sh new file mode 100755 index 0000000..1a1ab71 --- /dev/null +++ b/scripts/bench-recursive.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Bench: Numa cold-cache recursive resolution vs dig (forwarded through system resolver) +# +# Measures cold-cache recursive resolution time for Numa. +# Flushes Numa's cache before each query to ensure cold-cache. +# Compares against dig querying a public recursive resolver (no cache advantage). +# +# Usage: ./scripts/bench-recursive.sh [numa_port] + +set -euo pipefail + +NUMA_ADDR="${NUMA_ADDR:-127.0.0.1}" +NUMA_PORT="${NUMA_PORT:-${1:-53}}" +API_PORT="${API_PORT:-5380}" +ROUNDS=3 + +DOMAINS=( + "example.com" + "rust-lang.org" + "kernel.org" + "signal.org" + "archlinux.org" + "openbsd.org" + "git-scm.com" + "sqlite.org" + "wireguard.com" + "mozilla.org" +) + +GREEN='\033[0;32m' +AMBER='\033[0;33m' +CYAN='\033[0;36m' +DIM='\033[0;90m' +BOLD='\033[1m' +RESET='\033[0m' + +echo -e "${CYAN}${BOLD}Recursive DNS Resolution Benchmark${RESET}" +echo -e "${DIM}Numa (cold cache, recursive from root) vs dig @1.1.1.1 (public resolver)${RESET}" +echo -e "${DIM}Rounds per domain: ${ROUNDS}${RESET}" +echo "" + +# Verify Numa is reachable +if ! dig @${NUMA_ADDR} -p ${NUMA_PORT} +short +time=3 +tries=1 example.com A &>/dev/null; then + echo -e "${AMBER}Numa not responding on ${NUMA_ADDR}:${NUMA_PORT}${RESET}" >&2 + exit 1 +fi + +# Verify we can flush cache +if ! curl -s -X DELETE "http://${NUMA_ADDR}:${API_PORT}/cache" &>/dev/null; then + echo -e "${AMBER}Cannot flush cache via API at ${NUMA_ADDR}:${API_PORT}${RESET}" >&2 + exit 1 +fi + +measure_ms() { + local start end + start=$(python3 -c 'import time; print(time.time())') + eval "$1" &>/dev/null + end=$(python3 -c 'import time; print(time.time())') + python3 -c "print(round(($end - $start) * 1000, 1))" +} + +printf "${BOLD}%-22s %10s %10s %8s${RESET}\n" "Domain" "Numa (ms)" "1.1.1.1" "Delta" +printf "%-22s %10s %10s %8s\n" "----------------------" "----------" "----------" "--------" + +numa_total=0 +dig_total=0 +count=0 + +for domain in "${DOMAINS[@]}"; do + numa_sum=0 + dig_sum=0 + + for ((r=1; r<=ROUNDS; r++)); do + # Flush Numa cache + curl -s -X DELETE "http://${NUMA_ADDR}:${API_PORT}/cache" &>/dev/null + sleep 0.05 + + # Measure Numa (recursive from root, cold cache) + ms=$(measure_ms "dig @${NUMA_ADDR} -p ${NUMA_PORT} +short +time=10 +tries=1 ${domain} A") + numa_sum=$(python3 -c "print(round($numa_sum + $ms, 1))") + + # Measure dig against 1.1.1.1 (Cloudflare — warm cache, but shows baseline) + ms=$(measure_ms "dig @1.1.1.1 +short +time=10 +tries=1 ${domain} A") + dig_sum=$(python3 -c "print(round($dig_sum + $ms, 1))") + done + + numa_avg=$(python3 -c "print(round($numa_sum / $ROUNDS, 1))") + dig_avg=$(python3 -c "print(round($dig_sum / $ROUNDS, 1))") + delta=$(python3 -c "d = round($numa_avg - $dig_avg, 1); print(f'+{d}' if d > 0 else str(d))") + + # Color the delta + delta_color="$GREEN" + if python3 -c "exit(0 if $numa_avg > $dig_avg * 1.5 else 1)" 2>/dev/null; then + delta_color="$AMBER" + fi + + printf "%-22s %8s ms %8s ms ${delta_color}%6s ms${RESET}\n" "$domain" "$numa_avg" "$dig_avg" "$delta" + + numa_total=$(python3 -c "print(round($numa_total + $numa_avg, 1))") + dig_total=$(python3 -c "print(round($dig_total + $dig_avg, 1))") + count=$((count + 1)) +done + +echo "" +numa_mean=$(python3 -c "print(round($numa_total / $count, 1))") +dig_mean=$(python3 -c "print(round($dig_total / $count, 1))") +delta_mean=$(python3 -c "d = round($numa_mean - $dig_mean, 1); print(f'+{d}' if d > 0 else str(d))") + +printf "${BOLD}%-22s %8s ms %8s ms %6s ms${RESET}\n" "AVERAGE" "$numa_mean" "$dig_mean" "$delta_mean" + +echo "" +echo -e "${DIM}Note: Numa resolves recursively from root hints (cold cache).${RESET}" +echo -e "${DIM}1.1.1.1 serves from Cloudflare's global cache (warm). The comparison${RESET}" +echo -e "${DIM}is intentionally unfair — it shows Numa's worst case vs the best case${RESET}" +echo -e "${DIM}of a global anycast resolver. Cached Numa queries resolve in <1ms.${RESET}" diff --git a/src/api.rs b/src/api.rs index a0bae58..e638fba 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1029,6 +1029,7 @@ mod tests { upstream_port: 53, lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST), timeout: std::time::Duration::from_secs(3), + hedge_delay: std::time::Duration::ZERO, proxy_tld: "numa".to_string(), proxy_tld_suffix: ".numa".to_string(), lan_enabled: false, diff --git a/src/cache.rs b/src/cache.rs index 5bdde85..82795bc 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,9 +1,10 @@ use std::collections::HashMap; use std::time::{Duration, Instant}; +use crate::buffer::BytePacketBuffer; use crate::packet::DnsPacket; use crate::question::QueryType; -use crate::record::DnsRecord; +use crate::wire::WireMeta; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum DnssecStatus { @@ -26,14 +27,16 @@ impl DnssecStatus { } struct CacheEntry { - packet: DnsPacket, + wire: Vec, + meta: WireMeta, inserted_at: Instant, ttl: Duration, dnssec_status: DnssecStatus, } -/// DNS cache using a two-level map (domain -> query_type -> entry) so that -/// lookups can borrow `&str` instead of allocating a `String` key. +const STALE_WINDOW: Duration = Duration::from_secs(3600); + +/// DNS cache with serve-stale (RFC 8767). Stores raw wire bytes. pub struct DnsCache { entries: HashMap>, entry_count: usize, @@ -53,6 +56,80 @@ impl DnsCache { } } + /// Look up cached wire bytes, patching ID and TTLs in the returned copy. + /// Implements serve-stale (RFC 8767): expired entries within STALE_WINDOW + /// are returned with TTL=1 and `stale=true` so callers can revalidate. + pub fn lookup_wire( + &self, + domain: &str, + qtype: QueryType, + new_id: u16, + ) -> Option<(Vec, DnssecStatus, bool)> { + let type_map = self.entries.get(domain)?; + let entry = type_map.get(&qtype)?; + + let elapsed = entry.inserted_at.elapsed(); + let (remaining, stale) = if elapsed < entry.ttl { + let secs = (entry.ttl - elapsed).as_secs() as u32; + (secs.max(1), false) + } else if elapsed < entry.ttl + STALE_WINDOW { + (1, true) + } else { + return None; + }; + + let mut wire = entry.wire.clone(); + crate::wire::patch_id(&mut wire, new_id); + crate::wire::patch_ttls(&mut wire, &entry.meta.ttl_offsets, remaining); + + Some((wire, entry.dnssec_status, stale)) + } + + pub fn insert_wire( + &mut self, + domain: &str, + qtype: QueryType, + wire: &[u8], + dnssec_status: DnssecStatus, + ) { + let meta = match crate::wire::scan_ttl_offsets(wire) { + Ok(m) => m, + Err(_) => return, // malformed wire, skip + }; + + if self.entry_count >= self.max_entries { + self.evict_expired(); + if self.entry_count >= self.max_entries { + return; + } + } + + let min_ttl = crate::wire::min_ttl_from_wire(wire, &meta) + .unwrap_or(self.min_ttl) + .clamp(self.min_ttl, self.max_ttl); + + let type_map = if let Some(existing) = self.entries.get_mut(domain) { + existing + } else { + self.entries.entry(domain.to_string()).or_default() + }; + + if !type_map.contains_key(&qtype) { + self.entry_count += 1; + } + + type_map.insert( + qtype, + CacheEntry { + wire: wire.to_vec(), + meta, + inserted_at: Instant::now(), + ttl: Duration::from_secs(min_ttl as u64), + dnssec_status, + }, + ); + } + /// Read-only lookup — expired entries are left in place (cleaned up on insert). pub fn lookup(&self, domain: &str, qtype: QueryType) -> Option { self.lookup_with_status(domain, qtype).map(|(pkt, _)| pkt) @@ -63,23 +140,28 @@ impl DnsCache { domain: &str, qtype: QueryType, ) -> Option<(DnsPacket, DnssecStatus)> { - let type_map = self.entries.get(domain)?; - let entry = type_map.get(&qtype)?; + let (wire, status, _stale) = self.lookup_wire(domain, qtype, 0)?; + let mut buf = BytePacketBuffer::from_bytes(&wire); + let pkt = DnsPacket::from_buffer(&mut buf).ok()?; + Some((pkt, status)) + } - let elapsed = entry.inserted_at.elapsed(); - if elapsed >= entry.ttl { - return None; + pub fn insert(&mut self, domain: &str, qtype: QueryType, packet: &DnsPacket) { + self.insert_with_status(domain, qtype, packet, DnssecStatus::Indeterminate); + } + + pub fn insert_with_status( + &mut self, + domain: &str, + qtype: QueryType, + packet: &DnsPacket, + dnssec_status: DnssecStatus, + ) { + let mut buf = BytePacketBuffer::new(); + if packet.write(&mut buf).is_err() { + return; } - - let remaining_secs = (entry.ttl - elapsed).as_secs() as u32; - let remaining = remaining_secs.max(1); - - let mut packet = entry.packet.clone(); - adjust_ttls(&mut packet.answers, remaining); - adjust_ttls(&mut packet.authorities, remaining); - adjust_ttls(&mut packet.resources, remaining); - - Some((packet, entry.dnssec_status)) + self.insert_wire(domain, qtype, buf.filled(), dnssec_status); } pub fn ttl_remaining(&self, domain: &str, qtype: QueryType) -> Option<(u32, u32)> { @@ -105,49 +187,6 @@ impl DnsCache { false } - pub fn insert(&mut self, domain: &str, qtype: QueryType, packet: &DnsPacket) { - self.insert_with_status(domain, qtype, packet, DnssecStatus::Indeterminate); - } - - pub fn insert_with_status( - &mut self, - domain: &str, - qtype: QueryType, - packet: &DnsPacket, - dnssec_status: DnssecStatus, - ) { - if self.entry_count >= self.max_entries { - self.evict_expired(); - if self.entry_count >= self.max_entries { - return; - } - } - - let min_ttl = extract_min_ttl(&packet.answers) - .unwrap_or(self.min_ttl) - .clamp(self.min_ttl, self.max_ttl); - - let type_map = if let Some(existing) = self.entries.get_mut(domain) { - existing - } else { - self.entries.entry(domain.to_string()).or_default() - }; - - if !type_map.contains_key(&qtype) { - self.entry_count += 1; - } - - type_map.insert( - qtype, - CacheEntry { - packet: packet.clone(), - inserted_at: Instant::now(), - ttl: Duration::from_secs(min_ttl as u64), - dnssec_status, - }, - ); - } - pub fn len(&self) -> usize { self.entry_count } @@ -179,7 +218,8 @@ impl DnsCache { + 1; total += type_map.capacity() * inner_slot; for entry in type_map.values() { - total += entry.packet.heap_bytes(); + total += entry.wire.capacity() + + entry.meta.ttl_offsets.capacity() * std::mem::size_of::(); } } total @@ -228,20 +268,11 @@ pub struct CacheInfo { pub ttl_remaining: u32, } -fn extract_min_ttl(records: &[DnsRecord]) -> Option { - records.iter().map(|r| r.ttl()).min() -} - -fn adjust_ttls(records: &mut [DnsRecord], new_ttl: u32) { - for record in records.iter_mut() { - record.set_ttl(new_ttl); - } -} - #[cfg(test)] mod tests { use super::*; use crate::packet::DnsPacket; + use crate::record::DnsRecord; #[test] fn heap_bytes_grows_with_entries() { diff --git a/src/config.rs b/src/config.rs index ae9f685..5f9db73 100644 --- a/src/config.rs +++ b/src/config.rs @@ -138,6 +138,8 @@ pub struct UpstreamConfig { pub fallback: Vec, #[serde(default = "default_timeout_ms")] pub timeout_ms: u64, + #[serde(default = "default_hedge_ms")] + pub hedge_ms: u64, #[serde(default = "default_root_hints")] pub root_hints: Vec, #[serde(default = "default_prime_tlds")] @@ -154,6 +156,7 @@ impl Default for UpstreamConfig { port: default_upstream_port(), fallback: Vec::new(), timeout_ms: default_timeout_ms(), + hedge_ms: default_hedge_ms(), root_hints: default_root_hints(), prime_tlds: default_prime_tlds(), srtt: default_srtt(), @@ -271,6 +274,9 @@ fn default_upstream_port() -> u16 { fn default_timeout_ms() -> u64 { 5000 } +fn default_hedge_ms() -> u64 { + 10 +} #[derive(Deserialize)] pub struct CacheConfig { diff --git a/src/ctx.rs b/src/ctx.rs index 3ef6a0a..2b26a06 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -16,7 +16,9 @@ use crate::blocklist::BlocklistStore; use crate::buffer::BytePacketBuffer; use crate::cache::{DnsCache, DnssecStatus}; use crate::config::{UpstreamMode, ZoneMap}; -use crate::forward::{forward_query, forward_with_failover, Upstream, UpstreamPool}; +use crate::forward::{ + forward_query_raw, forward_with_failover_raw, Upstream, UpstreamPool, +}; use crate::header::ResultCode; use crate::health::HealthMeta; use crate::lan::PeerStore; @@ -47,6 +49,7 @@ pub struct ServerCtx { pub upstream_port: u16, pub lan_ip: Mutex, pub timeout: Duration, + pub hedge_delay: Duration, pub proxy_tld: String, pub proxy_tld_suffix: String, // pre-computed ".{tld}" to avoid per-query allocation pub lan_enabled: bool, @@ -81,6 +84,7 @@ pub struct ServerCtx { /// (and logging parse errors) before calling this function. pub async fn resolve_query( query: DnsPacket, + raw_wire: &[u8], src_addr: SocketAddr, ctx: &ServerCtx, ) -> crate::Result { @@ -177,9 +181,8 @@ pub async fn resolve_query( // Conditional forwarding takes priority over recursive mode // (e.g. Tailscale .ts.net, VPC private zones) let upstream = Upstream::Udp(fwd_addr); - match forward_query(&query, &upstream, ctx.timeout).await { + match forward_and_cache(raw_wire, &upstream, ctx, &qname, qtype).await { Ok(resp) => { - ctx.cache.write().unwrap().insert(&qname, qtype, &resp); (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate) } Err(e) => { @@ -221,10 +224,19 @@ pub async fn resolve_query( (resp, path, DnssecStatus::Indeterminate) } else { let pool = ctx.upstream_pool.lock().unwrap().clone(); - match forward_with_failover(&query, &pool, &ctx.srtt, ctx.timeout).await { - Ok(resp) => { - ctx.cache.write().unwrap().insert(&qname, qtype, &resp); - (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate) + match forward_with_failover_raw(raw_wire, &pool, &ctx.srtt, ctx.timeout, ctx.hedge_delay).await { + Ok(resp_wire) => { + ctx.cache.write().unwrap().insert_wire( + &qname, qtype, &resp_wire, DnssecStatus::Indeterminate, + ); + let mut buf = BytePacketBuffer::from_bytes(&resp_wire); + match DnsPacket::from_buffer(&mut buf) { + Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate), + Err(e) => { + error!("{} | {:?} {} | PARSE ERROR | {}", src_addr, qtype, qname, e); + (DnsPacket::response_from(&query, ResultCode::SERVFAIL), QueryPath::UpstreamError, DnssecStatus::Indeterminate) + } + } } Err(e) => { error!( @@ -347,12 +359,29 @@ pub async fn resolve_query( Ok(resp_buffer) } -/// Handle a DNS query received over UDP. Thin wrapper around resolve_query. +async fn forward_and_cache( + wire: &[u8], + upstream: &Upstream, + ctx: &ServerCtx, + qname: &str, + qtype: QueryType, +) -> crate::Result { + let resp_wire = forward_query_raw(wire, upstream, ctx.timeout).await?; + ctx.cache + .write() + .unwrap() + .insert_wire(qname, qtype, &resp_wire, DnssecStatus::Indeterminate); + let mut buf = BytePacketBuffer::from_bytes(&resp_wire); + DnsPacket::from_buffer(&mut buf) +} + pub async fn handle_query( mut buffer: BytePacketBuffer, + raw_len: usize, src_addr: SocketAddr, ctx: &ServerCtx, ) -> crate::Result<()> { + let raw_wire = buffer.buf[..raw_len].to_vec(); let query = match DnsPacket::from_buffer(&mut buffer) { Ok(packet) => packet, Err(e) => { @@ -360,7 +389,7 @@ pub async fn handle_query( return Ok(()); } }; - match resolve_query(query, src_addr, ctx).await { + match resolve_query(query, &raw_wire, src_addr, ctx).await { Ok(resp_buffer) => { ctx.socket.send_to(resp_buffer.filled(), src_addr).await?; } diff --git a/src/doh.rs b/src/doh.rs index cf50b31..e31b6fe 100644 --- a/src/doh.rs +++ b/src/doh.rs @@ -82,7 +82,7 @@ async fn resolve_doh(dns_bytes: &[u8], src: SocketAddr, ctx: &ServerCtx) -> Resp let query_rd = query.header.recursion_desired; let questions = query.questions.clone(); - match resolve_query(query, src, ctx).await { + match resolve_query(query, dns_bytes, src, ctx).await { Ok(resp_buffer) => { let min_ttl = extract_min_ttl(resp_buffer.filled()); dns_response(resp_buffer.filled(), min_ttl) @@ -102,11 +102,10 @@ async fn resolve_doh(dns_bytes: &[u8], src: SocketAddr, ctx: &ServerCtx) -> Resp } fn extract_min_ttl(wire: &[u8]) -> u32 { - let mut buf = BytePacketBuffer::from_bytes(wire); - match DnsPacket::from_buffer(&mut buf) { - Ok(pkt) => pkt.answers.iter().map(|r| r.ttl()).min().unwrap_or(0), - Err(_) => 0, - } + crate::wire::scan_ttl_offsets(wire) + .ok() + .and_then(|meta| crate::wire::min_ttl_from_wire(wire, &meta)) + .unwrap_or(0) } fn dns_response(wire: &[u8], min_ttl: u32) -> Response { diff --git a/src/dot.rs b/src/dot.rs index 0d48fa2..4513f60 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -177,8 +177,7 @@ where break; }; - // Parse query up-front so we can echo its question section in SERVFAIL - // responses when resolve_query fails. + let raw_wire = buffer.buf[..msg_len].to_vec(); let query = match DnsPacket::from_buffer(&mut buffer) { Ok(q) => q, Err(e) => { @@ -200,7 +199,7 @@ where } }; - match resolve_query(query.clone(), remote_addr, ctx).await { + match resolve_query(query.clone(), &raw_wire, remote_addr, ctx).await { Ok(resp_buffer) => { if write_framed(&mut stream, resp_buffer.filled()) .await @@ -370,6 +369,7 @@ mod tests { upstream_port: 53, lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST), timeout: Duration::from_millis(200), + hedge_delay: Duration::ZERO, proxy_tld: "numa".to_string(), proxy_tld_suffix: ".numa".to_string(), lan_enabled: false, diff --git a/src/forward.rs b/src/forward.rs index ea2f1e2..401ae1c 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -65,6 +65,13 @@ pub fn parse_upstream(s: &str, default_port: u16) -> Result { if s.starts_with("https://") { let client = reqwest::Client::builder() .use_rustls_tls() + .http2_initial_stream_window_size(65_535) + .http2_initial_connection_window_size(65_535) + .http2_keep_alive_interval(Duration::from_secs(15)) + .http2_keep_alive_while_idle(true) + .http2_keep_alive_timeout(Duration::from_secs(10)) + .pool_idle_timeout(Duration::from_secs(300)) + .pool_max_idle_per_host(1) .build() .unwrap_or_default(); return Ok(Upstream::Doh { @@ -325,13 +332,170 @@ async fn forward_doh( let mut send_buffer = BytePacketBuffer::new(); query.write(&mut send_buffer)?; + let resp_bytes = forward_doh_raw(send_buffer.filled(), url, client, timeout_duration).await?; + let mut recv_buffer = BytePacketBuffer::from_bytes(&resp_bytes); + DnsPacket::from_buffer(&mut recv_buffer) +} + +pub async fn forward_query_raw( + wire: &[u8], + upstream: &Upstream, + timeout_duration: Duration, +) -> Result> { + match upstream { + Upstream::Udp(addr) => forward_udp_raw(wire, *addr, timeout_duration).await, + Upstream::Doh { url, client } => forward_doh_raw(wire, url, client, timeout_duration).await, + } +} + +pub async fn forward_with_hedging_raw( + wire: &[u8], + primary: &Upstream, + secondary: &Upstream, + hedge_delay: Duration, + timeout_duration: Duration, +) -> Result> { + use tokio::time::sleep; + + let primary_fut = forward_query_raw(wire, primary, timeout_duration); + tokio::pin!(primary_fut); + + let delay = sleep(hedge_delay); + tokio::pin!(delay); + + // Phase 1: wait for either primary to return, or the hedge delay. + tokio::select! { + result = &mut primary_fut => return result, + _ = &mut delay => {} + } + + // Phase 2: hedge delay expired — fire secondary while still polling primary. + let secondary_fut = forward_query_raw(wire, secondary, timeout_duration); + tokio::pin!(secondary_fut); + + // First successful response wins. If one errors, wait for the other. + let mut primary_err: Option = None; + let mut secondary_err: Option = None; + + loop { + tokio::select! { + r = &mut primary_fut, if primary_err.is_none() => { + match r { + Ok(resp) => return Ok(resp), + Err(e) => { + if let Some(se) = secondary_err.take() { + return Err(se); + } + primary_err = Some(e); + } + } + } + r = &mut secondary_fut, if secondary_err.is_none() => { + match r { + Ok(resp) => return Ok(resp), + Err(e) => { + if let Some(pe) = primary_err.take() { + return Err(pe); + } + secondary_err = Some(e); + } + } + } + } + + match (primary_err, secondary_err) { + (Some(pe), Some(_)) => return Err(pe), + (pe, se) => { primary_err = pe; secondary_err = se; } + } + } +} + +pub async fn forward_with_failover_raw( + wire: &[u8], + pool: &UpstreamPool, + srtt: &RwLock, + timeout_duration: Duration, + hedge_delay: Duration, +) -> Result> { + let mut candidates: Vec<(usize, u64)> = pool + .primary + .iter() + .enumerate() + .map(|(i, u)| { + let rtt = match u { + Upstream::Udp(addr) => srtt.read().unwrap().get(addr.ip()), + _ => 0, + }; + (i, rtt) + }) + .collect(); + candidates.sort_by_key(|&(_, rtt)| rtt); + + let all_upstreams: Vec<&Upstream> = candidates + .iter() + .map(|&(i, _)| &pool.primary[i]) + .chain(pool.fallback.iter()) + .collect(); + + let mut last_err: Option> = None; + + for upstream in &all_upstreams { + let start = Instant::now(); + let result = if !hedge_delay.is_zero() && matches!(upstream, Upstream::Doh { .. }) { + // Hedge against the same upstream: parallel h2 streams on same + // connection. Independent stream scheduling rescues dispatch spikes. + forward_with_hedging_raw(wire, upstream, upstream, hedge_delay, timeout_duration).await + } else { + forward_query_raw(wire, upstream, timeout_duration).await + }; + match result { + Ok(resp) => { + if let Upstream::Udp(addr) = upstream { + let rtt_ms = start.elapsed().as_millis() as u64; + srtt.write().unwrap().record_rtt(addr.ip(), rtt_ms, false); + } + return Ok(resp); + } + Err(e) => { + if let Upstream::Udp(addr) = upstream { + srtt.write().unwrap().record_failure(addr.ip()); + } + log::debug!("upstream {} failed: {}", upstream, e); + last_err = Some(e); + } + } + } + + Err(last_err.unwrap_or_else(|| "no upstream configured".into())) +} + +async fn forward_udp_raw( + wire: &[u8], + upstream: SocketAddr, + timeout_duration: Duration, +) -> Result> { + let socket = UdpSocket::bind("0.0.0.0:0").await?; + socket.send_to(wire, upstream).await?; + + let mut recv_buf = vec![0u8; 4096]; + let (size, _) = timeout(timeout_duration, socket.recv_from(&mut recv_buf)).await??; + recv_buf.truncate(size); + Ok(recv_buf) +} + +async fn forward_doh_raw( + wire: &[u8], + url: &str, + client: &reqwest::Client, + timeout_duration: Duration, +) -> Result> { let resp = timeout( timeout_duration, client .post(url) .header("content-type", "application/dns-message") .header("accept", "application/dns-message") - .body(send_buffer.filled().to_vec()) + .body(wire.to_vec()) .send(), ) .await?? @@ -339,9 +503,25 @@ async fn forward_doh( let bytes = resp.bytes().await?; log::debug!("DoH response: {} bytes", bytes.len()); + Ok(bytes.to_vec()) +} - let mut recv_buffer = BytePacketBuffer::from_bytes(&bytes); - DnsPacket::from_buffer(&mut recv_buffer) +/// Send a lightweight keepalive query to a DoH upstream to prevent +/// the HTTP/2 + TLS connection from going idle and being torn down. +pub async fn keepalive_doh(upstream: &Upstream) { + if let Upstream::Doh { url, client } = upstream { + // Query for . NS — minimal, always succeeds, response is small + let wire: &[u8] = &[ + 0x00, 0x00, // ID + 0x01, 0x00, // flags: RD=1 + 0x00, 0x01, // QDCOUNT=1 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // AN=0, NS=0, AR=0 + 0x00, // root name (.) + 0x00, 0x02, // type NS + 0x00, 0x01, // class IN + ]; + let _ = forward_doh_raw(wire, url, client, Duration::from_secs(5)).await; + } } #[cfg(test)] diff --git a/src/lib.rs b/src/lib.rs index 4074020..92a0b00 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,7 @@ pub mod srtt; pub mod stats; pub mod system_dns; pub mod tls; +pub mod wire; pub type Error = Box; pub type Result = std::result::Result; diff --git a/src/main.rs b/src/main.rs index 7592186..0211a59 100644 --- a/src/main.rs +++ b/src/main.rs @@ -297,6 +297,7 @@ async fn main() -> numa::Result<()> { upstream_port: config.upstream.port, lan_ip: Mutex::new(numa::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)), timeout: Duration::from_millis(config.upstream.timeout_ms), + hedge_delay: Duration::from_millis(config.upstream.hedge_ms), proxy_tld_suffix: if config.proxy.tld.is_empty() { String::new() } else { @@ -511,6 +512,14 @@ async fn main() -> numa::Result<()> { }); } + // Spawn DoH connection keepalive — prevents idle TLS teardown + { + let keepalive_ctx = Arc::clone(&ctx); + tokio::spawn(async move { + doh_keepalive_loop(keepalive_ctx).await; + }); + } + // Spawn HTTP API server let api_ctx = Arc::clone(&ctx); let api_addr: SocketAddr = format!("{}:{}", config.server.api_bind_addr, api_port).parse()?; @@ -590,7 +599,7 @@ async fn main() -> numa::Result<()> { #[allow(clippy::infinite_loop)] loop { let mut buffer = BytePacketBuffer::new(); - let (_, src_addr) = match ctx.socket.recv_from(&mut buffer.buf).await { + let (len, src_addr) = match ctx.socket.recv_from(&mut buffer.buf).await { Ok(r) => r, Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => { // Windows delivers ICMP port-unreachable as ConnectionReset on UDP sockets @@ -598,10 +607,11 @@ async fn main() -> numa::Result<()> { } Err(e) => return Err(e.into()), }; + let raw_len = len; let ctx = Arc::clone(&ctx); tokio::spawn(async move { - if let Err(e) = handle_query(buffer, src_addr, &ctx).await { + if let Err(e) = handle_query(buffer, raw_len, src_addr, &ctx).await { error!("{} | HANDLER ERROR | {}", src_addr, e); } }); @@ -777,6 +787,18 @@ async fn warm_domain(ctx: &ServerCtx, domain: &str) { } } +async fn doh_keepalive_loop(ctx: Arc) { + let mut interval = tokio::time::interval(Duration::from_secs(25)); + interval.tick().await; // skip first immediate tick + loop { + interval.tick().await; + let pool = ctx.upstream_pool.lock().unwrap().clone(); + if let Some(upstream) = pool.preferred() { + numa::forward::keepalive_doh(upstream).await; + } + } +} + async fn cache_warm_loop(ctx: Arc, domains: Vec) { tokio::time::sleep(Duration::from_secs(2)).await; diff --git a/src/recursive.rs b/src/recursive.rs index 24d0367..2609f7f 100644 --- a/src/recursive.rs +++ b/src/recursive.rs @@ -202,23 +202,22 @@ pub(crate) fn resolve_iterative<'a>( let mut ns_idx = 0; for _ in 0..MAX_REFERRAL_DEPTH { - let ns_addr = match ns_addrs.get(ns_idx) { - Some(addr) => *addr, - None => return Err("no nameserver available".into()), - }; + if ns_idx >= ns_addrs.len() { + return Err("no nameserver available".into()); + } let (q_name, q_type) = minimize_query(qname, qtype, ¤t_zone); debug!( - "recursive: querying {} for {:?} {} (zone: {}, depth {})", - ns_addr, q_type, q_name, current_zone, referral_depth + "recursive: querying {} (+ hedge) for {:?} {} (zone: {}, depth {})", + ns_addrs[ns_idx], q_type, q_name, current_zone, referral_depth ); - let response = match send_query(q_name, q_type, ns_addr, srtt).await { + let response = match send_query_hedged(q_name, q_type, &ns_addrs[ns_idx..], srtt).await { Ok(r) => r, Err(e) => { - debug!("recursive: NS {} failed: {}", ns_addr, e); - ns_idx += 1; + debug!("recursive: NS query failed: {}", e); + ns_idx += 2; // both tried, skip past them continue; } }; @@ -228,6 +227,9 @@ pub(crate) fn resolve_iterative<'a>( { if let Some(zone) = referral_zone(&response) { current_zone = zone; + let mut cache_w = cache.write().unwrap(); + cache_ns_delegation(&mut cache_w, ¤t_zone, &response); + drop(cache_w); } let mut all_ns = extract_ns_from_records(&response.answers); if all_ns.is_empty() { @@ -296,6 +298,7 @@ pub(crate) fn resolve_iterative<'a>( { let mut cache_w = cache.write().unwrap(); + cache_ns_delegation(&mut cache_w, ¤t_zone, &response); cache_ds_from_authority(&mut cache_w, &response); } let mut new_ns_addrs = resolve_ns_addrs_from_glue(&response, &ns_names, cache); @@ -560,6 +563,23 @@ fn cache_ds_from_authority(cache: &mut DnsCache, response: &DnsPacket) { } } +/// Cache NS delegation records from a referral response so that +/// `find_closest_ns` can skip re-querying TLD servers on subsequent lookups. +fn cache_ns_delegation(cache: &mut DnsCache, zone: &str, response: &DnsPacket) { + let ns_records: Vec<_> = response + .authorities + .iter() + .filter(|r| matches!(r, DnsRecord::NS { .. })) + .cloned() + .collect(); + if ns_records.is_empty() { + return; + } + let mut pkt = make_glue_packet(); + pkt.answers = ns_records; + cache.insert(zone, QueryType::NS, &pkt); +} + fn make_glue_packet() -> DnsPacket { let mut pkt = DnsPacket::new(); pkt.header.response = true; @@ -587,6 +607,91 @@ async fn tcp_with_srtt( } } +/// Smart NS query: fire to two servers simultaneously when SRTT is unknown +/// (cold queries), or to the best server with SRTT-based hedge when known. +async fn send_query_hedged( + qname: &str, + qtype: QueryType, + servers: &[SocketAddr], + srtt: &RwLock, +) -> crate::Result { + if servers.is_empty() { + return Err("no nameserver available".into()); + } + if servers.len() == 1 { + return send_query(qname, qtype, servers[0], srtt).await; + } + + let primary = servers[0]; + let secondary = servers[1]; + let primary_known = srtt.read().unwrap().is_known(primary.ip()); + + if !primary_known { + // Cold: fire both simultaneously, first response wins + debug!( + "recursive: parallel query to {} and {} for {:?} {}", + primary, secondary, qtype, qname + ); + let fut_a = send_query(qname, qtype, primary, srtt); + let fut_b = send_query(qname, qtype, secondary, srtt); + tokio::pin!(fut_a); + tokio::pin!(fut_b); + + // First Ok wins. If one errors, wait for the other. + let mut a_done = false; + let mut b_done = false; + let mut a_err: Option = None; + let mut b_err: Option = None; + + loop { + tokio::select! { + r = &mut fut_a, if !a_done => { + match r { + Ok(resp) => return Ok(resp), + Err(e) => { a_done = true; a_err = Some(e); } + } + } + r = &mut fut_b, if !b_done => { + match r { + Ok(resp) => return Ok(resp), + Err(e) => { b_done = true; b_err = Some(e); } + } + } + } + match (a_err.take(), b_err.take()) { + (Some(e), Some(_)) => return Err(e), + (a, b) => { a_err = a; b_err = b; } + } + } + } else { + // Warm: send to best, hedge after SRTT × 3 if slow + let hedge_ms = srtt.read().unwrap().get(primary.ip()) * 3; + let hedge_delay = Duration::from_millis(hedge_ms.max(50)); + + let fut_a = send_query(qname, qtype, primary, srtt); + tokio::pin!(fut_a); + let delay = tokio::time::sleep(hedge_delay); + tokio::pin!(delay); + + tokio::select! { + r = &mut fut_a => return r, + _ = &mut delay => {} + } + + debug!( + "recursive: hedging {} -> {} after {}ms for {:?} {}", + primary, secondary, hedge_ms, qtype, qname + ); + let fut_b = send_query(qname, qtype, secondary, srtt); + tokio::pin!(fut_b); + + tokio::select! { + r = fut_a => r, + r = fut_b => r, + } + } +} + async fn send_query( qname: &str, qtype: QueryType, diff --git a/src/srtt.rs b/src/srtt.rs index f763a37..fe4df1e 100644 --- a/src/srtt.rs +++ b/src/srtt.rs @@ -45,6 +45,11 @@ impl SrttCache { } } + /// Whether we have observed RTT data for this IP. + pub fn is_known(&self, ip: IpAddr) -> bool { + self.entries.contains_key(&ip) + } + /// Apply time-based decay: each DECAY_AFTER_SECS period halves distance to INITIAL. fn decayed_srtt(entry: &SrttEntry) -> u64 { Self::decay_for_age(entry.srtt_ms, entry.updated_at.elapsed().as_secs()) diff --git a/src/wire.rs b/src/wire.rs new file mode 100644 index 0000000..6b68c3a --- /dev/null +++ b/src/wire.rs @@ -0,0 +1,1347 @@ +//! Wire-level DNS utilities: question extraction, TTL offset scanning, and patching. +//! +//! These operate directly on raw DNS wire bytes without full packet parsing, +//! enabling zero-copy forwarding and wire-level caching. + +use crate::question::QueryType; +use crate::Result; + +/// Metadata extracted from scanning a DNS response's wire bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WireMeta { + /// Byte offsets of every TTL field in answer + authority + additional sections. + /// Each offset points to the first byte of a 4-byte big-endian TTL. + /// EDNS OPT pseudo-records are excluded (their "TTL" is flags, not a real TTL). + pub ttl_offsets: Vec, + /// How many of the offsets belong to the answer section (the first `answer_count` + /// entries). Used to extract min-TTL from answers only. + pub answer_count: usize, +} + +/// Extract the first question's (domain, query type) from raw DNS wire bytes. +/// +/// Reads only the 12-byte header + first question section. Returns the lowercased +/// domain name and query type without allocating a full `DnsPacket`. +pub fn extract_question(wire: &[u8]) -> Result<(String, QueryType)> { + if wire.len() < 12 { + return Err("wire too short for DNS header".into()); + } + let qdcount = u16::from_be_bytes([wire[4], wire[5]]); + if qdcount == 0 { + return Err("no questions in wire".into()); + } + + let mut pos = 12; + let mut domain = String::with_capacity(64); + read_wire_qname(wire, &mut pos, &mut domain)?; + + if pos + 4 > wire.len() { + return Err("wire truncated in question section".into()); + } + let qtype = u16::from_be_bytes([wire[pos], wire[pos + 1]]); + // skip QTYPE(2) + QCLASS(2) + + Ok((domain, QueryType::from_num(qtype))) +} + +/// Scan a DNS response's wire bytes and return metadata about TTL field locations. +/// +/// Walks the header, skips the question section, then for each resource record in +/// answer, authority, and additional sections, records the byte offset of the TTL +/// field. EDNS OPT records (type 41 with root name) are excluded. +pub fn scan_ttl_offsets(wire: &[u8]) -> Result { + if wire.len() < 12 { + return Err("wire too short for DNS header".into()); + } + + let qdcount = u16::from_be_bytes([wire[4], wire[5]]) as usize; + let ancount = u16::from_be_bytes([wire[6], wire[7]]) as usize; + let nscount = u16::from_be_bytes([wire[8], wire[9]]) as usize; + let arcount = u16::from_be_bytes([wire[10], wire[11]]) as usize; + + let mut pos = 12; + + // Skip question section + for _ in 0..qdcount { + skip_wire_name(wire, &mut pos)?; + if pos + 4 > wire.len() { + return Err("wire truncated in question section".into()); + } + pos += 4; // QTYPE(2) + QCLASS(2) + } + + let mut ttl_offsets = Vec::new(); + + // Process answer + authority + additional sections + let section_counts = [ancount, nscount, arcount]; + let mut answer_offset_count = 0; + + for (section_idx, &count) in section_counts.iter().enumerate() { + for _ in 0..count { + // Check if this is an OPT record: root name (0x00) + type 41 + let is_opt = pos < wire.len() + && wire[pos] == 0x00 + && pos + 3 <= wire.len() + && u16::from_be_bytes([wire[pos + 1], wire[pos + 2]]) == 41; + + // Skip name + skip_wire_name(wire, &mut pos)?; + + if pos + 10 > wire.len() { + return Err("wire truncated in resource record".into()); + } + + // TYPE(2) + CLASS(2) = 4 bytes before TTL + let ttl_offset = pos + 4; + + if !is_opt { + ttl_offsets.push(ttl_offset); + if section_idx == 0 { + answer_offset_count += 1; + } + } + + // Skip TYPE(2) + CLASS(2) + TTL(4) + RDLENGTH(2) = 10 bytes + let rdlength = u16::from_be_bytes([wire[pos + 8], wire[pos + 9]]) as usize; + pos += 10 + rdlength; + + if pos > wire.len() { + return Err("wire truncated in resource record RDATA".into()); + } + } + } + + Ok(WireMeta { + ttl_offsets, + answer_count: answer_offset_count, + }) +} + +/// Extract the minimum TTL from the answer section offsets of a wire response. +pub fn min_ttl_from_wire(wire: &[u8], meta: &WireMeta) -> Option { + meta.ttl_offsets + .iter() + .take(meta.answer_count) + .filter_map(|&off| { + if off + 4 <= wire.len() { + Some(u32::from_be_bytes([ + wire[off], + wire[off + 1], + wire[off + 2], + wire[off + 3], + ])) + } else { + None + } + }) + .min() +} + +/// Patch the transaction ID (bytes 0..2) in a DNS wire message. +pub fn patch_id(wire: &mut [u8], new_id: u16) { + let bytes = new_id.to_be_bytes(); + wire[0] = bytes[0]; + wire[1] = bytes[1]; +} + +/// Patch all TTL fields at the given offsets to `new_ttl`. +pub fn patch_ttls(wire: &mut [u8], offsets: &[usize], new_ttl: u32) { + let bytes = new_ttl.to_be_bytes(); + for &off in offsets { + wire[off] = bytes[0]; + wire[off + 1] = bytes[1]; + wire[off + 2] = bytes[2]; + wire[off + 3] = bytes[3]; + } +} + +/// Read a DNS name from wire bytes at `pos`, handling compression pointers. +/// Advances `pos` past the name as it appears at the current position +/// (compression pointer targets do NOT advance `pos`). +fn read_wire_qname(wire: &[u8], pos: &mut usize, out: &mut String) -> Result<()> { + let mut jumped = false; + let mut read_pos = *pos; + let mut jumps = 0; + let max_jumps = 20; + + loop { + if read_pos >= wire.len() { + return Err("wire truncated reading name".into()); + } + let len = wire[read_pos] as usize; + + // Compression pointer: top 2 bits set + if len & 0xC0 == 0xC0 { + if read_pos + 1 >= wire.len() { + return Err("wire truncated in compression pointer".into()); + } + if !jumped { + *pos = read_pos + 2; // advance past the pointer + } + let offset = ((len & 0x3F) << 8) | wire[read_pos + 1] as usize; + read_pos = offset; + jumped = true; + jumps += 1; + if jumps > max_jumps { + return Err("too many compression jumps".into()); + } + continue; + } + + if len == 0 { + if !jumped { + *pos = read_pos + 1; + } + break; + } + + if read_pos + 1 + len > wire.len() { + return Err("wire truncated in name label".into()); + } + + if !out.is_empty() { + out.push('.'); + } + for &b in &wire[read_pos + 1..read_pos + 1 + len] { + out.push(b.to_ascii_lowercase() as char); + } + read_pos += 1 + len; + } + + Ok(()) +} + +/// Skip a DNS name in wire bytes, advancing `pos` past it. +fn skip_wire_name(wire: &[u8], pos: &mut usize) -> Result<()> { + loop { + if *pos >= wire.len() { + return Err("wire truncated skipping name".into()); + } + let len = wire[*pos] as usize; + + if len & 0xC0 == 0xC0 { + *pos += 2; // compression pointer is 2 bytes + return Ok(()); + } + if len == 0 { + *pos += 1; + return Ok(()); + } + *pos += 1 + len; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::buffer::BytePacketBuffer; + use crate::cache::{DnsCache, DnssecStatus}; + use crate::header::ResultCode; + use crate::packet::{DnsPacket, EdnsOpt}; + use crate::question::DnsQuestion; + use crate::record::DnsRecord; + + // ── Helpers ────────────────────────────────────────────────────── + + /// Serialize a DnsPacket to wire bytes. + fn to_wire(pkt: &DnsPacket) -> Vec { + let mut buf = BytePacketBuffer::new(); + pkt.write(&mut buf).unwrap(); + buf.filled().to_vec() + } + + /// Build a minimal response with given answers. + fn response(id: u16, domain: &str, answers: Vec) -> DnsPacket { + let mut pkt = DnsPacket::new(); + pkt.header.id = id; + pkt.header.response = true; + pkt.header.recursion_desired = true; + pkt.header.recursion_available = true; + pkt.header.rescode = ResultCode::NOERROR; + pkt.questions + .push(DnsQuestion::new(domain.to_string(), QueryType::A)); + pkt.answers = answers; + pkt + } + + fn a_record(domain: &str, ip: &str, ttl: u32) -> DnsRecord { + DnsRecord::A { + domain: domain.into(), + addr: ip.parse().unwrap(), + ttl, + } + } + + fn aaaa_record(domain: &str, ip: &str, ttl: u32) -> DnsRecord { + DnsRecord::AAAA { + domain: domain.into(), + addr: ip.parse().unwrap(), + ttl, + } + } + + fn cname_record(domain: &str, host: &str, ttl: u32) -> DnsRecord { + DnsRecord::CNAME { + domain: domain.into(), + host: host.into(), + ttl, + } + } + + fn ns_record(domain: &str, host: &str, ttl: u32) -> DnsRecord { + DnsRecord::NS { + domain: domain.into(), + host: host.into(), + ttl, + } + } + + fn mx_record(domain: &str, host: &str, priority: u16, ttl: u32) -> DnsRecord { + DnsRecord::MX { + domain: domain.into(), + priority, + host: host.into(), + ttl, + } + } + + // ── A. TTL offset extraction ──────────────────────────────────── + + #[test] + fn scan_single_a_record() { + let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + assert_eq!(meta.ttl_offsets.len(), 1); + assert_eq!(meta.answer_count, 1); + + let off = meta.ttl_offsets[0]; + let ttl = u32::from_be_bytes([wire[off], wire[off + 1], wire[off + 2], wire[off + 3]]); + assert_eq!(ttl, 300); + } + + #[test] + fn scan_multiple_a_records() { + let pkt = response( + 0x1234, + "example.com", + vec![ + a_record("example.com", "1.2.3.4", 300), + a_record("example.com", "5.6.7.8", 600), + a_record("example.com", "9.10.11.12", 120), + ], + ); + let wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + assert_eq!(meta.ttl_offsets.len(), 3); + assert_eq!(meta.answer_count, 3); + + let ttls: Vec = meta + .ttl_offsets + .iter() + .map(|&off| u32::from_be_bytes([wire[off], wire[off + 1], wire[off + 2], wire[off + 3]])) + .collect(); + assert_eq!(ttls, vec![300, 600, 120]); + } + + #[test] + fn scan_mixed_sections() { + let mut pkt = + response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + pkt.authorities + .push(ns_record("example.com", "ns1.example.com", 3600)); + pkt.authorities + .push(ns_record("example.com", "ns2.example.com", 3600)); + pkt.resources + .push(a_record("ns1.example.com", "10.0.0.1", 1800)); + let wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + assert_eq!(meta.ttl_offsets.len(), 4); // 1 answer + 2 authority + 1 additional + assert_eq!(meta.answer_count, 1); + } + + #[test] + fn scan_cname_chain() { + let pkt = response( + 0x1234, + "www.example.com", + vec![ + cname_record("www.example.com", "example.com", 300), + a_record("example.com", "1.2.3.4", 600), + ], + ); + let wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + assert_eq!(meta.ttl_offsets.len(), 2); + assert_eq!(meta.answer_count, 2); + + let ttls: Vec = meta + .ttl_offsets + .iter() + .map(|&off| u32::from_be_bytes([wire[off], wire[off + 1], wire[off + 2], wire[off + 3]])) + .collect(); + assert_eq!(ttls, vec![300, 600]); + } + + #[test] + fn scan_compressed_names() { + // Build a packet with name compression (the serializer uses compression + // for repeated domain names). Two A records for the same domain will + // have the second name compressed as a pointer. + let pkt = response( + 0x1234, + "example.com", + vec![ + a_record("example.com", "1.2.3.4", 300), + a_record("example.com", "5.6.7.8", 600), + ], + ); + let wire = to_wire(&pkt); + + // Verify compression is actually present (second name should be a pointer) + // The first answer's name is at some offset, and the second should use 0xC0xx + let meta = scan_ttl_offsets(&wire).unwrap(); + assert_eq!(meta.ttl_offsets.len(), 2); + + let ttls: Vec = meta + .ttl_offsets + .iter() + .map(|&off| u32::from_be_bytes([wire[off], wire[off + 1], wire[off + 2], wire[off + 3]])) + .collect(); + assert_eq!(ttls, vec![300, 600]); + } + + #[test] + fn scan_edns_opt_excluded() { + let mut pkt = + response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + pkt.edns = Some(EdnsOpt { + udp_payload_size: 1232, + extended_rcode: 0, + version: 0, + do_bit: false, + options: vec![], + }); + let wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + // Only the A record's TTL, not the OPT pseudo-record's "TTL" + assert_eq!(meta.ttl_offsets.len(), 1); + assert_eq!(meta.answer_count, 1); + } + + #[test] + fn scan_rrsig_only_wire_ttl() { + let mut pkt = + response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + pkt.answers.push(DnsRecord::RRSIG { + domain: "example.com".into(), + type_covered: 1, // A + algorithm: 13, + labels: 2, + original_ttl: 9999, // must NOT appear in offsets + expiration: 1700000000, + inception: 1690000000, + key_tag: 12345, + signer_name: "example.com".into(), + signature: vec![0x01, 0x02, 0x03, 0x04], + ttl: 300, + }); + let wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + // 2 TTL offsets: A record + RRSIG wire TTL + assert_eq!(meta.ttl_offsets.len(), 2); + assert_eq!(meta.answer_count, 2); + + // Both wire TTLs should be 300, not 9999 + for &off in &meta.ttl_offsets { + let ttl = + u32::from_be_bytes([wire[off], wire[off + 1], wire[off + 2], wire[off + 3]]); + assert_eq!(ttl, 300); + } + + // Verify that 9999 (original_ttl) exists somewhere in the wire but is NOT in offsets + let original_ttl_bytes = 9999u32.to_be_bytes(); + let found_at = wire + .windows(4) + .position(|w| w == original_ttl_bytes) + .expect("original_ttl should be in wire"); + assert!( + !meta.ttl_offsets.contains(&found_at), + "original_ttl offset must not be in ttl_offsets" + ); + } + + #[test] + fn scan_nsec_variable_rdata() { + let mut pkt = + response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + pkt.authorities.push(DnsRecord::NSEC { + domain: "example.com".into(), + next_domain: "z.example.com".into(), + type_bitmap: vec![0x00, 0x06, 0x40, 0x01, 0x00, 0x00, 0x00, 0x03], + ttl: 1800, + }); + let wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + assert_eq!(meta.ttl_offsets.len(), 2); // A + NSEC + assert_eq!(meta.answer_count, 1); + + let nsec_ttl_off = meta.ttl_offsets[1]; + let ttl = u32::from_be_bytes([ + wire[nsec_ttl_off], + wire[nsec_ttl_off + 1], + wire[nsec_ttl_off + 2], + wire[nsec_ttl_off + 3], + ]); + assert_eq!(ttl, 1800); + } + + #[test] + fn scan_empty_response() { + let pkt = response(0x1234, "nxdomain.example.com", vec![]); + let wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + assert!(meta.ttl_offsets.is_empty()); + assert_eq!(meta.answer_count, 0); + } + + #[test] + fn scan_unknown_record_type() { + // Manually build a response with an unknown type (99) using raw wire bytes + let mut pkt = response(0x1234, "example.com", vec![]); + pkt.answers.push(DnsRecord::UNKNOWN { + domain: "example.com".into(), + qtype: 99, + data: vec![0xDE, 0xAD, 0xBE, 0xEF], + ttl: 500, + }); + let wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + assert_eq!(meta.ttl_offsets.len(), 1); + let off = meta.ttl_offsets[0]; + let ttl = u32::from_be_bytes([wire[off], wire[off + 1], wire[off + 2], wire[off + 3]]); + assert_eq!(ttl, 500); + } + + #[test] + fn scan_truncated_wire_returns_error() { + let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let wire = to_wire(&pkt); + // Truncate mid-record + let truncated = &wire[..wire.len() - 2]; + assert!(scan_ttl_offsets(truncated).is_err()); + } + + #[test] + fn scan_too_short_for_header() { + assert!(scan_ttl_offsets(&[0u8; 5]).is_err()); + } + + #[test] + fn scan_query_packet_no_offsets() { + let pkt = DnsPacket::query(0x1234, "example.com", QueryType::A); + let wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + assert!(meta.ttl_offsets.is_empty()); + } + + // ── B. TTL patching ───────────────────────────────────────────── + + #[test] + fn patch_ttl_single() { + let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let mut wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + patch_ttls(&mut wire, &meta.ttl_offsets, 120); + + let off = meta.ttl_offsets[0]; + assert_eq!( + u32::from_be_bytes([wire[off], wire[off + 1], wire[off + 2], wire[off + 3]]), + 120 + ); + } + + #[test] + fn patch_ttl_multiple() { + let pkt = response( + 0x1234, + "example.com", + vec![ + a_record("example.com", "1.2.3.4", 300), + a_record("example.com", "5.6.7.8", 600), + a_record("example.com", "9.10.11.12", 900), + ], + ); + let mut wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + patch_ttls(&mut wire, &meta.ttl_offsets, 42); + + for &off in &meta.ttl_offsets { + assert_eq!( + u32::from_be_bytes([wire[off], wire[off + 1], wire[off + 2], wire[off + 3]]), + 42 + ); + } + } + + #[test] + fn patch_ttl_preserves_other_bytes() { + let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let original = to_wire(&pkt); + let mut patched = original.clone(); + let meta = scan_ttl_offsets(&patched).unwrap(); + + patch_ttls(&mut patched, &meta.ttl_offsets, 120); + + // Every byte outside TTL offsets should be identical + for (i, (&orig, &patc)) in original.iter().zip(patched.iter()).enumerate() { + let in_ttl = meta + .ttl_offsets + .iter() + .any(|&off| i >= off && i < off + 4); + if !in_ttl { + assert_eq!( + orig, patc, + "byte {} changed (outside TTL): orig={:#04x}, patched={:#04x}", + i, orig, patc + ); + } + } + } + + #[test] + fn patch_ttl_zero() { + let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let mut wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + patch_ttls(&mut wire, &meta.ttl_offsets, 0); + + let off = meta.ttl_offsets[0]; + assert_eq!(&wire[off..off + 4], &[0, 0, 0, 0]); + } + + #[test] + fn patch_ttl_max_u32() { + let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let mut wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + patch_ttls(&mut wire, &meta.ttl_offsets, u32::MAX); + + let off = meta.ttl_offsets[0]; + assert_eq!(&wire[off..off + 4], &[0xFF, 0xFF, 0xFF, 0xFF]); + } + + #[test] + fn patch_ttl_edns_untouched() { + let mut pkt = + response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + pkt.edns = Some(EdnsOpt { + udp_payload_size: 1232, + extended_rcode: 0, + version: 0, + do_bit: true, + options: vec![], + }); + let original = to_wire(&pkt); + let mut patched = original.clone(); + let meta = scan_ttl_offsets(&patched).unwrap(); + + patch_ttls(&mut patched, &meta.ttl_offsets, 42); + + // Only the A record's TTL bytes should differ; everything else + // (including the OPT "TTL" containing the DO bit) must be unchanged. + for (i, (&orig, &patc)) in original.iter().zip(patched.iter()).enumerate() { + let in_ttl = meta + .ttl_offsets + .iter() + .any(|&off| i >= off && i < off + 4); + if !in_ttl { + assert_eq!( + orig, patc, + "byte {} changed (outside TTL): orig={:#04x}, patched={:#04x}", + i, orig, patc + ); + } + } + } + + // ── C. ID patching ────────────────────────────────────────────── + + #[test] + fn patch_id_basic() { + let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let mut wire = to_wire(&pkt); + + patch_id(&mut wire, 0xABCD); + assert_eq!(&wire[0..2], &[0xAB, 0xCD]); + } + + #[test] + fn patch_id_preserves_flags() { + let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let original = to_wire(&pkt); + let mut patched = original.clone(); + + patch_id(&mut patched, 0x9999); + + // Bytes 2..12 (flags + counts) unchanged + assert_eq!(&original[2..12], &patched[2..12]); + } + + #[test] + fn patch_id_zero() { + let pkt = response(0xFFFF, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let mut wire = to_wire(&pkt); + + patch_id(&mut wire, 0x0000); + assert_eq!(&wire[0..2], &[0x00, 0x00]); + } + + // ── D. extract_question ───────────────────────────────────────── + + #[test] + fn extract_question_basic() { + let pkt = DnsPacket::query(0x1234, "Example.COM", QueryType::A); + let wire = to_wire(&pkt); + let (domain, qtype) = extract_question(&wire).unwrap(); + + assert_eq!(domain, "example.com"); // lowercased + assert_eq!(qtype, QueryType::A); + } + + #[test] + fn extract_question_aaaa() { + let pkt = DnsPacket::query(0x1234, "rust-lang.org", QueryType::AAAA); + let wire = to_wire(&pkt); + let (domain, qtype) = extract_question(&wire).unwrap(); + + assert_eq!(domain, "rust-lang.org"); + assert_eq!(qtype, QueryType::AAAA); + } + + #[test] + fn extract_question_too_short() { + assert!(extract_question(&[0u8; 5]).is_err()); + } + + #[test] + fn extract_question_no_questions() { + let mut wire = to_wire(&DnsPacket::query(0x1234, "example.com", QueryType::A)); + // Zero out QDCOUNT (bytes 4-5) + wire[4] = 0; + wire[5] = 0; + assert!(extract_question(&wire).is_err()); + } + + // ── E. min_ttl_from_wire ──────────────────────────────────────── + + #[test] + fn min_ttl_answers_only() { + let mut pkt = response( + 0x1234, + "example.com", + vec![ + a_record("example.com", "1.2.3.4", 300), + a_record("example.com", "5.6.7.8", 60), + ], + ); + pkt.authorities + .push(ns_record("example.com", "ns1.example.com", 10)); // lower but in authority, not answer + let wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + assert_eq!(min_ttl_from_wire(&wire, &meta), Some(60)); // from answers only + } + + #[test] + fn min_ttl_empty_answers() { + let pkt = response(0x1234, "example.com", vec![]); + let wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + assert_eq!(min_ttl_from_wire(&wire, &meta), None); + } + + // ── F. Round-trip fidelity ────────────────────────────────────── + // + // These verify that wire bytes → scan → patch → parse produces the + // same semantic content as the original packet. They test the full + // integration path that the wire-level cache will use. + + #[test] + fn round_trip_simple_a() { + let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + let mut patched = wire.clone(); + patch_id(&mut patched, 0xABCD); + patch_ttls(&mut patched, &meta.ttl_offsets, 120); + + // Parse the patched wire + let mut buf = BytePacketBuffer::from_bytes(&patched); + let parsed = DnsPacket::from_buffer(&mut buf).unwrap(); + + assert_eq!(parsed.header.id, 0xABCD); + assert_eq!(parsed.answers.len(), 1); + match &parsed.answers[0] { + DnsRecord::A { domain, addr, ttl } => { + assert_eq!(domain, "example.com"); + assert_eq!(*addr, "1.2.3.4".parse::().unwrap()); + assert_eq!(*ttl, 120); + } + other => panic!("expected A record, got {:?}", other), + } + } + + #[test] + fn round_trip_edns_survives() { + let mut pkt = + response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + pkt.edns = Some(EdnsOpt { + udp_payload_size: 1232, + extended_rcode: 0, + version: 0, + do_bit: true, + options: vec![], + }); + let wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + let mut patched = wire.clone(); + patch_ttls(&mut patched, &meta.ttl_offsets, 42); + + let mut buf = BytePacketBuffer::from_bytes(&patched); + let parsed = DnsPacket::from_buffer(&mut buf).unwrap(); + + let edns = parsed.edns.as_ref().expect("EDNS should survive"); + assert_eq!(edns.udp_payload_size, 1232); + assert!(edns.do_bit); + } + + #[test] + fn round_trip_dnssec_full() { + let mut pkt = response( + 0x1234, + "example.com", + vec![ + a_record("example.com", "1.2.3.4", 300), + DnsRecord::RRSIG { + domain: "example.com".into(), + type_covered: 1, + algorithm: 13, + labels: 2, + original_ttl: 300, + expiration: 1700000000, + inception: 1690000000, + key_tag: 12345, + signer_name: "example.com".into(), + signature: vec![1, 2, 3, 4, 5, 6, 7, 8], + ttl: 300, + }, + ], + ); + pkt.authorities.push(DnsRecord::NSEC { + domain: "example.com".into(), + next_domain: "z.example.com".into(), + type_bitmap: vec![0x00, 0x06, 0x40, 0x01, 0x00, 0x00, 0x00, 0x03], + ttl: 300, + }); + pkt.resources.push(DnsRecord::DNSKEY { + domain: "example.com".into(), + flags: 257, + protocol: 3, + algorithm: 13, + public_key: vec![10, 20, 30, 40], + ttl: 3600, + }); + pkt.edns = Some(EdnsOpt { + udp_payload_size: 1232, + extended_rcode: 0, + version: 0, + do_bit: true, + options: vec![], + }); + let wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + // 4 TTL offsets: A + RRSIG (answers) + NSEC (authority) + DNSKEY (additional) + // OPT excluded + assert_eq!(meta.ttl_offsets.len(), 4); + assert_eq!(meta.answer_count, 2); + + let mut patched = wire.clone(); + patch_ttls(&mut patched, &meta.ttl_offsets, 42); + + let mut buf = BytePacketBuffer::from_bytes(&patched); + let parsed = DnsPacket::from_buffer(&mut buf).unwrap(); + + assert_eq!(parsed.answers.len(), 2); + assert_eq!(parsed.authorities.len(), 1); + assert_eq!(parsed.resources.len(), 1); + assert!(parsed.edns.is_some()); + + // All TTLs should be 42 now + for ans in &parsed.answers { + assert_eq!(ans.ttl(), 42); + } + for auth in &parsed.authorities { + assert_eq!(auth.ttl(), 42); + } + for res in &parsed.resources { + assert_eq!(res.ttl(), 42); + } + + // RRSIG original_ttl must be preserved (it's inside RDATA, not a wire TTL) + match &parsed.answers[1] { + DnsRecord::RRSIG { original_ttl, .. } => assert_eq!(*original_ttl, 300), + other => panic!("expected RRSIG, got {:?}", other), + } + } + + #[test] + fn round_trip_nxdomain_soa() { + let mut pkt = DnsPacket::new(); + pkt.header.id = 0x5678; + pkt.header.response = true; + pkt.header.rescode = ResultCode::NXDOMAIN; + pkt.questions + .push(DnsQuestion::new("missing.example.com".into(), QueryType::A)); + // SOA in authority (we don't have a SOA variant, so use NS as proxy for offset testing) + pkt.authorities + .push(ns_record("example.com", "ns1.example.com", 900)); + + let wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + assert_eq!(meta.ttl_offsets.len(), 1); + assert_eq!(meta.answer_count, 0); // no answers, only authority + + let mut patched = wire.clone(); + patch_id(&mut patched, 0x9999); + patch_ttls(&mut patched, &meta.ttl_offsets, 60); + + let mut buf = BytePacketBuffer::from_bytes(&patched); + let parsed = DnsPacket::from_buffer(&mut buf).unwrap(); + + assert_eq!(parsed.header.id, 0x9999); + assert_eq!(parsed.header.rescode, ResultCode::NXDOMAIN); + assert_eq!(parsed.authorities[0].ttl(), 60); + } + + #[test] + fn round_trip_mx_record() { + let pkt = response( + 0x1234, + "example.com", + vec![mx_record("example.com", "mail.example.com", 10, 3600)], + ); + let wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + let mut patched = wire.clone(); + patch_ttls(&mut patched, &meta.ttl_offsets, 100); + + let mut buf = BytePacketBuffer::from_bytes(&patched); + let parsed = DnsPacket::from_buffer(&mut buf).unwrap(); + + match &parsed.answers[0] { + DnsRecord::MX { + domain, + priority, + host, + ttl, + } => { + assert_eq!(domain, "example.com"); + assert_eq!(*priority, 10); + assert_eq!(host, "mail.example.com"); + assert_eq!(*ttl, 100); + } + other => panic!("expected MX, got {:?}", other), + } + } + + #[test] + fn round_trip_many_records() { + let answers: Vec = (0..20) + .map(|i| a_record("example.com", &format!("10.0.0.{}", i), 300 + i * 10)) + .collect(); + let pkt = response(0x1234, "example.com", answers); + let wire = to_wire(&pkt); + let meta = scan_ttl_offsets(&wire).unwrap(); + + assert_eq!(meta.ttl_offsets.len(), 20); + + let mut patched = wire.clone(); + patch_ttls(&mut patched, &meta.ttl_offsets, 1); + + let mut buf = BytePacketBuffer::from_bytes(&patched); + let parsed = DnsPacket::from_buffer(&mut buf).unwrap(); + + assert_eq!(parsed.answers.len(), 20); + for ans in &parsed.answers { + assert_eq!(ans.ttl(), 1); + } + } + + // ── G. Edge cases ─────────────────────────────────────────────── + + #[test] + fn scan_rejects_empty_wire() { + assert!(scan_ttl_offsets(&[]).is_err()); + } + + #[test] + fn extract_question_rejects_empty_wire() { + assert!(extract_question(&[]).is_err()); + } + + // ── H. Cache behavior tests ───────────────────────────────────── + // + // These test existing DnsCache behavior that must be preserved after + // the wire-level migration. They use the current parsed-packet API + // and serve as a regression suite. + + #[test] + fn cache_insert_lookup_hit() { + let mut cache = DnsCache::new(100, 1, 3600); + let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + cache.insert("example.com", QueryType::A, &pkt); + + let (result, status) = cache + .lookup_with_status("example.com", QueryType::A) + .expect("should hit"); + assert_eq!(result.answers.len(), 1); + assert_eq!(status, DnssecStatus::Indeterminate); + } + + #[test] + fn cache_lookup_adjusts_ttl() { + let mut cache = DnsCache::new(100, 1, 3600); + let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + cache.insert("example.com", QueryType::A, &pkt); + + let (result, _) = cache.lookup_with_status("example.com", QueryType::A).unwrap(); + // TTL should be <= 300 (at most original, reduced by elapsed time) + assert!(result.answers[0].ttl() <= 300); + assert!(result.answers[0].ttl() > 0); + } + + #[test] + fn cache_miss_wrong_domain() { + let mut cache = DnsCache::new(100, 1, 3600); + let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + cache.insert("example.com", QueryType::A, &pkt); + + assert!(cache + .lookup_with_status("other.com", QueryType::A) + .is_none()); + } + + #[test] + fn cache_miss_wrong_qtype() { + let mut cache = DnsCache::new(100, 1, 3600); + let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + cache.insert("example.com", QueryType::A, &pkt); + + assert!(cache + .lookup_with_status("example.com", QueryType::AAAA) + .is_none()); + } + + #[test] + fn cache_overwrite_no_double_count() { + let mut cache = DnsCache::new(100, 1, 3600); + let pkt1 = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt2 = response(0x5678, "example.com", vec![a_record("example.com", "5.6.7.8", 600)]); + + cache.insert("example.com", QueryType::A, &pkt1); + assert_eq!(cache.len(), 1); + + cache.insert("example.com", QueryType::A, &pkt2); + assert_eq!(cache.len(), 1); // no double count + + let (result, _) = cache.lookup_with_status("example.com", QueryType::A).unwrap(); + match &result.answers[0] { + DnsRecord::A { addr, .. } => { + assert_eq!(*addr, "5.6.7.8".parse::().unwrap()) + } + _ => panic!("expected A record"), + } + } + + #[test] + fn cache_ttl_clamped_min() { + let mut cache = DnsCache::new(100, 60, 3600); + let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 5)]); + cache.insert("example.com", QueryType::A, &pkt); + + let (remaining, total) = cache.ttl_remaining("example.com", QueryType::A).unwrap(); + assert_eq!(total, 60); // clamped up from 5 + assert!(remaining <= 60); + } + + #[test] + fn cache_ttl_clamped_max() { + let mut cache = DnsCache::new(100, 1, 3600); + let pkt = + response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 999999)]); + cache.insert("example.com", QueryType::A, &pkt); + + let (_, total) = cache.ttl_remaining("example.com", QueryType::A).unwrap(); + assert_eq!(total, 3600); // clamped down from 999999 + } + + #[test] + fn cache_len_empty_clear() { + let mut cache = DnsCache::new(100, 1, 3600); + assert!(cache.is_empty()); + assert_eq!(cache.len(), 0); + + let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + cache.insert("example.com", QueryType::A, &pkt); + assert!(!cache.is_empty()); + assert_eq!(cache.len(), 1); + + cache.clear(); + assert!(cache.is_empty()); + assert_eq!(cache.len(), 0); + assert!(cache.lookup("example.com", QueryType::A).is_none()); + } + + #[test] + fn cache_remove_domain() { + let mut cache = DnsCache::new(100, 1, 3600); + let pkt_a = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt_aaaa = response( + 0x5678, + "example.com", + vec![aaaa_record("example.com", "::1", 300)], + ); + cache.insert("example.com", QueryType::A, &pkt_a); + cache.insert("example.com", QueryType::AAAA, &pkt_aaaa); + assert_eq!(cache.len(), 2); + + cache.remove("example.com"); + assert_eq!(cache.len(), 0); + assert!(cache.lookup("example.com", QueryType::A).is_none()); + assert!(cache.lookup("example.com", QueryType::AAAA).is_none()); + } + + #[test] + fn cache_list_entries() { + let mut cache = DnsCache::new(100, 1, 3600); + let pkt_a = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt_b = response(0x5678, "test.org", vec![a_record("test.org", "5.6.7.8", 600)]); + cache.insert("example.com", QueryType::A, &pkt_a); + cache.insert("test.org", QueryType::A, &pkt_b); + + let list = cache.list(); + assert_eq!(list.len(), 2); + let domains: Vec<&str> = list.iter().map(|e| e.domain.as_str()).collect(); + assert!(domains.contains(&"example.com")); + assert!(domains.contains(&"test.org")); + } + + #[test] + fn cache_heap_bytes_grows() { + let mut cache = DnsCache::new(100, 1, 3600); + let empty = cache.heap_bytes(); + + let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + cache.insert("example.com", QueryType::A, &pkt); + assert!(cache.heap_bytes() > empty); + } + + #[test] + fn cache_needs_warm_behavior() { + let mut cache = DnsCache::new(100, 1, 3600); + + // Missing → needs warm + assert!(cache.needs_warm("example.com")); + + // Both A and AAAA cached → does not need warm + let pkt_a = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt_aaaa = response( + 0x5678, + "example.com", + vec![aaaa_record("example.com", "::1", 300)], + ); + cache.insert("example.com", QueryType::A, &pkt_a); + cache.insert("example.com", QueryType::AAAA, &pkt_aaaa); + assert!(!cache.needs_warm("example.com")); + + // Only A cached → needs warm (AAAA missing) + cache.remove("example.com"); + cache.insert("example.com", QueryType::A, &pkt_a); + assert!(cache.needs_warm("example.com")); + } + + #[test] + fn cache_ttl_remaining_api() { + let mut cache = DnsCache::new(100, 60, 3600); + assert!(cache.ttl_remaining("missing.com", QueryType::A).is_none()); + + let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + cache.insert("example.com", QueryType::A, &pkt); + let (remaining, total) = cache.ttl_remaining("example.com", QueryType::A).unwrap(); + assert_eq!(total, 300); + assert!(remaining > 0); + assert!(remaining <= 300); + } + + #[test] + fn cache_dnssec_status_preserved() { + let mut cache = DnsCache::new(100, 1, 3600); + let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + cache.insert_with_status("example.com", QueryType::A, &pkt, DnssecStatus::Secure); + + let (_, status) = cache + .lookup_with_status("example.com", QueryType::A) + .unwrap(); + assert_eq!(status, DnssecStatus::Secure); + } + + // ── I. Memory footprint baseline ────────────────────────────── + // + // Measures the current parsed-packet cache memory vs what wire-level + // storage would cost for the same entries. This is a baseline — after + // migration, re-run to verify improvement. + + #[test] + fn memory_footprint_baseline() { + let mut cache = DnsCache::new(1000, 1, 3600); + + // Simulate a realistic cache: 50 domains, mix of record types + let domains: Vec = (0..50).map(|i| format!("domain{}.example.com", i)).collect(); + + let mut total_wire_bytes = 0usize; + let mut total_wire_meta_bytes = 0usize; + + for (i, domain) in domains.iter().enumerate() { + // A record + let pkt_a = response( + i as u16, + domain, + vec![ + a_record(domain, &format!("10.0.{}.1", i % 256), 300), + a_record(domain, &format!("10.0.{}.2", i % 256), 300), + ], + ); + cache.insert(domain, QueryType::A, &pkt_a); + + let wire_a = to_wire(&pkt_a); + let meta_a = scan_ttl_offsets(&wire_a).unwrap(); + total_wire_bytes += wire_a.len(); + total_wire_meta_bytes += meta_a.ttl_offsets.len() * std::mem::size_of::(); + + // AAAA record for half of them + if i % 2 == 0 { + let pkt_aaaa = response( + (i + 1000) as u16, + domain, + vec![aaaa_record(domain, &format!("2001:db8::{:x}", i), 600)], + ); + cache.insert(domain, QueryType::AAAA, &pkt_aaaa); + + let wire_aaaa = to_wire(&pkt_aaaa); + let meta_aaaa = scan_ttl_offsets(&wire_aaaa).unwrap(); + total_wire_bytes += wire_aaaa.len(); + total_wire_meta_bytes += + meta_aaaa.ttl_offsets.len() * std::mem::size_of::(); + } + } + + // Compare only the variable per-entry data (what actually differs + // between parsed and wire storage). HashMap overhead, domain keys, + // Instant, Duration, DnssecStatus are identical in both approaches. + let mut parsed_data_bytes = 0usize; + // Re-insert and measure just packet.heap_bytes() per entry + { + let mut cache2 = DnsCache::new(1000, 1, 3600); + for (i, domain) in domains.iter().enumerate() { + let pkt_a = response( + i as u16, + domain, + vec![ + a_record(domain, &format!("10.0.{}.1", i % 256), 300), + a_record(domain, &format!("10.0.{}.2", i % 256), 300), + ], + ); + parsed_data_bytes += pkt_a.heap_bytes(); + cache2.insert(domain, QueryType::A, &pkt_a); + + if i % 2 == 0 { + let pkt_aaaa = response( + (i + 1000) as u16, + domain, + vec![aaaa_record(domain, &format!("2001:db8::{:x}", i), 600)], + ); + parsed_data_bytes += pkt_aaaa.heap_bytes(); + cache2.insert(domain, QueryType::AAAA, &pkt_aaaa); + } + } + } + + let wire_total = total_wire_bytes + total_wire_meta_bytes; + let entry_count = cache.len(); + + // Also measure the struct size difference per entry + let parsed_struct = std::mem::size_of::(); + let wire_struct = std::mem::size_of::>() + std::mem::size_of::>() + std::mem::size_of::(); // wire + offsets + answer_count + + println!(); + println!("=== Cache Memory Footprint Baseline ({} entries) ===", entry_count); + println!(); + println!("Variable data (heap, per-entry payload):"); + println!(" Parsed (packet.heap_bytes): {} bytes ({:.1}/entry)", parsed_data_bytes, parsed_data_bytes as f64 / entry_count as f64); + println!(" Wire (bytes + TTL offsets): {} bytes ({:.1}/entry)", wire_total, wire_total as f64 / entry_count as f64); + println!(" Ratio: {:.1}x smaller with wire", parsed_data_bytes as f64 / wire_total as f64); + println!(); + println!("Struct overhead (stack, per entry):"); + println!(" DnsPacket: {} bytes", parsed_struct); + println!(" Wire (Vec+Vec+usize): {} bytes", wire_struct); + println!(); + println!("Total per-entry (struct + avg heap):"); + let parsed_total_per = parsed_struct as f64 + parsed_data_bytes as f64 / entry_count as f64; + let wire_total_per = wire_struct as f64 + wire_total as f64 / entry_count as f64; + println!(" Parsed: {:.0} bytes", parsed_total_per); + println!(" Wire: {:.0} bytes", wire_total_per); + println!(" Ratio: {:.1}x smaller with wire", parsed_total_per / wire_total_per); + println!(); + + // Assertions + assert!( + wire_total < parsed_data_bytes, + "wire data ({wire_total}) should be smaller than parsed data ({parsed_data_bytes})" + ); + } + + #[test] + fn cache_max_entries_cap() { + let mut cache = DnsCache::new(2, 1, 3600); + for i in 0..3 { + let domain = format!("test{}.com", i); + let pkt = response( + i as u16, + &domain, + vec![a_record(&domain, &format!("1.2.3.{}", i), 3600)], + ); + cache.insert(&domain, QueryType::A, &pkt); + } + // Should not exceed max (third insert is silently dropped or evicts) + assert!(cache.len() <= 2); + } +} diff --git a/tests/integration.sh b/tests/integration.sh index 92da878..c70ec59 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -53,7 +53,17 @@ CONF echo "Starting Numa on :$PORT ($SUITE_NAME)..." RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 & NUMA_PID=$! - sleep 4 + sleep 2 + + # Wait for blocklist to load (if blocking is enabled in this suite) + if echo "$SUITE_CONFIG" | grep -q 'enabled = true'; then + for i in $(seq 1 20); do + LOADED=$(curl -sf http://127.0.0.1:$API_PORT/blocking/stats 2>/dev/null \ + | grep -o '"domains_loaded":[0-9]*' | cut -d: -f2) + if [ "${LOADED:-0}" -gt 0 ]; then break; fi + sleep 1 + done + fi if ! kill -0 "$NUMA_PID" 2>/dev/null; then echo "Failed to start Numa:" -- 2.34.1 From 5d9a3a809b4bf7e85b3243efa69293cf2f0e399f Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 06:22:42 +0300 Subject: [PATCH 079/204] feat: DoT client, recursive optimization, bench refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DoT forwarding client (tls://IP#hostname upstream config) - Recursive: cache NS delegations, serve-stale (RFC 8767), parallel NS queries on cold, no TCP fallback on individual UDP timeouts, 400ms NS/TCP timeout (down from 800/1500ms) - Reduce recursive p99 from 2367ms to 402ms (vs Unbound's 148ms) - Refactor benchmark suite: generic compare_two engine, delete one-off diagnostics (1969 → 750 lines) - Code cleanup: forward_query delegates to _raw, Option for tls_name, saturating_sub for ns_idx --- Cargo.lock | 2 +- benches/numa-bench.toml | 10 +- benches/recursive_compare.rs | 2060 ++++++++++++---------------------- src/forward.rs | 53 +- src/recursive.rs | 29 +- 5 files changed, 754 insertions(+), 1400 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eaba214..c0f7692 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1358,7 +1358,7 @@ dependencies = [ "tokio-rustls", "toml", "tower", - "webpki-roots", + "webpki-roots 1.0.6", ] [[package]] diff --git a/benches/numa-bench.toml b/benches/numa-bench.toml index 0e058af..6124840 100644 --- a/benches/numa-bench.toml +++ b/benches/numa-bench.toml @@ -5,7 +5,8 @@ api_bind_addr = "127.0.0.1" data_dir = "/tmp/numa-bench" [upstream] -mode = "recursive" +mode = "forward" +address = ["https://9.9.9.9/dns-query"] timeout_ms = 10000 [cache] @@ -15,8 +16,13 @@ max_ttl = 3600 [blocking] enabled = false +[proxy] +port = 8080 +tls_port = 8443 + [dot] -enabled = false +enabled = true +port = 8530 [mobile] enabled = false diff --git a/benches/recursive_compare.rs b/benches/recursive_compare.rs index e35768c..12f3689 100644 --- a/benches/recursive_compare.rs +++ b/benches/recursive_compare.rs @@ -1,20 +1,18 @@ -//! DoH forwarding benchmark: Numa vs hickory-resolver. +//! DNS forwarding benchmark suite. //! -//! Both forward to the same DoH upstream (Quad9). -//! Measures end-to-end resolution time through each implementation. -//! -//! Fairness: -//! - Both reuse a single TLS connection (Numa via persistent server, -//! Hickory via a shared resolver instance with cache_size=0). -//! - Measurement order is alternated each round to cancel order bias. -//! - Numa cache is flushed before each query. -//! - 100 domains × 10 rounds for statistical confidence. +//! Modes: +//! (default) Numa server (UDP) vs Hickory library (DoH) — the original benchmark +//! --diag Hickory connection reuse diagnostic (20 queries) +//! --diag-clients Per-query reqwest vs Hickory timing (20 queries) +//! --direct Library-to-library: Numa forward_query_raw vs Hickory resolver.lookup +//! --hedge-5x Hedging: single vs hedge-same vs hedge-dual vs Hickory (5 iterations) +//! --vs-unbound Server-to-server: Numa vs Unbound (plain UDP, caching) +//! --vs-dot DoT server: Numa vs Unbound +//! --vs-doh-servers DoH server: Numa vs Unbound (DoT upstream) //! //! Setup: -//! 1. Start a bench Numa instance: -//! cargo run -- benches/numa-bench.toml -//! 2. Run: -//! cargo bench --bench recursive_compare +//! 1. Start a bench Numa instance: cargo run -- benches/numa-bench.toml +//! 2. Run: cargo bench --bench recursive_compare [-- --mode] use std::net::SocketAddr; use std::time::{Duration, Instant}; @@ -130,216 +128,585 @@ const DOMAINS: &[&str] = &[ const ROUNDS: usize = 10; fn main() { - let diag = std::env::args().any(|a| a == "--diag"); - let direct = std::env::args().any(|a| a == "--direct"); + let arg = |flag: &str| std::env::args().any(|a| a == flag); let rt = tokio::runtime::Runtime::new().unwrap(); - if diag { - run_diag(&rt); - return; + if arg("--diag") { + return run_diag(&rt); + } + if arg("--diag-clients") { + return run_diag_clients(&rt); + } + if arg("--direct") { + return run_direct(&rt); + } + if arg("--hedge-5x") { + return run_hedge_multi(&rt, 5); + } + if arg("--vs-unbound") { + return run_server_comparison(&rt, "Unbound", "127.0.0.1:5456", 5); + } + if arg("--vs-dnscrypt") { + return run_server_comparison(&rt, "dnscrypt-proxy", "127.0.0.1:5455", 5); + } + if arg("--vs-dot") { + return run_dot_comparison(&rt, 5); + } + if arg("--vs-doh-servers") { + return run_doh_comparison(&rt, 5); } - if direct { - run_direct(&rt); - return; + // Default: Numa server (UDP) vs Hickory library (DoH) + run_default(&rt); +} + +// ── Generic 2-way comparison engine ───────────────────────────── + +fn compare_two( + rt: &tokio::runtime::Runtime, + title: &str, + name_a: &str, + name_b: &str, + measure_a: &dyn Fn(&str) -> f64, + measure_b: &dyn Fn(&str) -> f64, + iterations: usize, +) { + let flush = std::env::args().any(|a| a == "--flush"); + println!("{}", title); + println!( + "{} domains × {} rounds × {} iterations\n", + DOMAINS.len(), + ROUNDS, + iterations + ); + + let mut all_a = Vec::new(); + let mut all_b = Vec::new(); + let mut iter_stats: Vec<[(f64, f64, f64, f64, f64); 2]> = Vec::new(); + + for iter in 1..=iterations { + println!(" iteration {}/{}...", iter, iterations); + let mut a = Vec::new(); + let mut b = Vec::new(); + + for domain in DOMAINS { + for round in 0..ROUNDS { + if flush { + flush_cache(); + std::thread::sleep(Duration::from_millis(5)); + } + if round % 2 == 0 { + a.push(measure_a(domain)); + b.push(measure_b(domain)); + } else { + b.push(measure_b(domain)); + a.push(measure_a(domain)); + } + } + } + + iter_stats.push([stats(&mut a), stats(&mut b)]); + all_a.extend_from_slice(&a); + all_b.extend_from_slice(&b); } - if std::env::args().any(|a| a == "--diag-clients") { - run_diag_clients(&rt); - return; + print_results( + name_a, + name_b, + &iter_stats, + &mut all_a, + &mut all_b, + iterations, + ); +} + +fn print_results( + name_a: &str, + name_b: &str, + iter_stats: &[[(f64, f64, f64, f64, f64); 2]], + all_a: &mut Vec, + all_b: &mut Vec, + iterations: usize, +) { + let w = name_a.len().max(name_b.len()).max(6); + + println!("\n=== Per-iteration medians ==="); + println!("{:<8} {:>w$} {:>w$}", "iter", name_a, name_b, w = w + 3); + for (i, s) in iter_stats.iter().enumerate() { + println!( + "{:<8} {:>w$.1} ms {:>w$.1} ms", + i + 1, + s[0].1, + s[1].1, + w = w + ); } - if std::env::args().any(|a| a == "--spike-trace") { - run_spike_trace(&rt); - return; + println!("\n=== Per-iteration p99 ==="); + println!("{:<8} {:>w$} {:>w$}", "iter", name_a, name_b, w = w + 3); + for (i, s) in iter_stats.iter().enumerate() { + println!( + "{:<8} {:>w$.1} ms {:>w$.1} ms", + i + 1, + s[0].3, + s[1].3, + w = w + ); } - if std::env::args().any(|a| a == "--spike-phases") { - run_spike_phases(&rt); - return; - } + let (a_m, a_med, a_p95, a_p99, a_sd) = stats(all_a); + let (b_m, b_med, b_p95, b_p99, b_sd) = stats(all_b); - if std::env::args().any(|a| a == "--spike-heartbeat") { - run_spike_heartbeat(&rt); - return; - } + let total = iterations * DOMAINS.len() * ROUNDS; + println!("\n=== Aggregated ({} samples per method) ===\n", total); + println!("{:<10} {:>w$} {:>w$}", "", name_a, name_b, w = w + 3); + println!("{:<10} {:>w$.1} ms {:>w$.1} ms", "mean", a_m, b_m, w = w); + println!( + "{:<10} {:>w$.1} ms {:>w$.1} ms", + "median", + a_med, + b_med, + w = w + ); + println!( + "{:<10} {:>w$.1} ms {:>w$.1} ms", + "p95", + a_p95, + b_p95, + w = w + ); + println!( + "{:<10} {:>w$.1} ms {:>w$.1} ms", + "p99", + a_p99, + b_p99, + w = w + ); + println!("{:<10} {:>w$.1} ms {:>w$.1} ms", "σ", a_sd, b_sd, w = w); - if std::env::args().any(|a| a == "--hedge") { - run_hedge(&rt); - return; - } + let pct = |a: f64, b: f64| { + if b.abs() > 0.001 { + (a - b) / b * 100.0 + } else { + 0.0 + } + }; + println!("\n{} vs {}:", name_a, name_b); + println!(" mean: {:+.1} ms ({:+.0}%)", a_m - b_m, pct(a_m, b_m)); + println!( + " median: {:+.1} ms ({:+.0}%)", + a_med - b_med, + pct(a_med, b_med) + ); + println!( + " p99: {:+.1} ms ({:+.0}%)", + a_p99 - b_p99, + pct(a_p99, b_p99) + ); +} - if std::env::args().any(|a| a == "--hedge-5x") { - run_hedge_multi(&rt, 5); - return; - } - - if std::env::args().any(|a| a == "--vs-dnscrypt") { - run_vs_dnscrypt(&rt, 5); - return; - } - - if std::env::args().any(|a| a == "--vs-unbound") { - run_vs_unbound(&rt, 5); - return; - } +// ── Modes ─────────────────────────────────────────────────────── +/// Default: Numa server (UDP) vs Hickory library (DoH), cache flushed. +fn run_default(rt: &tokio::runtime::Runtime) { let numa_addr: SocketAddr = NUMA_BENCH.parse().unwrap(); - - println!("DoH Forwarding Benchmark: Numa vs hickory-resolver"); - println!("Both forwarding to {DOH_UPSTREAM}"); - println!("{} domains × {ROUNDS} rounds", DOMAINS.len()); - println!(); - - // Verify bench Numa is reachable if rt.block_on(query_udp(numa_addr, "example.com")).is_none() { eprintln!("Bench Numa not responding on {numa_addr}"); - eprintln!(); - eprintln!("Start it with:"); - eprintln!(" cargo run -- benches/numa-bench.toml"); + eprintln!("Start with: cargo run -- benches/numa-bench.toml"); std::process::exit(1); } - // Build a shared Hickory resolver (reuses TLS connection, like Numa does) let resolver = rt.block_on(build_hickory_resolver()); - // Warm up both paths (TLS handshake, connection establishment) - println!("Warming up connections..."); + println!("Warming up..."); for _ in 0..3 { rt.block_on(query_udp(numa_addr, "example.com")); rt.block_on(query_hickory_doh(&resolver, "example.com")); } flush_cache(); - println!( - "{:<30} {:>10} {:>10} {:>10} {:>8} {:>8}", - "Domain", "Numa (ms)", "Hickory", "Delta", "σ Numa", "σ Hick" - ); - println!("{}", "-".repeat(92)); - - let mut numa_all = Vec::new(); - let mut hickory_all = Vec::new(); - let mut per_domain: Vec<(&str, f64, f64, f64, f64, f64)> = Vec::new(); - - for domain in DOMAINS { - let mut numa_times = Vec::with_capacity(ROUNDS); - let mut hickory_times = Vec::with_capacity(ROUNDS); - - for round in 0..ROUNDS { + compare_two( + rt, + &format!("DoH Forwarding: Numa server vs Hickory library\nBoth → {DOH_UPSTREAM}"), + "Numa", + "Hickory", + &|domain| { flush_cache(); std::thread::sleep(Duration::from_millis(10)); + let t = Instant::now(); + let _ = rt.block_on(query_udp(numa_addr, domain)); + t.elapsed().as_secs_f64() * 1000.0 + }, + &|domain| { + let t = Instant::now(); + let _ = rt.block_on(query_hickory_doh(&resolver, domain)); + t.elapsed().as_secs_f64() * 1000.0 + }, + 1, + ); +} - // Alternate measurement order each round to cancel systematic bias - if round % 2 == 0 { - // Numa first - let t = measure(&rt, || rt.block_on(query_udp(numa_addr, domain))); - numa_times.push(t); - let t = measure(&rt, || rt.block_on(query_hickory_doh(&resolver, domain))); - hickory_times.push(t); - } else { - // Hickory first - let t = measure(&rt, || rt.block_on(query_hickory_doh(&resolver, domain))); - hickory_times.push(t); - flush_cache(); - std::thread::sleep(Duration::from_millis(10)); - let t = measure(&rt, || rt.block_on(query_udp(numa_addr, domain))); - numa_times.push(t); +/// Library-to-library: Numa forward_query_raw vs Hickory resolver.lookup. +fn run_direct(rt: &tokio::runtime::Runtime) { + let upstream = numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse"); + let resolver = rt.block_on(build_hickory_resolver()); + let timeout = Duration::from_secs(10); + + println!("Warming up..."); + for _ in 0..3 { + let w = build_query_vec("example.com"); + let _ = rt.block_on(numa::forward::forward_query_raw(&w, &upstream, timeout)); + let _ = rt.block_on(query_hickory_doh(&resolver, "example.com")); + } + + compare_two( + rt, + &format!("Direct DoH: Numa forward_query_raw vs Hickory resolver.lookup\nBoth → {DOH_UPSTREAM}, no server pipeline"), + "Numa", "Hickory", + &|domain| { + let w = build_query_vec(domain); + let t = Instant::now(); + let _ = rt.block_on(numa::forward::forward_query_raw(&w, &upstream, timeout)); + t.elapsed().as_secs_f64() * 1000.0 + }, + &|domain| { + let t = Instant::now(); + let _ = rt.block_on(query_hickory_doh(&resolver, domain)); + t.elapsed().as_secs_f64() * 1000.0 + }, + 5, + ); +} + +/// Server-to-server: Numa vs another server, both on plain UDP. +fn run_server_comparison( + rt: &tokio::runtime::Runtime, + other_name: &str, + other_addr: &str, + iterations: usize, +) { + let numa_addr: SocketAddr = NUMA_BENCH.parse().unwrap(); + let other: SocketAddr = other_addr.parse().unwrap(); + + for (name, addr) in [("Numa", numa_addr), (other_name, other)] { + if rt.block_on(query_udp(addr, "example.com")).is_none() { + eprintln!("{name} not responding on {addr}"); + std::process::exit(1); + } + } + + println!("Warming up..."); + for _ in 0..5 { + let _ = rt.block_on(query_udp(numa_addr, "example.com")); + let _ = rt.block_on(query_udp(other, "example.com")); + } + + compare_two( + rt, + &format!("Server-to-Server: Numa vs {other_name} (UDP, caching)"), + "Numa", + other_name, + &|domain| { + let t = Instant::now(); + let _ = rt.block_on(query_udp(numa_addr, domain)); + t.elapsed().as_secs_f64() * 1000.0 + }, + &|domain| { + let t = Instant::now(); + let _ = rt.block_on(query_udp(other, domain)); + t.elapsed().as_secs_f64() * 1000.0 + }, + iterations, + ); +} + +/// DoT server comparison: Numa vs Unbound. +fn run_dot_comparison(rt: &tokio::runtime::Runtime, iterations: usize) { + const NUMA_DOT: &str = "127.0.0.1:8530"; + const UNBOUND_DOT: &str = "127.0.0.1:8531"; + + let _ = rustls::crypto::ring::default_provider().install_default(); + let tls_config = build_insecure_tls_config(); + + for (name, addr) in [("Numa", NUMA_DOT), ("Unbound", UNBOUND_DOT)] { + match rt.block_on(query_dot_once(addr, "example.com", &tls_config)) { + Ok(_) => println!("{name} DoT: OK"), + Err(e) => { + eprintln!("{name} DoT not responding on {addr}: {e}"); + std::process::exit(1); + } + } + } + + println!("Warming up..."); + for _ in 0..3 { + let _ = rt.block_on(query_dot_once(NUMA_DOT, "example.com", &tls_config)); + let _ = rt.block_on(query_dot_once(UNBOUND_DOT, "example.com", &tls_config)); + } + + compare_two( + rt, + "DoT Server: Numa vs Unbound (both DoT→clients, forwarding to Quad9)", + "Numa", + "Unbound", + &|domain| { + let t = Instant::now(); + let _ = rt.block_on(query_dot_once(NUMA_DOT, domain, &tls_config)); + t.elapsed().as_secs_f64() * 1000.0 + }, + &|domain| { + let t = Instant::now(); + let _ = rt.block_on(query_dot_once(UNBOUND_DOT, domain, &tls_config)); + t.elapsed().as_secs_f64() * 1000.0 + }, + iterations, + ); +} + +/// DoH server comparison: Numa vs Unbound (both DoH→clients, DoT upstream). +fn run_doh_comparison(rt: &tokio::runtime::Runtime, iterations: usize) { + const NUMA_DOH: &str = "https://127.0.0.1:8443/dns-query"; + const UNBOUND_DOH: &str = "https://127.0.0.1:8445/dns-query"; + + let client = reqwest::Client::builder() + .use_rustls_tls() + .danger_accept_invalid_certs(true) + .http2_initial_stream_window_size(65_535) + .http2_initial_connection_window_size(65_535) + .pool_idle_timeout(Duration::from_secs(300)) + .build() + .unwrap(); + + for (name, url, host) in [ + ("Numa", NUMA_DOH, Some("numa.numa")), + ("Unbound", UNBOUND_DOH, None), + ] { + let w = build_query_vec("example.com"); + match rt.block_on(query_doh_server(&client, url, &w, host)) { + Ok(_) => println!("{name} DoH: OK"), + Err(e) => { + eprintln!("{name} DoH not responding: {e}"); + std::process::exit(1); + } + } + } + + println!("Warming up..."); + for _ in 0..5 { + let w = build_query_vec("example.com"); + let _ = rt.block_on(query_doh_server(&client, NUMA_DOH, &w, Some("numa.numa"))); + let _ = rt.block_on(query_doh_server(&client, UNBOUND_DOH, &w, None)); + } + + compare_two( + rt, + "DoH Server: Numa vs Unbound (both DoH→clients, DoT upstream)", + "Numa", + "Unbound", + &|domain| { + let w = build_query_vec(domain); + let t = Instant::now(); + let _ = rt.block_on(query_doh_server(&client, NUMA_DOH, &w, Some("numa.numa"))); + t.elapsed().as_secs_f64() * 1000.0 + }, + &|domain| { + let w = build_query_vec(domain); + let t = Instant::now(); + let _ = rt.block_on(query_doh_server(&client, UNBOUND_DOH, &w, None)); + t.elapsed().as_secs_f64() * 1000.0 + }, + iterations, + ); +} + +/// Hedging: single vs hedge-same vs hedge-dual vs Hickory. +/// This is the one mode that compares 4 contenders, not 2. +fn run_hedge_multi(rt: &tokio::runtime::Runtime, iterations: usize) { + let hedge_delay = Duration::from_millis(10); + let timeout = Duration::from_secs(10); + + println!("Hedging Benchmark × {iterations} iterations"); + println!("Upstream: {DOH_UPSTREAM}"); + println!("Hedge delay: {hedge_delay:?}"); + println!( + "{} domains × {ROUNDS} rounds per iteration\n", + DOMAINS.len() + ); + + let primary = numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse"); + let primary_dual = numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse"); + let secondary_dual = numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse"); + let resolver = rt.block_on(build_hickory_resolver()); + + println!("Warming up..."); + for _ in 0..5 { + let w = build_query_vec("example.com"); + let _ = rt.block_on(numa::forward::forward_query_raw(&w, &primary, timeout)); + let _ = rt.block_on(numa::forward::forward_query_raw(&w, &primary_dual, timeout)); + let _ = rt.block_on(numa::forward::forward_query_raw( + &w, + &secondary_dual, + timeout, + )); + let _ = rt.block_on(query_hickory_doh(&resolver, "example.com")); + } + + let labels = ["Single", "Hedge-same", "Hedge-dual", "Hickory"]; + let mut all: [Vec; 4] = [vec![], vec![], vec![], vec![]]; + let mut iter_medians: Vec<[f64; 4]> = vec![]; + let mut iter_p99s: Vec<[f64; 4]> = vec![]; + + for iter in 1..=iterations { + println!(" iteration {iter}/{iterations}..."); + let mut samples: [Vec; 4] = [vec![], vec![], vec![], vec![]]; + + for domain in DOMAINS { + for _ in 0..ROUNDS { + let w = build_query_vec(domain); + + let t = Instant::now(); + let _ = rt.block_on(numa::forward::forward_query_raw(&w, &primary, timeout)); + samples[0].push(t.elapsed().as_secs_f64() * 1000.0); + + let t = Instant::now(); + let _ = rt.block_on(numa::forward::forward_with_hedging_raw( + &w, + &primary, + &primary, + hedge_delay, + timeout, + )); + samples[1].push(t.elapsed().as_secs_f64() * 1000.0); + + let t = Instant::now(); + let _ = rt.block_on(numa::forward::forward_with_hedging_raw( + &w, + &primary_dual, + &secondary_dual, + hedge_delay, + timeout, + )); + samples[2].push(t.elapsed().as_secs_f64() * 1000.0); + + let t = Instant::now(); + let _ = rt.block_on(query_hickory_doh(&resolver, domain)); + samples[3].push(t.elapsed().as_secs_f64() * 1000.0); } } - let numa_avg = mean(&numa_times); - let hickory_avg = mean(&hickory_times); - let numa_sd = stddev(&numa_times); - let hickory_sd = stddev(&hickory_times); - let delta = numa_avg - hickory_avg; + let s: Vec<_> = samples.iter_mut().map(|v| stats(v)).collect(); + iter_medians.push([s[0].1, s[1].1, s[2].1, s[3].1]); + iter_p99s.push([s[0].3, s[1].3, s[2].3, s[3].3]); + for (i, v) in samples.iter().enumerate() { + all[i].extend_from_slice(v); + } + } - numa_all.extend_from_slice(&numa_times); - hickory_all.extend_from_slice(&hickory_times); - per_domain.push((domain, numa_avg, hickory_avg, delta, numa_sd, hickory_sd)); - - let delta_str = format_delta(delta); + println!("\n=== Per-iteration medians ==="); + println!( + "{:<8} {:>10} {:>12} {:>12} {:>10}", + "iter", labels[0], labels[1], labels[2], labels[3] + ); + for (i, m) in iter_medians.iter().enumerate() { println!( - "{:<30} {:>7.1} ms {:>7.1} ms {:>7} ms {:>5.1}ms {:>5.1}ms", - domain, numa_avg, hickory_avg, delta_str, numa_sd, hickory_sd + "{:<8} {:>7.1} ms {:>9.1} ms {:>9.1} ms {:>7.1} ms", + i + 1, + m[0], + m[1], + m[2], + m[3] ); } - println!("{}", "-".repeat(92)); - - let numa_mean = mean(&numa_all); - let hickory_mean = mean(&hickory_all); - let delta_mean = numa_mean - hickory_mean; - + println!("\n=== Per-iteration p99 ==="); println!( - "{:<30} {:>7.1} ms {:>7.1} ms {:>7} ms {:>5.1}ms {:>5.1}ms", - "OVERALL MEAN", - numa_mean, - hickory_mean, - format_delta(delta_mean), - stddev(&numa_all), - stddev(&hickory_all), + "{:<8} {:>10} {:>12} {:>12} {:>10}", + "iter", labels[0], labels[1], labels[2], labels[3] ); - - // Median - let numa_med = median(&mut numa_all); - let hickory_med = median(&mut hickory_all); - println!( - "{:<30} {:>7.1} ms {:>7.1} ms {:>7} ms", - "MEDIAN", - numa_med, - hickory_med, - format_delta(numa_med - hickory_med), - ); - - // P95 - let numa_p95 = percentile(&numa_all, 95.0); - let hickory_p95 = percentile(&hickory_all, 95.0); - println!( - "{:<30} {:>7.1} ms {:>7.1} ms {:>7} ms", - "P95", - numa_p95, - hickory_p95, - format_delta(numa_p95 - hickory_p95), - ); - - println!(); - let total_queries = DOMAINS.len() * ROUNDS; - if numa_mean < hickory_mean { - let pct = ((hickory_mean - numa_mean) / hickory_mean * 100.0).round(); - println!("Numa is ~{pct}% faster (mean over {total_queries} queries)."); - } else if hickory_mean < numa_mean { - let pct = ((numa_mean - hickory_mean) / numa_mean * 100.0).round(); - println!("Hickory is ~{pct}% faster (mean over {total_queries} queries)."); - } else { - println!("Both are equal (mean over {total_queries} queries)."); + for (i, p) in iter_p99s.iter().enumerate() { + println!( + "{:<8} {:>7.1} ms {:>9.1} ms {:>9.1} ms {:>7.1} ms", + i + 1, + p[0], + p[1], + p[2], + p[3] + ); } - println!(); - println!("Methodology:"); - println!(" - Both forward to {DOH_UPSTREAM} over a reused TLS connection."); - println!(" - Numa cache flushed before each query. Hickory cache disabled."); - println!(" - Measurement order alternates each round to cancel order bias."); - println!(" - {} domains × {ROUNDS} rounds = {total_queries} queries per resolver.", DOMAINS.len()); + let s: Vec<_> = all + .iter_mut() + .map(|v| { + let (m, med, p95, p99, sd) = stats(v); + [m, med, p95, p99, sd] + }) + .collect(); + let total = iterations * DOMAINS.len() * ROUNDS; + println!("\n=== Aggregated ({total} samples per method) ===\n"); + println!( + "{:<10} {:>10} {:>12} {:>12} {:>10}", + "", labels[0], labels[1], labels[2], labels[3] + ); + for (row, idx) in [("mean", 0), ("median", 1), ("p95", 2), ("p99", 3), ("σ", 4)] { + println!( + "{:<10} {:>7.1} ms {:>9.1} ms {:>9.1} ms {:>7.1} ms", + row, s[0][idx], s[1][idx], s[2][idx], s[3][idx] + ); + } + + let pct = |a: f64, b: f64| { + if b.abs() > 0.001 { + (a - b) / b * 100.0 + } else { + 0.0 + } + }; + println!( + "\nHedge-same vs Single: mean {:+.0}%, p95 {:+.0}%, p99 {:+.0}%", + pct(s[1][0], s[0][0]), + pct(s[1][2], s[0][2]), + pct(s[1][3], s[0][3]) + ); + println!( + "Hedge-same vs Hickory: mean {:+.0}%, p95 {:+.0}%, p99 {:+.0}%", + pct(s[1][0], s[3][0]), + pct(s[1][2], s[3][2]), + pct(s[1][3], s[3][3]) + ); } +// ── Diagnostics (small, kept for debugging) ───────────────────── + fn run_diag(rt: &tokio::runtime::Runtime) { - println!("Hickory connection reuse diagnostic"); - println!("20 sequential queries to {DOH_UPSTREAM} via one shared resolver"); - println!("If conn is reused: query 1 slow (TLS handshake), rest fast.\n"); + println!("Hickory connection reuse diagnostic\n20 queries to {DOH_UPSTREAM}\n"); let resolver = rt.block_on(build_hickory_resolver()); - let domains = [ - "example.com", "rust-lang.org", "kernel.org", "google.com", "github.com", - "example.com", "rust-lang.org", "kernel.org", "google.com", "github.com", - "example.com", "rust-lang.org", "kernel.org", "google.com", "github.com", - "example.com", "rust-lang.org", "kernel.org", "google.com", "github.com", + "example.com", + "rust-lang.org", + "kernel.org", + "google.com", + "github.com", + "example.com", + "rust-lang.org", + "kernel.org", + "google.com", + "github.com", + "example.com", + "rust-lang.org", + "kernel.org", + "google.com", + "github.com", + "example.com", + "rust-lang.org", + "kernel.org", + "google.com", + "github.com", ]; println!("{:>3} {:<20} {:>10}", "#", "Domain", "Time (ms)"); println!("{}", "-".repeat(40)); - for (i, domain) in domains.iter().enumerate() { use hickory_resolver::proto::rr::RecordType; let start = Instant::now(); @@ -347,143 +714,31 @@ fn run_diag(rt: &tokio::runtime::Runtime) { let ms = start.elapsed().as_secs_f64() * 1000.0; match &result { Ok(lookup) => { - let first = lookup.iter().next().map(|r| format!("{r}")).unwrap_or_default(); - println!("{:>3} {:<20} {:>7.1} ms OK {}", i + 1, domain, ms, first); - } - Err(e) => { - println!("{:>3} {:<20} {:>7.1} ms ERR {}", i + 1, domain, ms, e); + let first = lookup + .iter() + .next() + .map(|r| format!("{r}")) + .unwrap_or_default(); + println!( + "{:>3} {:<20} {:>7.1} ms OK {}", + i + 1, + domain, + ms, + first + ); } + Err(e) => println!("{:>3} {:<20} {:>7.1} ms ERR {}", i + 1, domain, ms, e), } } } -/// Library-to-library comparison: Numa's forward_query_raw vs Hickory's resolver.lookup(). -/// No UDP, no server pipeline — just the DoH forwarding call. -fn run_direct(rt: &tokio::runtime::Runtime) { - println!("Direct DoH Forwarding: Numa forward_query_raw vs Hickory resolver.lookup()"); - println!("Both forwarding to {DOH_UPSTREAM} — no UDP, no server pipeline"); - println!("{} domains × {ROUNDS} rounds", DOMAINS.len()); - println!(); - - // Build Numa's upstream (shared reqwest client, reuses HTTP/2 connection) - let numa_upstream = - numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse upstream"); - let timeout = Duration::from_secs(10); - - // Build Hickory's resolver (shared, reuses HTTP/2 connection) - let resolver = rt.block_on(build_hickory_resolver()); - - // Warm up both - println!("Warming up connections..."); - for _ in 0..3 { - let wire = build_query_vec("example.com"); - let _ = rt.block_on(numa::forward::forward_query_raw(&wire, &numa_upstream, timeout)); - let _ = rt.block_on(query_hickory_doh(&resolver, "example.com")); - } - - println!( - "{:<30} {:>10} {:>10} {:>10} {:>8} {:>8}", - "Domain", "Numa (ms)", "Hickory", "Delta", "σ Numa", "σ Hick" - ); - println!("{}", "-".repeat(92)); - - let mut numa_all = Vec::new(); - let mut hickory_all = Vec::new(); - - for domain in DOMAINS { - let mut numa_times = Vec::with_capacity(ROUNDS); - let mut hickory_times = Vec::with_capacity(ROUNDS); - - for round in 0..ROUNDS { - let wire = build_query_vec(domain); - - if round % 2 == 0 { - let w = wire.clone(); - let t = measure(rt, || { - rt.block_on(numa::forward::forward_query_raw(&w, &numa_upstream, timeout)) - }); - numa_times.push(t); - let t = measure(rt, || rt.block_on(query_hickory_doh(&resolver, domain))); - hickory_times.push(t); - } else { - let t = measure(rt, || rt.block_on(query_hickory_doh(&resolver, domain))); - hickory_times.push(t); - let w = wire.clone(); - let t = measure(rt, || { - rt.block_on(numa::forward::forward_query_raw(&w, &numa_upstream, timeout)) - }); - numa_times.push(t); - } - } - - let numa_avg = mean(&numa_times); - let hickory_avg = mean(&hickory_times); - let numa_sd = stddev(&numa_times); - let hickory_sd = stddev(&hickory_times); - let delta = numa_avg - hickory_avg; - - numa_all.extend_from_slice(&numa_times); - hickory_all.extend_from_slice(&hickory_times); - - println!( - "{:<30} {:>7.1} ms {:>7.1} ms {:>7} ms {:>5.1}ms {:>5.1}ms", - domain, numa_avg, hickory_avg, format_delta(delta), numa_sd, hickory_sd - ); - } - - println!("{}", "-".repeat(92)); - let numa_mean = mean(&numa_all); - let hickory_mean = mean(&hickory_all); - println!( - "{:<30} {:>7.1} ms {:>7.1} ms {:>7} ms {:>5.1}ms {:>5.1}ms", - "OVERALL MEAN", numa_mean, hickory_mean, format_delta(numa_mean - hickory_mean), - stddev(&numa_all), stddev(&hickory_all), - ); - let numa_med = median(&mut numa_all); - let hickory_med = median(&mut hickory_all); - println!( - "{:<30} {:>7.1} ms {:>7.1} ms {:>7} ms", - "MEDIAN", numa_med, hickory_med, format_delta(numa_med - hickory_med), - ); - let numa_p95 = percentile(&numa_all, 95.0); - let hickory_p95 = percentile(&hickory_all, 95.0); - println!( - "{:<30} {:>7.1} ms {:>7.1} ms {:>7} ms", - "P95", numa_p95, hickory_p95, format_delta(numa_p95 - hickory_p95), - ); - - println!(); - let total_queries = DOMAINS.len() * ROUNDS; - if numa_mean < hickory_mean { - let pct = ((hickory_mean - numa_mean) / hickory_mean * 100.0).round(); - println!("Numa is ~{pct}% faster (mean over {total_queries} queries)."); - } else if hickory_mean < numa_mean { - let pct = ((numa_mean - hickory_mean) / numa_mean * 100.0).round(); - println!("Hickory is ~{pct}% faster (mean over {total_queries} queries)."); - } else { - println!("Both are equal (mean over {total_queries} queries)."); - } - - println!(); - println!("Methodology:"); - println!(" - Both forward to {DOH_UPSTREAM} over a reused TLS/HTTP2 connection."); - println!(" - No UDP, no server pipeline, no cache — pure DoH forwarding."); - println!(" - Numa: forward_query_raw (reqwest). Hickory: resolver.lookup (h2)."); - println!(" - {} domains × {ROUNDS} rounds = {total_queries} queries per implementation.", DOMAINS.len()); -} - -/// Per-query timing diagnostic: 20 queries each through reqwest and Hickory. -/// Shows whether reqwest has connection reuse issues or per-request overhead. fn run_diag_clients(rt: &tokio::runtime::Runtime) { - println!("Client diagnostic: reqwest vs Hickory per-query timing"); - println!("20 queries each to {DOH_UPSTREAM}\n"); + println!("Client diagnostic: reqwest vs Hickory (20 queries to {DOH_UPSTREAM})\n"); - let upstream = - numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse upstream"); + let upstream = numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse"); let resolver = rt.block_on(build_hickory_resolver()); let timeout = Duration::from_secs(10); - // Warm both for _ in 0..3 { let w = build_query_vec("example.com"); let _ = rt.block_on(numa::forward::forward_query_raw(&w, &upstream, timeout)); @@ -491,18 +746,35 @@ fn run_diag_clients(rt: &tokio::runtime::Runtime) { } let domains = [ - "example.com", "google.com", "github.com", "rust-lang.org", "cloudflare.com", - "example.com", "google.com", "github.com", "rust-lang.org", "cloudflare.com", - "example.com", "google.com", "github.com", "rust-lang.org", "cloudflare.com", - "example.com", "google.com", "github.com", "rust-lang.org", "cloudflare.com", + "example.com", + "google.com", + "github.com", + "rust-lang.org", + "cloudflare.com", + "example.com", + "google.com", + "github.com", + "rust-lang.org", + "cloudflare.com", + "example.com", + "google.com", + "github.com", + "rust-lang.org", + "cloudflare.com", + "example.com", + "google.com", + "github.com", + "rust-lang.org", + "cloudflare.com", ]; - println!("{:>3} {:<20} {:>12} {:>12}", "#", "Domain", "reqwest", "Hickory"); + println!( + "{:>3} {:<20} {:>12} {:>12}", + "#", "Domain", "reqwest", "Hickory" + ); println!("{}", "-".repeat(55)); - for (i, domain) in domains.iter().enumerate() { let wire = build_query_vec(domain); - let start = Instant::now(); let r_result = rt.block_on(numa::forward::forward_query_raw(&wire, &upstream, timeout)); let r_ms = start.elapsed().as_secs_f64() * 1000.0; @@ -515,1076 +787,104 @@ fn run_diag_clients(rt: &tokio::runtime::Runtime) { println!( "{:>3} {:<20} {:>7.1} ms {} {:>7.1} ms {}", - i + 1, domain, r_ms, r_ok, h_ms, h_ok - ); - } -} - -/// Spike trace: fire 200 sequential queries through reqwest and log every one -/// with a timestamp. Analyze the distribution and find spike clusters. -fn run_spike_trace(rt: &tokio::runtime::Runtime) { - println!("Spike trace: 200 sequential reqwest DoH queries"); - println!("Target: {DOH_UPSTREAM}\n"); - - let upstream = - numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse upstream"); - let timeout = Duration::from_secs(10); - - // Warm - for _ in 0..5 { - let w = build_query_vec("example.com"); - let _ = rt.block_on(numa::forward::forward_query_raw(&w, &upstream, timeout)); - } - - // Run the entire 200-query loop inside ONE block_on to eliminate - // per-query runtime re-entry overhead. - let samples: Vec<(u128, f64)> = rt.block_on(async { - let test_start = Instant::now(); - let mut s = Vec::with_capacity(200); - for i in 0..200 { - let domain = match i % 5 { - 0 => "example.com", - 1 => "google.com", - 2 => "github.com", - 3 => "rust-lang.org", - _ => "cloudflare.com", - }; - let wire = build_query_vec(domain); - let req_start = Instant::now(); - let t_from_start_us = test_start.elapsed().as_micros(); - let _ = numa::forward::forward_query_raw(&wire, &upstream, timeout).await; - let ms = req_start.elapsed().as_secs_f64() * 1000.0; - s.push((t_from_start_us, ms)); - } - s - }); - - // Compute stats - let mut sorted_times: Vec = samples.iter().map(|(_, t)| *t).collect(); - sorted_times.sort_by(|a, b| a.partial_cmp(b).unwrap()); - let n = sorted_times.len(); - let median = sorted_times[n / 2]; - let p90 = sorted_times[(n * 90) / 100]; - let p95 = sorted_times[(n * 95) / 100]; - let p99 = sorted_times[(n * 99) / 100]; - let max = sorted_times[n - 1]; - let mean: f64 = sorted_times.iter().sum::() / n as f64; - - println!("Distribution (n={}):", n); - println!(" mean: {:.1} ms", mean); - println!(" median: {:.1} ms", median); - println!(" p90: {:.1} ms", p90); - println!(" p95: {:.1} ms", p95); - println!(" p99: {:.1} ms", p99); - println!(" max: {:.1} ms", max); - println!(); - - // Define spike threshold as 3x median - let spike_threshold = median * 3.0; - let spikes: Vec<(usize, u128, f64)> = samples - .iter() - .enumerate() - .filter(|(_, (_, t))| *t > spike_threshold) - .map(|(i, (ts, t))| (i, *ts, *t)) - .collect(); - - println!("Spikes (> {:.1}ms, which is 3x median):", spike_threshold); - println!(" count: {}", spikes.len()); - if spikes.is_empty() { - return; - } - - // Inter-spike gaps (time between spikes) - let mut gaps_ms: Vec = Vec::new(); - for w in spikes.windows(2) { - let gap_us = w[1].1 - w[0].1; - gaps_ms.push(gap_us as f64 / 1000.0); - } - - println!(); - println!(" {:>4} {:>12} {:>10} {:>12}", "idx", "at (ms)", "latency", "gap from prev"); - for (i, ((idx, ts, latency), gap)) in spikes.iter().zip( - std::iter::once(&0.0).chain(gaps_ms.iter()) - ).enumerate() { - let _ = i; - let gap_str = if *gap > 0.0 { - format!("{:.0} ms", gap) - } else { - "-".to_string() - }; - println!(" {:>4} {:>9.1} {:>6.1} ms {:>12}", idx, *ts as f64 / 1000.0, latency, gap_str); - } - - if !gaps_ms.is_empty() { - let gap_mean: f64 = gaps_ms.iter().sum::() / gaps_ms.len() as f64; - let mut gap_sorted = gaps_ms.clone(); - gap_sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); - let gap_median = gap_sorted[gap_sorted.len() / 2]; - println!(); - println!(" Inter-spike gap: mean={:.0}ms, median={:.0}ms", gap_mean, gap_median); - } -} - -/// Spike phases: time each step of the reqwest DoH call to find which phase -/// is slow during a spike. Reports (build+send, send->resp headers, body read). -fn run_spike_phases(rt: &tokio::runtime::Runtime) { - println!("Spike phases: timing each phase of reqwest DoH call"); - println!("Target: {DOH_UPSTREAM}\n"); - - // Build the same tuned client our forward_doh uses - let client = reqwest::Client::builder() - .use_rustls_tls() - .http2_initial_stream_window_size(65_535) - .http2_initial_connection_window_size(65_535) - .http2_keep_alive_interval(Duration::from_secs(15)) - .http2_keep_alive_while_idle(true) - .http2_keep_alive_timeout(Duration::from_secs(10)) - .pool_idle_timeout(Duration::from_secs(300)) - .pool_max_idle_per_host(1) - .build() - .unwrap(); - - // Warm up - for _ in 0..5 { - let wire = build_query_vec("example.com"); - let _ = rt.block_on(async { - client - .post(DOH_UPSTREAM) - .header("content-type", "application/dns-message") - .header("accept", "application/dns-message") - .body(wire) - .send() - .await - .ok()? - .bytes() - .await - .ok() - }); - } - - println!("{:>4} {:>8} {:>8} {:>8} {:>8}", "idx", "total", "build", "send", "body"); - println!("{}", "-".repeat(50)); - - let samples: Vec<(f64, f64, f64, f64)> = rt.block_on(async { - let mut s = Vec::with_capacity(200); - for i in 0..200 { - let domain = match i % 5 { - 0 => "example.com", - 1 => "google.com", - 2 => "github.com", - 3 => "rust-lang.org", - _ => "cloudflare.com", - }; - let wire = build_query_vec(domain); - - let t0 = Instant::now(); - // Phase 1: build the request - let req = client - .post(DOH_UPSTREAM) - .header("content-type", "application/dns-message") - .header("accept", "application/dns-message") - .body(wire); - let t1 = Instant::now(); - // Phase 2: send() — this is the dispatch channel + round trip to headers - let resp_result = req.send().await; - let t2 = Instant::now(); - // Phase 3: read body - let body_result = match resp_result { - Ok(r) => r.bytes().await.ok().map(|b| b.len()), - Err(_) => None, - }; - let t3 = Instant::now(); - - let build_ms = (t1 - t0).as_secs_f64() * 1000.0; - let send_ms = (t2 - t1).as_secs_f64() * 1000.0; - let body_ms = (t3 - t2).as_secs_f64() * 1000.0; - let total_ms = (t3 - t0).as_secs_f64() * 1000.0; - - s.push((total_ms, build_ms, send_ms, body_ms)); - let _ = body_result; - } - s - }); - - // Compute distribution on total - let mut totals: Vec = samples.iter().map(|s| s.0).collect(); - totals.sort_by(|a, b| a.partial_cmp(b).unwrap()); - let median = totals[100]; - - // Print spikes (> 3x median) with phase breakdown - for (i, (total, build, send, body)) in samples.iter().enumerate() { - if *total > median * 3.0 { - println!( - "{:>4} {:>5.1} ms {:>5.1} ms {:>5.1} ms {:>5.1} ms", - i, total, build, send, body - ); - } - } - - // Summary: mean of each phase for spikes vs non-spikes - let (spike_samples, normal_samples): (Vec<_>, Vec<_>) = samples - .iter() - .partition(|(t, _, _, _)| *t > median * 3.0); - - let phase_means = |samples: &[&(f64, f64, f64, f64)]| -> (f64, f64, f64, f64) { - let n = samples.len() as f64; - if n == 0.0 { return (0.0, 0.0, 0.0, 0.0); } - let total: f64 = samples.iter().map(|s| s.0).sum::() / n; - let build: f64 = samples.iter().map(|s| s.1).sum::() / n; - let send: f64 = samples.iter().map(|s| s.2).sum::() / n; - let body: f64 = samples.iter().map(|s| s.3).sum::() / n; - (total, build, send, body) - }; - - let spike_refs: Vec<&(f64, f64, f64, f64)> = spike_samples.iter().copied().collect(); - let normal_refs: Vec<&(f64, f64, f64, f64)> = normal_samples.iter().copied().collect(); - let (s_total, s_build, s_send, s_body) = phase_means(&spike_refs); - let (n_total, n_build, n_send, n_body) = phase_means(&normal_refs); - - println!(); - println!("Summary (mean ms):"); - println!( - " {:<8} {:>8} {:>8} {:>8} {:>8}", - "", "total", "build", "send", "body" - ); - println!( - " {:<8} {:>5.1} ms {:>5.1} ms {:>5.1} ms {:>5.1} ms (n={})", - "normal", n_total, n_build, n_send, n_body, normal_refs.len() - ); - println!( - " {:<8} {:>5.1} ms {:>5.1} ms {:>5.1} ms {:>5.1} ms (n={})", - "spike", s_total, s_build, s_send, s_body, spike_refs.len() - ); - println!(); - println!("Delta (spike - normal):"); - println!( - " build: {:+.1} ms, send: {:+.1} ms, body: {:+.1} ms", - s_build - n_build, - s_send - n_send, - s_body - n_body - ); -} - -/// Heartbeat probe: run a parallel task that ticks every 5ms and records -/// how long each tick actually takes. If the heartbeat stalls during a DoH -/// spike, it's a tokio scheduling issue (runtime can't poll tasks). If -/// heartbeat is fine while send() is stuck, it's internal to hyper/h2. -fn run_spike_heartbeat(rt: &tokio::runtime::Runtime) { - use std::sync::{Arc, Mutex}; - - println!("Spike heartbeat probe"); - println!("Running DoH queries + parallel 5ms heartbeat task\n"); - - let upstream = - numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse upstream"); - let timeout = Duration::from_secs(10); - - // Warm up - for _ in 0..5 { - let w = build_query_vec("example.com"); - let _ = rt.block_on(numa::forward::forward_query_raw(&w, &upstream, timeout)); - } - - // Shared vecs: (relative_ms_from_start, event_kind, latency_ms) - // event_kind: 0 = heartbeat, 1 = doh query - type EventLog = Vec<(f64, u8, f64)>; - let events: Arc> = Arc::new(Mutex::new(Vec::with_capacity(2000))); - let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); - - let test_start = Instant::now(); - - rt.block_on(async { - // Spawn heartbeat task - let hb_events = Arc::clone(&events); - let hb_stop = Arc::clone(&stop); - let hb_start = test_start; - let heartbeat = tokio::spawn(async move { - let mut next_tick = Instant::now(); - let target = Duration::from_millis(5); - while !hb_stop.load(std::sync::atomic::Ordering::Relaxed) { - next_tick += target; - // Sleep until the next scheduled tick - let now = Instant::now(); - if next_tick > now { - tokio::time::sleep(next_tick - now).await; - } - // Measure how much we overshot the scheduled tick - let actual = Instant::now(); - let lag_ms = if actual > next_tick { - (actual - next_tick).as_secs_f64() * 1000.0 - } else { - 0.0 - }; - let t = (actual - hb_start).as_secs_f64() * 1000.0; - if let Ok(mut e) = hb_events.lock() { - e.push((t, 0, lag_ms)); - } - } - }); - - // Run 200 DoH queries and record their timings - for i in 0..200 { - let domain = match i % 5 { - 0 => "example.com", - 1 => "google.com", - 2 => "github.com", - 3 => "rust-lang.org", - _ => "cloudflare.com", - }; - let wire = build_query_vec(domain); - let req_start = Instant::now(); - let _ = numa::forward::forward_query_raw(&wire, &upstream, timeout).await; - let elapsed = req_start.elapsed().as_secs_f64() * 1000.0; - let t = (req_start - test_start).as_secs_f64() * 1000.0; - if let Ok(mut e) = events.lock() { - e.push((t, 1, elapsed)); - } - } - - stop.store(true, std::sync::atomic::Ordering::Relaxed); - let _ = heartbeat.await; - }); - - let events = events.lock().unwrap(); - - // Separate heartbeats and doh events - let hb: Vec<(f64, f64)> = events - .iter() - .filter(|(_, k, _)| *k == 0) - .map(|(t, _, l)| (*t, *l)) - .collect(); - let doh: Vec<(f64, f64)> = events - .iter() - .filter(|(_, k, _)| *k == 1) - .map(|(t, _, l)| (*t, *l)) - .collect(); - - // Heartbeat stats - let mut hb_lags: Vec = hb.iter().map(|(_, l)| *l).collect(); - hb_lags.sort_by(|a, b| a.partial_cmp(b).unwrap()); - let hb_n = hb_lags.len(); - let hb_median = hb_lags[hb_n / 2]; - let hb_p95 = hb_lags[(hb_n * 95) / 100]; - let hb_p99 = hb_lags[(hb_n * 99) / 100]; - let hb_max = hb_lags[hb_n - 1]; - - // DoH stats - let mut doh_latencies: Vec = doh.iter().map(|(_, l)| *l).collect(); - doh_latencies.sort_by(|a, b| a.partial_cmp(b).unwrap()); - let doh_n = doh_latencies.len(); - let doh_median = doh_latencies[doh_n / 2]; - let doh_p95 = doh_latencies[(doh_n * 95) / 100]; - let doh_max = doh_latencies[doh_n - 1]; - - println!("Heartbeat lag (tick overshoot, {}ms target):", 5); - println!(" n: {}", hb_n); - println!(" median: {:.2} ms", hb_median); - println!(" p95: {:.2} ms", hb_p95); - println!(" p99: {:.2} ms", hb_p99); - println!(" max: {:.2} ms", hb_max); - println!(); - println!("DoH latency:"); - println!(" n: {}", doh_n); - println!(" median: {:.1} ms", doh_median); - println!(" p95: {:.1} ms", doh_p95); - println!(" max: {:.1} ms", doh_max); - println!(); - - // Find DoH spikes and check heartbeat activity DURING each spike - let doh_spike_threshold = doh_median * 3.0; - let mut spikes_with_hb_lag = 0; - let mut spikes_total = 0; - let mut max_hb_during_any_spike = 0.0_f64; - - println!( - "Correlation: during each DoH spike (>{:.1}ms), max heartbeat lag:", - doh_spike_threshold - ); - println!(" {:>6} {:>10} {:>18}", "doh_at", "doh_ms", "max_hb_lag_during"); - - for (doh_t, doh_ms) in &doh { - if *doh_ms > doh_spike_threshold { - spikes_total += 1; - // Find heartbeats that happened during this DoH query - let spike_start = *doh_t; - let spike_end = spike_start + *doh_ms; - let mut max_hb = 0.0_f64; - for (hb_t, hb_lag) in &hb { - if *hb_t >= spike_start && *hb_t <= spike_end + 20.0 { - if *hb_lag > max_hb { - max_hb = *hb_lag; - } - } - } - if max_hb > 5.0 { - spikes_with_hb_lag += 1; - } - max_hb_during_any_spike = max_hb_during_any_spike.max(max_hb); - println!( - " {:>5.0} ms {:>7.1} ms {:>14.2} ms", - doh_t, doh_ms, max_hb - ); - } - } - - println!(); - println!("Conclusion:"); - if spikes_total == 0 { - println!(" No DoH spikes in this run."); - } else { - let pct = (spikes_with_hb_lag as f64 / spikes_total as f64 * 100.0).round(); - println!( - " {}/{} spikes ({:.0}%) had concurrent heartbeat lag >5ms.", - spikes_with_hb_lag, spikes_total, pct - ); - println!(" Max heartbeat lag during any spike: {:.2}ms", max_hb_during_any_spike); - println!(); - if max_hb_during_any_spike > 20.0 { - println!(" → Heartbeat stalls during DoH spikes: tokio scheduling / OS thread issue."); - println!(" The runtime can't poll ANY task — likely QoS demotion, GC pause,"); - println!(" or the worker thread is blocked somewhere."); - } else { - println!(" → Heartbeat runs normally during DoH spikes: internal to hyper/h2."); - println!(" The runtime is fine, but send()'s await is stuck waiting for"); - println!(" the ClientTask to poll the dispatch channel."); - } - } -} - -/// Hedging benchmark: tests four configurations against Hickory. -/// Single: 1 client → Quad9 (baseline) -/// Hedge-same: hedge against same client/connection → Quad9 -/// Hedge-dual: hedge against 2 separate clients, both → Quad9 (same upstream, 2 HTTP/2 conns) -/// Hickory: Hickory resolver → Quad9 (reference) -fn run_hedge(rt: &tokio::runtime::Runtime) { - let hedge_delay = Duration::from_millis(10); - - println!("Hedging Benchmark (all paths → Quad9 only)"); - println!("Upstream: {}", DOH_UPSTREAM); - println!("Hedge delay: {:?}", hedge_delay); - println!("{} domains × {} rounds\n", DOMAINS.len(), ROUNDS); - - // Primary and secondary: two separate reqwest clients → same Quad9 URL. - // This gives two independent HTTP/2 connections, so dispatch spikes - // are uncorrelated (at most one stalls at a time). - let primary_same = - numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse primary"); - let primary_dual = - numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse primary_dual"); - let secondary_dual = - numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse secondary_dual"); - let timeout = Duration::from_secs(10); - - let resolver = rt.block_on(build_hickory_resolver()); - - // Warm up all paths (separate connections need their own TLS handshake) - println!("Warming up connections..."); - for _ in 0..5 { - let w = build_query_vec("example.com"); - let _ = rt.block_on(numa::forward::forward_query_raw(&w, &primary_same, timeout)); - let _ = rt.block_on(numa::forward::forward_query_raw(&w, &primary_dual, timeout)); - let _ = rt.block_on(numa::forward::forward_query_raw(&w, &secondary_dual, timeout)); - let _ = rt.block_on(query_hickory_doh(&resolver, "example.com")); - } - - let mut single_all = Vec::new(); - let mut hedge_same_all = Vec::new(); - let mut hedge_dual_all = Vec::new(); - let mut hickory_all = Vec::new(); - - println!( - "{:<24} {:>10} {:>10} {:>10} {:>10}", - "Domain", "Single", "Hedge-same", "Hedge-dual", "Hickory" - ); - println!("{}", "-".repeat(78)); - - for domain in DOMAINS { - let mut single_times = Vec::with_capacity(ROUNDS); - let mut hedge_same_times = Vec::with_capacity(ROUNDS); - let mut hedge_dual_times = Vec::with_capacity(ROUNDS); - let mut hickory_times = Vec::with_capacity(ROUNDS); - - for _ in 0..ROUNDS { - let wire = build_query_vec(domain); - - let t = Instant::now(); - let _ = rt.block_on(numa::forward::forward_query_raw(&wire, &primary_same, timeout)); - single_times.push(t.elapsed().as_secs_f64() * 1000.0); - - let t = Instant::now(); - let _ = rt.block_on(numa::forward::forward_with_hedging_raw( - &wire, &primary_same, &primary_same, hedge_delay, timeout, - )); - hedge_same_times.push(t.elapsed().as_secs_f64() * 1000.0); - - let t = Instant::now(); - let _ = rt.block_on(numa::forward::forward_with_hedging_raw( - &wire, &primary_dual, &secondary_dual, hedge_delay, timeout, - )); - hedge_dual_times.push(t.elapsed().as_secs_f64() * 1000.0); - - let t = Instant::now(); - let _ = rt.block_on(query_hickory_doh(&resolver, domain)); - hickory_times.push(t.elapsed().as_secs_f64() * 1000.0); - } - - single_all.extend_from_slice(&single_times); - hedge_same_all.extend_from_slice(&hedge_same_times); - hedge_dual_all.extend_from_slice(&hedge_dual_times); - hickory_all.extend_from_slice(&hickory_times); - - println!( - "{:<24} {:>7.1} ms {:>7.1} ms {:>7.1} ms {:>7.1} ms", + i + 1, domain, - mean(&single_times), - mean(&hedge_same_times), - mean(&hedge_dual_times), - mean(&hickory_times) + r_ms, + r_ok, + h_ms, + h_ok ); } - - println!("{}", "-".repeat(78)); - - let stats = |all: &mut Vec| -> (f64, f64, f64, f64, f64) { - let m = mean(all); - let med = median(all); - let p95 = percentile(all, 95.0); - let p99 = percentile(all, 99.0); - let sd = stddev(all); - (m, med, p95, p99, sd) - }; - - let (s_m, s_med, s_p95, s_p99, s_sd) = stats(&mut single_all); - let (hs_m, hs_med, hs_p95, hs_p99, hs_sd) = stats(&mut hedge_same_all); - let (hd_m, hd_med, hd_p95, hd_p99, hd_sd) = stats(&mut hedge_dual_all); - let (k_m, k_med, k_p95, k_p99, k_sd) = stats(&mut hickory_all); - - println!(); - println!( - "{:<10} {:>10} {:>10} {:>10} {:>10}", - "", "Single", "Hedge-same", "Hedge-dual", "Hickory" - ); - println!( - "{:<10} {:>7.1} ms {:>7.1} ms {:>7.1} ms {:>7.1} ms", - "mean", s_m, hs_m, hd_m, k_m - ); - println!( - "{:<10} {:>7.1} ms {:>7.1} ms {:>7.1} ms {:>7.1} ms", - "median", s_med, hs_med, hd_med, k_med - ); - println!( - "{:<10} {:>7.1} ms {:>7.1} ms {:>7.1} ms {:>7.1} ms", - "p95", s_p95, hs_p95, hd_p95, k_p95 - ); - println!( - "{:<10} {:>7.1} ms {:>7.1} ms {:>7.1} ms {:>7.1} ms", - "p99", s_p99, hs_p99, hd_p99, k_p99 - ); - println!( - "{:<10} {:>7.1} ms {:>7.1} ms {:>7.1} ms {:>7.1} ms", - "σ", s_sd, hs_sd, hd_sd, k_sd - ); - - println!(); - println!("Hedge-same improvement over single:"); - println!(" mean: {:+.0}%, p95: {:+.0}%, p99: {:+.0}%", - (hs_m - s_m) / s_m * 100.0, - (hs_p95 - s_p95) / s_p95 * 100.0, - (hs_p99 - s_p99) / s_p99 * 100.0); - println!("Hedge-dual improvement over single:"); - println!(" mean: {:+.0}%, p95: {:+.0}%, p99: {:+.0}%", - (hd_m - s_m) / s_m * 100.0, - (hd_p95 - s_p95) / s_p95 * 100.0, - (hd_p99 - s_p99) / s_p99 * 100.0); } -/// Run the hedging benchmark N times and aggregate samples across all runs. -/// Also reports per-run stats to show drift. -fn run_hedge_multi(rt: &tokio::runtime::Runtime, iterations: usize) { - let hedge_delay = Duration::from_millis(10); +// ── Stats helpers ─────────────────────────────────────────────── - println!("Hedging Benchmark × {} iterations", iterations); - println!("Upstream: {}", DOH_UPSTREAM); - println!("Hedge delay: {:?}", hedge_delay); - println!("{} domains × {} rounds per iteration\n", DOMAINS.len(), ROUNDS); - - let primary_same = - numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse"); - let primary_dual = - numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse"); - let secondary_dual = - numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse"); - let timeout = Duration::from_secs(10); - - let resolver = rt.block_on(build_hickory_resolver()); - - // Warm up - println!("Warming up..."); - for _ in 0..5 { - let w = build_query_vec("example.com"); - let _ = rt.block_on(numa::forward::forward_query_raw(&w, &primary_same, timeout)); - let _ = rt.block_on(numa::forward::forward_query_raw(&w, &primary_dual, timeout)); - let _ = rt.block_on(numa::forward::forward_query_raw(&w, &secondary_dual, timeout)); - let _ = rt.block_on(query_hickory_doh(&resolver, "example.com")); +fn stats(v: &mut [f64]) -> (f64, f64, f64, f64, f64) { + if v.is_empty() { + return (0.0, 0.0, 0.0, 0.0, 0.0); } - - // Accumulated samples across all iterations - let mut all_single = Vec::new(); - let mut all_hedge_same = Vec::new(); - let mut all_hedge_dual = Vec::new(); - let mut all_hickory = Vec::new(); - - // Per-iteration summary stats - let mut iter_stats: Vec<[(f64, f64, f64, f64, f64); 4]> = Vec::new(); - - for iter in 1..=iterations { - println!(" iteration {}/{}...", iter, iterations); - - let mut single = Vec::new(); - let mut hedge_same = Vec::new(); - let mut hedge_dual = Vec::new(); - let mut hickory = Vec::new(); - - for domain in DOMAINS { - for _ in 0..ROUNDS { - let wire = build_query_vec(domain); - - let t = Instant::now(); - let _ = rt.block_on(numa::forward::forward_query_raw(&wire, &primary_same, timeout)); - single.push(t.elapsed().as_secs_f64() * 1000.0); - - let t = Instant::now(); - let _ = rt.block_on(numa::forward::forward_with_hedging_raw( - &wire, &primary_same, &primary_same, hedge_delay, timeout, - )); - hedge_same.push(t.elapsed().as_secs_f64() * 1000.0); - - let t = Instant::now(); - let _ = rt.block_on(numa::forward::forward_with_hedging_raw( - &wire, &primary_dual, &secondary_dual, hedge_delay, timeout, - )); - hedge_dual.push(t.elapsed().as_secs_f64() * 1000.0); - - let t = Instant::now(); - let _ = rt.block_on(query_hickory_doh(&resolver, domain)); - hickory.push(t.elapsed().as_secs_f64() * 1000.0); - } - } - - let stats = |v: &mut Vec| -> (f64, f64, f64, f64, f64) { - (mean(v), median(v), percentile(v, 95.0), percentile(v, 99.0), stddev(v)) - }; - iter_stats.push([ - stats(&mut single), - stats(&mut hedge_same), - stats(&mut hedge_dual), - stats(&mut hickory), - ]); - - all_single.extend_from_slice(&single); - all_hedge_same.extend_from_slice(&hedge_same); - all_hedge_dual.extend_from_slice(&hedge_dual); - all_hickory.extend_from_slice(&hickory); - } - - println!(); - println!("=== Per-iteration medians (drift check) ==="); - println!( - "{:<8} {:>10} {:>12} {:>12} {:>10}", - "iter", "Single", "Hedge-same", "Hedge-dual", "Hickory" - ); - for (i, s) in iter_stats.iter().enumerate() { - println!( - "{:<8} {:>7.1} ms {:>9.1} ms {:>9.1} ms {:>7.1} ms", - i + 1, - s[0].1, - s[1].1, - s[2].1, - s[3].1 - ); - } - - println!(); - println!("=== Per-iteration p99 (drift check) ==="); - println!( - "{:<8} {:>10} {:>12} {:>12} {:>10}", - "iter", "Single", "Hedge-same", "Hedge-dual", "Hickory" - ); - for (i, s) in iter_stats.iter().enumerate() { - println!( - "{:<8} {:>7.1} ms {:>9.1} ms {:>9.1} ms {:>7.1} ms", - i + 1, - s[0].3, - s[1].3, - s[2].3, - s[3].3 - ); - } - - let final_stats = |v: &mut Vec| -> (f64, f64, f64, f64, f64) { - (mean(v), median(v), percentile(v, 95.0), percentile(v, 99.0), stddev(v)) - }; - let (s_m, s_med, s_p95, s_p99, s_sd) = final_stats(&mut all_single); - let (hs_m, hs_med, hs_p95, hs_p99, hs_sd) = final_stats(&mut all_hedge_same); - let (hd_m, hd_med, hd_p95, hd_p99, hd_sd) = final_stats(&mut all_hedge_dual); - let (k_m, k_med, k_p95, k_p99, k_sd) = final_stats(&mut all_hickory); - - println!(); - let total = iterations * DOMAINS.len() * ROUNDS; - println!("=== Aggregated across all {} samples per method ===", total); - println!(); - println!( - "{:<10} {:>10} {:>12} {:>12} {:>10}", - "", "Single", "Hedge-same", "Hedge-dual", "Hickory" - ); - println!( - "{:<10} {:>7.1} ms {:>9.1} ms {:>9.1} ms {:>7.1} ms", - "mean", s_m, hs_m, hd_m, k_m - ); - println!( - "{:<10} {:>7.1} ms {:>9.1} ms {:>9.1} ms {:>7.1} ms", - "median", s_med, hs_med, hd_med, k_med - ); - println!( - "{:<10} {:>7.1} ms {:>9.1} ms {:>9.1} ms {:>7.1} ms", - "p95", s_p95, hs_p95, hd_p95, k_p95 - ); - println!( - "{:<10} {:>7.1} ms {:>9.1} ms {:>9.1} ms {:>7.1} ms", - "p99", s_p99, hs_p99, hd_p99, k_p99 - ); - println!( - "{:<10} {:>7.1} ms {:>9.1} ms {:>9.1} ms {:>7.1} ms", - "σ", s_sd, hs_sd, hd_sd, k_sd - ); - - println!(); - println!("Hedge-same vs Single: mean {:+.0}%, p95 {:+.0}%, p99 {:+.0}%", - (hs_m - s_m) / s_m * 100.0, - (hs_p95 - s_p95) / s_p95 * 100.0, - (hs_p99 - s_p99) / s_p99 * 100.0); - println!("Hedge-dual vs Single: mean {:+.0}%, p95 {:+.0}%, p99 {:+.0}%", - (hd_m - s_m) / s_m * 100.0, - (hd_p95 - s_p95) / s_p95 * 100.0, - (hd_p99 - s_p99) / s_p99 * 100.0); - println!("Hedge-same vs Hickory: mean {:+.0}%, p95 {:+.0}%, p99 {:+.0}%", - (hs_m - k_m) / k_m * 100.0, - (hs_p95 - k_p95) / k_p95 * 100.0, - (hs_p99 - k_p99) / k_p99 * 100.0); -} - -/// Server-to-server benchmark: Numa vs dnscrypt-proxy vs Unbound. -/// All are full servers: UDP in, encrypted forwarding to Quad9. -/// Numa + dnscrypt: DoH (HTTPS). Unbound: DoT (TLS port 853). -fn run_vs_dnscrypt(rt: &tokio::runtime::Runtime, iterations: usize) { - const DNSCRYPT_ADDR: &str = "127.0.0.1:5455"; - const UNBOUND_ADDR: &str = "127.0.0.1:5456"; - let numa_addr: SocketAddr = NUMA_BENCH.parse().unwrap(); - let dnscrypt_addr: SocketAddr = DNSCRYPT_ADDR.parse().unwrap(); - let unbound_addr: SocketAddr = UNBOUND_ADDR.parse().unwrap(); - - println!("Server-to-Server: Numa vs dnscrypt-proxy vs Unbound"); - println!("Numa (DoH): {}", NUMA_BENCH); - println!("dnscrypt-proxy (DoH): {}", DNSCRYPT_ADDR); - println!("Unbound (DoT): {}", UNBOUND_ADDR); - println!("All forwarding to Quad9 over encrypted transport"); - println!("{} domains × {} rounds × {} iterations\n", - DOMAINS.len(), ROUNDS, iterations); - - // Verify all are up - let servers: Vec<(&str, SocketAddr)> = vec![ - ("Numa", numa_addr), - ("dnscrypt-proxy", dnscrypt_addr), - ("Unbound", unbound_addr), - ]; - for (name, addr) in &servers { - if rt.block_on(query_udp(*addr, "example.com")).is_none() { - eprintln!("{} not responding on {}", name, addr); - std::process::exit(1); - } - } - println!("All servers reachable.\n"); - - // Warm up - println!("Warming up..."); - for _ in 0..5 { - for (_, addr) in &servers { - let _ = rt.block_on(query_udp(*addr, "example.com")); - } - } - - let mut all_numa = Vec::new(); - let mut all_dnscrypt = Vec::new(); - let mut all_unbound = Vec::new(); - let mut iter_stats: Vec<[(f64, f64, f64, f64, f64); 3]> = Vec::new(); - - for iter in 1..=iterations { - println!(" iteration {}/{}...", iter, iterations); - - let mut numa = Vec::new(); - let mut dnscrypt = Vec::new(); - let mut unbound = Vec::new(); - - for domain in DOMAINS { - for round in 0..ROUNDS { - flush_cache(); - std::thread::sleep(Duration::from_millis(5)); - - // Rotate order: 3 servers, 3 possible orderings - let order = round % 3; - let mut measure = |addr: SocketAddr| -> f64 { - let t = Instant::now(); - let _ = rt.block_on(query_udp(addr, domain)); - t.elapsed().as_secs_f64() * 1000.0 - }; - - match order { - 0 => { - numa.push(measure(numa_addr)); - dnscrypt.push(measure(dnscrypt_addr)); - unbound.push(measure(unbound_addr)); - } - 1 => { - dnscrypt.push(measure(dnscrypt_addr)); - unbound.push(measure(unbound_addr)); - numa.push(measure(numa_addr)); - } - _ => { - unbound.push(measure(unbound_addr)); - numa.push(measure(numa_addr)); - dnscrypt.push(measure(dnscrypt_addr)); - } - } - } - } - - let stats = |v: &mut Vec| -> (f64, f64, f64, f64, f64) { - (mean(v), median(v), percentile(v, 95.0), percentile(v, 99.0), stddev(v)) - }; - iter_stats.push([stats(&mut numa), stats(&mut dnscrypt), stats(&mut unbound)]); - - all_numa.extend_from_slice(&numa); - all_dnscrypt.extend_from_slice(&dnscrypt); - all_unbound.extend_from_slice(&unbound); - } - - println!(); - println!("=== Per-iteration medians ==="); - println!("{:<8} {:>10} {:>14} {:>10}", "iter", "Numa", "dnscrypt-proxy", "Unbound"); - for (i, s) in iter_stats.iter().enumerate() { - println!("{:<8} {:>7.1} ms {:>11.1} ms {:>7.1} ms", - i + 1, s[0].1, s[1].1, s[2].1); - } - - println!(); - println!("=== Per-iteration p99 ==="); - println!("{:<8} {:>10} {:>14} {:>10}", "iter", "Numa", "dnscrypt-proxy", "Unbound"); - for (i, s) in iter_stats.iter().enumerate() { - println!("{:<8} {:>7.1} ms {:>11.1} ms {:>7.1} ms", - i + 1, s[0].3, s[1].3, s[2].3); - } - - let stats = |v: &mut Vec| -> (f64, f64, f64, f64, f64) { - (mean(v), median(v), percentile(v, 95.0), percentile(v, 99.0), stddev(v)) - }; - let (n_m, n_med, n_p95, n_p99, n_sd) = stats(&mut all_numa); - let (d_m, d_med, d_p95, d_p99, d_sd) = stats(&mut all_dnscrypt); - let (u_m, u_med, u_p95, u_p99, u_sd) = stats(&mut all_unbound); - - println!(); - let total = iterations * DOMAINS.len() * ROUNDS; - println!("=== Aggregated ({} samples per method) ===", total); - println!(); - println!("{:<10} {:>10} {:>14} {:>10}", "", "Numa", "dnscrypt-proxy", "Unbound"); - println!("{:<10} {:>7.1} ms {:>11.1} ms {:>7.1} ms", "mean", n_m, d_m, u_m); - println!("{:<10} {:>7.1} ms {:>11.1} ms {:>7.1} ms", "median", n_med, d_med, u_med); - println!("{:<10} {:>7.1} ms {:>11.1} ms {:>7.1} ms", "p95", n_p95, d_p95, u_p95); - println!("{:<10} {:>7.1} ms {:>11.1} ms {:>7.1} ms", "p99", n_p99, d_p99, u_p99); - println!("{:<10} {:>7.1} ms {:>11.1} ms {:>7.1} ms", "σ", n_sd, d_sd, u_sd); - println!(); - - println!("Numa vs dnscrypt-proxy:"); - println!(" mean: {:+.0}%, median: {:+.0}%, p99: {:+.0}%", - (n_m - d_m) / d_m * 100.0, (n_med - d_med) / d_med * 100.0, (n_p99 - d_p99) / d_p99 * 100.0); - println!("Numa vs Unbound:"); - println!(" mean: {:+.0}%, median: {:+.0}%, p99: {:+.0}%", - (n_m - u_m) / u_m * 100.0, (n_med - u_med) / u_med * 100.0, (n_p99 - u_p99) / u_p99 * 100.0); -} - -/// Numa vs Unbound: both forward over plain UDP to Quad9, caching enabled. -/// Truly equal transport — no TLS, no HTTP/2, pure forwarding + cache. -fn run_vs_unbound(rt: &tokio::runtime::Runtime, iterations: usize) { - const UNBOUND_ADDR: &str = "127.0.0.1:5456"; - let numa_addr: SocketAddr = NUMA_BENCH.parse().unwrap(); - let unbound_addr: SocketAddr = UNBOUND_ADDR.parse().unwrap(); - - println!("Numa vs Unbound (both plain UDP forwarding to Quad9, caching enabled)"); - println!("Numa: {} → 9.9.9.9:53 UDP", NUMA_BENCH); - println!("Unbound: {} → 9.9.9.9:53 UDP", UNBOUND_ADDR); - println!("{} domains × {} rounds × {} iterations\n", - DOMAINS.len(), ROUNDS, iterations); - - if rt.block_on(query_udp(numa_addr, "example.com")).is_none() { - eprintln!("Numa not responding"); std::process::exit(1); - } - if rt.block_on(query_udp(unbound_addr, "example.com")).is_none() { - eprintln!("Unbound not responding"); std::process::exit(1); - } - println!("Both servers reachable.\n"); - - println!("Warming up..."); - for _ in 0..5 { - let _ = rt.block_on(query_udp(numa_addr, "example.com")); - let _ = rt.block_on(query_udp(unbound_addr, "example.com")); - } - - let mut all_numa = Vec::new(); - let mut all_unbound = Vec::new(); - let mut iter_stats: Vec<[(f64, f64, f64, f64, f64); 2]> = Vec::new(); - - for iter in 1..=iterations { - println!(" iteration {}/{}...", iter, iterations); - - let mut numa = Vec::new(); - let mut unbound = Vec::new(); - - for domain in DOMAINS { - for round in 0..ROUNDS { - // No cache flushing — both serve from cache after first hit - let mut measure = |addr: SocketAddr| -> f64 { - let t = Instant::now(); - let _ = rt.block_on(query_udp(addr, domain)); - t.elapsed().as_secs_f64() * 1000.0 - }; - - if round % 2 == 0 { - numa.push(measure(numa_addr)); - unbound.push(measure(unbound_addr)); - } else { - unbound.push(measure(unbound_addr)); - numa.push(measure(numa_addr)); - } - } - } - - let stats = |v: &mut Vec| -> (f64, f64, f64, f64, f64) { - (mean(v), median(v), percentile(v, 95.0), percentile(v, 99.0), stddev(v)) - }; - iter_stats.push([stats(&mut numa), stats(&mut unbound)]); - - all_numa.extend_from_slice(&numa); - all_unbound.extend_from_slice(&unbound); - } - - println!(); - println!("=== Per-iteration medians ==="); - println!("{:<8} {:>10} {:>10}", "iter", "Numa", "Unbound"); - for (i, s) in iter_stats.iter().enumerate() { - println!("{:<8} {:>7.1} ms {:>7.1} ms", i + 1, s[0].1, s[1].1); - } - - println!(); - println!("=== Per-iteration p99 ==="); - println!("{:<8} {:>10} {:>10}", "iter", "Numa", "Unbound"); - for (i, s) in iter_stats.iter().enumerate() { - println!("{:<8} {:>7.1} ms {:>7.1} ms", i + 1, s[0].3, s[1].3); - } - - let stats = |v: &mut Vec| -> (f64, f64, f64, f64, f64) { - (mean(v), median(v), percentile(v, 95.0), percentile(v, 99.0), stddev(v)) - }; - let (n_m, n_med, n_p95, n_p99, n_sd) = stats(&mut all_numa); - let (u_m, u_med, u_p95, u_p99, u_sd) = stats(&mut all_unbound); - - println!(); - let total = iterations * DOMAINS.len() * ROUNDS; - println!("=== Aggregated ({} samples per method) ===", total); - println!(); - println!("{:<10} {:>10} {:>10}", "", "Numa", "Unbound"); - println!("{:<10} {:>7.1} ms {:>7.1} ms", "mean", n_m, u_m); - println!("{:<10} {:>7.1} ms {:>7.1} ms", "median", n_med, u_med); - println!("{:<10} {:>7.1} ms {:>7.1} ms", "p95", n_p95, u_p95); - println!("{:<10} {:>7.1} ms {:>7.1} ms", "p99", n_p99, u_p99); - println!("{:<10} {:>7.1} ms {:>7.1} ms", "σ", n_sd, u_sd); - println!(); - - println!("Numa vs Unbound:"); - println!(" mean: {:+.1} ms ({:+.0}%)", n_m - u_m, (n_m - u_m) / u_m * 100.0); - println!(" median: {:+.1} ms ({:+.0}%)", n_med - u_med, (n_med - u_med) / u_med * 100.0); - println!(" p95: {:+.1} ms ({:+.0}%)", n_p95 - u_p95, (n_p95 - u_p95) / u_p95 * 100.0); - println!(" p99: {:+.1} ms ({:+.0}%)", n_p99 - u_p99, (n_p99 - u_p99) / u_p99 * 100.0); -} - -/// Build a DNS query as a Vec for use with forward_query_raw. -fn build_query_vec(domain: &str) -> Vec { - let mut buf = vec![0u8; 512]; - let len = build_query(&mut buf, domain); - buf.truncate(len); - buf -} - -fn measure R, R>(_rt: &tokio::runtime::Runtime, f: F) -> f64 { - let start = Instant::now(); - f(); - start.elapsed().as_secs_f64() * 1000.0 -} - -fn mean(v: &[f64]) -> f64 { - v.iter().sum::() / v.len() as f64 -} - -fn stddev(v: &[f64]) -> f64 { - let m = mean(v); - let var = v.iter().map(|x| (x - m).powi(2)).sum::() / v.len() as f64; - var.sqrt() -} - -fn median(v: &mut [f64]) -> f64 { + let mean = v.iter().sum::() / v.len() as f64; v.sort_by(|a, b| a.partial_cmp(b).unwrap()); let n = v.len(); - if n % 2 == 0 { + let median = if n % 2 == 0 { (v[n / 2 - 1] + v[n / 2]) / 2.0 } else { v[n / 2] - } + }; + let p95 = v[((n as f64 * 0.95).round() as usize).min(n - 1)]; + let p99 = v[((n as f64 * 0.99).round() as usize).min(n - 1)]; + let var = v.iter().map(|x| (x - mean).powi(2)).sum::() / n as f64; + (mean, median, p95, p99, var.sqrt()) } -fn percentile(sorted: &[f64], p: f64) -> f64 { - let idx = (p / 100.0 * (sorted.len() - 1) as f64).round() as usize; - sorted[idx.min(sorted.len() - 1)] -} +// ── Query helpers ─────────────────────────────────────────────── -fn format_delta(delta: f64) -> String { - if delta > 0.0 { - format!("+{:.1}", delta) - } else { - format!("{:.1}", delta) - } -} - -/// Query a DNS server over UDP. async fn query_udp(addr: SocketAddr, domain: &str) -> Option<()> { use tokio::net::UdpSocket; - let sock = UdpSocket::bind("0.0.0.0:0").await.ok()?; let mut buf = vec![0u8; 512]; let len = build_query(&mut buf, domain); - sock.send_to(&buf[..len], addr).await.ok()?; - let mut resp = vec![0u8; 4096]; tokio::time::timeout(Duration::from_secs(10), sock.recv_from(&mut resp)) .await .ok()? .ok()?; - Some(()) } -/// Build a shared Hickory DoH resolver (reuses TLS connection across queries). +async fn query_dot_once( + addr: &str, + domain: &str, + tls_config: &std::sync::Arc, +) -> Result<(), Box> { + use rustls::pki_types::ServerName; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpStream; + use tokio_rustls::TlsConnector; + + let connector = TlsConnector::from(tls_config.clone()); + let stream = TcpStream::connect(addr).await?; + let server_name = ServerName::try_from("localhost")?; + let mut tls = connector.connect(server_name, stream).await?; + + let mut buf = vec![0u8; 512]; + let len = build_query(&mut buf, domain); + let msg = &buf[..len]; + + 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); + tls.write_all(&out).await?; + + let mut len_buf = [0u8; 2]; + tls.read_exact(&mut len_buf).await?; + let resp_len = u16::from_be_bytes(len_buf) as usize; + let mut resp = vec![0u8; resp_len]; + tls.read_exact(&mut resp).await?; + Ok(()) +} + +async fn query_doh_server( + client: &reqwest::Client, + url: &str, + wire: &[u8], + host: Option<&str>, +) -> Result, Box> { + let mut req = client + .post(url) + .header("content-type", "application/dns-message") + .header("accept", "application/dns-message") + .body(wire.to_vec()); + if let Some(h) = host { + req = req.header("host", h); + } + let resp = req.send().await?.error_for_status()?; + Ok(resp.bytes().await?.to_vec()) +} + async fn build_hickory_resolver() -> hickory_resolver::TokioResolver { use hickory_resolver::config::*; - let ns = NameServerConfig { socket_addr: "9.9.9.9:443".parse().unwrap(), protocol: hickory_proto::xfer::Protocol::Https, @@ -1593,29 +893,79 @@ async fn build_hickory_resolver() -> hickory_resolver::TokioResolver { bind_addr: None, http_endpoint: Some("/dns-query".to_string()), }; - let config = ResolverConfig::from_parts(None, vec![], NameServerConfigGroup::from(vec![ns])); - let mut opts = ResolverOpts::default(); opts.cache_size = 0; opts.num_concurrent_reqs = 1; opts.timeout = Duration::from_secs(10); - hickory_resolver::TokioResolver::builder_with_config(config, Default::default()) .with_options(opts) .build() } -/// Query using the shared Hickory resolver. -async fn query_hickory_doh( - resolver: &hickory_resolver::TokioResolver, - domain: &str, -) -> Option<()> { +async fn query_hickory_doh(resolver: &hickory_resolver::TokioResolver, domain: &str) -> Option<()> { use hickory_resolver::proto::rr::RecordType; let _ = resolver.lookup(domain, RecordType::A).await.ok()?; Some(()) } +fn build_insecure_tls_config() -> std::sync::Arc { + use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; + use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; + use rustls::DigitallySignedStruct; + + #[derive(Debug)] + struct NoVerify; + impl ServerCertVerifier for NoVerify { + fn verify_server_cert( + &self, + _: &CertificateDer<'_>, + _: &[CertificateDer<'_>], + _: &ServerName<'_>, + _: &[u8], + _: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + fn verify_tls12_signature( + &self, + _: &[u8], + _: &CertificateDer<'_>, + _: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + fn verify_tls13_signature( + &self, + _: &[u8], + _: &CertificateDer<'_>, + _: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } + } + std::sync::Arc::new( + rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(std::sync::Arc::new(NoVerify)) + .with_no_client_auth(), + ) +} + +// ── Wire helpers ──────────────────────────────────────────────── + +fn build_query_vec(domain: &str) -> Vec { + let mut buf = vec![0u8; 512]; + let len = build_query(&mut buf, domain); + buf.truncate(len); + buf +} + fn build_query(buf: &mut [u8], domain: &str) -> usize { let mut pos = 0; buf[pos..pos + 2].copy_from_slice(&0x1234u16.to_be_bytes()); @@ -1626,7 +976,6 @@ fn build_query(buf: &mut [u8], domain: &str) -> usize { pos += 2; buf[pos..pos + 6].fill(0); pos += 6; - for label in domain.split('.') { buf[pos] = label.len() as u8; pos += 1; @@ -1644,6 +993,11 @@ fn build_query(buf: &mut [u8], domain: &str) -> usize { fn flush_cache() { let _ = std::process::Command::new("curl") - .args(["-s", "-X", "DELETE", &format!("http://127.0.0.1:{NUMA_API}/cache")]) + .args([ + "-s", + "-X", + "DELETE", + &format!("http://127.0.0.1:{NUMA_API}/cache"), + ]) .output(); } diff --git a/src/forward.rs b/src/forward.rs index 401ae1c..6afb7e5 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -214,15 +214,11 @@ pub async fn forward_query( upstream: &Upstream, timeout_duration: Duration, ) -> Result { - match upstream { - Upstream::Udp(addr) => forward_udp(query, *addr, timeout_duration).await, - Upstream::Doh { url, client } => forward_doh(query, url, client, timeout_duration).await, - Upstream::Dot { - addr, - tls_name, - connector, - } => forward_dot(query, *addr, tls_name, connector, timeout_duration).await, - } + let mut send_buffer = BytePacketBuffer::new(); + query.write(&mut send_buffer)?; + let data = forward_query_raw(send_buffer.filled(), upstream, timeout_duration).await?; + let mut recv_buffer = BytePacketBuffer::from_bytes(&data); + DnsPacket::from_buffer(&mut recv_buffer) } pub(crate) async fn forward_udp( @@ -284,13 +280,13 @@ pub(crate) async fn forward_tcp( DnsPacket::from_buffer(&mut recv_buffer) } -async fn forward_dot( - query: &DnsPacket, +async fn forward_dot_raw( + wire: &[u8], addr: SocketAddr, tls_name: &Option, connector: &tokio_rustls::TlsConnector, timeout_duration: Duration, -) -> Result { +) -> Result> { use rustls::pki_types::ServerName; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; @@ -303,10 +299,6 @@ async fn forward_dot( let tcp = timeout(timeout_duration, TcpStream::connect(addr)).await??; let mut tls = timeout(timeout_duration, connector.connect(server_name, tcp)).await??; - let mut send_buffer = BytePacketBuffer::new(); - query.write(&mut send_buffer)?; - let wire = send_buffer.filled(); - let mut outbuf = Vec::with_capacity(2 + wire.len()); outbuf.extend_from_slice(&(wire.len() as u16).to_be_bytes()); outbuf.extend_from_slice(wire); @@ -319,22 +311,7 @@ async fn forward_dot( let mut data = vec![0u8; resp_len]; timeout(timeout_duration, tls.read_exact(&mut data)).await??; - let mut recv_buffer = BytePacketBuffer::from_bytes(&data); - DnsPacket::from_buffer(&mut recv_buffer) -} - -async fn forward_doh( - query: &DnsPacket, - url: &str, - client: &reqwest::Client, - timeout_duration: Duration, -) -> Result { - let mut send_buffer = BytePacketBuffer::new(); - query.write(&mut send_buffer)?; - - let resp_bytes = forward_doh_raw(send_buffer.filled(), url, client, timeout_duration).await?; - let mut recv_buffer = BytePacketBuffer::from_bytes(&resp_bytes); - DnsPacket::from_buffer(&mut recv_buffer) + Ok(data) } pub async fn forward_query_raw( @@ -345,6 +322,11 @@ pub async fn forward_query_raw( match upstream { Upstream::Udp(addr) => forward_udp_raw(wire, *addr, timeout_duration).await, Upstream::Doh { url, client } => forward_doh_raw(wire, url, client, timeout_duration).await, + Upstream::Dot { + addr, + tls_name, + connector, + } => forward_dot_raw(wire, *addr, tls_name, connector, timeout_duration).await, } } @@ -405,7 +387,10 @@ pub async fn forward_with_hedging_raw( match (primary_err, secondary_err) { (Some(pe), Some(_)) => return Err(pe), - (pe, se) => { primary_err = pe; secondary_err = se; } + (pe, se) => { + primary_err = pe; + secondary_err = se; + } } } } @@ -516,7 +501,7 @@ pub async fn keepalive_doh(upstream: &Upstream) { 0x01, 0x00, // flags: RD=1 0x00, 0x01, // QDCOUNT=1 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // AN=0, NS=0, AR=0 - 0x00, // root name (.) + 0x00, // root name (.) 0x00, 0x02, // type NS 0x00, 0x01, // class IN ]; diff --git a/src/recursive.rs b/src/recursive.rs index 2609f7f..190a57a 100644 --- a/src/recursive.rs +++ b/src/recursive.rs @@ -15,8 +15,8 @@ use crate::srtt::SrttCache; const MAX_REFERRAL_DEPTH: u8 = 10; const MAX_CNAME_DEPTH: u8 = 8; -const NS_QUERY_TIMEOUT: Duration = Duration::from_millis(800); -const TCP_TIMEOUT: Duration = Duration::from_millis(1500); +const NS_QUERY_TIMEOUT: Duration = Duration::from_millis(400); +const TCP_TIMEOUT: Duration = Duration::from_millis(400); const UDP_FAIL_THRESHOLD: u8 = 3; static QUERY_ID: AtomicU16 = AtomicU16::new(1); @@ -213,11 +213,13 @@ pub(crate) fn resolve_iterative<'a>( ns_addrs[ns_idx], q_type, q_name, current_zone, referral_depth ); - let response = match send_query_hedged(q_name, q_type, &ns_addrs[ns_idx..], srtt).await { + let response = match send_query_hedged(q_name, q_type, &ns_addrs[ns_idx..], srtt).await + { Ok(r) => r, Err(e) => { debug!("recursive: NS query failed: {}", e); - ns_idx += 2; // both tried, skip past them + let remaining = ns_addrs.len().saturating_sub(ns_idx); + ns_idx += remaining.min(2); continue; } }; @@ -660,7 +662,10 @@ async fn send_query_hedged( } match (a_err.take(), b_err.take()) { (Some(e), Some(_)) => return Err(e), - (a, b) => { a_err = a; b_err = b; } + (a, b) => { + a_err = a; + b_err = b; + } } } } else { @@ -739,9 +744,13 @@ async fn send_query( "send_query: {} consecutive UDP failures — switching to TCP-first", fails ); + // Now that UDP is disabled, retry this query via TCP + return tcp_with_srtt(&query, server, srtt, start).await; } - debug!("send_query: UDP failed for {}: {}, trying TCP", server, e); - tcp_with_srtt(&query, server, srtt, start).await + // UDP works in general (priming succeeded) but this server timed out. + // Don't waste another 400ms on TCP — the server is unreachable. + srtt.write().unwrap().record_failure(server.ip()); + Err(e) } } } @@ -1021,10 +1030,10 @@ mod tests { } /// TCP-only server returns authoritative answer directly. - /// Verifies: UDP fails → TCP fallback → resolves. + /// Verifies: when UDP is disabled, TCP-first resolves. #[tokio::test] async fn tcp_fallback_resolves_when_udp_blocked() { - UDP_DISABLED.store(false, Ordering::Relaxed); + UDP_DISABLED.store(true, Ordering::Relaxed); UDP_FAILURES.store(0, Ordering::Release); let server_addr = spawn_tcp_dns_server(|query| { @@ -1107,7 +1116,7 @@ mod tests { #[tokio::test] async fn tcp_fallback_handles_nxdomain() { - UDP_DISABLED.store(false, Ordering::Relaxed); + UDP_DISABLED.store(true, Ordering::Relaxed); UDP_FAILURES.store(0, Ordering::Release); let server_addr = spawn_tcp_dns_server(|query| { -- 2.34.1 From c1b651aa636acf9fe582e5809f43e913ce182f88 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 06:25:42 +0300 Subject: [PATCH 080/204] chore: remove obsolete bash benchmark script --- scripts/bench-recursive.sh | 115 ------------------------------------- 1 file changed, 115 deletions(-) delete mode 100755 scripts/bench-recursive.sh diff --git a/scripts/bench-recursive.sh b/scripts/bench-recursive.sh deleted file mode 100755 index 1a1ab71..0000000 --- a/scripts/bench-recursive.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bash -# Bench: Numa cold-cache recursive resolution vs dig (forwarded through system resolver) -# -# Measures cold-cache recursive resolution time for Numa. -# Flushes Numa's cache before each query to ensure cold-cache. -# Compares against dig querying a public recursive resolver (no cache advantage). -# -# Usage: ./scripts/bench-recursive.sh [numa_port] - -set -euo pipefail - -NUMA_ADDR="${NUMA_ADDR:-127.0.0.1}" -NUMA_PORT="${NUMA_PORT:-${1:-53}}" -API_PORT="${API_PORT:-5380}" -ROUNDS=3 - -DOMAINS=( - "example.com" - "rust-lang.org" - "kernel.org" - "signal.org" - "archlinux.org" - "openbsd.org" - "git-scm.com" - "sqlite.org" - "wireguard.com" - "mozilla.org" -) - -GREEN='\033[0;32m' -AMBER='\033[0;33m' -CYAN='\033[0;36m' -DIM='\033[0;90m' -BOLD='\033[1m' -RESET='\033[0m' - -echo -e "${CYAN}${BOLD}Recursive DNS Resolution Benchmark${RESET}" -echo -e "${DIM}Numa (cold cache, recursive from root) vs dig @1.1.1.1 (public resolver)${RESET}" -echo -e "${DIM}Rounds per domain: ${ROUNDS}${RESET}" -echo "" - -# Verify Numa is reachable -if ! dig @${NUMA_ADDR} -p ${NUMA_PORT} +short +time=3 +tries=1 example.com A &>/dev/null; then - echo -e "${AMBER}Numa not responding on ${NUMA_ADDR}:${NUMA_PORT}${RESET}" >&2 - exit 1 -fi - -# Verify we can flush cache -if ! curl -s -X DELETE "http://${NUMA_ADDR}:${API_PORT}/cache" &>/dev/null; then - echo -e "${AMBER}Cannot flush cache via API at ${NUMA_ADDR}:${API_PORT}${RESET}" >&2 - exit 1 -fi - -measure_ms() { - local start end - start=$(python3 -c 'import time; print(time.time())') - eval "$1" &>/dev/null - end=$(python3 -c 'import time; print(time.time())') - python3 -c "print(round(($end - $start) * 1000, 1))" -} - -printf "${BOLD}%-22s %10s %10s %8s${RESET}\n" "Domain" "Numa (ms)" "1.1.1.1" "Delta" -printf "%-22s %10s %10s %8s\n" "----------------------" "----------" "----------" "--------" - -numa_total=0 -dig_total=0 -count=0 - -for domain in "${DOMAINS[@]}"; do - numa_sum=0 - dig_sum=0 - - for ((r=1; r<=ROUNDS; r++)); do - # Flush Numa cache - curl -s -X DELETE "http://${NUMA_ADDR}:${API_PORT}/cache" &>/dev/null - sleep 0.05 - - # Measure Numa (recursive from root, cold cache) - ms=$(measure_ms "dig @${NUMA_ADDR} -p ${NUMA_PORT} +short +time=10 +tries=1 ${domain} A") - numa_sum=$(python3 -c "print(round($numa_sum + $ms, 1))") - - # Measure dig against 1.1.1.1 (Cloudflare — warm cache, but shows baseline) - ms=$(measure_ms "dig @1.1.1.1 +short +time=10 +tries=1 ${domain} A") - dig_sum=$(python3 -c "print(round($dig_sum + $ms, 1))") - done - - numa_avg=$(python3 -c "print(round($numa_sum / $ROUNDS, 1))") - dig_avg=$(python3 -c "print(round($dig_sum / $ROUNDS, 1))") - delta=$(python3 -c "d = round($numa_avg - $dig_avg, 1); print(f'+{d}' if d > 0 else str(d))") - - # Color the delta - delta_color="$GREEN" - if python3 -c "exit(0 if $numa_avg > $dig_avg * 1.5 else 1)" 2>/dev/null; then - delta_color="$AMBER" - fi - - printf "%-22s %8s ms %8s ms ${delta_color}%6s ms${RESET}\n" "$domain" "$numa_avg" "$dig_avg" "$delta" - - numa_total=$(python3 -c "print(round($numa_total + $numa_avg, 1))") - dig_total=$(python3 -c "print(round($dig_total + $dig_avg, 1))") - count=$((count + 1)) -done - -echo "" -numa_mean=$(python3 -c "print(round($numa_total / $count, 1))") -dig_mean=$(python3 -c "print(round($dig_total / $count, 1))") -delta_mean=$(python3 -c "d = round($numa_mean - $dig_mean, 1); print(f'+{d}' if d > 0 else str(d))") - -printf "${BOLD}%-22s %8s ms %8s ms %6s ms${RESET}\n" "AVERAGE" "$numa_mean" "$dig_mean" "$delta_mean" - -echo "" -echo -e "${DIM}Note: Numa resolves recursively from root hints (cold cache).${RESET}" -echo -e "${DIM}1.1.1.1 serves from Cloudflare's global cache (warm). The comparison${RESET}" -echo -e "${DIM}is intentionally unfair — it shows Numa's worst case vs the best case${RESET}" -echo -e "${DIM}of a global anycast resolver. Cached Numa queries resolve in <1ms.${RESET}" -- 2.34.1 From 72b540a44aadbb34867e2d12ab9c9630015b4b44 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 06:27:38 +0300 Subject: [PATCH 081/204] feat: wire-level cache, serve-stale, raw wire passthrough - Cache stores raw DNS wire bytes + TTL offsets (2.4x memory reduction) - Serve-stale (RFC 8767): expired entries returned with TTL=1 for 1hr - handle_query captures raw_len from recv_from for zero-copy forwarding - resolve_query accepts raw wire bytes, forwards without re-serializing - wire.rs: TTL offset scanner, ID/TTL patching, question extraction - 52 wire tests + 16 cache regression tests --- src/ctx.rs | 34 +++++-- src/wire.rs | 270 ++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 231 insertions(+), 73 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index 2b26a06..46316f2 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -16,9 +16,7 @@ use crate::blocklist::BlocklistStore; use crate::buffer::BytePacketBuffer; use crate::cache::{DnsCache, DnssecStatus}; use crate::config::{UpstreamMode, ZoneMap}; -use crate::forward::{ - forward_query_raw, forward_with_failover_raw, Upstream, UpstreamPool, -}; +use crate::forward::{forward_query_raw, forward_with_failover_raw, Upstream, UpstreamPool}; use crate::header::ResultCode; use crate::health::HealthMeta; use crate::lan::PeerStore; @@ -182,9 +180,7 @@ pub async fn resolve_query( // (e.g. Tailscale .ts.net, VPC private zones) let upstream = Upstream::Udp(fwd_addr); match forward_and_cache(raw_wire, &upstream, ctx, &qname, qtype).await { - Ok(resp) => { - (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate) - } + Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate), Err(e) => { error!( "{} | {:?} {} | FORWARD ERROR | {}", @@ -224,17 +220,35 @@ pub async fn resolve_query( (resp, path, DnssecStatus::Indeterminate) } else { let pool = ctx.upstream_pool.lock().unwrap().clone(); - match forward_with_failover_raw(raw_wire, &pool, &ctx.srtt, ctx.timeout, ctx.hedge_delay).await { + match forward_with_failover_raw( + raw_wire, + &pool, + &ctx.srtt, + ctx.timeout, + ctx.hedge_delay, + ) + .await + { Ok(resp_wire) => { ctx.cache.write().unwrap().insert_wire( - &qname, qtype, &resp_wire, DnssecStatus::Indeterminate, + &qname, + qtype, + &resp_wire, + DnssecStatus::Indeterminate, ); let mut buf = BytePacketBuffer::from_bytes(&resp_wire); match DnsPacket::from_buffer(&mut buf) { Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate), Err(e) => { - error!("{} | {:?} {} | PARSE ERROR | {}", src_addr, qtype, qname, e); - (DnsPacket::response_from(&query, ResultCode::SERVFAIL), QueryPath::UpstreamError, DnssecStatus::Indeterminate) + error!( + "{} | {:?} {} | PARSE ERROR | {}", + src_addr, qtype, qname, e + ); + ( + DnsPacket::response_from(&query, ResultCode::SERVFAIL), + QueryPath::UpstreamError, + DnssecStatus::Indeterminate, + ) } } } diff --git a/src/wire.rs b/src/wire.rs index 6b68c3a..a93fe27 100644 --- a/src/wire.rs +++ b/src/wire.rs @@ -309,7 +309,11 @@ mod tests { #[test] fn scan_single_a_record() { - let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); let wire = to_wire(&pkt); let meta = scan_ttl_offsets(&wire).unwrap(); @@ -341,15 +345,20 @@ mod tests { let ttls: Vec = meta .ttl_offsets .iter() - .map(|&off| u32::from_be_bytes([wire[off], wire[off + 1], wire[off + 2], wire[off + 3]])) + .map(|&off| { + u32::from_be_bytes([wire[off], wire[off + 1], wire[off + 2], wire[off + 3]]) + }) .collect(); assert_eq!(ttls, vec![300, 600, 120]); } #[test] fn scan_mixed_sections() { - let mut pkt = - response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let mut pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); pkt.authorities .push(ns_record("example.com", "ns1.example.com", 3600)); pkt.authorities @@ -382,7 +391,9 @@ mod tests { let ttls: Vec = meta .ttl_offsets .iter() - .map(|&off| u32::from_be_bytes([wire[off], wire[off + 1], wire[off + 2], wire[off + 3]])) + .map(|&off| { + u32::from_be_bytes([wire[off], wire[off + 1], wire[off + 2], wire[off + 3]]) + }) .collect(); assert_eq!(ttls, vec![300, 600]); } @@ -410,15 +421,20 @@ mod tests { let ttls: Vec = meta .ttl_offsets .iter() - .map(|&off| u32::from_be_bytes([wire[off], wire[off + 1], wire[off + 2], wire[off + 3]])) + .map(|&off| { + u32::from_be_bytes([wire[off], wire[off + 1], wire[off + 2], wire[off + 3]]) + }) .collect(); assert_eq!(ttls, vec![300, 600]); } #[test] fn scan_edns_opt_excluded() { - let mut pkt = - response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let mut pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); pkt.edns = Some(EdnsOpt { udp_payload_size: 1232, extended_rcode: 0, @@ -436,8 +452,11 @@ mod tests { #[test] fn scan_rrsig_only_wire_ttl() { - let mut pkt = - response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let mut pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); pkt.answers.push(DnsRecord::RRSIG { domain: "example.com".into(), type_covered: 1, // A @@ -460,8 +479,7 @@ mod tests { // Both wire TTLs should be 300, not 9999 for &off in &meta.ttl_offsets { - let ttl = - u32::from_be_bytes([wire[off], wire[off + 1], wire[off + 2], wire[off + 3]]); + let ttl = u32::from_be_bytes([wire[off], wire[off + 1], wire[off + 2], wire[off + 3]]); assert_eq!(ttl, 300); } @@ -479,8 +497,11 @@ mod tests { #[test] fn scan_nsec_variable_rdata() { - let mut pkt = - response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let mut pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); pkt.authorities.push(DnsRecord::NSEC { domain: "example.com".into(), next_domain: "z.example.com".into(), @@ -534,7 +555,11 @@ mod tests { #[test] fn scan_truncated_wire_returns_error() { - let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); let wire = to_wire(&pkt); // Truncate mid-record let truncated = &wire[..wire.len() - 2]; @@ -558,7 +583,11 @@ mod tests { #[test] fn patch_ttl_single() { - let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); let mut wire = to_wire(&pkt); let meta = scan_ttl_offsets(&wire).unwrap(); @@ -597,7 +626,11 @@ mod tests { #[test] fn patch_ttl_preserves_other_bytes() { - let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); let original = to_wire(&pkt); let mut patched = original.clone(); let meta = scan_ttl_offsets(&patched).unwrap(); @@ -606,10 +639,7 @@ mod tests { // Every byte outside TTL offsets should be identical for (i, (&orig, &patc)) in original.iter().zip(patched.iter()).enumerate() { - let in_ttl = meta - .ttl_offsets - .iter() - .any(|&off| i >= off && i < off + 4); + let in_ttl = meta.ttl_offsets.iter().any(|&off| i >= off && i < off + 4); if !in_ttl { assert_eq!( orig, patc, @@ -622,7 +652,11 @@ mod tests { #[test] fn patch_ttl_zero() { - let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); let mut wire = to_wire(&pkt); let meta = scan_ttl_offsets(&wire).unwrap(); @@ -634,7 +668,11 @@ mod tests { #[test] fn patch_ttl_max_u32() { - let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); let mut wire = to_wire(&pkt); let meta = scan_ttl_offsets(&wire).unwrap(); @@ -646,8 +684,11 @@ mod tests { #[test] fn patch_ttl_edns_untouched() { - let mut pkt = - response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let mut pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); pkt.edns = Some(EdnsOpt { udp_payload_size: 1232, extended_rcode: 0, @@ -664,10 +705,7 @@ mod tests { // Only the A record's TTL bytes should differ; everything else // (including the OPT "TTL" containing the DO bit) must be unchanged. for (i, (&orig, &patc)) in original.iter().zip(patched.iter()).enumerate() { - let in_ttl = meta - .ttl_offsets - .iter() - .any(|&off| i >= off && i < off + 4); + let in_ttl = meta.ttl_offsets.iter().any(|&off| i >= off && i < off + 4); if !in_ttl { assert_eq!( orig, patc, @@ -682,7 +720,11 @@ mod tests { #[test] fn patch_id_basic() { - let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); let mut wire = to_wire(&pkt); patch_id(&mut wire, 0xABCD); @@ -691,7 +733,11 @@ mod tests { #[test] fn patch_id_preserves_flags() { - let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); let original = to_wire(&pkt); let mut patched = original.clone(); @@ -703,7 +749,11 @@ mod tests { #[test] fn patch_id_zero() { - let pkt = response(0xFFFF, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt = response( + 0xFFFF, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); let mut wire = to_wire(&pkt); patch_id(&mut wire, 0x0000); @@ -782,7 +832,11 @@ mod tests { #[test] fn round_trip_simple_a() { - let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); let wire = to_wire(&pkt); let meta = scan_ttl_offsets(&wire).unwrap(); @@ -808,8 +862,11 @@ mod tests { #[test] fn round_trip_edns_survives() { - let mut pkt = - response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let mut pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); pkt.edns = Some(EdnsOpt { udp_payload_size: 1232, extended_rcode: 0, @@ -1017,7 +1074,11 @@ mod tests { #[test] fn cache_insert_lookup_hit() { let mut cache = DnsCache::new(100, 1, 3600); - let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); cache.insert("example.com", QueryType::A, &pkt); let (result, status) = cache @@ -1030,10 +1091,16 @@ mod tests { #[test] fn cache_lookup_adjusts_ttl() { let mut cache = DnsCache::new(100, 1, 3600); - let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); cache.insert("example.com", QueryType::A, &pkt); - let (result, _) = cache.lookup_with_status("example.com", QueryType::A).unwrap(); + let (result, _) = cache + .lookup_with_status("example.com", QueryType::A) + .unwrap(); // TTL should be <= 300 (at most original, reduced by elapsed time) assert!(result.answers[0].ttl() <= 300); assert!(result.answers[0].ttl() > 0); @@ -1042,7 +1109,11 @@ mod tests { #[test] fn cache_miss_wrong_domain() { let mut cache = DnsCache::new(100, 1, 3600); - let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); cache.insert("example.com", QueryType::A, &pkt); assert!(cache @@ -1053,7 +1124,11 @@ mod tests { #[test] fn cache_miss_wrong_qtype() { let mut cache = DnsCache::new(100, 1, 3600); - let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); cache.insert("example.com", QueryType::A, &pkt); assert!(cache @@ -1064,8 +1139,16 @@ mod tests { #[test] fn cache_overwrite_no_double_count() { let mut cache = DnsCache::new(100, 1, 3600); - let pkt1 = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); - let pkt2 = response(0x5678, "example.com", vec![a_record("example.com", "5.6.7.8", 600)]); + let pkt1 = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); + let pkt2 = response( + 0x5678, + "example.com", + vec![a_record("example.com", "5.6.7.8", 600)], + ); cache.insert("example.com", QueryType::A, &pkt1); assert_eq!(cache.len(), 1); @@ -1073,7 +1156,9 @@ mod tests { cache.insert("example.com", QueryType::A, &pkt2); assert_eq!(cache.len(), 1); // no double count - let (result, _) = cache.lookup_with_status("example.com", QueryType::A).unwrap(); + let (result, _) = cache + .lookup_with_status("example.com", QueryType::A) + .unwrap(); match &result.answers[0] { DnsRecord::A { addr, .. } => { assert_eq!(*addr, "5.6.7.8".parse::().unwrap()) @@ -1085,7 +1170,11 @@ mod tests { #[test] fn cache_ttl_clamped_min() { let mut cache = DnsCache::new(100, 60, 3600); - let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 5)]); + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 5)], + ); cache.insert("example.com", QueryType::A, &pkt); let (remaining, total) = cache.ttl_remaining("example.com", QueryType::A).unwrap(); @@ -1096,8 +1185,11 @@ mod tests { #[test] fn cache_ttl_clamped_max() { let mut cache = DnsCache::new(100, 1, 3600); - let pkt = - response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 999999)]); + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 999999)], + ); cache.insert("example.com", QueryType::A, &pkt); let (_, total) = cache.ttl_remaining("example.com", QueryType::A).unwrap(); @@ -1110,7 +1202,11 @@ mod tests { assert!(cache.is_empty()); assert_eq!(cache.len(), 0); - let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); cache.insert("example.com", QueryType::A, &pkt); assert!(!cache.is_empty()); assert_eq!(cache.len(), 1); @@ -1124,7 +1220,11 @@ mod tests { #[test] fn cache_remove_domain() { let mut cache = DnsCache::new(100, 1, 3600); - let pkt_a = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt_a = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); let pkt_aaaa = response( 0x5678, "example.com", @@ -1143,8 +1243,16 @@ mod tests { #[test] fn cache_list_entries() { let mut cache = DnsCache::new(100, 1, 3600); - let pkt_a = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); - let pkt_b = response(0x5678, "test.org", vec![a_record("test.org", "5.6.7.8", 600)]); + let pkt_a = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); + let pkt_b = response( + 0x5678, + "test.org", + vec![a_record("test.org", "5.6.7.8", 600)], + ); cache.insert("example.com", QueryType::A, &pkt_a); cache.insert("test.org", QueryType::A, &pkt_b); @@ -1160,7 +1268,11 @@ mod tests { let mut cache = DnsCache::new(100, 1, 3600); let empty = cache.heap_bytes(); - let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); cache.insert("example.com", QueryType::A, &pkt); assert!(cache.heap_bytes() > empty); } @@ -1173,7 +1285,11 @@ mod tests { assert!(cache.needs_warm("example.com")); // Both A and AAAA cached → does not need warm - let pkt_a = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt_a = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); let pkt_aaaa = response( 0x5678, "example.com", @@ -1194,7 +1310,11 @@ mod tests { let mut cache = DnsCache::new(100, 60, 3600); assert!(cache.ttl_remaining("missing.com", QueryType::A).is_none()); - let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); cache.insert("example.com", QueryType::A, &pkt); let (remaining, total) = cache.ttl_remaining("example.com", QueryType::A).unwrap(); assert_eq!(total, 300); @@ -1205,7 +1325,11 @@ mod tests { #[test] fn cache_dnssec_status_preserved() { let mut cache = DnsCache::new(100, 1, 3600); - let pkt = response(0x1234, "example.com", vec![a_record("example.com", "1.2.3.4", 300)]); + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 300)], + ); cache.insert_with_status("example.com", QueryType::A, &pkt, DnssecStatus::Secure); let (_, status) = cache @@ -1225,7 +1349,9 @@ mod tests { let mut cache = DnsCache::new(1000, 1, 3600); // Simulate a realistic cache: 50 domains, mix of record types - let domains: Vec = (0..50).map(|i| format!("domain{}.example.com", i)).collect(); + let domains: Vec = (0..50) + .map(|i| format!("domain{}.example.com", i)) + .collect(); let mut total_wire_bytes = 0usize; let mut total_wire_meta_bytes = 0usize; @@ -1259,8 +1385,7 @@ mod tests { let wire_aaaa = to_wire(&pkt_aaaa); let meta_aaaa = scan_ttl_offsets(&wire_aaaa).unwrap(); total_wire_bytes += wire_aaaa.len(); - total_wire_meta_bytes += - meta_aaaa.ttl_offsets.len() * std::mem::size_of::(); + total_wire_meta_bytes += meta_aaaa.ttl_offsets.len() * std::mem::size_of::(); } } @@ -1300,15 +1425,31 @@ mod tests { // Also measure the struct size difference per entry let parsed_struct = std::mem::size_of::(); - let wire_struct = std::mem::size_of::>() + std::mem::size_of::>() + std::mem::size_of::(); // wire + offsets + answer_count + let wire_struct = std::mem::size_of::>() + + std::mem::size_of::>() + + std::mem::size_of::(); // wire + offsets + answer_count println!(); - println!("=== Cache Memory Footprint Baseline ({} entries) ===", entry_count); + println!( + "=== Cache Memory Footprint Baseline ({} entries) ===", + entry_count + ); println!(); println!("Variable data (heap, per-entry payload):"); - println!(" Parsed (packet.heap_bytes): {} bytes ({:.1}/entry)", parsed_data_bytes, parsed_data_bytes as f64 / entry_count as f64); - println!(" Wire (bytes + TTL offsets): {} bytes ({:.1}/entry)", wire_total, wire_total as f64 / entry_count as f64); - println!(" Ratio: {:.1}x smaller with wire", parsed_data_bytes as f64 / wire_total as f64); + println!( + " Parsed (packet.heap_bytes): {} bytes ({:.1}/entry)", + parsed_data_bytes, + parsed_data_bytes as f64 / entry_count as f64 + ); + println!( + " Wire (bytes + TTL offsets): {} bytes ({:.1}/entry)", + wire_total, + wire_total as f64 / entry_count as f64 + ); + println!( + " Ratio: {:.1}x smaller with wire", + parsed_data_bytes as f64 / wire_total as f64 + ); println!(); println!("Struct overhead (stack, per entry):"); println!(" DnsPacket: {} bytes", parsed_struct); @@ -1319,7 +1460,10 @@ mod tests { let wire_total_per = wire_struct as f64 + wire_total as f64 / entry_count as f64; println!(" Parsed: {:.0} bytes", parsed_total_per); println!(" Wire: {:.0} bytes", wire_total_per); - println!(" Ratio: {:.1}x smaller with wire", parsed_total_per / wire_total_per); + println!( + " Ratio: {:.1}x smaller with wire", + parsed_total_per / wire_total_per + ); println!(); // Assertions -- 2.34.1 From 17a1a6ddba351d8b5ec529ef5ef242e57bcb56ec Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 06:42:59 +0300 Subject: [PATCH 082/204] refactor: remove forward_with_failover duplication, fix warm-branch hedge bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove forward_with_failover (parsed): warm_domain now uses _raw + insert_wire - forward_udp delegates to forward_udp_raw (single UDP socket implementation) - forward_query uses unified _raw path for all protocols - Fix send_query_hedged warm branch: bare select! dropped secondary on primary error instead of waiting for it — now drains both futures like the cold branch - Remove pointless raw_len = len rename --- src/forward.rs | 85 +++++++++--------------------------------------- src/main.rs | 52 +++++++++++++++++------------ src/recursive.rs | 27 +++++++++++++-- 3 files changed, 71 insertions(+), 93 deletions(-) diff --git a/src/forward.rs b/src/forward.rs index 6afb7e5..ebbe777 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -157,58 +157,6 @@ impl UpstreamPool { } } -pub async fn forward_with_failover( - query: &DnsPacket, - pool: &UpstreamPool, - srtt: &RwLock, - timeout_duration: Duration, -) -> Result { - // Build candidate list: primary (sorted by SRTT for UDP) then fallback - let mut candidates: Vec<(usize, u64)> = pool - .primary - .iter() - .enumerate() - .map(|(i, u)| { - let rtt = match u { - Upstream::Udp(addr) => srtt.read().unwrap().get(addr.ip()), - _ => 0, // DoH: keep config order (stable sort preserves it) - }; - (i, rtt) - }) - .collect(); - candidates.sort_by_key(|&(_, rtt)| rtt); - - let all_upstreams: Vec<&Upstream> = candidates - .iter() - .map(|&(i, _)| &pool.primary[i]) - .chain(pool.fallback.iter()) - .collect(); - - let mut last_err: Option> = None; - - for upstream in &all_upstreams { - let start = Instant::now(); - match forward_query(query, upstream, timeout_duration).await { - Ok(resp) => { - if let Upstream::Udp(addr) = upstream { - let rtt_ms = start.elapsed().as_millis() as u64; - srtt.write().unwrap().record_rtt(addr.ip(), rtt_ms, false); - } - return Ok(resp); - } - Err(e) => { - if let Upstream::Udp(addr) = upstream { - srtt.write().unwrap().record_failure(addr.ip()); - } - log::debug!("upstream {} failed: {}", upstream, e); - last_err = Some(e); - } - } - } - - Err(last_err.unwrap_or_else(|| "no upstream configured".into())) -} - pub async fn forward_query( query: &DnsPacket, upstream: &Upstream, @@ -226,24 +174,14 @@ pub(crate) async fn forward_udp( upstream: SocketAddr, timeout_duration: Duration, ) -> Result { - let socket = UdpSocket::bind("0.0.0.0:0").await?; - let mut send_buffer = BytePacketBuffer::new(); query.write(&mut send_buffer)?; - socket.send_to(send_buffer.filled(), upstream).await?; - - let mut recv_buffer = BytePacketBuffer::new(); - let (size, _) = timeout(timeout_duration, socket.recv_from(&mut recv_buffer.buf)).await??; - - if size == recv_buffer.buf.len() { - log::debug!( - "upstream response truncated ({} bytes, buffer {})", - size, - recv_buffer.buf.len() - ); + let data = forward_udp_raw(send_buffer.filled(), upstream, timeout_duration).await?; + if data.len() >= 4096 { + log::debug!("upstream response may be truncated ({} bytes)", data.len()); } - + let mut recv_buffer = BytePacketBuffer::from_bytes(&data); DnsPacket::from_buffer(&mut recv_buffer) } @@ -721,10 +659,19 @@ mod tests { ); let srtt = RwLock::new(SrttCache::new(true)); - let result = forward_with_failover(&query, &pool, &srtt, Duration::from_millis(500)) - .await - .expect("should fail over to second upstream"); + let wire = to_wire(&query); + let resp_wire = forward_with_failover_raw( + &wire, + &pool, + &srtt, + Duration::from_millis(500), + Duration::ZERO, + ) + .await + .expect("should fail over to second upstream"); + let mut buf = BytePacketBuffer::from_bytes(&resp_wire); + let result = DnsPacket::from_buffer(&mut buf).unwrap(); assert_eq!(result.header.id, 0xABCD); assert_eq!(result.answers.len(), 1); } diff --git a/src/main.rs b/src/main.rs index 0211a59..68e4794 100644 --- a/src/main.rs +++ b/src/main.rs @@ -607,11 +607,9 @@ async fn main() -> numa::Result<()> { } Err(e) => return Err(e.into()), }; - let raw_len = len; - let ctx = Arc::clone(&ctx); tokio::spawn(async move { - if let Err(e) = handle_query(buffer, raw_len, src_addr, &ctx).await { + if let Err(e) = handle_query(buffer, len, src_addr, &ctx).await { error!("{} | HANDLER ERROR | {}", src_addr, e); } }); @@ -762,27 +760,39 @@ async fn warm_domain(ctx: &ServerCtx, domain: &str) { use numa::question::QueryType; for qtype in [QueryType::A, QueryType::AAAA] { - let query = numa::packet::DnsPacket::query(0, domain, qtype); - let result = if ctx.upstream_mode == numa::config::UpstreamMode::Recursive { - numa::recursive::resolve_recursive( - domain, - qtype, - &ctx.cache, - &query, - &ctx.root_hints, - &ctx.srtt, + if ctx.upstream_mode == numa::config::UpstreamMode::Recursive { + let query = numa::packet::DnsPacket::query(0, domain, qtype); + match numa::recursive::resolve_recursive( + domain, qtype, &ctx.cache, &query, &ctx.root_hints, &ctx.srtt, ) .await - } else { - let pool = ctx.upstream_pool.lock().unwrap().clone(); - numa::forward::forward_with_failover(&query, &pool, &ctx.srtt, ctx.timeout).await - }; - match result { - Ok(resp) => { - ctx.cache.write().unwrap().insert(domain, qtype, &resp); - log::debug!("cache warm: {} {:?}", domain, qtype); + { + Ok(resp) => { + ctx.cache.write().unwrap().insert(domain, qtype, &resp); + log::debug!("cache warm: {} {:?}", domain, qtype); + } + Err(e) => log::warn!("cache warm: {} {:?} failed: {}", domain, qtype, e), + } + } else { + let query = numa::packet::DnsPacket::query(0, domain, qtype); + let mut buf = numa::buffer::BytePacketBuffer::new(); + if query.write(&mut buf).is_err() { + continue; + } + let pool = ctx.upstream_pool.lock().unwrap().clone(); + match numa::forward::forward_with_failover_raw( + buf.filled(), &pool, &ctx.srtt, ctx.timeout, ctx.hedge_delay, + ) + .await + { + Ok(wire) => { + ctx.cache.write().unwrap().insert_wire( + domain, qtype, &wire, numa::cache::DnssecStatus::Indeterminate, + ); + log::debug!("cache warm: {} {:?}", domain, qtype); + } + Err(e) => log::warn!("cache warm: {} {:?} failed: {}", domain, qtype, e), } - Err(e) => log::warn!("cache warm: {} {:?} failed: {}", domain, qtype, e), } } } diff --git a/src/recursive.rs b/src/recursive.rs index 190a57a..70f35c0 100644 --- a/src/recursive.rs +++ b/src/recursive.rs @@ -690,9 +690,30 @@ async fn send_query_hedged( let fut_b = send_query(qname, qtype, secondary, srtt); tokio::pin!(fut_b); - tokio::select! { - r = fut_a => r, - r = fut_b => r, + // First Ok wins; if one errors, wait for the other. + let mut a_err: Option = None; + let mut b_err: Option = None; + loop { + tokio::select! { + r = &mut fut_a, if a_err.is_none() => { + match r { + Ok(resp) => return Ok(resp), + Err(e) => { + if b_err.is_some() { return Err(e); } + a_err = Some(e); + } + } + } + r = &mut fut_b, if b_err.is_none() => { + match r { + Ok(resp) => return Ok(resp), + Err(e) => { + if let Some(ae) = a_err.take() { return Err(ae); } + b_err = Some(e); + } + } + } + } } } } -- 2.34.1 From f705f8c49fc89d2919ed5f39d95239318ca7814d Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 06:45:10 +0300 Subject: [PATCH 083/204] fix: bump TCP_TIMEOUT to 800ms to fix flaky CI test --- src/recursive.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/recursive.rs b/src/recursive.rs index 70f35c0..0910421 100644 --- a/src/recursive.rs +++ b/src/recursive.rs @@ -16,7 +16,7 @@ use crate::srtt::SrttCache; const MAX_REFERRAL_DEPTH: u8 = 10; const MAX_CNAME_DEPTH: u8 = 8; const NS_QUERY_TIMEOUT: Duration = Duration::from_millis(400); -const TCP_TIMEOUT: Duration = Duration::from_millis(400); +const TCP_TIMEOUT: Duration = Duration::from_millis(800); const UDP_FAIL_THRESHOLD: u8 = 3; static QUERY_ID: AtomicU16 = AtomicU16::new(1); -- 2.34.1 From 700cca9cb616aeecf5d28c52a099f2f134b318ac Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 11:09:20 +0300 Subject: [PATCH 084/204] style: rustfmt warm_domain --- src/main.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 68e4794..ebc16cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -763,7 +763,12 @@ async fn warm_domain(ctx: &ServerCtx, domain: &str) { if ctx.upstream_mode == numa::config::UpstreamMode::Recursive { let query = numa::packet::DnsPacket::query(0, domain, qtype); match numa::recursive::resolve_recursive( - domain, qtype, &ctx.cache, &query, &ctx.root_hints, &ctx.srtt, + domain, + qtype, + &ctx.cache, + &query, + &ctx.root_hints, + &ctx.srtt, ) .await { @@ -781,13 +786,20 @@ async fn warm_domain(ctx: &ServerCtx, domain: &str) { } let pool = ctx.upstream_pool.lock().unwrap().clone(); match numa::forward::forward_with_failover_raw( - buf.filled(), &pool, &ctx.srtt, ctx.timeout, ctx.hedge_delay, + buf.filled(), + &pool, + &ctx.srtt, + ctx.timeout, + ctx.hedge_delay, ) .await { Ok(wire) => { ctx.cache.write().unwrap().insert_wire( - domain, qtype, &wire, numa::cache::DnssecStatus::Indeterminate, + domain, + qtype, + &wire, + numa::cache::DnssecStatus::Indeterminate, ); log::debug!("cache warm: {} {:?}", domain, qtype); } -- 2.34.1 From 67b472fea787227faa99c19a6bab6f24fd981d29 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 11:47:48 +0300 Subject: [PATCH 085/204] fix: serialize tests that share global UDP_DISABLED state The tcp_only_iterative_resolution, tcp_fallback_resolves_when_udp_blocked, tcp_fallback_handles_nxdomain, and udp_auto_disable_resets tests all mutate global UDP_DISABLED / UDP_FAILURES atomics. Under cargo test parallelism, udp_auto_disable_resets would reset the flag mid-flight causing other tests to attempt UDP against TCP-only mock servers and time out. Fix: static Mutex serializes tests that depend on global UDP state. Also: tcp_only_iterative_resolution now calls forward_tcp directly, removing its dependence on the flag entirely. --- src/recursive.rs | 54 ++++++++++++++++++++---------------------------- 1 file changed, 22 insertions(+), 32 deletions(-) diff --git a/src/recursive.rs b/src/recursive.rs index 0910421..53397d2 100644 --- a/src/recursive.rs +++ b/src/recursive.rs @@ -813,6 +813,10 @@ mod tests { use super::*; use std::net::{Ipv4Addr, Ipv6Addr}; + /// Tests that mutate the global UDP_DISABLED / UDP_FAILURES flags must hold + /// this lock to avoid racing with each other under `cargo test` parallelism. + static UDP_STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] fn extract_ns_from_authority() { let mut pkt = DnsPacket::new(); @@ -1054,6 +1058,7 @@ mod tests { /// Verifies: when UDP is disabled, TCP-first resolves. #[tokio::test] async fn tcp_fallback_resolves_when_udp_blocked() { + let _guard = UDP_STATE_LOCK.lock().unwrap(); UDP_DISABLED.store(true, Ordering::Relaxed); UDP_FAILURES.store(0, Ordering::Release); @@ -1085,49 +1090,32 @@ mod tests { } } - /// Full iterative resolution through TCP-only mock: root referral → authoritative answer. - /// The mock plays both roles (returns referral for NS queries, answer for A queries). + /// TCP round-trip through mock: query → authoritative answer via forward_tcp. + /// Uses forward_tcp directly to avoid dependence on the global UDP_DISABLED flag + /// which is shared across concurrent tests. #[tokio::test] async fn tcp_only_iterative_resolution() { - UDP_DISABLED.store(true, Ordering::Release); // Skip UDP entirely for speed - let server_addr = spawn_tcp_dns_server(|query| { let q = match query.questions.first() { Some(q) => q, None => return DnsPacket::response_from(query, ResultCode::SERVFAIL), }; - if q.qtype == QueryType::NS || q.name == "com" { - // Return referral — NS points back to ourselves (same IP, port 53 in glue - // won't work, but cache will have our address from root_hints) - let mut resp = DnsPacket::new(); - resp.header.id = query.header.id; - resp.header.response = true; - resp.header.rescode = ResultCode::NOERROR; - resp.questions = query.questions.clone(); - resp.authorities.push(DnsRecord::NS { - domain: "com".into(), - host: "ns1.com".into(), - ttl: 3600, - }); - resp - } else { - // Return authoritative answer - let mut resp = DnsPacket::response_from(query, ResultCode::NOERROR); - resp.header.authoritative_answer = true; - resp.answers.push(DnsRecord::A { - domain: q.name.clone(), - addr: Ipv4Addr::new(10, 0, 0, 42), - ttl: 300, - }); - resp - } + let mut resp = DnsPacket::response_from(query, ResultCode::NOERROR); + resp.header.authoritative_answer = true; + resp.answers.push(DnsRecord::A { + domain: q.name.clone(), + addr: Ipv4Addr::new(10, 0, 0, 42), + ttl: 300, + }); + resp }) .await; - let srtt = RwLock::new(SrttCache::new(true)); - let result = send_query("hello.example.com", QueryType::A, server_addr, &srtt).await; - let resp = result.expect("TCP-only send_query should work"); + let query = DnsPacket::query(0x1234, "hello.example.com", QueryType::A); + let resp = crate::forward::forward_tcp(&query, server_addr, TCP_TIMEOUT) + .await + .expect("TCP query should work"); assert_eq!(resp.header.rescode, ResultCode::NOERROR); match &resp.answers[0] { DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::new(10, 0, 0, 42)), @@ -1137,6 +1125,7 @@ mod tests { #[tokio::test] async fn tcp_fallback_handles_nxdomain() { + let _guard = UDP_STATE_LOCK.lock().unwrap(); UDP_DISABLED.store(true, Ordering::Relaxed); UDP_FAILURES.store(0, Ordering::Release); @@ -1169,6 +1158,7 @@ mod tests { #[tokio::test] async fn udp_auto_disable_resets() { + let _guard = UDP_STATE_LOCK.lock().unwrap(); UDP_DISABLED.store(true, Ordering::Release); UDP_FAILURES.store(5, Ordering::Relaxed); -- 2.34.1 From 85cff052a4e4efd513b8ef4eb8f4a3b4dcc923a3 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 12:34:20 +0300 Subject: [PATCH 086/204] fix: restore TCP_TIMEOUT to 400ms (test race was the real issue) --- src/recursive.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/recursive.rs b/src/recursive.rs index 53397d2..a4dff08 100644 --- a/src/recursive.rs +++ b/src/recursive.rs @@ -16,7 +16,7 @@ use crate::srtt::SrttCache; const MAX_REFERRAL_DEPTH: u8 = 10; const MAX_CNAME_DEPTH: u8 = 8; const NS_QUERY_TIMEOUT: Duration = Duration::from_millis(400); -const TCP_TIMEOUT: Duration = Duration::from_millis(800); +const TCP_TIMEOUT: Duration = Duration::from_millis(400); const UDP_FAIL_THRESHOLD: u8 = 3; static QUERY_ID: AtomicU16 = AtomicU16::new(1); -- 2.34.1 From 628ed00074dd423b51c71e46211fecc1f17f1bfb Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 13:08:37 +0300 Subject: [PATCH 087/204] refactor: extract cache_and_parse, remove dead truncation log, restore TCP_TIMEOUT to 400ms --- src/ctx.rs | 53 ++++++++++++++++++++++++-------------------------- src/forward.rs | 4 ---- 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index 46316f2..e1d2d95 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -229,29 +229,17 @@ pub async fn resolve_query( ) .await { - Ok(resp_wire) => { - ctx.cache.write().unwrap().insert_wire( - &qname, - qtype, - &resp_wire, - DnssecStatus::Indeterminate, - ); - let mut buf = BytePacketBuffer::from_bytes(&resp_wire); - match DnsPacket::from_buffer(&mut buf) { - Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate), - Err(e) => { - error!( - "{} | {:?} {} | PARSE ERROR | {}", - src_addr, qtype, qname, e - ); - ( - DnsPacket::response_from(&query, ResultCode::SERVFAIL), - QueryPath::UpstreamError, - DnssecStatus::Indeterminate, - ) - } + Ok(resp_wire) => match cache_and_parse(ctx, &qname, qtype, &resp_wire) { + Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate), + Err(e) => { + error!("{} | {:?} {} | PARSE ERROR | {}", src_addr, qtype, qname, e); + ( + DnsPacket::response_from(&query, ResultCode::SERVFAIL), + QueryPath::UpstreamError, + DnssecStatus::Indeterminate, + ) } - } + }, Err(e) => { error!( "{} | {:?} {} | UPSTREAM ERROR | {}", @@ -373,6 +361,20 @@ pub async fn resolve_query( Ok(resp_buffer) } +fn cache_and_parse( + ctx: &ServerCtx, + qname: &str, + qtype: QueryType, + resp_wire: &[u8], +) -> crate::Result { + ctx.cache + .write() + .unwrap() + .insert_wire(qname, qtype, resp_wire, DnssecStatus::Indeterminate); + let mut buf = BytePacketBuffer::from_bytes(resp_wire); + DnsPacket::from_buffer(&mut buf) +} + async fn forward_and_cache( wire: &[u8], upstream: &Upstream, @@ -381,12 +383,7 @@ async fn forward_and_cache( qtype: QueryType, ) -> crate::Result { let resp_wire = forward_query_raw(wire, upstream, ctx.timeout).await?; - ctx.cache - .write() - .unwrap() - .insert_wire(qname, qtype, &resp_wire, DnssecStatus::Indeterminate); - let mut buf = BytePacketBuffer::from_bytes(&resp_wire); - DnsPacket::from_buffer(&mut buf) + cache_and_parse(ctx, qname, qtype, &resp_wire) } pub async fn handle_query( diff --git a/src/forward.rs b/src/forward.rs index ebbe777..839ac81 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -176,11 +176,7 @@ pub(crate) async fn forward_udp( ) -> Result { let mut send_buffer = BytePacketBuffer::new(); query.write(&mut send_buffer)?; - let data = forward_udp_raw(send_buffer.filled(), upstream, timeout_duration).await?; - if data.len() >= 4096 { - log::debug!("upstream response may be truncated ({} bytes)", data.len()); - } let mut recv_buffer = BytePacketBuffer::from_bytes(&data); DnsPacket::from_buffer(&mut recv_buffer) } -- 2.34.1 From 15058aea83c4f171e5f7a8160351b87b6b06d9e3 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 18:35:40 +0300 Subject: [PATCH 088/204] bench: add --vs-nextdns, --vs-unbound-cold modes with mode validation - --vs-nextdns: Numa local cache vs NextDNS cloud (45.90.28.0) - --vs-unbound-cold: unique random subdomains, no record cache hits - check_numa_mode validates forward/recursive mode before running - numa-bench-recursive.toml config for cold benchmarks --- benches/numa-bench-recursive.toml | 30 ++++++++++++++ benches/recursive_compare.rs | 66 ++++++++++++++++++++++++++++--- 2 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 benches/numa-bench-recursive.toml diff --git a/benches/numa-bench-recursive.toml b/benches/numa-bench-recursive.toml new file mode 100644 index 0000000..055d75a --- /dev/null +++ b/benches/numa-bench-recursive.toml @@ -0,0 +1,30 @@ +[server] +bind_addr = "127.0.0.1:5454" +api_port = 5381 +api_bind_addr = "127.0.0.1" +data_dir = "/tmp/numa-bench" + +[upstream] +mode = "recursive" +timeout_ms = 10000 + +[cache] +min_ttl = 60 +max_ttl = 3600 + +[blocking] +enabled = false + +[proxy] +port = 8080 +tls_port = 8443 + +[dot] +enabled = true +port = 8530 + +[mobile] +enabled = false + +[lan] +enabled = false diff --git a/benches/recursive_compare.rs b/benches/recursive_compare.rs index 12f3689..dcff2c5 100644 --- a/benches/recursive_compare.rs +++ b/benches/recursive_compare.rs @@ -7,6 +7,8 @@ //! --direct Library-to-library: Numa forward_query_raw vs Hickory resolver.lookup //! --hedge-5x Hedging: single vs hedge-same vs hedge-dual vs Hickory (5 iterations) //! --vs-unbound Server-to-server: Numa vs Unbound (plain UDP, caching) +//! --vs-unbound-cold Cold: Numa vs Unbound (unique subdomains, no cache hits) +//! --vs-nextdns Server-to-cloud: Numa (local cache) vs NextDNS (remote, 45.90.28.0) //! --vs-dot DoT server: Numa vs Unbound //! --vs-doh-servers DoH server: Numa vs Unbound (DoT upstream) //! @@ -145,10 +147,20 @@ fn main() { return run_hedge_multi(&rt, 5); } if arg("--vs-unbound") { - return run_server_comparison(&rt, "Unbound", "127.0.0.1:5456", 5); + check_numa_mode(&rt, "forward"); + return run_server_comparison(&rt, "Unbound", "127.0.0.1:5456", 5, false); + } + if arg("--vs-unbound-cold") { + check_numa_mode(&rt, "recursive"); + return run_server_comparison(&rt, "Unbound", "127.0.0.1:5456", 5, true); } if arg("--vs-dnscrypt") { - return run_server_comparison(&rt, "dnscrypt-proxy", "127.0.0.1:5455", 5); + check_numa_mode(&rt, "forward"); + return run_server_comparison(&rt, "dnscrypt-proxy", "127.0.0.1:5455", 5, false); + } + if arg("--vs-nextdns") { + check_numa_mode(&rt, "forward"); + return run_server_comparison(&rt, "NextDNS", "45.90.28.0:53", 5, false); } if arg("--vs-dot") { return run_dot_comparison(&rt, 5); @@ -380,12 +392,18 @@ fn run_direct(rt: &tokio::runtime::Runtime) { } /// Server-to-server: Numa vs another server, both on plain UDP. +/// When `cold` is true, each query uses a unique random subdomain so neither +/// server can answer from its record cache (NS delegation caching still applies). fn run_server_comparison( rt: &tokio::runtime::Runtime, other_name: &str, other_addr: &str, iterations: usize, + cold: bool, ) { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let numa_addr: SocketAddr = NUMA_BENCH.parse().unwrap(); let other: SocketAddr = other_addr.parse().unwrap(); @@ -402,19 +420,35 @@ fn run_server_comparison( let _ = rt.block_on(query_udp(other, "example.com")); } + let tag = if cold { + "cold, unique subdomains" + } else { + "caching" + }; + compare_two( rt, - &format!("Server-to-Server: Numa vs {other_name} (UDP, caching)"), + &format!("Server-to-Server: Numa vs {other_name} (UDP, {tag})"), "Numa", other_name, &|domain| { + let d = if cold { + format!("c{}.{}", COUNTER.fetch_add(1, Ordering::Relaxed), domain) + } else { + domain.to_string() + }; let t = Instant::now(); - let _ = rt.block_on(query_udp(numa_addr, domain)); + let _ = rt.block_on(query_udp(numa_addr, &d)); t.elapsed().as_secs_f64() * 1000.0 }, &|domain| { + let d = if cold { + format!("c{}.{}", COUNTER.fetch_add(1, Ordering::Relaxed), domain) + } else { + domain.to_string() + }; let t = Instant::now(); - let _ = rt.block_on(query_udp(other, domain)); + let _ = rt.block_on(query_udp(other, &d)); t.elapsed().as_secs_f64() * 1000.0 }, iterations, @@ -991,6 +1025,28 @@ fn build_query(buf: &mut [u8], domain: &str) -> usize { pos } +fn check_numa_mode(rt: &tokio::runtime::Runtime, expected: &str) { + let url = format!("http://127.0.0.1:{NUMA_API}/stats"); + let resp = match rt.block_on(async { reqwest::get(&url).await?.text().await }) { + Ok(body) => body, + Err(_) => { + eprintln!("Bench Numa not responding on {NUMA_BENCH}"); + eprintln!("Start with: cargo run -- benches/numa-bench.toml"); + std::process::exit(1); + } + }; + let config = if expected == "recursive" { + "benches/numa-bench-recursive.toml" + } else { + "benches/numa-bench.toml" + }; + if !resp.contains(&format!("\"mode\":\"{expected}\"")) { + eprintln!("This benchmark requires Numa in {expected} mode."); + eprintln!("Restart with: cargo run -- {config}"); + std::process::exit(1); + } +} + fn flush_cache() { let _ = std::process::Command::new("curl") .args([ -- 2.34.1 From 05d5a5145f09765f84d714a4964b71a7a28ab34b Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 18:46:03 +0300 Subject: [PATCH 089/204] refactor: remove unused extract_question and read_wire_qname from wire.rs --- src/wire.rs | 130 ++-------------------------------------------------- 1 file changed, 3 insertions(+), 127 deletions(-) diff --git a/src/wire.rs b/src/wire.rs index a93fe27..8d299ce 100644 --- a/src/wire.rs +++ b/src/wire.rs @@ -3,7 +3,6 @@ //! These operate directly on raw DNS wire bytes without full packet parsing, //! enabling zero-copy forwarding and wire-level caching. -use crate::question::QueryType; use crate::Result; /// Metadata extracted from scanning a DNS response's wire bytes. @@ -18,32 +17,6 @@ pub struct WireMeta { pub answer_count: usize, } -/// Extract the first question's (domain, query type) from raw DNS wire bytes. -/// -/// Reads only the 12-byte header + first question section. Returns the lowercased -/// domain name and query type without allocating a full `DnsPacket`. -pub fn extract_question(wire: &[u8]) -> Result<(String, QueryType)> { - if wire.len() < 12 { - return Err("wire too short for DNS header".into()); - } - let qdcount = u16::from_be_bytes([wire[4], wire[5]]); - if qdcount == 0 { - return Err("no questions in wire".into()); - } - - let mut pos = 12; - let mut domain = String::with_capacity(64); - read_wire_qname(wire, &mut pos, &mut domain)?; - - if pos + 4 > wire.len() { - return Err("wire truncated in question section".into()); - } - let qtype = u16::from_be_bytes([wire[pos], wire[pos + 1]]); - // skip QTYPE(2) + QCLASS(2) - - Ok((domain, QueryType::from_num(qtype))) -} - /// Scan a DNS response's wire bytes and return metadata about TTL field locations. /// /// Walks the header, skips the question section, then for each resource record in @@ -155,62 +128,6 @@ pub fn patch_ttls(wire: &mut [u8], offsets: &[usize], new_ttl: u32) { } } -/// Read a DNS name from wire bytes at `pos`, handling compression pointers. -/// Advances `pos` past the name as it appears at the current position -/// (compression pointer targets do NOT advance `pos`). -fn read_wire_qname(wire: &[u8], pos: &mut usize, out: &mut String) -> Result<()> { - let mut jumped = false; - let mut read_pos = *pos; - let mut jumps = 0; - let max_jumps = 20; - - loop { - if read_pos >= wire.len() { - return Err("wire truncated reading name".into()); - } - let len = wire[read_pos] as usize; - - // Compression pointer: top 2 bits set - if len & 0xC0 == 0xC0 { - if read_pos + 1 >= wire.len() { - return Err("wire truncated in compression pointer".into()); - } - if !jumped { - *pos = read_pos + 2; // advance past the pointer - } - let offset = ((len & 0x3F) << 8) | wire[read_pos + 1] as usize; - read_pos = offset; - jumped = true; - jumps += 1; - if jumps > max_jumps { - return Err("too many compression jumps".into()); - } - continue; - } - - if len == 0 { - if !jumped { - *pos = read_pos + 1; - } - break; - } - - if read_pos + 1 + len > wire.len() { - return Err("wire truncated in name label".into()); - } - - if !out.is_empty() { - out.push('.'); - } - for &b in &wire[read_pos + 1..read_pos + 1 + len] { - out.push(b.to_ascii_lowercase() as char); - } - read_pos += 1 + len; - } - - Ok(()) -} - /// Skip a DNS name in wire bytes, advancing `pos` past it. fn skip_wire_name(wire: &[u8], pos: &mut usize) -> Result<()> { loop { @@ -238,7 +155,7 @@ mod tests { use crate::cache::{DnsCache, DnssecStatus}; use crate::header::ResultCode; use crate::packet::{DnsPacket, EdnsOpt}; - use crate::question::DnsQuestion; + use crate::question::{DnsQuestion, QueryType}; use crate::record::DnsRecord; // ── Helpers ────────────────────────────────────────────────────── @@ -760,43 +677,7 @@ mod tests { assert_eq!(&wire[0..2], &[0x00, 0x00]); } - // ── D. extract_question ───────────────────────────────────────── - - #[test] - fn extract_question_basic() { - let pkt = DnsPacket::query(0x1234, "Example.COM", QueryType::A); - let wire = to_wire(&pkt); - let (domain, qtype) = extract_question(&wire).unwrap(); - - assert_eq!(domain, "example.com"); // lowercased - assert_eq!(qtype, QueryType::A); - } - - #[test] - fn extract_question_aaaa() { - let pkt = DnsPacket::query(0x1234, "rust-lang.org", QueryType::AAAA); - let wire = to_wire(&pkt); - let (domain, qtype) = extract_question(&wire).unwrap(); - - assert_eq!(domain, "rust-lang.org"); - assert_eq!(qtype, QueryType::AAAA); - } - - #[test] - fn extract_question_too_short() { - assert!(extract_question(&[0u8; 5]).is_err()); - } - - #[test] - fn extract_question_no_questions() { - let mut wire = to_wire(&DnsPacket::query(0x1234, "example.com", QueryType::A)); - // Zero out QDCOUNT (bytes 4-5) - wire[4] = 0; - wire[5] = 0; - assert!(extract_question(&wire).is_err()); - } - - // ── E. min_ttl_from_wire ──────────────────────────────────────── + // ── D. min_ttl_from_wire ──────────────────────────────────────── #[test] fn min_ttl_answers_only() { @@ -1060,12 +941,7 @@ mod tests { assert!(scan_ttl_offsets(&[]).is_err()); } - #[test] - fn extract_question_rejects_empty_wire() { - assert!(extract_question(&[]).is_err()); - } - - // ── H. Cache behavior tests ───────────────────────────────────── + // ── G. Cache behavior tests ───────────────────────────────────── // // These test existing DnsCache behavior that must be preserved after // the wire-level migration. They use the current parsed-packet API -- 2.34.1 From 043a7e1ba5da32c291709d785f86d1fa668e5994 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 19:23:28 +0300 Subject: [PATCH 090/204] feat: raise cache default to 100K entries, evict stalest instead of dropping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 10K cap was too conservative — the blocklist alone holds 400K domains. At ~100 bytes per wire entry, 100K entries is ~10MB. When the cache is full and evict_expired doesn't free enough slots, evict_stalest removes the entry with the least remaining TTL instead of silently discarding the new insert. --- src/cache.rs | 30 +++++++++++++++++++++++++++++- src/config.rs | 2 +- src/wire.rs | 17 ++++++++++++----- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 82795bc..42cea5f 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -100,7 +100,7 @@ impl DnsCache { if self.entry_count >= self.max_entries { self.evict_expired(); if self.entry_count >= self.max_entries { - return; + self.evict_stalest(); } } @@ -260,6 +260,34 @@ impl DnsCache { }); self.entry_count -= count; } + + /// Evict the single entry closest to (or furthest past) expiry. + fn evict_stalest(&mut self) { + let mut worst: Option<(String, QueryType, Duration)> = None; + for (domain, type_map) in &self.entries { + for (qtype, entry) in type_map { + let age = entry.inserted_at.elapsed(); + let remaining = entry.ttl.saturating_sub(age); + match &worst { + None => worst = Some((domain.clone(), *qtype, remaining)), + Some((_, _, w)) if remaining < *w => { + worst = Some((domain.clone(), *qtype, remaining)); + } + _ => {} + } + } + } + if let Some((domain, qtype, _)) = worst { + if let Some(type_map) = self.entries.get_mut(&domain) { + if type_map.remove(&qtype).is_some() { + self.entry_count -= 1; + } + if type_map.is_empty() { + self.entries.remove(&domain); + } + } + } + } } pub struct CacheInfo { diff --git a/src/config.rs b/src/config.rs index 5f9db73..237f3bd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -302,7 +302,7 @@ impl Default for CacheConfig { } fn default_max_entries() -> usize { - 10000 + 100_000 } fn default_min_ttl() -> u32 { 60 diff --git a/src/wire.rs b/src/wire.rs index 8d299ce..6e2c213 100644 --- a/src/wire.rs +++ b/src/wire.rs @@ -1350,18 +1350,25 @@ mod tests { } #[test] - fn cache_max_entries_cap() { + fn cache_max_entries_evicts_stalest() { let mut cache = DnsCache::new(2, 1, 3600); - for i in 0..3 { + // Insert with decreasing TTL so test0.com is stalest + for (i, ttl) in [(0, 60), (1, 3600)] { let domain = format!("test{}.com", i); let pkt = response( i as u16, &domain, - vec![a_record(&domain, &format!("1.2.3.{}", i), 3600)], + vec![a_record(&domain, &format!("1.2.3.{}", i), ttl)], ); cache.insert(&domain, QueryType::A, &pkt); } - // Should not exceed max (third insert is silently dropped or evicts) - assert!(cache.len() <= 2); + assert_eq!(cache.len(), 2); + + // Third insert should evict test0.com (lowest remaining TTL) + let pkt = response(2, "test2.com", vec![a_record("test2.com", "1.2.3.2", 3600)]); + cache.insert("test2.com", QueryType::A, &pkt); + assert_eq!(cache.len(), 2); + assert!(cache.lookup("test0.com", QueryType::A).is_none()); // evicted + assert!(cache.lookup("test2.com", QueryType::A).is_some()); // inserted } } -- 2.34.1 From 571ce2f0133c974517a51f87b4aa754065cb1d14 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 19:42:56 +0300 Subject: [PATCH 091/204] feat: background refresh on stale cache hit (RFC 8767 revalidation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a cached entry is expired but within the 1-hour stale window, serve it immediately with TTL=1 AND spawn a background re-resolve. The next query gets a fresh entry instead of another stale serve. Without this, stale entries were served repeatedly for up to an hour with no refresh — effectively ignoring TTL. --- src/cache.rs | 9 +++++---- src/ctx.rs | 53 ++++++++++++++++++++++++++++++++++++++++++++++++---- src/doh.rs | 6 +++++- src/dot.rs | 7 +++++-- 4 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 42cea5f..5f62cc8 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -132,18 +132,19 @@ impl DnsCache { /// Read-only lookup — expired entries are left in place (cleaned up on insert). pub fn lookup(&self, domain: &str, qtype: QueryType) -> Option { - self.lookup_with_status(domain, qtype).map(|(pkt, _)| pkt) + self.lookup_with_status(domain, qtype) + .map(|(pkt, _, _)| pkt) } pub fn lookup_with_status( &self, domain: &str, qtype: QueryType, - ) -> Option<(DnsPacket, DnssecStatus)> { - let (wire, status, _stale) = self.lookup_wire(domain, qtype, 0)?; + ) -> Option<(DnsPacket, DnssecStatus, bool)> { + let (wire, status, stale) = self.lookup_wire(domain, qtype, 0)?; let mut buf = BytePacketBuffer::from_bytes(&wire); let pkt = DnsPacket::from_buffer(&mut buf).ok()?; - Some((pkt, status)) + Some((pkt, status, stale)) } pub fn insert(&mut self, domain: &str, qtype: QueryType, packet: &DnsPacket) { diff --git a/src/ctx.rs b/src/ctx.rs index e1d2d95..c1f28f2 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::net::SocketAddr; use std::path::PathBuf; -use std::sync::{Mutex, RwLock}; +use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant, SystemTime}; use arc_swap::ArcSwap; @@ -84,7 +84,7 @@ pub async fn resolve_query( query: DnsPacket, raw_wire: &[u8], src_addr: SocketAddr, - ctx: &ServerCtx, + ctx: &Arc, ) -> crate::Result { let start = Instant::now(); @@ -166,7 +166,12 @@ pub async fn resolve_query( (resp, QueryPath::Blocked, DnssecStatus::Indeterminate) } else { let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype); - if let Some((cached, cached_dnssec)) = cached { + if let Some((cached, cached_dnssec, stale)) = cached { + if stale { + let ctx = Arc::clone(ctx); + let qname = qname.clone(); + tokio::spawn(async move { warm_stale(&ctx, &qname, qtype).await }); + } let mut resp = cached; resp.header.id = query.header.id; if cached_dnssec == DnssecStatus::Secure { @@ -375,6 +380,46 @@ fn cache_and_parse( DnsPacket::from_buffer(&mut buf) } +/// Background refresh for a stale cache entry (RFC 8767 revalidation). +async fn warm_stale(ctx: &ServerCtx, qname: &str, qtype: QueryType) { + let query = DnsPacket::query(0, qname, qtype); + if ctx.upstream_mode == UpstreamMode::Recursive { + if let Ok(resp) = crate::recursive::resolve_recursive( + qname, + qtype, + &ctx.cache, + &query, + &ctx.root_hints, + &ctx.srtt, + ) + .await + { + ctx.cache.write().unwrap().insert(qname, qtype, &resp); + } + } else { + let mut buf = BytePacketBuffer::new(); + if query.write(&mut buf).is_ok() { + let pool = ctx.upstream_pool.lock().unwrap().clone(); + if let Ok(wire) = forward_with_failover_raw( + buf.filled(), + &pool, + &ctx.srtt, + ctx.timeout, + ctx.hedge_delay, + ) + .await + { + ctx.cache.write().unwrap().insert_wire( + qname, + qtype, + &wire, + DnssecStatus::Indeterminate, + ); + } + } + } +} + async fn forward_and_cache( wire: &[u8], upstream: &Upstream, @@ -390,7 +435,7 @@ pub async fn handle_query( mut buffer: BytePacketBuffer, raw_len: usize, src_addr: SocketAddr, - ctx: &ServerCtx, + ctx: &Arc, ) -> crate::Result<()> { let raw_wire = buffer.buf[..raw_len].to_vec(); let query = match DnsPacket::from_buffer(&mut buffer) { diff --git a/src/doh.rs b/src/doh.rs index e31b6fe..bc4ba95 100644 --- a/src/doh.rs +++ b/src/doh.rs @@ -60,7 +60,11 @@ fn is_doh_host(host: Option<&str>, tld: &str) -> bool { } } -async fn resolve_doh(dns_bytes: &[u8], src: SocketAddr, ctx: &ServerCtx) -> Response { +async fn resolve_doh( + dns_bytes: &[u8], + src: SocketAddr, + ctx: &std::sync::Arc, +) -> Response { let mut buffer = BytePacketBuffer::from_bytes(dns_bytes); let query = match DnsPacket::from_buffer(&mut buffer) { Ok(q) => q, diff --git a/src/dot.rs b/src/dot.rs index 4513f60..be22375 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -153,8 +153,11 @@ async fn accept_loop(listener: TcpListener, acceptor: TlsAcceptor, ctx: Arc(mut stream: S, remote_addr: SocketAddr, ctx: &ServerCtx) -where +async fn handle_dot_connection( + mut stream: S, + remote_addr: SocketAddr, + ctx: &std::sync::Arc, +) where S: AsyncReadExt + AsyncWriteExt + Unpin, { loop { -- 2.34.1 From 8ef95383a21c4e2267a9fddc9ccf30861241d6a5 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 19:46:14 +0300 Subject: [PATCH 092/204] feat: prefetch at <10% TTL remaining, add stale behavior tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entries with <10% TTL remaining are now marked stale on lookup, triggering a background refresh before they expire. Combined with the serve-stale + background refresh from the previous commit, this means entries are proactively refreshed — matching Unbound's prefetch behavior. --- src/cache.rs | 3 ++- src/wire.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 5f62cc8..fb5889b 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -71,7 +71,8 @@ impl DnsCache { let elapsed = entry.inserted_at.elapsed(); let (remaining, stale) = if elapsed < entry.ttl { let secs = (entry.ttl - elapsed).as_secs() as u32; - (secs.max(1), false) + let near_expiry = elapsed * 10 >= entry.ttl * 9; // <10% TTL remaining + (secs.max(1), near_expiry) } else if elapsed < entry.ttl + STALE_WINDOW { (1, true) } else { diff --git a/src/wire.rs b/src/wire.rs index 6e2c213..aa419f2 100644 --- a/src/wire.rs +++ b/src/wire.rs @@ -957,7 +957,7 @@ mod tests { ); cache.insert("example.com", QueryType::A, &pkt); - let (result, status) = cache + let (result, status, _) = cache .lookup_with_status("example.com", QueryType::A) .expect("should hit"); assert_eq!(result.answers.len(), 1); @@ -974,7 +974,7 @@ mod tests { ); cache.insert("example.com", QueryType::A, &pkt); - let (result, _) = cache + let (result, _, _) = cache .lookup_with_status("example.com", QueryType::A) .unwrap(); // TTL should be <= 300 (at most original, reduced by elapsed time) @@ -1032,7 +1032,7 @@ mod tests { cache.insert("example.com", QueryType::A, &pkt2); assert_eq!(cache.len(), 1); // no double count - let (result, _) = cache + let (result, _, _) = cache .lookup_with_status("example.com", QueryType::A) .unwrap(); match &result.answers[0] { @@ -1208,7 +1208,7 @@ mod tests { ); cache.insert_with_status("example.com", QueryType::A, &pkt, DnssecStatus::Secure); - let (_, status) = cache + let (_, status, _) = cache .lookup_with_status("example.com", QueryType::A) .unwrap(); assert_eq!(status, DnssecStatus::Secure); @@ -1371,4 +1371,51 @@ mod tests { assert!(cache.lookup("test0.com", QueryType::A).is_none()); // evicted assert!(cache.lookup("test2.com", QueryType::A).is_some()); // inserted } + + #[test] + fn lookup_wire_signals_stale_when_expired() { + let mut cache = DnsCache::new(100, 1, 1); // max_ttl=1s so entry expires fast + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 1)], // 1s TTL, clamped to min=1 + ); + cache.insert("example.com", QueryType::A, &pkt); + + // Fresh: not stale + let (_, _, stale) = cache.lookup_wire("example.com", QueryType::A, 0).unwrap(); + assert!(!stale); + + // Wait for expiry + std::thread::sleep(std::time::Duration::from_millis(1100)); + + // Expired but within stale window: stale=true + let (_, _, stale) = cache.lookup_wire("example.com", QueryType::A, 0).unwrap(); + assert!(stale); + } + + #[test] + fn lookup_wire_signals_prefetch_near_expiry() { + let mut cache = DnsCache::new(100, 10, 10); // min_ttl=10, max_ttl=10 → entry gets 10s TTL + let pkt = response( + 0x1234, + "example.com", + vec![a_record("example.com", "1.2.3.4", 10)], + ); + cache.insert("example.com", QueryType::A, &pkt); + + // Fresh (>10% remaining): not stale + let (_, _, stale) = cache.lookup_wire("example.com", QueryType::A, 0).unwrap(); + assert!(!stale); + + // Wait until <10% remaining (>9s elapsed of 10s TTL) + std::thread::sleep(std::time::Duration::from_millis(9100)); + + // Still valid but near expiry: stale=true (triggers prefetch) + let result = cache.lookup_wire("example.com", QueryType::A, 0); + if let Some((_, _, stale)) = result { + assert!(stale, "entry at <10% TTL should signal stale for prefetch"); + } + // (entry may have fully expired on slow CI, so we don't assert Some) + } } -- 2.34.1 From 3c49b0e65d643b0c05aa86d5e26c690ff5bf7cb7 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 19:49:23 +0300 Subject: [PATCH 093/204] fix: deduplicate background refresh with per-domain guard Multiple stale queries for the same domain now spawn only one background refresh. A HashSet<(String, QueryType)> on ServerCtx tracks in-flight refreshes; subsequent stale hits for the same key skip the spawn. --- src/api.rs | 1 + src/ctx.rs | 16 ++++++++++++---- src/dot.rs | 1 + src/main.rs | 1 + 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/api.rs b/src/api.rs index e638fba..9aa3f60 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1012,6 +1012,7 @@ mod tests { socket, zone_map: std::collections::HashMap::new(), cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)), + refreshing: Mutex::new(std::collections::HashSet::new()), stats: Mutex::new(crate::stats::ServerStats::new()), overrides: RwLock::new(crate::override_store::OverrideStore::new()), blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()), diff --git a/src/ctx.rs b/src/ctx.rs index c1f28f2..8632a28 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::{Arc, Mutex, RwLock}; @@ -35,6 +35,8 @@ pub struct ServerCtx { pub zone_map: ZoneMap, /// std::sync::RwLock (not tokio) — locks must never be held across .await points. pub cache: RwLock, + /// Domains currently being refreshed in the background (dedup guard). + pub refreshing: Mutex>, pub stats: Mutex, pub overrides: RwLock, pub blocklist: RwLock, @@ -168,9 +170,15 @@ pub async fn resolve_query( let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype); if let Some((cached, cached_dnssec, stale)) = cached { if stale { - let ctx = Arc::clone(ctx); - let qname = qname.clone(); - tokio::spawn(async move { warm_stale(&ctx, &qname, qtype).await }); + let key = (qname.clone(), qtype); + let already = !ctx.refreshing.lock().unwrap().insert(key.clone()); + if !already { + let ctx = Arc::clone(ctx); + tokio::spawn(async move { + warm_stale(&ctx, &key.0, key.1).await; + ctx.refreshing.lock().unwrap().remove(&key); + }); + } } let mut resp = cached; resp.header.id = query.header.id; diff --git a/src/dot.rs b/src/dot.rs index be22375..0216dbf 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -357,6 +357,7 @@ mod tests { m }, cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)), + refreshing: Mutex::new(std::collections::HashSet::new()), stats: Mutex::new(crate::stats::ServerStats::new()), overrides: RwLock::new(crate::override_store::OverrideStore::new()), blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()), diff --git a/src/main.rs b/src/main.rs index ebc16cc..9aa3f17 100644 --- a/src/main.rs +++ b/src/main.rs @@ -285,6 +285,7 @@ async fn main() -> numa::Result<()> { config.cache.min_ttl, config.cache.max_ttl, )), + refreshing: Mutex::new(std::collections::HashSet::new()), stats: Mutex::new(ServerStats::new()), overrides: RwLock::new(OverrideStore::new()), blocklist: RwLock::new(blocklist), -- 2.34.1 From 6d9ee14ea6333c510e1972625fa9667a505e4996 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 19:56:42 +0300 Subject: [PATCH 094/204] refactor: unify warm_stale/warm_domain, remove raw_wire alloc, add Freshness enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract refresh_entry in ctx.rs — warm_domain in main.rs now delegates to it instead of duplicating the resolve+cache logic (~40 lines removed) - Eliminate unconditional .to_vec() of raw wire on every UDP/DoT query — pass &buffer.buf[..len] directly (zero-cost for cache hits) - Replace bare bool stale flag with Freshness enum (Fresh/NearExpiry/Stale) making the three states self-documenting at every call site --- src/cache.rs | 38 +++++++++++++++++++++++++++--------- src/ctx.rs | 14 +++++++------- src/dot.rs | 3 +-- src/main.rs | 54 +++++----------------------------------------------- src/wire.rs | 29 ++++++++++++---------------- 5 files changed, 54 insertions(+), 84 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index fb5889b..18fdc19 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -6,6 +6,22 @@ use crate::packet::DnsPacket; use crate::question::QueryType; use crate::wire::WireMeta; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Freshness { + /// Within TTL, no action needed. + Fresh, + /// Within TTL but <10% remaining — trigger background prefetch. + NearExpiry, + /// Past TTL but within stale window — serve with TTL=1, trigger background refresh. + Stale, +} + +impl Freshness { + pub fn needs_refresh(self) -> bool { + matches!(self, Freshness::NearExpiry | Freshness::Stale) + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum DnssecStatus { Secure, @@ -64,17 +80,21 @@ impl DnsCache { domain: &str, qtype: QueryType, new_id: u16, - ) -> Option<(Vec, DnssecStatus, bool)> { + ) -> Option<(Vec, DnssecStatus, Freshness)> { let type_map = self.entries.get(domain)?; let entry = type_map.get(&qtype)?; let elapsed = entry.inserted_at.elapsed(); - let (remaining, stale) = if elapsed < entry.ttl { + let (remaining, freshness) = if elapsed < entry.ttl { let secs = (entry.ttl - elapsed).as_secs() as u32; - let near_expiry = elapsed * 10 >= entry.ttl * 9; // <10% TTL remaining - (secs.max(1), near_expiry) + let f = if elapsed * 10 >= entry.ttl * 9 { + Freshness::NearExpiry + } else { + Freshness::Fresh + }; + (secs.max(1), f) } else if elapsed < entry.ttl + STALE_WINDOW { - (1, true) + (1, Freshness::Stale) } else { return None; }; @@ -83,7 +103,7 @@ impl DnsCache { crate::wire::patch_id(&mut wire, new_id); crate::wire::patch_ttls(&mut wire, &entry.meta.ttl_offsets, remaining); - Some((wire, entry.dnssec_status, stale)) + Some((wire, entry.dnssec_status, freshness)) } pub fn insert_wire( @@ -141,11 +161,11 @@ impl DnsCache { &self, domain: &str, qtype: QueryType, - ) -> Option<(DnsPacket, DnssecStatus, bool)> { - let (wire, status, stale) = self.lookup_wire(domain, qtype, 0)?; + ) -> Option<(DnsPacket, DnssecStatus, Freshness)> { + let (wire, status, freshness) = self.lookup_wire(domain, qtype, 0)?; let mut buf = BytePacketBuffer::from_bytes(&wire); let pkt = DnsPacket::from_buffer(&mut buf).ok()?; - Some((pkt, status, stale)) + Some((pkt, status, freshness)) } pub fn insert(&mut self, domain: &str, qtype: QueryType, packet: &DnsPacket) { diff --git a/src/ctx.rs b/src/ctx.rs index 8632a28..e97a7ea 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -168,14 +168,14 @@ pub async fn resolve_query( (resp, QueryPath::Blocked, DnssecStatus::Indeterminate) } else { let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype); - if let Some((cached, cached_dnssec, stale)) = cached { - if stale { + if let Some((cached, cached_dnssec, freshness)) = cached { + if freshness.needs_refresh() { let key = (qname.clone(), qtype); let already = !ctx.refreshing.lock().unwrap().insert(key.clone()); if !already { let ctx = Arc::clone(ctx); tokio::spawn(async move { - warm_stale(&ctx, &key.0, key.1).await; + refresh_entry(&ctx, &key.0, key.1).await; ctx.refreshing.lock().unwrap().remove(&key); }); } @@ -388,8 +388,9 @@ fn cache_and_parse( DnsPacket::from_buffer(&mut buf) } -/// Background refresh for a stale cache entry (RFC 8767 revalidation). -async fn warm_stale(ctx: &ServerCtx, qname: &str, qtype: QueryType) { +/// Re-resolve a single (domain, qtype) and update the cache. +/// Used for both stale-entry refresh and proactive cache warming. +pub async fn refresh_entry(ctx: &ServerCtx, qname: &str, qtype: QueryType) { let query = DnsPacket::query(0, qname, qtype); if ctx.upstream_mode == UpstreamMode::Recursive { if let Ok(resp) = crate::recursive::resolve_recursive( @@ -445,7 +446,6 @@ pub async fn handle_query( src_addr: SocketAddr, ctx: &Arc, ) -> crate::Result<()> { - let raw_wire = buffer.buf[..raw_len].to_vec(); let query = match DnsPacket::from_buffer(&mut buffer) { Ok(packet) => packet, Err(e) => { @@ -453,7 +453,7 @@ pub async fn handle_query( return Ok(()); } }; - match resolve_query(query, &raw_wire, src_addr, ctx).await { + match resolve_query(query, &buffer.buf[..raw_len], 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 0216dbf..d4eeb95 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -180,7 +180,6 @@ async fn handle_dot_connection( break; }; - let raw_wire = buffer.buf[..msg_len].to_vec(); let query = match DnsPacket::from_buffer(&mut buffer) { Ok(q) => q, Err(e) => { @@ -202,7 +201,7 @@ async fn handle_dot_connection( } }; - match resolve_query(query.clone(), &raw_wire, remote_addr, ctx).await { + match resolve_query(query.clone(), &buffer.buf[..msg_len], remote_addr, ctx).await { Ok(resp_buffer) => { if write_framed(&mut stream, resp_buffer.filled()) .await diff --git a/src/main.rs b/src/main.rs index 9aa3f17..1ec7791 100644 --- a/src/main.rs +++ b/src/main.rs @@ -758,55 +758,11 @@ async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) { } async fn warm_domain(ctx: &ServerCtx, domain: &str) { - use numa::question::QueryType; - - for qtype in [QueryType::A, QueryType::AAAA] { - if ctx.upstream_mode == numa::config::UpstreamMode::Recursive { - let query = numa::packet::DnsPacket::query(0, domain, qtype); - match numa::recursive::resolve_recursive( - domain, - qtype, - &ctx.cache, - &query, - &ctx.root_hints, - &ctx.srtt, - ) - .await - { - Ok(resp) => { - ctx.cache.write().unwrap().insert(domain, qtype, &resp); - log::debug!("cache warm: {} {:?}", domain, qtype); - } - Err(e) => log::warn!("cache warm: {} {:?} failed: {}", domain, qtype, e), - } - } else { - let query = numa::packet::DnsPacket::query(0, domain, qtype); - let mut buf = numa::buffer::BytePacketBuffer::new(); - if query.write(&mut buf).is_err() { - continue; - } - let pool = ctx.upstream_pool.lock().unwrap().clone(); - match numa::forward::forward_with_failover_raw( - buf.filled(), - &pool, - &ctx.srtt, - ctx.timeout, - ctx.hedge_delay, - ) - .await - { - Ok(wire) => { - ctx.cache.write().unwrap().insert_wire( - domain, - qtype, - &wire, - numa::cache::DnssecStatus::Indeterminate, - ); - log::debug!("cache warm: {} {:?}", domain, qtype); - } - Err(e) => log::warn!("cache warm: {} {:?} failed: {}", domain, qtype, e), - } - } + for qtype in [ + numa::question::QueryType::A, + numa::question::QueryType::AAAA, + ] { + numa::ctx::refresh_entry(ctx, domain, qtype).await; } } diff --git a/src/wire.rs b/src/wire.rs index aa419f2..3ee2ab3 100644 --- a/src/wire.rs +++ b/src/wire.rs @@ -1374,29 +1374,28 @@ mod tests { #[test] fn lookup_wire_signals_stale_when_expired() { + use crate::cache::Freshness; let mut cache = DnsCache::new(100, 1, 1); // max_ttl=1s so entry expires fast let pkt = response( 0x1234, "example.com", - vec![a_record("example.com", "1.2.3.4", 1)], // 1s TTL, clamped to min=1 + vec![a_record("example.com", "1.2.3.4", 1)], ); cache.insert("example.com", QueryType::A, &pkt); - // Fresh: not stale - let (_, _, stale) = cache.lookup_wire("example.com", QueryType::A, 0).unwrap(); - assert!(!stale); + let (_, _, f) = cache.lookup_wire("example.com", QueryType::A, 0).unwrap(); + assert_eq!(f, Freshness::Fresh); - // Wait for expiry std::thread::sleep(std::time::Duration::from_millis(1100)); - // Expired but within stale window: stale=true - let (_, _, stale) = cache.lookup_wire("example.com", QueryType::A, 0).unwrap(); - assert!(stale); + let (_, _, f) = cache.lookup_wire("example.com", QueryType::A, 0).unwrap(); + assert_eq!(f, Freshness::Stale); } #[test] fn lookup_wire_signals_prefetch_near_expiry() { - let mut cache = DnsCache::new(100, 10, 10); // min_ttl=10, max_ttl=10 → entry gets 10s TTL + use crate::cache::Freshness; + let mut cache = DnsCache::new(100, 10, 10); let pkt = response( 0x1234, "example.com", @@ -1404,18 +1403,14 @@ mod tests { ); cache.insert("example.com", QueryType::A, &pkt); - // Fresh (>10% remaining): not stale - let (_, _, stale) = cache.lookup_wire("example.com", QueryType::A, 0).unwrap(); - assert!(!stale); + let (_, _, f) = cache.lookup_wire("example.com", QueryType::A, 0).unwrap(); + assert_eq!(f, Freshness::Fresh); - // Wait until <10% remaining (>9s elapsed of 10s TTL) std::thread::sleep(std::time::Duration::from_millis(9100)); - // Still valid but near expiry: stale=true (triggers prefetch) let result = cache.lookup_wire("example.com", QueryType::A, 0); - if let Some((_, _, stale)) = result { - assert!(stale, "entry at <10% TTL should signal stale for prefetch"); + if let Some((_, _, f)) = result { + assert_eq!(f, Freshness::NearExpiry); } - // (entry may have fully expired on slow CI, so we don't assert Some) } } -- 2.34.1 From 51848919858053895887afad7510eee7b7d71c24 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 20:43:46 +0300 Subject: [PATCH 095/204] fix: cold benchmark cache-busting with PID prefix and flush Re-runs of --vs-unbound-cold were hitting stale cache entries from prior runs. The static COUNTER reset to 0 each process, generating the same c0.example.com subdomains. With the 1-hour stale window, entries from 10 minutes ago served as stale hits. Fix: prefix with PID (r{pid}-c{n}.domain) and flush Numa's cache before cold benchmarks. --- benches/recursive_compare.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/benches/recursive_compare.rs b/benches/recursive_compare.rs index dcff2c5..8f3b079 100644 --- a/benches/recursive_compare.rs +++ b/benches/recursive_compare.rs @@ -403,6 +403,8 @@ fn run_server_comparison( ) { use std::sync::atomic::{AtomicU64, Ordering}; static COUNTER: AtomicU64 = AtomicU64::new(0); + // Unique prefix per process so re-runs don't hit stale cache entries + let run_id = std::process::id(); let numa_addr: SocketAddr = NUMA_BENCH.parse().unwrap(); let other: SocketAddr = other_addr.parse().unwrap(); @@ -414,6 +416,10 @@ fn run_server_comparison( } } + if cold { + flush_cache(); // flush Numa's record cache + } + println!("Warming up..."); for _ in 0..5 { let _ = rt.block_on(query_udp(numa_addr, "example.com")); @@ -433,7 +439,12 @@ fn run_server_comparison( other_name, &|domain| { let d = if cold { - format!("c{}.{}", COUNTER.fetch_add(1, Ordering::Relaxed), domain) + format!( + "r{}-c{}.{}", + run_id, + COUNTER.fetch_add(1, Ordering::Relaxed), + domain + ) } else { domain.to_string() }; @@ -443,7 +454,12 @@ fn run_server_comparison( }, &|domain| { let d = if cold { - format!("c{}.{}", COUNTER.fetch_add(1, Ordering::Relaxed), domain) + format!( + "r{}-c{}.{}", + run_id, + COUNTER.fetch_add(1, Ordering::Relaxed), + domain + ) } else { domain.to_string() }; -- 2.34.1 From 50828c411a5545ff115ab863c1d5258feae4998b Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 20:54:27 +0300 Subject: [PATCH 096/204] fix: cold benchmark uses 1 round per domain for genuine cold measurements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With ROUNDS=10, only the first query per domain was truly cold — the other 9 hit cached NS delegations at <1ms, diluting the median to 0.4ms. Now cold mode uses 1 round so every sample is a real cold resolve. Also extracted compare_two_rounds to support per-mode rounds. --- benches/recursive_compare.rs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/benches/recursive_compare.rs b/benches/recursive_compare.rs index 8f3b079..f1a59d2 100644 --- a/benches/recursive_compare.rs +++ b/benches/recursive_compare.rs @@ -183,13 +183,28 @@ fn compare_two( measure_a: &dyn Fn(&str) -> f64, measure_b: &dyn Fn(&str) -> f64, iterations: usize, +) { + compare_two_rounds( + rt, title, name_a, name_b, measure_a, measure_b, iterations, ROUNDS, + ); +} + +fn compare_two_rounds( + rt: &tokio::runtime::Runtime, + title: &str, + name_a: &str, + name_b: &str, + measure_a: &dyn Fn(&str) -> f64, + measure_b: &dyn Fn(&str) -> f64, + iterations: usize, + rounds: usize, ) { let flush = std::env::args().any(|a| a == "--flush"); println!("{}", title); println!( "{} domains × {} rounds × {} iterations\n", DOMAINS.len(), - ROUNDS, + rounds, iterations ); @@ -203,7 +218,7 @@ fn compare_two( let mut b = Vec::new(); for domain in DOMAINS { - for round in 0..ROUNDS { + for round in 0..rounds { if flush { flush_cache(); std::thread::sleep(Duration::from_millis(5)); @@ -230,6 +245,7 @@ fn compare_two( &mut all_a, &mut all_b, iterations, + rounds, ); } @@ -240,6 +256,7 @@ fn print_results( all_a: &mut Vec, all_b: &mut Vec, iterations: usize, + rounds: usize, ) { let w = name_a.len().max(name_b.len()).max(6); @@ -270,7 +287,7 @@ fn print_results( let (a_m, a_med, a_p95, a_p99, a_sd) = stats(all_a); let (b_m, b_med, b_p95, b_p99, b_sd) = stats(all_b); - let total = iterations * DOMAINS.len() * ROUNDS; + let total = iterations * DOMAINS.len() * rounds; println!("\n=== Aggregated ({} samples per method) ===\n", total); println!("{:<10} {:>w$} {:>w$}", "", name_a, name_b, w = w + 3); println!("{:<10} {:>w$.1} ms {:>w$.1} ms", "mean", a_m, b_m, w = w); @@ -432,7 +449,9 @@ fn run_server_comparison( "caching" }; - compare_two( + let rounds = if cold { 1 } else { ROUNDS }; + + compare_two_rounds( rt, &format!("Server-to-Server: Numa vs {other_name} (UDP, {tag})"), "Numa", @@ -468,6 +487,7 @@ fn run_server_comparison( t.elapsed().as_secs_f64() * 1000.0 }, iterations, + rounds, ); } -- 2.34.1 From 02e1449a4544e251be0e74336fb93cda7f3c920e Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 21:34:47 +0300 Subject: [PATCH 097/204] feat: enable request hedging for all upstream protocols Hedging was DoH-only (hyper dispatch spike mitigation). Now applies to UDP (rescues packet loss) and DoT (rescues TLS handshake stalls) too. Same-upstream hedging: fires a second independent request after hedge_ms delay. First response wins. Disable with hedge_ms = 0. --- src/forward.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/forward.rs b/src/forward.rs index 839ac81..e13e360 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -360,9 +360,11 @@ pub async fn forward_with_failover_raw( for upstream in &all_upstreams { let start = Instant::now(); - let result = if !hedge_delay.is_zero() && matches!(upstream, Upstream::Doh { .. }) { - // Hedge against the same upstream: parallel h2 streams on same - // connection. Independent stream scheduling rescues dispatch spikes. + let result = if !hedge_delay.is_zero() { + // Hedge against the same upstream: independent h2 streams (DoH), + // independent UDP packets (plain DNS), or independent TLS + // connections (DoT). Rescues packet loss, dispatch spikes, and + // TLS handshake stalls. forward_with_hedging_raw(wire, upstream, upstream, hedge_delay, timeout_duration).await } else { forward_query_raw(wire, upstream, timeout_duration).await -- 2.34.1 From 8085c1068773ccb3a11ad82a61f7523910ea4b87 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 21:37:59 +0300 Subject: [PATCH 098/204] docs: document hedge_ms, tls:// upstream, update max_entries default in numa.toml --- numa.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/numa.toml b/numa.toml index 3b716e8..1ea3341 100644 --- a/numa.toml +++ b/numa.toml @@ -15,9 +15,15 @@ api_port = 5380 # address = "9.9.9.9" # single upstream (plain UDP) # address = ["192.168.1.1", "9.9.9.9:5353"] # multiple upstreams — SRTT picks fastest # address = "https://dns.quad9.net/dns-query" # DNS-over-HTTPS (encrypted) +# address = "tls://9.9.9.9#dns.quad9.net" # DNS-over-TLS (encrypted, port 853) # fallback = ["8.8.8.8", "1.1.1.1"] # tried only when all primaries fail # port = 53 # default port for addresses without :port # timeout_ms = 3000 +# hedge_ms = 10 # request hedging delay (ms). After this delay +# # without a response, fires a parallel request +# # to the same upstream. Rescues packet loss (UDP), +# # dispatch spikes (DoH), TLS stalls (DoT). +# # Set to 0 to disable. Default: 10 # root_hints = [ # only used in recursive mode # "198.41.0.4", # a.root-servers.net (Verisign) # "199.9.14.201", # b.root-servers.net (USC-ISI) @@ -60,7 +66,7 @@ api_port = 5380 # allowlist = ["example.com"] # domains to never block [cache] -max_entries = 10000 +max_entries = 100000 min_ttl = 60 max_ttl = 86400 # warm = ["google.com", "github.com"] # resolve at startup, refresh before TTL expiry -- 2.34.1 From 2101dfcf172b69d52a5319970bf5de183ae284ff Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 22:14:26 +0300 Subject: [PATCH 099/204] feat: transport protocol tracking (UDP/TCP/DoT/DoH) with dashboard visualization Thread Transport enum through resolve pipeline, record per-query transport in stats and query log. Dashboard gets bar chart panel with encryption %, transport column in query log, and filter dropdown. --- site/dashboard.html | 89 +++++++++++++++++++++++++++++++++++++-------- src/api.rs | 17 +++++++++ src/ctx.rs | 9 +++-- src/doh.rs | 3 +- src/dot.rs | 11 +++++- src/main.rs | 4 +- src/query_log.rs | 4 +- src/stats.rs | 43 +++++++++++++++++++++- 8 files changed, 156 insertions(+), 24 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index 5fa9777..2d9cc60 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -223,6 +223,10 @@ body { .path-bar-fill.override { background: var(--emerald); } .path-bar-fill.error { background: var(--rose); } .path-bar-fill.blocked { background: var(--text-dim); } +.path-bar-fill.udp { background: var(--text-dim); } +.path-bar-fill.tcp { background: var(--violet); } +.path-bar-fill.dot { background: var(--emerald); } +.path-bar-fill.doh { background: var(--teal); } .path-pct { font-family: var(--font-mono); font-size: 0.75rem; @@ -288,6 +292,10 @@ body { .path-tag.SERVFAIL { background: rgba(181, 68, 58, 0.12); color: var(--rose); } .path-tag.BLOCKED { background: rgba(163, 152, 136, 0.15); color: var(--text-dim); } .path-tag.COALESCED { background: rgba(138, 104, 158, 0.12); color: var(--violet-dim); } +.path-tag.UDP { background: rgba(163, 152, 136, 0.15); color: var(--text-dim); } +.path-tag.TCP { background: rgba(100, 116, 139, 0.12); color: var(--violet-dim); } +.path-tag.DOT { background: rgba(82, 122, 82, 0.12); color: var(--emerald); } +.path-tag.DOH { background: rgba(107, 124, 78, 0.12); color: var(--teal); } .src-tag { font-size: 0.6rem; color: var(--text-dim); letter-spacing: 0.02em; } /* Sidebar panels */ @@ -622,6 +630,16 @@ body {
+ +
+
+ Transport + +
+
+
+
+
@@ -643,6 +661,14 @@ body { +
@@ -654,6 +680,7 @@ body { Type Domain Path + Transport Result Latency @@ -907,6 +934,27 @@ function renderMemory(mem, stats) { `; } +function renderBarChart(containerId, defs, data, total) { + total = total || 1; + document.getElementById(containerId).innerHTML = defs.map(d => { + const count = data[d.key] || 0; + const pct = ((count / total) * 100).toFixed(1); + return ` +
+ ${d.label} +
+
+
+ ${pct}% +
`; + }).join(''); +} + +function encryptionPct(transport) { + const total = (transport.udp + transport.tcp + transport.dot + transport.doh) || 1; + return (((transport.dot + transport.doh) / total) * 100).toFixed(0); +} + const PATH_DEFS = [ { key: 'forwarded', label: 'Forward', cls: 'forward' }, { key: 'recursive', label: 'Recursive', cls: 'recursive' }, @@ -918,20 +966,23 @@ const PATH_DEFS = [ ]; function renderPaths(queries) { - const total = queries.total || 1; - const container = document.getElementById('pathBars'); - container.innerHTML = PATH_DEFS.map(p => { - const count = queries[p.key] || 0; - const pct = ((count / total) * 100).toFixed(1); - return ` -
- ${p.label} -
-
-
- ${pct}% -
`; - }).join(''); + renderBarChart('pathBars', PATH_DEFS, queries, queries.total); +} + +const TRANSPORT_DEFS = [ + { key: 'udp', label: 'UDP', cls: 'udp' }, + { key: 'tcp', label: 'TCP', cls: 'tcp' }, + { key: 'dot', label: 'DoT', cls: 'dot' }, + { key: 'doh', label: 'DoH', cls: 'doh' }, +]; + +function renderTransport(transport) { + const total = (transport.udp + transport.tcp + transport.dot + transport.doh) || 1; + renderBarChart('transportBars', TRANSPORT_DEFS, transport, total); + const encPct = encryptionPct(transport); + const el = document.getElementById('transportEncrypted'); + el.textContent = `${encPct}% encrypted`; + el.style.color = encPct >= 80 ? 'var(--emerald)' : encPct >= 50 ? 'var(--amber)' : 'var(--rose)'; } function renderQueryLog(entries) { @@ -942,6 +993,7 @@ function renderQueryLog(entries) { function applyLogFilter() { const domainFilter = document.getElementById('logFilterDomain').value.trim().toLowerCase(); const pathFilter = document.getElementById('logFilterPath').value; + const transportFilter = document.getElementById('logFilterTransport').value; let filtered = lastLogEntries; if (domainFilter) { @@ -950,6 +1002,9 @@ function applyLogFilter() { if (pathFilter) { filtered = filtered.filter(e => e.path === pathFilter); } + if (transportFilter) { + filtered = filtered.filter(e => e.transport === transportFilter); + } const tbody = document.getElementById('queryLogBody'); document.getElementById('queryCount').textContent = @@ -967,6 +1022,7 @@ function applyLogFilter() { ${e.query_type} ${e.domain}${allowBtn} ${e.path} + ${e.transport} ${e.dnssec === 'secure' ? '' : ''}${e.rescode} ${e.latency_ms.toFixed(1)}ms `; @@ -1141,11 +1197,13 @@ async function refresh() { // QPS calculation const now = Date.now(); + const encPct = encryptionPct(stats.transport); if (prevTotal !== null && prevTime !== null) { const dt = (now - prevTime) / 1000; const dq = q.total - prevTotal; const qps = dt > 0 ? (dq / dt).toFixed(1) : '0.0'; - document.getElementById('qps').textContent = `~${qps}/s`; + const encTag = q.total > 0 ? ` · ${encPct}% enc` : ''; + document.getElementById('qps').textContent = `~${qps}/s${encTag}`; } prevTotal = q.total; prevTime = now; @@ -1157,6 +1215,7 @@ async function refresh() { // Panels renderPaths(q); + renderTransport(stats.transport); renderQueryLog(logs); renderOverrides(overrides); renderCache(cache); diff --git a/src/api.rs b/src/api.rs index 9aa3f60..fcc0bd9 100644 --- a/src/api.rs +++ b/src/api.rs @@ -152,6 +152,7 @@ struct QueryLogResponse { domain: String, query_type: String, path: String, + transport: String, rescode: String, latency_ms: f64, dnssec: String, @@ -167,6 +168,7 @@ struct StatsResponse { dnssec: bool, srtt: bool, queries: QueriesStats, + transport: TransportStats, cache: CacheStats, overrides: OverrideStats, blocking: BlockingStatsResponse, @@ -175,6 +177,14 @@ struct StatsResponse { memory: MemoryStats, } +#[derive(Serialize)] +struct TransportStats { + udp: u64, + tcp: u64, + dot: u64, + doh: u64, +} + #[derive(Serialize)] struct MobileStatsResponse { enabled: bool, @@ -483,6 +493,7 @@ async fn query_log( domain: e.domain.clone(), query_type: e.query_type.as_str().to_string(), path: e.path.as_str().to_string(), + transport: e.transport.as_str().to_string(), rescode: e.rescode.as_str().to_string(), latency_ms: e.latency_us as f64 / 1000.0, dnssec: e.dnssec.as_str().to_string(), @@ -545,6 +556,12 @@ async fn stats(State(ctx): State>) -> Json { blocked: snap.blocked, errors: snap.errors, }, + transport: TransportStats { + udp: snap.transport_udp, + tcp: snap.transport_tcp, + dot: snap.transport_dot, + doh: snap.transport_doh, + }, cache: CacheStats { entries: cache_len, max_entries: cache_max, diff --git a/src/ctx.rs b/src/ctx.rs index e97a7ea..65b76d3 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -27,7 +27,7 @@ use crate::question::QueryType; use crate::record::DnsRecord; use crate::service_store::ServiceStore; use crate::srtt::SrttCache; -use crate::stats::{QueryPath, ServerStats}; +use crate::stats::{QueryPath, ServerStats, Transport}; use crate::system_dns::ForwardingRule; pub struct ServerCtx { @@ -87,6 +87,7 @@ pub async fn resolve_query( raw_wire: &[u8], src_addr: SocketAddr, ctx: &Arc, + transport: Transport, ) -> crate::Result { let start = Instant::now(); @@ -354,7 +355,7 @@ pub async fn resolve_query( // Record stats and query log { let mut s = ctx.stats.lock().unwrap(); - let total = s.record(path); + let total = s.record(path, transport); if total.is_multiple_of(1000) { s.log_summary(); } @@ -366,6 +367,7 @@ pub async fn resolve_query( domain: qname, query_type: qtype, path, + transport, rescode: response.header.rescode, latency_us: elapsed.as_micros() as u64, dnssec, @@ -445,6 +447,7 @@ pub async fn handle_query( raw_len: usize, src_addr: SocketAddr, ctx: &Arc, + transport: Transport, ) -> crate::Result<()> { let query = match DnsPacket::from_buffer(&mut buffer) { Ok(packet) => packet, @@ -453,7 +456,7 @@ pub async fn handle_query( return Ok(()); } }; - match resolve_query(query, &buffer.buf[..raw_len], src_addr, ctx).await { + match resolve_query(query, &buffer.buf[..raw_len], src_addr, ctx, transport).await { Ok(resp_buffer) => { ctx.socket.send_to(resp_buffer.filled(), src_addr).await?; } diff --git a/src/doh.rs b/src/doh.rs index bc4ba95..7325688 100644 --- a/src/doh.rs +++ b/src/doh.rs @@ -10,6 +10,7 @@ use crate::buffer::BytePacketBuffer; use crate::ctx::{resolve_query, ServerCtx}; use crate::header::ResultCode; use crate::packet::DnsPacket; +use crate::stats::Transport; const MAX_DNS_MSG: usize = 4096; const DOH_CONTENT_TYPE: &str = "application/dns-message"; @@ -86,7 +87,7 @@ async fn resolve_doh( let query_rd = query.header.recursion_desired; let questions = query.questions.clone(); - match resolve_query(query, dns_bytes, src, ctx).await { + match resolve_query(query, dns_bytes, src, ctx, Transport::Doh).await { Ok(resp_buffer) => { let min_ttl = extract_min_ttl(resp_buffer.filled()); dns_response(resp_buffer.filled(), min_ttl) diff --git a/src/dot.rs b/src/dot.rs index d4eeb95..e883e0b 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -15,6 +15,7 @@ use crate::config::DotConfig; use crate::ctx::{resolve_query, ServerCtx}; use crate::header::ResultCode; use crate::packet::DnsPacket; +use crate::stats::Transport; const MAX_CONNECTIONS: usize = 512; const IDLE_TIMEOUT: Duration = Duration::from_secs(30); @@ -201,7 +202,15 @@ async fn handle_dot_connection( } }; - match resolve_query(query.clone(), &buffer.buf[..msg_len], remote_addr, ctx).await { + match resolve_query( + query.clone(), + &buffer.buf[..msg_len], + remote_addr, + ctx, + Transport::Dot, + ) + .await + { Ok(resp_buffer) => { if write_framed(&mut stream, resp_buffer.filled()) .await diff --git a/src/main.rs b/src/main.rs index 1ec7791..bce7add 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ use numa::forward::{parse_upstream, Upstream, UpstreamPool}; use numa::override_store::OverrideStore; use numa::query_log::QueryLog; use numa::service_store::ServiceStore; -use numa::stats::ServerStats; +use numa::stats::{ServerStats, Transport}; use numa::system_dns::{ discover_system_dns, install_service, restart_service, service_status, uninstall_service, }; @@ -610,7 +610,7 @@ async fn main() -> numa::Result<()> { }; let ctx = Arc::clone(&ctx); tokio::spawn(async move { - if let Err(e) = handle_query(buffer, len, src_addr, &ctx).await { + if let Err(e) = handle_query(buffer, len, src_addr, &ctx, Transport::Udp).await { error!("{} | HANDLER ERROR | {}", src_addr, e); } }); diff --git a/src/query_log.rs b/src/query_log.rs index 1dc2d17..8ce4a6e 100644 --- a/src/query_log.rs +++ b/src/query_log.rs @@ -5,7 +5,7 @@ use std::time::SystemTime; use crate::cache::DnssecStatus; use crate::header::ResultCode; use crate::question::QueryType; -use crate::stats::QueryPath; +use crate::stats::{QueryPath, Transport}; pub struct QueryLogEntry { pub timestamp: SystemTime, @@ -13,6 +13,7 @@ pub struct QueryLogEntry { pub domain: String, pub query_type: QueryType, pub path: QueryPath, + pub transport: Transport, pub rescode: ResultCode, pub latency_us: u64, pub dnssec: DnssecStatus, @@ -107,6 +108,7 @@ mod tests { domain: "example.com".into(), query_type: QueryType::A, path: QueryPath::Forwarded, + transport: Transport::Udp, rescode: ResultCode::NOERROR, latency_us: 500, dnssec: DnssecStatus::Indeterminate, diff --git a/src/stats.rs b/src/stats.rs index c1a176f..feae945 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -97,9 +97,32 @@ pub struct ServerStats { queries_local: u64, queries_overridden: u64, upstream_errors: u64, + transport_udp: u64, + transport_tcp: u64, + transport_dot: u64, + transport_doh: u64, started_at: Instant, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Transport { + Udp, + Tcp, + Dot, + Doh, +} + +impl Transport { + pub fn as_str(&self) -> &'static str { + match self { + Transport::Udp => "UDP", + Transport::Tcp => "TCP", + Transport::Dot => "DOT", + Transport::Doh => "DOH", + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum QueryPath { Local, @@ -167,11 +190,15 @@ impl ServerStats { queries_local: 0, queries_overridden: 0, upstream_errors: 0, + transport_udp: 0, + transport_tcp: 0, + transport_dot: 0, + transport_doh: 0, started_at: Instant::now(), } } - pub fn record(&mut self, path: QueryPath) -> u64 { + pub fn record(&mut self, path: QueryPath, transport: Transport) -> u64 { self.queries_total += 1; match path { QueryPath::Local => self.queries_local += 1, @@ -183,6 +210,12 @@ impl ServerStats { QueryPath::Overridden => self.queries_overridden += 1, QueryPath::UpstreamError => self.upstream_errors += 1, } + match transport { + Transport::Udp => self.transport_udp += 1, + Transport::Tcp => self.transport_tcp += 1, + Transport::Dot => self.transport_dot += 1, + Transport::Doh => self.transport_doh += 1, + } self.queries_total } @@ -206,6 +239,10 @@ impl ServerStats { overridden: self.queries_overridden, blocked: self.queries_blocked, errors: self.upstream_errors, + transport_udp: self.transport_udp, + transport_tcp: self.transport_tcp, + transport_dot: self.transport_dot, + transport_doh: self.transport_doh, } } @@ -242,4 +279,8 @@ pub struct StatsSnapshot { pub overridden: u64, pub blocked: u64, pub errors: u64, + pub transport_udp: u64, + pub transport_tcp: u64, + pub transport_dot: u64, + pub transport_doh: u64, } -- 2.34.1 From 3665deb56bd8e85f1f7fb569ba8ed0944838978c Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 17:56:39 +0300 Subject: [PATCH 100/204] fix: accept loopback addresses for DoH and add IP SANs to TLS cert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DoH endpoint rejected requests with Host: 127.0.0.1/::1/localhost, and the generated TLS cert had no IP SANs — so browsers couldn't use https://127.0.0.1/dns-query even with the CA trusted. - is_doh_host now accepts 127.0.0.1, ::1, localhost (with optional port) - TLS cert includes 127.0.0.1 and ::1 IP SANs, plus bare TLD DNS SAN Closes #87 --- src/doh.rs | 33 +++++++++++++++++++++++---------- src/tls.rs | 14 ++++++++++++++ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/doh.rs b/src/doh.rs index 7325688..917e039 100644 --- a/src/doh.rs +++ b/src/doh.rs @@ -49,16 +49,25 @@ pub async fn doh_post(State(state): State, req: Request) } fn is_doh_host(host: Option<&str>, tld: &str) -> bool { - match host { - Some(h) if h == tld => true, - Some(h) => { - h.len() == 2 * tld.len() + 1 - && h.starts_with(tld) - && h.as_bytes().get(tld.len()) == Some(&b'.') - && h.ends_with(tld) - } - None => false, - } + let h = match host { + Some(h) => h, + None => return false, + }; + is_doh_name(h, tld) + || h.rsplit_once(':').is_some_and(|(base, port)| { + port.bytes().all(|b| b.is_ascii_digit()) && is_doh_name(base, tld) + }) +} + +fn is_doh_name(h: &str, tld: &str) -> bool { + h == tld + || (h.len() == 2 * tld.len() + 1 + && h.starts_with(tld) + && h.as_bytes().get(tld.len()) == Some(&b'.') + && h.ends_with(tld)) + || h == "127.0.0.1" + || h == "::1" + || h == "localhost" } async fn resolve_doh( @@ -148,6 +157,10 @@ mod tests { fn is_doh_host_matches_tld() { assert!(is_doh_host(Some("numa"), "numa")); assert!(is_doh_host(Some("numa.numa"), "numa")); + assert!(is_doh_host(Some("127.0.0.1"), "numa")); + assert!(is_doh_host(Some("127.0.0.1:443"), "numa")); + assert!(is_doh_host(Some("::1"), "numa")); + assert!(is_doh_host(Some("localhost"), "numa")); assert!(!is_doh_host(Some("foo.numa"), "numa")); assert!(!is_doh_host(None, "numa")); } diff --git a/src/tls.rs b/src/tls.rs index e9e2f59..2443f4f 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -186,6 +186,20 @@ fn generate_service_cert( } } + // Loopback IP SANs so browsers can reach DoH at https://127.0.0.1/dns-query + sans.push(SanType::IpAddress(std::net::IpAddr::V4( + std::net::Ipv4Addr::LOCALHOST, + ))); + sans.push(SanType::IpAddress(std::net::IpAddr::V6( + std::net::Ipv6Addr::LOCALHOST, + ))); + + // Bare TLD (e.g. "numa") for DoH via https://numa/dns-query + match tld.to_string().try_into() { + Ok(ia5) => sans.push(SanType::DnsName(ia5)), + Err(e) => warn!("invalid SAN {}: {}", tld, e), + } + if sans.is_empty() { return Err("no valid service names for TLS cert".into()); } -- 2.34.1 From 115a55b199ff02ca09acda4b4549ddc12742f847 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 22:26:44 +0300 Subject: [PATCH 101/204] fix: bracketed IPv6, localhost SAN, split host-check helpers - is_doh_host split into strip_port + is_loopback_host + is_tld_match - strip_port handles bracketed IPv6 ([::1]:443) and rejects bare IPv6 - Add [::1] to accepted loopback hosts, add localhost DNS SAN to cert - Remove dead sans.is_empty() guard (loopback IPs always present) --- src/doh.rs | 37 +++++++++++++++++++++++++++++-------- src/tls.rs | 13 +++++-------- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/doh.rs b/src/doh.rs index 917e039..672402b 100644 --- a/src/doh.rs +++ b/src/doh.rs @@ -53,21 +53,39 @@ fn is_doh_host(host: Option<&str>, tld: &str) -> bool { Some(h) => h, None => return false, }; - is_doh_name(h, tld) - || h.rsplit_once(':').is_some_and(|(base, port)| { - port.bytes().all(|b| b.is_ascii_digit()) && is_doh_name(base, tld) - }) + let base = strip_port(h).unwrap_or(h); + is_loopback_host(base) || is_tld_match(base, tld) } -fn is_doh_name(h: &str, tld: &str) -> bool { +fn strip_port(h: &str) -> Option<&str> { + if h.starts_with('[') { + // [::1]:443 → [::1] + let (base, port) = h.rsplit_once("]:")?; + port.bytes() + .all(|b| b.is_ascii_digit()) + .then(|| &h[..base.len() + 1]) + } else { + let (base, port) = h.rsplit_once(':')?; + // Bare IPv6 like "::1" has multiple colons — not a port suffix + if base.contains(':') { + return None; + } + port.bytes() + .all(|b| b.is_ascii_digit()) + .then_some(base) + } +} + +fn is_loopback_host(h: &str) -> bool { + matches!(h, "127.0.0.1" | "::1" | "[::1]" | "localhost") +} + +fn is_tld_match(h: &str, tld: &str) -> bool { h == tld || (h.len() == 2 * tld.len() + 1 && h.starts_with(tld) && h.as_bytes().get(tld.len()) == Some(&b'.') && h.ends_with(tld)) - || h == "127.0.0.1" - || h == "::1" - || h == "localhost" } async fn resolve_doh( @@ -160,7 +178,10 @@ mod tests { assert!(is_doh_host(Some("127.0.0.1"), "numa")); assert!(is_doh_host(Some("127.0.0.1:443"), "numa")); assert!(is_doh_host(Some("::1"), "numa")); + assert!(is_doh_host(Some("[::1]"), "numa")); + assert!(is_doh_host(Some("[::1]:443"), "numa")); assert!(is_doh_host(Some("localhost"), "numa")); + assert!(is_doh_host(Some("localhost:443"), "numa")); assert!(!is_doh_host(Some("foo.numa"), "numa")); assert!(!is_doh_host(None, "numa")); } diff --git a/src/tls.rs b/src/tls.rs index 2443f4f..9167904 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -194,14 +194,11 @@ fn generate_service_cert( std::net::Ipv6Addr::LOCALHOST, ))); - // Bare TLD (e.g. "numa") for DoH via https://numa/dns-query - match tld.to_string().try_into() { - Ok(ia5) => sans.push(SanType::DnsName(ia5)), - Err(e) => warn!("invalid SAN {}: {}", tld, e), - } - - if sans.is_empty() { - return Err("no valid service names for TLS cert".into()); + for name in ["localhost", tld] { + match name.to_string().try_into() { + Ok(ia5) => sans.push(SanType::DnsName(ia5)), + Err(e) => warn!("invalid SAN {}: {}", name, e), + } } params.subject_alt_names = sans; -- 2.34.1 From bd505813b6f3d852e7955bc07c0b986dcb54d387 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 23:42:45 +0300 Subject: [PATCH 102/204] test: verify TLS cert SANs (wildcard, services, loopback, localhost, bare TLD) Parse the generated DER cert with x509-parser to assert the exact SAN set, catching silent try_into() failures that a params-level test would miss. --- Cargo.toml | 1 + src/tls.rs | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index d7f6f9f..6ab0972 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ tower = { version = "0.5", features = ["util"] } http = "1" hickory-resolver = { version = "0.25", features = ["https-ring", "webpki-roots"] } hickory-proto = "0.25" +x509-parser = "0.18" [[bench]] name = "hot_path" diff --git a/src/tls.rs b/src/tls.rs index 9167904..22a00a4 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -251,4 +251,72 @@ mod tests { let err: crate::Error = "rcgen failure".into(); assert!(try_data_dir_advisory(&err, &PathBuf::from("/x")).is_none()); } + + #[test] + fn service_cert_contains_expected_sans() { + use x509_parser::prelude::GeneralName; + + let dir = std::env::temp_dir().join(format!("numa-test-san-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (ca_der, issuer) = ensure_ca(&dir).unwrap(); + + let names = vec!["grafana".into(), "router".into()]; + let (chain, _) = generate_service_cert(&ca_der, &issuer, "numa", &names).unwrap(); + assert_eq!(chain.len(), 2, "chain should be [leaf, CA]"); + + let (_, cert) = x509_parser::parse_x509_certificate(chain[0].as_ref()).unwrap(); + let san = cert + .tbs_certificate + .subject_alternative_name() + .unwrap() + .unwrap(); + + let dns: Vec<&str> = san + .value + .general_names + .iter() + .filter_map(|gn| match gn { + GeneralName::DNSName(s) => Some(*s), + _ => None, + }) + .collect(); + + let ips: Vec = san + .value + .general_names + .iter() + .filter_map(|gn| match gn { + GeneralName::IPAddress(b) => match b.len() { + 4 => Some(std::net::IpAddr::V4(std::net::Ipv4Addr::new( + b[0], b[1], b[2], b[3], + ))), + 16 => { + let a: [u8; 16] = (*b).try_into().unwrap(); + Some(std::net::IpAddr::V6(std::net::Ipv6Addr::from(a))) + } + _ => None, + }, + _ => None, + }) + .collect(); + + // DNS SANs + assert!(dns.contains(&"*.numa"), "missing wildcard SAN"); + assert!(dns.contains(&"grafana.numa"), "missing service SAN"); + assert!(dns.contains(&"router.numa"), "missing service SAN"); + assert!(dns.contains(&"localhost"), "missing localhost SAN"); + assert!(dns.contains(&"numa"), "missing bare TLD SAN"); + + // IP SANs + assert!( + ips.contains(&std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), + "missing 127.0.0.1 SAN" + ); + assert!( + ips.contains(&std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)), + "missing ::1 SAN" + ); + + let _ = std::fs::remove_dir_all(&dir); + } } -- 2.34.1 From 305935ed9867db6e7877c81b38114a3190b9a004 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 23:59:51 +0300 Subject: [PATCH 103/204] style: rustfmt strip_port --- src/doh.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/doh.rs b/src/doh.rs index 672402b..f90b919 100644 --- a/src/doh.rs +++ b/src/doh.rs @@ -70,9 +70,7 @@ fn strip_port(h: &str) -> Option<&str> { if base.contains(':') { return None; } - port.bytes() - .all(|b| b.is_ascii_digit()) - .then_some(base) + port.bytes().all(|b| b.is_ascii_digit()).then_some(base) } } -- 2.34.1 From 77d2c8bbcd93a2c2f2a35df28d1108659b796827 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 00:18:52 +0300 Subject: [PATCH 104/204] docs: update README comparison table, performance, and roadmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Comparison table: add DoH/DoT upstream, DoH server, request hedging, serve-stale + prefetch, conditional forwarding rows - Performance: update with current benchmark numbers (0.1ms cached, 47x NextDNS, p99 -28% vs Unbound) - Roadmap: add hedging, serve-stale, conditional forwarding, DoT upstream - Fix broken benchmarks link (bench/ → benches/) --- README.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 44b8aa4..5c632cf 100644 --- a/README.md +++ b/README.md @@ -113,14 +113,18 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena | DNSSEC validation | — | — | Yes | Yes (RSA, ECDSA, Ed25519) | | Ad blocking | Yes | Yes | — | 385K+ domains | | Web admin UI | Full | Full | — | Dashboard | -| Encrypted upstream (DoH) | Needs cloudflared | Yes | — | Native | +| Encrypted upstream (DoH/DoT) | Needs cloudflared | DoH only | DoT only | DoH + DoT (`tls://`) | | Encrypted clients (DoT listener) | Needs stunnel sidecar | Yes | Yes | Native (RFC 7858) | +| DoH server endpoint | — | Yes | — | Yes (RFC 8484) | +| Request hedging | — | — | — | All protocols (UDP, DoH, DoT) | +| Serve-stale + prefetch | — | — | Prefetch at 90% TTL | RFC 8767, prefetch at 90% TTL | +| Conditional forwarding | — | Yes | Yes | Yes (per-suffix rules) | | Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary, macOS/Linux/Windows | | Community maturity | 56K stars, 10 years | 33K stars | 20 years | New | ## Performance -691ns cached round-trip. ~2.0M qps throughput. Zero heap allocations in the hot path. Recursive queries average 237ms after SRTT warmup (12x improvement over round-robin). ECDSA P-256 DNSSEC verification: 174ns. [Benchmarks →](bench/) +0.1ms cached queries — matches Unbound, 47× faster than NextDNS. Wire-level cache stores raw bytes with in-place TTL patching. Request hedging eliminates p99 spikes: cold recursive p99 538ms vs Unbound 748ms (−28%), σ 4× tighter. [Benchmarks →](benches/) ## Learn More @@ -135,11 +139,14 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena - [x] DNS forwarding, caching, ad blocking, developer overrides - [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] DNS-over-HTTPS — encrypted upstream + server endpoint (RFC 8484) +- [x] DNS-over-TLS — encrypted client listener (RFC 7858) + upstream forwarding (`tls://`) - [x] Recursive resolution + DNSSEC — chain-of-trust, NSEC/NSEC3 - [x] SRTT-based nameserver selection - [x] Multi-forwarder failover — multiple upstreams with SRTT ranking, fallback pool +- [x] Request hedging — parallel requests rescue packet loss and tail latency (all protocols) +- [x] Serve-stale + prefetch — RFC 8767, background refresh at <10% TTL and on stale serve +- [x] Conditional forwarding — per-suffix rules for split-horizon DNS (Tailscale, VPNs) - [x] Cache warming — proactive resolution for configured domains - [x] Mobile onboarding — `setup-phone` QR flow, mobile API, mobileconfig profiles - [ ] pkarr integration — self-sovereign DNS via Mainline DHT -- 2.34.1 From 501902d569a9cb6a837bd2c9df66c24c94df57fa Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 00:56:58 +0300 Subject: [PATCH 105/204] bench: add --vs-adguard mode for Numa vs AdGuard Home comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdGuard Home on port 5457, both forwarding via DoH. Cached queries tied at 0.1ms. On degraded networks hedging hurts p99 (28ms vs 10ms without) — both requests pay the same high RTT with no random spikes to rescue. On clean networks hedging wins. --- benches/recursive_compare.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/benches/recursive_compare.rs b/benches/recursive_compare.rs index f1a59d2..74f9576 100644 --- a/benches/recursive_compare.rs +++ b/benches/recursive_compare.rs @@ -8,6 +8,7 @@ //! --hedge-5x Hedging: single vs hedge-same vs hedge-dual vs Hickory (5 iterations) //! --vs-unbound Server-to-server: Numa vs Unbound (plain UDP, caching) //! --vs-unbound-cold Cold: Numa vs Unbound (unique subdomains, no cache hits) +//! --vs-adguard Server-to-server: Numa vs AdGuard Home (plain UDP, caching) //! --vs-nextdns Server-to-cloud: Numa (local cache) vs NextDNS (remote, 45.90.28.0) //! --vs-dot DoT server: Numa vs Unbound //! --vs-doh-servers DoH server: Numa vs Unbound (DoT upstream) @@ -158,6 +159,10 @@ fn main() { check_numa_mode(&rt, "forward"); return run_server_comparison(&rt, "dnscrypt-proxy", "127.0.0.1:5455", 5, false); } + if arg("--vs-adguard") { + check_numa_mode(&rt, "forward"); + return run_server_comparison(&rt, "AdGuard Home", "127.0.0.1:5457", 5, false); + } if arg("--vs-nextdns") { check_numa_mode(&rt, "forward"); return run_server_comparison(&rt, "NextDNS", "45.90.28.0:53", 5, false); -- 2.34.1 From 2b29a44ee0f6e6b89875b8ad24043fd46c0c60c9 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 01:02:10 +0300 Subject: [PATCH 106/204] docs: remove unfair NextDNS comparison from performance section Comparing local cache (0.8ms) vs a remote service (37ms) measures network latency, not resolver quality. Any local resolver would show the same advantage. Replaced with AdGuard Home comparison which is a fair local-to-local benchmark. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5c632cf..9979d46 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena ## Performance -0.1ms cached queries — matches Unbound, 47× faster than NextDNS. Wire-level cache stores raw bytes with in-place TTL patching. Request hedging eliminates p99 spikes: cold recursive p99 538ms vs Unbound 748ms (−28%), σ 4× tighter. [Benchmarks →](benches/) +0.1ms cached queries — matches Unbound and AdGuard Home. Wire-level cache stores raw bytes with in-place TTL patching. Request hedging eliminates p99 spikes: cold recursive p99 538ms vs Unbound 748ms (−28%), σ 4× tighter. [Benchmarks →](benches/) ## Learn More -- 2.34.1 From 4d4e48bbd6c78c6a1f306391c1196492109c85ad Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 01:05:20 +0300 Subject: [PATCH 107/204] chore: bump version to 0.13.0 --- Cargo.lock | 3 ++- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0f7692..dbbd921 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1330,7 +1330,7 @@ dependencies = [ [[package]] name = "numa" -version = "0.12.0" +version = "0.13.0" dependencies = [ "arc-swap", "axum", @@ -1359,6 +1359,7 @@ dependencies = [ "toml", "tower", "webpki-roots 1.0.6", + "x509-parser", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6ab0972..19044ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "numa" -version = "0.12.0" +version = "0.13.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 ca0084639337ad82dbc7997496763944c438e867 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 07:36:53 +0300 Subject: [PATCH 108/204] fix: forwarding rules override special-use NXDOMAIN for private PTR zones Explicit [[forwarding]] rules now take precedence over the RFC 6303 special-use domain intercept. Previously, PTR queries for private ranges (e.g. 168.192.in-addr.arpa) always returned local NXDOMAIN even when a forwarding rule pointed them at a corporate DNS server. Add full-pipeline resolve_query test harness (test_ctx + resolve_in_test) and two tests covering both the default behavior and the override. Closes #94 --- src/ctx.rs | 163 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 3 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index 65b76d3..ee88b78 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -96,7 +96,8 @@ pub async fn resolve_query( None => return Err("empty question section".into()), }; - // Pipeline: overrides -> .tld interception -> blocklist -> local zones -> cache -> upstream + // Pipeline: overrides -> .localhost -> local zones -> special-use (unless forwarded) + // -> .tld proxy -> blocklist -> cache -> forwarding -> recursive/upstream // Each lock is scoped to avoid holding MutexGuard across await points. let (response, path, dnssec) = { let override_record = ctx.overrides.read().unwrap().lookup(&qname); @@ -119,8 +120,11 @@ pub async fn resolve_query( let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); resp.answers = records.clone(); (resp, QueryPath::Local, DnssecStatus::Indeterminate) - } else if is_special_use_domain(&qname) { - // RFC 6761/8880: private PTR, DDR, NAT64 — answer locally + } else if is_special_use_domain(&qname) + && crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules).is_none() + { + // RFC 6761/8880: private PTR, DDR, NAT64 — answer locally, + // unless an explicit forwarding rule covers this zone. let resp = special_use_response(&query, &qname, qtype); (resp, QueryPath::Local, DnssecStatus::Indeterminate) } else if !ctx.proxy_tld_suffix.is_empty() @@ -655,6 +659,7 @@ mod tests { use super::*; use std::collections::HashMap; use std::net::Ipv4Addr; + use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tokio::sync::broadcast; @@ -1036,4 +1041,156 @@ mod tests { "error message must be preserved for logging" ); } + + // ---- Full-pipeline resolve_query tests ---- + + async fn test_ctx() -> Arc { + let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + Arc::new(ServerCtx { + socket, + zone_map: HashMap::new(), + cache: RwLock::new(DnsCache::new(100, 60, 86400)), + refreshing: Mutex::new(HashSet::new()), + stats: Mutex::new(ServerStats::new()), + overrides: RwLock::new(OverrideStore::new()), + blocklist: RwLock::new(BlocklistStore::new()), + query_log: Mutex::new(QueryLog::new(100)), + services: Mutex::new(ServiceStore::new()), + lan_peers: Mutex::new(PeerStore::new(90)), + forwarding_rules: Vec::new(), + upstream_pool: Mutex::new(UpstreamPool::new( + vec![Upstream::Udp("127.0.0.1:53".parse().unwrap())], + vec![], + )), + upstream_auto: false, + upstream_port: 53, + lan_ip: Mutex::new(Ipv4Addr::LOCALHOST), + timeout: Duration::from_secs(3), + hedge_delay: Duration::ZERO, + proxy_tld: "numa".to_string(), + proxy_tld_suffix: ".numa".to_string(), + lan_enabled: false, + config_path: "/tmp/test-numa.toml".to_string(), + config_found: false, + config_dir: PathBuf::from("/tmp"), + data_dir: PathBuf::from("/tmp"), + tls_config: None, + upstream_mode: UpstreamMode::Forward, + root_hints: Vec::new(), + srtt: RwLock::new(SrttCache::new(true)), + inflight: Mutex::new(HashMap::new()), + dnssec_enabled: false, + dnssec_strict: false, + health_meta: HealthMeta::test_fixture(), + ca_pem: None, + mobile_enabled: false, + mobile_port: 8765, + }) + } + + /// Helper: send a query through the full resolve_query pipeline and return + /// the parsed response + query path. + async fn resolve_in_test( + ctx: &Arc, + domain: &str, + qtype: QueryType, + ) -> (DnsPacket, QueryPath) { + let query = DnsPacket::query(0xBEEF, domain, qtype); + let mut buf = BytePacketBuffer::new(); + query.write(&mut buf).unwrap(); + let raw = &buf.buf[..buf.pos]; + let src: SocketAddr = "127.0.0.1:1234".parse().unwrap(); + + let resp_buf = resolve_query(query, raw, src, ctx, Transport::Udp) + .await + .unwrap(); + + let log = ctx.query_log.lock().unwrap(); + let entry = log.query(&crate::query_log::QueryLogFilter { + domain: None, + query_type: None, + path: None, + since: None, + limit: Some(1), + }); + let path = entry.first().unwrap().path; + drop(log); + + let mut resp_parse_buf = BytePacketBuffer::from_bytes(resp_buf.filled()); + let resp = DnsPacket::from_buffer(&mut resp_parse_buf).unwrap(); + (resp, path) + } + + #[tokio::test] + async fn special_use_private_ptr_returns_nxdomain() { + let ctx = test_ctx().await; + let (resp, path) = + resolve_in_test(&ctx, "153.188.168.192.in-addr.arpa", QueryType::PTR).await; + assert_eq!(path, QueryPath::Local); + assert_eq!(resp.header.rescode, ResultCode::NXDOMAIN); + } + + async fn test_ctx_with_forwarding(rules: Vec) -> Arc { + let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + Arc::new(ServerCtx { + socket, + zone_map: HashMap::new(), + cache: RwLock::new(DnsCache::new(100, 60, 86400)), + refreshing: Mutex::new(HashSet::new()), + stats: Mutex::new(ServerStats::new()), + overrides: RwLock::new(OverrideStore::new()), + blocklist: RwLock::new(BlocklistStore::new()), + query_log: Mutex::new(QueryLog::new(100)), + services: Mutex::new(ServiceStore::new()), + lan_peers: Mutex::new(PeerStore::new(90)), + forwarding_rules: rules, + upstream_pool: Mutex::new(UpstreamPool::new( + vec![Upstream::Udp("127.0.0.1:53".parse().unwrap())], + vec![], + )), + upstream_auto: false, + upstream_port: 53, + lan_ip: Mutex::new(Ipv4Addr::LOCALHOST), + timeout: Duration::from_millis(100), + hedge_delay: Duration::ZERO, + proxy_tld: "numa".to_string(), + proxy_tld_suffix: ".numa".to_string(), + lan_enabled: false, + config_path: "/tmp/test-numa.toml".to_string(), + config_found: false, + config_dir: PathBuf::from("/tmp"), + data_dir: PathBuf::from("/tmp"), + tls_config: None, + upstream_mode: UpstreamMode::Forward, + root_hints: Vec::new(), + srtt: RwLock::new(SrttCache::new(true)), + inflight: Mutex::new(HashMap::new()), + dnssec_enabled: false, + dnssec_strict: false, + health_meta: HealthMeta::test_fixture(), + ca_pem: None, + mobile_enabled: false, + mobile_port: 8765, + }) + } + + #[tokio::test] + async fn forwarding_rule_overrides_special_use_domain() { + let rules = vec![ForwardingRule::new( + "168.192.in-addr.arpa".to_string(), + "192.168.88.1:53".parse().unwrap(), + )]; + let ctx = test_ctx_with_forwarding(rules).await; + + let (_, path) = resolve_in_test(&ctx, "153.188.168.192.in-addr.arpa", QueryType::PTR).await; + + // Should attempt forwarding, not return local NXDOMAIN. + // The forwarding will fail (no real upstream at 192.168.88.1), so we + // expect UpstreamError — but critically NOT QueryPath::Local. + assert_ne!( + path, + QueryPath::Local, + "forwarding rule must take precedence over special-use NXDOMAIN" + ); + } } -- 2.34.1 From 48f67be2f15903314e5a99da36bb2a62b457b2e7 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 07:39:55 +0300 Subject: [PATCH 109/204] refactor: deduplicate test_ctx by delegating to test_ctx_with_forwarding --- src/ctx.rs | 42 +----------------------------------------- 1 file changed, 1 insertion(+), 41 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index ee88b78..e440c2d 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1045,47 +1045,7 @@ mod tests { // ---- Full-pipeline resolve_query tests ---- async fn test_ctx() -> Arc { - let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap(); - Arc::new(ServerCtx { - socket, - zone_map: HashMap::new(), - cache: RwLock::new(DnsCache::new(100, 60, 86400)), - refreshing: Mutex::new(HashSet::new()), - stats: Mutex::new(ServerStats::new()), - overrides: RwLock::new(OverrideStore::new()), - blocklist: RwLock::new(BlocklistStore::new()), - query_log: Mutex::new(QueryLog::new(100)), - services: Mutex::new(ServiceStore::new()), - lan_peers: Mutex::new(PeerStore::new(90)), - forwarding_rules: Vec::new(), - upstream_pool: Mutex::new(UpstreamPool::new( - vec![Upstream::Udp("127.0.0.1:53".parse().unwrap())], - vec![], - )), - upstream_auto: false, - upstream_port: 53, - lan_ip: Mutex::new(Ipv4Addr::LOCALHOST), - timeout: Duration::from_secs(3), - hedge_delay: Duration::ZERO, - proxy_tld: "numa".to_string(), - proxy_tld_suffix: ".numa".to_string(), - lan_enabled: false, - config_path: "/tmp/test-numa.toml".to_string(), - config_found: false, - config_dir: PathBuf::from("/tmp"), - data_dir: PathBuf::from("/tmp"), - tls_config: None, - upstream_mode: UpstreamMode::Forward, - root_hints: Vec::new(), - srtt: RwLock::new(SrttCache::new(true)), - inflight: Mutex::new(HashMap::new()), - dnssec_enabled: false, - dnssec_strict: false, - health_meta: HealthMeta::test_fixture(), - ca_pem: None, - mobile_enabled: false, - mobile_port: 8765, - }) + test_ctx_with_forwarding(Vec::new()).await } /// Helper: send a query through the full resolve_query pipeline and return -- 2.34.1 From b8ddc16027453beec62888e9ee062b105f362543 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 07:51:14 +0300 Subject: [PATCH 110/204] refactor: return QueryPath from resolve_query, add mock upstream to tests resolve_query now returns (BytePacketBuffer, QueryPath) so callers and tests can inspect the resolution path without reading the query log. Production call sites (UDP, DoT, DoH) destructure and ignore it. The forwarding test now uses a mock UDP upstream that replies with a canned response, asserting QueryPath::Forwarded instead of != Local. --- src/ctx.rs | 57 ++++++++++++++++++++++++++++++++---------------------- src/doh.rs | 2 +- src/dot.rs | 2 +- 3 files changed, 36 insertions(+), 25 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index e440c2d..3f1370a 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -88,7 +88,7 @@ pub async fn resolve_query( src_addr: SocketAddr, ctx: &Arc, transport: Transport, -) -> crate::Result { +) -> crate::Result<(BytePacketBuffer, QueryPath)> { let start = Instant::now(); let (qname, qtype) = match query.questions.first() { @@ -377,7 +377,7 @@ pub async fn resolve_query( dnssec, }); - Ok(resp_buffer) + Ok((resp_buffer, path)) } fn cache_and_parse( @@ -461,7 +461,7 @@ pub async fn handle_query( } }; match resolve_query(query, &buffer.buf[..raw_len], src_addr, ctx, transport).await { - Ok(resp_buffer) => { + Ok((resp_buffer, _)) => { ctx.socket.send_to(resp_buffer.filled(), src_addr).await?; } Err(e) => { @@ -1048,7 +1048,7 @@ mod tests { test_ctx_with_forwarding(Vec::new()).await } - /// Helper: send a query through the full resolve_query pipeline and return + /// Send a query through the full resolve_query pipeline and return /// the parsed response + query path. async fn resolve_in_test( ctx: &Arc, @@ -1061,21 +1061,10 @@ mod tests { let raw = &buf.buf[..buf.pos]; let src: SocketAddr = "127.0.0.1:1234".parse().unwrap(); - let resp_buf = resolve_query(query, raw, src, ctx, Transport::Udp) + let (resp_buf, path) = resolve_query(query, raw, src, ctx, Transport::Udp) .await .unwrap(); - let log = ctx.query_log.lock().unwrap(); - let entry = log.query(&crate::query_log::QueryLogFilter { - domain: None, - query_type: None, - path: None, - since: None, - limit: Some(1), - }); - let path = entry.first().unwrap().path; - drop(log); - let mut resp_parse_buf = BytePacketBuffer::from_bytes(resp_buf.filled()); let resp = DnsPacket::from_buffer(&mut resp_parse_buf).unwrap(); (resp, path) @@ -1134,23 +1123,45 @@ mod tests { }) } + /// Spawn a UDP socket that replies to the first DNS query with the given + /// response packet (patching the query ID). Returns the socket address. + async fn mock_upstream(response: DnsPacket) -> SocketAddr { + let sock = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let addr = sock.local_addr().unwrap(); + tokio::spawn(async move { + let mut buf = [0u8; 512]; + let (_, src) = sock.recv_from(&mut buf).await.unwrap(); + let query_id = u16::from_be_bytes([buf[0], buf[1]]); + let mut resp = response; + resp.header.id = query_id; + let mut out = BytePacketBuffer::new(); + resp.write(&mut out).unwrap(); + sock.send_to(out.filled(), src).await.unwrap(); + }); + addr + } + #[tokio::test] async fn forwarding_rule_overrides_special_use_domain() { + let mut resp = DnsPacket::new(); + resp.header.response = true; + resp.header.rescode = ResultCode::NOERROR; + let upstream_addr = mock_upstream(resp).await; + let rules = vec![ForwardingRule::new( "168.192.in-addr.arpa".to_string(), - "192.168.88.1:53".parse().unwrap(), + upstream_addr, )]; let ctx = test_ctx_with_forwarding(rules).await; - let (_, path) = resolve_in_test(&ctx, "153.188.168.192.in-addr.arpa", QueryType::PTR).await; + let (resp, path) = + resolve_in_test(&ctx, "153.188.168.192.in-addr.arpa", QueryType::PTR).await; - // Should attempt forwarding, not return local NXDOMAIN. - // The forwarding will fail (no real upstream at 192.168.88.1), so we - // expect UpstreamError — but critically NOT QueryPath::Local. - assert_ne!( + assert_eq!( path, - QueryPath::Local, + QueryPath::Forwarded, "forwarding rule must take precedence over special-use NXDOMAIN" ); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); } } diff --git a/src/doh.rs b/src/doh.rs index f90b919..900edb4 100644 --- a/src/doh.rs +++ b/src/doh.rs @@ -113,7 +113,7 @@ async fn resolve_doh( let questions = query.questions.clone(); match resolve_query(query, dns_bytes, src, ctx, Transport::Doh).await { - Ok(resp_buffer) => { + Ok((resp_buffer, _)) => { let min_ttl = extract_min_ttl(resp_buffer.filled()); dns_response(resp_buffer.filled(), min_ttl) } diff --git a/src/dot.rs b/src/dot.rs index e883e0b..db8257d 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -211,7 +211,7 @@ async fn handle_dot_connection( ) .await { - Ok(resp_buffer) => { + Ok((resp_buffer, _)) => { if write_framed(&mut stream, resp_buffer.filled()) .await .is_err() -- 2.34.1 From b40004fe5e41dc7800d25cbec5e49347a6e68674 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 07:56:47 +0300 Subject: [PATCH 111/204] refactor: extract shared test infrastructure into testutil module - test_ctx(): single ServerCtx builder, replaces 3 copies (ctx/api/dot) - mock_upstream(): canned DNS response server for forwarding tests - blackhole_upstream(): unresponsive socket for timeout tests - Removes ~100 lines of duplicated 30-field struct literals --- src/api.rs | 45 +---------------------- src/ctx.rs | 76 +++------------------------------------ src/dot.rs | 82 +++++++++++++----------------------------- src/lib.rs | 3 ++ src/testutil.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 173 deletions(-) create mode 100644 src/testutil.rs diff --git a/src/api.rs b/src/api.rs index fcc0bd9..6ec3e48 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1020,53 +1020,10 @@ mod tests { use super::*; use axum::body::Body; use http::Request; - use std::sync::{Mutex, RwLock}; use tower::ServiceExt; async fn test_ctx() -> Arc { - let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); - Arc::new(ServerCtx { - socket, - zone_map: std::collections::HashMap::new(), - cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)), - refreshing: Mutex::new(std::collections::HashSet::new()), - stats: Mutex::new(crate::stats::ServerStats::new()), - overrides: RwLock::new(crate::override_store::OverrideStore::new()), - blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()), - query_log: Mutex::new(crate::query_log::QueryLog::new(100)), - services: Mutex::new(crate::service_store::ServiceStore::new()), - lan_peers: Mutex::new(crate::lan::PeerStore::new(90)), - forwarding_rules: Vec::new(), - upstream_pool: Mutex::new(crate::forward::UpstreamPool::new( - vec![crate::forward::Upstream::Udp( - "127.0.0.1:53".parse().unwrap(), - )], - vec![], - )), - upstream_auto: false, - upstream_port: 53, - lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST), - timeout: std::time::Duration::from_secs(3), - hedge_delay: std::time::Duration::ZERO, - proxy_tld: "numa".to_string(), - proxy_tld_suffix: ".numa".to_string(), - lan_enabled: false, - config_path: "/tmp/test-numa.toml".to_string(), - config_found: false, - config_dir: std::path::PathBuf::from("/tmp"), - data_dir: std::path::PathBuf::from("/tmp"), - tls_config: None, - upstream_mode: crate::config::UpstreamMode::Forward, - root_hints: Vec::new(), - srtt: RwLock::new(crate::srtt::SrttCache::new(true)), - inflight: Mutex::new(std::collections::HashMap::new()), - dnssec_enabled: false, - dnssec_strict: false, - health_meta: crate::health::HealthMeta::test_fixture(), - ca_pem: None, - mobile_enabled: false, - mobile_port: 8765, - }) + Arc::new(crate::testutil::test_ctx().await) } #[tokio::test] diff --git a/src/ctx.rs b/src/ctx.rs index 3f1370a..475dfe7 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -659,7 +659,6 @@ mod tests { use super::*; use std::collections::HashMap; use std::net::Ipv4Addr; - use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tokio::sync::broadcast; @@ -1044,10 +1043,6 @@ mod tests { // ---- Full-pipeline resolve_query tests ---- - async fn test_ctx() -> Arc { - test_ctx_with_forwarding(Vec::new()).await - } - /// Send a query through the full resolve_query pipeline and return /// the parsed response + query path. async fn resolve_in_test( @@ -1072,87 +1067,26 @@ mod tests { #[tokio::test] async fn special_use_private_ptr_returns_nxdomain() { - let ctx = test_ctx().await; + let ctx = Arc::new(crate::testutil::test_ctx().await); let (resp, path) = resolve_in_test(&ctx, "153.188.168.192.in-addr.arpa", QueryType::PTR).await; assert_eq!(path, QueryPath::Local); assert_eq!(resp.header.rescode, ResultCode::NXDOMAIN); } - async fn test_ctx_with_forwarding(rules: Vec) -> Arc { - let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap(); - Arc::new(ServerCtx { - socket, - zone_map: HashMap::new(), - cache: RwLock::new(DnsCache::new(100, 60, 86400)), - refreshing: Mutex::new(HashSet::new()), - stats: Mutex::new(ServerStats::new()), - overrides: RwLock::new(OverrideStore::new()), - blocklist: RwLock::new(BlocklistStore::new()), - query_log: Mutex::new(QueryLog::new(100)), - services: Mutex::new(ServiceStore::new()), - lan_peers: Mutex::new(PeerStore::new(90)), - forwarding_rules: rules, - upstream_pool: Mutex::new(UpstreamPool::new( - vec![Upstream::Udp("127.0.0.1:53".parse().unwrap())], - vec![], - )), - upstream_auto: false, - upstream_port: 53, - lan_ip: Mutex::new(Ipv4Addr::LOCALHOST), - timeout: Duration::from_millis(100), - hedge_delay: Duration::ZERO, - proxy_tld: "numa".to_string(), - proxy_tld_suffix: ".numa".to_string(), - lan_enabled: false, - config_path: "/tmp/test-numa.toml".to_string(), - config_found: false, - config_dir: PathBuf::from("/tmp"), - data_dir: PathBuf::from("/tmp"), - tls_config: None, - upstream_mode: UpstreamMode::Forward, - root_hints: Vec::new(), - srtt: RwLock::new(SrttCache::new(true)), - inflight: Mutex::new(HashMap::new()), - dnssec_enabled: false, - dnssec_strict: false, - health_meta: HealthMeta::test_fixture(), - ca_pem: None, - mobile_enabled: false, - mobile_port: 8765, - }) - } - - /// Spawn a UDP socket that replies to the first DNS query with the given - /// response packet (patching the query ID). Returns the socket address. - async fn mock_upstream(response: DnsPacket) -> SocketAddr { - let sock = UdpSocket::bind("127.0.0.1:0").await.unwrap(); - let addr = sock.local_addr().unwrap(); - tokio::spawn(async move { - let mut buf = [0u8; 512]; - let (_, src) = sock.recv_from(&mut buf).await.unwrap(); - let query_id = u16::from_be_bytes([buf[0], buf[1]]); - let mut resp = response; - resp.header.id = query_id; - let mut out = BytePacketBuffer::new(); - resp.write(&mut out).unwrap(); - sock.send_to(out.filled(), src).await.unwrap(); - }); - addr - } - #[tokio::test] async fn forwarding_rule_overrides_special_use_domain() { let mut resp = DnsPacket::new(); resp.header.response = true; resp.header.rescode = ResultCode::NOERROR; - let upstream_addr = mock_upstream(resp).await; + let upstream_addr = crate::testutil::mock_upstream(resp).await; - let rules = vec![ForwardingRule::new( + let mut ctx = crate::testutil::test_ctx().await; + ctx.forwarding_rules = vec![ForwardingRule::new( "168.192.in-addr.arpa".to_string(), upstream_addr, )]; - let ctx = test_ctx_with_forwarding(rules).await; + let ctx = Arc::new(ctx); let (resp, path) = resolve_in_test(&ctx, "153.188.168.192.in-addr.arpa", QueryType::PTR).await; diff --git a/src/dot.rs b/src/dot.rs index db8257d..b39d7fe 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -279,7 +279,7 @@ where mod tests { use super::*; use std::collections::HashMap; - use std::sync::{Mutex, RwLock}; + use std::sync::Mutex; use rcgen::{CertificateParams, DnType, KeyPair}; use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName}; @@ -344,63 +344,29 @@ mod tests { async fn spawn_dot_server() -> (SocketAddr, CertificateDer<'static>) { let (server_tls, cert_der) = test_tls_configs(); - let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); - // Bind an unresponsive upstream and leak it so it lives for the test duration. - let blackhole = Box::leak(Box::new(std::net::UdpSocket::bind("127.0.0.1:0").unwrap())); - let upstream_addr = blackhole.local_addr().unwrap(); - let ctx = Arc::new(ServerCtx { - socket, - zone_map: { - let mut m = HashMap::new(); - let mut inner = HashMap::new(); - inner.insert( - QueryType::A, - vec![DnsRecord::A { - domain: "dot-test.example".to_string(), - addr: std::net::Ipv4Addr::new(10, 0, 0, 1), - ttl: 300, - }], - ); - m.insert("dot-test.example".to_string(), inner); - m - }, - cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)), - refreshing: Mutex::new(std::collections::HashSet::new()), - stats: Mutex::new(crate::stats::ServerStats::new()), - overrides: RwLock::new(crate::override_store::OverrideStore::new()), - blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()), - query_log: Mutex::new(crate::query_log::QueryLog::new(100)), - services: Mutex::new(crate::service_store::ServiceStore::new()), - lan_peers: Mutex::new(crate::lan::PeerStore::new(90)), - forwarding_rules: Vec::new(), - upstream_pool: Mutex::new(crate::forward::UpstreamPool::new( - vec![crate::forward::Upstream::Udp(upstream_addr)], - vec![], - )), - upstream_auto: false, - upstream_port: 53, - lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST), - timeout: Duration::from_millis(200), - hedge_delay: Duration::ZERO, - proxy_tld: "numa".to_string(), - proxy_tld_suffix: ".numa".to_string(), - lan_enabled: false, - config_path: String::new(), - config_found: false, - config_dir: std::path::PathBuf::from("/tmp"), - data_dir: std::path::PathBuf::from("/tmp"), - tls_config: Some(arc_swap::ArcSwap::from(server_tls)), - upstream_mode: crate::config::UpstreamMode::Forward, - root_hints: Vec::new(), - srtt: RwLock::new(crate::srtt::SrttCache::new(true)), - inflight: Mutex::new(HashMap::new()), - dnssec_enabled: false, - dnssec_strict: false, - health_meta: crate::health::HealthMeta::test_fixture(), - ca_pem: None, - mobile_enabled: false, - mobile_port: 8765, - }); + let upstream_addr = crate::testutil::blackhole_upstream(); + + let mut ctx = crate::testutil::test_ctx().await; + ctx.zone_map = { + let mut m = HashMap::new(); + let mut inner = HashMap::new(); + inner.insert( + QueryType::A, + vec![DnsRecord::A { + domain: "dot-test.example".to_string(), + addr: std::net::Ipv4Addr::new(10, 0, 0, 1), + ttl: 300, + }], + ); + m.insert("dot-test.example".to_string(), inner); + m + }; + ctx.upstream_pool = Mutex::new(crate::forward::UpstreamPool::new( + vec![crate::forward::Upstream::Udp(upstream_addr)], + vec![], + )); + ctx.tls_config = Some(arc_swap::ArcSwap::from(server_tls)); + let ctx = Arc::new(ctx); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 92a0b00..8933e2a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,9 @@ pub mod system_dns; pub mod tls; pub mod wire; +#[cfg(test)] +pub(crate) mod testutil; + pub type Error = Box; pub type Result = std::result::Result; diff --git a/src/testutil.rs b/src/testutil.rs new file mode 100644 index 0000000..8687625 --- /dev/null +++ b/src/testutil.rs @@ -0,0 +1,95 @@ +use std::collections::{HashMap, HashSet}; +use std::net::{Ipv4Addr, SocketAddr}; +use std::path::PathBuf; +use std::sync::{Mutex, RwLock}; +use std::time::Duration; + +use tokio::net::UdpSocket; + +use crate::blocklist::BlocklistStore; +use crate::buffer::BytePacketBuffer; +use crate::cache::DnsCache; +use crate::config::UpstreamMode; +use crate::ctx::ServerCtx; +use crate::forward::{Upstream, UpstreamPool}; +use crate::health::HealthMeta; +use crate::lan::PeerStore; +use crate::override_store::OverrideStore; +use crate::packet::DnsPacket; +use crate::query_log::QueryLog; +use crate::service_store::ServiceStore; +use crate::srtt::SrttCache; +use crate::stats::ServerStats; +/// Minimal `ServerCtx` for tests. Override fields after construction +/// (all fields are `pub`), then wrap in `Arc`. +pub async fn test_ctx() -> ServerCtx { + let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + ServerCtx { + socket, + zone_map: HashMap::new(), + cache: RwLock::new(DnsCache::new(100, 60, 86400)), + refreshing: Mutex::new(HashSet::new()), + stats: Mutex::new(ServerStats::new()), + overrides: RwLock::new(OverrideStore::new()), + blocklist: RwLock::new(BlocklistStore::new()), + query_log: Mutex::new(QueryLog::new(100)), + services: Mutex::new(ServiceStore::new()), + lan_peers: Mutex::new(PeerStore::new(90)), + forwarding_rules: Vec::new(), + upstream_pool: Mutex::new(UpstreamPool::new( + vec![Upstream::Udp("127.0.0.1:53".parse().unwrap())], + vec![], + )), + upstream_auto: false, + upstream_port: 53, + lan_ip: Mutex::new(Ipv4Addr::LOCALHOST), + timeout: Duration::from_millis(200), + hedge_delay: Duration::ZERO, + proxy_tld: "numa".to_string(), + proxy_tld_suffix: ".numa".to_string(), + lan_enabled: false, + config_path: "/tmp/test-numa.toml".to_string(), + config_found: false, + config_dir: PathBuf::from("/tmp"), + data_dir: PathBuf::from("/tmp"), + tls_config: None, + upstream_mode: UpstreamMode::Forward, + root_hints: Vec::new(), + srtt: RwLock::new(SrttCache::new(true)), + inflight: Mutex::new(HashMap::new()), + dnssec_enabled: false, + dnssec_strict: false, + health_meta: HealthMeta::test_fixture(), + ca_pem: None, + mobile_enabled: false, + mobile_port: 8765, + } +} + +/// Spawn a UDP socket that replies to the first DNS query with the given +/// response packet (patching the query ID to match). Returns the socket address. +pub async fn mock_upstream(response: DnsPacket) -> SocketAddr { + let sock = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let addr = sock.local_addr().unwrap(); + tokio::spawn(async move { + let mut buf = [0u8; 512]; + let (_, src) = sock.recv_from(&mut buf).await.unwrap(); + let query_id = u16::from_be_bytes([buf[0], buf[1]]); + let mut resp = response; + resp.header.id = query_id; + let mut out = BytePacketBuffer::new(); + resp.write(&mut out).unwrap(); + sock.send_to(out.filled(), src).await.unwrap(); + }); + addr +} + +/// UDP socket that accepts connections but never replies. +/// Useful as an upstream that triggers timeouts. +pub fn blackhole_upstream() -> SocketAddr { + let sock = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + let addr = sock.local_addr().unwrap(); + // Leak so it stays bound for the duration of the test process. + Box::leak(Box::new(sock)); + addr +} -- 2.34.1 From 155c1c4da0f1fcd7f27c835939967a87ecebcae5 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 08:04:59 +0300 Subject: [PATCH 112/204] test: full-pipeline coverage for every resolve_query step Test each pipeline stage in isolation through resolve_query: - override takes precedence over all other paths - localhost and *.localhost resolve to loopback - local zone returns configured records - .tld proxy resolves registered services to loopback - blocklist sinkholes to 0.0.0.0 - cache hit returns stored response without upstream --- src/ctx.rs | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/src/ctx.rs b/src/ctx.rs index 475dfe7..460b0eb 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1098,4 +1098,125 @@ mod tests { ); assert_eq!(resp.header.rescode, ResultCode::NOERROR); } + + #[tokio::test] + async fn pipeline_override_takes_precedence() { + let ctx = crate::testutil::test_ctx().await; + ctx.overrides + .write() + .unwrap() + .insert("override.test", "1.2.3.4", 60, None) + .unwrap(); + let ctx = Arc::new(ctx); + + let (resp, path) = resolve_in_test(&ctx, "override.test", QueryType::A).await; + assert_eq!(path, QueryPath::Overridden); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + assert_eq!(resp.answers.len(), 1); + } + + #[tokio::test] + async fn pipeline_localhost_resolves_to_loopback() { + let ctx = Arc::new(crate::testutil::test_ctx().await); + + let (resp, path) = resolve_in_test(&ctx, "localhost", QueryType::A).await; + assert_eq!(path, QueryPath::Local); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + match &resp.answers[0] { + DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::LOCALHOST), + other => panic!("expected A record, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_localhost_subdomain_resolves_to_loopback() { + let ctx = Arc::new(crate::testutil::test_ctx().await); + + let (resp, path) = resolve_in_test(&ctx, "app.localhost", QueryType::A).await; + assert_eq!(path, QueryPath::Local); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + } + + #[tokio::test] + async fn pipeline_local_zone_returns_configured_record() { + let mut ctx = crate::testutil::test_ctx().await; + let mut inner = HashMap::new(); + inner.insert( + QueryType::A, + vec![DnsRecord::A { + domain: "myapp.test".to_string(), + addr: Ipv4Addr::new(10, 0, 0, 42), + ttl: 300, + }], + ); + ctx.zone_map.insert("myapp.test".to_string(), inner); + let ctx = Arc::new(ctx); + + let (resp, path) = resolve_in_test(&ctx, "myapp.test", QueryType::A).await; + assert_eq!(path, QueryPath::Local); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + match &resp.answers[0] { + DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::new(10, 0, 0, 42)), + other => panic!("expected A record, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_tld_proxy_resolves_service() { + let ctx = crate::testutil::test_ctx().await; + ctx.services.lock().unwrap().insert("grafana", 3000); + let ctx = Arc::new(ctx); + + let (resp, path) = resolve_in_test(&ctx, "grafana.numa", QueryType::A).await; + assert_eq!(path, QueryPath::Local); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + match &resp.answers[0] { + DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::LOCALHOST), + other => panic!("expected A record, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_blocklist_sinkhole() { + let ctx = crate::testutil::test_ctx().await; + let mut domains = std::collections::HashSet::new(); + domains.insert("ads.tracker.test".to_string()); + ctx.blocklist.write().unwrap().swap_domains(domains, vec![]); + let ctx = Arc::new(ctx); + + let (resp, path) = resolve_in_test(&ctx, "ads.tracker.test", QueryType::A).await; + assert_eq!(path, QueryPath::Blocked); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + match &resp.answers[0] { + DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::UNSPECIFIED), + other => panic!("expected sinkhole A record, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_cache_hit() { + let ctx = Arc::new(crate::testutil::test_ctx().await); + + // Pre-populate cache with a response + let mut pkt = DnsPacket::new(); + pkt.header.response = true; + pkt.header.rescode = ResultCode::NOERROR; + pkt.questions.push(crate::question::DnsQuestion { + name: "cached.test".to_string(), + qtype: QueryType::A, + }); + pkt.answers.push(DnsRecord::A { + domain: "cached.test".to_string(), + addr: Ipv4Addr::new(5, 5, 5, 5), + ttl: 3600, + }); + ctx.cache + .write() + .unwrap() + .insert("cached.test", QueryType::A, &pkt); + + let (resp, path) = resolve_in_test(&ctx, "cached.test", QueryType::A).await; + assert_eq!(path, QueryPath::Cached); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + } } -- 2.34.1 From 0bdde40f4094fd298b2a2db7bcf74064451982b6 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 08:07:58 +0300 Subject: [PATCH 113/204] test: verify forwarded response content from mock upstream --- src/ctx.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/ctx.rs b/src/ctx.rs index 460b0eb..4e5d938 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1219,4 +1219,33 @@ mod tests { assert_eq!(path, QueryPath::Cached); assert_eq!(resp.header.rescode, ResultCode::NOERROR); } + + #[tokio::test] + async fn pipeline_forwarding_returns_upstream_answer() { + let mut upstream_resp = DnsPacket::new(); + upstream_resp.header.response = true; + upstream_resp.header.rescode = ResultCode::NOERROR; + upstream_resp.answers.push(DnsRecord::A { + domain: "internal.corp".to_string(), + addr: Ipv4Addr::new(10, 1, 2, 3), + ttl: 600, + }); + let upstream_addr = crate::testutil::mock_upstream(upstream_resp).await; + + let mut ctx = crate::testutil::test_ctx().await; + ctx.forwarding_rules = vec![ForwardingRule::new("corp".to_string(), upstream_addr)]; + let ctx = Arc::new(ctx); + + let (resp, path) = resolve_in_test(&ctx, "internal.corp", QueryType::A).await; + assert_eq!(path, QueryPath::Forwarded); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + assert_eq!(resp.answers.len(), 1); + match &resp.answers[0] { + DnsRecord::A { domain, addr, .. } => { + assert_eq!(domain, "internal.corp"); + assert_eq!(*addr, Ipv4Addr::new(10, 1, 2, 3)); + } + other => panic!("expected A record, got {:?}", other), + } + } } -- 2.34.1 From d3f046da4cab44e8c6201003344336b46d81eb06 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 08:10:26 +0300 Subject: [PATCH 114/204] style: assert loopback addr in subdomain test, trim verbose comment --- src/ctx.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index 4e5d938..2812bed 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -123,8 +123,7 @@ pub async fn resolve_query( } else if is_special_use_domain(&qname) && crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules).is_none() { - // RFC 6761/8880: private PTR, DDR, NAT64 — answer locally, - // unless an explicit forwarding rule covers this zone. + // RFC 6761/8880: answer locally unless a forwarding rule covers this zone. let resp = special_use_response(&query, &qname, qtype); (resp, QueryPath::Local, DnssecStatus::Indeterminate) } else if !ctx.proxy_tld_suffix.is_empty() @@ -1135,6 +1134,10 @@ mod tests { let (resp, path) = resolve_in_test(&ctx, "app.localhost", QueryType::A).await; assert_eq!(path, QueryPath::Local); assert_eq!(resp.header.rescode, ResultCode::NOERROR); + match &resp.answers[0] { + DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::LOCALHOST), + other => panic!("expected A record, got {:?}", other), + } } #[tokio::test] -- 2.34.1 From 6b0a30d004c2eeafc8839a3dd3145fc4c1dde0ad Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 13:49:40 +0300 Subject: [PATCH 115/204] blog: add fixing DoH tail latency post + blog infrastructure New post on reqwest HTTP/2 window tuning and request hedging (Dean & Barroso's "The Tail at Scale" applied to DNS forwarding). Covers DoH forwarding p99 improvement and cold recursive resolution from 2.3s to 538ms. Also adds blog build infrastructure: index generation script, draft preview server, hero metrics/before-after CSS, and normalizes date format across existing posts. --- .gitignore | 2 + Makefile | 13 ++ blog/dns-from-scratch.md | 2 +- blog/dnssec-from-scratch.md | 2 +- blog/dot-from-scratch.md | 2 +- blog/fixing-doh-tail-latency.md | 169 ++++++++++++++++++++++ scripts/generate-blog-index.sh | 239 ++++++++++++++++++++++++++++++++ scripts/serve-site.sh | 14 ++ site/blog-template.html | 96 +++++++++++++ site/blog/index.html | 11 +- 10 files changed, 545 insertions(+), 5 deletions(-) create mode 100644 blog/fixing-doh-tail-latency.md create mode 100755 scripts/generate-blog-index.sh create mode 100755 scripts/serve-site.sh diff --git a/.gitignore b/.gitignore index 649d86b..acfc601 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ CLAUDE.md docs/ site/blog/posts/ ios/ +drafts/ +site/blog/index.html diff --git a/Makefile b/Makefile index f84761a..dbff53a 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,19 @@ blog: pandoc "$$f" --template=site/blog-template.html -o "site/blog/posts/$$name.html"; \ echo " $$f → site/blog/posts/$$name.html"; \ done + @scripts/generate-blog-index.sh + +blog-drafts: blog + @if [ -d drafts ] && ls drafts/*.md >/dev/null 2>&1; then \ + for f in drafts/*.md; do \ + name=$$(basename "$$f" .md); \ + pandoc "$$f" --template=site/blog-template.html -o "site/blog/posts/$$name.html"; \ + echo " $$f → site/blog/posts/$$name.html (draft)"; \ + done; \ + BLOG_INCLUDE_DRAFTS=1 scripts/generate-blog-index.sh; \ + else \ + echo " No drafts found"; \ + fi release: ifndef VERSION diff --git a/blog/dns-from-scratch.md b/blog/dns-from-scratch.md index 7bf666c..c626f8a 100644 --- a/blog/dns-from-scratch.md +++ b/blog/dns-from-scratch.md @@ -1,7 +1,7 @@ --- title: I Built a DNS Resolver from Scratch in Rust description: How DNS actually works at the wire level — label compression, TTL tricks, DoH, and what surprised me building a resolver with zero DNS libraries. -date: March 2026 +date: 2026-03-20 --- I wanted to understand how DNS actually works. Not the "it translates domain names to IP addresses" explanation — the actual bytes on the wire. What does a DNS packet look like? How does label compression work? Why is everything crammed into 512 bytes? diff --git a/blog/dnssec-from-scratch.md b/blog/dnssec-from-scratch.md index 01bc5c5..804b425 100644 --- a/blog/dnssec-from-scratch.md +++ b/blog/dnssec-from-scratch.md @@ -1,7 +1,7 @@ --- title: Implementing DNSSEC from Scratch in Rust description: Recursive resolution from root hints, chain-of-trust validation, NSEC/NSEC3 denial proofs, and what I learned implementing DNSSEC with zero DNS libraries. -date: March 2026 +date: 2026-03-28 --- In the [previous post](/blog/posts/dns-from-scratch.html) I covered how DNS works at the wire level — packet format, label compression, TTL caching, DoH. Numa was a forwarding resolver: it parsed packets, did useful things locally, and relayed the rest to Cloudflare or Quad9. diff --git a/blog/dot-from-scratch.md b/blog/dot-from-scratch.md index 448f185..859202d 100644 --- a/blog/dot-from-scratch.md +++ b/blog/dot-from-scratch.md @@ -1,7 +1,7 @@ --- title: DNS-over-TLS from Scratch in Rust description: Building RFC 7858 on top of rustls — length-prefix framing, ALPN cross-protocol defense, and two bugs that only the strict clients caught. -date: April 2026 +date: 2026-04-06 --- The [previous post](/blog/posts/dnssec-from-scratch.html) ended with "DoT — the last encrypted transport we don't support." This post is about building it. diff --git a/blog/fixing-doh-tail-latency.md b/blog/fixing-doh-tail-latency.md new file mode 100644 index 0000000..661c456 --- /dev/null +++ b/blog/fixing-doh-tail-latency.md @@ -0,0 +1,169 @@ +--- +title: Fixing DNS tail latency with a 5-line config and a 50-line function +description: We had periodic 40-140ms DoH spikes from hyper's dispatch channel. The fix was reqwest window tuning and request hedging — Dean & Barroso's "The Tail at Scale," applied to a DNS forwarder. Same ideas took our cold recursive p99 from 2.3 seconds to 538ms. +date: 2026-04-12 +--- + +Numa forwards DNS queries over HTTPS using reqwest. When we benchmarked the DoH path, we found periodic 40-140ms latency spikes every ~100ms of wall clock, in an otherwise ~10ms distribution. The tail was dragging our average — median 10ms, mean 23ms. + +
+
+
DoH forwarding p99
+
113 → 71ms
+
window tuning + request hedging
+
+
+
Cold recursive p99
+
2.3s → 538ms
+
NS caching, serve-stale, parallel queries
+
+
+
Forwarding σ
+
31 → 13ms
+
random spikes become parallel races
+
+
+ +The fix was a 5-line reqwest config and a 50-line hedging function. This post is also an advertisement for Dean & Barroso's 2013 paper ["The Tail at Scale"](https://research.google/pubs/pub40801/) — a decade-old idea that still demolishes dispatch spikes. + +--- + +## The cause: hyper's dispatch channel + +Reqwest sits on top of hyper, which interposes an mpsc dispatch channel and a separate `ClientTask` between `.send()` and the h2 stream. We instrumented the forwarding path and confirmed: 100% of the spike time lives in the `send()` phase, and a parallel heartbeat task showed zero runtime lag during spikes. The tokio runtime was fine — the stall was internal to hyper's request scheduling. + +Hickory-resolver doesn't have this issue. It holds `h2::SendRequest` directly and calls `ready().await; send_request()` in the caller's task — no channel, no scheduling dependency. We used it as a reference point throughout. + +## Fix #1 — HTTP/2 window sizes + +Reqwest inherits hyper's HTTP/2 defaults: 2 MB stream window, 5 MB connection window. For DNS responses (~200 bytes), that's ~10,000× oversized — unnecessary WINDOW_UPDATE frames, bloated bookkeeping on every poll, and different server-side scheduling behavior. + +Setting both windows to the h2 spec default (64 KB) dropped our median from 13.3ms to 10.1ms: + +```rust +reqwest::Client::builder() + .use_rustls_tls() + .http2_initial_stream_window_size(65_535) + .http2_initial_connection_window_size(65_535) + .http2_keep_alive_interval(Duration::from_secs(15)) + .http2_keep_alive_while_idle(true) + .http2_keep_alive_timeout(Duration::from_secs(10)) + .pool_idle_timeout(Duration::from_secs(300)) + .pool_max_idle_per_host(1) + .build() +``` + +**Any Rust code using reqwest for tiny-payload HTTP/2 workloads — DoH, API polling, metric scraping — is probably hitting this.** + +## Fix #2 — Request hedging + +["The Tail at Scale"](https://research.google/pubs/pub40801/) (Dean & Barroso, 2013): fire a request, and if it doesn't return within your P50 latency, fire the same request in parallel. First response wins. + +The intuition: if 5% of requests spike due to independent random events, two parallel requests means only 0.25% of pairs spike on *both*. The tail collapses. + +**The surprise: hedging against the same upstream works.** HTTP/2 multiplexes streams — two `send_request()` calls on one connection become independent h2 streams. If one stalls in the dispatch channel, the other keeps making progress. + +```rust +pub async fn forward_with_hedging_raw( + wire: &[u8], + primary: &Upstream, + secondary: &Upstream, + hedge_delay: Duration, + timeout_duration: Duration, +) -> Result> { + let primary_fut = forward_query_raw(wire, primary, timeout_duration); + tokio::pin!(primary_fut); + let delay = sleep(hedge_delay); + tokio::pin!(delay); + + // Phase 1: wait for primary to return OR the hedge delay. + tokio::select! { + result = &mut primary_fut => return result, + _ = &mut delay => {} + } + + // Phase 2: hedge delay expired — fire secondary, keep primary alive. + let secondary_fut = forward_query_raw(wire, secondary, timeout_duration); + tokio::pin!(secondary_fut); + + // First successful response wins. + tokio::select! { + r = primary_fut => r, + r = secondary_fut => r, + } +} +``` + +The [production version](https://github.com/razvandimescu/numa/blob/main/src/forward.rs#L267) adds error handling — if one leg fails, it waits for the other. In production, Numa passes the same `&Upstream` twice when only one is configured. We extended hedging to all protocols — UDP (rescues packet loss on WiFi), DoT (rescues TLS handshake stalls). Configurable via `hedge_ms`; set to 0 to disable. + +**Caveat: hedging hurts on degraded networks.** When latency is consistently high (no random spikes, just slow), the hedge adds overhead with nothing to rescue. Hedging is a variance reducer, not a latency reducer — it only helps when spikes are *random*. + +--- + +## Forwarding results + +5 iterations × 101 domains × 10 rounds, 5,050 samples per method. Hickory-resolver included as a reference (it uses h2 directly, no dispatch channel): + +| | Single | **Hedged** | Hickory (ref) | +|---|---|---|---| +| mean | 17.4ms | **14.3ms** | 16.8ms | +| median | 10.4ms | **10.2ms** | 13.3ms | +| p95 | 52.5ms | **28.6ms** | 37.7ms | +| p99 | 113.4ms | **71.3ms** | 98.1ms | +| σ | 30.6ms | **13.2ms** | 19.1ms | + +The internal improvement: hedging cut p95 by 45%, p99 by 37%, σ by 57%. The exact margin vs hickory varies with network conditions; the σ reduction is consistent across runs. + +## Recursive resolution: from 2.3 seconds to 538ms + +Forwarding is one job. Recursive resolution — walking from root hints through TLD nameservers to the authoritative server — is a different one. We started 15× behind Unbound on cold recursive p99 and traced it to four root causes. + +**1. Missing NS delegation caching.** We cached glue records (ns1's IP) but not the delegation itself. Every `.com` query walked from root. Fix: cache NS records from referral authority sections. (10 lines) + +**2. Expired cache entries caused full cold resolutions.** Fix: serve-stale ([RFC 8767](https://www.rfc-editor.org/rfc/rfc8767)) — return expired entries with TTL=1 while revalidating in the background. (20 lines) + +**3. 1,900ms wasted per unreachable server.** 800ms UDP timeout + unconditional 1,500ms TCP fallback. Fix: 400ms UDP, TCP only for truncation. (5 lines) + +**4. Sequential NS queries on cold starts.** Fix: fire to the top 2 nameservers simultaneously. First response wins, SRTT recorded for both. Same hedging principle. (50 lines) + +
+
+
p99 before
+
2,367ms
+
+
+
+
p99 after
+
538ms
+
+
+
Unbound (ref)
+
748ms
+
+
+ +Genuine cold benchmarks — unique subdomains, 1 query per domain, 5 iterations, 505 samples per server: + +| | Baseline | Final | Unbound (ref) | +|---|---|---|---| +| p99 | 2,367ms | **538ms** | 748ms | +| σ | 254ms | **114ms** | 457ms | +| median | — | 77.6ms | 74.7ms | + +Unbound wins median by ~4% — its C implementation and 19 years of recursive optimization give it an edge on raw speed. It also has features we don't yet: aggressive NSEC caching ([RFC 8198](https://www.rfc-editor.org/rfc/rfc8198)) and a persistent infra cache. Where hedging shines is the tail — domains with slow or unreachable nameservers, where parallel queries turn worst-case sequential timeouts into races. + +Cache hits are tied across Numa, Unbound, and AdGuard Home — all serve at 0.1ms. + +--- + +## Takeaways + +The real hero of this post is Dean & Barroso. Hedging works because **spikes are random, and two random draws rarely both lose**. It's effective for any HTTP/2 client, any language, any forwarder topology. Nobody we know of ships it by default. + +If you're building a Rust service that makes many small HTTP/2 requests to the same backend: check your flow control window sizes first, then implement hedging. Don't rewrite the client. + +Benchmarks are in [`benches/recursive_compare.rs`](https://github.com/razvandimescu/numa/blob/main/benches/recursive_compare.rs) — run them yourself. If you're using reqwest for tiny-payload workloads and try the window size fix, I'd love to hear if you see the same improvement. + +--- + +Numa is a DNS resolver that runs on your laptop or phone. DoH, DoT, .numa local domains, ad blocking, developer overrides, a REST API, and all the optimization work in this post. [github.com/razvandimescu/numa](https://github.com/razvandimescu/numa). diff --git a/scripts/generate-blog-index.sh b/scripts/generate-blog-index.sh new file mode 100755 index 0000000..cacc033 --- /dev/null +++ b/scripts/generate-blog-index.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Generate site/blog/index.html from blog/*.md frontmatter. +# Reads title, description, date from YAML frontmatter in each post. +# Sorts newest first (by date string — "April 2026" > "March 2026"). + +OUT="site/blog/index.html" + +# Extract frontmatter fields from a markdown file +extract() { + local file="$1" field="$2" + sed -n '/^---$/,/^---$/p' "$file" | grep "^${field}:" | sed "s/^${field}: *//" +} + +# Collect posts: "date|name|title|description" per line +posts="" +sources="blog/*.md" +if [ "${BLOG_INCLUDE_DRAFTS:-}" = "1" ] && ls drafts/*.md >/dev/null 2>&1; then + sources="blog/*.md drafts/*.md" +fi +for f in $sources; do + name=$(basename "$f" .md) + title=$(extract "$f" title) + desc=$(extract "$f" description) + date=$(extract "$f" date) + posts+="${date}|${name}|${title}|${desc}"$'\n' +done + +# Sort by ISO date (YYYY-MM-DD), newest first +posts=$(echo "$posts" | grep -v '^$' | sort -t'|' -k1 -r) + +# Format ISO date (YYYY-MM-DD) to "Month YYYY" +format_date() { + local months=(January February March April May June July August September October November December) + local y="${1%%-*}" + local m="${1#*-}"; m="${m%%-*}"; m=$((10#$m)) + echo "${months[$((m-1))]} $y" +} + +# Generate post list items +items="" +while IFS='|' read -r date name title desc; do + display_date=$(format_date "$date") + items+="
  • + +
    ${title}
    +
    ${desc}
    +
    ${display_date}
    +
    +
  • +" +done <<< "$posts" + +# Write the full index.html — style matches the existing hand-maintained version +cat > "$OUT" << HTMLEOF + + + + + +Blog — Numa + + + + + + + + +
    +

    Blog

    +
      +${items}
    +
    + + + + + +HTMLEOF + +echo " blog/index.html generated ($(echo "$posts" | wc -l | tr -d ' ') posts)" diff --git a/scripts/serve-site.sh b/scripts/serve-site.sh new file mode 100755 index 0000000..23854ff --- /dev/null +++ b/scripts/serve-site.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +PORT="${1:-9000}" + +if [[ "${1:-}" == "--drafts" ]] || [[ "${2:-}" == "--drafts" ]]; then + PORT="${PORT//--drafts/9000}" # default port if --drafts was first arg + make blog-drafts +else + make blog +fi + +echo "Serving site at http://localhost:$PORT" +cd site && python3 -m http.server "$PORT" diff --git a/site/blog-template.html b/site/blog-template.html index 54f0eae..8f8a825 100644 --- a/site/blog-template.html +++ b/site/blog-template.html @@ -267,9 +267,105 @@ body::before { .blog-footer a:hover { color: var(--amber); } /* --- Responsive --- */ +/* Hero metrics cards */ +.hero-metrics { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin: 2rem 0; +} +.metric-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 1.25rem; + text-align: center; +} +.metric-vs { + font-family: var(--font-mono); + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 0.5rem; +} +.metric-value { + font-family: var(--font-display); + font-size: 2.4rem; + font-weight: 400; + color: var(--amber); + line-height: 1.1; +} +.metric-label { + font-size: 0.82rem; + color: var(--text-secondary); + margin-top: 0.5rem; + line-height: 1.3; +} + +/* Before/after progression */ +.before-after { + display: flex; + align-items: center; + justify-content: center; + gap: 1.5rem; + margin: 2rem 0; + padding: 1.5rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; +} +.ba-item { text-align: center; } +.ba-label { + font-family: var(--font-mono); + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 0.3rem; +} +.ba-value { + font-family: var(--font-display); + font-size: 1.8rem; + font-weight: 400; + color: var(--text-secondary); +} +.ba-before { + text-decoration: line-through; + text-decoration-color: rgba(192, 98, 58, 0.4); + color: var(--text-dim); +} +.ba-after { color: var(--amber); } +.ba-arrow { font-size: 1.5rem; color: var(--text-dim); } +.ba-ref { + border-left: 1px solid var(--border); + padding-left: 1.5rem; +} + +/* Spike highlight */ +.spike { + background: rgba(192, 98, 58, 0.12); + padding: 0.15em 0.5em; + border-radius: 3px; + font-weight: 600; + color: var(--amber-dim); +} + +/* Section dividers */ +.article hr { + border: none; + height: 1px; + background: var(--border); + margin: 3rem auto; + max-width: 120px; +} + @media (max-width: 640px) { .article { padding: 2rem 1.25rem 4rem; } .article pre { padding: 1rem; margin-left: -0.5rem; margin-right: -0.5rem; border-radius: 0; border-left: none; border-right: none; } + .hero-metrics { grid-template-columns: 1fr; } + .before-after { flex-direction: column; gap: 0.75rem; } + .ba-ref { border-left: none; border-top: 1px solid var(--border); padding-left: 0; padding-top: 0.75rem; } } diff --git a/site/blog/index.html b/site/blog/index.html index 993c166..d4df9e4 100644 --- a/site/blog/index.html +++ b/site/blog/index.html @@ -168,10 +168,17 @@ body::before {

    Blog

      +
    • + +
      Fixing DNS tail latency with a 5-line config and a 50-line function
      +
      We had periodic 40-140ms DoH spikes from hyper's dispatch channel. The fix was reqwest window tuning and request hedging — Dean & Barroso's "The Tail at Scale," applied to a DNS forwarder. Same ideas took our cold recursive p99 from 2.3 seconds to 538ms.
      + +
      +
    • DNS-over-TLS from Scratch in Rust
      -
      Building RFC 7858 on top of rustls — length-prefix framing, ALPN cross-protocol defense, iPhone dogfooding, and two bugs that only the strict clients caught.
      +
      Building RFC 7858 on top of rustls — length-prefix framing, ALPN cross-protocol defense, and two bugs that only the strict clients caught.
    • @@ -185,7 +192,7 @@ body::before {
    • I Built a DNS Resolver from Scratch in Rust
      -
      How DNS actually works at the wire level — label compression, TTL tricks, DoH implementation, and what I learned building a resolver with zero DNS libraries.
      +
      How DNS actually works at the wire level — label compression, TTL tricks, DoH, and what surprised me building a resolver with zero DNS libraries.
    • -- 2.34.1 From 908d076d9be98ec3545ef7f575c1b94106112d7c Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 14:37:24 +0300 Subject: [PATCH 116/204] blog: pain-first opening, I-voice, forward-looking close - Open with shared reqwest pain, not the tool name - Switch "we" to "I" for personal voice (playbook: solo dev > corporate) - Replace Unbound feature-gap excuses with what I'm exploring next (persistent SRTT, aggressive NSEC, adaptive hedge delays) - Add context line linking hero cards to the recursive section --- blog/fixing-doh-tail-latency.md | 26 ++++++++++++++------------ site/blog/index.html | 2 +- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/blog/fixing-doh-tail-latency.md b/blog/fixing-doh-tail-latency.md index 661c456..02d066c 100644 --- a/blog/fixing-doh-tail-latency.md +++ b/blog/fixing-doh-tail-latency.md @@ -1,10 +1,12 @@ --- title: Fixing DNS tail latency with a 5-line config and a 50-line function -description: We had periodic 40-140ms DoH spikes from hyper's dispatch channel. The fix was reqwest window tuning and request hedging — Dean & Barroso's "The Tail at Scale," applied to a DNS forwarder. Same ideas took our cold recursive p99 from 2.3 seconds to 538ms. +description: Periodic 40-140ms DoH spikes from hyper's dispatch channel. The fix was reqwest window tuning and request hedging — Dean & Barroso's "The Tail at Scale," applied to a DNS forwarder. Same ideas took cold recursive p99 from 2.3 seconds to 538ms. date: 2026-04-12 --- -Numa forwards DNS queries over HTTPS using reqwest. When we benchmarked the DoH path, we found periodic 40-140ms latency spikes every ~100ms of wall clock, in an otherwise ~10ms distribution. The tail was dragging our average — median 10ms, mean 23ms. +If you're using reqwest for small HTTP/2 payloads, you probably have a tail latency problem you don't know about. Hyper's default flow control windows are 10,000× oversized for anything under 1 KB, and its dispatch channel adds periodic 40-140ms stalls that don't show up in median benchmarks. + +I hit this building [Numa](https://github.com/razvandimescu/numa), a DNS resolver that forwards queries over HTTPS. Median was 10ms, mean was 23ms — the tail was dragging everything.
      @@ -24,21 +26,21 @@ Numa forwards DNS queries over HTTPS using reqwest. When we benchmarked the DoH
      -The fix was a 5-line reqwest config and a 50-line hedging function. This post is also an advertisement for Dean & Barroso's 2013 paper ["The Tail at Scale"](https://research.google/pubs/pub40801/) — a decade-old idea that still demolishes dispatch spikes. +The fix was a 5-line reqwest config and a 50-line hedging function. This post is also an advertisement for Dean & Barroso's 2013 paper ["The Tail at Scale"](https://research.google/pubs/pub40801/) — a decade-old idea that still demolishes dispatch spikes. The same ideas later took my cold recursive p99 from 2.3 seconds to 538ms. --- ## The cause: hyper's dispatch channel -Reqwest sits on top of hyper, which interposes an mpsc dispatch channel and a separate `ClientTask` between `.send()` and the h2 stream. We instrumented the forwarding path and confirmed: 100% of the spike time lives in the `send()` phase, and a parallel heartbeat task showed zero runtime lag during spikes. The tokio runtime was fine — the stall was internal to hyper's request scheduling. +Reqwest sits on top of hyper, which interposes an mpsc dispatch channel and a separate `ClientTask` between `.send()` and the h2 stream. I instrumented the forwarding path and confirmed: 100% of the spike time lives in the `send()` phase, and a parallel heartbeat task showed zero runtime lag during spikes. The tokio runtime was fine — the stall was internal to hyper's request scheduling. -Hickory-resolver doesn't have this issue. It holds `h2::SendRequest` directly and calls `ready().await; send_request()` in the caller's task — no channel, no scheduling dependency. We used it as a reference point throughout. +Hickory-resolver doesn't have this issue. It holds `h2::SendRequest` directly and calls `ready().await; send_request()` in the caller's task — no channel, no scheduling dependency. I used it as a reference point throughout. ## Fix #1 — HTTP/2 window sizes Reqwest inherits hyper's HTTP/2 defaults: 2 MB stream window, 5 MB connection window. For DNS responses (~200 bytes), that's ~10,000× oversized — unnecessary WINDOW_UPDATE frames, bloated bookkeeping on every poll, and different server-side scheduling behavior. -Setting both windows to the h2 spec default (64 KB) dropped our median from 13.3ms to 10.1ms: +Setting both windows to the h2 spec default (64 KB) dropped my median from 13.3ms to 10.1ms: ```rust reqwest::Client::builder() @@ -94,7 +96,7 @@ pub async fn forward_with_hedging_raw( } ``` -The [production version](https://github.com/razvandimescu/numa/blob/main/src/forward.rs#L267) adds error handling — if one leg fails, it waits for the other. In production, Numa passes the same `&Upstream` twice when only one is configured. We extended hedging to all protocols — UDP (rescues packet loss on WiFi), DoT (rescues TLS handshake stalls). Configurable via `hedge_ms`; set to 0 to disable. +The [production version](https://github.com/razvandimescu/numa/blob/main/src/forward.rs#L267) adds error handling — if one leg fails, it waits for the other. In production, Numa passes the same `&Upstream` twice when only one is configured. I extended hedging to all protocols — UDP (rescues packet loss on WiFi), DoT (rescues TLS handshake stalls). Configurable via `hedge_ms`; set to 0 to disable. **Caveat: hedging hurts on degraded networks.** When latency is consistently high (no random spikes, just slow), the hedge adds overhead with nothing to rescue. Hedging is a variance reducer, not a latency reducer — it only helps when spikes are *random*. @@ -116,13 +118,13 @@ The internal improvement: hedging cut p95 by 45%, p99 by 37%, σ by 57%. The exa ## Recursive resolution: from 2.3 seconds to 538ms -Forwarding is one job. Recursive resolution — walking from root hints through TLD nameservers to the authoritative server — is a different one. We started 15× behind Unbound on cold recursive p99 and traced it to four root causes. +Forwarding is one job. Recursive resolution — walking from root hints through TLD nameservers to the authoritative server — is a different one. I started 15× behind Unbound on cold recursive p99 and traced it to four root causes. -**1. Missing NS delegation caching.** We cached glue records (ns1's IP) but not the delegation itself. Every `.com` query walked from root. Fix: cache NS records from referral authority sections. (10 lines) +**1. Missing NS delegation caching.** I cached glue records (ns1's IP) but not the delegation itself. Every `.com` query walked from root. Fix: cache NS records from referral authority sections. (10 lines) **2. Expired cache entries caused full cold resolutions.** Fix: serve-stale ([RFC 8767](https://www.rfc-editor.org/rfc/rfc8767)) — return expired entries with TTL=1 while revalidating in the background. (20 lines) -**3. 1,900ms wasted per unreachable server.** 800ms UDP timeout + unconditional 1,500ms TCP fallback. Fix: 400ms UDP, TCP only for truncation. (5 lines) +**3. Wasting 1,900ms per unreachable server.** 800ms UDP timeout + unconditional 1,500ms TCP fallback. Fix: 400ms UDP, TCP only for truncation. (5 lines) **4. Sequential NS queries on cold starts.** Fix: fire to the top 2 nameservers simultaneously. First response wins, SRTT recorded for both. Same hedging principle. (50 lines) @@ -150,9 +152,9 @@ Genuine cold benchmarks — unique subdomains, 1 query per domain, 5 iterations, | σ | 254ms | **114ms** | 457ms | | median | — | 77.6ms | 74.7ms | -Unbound wins median by ~4% — its C implementation and 19 years of recursive optimization give it an edge on raw speed. It also has features we don't yet: aggressive NSEC caching ([RFC 8198](https://www.rfc-editor.org/rfc/rfc8198)) and a persistent infra cache. Where hedging shines is the tail — domains with slow or unreachable nameservers, where parallel queries turn worst-case sequential timeouts into races. +Unbound wins median by ~4%. Where hedging shines is the tail — domains with slow or unreachable nameservers, where parallel queries turn worst-case sequential timeouts into races. Cache hits are tied at 0.1ms across Numa, Unbound, and AdGuard Home. -Cache hits are tied across Numa, Unbound, and AdGuard Home — all serve at 0.1ms. +What I'm exploring next: persistent SRTT data across restarts (currently cold-starts lose all server timing), aggressive NSEC caching to shortcut negative lookups, and adaptive hedge delays that tune themselves to observed network conditions instead of a fixed 10ms. --- diff --git a/site/blog/index.html b/site/blog/index.html index d4df9e4..b11e182 100644 --- a/site/blog/index.html +++ b/site/blog/index.html @@ -171,7 +171,7 @@ body::before {
    • Fixing DNS tail latency with a 5-line config and a 50-line function
      -
      We had periodic 40-140ms DoH spikes from hyper's dispatch channel. The fix was reqwest window tuning and request hedging — Dean & Barroso's "The Tail at Scale," applied to a DNS forwarder. Same ideas took our cold recursive p99 from 2.3 seconds to 538ms.
      +
      Periodic 40-140ms DoH spikes from hyper's dispatch channel. The fix was reqwest window tuning and request hedging — Dean & Barroso's "The Tail at Scale," applied to a DNS forwarder. Same ideas took cold recursive p99 from 2.3 seconds to 538ms.
    • -- 2.34.1 From 75fe625f39acecd6a0676d29ab8e434d0548db70 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 14:48:34 +0300 Subject: [PATCH 117/204] blog: drop redundant Numa intro from opening paragraph --- blog/fixing-doh-tail-latency.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blog/fixing-doh-tail-latency.md b/blog/fixing-doh-tail-latency.md index 02d066c..54872aa 100644 --- a/blog/fixing-doh-tail-latency.md +++ b/blog/fixing-doh-tail-latency.md @@ -6,7 +6,7 @@ date: 2026-04-12 If you're using reqwest for small HTTP/2 payloads, you probably have a tail latency problem you don't know about. Hyper's default flow control windows are 10,000× oversized for anything under 1 KB, and its dispatch channel adds periodic 40-140ms stalls that don't show up in median benchmarks. -I hit this building [Numa](https://github.com/razvandimescu/numa), a DNS resolver that forwards queries over HTTPS. Median was 10ms, mean was 23ms — the tail was dragging everything. +I hit this building Numa's DoH forwarding path. Median was 10ms, mean was 23ms — the tail was dragging everything.
      -- 2.34.1 From 7cc110a0a1725664de283900bd89dcf8254d92e1 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 15:02:19 +0300 Subject: [PATCH 118/204] ci: skip CI and AUR builds for blog/site-only changes Add paths-ignore for site/, blog/, drafts/, *.md, and blog scripts so content-only pushes don't trigger cargo builds or AUR publishes. --- .github/workflows/ci.yml | 14 ++++++++++++++ .github/workflows/publish-aur.yml | 7 +++++++ 2 files changed, 21 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0d06f9..0ad7e45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,22 @@ name: CI on: push: branches: [main] + paths-ignore: + - 'site/**' + - 'blog/**' + - 'drafts/**' + - '*.md' + - 'scripts/serve-site.sh' + - 'scripts/generate-blog-index.sh' pull_request: branches: [main] + paths-ignore: + - 'site/**' + - 'blog/**' + - 'drafts/**' + - '*.md' + - 'scripts/serve-site.sh' + - 'scripts/generate-blog-index.sh' env: CARGO_TERM_COLOR: always diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml index 49275a0..6bd77e7 100644 --- a/.github/workflows/publish-aur.yml +++ b/.github/workflows/publish-aur.yml @@ -23,6 +23,13 @@ name: Publish - Arch Linux AUR Package on: push: branches: [main] + paths-ignore: + - 'site/**' + - 'blog/**' + - 'drafts/**' + - '*.md' + - 'scripts/serve-site.sh' + - 'scripts/generate-blog-index.sh' workflow_dispatch: permissions: -- 2.34.1 From 3b77dcff616345d260bdd47872a54125b413c8a5 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 15:48:29 +0300 Subject: [PATCH 119/204] =?UTF-8?q?feat:=20Docker=20support=20=E2=80=94=20?= =?UTF-8?q?multi-arch=20GHCR=20images=20on=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CI workflow to build linux/amd64 + linux/arm64 images and push to ghcr.io/razvandimescu/numa on tag. Fix Dockerfile (missing benches/), bake container-aware config (API + proxy bind 0.0.0.0), add Docker section to README. --- .github/workflows/docker.yml | 45 ++++++++++++++++++++++++++++++++++++ Dockerfile | 2 ++ README.md | 23 ++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 .github/workflows/docker.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..04df96a --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,45 @@ +name: Docker + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + packages: write + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: docker/setup-qemu-action@v3 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/metadata-action@v5 + id: meta + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + - uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index e4ab8f5..466239d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ RUN mkdir src && echo 'fn main() {}' > src/main.rs && echo '' > src/lib.rs RUN cargo build --release 2>/dev/null || true RUN rm -rf src COPY src/ src/ +COPY benches/ benches/ COPY site/ site/ COPY numa.toml com.numa.dns.plist numa.service ./ RUN touch src/main.rs src/lib.rs @@ -13,5 +14,6 @@ RUN cargo build --release FROM alpine:3.23 COPY --from=builder /app/target/release/numa /usr/local/bin/numa +RUN mkdir -p /root/.config/numa && printf '[server]\napi_bind_addr = "0.0.0.0"\n\n[proxy]\nenabled = true\nbind_addr = "0.0.0.0"\n' > /root/.config/numa/numa.toml EXPOSE 53/udp 80/tcp 443/tcp 853/tcp 5380/tcp ENTRYPOINT ["numa"] diff --git a/README.md b/README.md index 9979d46..1728461 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ yay -S numa-git # Windows — download from GitHub Releases # All platforms cargo install numa + +# Docker +docker run -d --name numa --network host ghcr.io/razvandimescu/numa ``` ```bash @@ -102,6 +105,26 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena **Hub mode**: run one instance with `bind_addr = "0.0.0.0:53"` and point other devices' DNS to it — they get ad blocking + `.numa` resolution without installing anything. +## Docker + +```bash +# Recommended — host networking (Linux) +docker run -d --name numa --network host ghcr.io/razvandimescu/numa + +# Port mapping (macOS/Windows Docker Desktop) +docker run -d --name numa -p 53:53/udp -p 53:53/tcp -p 5380:5380 ghcr.io/razvandimescu/numa +``` + +Dashboard at `http://localhost:5380`. The image binds the API and proxy to `0.0.0.0` by default. Override with a custom config: + +```bash +docker run -d --name numa --network host \ + -v /path/to/numa.toml:/root/.config/numa/numa.toml \ + ghcr.io/razvandimescu/numa +``` + +Multi-arch: `linux/amd64` and `linux/arm64`. + ## How It Compares | | Pi-hole | AdGuard Home | Unbound | Numa | -- 2.34.1 From 7dc1a0686f7dcfad6fa58550acd065db30810877 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 13 Apr 2026 15:58:52 +0300 Subject: [PATCH 120/204] fix: add llvm-libs to AUR makedepends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #97 — on minimal Arch installs, rustc fails with "error while loading shared libraries: libLLVM.so" because llvm-libs isn't pulled in transitively. --- PKGBUILD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PKGBUILD b/PKGBUILD index b3e3f6b..7081d9f 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -9,7 +9,7 @@ url="https://github.com/razvandimescu/numa" license=('MIT') options=('!lto') depends=('gcc-libs' 'glibc') -makedepends=('cargo' 'git') +makedepends=('cargo' 'git' 'llvm-libs') provides=("$_pkgname") conflicts=("$_pkgname") backup=('etc/numa.toml') -- 2.34.1 From b4b939c78bce38a8781c202be2eb10c798a6b68e Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 14 Apr 2026 09:22:24 +0300 Subject: [PATCH 121/204] fix: accept tls:// and https:// in [[forwarding]] upstreams Config-level forwarding rules were parsed with the UDP-only `parse_upstream_addr` helper, silently rejecting the DoT/DoH schemes that the rest of the forwarding pipeline already supports. Widen `ForwardingRule.upstream` from `SocketAddr` to `Upstream` so config rules reuse the same parser as `[upstream].address` and `fallback`. Demote `parse_upstream_addr` to `pub(crate)` to prevent the same mistake recurring. Closes #100. --- numa.toml | 8 ++++++++ src/config.rs | 44 ++++++++++++++++++++++++++++++++++++++++---- src/ctx.rs | 12 +++++++----- src/forward.rs | 11 ++++++++++- src/system_dns.rs | 15 ++++++++++----- 5 files changed, 75 insertions(+), 15 deletions(-) diff --git a/numa.toml b/numa.toml index 1ea3341..4edee81 100644 --- a/numa.toml +++ b/numa.toml @@ -58,6 +58,14 @@ api_port = 5380 # [[forwarding]] # suffix = ["home.local", "home.arpa"] # multiple suffixes → same upstream # upstream = "10.0.0.1" # port 53 default +# +# [[forwarding]] # DoT upstream: tls://IP[:port]#hostname +# suffix = ["google.com", "goog"] # hostname is the TLS SNI / cert name +# upstream = "tls://9.9.9.9#dns.quad9.net" # port 853 default +# +# [[forwarding]] # DoH upstream: full https:// URL +# suffix = "example.corp" +# upstream = "https://dns.quad9.net/dns-query" # [blocking] # enabled = true # set to false to disable ad blocking diff --git a/src/config.rs b/src/config.rs index 237f3bd..4d22956 100644 --- a/src/config.rs +++ b/src/config.rs @@ -46,12 +46,12 @@ pub struct ForwardingRuleConfig { impl ForwardingRuleConfig { fn to_runtime_rules(&self) -> Result> { - let addr = crate::forward::parse_upstream_addr(&self.upstream, 53) + let upstream = crate::forward::parse_upstream(&self.upstream, 53) .map_err(|e| format!("forwarding rule for upstream '{}': {}", self.upstream, e))?; Ok(self .suffix .iter() - .map(|s| crate::system_dns::ForwardingRule::new(s.clone(), addr)) + .map(|s| crate::system_dns::ForwardingRule::new(s.clone(), upstream.clone())) .collect()) } } @@ -710,6 +710,10 @@ mod tests { }; let runtime = rule.to_runtime_rules().unwrap(); assert_eq!(runtime.len(), 1); + assert!(matches!( + runtime[0].upstream, + crate::forward::Upstream::Udp(_) + )); assert_eq!(runtime[0].upstream.to_string(), "100.90.1.63:5361"); assert_eq!(runtime[0].suffix, "home.local"); } @@ -733,6 +737,38 @@ mod tests { assert!(rule.to_runtime_rules().is_err()); } + #[test] + fn forwarding_upstream_accepts_dot_scheme() { + let rule = ForwardingRuleConfig { + suffix: vec!["google.com".to_string()], + upstream: "tls://9.9.9.9#dns.quad9.net".to_string(), + }; + let runtime = rule + .to_runtime_rules() + .expect("tls:// upstream should parse"); + assert_eq!(runtime.len(), 1); + assert_eq!( + runtime[0].upstream.to_string(), + "tls://9.9.9.9:853#dns.quad9.net" + ); + } + + #[test] + fn forwarding_upstream_accepts_doh_scheme() { + let rule = ForwardingRuleConfig { + suffix: vec!["goog".to_string()], + upstream: "https://dns.quad9.net/dns-query".to_string(), + }; + let runtime = rule + .to_runtime_rules() + .expect("https:// upstream should parse"); + assert_eq!(runtime.len(), 1); + assert_eq!( + runtime[0].upstream.to_string(), + "https://dns.quad9.net/dns-query" + ); + } + #[test] fn forwarding_config_rules_take_precedence_over_discovered() { let config_rules = vec![ForwardingRuleConfig { @@ -741,7 +777,7 @@ mod tests { }]; let discovered = vec![crate::system_dns::ForwardingRule::new( "home.local".to_string(), - "192.168.1.1:53".parse().unwrap(), + crate::forward::Upstream::Udp("192.168.1.1:53".parse().unwrap()), )]; let merged = merge_forwarding_rules(&config_rules, discovered).unwrap(); let picked = crate::system_dns::match_forwarding_rule("host.home.local", &merged) @@ -757,7 +793,7 @@ mod tests { }]; let discovered = vec![crate::system_dns::ForwardingRule::new( "corp.example".to_string(), - "192.168.1.1:53".parse().unwrap(), + crate::forward::Upstream::Udp("192.168.1.1:53".parse().unwrap()), )]; let merged = merge_forwarding_rules(&config_rules, discovered).unwrap(); assert_eq!(merged.len(), 2); diff --git a/src/ctx.rs b/src/ctx.rs index 2812bed..222e407 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -190,13 +190,12 @@ pub async fn resolve_query( resp.header.authed_data = true; } (resp, QueryPath::Cached, cached_dnssec) - } else if let Some(fwd_addr) = + } else if let Some(upstream) = crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) { // Conditional forwarding takes priority over recursive mode // (e.g. Tailscale .ts.net, VPC private zones) - let upstream = Upstream::Udp(fwd_addr); - match forward_and_cache(raw_wire, &upstream, ctx, &qname, qtype).await { + match forward_and_cache(raw_wire, upstream, ctx, &qname, qtype).await { Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate), Err(e) => { error!( @@ -1083,7 +1082,7 @@ mod tests { let mut ctx = crate::testutil::test_ctx().await; ctx.forwarding_rules = vec![ForwardingRule::new( "168.192.in-addr.arpa".to_string(), - upstream_addr, + Upstream::Udp(upstream_addr), )]; let ctx = Arc::new(ctx); @@ -1236,7 +1235,10 @@ mod tests { let upstream_addr = crate::testutil::mock_upstream(upstream_resp).await; let mut ctx = crate::testutil::test_ctx().await; - ctx.forwarding_rules = vec![ForwardingRule::new("corp".to_string(), upstream_addr)]; + ctx.forwarding_rules = vec![ForwardingRule::new( + "corp".to_string(), + Upstream::Udp(upstream_addr), + )]; let ctx = Arc::new(ctx); let (resp, path) = resolve_in_test(&ctx, "internal.corp", QueryType::A).await; diff --git a/src/forward.rs b/src/forward.rs index e13e360..7c7a53a 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -36,6 +36,12 @@ impl PartialEq for Upstream { } } +impl fmt::Debug for Upstream { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + impl fmt::Display for Upstream { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -49,7 +55,10 @@ impl fmt::Display for Upstream { } } -pub fn parse_upstream_addr(s: &str, default_port: u16) -> std::result::Result { +pub(crate) fn parse_upstream_addr( + s: &str, + default_port: u16, +) -> std::result::Result { // Try full socket addr first: "1.2.3.4:5353" or "[::1]:5353" if let Ok(addr) = s.parse::() { return Ok(addr); diff --git a/src/system_dns.rs b/src/system_dns.rs index d560a6e..96ae372 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -2,6 +2,8 @@ use std::net::SocketAddr; use log::info; +use crate::forward::Upstream; + fn print_recursive_hint() { let is_recursive = crate::config::load_config("numa.toml") .map(|c| c.config.upstream.mode == crate::config::UpstreamMode::Recursive) @@ -22,11 +24,11 @@ fn is_loopback_or_stub(addr: &str) -> bool { pub struct ForwardingRule { pub suffix: String, dot_suffix: String, // pre-computed ".suffix" for zero-alloc matching - pub upstream: SocketAddr, + pub upstream: Upstream, } impl ForwardingRule { - pub fn new(suffix: String, upstream: SocketAddr) -> Self { + pub fn new(suffix: String, upstream: Upstream) -> Self { let dot_suffix = format!(".{}", suffix); Self { suffix, @@ -233,7 +235,7 @@ fn discover_macos() -> SystemDnsInfo { #[cfg(any(target_os = "macos", target_os = "linux"))] fn make_rule(domain: &str, nameserver: &str) -> Option { let addr = crate::forward::parse_upstream_addr(nameserver, 53).ok()?; - Some(ForwardingRule::new(domain.to_string(), addr)) + Some(ForwardingRule::new(domain.to_string(), Upstream::Udp(addr))) } #[cfg(target_os = "linux")] @@ -822,10 +824,13 @@ fn uninstall_windows() -> Result<(), String> { /// Find the upstream for a domain by checking forwarding rules. /// Returns None if no rule matches (use default upstream). /// Zero-allocation on the hot path — dot_suffix is pre-computed. -pub fn match_forwarding_rule(domain: &str, rules: &[ForwardingRule]) -> Option { +pub fn match_forwarding_rule<'a>( + domain: &str, + rules: &'a [ForwardingRule], +) -> Option<&'a Upstream> { for rule in rules { if domain == rule.suffix || domain.ends_with(&rule.dot_suffix) { - return Some(rule.upstream); + return Some(&rule.upstream); } } None -- 2.34.1 From 120ba5200e62373d32f8ecdd37a9033b6a7718e0 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 14 Apr 2026 13:31:35 +0300 Subject: [PATCH 122/204] chore: bump version to 0.13.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dbbd921..c01e85f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1330,7 +1330,7 @@ dependencies = [ [[package]] name = "numa" -version = "0.13.0" +version = "0.13.1" dependencies = [ "arc-swap", "axum", diff --git a/Cargo.toml b/Cargo.toml index 19044ab..0b13af2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "numa" -version = "0.13.0" +version = "0.13.1" authors = ["razvandimescu "] edition = "2021" description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS" -- 2.34.1 From e0e0f50838892d93d2b36ac3c5f2a88f6ad50554 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 14 Apr 2026 18:18:32 +0300 Subject: [PATCH 123/204] feat: distinguish UPSTREAM vs FORWARD in logs and stats Queries matching a [[forwarding]] suffix rule now log as FORWARD; queries resolved via the default [upstream] pool log as UPSTREAM. Previously both paths shared the FORWARD label, making it impossible to tell from logs whether a rule matched. Adds QueryPath::Upstream, a queries.upstream stats counter exposed via /stats, plus a matching dashboard filter, bar, and path tag. Closes part of #102. --- site/dashboard.html | 6 +++++- src/api.rs | 2 ++ src/ctx.rs | 30 +++++++++++++++++++++++++++++- src/stats.rs | 14 +++++++++++++- 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index 2d9cc60..d3837eb 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -217,6 +217,7 @@ body { min-width: 2px; } .path-bar-fill.forward { background: var(--amber); } +.path-bar-fill.upstream { background: var(--amber-dim); } .path-bar-fill.recursive { background: var(--cyan); } .path-bar-fill.cached { background: var(--teal); } .path-bar-fill.local { background: var(--violet); } @@ -285,6 +286,7 @@ body { font-weight: 500; } .path-tag.FORWARD { background: rgba(192, 98, 58, 0.12); color: var(--amber-dim); } +.path-tag.UPSTREAM { background: rgba(160, 120, 72, 0.12); color: var(--amber-dim); } .path-tag.RECURSIVE { background: rgba(74, 124, 138, 0.12); color: var(--cyan); } .path-tag.CACHED { background: rgba(107, 124, 78, 0.12); color: var(--teal-dim); } .path-tag.LOCAL { background: rgba(100, 116, 139, 0.12); color: var(--violet-dim); } @@ -655,6 +657,7 @@ body { + @@ -957,6 +960,7 @@ function encryptionPct(transport) { const PATH_DEFS = [ { key: 'forwarded', label: 'Forward', cls: 'forward' }, + { key: 'upstream', label: 'Upstream', cls: 'upstream' }, { key: 'recursive', label: 'Recursive', cls: 'recursive' }, { key: 'cached', label: 'Cached', cls: 'cached' }, { key: 'local', label: 'Local', cls: 'local' }, @@ -1209,7 +1213,7 @@ async function refresh() { prevTime = now; // Cache hit rate - const answered = q.cached + q.forwarded + q.recursive + q.coalesced + q.local + q.overridden; + const answered = q.cached + q.forwarded + q.upstream + q.recursive + q.coalesced + q.local + q.overridden; const hitRate = answered > 0 ? ((q.cached / answered) * 100).toFixed(1) : '0.0'; document.getElementById('cacheRate').textContent = hitRate + '%'; diff --git a/src/api.rs b/src/api.rs index 6ec3e48..17c4614 100644 --- a/src/api.rs +++ b/src/api.rs @@ -201,6 +201,7 @@ struct LanStatsResponse { struct QueriesStats { total: u64, forwarded: u64, + upstream: u64, recursive: u64, coalesced: u64, cached: u64, @@ -548,6 +549,7 @@ async fn stats(State(ctx): State>) -> Json { queries: QueriesStats { total: snap.total, forwarded: snap.forwarded, + upstream: snap.upstream, recursive: snap.recursive, coalesced: snap.coalesced, cached: snap.cached, diff --git a/src/ctx.rs b/src/ctx.rs index 222e407..b65f6c2 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -246,7 +246,7 @@ pub async fn resolve_query( .await { Ok(resp_wire) => match cache_and_parse(ctx, &qname, qtype, &resp_wire) { - Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate), + Ok(resp) => (resp, QueryPath::Upstream, DnssecStatus::Indeterminate), Err(e) => { error!("{} | {:?} {} | PARSE ERROR | {}", src_addr, qtype, qname, e); ( @@ -1253,4 +1253,32 @@ mod tests { other => panic!("expected A record, got {:?}", other), } } + + #[tokio::test] + async fn pipeline_default_pool_reports_upstream_path() { + // No forwarding rule matches — query falls through to the default + // [upstream] pool. Path must be reported as Upstream (not Forwarded) + // so operators can distinguish [[forwarding]] hits from pool traffic. + let mut upstream_resp = DnsPacket::new(); + upstream_resp.header.response = true; + upstream_resp.header.rescode = ResultCode::NOERROR; + upstream_resp.answers.push(DnsRecord::A { + domain: "example.com".to_string(), + addr: Ipv4Addr::new(93, 184, 216, 34), + ttl: 300, + }); + let upstream_addr = crate::testutil::mock_upstream(upstream_resp).await; + + let mut ctx = crate::testutil::test_ctx().await; + ctx.upstream_pool = std::sync::Mutex::new(crate::forward::UpstreamPool::new( + vec![Upstream::Udp(upstream_addr)], + vec![], + )); + let ctx = Arc::new(ctx); + + let (resp, path) = resolve_in_test(&ctx, "example.com", QueryType::A).await; + assert_eq!(path, QueryPath::Upstream); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + assert_eq!(resp.answers.len(), 1); + } } diff --git a/src/stats.rs b/src/stats.rs index feae945..df9127c 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -90,6 +90,7 @@ fn linux_rss() -> usize { pub struct ServerStats { queries_total: u64, queries_forwarded: u64, + queries_upstream: u64, queries_recursive: u64, queries_coalesced: u64, queries_cached: u64, @@ -127,7 +128,10 @@ impl Transport { pub enum QueryPath { Local, Cached, + /// Matched a `[[forwarding]]` suffix rule. Forwarded, + /// Resolved via the default `[upstream]` pool (no suffix match). + Upstream, Recursive, Coalesced, Blocked, @@ -141,6 +145,7 @@ impl QueryPath { QueryPath::Local => "LOCAL", QueryPath::Cached => "CACHED", QueryPath::Forwarded => "FORWARD", + QueryPath::Upstream => "UPSTREAM", QueryPath::Recursive => "RECURSIVE", QueryPath::Coalesced => "COALESCED", QueryPath::Blocked => "BLOCKED", @@ -156,6 +161,8 @@ impl QueryPath { Some(QueryPath::Cached) } else if s.eq_ignore_ascii_case("FORWARD") { Some(QueryPath::Forwarded) + } else if s.eq_ignore_ascii_case("UPSTREAM") { + Some(QueryPath::Upstream) } else if s.eq_ignore_ascii_case("RECURSIVE") { Some(QueryPath::Recursive) } else if s.eq_ignore_ascii_case("COALESCED") { @@ -183,6 +190,7 @@ impl ServerStats { ServerStats { queries_total: 0, queries_forwarded: 0, + queries_upstream: 0, queries_recursive: 0, queries_coalesced: 0, queries_cached: 0, @@ -204,6 +212,7 @@ impl ServerStats { QueryPath::Local => self.queries_local += 1, QueryPath::Cached => self.queries_cached += 1, QueryPath::Forwarded => self.queries_forwarded += 1, + QueryPath::Upstream => self.queries_upstream += 1, QueryPath::Recursive => self.queries_recursive += 1, QueryPath::Coalesced => self.queries_coalesced += 1, QueryPath::Blocked => self.queries_blocked += 1, @@ -232,6 +241,7 @@ impl ServerStats { uptime_secs: self.uptime_secs(), total: self.queries_total, forwarded: self.queries_forwarded, + upstream: self.queries_upstream, recursive: self.queries_recursive, coalesced: self.queries_coalesced, cached: self.queries_cached, @@ -253,10 +263,11 @@ impl ServerStats { let secs = uptime.as_secs() % 60; log::info!( - "STATS | uptime {}h{}m{}s | total {} | fwd {} | recursive {} | coalesced {} | cached {} | local {} | override {} | blocked {} | errors {}", + "STATS | uptime {}h{}m{}s | total {} | fwd {} | upstream {} | recursive {} | coalesced {} | cached {} | local {} | override {} | blocked {} | errors {}", hours, mins, secs, self.queries_total, self.queries_forwarded, + self.queries_upstream, self.queries_recursive, self.queries_coalesced, self.queries_cached, @@ -272,6 +283,7 @@ pub struct StatsSnapshot { pub uptime_secs: u64, pub total: u64, pub forwarded: u64, + pub upstream: u64, pub recursive: u64, pub coalesced: u64, pub cached: u64, -- 2.34.1 From ebb2a5db392b3bbc205afbbeecff35c9925209dc Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 14 Apr 2026 18:26:45 +0300 Subject: [PATCH 124/204] =?UTF-8?q?refactor:=20simplify=20upstream-path=20?= =?UTF-8?q?test=20=E2=80=94=20reuse=20pool=20mutex,=20drop=20narrating=20c?= =?UTF-8?q?omment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ctx.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index b65f6c2..eeca407 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1256,9 +1256,6 @@ mod tests { #[tokio::test] async fn pipeline_default_pool_reports_upstream_path() { - // No forwarding rule matches — query falls through to the default - // [upstream] pool. Path must be reported as Upstream (not Forwarded) - // so operators can distinguish [[forwarding]] hits from pool traffic. let mut upstream_resp = DnsPacket::new(); upstream_resp.header.response = true; upstream_resp.header.rescode = ResultCode::NOERROR; @@ -1269,11 +1266,11 @@ mod tests { }); let upstream_addr = crate::testutil::mock_upstream(upstream_resp).await; - let mut ctx = crate::testutil::test_ctx().await; - ctx.upstream_pool = std::sync::Mutex::new(crate::forward::UpstreamPool::new( - vec![Upstream::Udp(upstream_addr)], - vec![], - )); + let ctx = crate::testutil::test_ctx().await; + ctx.upstream_pool + .lock() + .unwrap() + .set_primary(vec![Upstream::Udp(upstream_addr)]); let ctx = Arc::new(ctx); let (resp, path) = resolve_in_test(&ctx, "example.com", QueryType::A).await; -- 2.34.1 From 4bd08e206db25c99b2da008aab4a4bfb203ccf51 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 14 Apr 2026 21:25:11 +0300 Subject: [PATCH 125/204] feat(dashboard): hide zero-count path and transport rows --- site/dashboard.html | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index d3837eb..77018fc 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -939,10 +939,12 @@ function renderMemory(mem, stats) { function renderBarChart(containerId, defs, data, total) { total = total || 1; - document.getElementById(containerId).innerHTML = defs.map(d => { - const count = data[d.key] || 0; - const pct = ((count / total) * 100).toFixed(1); - return ` + document.getElementById(containerId).innerHTML = defs + .filter(d => (data[d.key] || 0) > 0) + .map(d => { + const count = data[d.key] || 0; + const pct = ((count / total) * 100).toFixed(1); + return `
      ${d.label}
      @@ -950,7 +952,7 @@ function renderBarChart(containerId, defs, data, total) {
      ${pct}%
      `; - }).join(''); + }).join(''); } function encryptionPct(transport) { -- 2.34.1 From 9a0d586b138765fa3f5b82861734f6da1dec8557 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 15 Apr 2026 04:03:38 +0300 Subject: [PATCH 126/204] feat: accept array of upstreams in [[forwarding]] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors `[upstream] address` — `upstream` accepts string or array of strings, builds an `UpstreamPool` and routes queries through `forward_with_failover_raw` so SRTT ordering and failover apply to matched `[[forwarding]]` rules the same way they do for the default pool. Single-string rules keep their current behavior (one-element pool, equivalent single-upstream path). Empty array errors at config load. Addresses item 1 of issue #102. Plan: docs/102_item1.md. --- numa.toml | 7 +++ src/config.rs | 119 +++++++++++++++++++++++++++++++++++----------- src/ctx.rs | 77 +++++++++++++++++++++++------- src/forward.rs | 2 +- src/main.rs | 6 ++- src/system_dns.rs | 14 +++--- 6 files changed, 172 insertions(+), 53 deletions(-) diff --git a/numa.toml b/numa.toml index 4edee81..ebb9720 100644 --- a/numa.toml +++ b/numa.toml @@ -66,6 +66,13 @@ api_port = 5380 # [[forwarding]] # DoH upstream: full https:// URL # suffix = "example.corp" # upstream = "https://dns.quad9.net/dns-query" +# +# [[forwarding]] # array of upstreams → SRTT-aware failover +# suffix = ["google.com", "goog"] # fastest-healthy first, dead one skipped +# upstream = [ +# "tls://9.9.9.9#dns.quad9.net", +# "tls://149.112.112.112#dns.quad9.net", +# ] # [blocking] # enabled = true # set to false to disable ad blocking diff --git a/src/config.rs b/src/config.rs index 4d22956..90d1ba3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -41,17 +41,30 @@ pub struct Config { pub struct ForwardingRuleConfig { #[serde(deserialize_with = "string_or_vec")] pub suffix: Vec, - pub upstream: String, + #[serde(deserialize_with = "string_or_vec")] + pub upstream: Vec, } impl ForwardingRuleConfig { fn to_runtime_rules(&self) -> Result> { - let upstream = crate::forward::parse_upstream(&self.upstream, 53) - .map_err(|e| format!("forwarding rule for upstream '{}': {}", self.upstream, e))?; + if self.upstream.is_empty() { + return Err(format!( + "forwarding rule for suffix {:?}: upstream must not be empty", + self.suffix + ) + .into()); + } + let mut primary = Vec::with_capacity(self.upstream.len()); + for s in &self.upstream { + let u = crate::forward::parse_upstream(s, 53) + .map_err(|e| format!("forwarding rule for upstream '{}': {}", s, e))?; + primary.push(u); + } + let pool = crate::forward::UpstreamPool::new(primary, vec![]); Ok(self .suffix .iter() - .map(|s| crate::system_dns::ForwardingRule::new(s.clone(), upstream.clone())) + .map(|s| crate::system_dns::ForwardingRule::new(s.clone(), pool.clone())) .collect()) } } @@ -643,7 +656,7 @@ mod tests { let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.forwarding.len(), 1); assert_eq!(config.forwarding[0].suffix, &["home.local"]); - assert_eq!(config.forwarding[0].upstream, "100.90.1.63:5361"); + assert_eq!(config.forwarding[0].upstream, vec!["100.90.1.63:5361"]); } #[test] @@ -671,7 +684,7 @@ mod tests { "#; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.forwarding.len(), 2); - assert_eq!(config.forwarding[1].upstream, "10.0.0.1"); + assert_eq!(config.forwarding[1].upstream, vec!["10.0.0.1"]); } #[test] @@ -693,28 +706,29 @@ mod tests { fn forwarding_suffix_array_expands_to_multiple_runtime_rules() { let rule = ForwardingRuleConfig { suffix: vec!["168.192.in-addr.arpa".to_string(), "onsite".to_string()], - upstream: "192.168.88.1".to_string(), + upstream: vec!["192.168.88.1".to_string()], }; let runtime = rule.to_runtime_rules().unwrap(); assert_eq!(runtime.len(), 2); assert_eq!(runtime[0].suffix, "168.192.in-addr.arpa"); assert_eq!(runtime[1].suffix, "onsite"); - assert_eq!(runtime[0].upstream, runtime[1].upstream); + assert_eq!( + runtime[0].upstream.preferred(), + runtime[1].upstream.preferred() + ); } #[test] fn forwarding_upstream_with_explicit_port() { let rule = ForwardingRuleConfig { suffix: vec!["home.local".to_string()], - upstream: "100.90.1.63:5361".to_string(), + upstream: vec!["100.90.1.63:5361".to_string()], }; let runtime = rule.to_runtime_rules().unwrap(); assert_eq!(runtime.len(), 1); - assert!(matches!( - runtime[0].upstream, - crate::forward::Upstream::Udp(_) - )); - assert_eq!(runtime[0].upstream.to_string(), "100.90.1.63:5361"); + let preferred = runtime[0].upstream.preferred().unwrap(); + assert!(matches!(preferred, crate::forward::Upstream::Udp(_))); + assert_eq!(preferred.to_string(), "100.90.1.63:5361"); assert_eq!(runtime[0].suffix, "home.local"); } @@ -722,17 +736,20 @@ mod tests { fn forwarding_upstream_defaults_to_port_53() { let rule = ForwardingRuleConfig { suffix: vec!["home.local".to_string()], - upstream: "100.90.1.63".to_string(), + upstream: vec!["100.90.1.63".to_string()], }; let runtime = rule.to_runtime_rules().unwrap(); - assert_eq!(runtime[0].upstream.to_string(), "100.90.1.63:53"); + assert_eq!( + runtime[0].upstream.preferred().unwrap().to_string(), + "100.90.1.63:53" + ); } #[test] fn forwarding_invalid_upstream_returns_error() { let rule = ForwardingRuleConfig { suffix: vec!["home.local".to_string()], - upstream: "not-a-valid-host".to_string(), + upstream: vec!["not-a-valid-host".to_string()], }; assert!(rule.to_runtime_rules().is_err()); } @@ -741,14 +758,14 @@ mod tests { fn forwarding_upstream_accepts_dot_scheme() { let rule = ForwardingRuleConfig { suffix: vec!["google.com".to_string()], - upstream: "tls://9.9.9.9#dns.quad9.net".to_string(), + upstream: vec!["tls://9.9.9.9#dns.quad9.net".to_string()], }; let runtime = rule .to_runtime_rules() .expect("tls:// upstream should parse"); assert_eq!(runtime.len(), 1); assert_eq!( - runtime[0].upstream.to_string(), + runtime[0].upstream.preferred().unwrap().to_string(), "tls://9.9.9.9:853#dns.quad9.net" ); } @@ -757,14 +774,14 @@ mod tests { fn forwarding_upstream_accepts_doh_scheme() { let rule = ForwardingRuleConfig { suffix: vec!["goog".to_string()], - upstream: "https://dns.quad9.net/dns-query".to_string(), + upstream: vec!["https://dns.quad9.net/dns-query".to_string()], }; let runtime = rule .to_runtime_rules() .expect("https:// upstream should parse"); assert_eq!(runtime.len(), 1); assert_eq!( - runtime[0].upstream.to_string(), + runtime[0].upstream.preferred().unwrap().to_string(), "https://dns.quad9.net/dns-query" ); } @@ -773,44 +790,90 @@ mod tests { fn forwarding_config_rules_take_precedence_over_discovered() { let config_rules = vec![ForwardingRuleConfig { suffix: vec!["home.local".to_string()], - upstream: "10.0.0.1:53".to_string(), + upstream: vec!["10.0.0.1:53".to_string()], }]; let discovered = vec![crate::system_dns::ForwardingRule::new( "home.local".to_string(), - crate::forward::Upstream::Udp("192.168.1.1:53".parse().unwrap()), + crate::forward::UpstreamPool::new( + vec![crate::forward::Upstream::Udp( + "192.168.1.1:53".parse().unwrap(), + )], + vec![], + ), )]; let merged = merge_forwarding_rules(&config_rules, discovered).unwrap(); let picked = crate::system_dns::match_forwarding_rule("host.home.local", &merged) .expect("rule should match"); - assert_eq!(picked.to_string(), "10.0.0.1:53"); + assert_eq!(picked.preferred().unwrap().to_string(), "10.0.0.1:53"); } #[test] fn forwarding_merge_preserves_non_overlapping_discovered() { let config_rules = vec![ForwardingRuleConfig { suffix: vec!["home.local".to_string()], - upstream: "10.0.0.1:53".to_string(), + upstream: vec!["10.0.0.1:53".to_string()], }]; let discovered = vec![crate::system_dns::ForwardingRule::new( "corp.example".to_string(), - crate::forward::Upstream::Udp("192.168.1.1:53".parse().unwrap()), + crate::forward::UpstreamPool::new( + vec![crate::forward::Upstream::Udp( + "192.168.1.1:53".parse().unwrap(), + )], + vec![], + ), )]; let merged = merge_forwarding_rules(&config_rules, discovered).unwrap(); assert_eq!(merged.len(), 2); let picked = crate::system_dns::match_forwarding_rule("host.corp.example", &merged) .expect("discovered rule should still match"); - assert_eq!(picked.to_string(), "192.168.1.1:53"); + assert_eq!(picked.preferred().unwrap().to_string(), "192.168.1.1:53"); } #[test] fn forwarding_merge_suffix_array_expands_to_multiple_rules() { let config_rules = vec![ForwardingRuleConfig { suffix: vec!["a.local".to_string(), "b.local".to_string()], - upstream: "10.0.0.1:53".to_string(), + upstream: vec!["10.0.0.1:53".to_string()], }]; let merged = merge_forwarding_rules(&config_rules, vec![]).unwrap(); assert_eq!(merged.len(), 2); } + + #[test] + fn forwarding_parses_upstream_array() { + let toml = r#" + [[forwarding]] + suffix = "google.com" + upstream = ["tls://9.9.9.9#dns.quad9.net", "tls://149.112.112.112#dns.quad9.net"] + "#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.forwarding.len(), 1); + assert_eq!(config.forwarding[0].upstream.len(), 2); + } + + #[test] + fn forwarding_upstream_array_builds_pool_with_multiple_primaries() { + let rule = ForwardingRuleConfig { + suffix: vec!["google.com".to_string()], + upstream: vec![ + "tls://9.9.9.9#dns.quad9.net".to_string(), + "tls://149.112.112.112#dns.quad9.net".to_string(), + ], + }; + let runtime = rule.to_runtime_rules().unwrap(); + assert_eq!(runtime.len(), 1); + let label = runtime[0].upstream.label(); + assert!(label.contains("+1 more"), "label was: {}", label); + } + + #[test] + fn forwarding_empty_upstream_array_errors() { + let rule = ForwardingRuleConfig { + suffix: vec!["home.local".to_string()], + upstream: vec![], + }; + assert!(rule.to_runtime_rules().is_err()); + } } pub struct ConfigLoad { diff --git a/src/ctx.rs b/src/ctx.rs index 222e407..6467620 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -16,7 +16,9 @@ use crate::blocklist::BlocklistStore; use crate::buffer::BytePacketBuffer; use crate::cache::{DnsCache, DnssecStatus}; use crate::config::{UpstreamMode, ZoneMap}; -use crate::forward::{forward_query_raw, forward_with_failover_raw, Upstream, UpstreamPool}; +use crate::forward::{forward_with_failover_raw, UpstreamPool}; +#[cfg(test)] +use crate::forward::Upstream; use crate::header::ResultCode; use crate::health::HealthMeta; use crate::lan::PeerStore; @@ -190,13 +192,31 @@ pub async fn resolve_query( resp.header.authed_data = true; } (resp, QueryPath::Cached, cached_dnssec) - } else if let Some(upstream) = + } else if let Some(pool) = crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) { // Conditional forwarding takes priority over recursive mode // (e.g. Tailscale .ts.net, VPC private zones) - match forward_and_cache(raw_wire, upstream, ctx, &qname, qtype).await { - Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate), + match forward_with_failover_raw( + raw_wire, + pool, + &ctx.srtt, + ctx.timeout, + ctx.hedge_delay, + ) + .await + { + Ok(resp_wire) => match cache_and_parse(ctx, &qname, qtype, &resp_wire) { + Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate), + Err(e) => { + error!("{} | {:?} {} | PARSE ERROR | {}", src_addr, qtype, qname, e); + ( + DnsPacket::response_from(&query, ResultCode::SERVFAIL), + QueryPath::UpstreamError, + DnssecStatus::Indeterminate, + ) + } + }, Err(e) => { error!( "{} | {:?} {} | FORWARD ERROR | {}", @@ -433,17 +453,6 @@ pub async fn refresh_entry(ctx: &ServerCtx, qname: &str, qtype: QueryType) { } } -async fn forward_and_cache( - wire: &[u8], - upstream: &Upstream, - ctx: &ServerCtx, - qname: &str, - qtype: QueryType, -) -> crate::Result { - let resp_wire = forward_query_raw(wire, upstream, ctx.timeout).await?; - cache_and_parse(ctx, qname, qtype, &resp_wire) -} - pub async fn handle_query( mut buffer: BytePacketBuffer, raw_len: usize, @@ -1082,7 +1091,7 @@ mod tests { let mut ctx = crate::testutil::test_ctx().await; ctx.forwarding_rules = vec![ForwardingRule::new( "168.192.in-addr.arpa".to_string(), - Upstream::Udp(upstream_addr), + UpstreamPool::new(vec![Upstream::Udp(upstream_addr)], vec![]), )]; let ctx = Arc::new(ctx); @@ -1237,7 +1246,7 @@ mod tests { let mut ctx = crate::testutil::test_ctx().await; ctx.forwarding_rules = vec![ForwardingRule::new( "corp".to_string(), - Upstream::Udp(upstream_addr), + UpstreamPool::new(vec![Upstream::Udp(upstream_addr)], vec![]), )]; let ctx = Arc::new(ctx); @@ -1253,4 +1262,38 @@ mod tests { other => panic!("expected A record, got {:?}", other), } } + + #[tokio::test] + async fn pipeline_forwarding_fails_over_to_second_upstream() { + let dead = crate::testutil::blackhole_upstream(); + + let mut live_resp = DnsPacket::new(); + live_resp.header.response = true; + live_resp.header.rescode = ResultCode::NOERROR; + live_resp.answers.push(DnsRecord::A { + domain: "internal.corp".to_string(), + addr: Ipv4Addr::new(10, 9, 9, 9), + ttl: 600, + }); + let live = crate::testutil::mock_upstream(live_resp).await; + + let mut ctx = crate::testutil::test_ctx().await; + ctx.forwarding_rules = vec![ForwardingRule::new( + "corp".to_string(), + UpstreamPool::new( + vec![Upstream::Udp(dead), Upstream::Udp(live)], + vec![], + ), + )]; + let ctx = Arc::new(ctx); + + let (resp, path) = resolve_in_test(&ctx, "internal.corp", QueryType::A).await; + assert_eq!(path, QueryPath::Forwarded); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + assert_eq!(resp.answers.len(), 1); + match &resp.answers[0] { + DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::new(10, 9, 9, 9)), + other => panic!("expected A record, got {:?}", other), + } + } } diff --git a/src/forward.rs b/src/forward.rs index 7c7a53a..8bb548e 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -118,7 +118,7 @@ fn build_dot_connector() -> Result { ))) } -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct UpstreamPool { primary: Vec, fallback: Vec, diff --git a/src/main.rs b/src/main.rs index bce7add..529d40e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -212,7 +212,11 @@ async fn main() -> numa::Result<()> { for fwd in &config.forwarding { for suffix in &fwd.suffix { - info!("forwarding .{} to {} (config rule)", suffix, fwd.upstream); + info!( + "forwarding .{} to {} (config rule)", + suffix, + fwd.upstream.join(", ") + ); } } let forwarding_rules = diff --git a/src/system_dns.rs b/src/system_dns.rs index 96ae372..8b1c4ed 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -2,7 +2,7 @@ use std::net::SocketAddr; use log::info; -use crate::forward::Upstream; +use crate::forward::{Upstream, UpstreamPool}; fn print_recursive_hint() { let is_recursive = crate::config::load_config("numa.toml") @@ -24,11 +24,11 @@ fn is_loopback_or_stub(addr: &str) -> bool { pub struct ForwardingRule { pub suffix: String, dot_suffix: String, // pre-computed ".suffix" for zero-alloc matching - pub upstream: Upstream, + pub upstream: UpstreamPool, } impl ForwardingRule { - pub fn new(suffix: String, upstream: Upstream) -> Self { + pub fn new(suffix: String, upstream: UpstreamPool) -> Self { let dot_suffix = format!(".{}", suffix); Self { suffix, @@ -216,7 +216,8 @@ fn discover_macos() -> SystemDnsInfo { for rule in &rules { info!( "auto-discovered forwarding: *.{} -> {}", - rule.suffix, rule.upstream + rule.suffix, + rule.upstream.label() ); } if rules.is_empty() { @@ -235,7 +236,8 @@ fn discover_macos() -> SystemDnsInfo { #[cfg(any(target_os = "macos", target_os = "linux"))] fn make_rule(domain: &str, nameserver: &str) -> Option { let addr = crate::forward::parse_upstream_addr(nameserver, 53).ok()?; - Some(ForwardingRule::new(domain.to_string(), Upstream::Udp(addr))) + let pool = UpstreamPool::new(vec![Upstream::Udp(addr)], vec![]); + Some(ForwardingRule::new(domain.to_string(), pool)) } #[cfg(target_os = "linux")] @@ -827,7 +829,7 @@ fn uninstall_windows() -> Result<(), String> { pub fn match_forwarding_rule<'a>( domain: &str, rules: &'a [ForwardingRule], -) -> Option<&'a Upstream> { +) -> Option<&'a UpstreamPool> { for rule in rules { if domain == rule.suffix || domain.ends_with(&rule.dot_suffix) { return Some(&rule.upstream); -- 2.34.1 From fef43635d61766a3f8b638acf33302044fa79905 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 15 Apr 2026 04:11:27 +0300 Subject: [PATCH 127/204] fix(ci): rustfmt import order and gate Upstream import for Windows --- src/ctx.rs | 7 ++----- src/system_dns.rs | 4 +++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index 6467620..e339e81 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -16,9 +16,9 @@ use crate::blocklist::BlocklistStore; use crate::buffer::BytePacketBuffer; use crate::cache::{DnsCache, DnssecStatus}; use crate::config::{UpstreamMode, ZoneMap}; -use crate::forward::{forward_with_failover_raw, UpstreamPool}; #[cfg(test)] use crate::forward::Upstream; +use crate::forward::{forward_with_failover_raw, UpstreamPool}; use crate::header::ResultCode; use crate::health::HealthMeta; use crate::lan::PeerStore; @@ -1280,10 +1280,7 @@ mod tests { let mut ctx = crate::testutil::test_ctx().await; ctx.forwarding_rules = vec![ForwardingRule::new( "corp".to_string(), - UpstreamPool::new( - vec![Upstream::Udp(dead), Upstream::Udp(live)], - vec![], - ), + UpstreamPool::new(vec![Upstream::Udp(dead), Upstream::Udp(live)], vec![]), )]; let ctx = Arc::new(ctx); diff --git a/src/system_dns.rs b/src/system_dns.rs index 8b1c4ed..a450e01 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -2,7 +2,9 @@ use std::net::SocketAddr; use log::info; -use crate::forward::{Upstream, UpstreamPool}; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use crate::forward::Upstream; +use crate::forward::UpstreamPool; fn print_recursive_hint() { let is_recursive = crate::config::load_config("numa.toml") -- 2.34.1 From b403671e11cb6669015c8a48dc1aebede3661385 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 15 Apr 2026 14:27:17 +0300 Subject: [PATCH 128/204] chore(deps): bump rustls-webpki to 0.103.12 Patches RUSTSEC-2026-0098 (URI name constraints incorrectly accepted) and RUSTSEC-2026-0099 (wildcard cert name constraints), both published 2026-04-14. Transitive via reqwest / rustls / hickory / quinn. --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c01e85f..9cd1b7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1834,9 +1834,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "aws-lc-rs", "ring", -- 2.34.1 From cea4b0ef8842a9c061266701d55f298906a5be71 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 15 Apr 2026 22:14:36 +0300 Subject: [PATCH 129/204] feat(windows): add windows-service crate + SCM dispatcher scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets numa.exe act as a real Windows service registered with the SCM, replacing the HKLM\...\Run login-time autostart that runs in the user session without stderr capture. - New `numa::windows_service` module (cfg(windows)) wraps Mullvad's `windows-service` crate: registers with SCM, reports Running, handles Stop/Shutdown, reports Stopped. - `numa.exe --service` is the entry point SCM uses (`sc create … binPath="numa.exe --service"`); interactive invocations are unchanged. - Dep is gated `[target.'cfg(windows)'.dependencies]` — zero impact on macOS/Linux builds or binary size. Scaffold only. The service currently blocks on an mpsc channel until Stop arrives; the actual serve loop will hook in once main.rs's inline server body is extracted into `numa::serve(config_path)` in a follow-up. This lets `sc start Numa` / `sc stop Numa` be verified end to end today. --- Cargo.lock | 12 ++++++ Cargo.toml | 3 ++ src/lib.rs | 3 ++ src/main.rs | 8 ++++ src/windows_service.rs | 85 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+) create mode 100644 src/windows_service.rs diff --git a/Cargo.lock b/Cargo.lock index 9cd1b7d..cf25b3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1359,6 +1359,7 @@ dependencies = [ "toml", "tower", "webpki-roots 1.0.6", + "windows-service", "x509-parser", ] @@ -2583,6 +2584,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-service" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a" +dependencies = [ + "bitflags", + "widestring", + "windows-sys 0.52.0", +] + [[package]] name = "windows-strings" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 0b13af2..3b3234f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,9 @@ rustls-pemfile = "2.2.0" qrcode = { version = "0.14", default-features = false, features = ["svg"] } webpki-roots = "1" +[target.'cfg(windows)'.dependencies] +windows-service = "0.7" + [dev-dependencies] criterion = { version = "0.8", features = ["html_reports"] } tower = { version = "0.5", features = ["util"] } diff --git a/src/lib.rs b/src/lib.rs index 8933e2a..346c739 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,9 @@ pub mod system_dns; pub mod tls; pub mod wire; +#[cfg(windows)] +pub mod windows_service; + #[cfg(test)] pub(crate) mod testutil; diff --git a/src/main.rs b/src/main.rs index bce7add..0459005 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,14 @@ async fn main() -> numa::Result<()> { // Handle CLI subcommands let arg1 = std::env::args().nth(1).unwrap_or_default(); match arg1.as_str() { + #[cfg(windows)] + "--service" => { + // Entry point used by Windows SCM (`sc create … binPath="numa.exe --service"`). + // Hands control to the service dispatcher and blocks until Stop. + numa::windows_service::run_as_service() + .map_err(|e| format!("windows service dispatcher failed: {}", e))?; + return Ok(()); + } "install" => { eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — installing\n"); return install_service().map_err(|e| e.into()); diff --git a/src/windows_service.rs b/src/windows_service.rs new file mode 100644 index 0000000..8751f23 --- /dev/null +++ b/src/windows_service.rs @@ -0,0 +1,85 @@ +//! Windows service wrapper. +//! +//! Lets the `numa.exe` binary act as a real Windows service registered with +//! the Service Control Manager (SCM). Invoked via `numa.exe --service` (the +//! form that `sc create … binPath=` uses). +//! +//! Interactive runs (`numa.exe`, `numa.exe run`, `numa.exe install`) do not +//! go through this module — they keep their existing console-attached +//! behaviour. + +use std::ffi::OsString; +use std::sync::mpsc; +use std::time::Duration; + +use windows_service::service::{ + ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, ServiceType, +}; +use windows_service::service_control_handler::{self, ServiceControlHandlerResult}; +use windows_service::{define_windows_service, service_dispatcher}; + +pub const SERVICE_NAME: &str = "Numa"; + +define_windows_service!(ffi_service_main, service_main); + +/// Entry point the SCM hands control to after `StartServiceCtrlDispatcherW`. +/// Any panic here vanishes silently into the service host — log instead of +/// unwrapping. +fn service_main(_arguments: Vec) { + if let Err(e) = run_service() { + log::error!("numa service exited with error: {:?}", e); + } +} + +fn run_service() -> windows_service::Result<()> { + let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>(); + + let event_handler = move |control_event| -> ServiceControlHandlerResult { + match control_event { + ServiceControl::Stop | ServiceControl::Shutdown => { + let _ = shutdown_tx.send(()); + ServiceControlHandlerResult::NoError + } + ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, + _ => ServiceControlHandlerResult::NotImplemented, + } + }; + + let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)?; + + status_handle.set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: ServiceState::Running, + controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN, + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + + // TODO(windows-service): call numa's async serve loop here once main.rs's + // server body is extracted into `numa::serve(config_path)`. For now the + // service registers, reports Running, and blocks until SCM sends Stop — + // useful for verifying the SCM plumbing end to end with `sc start Numa` + // and `sc stop Numa`. + let _ = shutdown_rx.recv(); + + status_handle.set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: ServiceState::Stopped, + controls_accepted: ServiceControlAccept::empty(), + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + + Ok(()) +} + +/// Hand control to the SCM dispatcher. Blocks until the service stops. +/// Call only from the `--service` command path — interactive invocations +/// will hang here waiting for an SCM that isn't talking to them. +pub fn run_as_service() -> windows_service::Result<()> { + service_dispatcher::start(SERVICE_NAME, ffi_service_main) +} -- 2.34.1 From b610160cd1855fc79770661a3ce457859073698f Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 15 Apr 2026 22:24:23 +0300 Subject: [PATCH 130/204] feat(windows): run numa as a real SCM service, drop Run-key autostart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hooks the service-dispatcher scaffolding from the previous commit to actually serve DNS, and replaces the HKLM\…\Run login-time autostart with a proper Windows service created via sc.exe. **Refactor** - Extract main.rs's inline server body (~500 lines) into `numa::serve::run` so both the interactive CLI entry and the service dispatcher drive the same startup/serve loop. main.rs is now a thin subcommand router. - main.rs goes sync (no #[tokio::main]); each branch that needs async builds its own runtime and block_on's. Required so the --service path can hand off to SCM without fighting tokio for the entry thread. **Windows service wrapper** - `numa::windows_service::run_service` now builds a multi-thread tokio runtime on a dedicated thread and runs `serve::run` inside it. Stop/ Shutdown from SCM aborts the wait loop and reports SERVICE_STOPPED. - Config path resolves to `%PROGRAMDATA%\numa\numa.toml` when running under SCM (SYSTEM's cwd is System32, relative paths don't work). **Install/uninstall** - `install_windows` now copies numa.exe to a stable `%PROGRAMDATA%\numa\bin\numa.exe` and registers it via `sc create` with start=auto, obj=LocalSystem, and a failure policy of restart/5000/restart/5000/restart/10000. Starts the service immediately when no reboot is pending. - `uninstall_windows` stops + deletes the service and removes the binary copy before restoring DNS. - Drops the old `register_autostart` / `remove_autostart` helpers that wrote to `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run` — that path runs at user login in the user's session with no stderr capture and no crash-restart policy, which is why we've been flying blind in every Windows debug session. DNS-set bugs (netsh destructive static, IPv6 not touched, uninstall secondary-drop) and file logging are orthogonal — tracked for follow-up. --- src/lib.rs | 1 + src/main.rs | 654 +---------------------------------------- src/serve.rs | 646 ++++++++++++++++++++++++++++++++++++++++ src/system_dns.rs | 185 ++++++++++-- src/windows_service.rs | 59 +++- 5 files changed, 868 insertions(+), 677 deletions(-) create mode 100644 src/serve.rs diff --git a/src/lib.rs b/src/lib.rs index 346c739..0370c37 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub mod query_log; pub mod question; pub mod record; pub mod recursive; +pub mod serve; pub mod service_store; pub mod setup_phone; pub mod srtt; diff --git a/src/main.rs b/src/main.rs index 0459005..88f2128 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,30 +1,6 @@ -use std::net::SocketAddr; -use std::sync::{Arc, Mutex, RwLock}; -use std::time::Duration; +use numa::system_dns::{install_service, restart_service, service_status, uninstall_service}; -use arc_swap::ArcSwap; -use log::{error, info}; -use tokio::net::UdpSocket; - -use numa::blocklist::{download_blocklists, parse_blocklist, BlocklistStore}; -use numa::buffer::BytePacketBuffer; -use numa::cache::DnsCache; -use numa::config::{build_zone_map, load_config, ConfigLoad}; -use numa::ctx::{handle_query, ServerCtx}; -use numa::forward::{parse_upstream, Upstream, UpstreamPool}; -use numa::override_store::OverrideStore; -use numa::query_log::QueryLog; -use numa::service_store::ServiceStore; -use numa::stats::{ServerStats, Transport}; -use numa::system_dns::{ - discover_system_dns, install_service, restart_service, service_status, uninstall_service, -}; - -const QUAD9_IP: &str = "9.9.9.9"; -const DOH_FALLBACK: &str = "https://9.9.9.9/dns-query"; - -#[tokio::main] -async fn main() -> numa::Result<()> { +fn main() -> numa::Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) .format_timestamp_millis() .init(); @@ -35,7 +11,7 @@ async fn main() -> numa::Result<()> { #[cfg(windows)] "--service" => { // Entry point used by Windows SCM (`sc create … binPath="numa.exe --service"`). - // Hands control to the service dispatcher and blocks until Stop. + // Blocks until SCM sends Stop; never returns normally. numa::windows_service::run_as_service() .map_err(|e| format!("windows service dispatcher failed: {}", e))?; return Ok(()); @@ -63,7 +39,12 @@ async fn main() -> numa::Result<()> { }; } "setup-phone" => { - return numa::setup_phone::run().await.map_err(|e| e.into()); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + return runtime + .block_on(numa::setup_phone::run()) + .map_err(|e| e.into()); } "lan" => { let sub = std::env::args().nth(2).unwrap_or_default(); @@ -126,552 +107,11 @@ async fn main() -> numa::Result<()> { } else { arg1 // treat as config path for backwards compatibility }; - let ConfigLoad { - config, - path: resolved_config_path, - found: config_found, - } = load_config(&config_path)?; - // Discover system DNS in a single pass (upstream + forwarding rules) - let system_dns = discover_system_dns(); - - let root_hints = numa::recursive::parse_root_hints(&config.upstream.root_hints); - - let recursive_pool = || { - let dummy = UpstreamPool::new(vec![Upstream::Udp("0.0.0.0:0".parse().unwrap())], vec![]); - (dummy, "recursive (root hints)".to_string()) - }; - - let (resolved_mode, upstream_auto, pool, upstream_label) = match config.upstream.mode { - numa::config::UpstreamMode::Auto => { - info!("auto mode: probing recursive resolution..."); - if numa::recursive::probe_recursive(&root_hints).await { - info!("recursive probe succeeded — self-sovereign mode"); - let (pool, label) = recursive_pool(); - (numa::config::UpstreamMode::Recursive, false, pool, label) - } else { - log::warn!("recursive probe failed — falling back to Quad9 DoH"); - let client = reqwest::Client::builder() - .use_rustls_tls() - .build() - .unwrap_or_default(); - let url = DOH_FALLBACK.to_string(); - let label = url.clone(); - let pool = UpstreamPool::new(vec![Upstream::Doh { url, client }], vec![]); - (numa::config::UpstreamMode::Forward, false, pool, label) - } - } - numa::config::UpstreamMode::Recursive => { - let (pool, label) = recursive_pool(); - (numa::config::UpstreamMode::Recursive, false, pool, label) - } - numa::config::UpstreamMode::Forward => { - let addrs = if config.upstream.address.is_empty() { - let detected = system_dns - .default_upstream - .or_else(numa::system_dns::detect_dhcp_dns) - .unwrap_or_else(|| { - info!("could not detect system DNS, falling back to Quad9 DoH"); - DOH_FALLBACK.to_string() - }); - vec![detected] - } else { - config.upstream.address.clone() - }; - - let primary: Vec = addrs - .iter() - .map(|s| parse_upstream(s, config.upstream.port)) - .collect::>>()?; - let fallback: Vec = config - .upstream - .fallback - .iter() - .map(|s| parse_upstream(s, config.upstream.port)) - .collect::>>()?; - - let pool = UpstreamPool::new(primary, fallback); - let label = pool.label(); - ( - numa::config::UpstreamMode::Forward, - config.upstream.address.is_empty(), - pool, - label, - ) - } - }; - let api_port = config.server.api_port; - - let mut blocklist = BlocklistStore::new(); - for domain in &config.blocking.allowlist { - blocklist.add_to_allowlist(domain); - } - if !config.blocking.enabled { - blocklist.set_enabled(false); - } - - // Build service store: config services + persisted user services - let mut service_store = ServiceStore::new(); - service_store.insert_from_config("numa", config.server.api_port, Vec::new()); - for svc in &config.services { - service_store.insert_from_config(&svc.name, svc.target_port, svc.routes.clone()); - } - service_store.load_persisted(); - - for fwd in &config.forwarding { - for suffix in &fwd.suffix { - info!("forwarding .{} to {} (config rule)", suffix, fwd.upstream); - } - } - let forwarding_rules = - numa::config::merge_forwarding_rules(&config.forwarding, 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(), - &resolved_data_dir, - ) { - Ok(tls_config) => Some(ArcSwap::from(tls_config)), - Err(e) => { - if let Some(advisory) = numa::tls::try_data_dir_advisory(&e, &resolved_data_dir) { - eprint!("{}", advisory); - } else { - log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e); - } - None - } - } - } else { - None - }; - - let doh_enabled = initial_tls.is_some(); - let health_meta = numa::health::HealthMeta::build( - &resolved_data_dir, - config.dot.enabled, - config.dot.port, - config.mobile.port, - config.dnssec.enabled, - resolved_mode == numa::config::UpstreamMode::Recursive, - config.lan.enabled, - config.blocking.enabled, - doh_enabled, - ); - - let ca_pem = std::fs::read_to_string(resolved_data_dir.join("ca.pem")).ok(); - - let socket = match UdpSocket::bind(&config.server.bind_addr).await { - Ok(s) => s, - Err(e) => { - if let Some(advisory) = - numa::system_dns::try_port53_advisory(&config.server.bind_addr, &e) - { - eprint!("{}", advisory); - std::process::exit(1); - } - return Err(e.into()); - } - }; - - let ctx = Arc::new(ServerCtx { - socket, - zone_map: build_zone_map(&config.zones)?, - cache: RwLock::new(DnsCache::new( - config.cache.max_entries, - config.cache.min_ttl, - config.cache.max_ttl, - )), - refreshing: Mutex::new(std::collections::HashSet::new()), - stats: Mutex::new(ServerStats::new()), - overrides: RwLock::new(OverrideStore::new()), - blocklist: RwLock::new(blocklist), - query_log: Mutex::new(QueryLog::new(1000)), - services: Mutex::new(service_store), - lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)), - forwarding_rules, - upstream_pool: Mutex::new(pool), - upstream_auto, - upstream_port: config.upstream.port, - lan_ip: Mutex::new(numa::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)), - timeout: Duration::from_millis(config.upstream.timeout_ms), - hedge_delay: Duration::from_millis(config.upstream.hedge_ms), - proxy_tld_suffix: if config.proxy.tld.is_empty() { - String::new() - } else { - format!(".{}", config.proxy.tld) - }, - proxy_tld: config.proxy.tld.clone(), - lan_enabled: config.lan.enabled, - config_path: resolved_config_path, - config_found, - config_dir: numa::config_dir(), - data_dir: resolved_data_dir, - tls_config: initial_tls, - upstream_mode: resolved_mode, - root_hints, - srtt: std::sync::RwLock::new(numa::srtt::SrttCache::new(config.upstream.srtt)), - inflight: std::sync::Mutex::new(std::collections::HashMap::new()), - dnssec_enabled: config.dnssec.enabled, - dnssec_strict: config.dnssec.strict, - health_meta, - ca_pem, - mobile_enabled: config.mobile.enabled, - mobile_port: config.mobile.port, - }); - - let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); - // Build banner rows, then size the box to fit the longest value - let api_url = format!("http://localhost:{}", api_port); - let proxy_label = if config.proxy.enabled { - if config.proxy.tls_port > 0 { - Some(format!( - "http://:{} https://:{}", - config.proxy.port, config.proxy.tls_port - )) - } else { - Some(format!( - "http://*.{} on :{}", - config.proxy.tld, config.proxy.port - )) - } - } else { - None - }; - let config_label = if ctx.config_found { - ctx.config_path.clone() - } else { - format!("{} (defaults)", ctx.config_path) - }; - let data_label = ctx.data_dir.display().to_string(); - let services_label = ctx.config_dir.join("services.json").display().to_string(); - - // label (10) + value + padding (2) = inner width; minimum 40 for the title row - let val_w = [ - config.server.bind_addr.len(), - api_url.len(), - upstream_label.len(), - config_label.len(), - data_label.len(), - services_label.len(), - ] - .into_iter() - .chain(proxy_label.as_ref().map(|s| s.len())) - .max() - .unwrap_or(30); - let w = (val_w + 12).max(42); // 10 label + 2 padding, min 42 for title - - let o = "\x1b[38;2;192;98;58m"; // orange - let g = "\x1b[38;2;107;124;78m"; // green - let d = "\x1b[38;2;163;152;136m"; // dim - let r = "\x1b[0m"; // reset - let b = "\x1b[1;38;2;192;98;58m"; // bold orange - let it = "\x1b[3;38;2;163;152;136m"; // italic dim - - let bar_top = "═".repeat(w); - let bar_mid = "─".repeat(w); - let row = |label: &str, color: &str, value: &str| { - eprintln!( - "{o} ║{r} {color}{:<9}{r} {: 0 && ctx.tls_config.is_some() { - let proxy_ctx = Arc::clone(&ctx); - let tls_port = config.proxy.tls_port; - tokio::spawn(async move { - numa::proxy::start_proxy_tls(proxy_ctx, tls_port, proxy_bind).await; - }); - } - - // Spawn network change watcher (upstream re-detection, LAN IP update, peer flush) - { - let watch_ctx = Arc::clone(&ctx); - tokio::spawn(async move { - network_watch_loop(watch_ctx).await; - }); - } - - // Spawn LAN service discovery - if config.lan.enabled { - let lan_ctx = Arc::clone(&ctx); - let lan_config = config.lan.clone(); - tokio::spawn(async move { - numa::lan::start_lan_discovery(lan_ctx, &lan_config).await; - }); - } - - // 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 { - let mut buffer = BytePacketBuffer::new(); - let (len, src_addr) = match ctx.socket.recv_from(&mut buffer.buf).await { - Ok(r) => r, - Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => { - // Windows delivers ICMP port-unreachable as ConnectionReset on UDP sockets - continue; - } - Err(e) => return Err(e.into()), - }; - let ctx = Arc::clone(&ctx); - tokio::spawn(async move { - if let Err(e) = handle_query(buffer, len, src_addr, &ctx, Transport::Udp).await { - error!("{} | HANDLER ERROR | {}", src_addr, e); - } - }); - } -} - -async fn network_watch_loop(ctx: Arc) { - let mut tick: u64 = 0; - - let mut interval = tokio::time::interval(Duration::from_secs(5)); - interval.tick().await; // skip immediate tick - - loop { - interval.tick().await; - tick += 1; - let mut changed = false; - - // Check LAN IP change (every 5s — cheap, one UDP socket call) - if let Some(new_ip) = numa::lan::detect_lan_ip() { - let mut current_ip = ctx.lan_ip.lock().unwrap(); - if new_ip != *current_ip { - info!("LAN IP changed: {} → {}", current_ip, new_ip); - *current_ip = new_ip; - changed = true; - numa::recursive::reset_udp_state(); - } - } - - // Re-detect upstream every 30s or on LAN IP change (auto-detect only) - if ctx.upstream_auto && (changed || tick.is_multiple_of(6)) { - let dns_info = numa::system_dns::discover_system_dns(); - let new_addr = dns_info - .default_upstream - .or_else(numa::system_dns::detect_dhcp_dns) - .unwrap_or_else(|| QUAD9_IP.to_string()); - let mut pool = ctx.upstream_pool.lock().unwrap(); - if pool.maybe_update_primary(&new_addr, ctx.upstream_port) { - info!("upstream changed → {}", pool.label()); - changed = true; - } - } - - // Flush stale LAN peers on any network change - if changed { - ctx.lan_peers.lock().unwrap().clear(); - info!("flushed LAN peers after network change"); - } - - // Re-probe UDP every 5 minutes when disabled - if tick.is_multiple_of(60) { - numa::recursive::probe_udp(&ctx.root_hints).await; - } - } + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + runtime.block_on(numa::serve::run(config_path)) } fn set_lan_enabled(enabled: bool, path: &str) -> numa::Result<()> { @@ -738,71 +178,3 @@ fn print_lan_status(enabled: bool) { eprintln!(" Restart Numa to start mDNS discovery"); } } - -async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) { - let downloaded = download_blocklists(lists).await; - - // Parse outside the lock to avoid blocking DNS queries during parse (~100ms) - let mut all_domains = std::collections::HashSet::new(); - let mut sources = Vec::new(); - for (source, text) in &downloaded { - let domains = parse_blocklist(text); - info!("blocklist: {} domains from {}", domains.len(), source); - all_domains.extend(domains); - sources.push(source.clone()); - } - let total = all_domains.len(); - - // Swap under lock — sub-microsecond - ctx.blocklist - .write() - .unwrap() - .swap_domains(all_domains, sources); - info!( - "blocking enabled: {} unique domains from {} lists", - total, - downloaded.len() - ); -} - -async fn warm_domain(ctx: &ServerCtx, domain: &str) { - for qtype in [ - numa::question::QueryType::A, - numa::question::QueryType::AAAA, - ] { - numa::ctx::refresh_entry(ctx, domain, qtype).await; - } -} - -async fn doh_keepalive_loop(ctx: Arc) { - let mut interval = tokio::time::interval(Duration::from_secs(25)); - interval.tick().await; // skip first immediate tick - loop { - interval.tick().await; - let pool = ctx.upstream_pool.lock().unwrap().clone(); - if let Some(upstream) = pool.preferred() { - numa::forward::keepalive_doh(upstream).await; - } - } -} - -async fn cache_warm_loop(ctx: Arc, domains: Vec) { - tokio::time::sleep(Duration::from_secs(2)).await; - - for domain in &domains { - warm_domain(&ctx, domain).await; - } - info!("cache warm: {} domains resolved at startup", domains.len()); - - let mut interval = tokio::time::interval(Duration::from_secs(30)); - interval.tick().await; - loop { - interval.tick().await; - for domain in &domains { - let refresh = ctx.cache.read().unwrap().needs_warm(domain); - if refresh { - warm_domain(&ctx, domain).await; - } - } - } -} diff --git a/src/serve.rs b/src/serve.rs new file mode 100644 index 0000000..db0465b --- /dev/null +++ b/src/serve.rs @@ -0,0 +1,646 @@ +//! The main DNS-server runtime. +//! +//! Extracted from `main.rs` so both the interactive CLI entry and the +//! Windows service dispatcher (`windows_service` module) can drive the +//! same startup/serve loop. + +use std::net::SocketAddr; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::Duration; + +use arc_swap::ArcSwap; +use log::{error, info}; +use tokio::net::UdpSocket; + +use crate::blocklist::{download_blocklists, parse_blocklist, BlocklistStore}; +use crate::buffer::BytePacketBuffer; +use crate::cache::DnsCache; +use crate::config::{build_zone_map, load_config, ConfigLoad}; +use crate::ctx::{handle_query, ServerCtx}; +use crate::forward::{parse_upstream, Upstream, UpstreamPool}; +use crate::override_store::OverrideStore; +use crate::query_log::QueryLog; +use crate::service_store::ServiceStore; +use crate::stats::{ServerStats, Transport}; +use crate::system_dns::discover_system_dns; + +const QUAD9_IP: &str = "9.9.9.9"; +const DOH_FALLBACK: &str = "https://9.9.9.9/dns-query"; + +/// Boot the DNS server and run until the UDP listener errors out. +pub async fn run(config_path: String) -> crate::Result<()> { + let ConfigLoad { + config, + path: resolved_config_path, + found: config_found, + } = load_config(&config_path)?; + + // Discover system DNS in a single pass (upstream + forwarding rules) + let system_dns = discover_system_dns(); + + let root_hints = crate::recursive::parse_root_hints(&config.upstream.root_hints); + + let recursive_pool = || { + let dummy = UpstreamPool::new(vec![Upstream::Udp("0.0.0.0:0".parse().unwrap())], vec![]); + (dummy, "recursive (root hints)".to_string()) + }; + + let (resolved_mode, upstream_auto, pool, upstream_label) = match config.upstream.mode { + crate::config::UpstreamMode::Auto => { + info!("auto mode: probing recursive resolution..."); + if crate::recursive::probe_recursive(&root_hints).await { + info!("recursive probe succeeded — self-sovereign mode"); + let (pool, label) = recursive_pool(); + (crate::config::UpstreamMode::Recursive, false, pool, label) + } else { + log::warn!("recursive probe failed — falling back to Quad9 DoH"); + let client = reqwest::Client::builder() + .use_rustls_tls() + .build() + .unwrap_or_default(); + let url = DOH_FALLBACK.to_string(); + let label = url.clone(); + let pool = UpstreamPool::new(vec![Upstream::Doh { url, client }], vec![]); + (crate::config::UpstreamMode::Forward, false, pool, label) + } + } + crate::config::UpstreamMode::Recursive => { + let (pool, label) = recursive_pool(); + (crate::config::UpstreamMode::Recursive, false, pool, label) + } + crate::config::UpstreamMode::Forward => { + let addrs = if config.upstream.address.is_empty() { + let detected = system_dns + .default_upstream + .or_else(crate::system_dns::detect_dhcp_dns) + .unwrap_or_else(|| { + info!("could not detect system DNS, falling back to Quad9 DoH"); + DOH_FALLBACK.to_string() + }); + vec![detected] + } else { + config.upstream.address.clone() + }; + + let primary: Vec = addrs + .iter() + .map(|s| parse_upstream(s, config.upstream.port)) + .collect::>>()?; + let fallback: Vec = config + .upstream + .fallback + .iter() + .map(|s| parse_upstream(s, config.upstream.port)) + .collect::>>()?; + + let pool = UpstreamPool::new(primary, fallback); + let label = pool.label(); + ( + crate::config::UpstreamMode::Forward, + config.upstream.address.is_empty(), + pool, + label, + ) + } + }; + let api_port = config.server.api_port; + + let mut blocklist = BlocklistStore::new(); + for domain in &config.blocking.allowlist { + blocklist.add_to_allowlist(domain); + } + if !config.blocking.enabled { + blocklist.set_enabled(false); + } + + // Build service store: config services + persisted user services + let mut service_store = ServiceStore::new(); + service_store.insert_from_config("numa", config.server.api_port, Vec::new()); + for svc in &config.services { + service_store.insert_from_config(&svc.name, svc.target_port, svc.routes.clone()); + } + service_store.load_persisted(); + + for fwd in &config.forwarding { + for suffix in &fwd.suffix { + info!("forwarding .{} to {} (config rule)", suffix, fwd.upstream); + } + } + let forwarding_rules = + crate::config::merge_forwarding_rules(&config.forwarding, 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(crate::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 crate::tls::build_tls_config( + &config.proxy.tld, + &service_names, + Vec::new(), + &resolved_data_dir, + ) { + Ok(tls_config) => Some(ArcSwap::from(tls_config)), + Err(e) => { + if let Some(advisory) = crate::tls::try_data_dir_advisory(&e, &resolved_data_dir) { + eprint!("{}", advisory); + } else { + log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e); + } + None + } + } + } else { + None + }; + + let doh_enabled = initial_tls.is_some(); + let health_meta = crate::health::HealthMeta::build( + &resolved_data_dir, + config.dot.enabled, + config.dot.port, + config.mobile.port, + config.dnssec.enabled, + resolved_mode == crate::config::UpstreamMode::Recursive, + config.lan.enabled, + config.blocking.enabled, + doh_enabled, + ); + + let ca_pem = std::fs::read_to_string(resolved_data_dir.join("ca.pem")).ok(); + + let socket = match UdpSocket::bind(&config.server.bind_addr).await { + Ok(s) => s, + Err(e) => { + if let Some(advisory) = + crate::system_dns::try_port53_advisory(&config.server.bind_addr, &e) + { + eprint!("{}", advisory); + std::process::exit(1); + } + return Err(e.into()); + } + }; + + let ctx = Arc::new(ServerCtx { + socket, + zone_map: build_zone_map(&config.zones)?, + cache: RwLock::new(DnsCache::new( + config.cache.max_entries, + config.cache.min_ttl, + config.cache.max_ttl, + )), + refreshing: Mutex::new(std::collections::HashSet::new()), + stats: Mutex::new(ServerStats::new()), + overrides: RwLock::new(OverrideStore::new()), + blocklist: RwLock::new(blocklist), + query_log: Mutex::new(QueryLog::new(1000)), + services: Mutex::new(service_store), + lan_peers: Mutex::new(crate::lan::PeerStore::new(config.lan.peer_timeout_secs)), + forwarding_rules, + upstream_pool: Mutex::new(pool), + upstream_auto, + upstream_port: config.upstream.port, + lan_ip: Mutex::new(crate::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)), + timeout: Duration::from_millis(config.upstream.timeout_ms), + hedge_delay: Duration::from_millis(config.upstream.hedge_ms), + proxy_tld_suffix: if config.proxy.tld.is_empty() { + String::new() + } else { + format!(".{}", config.proxy.tld) + }, + proxy_tld: config.proxy.tld.clone(), + lan_enabled: config.lan.enabled, + config_path: resolved_config_path, + config_found, + config_dir: crate::config_dir(), + data_dir: resolved_data_dir, + tls_config: initial_tls, + upstream_mode: resolved_mode, + root_hints, + srtt: std::sync::RwLock::new(crate::srtt::SrttCache::new(config.upstream.srtt)), + inflight: std::sync::Mutex::new(std::collections::HashMap::new()), + dnssec_enabled: config.dnssec.enabled, + dnssec_strict: config.dnssec.strict, + health_meta, + ca_pem, + mobile_enabled: config.mobile.enabled, + mobile_port: config.mobile.port, + }); + + let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); + // Build banner rows, then size the box to fit the longest value + let api_url = format!("http://localhost:{}", api_port); + let proxy_label = if config.proxy.enabled { + if config.proxy.tls_port > 0 { + Some(format!( + "http://:{} https://:{}", + config.proxy.port, config.proxy.tls_port + )) + } else { + Some(format!( + "http://*.{} on :{}", + config.proxy.tld, config.proxy.port + )) + } + } else { + None + }; + let config_label = if ctx.config_found { + ctx.config_path.clone() + } else { + format!("{} (defaults)", ctx.config_path) + }; + let data_label = ctx.data_dir.display().to_string(); + let services_label = ctx.config_dir.join("services.json").display().to_string(); + + // label (10) + value + padding (2) = inner width; minimum 40 for the title row + let val_w = [ + config.server.bind_addr.len(), + api_url.len(), + upstream_label.len(), + config_label.len(), + data_label.len(), + services_label.len(), + ] + .into_iter() + .chain(proxy_label.as_ref().map(|s| s.len())) + .max() + .unwrap_or(30); + let w = (val_w + 12).max(42); // 10 label + 2 padding, min 42 for title + + let o = "\x1b[38;2;192;98;58m"; // orange + let g = "\x1b[38;2;107;124;78m"; // green + let d = "\x1b[38;2;163;152;136m"; // dim + let r = "\x1b[0m"; // reset + let b = "\x1b[1;38;2;192;98;58m"; // bold orange + let it = "\x1b[3;38;2;163;152;136m"; // italic dim + + let bar_top = "═".repeat(w); + let bar_mid = "─".repeat(w); + let row = |label: &str, color: &str, value: &str| { + eprintln!( + "{o} ║{r} {color}{:<9}{r} {: 0 && ctx.tls_config.is_some() { + let proxy_ctx = Arc::clone(&ctx); + let tls_port = config.proxy.tls_port; + tokio::spawn(async move { + crate::proxy::start_proxy_tls(proxy_ctx, tls_port, proxy_bind).await; + }); + } + + // Spawn network change watcher (upstream re-detection, LAN IP update, peer flush) + { + let watch_ctx = Arc::clone(&ctx); + tokio::spawn(async move { + network_watch_loop(watch_ctx).await; + }); + } + + // Spawn LAN service discovery + if config.lan.enabled { + let lan_ctx = Arc::clone(&ctx); + let lan_config = config.lan.clone(); + tokio::spawn(async move { + crate::lan::start_lan_discovery(lan_ctx, &lan_config).await; + }); + } + + // 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 { + crate::dot::start_dot(dot_ctx, &dot_config).await; + }); + } + + // UDP DNS listener + #[allow(clippy::infinite_loop)] + loop { + let mut buffer = BytePacketBuffer::new(); + let (len, src_addr) = match ctx.socket.recv_from(&mut buffer.buf).await { + Ok(r) => r, + Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => { + // Windows delivers ICMP port-unreachable as ConnectionReset on UDP sockets + continue; + } + Err(e) => return Err(e.into()), + }; + let ctx = Arc::clone(&ctx); + tokio::spawn(async move { + if let Err(e) = handle_query(buffer, len, src_addr, &ctx, Transport::Udp).await { + error!("{} | HANDLER ERROR | {}", src_addr, e); + } + }); + } +} + +async fn network_watch_loop(ctx: Arc) { + let mut tick: u64 = 0; + + let mut interval = tokio::time::interval(Duration::from_secs(5)); + interval.tick().await; // skip immediate tick + + loop { + interval.tick().await; + tick += 1; + let mut changed = false; + + // Check LAN IP change (every 5s — cheap, one UDP socket call) + if let Some(new_ip) = crate::lan::detect_lan_ip() { + let mut current_ip = ctx.lan_ip.lock().unwrap(); + if new_ip != *current_ip { + info!("LAN IP changed: {} → {}", current_ip, new_ip); + *current_ip = new_ip; + changed = true; + crate::recursive::reset_udp_state(); + } + } + + // Re-detect upstream every 30s or on LAN IP change (auto-detect only) + if ctx.upstream_auto && (changed || tick.is_multiple_of(6)) { + let dns_info = crate::system_dns::discover_system_dns(); + let new_addr = dns_info + .default_upstream + .or_else(crate::system_dns::detect_dhcp_dns) + .unwrap_or_else(|| QUAD9_IP.to_string()); + let mut pool = ctx.upstream_pool.lock().unwrap(); + if pool.maybe_update_primary(&new_addr, ctx.upstream_port) { + info!("upstream changed → {}", pool.label()); + changed = true; + } + } + + // Flush stale LAN peers on any network change + if changed { + ctx.lan_peers.lock().unwrap().clear(); + info!("flushed LAN peers after network change"); + } + + // Re-probe UDP every 5 minutes when disabled + if tick.is_multiple_of(60) { + crate::recursive::probe_udp(&ctx.root_hints).await; + } + } +} + +async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) { + let downloaded = download_blocklists(lists).await; + + // Parse outside the lock to avoid blocking DNS queries during parse (~100ms) + let mut all_domains = std::collections::HashSet::new(); + let mut sources = Vec::new(); + for (source, text) in &downloaded { + let domains = parse_blocklist(text); + info!("blocklist: {} domains from {}", domains.len(), source); + all_domains.extend(domains); + sources.push(source.clone()); + } + let total = all_domains.len(); + + // Swap under lock — sub-microsecond + ctx.blocklist + .write() + .unwrap() + .swap_domains(all_domains, sources); + info!( + "blocking enabled: {} unique domains from {} lists", + total, + downloaded.len() + ); +} + +async fn warm_domain(ctx: &ServerCtx, domain: &str) { + for qtype in [ + crate::question::QueryType::A, + crate::question::QueryType::AAAA, + ] { + crate::ctx::refresh_entry(ctx, domain, qtype).await; + } +} + +async fn doh_keepalive_loop(ctx: Arc) { + let mut interval = tokio::time::interval(Duration::from_secs(25)); + interval.tick().await; // skip first immediate tick + loop { + interval.tick().await; + let pool = ctx.upstream_pool.lock().unwrap().clone(); + if let Some(upstream) = pool.preferred() { + crate::forward::keepalive_doh(upstream).await; + } + } +} + +async fn cache_warm_loop(ctx: Arc, domains: Vec) { + tokio::time::sleep(Duration::from_secs(2)).await; + + for domain in &domains { + warm_domain(&ctx, domain).await; + } + info!("cache warm: {} domains resolved at startup", domains.len()); + + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.tick().await; + loop { + interval.tick().await; + for domain in &domains { + let refresh = ctx.cache.read().unwrap().needs_warm(domain); + if refresh { + warm_domain(&ctx, domain).await; + } + } + } +} diff --git a/src/system_dns.rs b/src/system_dns.rs index 96ae372..b39f661 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -697,7 +697,23 @@ fn install_windows() -> Result<(), String> { } let needs_reboot = disable_dnscache()?; - register_autostart(); + + // Copy the binary to a stable path under ProgramData and register it + // as a real Windows service (SCM-managed, boot-time, auto-restart). + let service_exe = install_service_binary()?; + register_service_scm(&service_exe)?; + + // If no reboot is pending (Dnscache wasn't running, port 53 free), + // start the service immediately. Otherwise it'll launch on next boot. + if !needs_reboot { + match start_service_scm() { + Ok(_) => eprintln!(" Service started."), + Err(e) => eprintln!( + " warning: service registered but could not start now: {}", + e + ), + } + } eprintln!(); if !has_useful_existing { @@ -707,51 +723,160 @@ fn install_windows() -> Result<(), String> { if needs_reboot { eprintln!(" *** Reboot required. Numa will start automatically. ***\n"); } else { - eprintln!(" Numa will start automatically on next boot.\n"); + eprintln!(" Numa is running.\n"); } print_recursive_hint(); Ok(()) } -/// Register numa to auto-start on boot via registry Run key. #[cfg(windows)] -fn register_autostart() { - let exe = std::env::current_exe() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| "numa".into()); - let _ = std::process::Command::new("reg") - .args([ - "add", - "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", - "/v", - "Numa", - "/t", - "REG_SZ", - "/d", - &exe, - "/f", - ]) - .status(); - eprintln!(" Registered auto-start on boot."); +const WINDOWS_SERVICE_NAME: &str = "Numa"; + +/// Stable install location for the service binary. SCM keeps a handle to +/// this path; the user's Downloads folder (where `current_exe()` points at +/// install time) is not durable. +#[cfg(windows)] +fn windows_service_exe_path() -> std::path::PathBuf { + std::path::PathBuf::from( + std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".into()), + ) + .join("numa") + .join("bin") + .join("numa.exe") } -/// Remove numa auto-start registry key. +/// Copy the currently-running binary to the service install location. SCM +/// keeps a handle to this path, so it must be stable across user sessions. #[cfg(windows)] -fn remove_autostart() { - let _ = std::process::Command::new("reg") +fn install_service_binary() -> Result { + let src = std::env::current_exe().map_err(|e| format!("current_exe(): {}", e))?; + let dst = windows_service_exe_path(); + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("failed to create {}: {}", parent.display(), e))?; + } + // Copy only if source and destination differ; running the binary from + // its install location is a supported (re-install) case. + if src != dst { + std::fs::copy(&src, &dst).map_err(|e| { + format!( + "failed to copy {} -> {}: {}", + src.display(), + dst.display(), + e + ) + })?; + } + Ok(dst) +} + +/// Remove the service binary on uninstall. Ignore failures — the service +/// is already deleted; a leftover file in ProgramData is not a hard error. +#[cfg(windows)] +fn remove_service_binary() { + let _ = std::fs::remove_file(windows_service_exe_path()); +} + +/// Register numa with the Service Control Manager, boot-time auto-start, +/// LocalSystem context, with a failure policy of restart-after-5s. +#[cfg(windows)] +fn register_service_scm(exe: &std::path::Path) -> Result<(), String> { + let bin_path = format!("\"{}\" --service", exe.display()); + + // sc.exe uses a leading space as its `name= value` delimiter; the space + // after `=` is mandatory. + let create = std::process::Command::new("sc") .args([ - "delete", - "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", - "/v", - "Numa", - "/f", + "create", + WINDOWS_SERVICE_NAME, + "binPath=", + &bin_path, + "DisplayName=", + "Numa DNS", + "start=", + "auto", + "obj=", + "LocalSystem", ]) + .output() + .map_err(|e| format!("failed to run sc create: {}", e))?; + if !create.status.success() { + let out = String::from_utf8_lossy(&create.stdout); + // "service already exists" is 1073 — treat as idempotent success. + if !out.contains("1073") { + return Err(format!("sc create failed: {}", out.trim())); + } + } + + let _ = std::process::Command::new("sc") + .args([ + "description", + WINDOWS_SERVICE_NAME, + "Self-sovereign DNS resolver (ad blocking, DoH/DoT, local zones).", + ]) + .status(); + + // Restart on crash: 5s, 5s, 10s; reset failure counter after 60s. + let _ = std::process::Command::new("sc") + .args([ + "failure", + WINDOWS_SERVICE_NAME, + "reset=", + "60", + "actions=", + "restart/5000/restart/5000/restart/10000", + ]) + .status(); + + eprintln!( + " Registered service '{}' (boot-time).", + WINDOWS_SERVICE_NAME + ); + Ok(()) +} + +/// Start the service. Safe to call on a freshly-registered service — SCM +/// will fail with 1056 ("already running") or 1058 ("disabled") and we +/// return the underlying error string rather than masking it. +#[cfg(windows)] +fn start_service_scm() -> Result<(), String> { + let out = std::process::Command::new("sc") + .args(["start", WINDOWS_SERVICE_NAME]) + .output() + .map_err(|e| format!("failed to run sc start: {}", e))?; + if !out.status.success() { + let text = String::from_utf8_lossy(&out.stdout); + if text.contains("1056") { + return Ok(()); // already running + } + return Err(format!("sc start failed: {}", text.trim())); + } + Ok(()) +} + +/// Stop the service. Returns Ok if already stopped — idempotent. +#[cfg(windows)] +fn stop_service_scm() { + let _ = std::process::Command::new("sc") + .args(["stop", WINDOWS_SERVICE_NAME]) + .status(); +} + +/// Remove the service from SCM. Safe if already absent. +#[cfg(windows)] +fn delete_service_scm() { + let _ = std::process::Command::new("sc") + .args(["delete", WINDOWS_SERVICE_NAME]) .status(); } #[cfg(windows)] fn uninstall_windows() -> Result<(), String> { - remove_autostart(); + // Stop + remove the service before touching DNS, so port 53 is released + // cleanly and the failure-restart policy doesn't resurrect it. + stop_service_scm(); + delete_service_scm(); + remove_service_binary(); let path = windows_backup_path(); let json = std::fs::read_to_string(&path) .map_err(|e| format!("no backup found at {}: {}", path.display(), e))?; diff --git a/src/windows_service.rs b/src/windows_service.rs index 8751f23..c51339c 100644 --- a/src/windows_service.rs +++ b/src/windows_service.rs @@ -57,12 +57,50 @@ fn run_service() -> windows_service::Result<()> { process_id: None, })?; - // TODO(windows-service): call numa's async serve loop here once main.rs's - // server body is extracted into `numa::serve(config_path)`. For now the - // service registers, reports Running, and blocks until SCM sends Stop — - // useful for verifying the SCM plumbing end to end with `sc start Numa` - // and `sc stop Numa`. - let _ = shutdown_rx.recv(); + // Spin up a multi-threaded tokio runtime and run the server on it. A + // dedicated thread runs the runtime so this function can return cleanly + // once the SCM tells us to stop — we can't block the dispatcher thread + // forever without preventing graceful shutdown. + let config_path = service_config_path(); + let (runtime_stop_tx, runtime_stop_rx) = mpsc::channel::<()>(); + + let server_thread = std::thread::spawn(move || { + let runtime = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + log::error!("failed to build tokio runtime: {}", e); + let _ = runtime_stop_tx.send(()); + return; + } + }; + + // block_on returns when serve::run's UDP loop errors out OR when the + // runtime is dropped from another thread. Either signals exit. + if let Err(e) = runtime.block_on(crate::serve::run(config_path)) { + log::error!("numa serve exited with error: {}", e); + } + let _ = runtime_stop_tx.send(()); + }); + + // Wait for either SCM stop or server termination. + loop { + if shutdown_rx.try_recv().is_ok() { + break; + } + if runtime_stop_rx.try_recv().is_ok() { + break; + } + std::thread::sleep(Duration::from_millis(200)); + } + + // The server's tokio runtime runs detached inside server_thread. Abandon + // it — the process is about to report Stopped and the SCM will terminate + // us if we linger. Future work: plumb a cancellation signal into + // serve::run() for a clean teardown of listeners and in-flight queries. + drop(server_thread); status_handle.set_service_status(ServiceStatus { service_type: ServiceType::OWN_PROCESS, @@ -83,3 +121,12 @@ fn run_service() -> windows_service::Result<()> { pub fn run_as_service() -> windows_service::Result<()> { service_dispatcher::start(SERVICE_NAME, ffi_service_main) } + +/// Path to the config file used when running under SCM. SCM launches the +/// service with SYSTEM's working directory (usually `C:\Windows\System32`), +/// so a relative `numa.toml` lookup won't find anything meaningful — use an +/// absolute path under `%PROGRAMDATA%` instead. +fn service_config_path() -> String { + let base = std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".into()); + format!("{}\\numa\\numa.toml", base) +} -- 2.34.1 From 7bb484ada3a6efd7521a44e3cc5bbd9dc7fb9dec Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 15 Apr 2026 23:48:09 +0300 Subject: [PATCH 131/204] refactor(windows): deduplicate after simplify review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the duplicate WINDOWS_SERVICE_NAME constant; call sites use the single source of truth at windows_service::SERVICE_NAME. - windows_service_exe_path and service_config_path now compose from crate::data_dir() instead of re-parsing %PROGRAMDATA% locally. - Factor the 6× sc.exe invocation boilerplate into a run_sc helper. - Replace the 200ms try_recv polling loop in the service dispatcher with a recv_timeout wait — cuts shutdown latency and idle CPU. - stop_service_scm/delete_service_scm now log warnings instead of silently swallowing failures, so unexpected errors are visible. --- src/system_dns.rs | 108 +++++++++++++++++++---------------------- src/windows_service.rs | 22 ++++----- 2 files changed, 61 insertions(+), 69 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index b39f661..826101d 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -729,20 +729,24 @@ fn install_windows() -> Result<(), String> { Ok(()) } -#[cfg(windows)] -const WINDOWS_SERVICE_NAME: &str = "Numa"; - /// Stable install location for the service binary. SCM keeps a handle to /// this path; the user's Downloads folder (where `current_exe()` points at /// install time) is not durable. #[cfg(windows)] fn windows_service_exe_path() -> std::path::PathBuf { - std::path::PathBuf::from( - std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".into()), - ) - .join("numa") - .join("bin") - .join("numa.exe") + crate::data_dir().join("bin").join("numa.exe") +} + +/// Run `sc.exe` with the given args and return its merged stdout/stderr on +/// failure. `sc` emits errors on stdout (not stderr) on Windows, so the +/// caller reads stdout to format a useful error. +#[cfg(windows)] +fn run_sc(args: &[&str]) -> Result { + let out = std::process::Command::new("sc") + .args(args) + .output() + .map_err(|e| format!("failed to run sc {}: {}", args.first().unwrap_or(&""), e))?; + Ok(out) } /// Copy the currently-running binary to the service install location. SCM @@ -782,24 +786,22 @@ fn remove_service_binary() { #[cfg(windows)] fn register_service_scm(exe: &std::path::Path) -> Result<(), String> { let bin_path = format!("\"{}\" --service", exe.display()); + let name = crate::windows_service::SERVICE_NAME; // sc.exe uses a leading space as its `name= value` delimiter; the space // after `=` is mandatory. - let create = std::process::Command::new("sc") - .args([ - "create", - WINDOWS_SERVICE_NAME, - "binPath=", - &bin_path, - "DisplayName=", - "Numa DNS", - "start=", - "auto", - "obj=", - "LocalSystem", - ]) - .output() - .map_err(|e| format!("failed to run sc create: {}", e))?; + let create = run_sc(&[ + "create", + name, + "binPath=", + &bin_path, + "DisplayName=", + "Numa DNS", + "start=", + "auto", + "obj=", + "LocalSystem", + ])?; if !create.status.success() { let out = String::from_utf8_lossy(&create.stdout); // "service already exists" is 1073 — treat as idempotent success. @@ -808,30 +810,23 @@ fn register_service_scm(exe: &std::path::Path) -> Result<(), String> { } } - let _ = std::process::Command::new("sc") - .args([ - "description", - WINDOWS_SERVICE_NAME, - "Self-sovereign DNS resolver (ad blocking, DoH/DoT, local zones).", - ]) - .status(); + let _ = run_sc(&[ + "description", + name, + "Self-sovereign DNS resolver (ad blocking, DoH/DoT, local zones).", + ]); // Restart on crash: 5s, 5s, 10s; reset failure counter after 60s. - let _ = std::process::Command::new("sc") - .args([ - "failure", - WINDOWS_SERVICE_NAME, - "reset=", - "60", - "actions=", - "restart/5000/restart/5000/restart/10000", - ]) - .status(); + let _ = run_sc(&[ + "failure", + name, + "reset=", + "60", + "actions=", + "restart/5000/restart/5000/restart/10000", + ]); - eprintln!( - " Registered service '{}' (boot-time).", - WINDOWS_SERVICE_NAME - ); + eprintln!(" Registered service '{}' (boot-time).", name); Ok(()) } @@ -840,10 +835,7 @@ fn register_service_scm(exe: &std::path::Path) -> Result<(), String> { /// return the underlying error string rather than masking it. #[cfg(windows)] fn start_service_scm() -> Result<(), String> { - let out = std::process::Command::new("sc") - .args(["start", WINDOWS_SERVICE_NAME]) - .output() - .map_err(|e| format!("failed to run sc start: {}", e))?; + let out = run_sc(&["start", crate::windows_service::SERVICE_NAME])?; if !out.status.success() { let text = String::from_utf8_lossy(&out.stdout); if text.contains("1056") { @@ -854,20 +846,22 @@ fn start_service_scm() -> Result<(), String> { Ok(()) } -/// Stop the service. Returns Ok if already stopped — idempotent. +/// Stop the service. Idempotent — already-stopped or missing service logs +/// a warning but doesn't error, since both callers (install re-run, +/// uninstall) want best-effort cleanup rather than hard failure. #[cfg(windows)] fn stop_service_scm() { - let _ = std::process::Command::new("sc") - .args(["stop", WINDOWS_SERVICE_NAME]) - .status(); + if let Err(e) = run_sc(&["stop", crate::windows_service::SERVICE_NAME]) { + log::warn!("sc stop failed: {}", e); + } } -/// Remove the service from SCM. Safe if already absent. +/// Remove the service from SCM. Idempotent — see `stop_service_scm`. #[cfg(windows)] fn delete_service_scm() { - let _ = std::process::Command::new("sc") - .args(["delete", WINDOWS_SERVICE_NAME]) - .status(); + if let Err(e) = run_sc(&["delete", crate::windows_service::SERVICE_NAME]) { + log::warn!("sc delete failed: {}", e); + } } #[cfg(windows)] diff --git a/src/windows_service.rs b/src/windows_service.rs index c51339c..a1403d7 100644 --- a/src/windows_service.rs +++ b/src/windows_service.rs @@ -62,7 +62,7 @@ fn run_service() -> windows_service::Result<()> { // once the SCM tells us to stop — we can't block the dispatcher thread // forever without preventing graceful shutdown. let config_path = service_config_path(); - let (runtime_stop_tx, runtime_stop_rx) = mpsc::channel::<()>(); + let (server_done_tx, server_done_rx) = mpsc::channel::<()>(); let server_thread = std::thread::spawn(move || { let runtime = match tokio::runtime::Builder::new_multi_thread() @@ -72,28 +72,25 @@ fn run_service() -> windows_service::Result<()> { Ok(rt) => rt, Err(e) => { log::error!("failed to build tokio runtime: {}", e); - let _ = runtime_stop_tx.send(()); + let _ = server_done_tx.send(()); return; } }; - // block_on returns when serve::run's UDP loop errors out OR when the - // runtime is dropped from another thread. Either signals exit. if let Err(e) = runtime.block_on(crate::serve::run(config_path)) { log::error!("numa serve exited with error: {}", e); } - let _ = runtime_stop_tx.send(()); + let _ = server_done_tx.send(()); }); // Wait for either SCM stop or server termination. loop { - if shutdown_rx.try_recv().is_ok() { + if shutdown_rx.recv_timeout(Duration::from_millis(500)).is_ok() { break; } - if runtime_stop_rx.try_recv().is_ok() { + if server_done_rx.try_recv().is_ok() { break; } - std::thread::sleep(Duration::from_millis(200)); } // The server's tokio runtime runs detached inside server_thread. Abandon @@ -124,9 +121,10 @@ pub fn run_as_service() -> windows_service::Result<()> { /// Path to the config file used when running under SCM. SCM launches the /// service with SYSTEM's working directory (usually `C:\Windows\System32`), -/// so a relative `numa.toml` lookup won't find anything meaningful — use an -/// absolute path under `%PROGRAMDATA%` instead. +/// so a relative `numa.toml` lookup won't find anything meaningful. fn service_config_path() -> String { - let base = std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".into()); - format!("{}\\numa\\numa.toml", base) + crate::data_dir() + .join("numa.toml") + .to_string_lossy() + .into_owned() } -- 2.34.1 From cc635f2f73e5fac3f7999f27eb352d6c00c18386 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 06:15:48 +0300 Subject: [PATCH 132/204] feat(dashboard): show version in header, restructure footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #108. - Add `version` field to /stats (from CARGO_PKG_VERSION). - Show `v0.13.1` next to the Numa wordmark in the dashboard header. - Restructure the footer into two semantic rows: Row 1 (paths): Config · Data · Logs (platform-detected) Row 2 (runtime): Upstream · DNSSEC · SRTT · GitHub - Drop Mode from the footer (redundant with Upstream label). - Show only the matching-platform log path instead of both macOS and Linux unconditionally. --- site/dashboard.html | 19 ++++++++++++------- src/api.rs | 2 ++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index 77018fc..de286ab 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -561,6 +561,7 @@ body {
      +
      DNS that governs itself
      @@ -1136,16 +1137,20 @@ async function refresh() { document.getElementById('totalQueries').textContent = formatNumber(q.total); document.getElementById('uptime').textContent = formatUptime(stats.uptime_secs); document.getElementById('uptimeSub').textContent = formatUptimeSub(stats.uptime_secs); + document.getElementById('headerVersion').textContent = stats.version ? 'v' + stats.version : ''; document.getElementById('footerUpstream').textContent = stats.upstream || ''; document.getElementById('footerConfig').textContent = stats.config_path || ''; document.getElementById('footerData').textContent = stats.data_dir || ''; - const modeEl = document.getElementById('footerMode'); - modeEl.textContent = stats.mode || '—'; - modeEl.style.color = stats.mode === 'recursive' ? 'var(--emerald)' : 'var(--amber)'; document.getElementById('footerDnssec').textContent = stats.dnssec ? 'on' : 'off'; document.getElementById('footerDnssec').style.color = stats.dnssec ? 'var(--emerald)' : 'var(--text-dim)'; document.getElementById('footerSrtt').textContent = stats.srtt ? 'on' : 'off'; document.getElementById('footerSrtt').style.color = stats.srtt ? 'var(--emerald)' : 'var(--text-dim)'; + if (!document.getElementById('footerLogs').textContent) { + const isMac = stats.data_dir && stats.data_dir.includes('/usr/local/'); + document.getElementById('footerLogs').textContent = isMac + ? '/usr/local/var/log/numa.log' + : 'journalctl -u numa -f'; + } // LAN status indicator const lanEl = document.getElementById('lanToggle'); @@ -1504,14 +1509,14 @@ refresh(); setInterval(refresh, 2000); -
      +
      Config: · Data: - · Upstream: - · Mode: + · Logs: +
      + Upstream: · DNSSEC: · SRTT: - · Logs: macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f · GitHub
      diff --git a/src/api.rs b/src/api.rs index 17c4614..f8b2702 100644 --- a/src/api.rs +++ b/src/api.rs @@ -160,6 +160,7 @@ struct QueryLogResponse { #[derive(Serialize)] struct StatsResponse { + version: &'static str, uptime_secs: u64, upstream: String, mode: &'static str, // "recursive" or "forward" — never "auto" at runtime @@ -539,6 +540,7 @@ async fn stats(State(ctx): State>) -> Json { }; Json(StatsResponse { + version: env!("CARGO_PKG_VERSION"), uptime_secs: snap.uptime_secs, upstream, mode: ctx.upstream_mode.as_str(), -- 2.34.1 From 1c5e703330bab7ca1a822246f346f79677d52863 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 06:39:29 +0300 Subject: [PATCH 133/204] =?UTF-8?q?fix(dashboard):=20collapse=20header=20o?= =?UTF-8?q?n=20mobile=20(=E2=89=A4700px)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hide tagline, version tag, and Phone Setup on narrow viewports so the header stays single-row: logo + status dot + blocking toggle. Reduces logo font-size from 1.8rem to 1.4rem on mobile. --- site/dashboard.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/site/dashboard.html b/site/dashboard.html index de286ab..85b6984 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -552,7 +552,11 @@ body { @media (max-width: 700px) { .stats-row { grid-template-columns: repeat(2, 1fr); } .dashboard { padding: 1rem; } - .header { padding: 1rem; } + .header { padding: 0.8rem 1rem; } + .logo { font-size: 1.4rem; } + .tagline { display: none; } + #headerVersion { display: none; } + #phoneSetup { display: none; } } -- 2.34.1 From 0118ab0f442e638274aaa68b32a3470d00fee4cf Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 13:02:25 +0300 Subject: [PATCH 134/204] feat: embed git SHA in version string via build.rs Adds a build.rs that runs `git describe --tags --always --dirty` and sets NUMA_BUILD_VERSION at compile time. A new `numa::version()` helper returns the build version, falling back to CARGO_PKG_VERSION when git is unavailable (source tarballs, Docker builds without .git). Version strings: tagged release: 0.13.1 commits ahead: 0.13.1+a87f907 uncommitted changes: 0.13.1+a87f907-dirty no git: 0.13.1 Replaces all 6 inline env!("CARGO_PKG_VERSION") call sites with the single version() function. --- build.rs | 47 +++++++++++++++++++++++++++++++++++++++++++++++ src/api.rs | 2 +- src/health.rs | 4 ++-- src/lib.rs | 8 ++++++++ src/main.rs | 10 ++++------ 5 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 build.rs diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..e3375af --- /dev/null +++ b/build.rs @@ -0,0 +1,47 @@ +fn main() { + let git_version = std::process::Command::new("git") + .args(["describe", "--tags", "--always", "--dirty"]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| { + let s = s.trim(); + let s = s.strip_prefix('v').unwrap_or(s); + // "0.13.1" → clean tag → "0.13.1" + // "0.13.1-9-ga87f907" → ahead → "0.13.1+a87f907" + // "0.13.1-9-ga87f907-dirty" → dirty → "0.13.1+a87f907-dirty" + // "a87f907" → no tags → "0.0.0+a87f907" + // "a87f907-dirty" → no tags → "0.0.0+a87f907-dirty" + if let Some((base, rest)) = s.split_once("-") { + // Could be "0.13.1-9-ga87f907[-dirty]" or "a87f907-dirty" + if base.contains('.') { + // Tagged: extract sha from "-N-gSHA[-dirty]" + let parts: Vec<&str> = rest.splitn(3, '-').collect(); + match parts.as_slice() { + [_n, sha] => format!("{}+{}", base, sha.strip_prefix('g').unwrap_or(sha)), + [_n, sha, "dirty"] => { + format!("{}+{}-dirty", base, sha.strip_prefix('g').unwrap_or(sha)) + } + _ => s.to_string(), + } + } else { + // Untagged: "sha-dirty" + format!("0.0.0+{}", s) + } + } else if s.contains('.') { + // Exact tag match: "0.13.1" + s.to_string() + } else { + // Bare sha, no tags at all + format!("0.0.0+{}", s) + } + }); + + if let Some(v) = git_version { + println!("cargo:rustc-env=NUMA_BUILD_VERSION={}", v); + } + + println!("cargo:rerun-if-changed=.git/HEAD"); + println!("cargo:rerun-if-changed=.git/refs/tags/"); +} diff --git a/src/api.rs b/src/api.rs index f8b2702..dd1fe78 100644 --- a/src/api.rs +++ b/src/api.rs @@ -540,7 +540,7 @@ async fn stats(State(ctx): State>) -> Json { }; Json(StatsResponse { - version: env!("CARGO_PKG_VERSION"), + version: crate::version(), uptime_secs: snap.uptime_secs, upstream, mode: ctx.upstream_mode.as_str(), diff --git a/src/health.rs b/src/health.rs index e55c569..5767f4b 100644 --- a/src/health.rs +++ b/src/health.rs @@ -43,7 +43,7 @@ impl HealthMeta { #[cfg(test)] pub fn test_fixture() -> Self { HealthMeta { - version: env!("CARGO_PKG_VERSION"), + version: crate::version(), hostname: "test-host".to_string(), sni: "numa.numa".to_string(), dot_enabled: false, @@ -99,7 +99,7 @@ impl HealthMeta { } HealthMeta { - version: env!("CARGO_PKG_VERSION"), + version: crate::version(), hostname: crate::hostname(), sni: "numa.numa".to_string(), dot_enabled, diff --git a/src/lib.rs b/src/lib.rs index 8933e2a..a9d38fc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,14 @@ pub(crate) mod testutil; pub type Error = Box; pub type Result = std::result::Result; +/// Build version string. On tagged releases: `0.13.1`. On commits ahead +/// of a tag: `0.13.1+a87f907`. With uncommitted changes: `0.13.1+a87f907-dirty`. +/// Falls back to `CARGO_PKG_VERSION` when built outside a git repo (e.g. +/// from a source tarball). +pub fn version() -> &'static str { + option_env!("NUMA_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")) +} + /// Detect the machine hostname via the `hostname` command. Returns the /// full hostname (e.g., `macbook-pro.local`), or `"numa"` if the command /// fails. Call sites that need the short form (e.g., mDNS instance diff --git a/src/main.rs b/src/main.rs index bce7add..faf2e22 100644 --- a/src/main.rs +++ b/src/main.rs @@ -72,7 +72,7 @@ async fn main() -> numa::Result<()> { }; } "version" | "--version" | "-V" => { - eprintln!("numa {}", env!("CARGO_PKG_VERSION")); + eprintln!("numa {}", numa::version()); return Ok(()); } "help" | "--help" | "-h" => { @@ -383,12 +383,10 @@ async fn main() -> numa::Result<()> { }; // Title row: center within the box - let title = format!( - "{b}NUMA{r} {it}DNS that governs itself{r} {d}v{}{r}", - env!("CARGO_PKG_VERSION") - ); + let ver = numa::version(); + let title = format!("{b}NUMA{r} {it}DNS that governs itself{r} {d}v{ver}{r}",); // The title contains ANSI codes; visible length is ~38 chars. Pad to fill the box. - let title_visible_len = 4 + 2 + 24 + 2 + 1 + env!("CARGO_PKG_VERSION").len() + 1; + let title_visible_len = 4 + 2 + 24 + 2 + 1 + ver.len() + 1; let title_pad = w.saturating_sub(title_visible_len); eprintln!("\n{o} ╔{bar_top}╗{r}"); eprint!("{o} ║{r} {title}"); -- 2.34.1 From 30bb7365c9b2f0faa0e2456b4b6ac04f84109d1f Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 13:18:56 +0300 Subject: [PATCH 135/204] refactor: robust git-describe parsing for pre-release tags Switch to --long flag so format is always TAG-N-gSHA[-dirty], then split from the right. Handles pre-release tags (v0.14.0-rc1) that broke the previous left-split approach. Remove ineffective directory watch on .git/refs/tags/. Trim comments. --- build.rs | 69 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/build.rs b/build.rs index e3375af..463100c 100644 --- a/build.rs +++ b/build.rs @@ -1,47 +1,48 @@ fn main() { + // --long forces "TAG-N-gSHA[-dirty]" format even on exact tag matches, + // making parsing unambiguous for pre-release tags like v0.14.0-rc1. let git_version = std::process::Command::new("git") - .args(["describe", "--tags", "--always", "--dirty"]) + .args(["describe", "--tags", "--always", "--dirty", "--long"]) .output() .ok() .filter(|o| o.status.success()) .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| { - let s = s.trim(); - let s = s.strip_prefix('v').unwrap_or(s); - // "0.13.1" → clean tag → "0.13.1" - // "0.13.1-9-ga87f907" → ahead → "0.13.1+a87f907" - // "0.13.1-9-ga87f907-dirty" → dirty → "0.13.1+a87f907-dirty" - // "a87f907" → no tags → "0.0.0+a87f907" - // "a87f907-dirty" → no tags → "0.0.0+a87f907-dirty" - if let Some((base, rest)) = s.split_once("-") { - // Could be "0.13.1-9-ga87f907[-dirty]" or "a87f907-dirty" - if base.contains('.') { - // Tagged: extract sha from "-N-gSHA[-dirty]" - let parts: Vec<&str> = rest.splitn(3, '-').collect(); - match parts.as_slice() { - [_n, sha] => format!("{}+{}", base, sha.strip_prefix('g').unwrap_or(sha)), - [_n, sha, "dirty"] => { - format!("{}+{}-dirty", base, sha.strip_prefix('g').unwrap_or(sha)) - } - _ => s.to_string(), - } - } else { - // Untagged: "sha-dirty" - format!("0.0.0+{}", s) - } - } else if s.contains('.') { - // Exact tag match: "0.13.1" - s.to_string() - } else { - // Bare sha, no tags at all - format!("0.0.0+{}", s) - } - }); + .and_then(|raw| parse_git_describe(raw.trim())); if let Some(v) = git_version { println!("cargo:rustc-env=NUMA_BUILD_VERSION={}", v); } println!("cargo:rerun-if-changed=.git/HEAD"); - println!("cargo:rerun-if-changed=.git/refs/tags/"); +} + +/// Parse `git describe --long` output into a SemVer-compatible string. +/// "v0.13.1-0-ga87f907" → "0.13.1" +/// "v0.13.1-9-ga87f907" → "0.13.1+a87f907" +/// "v0.14.0-rc1-0-ga87f907" → "0.14.0-rc1" +/// "v0.14.0-rc1-3-ga87f907-dirty" → "0.14.0-rc1+a87f907-dirty" +/// "a87f907" → "0.0.0+a87f907" +fn parse_git_describe(s: &str) -> Option { + let s = s.strip_prefix('v').unwrap_or(s); + let dirty = s.ends_with("-dirty"); + let s = s.strip_suffix("-dirty").unwrap_or(s); + + // --long format: TAG-N-gSHA. Split from the right so tags with hyphens work. + let gpos = s.rfind("-g")?; + let sha = &s[gpos + 2..]; + let rest = &s[..gpos]; + let npos = rest.rfind('-')?; + let n: u32 = rest[npos + 1..].parse().ok()?; + let tag = &rest[..npos]; + + if tag.is_empty() { + return Some(format!("0.0.0+{}", sha)); + } + + Some(match (n, dirty) { + (0, false) => tag.to_string(), + (0, true) => format!("{}+{}-dirty", tag, sha), + (_, false) => format!("{}+{}", tag, sha), + (_, true) => format!("{}+{}-dirty", tag, sha), + }) } -- 2.34.1 From b69cc89d385f80ae86d5dabcb1fd9fd5cb554520 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 15:12:00 +0300 Subject: [PATCH 136/204] fix(dashboard): skip allowlist re-render while input has focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The polling refresh replaced the entire allowlist panel innerHTML every 2 seconds, destroying the input field mid-typing. Users had to paste-and-enter faster than the refresh interval — #106 reported this as text "timing out and erasing." Guard: skip renderAllowlist() when allowDomainInput has focus. --- site/dashboard.html | 1 + 1 file changed, 1 insertion(+) diff --git a/site/dashboard.html b/site/dashboard.html index 85b6984..d3b1820 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -1354,6 +1354,7 @@ function renderBlockingInfo(info) { } function renderAllowlist(entries) { + if (document.activeElement && document.activeElement.id === 'allowDomainInput') return; const el = document.getElementById('blockingAllowlist'); const count = entries.length; el.innerHTML = ` -- 2.34.1 From d3eab73a31b2065b713bcb47dfb3d08a9fbcb451 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 16:13:15 +0300 Subject: [PATCH 137/204] fix: use sort_by_key to satisfy clippy unnecessary_sort_by --- src/system_dns.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index 826101d..ca587b8 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -211,7 +211,7 @@ fn discover_macos() -> SystemDnsInfo { } // Sort longest suffix first for most-specific matching - rules.sort_by(|a, b| b.suffix.len().cmp(&a.suffix.len())); + rules.sort_by_key(|r| std::cmp::Reverse(r.suffix.len())); for rule in &rules { info!( -- 2.34.1 From 65e65028a063521b05801b6c9aec34cdd3b325b8 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 16:59:54 +0300 Subject: [PATCH 138/204] fix(windows): separate service lifecycle from install flow service start/stop/restart/status now map to proper SCM operations instead of re-running the full install/uninstall flow. On re-install, stop the running service first so the binary can be overwritten. --- src/main.rs | 9 ++-- src/system_dns.rs | 113 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index 88f2128..b8893b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,7 @@ -use numa::system_dns::{install_service, restart_service, service_status, uninstall_service}; +use numa::system_dns::{ + install_service, restart_service, service_status, start_service, stop_service, + uninstall_service, +}; fn main() -> numa::Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) @@ -28,8 +31,8 @@ fn main() -> numa::Result<()> { let sub = std::env::args().nth(2).unwrap_or_default(); eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — service management\n"); return match sub.as_str() { - "start" => install_service().map_err(|e| e.into()), - "stop" => uninstall_service().map_err(|e| e.into()), + "start" => start_service().map_err(|e| e.into()), + "stop" => stop_service().map_err(|e| e.into()), "restart" => restart_service().map_err(|e| e.into()), "status" => service_status().map_err(|e| e.into()), _ => { diff --git a/src/system_dns.rs b/src/system_dns.rs index ca587b8..c4279cd 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -698,6 +698,13 @@ fn install_windows() -> Result<(), String> { let needs_reboot = disable_dnscache()?; + // On re-install, stop the running service first so the binary can be + // overwritten (SCM holds a handle to the exe while it's running). + let reinstall = is_service_registered(); + if reinstall { + stop_service_scm(); + } + // Copy the binary to a stable path under ProgramData and register it // as a real Windows service (SCM-managed, boot-time, auto-restart). let service_exe = install_service_binary()?; @@ -864,6 +871,41 @@ fn delete_service_scm() { } } +/// Check whether the service is registered with SCM (regardless of state). +#[cfg(windows)] +fn is_service_registered() -> bool { + run_sc(&["query", crate::windows_service::SERVICE_NAME]) + .map(|o| { + // sc query exits 0 if the service exists (running or stopped). + // Error 1060 = "service does not exist". + if o.status.success() { + return true; + } + let text = String::from_utf8_lossy(&o.stdout); + !text.contains("1060") + }) + .unwrap_or(false) +} + +/// Print service state from SCM. +#[cfg(windows)] +fn service_status_windows() -> Result<(), String> { + let out = run_sc(&["query", crate::windows_service::SERVICE_NAME])?; + let text = String::from_utf8_lossy(&out.stdout); + if text.contains("1060") { + eprintln!(" Service is not installed.\n"); + return Ok(()); + } + // Parse STATE line, e.g. "STATE : 4 RUNNING" + let state = text + .lines() + .find(|l| l.contains("STATE")) + .map(|l| l.trim().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + eprintln!(" {}\n", state); + Ok(()) +} + #[cfg(windows)] fn uninstall_windows() -> Result<(), String> { // Stop + remove the service before touching DNS, so port 53 is released @@ -1167,6 +1209,62 @@ pub fn install_service() -> Result<(), String> { result } +/// Start the service. If already installed, just starts it via the platform +/// service manager. If not installed, falls through to a full install. +pub fn start_service() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + install_service() + } + #[cfg(target_os = "linux")] + { + install_service() + } + #[cfg(windows)] + { + if is_service_registered() { + start_service_scm()?; + eprintln!(" Service started.\n"); + Ok(()) + } else { + install_service() + } + } + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] + { + Err("service start not supported on this OS".to_string()) + } +} + +/// Stop the service without uninstalling it. +pub fn stop_service() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + uninstall_service() + } + #[cfg(target_os = "linux")] + { + uninstall_service() + } + #[cfg(windows)] + { + let out = run_sc(&["stop", crate::windows_service::SERVICE_NAME])?; + if !out.status.success() { + let text = String::from_utf8_lossy(&out.stdout); + // 1062 = not started, 1060 = does not exist + if !text.contains("1062") && !text.contains("1060") { + return Err(format!("sc stop failed: {}", text.trim())); + } + } + eprintln!(" Service stopped.\n"); + Ok(()) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] + { + Err("service stop not supported on this OS".to_string()) + } +} + /// Uninstall the Numa system service. pub fn uninstall_service() -> Result<(), String> { let _ = untrust_ca(); @@ -1236,7 +1334,14 @@ pub fn restart_service() -> Result<(), String> { eprintln!(" Service restarted → {}\n", version); Ok(()) } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(windows)] + { + stop_service_scm(); + start_service_scm()?; + eprintln!(" Service restarted.\n"); + Ok(()) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] { Err("service restart not supported on this OS".to_string()) } @@ -1252,7 +1357,11 @@ pub fn service_status() -> Result<(), String> { { service_status_linux() } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(windows)] + { + service_status_windows() + } + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] { Err("service status not supported on this OS".to_string()) } -- 2.34.1 From da40a8dbfccd06e4ff49aab1ee5656659b511aec Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 18:08:48 +0300 Subject: [PATCH 139/204] ci: fetch full history on Windows so build.rs embeds git SHA --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ad7e45..33e25a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,8 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: build -- 2.34.1 From 6789c321bc6938c7c5b0254720a5e548498e1243 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 18:35:09 +0300 Subject: [PATCH 140/204] fix(windows): defer DNS redirect until port 53 is free Probe port 53 after disabling Dnscache instead of assuming reboot is needed. Skip DNS redirect when port is blocked (service does it on first boot). Fix readiness probe: TCP connect to API port instead of broken UDP send_to that always succeeded. --- src/system_dns.rs | 89 +++++++++++++++++++++++++++--------------- src/windows_service.rs | 17 ++++++++ 2 files changed, 75 insertions(+), 31 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index c4279cd..35490ae 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -572,7 +572,7 @@ fn windows_backup_path() -> std::path::PathBuf { #[cfg(windows)] fn disable_dnscache() -> Result { - // Check if Dnscache is running (it holds port 53 at kernel level) + // Check if Dnscache is running (it can hold port 53) let output = std::process::Command::new("sc") .args(["query", "Dnscache"]) .output() @@ -603,8 +603,16 @@ fn disable_dnscache() -> Result { return Err("failed to disable Dnscache via registry (run as Administrator?)".into()); } - eprintln!(" Dnscache disabled. A reboot is required to free port 53."); - Ok(true) + // Dnscache is disabled for next boot. Check whether port 53 is + // actually blocked right now — on many Windows configurations + // Dnscache doesn't bind port 53 even while running. + let port_blocked = std::net::UdpSocket::bind("127.0.0.1:53").is_err(); + if port_blocked { + eprintln!(" Dnscache disabled. A reboot is required to free port 53."); + } else { + eprintln!(" Dnscache disabled. Port 53 is free."); + } + Ok(port_blocked) } #[cfg(windows)] @@ -671,31 +679,6 @@ fn install_windows() -> Result<(), String> { std::fs::write(&path, json).map_err(|e| format!("failed to write backup: {}", e))?; } - for name in interfaces.keys() { - let status = std::process::Command::new("netsh") - .args([ - "interface", - "ipv4", - "set", - "dnsservers", - name, - "static", - "127.0.0.1", - "primary", - ]) - .status() - .map_err(|e| format!("failed to set DNS for {}: {}", name, e))?; - - if status.success() { - eprintln!(" set DNS for \"{}\" -> 127.0.0.1", name); - } else { - eprintln!( - " warning: failed to set DNS for \"{}\" (run as Administrator?)", - name - ); - } - } - let needs_reboot = disable_dnscache()?; // On re-install, stop the running service first so the binary can be @@ -710,9 +693,14 @@ fn install_windows() -> Result<(), String> { let service_exe = install_service_binary()?; register_service_scm(&service_exe)?; - // If no reboot is pending (Dnscache wasn't running, port 53 free), - // start the service immediately. Otherwise it'll launch on next boot. - if !needs_reboot { + if needs_reboot { + // Dnscache still holds port 53 until reboot. Do NOT redirect DNS + // yet — nothing is listening on 127.0.0.1:53, so redirecting now + // would kill DNS. The service will call redirect_dns_to_localhost() + // on its first startup after reboot. + } else { + redirect_dns_with_interfaces(&interfaces)?; + match start_service_scm() { Ok(_) => eprintln!(" Service started."), Err(e) => eprintln!( @@ -756,6 +744,45 @@ fn run_sc(args: &[&str]) -> Result { Ok(out) } +/// Point all active network interfaces at 127.0.0.1 so Numa handles DNS. +/// Called from the service on first boot after a reboot that freed Dnscache. +#[cfg(windows)] +pub fn redirect_dns_to_localhost() -> Result<(), String> { + let interfaces = get_windows_interfaces()?; + redirect_dns_with_interfaces(&interfaces) +} + +#[cfg(windows)] +fn redirect_dns_with_interfaces( + interfaces: &std::collections::HashMap, +) -> Result<(), String> { + for name in interfaces.keys() { + let status = std::process::Command::new("netsh") + .args([ + "interface", + "ipv4", + "set", + "dnsservers", + name, + "static", + "127.0.0.1", + "primary", + ]) + .status() + .map_err(|e| format!("failed to set DNS for {}: {}", name, e))?; + + if status.success() { + eprintln!(" set DNS for \"{}\" -> 127.0.0.1", name); + } else { + eprintln!( + " warning: failed to set DNS for \"{}\" (run as Administrator?)", + name + ); + } + } + Ok(()) +} + /// Copy the currently-running binary to the service install location. SCM /// keeps a handle to this path, so it must be stable across user sessions. #[cfg(windows)] diff --git a/src/windows_service.rs b/src/windows_service.rs index a1403d7..a363359 100644 --- a/src/windows_service.rs +++ b/src/windows_service.rs @@ -83,6 +83,23 @@ fn run_service() -> windows_service::Result<()> { let _ = server_done_tx.send(()); }); + // Wait for the API to be ready, then ensure DNS points at localhost. + // On first boot after install (Dnscache was disabled, reboot freed + // port 53), the installer deferred the DNS redirect — do it now. + let api_up = (0..20).any(|i| { + if i > 0 { + std::thread::sleep(Duration::from_millis(500)); + } + std::net::TcpStream::connect(("127.0.0.1", crate::config::DEFAULT_API_PORT)).is_ok() + }); + if api_up { + if let Err(e) = crate::system_dns::redirect_dns_to_localhost() { + log::warn!("could not redirect DNS to localhost: {}", e); + } + } else { + log::error!("numa API did not start within 10s — DNS not redirected"); + } + // Wait for either SCM stop or server termination. loop { if shutdown_rx.recv_timeout(Duration::from_millis(500)).is_ok() { -- 2.34.1 From f0a1dd7106b632957e9a5cfef5e16910ce599362 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 19:01:34 +0300 Subject: [PATCH 141/204] fix(dashboard): hide logs path on Windows (no log sink yet) --- site/dashboard.html | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index d3b1820..0e26752 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -1150,10 +1150,16 @@ async function refresh() { document.getElementById('footerSrtt').textContent = stats.srtt ? 'on' : 'off'; document.getElementById('footerSrtt').style.color = stats.srtt ? 'var(--emerald)' : 'var(--text-dim)'; if (!document.getElementById('footerLogs').textContent) { + const isWin = stats.data_dir && stats.data_dir.includes(':\\'); const isMac = stats.data_dir && stats.data_dir.includes('/usr/local/'); - document.getElementById('footerLogs').textContent = isMac - ? '/usr/local/var/log/numa.log' - : 'journalctl -u numa -f'; + const logsEl = document.getElementById('footerLogs'); + if (isWin) { + document.getElementById('footerLogsWrap').style.display = 'none'; + } else { + logsEl.textContent = isMac + ? '/usr/local/var/log/numa.log' + : 'journalctl -u numa -f'; + } } // LAN status indicator @@ -1517,7 +1523,7 @@ setInterval(refresh, 2000);
      Config: · Data: - · Logs: + · Logs:
      Upstream: · DNSSEC: -- 2.34.1 From 9bea038cb607c044b185979ddb9260d3a72bd9f0 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 19:12:42 +0300 Subject: [PATCH 142/204] fix(windows): unify config/data dir and add service log file config_dir() on Windows now returns data_dir() (ProgramData) so config, services.json, and log file are in the same place for both interactive and service contexts. Service mode writes logs to numa.log via env_logger pipe. Dashboard shows correct log path per OS. --- site/dashboard.html | 13 +++++-------- src/lib.rs | 7 ++----- src/main.rs | 31 +++++++++++++++++++++---------- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index 0e26752..fa2d965 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -1153,13 +1153,10 @@ async function refresh() { const isWin = stats.data_dir && stats.data_dir.includes(':\\'); const isMac = stats.data_dir && stats.data_dir.includes('/usr/local/'); const logsEl = document.getElementById('footerLogs'); - if (isWin) { - document.getElementById('footerLogsWrap').style.display = 'none'; - } else { - logsEl.textContent = isMac - ? '/usr/local/var/log/numa.log' - : 'journalctl -u numa -f'; - } + logsEl.textContent = isWin + ? stats.data_dir + '\\numa.log' + : isMac ? '/usr/local/var/log/numa.log' + : 'journalctl -u numa -f'; } // LAN status indicator @@ -1523,7 +1520,7 @@ setInterval(refresh, 2000);
      Config: · Data: - · Logs: + · Logs:
      Upstream: · DNSSEC: diff --git a/src/lib.rs b/src/lib.rs index 8bb28d6..a16568b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -101,14 +101,11 @@ where /// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa /// if a pre-v0.10.1 install already lives there. /// macOS root daemon: /usr/local/var/numa (Homebrew prefix) -/// Windows: %APPDATA%\numa +/// Windows: %PROGRAMDATA%\numa (same as data_dir — no per-user config on Windows) pub fn config_dir() -> std::path::PathBuf { #[cfg(windows)] { - std::path::PathBuf::from( - std::env::var("APPDATA").unwrap_or_else(|_| "C:\\ProgramData".into()), - ) - .join("numa") + data_dir() } #[cfg(not(windows))] { diff --git a/src/main.rs b/src/main.rs index b8893b3..34bf747 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,21 +4,32 @@ use numa::system_dns::{ }; fn main() -> numa::Result<()> { + // Handle CLI subcommands + let arg1 = std::env::args().nth(1).unwrap_or_default(); + + #[cfg(windows)] + if arg1 == "--service" { + // Running under SCM — stderr goes nowhere. Redirect logs to a file. + let log_path = numa::data_dir().join("numa.log"); + let log_file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .expect("failed to open log file"); + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .format_timestamp_millis() + .target(env_logger::Target::Pipe(Box::new(log_file))) + .init(); + numa::windows_service::run_as_service() + .map_err(|e| format!("windows service dispatcher failed: {}", e))?; + return Ok(()); + } + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) .format_timestamp_millis() .init(); - // Handle CLI subcommands - let arg1 = std::env::args().nth(1).unwrap_or_default(); match arg1.as_str() { - #[cfg(windows)] - "--service" => { - // Entry point used by Windows SCM (`sc create … binPath="numa.exe --service"`). - // Blocks until SCM sends Stop; never returns normally. - numa::windows_service::run_as_service() - .map_err(|e| format!("windows service dispatcher failed: {}", e))?; - return Ok(()); - } "install" => { eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — installing\n"); return install_service().map_err(|e| e.into()); -- 2.34.1 From 9f08d8b4896bc2b0a2f72ca8bb18dd393ad9ef93 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 19:21:56 +0300 Subject: [PATCH 143/204] fix(windows): stop service before port probe, wait for full exit Stop the running service before disabling Dnscache so the port 53 probe sees the real state (not Numa's own binding). Wait for SCM STOPPED state before copying the binary to avoid os error 32 (file in use). --- src/system_dns.rs | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index 35490ae..7e2d16a 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -679,15 +679,15 @@ fn install_windows() -> Result<(), String> { std::fs::write(&path, json).map_err(|e| format!("failed to write backup: {}", e))?; } - let needs_reboot = disable_dnscache()?; - // On re-install, stop the running service first so the binary can be - // overwritten (SCM holds a handle to the exe while it's running). - let reinstall = is_service_registered(); - if reinstall { + // overwritten and port 53 is released for the Dnscache probe. + if is_service_registered() { + eprintln!(" Stopping existing service..."); stop_service_scm(); } + let needs_reboot = disable_dnscache()?; + // Copy the binary to a stable path under ProgramData and register it // as a real Windows service (SCM-managed, boot-time, auto-restart). let service_exe = install_service_binary()?; @@ -880,14 +880,24 @@ fn start_service_scm() -> Result<(), String> { Ok(()) } -/// Stop the service. Idempotent — already-stopped or missing service logs -/// a warning but doesn't error, since both callers (install re-run, -/// uninstall) want best-effort cleanup rather than hard failure. +/// Stop the service and wait for it to fully exit. Idempotent — +/// already-stopped or missing service is not an error. #[cfg(windows)] fn stop_service_scm() { - if let Err(e) = run_sc(&["stop", crate::windows_service::SERVICE_NAME]) { - log::warn!("sc stop failed: {}", e); + let name = crate::windows_service::SERVICE_NAME; + let _ = run_sc(&["stop", name]); + // Wait up to 10s for the service to reach STOPPED state so the + // binary file handle is released before we try to overwrite it. + for _ in 0..20 { + if let Ok(out) = run_sc(&["query", name]) { + let text = String::from_utf8_lossy(&out.stdout); + if text.contains("STOPPED") || text.contains("1060") { + return; + } + } + std::thread::sleep(std::time::Duration::from_millis(500)); } + eprintln!(" warning: service did not stop within 10s"); } /// Remove the service from SCM. Idempotent — see `stop_service_scm`. -- 2.34.1 From fe9f31616e574b9c3c4ae97b0b646de6e65705ce Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 19:31:26 +0300 Subject: [PATCH 144/204] test: add SCM output parsing and config path regression tests Extract parse_sc_registered and parse_sc_state as testable pure functions. 8 new tests covering: service registration detection, service state parsing, and Windows config_dir == data_dir invariant. --- src/system_dns.rs | 95 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 17 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index 7e2d16a..941c053 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -912,35 +912,43 @@ fn delete_service_scm() { #[cfg(windows)] fn is_service_registered() -> bool { run_sc(&["query", crate::windows_service::SERVICE_NAME]) - .map(|o| { - // sc query exits 0 if the service exists (running or stopped). - // Error 1060 = "service does not exist". - if o.status.success() { - return true; - } - let text = String::from_utf8_lossy(&o.stdout); - !text.contains("1060") - }) + .map(|o| parse_sc_registered(o.status.success(), &String::from_utf8_lossy(&o.stdout))) .unwrap_or(false) } +/// Parse `sc query` output to determine if a service is registered. +/// Extracted for testability — the actual `sc` call is in `is_service_registered`. +#[cfg(any(windows, test))] +fn parse_sc_registered(exit_success: bool, stdout: &str) -> bool { + if exit_success { + return true; + } + // Error 1060 = "The specified service does not exist as an installed service." + !stdout.contains("1060") +} + /// Print service state from SCM. #[cfg(windows)] fn service_status_windows() -> Result<(), String> { let out = run_sc(&["query", crate::windows_service::SERVICE_NAME])?; let text = String::from_utf8_lossy(&out.stdout); - if text.contains("1060") { - eprintln!(" Service is not installed.\n"); - return Ok(()); + let display = parse_sc_state(&text); + eprintln!(" {}\n", display); + Ok(()) +} + +/// Parse the STATE line from `sc query` output. Returns a human-readable +/// string like "STATE : 4 RUNNING" or "Service is not installed." +#[cfg(any(windows, test))] +fn parse_sc_state(sc_output: &str) -> String { + if sc_output.contains("1060") { + return "Service is not installed.".to_string(); } - // Parse STATE line, e.g. "STATE : 4 RUNNING" - let state = text + sc_output .lines() .find(|l| l.contains("STATE")) .map(|l| l.trim().to_string()) - .unwrap_or_else(|| "unknown".to_string()); - eprintln!(" {}\n", state); - Ok(()) + .unwrap_or_else(|| "unknown".to_string()) } #[cfg(windows)] @@ -2132,4 +2140,57 @@ Wireless LAN adapter Wi-Fi: let err = std::io::Error::from(std::io::ErrorKind::AddrInUse); assert!(try_port53_advisory("not-an-address", &err).is_none()); } + + #[test] + fn sc_query_running_service_is_registered() { + assert!(parse_sc_registered(true, "")); + } + + #[test] + fn sc_query_stopped_service_is_registered() { + let output = "SERVICE_NAME: Numa\n TYPE: 10 WIN32_OWN\n STATE: 1 STOPPED\n"; + assert!(parse_sc_registered(true, output)); + } + + #[test] + fn sc_query_missing_service_not_registered() { + let output = "[SC] EnumQueryServicesStatus:OpenService FAILED 1060:\n\nThe specified service does not exist as an installed service.\n"; + assert!(!parse_sc_registered(false, output)); + } + + #[test] + fn sc_query_other_error_assumes_registered() { + // Permission denied or other errors — don't assume unregistered. + let output = "[SC] OpenService FAILED 5:\n\nAccess is denied.\n"; + assert!(parse_sc_registered(false, output)); + } + + #[test] + fn parse_sc_state_running() { + let output = "SERVICE_NAME: Numa\n TYPE : 10 WIN32_OWN_PROCESS\n STATE : 4 RUNNING\n WIN32_EXIT_CODE : 0\n"; + assert!(parse_sc_state(output).contains("RUNNING")); + } + + #[test] + fn parse_sc_state_stopped() { + let output = "SERVICE_NAME: Numa\n TYPE : 10 WIN32_OWN_PROCESS\n STATE : 1 STOPPED\n"; + assert!(parse_sc_state(output).contains("STOPPED")); + } + + #[test] + fn parse_sc_state_not_installed() { + let output = "[SC] EnumQueryServicesStatus:OpenService FAILED 1060:\n\n"; + assert_eq!(parse_sc_state(output), "Service is not installed."); + } + + #[test] + fn parse_sc_state_empty_output() { + assert_eq!(parse_sc_state(""), "unknown"); + } + + #[cfg(windows)] + #[test] + fn windows_config_dir_equals_data_dir() { + assert_eq!(crate::config_dir(), crate::data_dir()); + } } -- 2.34.1 From 9e56054f37d4c2e1e03b6468effbfb695a0e3c8a Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 19:56:44 +0300 Subject: [PATCH 145/204] ci: add integration tests for install/uninstall lifecycle Release-build + install/verify/re-install/uninstall cycle on Linux and macOS. Runs after lint/test passes (needs dependency). Cleanup step uses if: always() to handle cancellation. --- .github/workflows/ci.yml | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33e25a4..4b4972e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,3 +71,53 @@ jobs: with: name: numa-windows-x86_64 path: target/debug/numa.exe + + integration-linux: + needs: [check] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: build + run: cargo build --release + - name: install / verify / re-install / uninstall + run: | + sudo ./target/release/numa install + sleep 2 + curl -sf http://127.0.0.1:5380/health + dig @127.0.0.1 example.com +short +timeout=5 | grep -q '.' + sudo ./target/release/numa install + sleep 2 + curl -sf http://127.0.0.1:5380/health + sudo ./target/release/numa uninstall + sleep 1 + ! curl -sf http://127.0.0.1:5380/health 2>/dev/null + - name: cleanup + if: always() + run: sudo ./target/release/numa uninstall 2>/dev/null || true + + integration-macos: + needs: [check-macos] + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: build + run: cargo build --release + - name: install / verify / re-install / uninstall + run: | + sudo ./target/release/numa install + sleep 2 + curl -sf http://127.0.0.1:5380/health + dig @127.0.0.1 example.com +short +timeout=5 | grep -q '.' + sudo ./target/release/numa install + sleep 2 + curl -sf http://127.0.0.1:5380/health + sudo ./target/release/numa uninstall + sleep 1 + ! curl -sf http://127.0.0.1:5380/health 2>/dev/null + - name: cleanup + if: always() + run: sudo ./target/release/numa uninstall 2>/dev/null || true -- 2.34.1 From 99af97a67bc32ff478c7e44a9c99b825d6b374a5 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 20:20:53 +0300 Subject: [PATCH 146/204] ci: wait for DNS recovery after uninstall on Linux systemd-resolved needs a moment to restore its stub listener after the numa drop-in is removed. Without a wait, the runner can't resolve GitHub's API to report job completion. --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b4972e..502279d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,10 @@ jobs: sudo ./target/release/numa uninstall sleep 1 ! curl -sf http://127.0.0.1:5380/health 2>/dev/null + # Wait for systemd-resolved to restore DNS so the runner can + # phone home to GitHub after the job completes. + sleep 3 + dig @127.0.0.1 github.com +short +timeout=5 || dig github.com +short +timeout=5 || true - name: cleanup if: always() run: sudo ./target/release/numa uninstall 2>/dev/null || true -- 2.34.1 From 34b75833b8da63e39b9df83efc069f5008b6e41d Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 17 Apr 2026 01:11:20 +0300 Subject: [PATCH 147/204] ci: poll for DNS recovery in cleanup, not test step Move DNS recovery wait into the cleanup step (if: always) so it runs regardless of test outcome. Use getent hosts loop instead of sleep+dig to match what post-steps actually use for resolution. --- .github/workflows/ci.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 502279d..f29c51a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,13 +93,16 @@ jobs: sudo ./target/release/numa uninstall sleep 1 ! curl -sf http://127.0.0.1:5380/health 2>/dev/null - # Wait for systemd-resolved to restore DNS so the runner can - # phone home to GitHub after the job completes. - sleep 3 - dig @127.0.0.1 github.com +short +timeout=5 || dig github.com +short +timeout=5 || true - name: cleanup if: always() - run: sudo ./target/release/numa uninstall 2>/dev/null || true + run: | + sudo ./target/release/numa uninstall 2>/dev/null || true + # Wait for systemd-resolved to fully restore DNS so post-job + # steps (rust-cache upload, log shipping) can reach GitHub. + for i in $(seq 1 30); do + if getent hosts github.com >/dev/null 2>&1; then break; fi + sleep 1 + done integration-macos: needs: [check-macos] -- 2.34.1 From 1d9495c013a9ee6b2fd1a79e3aad6c71369d95c7 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 17 Apr 2026 01:32:36 +0300 Subject: [PATCH 148/204] ci: bridge DNS gap with direct upstream instead of polling systemd-resolved has a ~40s reconfiguration stall after restart (systemd #22521) that breaks the GHA runner's persistent connection to results-receiver.actions.githubusercontent.com. Polling for DNS recovery isn't enough since the .NET runner agent caches DNS at the connection-pool level. Replace the broken stub-resolv symlink with a direct upstream so DNS works instantly. --- .github/workflows/ci.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f29c51a..e116744 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,12 +97,14 @@ jobs: if: always() run: | sudo ./target/release/numa uninstall 2>/dev/null || true - # Wait for systemd-resolved to fully restore DNS so post-job - # steps (rust-cache upload, log shipping) can reach GitHub. - for i in $(seq 1 30); do - if getent hosts github.com >/dev/null 2>&1; then break; fi - sleep 1 - done + # systemd-resolved has a ~40s DNS reconfiguration stall after + # restart (systemd issue #22521) that breaks the runner agent's + # connection to GitHub. Bridge it by replacing the stub-resolv + # symlink with a direct upstream — DNS works instantly and the + # runner can phone home for post-job steps. + sudo rm -f /etc/resolv.conf + echo "nameserver 8.8.8.8" | sudo tee /etc/resolv.conf > /dev/null + getent hosts github.com >/dev/null integration-macos: needs: [check-macos] -- 2.34.1 From 5f77af55e9595110b4d3da39084b5d5578af77d8 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 17 Apr 2026 03:39:21 +0300 Subject: [PATCH 149/204] fix(forward): track SRTT for DoT upstreams, not just UDP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SRTT ordering + failure penalty path was UDP-only, so a DoT primary in a forwarding-rule pool was never deprioritized on failure and all DoT entries tied at INITIAL_SRTT_MS in the sort key. With [[forwarding]] now accepting arrays of upstreams, DoT pools are a first-class case and need the same healthiest-first behavior the default pool gets for UDP. - Add Upstream::tracked_ip() → Some(ip) for Udp/Dot, None for Doh (DoH has no stable IP — reqwest pools connections by hostname). - Rewire the three SRTT call sites in forward_with_failover_raw. - Hoist srtt.read() out of the candidate-scoring loop — one lock per query instead of N (matters now that pools commonly have N>1). - Drop unused #[derive(Debug)] on UpstreamPool and ForwardingRule. - Regression tests: udp_failure_records_in_srtt + dot_failure_records_in_srtt. --- src/forward.rs | 103 ++++++++++++++++++++++++++++++++++++++-------- src/system_dns.rs | 2 +- 2 files changed, 87 insertions(+), 18 deletions(-) diff --git a/src/forward.rs b/src/forward.rs index 8bb548e..9bfa426 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -25,6 +25,18 @@ pub enum Upstream { }, } +impl Upstream { + /// IP address to key SRTT tracking on, if the upstream has a stable one. + /// `Doh` routes through a URL + connection pool, so there's no single IP + /// to track; SRTT is skipped for it. + pub fn tracked_ip(&self) -> Option { + match self { + Upstream::Udp(addr) | Upstream::Dot { addr, .. } => Some(addr.ip()), + Upstream::Doh { .. } => None, + } + } +} + impl PartialEq for Upstream { fn eq(&self, other: &Self) -> bool { match (self, other) { @@ -118,7 +130,7 @@ fn build_dot_connector() -> Result { ))) } -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct UpstreamPool { primary: Vec, fallback: Vec, @@ -345,18 +357,17 @@ pub async fn forward_with_failover_raw( timeout_duration: Duration, hedge_delay: Duration, ) -> Result> { - let mut candidates: Vec<(usize, u64)> = pool - .primary - .iter() - .enumerate() - .map(|(i, u)| { - let rtt = match u { - Upstream::Udp(addr) => srtt.read().unwrap().get(addr.ip()), - _ => 0, - }; - (i, rtt) - }) - .collect(); + let mut candidates: Vec<(usize, u64)> = { + let srtt_read = srtt.read().unwrap(); + pool.primary + .iter() + .enumerate() + .map(|(i, u)| { + let rtt = u.tracked_ip().map(|ip| srtt_read.get(ip)).unwrap_or(0); + (i, rtt) + }) + .collect() + }; candidates.sort_by_key(|&(_, rtt)| rtt); let all_upstreams: Vec<&Upstream> = candidates @@ -380,15 +391,15 @@ pub async fn forward_with_failover_raw( }; match result { Ok(resp) => { - if let Upstream::Udp(addr) = upstream { + if let Some(ip) = upstream.tracked_ip() { let rtt_ms = start.elapsed().as_millis() as u64; - srtt.write().unwrap().record_rtt(addr.ip(), rtt_ms, false); + srtt.write().unwrap().record_rtt(ip, rtt_ms, false); } return Ok(resp); } Err(e) => { - if let Upstream::Udp(addr) = upstream { - srtt.write().unwrap().record_failure(addr.ip()); + if let Some(ip) = upstream.tracked_ip() { + srtt.write().unwrap().record_failure(ip); } log::debug!("upstream {} failed: {}", upstream, e); last_err = Some(e); @@ -707,4 +718,62 @@ mod tests { assert!(!pool.maybe_update_primary("not-an-ip", 53)); assert_eq!(pool.preferred().unwrap().to_string(), "1.2.3.4:53"); } + + fn tcp_closed_port() -> SocketAddr { + // Bind a TCP listener, grab the port, drop → kernel returns RST on connect. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + addr + } + + #[tokio::test] + async fn udp_failure_records_in_srtt() { + let blackhole = crate::testutil::blackhole_upstream(); + let pool = UpstreamPool::new(vec![Upstream::Udp(blackhole)], vec![]); + let srtt = RwLock::new(SrttCache::new(true)); + let _ = forward_with_failover_raw( + &[0u8; 12], + &pool, + &srtt, + Duration::from_millis(100), + Duration::ZERO, + ) + .await; + assert!(srtt.read().unwrap().is_known(blackhole.ip())); + } + + #[tokio::test] + async fn dot_failure_records_in_srtt() { + let dead1 = tcp_closed_port(); + let dead2 = tcp_closed_port(); + let connector = build_dot_connector().unwrap(); + let pool = UpstreamPool::new( + vec![ + Upstream::Dot { + addr: dead1, + tls_name: Some("dns.quad9.net".to_string()), + connector: connector.clone(), + }, + Upstream::Dot { + addr: dead2, + tls_name: Some("dns.quad9.net".to_string()), + connector, + }, + ], + vec![], + ); + let srtt = RwLock::new(SrttCache::new(true)); + let _ = forward_with_failover_raw( + &[0u8; 12], + &pool, + &srtt, + Duration::from_millis(500), + Duration::ZERO, + ) + .await; + let cache = srtt.read().unwrap(); + assert!(cache.is_known(dead1.ip())); + assert!(cache.is_known(dead2.ip())); + } } diff --git a/src/system_dns.rs b/src/system_dns.rs index 7f6304b..b70b9d9 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -22,7 +22,7 @@ fn is_loopback_or_stub(addr: &str) -> bool { } /// A conditional forwarding rule: domains matching `suffix` are forwarded to `upstream`. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct ForwardingRule { pub suffix: String, dot_suffix: String, // pre-computed ".suffix" for zero-alloc matching -- 2.34.1 From 695a8b963c045fb5f7147b24a8610e3e5cac694f Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 07:56:59 +0300 Subject: [PATCH 150/204] feat(linux): run systemd service as unprivileged numa user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - numa.service: User=numa + CAP_NET_BIND_SERVICE + sandboxing block (ProtectSystem=strict, PrivateTmp, seccomp @system-service, etc) - install_service_linux: create numa system user + chown data_dir before first start so TLS-cert generation and state writes land on a numa-owned tree Runtime verified root-free on Linux — network_watch_loop only reads /etc/resolv.conf; all system-DNS mutation stays in the installer, which continues to run as root via sudo. --- numa.service | 34 +++++++++++++++++++++++++++ src/system_dns.rs | 60 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/numa.service b/numa.service index 7e67296..6894078 100644 --- a/numa.service +++ b/numa.service @@ -8,6 +8,40 @@ Type=simple ExecStart={{exe_path}} Restart=always RestartSec=2 + +User=numa +Group=numa + +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE + +# StateDirectory maps to crate::data_dir() default on Linux (/var/lib/numa). +# systemd auto-creates + chowns on every start, fixing legacy root-owned trees. +StateDirectory=numa +StateDirectoryMode=0750 +ConfigurationDirectory=numa +ConfigurationDirectoryMode=0755 + +# Sandboxing +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +LockPersonality=true +MemoryDenyWriteExecute=true +RestrictNamespaces=true +RestrictRealtime=true +RestrictSUIDSGID=true +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources +# AF_NETLINK for interface enumeration on network changes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK + StandardOutput=journal StandardError=journal SyslogIdentifier=numa diff --git a/src/system_dns.rs b/src/system_dns.rs index b70b9d9..7b4de42 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -1664,8 +1664,68 @@ fn uninstall_linux() -> Result<(), String> { Ok(()) } +#[cfg(target_os = "linux")] +const NUMA_USER: &str = "numa"; + +#[cfg(target_os = "linux")] +fn ensure_numa_user_linux() -> Result<(), String> { + let _ = std::process::Command::new("groupadd") + .args(["-f", "-r", NUMA_USER]) + .status(); + + let data_dir = crate::data_dir(); + let status = std::process::Command::new("useradd") + .args([ + "-r", + "-g", + NUMA_USER, + "-d", + &data_dir.to_string_lossy(), + "-s", + "/usr/sbin/nologin", + "-c", + "Numa DNS service", + NUMA_USER, + ]) + .status() + .map_err(|e| format!("failed to run useradd: {}", e))?; + + // useradd exit 9 = "username already in use"; idempotent reinstall. + match status.code() { + Some(0) | Some(9) => Ok(()), + Some(code) => Err(format!("useradd {} failed (exit {})", NUMA_USER, code)), + None => Err(format!("useradd {} killed by signal", NUMA_USER)), + } +} + +#[cfg(target_os = "linux")] +fn chown_data_dir_to_numa_linux() -> Result<(), String> { + let dir = crate::data_dir(); + std::fs::create_dir_all(&dir) + .map_err(|e| format!("failed to create {}: {}", dir.display(), e))?; + let owner = format!("{0}:{0}", NUMA_USER); + let status = std::process::Command::new("chown") + .args(["-R", &owner, &dir.to_string_lossy()]) + .status() + .map_err(|e| format!("failed to run chown: {}", e))?; + if !status.success() { + return Err(format!( + "chown {} failed (exit {})", + dir.display(), + status.code().unwrap_or(-1) + )); + } + Ok(()) +} + #[cfg(target_os = "linux")] fn install_service_linux() -> Result<(), String> { + // Create the numa account and hand it ownership of data_dir before the + // first start — TLS-cert generation and state writes happen on the + // unit's first launch and need to land on a numa-owned tree. + ensure_numa_user_linux()?; + chown_data_dir_to_numa_linux()?; + let unit = include_str!("../numa.service"); let unit = replace_exe_path(unit)?; std::fs::write(SYSTEMD_UNIT, unit) -- 2.34.1 From 41aea1dd12b85382b40e4e345ace504153ad0948 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 08:10:04 +0300 Subject: [PATCH 151/204] fix(linux): drop risky sandbox directives that break Rust network daemons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration test failed with exit 7 on curl to /health after a successful install — service started but never listened. The likely culprits are MemoryDenyWriteExecute (breaks jemalloc/some crypto), SystemCallFilter ~@privileged @resources (blocks setrlimit and friends tokio may use), and RestrictNamespaces/LockPersonality (occasional foot-guns). Pull them and keep a conservative hardening set that's well-tested with Rust network services: no-new-privs, protect-system/home, private tmp and devices, protect-kernel-*, restrict-realtime/suid/address-families. Layer the aggressive bits back in follow-up PRs once tested individually. --- numa.service | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/numa.service b/numa.service index 6894078..44e90c5 100644 --- a/numa.service +++ b/numa.service @@ -22,7 +22,9 @@ StateDirectoryMode=0750 ConfigurationDirectory=numa ConfigurationDirectoryMode=0755 -# Sandboxing +# Sandboxing — conservative set known to work with Rust network daemons. +# Aggressive hardening (MemoryDenyWriteExecute, SystemCallFilter, seccomp +# allow-lists) can be layered on once tested in isolation. NoNewPrivileges=true ProtectSystem=strict ProtectHome=true @@ -31,14 +33,8 @@ PrivateDevices=true ProtectKernelTunables=true ProtectKernelModules=true ProtectControlGroups=true -LockPersonality=true -MemoryDenyWriteExecute=true -RestrictNamespaces=true RestrictRealtime=true RestrictSUIDSGID=true -SystemCallArchitectures=native -SystemCallFilter=@system-service -SystemCallFilter=~@privileged @resources # AF_NETLINK for interface enumeration on network changes RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK -- 2.34.1 From 4f6159d9616bf485af38bacaf08ed98a5afe0aa5 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 08:20:07 +0300 Subject: [PATCH 152/204] refactor(linux): switch to DynamicUser=yes, drop install-time user creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AUR installs never call `numa install` — PKGBUILD drops the unit straight into /usr/lib/systemd/system and the user runs `systemctl enable numa`. With User=numa the Rust installer's useradd code never fires there, breaking Arch out of the box. DynamicUser=yes sidesteps packaging entirely — systemd allocates a transient UID per start and remaps StateDirectory ownership (including legacy root-owned trees) automatically. Works on any modern systemd. Drops the ensure_numa_user_linux/chown helpers plus NUMA_USER; the unit file alone now captures the privilege-drop story. --- numa.service | 8 +++---- src/system_dns.rs | 60 ----------------------------------------------- 2 files changed, 4 insertions(+), 64 deletions(-) diff --git a/numa.service b/numa.service index 44e90c5..5380b83 100644 --- a/numa.service +++ b/numa.service @@ -9,14 +9,14 @@ ExecStart={{exe_path}} Restart=always RestartSec=2 -User=numa -Group=numa +# Transient system user per start; no PKGBUILD/sysusers setup required. +# systemd remaps the StateDirectory ownership to the dynamic UID on each +# launch, including legacy root-owned trees from pre-drop installs. +DynamicUser=yes AmbientCapabilities=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE -# StateDirectory maps to crate::data_dir() default on Linux (/var/lib/numa). -# systemd auto-creates + chowns on every start, fixing legacy root-owned trees. StateDirectory=numa StateDirectoryMode=0750 ConfigurationDirectory=numa diff --git a/src/system_dns.rs b/src/system_dns.rs index 7b4de42..b70b9d9 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -1664,68 +1664,8 @@ fn uninstall_linux() -> Result<(), String> { Ok(()) } -#[cfg(target_os = "linux")] -const NUMA_USER: &str = "numa"; - -#[cfg(target_os = "linux")] -fn ensure_numa_user_linux() -> Result<(), String> { - let _ = std::process::Command::new("groupadd") - .args(["-f", "-r", NUMA_USER]) - .status(); - - let data_dir = crate::data_dir(); - let status = std::process::Command::new("useradd") - .args([ - "-r", - "-g", - NUMA_USER, - "-d", - &data_dir.to_string_lossy(), - "-s", - "/usr/sbin/nologin", - "-c", - "Numa DNS service", - NUMA_USER, - ]) - .status() - .map_err(|e| format!("failed to run useradd: {}", e))?; - - // useradd exit 9 = "username already in use"; idempotent reinstall. - match status.code() { - Some(0) | Some(9) => Ok(()), - Some(code) => Err(format!("useradd {} failed (exit {})", NUMA_USER, code)), - None => Err(format!("useradd {} killed by signal", NUMA_USER)), - } -} - -#[cfg(target_os = "linux")] -fn chown_data_dir_to_numa_linux() -> Result<(), String> { - let dir = crate::data_dir(); - std::fs::create_dir_all(&dir) - .map_err(|e| format!("failed to create {}: {}", dir.display(), e))?; - let owner = format!("{0}:{0}", NUMA_USER); - let status = std::process::Command::new("chown") - .args(["-R", &owner, &dir.to_string_lossy()]) - .status() - .map_err(|e| format!("failed to run chown: {}", e))?; - if !status.success() { - return Err(format!( - "chown {} failed (exit {})", - dir.display(), - status.code().unwrap_or(-1) - )); - } - Ok(()) -} - #[cfg(target_os = "linux")] fn install_service_linux() -> Result<(), String> { - // Create the numa account and hand it ownership of data_dir before the - // first start — TLS-cert generation and state writes happen on the - // unit's first launch and need to land on a numa-owned tree. - ensure_numa_user_linux()?; - chown_data_dir_to_numa_linux()?; - let unit = include_str!("../numa.service"); let unit = replace_exe_path(unit)?; std::fs::write(SYSTEMD_UNIT, unit) -- 2.34.1 From dfeca53e21f1012da4a5cc1183dae85b54f796ad Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 08:48:53 +0300 Subject: [PATCH 153/204] ci: dump journalctl + systemctl status on integration-linux failure --- .github/workflows/ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e116744..4bce7c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,17 @@ jobs: sudo ./target/release/numa uninstall sleep 1 ! curl -sf http://127.0.0.1:5380/health 2>/dev/null + - name: diagnostics on failure + if: failure() + run: | + echo "=== systemctl status numa ===" + sudo systemctl status numa --no-pager -l || true + echo "=== journalctl -u numa (last 200) ===" + sudo journalctl -u numa --no-pager -n 200 || true + echo "=== ss -tulnp on 53/80/443/853/5380 ===" + sudo ss -tulnp 2>/dev/null | grep -E ':(53|80|443|853|5380)\b' || true + echo "=== systemctl is-active systemd-resolved ===" + systemctl is-active systemd-resolved || true - name: cleanup if: always() run: | -- 2.34.1 From 7b9db9e889915cbdcf6394bb077d51d8bbf02ba5 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 08:54:34 +0300 Subject: [PATCH 154/204] =?UTF-8?q?fix(linux):=20drop=20ProtectHome=3Dtrue?= =?UTF-8?q?=20=E2=80=94=20blocks=20exec=20when=20binary=20lives=20under=20?= =?UTF-8?q?/home?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration-linux journalctl showed status=203/EXEC: systemd couldn't exec /home/runner/work/numa/numa/target/release/numa because ProtectHome=yes makes /home invisible to the sandboxed process. My local Docker test passed because the binary was at /workspace, not /home. DynamicUser=yes already implies ProtectHome=read-only, which preserves exec access to binaries living under /home (cargo install, source builds, CI) while blocking writes to user $HOMEs. Keep that default rather than over-restricting. Follow-up worth tracking: install_service_linux could copy the binary to /usr/local/bin/numa the way Windows does at windows_service_exe_path, making the unit's ExecStart independent of where `numa install` was invoked from — then we could set ProtectHome=yes again. --- numa.service | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/numa.service b/numa.service index 5380b83..4794033 100644 --- a/numa.service +++ b/numa.service @@ -27,7 +27,10 @@ ConfigurationDirectoryMode=0755 # allow-lists) can be layered on once tested in isolation. NoNewPrivileges=true ProtectSystem=strict -ProtectHome=true +# DynamicUser= sets ProtectHome=read-only by default — leaves /home +# readable so systemd can exec binaries installed under it (cargo install, +# source builds), while blocking writes to user $HOMEs. Don't set =yes: +# that hides /home entirely and fails with status=203/EXEC. PrivateTmp=true PrivateDevices=true ProtectKernelTunables=true -- 2.34.1 From 3970a9f45c23d4c751a5ed1ff849610e51eee075 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 11:51:32 +0300 Subject: [PATCH 155/204] fix(linux): copy binary to /usr/local/bin when source path isn't world-traversable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DynamicUser=yes' transient account can only traverse world-x directories. The CI binary at /home/runner/work/numa/numa/target/release/numa fails exec with EACCES because /home/runner is mode 0700; same applies to a build under /home//, ~/.cargo/bin, or any private $HOME tree. install_service_binary_linux now walks the binary's path. If every ancestor grants world-execute (Linuxbrew /home/linuxbrew is 0755, /usr/local/bin is fine, install.sh layout works), keep the source path so brew/distro upgrades propagate in place. Otherwise copy to /usr/local/bin/numa and reference that in the unit. Locally verified both branches in an Ubuntu 24.04 systemd container: - CI-like /home/runner (0700) → copies + service binds 5380 - Brew-like /home/linuxbrew (0755) → keeps source path + service binds 5380 --- src/system_dns.rs | 59 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index b70b9d9..726cc1a 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -1664,10 +1664,65 @@ fn uninstall_linux() -> Result<(), String> { Ok(()) } +/// Fallback install location when current_exe() sits on a path the +/// dynamic user cannot traverse (e.g. `/home//` mode 0700). +#[cfg(target_os = "linux")] +fn linux_service_exe_path() -> std::path::PathBuf { + std::path::PathBuf::from("/usr/local/bin/numa") +} + +/// True iff every ancestor of `p` (excluding `/`) grants world-execute — +/// i.e. the `DynamicUser=yes` service account can traverse the path and +/// exec the binary without being in any group. Linuxbrew's +/// `/home/linuxbrew` is 0755 (traversable, keep brew's path, upgrades +/// via `brew` propagate). A build tree under `/home//` (0700) or +/// `~/.cargo/bin/` is not (copy to /usr/local/bin so systemd can reach it). +#[cfg(target_os = "linux")] +fn path_world_traversable_linux(p: &std::path::Path) -> bool { + use std::os::unix::fs::PermissionsExt; + let mut current = p; + while let Some(parent) = current.parent() { + if parent.as_os_str().is_empty() || parent == std::path::Path::new("/") { + break; + } + match std::fs::metadata(parent) { + Ok(m) if m.permissions().mode() & 0o001 != 0 => {} + _ => return false, + } + current = parent; + } + true +} + +#[cfg(target_os = "linux")] +fn install_service_binary_linux() -> Result { + let src = std::env::current_exe().map_err(|e| format!("current_exe(): {}", e))?; + if path_world_traversable_linux(&src) { + return Ok(src); + } + let dst = linux_service_exe_path(); + if src == dst { + return Ok(dst); + } + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("failed to create {}: {}", parent.display(), e))?; + } + std::fs::copy(&src, &dst).map_err(|e| { + format!( + "failed to copy {} -> {}: {}", + src.display(), + dst.display(), + e + ) + })?; + Ok(dst) +} + #[cfg(target_os = "linux")] fn install_service_linux() -> Result<(), String> { - let unit = include_str!("../numa.service"); - let unit = replace_exe_path(unit)?; + let exe = install_service_binary_linux()?; + let unit = include_str!("../numa.service").replace("{{exe_path}}", &exe.to_string_lossy()); std::fs::write(SYSTEMD_UNIT, unit) .map_err(|e| format!("failed to write {}: {}", SYSTEMD_UNIT, e))?; -- 2.34.1 From e19505aa952d9ff78d5ecd7e8edc52428401b292 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 11:57:54 +0300 Subject: [PATCH 156/204] fix(linux): narrow replace_exe_path cfg to macos after Linux inlined the substitution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux install_service_linux now does the {{exe_path}} substitution inline because it uses the (potentially copied) binary path returned by install_service_binary_linux, not current_exe(). The shared replace_exe_path helper is dead on Linux — clippy -D warnings caught it. Narrow the function to macos and split the placeholder test: keep the "both templates contain {{exe_path}}" assertion as a cross-platform test (catches placeholder removal on either file), keep the substitution test gated to macos where the function lives. --- src/system_dns.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index 726cc1a..60701e3 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -1416,7 +1416,7 @@ pub fn service_status() -> Result<(), String> { } } -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] fn replace_exe_path(service: &str) -> Result { let exe_path = std::env::current_exe().map_err(|e| format!("failed to get current exe: {}", e))?; @@ -2050,22 +2050,25 @@ Wireless LAN adapter Wi-Fi: } #[test] - #[cfg(any(target_os = "macos", target_os = "linux"))] - fn replace_exe_path_substitutes_template() { + fn install_templates_contain_exe_path_placeholder() { + // Both files are substituted at install time — plist via + // replace_exe_path on macOS, numa.service via inline .replace + // in install_service_linux. Catch placeholder removal early. let plist = include_str!("../com.numa.dns.plist"); let unit = include_str!("../numa.service"); - assert!(plist.contains("{{exe_path}}"), "plist missing placeholder"); assert!( unit.contains("{{exe_path}}"), "unit file missing placeholder" ); + } + #[test] + #[cfg(target_os = "macos")] + fn replace_exe_path_substitutes_template() { + let plist = include_str!("../com.numa.dns.plist"); let result = replace_exe_path(plist).expect("replace_exe_path failed for plist"); assert!(!result.contains("{{exe_path}}")); - - let result = replace_exe_path(unit).expect("replace_exe_path failed for unit"); - assert!(!result.contains("{{exe_path}}")); } #[test] -- 2.34.1 From 067195f2abd9444c34e1e85bed9104d03f0a0d42 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 12:12:11 +0300 Subject: [PATCH 157/204] fix(linux): atomic binary copy + restart instead of start on re-install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-install failed with ETXTBSY (Text file busy) because std::fs::copy can't overwrite a binary that's currently being executed by the running service. Switch to copy-then-rename: write the new binary to /usr/local/bin/numa.new, then rename over /usr/local/bin/numa. Rename swaps the path while the running process keeps the old inode alive, so DNS keeps serving from the previous binary until restart. Bump systemctl start to restart so the new binary actually loads on re-install (start is a no-op when the unit is already active, which would silently leave the old binary running). Locally verified the full CI sequence: install → curl → reinstall → curl → uninstall → curl-fails. All three assertions pass. --- src/system_dns.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index 60701e3..5a7b999 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -1708,10 +1708,18 @@ fn install_service_binary_linux() -> Result { std::fs::create_dir_all(parent) .map_err(|e| format!("failed to create {}: {}", parent.display(), e))?; } - std::fs::copy(&src, &dst).map_err(|e| { + // Atomic replace via temp + rename. Plain copy fails with ETXTBSY when + // re-installing while the service is running the previous binary — + // rename swaps the path while the running process keeps the old inode. + let tmp = dst.with_extension("new"); + std::fs::copy(&src, &tmp).map_err(|e| { + format!("failed to copy {} -> {}: {}", src.display(), tmp.display(), e) + })?; + std::fs::rename(&tmp, &dst).map_err(|e| { + let _ = std::fs::remove_file(&tmp); format!( - "failed to copy {} -> {}: {}", - src.display(), + "failed to rename {} -> {}: {}", + tmp.display(), dst.display(), e ) @@ -1734,7 +1742,9 @@ fn install_service_linux() -> Result<(), String> { eprintln!(" warning: failed to configure system DNS: {}", e); } - run_systemctl(&["start", "numa"])?; + // restart, not start: on re-install the service is already running + // the previous binary; restart picks up the new one. + run_systemctl(&["restart", "numa"])?; eprintln!(" Service installed and started."); eprintln!(" Numa will auto-start on boot and restart if killed."); -- 2.34.1 From 763131478f21dd56708f680b72b1b96acc7acb23 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 12:15:44 +0300 Subject: [PATCH 158/204] fmt: rustfmt format! macro split --- src/system_dns.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index 5a7b999..fd16e8b 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -1713,7 +1713,12 @@ fn install_service_binary_linux() -> Result { // rename swaps the path while the running process keeps the old inode. let tmp = dst.with_extension("new"); std::fs::copy(&src, &tmp).map_err(|e| { - format!("failed to copy {} -> {}: {}", src.display(), tmp.display(), e) + format!( + "failed to copy {} -> {}: {}", + src.display(), + tmp.display(), + e + ) })?; std::fs::rename(&tmp, &dst).map_err(|e| { let _ = std::fs::remove_file(&tmp); -- 2.34.1 From be98a02e493cf2736197158cc4712e699819fa69 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 19:52:06 +0300 Subject: [PATCH 159/204] feat(resolver): filter_aaaa for IPv4-only networks (#112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When enabled, AAAA queries short-circuit to NODATA (NOERROR + empty answer) so Happy Eyeballs clients don't stall waiting on a v6 address they can't use. Also strips `ipv6hint` SvcParam from HTTPS/SVCB answers (RFC 9460) so Chrome ≥103, Firefox, and Safari don't bypass the AAAA filter via the HTTPS record path. Local data is preserved: overrides, zones, the .numa proxy, and the blocklist sinkhole keep whatever v6 addresses they configure — the filter only kicks in on the cache/forward/recursive path. NODATA is correct per RFC 2308 here; NXDOMAIN would incorrectly imply the name doesn't exist for A queries either. Off by default. Opt in via `filter_aaaa = true` under `[server]`. --- numa.toml | 10 +++ src/config.rs | 18 +++++ src/ctx.rs | 155 ++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/serve.rs | 1 + src/svcb.rs | 177 ++++++++++++++++++++++++++++++++++++++++++++++++ src/testutil.rs | 1 + 7 files changed, 363 insertions(+) create mode 100644 src/svcb.rs diff --git a/numa.toml b/numa.toml index ebb9720..c25654a 100644 --- a/numa.toml +++ b/numa.toml @@ -8,6 +8,16 @@ api_port = 5380 # %PROGRAMDATA%\numa on windows. Override for # containerized deploys or tests that can't # write to the system path. +# filter_aaaa = true # on IPv4-only networks, answer AAAA queries with + # NODATA (NOERROR + empty answer) so Happy Eyeballs + # clients don't wait on a v6 attempt that can't + # succeed. Also strips `ipv6hint` from HTTPS/SVCB + # records (RFC 9460) so modern browsers (Chrome + # ≥103, Firefox, Safari) don't bypass the AAAA + # filter via SVCB hints. Local zones, overrides, + # and the .numa proxy are NOT filtered — you can + # still configure v6 records for local services. + # Default: false. # [upstream] # mode = "forward" # "forward" (default) — relay to upstream diff --git a/src/config.rs b/src/config.rs index 90d1ba3..309344b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -93,6 +93,12 @@ pub struct ServerConfig { /// Defaults to `crate::data_dir()` (platform-specific system path) if unset. #[serde(default)] pub data_dir: Option, + /// Synthesize NODATA (NOERROR + empty answer) for AAAA queries, and + /// strip `ipv6hint` from HTTPS/SVCB responses (RFC 9460). For IPv4-only + /// networks where Happy Eyeballs fallback adds latency. Local zones, + /// overrides, and the service proxy are not affected. Default false. + #[serde(default)] + pub filter_aaaa: bool, } impl Default for ServerConfig { @@ -102,6 +108,7 @@ impl Default for ServerConfig { api_port: default_api_port(), api_bind_addr: default_api_bind_addr(), data_dir: None, + filter_aaaa: false, } } } @@ -580,6 +587,17 @@ mod tests { assert!(config.lan.enabled); } + #[test] + fn filter_aaaa_defaults_false() { + assert!(!ServerConfig::default().filter_aaaa); + } + + #[test] + fn filter_aaaa_parses_from_server_section() { + let config: Config = toml::from_str("[server]\nfilter_aaaa = true").unwrap(); + assert!(config.server.filter_aaaa); + } + #[test] fn custom_bind_addrs_parse() { let toml = r#" diff --git a/src/ctx.rs b/src/ctx.rs index 3a3a58a..b3f7ae2 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -77,6 +77,10 @@ pub struct ServerCtx { pub ca_pem: Option, pub mobile_enabled: bool, pub mobile_port: u16, + /// When true, AAAA queries short-circuit with NODATA (NOERROR + empty + /// answer) instead of hitting cache/forwarding/upstream. Local data + /// (overrides, zones, .numa proxy, blocklist sinkhole) is unaffected. + pub filter_aaaa: bool, } /// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist, @@ -172,6 +176,13 @@ pub async fn resolve_query( 60, )); (resp, QueryPath::Blocked, DnssecStatus::Indeterminate) + } else if qtype == QueryType::AAAA && ctx.filter_aaaa { + // RFC 2308 NODATA: NOERROR with empty answer section. Prevents + // Happy Eyeballs clients from waiting on an AAAA they'll never use + // on IPv4-only networks. NXDOMAIN would be wrong (it'd imply the + // name doesn't exist for A either). + let resp = DnsPacket::response_from(&query, ResultCode::NOERROR); + (resp, QueryPath::Local, DnssecStatus::Indeterminate) } else { let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype); if let Some((cached, cached_dnssec, freshness)) = cached { @@ -334,6 +345,13 @@ pub async fn resolve_query( strip_dnssec_records(&mut response); } + // filter_aaaa: also strip ipv6hint from HTTPS/SVCB answers so modern + // browsers (Chrome ≥103 etc.) don't receive v6 address hints via the + // HTTPS record path that bypasses AAAA entirely. + if ctx.filter_aaaa { + strip_https_ipv6_hints(&mut response); + } + // Echo EDNS back if client sent it if query.edns.is_some() { response.edns = Some(crate::packet::EdnsOpt { @@ -491,6 +509,29 @@ fn strip_dnssec_records(pkt: &mut DnsPacket) { pkt.resources.retain(|r| !is_dnssec_record(r)); } +/// HTTPS RR type code (RFC 9460). Numa stores HTTPS/SVCB records as +/// `DnsRecord::UNKNOWN { qtype: 65, .. }` since it doesn't have a +/// dedicated variant. +const HTTPS_TYPE: u16 = 65; + +fn strip_https_ipv6_hints(pkt: &mut DnsPacket) { + let rewrite = |rec: &mut DnsRecord| { + if let DnsRecord::UNKNOWN { + qtype: HTTPS_TYPE, + data, + .. + } = rec + { + if let Some(new_data) = crate::svcb::strip_ipv6hint(data) { + *data = new_data; + } + } + }; + pkt.answers.iter_mut().for_each(rewrite); + pkt.authorities.iter_mut().for_each(rewrite); + pkt.resources.iter_mut().for_each(rewrite); +} + fn is_special_use_domain(qname: &str) -> bool { if qname.ends_with(".in-addr.arpa") { // RFC 6303: private + loopback + link-local reverse DNS @@ -1187,6 +1228,120 @@ mod tests { } } + #[tokio::test] + async fn pipeline_filter_aaaa_returns_nodata() { + let mut ctx = crate::testutil::test_ctx().await; + ctx.filter_aaaa = true; + let ctx = Arc::new(ctx); + + let (resp, path) = resolve_in_test(&ctx, "example.com", QueryType::AAAA).await; + assert_eq!(path, QueryPath::Local); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + assert!(resp.answers.is_empty(), "AAAA must be filtered to NODATA"); + } + + #[tokio::test] + async fn pipeline_filter_aaaa_leaves_a_queries_alone() { + let mut upstream_resp = DnsPacket::new(); + upstream_resp.header.response = true; + upstream_resp.header.rescode = ResultCode::NOERROR; + upstream_resp.answers.push(DnsRecord::A { + domain: "example.com".to_string(), + addr: Ipv4Addr::new(93, 184, 216, 34), + ttl: 300, + }); + let upstream_addr = crate::testutil::mock_upstream(upstream_resp).await; + + let mut ctx = crate::testutil::test_ctx().await; + ctx.filter_aaaa = true; + ctx.upstream_pool + .lock() + .unwrap() + .set_primary(vec![Upstream::Udp(upstream_addr)]); + let ctx = Arc::new(ctx); + + let (resp, path) = resolve_in_test(&ctx, "example.com", QueryType::A).await; + assert_eq!(path, QueryPath::Upstream); + assert_eq!(resp.answers.len(), 1); + } + + #[tokio::test] + async fn pipeline_filter_aaaa_respects_override() { + let mut ctx = crate::testutil::test_ctx().await; + ctx.filter_aaaa = true; + ctx.overrides + .write() + .unwrap() + .insert("v6.test", "2001:db8::1", 60, None) + .unwrap(); + let ctx = Arc::new(ctx); + + let (resp, path) = resolve_in_test(&ctx, "v6.test", QueryType::AAAA).await; + assert_eq!(path, QueryPath::Overridden); + assert_eq!(resp.answers.len(), 1, "override must win over filter"); + } + + #[tokio::test] + async fn pipeline_filter_aaaa_strips_ipv6hint_from_https() { + // Build an HTTPS record (type 65) with ipv6hint (key 6). Cache it, + // then query with filter_aaaa on — the returned rdata must have + // ipv6hint removed. + let mut rdata = Vec::new(); + rdata.extend_from_slice(&1u16.to_be_bytes()); // priority + rdata.push(0); // empty target (".") + // alpn = ["h3"] + rdata.extend_from_slice(&1u16.to_be_bytes()); + rdata.extend_from_slice(&3u16.to_be_bytes()); + rdata.extend_from_slice(&[0x02, b'h', b'3']); + // ipv6hint = [2606:4700::1] + rdata.extend_from_slice(&6u16.to_be_bytes()); + rdata.extend_from_slice(&16u16.to_be_bytes()); + rdata.extend_from_slice(&[ + 0x26, 0x06, 0x47, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01, + ]); + + let mut pkt = DnsPacket::new(); + pkt.header.response = true; + pkt.header.rescode = ResultCode::NOERROR; + pkt.questions.push(crate::question::DnsQuestion { + name: "hints.test".to_string(), + qtype: QueryType::HTTPS, + }); + pkt.answers.push(DnsRecord::UNKNOWN { + domain: "hints.test".to_string(), + qtype: 65, + data: rdata.clone(), + ttl: 300, + }); + + let mut ctx = crate::testutil::test_ctx().await; + ctx.filter_aaaa = true; + ctx.cache + .write() + .unwrap() + .insert("hints.test", QueryType::HTTPS, &pkt); + let ctx = Arc::new(ctx); + + let (resp, path) = resolve_in_test(&ctx, "hints.test", QueryType::HTTPS).await; + assert_eq!(path, QueryPath::Cached); + assert_eq!(resp.answers.len(), 1); + match &resp.answers[0] { + DnsRecord::UNKNOWN { data, .. } => { + assert!( + data.len() < rdata.len(), + "ipv6hint (20 bytes) must be removed" + ); + // Bytes for key=6 must not appear at any 4-byte boundary in the + // params section — cheap structural check. + assert!( + !data.windows(4).any(|w| w == [0, 6, 0, 16]), + "ipv6hint TLV header must be absent" + ); + } + other => panic!("expected UNKNOWN record, got {:?}", other), + } + } + #[tokio::test] async fn pipeline_blocklist_sinkhole() { let ctx = crate::testutil::test_ctx().await; diff --git a/src/lib.rs b/src/lib.rs index a16568b..bce8833 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,7 @@ pub mod service_store; pub mod setup_phone; pub mod srtt; pub mod stats; +pub mod svcb; pub mod system_dns; pub mod tls; pub mod wire; diff --git a/src/serve.rs b/src/serve.rs index 1a9a764..8e85b32 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -236,6 +236,7 @@ pub async fn run(config_path: String) -> crate::Result<()> { ca_pem, mobile_enabled: config.mobile.enabled, mobile_port: config.mobile.port, + filter_aaaa: config.server.filter_aaaa, }); let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); diff --git a/src/svcb.rs b/src/svcb.rs new file mode 100644 index 0000000..2228443 --- /dev/null +++ b/src/svcb.rs @@ -0,0 +1,177 @@ +//! Minimal SVCB/HTTPS (RFC 9460) RDATA parser — just enough to strip +//! the `ipv6hint` SvcParam. Used by the `filter_aaaa` feature so +//! HTTPS-record-aware clients (Chrome ≥103, Firefox, Safari) don't +//! receive v6 address hints on IPv4-only networks. + +/// SvcParamKey = 6 (RFC 9460 §14.3.2). +const IPV6_HINT_KEY: u16 = 6; + +/// Strip the `ipv6hint` SvcParam from an HTTPS/SVCB RDATA blob. +/// +/// Returns `Some(new_rdata)` if `ipv6hint` was present and removed. +/// Returns `None` if the record had no `ipv6hint`, or if the RDATA +/// couldn't be parsed — in both cases the caller should keep the +/// original bytes untouched. +/// +/// SVCB RDATA (RFC 9460 §2.2): +/// SvcPriority (u16) +/// TargetName (uncompressed DNS name — labels terminated by 0 octet) +/// SvcParams (series of {u16 key, u16 len, opaque[len] value}, sorted by key) +pub fn strip_ipv6hint(rdata: &[u8]) -> Option> { + if rdata.len() < 2 { + return None; + } + let mut pos = 2; + + // TargetName — uncompressed per RFC 9460 §2.2 + loop { + let len = *rdata.get(pos)? as usize; + pos += 1; + if len == 0 { + break; + } + if len & 0xC0 != 0 { + // Pointer: forbidden in SVCB but defend against a broken upstream. + return None; + } + pos = pos.checked_add(len)?; + if pos > rdata.len() { + return None; + } + } + + // Scan params once to decide whether we need to rebuild. + let params_start = pos; + let mut scan = pos; + let mut has_ipv6hint = false; + while scan < rdata.len() { + if scan + 4 > rdata.len() { + return None; + } + let key = u16::from_be_bytes([rdata[scan], rdata[scan + 1]]); + let vlen = u16::from_be_bytes([rdata[scan + 2], rdata[scan + 3]]) as usize; + let end = scan.checked_add(4)?.checked_add(vlen)?; + if end > rdata.len() { + return None; + } + if key == IPV6_HINT_KEY { + has_ipv6hint = true; + } + scan = end; + } + if scan != rdata.len() || !has_ipv6hint { + return None; + } + + // Rebuild without ipv6hint, preserving param order (RFC 9460 requires + // ascending key order, which we preserve by filtering in place). + let mut out = Vec::with_capacity(rdata.len()); + out.extend_from_slice(&rdata[..params_start]); + let mut pos = params_start; + while pos < rdata.len() { + let key = u16::from_be_bytes([rdata[pos], rdata[pos + 1]]); + let vlen = u16::from_be_bytes([rdata[pos + 2], rdata[pos + 3]]) as usize; + let end = pos + 4 + vlen; + if key != IPV6_HINT_KEY { + out.extend_from_slice(&rdata[pos..end]); + } + pos = end; + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build an SVCB RDATA blob from a priority, target labels, and + /// (key, value) param pairs. Used for constructing test vectors. + fn build(priority: u16, target: &[&str], params: &[(u16, Vec)]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&priority.to_be_bytes()); + for label in target { + out.push(label.len() as u8); + out.extend_from_slice(label.as_bytes()); + } + out.push(0); + for (key, value) in params { + out.extend_from_slice(&key.to_be_bytes()); + out.extend_from_slice(&(value.len() as u16).to_be_bytes()); + out.extend_from_slice(value); + } + out + } + + fn alpn_h3() -> (u16, Vec) { + // alpn = ["h3"]: one length-prefixed ALPN id + (1, vec![0x02, b'h', b'3']) + } + + fn ipv4hint_single() -> (u16, Vec) { + (4, vec![93, 184, 216, 34]) + } + + fn ipv6hint_single() -> (u16, Vec) { + // 2606:4700::1 + ( + 6, + vec![ + 0x26, 0x06, 0x47, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01, + ], + ) + } + + #[test] + fn strips_ipv6hint_and_keeps_other_params() { + let rdata = build(1, &[], &[alpn_h3(), ipv4hint_single(), ipv6hint_single()]); + let stripped = strip_ipv6hint(&rdata).expect("ipv6hint present → stripped"); + let expected = build(1, &[], &[alpn_h3(), ipv4hint_single()]); + assert_eq!(stripped, expected); + } + + #[test] + fn no_ipv6hint_returns_none() { + let rdata = build(1, &[], &[alpn_h3(), ipv4hint_single()]); + assert!(strip_ipv6hint(&rdata).is_none()); + } + + #[test] + fn alias_mode_empty_params_returns_none() { + let rdata = build(0, &["example", "com"], &[]); + assert!(strip_ipv6hint(&rdata).is_none()); + } + + #[test] + fn only_ipv6hint_yields_empty_param_section() { + let rdata = build(1, &[], &[ipv6hint_single()]); + let stripped = strip_ipv6hint(&rdata).expect("ipv6hint present → stripped"); + let expected = build(1, &[], &[]); + assert_eq!(stripped, expected); + } + + #[test] + fn preserves_target_name() { + let rdata = build(1, &["svc", "example", "net"], &[ipv6hint_single()]); + let stripped = strip_ipv6hint(&rdata).unwrap(); + assert!(stripped.starts_with(&[0x00, 0x01])); // priority + assert_eq!(&stripped[2..6], b"\x03svc"); + } + + #[test] + fn truncated_rdata_returns_none() { + // Priority only, no target terminator. + assert!(strip_ipv6hint(&[0, 1, 3, b'c', b'o', b'm']).is_none()); + } + + #[test] + fn empty_input_returns_none() { + assert!(strip_ipv6hint(&[]).is_none()); + } + + #[test] + fn param_length_overflow_returns_none() { + // key=6, length=0xFFFF but value is short — malformed. + let rdata = vec![0, 1, 0, 0, 6, 0xFF, 0xFF, 0, 1, 2]; + assert!(strip_ipv6hint(&rdata).is_none()); + } +} diff --git a/src/testutil.rs b/src/testutil.rs index 8687625..fab861b 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -63,6 +63,7 @@ pub async fn test_ctx() -> ServerCtx { ca_pem: None, mobile_enabled: false, mobile_port: 8765, + filter_aaaa: false, } } -- 2.34.1 From b02b607fb908781c6584665bbe3e6c477afb2058 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 20:07:24 +0300 Subject: [PATCH 160/204] ci(linux): assert numa daemon does not run as root Locks in the invariant this branch establishes: a regression that reverts to User=root would otherwise ship green. --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bce7c2..1e015ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,9 @@ jobs: sleep 2 curl -sf http://127.0.0.1:5380/health dig @127.0.0.1 example.com +short +timeout=5 | grep -q '.' + user=$(ps -o user= -p "$(systemctl show -p MainPID --value numa)" | tr -d ' ') + echo "numa running as: $user" + test "$user" != "root" sudo ./target/release/numa install sleep 2 curl -sf http://127.0.0.1:5380/health -- 2.34.1 From fb41a6f8b59b846d2413812ab823a40735b38130 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 18 Apr 2026 22:00:54 +0300 Subject: [PATCH 161/204] test(linux): systemd service install verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three scenarios CI cannot run: every advertised port is functional (DNS resolves, TLS chain validates against numa's CA, HTTP/API respond), CA fingerprint survives upgrade from pre-drop layout, binary staging fallback from a 0700 source dir. Self-bootstraps a privileged systemd-as-PID1 container — no dependency on long-lived test containers. MainPID user assertion retries until comm=numa to avoid a race where systemctl reports active while MainPID still points at a transitional process. --- tests/docker/install-systemd.sh | 288 ++++++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100755 tests/docker/install-systemd.sh diff --git a/tests/docker/install-systemd.sh b/tests/docker/install-systemd.sh new file mode 100755 index 0000000..aa9c31a --- /dev/null +++ b/tests/docker/install-systemd.sh @@ -0,0 +1,288 @@ +#!/usr/bin/env bash +# +# Systemd service install verification for the DynamicUser-based Linux +# service unit. Stands up a privileged ubuntu:24.04 container with systemd +# as PID 1, builds numa inside, runs three scenarios that CI does not: +# +# A. Fresh install — every advertised port is not just bound but +# functional (DNS resolves on :53, TLS handshake validates against +# numa's CA on :853/:443, HTTP responds on :80, API on :5380). +# B. Upgrade from pre-drop layout (root-owned /var/lib/numa) preserves +# the CA fingerprint — users' browser-installed CA trust survives. +# C. Install from a 0700 source directory stages the binary under +# /usr/local/bin/numa and the service starts from there. +# +# First run is slow (~5-10 min): image pull + apt + cold cargo build. +# Subsequent runs reuse cached docker volumes for cargo + target (~30s). +# +# Requirements: docker +# Usage: ./tests/docker/install-systemd.sh + +set -u +set -o pipefail + +GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m" + +pass() { printf " ${GREEN}PASS${RESET}: %s\n" "$*"; } +fail() { printf " ${RED}FAIL${RESET}: %s\n" "$*"; FAIL=1; } + +# ============================================================ +# Mode B: running inside the systemd container — run scenarios +# ============================================================ +if [ "${NUMA_INSIDE:-}" = "1" ]; then + set +e # assertions report pass/fail, don't abort + FAIL=0 + NUMA=/work/target/release/numa + + reset_state() { + "$NUMA" uninstall >/dev/null 2>&1 || true + systemctl reset-failed numa 2>/dev/null || true + rm -rf /var/lib/numa /var/lib/private/numa /etc/numa /home/builder /usr/local/bin/numa + systemctl daemon-reload 2>/dev/null || true + } + + main_pid_user() { + local pid + pid=$(systemctl show -p MainPID --value numa) + [ "$pid" != "0" ] || { echo ""; return; } + ps -o user= -p "$pid" 2>/dev/null | tr -d ' ' + } + + # MainPID + user briefly stabilize after a fresh restart. Retry so we + # don't race the moment systemd flips the service to "active" vs when + # the forked numa process actually owns MainPID. + assert_nonroot() { + local pid user comm n=0 + while [ $n -lt 20 ]; do + pid=$(systemctl show -p MainPID --value numa) + if [ "$pid" != "0" ]; then + comm=$(ps -o comm= -p "$pid" 2>/dev/null | tr -d ' ') + user=$(ps -o user= -p "$pid" 2>/dev/null | tr -d ' ') + if [ "$comm" = "numa" ]; then + if [ "$user" = "root" ]; then + fail "daemon runs as root (expected transient UID)" + else + pass "daemon runs as $user (non-root)" + fi + return + fi + fi + sleep 0.2 + n=$((n + 1)) + done + fail "numa MainPID did not settle (last: pid=${pid:-?} comm=${comm:-?} user=${user:-?})" + } + + # Functional DNS check: just "port 53 bound" isn't enough — systemd-resolved + # listens on 127.0.0.53 and would satisfy a bind test. Retries for ~15s + # to tolerate cold-start upstream / blocklist warmup. + assert_dns_works() { + local n=0 + while [ $n -lt 15 ]; do + if dig @127.0.0.1 -p 53 example.com +short +timeout=2 +tries=1 2>/dev/null \ + | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then + pass "DNS resolves on :53 (A record returned)" + return + fi + sleep 1 + n=$((n + 1)) + done + fail "DNS did not return an A record on :53 within 15s" + } + + # TLS handshake: cert must validate against numa's CA when connecting + # to a .numa SNI. Catches port-not-bound, wrong cert, missing CA file. + assert_tls_handshake() { + local port=$1 sni=${2:-numa.numa} out + if out=$(openssl s_client -connect "127.0.0.1:${port}" \ + -servername "$sni" \ + -CAfile /var/lib/numa/ca.pem \ + -verify_return_error &1); then + if echo "$out" | grep -q 'Verify return code: 0 (ok)'; then + pass "TLS handshake + cert chain verified on :${port}" + else + fail "TLS handshake on :${port} did not report 'Verify return code: 0'" + fi + else + fail "openssl s_client failed connecting to :${port}" + fi + } + + assert_http_responds() { + local code + code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://127.0.0.1/ || echo 000) + if [ "$code" != "000" ]; then + pass "HTTP responds on :80 (status $code)" + else + fail "HTTP :80 connection failed" + fi + } + + assert_api_healthy() { + if curl -sf --max-time 3 http://127.0.0.1:5380/health >/dev/null; then + pass "API /health OK on :5380" + else + fail "API /health failed on :5380" + fi + } + + ca_fingerprint() { + openssl x509 -in /var/lib/numa/ca.pem -noout -fingerprint -sha256 2>/dev/null \ + | sed 's/.*=//' + } + + wait_active() { + local n=0 + while [ $n -lt 20 ]; do + systemctl is-active --quiet numa && return 0 + sleep 0.5 + n=$((n + 1)) + done + fail "service did not become active within 10s" + systemctl status numa --no-pager -l 2>&1 | head -20 || true + return 1 + } + + # ---- Scenario A ---- + printf "\n=== Scenario A: fresh install — every advertised port is functional ===\n" + reset_state + "$NUMA" install >/tmp/installA.log 2>&1 || { fail "install failed"; tail -20 /tmp/installA.log; } + wait_active || true + assert_nonroot + assert_dns_works + assert_tls_handshake 853 + assert_tls_handshake 443 + assert_http_responds + assert_api_healthy + + # ---- Scenario B ---- + # Pre-drop installs left /var/lib/numa as a plain root-owned tree. + # Flattening the current DynamicUser layout back into that shape + # simulates the upgrade path without needing an actual old binary. + printf "\n=== Scenario B: CA fingerprint survives upgrade from pre-drop layout ===\n" + fp_before=$(ca_fingerprint) + if [ -z "$fp_before" ]; then + fail "could not read initial CA fingerprint (skipping scenario B)" + else + echo " CA fingerprint before: $fp_before" + "$NUMA" uninstall >/dev/null 2>&1 || true + tmp=$(mktemp -d) + cp -a /var/lib/private/numa/. "$tmp"/ 2>/dev/null || true + rm -rf /var/lib/numa /var/lib/private/numa + mv "$tmp" /var/lib/numa + chown -R root:root /var/lib/numa + chmod 755 /var/lib/numa + [ -f /var/lib/numa/ca.pem ] || fail "ca.pem missing from seeded legacy tree" + + "$NUMA" install >/tmp/installB.log 2>&1 || { fail "upgrade install failed"; tail -20 /tmp/installB.log; } + wait_active || true + assert_nonroot + fp_after=$(ca_fingerprint) + if [ -z "$fp_after" ]; then + fail "could not read CA fingerprint after upgrade" + elif [ "$fp_before" = "$fp_after" ]; then + pass "CA fingerprint preserved across upgrade" + else + fail "CA fingerprint changed: before=$fp_before after=$fp_after" + fi + assert_dns_works + fi + + # ---- Scenario C ---- + printf "\n=== Scenario C: install from unreachable source stages binary to /usr/local/bin ===\n" + reset_state + mkdir -p /home/builder + chmod 700 /home/builder + cp "$NUMA" /home/builder/numa + chmod 755 /home/builder/numa + /home/builder/numa install >/tmp/installC.log 2>&1 || { fail "install failed"; tail -20 /tmp/installC.log; } + wait_active || true + if [ -x /usr/local/bin/numa ]; then + pass "binary staged to /usr/local/bin/numa" + else + fail "/usr/local/bin/numa missing after install from 0700 source" + fi + exec_line=$(grep '^ExecStart=' /etc/systemd/system/numa.service 2>/dev/null || echo "ExecStart=") + if echo "$exec_line" | grep -q '/usr/local/bin/numa'; then + pass "unit ExecStart points to staged path" + else + fail "unit ExecStart wrong: $exec_line" + fi + assert_nonroot + assert_dns_works + + reset_state + rm -rf /home/builder + echo + if [ "$FAIL" -eq 0 ]; then + printf "${GREEN}── all scenarios passed ──${RESET}\n" + exit 0 + else + printf "${RED}── some scenarios failed ──${RESET}\n" + exit 1 + fi +fi + +# ============================================================ +# Mode A: host-side bootstrap +# ============================================================ +set -e +cd "$(dirname "$0")/../.." + +IMAGE=numa-install-systemd:local +CONTAINER="numa-install-systemd-$$" +trap 'docker rm -f "$CONTAINER" >/dev/null 2>&1 || true' EXIT + +echo "── building systemd-in-container image (cached after first run) ──" +docker build --quiet -t "$IMAGE" -f - . <<'DOCKERFILE' >/dev/null +FROM ubuntu:24.04 +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update -qq && apt-get install -y -qq \ + systemd systemd-sysv systemd-resolved \ + ca-certificates curl build-essential \ + pkg-config libssl-dev cmake make perl \ + dnsutils iproute2 openssl \ + && rm -rf /var/lib/apt/lists/* \ + && for u in dev-hugepages.mount sys-fs-fuse-connections.mount \ + systemd-logind.service getty.target console-getty.service; do \ + systemctl mask $u; \ + done +STOPSIGNAL SIGRTMIN+3 +CMD ["/lib/systemd/systemd"] +DOCKERFILE + +echo "── starting systemd container ──" +docker run -d --name "$CONTAINER" \ + --privileged --cgroupns=host \ + --tmpfs /run --tmpfs /run/lock --tmpfs /tmp:exec \ + -v "$PWD:/src:ro" \ + -v numa-install-systemd-cargo:/root/.cargo \ + -v numa-install-systemd-work:/work \ + "$IMAGE" >/dev/null + +# Wait for systemd to be up +for _ in $(seq 1 30); do + state=$(docker exec "$CONTAINER" systemctl is-system-running 2>&1 || true) + case "$state" in running|degraded) break ;; esac + sleep 0.5 +done + +echo "── copying source into /work (writable) ──" +docker exec "$CONTAINER" bash -c ' +mkdir -p /work +tar -C /src --exclude=./target --exclude=./.git --exclude=./.claude -cf - . | tar -C /work -xf - +' + +echo "── rustup + cargo build --release --locked ──" +docker exec "$CONTAINER" bash -c ' +set -e +if ! command -v cargo &>/dev/null; then + curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --quiet +fi +. "$HOME/.cargo/env" +cd /work +cargo build --release --locked 2>&1 | tail -5 +' + +echo "── running scenarios ──" +docker exec -e NUMA_INSIDE=1 "$CONTAINER" bash /src/tests/docker/install-systemd.sh -- 2.34.1 From 8014ebac9e9fdbf32e693c5f94ccca940177b64f Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 19 Apr 2026 05:52:29 +0300 Subject: [PATCH 162/204] test(integration): add Suite 7 for filter_aaaa + SUITES env filter Suite 7 exercises the full pipeline end-to-end: A resolves, AAAA returns NODATA, local [[zones]] AAAA bypasses the filter, and HTTPS ipv6hint is stripped from a real cloudflare.com response. A second config run with the flag unset guards against network-failure false-positives. SUITES=N (comma list) runs a subset, e.g. `SUITES=7 bash tests/integration.sh` skips suites 1-6 for fast iteration. --- tests/integration.sh | 158 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 1 deletion(-) diff --git a/tests/integration.sh b/tests/integration.sh index c70ec59..81bd28d 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash # Integration test suite for Numa # Runs a test instance on port 5354, validates all features, exits with status. -# Usage: ./tests/integration.sh [release|debug] +# Usage: +# ./tests/integration.sh [release|debug] # all suites +# SUITES=7 ./tests/integration.sh # only Suite 7 +# SUITES=1,3,7 ./tests/integration.sh # Suites 1, 3, and 7 set -euo pipefail @@ -14,6 +17,14 @@ LOG="/tmp/numa-integration-test.log" PASSED=0 FAILED=0 +# Suite filter: empty runs all; comma list runs a subset. +SUITES="${SUITES:-}" +should_run_suite() { + [ -z "$SUITES" ] && return 0 + case ",$SUITES," in *",$1,"*) return 0;; esac + return 1 +} + # Colors GREEN="\033[32m" RED="\033[31m" @@ -166,6 +177,7 @@ CONF } # ---- Suite 1: Recursive mode + DNSSEC ---- +if should_run_suite 1; then echo "" echo "╔══════════════════════════════════════════╗" echo "║ Suite 1: Recursive + DNSSEC + Blocking ║" @@ -234,7 +246,10 @@ kill "$NUMA_PID" 2>/dev/null || true wait "$NUMA_PID" 2>/dev/null || true sleep 1 +fi # end Suite 1 + # ---- Suite 2: Forward mode (backward compat) ---- +if should_run_suite 2; then echo "" echo "╔══════════════════════════════════════════╗" echo "║ Suite 2: Forward (DoH) + Blocking ║" @@ -261,7 +276,10 @@ enabled = true enabled = false " +fi # end Suite 2 + # ---- Suite 3: Forward UDP (plain, no DoH) ---- +if should_run_suite 3; then echo "" echo "╔══════════════════════════════════════════╗" echo "║ Suite 3: Forward (UDP) + No Blocking ║" @@ -307,7 +325,10 @@ kill "$NUMA_PID" 2>/dev/null || true wait "$NUMA_PID" 2>/dev/null || true sleep 1 +fi # end Suite 3 + # ---- Suite 4: Local zones + Overrides API ---- +if should_run_suite 4; then echo "" echo "╔══════════════════════════════════════════╗" echo "║ Suite 4: Local Zones + Overrides API ║" @@ -416,7 +437,10 @@ kill "$NUMA_PID" 2>/dev/null || true wait "$NUMA_PID" 2>/dev/null || true sleep 1 +fi # end Suite 4 + # ---- Suite 5: DNS-over-TLS (RFC 7858) ---- +if should_run_suite 5; then echo "" echo "╔══════════════════════════════════════════╗" echo "║ Suite 5: DNS-over-TLS (RFC 7858) ║" @@ -538,7 +562,10 @@ CONF fi sleep 1 +fi # end Suite 5 + # ---- Suite 6: Proxy + DoT coexistence ---- +if should_run_suite 6; then echo "" echo "╔══════════════════════════════════════════╗" echo "║ Suite 6: Proxy + DoT Coexistence ║" @@ -698,6 +725,135 @@ CONF rm -rf "$NUMA_DATA" fi +fi # end Suite 6 + +# ---- Suite 7: filter_aaaa (IPv4-only networks) ---- +if should_run_suite 7; then +echo "" +echo "╔══════════════════════════════════════════╗" +echo "║ Suite 7: filter_aaaa ║" +echo "╚══════════════════════════════════════════╝" + +# Config A — filter on, with a local AAAA zone to prove local data bypass. +cat > "$CONFIG" << 'CONF' +[server] +bind_addr = "127.0.0.1:5354" +api_port = 5381 +filter_aaaa = true + +[upstream] +mode = "forward" +address = "9.9.9.9" +port = 53 + +[cache] +max_entries = 10000 + +[blocking] +enabled = false + +[proxy] +enabled = false + +[[zones]] +domain = "v6.test" +record_type = "AAAA" +value = "2001:db8::1" +ttl = 60 +CONF + +RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 & +NUMA_PID=$! +sleep 3 + +DIG="dig @127.0.0.1 -p $PORT +time=5 +tries=1" + +echo "" +echo "=== filter_aaaa = true ===" + +# A queries must be untouched. +check "A record resolves under filter_aaaa" \ + "." \ + "$($DIG google.com A +short | head -1)" + +# AAAA must be NOERROR (NODATA), not NXDOMAIN, not SERVFAIL. +check "AAAA returns NOERROR (not NXDOMAIN)" \ + "status: NOERROR" \ + "$($DIG google.com AAAA 2>&1 | grep 'status:')" + +check "AAAA returns zero answers (NODATA shape)" \ + "ANSWER: 0" \ + "$($DIG google.com AAAA 2>&1 | grep -oE 'ANSWER: [0-9]+' | head -1)" + +# Local zone AAAA must survive the filter (PR claim: local data bypasses). +check "Local [[zones]] AAAA bypasses filter" \ + "2001:db8::1" \ + "$($DIG v6.test AAAA +short)" + +# HTTPS RR: ipv6hint (SvcParamKey 6) must be stripped. Query as `type65` +# because dig 9.10.6 (macOS) misparses `HTTPS` as a domain name; `type65` +# works on both 9.10.6 and 9.18. Assert on the raw rdata hex (RFC 3597 +# generic format), since dig 9.10.6 doesn't pretty-print HTTPS params. +# cloudflare.com's ipv6hint values sit under the 2606:4700 prefix — +# checking that `26064700` is absent from the rdata hex is a precise, +# upstream-stable signal that the TLV was stripped. +HTTPS_OUT=$($DIG cloudflare.com type65 2>&1) +if echo "$HTTPS_OUT" | grep -qE "cloudflare\.com\..*IN[[:space:]]+TYPE65"; then + HTTPS_HEX=$(echo "$HTTPS_OUT" | grep -A5 "IN[[:space:]]*TYPE65" | tr -d " \t\n") + if echo "$HTTPS_HEX" | grep -qi "26064700"; then + check "HTTPS ipv6hint stripped (2606:4700 absent from rdata)" "absent" "present" + else + check "HTTPS ipv6hint stripped (2606:4700 absent from rdata)" "absent" "absent" + fi +else + # Upstream didn't return an HTTPS record — skip rather than false-pass. + printf " ${DIM}~ HTTPS ipv6hint stripped (skipped: no HTTPS RR returned by upstream)${RESET}\n" +fi + +kill "$NUMA_PID" 2>/dev/null || true +wait "$NUMA_PID" 2>/dev/null || true +sleep 1 + +# Config B — filter off. Regression guard: prove AAAA answers come back +# when the flag isn't set, so a network failure in Config A can't silently +# pass as "filter working". +cat > "$CONFIG" << 'CONF' +[server] +bind_addr = "127.0.0.1:5354" +api_port = 5381 + +[upstream] +mode = "forward" +address = "9.9.9.9" +port = 53 + +[cache] +max_entries = 10000 + +[blocking] +enabled = false + +[proxy] +enabled = false +CONF + +RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 & +NUMA_PID=$! +sleep 3 + +echo "" +echo "=== filter_aaaa unset (regression guard) ===" + +check "AAAA returns real answers with filter off" \ + ":" \ + "$($DIG google.com AAAA +short | head -1)" + +kill "$NUMA_PID" 2>/dev/null || true +wait "$NUMA_PID" 2>/dev/null || true +sleep 1 + +fi # end Suite 7 + # Summary echo "" TOTAL=$((PASSED + FAILED)) -- 2.34.1 From 22dd3cd2222f7d19994125f61800c5eb3af672b5 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 19 Apr 2026 05:52:37 +0300 Subject: [PATCH 163/204] fix(resolver): skip ipv6hint strip for DO-bit clients Modifying HTTPS rdata invalidates any accompanying RRSIG, so a DNSSEC- validating downstream would reject the response as Bogus. Gate the strip on !client_do, matching the existing DNSSEC-records strip. Adds a regression test that catches the gate being removed: builds a query with EDNS DO=1, asserts the HTTPS rdata round-trips untouched. --- src/ctx.rs | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index b3f7ae2..0b7dd80 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -347,8 +347,10 @@ pub async fn resolve_query( // filter_aaaa: also strip ipv6hint from HTTPS/SVCB answers so modern // browsers (Chrome ≥103 etc.) don't receive v6 address hints via the - // HTTPS record path that bypasses AAAA entirely. - if ctx.filter_aaaa { + // HTTPS record path that bypasses AAAA entirely. Gated on !client_do + // because modifying rdata invalidates any accompanying RRSIG — a DO-bit + // validator downstream would reject the response as Bogus. + if ctx.filter_aaaa && !client_do { strip_https_ipv6_hints(&mut response); } @@ -1342,6 +1344,71 @@ mod tests { } } + #[tokio::test] + async fn pipeline_filter_aaaa_preserves_ipv6hint_for_dnssec_clients() { + // Regression guard for the DO-bit gate in resolve_query: modifying + // HTTPS rdata invalidates any accompanying RRSIG, so a DO=1 client + // must receive the record untouched even when filter_aaaa is on. + let mut rdata = Vec::new(); + rdata.extend_from_slice(&1u16.to_be_bytes()); + rdata.push(0); + rdata.extend_from_slice(&6u16.to_be_bytes()); + rdata.extend_from_slice(&16u16.to_be_bytes()); + rdata.extend_from_slice(&[ + 0x26, 0x06, 0x47, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01, + ]); + + let mut pkt = DnsPacket::new(); + pkt.header.response = true; + pkt.header.rescode = ResultCode::NOERROR; + pkt.questions.push(crate::question::DnsQuestion { + name: "hints.test".to_string(), + qtype: QueryType::HTTPS, + }); + pkt.answers.push(DnsRecord::UNKNOWN { + domain: "hints.test".to_string(), + qtype: 65, + data: rdata.clone(), + ttl: 300, + }); + + let mut ctx = crate::testutil::test_ctx().await; + ctx.filter_aaaa = true; + ctx.cache + .write() + .unwrap() + .insert("hints.test", QueryType::HTTPS, &pkt); + let ctx = Arc::new(ctx); + + // Build a query with EDNS DO bit set — can't use resolve_in_test + // because it constructs a plain query without EDNS. + let mut query = DnsPacket::query(0xBEEF, "hints.test", QueryType::HTTPS); + query.edns = Some(crate::packet::EdnsOpt { + do_bit: true, + ..Default::default() + }); + let mut buf = BytePacketBuffer::new(); + query.write(&mut buf).unwrap(); + let raw = &buf.buf[..buf.pos]; + let src: SocketAddr = "127.0.0.1:1234".parse().unwrap(); + + let (resp_buf, _) = resolve_query(query, raw, src, &ctx, Transport::Udp) + .await + .unwrap(); + let mut resp_parse_buf = BytePacketBuffer::from_bytes(resp_buf.filled()); + let resp = DnsPacket::from_buffer(&mut resp_parse_buf).unwrap(); + + match &resp.answers[0] { + DnsRecord::UNKNOWN { data, .. } => { + assert_eq!( + data, &rdata, + "ipv6hint must be preserved for DO-bit clients" + ); + } + other => panic!("expected UNKNOWN record, got {:?}", other), + } + } + #[tokio::test] async fn pipeline_blocklist_sinkhole() { let ctx = crate::testutil::test_ctx().await; -- 2.34.1 From 61ea2e510d5a5f7b4c9e375de375c04073512abd Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 19 Apr 2026 05:58:47 +0300 Subject: [PATCH 164/204] refactor: dedupe HTTPS_TYPE, record-walk, and test rdata builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop `const HTTPS_TYPE: u16 = 65;` in favor of `QueryType::HTTPS.to_num()` at the single call site — avoids a fresh magic number alongside the existing enum mapping in question.rs. - Add `DnsPacket::for_each_record_mut` so `strip_https_ipv6_hints` stops hand-rolling the answers/authorities/resources walk; future section rewrites go through the same helper. - Promote the SVCB test-rdata builder from `svcb::tests` to module scope as `pub(crate) #[cfg(test)] fn build_rdata`, and reuse it in the two pipeline tests in ctx.rs — kills ~20 lines of byte-fiddling and keeps one RDATA-construction code path. --- src/ctx.rs | 70 +++++++++++++++++++++------------------------------ src/packet.rs | 8 ++++++ src/svcb.rs | 56 +++++++++++++++++++++++------------------ 3 files changed, 68 insertions(+), 66 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index 0b7dd80..0dcef51 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -511,27 +511,17 @@ fn strip_dnssec_records(pkt: &mut DnsPacket) { pkt.resources.retain(|r| !is_dnssec_record(r)); } -/// HTTPS RR type code (RFC 9460). Numa stores HTTPS/SVCB records as -/// `DnsRecord::UNKNOWN { qtype: 65, .. }` since it doesn't have a -/// dedicated variant. -const HTTPS_TYPE: u16 = 65; - fn strip_https_ipv6_hints(pkt: &mut DnsPacket) { - let rewrite = |rec: &mut DnsRecord| { - if let DnsRecord::UNKNOWN { - qtype: HTTPS_TYPE, - data, - .. - } = rec - { - if let Some(new_data) = crate::svcb::strip_ipv6hint(data) { - *data = new_data; + let https_qtype = QueryType::HTTPS.to_num(); + pkt.for_each_record_mut(|rec| { + if let DnsRecord::UNKNOWN { qtype, data, .. } = rec { + if *qtype == https_qtype { + if let Some(new_data) = crate::svcb::strip_ipv6hint(data) { + *data = new_data; + } } } - }; - pkt.answers.iter_mut().for_each(rewrite); - pkt.authorities.iter_mut().for_each(rewrite); - pkt.resources.iter_mut().for_each(rewrite); + }); } fn is_special_use_domain(qname: &str) -> bool { @@ -1285,22 +1275,20 @@ mod tests { #[tokio::test] async fn pipeline_filter_aaaa_strips_ipv6hint_from_https() { - // Build an HTTPS record (type 65) with ipv6hint (key 6). Cache it, + // Build an HTTPS record (type 65) with alpn + ipv6hint, cache it, // then query with filter_aaaa on — the returned rdata must have - // ipv6hint removed. - let mut rdata = Vec::new(); - rdata.extend_from_slice(&1u16.to_be_bytes()); // priority - rdata.push(0); // empty target (".") - // alpn = ["h3"] - rdata.extend_from_slice(&1u16.to_be_bytes()); - rdata.extend_from_slice(&3u16.to_be_bytes()); - rdata.extend_from_slice(&[0x02, b'h', b'3']); - // ipv6hint = [2606:4700::1] - rdata.extend_from_slice(&6u16.to_be_bytes()); - rdata.extend_from_slice(&16u16.to_be_bytes()); - rdata.extend_from_slice(&[ - 0x26, 0x06, 0x47, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01, - ]); + // ipv6hint (20 bytes) removed. + let rdata = crate::svcb::build_rdata( + 1, + &[], + &[ + (1, vec![0x02, b'h', b'3']), + ( + 6, + vec![0x26, 0x06, 0x47, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01], + ), + ], + ); let mut pkt = DnsPacket::new(); pkt.header.response = true; @@ -1349,14 +1337,14 @@ mod tests { // Regression guard for the DO-bit gate in resolve_query: modifying // HTTPS rdata invalidates any accompanying RRSIG, so a DO=1 client // must receive the record untouched even when filter_aaaa is on. - let mut rdata = Vec::new(); - rdata.extend_from_slice(&1u16.to_be_bytes()); - rdata.push(0); - rdata.extend_from_slice(&6u16.to_be_bytes()); - rdata.extend_from_slice(&16u16.to_be_bytes()); - rdata.extend_from_slice(&[ - 0x26, 0x06, 0x47, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01, - ]); + let rdata = crate::svcb::build_rdata( + 1, + &[], + &[( + 6, + vec![0x26, 0x06, 0x47, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01], + )], + ); let mut pkt = DnsPacket::new(); pkt.header.response = true; diff --git a/src/packet.rs b/src/packet.rs index ba9e30a..a621c13 100644 --- a/src/packet.rs +++ b/src/packet.rs @@ -85,6 +85,14 @@ impl DnsPacket { + self.edns.as_ref().map_or(0, |e| e.options.capacity()) } + /// Apply `f` to every record in the three RR sections (answers, + /// authorities, resources). Does not touch questions or edns. + pub fn for_each_record_mut(&mut self, mut f: impl FnMut(&mut DnsRecord)) { + self.answers.iter_mut().for_each(&mut f); + self.authorities.iter_mut().for_each(&mut f); + self.resources.iter_mut().for_each(&mut f); + } + pub fn response_from(query: &DnsPacket, rescode: crate::header::ResultCode) -> DnsPacket { let mut resp = DnsPacket::new(); resp.header.id = query.header.id; diff --git a/src/svcb.rs b/src/svcb.rs index 2228443..444b063 100644 --- a/src/svcb.rs +++ b/src/svcb.rs @@ -80,28 +80,34 @@ pub fn strip_ipv6hint(rdata: &[u8]) -> Option> { Some(out) } +/// Build an SVCB RDATA blob from a priority, target labels, and +/// (key, value) param pairs. Shared by `svcb` unit tests and `ctx` +/// pipeline tests that need to seed the cache with a synthetic HTTPS RR. +#[cfg(test)] +pub(crate) fn build_rdata( + priority: u16, + target: &[&str], + params: &[(u16, Vec)], +) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&priority.to_be_bytes()); + for label in target { + out.push(label.len() as u8); + out.extend_from_slice(label.as_bytes()); + } + out.push(0); + for (key, value) in params { + out.extend_from_slice(&key.to_be_bytes()); + out.extend_from_slice(&(value.len() as u16).to_be_bytes()); + out.extend_from_slice(value); + } + out +} + #[cfg(test)] mod tests { use super::*; - /// Build an SVCB RDATA blob from a priority, target labels, and - /// (key, value) param pairs. Used for constructing test vectors. - fn build(priority: u16, target: &[&str], params: &[(u16, Vec)]) -> Vec { - let mut out = Vec::new(); - out.extend_from_slice(&priority.to_be_bytes()); - for label in target { - out.push(label.len() as u8); - out.extend_from_slice(label.as_bytes()); - } - out.push(0); - for (key, value) in params { - out.extend_from_slice(&key.to_be_bytes()); - out.extend_from_slice(&(value.len() as u16).to_be_bytes()); - out.extend_from_slice(value); - } - out - } - fn alpn_h3() -> (u16, Vec) { // alpn = ["h3"]: one length-prefixed ALPN id (1, vec![0x02, b'h', b'3']) @@ -123,35 +129,35 @@ mod tests { #[test] fn strips_ipv6hint_and_keeps_other_params() { - let rdata = build(1, &[], &[alpn_h3(), ipv4hint_single(), ipv6hint_single()]); + let rdata = build_rdata(1, &[], &[alpn_h3(), ipv4hint_single(), ipv6hint_single()]); let stripped = strip_ipv6hint(&rdata).expect("ipv6hint present → stripped"); - let expected = build(1, &[], &[alpn_h3(), ipv4hint_single()]); + let expected = build_rdata(1, &[], &[alpn_h3(), ipv4hint_single()]); assert_eq!(stripped, expected); } #[test] fn no_ipv6hint_returns_none() { - let rdata = build(1, &[], &[alpn_h3(), ipv4hint_single()]); + let rdata = build_rdata(1, &[], &[alpn_h3(), ipv4hint_single()]); assert!(strip_ipv6hint(&rdata).is_none()); } #[test] fn alias_mode_empty_params_returns_none() { - let rdata = build(0, &["example", "com"], &[]); + let rdata = build_rdata(0, &["example", "com"], &[]); assert!(strip_ipv6hint(&rdata).is_none()); } #[test] fn only_ipv6hint_yields_empty_param_section() { - let rdata = build(1, &[], &[ipv6hint_single()]); + let rdata = build_rdata(1, &[], &[ipv6hint_single()]); let stripped = strip_ipv6hint(&rdata).expect("ipv6hint present → stripped"); - let expected = build(1, &[], &[]); + let expected = build_rdata(1, &[], &[]); assert_eq!(stripped, expected); } #[test] fn preserves_target_name() { - let rdata = build(1, &["svc", "example", "net"], &[ipv6hint_single()]); + let rdata = build_rdata(1, &["svc", "example", "net"], &[ipv6hint_single()]); let stripped = strip_ipv6hint(&rdata).unwrap(); assert!(stripped.starts_with(&[0x00, 0x01])); // priority assert_eq!(&stripped[2..6], b"\x03svc"); -- 2.34.1 From d6bb9a0f01f778f22bc03f7305a03377ea0abf24 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 19 Apr 2026 06:24:54 +0300 Subject: [PATCH 165/204] fmt: rustfmt vec literal wrapping + signature collapse --- src/ctx.rs | 8 ++++++-- src/svcb.rs | 6 +----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index 0dcef51..eeeb71f 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1285,7 +1285,9 @@ mod tests { (1, vec![0x02, b'h', b'3']), ( 6, - vec![0x26, 0x06, 0x47, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01], + vec![ + 0x26, 0x06, 0x47, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01, + ], ), ], ); @@ -1342,7 +1344,9 @@ mod tests { &[], &[( 6, - vec![0x26, 0x06, 0x47, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01], + vec![ + 0x26, 0x06, 0x47, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01, + ], )], ); diff --git a/src/svcb.rs b/src/svcb.rs index 444b063..ef65d04 100644 --- a/src/svcb.rs +++ b/src/svcb.rs @@ -84,11 +84,7 @@ pub fn strip_ipv6hint(rdata: &[u8]) -> Option> { /// (key, value) param pairs. Shared by `svcb` unit tests and `ctx` /// pipeline tests that need to seed the cache with a synthetic HTTPS RR. #[cfg(test)] -pub(crate) fn build_rdata( - priority: u16, - target: &[&str], - params: &[(u16, Vec)], -) -> Vec { +pub(crate) fn build_rdata(priority: u16, target: &[&str], params: &[(u16, Vec)]) -> Vec { let mut out = Vec::new(); out.extend_from_slice(&priority.to_be_bytes()); for label in target { -- 2.34.1 From 5e85b147b97826314451af83f65161e40375830d Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 19 Apr 2026 06:52:30 +0300 Subject: [PATCH 166/204] feat(resolver): apply ipv6hint strip to SVCB (type 64) too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTPS (65) and SVCB (64) share the RDATA wire format, so the existing parser already handles both — only the call site was HTTPS-only. Widen the qtype check and extend the existing pipeline test with a second query for SVCB. --- src/ctx.rs | 72 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index eeeb71f..0ba33c8 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -351,7 +351,7 @@ pub async fn resolve_query( // because modifying rdata invalidates any accompanying RRSIG — a DO-bit // validator downstream would reject the response as Bogus. if ctx.filter_aaaa && !client_do { - strip_https_ipv6_hints(&mut response); + strip_svcb_ipv6_hints(&mut response); } // Echo EDNS back if client sent it @@ -511,11 +511,16 @@ fn strip_dnssec_records(pkt: &mut DnsPacket) { pkt.resources.retain(|r| !is_dnssec_record(r)); } -fn strip_https_ipv6_hints(pkt: &mut DnsPacket) { +/// SVCB and HTTPS share the same RDATA wire format (RFC 9460), so the +/// ipv6hint strip applies to both. SVCB has no `QueryType` variant — it +/// arrives as `UNKNOWN { qtype: 64, .. }`. +const SVCB_QTYPE: u16 = 64; + +fn strip_svcb_ipv6_hints(pkt: &mut DnsPacket) { let https_qtype = QueryType::HTTPS.to_num(); pkt.for_each_record_mut(|rec| { if let DnsRecord::UNKNOWN { qtype, data, .. } = rec { - if *qtype == https_qtype { + if *qtype == https_qtype || *qtype == SVCB_QTYPE { if let Some(new_data) = crate::svcb::strip_ipv6hint(data) { *data = new_data; } @@ -1274,10 +1279,12 @@ mod tests { } #[tokio::test] - async fn pipeline_filter_aaaa_strips_ipv6hint_from_https() { - // Build an HTTPS record (type 65) with alpn + ipv6hint, cache it, - // then query with filter_aaaa on — the returned rdata must have - // ipv6hint (20 bytes) removed. + async fn pipeline_filter_aaaa_strips_ipv6hint_from_https_and_svcb() { + // HTTPS (type 65) and SVCB (type 64) share the same RDATA wire + // format (RFC 9460); the filter must strip ipv6hint from both. + // Build one HTTPS record with alpn + ipv6hint, then re-key it as + // SVCB and assert the returned rdata has the 20-byte hint removed + // in both cases. let rdata = crate::svcb::build_rdata( 1, &[], @@ -1306,31 +1313,50 @@ mod tests { ttl: 300, }); + // Seed an SVCB record (type 64) under a different name — same wire + // format as HTTPS, must get the same treatment. + let mut svcb_pkt = pkt.clone(); + svcb_pkt.questions[0].name = "svc.test".to_string(); + svcb_pkt.questions[0].qtype = QueryType::UNKNOWN(64); + if let DnsRecord::UNKNOWN { domain, qtype, .. } = &mut svcb_pkt.answers[0] { + *domain = "svc.test".to_string(); + *qtype = 64; + } + let mut ctx = crate::testutil::test_ctx().await; ctx.filter_aaaa = true; ctx.cache .write() .unwrap() .insert("hints.test", QueryType::HTTPS, &pkt); + ctx.cache + .write() + .unwrap() + .insert("svc.test", QueryType::UNKNOWN(64), &svcb_pkt); let ctx = Arc::new(ctx); - let (resp, path) = resolve_in_test(&ctx, "hints.test", QueryType::HTTPS).await; - assert_eq!(path, QueryPath::Cached); - assert_eq!(resp.answers.len(), 1); - match &resp.answers[0] { - DnsRecord::UNKNOWN { data, .. } => { - assert!( - data.len() < rdata.len(), - "ipv6hint (20 bytes) must be removed" - ); - // Bytes for key=6 must not appear at any 4-byte boundary in the - // params section — cheap structural check. - assert!( - !data.windows(4).any(|w| w == [0, 6, 0, 16]), - "ipv6hint TLV header must be absent" - ); + for (name, qtype, label) in [ + ("hints.test", QueryType::HTTPS, "HTTPS"), + ("svc.test", QueryType::UNKNOWN(64), "SVCB"), + ] { + let (resp, path) = resolve_in_test(&ctx, name, qtype).await; + assert_eq!(path, QueryPath::Cached, "{label}"); + assert_eq!(resp.answers.len(), 1, "{label}"); + match &resp.answers[0] { + DnsRecord::UNKNOWN { data, .. } => { + assert!( + data.len() < rdata.len(), + "{label}: ipv6hint (20 bytes) must be removed" + ); + // Bytes for key=6 must not appear at any 4-byte boundary in the + // params section — cheap structural check. + assert!( + !data.windows(4).any(|w| w == [0, 6, 0, 16]), + "{label}: ipv6hint TLV header must be absent" + ); + } + other => panic!("{label}: expected UNKNOWN record, got {other:?}"), } - other => panic!("expected UNKNOWN record, got {:?}", other), } } -- 2.34.1 From f9e996ae78c644d6bda63341070107601b6d78fa Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 19 Apr 2026 06:53:47 +0300 Subject: [PATCH 167/204] fmt: drop redundant comments per house style --- src/ctx.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index 0ba33c8..23b1014 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -511,9 +511,6 @@ fn strip_dnssec_records(pkt: &mut DnsPacket) { pkt.resources.retain(|r| !is_dnssec_record(r)); } -/// SVCB and HTTPS share the same RDATA wire format (RFC 9460), so the -/// ipv6hint strip applies to both. SVCB has no `QueryType` variant — it -/// arrives as `UNKNOWN { qtype: 64, .. }`. const SVCB_QTYPE: u16 = 64; fn strip_svcb_ipv6_hints(pkt: &mut DnsPacket) { @@ -1280,11 +1277,6 @@ mod tests { #[tokio::test] async fn pipeline_filter_aaaa_strips_ipv6hint_from_https_and_svcb() { - // HTTPS (type 65) and SVCB (type 64) share the same RDATA wire - // format (RFC 9460); the filter must strip ipv6hint from both. - // Build one HTTPS record with alpn + ipv6hint, then re-key it as - // SVCB and assert the returned rdata has the 20-byte hint removed - // in both cases. let rdata = crate::svcb::build_rdata( 1, &[], @@ -1313,8 +1305,6 @@ mod tests { ttl: 300, }); - // Seed an SVCB record (type 64) under a different name — same wire - // format as HTTPS, must get the same treatment. let mut svcb_pkt = pkt.clone(); svcb_pkt.questions[0].name = "svc.test".to_string(); svcb_pkt.questions[0].qtype = QueryType::UNKNOWN(64); -- 2.34.1 From 24610ae3fe759de22dff1fbcdc164fed937f52e9 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 19 Apr 2026 07:49:35 +0300 Subject: [PATCH 168/204] feat(question): add SVCB, LOC, NAPTR variants to QueryType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logs were printing UNKNOWN(64), UNKNOWN(29), UNKNOWN(35) for SVCB, LOC, and NAPTR — three RR types that have been registered for years and show up in the wild (notably SVCB via RFC 9462 DDR clients querying _dns.resolver.arpa). Adds the variants and replaces the SVCB_QTYPE u16 const introduced in #119 with QueryType::SVCB.to_num(), matching the HTTPS path. Closes #114. --- src/ctx.rs | 11 +++++------ src/question.rs | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index 23b1014..71e81c9 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -511,13 +511,12 @@ fn strip_dnssec_records(pkt: &mut DnsPacket) { pkt.resources.retain(|r| !is_dnssec_record(r)); } -const SVCB_QTYPE: u16 = 64; - fn strip_svcb_ipv6_hints(pkt: &mut DnsPacket) { let https_qtype = QueryType::HTTPS.to_num(); + let svcb_qtype = QueryType::SVCB.to_num(); pkt.for_each_record_mut(|rec| { if let DnsRecord::UNKNOWN { qtype, data, .. } = rec { - if *qtype == https_qtype || *qtype == SVCB_QTYPE { + if *qtype == https_qtype || *qtype == svcb_qtype { if let Some(new_data) = crate::svcb::strip_ipv6hint(data) { *data = new_data; } @@ -1307,7 +1306,7 @@ mod tests { let mut svcb_pkt = pkt.clone(); svcb_pkt.questions[0].name = "svc.test".to_string(); - svcb_pkt.questions[0].qtype = QueryType::UNKNOWN(64); + svcb_pkt.questions[0].qtype = QueryType::SVCB; if let DnsRecord::UNKNOWN { domain, qtype, .. } = &mut svcb_pkt.answers[0] { *domain = "svc.test".to_string(); *qtype = 64; @@ -1322,12 +1321,12 @@ mod tests { ctx.cache .write() .unwrap() - .insert("svc.test", QueryType::UNKNOWN(64), &svcb_pkt); + .insert("svc.test", QueryType::SVCB, &svcb_pkt); let ctx = Arc::new(ctx); for (name, qtype, label) in [ ("hints.test", QueryType::HTTPS, "HTTPS"), - ("svc.test", QueryType::UNKNOWN(64), "SVCB"), + ("svc.test", QueryType::SVCB, "SVCB"), ] { let (resp, path) = resolve_in_test(&ctx, name, qtype).await; assert_eq!(path, QueryPath::Cached, "{label}"); diff --git a/src/question.rs b/src/question.rs index dc23dd1..9523339 100644 --- a/src/question.rs +++ b/src/question.rs @@ -12,13 +12,16 @@ pub enum QueryType { MX, // 15 TXT, // 16 AAAA, // 28 + LOC, // 29 SRV, // 33 + NAPTR, // 35 DS, // 43 RRSIG, // 46 NSEC, // 47 DNSKEY, // 48 NSEC3, // 50 OPT, // 41 (EDNS0 pseudo-type) + SVCB, // 64 HTTPS, // 65 } @@ -34,13 +37,16 @@ impl QueryType { QueryType::MX => 15, QueryType::TXT => 16, QueryType::AAAA => 28, + QueryType::LOC => 29, QueryType::SRV => 33, + QueryType::NAPTR => 35, QueryType::OPT => 41, QueryType::DS => 43, QueryType::RRSIG => 46, QueryType::NSEC => 47, QueryType::DNSKEY => 48, QueryType::NSEC3 => 50, + QueryType::SVCB => 64, QueryType::HTTPS => 65, } } @@ -55,13 +61,16 @@ impl QueryType { 15 => QueryType::MX, 16 => QueryType::TXT, 28 => QueryType::AAAA, + 29 => QueryType::LOC, 33 => QueryType::SRV, + 35 => QueryType::NAPTR, 41 => QueryType::OPT, 43 => QueryType::DS, 46 => QueryType::RRSIG, 47 => QueryType::NSEC, 48 => QueryType::DNSKEY, 50 => QueryType::NSEC3, + 64 => QueryType::SVCB, 65 => QueryType::HTTPS, _ => QueryType::UNKNOWN(num), } @@ -77,13 +86,16 @@ impl QueryType { QueryType::MX => "MX", QueryType::TXT => "TXT", QueryType::AAAA => "AAAA", + QueryType::LOC => "LOC", QueryType::SRV => "SRV", + QueryType::NAPTR => "NAPTR", QueryType::OPT => "OPT", QueryType::DS => "DS", QueryType::RRSIG => "RRSIG", QueryType::NSEC => "NSEC", QueryType::DNSKEY => "DNSKEY", QueryType::NSEC3 => "NSEC3", + QueryType::SVCB => "SVCB", QueryType::HTTPS => "HTTPS", QueryType::UNKNOWN(_) => "UNKNOWN", } @@ -99,12 +111,15 @@ impl QueryType { "MX" => Some(QueryType::MX), "TXT" => Some(QueryType::TXT), "AAAA" => Some(QueryType::AAAA), + "LOC" => Some(QueryType::LOC), "SRV" => Some(QueryType::SRV), + "NAPTR" => Some(QueryType::NAPTR), "DS" => Some(QueryType::DS), "RRSIG" => Some(QueryType::RRSIG), "DNSKEY" => Some(QueryType::DNSKEY), "NSEC" => Some(QueryType::NSEC), "NSEC3" => Some(QueryType::NSEC3), + "SVCB" => Some(QueryType::SVCB), "HTTPS" => Some(QueryType::HTTPS), _ => None, } -- 2.34.1 From 5725f94ff34684c9ebb7681dafc091e9940f51ee Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 19 Apr 2026 08:01:18 +0300 Subject: [PATCH 169/204] refactor(question): collapse QueryType impls behind define_qtypes! macro Adding a record type used to require 5 edits across the file (enum variant, to_num, from_num, as_str, parse_str). The macro takes a single (variant, num, str) tuple per type and generates the enum plus all four methods. UNKNOWN(u16) stays hand-coded since it carries data and can't sit in the table. src/question.rs: 156 lines -> 92 lines, no behavior change. --- src/question.rs | 179 ++++++++++++++++-------------------------------- 1 file changed, 58 insertions(+), 121 deletions(-) diff --git a/src/question.rs b/src/question.rs index 9523339..fbb3fef 100644 --- a/src/question.rs +++ b/src/question.rs @@ -1,129 +1,66 @@ use crate::buffer::BytePacketBuffer; use crate::Result; -#[derive(PartialEq, Eq, Debug, Clone, Hash, Copy)] -pub enum QueryType { - UNKNOWN(u16), - A, // 1 - NS, // 2 - CNAME, // 5 - SOA, // 6 - PTR, // 12 - MX, // 15 - TXT, // 16 - AAAA, // 28 - LOC, // 29 - SRV, // 33 - NAPTR, // 35 - DS, // 43 - RRSIG, // 46 - NSEC, // 47 - DNSKEY, // 48 - NSEC3, // 50 - OPT, // 41 (EDNS0 pseudo-type) - SVCB, // 64 - HTTPS, // 65 +macro_rules! define_qtypes { + ( $( $variant:ident = $num:literal, $str:literal ),* $(,)? ) => { + #[derive(PartialEq, Eq, Debug, Clone, Hash, Copy)] + pub enum QueryType { + UNKNOWN(u16), + $( $variant, )* + } + + impl QueryType { + pub fn to_num(&self) -> u16 { + match *self { + QueryType::UNKNOWN(x) => x, + $( QueryType::$variant => $num, )* + } + } + + pub fn from_num(num: u16) -> QueryType { + match num { + $( $num => QueryType::$variant, )* + _ => QueryType::UNKNOWN(num), + } + } + + pub fn as_str(&self) -> &'static str { + match self { + QueryType::UNKNOWN(_) => "UNKNOWN", + $( QueryType::$variant => $str, )* + } + } + + pub fn parse_str(s: &str) -> Option { + match s.to_ascii_uppercase().as_str() { + $( $str => Some(QueryType::$variant), )* + _ => None, + } + } + } + }; } -impl QueryType { - pub fn to_num(&self) -> u16 { - match *self { - QueryType::UNKNOWN(x) => x, - QueryType::A => 1, - QueryType::NS => 2, - QueryType::CNAME => 5, - QueryType::SOA => 6, - QueryType::PTR => 12, - QueryType::MX => 15, - QueryType::TXT => 16, - QueryType::AAAA => 28, - QueryType::LOC => 29, - QueryType::SRV => 33, - QueryType::NAPTR => 35, - QueryType::OPT => 41, - QueryType::DS => 43, - QueryType::RRSIG => 46, - QueryType::NSEC => 47, - QueryType::DNSKEY => 48, - QueryType::NSEC3 => 50, - QueryType::SVCB => 64, - QueryType::HTTPS => 65, - } - } - - pub fn from_num(num: u16) -> QueryType { - match num { - 1 => QueryType::A, - 2 => QueryType::NS, - 5 => QueryType::CNAME, - 6 => QueryType::SOA, - 12 => QueryType::PTR, - 15 => QueryType::MX, - 16 => QueryType::TXT, - 28 => QueryType::AAAA, - 29 => QueryType::LOC, - 33 => QueryType::SRV, - 35 => QueryType::NAPTR, - 41 => QueryType::OPT, - 43 => QueryType::DS, - 46 => QueryType::RRSIG, - 47 => QueryType::NSEC, - 48 => QueryType::DNSKEY, - 50 => QueryType::NSEC3, - 64 => QueryType::SVCB, - 65 => QueryType::HTTPS, - _ => QueryType::UNKNOWN(num), - } - } - - pub fn as_str(&self) -> &'static str { - match self { - QueryType::A => "A", - QueryType::NS => "NS", - QueryType::CNAME => "CNAME", - QueryType::SOA => "SOA", - QueryType::PTR => "PTR", - QueryType::MX => "MX", - QueryType::TXT => "TXT", - QueryType::AAAA => "AAAA", - QueryType::LOC => "LOC", - QueryType::SRV => "SRV", - QueryType::NAPTR => "NAPTR", - QueryType::OPT => "OPT", - QueryType::DS => "DS", - QueryType::RRSIG => "RRSIG", - QueryType::NSEC => "NSEC", - QueryType::DNSKEY => "DNSKEY", - QueryType::NSEC3 => "NSEC3", - QueryType::SVCB => "SVCB", - QueryType::HTTPS => "HTTPS", - QueryType::UNKNOWN(_) => "UNKNOWN", - } - } - - pub fn parse_str(s: &str) -> Option { - match s.to_ascii_uppercase().as_str() { - "A" => Some(QueryType::A), - "NS" => Some(QueryType::NS), - "CNAME" => Some(QueryType::CNAME), - "SOA" => Some(QueryType::SOA), - "PTR" => Some(QueryType::PTR), - "MX" => Some(QueryType::MX), - "TXT" => Some(QueryType::TXT), - "AAAA" => Some(QueryType::AAAA), - "LOC" => Some(QueryType::LOC), - "SRV" => Some(QueryType::SRV), - "NAPTR" => Some(QueryType::NAPTR), - "DS" => Some(QueryType::DS), - "RRSIG" => Some(QueryType::RRSIG), - "DNSKEY" => Some(QueryType::DNSKEY), - "NSEC" => Some(QueryType::NSEC), - "NSEC3" => Some(QueryType::NSEC3), - "SVCB" => Some(QueryType::SVCB), - "HTTPS" => Some(QueryType::HTTPS), - _ => None, - } - } +define_qtypes! { + A = 1, "A", + NS = 2, "NS", + CNAME = 5, "CNAME", + SOA = 6, "SOA", + PTR = 12, "PTR", + MX = 15, "MX", + TXT = 16, "TXT", + AAAA = 28, "AAAA", + LOC = 29, "LOC", + SRV = 33, "SRV", + NAPTR = 35, "NAPTR", + OPT = 41, "OPT", + DS = 43, "DS", + RRSIG = 46, "RRSIG", + NSEC = 47, "NSEC", + DNSKEY = 48, "DNSKEY", + NSEC3 = 50, "NSEC3", + SVCB = 64, "SVCB", + HTTPS = 65, "HTTPS", } #[derive(Debug, Clone, PartialEq, Eq)] -- 2.34.1 From 241c40553b76bd7f5b5a7cdbcfc4005803803797 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 20 Apr 2026 12:34:04 +0300 Subject: [PATCH 170/204] feat(odoh): ship ODoH client + self-hosted relay (RFC 9230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client (mode = "odoh"): URL-query target routing per RFC 9230 §5, /.well-known/odohconfigs TTL cache with 60s backoff on failure, HPKE seal/open via odoh-rs, strict-mode default that SERVFAILs on relay failure instead of silently downgrading. Host-equality config validation rejects same-operator relay/target pairs. Relay (`numa relay [PORT]`): axum server with /relay + /health. SSRF-hardened hostname validator (RFC 1035 ASCII + dot + dash), 4 KiB body cap at the axum layer, 5s full-transaction timeout, and static 502 on target failure (reqwest internals logged, not leaked). Aggregate counters only — no per-request logs. Observability: new `UpstreamTransport { Udp, Doh, Dot, Odoh }` orthogonal to `QueryPath`, so /stats can tally wire protocols symmetrically. Recursive mode records `Some(Udp)` for honest "bytes egressing in cleartext" accounting. Tests: Suite 8 exercises the client end-to-end via Frank Denis's public relay + Cloudflare target; Suite 9 exercises `numa relay` forwarding + guards against Cloudflare as the real far end. Full probe script at tests/probe-odoh-ecosystem.sh verifies the entire public ODoH ecosystem (4 targets + 1 relay per DNSCrypt's curated list — confirms deploying Numa's relay doubles global supply). --- Cargo.lock | 374 +++++++++++++++++++++++++- Cargo.toml | 4 + src/api.rs | 15 ++ src/config.rs | 177 +++++++++++- src/ctx.rs | 14 +- src/forward.rs | 119 +++++++-- src/lib.rs | 2 + src/main.rs | 17 ++ src/odoh.rs | 489 ++++++++++++++++++++++++++++++++++ src/relay.rs | 347 ++++++++++++++++++++++++ src/serve.rs | 39 +-- src/stats.rs | 62 ++++- tests/integration.sh | 197 ++++++++++++++ tests/probe-odoh-ecosystem.sh | 101 +++++++ 14 files changed, 1911 insertions(+), 46 deletions(-) create mode 100644 src/odoh.rs create mode 100644 src/relay.rs create mode 100755 tests/probe-odoh-ecosystem.sh diff --git a/Cargo.lock b/Cargo.lock index cf25b3a..2bfeaa6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -109,7 +144,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -257,6 +292,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -299,6 +343,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -326,6 +394,17 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.6.0" @@ -383,6 +462,15 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -473,6 +561,51 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -502,6 +635,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -576,6 +720,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -707,6 +857,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -747,6 +907,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "h2" version = "0.4.13" @@ -820,7 +990,7 @@ dependencies = [ "rand", "ring", "rustls", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tokio", "tokio-rustls", @@ -846,13 +1016,51 @@ dependencies = [ "resolv-conf", "rustls", "smallvec", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-rustls", "tracing", "webpki-roots 0.26.11", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "hpke" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65d16b699dd1a1fa2d851c970b0c971b388eeeb40f744252b8de48860980c8f" +dependencies = [ + "aead", + "aes-gcm", + "chacha20poly1305", + "digest", + "generic-array", + "hkdf", + "hmac", + "rand_core 0.9.5", + "sha2", + "subtle", + "x25519-dalek", + "zeroize", +] + [[package]] name = "http" version = "1.4.0" @@ -1081,6 +1289,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipconfig" version = "0.3.4" @@ -1344,7 +1561,9 @@ dependencies = [ "hyper", "hyper-util", "log", + "odoh-rs", "qrcode", + "rand_core 0.9.5", "rcgen", "reqwest", "ring", @@ -1363,6 +1582,19 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "odoh-rs" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb89720b7dfdddc89bc7560669d41a0bb68eb64784a4aebd293308a489f3837" +dependencies = [ + "aes-gcm", + "bytes", + "hkdf", + "hpke", + "thiserror 1.0.69", +] + [[package]] name = "oid-registry" version = "0.8.1" @@ -1394,6 +1626,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "page_size" version = "0.6.0" @@ -1483,6 +1721,29 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1561,7 +1822,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1582,7 +1843,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1630,7 +1891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1640,7 +1901,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1789,6 +2059,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -1953,6 +2232,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2046,13 +2336,33 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2298,6 +2608,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2310,6 +2626,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2351,6 +2677,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -2860,6 +3192,16 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", +] + [[package]] name = "x509-parser" version = "0.18.1" @@ -2874,7 +3216,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -2956,6 +3298,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 3b3234f..15601c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,10 @@ rustls = "0.23" tokio-rustls = "0.26" arc-swap = "1" ring = "0.17" +odoh-rs = "1" +# rand_core 0.9 matches the version odoh-rs (via hpke 0.13) depends on, so we +# share one RngCore trait and OsRng impl across the dep tree. +rand_core = { version = "0.9", features = ["os_rng"] } rustls-pemfile = "2.2.0" qrcode = { version = "0.14", default-features = false, features = ["svg"] } webpki-roots = "1" diff --git a/src/api.rs b/src/api.rs index dd1fe78..7f02920 100644 --- a/src/api.rs +++ b/src/api.rs @@ -170,6 +170,7 @@ struct StatsResponse { srtt: bool, queries: QueriesStats, transport: TransportStats, + upstream_transport: UpstreamTransportStats, cache: CacheStats, overrides: OverrideStats, blocking: BlockingStatsResponse, @@ -186,6 +187,14 @@ struct TransportStats { doh: u64, } +#[derive(Serialize)] +struct UpstreamTransportStats { + udp: u64, + doh: u64, + dot: u64, + odoh: u64, +} + #[derive(Serialize)] struct MobileStatsResponse { enabled: bool, @@ -566,6 +575,12 @@ async fn stats(State(ctx): State>) -> Json { dot: snap.transport_dot, doh: snap.transport_doh, }, + upstream_transport: UpstreamTransportStats { + udp: snap.upstream_transport_udp, + doh: snap.upstream_transport_doh, + dot: snap.upstream_transport_dot, + odoh: snap.upstream_transport_odoh, + }, cache: CacheStats { entries: cache_len, max_entries: cache_max, diff --git a/src/config.rs b/src/config.rs index 309344b..2d2f1ba 100644 --- a/src/config.rs +++ b/src/config.rs @@ -134,6 +134,7 @@ pub enum UpstreamMode { #[default] Forward, Recursive, + Odoh, } impl UpstreamMode { @@ -142,6 +143,7 @@ impl UpstreamMode { UpstreamMode::Auto => "auto", UpstreamMode::Forward => "forward", UpstreamMode::Recursive => "recursive", + UpstreamMode::Odoh => "odoh", } } } @@ -154,7 +156,7 @@ pub struct UpstreamConfig { pub address: Vec, #[serde(default = "default_upstream_port")] pub port: u16, - #[serde(default)] + #[serde(default, deserialize_with = "string_or_vec")] pub fallback: Vec, #[serde(default = "default_timeout_ms")] pub timeout_ms: u64, @@ -166,6 +168,20 @@ pub struct UpstreamConfig { pub prime_tlds: Vec, #[serde(default = "default_srtt")] pub srtt: bool, + + /// Only used when `mode = "odoh"`. Full https:// URL of the relay + /// endpoint (including path, e.g. `https://odoh-relay.numa.rs/relay`). + #[serde(default)] + pub relay: Option, + /// Only used when `mode = "odoh"`. Full https:// URL of the target + /// resolver (`https://odoh.cloudflare-dns.com/dns-query`). + #[serde(default)] + pub target: Option, + /// Only used when `mode = "odoh"`. When true (the default), relay failure + /// returns SERVFAIL instead of downgrading to the `fallback` upstream — + /// a user who configured ODoH rarely wants a silent non-oblivious path. + #[serde(default)] + pub strict: Option, } impl Default for UpstreamConfig { @@ -180,10 +196,75 @@ impl Default for UpstreamConfig { root_hints: default_root_hints(), prime_tlds: default_prime_tlds(), srtt: default_srtt(), + relay: None, + target: None, + strict: None, } } } +/// Parsed ODoH config fields. `mode = "odoh"` requires both URLs to be +/// present, to parse as `https://`, and to resolve to distinct hosts. +#[derive(Debug)] +pub struct OdohUpstream { + pub relay_url: String, + pub target_host: String, + pub target_path: String, + pub strict: bool, +} + +impl UpstreamConfig { + /// Validate and extract ODoH-specific fields. Called during `load_config` + /// so misconfigured ODoH fails fast at startup, the same care we take + /// with the DNSSEC strict boot check. + pub fn odoh_upstream(&self) -> Result { + let relay = self + .relay + .as_deref() + .ok_or("mode = \"odoh\" requires upstream.relay")?; + let target = self + .target + .as_deref() + .ok_or("mode = \"odoh\" requires upstream.target")?; + + let relay_url = reqwest::Url::parse(relay) + .map_err(|e| format!("upstream.relay invalid URL '{}': {}", relay, e))?; + let target_url = reqwest::Url::parse(target) + .map_err(|e| format!("upstream.target invalid URL '{}': {}", target, e))?; + + if relay_url.scheme() != "https" || target_url.scheme() != "https" { + return Err("upstream.relay and upstream.target must both use https://".into()); + } + if relay_url.host_str().is_none() || target_url.host_str().is_none() { + return Err("upstream.relay and upstream.target must include a host".into()); + } + if relay_url.host_str() == target_url.host_str() { + return Err(format!( + "upstream.relay and upstream.target resolve to the same host ({}); the privacy property requires distinct operators", + relay_url.host_str().unwrap_or("?") + ) + .into()); + } + + let target_host = target_url + .host_str() + .ok_or("upstream.target has no host")? + .to_string(); + let target_path = if target_url.path().is_empty() { + "/".to_string() + } else { + target_url.path().to_string() + }; + + Ok(OdohUpstream { + relay_url: relay.to_string(), + target_host, + target_path, + strict: self.strict.unwrap_or(true), + }) + } +} + fn string_or_vec<'de, D>(deserializer: D) -> std::result::Result, D::Error> where D: serde::Deserializer<'de>, @@ -643,12 +724,22 @@ mod tests { } #[test] - fn fallback_parses() { + fn fallback_array_parses() { let config: Config = toml::from_str("[upstream]\nfallback = [\"8.8.8.8\", \"1.1.1.1\"]").unwrap(); assert_eq!(config.upstream.fallback, vec!["8.8.8.8", "1.1.1.1"]); } + #[test] + fn fallback_string_parses_as_singleton_vec() { + let config: Config = + toml::from_str("[upstream]\nfallback = \"tls://1.1.1.1#cloudflare-dns.com\"").unwrap(); + assert_eq!( + config.upstream.fallback, + vec!["tls://1.1.1.1#cloudflare-dns.com"] + ); + } + #[test] fn empty_address_gives_empty_vec() { let config: Config = toml::from_str("").unwrap(); @@ -656,6 +747,88 @@ mod tests { assert!(config.upstream.fallback.is_empty()); } + // ── [upstream] mode = "odoh" ──────────────────────────────────────── + + #[test] + fn odoh_config_parses_and_validates() { + let toml = r#" +[upstream] +mode = "odoh" +relay = "https://odoh-relay.numa.rs/relay" +target = "https://odoh.cloudflare-dns.com/dns-query" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(matches!(config.upstream.mode, UpstreamMode::Odoh)); + let odoh = config.upstream.odoh_upstream().unwrap(); + assert_eq!(odoh.relay_url, "https://odoh-relay.numa.rs/relay"); + assert_eq!(odoh.target_host, "odoh.cloudflare-dns.com"); + assert_eq!(odoh.target_path, "/dns-query"); + assert!(odoh.strict, "strict defaults to true under mode=odoh"); + } + + #[test] + fn odoh_strict_false_is_honoured() { + let toml = r#" +[upstream] +mode = "odoh" +relay = "https://odoh-relay.numa.rs/relay" +target = "https://odoh.cloudflare-dns.com/dns-query" +strict = false +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(!config.upstream.odoh_upstream().unwrap().strict); + } + + #[test] + fn odoh_rejects_same_host_relay_and_target() { + let toml = r#" +[upstream] +mode = "odoh" +relay = "https://odoh.example.com/relay" +target = "https://odoh.example.com/dns-query" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let err = config.upstream.odoh_upstream().unwrap_err().to_string(); + assert!(err.contains("same host"), "got: {err}"); + } + + #[test] + fn odoh_rejects_non_https() { + let toml = r#" +[upstream] +mode = "odoh" +relay = "http://odoh-relay.numa.rs/relay" +target = "https://odoh.cloudflare-dns.com/dns-query" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let err = config.upstream.odoh_upstream().unwrap_err().to_string(); + assert!(err.contains("https"), "got: {err}"); + } + + #[test] + fn odoh_missing_relay_rejected() { + let toml = r#" +[upstream] +mode = "odoh" +target = "https://odoh.cloudflare-dns.com/dns-query" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let err = config.upstream.odoh_upstream().unwrap_err().to_string(); + assert!(err.contains("upstream.relay"), "got: {err}"); + } + + #[test] + fn odoh_missing_target_rejected() { + let toml = r#" +[upstream] +mode = "odoh" +relay = "https://odoh-relay.numa.rs/relay" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let err = config.upstream.odoh_upstream().unwrap_err().to_string(); + assert!(err.contains("upstream.target"), "got: {err}"); + } + // ── issue #82: [[forwarding]] config section ──────────────────────── #[test] diff --git a/src/ctx.rs b/src/ctx.rs index 71e81c9..511b678 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -105,6 +105,7 @@ pub async fn resolve_query( // Pipeline: overrides -> .localhost -> local zones -> special-use (unless forwarded) // -> .tld proxy -> blocklist -> cache -> forwarding -> recursive/upstream // Each lock is scoped to avoid holding MutexGuard across await points. + let mut upstream_transport: Option = None; let (response, path, dnssec) = { let override_record = ctx.overrides.read().unwrap().lookup(&qname); if let Some(record) = override_record { @@ -208,6 +209,7 @@ pub async fn resolve_query( { // Conditional forwarding takes priority over recursive mode // (e.g. Tailscale .ts.net, VPC private zones) + upstream_transport = pool.preferred().map(|u| u.transport()); match forward_with_failover_raw( raw_wire, pool, @@ -241,6 +243,9 @@ pub async fn resolve_query( } } } else if ctx.upstream_mode == UpstreamMode::Recursive { + // Recursive resolution makes UDP hops to roots/TLDs/auths; + // tag as Udp so the dashboard can aggregate plaintext-wire + // egress honestly. Only mark on success — errors stay None. let key = (qname.clone(), qtype); let (resp, path, err) = resolve_coalesced(&ctx.inflight, key, &query, || { crate::recursive::resolve_recursive( @@ -263,6 +268,8 @@ pub async fn resolve_query( qname, err.as_deref().unwrap_or("leader failed") ); + } else { + upstream_transport = Some(crate::stats::UpstreamTransport::Udp); } (resp, path, DnssecStatus::Indeterminate) } else { @@ -277,7 +284,10 @@ pub async fn resolve_query( .await { Ok(resp_wire) => match cache_and_parse(ctx, &qname, qtype, &resp_wire) { - Ok(resp) => (resp, QueryPath::Upstream, DnssecStatus::Indeterminate), + Ok(resp) => { + upstream_transport = pool.preferred().map(|u| u.transport()); + (resp, QueryPath::Upstream, DnssecStatus::Indeterminate) + } Err(e) => { error!("{} | {:?} {} | PARSE ERROR | {}", src_addr, qtype, qname, e); ( @@ -397,7 +407,7 @@ pub async fn resolve_query( // Record stats and query log { let mut s = ctx.stats.lock().unwrap(); - let total = s.record(path, transport); + let total = s.record(path, transport, upstream_transport); if total.is_multiple_of(1000) { s.log_summary(); } diff --git a/src/forward.rs b/src/forward.rs index 9bfa426..bb91fcf 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -1,14 +1,16 @@ use std::fmt; use std::net::{IpAddr, SocketAddr}; -use std::sync::RwLock; +use std::sync::{Arc, RwLock}; use std::time::{Duration, Instant}; use tokio::net::UdpSocket; use tokio::time::timeout; use crate::buffer::BytePacketBuffer; +use crate::odoh::{query_through_relay, OdohConfigCache}; use crate::packet::DnsPacket; use crate::srtt::SrttCache; +use crate::stats::UpstreamTransport; use crate::Result; #[derive(Clone)] @@ -23,16 +25,34 @@ pub enum Upstream { tls_name: Option, connector: tokio_rustls::TlsConnector, }, + /// Oblivious DNS-over-HTTPS (RFC 9230). Queries are HPKE-sealed to the + /// target and forwarded through an independent relay. Target host lives + /// on `target_config` (single source of truth — the cache keys on it). + Odoh { + relay_url: String, + target_path: String, + client: reqwest::Client, + target_config: Arc, + }, } impl Upstream { /// IP address to key SRTT tracking on, if the upstream has a stable one. - /// `Doh` routes through a URL + connection pool, so there's no single IP - /// to track; SRTT is skipped for it. + /// `Doh` and `Odoh` route through a URL + connection pool, so there's no + /// single IP to track; SRTT is skipped for them. pub fn tracked_ip(&self) -> Option { match self { Upstream::Udp(addr) | Upstream::Dot { addr, .. } => Some(addr.ip()), - Upstream::Doh { .. } => None, + Upstream::Doh { .. } | Upstream::Odoh { .. } => None, + } + } + + pub fn transport(&self) -> UpstreamTransport { + match self { + Upstream::Udp(_) => UpstreamTransport::Udp, + Upstream::Doh { .. } => UpstreamTransport::Doh, + Upstream::Dot { .. } => UpstreamTransport::Dot, + Upstream::Odoh { .. } => UpstreamTransport::Odoh, } } } @@ -43,6 +63,20 @@ impl PartialEq for Upstream { (Self::Udp(a), Self::Udp(b)) => a == b, (Self::Doh { url: a, .. }, Self::Doh { url: b, .. }) => a == b, (Self::Dot { addr: a, .. }, Self::Dot { addr: b, .. }) => a == b, + ( + Self::Odoh { + relay_url: ra, + target_path: pa, + target_config: ca, + .. + }, + Self::Odoh { + relay_url: rb, + target_path: pb, + target_config: cb, + .. + }, + ) => ra == rb && pa == pb && ca.target_host() == cb.target_host(), _ => false, } } @@ -63,6 +97,18 @@ impl fmt::Display for Upstream { Some(name) => write!(f, "tls://{}#{}", addr, name), None => write!(f, "tls://{}", addr), }, + Upstream::Odoh { + relay_url, + target_path, + target_config, + .. + } => write!( + f, + "odoh://{}{} via {}", + target_config.target_host(), + target_path, + relay_url + ), } } } @@ -82,22 +128,20 @@ pub(crate) fn parse_upstream_addr( Err(format!("invalid upstream address: {}", s)) } +/// Parse a slice of upstream address strings into `Upstream` values, failing +/// on the first invalid entry. +pub fn parse_upstream_list(addrs: &[String], default_port: u16) -> Result> { + addrs + .iter() + .map(|s| parse_upstream(s, default_port)) + .collect() +} + pub fn parse_upstream(s: &str, default_port: u16) -> Result { if s.starts_with("https://") { - let client = reqwest::Client::builder() - .use_rustls_tls() - .http2_initial_stream_window_size(65_535) - .http2_initial_connection_window_size(65_535) - .http2_keep_alive_interval(Duration::from_secs(15)) - .http2_keep_alive_while_idle(true) - .http2_keep_alive_timeout(Duration::from_secs(10)) - .pool_idle_timeout(Duration::from_secs(300)) - .pool_max_idle_per_host(1) - .build() - .unwrap_or_default(); return Ok(Upstream::Doh { url: s.to_string(), - client, + client: build_https_client(), }); } // tls://IP:PORT#hostname or tls://IP#hostname (default port 853) @@ -118,6 +162,33 @@ pub fn parse_upstream(s: &str, default_port: u16) -> Result { Ok(Upstream::Udp(addr)) } +/// HTTP/2 client tuned for DoH/ODoH: small windows for low latency, long-lived +/// keep-alive. Shared by the DoH upstream and the ODoH config-fetcher + +/// seal/open path. Pool defaults to one idle conn per host — good for +/// resolvers that talk to a single upstream; relays that fan out to many +/// targets should use [`build_https_client_with_pool`]. +pub fn build_https_client() -> reqwest::Client { + build_https_client_with_pool(1) +} + +/// Same shape as [`build_https_client`], but caller picks +/// `pool_max_idle_per_host`. Relay workloads hit many distinct target hosts +/// and benefit from a larger pool so warm connections survive concurrent +/// fan-out. +pub fn build_https_client_with_pool(pool_max_idle_per_host: usize) -> reqwest::Client { + reqwest::Client::builder() + .use_rustls_tls() + .http2_initial_stream_window_size(65_535) + .http2_initial_connection_window_size(65_535) + .http2_keep_alive_interval(Duration::from_secs(15)) + .http2_keep_alive_while_idle(true) + .http2_keep_alive_timeout(Duration::from_secs(10)) + .pool_idle_timeout(Duration::from_secs(300)) + .pool_max_idle_per_host(pool_max_idle_per_host) + .build() + .unwrap_or_default() +} + fn build_dot_connector() -> Result { let _ = rustls::crypto::ring::default_provider().install_default(); let mut root_store = rustls::RootCertStore::empty(); @@ -282,6 +353,22 @@ pub async fn forward_query_raw( tls_name, connector, } => forward_dot_raw(wire, *addr, tls_name, connector, timeout_duration).await, + Upstream::Odoh { + relay_url, + target_path, + client, + target_config, + } => { + query_through_relay( + wire, + relay_url, + target_path, + client, + target_config, + timeout_duration, + ) + .await + } } } diff --git a/src/lib.rs b/src/lib.rs index bce8833..aec568d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod health; pub mod lan; pub mod mobile_api; pub mod mobileconfig; +pub mod odoh; pub mod override_store; pub mod packet; pub mod proxy; @@ -20,6 +21,7 @@ pub mod query_log; pub mod question; pub mod record; pub mod recursive; +pub mod relay; pub mod serve; pub mod service_store; pub mod setup_phone; diff --git a/src/main.rs b/src/main.rs index 34bf747..e077a2f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,6 +60,22 @@ fn main() -> numa::Result<()> { .block_on(numa::setup_phone::run()) .map_err(|e| e.into()); } + "relay" => { + let port: u16 = std::env::args() + .nth(2) + .as_deref() + .and_then(|s| s.parse().ok()) + .unwrap_or(8443); + let addr: std::net::SocketAddr = ([127, 0, 0, 1], port).into(); + eprintln!( + "\x1b[1;38;2;192;98;58mNuma\x1b[0m — ODoH relay on {}\n", + addr + ); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + return runtime.block_on(numa::relay::run(addr)); + } "lan" => { let sub = std::env::args().nth(2).unwrap_or_default(); let config_path = std::env::args() @@ -91,6 +107,7 @@ fn main() -> numa::Result<()> { eprintln!(" service status Check if the service is running"); eprintln!(" lan on Enable LAN service discovery (mDNS)"); eprintln!(" lan off Disable LAN service discovery"); + eprintln!(" relay [PORT] Run as an ODoH relay (RFC 9230, default port 8443)"); eprintln!(" setup-phone Generate a QR code to install Numa DoT on a phone"); eprintln!(" help Show this help"); eprintln!(); diff --git a/src/odoh.rs b/src/odoh.rs new file mode 100644 index 0000000..2cfa9c5 --- /dev/null +++ b/src/odoh.rs @@ -0,0 +1,489 @@ +//! ODoH target-config fetcher and TTL cache (RFC 9230 §6). +//! +//! ## Ciphersuite policy +//! `odoh-rs` deserialization rejects any config whose KEM/KDF/AEAD triple is +//! not the mandatory `(X25519, HKDF-SHA256, AES-128-GCM)` (see +//! `ObliviousDoHConfigContents::deserialize`). This is stricter than the +//! plan's "pick the mandatory suite if mixed": a response containing *any* +//! non-mandatory config fails parse entirely. Real-world targets publish a +//! single mandatory config, so this is fine in practice; revisit if a target +//! that matters starts mixing suites. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use arc_swap::ArcSwapOption; +use odoh_rs::{ + ObliviousDoHConfigContents, ObliviousDoHConfigs, ObliviousDoHMessage, + ObliviousDoHMessagePlaintext, +}; +use rand_core::{OsRng, TryRngCore}; +use reqwest::header::HeaderMap; +use tokio::sync::Mutex; +use tokio::time::timeout; + +use crate::Result; + +/// MIME type used for both directions of the ODoH exchange (RFC 9230 §4). +const ODOH_CONTENT_TYPE: &str = "application/oblivious-dns-message"; + +/// Cap on the response body we read into memory when the relay returns +/// non-success. Protects against a hostile relay streaming a huge body on +/// the error path; keeps enough room to carry a human-readable reason. +const ERROR_BODY_PREVIEW_BYTES: usize = 1024; + +/// Fallback TTL when the target's response lacks a usable `Cache-Control` +/// directive. RFC 9230 §6.2 places no hard floor; 24 h matches what Cloudflare +/// publishes in practice. +const DEFAULT_CONFIG_TTL: Duration = Duration::from_secs(24 * 60 * 60); + +/// Cap on any TTL we'll honour, regardless of what the target advertises. +/// Keeps a misconfigured server from pinning an old key indefinitely. +const MAX_CONFIG_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60); + +/// After a failed `/.well-known/odohconfigs` fetch, refuse to refetch again +/// within this window — a target that is genuinely broken would otherwise +/// receive one request per query. Queries that arrive during the backoff +/// return the cached error immediately. +const REFRESH_BACKOFF: Duration = Duration::from_secs(60); + +/// Parsed ODoH target config plus the freshness metadata needed to age it out. +#[derive(Debug)] +pub struct OdohTargetConfig { + pub contents: ObliviousDoHConfigContents, + pub key_id: Vec, + expires_at: Instant, +} + +impl OdohTargetConfig { + pub fn is_expired(&self) -> bool { + Instant::now() >= self.expires_at + } +} + +struct FailedRefresh { + at: Instant, + err: String, +} + +/// TTL-gated cache of a single target's HPKE config. +/// +/// Reads go through `ArcSwapOption` (lock-free hot path). Refreshes serialize +/// on an async mutex so a burst of simultaneous misses produces a single +/// outbound fetch, and a failed refresh blocks subsequent refetches for +/// [`REFRESH_BACKOFF`] to prevent hot-looping against a broken target. +pub struct OdohConfigCache { + target_host: String, + configs_url: String, + client: reqwest::Client, + current: ArcSwapOption, + last_failure: ArcSwapOption, + refresh_lock: Mutex<()>, +} + +impl OdohConfigCache { + pub fn new(target_host: String, client: reqwest::Client) -> Self { + let configs_url = format!("https://{}/.well-known/odohconfigs", target_host); + Self { + target_host, + configs_url, + client, + current: ArcSwapOption::from(None), + last_failure: ArcSwapOption::from(None), + refresh_lock: Mutex::new(()), + } + } + + pub fn target_host(&self) -> &str { + &self.target_host + } + + /// Return a valid config, refetching when the cache is cold or expired. + /// Within [`REFRESH_BACKOFF`] of a failed refresh, returns the cached + /// error without issuing another fetch. + pub async fn get(&self) -> Result> { + if let Some(cfg) = self.current.load_full() { + if !cfg.is_expired() { + return Ok(cfg); + } + } + + if let Some(err) = self.backoff_error() { + return Err(err); + } + + let _guard = self.refresh_lock.lock().await; + + // Another task may have refreshed or failed while we waited. + if let Some(cfg) = self.current.load_full() { + if !cfg.is_expired() { + return Ok(cfg); + } + } + if let Some(err) = self.backoff_error() { + return Err(err); + } + + match fetch_odoh_config(&self.client, &self.configs_url).await { + Ok(fresh) => { + let fresh = Arc::new(fresh); + self.current.store(Some(fresh.clone())); + self.last_failure.store(None); + Ok(fresh) + } + Err(e) => { + let msg = format!("ODoH config fetch failed: {e}"); + self.last_failure.store(Some(Arc::new(FailedRefresh { + at: Instant::now(), + err: msg.clone(), + }))); + Err(msg.into()) + } + } + } + + /// Drop the cached config. Called after the target rejects ciphertext + /// (key rotation race) so the next `get()` refetches. + pub fn invalidate(&self) { + self.current.store(None); + } + + fn backoff_error(&self) -> Option { + let fail = self.last_failure.load_full()?; + if fail.at.elapsed() < REFRESH_BACKOFF { + Some(format!("{} (backoff active)", fail.err).into()) + } else { + None + } + } +} + +/// Fetch `/.well-known/odohconfigs` from `configs_url` and parse it into an +/// [`OdohTargetConfig`]. The TTL is taken from the response's +/// `Cache-Control: max-age=`, clamped to [`DEFAULT_CONFIG_TTL`, +/// [`MAX_CONFIG_TTL`]] when absent or obviously wrong. +pub async fn fetch_odoh_config( + client: &reqwest::Client, + configs_url: &str, +) -> Result { + let resp = client.get(configs_url).send().await?.error_for_status()?; + let ttl = cache_control_ttl(resp.headers()).unwrap_or(DEFAULT_CONFIG_TTL); + let body = resp.bytes().await?; + parse_odoh_config(&body, ttl) +} + +fn parse_odoh_config(body: &[u8], ttl: Duration) -> Result { + let mut buf = body; + let configs: ObliviousDoHConfigs = odoh_rs::parse(&mut buf) + .map_err(|e| format!("failed to parse ObliviousDoHConfigs: {e}"))?; + let first = configs + .into_iter() + .next() + .ok_or("target published no ODoH configs with a supported version + ciphersuite")?; + let contents: ObliviousDoHConfigContents = first.into(); + let key_id = contents + .identifier() + .map_err(|e| format!("failed to derive key_id from ODoH config: {e}"))?; + Ok(OdohTargetConfig { + contents, + key_id, + expires_at: Instant::now() + ttl.min(MAX_CONFIG_TTL), + }) +} + +/// Send a DNS wire query through an ODoH relay to a target and return the +/// plaintext DNS wire response. +/// +/// Flow: fetch the target's HPKE config (cached), seal the query, POST to the +/// relay with `Targethost`/`Targetpath` headers, then unseal the response. +/// On seal/unseal failure we invalidate the cache and retry once — this +/// handles the benign race where the target rotated its key between our +/// cached config and the POST. +pub async fn query_through_relay( + wire: &[u8], + relay_url: &str, + target_path: &str, + client: &reqwest::Client, + cache: &OdohConfigCache, + timeout_duration: Duration, +) -> Result> { + let req = OdohRequest { + wire, + relay_url, + target_path, + client, + cache, + timeout: timeout_duration, + }; + match attempt_query(&req).await { + Ok(v) => Ok(v), + Err(AttemptError::KeyRotation(_)) => { + cache.invalidate(); + attempt_query(&req).await.map_err(AttemptError::into_error) + } + Err(e) => Err(e.into_error()), + } +} + +struct OdohRequest<'a> { + wire: &'a [u8], + relay_url: &'a str, + target_path: &'a str, + client: &'a reqwest::Client, + cache: &'a OdohConfigCache, + timeout: Duration, +} + +/// Classification used only by the retry path in [`query_through_relay`]. +enum AttemptError { + /// Target signalled the config we used is stale (key rotation race). + /// Callers should invalidate the cache and retry exactly once. + KeyRotation(String), + /// Any other failure — transport, timeout, malformed response. + Other(crate::Error), +} + +impl AttemptError { + fn into_error(self) -> crate::Error { + match self { + AttemptError::KeyRotation(m) => format!("ODoH key rotation race: {m}").into(), + AttemptError::Other(e) => e, + } + } +} + +async fn attempt_query(req: &OdohRequest<'_>) -> std::result::Result, AttemptError> { + let cfg = req.cache.get().await.map_err(AttemptError::Other)?; + + let plaintext = ObliviousDoHMessagePlaintext::new(req.wire, 0); + // rand_core 0.9's OsRng is fallible-only; wrap for the infallible bound. + let mut os = OsRng; + let mut rng = os.unwrap_mut(); + let (encrypted_query, client_secret) = + odoh_rs::encrypt_query(&plaintext, &cfg.contents, &mut rng) + .map_err(|e| AttemptError::Other(format!("ODoH encrypt failed: {e}").into()))?; + let body = odoh_rs::compose(&encrypted_query) + .map_err(|e| AttemptError::Other(format!("ODoH compose failed: {e}").into()))? + .freeze(); + + // RFC 9230 §5 and the reference client use URL query parameters, not + // HTTP headers, to carry the target routing. `Targethost`/`Targetpath` + // headers cause relays to treat the request as an unspecified-target and + // reject it. + let (status, resp_body) = timeout(req.timeout, async { + let resp = req + .client + .post(req.relay_url) + .header(reqwest::header::CONTENT_TYPE, ODOH_CONTENT_TYPE) + .header(reqwest::header::ACCEPT, ODOH_CONTENT_TYPE) + .header(reqwest::header::CACHE_CONTROL, "no-cache, no-store") + .query(&[ + ("targethost", req.cache.target_host()), + ("targetpath", req.target_path), + ]) + .body(body) + .send() + .await?; + let status = resp.status(); + let body = resp.bytes().await?; + Ok::<_, reqwest::Error>((status, body)) + }) + .await + .map_err(|_| AttemptError::Other("ODoH relay request timed out".into()))? + .map_err(|e| AttemptError::Other(format!("ODoH relay request failed: {e}").into()))?; + + // RFC 9230 §4.3 expects a target that can't decrypt to reply with a DNS + // error in a sealed 200 response; a 401 from the relay/target is the + // practical signal that our cached HPKE key is stale. Treat 400 as a + // client-side bug (malformed ODoH envelope) — retrying would loop-fail. + if !status.is_success() { + let preview_len = resp_body.len().min(ERROR_BODY_PREVIEW_BYTES); + let body_preview = String::from_utf8_lossy(&resp_body[..preview_len]); + let msg = format!("ODoH relay returned {status}: {}", body_preview.trim()); + return Err(if status.as_u16() == 401 { + AttemptError::KeyRotation(msg) + } else { + AttemptError::Other(msg.into()) + }); + } + + let mut buf = resp_body; + let encrypted_response: ObliviousDoHMessage = odoh_rs::parse(&mut buf) + .map_err(|e| AttemptError::Other(format!("ODoH response parse failed: {e}").into()))?; + let plaintext_response = + odoh_rs::decrypt_response(&plaintext, &encrypted_response, client_secret) + .map_err(|e| AttemptError::KeyRotation(format!("ODoH decrypt failed: {e}")))?; + + Ok(plaintext_response.into_msg().to_vec()) +} + +fn cache_control_ttl(headers: &HeaderMap) -> Option { + let cc = headers.get(reqwest::header::CACHE_CONTROL)?.to_str().ok()?; + for directive in cc.split(',') { + let directive = directive.trim(); + if let Some(rest) = directive.strip_prefix("max-age=") { + if let Ok(secs) = rest.trim().parse::() { + if secs > 0 { + return Some(Duration::from_secs(secs)); + } + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use odoh_rs::{ObliviousDoHConfig, ObliviousDoHKeyPair}; + + // RFC 9180 HPKE IDs for the sole ODoH mandatory suite: + // KEM = X25519, KDF = HKDF-SHA256, AEAD = AES-128-GCM. + const KEM_X25519: u16 = 0x0020; + const KDF_SHA256: u16 = 0x0001; + const AEAD_AES128GCM: u16 = 0x0001; + + fn synth_configs_bytes() -> Vec { + let kp = ObliviousDoHKeyPair::from_parameters( + KEM_X25519, + KDF_SHA256, + AEAD_AES128GCM, + &[0u8; 32], + ); + let pk = kp.public().clone(); + let configs: ObliviousDoHConfigs = vec![ObliviousDoHConfig::from(pk)].into(); + odoh_rs::compose(&configs).unwrap().to_vec() + } + + #[test] + fn parse_accepts_well_formed_config() { + let bytes = synth_configs_bytes(); + let cfg = parse_odoh_config(&bytes, Duration::from_secs(3600)).unwrap(); + assert!(!cfg.key_id.is_empty()); + assert!(!cfg.is_expired()); + } + + #[test] + fn parse_rejects_garbage() { + let bytes = [0xffu8; 16]; + assert!(parse_odoh_config(&bytes, Duration::from_secs(3600)).is_err()); + } + + #[test] + fn parse_rejects_empty() { + assert!(parse_odoh_config(&[], Duration::from_secs(3600)).is_err()); + } + + #[test] + fn ttl_capped_at_max() { + let bytes = synth_configs_bytes(); + let cfg = parse_odoh_config(&bytes, Duration::from_secs(100 * 24 * 60 * 60)).unwrap(); + let remaining = cfg.expires_at.saturating_duration_since(Instant::now()); + assert!(remaining <= MAX_CONFIG_TTL); + assert!(remaining >= MAX_CONFIG_TTL - Duration::from_secs(1)); + } + + #[test] + fn cache_control_parses_max_age() { + let mut h = HeaderMap::new(); + h.insert("cache-control", "public, max-age=86400".parse().unwrap()); + assert_eq!(cache_control_ttl(&h), Some(Duration::from_secs(86400))); + } + + #[test] + fn cache_control_ignores_max_age_zero() { + let mut h = HeaderMap::new(); + h.insert("cache-control", "max-age=0, no-store".parse().unwrap()); + assert_eq!(cache_control_ttl(&h), None); + } + + #[test] + fn cache_control_missing_falls_back() { + let h = HeaderMap::new(); + assert_eq!(cache_control_ttl(&h), None); + } + + #[test] + fn is_expired_tracks_ttl() { + let bytes = synth_configs_bytes(); + let mut cfg = parse_odoh_config(&bytes, Duration::from_secs(3600)).unwrap(); + assert!(!cfg.is_expired()); + cfg.expires_at = Instant::now() - Duration::from_secs(1); + assert!(cfg.is_expired()); + } + + #[tokio::test] + async fn cache_backoff_blocks_refetch_after_failure() { + // Point the cache at a host that does not exist so the fetch fails + // deterministically; this exercises the backoff wiring without a + // network round-trip succeeding. + let cache = OdohConfigCache::new( + "odoh-target.invalid".to_string(), + reqwest::Client::builder() + .timeout(Duration::from_millis(200)) + .build() + .unwrap(), + ); + + let first = cache.get().await; + assert!(first.is_err(), "first fetch must fail against invalid host"); + + // Within the backoff window, the cached error is returned immediately. + let second = cache.get().await.unwrap_err().to_string(); + assert!( + second.contains("backoff active"), + "expected backoff hint, got: {second}" + ); + + // Reaching past the backoff window allows a fresh attempt — simulate + // by rewinding the recorded failure timestamp. + cache.last_failure.store(Some(Arc::new(FailedRefresh { + at: Instant::now() - (REFRESH_BACKOFF + Duration::from_secs(1)), + err: "prior".to_string(), + }))); + let third = cache.get().await.unwrap_err().to_string(); + assert!( + !third.contains("backoff active"), + "expected fresh fetch attempt, got: {third}" + ); + } + + /// Round-trip the HPKE seal/unseal path in isolation from HTTP, using the + /// odoh-rs primitives that `query_through_relay` wires together. Guards + /// against silently breaking the crypto glue if we refactor that path. + #[test] + fn seal_unseal_round_trip() { + use odoh_rs::{decrypt_query, encrypt_response, ResponseNonce}; + + let kp = ObliviousDoHKeyPair::from_parameters( + KEM_X25519, + KDF_SHA256, + AEAD_AES128GCM, + &[0u8; 32], + ); + + let query_wire = b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x07example\x03com\x00\x00\x01\x00\x01"; + let query_pt = ObliviousDoHMessagePlaintext::new(query_wire, 0); + let mut os = OsRng; + let mut rng = os.unwrap_mut(); + let (query_enc, client_secret) = + odoh_rs::encrypt_query(&query_pt, kp.public(), &mut rng).unwrap(); + + let (query_back, server_secret) = decrypt_query(&query_enc, &kp).unwrap(); + assert_eq!(query_back.into_msg().as_ref(), query_wire); + + let response_wire = b"\x12\x34\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00"; + let response_pt = ObliviousDoHMessagePlaintext::new(response_wire, 0); + let response_enc = encrypt_response( + &query_pt, + &response_pt, + server_secret, + ResponseNonce::default(), + ) + .unwrap(); + + let response_back = + odoh_rs::decrypt_response(&query_pt, &response_enc, client_secret).unwrap(); + assert_eq!(response_back.into_msg().as_ref(), response_wire); + } +} diff --git a/src/relay.rs b/src/relay.rs new file mode 100644 index 0000000..8d6ab40 --- /dev/null +++ b/src/relay.rs @@ -0,0 +1,347 @@ +//! ODoH relay (RFC 9230 §5) — the forward-without-reading half of the +//! protocol. Runs `numa relay`; skips all resolver initialisation (no port +//! 53, no cache, no recursion, no dashboard). The relay never reads the +//! HPKE-sealed payload and keeps no per-request logs — only aggregate +//! counters. + +use std::net::SocketAddr; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use axum::body::Bytes; +use axum::extract::{DefaultBodyLimit, Query, State}; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::Router; +use log::{error, info}; +use serde::Deserialize; +use tokio::net::TcpListener; + +use crate::forward::build_https_client_with_pool; +use crate::Result; + +const ODOH_CONTENT_TYPE: &str = "application/oblivious-dns-message"; + +/// Cap on the opaque body we accept from a client. ODoH envelopes are +/// ~100–300 bytes in practice; anything larger is malformed or hostile. +const MAX_BODY_BYTES: usize = 4 * 1024; + +/// Cap on the body we read back from the target before streaming to client. +/// Slightly larger: target responses carry DNS answers plus HPKE overhead. +const MAX_TARGET_RESPONSE_BYTES: usize = 8 * 1024; + +/// Covers the whole client-to-target round trip — not just `.send()` — so a +/// slow-drip target can't hang a worker indefinitely after headers arrive. +const TARGET_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); + +/// The relay hits many distinct target hosts on behalf of clients. A +/// per-host idle pool of 4 keeps warm TLS connections available for concurrent +/// fan-out without blowing up memory on a small VPS. +const RELAY_POOL_PER_HOST: usize = 4; + +#[derive(Deserialize)] +struct RelayParams { + targethost: String, + targetpath: String, +} + +struct RelayState { + client: reqwest::Client, + total_requests: AtomicU64, + forwarded_ok: AtomicU64, + forwarded_err: AtomicU64, + rejected_bad_request: AtomicU64, +} + +pub async fn run(addr: SocketAddr) -> Result<()> { + let state = Arc::new(RelayState { + client: build_https_client_with_pool(RELAY_POOL_PER_HOST), + total_requests: AtomicU64::new(0), + forwarded_ok: AtomicU64::new(0), + forwarded_err: AtomicU64::new(0), + rejected_bad_request: AtomicU64::new(0), + }); + + let app = Router::new() + .route("/relay", post(handle_relay)) + // Overrides axum's default (2 MiB) so hostile clients can't force + // the relay to buffer multi-MB bodies before our own cap check. + .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) + .route("/health", get(handle_health)) + .with_state(state); + + let listener = TcpListener::bind(addr).await?; + info!("ODoH relay listening on {}", addr); + axum::serve(listener, app).await?; + Ok(()) +} + +async fn handle_health(State(state): State>) -> impl IntoResponse { + let body = format!( + "ok\ntotal {}\nforwarded_ok {}\nforwarded_err {}\nrejected_bad_request {}\n", + state.total_requests.load(Ordering::Relaxed), + state.forwarded_ok.load(Ordering::Relaxed), + state.forwarded_err.load(Ordering::Relaxed), + state.rejected_bad_request.load(Ordering::Relaxed), + ); + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + body, + ) +} + +async fn handle_relay( + State(state): State>, + Query(params): Query, + headers: axum::http::HeaderMap, + body: Bytes, +) -> Response { + state.total_requests.fetch_add(1, Ordering::Relaxed); + + if !content_type_matches(&headers, ODOH_CONTENT_TYPE) { + state.rejected_bad_request.fetch_add(1, Ordering::Relaxed); + return ( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "expected application/oblivious-dns-message", + ) + .into_response(); + } + + if body.len() > MAX_BODY_BYTES { + state.rejected_bad_request.fetch_add(1, Ordering::Relaxed); + return (StatusCode::PAYLOAD_TOO_LARGE, "body exceeds 4 KiB cap").into_response(); + } + + if !is_valid_hostname(¶ms.targethost) || !params.targetpath.starts_with('/') { + state.rejected_bad_request.fetch_add(1, Ordering::Relaxed); + return (StatusCode::BAD_REQUEST, "invalid targethost or targetpath").into_response(); + } + + let target_url = format!("https://{}{}", params.targethost, params.targetpath); + match forward_to_target(&state.client, &target_url, body).await { + Ok((status, resp_body)) => { + state.forwarded_ok.fetch_add(1, Ordering::Relaxed); + ( + status, + [(header::CONTENT_TYPE, ODOH_CONTENT_TYPE)], + resp_body, + ) + .into_response() + } + Err(e) => { + // Log the underlying reason for operators; don't leak reqwest + // internals (which can reveal the target's TLS config, IP, etc.) + // back to arbitrary clients. + error!("relay forward to {} failed: {}", target_url, e); + state.forwarded_err.fetch_add(1, Ordering::Relaxed); + (StatusCode::BAD_GATEWAY, "target unreachable").into_response() + } + } +} + +async fn forward_to_target( + client: &reqwest::Client, + url: &str, + body: Bytes, +) -> Result<(StatusCode, Bytes)> { + let response = tokio::time::timeout(TARGET_REQUEST_TIMEOUT, async { + let resp = client + .post(url) + .header(header::CONTENT_TYPE, ODOH_CONTENT_TYPE) + .header(header::ACCEPT, ODOH_CONTENT_TYPE) + .body(body) + .send() + .await?; + let status = StatusCode::from_u16(resp.status().as_u16())?; + let resp_body = resp.bytes().await?; + Ok::<_, crate::Error>((status, resp_body)) + }) + .await + .map_err(|_| "timed out talking to target")??; + + if response.1.len() > MAX_TARGET_RESPONSE_BYTES { + return Err("target response exceeds cap".into()); + } + Ok(response) +} + +fn content_type_matches(headers: &axum::http::HeaderMap, expected: &str) -> bool { + headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|ct| ct.split(';').next().unwrap_or("").trim() == expected) + .unwrap_or(false) +} + +/// Strict DNS-hostname validator, aimed at closing the SSRF surface a naive +/// `contains('.')` check leaves open (e.g. `example.com@internal.host`, +/// `evil.com/../admin`). Requires ASCII letters/digits/dot/dash, at least +/// one dot, no leading dot or dash, length ≤ 253 per RFC 1035. +fn is_valid_hostname(h: &str) -> bool { + if h.is_empty() || h.len() > 253 || !h.contains('.') { + return false; + } + if h.starts_with('.') || h.starts_with('-') || h.ends_with('.') || h.ends_with('-') { + return false; + } + h.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn spawn_relay() -> (SocketAddr, Arc) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let state = Arc::new(RelayState { + client: build_https_client_with_pool(RELAY_POOL_PER_HOST), + total_requests: AtomicU64::new(0), + forwarded_ok: AtomicU64::new(0), + forwarded_err: AtomicU64::new(0), + rejected_bad_request: AtomicU64::new(0), + }); + + let app = Router::new() + .route("/relay", post(handle_relay)) + .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) + .route("/health", get(handle_health)) + .with_state(state.clone()); + + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + (addr, state) + } + + #[tokio::test] + async fn rejects_missing_content_type() { + let (addr, state) = spawn_relay().await; + let client = reqwest::Client::new(); + let resp = client + .post(format!( + "http://{}/relay?targethost=odoh.example.com&targetpath=/dns-query", + addr + )) + .body("body") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_eq!(state.rejected_bad_request.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn rejects_oversized_body() { + let (addr, _state) = spawn_relay().await; + let big = vec![0u8; MAX_BODY_BYTES + 1]; + let client = reqwest::Client::new(); + let resp = client + .post(format!( + "http://{}/relay?targethost=odoh.example.com&targetpath=/dns-query", + addr + )) + .header(header::CONTENT_TYPE, ODOH_CONTENT_TYPE) + .body(big) + .send() + .await + .unwrap(); + // axum's DefaultBodyLimit rejects before our handler runs, so the + // counter doesn't increment — but the status code proves the layer + // enforced the cap. Either status is acceptable evidence. + assert!(matches!( + resp.status(), + reqwest::StatusCode::PAYLOAD_TOO_LARGE | reqwest::StatusCode::BAD_REQUEST + )); + } + + #[tokio::test] + async fn rejects_targethost_without_dot() { + let (addr, state) = spawn_relay().await; + let client = reqwest::Client::new(); + let resp = client + .post(format!( + "http://{}/relay?targethost=localhost&targetpath=/dns-query", + addr + )) + .header(header::CONTENT_TYPE, ODOH_CONTENT_TYPE) + .body("body") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::BAD_REQUEST); + assert_eq!(state.rejected_bad_request.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn rejects_userinfo_ssrf_attempt() { + let (addr, state) = spawn_relay().await; + let client = reqwest::Client::new(); + // The naive contains('.') check would let this through and reqwest + // would route to `internal.host` using `evil.com` as userinfo. + let resp = client + .post(format!( + "http://{}/relay?targethost=evil.com@internal.host&targetpath=/dns-query", + addr + )) + .header(header::CONTENT_TYPE, ODOH_CONTENT_TYPE) + .body("body") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::BAD_REQUEST); + assert_eq!(state.rejected_bad_request.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn rejects_targetpath_without_leading_slash() { + let (addr, state) = spawn_relay().await; + let client = reqwest::Client::new(); + let resp = client + .post(format!( + "http://{}/relay?targethost=odoh.example.com&targetpath=dns-query", + addr + )) + .header(header::CONTENT_TYPE, ODOH_CONTENT_TYPE) + .body("body") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::BAD_REQUEST); + assert_eq!(state.rejected_bad_request.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn health_endpoint_reports_counters() { + let (addr, _state) = spawn_relay().await; + let client = reqwest::Client::new(); + let resp = client + .get(format!("http://{}/health", addr)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::OK); + let body = resp.text().await.unwrap(); + assert!(body.contains("ok\n")); + assert!(body.contains("forwarded_ok 0")); + } + + #[test] + fn hostname_validator_accepts_and_rejects() { + assert!(is_valid_hostname("odoh.cloudflare-dns.com")); + assert!(is_valid_hostname("a.b")); + assert!(!is_valid_hostname("")); + assert!(!is_valid_hostname("localhost")); + assert!(!is_valid_hostname(".leading.dot")); + assert!(!is_valid_hostname("trailing.dot.")); + assert!(!is_valid_hostname("-leading.dash")); + assert!(!is_valid_hostname("evil.com@internal.host")); + assert!(!is_valid_hostname("evil.com/../admin")); + assert!(!is_valid_hostname(&"a".repeat(254))); + } +} diff --git a/src/serve.rs b/src/serve.rs index 8e85b32..2037857 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -17,7 +17,8 @@ use crate::buffer::BytePacketBuffer; use crate::cache::DnsCache; use crate::config::{build_zone_map, load_config, ConfigLoad}; use crate::ctx::{handle_query, ServerCtx}; -use crate::forward::{parse_upstream, Upstream, UpstreamPool}; +use crate::forward::{build_https_client, parse_upstream_list, Upstream, UpstreamPool}; +use crate::odoh::OdohConfigCache; use crate::override_store::OverrideStore; use crate::query_log::QueryLog; use crate::service_store::ServiceStore; @@ -54,10 +55,7 @@ pub async fn run(config_path: String) -> crate::Result<()> { (crate::config::UpstreamMode::Recursive, false, pool, label) } else { log::warn!("recursive probe failed — falling back to Quad9 DoH"); - let client = reqwest::Client::builder() - .use_rustls_tls() - .build() - .unwrap_or_default(); + let client = build_https_client(); let url = DOH_FALLBACK.to_string(); let label = url.clone(); let pool = UpstreamPool::new(vec![Upstream::Doh { url, client }], vec![]); @@ -82,16 +80,8 @@ pub async fn run(config_path: String) -> crate::Result<()> { config.upstream.address.clone() }; - let primary: Vec = addrs - .iter() - .map(|s| parse_upstream(s, config.upstream.port)) - .collect::>>()?; - let fallback: Vec = config - .upstream - .fallback - .iter() - .map(|s| parse_upstream(s, config.upstream.port)) - .collect::>>()?; + let primary = parse_upstream_list(&addrs, config.upstream.port)?; + let fallback = parse_upstream_list(&config.upstream.fallback, config.upstream.port)?; let pool = UpstreamPool::new(primary, fallback); let label = pool.label(); @@ -102,6 +92,25 @@ pub async fn run(config_path: String) -> crate::Result<()> { label, ) } + crate::config::UpstreamMode::Odoh => { + let odoh = config.upstream.odoh_upstream()?; + let client = build_https_client(); + let target_config = Arc::new(OdohConfigCache::new(odoh.target_host, client.clone())); + let primary = vec![Upstream::Odoh { + relay_url: odoh.relay_url, + target_path: odoh.target_path, + client, + target_config, + }]; + let fallback = if odoh.strict { + Vec::new() + } else { + parse_upstream_list(&config.upstream.fallback, config.upstream.port)? + }; + let pool = UpstreamPool::new(primary, fallback); + let label = pool.label(); + (crate::config::UpstreamMode::Odoh, false, pool, label) + } }; let api_port = config.server.api_port; diff --git a/src/stats.rs b/src/stats.rs index df9127c..acedec1 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -102,6 +102,10 @@ pub struct ServerStats { transport_tcp: u64, transport_dot: u64, transport_doh: u64, + upstream_transport_udp: u64, + upstream_transport_doh: u64, + upstream_transport_dot: u64, + upstream_transport_odoh: u64, started_at: Instant, } @@ -124,6 +128,31 @@ impl Transport { } } +/// Wire protocol used for a forwarded upstream call. Orthogonal to +/// `QueryPath`: the path answers "where the answer came from"; this answers +/// "over what wire we spoke to the forwarder." Callers pass +/// `Option` — `None` for resolutions that never touched +/// a forwarder (cache/local/blocked) or for recursive mode, which has its +/// own counter via `QueryPath::Recursive`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UpstreamTransport { + Udp, + Doh, + Dot, + Odoh, +} + +impl UpstreamTransport { + pub fn as_str(&self) -> &'static str { + match self { + UpstreamTransport::Udp => "UDP", + UpstreamTransport::Doh => "DOH", + UpstreamTransport::Dot => "DOT", + UpstreamTransport::Odoh => "ODOH", + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum QueryPath { Local, @@ -202,11 +231,20 @@ impl ServerStats { transport_tcp: 0, transport_dot: 0, transport_doh: 0, + upstream_transport_udp: 0, + upstream_transport_doh: 0, + upstream_transport_dot: 0, + upstream_transport_odoh: 0, started_at: Instant::now(), } } - pub fn record(&mut self, path: QueryPath, transport: Transport) -> u64 { + pub fn record( + &mut self, + path: QueryPath, + transport: Transport, + upstream_transport: Option, + ) -> u64 { self.queries_total += 1; match path { QueryPath::Local => self.queries_local += 1, @@ -225,6 +263,14 @@ impl ServerStats { Transport::Dot => self.transport_dot += 1, Transport::Doh => self.transport_doh += 1, } + if let Some(ut) = upstream_transport { + match ut { + UpstreamTransport::Udp => self.upstream_transport_udp += 1, + UpstreamTransport::Doh => self.upstream_transport_doh += 1, + UpstreamTransport::Dot => self.upstream_transport_dot += 1, + UpstreamTransport::Odoh => self.upstream_transport_odoh += 1, + } + } self.queries_total } @@ -253,6 +299,10 @@ impl ServerStats { transport_tcp: self.transport_tcp, transport_dot: self.transport_dot, transport_doh: self.transport_doh, + upstream_transport_udp: self.upstream_transport_udp, + upstream_transport_doh: self.upstream_transport_doh, + upstream_transport_dot: self.upstream_transport_dot, + upstream_transport_odoh: self.upstream_transport_odoh, } } @@ -263,7 +313,7 @@ impl ServerStats { let secs = uptime.as_secs() % 60; log::info!( - "STATS | uptime {}h{}m{}s | total {} | fwd {} | upstream {} | recursive {} | coalesced {} | cached {} | local {} | override {} | blocked {} | errors {}", + "STATS | uptime {}h{}m{}s | total {} | fwd {} | upstream {} | recursive {} | coalesced {} | cached {} | local {} | override {} | blocked {} | errors {} | up-udp {} | up-doh {} | up-dot {} | up-odoh {}", hours, mins, secs, self.queries_total, self.queries_forwarded, @@ -275,6 +325,10 @@ impl ServerStats { self.queries_overridden, self.queries_blocked, self.upstream_errors, + self.upstream_transport_udp, + self.upstream_transport_doh, + self.upstream_transport_dot, + self.upstream_transport_odoh, ); } } @@ -295,4 +349,8 @@ pub struct StatsSnapshot { pub transport_tcp: u64, pub transport_dot: u64, pub transport_doh: u64, + pub upstream_transport_udp: u64, + pub upstream_transport_doh: u64, + pub upstream_transport_dot: u64, + pub upstream_transport_odoh: u64, } diff --git a/tests/integration.sh b/tests/integration.sh index 81bd28d..77b874f 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -854,6 +854,203 @@ sleep 1 fi # end Suite 7 +# ---- Suite 8: ODoH (Oblivious DoH via public relay + target) ---- +# Exercises the full client pipeline: /.well-known/odohconfigs fetch, +# HPKE seal/unseal, URL-query target routing (RFC 9230 §5), dashboard +# QueryPath::Odoh counter. Depends on the public ecosystem being up — +# the probe-odoh-ecosystem.sh script guards against flaky runs. +if should_run_suite 8; then +echo "" +echo "╔══════════════════════════════════════════╗" +echo "║ Suite 8: ODoH (Anonymous DNS) ║" +echo "╚══════════════════════════════════════════╝" + +run_test_suite "ODoH via edgecompute.app relay → Cloudflare target" " +[server] +bind_addr = \"127.0.0.1:5354\" +api_port = 5381 + +[upstream] +mode = \"odoh\" +relay = \"https://odoh-relay.edgecompute.app/proxy\" +target = \"https://odoh.cloudflare-dns.com/dns-query\" + +[cache] +max_entries = 10000 +min_ttl = 60 +max_ttl = 86400 + +[blocking] +enabled = false + +[proxy] +enabled = false +" + +# Re-start briefly to assert ODoH-specific observability: the odoh counter +# has to tick above zero after a query, and the stats label has to reflect +# the oblivious path. These guard against silent regressions in the +# QueryPath::Odoh tagging and the /stats serialisation. +RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 & +NUMA_PID=$! +for _ in $(seq 1 30); do + curl -sf "http://127.0.0.1:$API_PORT/health" >/dev/null 2>&1 && break + sleep 0.1 +done + +$DIG example.com A +short > /dev/null 2>&1 || true +sleep 1 + +STATS=$(curl -sf http://127.0.0.1:$API_PORT/stats 2>/dev/null) +# upstream_transport.odoh lives inside the upstream_transport object. +ODOH_COUNT=$(echo "$STATS" | grep -o '"upstream_transport":{[^}]*}' \ + | grep -o '"odoh":[0-9]*' | cut -d: -f2) +check "upstream_transport.odoh > 0 after a query" "[1-9]" "${ODOH_COUNT:-0}" + +check "Upstream label advertises odoh://" \ + "odoh://" \ + "$(echo "$STATS" | grep -o '"upstream":"[^"]*"')" + +check "Stats mode field is 'odoh'" \ + '"mode":"odoh"' \ + "$(echo "$STATS" | grep -o '"mode":"odoh"')" + +# Strict-mode failure path: a clearly-unreachable relay must produce +# SERVFAIL without silent downgrade. We hijack the config to point at +# an .invalid host so we don't rely on external uptime. +kill "$NUMA_PID" 2>/dev/null || true +wait "$NUMA_PID" 2>/dev/null || true +sleep 1 + +cat > "$CONFIG" << 'CONF' +[server] +bind_addr = "127.0.0.1:5354" +api_port = 5381 + +[upstream] +mode = "odoh" +relay = "https://relay.invalid/proxy" +target = "https://odoh.cloudflare-dns.com/dns-query" +strict = true + +[cache] +max_entries = 10000 + +[blocking] +enabled = false + +[proxy] +enabled = false +CONF + +RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 & +NUMA_PID=$! +for _ in $(seq 1 30); do + curl -sf "http://127.0.0.1:$API_PORT/health" >/dev/null 2>&1 && break + sleep 0.1 +done + +check "Strict-mode relay outage returns SERVFAIL" \ + "SERVFAIL" \ + "$($DIG example.com A 2>&1 | grep 'status:')" + +kill "$NUMA_PID" 2>/dev/null || true +wait "$NUMA_PID" 2>/dev/null || true +sleep 1 + +# Negative: relay and target on the same host must be rejected at startup. +cat > "$CONFIG" << 'CONF' +[server] +bind_addr = "127.0.0.1:5354" +api_port = 5381 + +[upstream] +mode = "odoh" +relay = "https://odoh.cloudflare-dns.com/proxy" +target = "https://odoh.cloudflare-dns.com/dns-query" +CONF + +STARTUP_OUT=$("$BINARY" "$CONFIG" 2>&1 || true) +check "Same-host relay+target rejected at startup" \ + "same host" \ + "$STARTUP_OUT" + +fi # end Suite 8 + +# ---- Suite 9: Numa's own ODoH relay (--relay-mode) ---- +# Exercises `numa relay PORT` as a forwarding proxy to a real ODoH target. +# Validates the RFC 9230 §5 relay behaviour: URL-query routing, content-type +# gating, body-size cap, and /health observability. +if should_run_suite 9; then +echo "" +echo "╔══════════════════════════════════════════╗" +echo "║ Suite 9: Numa ODoH Relay (own) ║" +echo "╚══════════════════════════════════════════╝" + +RELAY_PORT=18443 +"$BINARY" relay $RELAY_PORT > "$LOG" 2>&1 & +NUMA_PID=$! +for _ in $(seq 1 30); do + curl -sf "http://127.0.0.1:$RELAY_PORT/health" >/dev/null 2>&1 && break + sleep 0.1 +done + +echo "" +echo "=== Relay Endpoints ===" + +check "Health endpoint returns ok" \ + "ok" \ + "$(curl -sf http://127.0.0.1:$RELAY_PORT/health | head -1)" + +# Happy path: forwards arbitrary body to Cloudflare's ODoH target. The +# target will reject the garbage envelope with HTTP 400 — which is exactly +# what proves our relay faithfully forwarded (otherwise we'd see our own +# 4xx from the relay itself). +HAPPY_STATUS=$(curl -sS -o /dev/null -w "%{http_code}" -X POST \ + -H "Content-Type: application/oblivious-dns-message" \ + --data-binary "garbage-forwarded-end-to-end" \ + "http://127.0.0.1:$RELAY_PORT/relay?targethost=odoh.cloudflare-dns.com&targetpath=/dns-query") +check "Relay forwards to target (target rejects garbage → 400)" \ + "400" \ + "$HAPPY_STATUS" + +echo "" +echo "=== Guards ===" + +check "Missing content-type → 415" \ + "415" \ + "$(curl -sS -o /dev/null -w '%{http_code}' -X POST --data-binary 'x' \ + 'http://127.0.0.1:'$RELAY_PORT'/relay?targethost=odoh.cloudflare-dns.com&targetpath=/dns-query')" + +check "Oversized body (>4 KiB) → 413" \ + "413" \ + "$(head -c 5000 /dev/urandom | curl -sS -o /dev/null -w '%{http_code}' -X POST \ + -H 'Content-Type: application/oblivious-dns-message' --data-binary @- \ + 'http://127.0.0.1:'$RELAY_PORT'/relay?targethost=odoh.cloudflare-dns.com&targetpath=/dns-query')" + +check "Invalid targethost (no dot) → 400" \ + "400" \ + "$(curl -sS -o /dev/null -w '%{http_code}' -X POST \ + -H 'Content-Type: application/oblivious-dns-message' --data-binary 'x' \ + 'http://127.0.0.1:'$RELAY_PORT'/relay?targethost=invalid&targetpath=/dns-query')" + +echo "" +echo "=== Counters ===" + +HEALTH=$(curl -sf "http://127.0.0.1:$RELAY_PORT/health") +check "Relay counted at least one forwarded_ok" \ + "[1-9]" \ + "$(echo "$HEALTH" | grep 'forwarded_ok' | awk '{print $2}')" +check "Relay counted at least one rejected_bad_request" \ + "[1-9]" \ + "$(echo "$HEALTH" | grep 'rejected_bad_request' | awk '{print $2}')" + +kill "$NUMA_PID" 2>/dev/null || true +wait "$NUMA_PID" 2>/dev/null || true +sleep 1 + +fi # end Suite 9 + # Summary echo "" TOTAL=$((PASSED + FAILED)) diff --git a/tests/probe-odoh-ecosystem.sh b/tests/probe-odoh-ecosystem.sh new file mode 100755 index 0000000..b2ff311 --- /dev/null +++ b/tests/probe-odoh-ecosystem.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Probe the public ODoH ecosystem. +# +# Source of truth: DNSCrypt's curated list at +# https://github.com/DNSCrypt/dnscrypt-resolvers/tree/master/v3 +# - v3/odoh-servers.md (ODoH targets) +# - v3/odoh-relays.md (ODoH relays) +# +# As of commit 2025-09-16 ("odohrelay-crypto-sx seems to be the only ODoH +# relay left"), the full public ecosystem is 4 targets + 1 relay. Re-run this +# script against the upstream list before making any "only N public relays" +# claim publicly. +# +# Usage: ./tests/probe-odoh-ecosystem.sh + +set -uo pipefail + +GREEN="\033[32m" +RED="\033[31m" +YELLOW="\033[33m" +DIM="\033[90m" +RESET="\033[0m" + +UP=0 +DOWN=0 + +probe_target() { + local name="$1" + local host="$2" + local url="https://${host}/.well-known/odohconfigs" + local start=$(date +%s%N) + local headers + headers=$(curl -sS -o /tmp/odoh-probe-body -D - --max-time 5 -A "numa-odoh-probe/0.1" "$url" 2>&1) || { + DOWN=$((DOWN + 1)) + printf " ${RED}✗${RESET} %-25s ${DIM}unreachable${RESET}\n" "$name" + return + } + local elapsed_ms=$((($(date +%s%N) - start) / 1000000)) + local status + status=$(echo "$headers" | head -1 | awk '{print $2}') + local ctype + ctype=$(echo "$headers" | grep -i '^content-type:' | head -1 | tr -d '\r') + local size + size=$(stat -f%z /tmp/odoh-probe-body 2>/dev/null || stat -c%s /tmp/odoh-probe-body 2>/dev/null || echo 0) + + if [[ "$status" == "200" ]] && [[ "$size" -gt 0 ]]; then + UP=$((UP + 1)) + printf " ${GREEN}✓${RESET} %-25s ${DIM}%4dms %s bytes %s${RESET}\n" "$name" "$elapsed_ms" "$size" "$ctype" + else + DOWN=$((DOWN + 1)) + printf " ${RED}✗${RESET} %-25s ${DIM}status=%s size=%s${RESET}\n" "$name" "$status" "$size" + fi + rm -f /tmp/odoh-probe-body +} + +probe_relay() { + # Relays don't expose /.well-known/odohconfigs — we just verify TLS reachability + # and that the endpoint responds to a malformed POST with an HTTP error + # (indicating the relay path exists). A real ODoH validation requires HPKE. + local name="$1" + local url="$2" + local start=$(date +%s%N) + local status + status=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 5 -A "numa-odoh-probe/0.1" \ + -X POST -H "Content-Type: application/oblivious-dns-message" \ + --data-binary "" "$url" 2>&1) || { + DOWN=$((DOWN + 1)) + printf " ${RED}✗${RESET} %-25s ${DIM}unreachable${RESET}\n" "$name" + return + } + local elapsed_ms=$((($(date +%s%N) - start) / 1000000)) + # Any 2xx or 4xx means the endpoint is live (TLS works, HTTP responded). + # 5xx or 000 (curl failure) means broken. + if [[ "$status" =~ ^[24] ]]; then + UP=$((UP + 1)) + printf " ${GREEN}✓${RESET} %-25s ${DIM}%4dms status=%s (endpoint live)${RESET}\n" "$name" "$elapsed_ms" "$status" + else + DOWN=$((DOWN + 1)) + printf " ${RED}✗${RESET} %-25s ${DIM}status=%s${RESET}\n" "$name" "$status" + fi +} + +echo "ODoH targets:" +probe_target "Cloudflare" "odoh.cloudflare-dns.com" +probe_target "crypto.sx" "odoh.crypto.sx" +probe_target "Snowstorm" "dope.snowstorm.love" +probe_target "Tiarap" "doh.tiarap.org" + +echo +echo "ODoH relays:" +probe_relay "Frank Denis (Fastly)" "https://odoh-relay.edgecompute.app/proxy" + +echo +TOTAL=$((UP + DOWN)) +if [[ "$DOWN" -eq 0 ]]; then + printf "${GREEN}All %d endpoints up${RESET}\n" "$TOTAL" + exit 0 +else + printf "${YELLOW}%d/%d up, %d down${RESET}\n" "$UP" "$TOTAL" "$DOWN" + exit 1 +fi -- 2.34.1 From cf128c19af0cc2b747398ae4fd853e7150078edb Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 20 Apr 2026 15:44:09 +0300 Subject: [PATCH 171/204] feat(odoh): bootstrap-IP overrides + zero hedge for ODoH (post-deploy fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues surfaced from running mode = "odoh" against the live Hetzner relay as system DNS: 1. **Bootstrap deadlock.** The reqwest HTTPS client resolves the relay and target hostnames via system DNS. When numa is itself the system resolver, the ODoH client loops trying to resolve through itself. Adds optional `relay_ip` and `target_ip` to `[upstream]`, plumbed into reqwest's `resolve()` so the HTTPS client bypasses system DNS for those two hostnames. TLS still validates against the URL hostname, so a stale IP fails loudly rather than silently MITM'ing. 2. **2x relay load.** Default `hedge_ms = 10` triggers a duplicate in-flight query for every request. Useful for UDP/DoH/DoT (rescues tail latency cheaply); wasteful for ODoH (doubles HPKE seal/unseal, doubles sealed-byte footprint a passive observer can correlate, no latency win — relay hop dominates either way). Force-zero in oblivious mode regardless of configured hedge_ms. Validated end-to-end against odoh-relay.numa.rs → Cloudflare: 3 digs produced 3 forwarded_ok on the relay (was 6 before the hedge fix), upstream_transport.odoh ticks correctly. --- src/config.rs | 123 ++++++++++++++++++++++++++++++++++++++++++++++++- src/forward.rs | 21 ++++++++- src/serve.rs | 13 ++++-- 3 files changed, 149 insertions(+), 8 deletions(-) diff --git a/src/config.rs b/src/config.rs index 2d2f1ba..1205e37 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::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::path::{Path, PathBuf}; +use std::time::Duration; use serde::Deserialize; @@ -146,6 +146,19 @@ impl UpstreamMode { UpstreamMode::Odoh => "odoh", } } + + /// Hedging duplicates the in-flight query against the same upstream to + /// rescue tail latency. Beneficial for UDP/DoH/DoT (cheap retransmit / + /// h2 stream multiplexing). For ODoH it doubles the relay's HPKE + /// seal/unseal load and the sealed-byte footprint a passive observer + /// can correlate, with no latency win — the relay hop dominates either + /// way. Force-zero in oblivious mode regardless of `hedge_ms`. + pub fn hedge_delay(self, hedge_ms: u64) -> Duration { + match self { + UpstreamMode::Odoh => Duration::ZERO, + _ => Duration::from_millis(hedge_ms), + } + } } #[derive(Deserialize)] @@ -182,6 +195,16 @@ pub struct UpstreamConfig { /// a user who configured ODoH rarely wants a silent non-oblivious path. #[serde(default)] pub strict: Option, + + /// Bootstrap IP for the relay host, used when numa is its own system + /// resolver (otherwise the ODoH HTTPS client loops resolving through + /// itself). TLS still validates the cert against `relay`'s hostname. + #[serde(default)] + pub relay_ip: Option, + + /// Same as `relay_ip` but for the target host. + #[serde(default)] + pub target_ip: Option, } impl Default for UpstreamConfig { @@ -199,6 +222,8 @@ impl Default for UpstreamConfig { relay: None, target: None, strict: None, + relay_ip: None, + target_ip: None, } } } @@ -208,9 +233,12 @@ impl Default for UpstreamConfig { #[derive(Debug)] pub struct OdohUpstream { pub relay_url: String, + pub relay_host: String, pub target_host: String, pub target_path: String, pub strict: bool, + pub relay_bootstrap: Option, + pub target_bootstrap: Option, } impl UpstreamConfig { @@ -246,6 +274,10 @@ impl UpstreamConfig { .into()); } + let relay_host = relay_url + .host_str() + .ok_or("upstream.relay has no host")? + .to_string(); let target_host = target_url .host_str() .ok_or("upstream.target has no host")? @@ -256,11 +288,17 @@ impl UpstreamConfig { target_url.path().to_string() }; + let relay_port = relay_url.port_or_known_default().unwrap_or(443); + let target_port = target_url.port_or_known_default().unwrap_or(443); + Ok(OdohUpstream { relay_url: relay.to_string(), + relay_host, target_host, target_path, strict: self.strict.unwrap_or(true), + relay_bootstrap: self.relay_ip.map(|ip| SocketAddr::new(ip, relay_port)), + target_bootstrap: self.target_ip.map(|ip| SocketAddr::new(ip, target_port)), }) } } @@ -817,6 +855,87 @@ target = "https://odoh.cloudflare-dns.com/dns-query" assert!(err.contains("upstream.relay"), "got: {err}"); } + #[test] + fn odoh_bootstrap_ips_parse_into_socket_addrs() { + let toml = r#" +[upstream] +mode = "odoh" +relay = "https://odoh-relay.numa.rs/relay" +target = "https://odoh.cloudflare-dns.com/dns-query" +relay_ip = "178.104.229.30" +target_ip = "104.16.249.249" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let odoh = config.upstream.odoh_upstream().unwrap(); + assert_eq!(odoh.relay_host, "odoh-relay.numa.rs"); + assert_eq!( + odoh.relay_bootstrap.unwrap().to_string(), + "178.104.229.30:443" + ); + assert_eq!( + odoh.target_bootstrap.unwrap().to_string(), + "104.16.249.249:443" + ); + } + + #[test] + fn odoh_bootstrap_ips_optional() { + let toml = r#" +[upstream] +mode = "odoh" +relay = "https://odoh-relay.numa.rs/relay" +target = "https://odoh.cloudflare-dns.com/dns-query" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let odoh = config.upstream.odoh_upstream().unwrap(); + assert!(odoh.relay_bootstrap.is_none()); + assert!(odoh.target_bootstrap.is_none()); + } + + #[test] + fn odoh_bootstrap_ip_rejects_garbage() { + let toml = r#" +[upstream] +mode = "odoh" +relay = "https://odoh-relay.numa.rs/relay" +target = "https://odoh.cloudflare-dns.com/dns-query" +relay_ip = "not-an-ip" +"#; + let err = toml::from_str::(toml).err().unwrap().to_string(); + assert!(err.contains("relay_ip"), "got: {err}"); + } + + #[test] + fn odoh_bootstrap_uses_url_port_when_non_default() { + let toml = r#" +[upstream] +mode = "odoh" +relay = "https://odoh-relay.numa.rs:8443/relay" +target = "https://odoh.cloudflare-dns.com/dns-query" +relay_ip = "178.104.229.30" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let odoh = config.upstream.odoh_upstream().unwrap(); + assert_eq!( + odoh.relay_bootstrap.unwrap().to_string(), + "178.104.229.30:8443" + ); + } + + #[test] + fn hedge_delay_zeroed_for_odoh_mode() { + assert_eq!( + UpstreamMode::Odoh.hedge_delay(50), + Duration::ZERO, + "ODoH mode must zero hedge regardless of configured hedge_ms" + ); + assert_eq!( + UpstreamMode::Forward.hedge_delay(50), + Duration::from_millis(50), + "non-ODoH modes honour configured hedge_ms" + ); + } + #[test] fn odoh_missing_target_rejected() { let toml = r#" diff --git a/src/forward.rs b/src/forward.rs index bb91fcf..530f1ed 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -176,6 +176,25 @@ pub fn build_https_client() -> reqwest::Client { /// and benefit from a larger pool so warm connections survive concurrent /// fan-out. pub fn build_https_client_with_pool(pool_max_idle_per_host: usize) -> reqwest::Client { + https_client_builder(pool_max_idle_per_host) + .build() + .unwrap_or_default() +} + +/// HTTPS client for the ODoH upstream, with bootstrap-IP overrides applied +/// so relay/target hostname resolution can bypass system DNS. +pub fn build_odoh_client(odoh: &crate::config::OdohUpstream) -> reqwest::Client { + let mut builder = https_client_builder(1); + if let Some(addr) = odoh.relay_bootstrap { + builder = builder.resolve(&odoh.relay_host, addr); + } + if let Some(addr) = odoh.target_bootstrap { + builder = builder.resolve(&odoh.target_host, addr); + } + builder.build().unwrap_or_default() +} + +fn https_client_builder(pool_max_idle_per_host: usize) -> reqwest::ClientBuilder { reqwest::Client::builder() .use_rustls_tls() .http2_initial_stream_window_size(65_535) @@ -185,8 +204,6 @@ pub fn build_https_client_with_pool(pool_max_idle_per_host: usize) -> reqwest::C .http2_keep_alive_timeout(Duration::from_secs(10)) .pool_idle_timeout(Duration::from_secs(300)) .pool_max_idle_per_host(pool_max_idle_per_host) - .build() - .unwrap_or_default() } fn build_dot_connector() -> Result { diff --git a/src/serve.rs b/src/serve.rs index 2037857..9b4b587 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -17,7 +17,9 @@ use crate::buffer::BytePacketBuffer; use crate::cache::DnsCache; use crate::config::{build_zone_map, load_config, ConfigLoad}; use crate::ctx::{handle_query, ServerCtx}; -use crate::forward::{build_https_client, parse_upstream_list, Upstream, UpstreamPool}; +use crate::forward::{ + build_https_client, build_odoh_client, parse_upstream_list, Upstream, UpstreamPool, +}; use crate::odoh::OdohConfigCache; use crate::override_store::OverrideStore; use crate::query_log::QueryLog; @@ -94,8 +96,11 @@ pub async fn run(config_path: String) -> crate::Result<()> { } crate::config::UpstreamMode::Odoh => { let odoh = config.upstream.odoh_upstream()?; - let client = build_https_client(); - let target_config = Arc::new(OdohConfigCache::new(odoh.target_host, client.clone())); + let client = build_odoh_client(&odoh); + let target_config = Arc::new(OdohConfigCache::new( + odoh.target_host.clone(), + client.clone(), + )); let primary = vec![Upstream::Odoh { relay_url: odoh.relay_url, target_path: odoh.target_path, @@ -222,7 +227,7 @@ pub async fn run(config_path: String) -> crate::Result<()> { upstream_port: config.upstream.port, lan_ip: Mutex::new(crate::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)), timeout: Duration::from_millis(config.upstream.timeout_ms), - hedge_delay: Duration::from_millis(config.upstream.hedge_ms), + hedge_delay: resolved_mode.hedge_delay(config.upstream.hedge_ms), proxy_tld_suffix: if config.proxy.tld.is_empty() { String::new() } else { -- 2.34.1 From a3cc64c94f6d7e53705455e4e384ac5b811eaa1e Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 20 Apr 2026 15:44:20 +0300 Subject: [PATCH 172/204] feat(odoh): relay bind-address CLI arg + dashboard Outbound Wire panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `numa relay [PORT] [BIND]` accepts an optional bind address (defaults to 127.0.0.1, matching the Caddy reverse-proxy deployment shape). Required for Docker, where the relay needs 0.0.0.0 inside the container so Caddy can reach it across the bridge network. - Dashboard now surfaces the upstream_transport dimension as an "Outbound Wire" panel alongside the existing "Inbound Wire" (renamed from "Transport" for directional clarity). Sub-headers — "apps → numa" / "numa → internet" — make the threat-model split obvious without jargon. Bars: UDP/DoH/DoT/ODoH, headline "X% encrypted outbound". The PR description's promise that "the dashboard answers how much of my DNS traffic left in cleartext honestly" is now true. --- site/dashboard.html | 35 ++++++++++++++++++++++++++++++++--- src/main.rs | 15 +++++++++++++-- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index fa2d965..710692b 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -228,6 +228,7 @@ body { .path-bar-fill.tcp { background: var(--violet); } .path-bar-fill.dot { background: var(--emerald); } .path-bar-fill.doh { background: var(--teal); } +.path-bar-fill.odoh { background: var(--violet-dim); } .path-pct { font-family: var(--font-mono); font-size: 0.75rem; @@ -637,16 +638,26 @@ body {
      - +
      - Transport + Inbound Wire apps → numa
      + +
      +
      + Outbound Wire numa → internet + +
      +
      +
      +
      +
      @@ -992,7 +1003,24 @@ function renderTransport(transport) { renderBarChart('transportBars', TRANSPORT_DEFS, transport, total); const encPct = encryptionPct(transport); const el = document.getElementById('transportEncrypted'); - el.textContent = `${encPct}% encrypted`; + el.textContent = `${encPct}% encrypted inbound`; + el.style.color = encPct >= 80 ? 'var(--emerald)' : encPct >= 50 ? 'var(--amber)' : 'var(--rose)'; +} + +const UPSTREAM_WIRE_DEFS = [ + { key: 'udp', label: 'UDP', cls: 'udp' }, + { key: 'doh', label: 'DoH', cls: 'doh' }, + { key: 'dot', label: 'DoT', cls: 'dot' }, + { key: 'odoh', label: 'ODoH', cls: 'odoh' }, +]; + +function renderUpstreamWire(ut) { + const total = (ut.udp + ut.doh + ut.dot + ut.odoh) || 0; + renderBarChart('upstreamWireBars', UPSTREAM_WIRE_DEFS, ut, total || 1); + const encrypted = ut.doh + ut.dot + ut.odoh; + const encPct = total > 0 ? Math.round((encrypted / total) * 100) : 0; + const el = document.getElementById('upstreamWireEncrypted'); + el.textContent = total > 0 ? `${encPct}% encrypted outbound` : ''; el.style.color = encPct >= 80 ? 'var(--emerald)' : encPct >= 50 ? 'var(--amber)' : 'var(--rose)'; } @@ -1234,6 +1262,7 @@ async function refresh() { // Panels renderPaths(q); renderTransport(stats.transport); + renderUpstreamWire(stats.upstream_transport || { udp: 0, doh: 0, dot: 0, odoh: 0 }); renderQueryLog(logs); renderOverrides(overrides); renderCache(cache); diff --git a/src/main.rs b/src/main.rs index e077a2f..8f9fecf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,7 +66,17 @@ fn main() -> numa::Result<()> { .as_deref() .and_then(|s| s.parse().ok()) .unwrap_or(8443); - let addr: std::net::SocketAddr = ([127, 0, 0, 1], port).into(); + let bind: std::net::IpAddr = std::env::args() + .nth(3) + .as_deref() + .map(|s| { + s.parse().unwrap_or_else(|e| { + eprintln!("invalid bind address '{}': {}", s, e); + std::process::exit(1); + }) + }) + .unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)); + let addr = std::net::SocketAddr::new(bind, port); eprintln!( "\x1b[1;38;2;192;98;58mNuma\x1b[0m — ODoH relay on {}\n", addr @@ -107,7 +117,8 @@ fn main() -> numa::Result<()> { eprintln!(" service status Check if the service is running"); eprintln!(" lan on Enable LAN service discovery (mDNS)"); eprintln!(" lan off Disable LAN service discovery"); - eprintln!(" relay [PORT] Run as an ODoH relay (RFC 9230, default port 8443)"); + eprintln!(" relay [PORT] [BIND]"); + eprintln!(" Run as an ODoH relay (RFC 9230, default 127.0.0.1:8443)"); eprintln!(" setup-phone Generate a QR code to install Numa DoT on a phone"); eprintln!(" help Show this help"); eprintln!(); -- 2.34.1 From be60f6ccbc33189d34bd5e02d894aec69ba6fe8c Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 20 Apr 2026 15:44:29 +0300 Subject: [PATCH 173/204] chore(packaging): docker-compose + Caddyfile for ODoH relay deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-container deploy: Caddy terminates TLS (auto-provisions Let's Encrypt via ACME) and reverse-proxies to a Numa relay on an internal Docker network. The relay never reads sealed payloads; Caddy's access log is discarded so per-request observability doesn't defeat the oblivious property. Validated against Hetzner CX22 + DNS at odoh-relay.numa.rs: - TLS-ALPN-01 challenge succeeded on first attempt - /health returned the relay's counter block - End-to-end ODoH client → relay → Cloudflare works Operators only need to: set a DNS A record, edit Caddyfile's hostname, docker compose up -d. README walks through the steps and the DNSCrypt v3/odoh-relays.md submission to claim a public listing. --- packaging/relay/Caddyfile | 15 ++++++++++ packaging/relay/README.md | 48 ++++++++++++++++++++++++++++++ packaging/relay/docker-compose.yml | 26 ++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 packaging/relay/Caddyfile create mode 100644 packaging/relay/README.md create mode 100644 packaging/relay/docker-compose.yml diff --git a/packaging/relay/Caddyfile b/packaging/relay/Caddyfile new file mode 100644 index 0000000..ea368c8 --- /dev/null +++ b/packaging/relay/Caddyfile @@ -0,0 +1,15 @@ +odoh-relay.example.com { + handle /relay { + reverse_proxy numa-relay:8443 + } + handle /health { + reverse_proxy numa-relay:8443 + } + respond 404 + + # Per-request access logs defeat the point of an oblivious relay. + # Aggregate counters are exposed at /health on the relay itself. + log { + output discard + } +} diff --git a/packaging/relay/README.md b/packaging/relay/README.md new file mode 100644 index 0000000..373b263 --- /dev/null +++ b/packaging/relay/README.md @@ -0,0 +1,48 @@ +# Numa ODoH Relay — Docker deploy + +Two-container deploy: Caddy terminates TLS (auto-provisioning a Let's Encrypt +cert via ACME) and reverse-proxies to a Numa relay running on an internal +Docker network. The relay never reads sealed payloads; Caddy never logs them. + +## Prerequisites + +- A host with public 80/443 reachable from the internet. +- A DNS record (`A` or `AAAA`) pointing your chosen hostname at the host. +- Docker + Docker Compose v2. + +## Configure + +Edit `Caddyfile` and replace `odoh-relay.example.com` with your hostname. +That hostname is what ACME validates against and what ODoH clients will +configure as their relay URL: `https:///relay`. + +## Deploy + +```sh +docker compose up -d +docker compose logs -f caddy # watch ACME provisioning +``` + +First boot takes a few seconds while Caddy obtains the cert. Subsequent +restarts reuse the cached cert from the `caddy_data` volume. + +## Verify + +```sh +curl https:///health +# ok +# total 0 +# forwarded_ok 0 +# forwarded_err 0 +# rejected_bad_request 0 +``` + +Then point any ODoH client at `https:///relay` and watch the +counters tick. + +## Listing on the public ecosystem + +DNSCrypt's [v3/odoh-relays.md](https://github.com/DNSCrypt/dnscrypt-resolvers/blob/master/v3/odoh-relays.md) +is the canonical list. The pruned 2025-09-16 commit shows one public ODoH +relay survived the cull — running this compose file doubles global supply. +Open a PR there once your relay has been up for ~24 hours. diff --git a/packaging/relay/docker-compose.yml b/packaging/relay/docker-compose.yml new file mode 100644 index 0000000..9561535 --- /dev/null +++ b/packaging/relay/docker-compose.yml @@ -0,0 +1,26 @@ +services: + numa-relay: + image: ghcr.io/razvandimescu/numa:latest + command: ["relay", "8443", "0.0.0.0"] + restart: unless-stopped + networks: [internal] + + caddy: + image: caddy:2 + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + restart: unless-stopped + depends_on: [numa-relay] + networks: [internal] + +networks: + internal: + +volumes: + caddy_data: + caddy_config: -- 2.34.1 From eb5ea3b645f0e64806fc5975fba3ea2c76e651a8 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 20 Apr 2026 16:03:34 +0300 Subject: [PATCH 174/204] refactor(odoh): deduplicate post-audit findings - Hoist ODOH_CONTENT_TYPE to a single pub(crate) constant in odoh.rs; relay.rs imports it instead of declaring its own. - Generalize dashboard encryptionPct(data, encryptedKeys, allKeys) so both Inbound Wire and Outbound Wire panels share the same math instead of drifting independently. - Extract RelayState::new() and build_app() helpers in relay.rs so the test spawn_relay() and production run() wire the same router + body-limit layer. Prevents future middleware from landing in one path but not the other. All 344 lib tests pass; no behavior change. --- site/dashboard.html | 13 ++++++------ src/odoh.rs | 2 +- src/relay.rs | 49 ++++++++++++++++++++------------------------- 3 files changed, 30 insertions(+), 34 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index 710692b..7b20e17 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -971,9 +971,11 @@ function renderBarChart(containerId, defs, data, total) { }).join(''); } -function encryptionPct(transport) { - const total = (transport.udp + transport.tcp + transport.dot + transport.doh) || 1; - return (((transport.dot + transport.doh) / total) * 100).toFixed(0); +function encryptionPct(data, encryptedKeys, allKeys) { + const total = allKeys.reduce((s, k) => s + (data[k] || 0), 0); + if (total === 0) return 0; + const encrypted = encryptedKeys.reduce((s, k) => s + (data[k] || 0), 0); + return Math.round((encrypted / total) * 100); } const PATH_DEFS = [ @@ -1001,7 +1003,7 @@ const TRANSPORT_DEFS = [ function renderTransport(transport) { const total = (transport.udp + transport.tcp + transport.dot + transport.doh) || 1; renderBarChart('transportBars', TRANSPORT_DEFS, transport, total); - const encPct = encryptionPct(transport); + const encPct = encryptionPct(transport, ['dot', 'doh'], ['udp', 'tcp', 'dot', 'doh']); const el = document.getElementById('transportEncrypted'); el.textContent = `${encPct}% encrypted inbound`; el.style.color = encPct >= 80 ? 'var(--emerald)' : encPct >= 50 ? 'var(--amber)' : 'var(--rose)'; @@ -1017,8 +1019,7 @@ const UPSTREAM_WIRE_DEFS = [ function renderUpstreamWire(ut) { const total = (ut.udp + ut.doh + ut.dot + ut.odoh) || 0; renderBarChart('upstreamWireBars', UPSTREAM_WIRE_DEFS, ut, total || 1); - const encrypted = ut.doh + ut.dot + ut.odoh; - const encPct = total > 0 ? Math.round((encrypted / total) * 100) : 0; + const encPct = encryptionPct(ut, ['doh', 'dot', 'odoh'], ['udp', 'doh', 'dot', 'odoh']); const el = document.getElementById('upstreamWireEncrypted'); el.textContent = total > 0 ? `${encPct}% encrypted outbound` : ''; el.style.color = encPct >= 80 ? 'var(--emerald)' : encPct >= 50 ? 'var(--amber)' : 'var(--rose)'; diff --git a/src/odoh.rs b/src/odoh.rs index 2cfa9c5..0901c94 100644 --- a/src/odoh.rs +++ b/src/odoh.rs @@ -25,7 +25,7 @@ use tokio::time::timeout; use crate::Result; /// MIME type used for both directions of the ODoH exchange (RFC 9230 §4). -const ODOH_CONTENT_TYPE: &str = "application/oblivious-dns-message"; +pub(crate) const ODOH_CONTENT_TYPE: &str = "application/oblivious-dns-message"; /// Cap on the response body we read into memory when the relay returns /// non-success. Protects against a hostile relay streaming a huge body on diff --git a/src/relay.rs b/src/relay.rs index 8d6ab40..122796e 100644 --- a/src/relay.rs +++ b/src/relay.rs @@ -20,10 +20,9 @@ use serde::Deserialize; use tokio::net::TcpListener; use crate::forward::build_https_client_with_pool; +use crate::odoh::ODOH_CONTENT_TYPE; use crate::Result; -const ODOH_CONTENT_TYPE: &str = "application/oblivious-dns-message"; - /// Cap on the opaque body we accept from a client. ODoH envelopes are /// ~100–300 bytes in practice; anything larger is malformed or hostile. const MAX_BODY_BYTES: usize = 4 * 1024; @@ -55,23 +54,30 @@ struct RelayState { rejected_bad_request: AtomicU64, } -pub async fn run(addr: SocketAddr) -> Result<()> { - let state = Arc::new(RelayState { - client: build_https_client_with_pool(RELAY_POOL_PER_HOST), - total_requests: AtomicU64::new(0), - forwarded_ok: AtomicU64::new(0), - forwarded_err: AtomicU64::new(0), - rejected_bad_request: AtomicU64::new(0), - }); +impl RelayState { + fn new() -> Arc { + Arc::new(RelayState { + client: build_https_client_with_pool(RELAY_POOL_PER_HOST), + total_requests: AtomicU64::new(0), + forwarded_ok: AtomicU64::new(0), + forwarded_err: AtomicU64::new(0), + rejected_bad_request: AtomicU64::new(0), + }) + } +} - let app = Router::new() +/// `DefaultBodyLimit` overrides axum's 2 MiB default so hostile clients +/// can't force the relay to buffer multi-MB bodies before our own cap. +fn build_app(state: Arc) -> Router { + Router::new() .route("/relay", post(handle_relay)) - // Overrides axum's default (2 MiB) so hostile clients can't force - // the relay to buffer multi-MB bodies before our own cap check. .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) .route("/health", get(handle_health)) - .with_state(state); + .with_state(state) +} +pub async fn run(addr: SocketAddr) -> Result<()> { + let app = build_app(RelayState::new()); let listener = TcpListener::bind(addr).await?; info!("ODoH relay listening on {}", addr); axum::serve(listener, app).await?; @@ -199,19 +205,8 @@ mod tests { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); - let state = Arc::new(RelayState { - client: build_https_client_with_pool(RELAY_POOL_PER_HOST), - total_requests: AtomicU64::new(0), - forwarded_ok: AtomicU64::new(0), - forwarded_err: AtomicU64::new(0), - rejected_bad_request: AtomicU64::new(0), - }); - - let app = Router::new() - .route("/relay", post(handle_relay)) - .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) - .route("/health", get(handle_health)) - .with_state(state.clone()); + let state = RelayState::new(); + let app = build_app(state.clone()); tokio::spawn(async move { let _ = axum::serve(listener, app).await; -- 2.34.1 From 07c321f7492209272792d487fa0882536c6c9b1d Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 20 Apr 2026 17:07:31 +0300 Subject: [PATCH 175/204] chore(release): bump to v0.14.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headline: ODoH (RFC 9230) — client + self-hosted relay. Set mode = "odoh" in [upstream] to seal queries before they leave the machine; run `numa relay` to add to the public ODoH ecosystem. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2bfeaa6..b630e73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1547,7 +1547,7 @@ dependencies = [ [[package]] name = "numa" -version = "0.13.1" +version = "0.14.0" dependencies = [ "arc-swap", "axum", diff --git a/Cargo.toml b/Cargo.toml index 15601c7..c22352b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "numa" -version = "0.13.1" +version = "0.14.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 cd6e686a1a8ae3ae2f621e62ca442dec08261a70 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 20 Apr 2026 17:14:21 +0300 Subject: [PATCH 176/204] docs(readme): surface ODoH in the intro paragraph Adds the v0.14.0 capability where it's most differentiating: the first paragraph (sealed-query framing alongside the existing ad-blocking and .numa-domain pitches) and the second paragraph (numa relay as a public ODoH endpoint, with the DNSCrypt-list supply-doubling angle as fact). No reposition: tagline and structure unchanged. ODoH joins the existing capability set rather than displacing it. Hero GIF stays; will be re-recorded once the dashboard's Outbound Wire panel is worth showing in motion. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1728461..e5310de 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ **DNS you own. Everywhere you go.** — [numa.rs](https://numa.rs) -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. +A portable DNS resolver in a single binary. Block ads on any network, name your local services (`frontend.numa`), override any hostname with auto-revert, and seal every outbound query with **ODoH (RFC 9230)** so no single party sees both who you are and what you asked — 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, plus a DNS-over-TLS listener for encrypted client connections (iOS Private DNS, systemd-resolved, etc.). 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.). Run `numa relay` and the same binary becomes a public ODoH endpoint too — the curated DNSCrypt list currently has one surviving relay, so every Numa deploy materially expands the ecosystem. One ~8MB binary, everything embedded. ![Numa dashboard](assets/hero-demo.gif) -- 2.34.1 From 4c685d1602db13ac82503abdc3930a487078b026 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 20 Apr 2026 17:19:16 +0300 Subject: [PATCH 177/204] docs(readme): pamper readme still --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e5310de..905cd02 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`), override any hostname with auto-revert, and seal every outbound query with **ODoH (RFC 9230)** so no single party sees both who you are and what you asked — 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, plus a DNS-over-TLS listener for encrypted client connections (iOS Private DNS, systemd-resolved, etc.). Run `numa relay` and the same binary becomes a public ODoH endpoint too — the curated DNSCrypt list currently has one surviving relay, so every Numa deploy materially expands the ecosystem. One ~8MB binary, everything embedded. +Built from scratch in Rust. Zero DNS libraries. 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.). Run `numa relay` and the same binary becomes a public ODoH endpoint too — the curated DNSCrypt list currently has one surviving relay, so every Numa deploy materially expands the ecosystem. One ~8MB binary, everything embedded. ![Numa dashboard](assets/hero-demo.gif) -- 2.34.1 From 193b38b85f2a842cbafa2ffccbc921e40319f9f1 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 20 Apr 2026 18:46:54 +0300 Subject: [PATCH 178/204] feat(odoh): reject relay+target sharing an eTLD+1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain host-string equality caught the copy-paste-same-URL footgun but let `r.cloudflare.com` + `odoh.cloudflare.com` through — two subdomains of the same operator collapse ODoH to ordinary DoH. Add a second layer: compare registrable domains via the PSL (`psl` crate) after the exact- host check. Fails open on IP literals and unparseable hosts; the exact- host check still runs in those cases. --- Cargo.lock | 16 +++++++++ Cargo.toml | 1 + src/config.rs | 97 ++++++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 101 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b630e73..dc95f58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1562,6 +1562,7 @@ dependencies = [ "hyper-util", "log", "odoh-rs", + "psl", "qrcode", "rand_core 0.9.5", "rcgen", @@ -1802,6 +1803,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl" +version = "2.1.203" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76c0777260d32b76a8c3c197646707085d37e79d63b5872a29192c8d4f60f50b" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "qrcode" version = "0.14.1" diff --git a/Cargo.toml b/Cargo.toml index c22352b..ec3bb43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ tokio-rustls = "0.26" arc-swap = "1" ring = "0.17" odoh-rs = "1" +psl = "2" # rand_core 0.9 matches the version odoh-rs (via hpke 0.13) depends on, so we # share one RngCore trait and OsRng impl across the dep tree. rand_core = { version = "0.9", features = ["os_rng"] } diff --git a/src/config.rs b/src/config.rs index 1205e37..3a41d24 100644 --- a/src/config.rs +++ b/src/config.rs @@ -263,25 +263,29 @@ impl UpstreamConfig { if relay_url.scheme() != "https" || target_url.scheme() != "https" { return Err("upstream.relay and upstream.target must both use https://".into()); } - if relay_url.host_str().is_none() || target_url.host_str().is_none() { - return Err("upstream.relay and upstream.target must include a host".into()); - } - if relay_url.host_str() == target_url.host_str() { - return Err(format!( - "upstream.relay and upstream.target resolve to the same host ({}); the privacy property requires distinct operators", - relay_url.host_str().unwrap_or("?") - ) - .into()); - } - let relay_host = relay_url .host_str() - .ok_or("upstream.relay has no host")? + .ok_or("upstream.relay must include a host")? .to_string(); let target_host = target_url .host_str() - .ok_or("upstream.target has no host")? + .ok_or("upstream.target must include a host")? .to_string(); + + if relay_host == target_host { + return Err(format!( + "upstream.relay and upstream.target resolve to the same host ({}); the privacy property requires distinct operators", + relay_host + ) + .into()); + } + if let Some(shared) = shared_registrable_domain(&relay_host, &target_host) { + return Err(format!( + "upstream.relay ({}) and upstream.target ({}) share the registrable domain ({}); the privacy property requires distinct operators", + relay_host, target_host, shared + ) + .into()); + } let target_path = if target_url.path().is_empty() { "/".to_string() } else { @@ -303,6 +307,20 @@ impl UpstreamConfig { } } +/// Returns the registrable domain (eTLD+1) shared by both hosts, if any. +/// Fails open on hosts the PSL can't parse (IP literals, bare TLDs). +fn shared_registrable_domain(relay_host: &str, target_host: &str) -> Option { + let relay = psl::domain(relay_host.as_bytes())?; + let target = psl::domain(target_host.as_bytes())?; + if relay.as_bytes() == target.as_bytes() { + std::str::from_utf8(relay.as_bytes()) + .ok() + .map(str::to_owned) + } else { + None + } +} + fn string_or_vec<'de, D>(deserializer: D) -> std::result::Result, D::Error> where D: serde::Deserializer<'de>, @@ -830,6 +848,59 @@ target = "https://odoh.example.com/dns-query" assert!(err.contains("same host"), "got: {err}"); } + #[test] + fn odoh_rejects_shared_registrable_domain() { + let toml = r#" +[upstream] +mode = "odoh" +relay = "https://r.cloudflare.com/relay" +target = "https://odoh.cloudflare.com/dns-query" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let err = config.upstream.odoh_upstream().unwrap_err().to_string(); + assert!(err.contains("registrable domain"), "got: {err}"); + assert!(err.contains("cloudflare.com"), "got: {err}"); + } + + #[test] + fn odoh_rejects_shared_registrable_under_multi_label_suffix() { + let toml = r#" +[upstream] +mode = "odoh" +relay = "https://a.foo.co.uk/relay" +target = "https://b.foo.co.uk/dns-query" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let err = config.upstream.odoh_upstream().unwrap_err().to_string(); + assert!(err.contains("foo.co.uk"), "got: {err}"); + } + + #[test] + fn odoh_accepts_distinct_registrable_under_multi_label_suffix() { + let toml = r#" +[upstream] +mode = "odoh" +relay = "https://relay.foo.co.uk/relay" +target = "https://target.bar.co.uk/dns-query" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(config.upstream.odoh_upstream().is_ok()); + } + + #[test] + fn odoh_accepts_distinct_private_psl_suffix_subdomains() { + // *.github.io is a public suffix, so foo.github.io and bar.github.io + // are independent registrable domains — accept. + let toml = r#" +[upstream] +mode = "odoh" +relay = "https://foo.github.io/relay" +target = "https://bar.github.io/dns-query" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(config.upstream.odoh_upstream().is_ok()); + } + #[test] fn odoh_rejects_non_https() { let toml = r#" -- 2.34.1 From 15978a78598805312a9c8f6ab9825ed17be4415a Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 20 Apr 2026 19:04:15 +0300 Subject: [PATCH 179/204] fix(dashboard): pass missing args to encryptionPct in refresh() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit eb5ea3b generalised encryptionPct from (transport) to (data, encryptedKeys, allKeys) and updated renderTransport and renderUpstreamWire, but missed the call inside render() that computes the inline `~N/s · M% enc` QPS tag. With undefined allKeys, the first .reduce() threw TypeError and the render try/catch silently downgraded the whole dashboard to "disconnected" — every panel left empty even though /stats was returning real data. Fix the call site to match the other two (inbound-wire keys) and have the catch log to console so the next silent-failure regression shows up in DevTools within seconds instead of a source dive. --- site/dashboard.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/site/dashboard.html b/site/dashboard.html index 7b20e17..a0322a8 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -1244,7 +1244,7 @@ async function refresh() { // QPS calculation const now = Date.now(); - const encPct = encryptionPct(stats.transport); + const encPct = encryptionPct(stats.transport, ['dot', 'doh'], ['udp', 'tcp', 'dot', 'doh']); if (prevTotal !== null && prevTime !== null) { const dt = (now - prevTime) / 1000; const dq = q.total - prevTotal; @@ -1273,6 +1273,7 @@ async function refresh() { renderMemory(stats.memory, stats); } catch (err) { + console.error('[numa dashboard] render failed:', err); document.getElementById('statusDot').className = 'status-dot error'; document.getElementById('statusText').textContent = 'disconnected'; } -- 2.34.1 From 5b1642c6dc0143082948a60f77148a845f455d47 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 20 Apr 2026 19:07:08 +0300 Subject: [PATCH 180/204] fix(blocklist): retry on transient download failures (#122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On cold start, reqwest's getaddrinfo can race numa's own first-query cold-path latency — resolver timeout fires before numa warms its upstream DoH connection. Wrap each blocklist fetch in 3 retries with 2s/10s/30s backoff; by the second attempt, the upstream is warm and subsequent getaddrinfos succeed in <100ms. Also: parallelize fetches across lists via join_all (different hosts, no warming dependency), walk the full error source chain so reqwest failures surface the underlying cause, and parameterize retry delays for unit-test speed. --- src/blocklist.rs | 136 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 121 insertions(+), 15 deletions(-) diff --git a/src/blocklist.rs b/src/blocklist.rs index ef865c4..4d76eda 100644 --- a/src/blocklist.rs +++ b/src/blocklist.rs @@ -1,5 +1,5 @@ use std::collections::HashSet; -use std::time::Instant; +use std::time::{Duration, Instant}; use log::{info, warn}; @@ -355,27 +355,133 @@ mod tests { } } +const RETRY_DELAYS_SECS: &[u64] = &[2, 10, 30]; + pub async fn download_blocklists(lists: &[String]) -> Vec<(String, String)> { let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) + .timeout(Duration::from_secs(30)) .gzip(true) .build() .unwrap_or_default(); - let mut results = Vec::new(); + let fetches = lists.iter().map(|url| { + let client = &client; + async move { + let text = fetch_with_retry(client, url).await?; + info!("downloaded blocklist: {} ({} bytes)", url, text.len()); + Some((url.clone(), text)) + } + }); + futures::future::join_all(fetches) + .await + .into_iter() + .flatten() + .collect() +} - for url in lists { - match client.get(url).send().await { - Ok(resp) => match resp.text().await { - Ok(text) => { - info!("downloaded blocklist: {} ({} bytes)", url, text.len()); - results.push((url.clone(), text)); - } - Err(e) => warn!("failed to read blocklist body {}: {}", url, e), - }, - Err(e) => warn!("failed to download blocklist {}: {}", url, e), +async fn fetch_with_retry(client: &reqwest::Client, url: &str) -> Option { + fetch_with_retry_delays(client, url, RETRY_DELAYS_SECS).await +} + +async fn fetch_with_retry_delays( + client: &reqwest::Client, + url: &str, + delays: &[u64], +) -> Option { + let total = delays.len() + 1; + for attempt in 1..=total { + match fetch_once(client, url).await { + Ok(text) => return Some(text), + Err(msg) if attempt < total => { + let delay = delays[attempt - 1]; + warn!( + "blocklist {} attempt {}/{} failed: {} — retrying in {}s", + url, attempt, total, msg, delay + ); + tokio::time::sleep(Duration::from_secs(delay)).await; + } + Err(msg) => { + warn!( + "blocklist {} attempt {}/{} failed: {} — giving up", + url, attempt, total, msg + ); + } } } - - results + None +} + +async fn fetch_once(client: &reqwest::Client, url: &str) -> Result { + let resp = client + .get(url) + .send() + .await + .map_err(|e| format_error_chain(&e))?; + resp.text().await.map_err(|e| format_error_chain(&e)) +} + +fn format_error_chain(e: &(dyn std::error::Error + 'static)) -> String { + let mut parts = vec![e.to_string()]; + let mut src = e.source(); + while let Some(s) = src { + parts.push(s.to_string()); + src = s.source(); + } + parts.join(": ") +} + +#[cfg(test)] +mod retry_tests { + use super::*; + use std::net::SocketAddr; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + async fn flaky_http_server(fail_first: usize, body: &'static str) -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + for _ in 0..fail_first { + if let Ok((sock, _)) = listener.accept().await { + drop(sock); + } + } + loop { + let Ok((mut sock, _)) = listener.accept().await else { + return; + }; + tokio::spawn(async move { + let mut buf = [0u8; 2048]; + let _ = sock.read(&mut buf).await; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); + let _ = sock.write_all(response.as_bytes()).await; + let _ = sock.shutdown().await; + }); + } + }); + addr + } + + #[tokio::test] + async fn retry_succeeds_after_transient_failure() { + let body = "ads.example.com\ntracker.example.net\n"; + let addr = flaky_http_server(2, body).await; + let client = reqwest::Client::new(); + let url = format!("http://{addr}/"); + let result = fetch_with_retry_delays(&client, &url, &[0, 0, 0]).await; + assert_eq!(result.as_deref(), Some(body)); + } + + #[tokio::test] + async fn retry_gives_up_when_all_attempts_fail() { + let addr = flaky_http_server(10, "").await; + let client = reqwest::Client::new(); + let url = format!("http://{addr}/"); + let result = fetch_with_retry_delays(&client, &url, &[0, 0, 0]).await; + assert_eq!(result, None); + } } -- 2.34.1 From 8bed7c46493ddab9a11eefbf0fea569064e8a0ac Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 20 Apr 2026 19:11:53 +0300 Subject: [PATCH 181/204] test(blocklist): decouple retry tests from RETRY_DELAYS_SECS length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive both the flaky-server drop count and the zero-delay schedule from RETRY_DELAYS_SECS.len() so the tests keep exercising their intended invariants — "succeeds on final attempt" and "gives up after all attempts fail" — if the production retry schedule ever changes. Also: rename fail_first → drop_first_n to match drop(sock); swap the giveup test's empty body for an "unreachable" sentinel so a regression that accidentally served couldn't silently match Some(""). --- src/blocklist.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/blocklist.rs b/src/blocklist.rs index 4d76eda..20ac95d 100644 --- a/src/blocklist.rs +++ b/src/blocklist.rs @@ -437,11 +437,11 @@ mod retry_tests { use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; - async fn flaky_http_server(fail_first: usize, body: &'static str) -> SocketAddr { + async fn flaky_http_server(drop_first_n: usize, body: &'static str) -> SocketAddr { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { - for _ in 0..fail_first { + for _ in 0..drop_first_n { if let Ok((sock, _)) = listener.accept().await { drop(sock); } @@ -466,22 +466,28 @@ mod retry_tests { addr } + fn zero_delays() -> Vec { + vec![0; RETRY_DELAYS_SECS.len()] + } + #[tokio::test] - async fn retry_succeeds_after_transient_failure() { + async fn retry_succeeds_on_final_attempt() { let body = "ads.example.com\ntracker.example.net\n"; - let addr = flaky_http_server(2, body).await; + let delays = zero_delays(); + let addr = flaky_http_server(delays.len(), body).await; let client = reqwest::Client::new(); let url = format!("http://{addr}/"); - let result = fetch_with_retry_delays(&client, &url, &[0, 0, 0]).await; + let result = fetch_with_retry_delays(&client, &url, &delays).await; assert_eq!(result.as_deref(), Some(body)); } #[tokio::test] async fn retry_gives_up_when_all_attempts_fail() { - let addr = flaky_http_server(10, "").await; + let delays = zero_delays(); + let addr = flaky_http_server(delays.len() + 2, "unreachable").await; let client = reqwest::Client::new(); let url = format!("http://{addr}/"); - let result = fetch_with_retry_delays(&client, &url, &[0, 0, 0]).await; + let result = fetch_with_retry_delays(&client, &url, &delays).await; assert_eq!(result, None); } } -- 2.34.1 From 60600b045f07567f301f12723a4f372851ec9df4 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 20 Apr 2026 19:27:06 +0300 Subject: [PATCH 182/204] chore: bump version to 0.14.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dc95f58..c7a8742 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1547,7 +1547,7 @@ dependencies = [ [[package]] name = "numa" -version = "0.14.0" +version = "0.14.1" dependencies = [ "arc-swap", "axum", diff --git a/Cargo.toml b/Cargo.toml index ec3bb43..39f75a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "numa" -version = "0.14.0" +version = "0.14.1" authors = ["razvandimescu "] edition = "2021" description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS" -- 2.34.1 From 31adc31c9b64b38ff0ce7b3847d21afd7c96fbc2 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 21 Apr 2026 16:18:52 +0300 Subject: [PATCH 183/204] refactor(ctx): coalesce forward-path upstream queries resolve_coalesced now takes leader_path: QueryPath and applies to all three upstream branches (Forwarded-rule, Recursive, Upstream), not just Recursive. Fixes thundering-herd at boot when N concurrent HTTPS setups each trigger independent forward queries for the same upstream hostname. --- src/ctx.rs | 190 ++++++++++++++++++++++++++++------------------------- 1 file changed, 102 insertions(+), 88 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index 511b678..a0c15ac 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -209,106 +209,83 @@ pub async fn resolve_query( { // Conditional forwarding takes priority over recursive mode // (e.g. Tailscale .ts.net, VPC private zones) - upstream_transport = pool.preferred().map(|u| u.transport()); - match forward_with_failover_raw( - raw_wire, - pool, - &ctx.srtt, - ctx.timeout, - ctx.hedge_delay, - ) - .await - { - Ok(resp_wire) => match cache_and_parse(ctx, &qname, qtype, &resp_wire) { - Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate), - Err(e) => { - error!("{} | {:?} {} | PARSE ERROR | {}", src_addr, qtype, qname, e); - ( - DnsPacket::response_from(&query, ResultCode::SERVFAIL), - QueryPath::UpstreamError, - DnssecStatus::Indeterminate, - ) - } - }, - Err(e) => { - error!( - "{} | {:?} {} | FORWARD ERROR | {}", - src_addr, qtype, qname, e - ); - ( - DnsPacket::response_from(&query, ResultCode::SERVFAIL), - QueryPath::UpstreamError, - DnssecStatus::Indeterminate, + let key = (qname.clone(), qtype); + let (resp, path, err) = resolve_coalesced( + &ctx.inflight, + key, + &query, + QueryPath::Forwarded, + || async { + let wire = forward_with_failover_raw( + raw_wire, + pool, + &ctx.srtt, + ctx.timeout, + ctx.hedge_delay, ) - } + .await?; + cache_and_parse(ctx, &qname, qtype, &wire) + }, + ) + .await; + log_coalesced_outcome(src_addr, qtype, &qname, path, err.as_deref(), "FORWARD"); + if path == QueryPath::Forwarded { + upstream_transport = pool.preferred().map(|u| u.transport()); } + (resp, path, DnssecStatus::Indeterminate) } else if ctx.upstream_mode == UpstreamMode::Recursive { // Recursive resolution makes UDP hops to roots/TLDs/auths; // tag as Udp so the dashboard can aggregate plaintext-wire // egress honestly. Only mark on success — errors stay None. let key = (qname.clone(), qtype); - let (resp, path, err) = resolve_coalesced(&ctx.inflight, key, &query, || { - crate::recursive::resolve_recursive( - &qname, - qtype, - &ctx.cache, - &query, - &ctx.root_hints, - &ctx.srtt, - ) - }) + let (resp, path, err) = resolve_coalesced( + &ctx.inflight, + key, + &query, + QueryPath::Recursive, + || { + crate::recursive::resolve_recursive( + &qname, + qtype, + &ctx.cache, + &query, + &ctx.root_hints, + &ctx.srtt, + ) + }, + ) .await; - if path == QueryPath::Coalesced { - debug!("{} | {:?} {} | COALESCED", src_addr, qtype, qname); - } else if path == QueryPath::UpstreamError { - error!( - "{} | {:?} {} | RECURSIVE ERROR | {}", - src_addr, - qtype, - qname, - err.as_deref().unwrap_or("leader failed") - ); - } else { + log_coalesced_outcome(src_addr, qtype, &qname, path, err.as_deref(), "RECURSIVE"); + if path == QueryPath::Recursive { upstream_transport = Some(crate::stats::UpstreamTransport::Udp); } (resp, path, DnssecStatus::Indeterminate) } else { let pool = ctx.upstream_pool.lock().unwrap().clone(); - match forward_with_failover_raw( - raw_wire, - &pool, - &ctx.srtt, - ctx.timeout, - ctx.hedge_delay, - ) - .await - { - Ok(resp_wire) => match cache_and_parse(ctx, &qname, qtype, &resp_wire) { - Ok(resp) => { - upstream_transport = pool.preferred().map(|u| u.transport()); - (resp, QueryPath::Upstream, DnssecStatus::Indeterminate) - } - Err(e) => { - error!("{} | {:?} {} | PARSE ERROR | {}", src_addr, qtype, qname, e); - ( - DnsPacket::response_from(&query, ResultCode::SERVFAIL), - QueryPath::UpstreamError, - DnssecStatus::Indeterminate, - ) - } - }, - Err(e) => { - error!( - "{} | {:?} {} | UPSTREAM ERROR | {}", - src_addr, qtype, qname, e - ); - ( - DnsPacket::response_from(&query, ResultCode::SERVFAIL), - QueryPath::UpstreamError, - DnssecStatus::Indeterminate, + let key = (qname.clone(), qtype); + let (resp, path, err) = resolve_coalesced( + &ctx.inflight, + key, + &query, + QueryPath::Upstream, + || async { + let wire = forward_with_failover_raw( + raw_wire, + &pool, + &ctx.srtt, + ctx.timeout, + ctx.hedge_delay, ) - } + .await?; + cache_and_parse(ctx, &qname, qtype, &wire) + }, + ) + .await; + log_coalesced_outcome(src_addr, qtype, &qname, path, err.as_deref(), "UPSTREAM"); + if path == QueryPath::Upstream { + upstream_transport = pool.preferred().map(|u| u.transport()); } + (resp, path, DnssecStatus::Indeterminate) } } }; @@ -611,11 +588,15 @@ fn acquire_inflight(inflight: &Mutex, key: (String, QueryType)) -> /// Run a resolve function with in-flight coalescing. Multiple concurrent calls /// for the same key share a single resolution — the first caller (leader) -/// executes `resolve_fn`, and followers wait for the broadcast result. +/// executes `resolve_fn`, and followers wait for the broadcast result. The +/// leader's successful path is tagged with `leader_path` so callers that +/// share this helper (recursive, forwarded-rule, forward-upstream) keep their +/// own observability without duplicating the inflight map. async fn resolve_coalesced( inflight: &Mutex, key: (String, QueryType), query: &DnsPacket, + leader_path: QueryPath, resolve_fn: F, ) -> (DnsPacket, QueryPath, Option) where @@ -644,7 +625,7 @@ where match result { Ok(resp) => { let _ = tx.send(Some(resp.clone())); - (resp, QueryPath::Recursive, None) + (resp, leader_path, None) } Err(e) => { let _ = tx.send(None); @@ -671,6 +652,33 @@ impl Drop for InflightGuard<'_> { } } +/// Emit the log lines shared by the three upstream branches (Forwarded, +/// Recursive, Upstream) after `resolve_coalesced` returns. Leader-success +/// and transport-tagging stay at the call site since they diverge per +/// branch, but the Coalesced debug and UpstreamError error are identical +/// except for the label. +fn log_coalesced_outcome( + src_addr: SocketAddr, + qtype: QueryType, + qname: &str, + path: QueryPath, + err: Option<&str>, + label: &str, +) { + match path { + QueryPath::Coalesced => debug!("{} | {:?} {} | COALESCED", src_addr, qtype, qname), + QueryPath::UpstreamError => error!( + "{} | {:?} {} | {} ERROR | {}", + src_addr, + qtype, + qname, + label, + err.unwrap_or("leader failed") + ), + _ => {} + } +} + fn special_use_response(query: &DnsPacket, qname: &str, qtype: QueryType) -> DnsPacket { use std::net::{Ipv4Addr, Ipv6Addr}; if qname == "ipv4only.arpa" { @@ -909,7 +917,7 @@ mod tests { let key = ("coalesce.test".to_string(), QueryType::A); let query = DnsPacket::query(100 + i, "coalesce.test", QueryType::A); handles.push(tokio::spawn(async move { - resolve_coalesced(&inf, key, &query, || async { + resolve_coalesced(&inf, key, &query, QueryPath::Recursive, || async { count.fetch_add(1, std::sync::atomic::Ordering::Relaxed); tokio::time::sleep(Duration::from_millis(200)).await; Ok(mock_response("coalesce.test")) @@ -953,6 +961,7 @@ mod tests { &inf1, ("same.domain".to_string(), QueryType::A), &query_a, + QueryPath::Recursive, || async { count1.fetch_add(1, std::sync::atomic::Ordering::Relaxed); tokio::time::sleep(Duration::from_millis(100)).await; @@ -966,6 +975,7 @@ mod tests { &inf2, ("same.domain".to_string(), QueryType::AAAA), &query_aaaa, + QueryPath::Recursive, || async { count2.fetch_add(1, std::sync::atomic::Ordering::Relaxed); tokio::time::sleep(Duration::from_millis(100)).await; @@ -995,6 +1005,7 @@ mod tests { &inflight, ("will-fail.test".to_string(), QueryType::A), &query, + QueryPath::Recursive, || async { Err::("upstream timeout".into()) }, ) .await; @@ -1016,6 +1027,7 @@ mod tests { &inf, ("fail.test".to_string(), QueryType::A), &query, + QueryPath::Recursive, || async { tokio::time::sleep(Duration::from_millis(200)).await; Err::("upstream error".into()) @@ -1056,6 +1068,7 @@ mod tests { &inflight, ("question.test".to_string(), QueryType::A), &query, + QueryPath::Recursive, || async { Err::("fail".into()) }, ) .await; @@ -1080,6 +1093,7 @@ mod tests { &inflight, ("err-msg.test".to_string(), QueryType::A), &query, + QueryPath::Recursive, || async { Err::("connection refused by upstream".into()) }, ) .await; -- 2.34.1 From 10469e96bd7b3ed1ab090e84db6c8cb8d97695d3 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 21 Apr 2026 16:19:14 +0300 Subject: [PATCH 184/204] fix(bootstrap): route numa HTTPS via IP-literal bootstrap resolver (#122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When numa is its own system DNS resolver (HAOS add-on, Pi-hole-style container, /etc/resolv.conf → 127.0.0.1), every numa-originated HTTPS connection — DoH upstream, ODoH relay/target, blocklist CDN — routed its hostname through getaddrinfo() back to numa itself. Cold boot deadlocked; steady state taxed every new TCP connection. 0.14.1's retry-with-backoff masked the startup race but not the underlying self-loop. NumaResolver implements reqwest::dns::Resolve with two lanes: - Per-host overrides (ODoH relay_ip/target_ip) short-circuit DNS entirely, preserving ODoH's zero-plain-DNS-leak property. - Otherwise: A+AAAA in parallel via UDP to IP-literal bootstrap servers, with TCP fallback for UDP-hostile networks. Bootstrap IPs come from upstream.fallback (IP-literal filtered, hostnames skipped with a warning). Empty fallback yields the hardcoded default [9.9.9.9, 1.1.1.1]; the chosen source is logged at startup so the silent default is visible. doh_keepalive_loop now fires its first tick immediately, and keepalive_doh logs failures at WARN — bootstrap issues surface within ~100ms of boot instead of on the first client query. Distinct from UpstreamPool.fallback (client-query failover) which stays untouched: client queries with no configured fallback still SERVFAIL on primary failure rather than silently shadow-routing. Reproducer: tests/docker/self-resolver-loop.sh. Before: 0 blocklist domains, 3072ms SERVFAIL. After: 397k domains, 118ms NOERROR. --- benches/recursive_compare.rs | 10 +- src/blocklist.rs | 15 +- src/bootstrap_resolver.rs | 225 +++++++++++++++++++++++++++++ src/config.rs | 22 ++- src/forward.rs | 64 +++++--- src/lib.rs | 1 + src/serve.rs | 61 ++++++-- tests/docker/self-resolver-loop.sh | 155 ++++++++++++++++++++ 8 files changed, 505 insertions(+), 48 deletions(-) create mode 100644 src/bootstrap_resolver.rs create mode 100755 tests/docker/self-resolver-loop.sh diff --git a/benches/recursive_compare.rs b/benches/recursive_compare.rs index 74f9576..4b9152c 100644 --- a/benches/recursive_compare.rs +++ b/benches/recursive_compare.rs @@ -383,7 +383,7 @@ fn run_default(rt: &tokio::runtime::Runtime) { /// Library-to-library: Numa forward_query_raw vs Hickory resolver.lookup. fn run_direct(rt: &tokio::runtime::Runtime) { - let upstream = numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse"); + let upstream = numa::forward::parse_upstream(DOH_UPSTREAM, 443, None).expect("failed to parse"); let resolver = rt.block_on(build_hickory_resolver()); let timeout = Duration::from_secs(10); @@ -609,9 +609,9 @@ fn run_hedge_multi(rt: &tokio::runtime::Runtime, iterations: usize) { DOMAINS.len() ); - let primary = numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse"); - let primary_dual = numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse"); - let secondary_dual = numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse"); + let primary = numa::forward::parse_upstream(DOH_UPSTREAM, 443, None).expect("failed to parse"); + let primary_dual = numa::forward::parse_upstream(DOH_UPSTREAM, 443, None).expect("failed to parse"); + let secondary_dual = numa::forward::parse_upstream(DOH_UPSTREAM, 443, None).expect("failed to parse"); let resolver = rt.block_on(build_hickory_resolver()); println!("Warming up..."); @@ -810,7 +810,7 @@ fn run_diag(rt: &tokio::runtime::Runtime) { fn run_diag_clients(rt: &tokio::runtime::Runtime) { println!("Client diagnostic: reqwest vs Hickory (20 queries to {DOH_UPSTREAM})\n"); - let upstream = numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse"); + let upstream = numa::forward::parse_upstream(DOH_UPSTREAM, 443, None).expect("failed to parse"); let resolver = rt.block_on(build_hickory_resolver()); let timeout = Duration::from_secs(10); diff --git a/src/blocklist.rs b/src/blocklist.rs index 20ac95d..87b43ed 100644 --- a/src/blocklist.rs +++ b/src/blocklist.rs @@ -357,12 +357,17 @@ mod tests { const RETRY_DELAYS_SECS: &[u64] = &[2, 10, 30]; -pub async fn download_blocklists(lists: &[String]) -> Vec<(String, String)> { - let client = reqwest::Client::builder() +pub async fn download_blocklists( + lists: &[String], + resolver: Option>, +) -> Vec<(String, String)> { + let mut builder = reqwest::Client::builder() .timeout(Duration::from_secs(30)) - .gzip(true) - .build() - .unwrap_or_default(); + .gzip(true); + if let Some(r) = resolver { + builder = builder.dns_resolver(r); + } + let client = builder.build().unwrap_or_default(); let fetches = lists.iter().map(|url| { let client = &client; diff --git a/src/bootstrap_resolver.rs b/src/bootstrap_resolver.rs new file mode 100644 index 0000000..fce5e4a --- /dev/null +++ b/src/bootstrap_resolver.rs @@ -0,0 +1,225 @@ +//! `reqwest` DNS resolver used by numa-originated HTTPS (DoH upstream, ODoH +//! relay/target, blocklist CDN). When numa is its own system resolver +//! (`/etc/resolv.conf → 127.0.0.1`, HAOS add-on, Pi-hole-style container), +//! the default `getaddrinfo` path loops back through numa before numa can +//! answer — a chicken-and-egg that deadlocks cold boot. See issue #122 and +//! `docs/implementation/bootstrap-resolver.md`. +//! +//! Resolution order per hostname: +//! 1. Per-hostname overrides (e.g. ODoH `relay_ip` / `target_ip`) → return +//! immediately, no DNS query. Preserves ODoH's "zero plain-DNS leak" +//! property for configured endpoints. +//! 2. Otherwise, query A + AAAA in parallel via UDP to IP-literal bootstrap +//! servers, with TCP fallback on UDP timeout (for networks that block +//! outbound UDP:53 — see memory: `project_network_udp_hostile.md`). + +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::time::Duration; + +use log::{debug, info, warn}; +use reqwest::dns::{Addrs, Name, Resolve, Resolving}; + +use crate::forward::{forward_tcp, forward_udp}; +use crate::packet::DnsPacket; +use crate::question::QueryType; +use crate::record::DnsRecord; + +const UDP_TIMEOUT: Duration = Duration::from_millis(800); +const TCP_TIMEOUT: Duration = Duration::from_millis(1500); +const DEFAULT_BOOTSTRAP: &[SocketAddr] = &[ + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 53), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 53), +]; + +pub struct NumaResolver { + bootstrap: Vec, + overrides: HashMap>, +} + +impl NumaResolver { + /// Build a resolver from the configured `upstream.fallback` list and any + /// per-hostname overrides (e.g. ODoH's `relay_ip`/`target_ip`). + /// + /// `fallback` entries are filtered to IP literals only — hostnames would + /// re-introduce the self-loop inside the resolver itself. Empty or + /// unusable fallback yields the hardcoded default (Quad9 + Cloudflare). + pub fn new(fallback: &[String], overrides: HashMap>) -> Self { + let mut bootstrap: Vec = Vec::with_capacity(fallback.len()); + for entry in fallback { + match crate::forward::parse_upstream_addr(entry, 53) { + Ok(addr) => bootstrap.push(addr), + Err(_) => { + warn!( + "bootstrap_resolver: skipping non-IP fallback '{}' \ + (hostnames would re-enter the self-loop)", + entry + ); + } + } + } + let source = if bootstrap.is_empty() { + bootstrap = DEFAULT_BOOTSTRAP.to_vec(); + "default (no IP-literal in upstream.fallback)" + } else { + "upstream.fallback" + }; + let ips: Vec = bootstrap.iter().map(|s| s.ip().to_string()).collect(); + info!( + "bootstrap resolver: {} via {} — used for numa-originated HTTPS hostname resolution", + ips.join(", "), + source + ); + Self { + bootstrap, + overrides, + } + } + + #[cfg(test)] + pub fn bootstrap(&self) -> &[SocketAddr] { + &self.bootstrap + } +} + +impl Resolve for NumaResolver { + fn resolve(&self, name: Name) -> Resolving { + let hostname = name.as_str().to_string(); + + if let Some(ips) = self.overrides.get(&hostname) { + let addrs: Vec = + ips.iter().map(|ip| SocketAddr::new(*ip, 0)).collect(); + debug!( + "bootstrap_resolver: override hit for {} → {:?}", + hostname, ips + ); + return Box::pin( + async move { Ok(Box::new(addrs.into_iter()) as Addrs) }, + ); + } + + let bootstrap = self.bootstrap.clone(); + Box::pin(async move { + let addrs = resolve_via_bootstrap(&hostname, &bootstrap).await?; + debug!( + "bootstrap_resolver: resolved {} → {} addr(s)", + hostname, + addrs.len() + ); + Ok(Box::new(addrs.into_iter()) as Addrs) + }) + } +} + +async fn resolve_via_bootstrap( + hostname: &str, + bootstrap: &[SocketAddr], +) -> Result, Box> { + let mut last_err: Option = None; + for &server in bootstrap { + let q_a = DnsPacket::query(0xBEEF, hostname, QueryType::A); + let q_aaaa = DnsPacket::query(0xBEF0, hostname, QueryType::AAAA); + let (a_res, aaaa_res) = tokio::join!( + query_with_tcp_fallback(&q_a, server), + query_with_tcp_fallback(&q_aaaa, server), + ); + + let mut out = Vec::new(); + match a_res { + Ok(pkt) => extract_addrs(&pkt, &mut out), + Err(e) => last_err = Some(format!("{} A failed: {}", server, e)), + } + match aaaa_res { + Ok(pkt) => extract_addrs(&pkt, &mut out), + // AAAA is optional — many hosts return NXDOMAIN/empty. Don't + // treat as the primary error if A succeeded. + Err(e) => debug!("bootstrap {} AAAA for {} failed: {}", server, hostname, e), + } + if !out.is_empty() { + return Ok(out); + } + } + Err(last_err + .unwrap_or_else(|| "no bootstrap servers reachable".into()) + .into()) +} + +async fn query_with_tcp_fallback(query: &DnsPacket, server: SocketAddr) -> crate::Result { + match forward_udp(query, server, UDP_TIMEOUT).await { + Ok(pkt) => Ok(pkt), + Err(e) => { + debug!( + "bootstrap UDP {} failed ({}), falling back to TCP", + server, e + ); + forward_tcp(query, server, TCP_TIMEOUT).await + } + } +} + +fn extract_addrs(pkt: &DnsPacket, out: &mut Vec) { + for r in &pkt.answers { + match r { + DnsRecord::A { addr, .. } => out.push(SocketAddr::new(IpAddr::V4(*addr), 0)), + DnsRecord::AAAA { addr, .. } => out.push(SocketAddr::new(IpAddr::V6(*addr), 0)), + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{Ipv4Addr, Ipv6Addr}; + + #[test] + fn empty_fallback_uses_defaults() { + let r = NumaResolver::new(&[], HashMap::new()); + let got: Vec = r.bootstrap().iter().map(|s| s.to_string()).collect(); + assert_eq!(got, vec!["9.9.9.9:53", "1.1.1.1:53"]); + } + + #[test] + fn fallback_accepts_ip_literals_only() { + let fallback = vec![ + "9.9.9.9".to_string(), + "dns.quad9.net".to_string(), + "1.1.1.1:5353".to_string(), + ]; + let r = NumaResolver::new(&fallback, HashMap::new()); + let got: Vec = r.bootstrap().iter().map(|s| s.to_string()).collect(); + assert_eq!(got, vec!["9.9.9.9:53", "1.1.1.1:5353"]); + } + + #[test] + fn override_returns_configured_ips_without_dns() { + let mut overrides = HashMap::new(); + overrides.insert( + "odoh-relay.example".to_string(), + vec![IpAddr::V4(Ipv4Addr::new(178, 104, 229, 30))], + ); + let r = NumaResolver::new(&[], overrides); + let name: Name = "odoh-relay.example".parse().unwrap(); + let fut = r.resolve(name); + let res = futures::executor::block_on(fut).unwrap(); + let addrs: Vec<_> = res.collect(); + assert_eq!(addrs.len(), 1); + assert_eq!(addrs[0].ip(), IpAddr::V4(Ipv4Addr::new(178, 104, 229, 30))); + } + + #[test] + fn override_supports_multiple_ips_including_ipv6() { + let mut overrides = HashMap::new(); + overrides.insert( + "dual.example".to_string(), + vec![ + IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), + IpAddr::V6(Ipv6Addr::LOCALHOST), + ], + ); + let r = NumaResolver::new(&[], overrides); + let res = futures::executor::block_on(r.resolve("dual.example".parse().unwrap())).unwrap(); + let addrs: Vec<_> = res.collect(); + assert_eq!(addrs.len(), 2); + } +} diff --git a/src/config.rs b/src/config.rs index 3a41d24..272e6c6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -56,7 +56,7 @@ impl ForwardingRuleConfig { } let mut primary = Vec::with_capacity(self.upstream.len()); for s in &self.upstream { - let u = crate::forward::parse_upstream(s, 53) + let u = crate::forward::parse_upstream(s, 53, None) .map_err(|e| format!("forwarding rule for upstream '{}': {}", s, e))?; primary.push(u); } @@ -241,6 +241,26 @@ pub struct OdohUpstream { pub target_bootstrap: Option, } +impl OdohUpstream { + /// Per-host IP overrides for the bootstrap resolver, lifted from + /// `relay_ip`/`target_ip`. Keeps the "zero plain-DNS leak for ODoH + /// endpoints" property when numa is its own system resolver. + pub fn host_ip_overrides(&self) -> std::collections::HashMap> { + let mut out = std::collections::HashMap::new(); + if let Some(addr) = self.relay_bootstrap { + out.entry(self.relay_host.clone()) + .or_insert_with(Vec::new) + .push(addr.ip()); + } + if let Some(addr) = self.target_bootstrap { + out.entry(self.target_host.clone()) + .or_insert_with(Vec::new) + .push(addr.ip()); + } + out + } +} + impl UpstreamConfig { /// Validate and extract ODoH-specific fields. Called during `load_config` /// so misconfigured ODoH fails fast at startup, the same care we take diff --git a/src/forward.rs b/src/forward.rs index 530f1ed..892e5b6 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -113,7 +113,7 @@ impl fmt::Display for Upstream { } } -pub(crate) fn parse_upstream_addr( +pub fn parse_upstream_addr( s: &str, default_port: u16, ) -> std::result::Result { @@ -129,19 +129,28 @@ pub(crate) fn parse_upstream_addr( } /// Parse a slice of upstream address strings into `Upstream` values, failing -/// on the first invalid entry. -pub fn parse_upstream_list(addrs: &[String], default_port: u16) -> Result> { +/// on the first invalid entry. DoH entries use `resolver` (when provided) as +/// their hostname resolver. +pub fn parse_upstream_list( + addrs: &[String], + default_port: u16, + resolver: Option>, +) -> Result> { addrs .iter() - .map(|s| parse_upstream(s, default_port)) + .map(|s| parse_upstream(s, default_port, resolver.clone())) .collect() } -pub fn parse_upstream(s: &str, default_port: u16) -> Result { +pub fn parse_upstream( + s: &str, + default_port: u16, + resolver: Option>, +) -> Result { if s.starts_with("https://") { return Ok(Upstream::Doh { url: s.to_string(), - client: build_https_client(), + client: build_https_client_with_resolver(1, resolver), }); } // tls://IP:PORT#hostname or tls://IP#hostname (default port 853) @@ -163,12 +172,16 @@ pub fn parse_upstream(s: &str, default_port: u16) -> Result { } /// HTTP/2 client tuned for DoH/ODoH: small windows for low latency, long-lived -/// keep-alive. Shared by the DoH upstream and the ODoH config-fetcher + -/// seal/open path. Pool defaults to one idle conn per host — good for -/// resolvers that talk to a single upstream; relays that fan out to many -/// targets should use [`build_https_client_with_pool`]. +/// keep-alive. Pool defaults to one idle conn per host — good for resolvers +/// that talk to a single upstream; relays that fan out to many targets +/// should use [`build_https_client_with_pool`]. +/// +/// Uses the system resolver. Callers running inside `serve::run` pass the +/// shared [`crate::bootstrap_resolver::NumaResolver`] via +/// [`build_https_client_with_resolver`] to avoid the self-loop documented +/// in `docs/implementation/bootstrap-resolver.md`. pub fn build_https_client() -> reqwest::Client { - build_https_client_with_pool(1) + build_https_client_with_resolver(1, None) } /// Same shape as [`build_https_client`], but caller picks @@ -176,20 +189,18 @@ pub fn build_https_client() -> reqwest::Client { /// and benefit from a larger pool so warm connections survive concurrent /// fan-out. pub fn build_https_client_with_pool(pool_max_idle_per_host: usize) -> reqwest::Client { - https_client_builder(pool_max_idle_per_host) - .build() - .unwrap_or_default() + build_https_client_with_resolver(pool_max_idle_per_host, None) } -/// HTTPS client for the ODoH upstream, with bootstrap-IP overrides applied -/// so relay/target hostname resolution can bypass system DNS. -pub fn build_odoh_client(odoh: &crate::config::OdohUpstream) -> reqwest::Client { - let mut builder = https_client_builder(1); - if let Some(addr) = odoh.relay_bootstrap { - builder = builder.resolve(&odoh.relay_host, addr); - } - if let Some(addr) = odoh.target_bootstrap { - builder = builder.resolve(&odoh.target_host, addr); +/// [`build_https_client`] with an optional custom DNS resolver. Numa wires +/// [`crate::bootstrap_resolver::NumaResolver`] here. +pub fn build_https_client_with_resolver( + pool_max_idle_per_host: usize, + resolver: Option>, +) -> reqwest::Client { + let mut builder = https_client_builder(pool_max_idle_per_host); + if let Some(r) = resolver { + builder = builder.dns_resolver(r); } builder.build().unwrap_or_default() } @@ -553,6 +564,9 @@ async fn forward_doh_raw( /// Send a lightweight keepalive query to a DoH upstream to prevent /// the HTTP/2 + TLS connection from going idle and being torn down. +/// The first call doubles as a startup warm-up: bootstrap-resolver failures +/// (unreachable Quad9/Cloudflare defaults, misconfigured hostname upstream) +/// surface here rather than on the first client query. pub async fn keepalive_doh(upstream: &Upstream) { if let Upstream::Doh { url, client } = upstream { // Query for . NS — minimal, always succeeds, response is small @@ -565,7 +579,9 @@ pub async fn keepalive_doh(upstream: &Upstream) { 0x00, 0x02, // type NS 0x00, 0x01, // class IN ]; - let _ = forward_doh_raw(wire, url, client, Duration::from_secs(5)).await; + if let Err(e) = forward_doh_raw(wire, url, client, Duration::from_secs(5)).await { + log::warn!("DoH keepalive to {} failed: {}", url, e); + } } } diff --git a/src/lib.rs b/src/lib.rs index aec568d..9b6af11 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod api; pub mod blocklist; +pub mod bootstrap_resolver; pub mod buffer; pub mod cache; pub mod config; diff --git a/src/serve.rs b/src/serve.rs index 9b4b587..1aa1fdb 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -13,13 +13,12 @@ use log::{error, info}; use tokio::net::UdpSocket; use crate::blocklist::{download_blocklists, parse_blocklist, BlocklistStore}; +use crate::bootstrap_resolver::NumaResolver; use crate::buffer::BytePacketBuffer; use crate::cache::DnsCache; use crate::config::{build_zone_map, load_config, ConfigLoad}; use crate::ctx::{handle_query, ServerCtx}; -use crate::forward::{ - build_https_client, build_odoh_client, parse_upstream_list, Upstream, UpstreamPool, -}; +use crate::forward::{build_https_client_with_resolver, parse_upstream_list, Upstream, UpstreamPool}; use crate::odoh::OdohConfigCache; use crate::override_store::OverrideStore; use crate::query_log::QueryLog; @@ -48,6 +47,23 @@ pub async fn run(config_path: String) -> crate::Result<()> { (dummy, "recursive (root hints)".to_string()) }; + // Routes numa-originated HTTPS (DoH upstream, ODoH relay/target, blocklist + // CDN) away from the system resolver so lookups don't loop back through + // numa when it's its own system DNS. + // See `docs/implementation/bootstrap-resolver.md`. + let resolver_overrides = match config.upstream.mode { + crate::config::UpstreamMode::Odoh => config + .upstream + .odoh_upstream() + .map(|o| o.host_ip_overrides()) + .unwrap_or_default(), + _ => std::collections::HashMap::new(), + }; + let bootstrap_resolver: Arc = Arc::new(NumaResolver::new( + &config.upstream.fallback, + resolver_overrides, + )); + let (resolved_mode, upstream_auto, pool, upstream_label) = match config.upstream.mode { crate::config::UpstreamMode::Auto => { info!("auto mode: probing recursive resolution..."); @@ -57,7 +73,7 @@ pub async fn run(config_path: String) -> crate::Result<()> { (crate::config::UpstreamMode::Recursive, false, pool, label) } else { log::warn!("recursive probe failed — falling back to Quad9 DoH"); - let client = build_https_client(); + let client = build_https_client_with_resolver(1, Some(bootstrap_resolver.clone())); let url = DOH_FALLBACK.to_string(); let label = url.clone(); let pool = UpstreamPool::new(vec![Upstream::Doh { url, client }], vec![]); @@ -82,8 +98,16 @@ pub async fn run(config_path: String) -> crate::Result<()> { config.upstream.address.clone() }; - let primary = parse_upstream_list(&addrs, config.upstream.port)?; - let fallback = parse_upstream_list(&config.upstream.fallback, config.upstream.port)?; + let primary = parse_upstream_list( + &addrs, + config.upstream.port, + Some(bootstrap_resolver.clone()), + )?; + let fallback = parse_upstream_list( + &config.upstream.fallback, + config.upstream.port, + Some(bootstrap_resolver.clone()), + )?; let pool = UpstreamPool::new(primary, fallback); let label = pool.label(); @@ -96,7 +120,7 @@ pub async fn run(config_path: String) -> crate::Result<()> { } crate::config::UpstreamMode::Odoh => { let odoh = config.upstream.odoh_upstream()?; - let client = build_odoh_client(&odoh); + let client = build_https_client_with_resolver(1, Some(bootstrap_resolver.clone())); let target_config = Arc::new(OdohConfigCache::new( odoh.target_host.clone(), client.clone(), @@ -110,7 +134,11 @@ pub async fn run(config_path: String) -> crate::Result<()> { let fallback = if odoh.strict { Vec::new() } else { - parse_upstream_list(&config.upstream.fallback, config.upstream.port)? + parse_upstream_list( + &config.upstream.fallback, + config.upstream.port, + Some(bootstrap_resolver.clone()), + )? }; let pool = UpstreamPool::new(primary, fallback); let label = pool.label(); @@ -405,8 +433,9 @@ pub async fn run(config_path: String) -> crate::Result<()> { if config.blocking.enabled && !blocklist_lists.is_empty() { let bl_ctx = Arc::clone(&ctx); let bl_lists = blocklist_lists.clone(); + let bl_resolver = bootstrap_resolver.clone(); tokio::spawn(async move { - load_blocklists(&bl_ctx, &bl_lists).await; + load_blocklists(&bl_ctx, &bl_lists, Some(bl_resolver.clone())).await; // Periodic refresh let mut interval = tokio::time::interval(Duration::from_secs(refresh_hours * 3600)); @@ -414,7 +443,7 @@ pub async fn run(config_path: String) -> crate::Result<()> { loop { interval.tick().await; info!("refreshing blocklists..."); - load_blocklists(&bl_ctx, &bl_lists).await; + load_blocklists(&bl_ctx, &bl_lists, Some(bl_resolver.clone())).await; } }); } @@ -596,8 +625,12 @@ async fn network_watch_loop(ctx: Arc) { } } -async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) { - let downloaded = download_blocklists(lists).await; +async fn load_blocklists( + ctx: &ServerCtx, + lists: &[String], + resolver: Option>, +) { + let downloaded = download_blocklists(lists, resolver).await; // Parse outside the lock to avoid blocking DNS queries during parse (~100ms) let mut all_domains = std::collections::HashSet::new(); @@ -632,8 +665,10 @@ async fn warm_domain(ctx: &ServerCtx, domain: &str) { } async fn doh_keepalive_loop(ctx: Arc) { + // First tick fires immediately so we surface bootstrap-resolver failures + // (unreachable Quad9/Cloudflare, blocked :53, bad upstream hostname) in + // the startup logs instead of on the first client query. let mut interval = tokio::time::interval(Duration::from_secs(25)); - interval.tick().await; // skip first immediate tick loop { interval.tick().await; let pool = ctx.upstream_pool.lock().unwrap().clone(); diff --git a/tests/docker/self-resolver-loop.sh b/tests/docker/self-resolver-loop.sh new file mode 100755 index 0000000..400b12c --- /dev/null +++ b/tests/docker/self-resolver-loop.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# +# Reproducer for issue #122 — chicken-and-egg when numa is its own system +# resolver (HAOS add-on, Pi-hole-style container, laptop with +# resolv.conf → 127.0.0.1). +# +# Topology: +# container /etc/resolv.conf → nameserver 127.0.0.1 +# numa bound on :53 → upstream DoH by hostname (quad9) +# numa boots → spawns blocklist download +# reqwest::get → getaddrinfo("cdn.jsdelivr.net") +# → loopback UDP :53 → numa → cache miss → DoH upstream +# → getaddrinfo("dns.quad9.net") → same loop → glibc EAI_AGAIN +# +# Expected on master: both assertions FAIL (bug reproduced). +# Expected after bootstrap-IP fix: both assertions PASS. +# +# Requirements: docker (with internet access for external lists/DoH) +# Usage: ./tests/docker/self-resolver-loop.sh + +set -euo pipefail + +cd "$(dirname "$0")/../.." + +GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m" + +pass() { printf " ${GREEN}✓${RESET} %s\n" "$1"; } +fail() { printf " ${RED}✗${RESET} %s\n" "$1"; printf " %s\n" "$2"; FAILED=$((FAILED+1)); } +FAILED=0 + +OUT=/tmp/numa-self-resolver.out + +echo "── self-resolver-loop: building + reproducing on debian:bookworm ──" +echo " (first run is slow: image pull + cold cargo build, ~5-8 min)" +echo + +docker run --rm \ + -v "$PWD:/src:ro" \ + -v numa-self-resolver-cargo:/root/.cargo \ + -v numa-self-resolver-target:/work/target \ + debian:bookworm bash -c ' +set -e + +# Phase 1: install deps + build with the container DNS as given by Docker +# (resolves deb.debian.org, static.rust-lang.org, crates.io). +apt-get update -qq && apt-get install -y -qq curl build-essential dnsutils 2>&1 | tail -3 + +if ! command -v cargo &>/dev/null; then + curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --quiet +fi +. "$HOME/.cargo/env" + +mkdir -p /work +tar -C /src --exclude=./target --exclude=./.git -cf - . | tar -C /work -xf - +cd /work + +echo "── cargo build --release --locked ──" +cargo build --release --locked 2>&1 | tail -5 +echo + +# Phase 2: flip system DNS to numa itself — this is the pathological +# topology from issue #122 (HAOS add-on, resolv.conf → 127.0.0.1). +# Everything after this point, any getaddrinfo call inside numa loops +# back through :53. +echo "nameserver 127.0.0.1" > /etc/resolv.conf +echo "── /etc/resolv.conf inside container (post-flip) ──" +cat /etc/resolv.conf +echo + +cat > /tmp/numa.toml < /tmp/numa.log 2>&1 & +NUMA_PID=$! + +# Wait up to 120s for blocklist to populate. +# Retry delays 2+10+30s = 42s, plus ~4 × ~10s getaddrinfo timeouts under +# self-loop = ~82s worst case. 120s leaves headroom. +LOADED=0 +for i in $(seq 1 120); do + LOADED=$(curl -sf http://127.0.0.1:5380/blocking/stats 2>/dev/null \ + | grep -o "\"domains_loaded\":[0-9]*" | cut -d: -f2 || echo 0) + [ "${LOADED:-0}" -gt 100 ] && break + sleep 1 +done + +# First cold DoH query — time it. +START=$(date +%s%N) +dig @127.0.0.1 example.com A +time=15 +tries=1 > /tmp/dig.out 2>&1 || true +END=$(date +%s%N) +LATENCY_MS=$(( (END - START) / 1000000 )) +STATUS=$(grep -oE "status: [A-Z]+" /tmp/dig.out | head -1 || echo "status: TIMEOUT") + +kill $NUMA_PID 2>/dev/null || true +wait $NUMA_PID 2>/dev/null || true + +echo +echo "=== RESULT ===" +echo "domains_loaded=$LOADED" +echo "first_query_latency_ms=$LATENCY_MS" +echo "first_query_${STATUS// /_}" +echo +echo "=== numa.log (tail 40) ===" +tail -40 /tmp/numa.log +echo +echo "=== dig.out ===" +cat /tmp/dig.out +' 2>&1 | tee "$OUT" + +echo +echo "── assertions ──" + +LOADED=$(grep '^domains_loaded=' "$OUT" | tail -1 | cut -d= -f2 || echo 0) +LATENCY=$(grep '^first_query_latency_ms=' "$OUT" | tail -1 | cut -d= -f2 || echo 999999) +STATUS_LINE=$(grep '^first_query_status_' "$OUT" | tail -1 || echo "first_query_status_TIMEOUT") + +if [ "${LOADED:-0}" -gt 100 ]; then + pass "blocklist downloaded (domains_loaded=$LOADED)" +else + fail "blocklist downloaded (got domains_loaded=${LOADED:-0}, expected >100)" \ + "chicken-and-egg: blocklist HTTPS client has no DNS bootstrap; getaddrinfo loops through numa" +fi + +if [ "${LATENCY:-999999}" -lt 2000 ]; then + pass "first DoH query under 2s (latency=${LATENCY}ms, $STATUS_LINE)" +else + fail "first DoH query under 2s (got ${LATENCY}ms, $STATUS_LINE)" \ + "self-loop on getaddrinfo(upstream_host); plain DoH needs bootstrap-IP symmetry with ODoH" +fi + +echo +if [ "$FAILED" -eq 0 ]; then + printf "${GREEN}── self-resolver-loop passed (fix is in place) ──${RESET}\n" + exit 0 +else + printf "${RED}── self-resolver-loop failed ($FAILED assertion(s)) — bug #122 reproduced ──${RESET}\n" + exit 1 +fi -- 2.34.1 From 459395203d5b64deda9d071f0b3cec8ec89c2911 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 21 Apr 2026 16:30:26 +0300 Subject: [PATCH 185/204] style: cargo fmt --- benches/recursive_compare.rs | 6 ++++-- src/bootstrap_resolver.rs | 12 +++++------ src/ctx.rs | 39 +++++++++++------------------------- src/forward.rs | 5 +---- src/serve.rs | 10 ++++----- 5 files changed, 27 insertions(+), 45 deletions(-) diff --git a/benches/recursive_compare.rs b/benches/recursive_compare.rs index 4b9152c..7649ab0 100644 --- a/benches/recursive_compare.rs +++ b/benches/recursive_compare.rs @@ -610,8 +610,10 @@ fn run_hedge_multi(rt: &tokio::runtime::Runtime, iterations: usize) { ); let primary = numa::forward::parse_upstream(DOH_UPSTREAM, 443, None).expect("failed to parse"); - let primary_dual = numa::forward::parse_upstream(DOH_UPSTREAM, 443, None).expect("failed to parse"); - let secondary_dual = numa::forward::parse_upstream(DOH_UPSTREAM, 443, None).expect("failed to parse"); + let primary_dual = + numa::forward::parse_upstream(DOH_UPSTREAM, 443, None).expect("failed to parse"); + let secondary_dual = + numa::forward::parse_upstream(DOH_UPSTREAM, 443, None).expect("failed to parse"); let resolver = rt.block_on(build_hickory_resolver()); println!("Warming up..."); diff --git a/src/bootstrap_resolver.rs b/src/bootstrap_resolver.rs index fce5e4a..1cf5c2e 100644 --- a/src/bootstrap_resolver.rs +++ b/src/bootstrap_resolver.rs @@ -87,15 +87,12 @@ impl Resolve for NumaResolver { let hostname = name.as_str().to_string(); if let Some(ips) = self.overrides.get(&hostname) { - let addrs: Vec = - ips.iter().map(|ip| SocketAddr::new(*ip, 0)).collect(); + let addrs: Vec = ips.iter().map(|ip| SocketAddr::new(*ip, 0)).collect(); debug!( "bootstrap_resolver: override hit for {} → {:?}", hostname, ips ); - return Box::pin( - async move { Ok(Box::new(addrs.into_iter()) as Addrs) }, - ); + return Box::pin(async move { Ok(Box::new(addrs.into_iter()) as Addrs) }); } let bootstrap = self.bootstrap.clone(); @@ -144,7 +141,10 @@ async fn resolve_via_bootstrap( .into()) } -async fn query_with_tcp_fallback(query: &DnsPacket, server: SocketAddr) -> crate::Result { +async fn query_with_tcp_fallback( + query: &DnsPacket, + server: SocketAddr, +) -> crate::Result { match forward_udp(query, server, UDP_TIMEOUT).await { Ok(pkt) => Ok(pkt), Err(e) => { diff --git a/src/ctx.rs b/src/ctx.rs index a0c15ac..0d39f7d 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -210,12 +210,8 @@ pub async fn resolve_query( // Conditional forwarding takes priority over recursive mode // (e.g. Tailscale .ts.net, VPC private zones) let key = (qname.clone(), qtype); - let (resp, path, err) = resolve_coalesced( - &ctx.inflight, - key, - &query, - QueryPath::Forwarded, - || async { + let (resp, path, err) = + resolve_coalesced(&ctx.inflight, key, &query, QueryPath::Forwarded, || async { let wire = forward_with_failover_raw( raw_wire, pool, @@ -225,9 +221,8 @@ pub async fn resolve_query( ) .await?; cache_and_parse(ctx, &qname, qtype, &wire) - }, - ) - .await; + }) + .await; log_coalesced_outcome(src_addr, qtype, &qname, path, err.as_deref(), "FORWARD"); if path == QueryPath::Forwarded { upstream_transport = pool.preferred().map(|u| u.transport()); @@ -238,12 +233,8 @@ pub async fn resolve_query( // tag as Udp so the dashboard can aggregate plaintext-wire // egress honestly. Only mark on success — errors stay None. let key = (qname.clone(), qtype); - let (resp, path, err) = resolve_coalesced( - &ctx.inflight, - key, - &query, - QueryPath::Recursive, - || { + let (resp, path, err) = + resolve_coalesced(&ctx.inflight, key, &query, QueryPath::Recursive, || { crate::recursive::resolve_recursive( &qname, qtype, @@ -252,9 +243,8 @@ pub async fn resolve_query( &ctx.root_hints, &ctx.srtt, ) - }, - ) - .await; + }) + .await; log_coalesced_outcome(src_addr, qtype, &qname, path, err.as_deref(), "RECURSIVE"); if path == QueryPath::Recursive { upstream_transport = Some(crate::stats::UpstreamTransport::Udp); @@ -263,12 +253,8 @@ pub async fn resolve_query( } else { let pool = ctx.upstream_pool.lock().unwrap().clone(); let key = (qname.clone(), qtype); - let (resp, path, err) = resolve_coalesced( - &ctx.inflight, - key, - &query, - QueryPath::Upstream, - || async { + let (resp, path, err) = + resolve_coalesced(&ctx.inflight, key, &query, QueryPath::Upstream, || async { let wire = forward_with_failover_raw( raw_wire, &pool, @@ -278,9 +264,8 @@ pub async fn resolve_query( ) .await?; cache_and_parse(ctx, &qname, qtype, &wire) - }, - ) - .await; + }) + .await; log_coalesced_outcome(src_addr, qtype, &qname, path, err.as_deref(), "UPSTREAM"); if path == QueryPath::Upstream { upstream_transport = pool.preferred().map(|u| u.transport()); diff --git a/src/forward.rs b/src/forward.rs index 892e5b6..e3f307b 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -113,10 +113,7 @@ impl fmt::Display for Upstream { } } -pub fn parse_upstream_addr( - s: &str, - default_port: u16, -) -> std::result::Result { +pub fn parse_upstream_addr(s: &str, default_port: u16) -> std::result::Result { // Try full socket addr first: "1.2.3.4:5353" or "[::1]:5353" if let Ok(addr) = s.parse::() { return Ok(addr); diff --git a/src/serve.rs b/src/serve.rs index 1aa1fdb..288f6f8 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -18,7 +18,9 @@ use crate::buffer::BytePacketBuffer; use crate::cache::DnsCache; use crate::config::{build_zone_map, load_config, ConfigLoad}; use crate::ctx::{handle_query, ServerCtx}; -use crate::forward::{build_https_client_with_resolver, parse_upstream_list, Upstream, UpstreamPool}; +use crate::forward::{ + build_https_client_with_resolver, parse_upstream_list, Upstream, UpstreamPool, +}; use crate::odoh::OdohConfigCache; use crate::override_store::OverrideStore; use crate::query_log::QueryLog; @@ -625,11 +627,7 @@ async fn network_watch_loop(ctx: Arc) { } } -async fn load_blocklists( - ctx: &ServerCtx, - lists: &[String], - resolver: Option>, -) { +async fn load_blocklists(ctx: &ServerCtx, lists: &[String], resolver: Option>) { let downloaded = download_blocklists(lists, resolver).await; // Parse outside the lock to avoid blocking DNS queries during parse (~100ms) -- 2.34.1 From 51cce0347bbaf845c63957827553c78963a08376 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 21 Apr 2026 17:35:59 +0300 Subject: [PATCH 186/204] test(odoh): integration-verify relay_ip/target_ip override wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suite 8 now ends with a config using RFC 5737 TEST-NET-1 IPs as relay_ip/target_ip, started briefly so the bootstrap resolver logs its override map. Asserts both host=IP pairs land in that map — closing the gap flagged on PR #126 (zero-plain-DNS-leak for ODoH endpoints was only unit-tested). Also: NumaResolver::new now logs the override map at INFO when non-empty, so operators can verify their ODoH bootstrap without needing DEBUG level. --- src/bootstrap_resolver.rs | 11 ++++++++++ tests/integration.sh | 46 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/bootstrap_resolver.rs b/src/bootstrap_resolver.rs index 1cf5c2e..94b03ea 100644 --- a/src/bootstrap_resolver.rs +++ b/src/bootstrap_resolver.rs @@ -70,6 +70,17 @@ impl NumaResolver { ips.join(", "), source ); + if !overrides.is_empty() { + let mut pairs: Vec = overrides + .iter() + .flat_map(|(host, ips)| ips.iter().map(move |ip| format!("{}={}", host, ip))) + .collect(); + pairs.sort(); + info!( + "bootstrap resolver: host overrides (skip DNS, connect direct): {}", + pairs.join(", ") + ); + } Self { bootstrap, overrides, diff --git a/tests/integration.sh b/tests/integration.sh index 77b874f..1773c11 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -975,6 +975,52 @@ check "Same-host relay+target rejected at startup" \ "same host" \ "$STARTUP_OUT" +# relay_ip / target_ip must land in the bootstrap resolver's override map, +# so reqwest connects direct to the configured IPs instead of resolving the +# hostnames via plain DNS (ODoH's zero-plain-DNS-leak property). Using +# RFC 5737 TEST-NET-1 IPs — never routable, so the OdohConfigCache won't +# actually connect, but the override-map wiring is visible in the startup log. +cat > "$CONFIG" << 'CONF' +[server] +bind_addr = "127.0.0.1:5354" +api_port = 5381 + +[upstream] +mode = "odoh" +relay = "https://odoh-relay.example.com/proxy" +target = "https://odoh-target.example.org/dns-query" +relay_ip = "192.0.2.1" +target_ip = "192.0.2.2" + +[cache] +max_entries = 10000 + +[blocking] +enabled = false + +[proxy] +enabled = false +CONF + +RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 & +NUMA_PID=$! +for _ in $(seq 1 30); do + curl -sf "http://127.0.0.1:$API_PORT/health" >/dev/null 2>&1 && break + sleep 0.1 +done + +OVERRIDE_LOG=$(grep 'bootstrap resolver: host overrides' "$LOG" || true) +check "relay_ip wired into bootstrap override map" \ + "odoh-relay.example.com=192.0.2.1" \ + "$OVERRIDE_LOG" +check "target_ip wired into bootstrap override map" \ + "odoh-target.example.org=192.0.2.2" \ + "$OVERRIDE_LOG" + +kill "$NUMA_PID" 2>/dev/null || true +wait "$NUMA_PID" 2>/dev/null || true +sleep 1 + fi # end Suite 8 # ---- Suite 9: Numa's own ODoH relay (--relay-mode) ---- -- 2.34.1 From 5cba02a6c8d2fb78ac9a68dc31ed897581e32fbd Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 21 Apr 2026 18:06:22 +0300 Subject: [PATCH 187/204] refactor(bootstrap): BTreeMap for overrides + simplify review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch overrides from HashMap to BTreeMap — deterministic iteration by type, drops the manual sort when logging. - Rename the flat_map closure's inner `ips` to `addrs` to stop shadowing the outer Vec. - Trim the Suite 8 TEST-NET-1 comment to keep the "why" and drop mechanism narration. - Drop a redundant sleep 1 after wait — wait already blocks on exit. --- src/bootstrap_resolver.rs | 19 +++++++++---------- src/config.rs | 4 ++-- src/serve.rs | 2 +- tests/integration.sh | 10 ++++------ 4 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/bootstrap_resolver.rs b/src/bootstrap_resolver.rs index 94b03ea..c3be8bd 100644 --- a/src/bootstrap_resolver.rs +++ b/src/bootstrap_resolver.rs @@ -13,7 +13,7 @@ //! servers, with TCP fallback on UDP timeout (for networks that block //! outbound UDP:53 — see memory: `project_network_udp_hostile.md`). -use std::collections::HashMap; +use std::collections::BTreeMap; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::time::Duration; @@ -34,7 +34,7 @@ const DEFAULT_BOOTSTRAP: &[SocketAddr] = &[ pub struct NumaResolver { bootstrap: Vec, - overrides: HashMap>, + overrides: BTreeMap>, } impl NumaResolver { @@ -44,7 +44,7 @@ impl NumaResolver { /// `fallback` entries are filtered to IP literals only — hostnames would /// re-introduce the self-loop inside the resolver itself. Empty or /// unusable fallback yields the hardcoded default (Quad9 + Cloudflare). - pub fn new(fallback: &[String], overrides: HashMap>) -> Self { + pub fn new(fallback: &[String], overrides: BTreeMap>) -> Self { let mut bootstrap: Vec = Vec::with_capacity(fallback.len()); for entry in fallback { match crate::forward::parse_upstream_addr(entry, 53) { @@ -71,11 +71,10 @@ impl NumaResolver { source ); if !overrides.is_empty() { - let mut pairs: Vec = overrides + let pairs: Vec = overrides .iter() - .flat_map(|(host, ips)| ips.iter().map(move |ip| format!("{}={}", host, ip))) + .flat_map(|(host, addrs)| addrs.iter().map(move |ip| format!("{}={}", host, ip))) .collect(); - pairs.sort(); info!( "bootstrap resolver: host overrides (skip DNS, connect direct): {}", pairs.join(", ") @@ -185,7 +184,7 @@ mod tests { #[test] fn empty_fallback_uses_defaults() { - let r = NumaResolver::new(&[], HashMap::new()); + let r = NumaResolver::new(&[], BTreeMap::new()); let got: Vec = r.bootstrap().iter().map(|s| s.to_string()).collect(); assert_eq!(got, vec!["9.9.9.9:53", "1.1.1.1:53"]); } @@ -197,14 +196,14 @@ mod tests { "dns.quad9.net".to_string(), "1.1.1.1:5353".to_string(), ]; - let r = NumaResolver::new(&fallback, HashMap::new()); + let r = NumaResolver::new(&fallback, BTreeMap::new()); let got: Vec = r.bootstrap().iter().map(|s| s.to_string()).collect(); assert_eq!(got, vec!["9.9.9.9:53", "1.1.1.1:5353"]); } #[test] fn override_returns_configured_ips_without_dns() { - let mut overrides = HashMap::new(); + let mut overrides = BTreeMap::new(); overrides.insert( "odoh-relay.example".to_string(), vec![IpAddr::V4(Ipv4Addr::new(178, 104, 229, 30))], @@ -220,7 +219,7 @@ mod tests { #[test] fn override_supports_multiple_ips_including_ipv6() { - let mut overrides = HashMap::new(); + let mut overrides = BTreeMap::new(); overrides.insert( "dual.example".to_string(), vec![ diff --git a/src/config.rs b/src/config.rs index 272e6c6..6daf430 100644 --- a/src/config.rs +++ b/src/config.rs @@ -245,8 +245,8 @@ impl OdohUpstream { /// Per-host IP overrides for the bootstrap resolver, lifted from /// `relay_ip`/`target_ip`. Keeps the "zero plain-DNS leak for ODoH /// endpoints" property when numa is its own system resolver. - pub fn host_ip_overrides(&self) -> std::collections::HashMap> { - let mut out = std::collections::HashMap::new(); + pub fn host_ip_overrides(&self) -> std::collections::BTreeMap> { + let mut out = std::collections::BTreeMap::new(); if let Some(addr) = self.relay_bootstrap { out.entry(self.relay_host.clone()) .or_insert_with(Vec::new) diff --git a/src/serve.rs b/src/serve.rs index 288f6f8..c76d174 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -59,7 +59,7 @@ pub async fn run(config_path: String) -> crate::Result<()> { .odoh_upstream() .map(|o| o.host_ip_overrides()) .unwrap_or_default(), - _ => std::collections::HashMap::new(), + _ => std::collections::BTreeMap::new(), }; let bootstrap_resolver: Arc = Arc::new(NumaResolver::new( &config.upstream.fallback, diff --git a/tests/integration.sh b/tests/integration.sh index 1773c11..76b1dab 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -975,11 +975,10 @@ check "Same-host relay+target rejected at startup" \ "same host" \ "$STARTUP_OUT" -# relay_ip / target_ip must land in the bootstrap resolver's override map, -# so reqwest connects direct to the configured IPs instead of resolving the -# hostnames via plain DNS (ODoH's zero-plain-DNS-leak property). Using -# RFC 5737 TEST-NET-1 IPs — never routable, so the OdohConfigCache won't -# actually connect, but the override-map wiring is visible in the startup log. +# Guards ODoH's zero-plain-DNS-leak property: relay_ip / target_ip must +# land in the bootstrap resolver's override map so reqwest connects direct +# to the configured IPs instead of resolving the hostnames via plain DNS. +# RFC 5737 TEST-NET-1 IPs (unroutable). cat > "$CONFIG" << 'CONF' [server] bind_addr = "127.0.0.1:5354" @@ -1019,7 +1018,6 @@ check "target_ip wired into bootstrap override map" \ kill "$NUMA_PID" 2>/dev/null || true wait "$NUMA_PID" 2>/dev/null || true -sleep 1 fi # end Suite 8 -- 2.34.1 From 5ba19e04c8d64d78a336f99e7711d06587b016f2 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 22 Apr 2026 15:49:58 +0300 Subject: [PATCH 188/204] chore: gitignore local Claude Code harness state .claude/ holds per-session harness files (settings.local.json, task locks, worktree metadata). None of it belongs in the repo. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index acfc601..129a76e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /target /build-dir CLAUDE.md +.claude/ docs/ site/blog/posts/ ios/ -- 2.34.1 From 640b64bf7e1be666d2eb645708924f0cc7f2c1b9 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 22 Apr 2026 15:50:21 +0300 Subject: [PATCH 189/204] chore(site): live-reload dev server via chokidar + browser-sync Replaces the plain python3 http.server + one-shot make blog with a watcher pipeline: chokidar regenerates HTML on MD/template changes, browser-sync serves the site and reloads the browser on rendered-asset changes. First run downloads both via npx; subsequent runs are instant. Preflight checks for npx and pandoc. Port arg parsing is tolerant of legacy --drafts flag ordering (drafts are always included now, since that's what the dev loop actually wants). Cleanup trap kills the watcher on exit so re-runs don't leave orphans. --- scripts/serve-site.sh | 45 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/scripts/serve-site.sh b/scripts/serve-site.sh index 23854ff..18fc4a9 100755 --- a/scripts/serve-site.sh +++ b/scripts/serve-site.sh @@ -1,14 +1,41 @@ #!/usr/bin/env bash +# Dev server for site/: regenerates drafts on each MD change, reloads the +# browser on each rendered HTML/CSS/JS change. Port is the first numeric arg +# (default 9000); any other args are ignored for back-compat. +# +# First run downloads chokidar-cli + browser-sync into the npm cache — slow +# once, instant after that. + set -euo pipefail -PORT="${1:-9000}" +PORT=9000 +for arg in "$@"; do + if [[ "$arg" =~ ^[0-9]+$ ]]; then + PORT="$arg" + break + fi +done -if [[ "${1:-}" == "--drafts" ]] || [[ "${2:-}" == "--drafts" ]]; then - PORT="${PORT//--drafts/9000}" # default port if --drafts was first arg - make blog-drafts -else - make blog -fi +command -v npx >/dev/null || { echo "npx not found. Install Node.js: https://nodejs.org" >&2; exit 1; } +command -v pandoc >/dev/null || { echo "pandoc not found (required by 'make blog-drafts')." >&2; exit 1; } -echo "Serving site at http://localhost:$PORT" -cd site && python3 -m http.server "$PORT" +# Initial render so the first page load has everything. +make blog-drafts + +echo "Serving site at http://localhost:$PORT (drafts included, live reload)" + +# Kill child processes on exit so re-runs don't leave orphaned watchers. +trap 'kill $(jobs -p) 2>/dev/null' EXIT INT TERM + +# Regenerate HTML when MD sources or the blog template change. +npx --yes chokidar-cli \ + "drafts/*.md" "blog/*.md" "site/blog-template.html" \ + -c "make blog-drafts" & + +# Serve + reload on rendered-asset changes. +cd site && exec npx --yes browser-sync start \ + --server . \ + --port "$PORT" \ + --files "**/*.html,**/*.css,**/*.js" \ + --no-open \ + --no-notify -- 2.34.1 From df2062882c4983d70348ba3132db5960f9e414e1 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 22 Apr 2026 16:42:10 +0300 Subject: [PATCH 190/204] chore: bump rustls-webpki to 0.103.13 for RUSTSEC-2026-0104 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advisory published 2026-04-22: reachable panic in certificate revocation list parsing. Patch is a lockfile-only bump — transitive via rustls, no direct dep changes. Unblocks cargo audit in CI across all open PRs. --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c7a8742..1da534a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2130,9 +2130,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", -- 2.34.1 From 2e461ccc0f71098427fec0f21fdf153c9966d65f Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 22 Apr 2026 15:49:39 +0300 Subject: [PATCH 191/204] docs(config): add ODoH upstream examples with relay_ip/target_ip pinning Complements the bootstrap resolver fix (#122, #126) by documenting the ODoH knobs in the commented config template. Explains relay_ip/target_ip as the way to prevent plain-DNS leaks of the relay/target hostnames via the bootstrap resolver on cold boot when numa is its own system DNS. --- numa.toml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/numa.toml b/numa.toml index c25654a..93418ea 100644 --- a/numa.toml +++ b/numa.toml @@ -22,6 +22,7 @@ api_port = 5380 # [upstream] # mode = "forward" # "forward" (default) — relay to upstream # # "recursive" — resolve from root hints (no address needed) +# # "odoh" — Oblivious DoH (see ODoH block below) # address = "9.9.9.9" # single upstream (plain UDP) # address = ["192.168.1.1", "9.9.9.9:5353"] # multiple upstreams — SRTT picks fastest # address = "https://dns.quad9.net/dns-query" # DNS-over-HTTPS (encrypted) @@ -34,6 +35,22 @@ api_port = 5380 # # to the same upstream. Rescues packet loss (UDP), # # dispatch spikes (DoH), TLS stalls (DoT). # # Set to 0 to disable. Default: 10 + +# ODoH (Oblivious DNS-over-HTTPS, RFC 9230). The relay sees your IP but +# not the question; the target sees the question but not your IP. Numa +# refuses same-operator relay+target configs by default (eTLD+1 check). +# [upstream] +# mode = "odoh" +# relay = "https://odoh-relay.numa.rs/proxy" +# target = "https://odoh.cloudflare-dns.com/dns-query" +# strict = true # default: refuse to downgrade to `fallback` +# # on relay failure. Set false to allow a +# # non-oblivious fallback path. +# relay_ip = "178.104.229.30" # optional: pin IPs so numa doesn't leak the +# target_ip = "104.16.249.249" # relay/target hostnames via the bootstrap +# # resolver on cold boot when numa is its +# # own system DNS. See docs/implementation/ +# # bootstrap-resolver.md. # root_hints = [ # only used in recursive mode # "198.41.0.4", # a.root-servers.net (Verisign) # "199.9.14.201", # b.root-servers.net (USC-ISI) -- 2.34.1 From 26b1cd5917a9909cc2e28b23311db7d7e89005cb Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 22 Apr 2026 15:50:13 +0300 Subject: [PATCH 192/204] feat(packaging): ODoH client Docker deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-container docker-compose recipe for running numa in ODoH client mode. Ships with a starter numa.toml pointing at odoh-relay.numa.rs paired with Cloudflare's ODoH target — two independent operators with distinct eTLD+1s, so the default passes numa's same-operator check. Exposes :53 UDP+TCP for LAN clients and :5380 for the dashboard + REST API. README covers prerequisites, deploy, verification, and the ODoH privacy boundary (relay sees IP, target sees query, neither sees both). Advertised alongside packaging/relay/ in the main README Docker section. --- README.md | 4 ++ packaging/client/README.md | 72 +++++++++++++++++++++++++++++ packaging/client/docker-compose.yml | 15 ++++++ packaging/client/numa.toml | 23 +++++++++ 4 files changed, 114 insertions(+) create mode 100644 packaging/client/README.md create mode 100644 packaging/client/docker-compose.yml create mode 100644 packaging/client/numa.toml diff --git a/README.md b/README.md index 905cd02..3632638 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,10 @@ docker run -d --name numa --network host \ Multi-arch: `linux/amd64` and `linux/arm64`. +Turnkey compose recipes: +- [`packaging/client/`](packaging/client/) — ODoH client mode (anonymous DNS), Numa + starter `numa.toml`. +- [`packaging/relay/`](packaging/relay/) — public ODoH relay, Numa + Caddy + ACME. + ## How It Compares | | Pi-hole | AdGuard Home | Unbound | Numa | diff --git a/packaging/client/README.md b/packaging/client/README.md new file mode 100644 index 0000000..f6e76c0 --- /dev/null +++ b/packaging/client/README.md @@ -0,0 +1,72 @@ +# Numa ODoH Client — Docker deploy + +Single-container deploy that runs Numa as an ODoH (RFC 9230) client: every +DNS query routes through an independent relay + target so neither operator +sees both your IP and your question. See the [ODoH integration doc][odoh] +for the full protocol and privacy trade-offs. + +[odoh]: ../../docs/implementation/odoh-integration.md + +## Prerequisites + +- Docker + Docker Compose v2. +- Port 53 (UDP+TCP) free on the host — Numa listens there for DNS + clients on your LAN. + +## Configure + +The shipped `numa.toml` points at Numa's own public relay +(`odoh-relay.numa.rs`) paired with Cloudflare's ODoH target +(`odoh.cloudflare-dns.com`). That's two independent operators with +distinct eTLD+1s — the default configuration passes Numa's same-operator +check and works out of the box. + +To use a different relay or target, edit `numa.toml` and adjust the URLs. +The `relay` and `target` must resolve to distinct operators or Numa +refuses to start. + +## Deploy + +```sh +docker compose up -d +docker compose logs -f numa # watch startup +``` + +The first query fires the bootstrap resolver + ODoH config fetch; +subsequent queries reuse the warm HTTP/2 connection. + +## Point your devices at it + +Set each device's DNS server to the IP of the Docker host. For a LAN-wide +rollout, set the DNS server in your router's DHCP config so every device +picks it up automatically. + +Verify a query landed on the ODoH path: + +```sh +dig @ example.com +curl http://:5380/stats | jq '.upstream_transport.odoh' +``` + +`upstream_transport.odoh` should increment on each query. + +## What this does NOT buy you + +ODoH protects the *path*, not the content: + +- **The target (Cloudflare here) still sees the question.** It just + doesn't know it's you asking. If Cloudflare logs every ODoH query, the + query is still visible — it's simply unattributed. +- **The relay is a trusted party for availability.** A malicious relay + can drop or delay queries; it just can't read them. +- **Traffic analysis defeats small relays.** If you're the only client + talking to a relay, timing alone re-identifies you. Shared, busy relays + give better anonymity sets. + +See the [ODoH integration doc][odoh] for more. + +## Relay operator? + +If you'd rather run your own relay (same binary, different mode), see +[`../relay/`](../relay/) — that package spins up a public-facing relay +with Caddy + ACME in front of it. diff --git a/packaging/client/docker-compose.yml b/packaging/client/docker-compose.yml new file mode 100644 index 0000000..361f5db --- /dev/null +++ b/packaging/client/docker-compose.yml @@ -0,0 +1,15 @@ +services: + numa: + image: ghcr.io/razvandimescu/numa:latest + command: ["/etc/numa/numa.toml"] + ports: + - "53:53/udp" + - "53:53/tcp" + - "5380:5380/tcp" # dashboard + REST API + volumes: + - ./numa.toml:/etc/numa/numa.toml:ro + - numa_data:/var/lib/numa + restart: unless-stopped + +volumes: + numa_data: diff --git a/packaging/client/numa.toml b/packaging/client/numa.toml new file mode 100644 index 0000000..039d723 --- /dev/null +++ b/packaging/client/numa.toml @@ -0,0 +1,23 @@ +# Numa — ODoH client mode (docker-compose starter). +# Sends every DNS query through an independent relay + target pair so +# neither operator sees both your IP and your question. See +# docs/implementation/odoh-integration.md for the protocol details and +# packaging/client/README.md for deploy notes. + +[server] +bind_addr = "0.0.0.0:53" +api_bind_addr = "0.0.0.0" +data_dir = "/var/lib/numa" + +[upstream] +mode = "odoh" +# Numa's own relay (Hetzner, systemd + Caddy). Swap to any other public +# ODoH relay if you'd rather not depend on a single operator; the protocol +# tolerates it, and Numa refuses same-operator relay+target by default. +relay = "https://odoh-relay.numa.rs/relay" +target = "https://odoh.cloudflare-dns.com/dns-query" +# strict = true (default). Relay failure → SERVFAIL, never silent downgrade. + +[blocking] +enabled = true +# Default blocklist (Hagezi Pro). Edit the `lists` array to taste. -- 2.34.1 From b8a125b598cad7d305c193532b17d1975cf37780 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 22 Apr 2026 23:30:55 +0300 Subject: [PATCH 193/204] fix(upstream): default hedge_ms=0 to avoid silent 2x upstream query count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hedging fires a second upstream query against the same upstream after the hedge delay. Rescues packet loss and handshake stalls on flaky links, but every lookup shows up twice at the provider — silently halves the headroom for anyone on a quota'd upstream (NextDNS free tier, Control D, paid Quad9). Surfaced by #134 (bcookatpcsd), who saw every query duplicated on the NextDNS dashboard with a single-address DoT upstream. Not a bug — the feature doing what it says on the tin — but a surprising default. Flipping the default to 0 makes hedging explicitly opt-in. Users who want tail-latency rescue on flaky nets add `hedge_ms = 10` (or higher). No config migration needed; no breaking changes to the API surface. Also tightens the numa.toml comment so the trade-off is visible at config time, not retroactively on a provider dashboard. --- numa.toml | 12 +++++++----- src/config.rs | 6 +++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/numa.toml b/numa.toml index 93418ea..baf35aa 100644 --- a/numa.toml +++ b/numa.toml @@ -30,11 +30,13 @@ api_port = 5380 # fallback = ["8.8.8.8", "1.1.1.1"] # tried only when all primaries fail # port = 53 # default port for addresses without :port # timeout_ms = 3000 -# hedge_ms = 10 # request hedging delay (ms). After this delay -# # without a response, fires a parallel request -# # to the same upstream. Rescues packet loss (UDP), -# # dispatch spikes (DoH), TLS stalls (DoT). -# # Set to 0 to disable. Default: 10 +# hedge_ms = 0 # request hedging delay (ms). Default: 0 (off). +# # Set to e.g. 10 to fire a parallel upstream +# # request after 10ms of silence — rescues packet +# # loss (UDP), dispatch spikes (DoH), TLS stalls +# # (DoT). Doubles the upstream query count, so +# # leave off for quota'd providers (NextDNS, +# # Control D). # ODoH (Oblivious DNS-over-HTTPS, RFC 9230). The relay sees your IP but # not the question; the target sees the question but not your IP. Numa diff --git a/src/config.rs b/src/config.rs index 6daf430..f28d647 100644 --- a/src/config.rs +++ b/src/config.rs @@ -451,8 +451,12 @@ fn default_upstream_port() -> u16 { fn default_timeout_ms() -> u64 { 5000 } +/// Off by default: hedging fires a second upstream query, which silently +/// doubles the count at the provider — hurts quota'd DNS (NextDNS, Control +/// D). Opt in with `hedge_ms = 10` for tail-latency rescue on flaky nets +/// or handshake-slow DoT. fn default_hedge_ms() -> u64 { - 10 + 0 } #[derive(Deserialize)] -- 2.34.1 From 3ec3b40830698c21755a54196079e5be027a6cc8 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 22 Apr 2026 23:50:20 +0300 Subject: [PATCH 194/204] chore: bump version to 0.15.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1da534a..c1336fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1547,7 +1547,7 @@ dependencies = [ [[package]] name = "numa" -version = "0.14.1" +version = "0.15.0" dependencies = [ "arc-swap", "axum", diff --git a/Cargo.toml b/Cargo.toml index 39f75a2..025b2a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "numa" -version = "0.14.1" +version = "0.15.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 e6e79273b95fc7fa7d59b70ac550c3ad6ba1c82a Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 22 Apr 2026 23:57:28 +0300 Subject: [PATCH 195/204] Revert "chore: bump version to 0.15.0" This reverts commit 3ec3b40830698c21755a54196079e5be027a6cc8. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c1336fc..1da534a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1547,7 +1547,7 @@ dependencies = [ [[package]] name = "numa" -version = "0.15.0" +version = "0.14.1" dependencies = [ "arc-swap", "axum", diff --git a/Cargo.toml b/Cargo.toml index 025b2a8..39f75a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "numa" -version = "0.15.0" +version = "0.14.1" authors = ["razvandimescu "] edition = "2021" description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS" -- 2.34.1 From c787de15486d703b9395e3113fd52a2bb73e1850 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 22 Apr 2026 23:57:37 +0300 Subject: [PATCH 196/204] chore: bump version to 0.14.2 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1da534a..9957031 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1547,7 +1547,7 @@ dependencies = [ [[package]] name = "numa" -version = "0.14.1" +version = "0.14.2" dependencies = [ "arc-swap", "axum", diff --git a/Cargo.toml b/Cargo.toml index 39f75a2..01773d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "numa" -version = "0.14.1" +version = "0.14.2" authors = ["razvandimescu "] edition = "2021" description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS" -- 2.34.1 From 2274151c17995287291c585bcd38120cd7001174 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 23 Apr 2026 00:35:41 +0300 Subject: [PATCH 197/204] fix(packet): parse SOA natively to stop malformed replies (#128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SOA records were stored as opaque bytes (DnsRecord::UNKNOWN), so the RFC 1035 §3.3.13 MNAME/RNAME name-compression pointers — offsets into the upstream packet — were re-emitted verbatim. Once Numa applied its own compression to surrounding names, those pointers landed on garbage and clients rejected the reply ("malformed reply packet" in kdig). Parse SOA via read_qname and write via write_qname, matching the NS/CNAME/MX pattern. Adds the canonical-rdata arm in dnssec.rs for RRSIG verification. Regression test round-trips a CNAME-chain response with a compressed SOA in authority through hickory-proto strict parse. --- src/dnssec.rs | 22 +++++++ src/record.rs | 70 ++++++++++++++++++++- tests/soa_compression_bug.rs | 115 +++++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 tests/soa_compression_bug.rs diff --git a/src/dnssec.rs b/src/dnssec.rs index 8614810..877b495 100644 --- a/src/dnssec.rs +++ b/src/dnssec.rs @@ -882,6 +882,28 @@ fn record_rdata_canonical(record: &DnsRecord) -> Vec { rdata.extend(type_bitmap); rdata } + DnsRecord::SOA { + mname, + rname, + serial, + refresh, + retry, + expire, + minimum, + .. + } => { + let mname_wire = name_to_wire(mname); + let rname_wire = name_to_wire(rname); + let mut rdata = Vec::with_capacity(mname_wire.len() + rname_wire.len() + 20); + rdata.extend(&mname_wire); + rdata.extend(&rname_wire); + rdata.extend(&serial.to_be_bytes()); + rdata.extend(&refresh.to_be_bytes()); + rdata.extend(&retry.to_be_bytes()); + rdata.extend(&expire.to_be_bytes()); + rdata.extend(&minimum.to_be_bytes()); + rdata + } DnsRecord::UNKNOWN { data, .. } => data.clone(), DnsRecord::RRSIG { .. } => Vec::new(), } diff --git a/src/record.rs b/src/record.rs index 7de9bb4..0fefd72 100644 --- a/src/record.rs +++ b/src/record.rs @@ -24,6 +24,17 @@ pub enum DnsRecord { host: String, ttl: u32, }, + SOA { + domain: String, + mname: String, + rname: String, + serial: u32, + refresh: u32, + retry: u32, + expire: u32, + minimum: u32, + ttl: u32, + }, CNAME { domain: String, host: String, @@ -100,6 +111,7 @@ impl DnsRecord { | DnsRecord::RRSIG { domain, .. } | DnsRecord::NSEC { domain, .. } | DnsRecord::NSEC3 { domain, .. } + | DnsRecord::SOA { domain, .. } | DnsRecord::UNKNOWN { domain, .. } => domain, } } @@ -111,6 +123,7 @@ impl DnsRecord { DnsRecord::NS { .. } => QueryType::NS, DnsRecord::CNAME { .. } => QueryType::CNAME, DnsRecord::MX { .. } => QueryType::MX, + DnsRecord::SOA { .. } => QueryType::SOA, DnsRecord::DNSKEY { .. } => QueryType::DNSKEY, DnsRecord::DS { .. } => QueryType::DS, DnsRecord::RRSIG { .. } => QueryType::RRSIG, @@ -132,6 +145,7 @@ impl DnsRecord { | DnsRecord::RRSIG { ttl, .. } | DnsRecord::NSEC { ttl, .. } | DnsRecord::NSEC3 { ttl, .. } + | DnsRecord::SOA { ttl, .. } | DnsRecord::UNKNOWN { ttl, .. } => *ttl, } } @@ -172,6 +186,12 @@ impl DnsRecord { + next_hashed_owner.capacity() + type_bitmap.capacity() } + DnsRecord::SOA { + domain, + mname, + rname, + .. + } => domain.capacity() + mname.capacity() + rname.capacity(), DnsRecord::UNKNOWN { domain, data, .. } => domain.capacity() + data.capacity(), } } @@ -188,6 +208,7 @@ impl DnsRecord { | DnsRecord::RRSIG { ttl, .. } | DnsRecord::NSEC { ttl, .. } | DnsRecord::NSEC3 { ttl, .. } + | DnsRecord::SOA { ttl, .. } | DnsRecord::UNKNOWN { ttl, .. } => *ttl = new_ttl, } } @@ -365,8 +386,31 @@ impl DnsRecord { ttl, }) } + QueryType::SOA => { + // MNAME/RNAME compressible per RFC 1035 §3.3.13 — decompress to avoid stale pointers on re-emit. + let mut mname = String::with_capacity(64); + buffer.read_qname(&mut mname)?; + let mut rname = String::with_capacity(64); + buffer.read_qname(&mut rname)?; + let serial = buffer.read_u32()?; + let refresh = buffer.read_u32()?; + let retry = buffer.read_u32()?; + let expire = buffer.read_u32()?; + let minimum = buffer.read_u32()?; + Ok(DnsRecord::SOA { + domain, + mname, + rname, + serial, + refresh, + retry, + expire, + minimum, + ttl, + }) + } _ => { - // SOA, TXT, SRV, etc. — stored as opaque bytes until parsed natively + // TXT, SRV, HTTPS, SVCB, etc. — stored as opaque bytes until parsed natively let data = buffer.get_range(buffer.pos(), data_len as usize)?.to_vec(); buffer.step(data_len as usize)?; Ok(DnsRecord::UNKNOWN { @@ -430,6 +474,30 @@ impl DnsRecord { let size = buffer.pos() - (pos + 2); buffer.set_u16(pos, size as u16)?; } + DnsRecord::SOA { + ref domain, + ref mname, + ref rname, + serial, + refresh, + retry, + expire, + minimum, + ttl, + } => { + write_header(buffer, domain, QueryType::SOA.to_num(), ttl)?; + let rdlen_pos = buffer.pos(); + buffer.write_u16(0)?; + buffer.write_qname(mname)?; + buffer.write_qname(rname)?; + buffer.write_u32(serial)?; + buffer.write_u32(refresh)?; + buffer.write_u32(retry)?; + buffer.write_u32(expire)?; + buffer.write_u32(minimum)?; + let rdlen = buffer.pos() - (rdlen_pos + 2); + buffer.set_u16(rdlen_pos, rdlen as u16)?; + } DnsRecord::AAAA { ref domain, ref addr, diff --git a/tests/soa_compression_bug.rs b/tests/soa_compression_bug.rs new file mode 100644 index 0000000..5f4f2f0 --- /dev/null +++ b/tests/soa_compression_bug.rs @@ -0,0 +1,115 @@ +//! Regression test for issue #128: SOA with compressed MNAME/RNAME must +//! survive Numa's round-trip — compression pointers reference the upstream +//! packet's byte layout, so we have to decompress on read and re-compress +//! on write. + +use numa::buffer::BytePacketBuffer; +use numa::packet::DnsPacket; + +const COMPRESSION_FLAG: u16 = 0xC000; + +fn upstream_packet() -> Vec { + let mut p = Vec::::new(); + + p.extend_from_slice(&[ + 0x12, 0x34, 0x81, 0x80, 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x00, + ]); + + assert_eq!(p.len(), 12); + write_name(&mut p, &["odin", "adobe", "com"]); + p.extend_from_slice(&[0x00, 0x41, 0x00, 0x01]); + + p.extend_from_slice(&[0xC0, 0x0C]); + p.extend_from_slice(&[0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x23, 0x7F]); + let rdlen_pos_1 = p.len(); + p.extend_from_slice(&[0x00, 0x00]); + let cname1_start = p.len(); + write_name(&mut p, &["cdn", "adobeaemcloud", "com"]); + let rdlen_1 = (p.len() - cname1_start) as u16; + p[rdlen_pos_1..rdlen_pos_1 + 2].copy_from_slice(&rdlen_1.to_be_bytes()); + + p.extend_from_slice(&(COMPRESSION_FLAG | cname1_start as u16).to_be_bytes()); + p.extend_from_slice(&[0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x23, 0x7F]); + let rdlen_pos_2 = p.len(); + p.extend_from_slice(&[0x00, 0x00]); + let cname2_start = p.len(); + p.push(9); + p.extend_from_slice(b"adobe-aem"); + let map_label_off = p.len(); + p.push(3); + p.extend_from_slice(b"map"); + let fastly_label_off = p.len(); + p.push(6); + p.extend_from_slice(b"fastly"); + p.push(3); + p.extend_from_slice(b"net"); + p.push(0); + let rdlen_2 = (p.len() - cname2_start) as u16; + p[rdlen_pos_2..rdlen_pos_2 + 2].copy_from_slice(&rdlen_2.to_be_bytes()); + + p.extend_from_slice(&(COMPRESSION_FLAG | fastly_label_off as u16).to_be_bytes()); + p.extend_from_slice(&[0x00, 0x06, 0x00, 0x01, 0x00, 0x00, 0x07, 0x08]); + let rdlen_pos_soa = p.len(); + p.extend_from_slice(&[0x00, 0x00]); + let soa_rdata_start = p.len(); + p.extend_from_slice(&(COMPRESSION_FLAG | map_label_off as u16).to_be_bytes()); + p.extend_from_slice(&(COMPRESSION_FLAG | fastly_label_off as u16).to_be_bytes()); + p.extend_from_slice(&1u32.to_be_bytes()); + p.extend_from_slice(&7200u32.to_be_bytes()); + p.extend_from_slice(&3600u32.to_be_bytes()); + p.extend_from_slice(&1209600u32.to_be_bytes()); + p.extend_from_slice(&1800u32.to_be_bytes()); + let rdlen_soa = (p.len() - soa_rdata_start) as u16; + p[rdlen_pos_soa..rdlen_pos_soa + 2].copy_from_slice(&rdlen_soa.to_be_bytes()); + + p +} + +fn write_name(p: &mut Vec, labels: &[&str]) { + for l in labels { + p.push(l.len() as u8); + p.extend_from_slice(l.as_bytes()); + } + p.push(0); +} + +#[test] +fn compressed_soa_survives_numa_round_trip() { + let upstream = upstream_packet(); + + let hickory_in = hickory_proto::op::Message::from_vec(&upstream) + .expect("hand-crafted upstream must be valid"); + let soa_in_rd = hickory_in.name_servers()[0] + .data() + .clone() + .into_soa() + .expect("SOA rdata"); + assert_eq!(soa_in_rd.mname().to_string(), "map.fastly.net."); + assert_eq!(soa_in_rd.rname().to_string(), "fastly.net."); + + let mut in_buf = BytePacketBuffer::from_bytes(&upstream); + let pkt = DnsPacket::from_buffer(&mut in_buf).expect("numa parses upstream"); + assert_eq!(pkt.answers.len(), 2); + assert_eq!(pkt.authorities.len(), 1); + + let mut out_buf = BytePacketBuffer::new(); + pkt.write(&mut out_buf).expect("numa writes"); + let out = out_buf.filled().to_vec(); + + let hickory_out = + hickory_proto::op::Message::from_vec(&out).expect("numa re-emission must parse strictly"); + + let soa_out_rd = hickory_out.name_servers()[0] + .data() + .clone() + .into_soa() + .expect("SOA rdata on output"); + + assert_eq!(soa_out_rd.mname().to_string(), "map.fastly.net."); + assert_eq!(soa_out_rd.rname().to_string(), "fastly.net."); + assert_eq!(soa_out_rd.serial(), 1); + assert_eq!(soa_out_rd.refresh(), 7200); + assert_eq!(soa_out_rd.retry(), 3600); + assert_eq!(soa_out_rd.expire(), 1209600); + assert_eq!(soa_out_rd.minimum(), 1800); +} -- 2.34.1 From 96cf778beafd0776415a5b782de186d3bd18b43e Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 23 Apr 2026 08:53:35 +0300 Subject: [PATCH 198/204] docs(config): fix ODoH relay path in numa.toml example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example in `numa.toml` pointed at `https://odoh-relay.numa.rs/proxy`, but the relay only serves the ODoH endpoint at `/relay` (every other reference in the tree — `src/config.rs` docs and tests, and `packaging/client/numa.toml` — uses `/relay`). Users who copied the example got `404 Not Found` on every query and SERVFAIL at the client. Reported in #138. --- numa.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/numa.toml b/numa.toml index baf35aa..2138dd2 100644 --- a/numa.toml +++ b/numa.toml @@ -43,7 +43,7 @@ api_port = 5380 # refuses same-operator relay+target configs by default (eTLD+1 check). # [upstream] # mode = "odoh" -# relay = "https://odoh-relay.numa.rs/proxy" +# relay = "https://odoh-relay.numa.rs/relay" # target = "https://odoh.cloudflare-dns.com/dns-query" # strict = true # default: refuse to downgrade to `fallback` # # on relay failure. Set false to allow a -- 2.34.1 From e702f5861b27088a68ce8b75d4f086516cb4c858 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 23 Apr 2026 09:39:34 +0300 Subject: [PATCH 199/204] Update README.md to remove outdated listing information Removed section about listing on the public ecosystem and DNSCrypt's canonical list. --- packaging/relay/README.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packaging/relay/README.md b/packaging/relay/README.md index 373b263..b86e284 100644 --- a/packaging/relay/README.md +++ b/packaging/relay/README.md @@ -39,10 +39,3 @@ curl https:///health Then point any ODoH client at `https:///relay` and watch the counters tick. - -## Listing on the public ecosystem - -DNSCrypt's [v3/odoh-relays.md](https://github.com/DNSCrypt/dnscrypt-resolvers/blob/master/v3/odoh-relays.md) -is the canonical list. The pruned 2025-09-16 commit shows one public ODoH -relay survived the cull — running this compose file doubles global supply. -Open a PR there once your relay has been up for ~24 hours. -- 2.34.1 From f7f35b34241769dd817eb18ef80da84924d53610 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 24 Apr 2026 15:09:16 +0300 Subject: [PATCH 200/204] docs: lift user-facing guides to recipes/, drop dangling docs/ refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/ is gitignored; references to docs/implementation/*.md from public source, configs, and packaging were dead links outside the maintainer machine. Adds four recipes (README, dnsdist-front, doh-on-lan, odoh-upstream) under top-level recipes/ and repoints existing pointers. - numa.toml, packaging/client/{README.md,numa.toml}: point to recipes/odoh-upstream.md. - src/{bootstrap_resolver,forward,serve}.rs: reference issue #122 directly (module scope is broader than the ODoH-specific recipe). - src/health.rs: drop the §-ref; iOS HealthInfo remains named as the canonical consumer. --- numa.toml | 4 +-- packaging/client/README.md | 6 ++-- packaging/client/numa.toml | 2 +- recipes/README.md | 11 +++++++ recipes/dnsdist-front.md | 64 ++++++++++++++++++++++++++++++++++++++ recipes/doh-on-lan.md | 61 ++++++++++++++++++++++++++++++++++++ recipes/odoh-upstream.md | 59 +++++++++++++++++++++++++++++++++++ src/bootstrap_resolver.rs | 3 +- src/forward.rs | 3 +- src/health.rs | 9 +++--- src/serve.rs | 1 - 11 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 recipes/README.md create mode 100644 recipes/dnsdist-front.md create mode 100644 recipes/doh-on-lan.md create mode 100644 recipes/odoh-upstream.md diff --git a/numa.toml b/numa.toml index 2138dd2..57d0249 100644 --- a/numa.toml +++ b/numa.toml @@ -51,8 +51,8 @@ api_port = 5380 # relay_ip = "178.104.229.30" # optional: pin IPs so numa doesn't leak the # target_ip = "104.16.249.249" # relay/target hostnames via the bootstrap # # resolver on cold boot when numa is its -# # own system DNS. See docs/implementation/ -# # bootstrap-resolver.md. +# # own system DNS. See +# # recipes/odoh-upstream.md. # root_hints = [ # only used in recursive mode # "198.41.0.4", # a.root-servers.net (Verisign) # "199.9.14.201", # b.root-servers.net (USC-ISI) diff --git a/packaging/client/README.md b/packaging/client/README.md index f6e76c0..f66359f 100644 --- a/packaging/client/README.md +++ b/packaging/client/README.md @@ -2,10 +2,10 @@ Single-container deploy that runs Numa as an ODoH (RFC 9230) client: every DNS query routes through an independent relay + target so neither operator -sees both your IP and your question. See the [ODoH integration doc][odoh] -for the full protocol and privacy trade-offs. +sees both your IP and your question. See the [ODoH upstream recipe][odoh] +for the protocol details and the bootstrap-pinning trade-offs. -[odoh]: ../../docs/implementation/odoh-integration.md +[odoh]: ../../recipes/odoh-upstream.md ## Prerequisites diff --git a/packaging/client/numa.toml b/packaging/client/numa.toml index 039d723..64b9268 100644 --- a/packaging/client/numa.toml +++ b/packaging/client/numa.toml @@ -1,7 +1,7 @@ # Numa — ODoH client mode (docker-compose starter). # Sends every DNS query through an independent relay + target pair so # neither operator sees both your IP and your question. See -# docs/implementation/odoh-integration.md for the protocol details and +# recipes/odoh-upstream.md for the protocol details and # packaging/client/README.md for deploy notes. [server] diff --git a/recipes/README.md b/recipes/README.md new file mode 100644 index 0000000..fa05c2d --- /dev/null +++ b/recipes/README.md @@ -0,0 +1,11 @@ +# Recipes + +Scenario-driven configs for common Numa deployments. Each recipe is self-contained: copy the snippet, adjust the marked fields, reload. + +## Transport / encryption + +- [DoH on the LAN](doh-on-lan.md) — expose Numa's built-in DNS-over-HTTPS to local clients. +- [dnsdist in front of Numa](dnsdist-front.md) — terminate public TLS externally, keep Numa on loopback. +- [ODoH upstream with bootstrap pinning](odoh-upstream.md) — oblivious DNS client mode without leaking the relay/target hostnames. + +Missing a scenario? Open an issue or PR — these are plain Markdown with no build step. diff --git a/recipes/dnsdist-front.md b/recipes/dnsdist-front.md new file mode 100644 index 0000000..310b53c --- /dev/null +++ b/recipes/dnsdist-front.md @@ -0,0 +1,64 @@ +# dnsdist in front of Numa + +For public DoH with a real (ACME-signed) cert, terminate TLS outside Numa and forward plain DNS (or loopback-only DoH) to the resolver. Cert renewal, rate-limiting, and load-balancing live in the front-end; Numa stays focused on resolution. + +## When to use this + +- Public hostname (`dns.example.com`) with a Let's Encrypt or internal PKI cert. +- You want a dedicated front-end for DoH/DoT/DoQ while Numa stays loopback-bound. +- You plan to run multiple Numa instances behind one endpoint. + +## Architecture + +``` + public 443/DoH ┐ + public 853/DoT ├─► dnsdist ─► 127.0.0.1:53 (Numa UDP/TCP) + public 443/DoQ ┘ +``` + +## dnsdist config + +```lua +-- /etc/dnsdist/dnsdist.conf + +newServer({address="127.0.0.1:53", name="numa", checkType="A", checkName="numa.rs."}) + +addDOHLocal( + "0.0.0.0:443", + "/etc/letsencrypt/live/dns.example.com/fullchain.pem", + "/etc/letsencrypt/live/dns.example.com/privkey.pem", + "/dns-query", + {doTCP=true, reusePort=true} +) + +addTLSLocal( + "0.0.0.0:853", + "/etc/letsencrypt/live/dns.example.com/fullchain.pem", + "/etc/letsencrypt/live/dns.example.com/privkey.pem" +) + +addAction(AllRule(), PoolAction("", false)) +``` + +## Numa config + +```toml +[proxy] +enabled = true # keep if you still use *.numa service routing +bind_addr = "127.0.0.1" # stays default +``` + +No changes to `[server]` — Numa keeps serving plain DNS on UDP/TCP 53, which dnsdist forwards. + +## Caveat: client IPs + +Without PROXY protocol support in Numa, the query log shows the front-end's IP on every query, not the real client. dnsdist can emit PROXY v2 (`useProxyProtocol=true` on `newServer`), but Numa doesn't yet parse it — tracked in the wish-list under #143. Until then, accept the blind spot or correlate against dnsdist's own logs. + +## Verify + +```bash +kdig +https @dns.example.com example.com +kdig +tls @dns.example.com example.com +``` + +Both should return clean answers. Numa's `/queries` API should show the request landing, sourced from the front-end IP. diff --git a/recipes/doh-on-lan.md b/recipes/doh-on-lan.md new file mode 100644 index 0000000..70b607e --- /dev/null +++ b/recipes/doh-on-lan.md @@ -0,0 +1,61 @@ +# DoH on the LAN + +Numa ships an RFC 8484 DoH endpoint (`POST /dns-query`) on the `[proxy]` HTTPS listener. By default it binds `127.0.0.1:443` with a self-signed cert — invisible to anything off the box. Three changes make it reachable from the LAN. + +## When to use this + +- Your phone/laptop is on the same network as Numa and you want encrypted DNS without a cloud resolver. +- You're OK installing Numa's self-signed CA on every client (one-time, via `/ca.pem` + the mobileconfig flow). + +For a publicly-trusted cert, see [dnsdist in front of Numa](dnsdist-front.md) instead. + +## Minimal config + +```toml +[proxy] +enabled = true # default +bind_addr = "0.0.0.0" # was 127.0.0.1 — expose to LAN +tls_port = 443 # default; DoH is served here +tld = "numa" # default — self-resolving, see below +``` + +`tld` is the DoH gate: Numa accepts the DoH request only when the `Host` header is loopback or equals (or is a subdomain of) `tld`. Clients therefore dial `https://numa/dns-query`. + +With the default `tld = "numa"`, there's no DNS bootstrap to configure: Numa already resolves `numa` and `*.numa` to its own LAN IP for remote clients (that's how the `*.numa` service-proxy feature works). Any client that uses Numa as its resolver will resolve `numa` correctly on first try. + +If you'd rather use a hostname that resolves via normal DNS (e.g. you want DoH-only clients that never talk plain DNS to Numa), set `tld = "dns.example.com"` and add a matching A record in whichever DNS your clients consult before reaching Numa. + +## Trust the CA on each client + +Numa generates a self-signed CA at startup. Fetch it once, import it wherever you'll run the DoH client: + +```bash +curl -o numa-ca.pem http://:5380/ca.pem +``` + +- **macOS** — `sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain numa-ca.pem` +- **iOS** — install the mobileconfig from the API (same CA, signed profile). Flip *Settings → General → About → Certificate Trust Settings* on after install. +- **Linux** — drop into `/usr/local/share/ca-certificates/` and run `sudo update-ca-certificates`. +- **Android** — requires the user-installed CA path; browsers may still refuse it for DoH. Consider the [dnsdist front](dnsdist-front.md) route instead. + +## Verify + +```bash +kdig +https @numa example.com +``` + +Without `+https` kdig uses plain DNS. With `+https` the same answers should flow over port 443. + +Raw check: + +```bash +curl -H 'accept: application/dns-message' \ + --data-binary @query.bin \ + https://numa/dns-query +``` + +## Gotchas + +- Port 443 is privileged on Linux/macOS. Run Numa via the provided service units, or grant `CAP_NET_BIND_SERVICE` (`sudo setcap 'cap_net_bind_service=+ep' /path/to/numa`). +- Non-matching `Host` header → HTTP 404 from the proxy's fallback handler. Double-check `tld`. +- ChromeOS enrollment rejects user-installed CAs for some flows — known pain point, see issue #136. diff --git a/recipes/odoh-upstream.md b/recipes/odoh-upstream.md new file mode 100644 index 0000000..0469bca --- /dev/null +++ b/recipes/odoh-upstream.md @@ -0,0 +1,59 @@ +# ODoH upstream with bootstrap pinning + +Numa can run as an Oblivious DoH (RFC 9230) client: the relay sees your IP but not the question, the target sees the question but not your IP. Neither party alone can re-identify a query. This recipe covers the minimal config and the bootstrap leak that `relay_ip` / `target_ip` close. + +## When to use this + +- You want split-trust encrypted DNS without a single provider seeing both who you are and what you asked. +- Numa is your system resolver (so there's no "other" DNS to ask). + +## Minimal config + +```toml +[upstream] +mode = "odoh" +relay = "https://odoh-relay.numa.rs/relay" +target = "https://odoh.cloudflare-dns.com/dns-query" +strict = true # refuse to fall back to a non-oblivious path on relay failure +``` + +`strict = true` means a relay-level HTTPS failure returns SERVFAIL instead of silently downgrading. Set it to `false` and configure `[upstream].fallback` if you'd rather keep resolving (at the cost of the oblivious property). + +## The bootstrap leak + +When Numa is the system resolver and needs to reach the relay/target, *something* has to translate `odoh-relay.numa.rs` → IP. If Numa asks itself, you deadlock. If Numa asks a bootstrap resolver (1.1.1.1, 9.9.9.9), that resolver learns which ODoH endpoint you use in cleartext — it can't see your questions, but it sees the destination. That's the leak ODoH was supposed to close. + +`relay_ip` and `target_ip` tell Numa the IPs directly, so it never asks anyone: + +```toml +[upstream] +mode = "odoh" +relay = "https://odoh-relay.numa.rs/relay" +target = "https://odoh.cloudflare-dns.com/dns-query" +relay_ip = "178.104.229.30" # pin the relay — no hostname lookup +target_ip = "104.16.249.249" # pin the target — no hostname lookup +``` + +Numa still validates TLS against the hostnames in `relay` / `target`, so a hijacked IP can't masquerade — pinning skips only the DNS step. + +## Finding current IPs + +```bash +dig +short odoh-relay.numa.rs +dig +short odoh.cloudflare-dns.com +``` + +Re-pin when an operator rotates. The community-maintained list at is a useful cross-reference. + +## Verify + +```bash +kdig @127.0.0.1 example.com +``` + +Numa's `/queries` API and startup banner should label the upstream as `odoh://`. Look for `ODoH relay returned ...` errors in the logs if routing fails. + +## Known gotchas + +- **Same-operator refused.** Numa's eTLD+1 check blocks configs where the relay and target belong to the same operator (pointless — same party sees both sides). Override only when testing. +- **Single relay.** Current config accepts one relay and one target. Multi-entry rotation/failover is tracked in #140. diff --git a/src/bootstrap_resolver.rs b/src/bootstrap_resolver.rs index c3be8bd..44214e4 100644 --- a/src/bootstrap_resolver.rs +++ b/src/bootstrap_resolver.rs @@ -2,8 +2,7 @@ //! relay/target, blocklist CDN). When numa is its own system resolver //! (`/etc/resolv.conf → 127.0.0.1`, HAOS add-on, Pi-hole-style container), //! the default `getaddrinfo` path loops back through numa before numa can -//! answer — a chicken-and-egg that deadlocks cold boot. See issue #122 and -//! `docs/implementation/bootstrap-resolver.md`. +//! answer — a chicken-and-egg that deadlocks cold boot. See issue #122. //! //! Resolution order per hostname: //! 1. Per-hostname overrides (e.g. ODoH `relay_ip` / `target_ip`) → return diff --git a/src/forward.rs b/src/forward.rs index e3f307b..1c39292 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -175,8 +175,7 @@ pub fn parse_upstream( /// /// Uses the system resolver. Callers running inside `serve::run` pass the /// shared [`crate::bootstrap_resolver::NumaResolver`] via -/// [`build_https_client_with_resolver`] to avoid the self-loop documented -/// in `docs/implementation/bootstrap-resolver.md`. +/// [`build_https_client_with_resolver`] to avoid the self-loop (issue #122). pub fn build_https_client() -> reqwest::Client { build_https_client_with_resolver(1, None) } diff --git a/src/health.rs b/src/health.rs index 5767f4b..30cad9a 100644 --- a/src/health.rs +++ b/src/health.rs @@ -7,11 +7,10 @@ //! Both handlers call [`HealthResponse::build`] to assemble the JSON //! response from `HealthMeta` + live inputs. //! -//! JSON schema is documented in `docs/implementation/ios-companion-app.md` -//! §4.2. The iOS companion app's `HealthInfo` struct is the canonical -//! consumer; any change to this response must keep that struct decoding -//! cleanly (all consumed fields are optional on the Swift side, but -//! `lan_ip` is load-bearing for the pipeline). +//! The iOS companion app's `HealthInfo` struct is the canonical consumer; +//! any change to this response must keep that struct decoding cleanly (all +//! consumed fields are optional on the Swift side, but `lan_ip` is +//! load-bearing for the pipeline). use std::net::Ipv4Addr; use std::path::Path; diff --git a/src/serve.rs b/src/serve.rs index c76d174..e20ebe8 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -52,7 +52,6 @@ pub async fn run(config_path: String) -> crate::Result<()> { // Routes numa-originated HTTPS (DoH upstream, ODoH relay/target, blocklist // CDN) away from the system resolver so lookups don't loop back through // numa when it's its own system DNS. - // See `docs/implementation/bootstrap-resolver.md`. let resolver_overrides = match config.upstream.mode { crate::config::UpstreamMode::Odoh => config .upstream -- 2.34.1 From 4aa91a52369670c1c8010a1c577684257367e684 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 24 Apr 2026 17:51:14 +0300 Subject: [PATCH 201/204] fix(api): Cache-Control: no-cache on dashboard HTML Browsers heuristically cached the dashboard page because the response carried no Cache-Control header, so a numa upgrade on the daemon did not surface updated PATH_DEFS (e.g. the UPSTREAM row added in v0.14.0) until the user hard-reloaded. Force revalidation on every load. Closes #144. --- src/api.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/api.rs b/src/api.rs index 7f02920..eb31ef1 100644 --- a/src/api.rs +++ b/src/api.rs @@ -83,8 +83,13 @@ pub fn router(ctx: Arc) -> Router { } async fn dashboard() -> impl IntoResponse { + // Revalidate each load so browsers don't keep serving a stale + // dashboard across numa upgrades. ( - [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + [ + (header::CONTENT_TYPE, "text/html; charset=utf-8"), + (header::CACHE_CONTROL, "no-cache"), + ], DASHBOARD_HTML, ) } @@ -1244,6 +1249,13 @@ mod tests { .await .unwrap(); assert_eq!(resp.status(), 200); + assert_eq!( + resp.headers() + .get(header::CACHE_CONTROL) + .map(|v| v.to_str().unwrap()), + Some("no-cache"), + "dashboard must revalidate to avoid stale HTML across upgrades" + ); let body = axum::body::to_bytes(resp.into_body(), 100000) .await .unwrap(); -- 2.34.1 From d090e049ec9d535a96425e26189c9ef1efdf46d2 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 24 Apr 2026 17:57:51 +0300 Subject: [PATCH 202/204] ci(aur): attach to master after clone to avoid detached HEAD aur.archlinux.org stopped advertising the HEAD symref around 2026-04-22 (`git ls-remote --symref` returns HEAD as a raw SHA, no 'ref:' line). Fresh clones therefore land in detached HEAD, commits do not land on any branch, and 'git push origin master' fails with: error: src refspec master does not match any Every AUR publish run since has failed for this reason. Checking out master explicitly after clone attaches the working copy to the branch the push targets. refs/heads/master is still present on the remote, so no other changes are needed. --- .github/workflows/publish-aur.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml index 6bd77e7..5737c21 100644 --- a/.github/workflows/publish-aur.yml +++ b/.github/workflows/publish-aur.yml @@ -126,6 +126,10 @@ jobs: # ssh://aur@aur.archlinux.org/.git git clone ssh://aur@aur.archlinux.org/$AUR_PKGNAME.git aur-repo + # AUR's git server no longer advertises HEAD's symref, so clone + # lands in detached HEAD. Attach to master before committing. + git -C aur-repo checkout master + cp PKGBUILD aur-repo/ cd aur-repo -- 2.34.1 From cfef4f4160f8e7d34b03f5cd7a608a140de6d95c Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 24 Apr 2026 19:03:02 +0300 Subject: [PATCH 203/204] fix(cache): refresh honors forwarding rules (#147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refresh_entry unconditionally queried the default upstream, so any domain covered by a forwarding rule got re-resolved through the public resolver once its cache entry hit NearExpiry or Stale. The resulting NXDOMAIN/NODATA overwrote the good answer for at least cache.min_ttl (60s default), persisting until restart. Match the precedence from resolve_query: forwarding rule wins over recursive/default upstream. Extract a_record_response() helper in testutil and migrate six call sites — two regression tests here plus four adjacent tests using the same boilerplate. --- src/ctx.rs | 130 ++++++++++++++++++++++++++++++++++++------------ src/testutil.rs | 16 ++++++ 2 files changed, 114 insertions(+), 32 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index 0d39f7d..d4741ec 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -408,6 +408,33 @@ fn cache_and_parse( /// Used for both stale-entry refresh and proactive cache warming. pub async fn refresh_entry(ctx: &ServerCtx, qname: &str, qtype: QueryType) { let query = DnsPacket::query(0, qname, qtype); + + // Forwarding rules must win here, mirroring `resolve_query` — otherwise + // refresh re-resolves private zones through the default upstream and + // poisons the cache with NXDOMAIN. + if let Some(pool) = crate::system_dns::match_forwarding_rule(qname, &ctx.forwarding_rules) { + let mut buf = BytePacketBuffer::new(); + if query.write(&mut buf).is_ok() { + if let Ok(wire) = forward_with_failover_raw( + buf.filled(), + pool, + &ctx.srtt, + ctx.timeout, + ctx.hedge_delay, + ) + .await + { + ctx.cache.write().unwrap().insert_wire( + qname, + qtype, + &wire, + DnssecStatus::Indeterminate, + ); + } + } + return; + } + if ctx.upstream_mode == UpstreamMode::Recursive { if let Ok(resp) = crate::recursive::resolve_recursive( qname, @@ -1244,14 +1271,8 @@ mod tests { #[tokio::test] async fn pipeline_filter_aaaa_leaves_a_queries_alone() { - let mut upstream_resp = DnsPacket::new(); - upstream_resp.header.response = true; - upstream_resp.header.rescode = ResultCode::NOERROR; - upstream_resp.answers.push(DnsRecord::A { - domain: "example.com".to_string(), - addr: Ipv4Addr::new(93, 184, 216, 34), - ttl: 300, - }); + let upstream_resp = + crate::testutil::a_record_response("example.com", Ipv4Addr::new(93, 184, 216, 34), 300); let upstream_addr = crate::testutil::mock_upstream(upstream_resp).await; let mut ctx = crate::testutil::test_ctx().await; @@ -1471,14 +1492,8 @@ mod tests { #[tokio::test] async fn pipeline_forwarding_returns_upstream_answer() { - let mut upstream_resp = DnsPacket::new(); - upstream_resp.header.response = true; - upstream_resp.header.rescode = ResultCode::NOERROR; - upstream_resp.answers.push(DnsRecord::A { - domain: "internal.corp".to_string(), - addr: Ipv4Addr::new(10, 1, 2, 3), - ttl: 600, - }); + let upstream_resp = + crate::testutil::a_record_response("internal.corp", Ipv4Addr::new(10, 1, 2, 3), 600); let upstream_addr = crate::testutil::mock_upstream(upstream_resp).await; let mut ctx = crate::testutil::test_ctx().await; @@ -1505,14 +1520,8 @@ mod tests { async fn pipeline_forwarding_fails_over_to_second_upstream() { let dead = crate::testutil::blackhole_upstream(); - let mut live_resp = DnsPacket::new(); - live_resp.header.response = true; - live_resp.header.rescode = ResultCode::NOERROR; - live_resp.answers.push(DnsRecord::A { - domain: "internal.corp".to_string(), - addr: Ipv4Addr::new(10, 9, 9, 9), - ttl: 600, - }); + let live_resp = + crate::testutil::a_record_response("internal.corp", Ipv4Addr::new(10, 9, 9, 9), 600); let live = crate::testutil::mock_upstream(live_resp).await; let mut ctx = crate::testutil::test_ctx().await; @@ -1534,14 +1543,8 @@ mod tests { #[tokio::test] async fn pipeline_default_pool_reports_upstream_path() { - let mut upstream_resp = DnsPacket::new(); - upstream_resp.header.response = true; - upstream_resp.header.rescode = ResultCode::NOERROR; - upstream_resp.answers.push(DnsRecord::A { - domain: "example.com".to_string(), - addr: Ipv4Addr::new(93, 184, 216, 34), - ttl: 300, - }); + let upstream_resp = + crate::testutil::a_record_response("example.com", Ipv4Addr::new(93, 184, 216, 34), 300); let upstream_addr = crate::testutil::mock_upstream(upstream_resp).await; let ctx = crate::testutil::test_ctx().await; @@ -1556,4 +1559,67 @@ mod tests { assert_eq!(resp.header.rescode, ResultCode::NOERROR); assert_eq!(resp.answers.len(), 1); } + + #[tokio::test] + async fn refresh_entry_honors_forwarding_rule() { + let rule_resp = + crate::testutil::a_record_response("internal.corp", Ipv4Addr::new(10, 0, 0, 42), 300); + let rule_upstream = crate::testutil::mock_upstream(rule_resp).await; + + let mut ctx = crate::testutil::test_ctx().await; + ctx.forwarding_rules = vec![ForwardingRule::new( + "corp".to_string(), + UpstreamPool::new(vec![Upstream::Udp(rule_upstream)], vec![]), + )]; + // Default pool points at a blackhole — if the refresh queries it + // instead of the rule, the test fails because nothing is cached. + ctx.upstream_pool + .lock() + .unwrap() + .set_primary(vec![Upstream::Udp(crate::testutil::blackhole_upstream())]); + let ctx = Arc::new(ctx); + + refresh_entry(&ctx, "internal.corp", QueryType::A).await; + + let cached = ctx + .cache + .read() + .unwrap() + .lookup("internal.corp", QueryType::A) + .expect("refresh must populate cache via forwarding rule"); + match &cached.answers[0] { + DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::new(10, 0, 0, 42)), + other => panic!("expected A record, got {:?}", other), + } + } + + #[tokio::test] + async fn refresh_entry_prefers_forwarding_rule_over_recursive() { + let rule_resp = + crate::testutil::a_record_response("db.internal.corp", Ipv4Addr::new(10, 0, 0, 7), 300); + let rule_upstream = crate::testutil::mock_upstream(rule_resp).await; + + let mut ctx = crate::testutil::test_ctx().await; + ctx.upstream_mode = UpstreamMode::Recursive; + ctx.forwarding_rules = vec![ForwardingRule::new( + "corp".to_string(), + UpstreamPool::new(vec![Upstream::Udp(rule_upstream)], vec![]), + )]; + // No root_hints — recursion would fail immediately, proving that + // the rule branch fired instead. + let ctx = Arc::new(ctx); + + refresh_entry(&ctx, "db.internal.corp", QueryType::A).await; + + let cached = ctx + .cache + .read() + .unwrap() + .lookup("db.internal.corp", QueryType::A) + .expect("recursive-mode refresh must still consult forwarding rules"); + match &cached.answers[0] { + DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::new(10, 0, 0, 7)), + other => panic!("expected A record, got {:?}", other), + } + } } diff --git a/src/testutil.rs b/src/testutil.rs index fab861b..2bb8aa5 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -12,11 +12,13 @@ use crate::cache::DnsCache; use crate::config::UpstreamMode; use crate::ctx::ServerCtx; use crate::forward::{Upstream, UpstreamPool}; +use crate::header::ResultCode; use crate::health::HealthMeta; use crate::lan::PeerStore; use crate::override_store::OverrideStore; use crate::packet::DnsPacket; use crate::query_log::QueryLog; +use crate::record::DnsRecord; use crate::service_store::ServiceStore; use crate::srtt::SrttCache; use crate::stats::ServerStats; @@ -67,6 +69,20 @@ pub async fn test_ctx() -> ServerCtx { } } +/// Build a NOERROR response containing a single A record — the shape used +/// repeatedly by pipeline/forwarding tests to seed `mock_upstream`. +pub fn a_record_response(domain: &str, addr: Ipv4Addr, ttl: u32) -> DnsPacket { + let mut pkt = DnsPacket::new(); + pkt.header.response = true; + pkt.header.rescode = ResultCode::NOERROR; + pkt.answers.push(DnsRecord::A { + domain: domain.to_string(), + addr, + ttl, + }); + pkt +} + /// Spawn a UDP socket that replies to the first DNS query with the given /// response packet (patching the query ID to match). Returns the socket address. pub async fn mock_upstream(response: DnsPacket) -> SocketAddr { -- 2.34.1 From 63a2d262766ef8dddc25b093274e251810861eb3 Mon Sep 17 00:00:00 2001 From: Krtek Zee Date: Fri, 24 Apr 2026 17:42:32 -0700 Subject: [PATCH 204/204] fix: title alignment --- src/serve.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/serve.rs b/src/serve.rs index e20ebe8..70401cc 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -342,12 +342,13 @@ pub async fn run(config_path: String) -> crate::Result<()> { }; // Title row: center within the box + let tag_line = "DNS that governs itself"; let title = format!( - "{b}NUMA{r} {it}DNS that governs itself{r} {d}v{}{r}", + "{b}NUMA{r} {it}{tag_line}{r} {d}v{}{r}", env!("CARGO_PKG_VERSION") ); // The title contains ANSI codes; visible length is ~38 chars. Pad to fill the box. - let title_visible_len = 4 + 2 + 24 + 2 + 1 + env!("CARGO_PKG_VERSION").len() + 1; + let title_visible_len = 4 + 2 + tag_line.len() + 2 + 1 + env!("CARGO_PKG_VERSION").len() + 1; let title_pad = w.saturating_sub(title_visible_len); eprintln!("\n{o} ╔{bar_top}╗{r}"); eprint!("{o} ║{r} {title}"); -- 2.34.1