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) <noreply@anthropic.com>
This commit is contained in:
Razvan Dimescu
2026-03-30 00:36:26 +03:00
parent 766935ec97
commit e4350ae81c
7 changed files with 534 additions and 11 deletions

View File

@@ -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<BytePacketBuffer> {
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(())
}