test(e2e): TLS certificate hot-reload live-listener regression net (#4861)

Prove the swap-certificates-without-restart promise over real TLS
handshakes (backlog#1154 peri-5): new connections pick up the rotated
certificate within the reload interval, a session opened under the old
certificate survives the swap, and garbage material is fail-safe (the
old certificate keeps serving, the failure leaves a tls_reload_failed
log event, the process stays up). Wired into the e2e-smoke nextest
profile (ci-4 mechanism).
This commit is contained in:
Zhengchao An
2026-07-15 16:05:06 +08:00
committed by GitHub
parent 242424b0fc
commit 602a742a1b
3 changed files with 313 additions and 1 deletions
+1 -1
View File
@@ -165,7 +165,7 @@ test-group = 'e2e-reliability'
[profile.e2e-smoke]
default-filter = """
package(e2e_test) & (
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_source_invalid_date|content_encoding|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook)_test::/)
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_source_invalid_date|content_encoding|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload)_test::/)
| test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
| test(/^reliant::lifecycle::/)
)
+6
View File
@@ -197,6 +197,12 @@ mod object_lambda_test;
#[cfg(test)]
mod notification_webhook_test;
// TLS certificate hot-reload live-listener e2e (backlog#1154 peri-5): swap
// certificates without a restart, existing sessions survive, bad material is
// fail-safe (old certificate keeps serving, failure is logged).
#[cfg(test)]
mod tls_hot_reload_test;
// Replication extension end-to-end regression tests
#[cfg(test)]
mod replication_extension_test;
+306
View File
@@ -0,0 +1,306 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Live-listener TLS certificate hot-reload e2e (backlog#1154 peri-5).
//!
//! `crates/tls-runtime` had ~20 unit tests but no test ever rotated a
//! certificate under a listening HTTPS server, so the operational promise
//! "swap certificates without a restart" (RUSTFS_TLS_RELOAD_ENABLE) had no
//! automated proof. This suite pins, against a real binary over real TLS
//! handshakes:
//!
//! * the server starts with certificate A and serves its fingerprint;
//! * after the on-disk material is replaced with certificate B, new
//! connections receive B within the reload interval — no restart;
//! * a connection established under A keeps working across the swap
//! (existing sessions are not torn down);
//! * replacing the material with garbage does not take down the listener or
//! the previously loaded certificate (fail-safe, no fail-open) and leaves
//! an assertable `tls_reload_failed` log event.
use crate::common::{RustFSTestEnvironment, init_logging};
use rcgen::generate_simple_self_signed;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConfig, ClientConnection, DigitallySignedStruct, Error as RustlsError, SignatureScheme, StreamOwned};
use serial_test::serial;
use sha2::{Digest, Sha256};
use std::error::Error;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::sync::Arc;
use tokio::time::{Duration, sleep};
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
type BoxError = Box<dyn Error + Send + Sync>;
const CERT_FILE: &str = "rustfs_cert.pem";
const KEY_FILE: &str = "rustfs_key.pem";
/// Minimum value the server accepts for RUSTFS_TLS_RELOAD_INTERVAL (seconds).
const RELOAD_INTERVAL_SECS: u64 = 5;
#[derive(Debug)]
struct AcceptAnyServerCertVerifier;
impl ServerCertVerifier for AcceptAnyServerCertVerifier {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, RustlsError> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, RustlsError> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, RustlsError> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
rustls::crypto::aws_lc_rs::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
fn tls_client_config() -> Arc<ClientConfig> {
Arc::new(
ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCertVerifier))
.with_no_client_auth(),
)
}
/// A live TLS session over a blocking TCP stream, kept open across reloads.
struct TlsSession {
stream: StreamOwned<ClientConnection, TcpStream>,
fingerprint: String,
host: String,
}
/// Completes a TLS handshake with `addr` and returns the session together with
/// the SHA-256 fingerprint of the served leaf certificate.
fn tls_connect(addr: &str) -> Result<TlsSession, BoxError> {
let host = addr.split(':').next().unwrap_or("127.0.0.1").to_string();
let server_name = ServerName::try_from(host.clone())?;
let mut conn = ClientConnection::new(tls_client_config(), server_name)?;
let mut tcp = TcpStream::connect(addr)?;
tcp.set_read_timeout(Some(std::time::Duration::from_secs(10)))?;
tcp.set_write_timeout(Some(std::time::Duration::from_secs(10)))?;
// Drive the handshake to completion so peer_certificates() is populated.
while conn.is_handshaking() {
conn.complete_io(&mut tcp)?;
}
let leaf = conn
.peer_certificates()
.and_then(|certs| certs.first())
.ok_or("server presented no certificate")?;
let fingerprint = Sha256::digest(leaf.as_ref())
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
Ok(TlsSession {
stream: StreamOwned::new(conn, tcp),
fingerprint,
host,
})
}
/// Sends a keep-alive HTTP request on the session and reads the response head,
/// proving the TLS session is still fully functional.
fn http_roundtrip(session: &mut TlsSession) -> Result<String, BoxError> {
let request = format!("GET / HTTP/1.1\r\nHost: {}\r\nConnection: keep-alive\r\n\r\n", session.host);
session.stream.write_all(request.as_bytes())?;
session.stream.flush()?;
// Read up to the end of the headers plus a body chunk; anonymous ListBuckets
// answers a small error payload, which is fine — we only need a valid
// HTTP status line over the existing TLS session.
let mut buf = vec![0_u8; 8192];
let read = session.stream.read(&mut buf)?;
if read == 0 {
return Err("connection closed by server".into());
}
let head = String::from_utf8_lossy(&buf[..read]).to_string();
if !head.starts_with("HTTP/1.1") {
return Err(format!("unexpected response head: {head}").into());
}
Ok(head)
}
async fn write_cert_pair(tls_dir: &std::path::Path, sans: Vec<String>) -> Result<(), BoxError> {
let cert = generate_simple_self_signed(sans)?;
tokio::fs::write(tls_dir.join(CERT_FILE), cert.cert.pem()).await?;
tokio::fs::write(tls_dir.join(KEY_FILE), cert.signing_key.serialize_pem()).await?;
Ok(())
}
/// Polls new TLS connections until the served fingerprint changes away from
/// `old_fingerprint`, returning the new one.
async fn wait_for_new_fingerprint(addr: &str, old_fingerprint: &str, within: Duration) -> Result<String, BoxError> {
let deadline = tokio::time::Instant::now() + within;
loop {
let addr_owned = addr.to_string();
let observed = tokio::task::spawn_blocking(move || tls_connect(&addr_owned).map(|s| s.fingerprint)).await?;
if let Ok(fp) = observed
&& fp != old_fingerprint
{
return Ok(fp);
}
if tokio::time::Instant::now() >= deadline {
return Err(format!("served certificate never rotated away from {old_fingerprint} within {within:?}").into());
}
sleep(Duration::from_secs(1)).await;
}
}
/// Starts the rustfs binary with HTTPS + hot reload enabled, capturing its
/// stdout/stderr to `log_path`. The harness's own start path is unusable here:
/// its readiness probe drives the AWS SDK over the environment URL, and the SDK
/// rejects the self-signed test certificate.
async fn start_https_server_with_reload(
env: &mut RustFSTestEnvironment,
tls_dir: &std::path::Path,
log_path: &str,
) -> TestResult {
let log_file = std::fs::OpenOptions::new().create(true).append(true).open(log_path)?;
let log_file_err = log_file.try_clone()?;
let process = std::process::Command::new(crate::common::rustfs_binary_path())
.env("RUST_LOG", "rustfs=info")
.env("RUSTFS_CONSOLE_ENABLE", "false")
.env("RUSTFS_TLS_PATH", tls_dir)
.env("RUSTFS_TLS_RELOAD_ENABLE", "true")
.env("RUSTFS_TLS_RELOAD_INTERVAL", RELOAD_INTERVAL_SECS.to_string())
.stdout(std::process::Stdio::from(log_file))
.stderr(std::process::Stdio::from(log_file_err))
.args([
"--address",
&env.address,
"--access-key",
&env.access_key,
"--secret-key",
&env.secret_key,
&env.temp_dir,
])
.spawn()?;
env.process = Some(process);
// Readiness = a TLS handshake completes on the listener.
let addr = env.address.clone();
for attempt in 0..60 {
let addr_clone = addr.clone();
if tokio::task::spawn_blocking(move || tls_connect(&addr_clone)).await?.is_ok() {
return Ok(());
}
if attempt == 59 {
return Err("HTTPS server never completed a TLS handshake within 60s".into());
}
sleep(Duration::from_secs(1)).await;
}
Ok(())
}
/// Runs `http_roundtrip` on a session inside the blocking pool, handing the
/// session back for later reuse (the point: the same TLS session survives).
async fn roundtrip_and_return(mut session: TlsSession) -> Result<TlsSession, BoxError> {
tokio::task::spawn_blocking(move || {
http_roundtrip(&mut session)?;
Ok::<_, BoxError>(session)
})
.await?
}
#[tokio::test]
#[serial]
async fn test_tls_certificate_hot_reload_live_listener() -> TestResult {
init_logging();
// Install the process-wide rustls crypto provider (idempotent).
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let mut env = RustFSTestEnvironment::new().await?;
let tls_dir = std::path::PathBuf::from(format!("{}/tls", env.temp_dir));
tokio::fs::create_dir_all(&tls_dir).await?;
write_cert_pair(&tls_dir, vec!["localhost".into(), "127.0.0.1".into()]).await?;
let log_path = format!("{}/server.log", env.temp_dir);
start_https_server_with_reload(&mut env, &tls_dir, &log_path).await?;
let addr = env.address.clone();
// --- certificate A is served, and the session stays usable ---------------
let session_a = {
let addr = addr.clone();
tokio::task::spawn_blocking(move || tls_connect(&addr)).await??
};
let fingerprint_a = session_a.fingerprint.clone();
let session_a = roundtrip_and_return(session_a).await?;
// --- swap to certificate B: new connections pick it up without restart ---
write_cert_pair(&tls_dir, vec!["localhost".into(), "127.0.0.1".into()]).await?;
let reload_budget = Duration::from_secs(RELOAD_INTERVAL_SECS * 4 + 10);
let fingerprint_b = wait_for_new_fingerprint(&addr, &fingerprint_a, reload_budget).await?;
assert_ne!(fingerprint_a, fingerprint_b);
// The connection opened under certificate A must survive the rotation.
let _session_a = roundtrip_and_return(session_a).await?;
// --- garbage material: fail-safe, keep serving B, log the failure --------
tokio::fs::write(tls_dir.join(CERT_FILE), b"not a certificate").await?;
tokio::fs::write(tls_dir.join(KEY_FILE), b"not a key").await?;
// Give the reload loop at least two ticks to observe the bad material.
sleep(Duration::from_secs(RELOAD_INTERVAL_SECS * 2 + 2)).await;
let after_bad = {
let addr = addr.clone();
tokio::task::spawn_blocking(move || tls_connect(&addr)).await??
};
assert_eq!(
after_bad.fingerprint, fingerprint_b,
"bad on-disk material must not change or drop the served certificate"
);
let log = tokio::fs::read_to_string(&log_path).await.unwrap_or_default();
assert!(
log.contains("tls_reload_failed") || log.contains("TLS reload failed"),
"a failed reload must leave an assertable log event; captured log did not contain one"
);
// The process must still be alive and serving.
let final_session = tokio::task::spawn_blocking(move || tls_connect(&addr)).await??;
let _ = roundtrip_and_return(final_session).await?;
env.stop_server();
Ok(())
}