deploy slice C+D+E (binary features): rustls Phase 1 + /metrics + ValkeyEventSink #25

Merged
alee merged 10 commits from deploy-c/binary-features into docs/deployment-topology-spec 2026-07-06 14:58:44 +00:00
2 changed files with 249 additions and 8 deletions
Showing only changes of commit 61fcc730cc - Show all commits

View File

@@ -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();
}

View File

@@ -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<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
vec![
SignatureScheme::RSA_PKCS1_SHA256,
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::ED25519,
SignatureScheme::RSA_PSS_SHA256,
]
}
}
fn client_config() -> Arc<ClientConfig> {
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<tokio::net::TcpStream> {
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<Vec<rustls::pki_types::CertificateDer<'static>>> = 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<Vec<rustls::pki_types::CertificateDer<'static>>> = 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);
}