feat: add DNS-over-TLS (DoT) listener #25

Merged
razvandimescu merged 19 commits from feat/dns-over-tls into main 2026-04-08 07:53:43 +08:00
3 changed files with 47 additions and 16 deletions
Showing only changes of commit d0deb08d2c - Show all commits

View File

@@ -19,10 +19,16 @@ use crate::packet::DnsPacket;
const MAX_CONNECTIONS: usize = 512; const MAX_CONNECTIONS: usize = 512;
const IDLE_TIMEOUT: Duration = Duration::from_secs(30); const IDLE_TIMEOUT: Duration = Duration::from_secs(30);
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); 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 // Matches BytePacketBuffer::BUF_SIZE — RFC 7858 allows up to 65535 but our
// buffer would silently truncate anything larger. // buffer would silently truncate anything larger.
const MAX_MSG_LEN: usize = 4096; const MAX_MSG_LEN: usize = 4096;
/// ALPN protocol identifier for DNS-over-TLS (RFC 7858 §3.2).
fn dot_alpn() -> Vec<Vec<u8>> {
vec![b"dot".to_vec()]
}
/// Build a TLS ServerConfig for DoT from user-provided cert/key PEM files. /// 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<Arc<ServerConfig>> { fn load_tls_config(cert_path: &Path, key_path: &Path) -> crate::Result<Arc<ServerConfig>> {
let cert_pem = std::fs::read(cert_path)?; let cert_pem = std::fs::read(cert_path)?;
@@ -32,18 +38,18 @@ fn load_tls_config(cert_path: &Path, key_path: &Path) -> crate::Result<Arc<Serve
let key = rustls_pemfile::private_key(&mut &key_pem[..])? let key = rustls_pemfile::private_key(&mut &key_pem[..])?
.ok_or("no private key found in key file")?; .ok_or("no private key found in key file")?;
let config = ServerConfig::builder() let mut config = ServerConfig::builder()
.with_no_client_auth() .with_no_client_auth()
.with_single_cert(certs, key)?; .with_single_cert(certs, key)?;
config.alpn_protocols = dot_alpn();
Ok(Arc::new(config)) Ok(Arc::new(config))
} }
fn fallback_tls(ctx: &ServerCtx) -> Option<Arc<ServerConfig>> { /// Build a self-signed DoT TLS config. Can't reuse `ctx.tls_config` (the
if let Some(arc_swap) = ctx.tls_config.as_ref() { /// proxy's shared config) because DoT needs its own ALPN advertisement.
return Some(Arc::clone(&*arc_swap.load())); fn self_signed_tls(ctx: &ServerCtx) -> Option<Arc<ServerConfig>> {
} match crate::tls::build_tls_config(&ctx.proxy_tld, &[], dot_alpn()) {
match crate::tls::build_tls_config(&ctx.proxy_tld, &[]) {
Ok(cfg) => Some(cfg), Ok(cfg) => Some(cfg),
Err(e) => { Err(e) => {
warn!( warn!(
@@ -65,7 +71,7 @@ pub async fn start_dot(ctx: Arc<ServerCtx>, config: &DotConfig) {
return; return;
} }
}, },
_ => match fallback_tls(&ctx) { _ => match self_signed_tls(&ctx) {
Some(cfg) => cfg, Some(cfg) => cfg,
None => return, None => return,
}, },
@@ -228,6 +234,7 @@ where
} }
/// Write a DNS message with its 2-byte length prefix, coalesced into one syscall. /// 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<S>(stream: &mut S, msg: &[u8]) -> std::io::Result<()> async fn write_framed<S>(stream: &mut S, msg: &[u8]) -> std::io::Result<()>
where where
S: AsyncWriteExt + Unpin, S: AsyncWriteExt + Unpin,
@@ -235,9 +242,15 @@ where
let mut out = Vec::with_capacity(2 + msg.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.len() as u16).to_be_bytes());
out.extend_from_slice(msg); out.extend_from_slice(msg);
match tokio::time::timeout(WRITE_TIMEOUT, async {
stream.write_all(&out).await?; stream.write_all(&out).await?;
stream.flush().await?; stream.flush().await
Ok(()) })
.await
{
Ok(result) => result,
Err(_) => Err(std::io::Error::other("write timeout")),
}
} }
#[cfg(test)] #[cfg(test)]
@@ -271,16 +284,18 @@ mod tests {
let cert_der = CertificateDer::from(cert.der().to_vec()); let cert_der = CertificateDer::from(cert.der().to_vec());
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der())); 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_no_client_auth()
.with_single_cert(vec![cert_der.clone()], key_der) .with_single_cert(vec![cert_der.clone()], key_der)
.unwrap(); .unwrap();
server_config.alpn_protocols = dot_alpn();
let mut root_store = rustls::RootCertStore::empty(); let mut root_store = rustls::RootCertStore::empty();
root_store.add(cert_der).unwrap(); 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_root_certificates(root_store)
.with_no_client_auth(); .with_no_client_auth();
client_config.alpn_protocols = dot_alpn();
(Arc::new(server_config), Arc::new(client_config)) (Arc::new(server_config), Arc::new(client_config))
} }
@@ -443,6 +458,15 @@ mod tests {
assert_eq!(resp.questions[0].name, "nonexistent.test"); 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] #[tokio::test]
async fn dot_concurrent_connections() { async fn dot_concurrent_connections() {
let (addr, client_config) = spawn_dot_server().await; let (addr, client_config) = spawn_dot_server().await;

View File

@@ -207,7 +207,7 @@ async fn main() -> numa::Result<()> {
// Build initial TLS config before ServerCtx (so ArcSwap is ready at construction) // 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 initial_tls = if config.proxy.enabled && config.proxy.tls_port > 0 {
let service_names = service_store.names(); 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)), Ok(tls_config) => Some(ArcSwap::from(tls_config)),
Err(e) => { Err(e) => {
log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e); log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e);

View File

@@ -24,7 +24,7 @@ pub fn regenerate_tls(ctx: &ServerCtx) {
names.extend(ctx.lan_peers.lock().unwrap().names()); names.extend(ctx.lan_peers.lock().unwrap().names());
let names: Vec<String> = names.into_iter().collect(); let names: Vec<String> = 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) => { Ok(new_config) => {
tls.store(new_config); tls.store(new_config);
info!("TLS cert regenerated for {} services", names.len()); 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. /// Build a TLS config with a cert covering all provided service names.
/// Wildcards under single-label TLDs (*.numa) are rejected by browsers, /// Wildcards under single-label TLDs (*.numa) are rejected by browsers,
/// so we list each service explicitly as a SAN. /// so we list each service explicitly as a SAN.
pub fn build_tls_config(tld: &str, service_names: &[String]) -> crate::Result<Arc<ServerConfig>> { /// `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<Vec<u8>>,
) -> crate::Result<Arc<ServerConfig>> {
let dir = crate::data_dir(); let dir = crate::data_dir();
let (ca_cert, ca_key) = ensure_ca(&dir)?; let (ca_cert, ca_key) = ensure_ca(&dir)?;
let (cert_chain, key) = generate_service_cert(&ca_cert, &ca_key, tld, service_names)?; 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<Ar
// Ensure a crypto provider is installed (rustls needs one) // Ensure a crypto provider is installed (rustls needs one)
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
let config = ServerConfig::builder() let mut config = ServerConfig::builder()
.with_no_client_auth() .with_no_client_auth()
.with_single_cert(cert_chain, key)?; .with_single_cert(cert_chain, key)?;
config.alpn_protocols = alpn;
info!( info!(
"TLS configured for {} .{} domains", "TLS configured for {} .{} domains",