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 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<u8>> {
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<Arc<ServerConfig>> {
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[..])?
.ok_or("no private key found in key file")?;
let config = ServerConfig::builder()
let mut config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)?;
config.alpn_protocols = dot_alpn();
Ok(Arc::new(config))
}
fn fallback_tls(ctx: &ServerCtx) -> Option<Arc<ServerConfig>> {
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<Arc<ServerConfig>> {
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<ServerCtx>, 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<S>(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;

View File

@@ -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);

View File

@@ -24,7 +24,7 @@ pub fn regenerate_tls(ctx: &ServerCtx) {
names.extend(ctx.lan_peers.lock().unwrap().names());
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) => {
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<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 (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<Ar
// 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",