feat: DoT write timeout and ALPN "dot" advertisement

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<Vec<u8>>` 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<ServerConfig> 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) <noreply@anthropic.com>
This commit is contained in:
Razvan Dimescu
2026-04-07 22:51:52 +03:00
parent 5713b9bed6
commit d0deb08d2c
3 changed files with 47 additions and 16 deletions

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",