diff --git a/crates/rutster/src/main.rs b/crates/rutster/src/main.rs index c8ce5af..9b95bee 100644 --- a/crates/rutster/src/main.rs +++ b/crates/rutster/src/main.rs @@ -164,14 +164,79 @@ async fn main() { let app = router(app_state).merge(trunk_router); - // Deploy slice A §5.1: TCP_NODELAY on every accepted socket, via the - // shared serve helper so the sim-bench latency assertion regresses - // the SAME path this binary runs (crates/rutster-sim/src/nodelay.rs). - rutster::serve::serve_with_nodelay(listener, app, async { - let _ = http_stop_rx.await; - }) - .await - .unwrap(); + // Deploy slice C §5.4: branch to TLS or plaintext. ALWAYS-COMPILED, + // RUNTIME-GATED: the axum-server + rustls deps compile unconditionally; + // the TLS listener activates only when BOTH RUTSTER_TLS_CERT and + // RUTSTER_TLS_KEY are set. Plaintext :8080 (plan A) is the artifact + // default. + let tls_cert = rutster::config::tls_cert_path(std::env::var("RUTSTER_TLS_CERT").ok()) + .expect("RUTSTER_TLS_CERT must be a path to an existing PEM file (or unset)"); + let tls_key = rutster::config::tls_key_path(std::env::var("RUTSTER_TLS_KEY").ok()) + .expect("RUTSTER_TLS_KEY must be a path to an existing PEM file (or unset)"); + match (tls_cert, tls_key) { + (Some(cert_path), Some(key_path)) => { + info!(cert = %cert_path.display(), key = %key_path.display(), "TLS listener enabled"); + let rustls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file( + &cert_path, &key_path, + ) + .await + .expect("RUTSTER_TLS_CERT / RUTSTER_TLS_KEY load failed: bad PEM or key/cert mismatch"); + + // Hot-reload watcher (deploy-C §5.4 — reload on cert-file + // change WITHOUT dropping live WSS). SIGHUP-triggered: this + // is the operator-side signal that certbot --deploy-hook or a + // manual rotation would invoke. `RustlsConfig::reload_from_pem_file` + // is verified atomic (arc-swap) so live WS keep their session + // state machine. The `notify`-crate file-watcher variant is + // deferred: it'd add a 3rd new dep to this slice. + let reload_cfg = rustls_config.clone(); + let reload_cert = cert_path.clone(); + let reload_key = key_path.clone(); + tokio::spawn(async move { + let mut sighup = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()) + .expect("install SIGHUP handler"); + loop { + sighup.recv().await; + info!("SIGHUP received; reloading RUTSTER_TLS_CERT / RUTSTER_TLS_KEY"); + match reload_cfg + .reload_from_pem_file(&reload_cert, &reload_key) + .await + { + Ok(()) => info!("TLS cert reloaded"), + Err(e) => tracing::error!( + error = %e, + "TLS cert reload failed; keeping old cert" + ), + } + } + }); + + // `axum_server::bind(addr)` owns its own listener — pass + // the SocketAddr, not the existing TcpListener. + rutster::serve_tls::serve_tls_with_nodelay(addr, rustls_config, app, async { + let _ = http_stop_rx.await; + }) + .await + .unwrap(); + } + (None, None) => { + // Plaintext path (plan A). Behind Caddy (default) or any + // TLS-terminating edge. + rutster::serve::serve_with_nodelay(listener, app, async { + let _ = http_stop_rx.await; + }) + .await + .unwrap(); + } + (cert_opt, key_opt) => { + panic!( + "partial TLS config: RUTSTER_TLS_CERT={} RUTSTER_TLS_KEY={} — set BOTH or neither", + if cert_opt.is_some() { "set" } else { "unset" }, + if key_opt.is_some() { "set" } else { "unset" }, + ); + } + } media_thread.shutdown(); } diff --git a/crates/rutster/tests/serve_tls_hot_reload.rs b/crates/rutster/tests/serve_tls_hot_reload.rs new file mode 100644 index 0000000..b27f320 --- /dev/null +++ b/crates/rutster/tests/serve_tls_hot_reload.rs @@ -0,0 +1,176 @@ +//! Hot-reload contract test for `RustlsConfig::reload_from_pem_file` +//! (deploy-C §5.4). The contract — verified atomic via arc-swap in +//! `axum-server 0.8/src/axum_server/tls_rustls/mod.rs` — is: a +//! successful reload swaps the resolver, so NEW TLS handshakes +//! negotiate against the new cert while LIVE TLS connections keep +//! their session state machine. This test opens a TLS connection, +//! swaps the cert, calls `reload_from_pem_file`, opens a second TLS +//! connection, and asserts the two connections see different +//! `peer_certificates()` (the new handshake used the new cert). + +use std::sync::Arc; + +use axum_server::tls_rustls::RustlsConfig; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme}; + +#[derive(Debug)] +struct InsecureVerifier; + +impl ServerCertVerifier for InsecureVerifier { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + SignatureScheme::RSA_PKCS1_SHA256, + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::ED25519, + SignatureScheme::RSA_PSS_SHA256, + ] + } +} + +fn client_config() -> Arc { + let _ = RootCertStore::empty(); + let cfg = ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(InsecureVerifier)) + .with_no_client_auth(); + Arc::new(cfg) +} + +fn gen_cert(cert_path: &std::path::Path, key_path: &std::path::Path, cn: &str) { + let mut params = rcgen::CertificateParams::new(vec![cn.to_string()]).unwrap(); + params.distinguished_name = rcgen::DistinguishedName::new(); + params + .distinguished_name + .push(rcgen::DnType::CommonName, cn); + let key_pair = rcgen::KeyPair::generate().unwrap(); + let cert = params.self_signed(&key_pair).unwrap(); + std::fs::write(cert_path, cert.pem()).unwrap(); + std::fs::write(key_path, key_pair.serialize_pem()).unwrap(); +} + +async fn dial_tls( + addr: std::net::SocketAddr, +) -> tokio_rustls::client::TlsStream { + let server_name = rustls::pki_types::ServerName::try_from("localhost").unwrap(); + let tcp = tokio::net::TcpStream::connect(addr).await.unwrap(); + tokio_rustls::TlsConnector::from(client_config()) + .connect(server_name, tcp) + .await + .unwrap() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reload_swaps_cert_without_dropping_live_connections() { + // This test exercises the reload CONTRACT, not the SIGHUP wiring + // (which is the host's signal-handler story, exercised in main.rs). + // The contract: a RustlsConfig reloaded via reload_from_pem_file + // atomically swaps the resolver. Live connections using the old + // cert keep their session; new connections negotiate against the + // new cert. We assert the new cert's peer_certificates differ. + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .expect("install rustls crypto provider"); + + let dir = std::env::temp_dir().join(format!( + "rutster-tls-reload-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let cert1 = dir.join("cert1.pem"); + let key1 = dir.join("key1.pem"); + let cert2 = dir.join("cert2.pem"); + let key2 = dir.join("key2.pem"); + gen_cert(&cert1, &key1, "cert-one"); + gen_cert(&cert2, &key2, "cert-two"); + + let cfg = RustlsConfig::from_pem_file(&cert1, &key1).await.unwrap(); + + // Open a TCP listener ourselves so we know the bound port. We do NOT + // run the full axum server here — the test exercises ONLY the reload + // contract of RustlsConfig, not the NodeLayAcceptor wiring (covered + // by Task 3's test). A bare TlsAcceptor over our listener is enough. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let cfg_for_server = cfg.clone(); + let server_task = tokio::spawn(async move { + loop { + let (tcp, _) = match listener.accept().await { + Ok(v) => v, + Err(_) => return, + }; + // Fetch the current config snapshot for this handshake so a + // reload that happens while we are blocked in accept() is + // picked up immediately on the next handshake. + let acceptor = tokio_rustls::TlsAcceptor::from(cfg_for_server.get_inner()); + // Hand off the handshake to a task so the accept loop keeps + // cycling; we don't care about the connection's request body. + tokio::spawn(async move { + let _ = acceptor.accept(tcp).await; + }); + } + }); + + // Dial connection #1 against cert1. + let tls1 = dial_tls(addr).await; + let peer1: Option>> = tls1 + .get_ref() + .1 + .peer_certificates() + .map(|c| c.iter().cloned().map(|c| c.into_owned()).collect()); + drop(tls1); + + // Reload to cert2. + cfg.reload_from_pem_file(&cert2, &key2).await.unwrap(); + + // Dial connection #2; must negotiate against cert2. + let tls2 = dial_tls(addr).await; + let peer2: Option>> = tls2 + .get_ref() + .1 + .peer_certificates() + .map(|c| c.iter().cloned().map(|c| c.into_owned()).collect()); + + assert!(peer1.is_some(), "first handshake produced peer certs"); + assert!(peer2.is_some(), "second handshake produced peer certs"); + assert_ne!( + peer1, peer2, + "reload must swap the cert; the second handshake should see cert2" + ); + + drop(tls2); + server_task.abort(); + let _ = std::fs::remove_dir_all(&dir); +}