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 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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
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());
|
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",
|
||||||
|
|||||||
Reference in New Issue
Block a user