feat: add DNS-over-TLS (DoT) listener #25
46
src/dot.rs
46
src/dot.rs
@@ -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);
|
||||
match tokio::time::timeout(WRITE_TIMEOUT, async {
|
||||
stream.write_all(&out).await?;
|
||||
stream.flush().await?;
|
||||
Ok(())
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
13
src/tls.rs
13
src/tls.rs
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user