diff --git a/Cargo.lock b/Cargo.lock index ea8eb0a..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", @@ -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..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" @@ -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/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/README.md b/README.md index e96ecda..4c32370 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,13 @@ 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. 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 Run Numa on multiple machines. They find each other automatically via mDNS: @@ -96,6 +103,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 +124,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 diff --git a/numa.toml b/numa.toml index 4fa0a3d..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 @@ -83,6 +88,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) diff --git a/src/config.rs b/src/config.rs index 0cf5cb0..45dc896 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)] @@ -39,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 { @@ -47,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, } } } @@ -370,6 +377,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..17a4979 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -62,24 +62,21 @@ pub struct ServerCtx { pub dnssec_strict: bool, } -pub async fn handle_query( - mut buffer: BytePacketBuffer, +/// 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( + query: DnsPacket, 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(()); - } - }; - 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 +303,17 @@ pub async fn handle_query( response.resources.len(), ); + // 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 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 +336,30 @@ 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( + mut buffer: BytePacketBuffer, + src_addr: SocketAddr, + ctx: &ServerCtx, +) -> crate::Result<()> { + 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?; + } + 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..a09b160 --- /dev/null +++ b/src/dot.rs @@ -0,0 +1,542 @@ +use std::net::{IpAddr, 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}; +use crate::header::ResultCode; +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; + +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> { + // 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)?; + + 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 mut config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key)?; + config.alpn_protocols = dot_alpn(); + + Ok(Arc::new(config)) +} + +/// 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. +/// +/// Pass `proxy_tld` itself as a service name so the cert gets an explicit +/// `{tld}.{tld}` SAN (e.g. "numa.numa") matching the ServerName that +/// setup-phone's mobileconfig sends as SNI. The `*.{tld}` wildcard alone +/// is rejected by strict TLS clients under single-label TLDs (per the +/// note in tls.rs::generate_service_cert). +fn self_signed_tls(ctx: &ServerCtx) -> Option> { + let service_names = [ctx.proxy_tld.clone()]; + match crate::tls::build_tls_config(&ctx.proxy_tld, &service_names, dot_alpn(), &ctx.data_dir) { + 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) { + (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 self_signed_tls(&ctx) { + Some(cfg) => cfg, + None => return, + }, + }; + + let bind_addr: IpAddr = config + .bind_addr + .parse() + .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) => { + warn!("DoT: could not bind {} ({}) — DoT disabled", addr, e); + return; + } + }; + info!("DoT listening on {}", addr); + + 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 { + let (tcp_stream, remote_addr) = match listener.accept().await { + Ok(conn) => 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(Duration::from_millis(100)).await; + 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 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; + }); + } +} + +/// 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]; + 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 msg_len > MAX_MSG_LEN { + debug!("DoT: oversized message {} from {}", msg_len, remote_addr); + break; + } + + let mut buffer = BytePacketBuffer::new(); + 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. + let query = match DnsPacket::from_buffer(&mut buffer) { + Ok(q) => q, + Err(e) => { + warn!("{} | PARSE ERROR | {}", remote_addr, e); + // 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; + resp.header.response = true; + resp.header.rescode = ResultCode::FORMERR; + if send_response(&mut stream, &resp, remote_addr) + .await + .is_err() + { + break; + } + continue; + } + }; + + 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; + } + } + } + } +} + +/// 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. +/// 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, +{ + 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); + 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)] +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 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 + // 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, "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()); + let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der())); + + 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(); + + (Arc::new(server_config), cert_der) + } + + /// 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, 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)), + 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(upstream_addr)), + upstream_auto: false, + upstream_port: 53, + lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST), + timeout: Duration::from_millis(200), + 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); + + tokio::spawn(accept_loop(listener, acceptor, ctx)); + + (addr, cert_der) + } + + /// 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, + ) -> 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("numa.numa").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, 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); + 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, 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 { + 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, 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); + let resp = dot_exchange(&mut stream, &query).await; + + assert_eq!(resp.header.id, 0xBEEF); + assert!(resp.header.response); + // 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] + async fn dot_negotiates_alpn() { + 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, 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 { + 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(); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index cff1a48..347e72f 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; @@ -65,7 +66,9 @@ fn config_dir_unix() -> std::path::PathBuf { std::path::PathBuf::from("/usr/local/var/numa") } -/// System-wide data directory for TLS certs. +/// 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 { diff --git a/src/main.rs b/src/main.rs index 68022fc..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) { + 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, @@ -370,6 +383,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 +493,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 { 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; } diff --git a/src/tls.rs b/src/tls.rs index a4d91bf..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) { + 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()); @@ -36,17 +36,26 @@ 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> { - let dir = crate::data_dir(); - let (ca_cert, ca_key) = ensure_ca(&dir)?; +/// `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 (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) let _ = rustls::crypto::ring::default_provider().install_default(); - let config = ServerConfig::builder() + let mut config = ServerConfig::builder() .with_no_client_auth() .with_single_cert(cert_chain, key)?; + config.alpn_protocols = alpn; info!( "TLS configured for {} .{} domains", diff --git a/tests/integration.sh b/tests/integration.sh index c83dd61..473356e 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -404,6 +404,241 @@ 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 +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. 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" + + cat > "$CONFIG" << CONF +[server] +bind_addr = "127.0.0.1:$PORT" +api_port = $API_PORT +data_dir = "$NUMA_DATA" + +[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 + + 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 ""