deploy slice C+D+E (binary features): rustls Phase 1 + /metrics + ValkeyEventSink #25
@@ -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();
|
||||
}
|
||||
|
||||
176
crates/rutster/tests/serve_tls_hot_reload.rs
Normal file
176
crates/rutster/tests/serve_tls_hot_reload.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user