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>
164 lines
5.7 KiB
Rust
164 lines
5.7 KiB
Rust
use std::collections::HashSet;
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
use log::{info, warn};
|
|
|
|
use crate::ctx::ServerCtx;
|
|
use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose, SanType};
|
|
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
|
use rustls::ServerConfig;
|
|
use time::{Duration, OffsetDateTime};
|
|
|
|
const CA_VALIDITY_DAYS: i64 = 3650; // 10 years
|
|
const CERT_VALIDITY_DAYS: i64 = 365; // 1 year
|
|
|
|
/// Collect all service + LAN peer names and regenerate the TLS cert.
|
|
pub fn regenerate_tls(ctx: &ServerCtx) {
|
|
let tls = match &ctx.tls_config {
|
|
Some(t) => t,
|
|
None => return,
|
|
};
|
|
|
|
let mut names: HashSet<String> = ctx.services.lock().unwrap().names().into_iter().collect();
|
|
names.extend(ctx.lan_peers.lock().unwrap().names());
|
|
let names: Vec<String> = names.into_iter().collect();
|
|
|
|
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());
|
|
}
|
|
Err(e) => warn!("TLS regeneration failed: {}", e),
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
/// `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)?;
|
|
|
|
// Ensure a crypto provider is installed (rustls needs one)
|
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
|
|
let mut config = ServerConfig::builder()
|
|
.with_no_client_auth()
|
|
.with_single_cert(cert_chain, key)?;
|
|
config.alpn_protocols = alpn;
|
|
|
|
info!(
|
|
"TLS configured for {} .{} domains",
|
|
service_names.len(),
|
|
tld
|
|
);
|
|
Ok(Arc::new(config))
|
|
}
|
|
|
|
fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> {
|
|
let ca_key_path = dir.join("ca.key");
|
|
let ca_cert_path = dir.join("ca.pem");
|
|
|
|
if ca_key_path.exists() && ca_cert_path.exists() {
|
|
let key_pem = std::fs::read_to_string(&ca_key_path)?;
|
|
let cert_pem = std::fs::read_to_string(&ca_cert_path)?;
|
|
let key_pair = KeyPair::from_pem(&key_pem)?;
|
|
let params = CertificateParams::from_ca_cert_pem(&cert_pem)?;
|
|
let cert = params.self_signed(&key_pair)?;
|
|
info!("loaded CA from {:?}", ca_cert_path);
|
|
return Ok((cert, key_pair));
|
|
}
|
|
|
|
// Generate new CA
|
|
std::fs::create_dir_all(dir)?;
|
|
|
|
let key_pair = KeyPair::generate()?;
|
|
let mut params = CertificateParams::default();
|
|
params
|
|
.distinguished_name
|
|
.push(DnType::CommonName, "Numa Local CA");
|
|
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
|
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
|
params.not_before = OffsetDateTime::now_utc();
|
|
params.not_after = OffsetDateTime::now_utc() + Duration::days(CA_VALIDITY_DAYS);
|
|
|
|
let cert = params.self_signed(&key_pair)?;
|
|
|
|
std::fs::write(&ca_key_path, key_pair.serialize_pem())?;
|
|
std::fs::write(&ca_cert_path, cert.pem())?;
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(&ca_key_path, std::fs::Permissions::from_mode(0o600))?;
|
|
}
|
|
|
|
info!("generated CA at {:?}", ca_cert_path);
|
|
Ok((cert, key_pair))
|
|
}
|
|
|
|
/// Generate a cert with explicit SANs for each service name.
|
|
/// Always regenerated at startup (~5ms) — no disk caching needed.
|
|
fn generate_service_cert(
|
|
ca_cert: &rcgen::Certificate,
|
|
ca_key: &KeyPair,
|
|
tld: &str,
|
|
service_names: &[String],
|
|
) -> crate::Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
|
|
let key_pair = KeyPair::generate()?;
|
|
let mut params = CertificateParams::default();
|
|
params
|
|
.distinguished_name
|
|
.push(DnType::CommonName, format!("Numa .{} services", tld));
|
|
|
|
// Add a wildcard SAN so any .numa domain gets a valid cert (including
|
|
// unregistered services — lets the proxy show a styled 404 over HTTPS).
|
|
// Also add each service explicitly for clients that don't match wildcards.
|
|
let mut sans = Vec::new();
|
|
let wildcard = format!("*.{}", tld);
|
|
match wildcard.clone().try_into() {
|
|
Ok(ia5) => sans.push(SanType::DnsName(ia5)),
|
|
Err(e) => warn!("invalid wildcard SAN {}: {}", wildcard, e),
|
|
}
|
|
for name in service_names {
|
|
let fqdn = format!("{}.{}", name, tld);
|
|
match fqdn.clone().try_into() {
|
|
Ok(ia5) => sans.push(SanType::DnsName(ia5)),
|
|
Err(e) => warn!("invalid SAN {}: {}", fqdn, e),
|
|
}
|
|
}
|
|
|
|
if sans.is_empty() {
|
|
return Err("no valid service names for TLS cert".into());
|
|
}
|
|
|
|
params.subject_alt_names = sans;
|
|
params.not_before = OffsetDateTime::now_utc();
|
|
params.not_after = OffsetDateTime::now_utc() + Duration::days(CERT_VALIDITY_DAYS);
|
|
|
|
let cert = params.signed_by(&key_pair, ca_cert, ca_key)?;
|
|
|
|
info!(
|
|
"generated TLS cert for: {}",
|
|
service_names
|
|
.iter()
|
|
.map(|n| format!("{}.{}", n, tld))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
);
|
|
|
|
let cert_der = CertificateDer::from(cert.der().to_vec());
|
|
let ca_der = CertificateDer::from(ca_cert.der().to_vec());
|
|
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der()));
|
|
|
|
Ok((vec![cert_der, ca_der], key_der))
|
|
}
|