tidaldb/tidal-net/src/tls.rs
jx12n 005e292cbb fix(m11): review remediation + tidal-stress perf sweep + perf wave 2
Resolve all BLOCKER/CRITICAL/WARNING findings from the m11p7/p8 review:
- tidalctl restore: safe_join path-traversal/Zip-Slip guard + fsync on write
- corrupt-WAL checkpoint_seq guard; PITR archive-before-delete
- cluster: x-tidal-relayed audit-dedup marker; forward_failures counts 5xx
- mTLS/HTTP-TLS handshake hardening; accept-loop EMFILE backoff
- per-principal rate-limit + node-token marker-pinning tests
- self-heal tier-3 coverage; 5 router-auth tests

tidal-stress: measurement-fidelity fixes (schedule-lag p99/max, exact
feed-over-SLO verdict, shed annotation) + typed Body, workload.next
184ns->68ns, RoundRobin len==1 short-circuit, HeaderValue cache;
new benches/hotpath.rs + lib.rs.

perf wave 2: signal_snapshot SmallVec/SignalKey carrier; one-get-per-type
ranking pre-pass.
2026-06-13 12:28:04 -06:00

427 lines
18 KiB
Rust

//! TLS configuration helpers for the gRPC transport.
//!
//! Two halves:
//!
//! * **Client** ([`client_tls_config`]): builds tonic's [`ClientTlsConfig`] for
//! outbound peer channels — pins the cluster CA and attaches this node's client
//! identity for mutual TLS. Unchanged since the optional-mTLS era.
//! * **Server** (m11p7): the inbound gRPC server is NO LONGER served through
//! tonic's `ServerTlsConfig`. tonic 0.12 caches a FIXED `Arc<rustls::ServerConfig>`
//! built once from a static identity and exposes no resolver hook, so a
//! running server's certificate can never be hot-swapped through its public
//! API — "rotation without restart" would force a connection-dropping rebuild.
//! Instead [`build_server_config`] builds a rustls [`ServerConfig`] whose
//! identity is a [`DynamicCertResolver`] (an [`ArcSwap`] over the current
//! [`CertifiedKey`]). [`server`](crate::server) serves over a `tokio-rustls`
//! acceptor fed by that config, and [`ServerCertReloader`] swaps the resolver's
//! cert on rotation. In-flight TLS sessions keep their already-negotiated keys,
//! so a rotation drops zero connections; only NEW handshakes pick up the new
//! cert. mTLS is preserved exactly as tonic enforced it: a
//! [`WebPkiClientVerifier`] over the cluster CA roots (NOT
//! `allow_unauthenticated`), so a peer with no/foreign client cert is rejected
//! at the handshake before any RPC is reached.
use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::Hasher;
use std::path::Path;
use std::sync::{Arc, Mutex, PoisonError};
use arc_swap::ArcSwap;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::server::{ClientHello, ResolvesServerCert, WebPkiClientVerifier};
use rustls::sign::CertifiedKey;
use rustls::{RootCertStore, ServerConfig};
use tonic::transport::{Certificate, ClientTlsConfig, Identity};
use crate::{config::TlsConfig, error::GrpcTransportError};
/// ALPN protocol identifier for HTTP/2. tonic's own TLS acceptor pushes this
/// onto the rustls config's `alpn_protocols`; the custom acceptor MUST do the
/// same or h2 negotiation fails and every gRPC handshake is rejected.
const ALPN_H2: &[u8] = b"h2";
/// ALPN protocol identifier for HTTP/1.1 (the HTTP plane also serves external
/// bearer clients that may speak h1).
const ALPN_HTTP11: &[u8] = b"http/1.1";
/// Build a tonic [`ClientTlsConfig`] for outbound peer channels.
///
/// Always pins the cluster CA. Attaches this node's client identity (for mutual
/// TLS) only when BOTH `client_cert` and `client_key` are configured — a peer
/// with no client identity builds a server-authenticated-only channel that any
/// mTLS-enforcing peer rejects at the handshake.
///
/// # Errors
///
/// Returns [`GrpcTransportError`] if the CA cert (or, when mutual TLS is
/// configured, the client cert/key) file cannot be read.
pub fn client_tls_config(tls: &TlsConfig) -> Result<ClientTlsConfig, GrpcTransportError> {
let ca_cert = fs::read(&tls.ca_cert)
.map_err(|e| GrpcTransportError::TlsConfig(format!("read CA cert: {e}")))?;
let ca = Certificate::from_pem(ca_cert);
let mut config = ClientTlsConfig::new().ca_certificate(ca);
if let (Some(cert_path), Some(key_path)) = (&tls.client_cert, &tls.client_key) {
let cert = fs::read(cert_path)
.map_err(|e| GrpcTransportError::TlsConfig(format!("read client cert: {e}")))?;
let key = fs::read(key_path)
.map_err(|e| GrpcTransportError::TlsConfig(format!("read client key: {e}")))?;
config = config.identity(Identity::from_pem(cert, key));
}
Ok(config)
}
/// Read a PEM certificate chain from `path` into rustls DER certificates.
fn read_cert_chain(path: &Path) -> Result<Vec<CertificateDer<'static>>, GrpcTransportError> {
let pem = fs::read(path)
.map_err(|e| GrpcTransportError::TlsConfig(format!("read cert {}: {e}", path.display())))?;
rustls_pemfile::certs(&mut pem.as_slice())
.collect::<Result<Vec<_>, _>>()
.map_err(|e| GrpcTransportError::TlsConfig(format!("parse cert {}: {e}", path.display())))
}
/// Read a single PEM private key from `path` into a rustls DER key.
fn read_private_key(path: &Path) -> Result<PrivateKeyDer<'static>, GrpcTransportError> {
let pem = fs::read(path)
.map_err(|e| GrpcTransportError::TlsConfig(format!("read key {}: {e}", path.display())))?;
rustls_pemfile::private_key(&mut pem.as_slice())
.map_err(|e| GrpcTransportError::TlsConfig(format!("parse key {}: {e}", path.display())))?
.ok_or_else(|| {
GrpcTransportError::TlsConfig(format!("no private key found in {}", path.display()))
})
}
/// Load the server's certificate chain + private key from PEM files into a
/// rustls [`CertifiedKey`] ready to hand to the [`DynamicCertResolver`].
///
/// Re-read on every rotation: the paths stay fixed (a k8s secret mount swaps the
/// file *content* behind a stable path), so reloading is exactly this call again.
///
/// # Errors
///
/// Returns [`GrpcTransportError::TlsConfig`] if a file cannot be read, the PEM is
/// malformed, or the key does not match the cert chain.
pub fn load_certified_key(
cert_path: &Path,
key_path: &Path,
) -> Result<Arc<CertifiedKey>, GrpcTransportError> {
crate::transport::ensure_crypto_provider();
let chain = read_cert_chain(cert_path)?;
if chain.is_empty() {
return Err(GrpcTransportError::TlsConfig(format!(
"no certificates found in {}",
cert_path.display()
)));
}
let key = read_private_key(key_path)?;
let provider = rustls::crypto::aws_lc_rs::default_provider();
let certified = CertifiedKey::from_der(chain, key, &provider).map_err(|e| {
GrpcTransportError::TlsConfig(format!(
"server cert {} / key {} do not form a valid identity: {e}",
cert_path.display(),
key_path.display()
))
})?;
Ok(Arc::new(certified))
}
/// A [`ResolvesServerCert`] whose certificate can be hot-swapped (m11p7).
///
/// The current [`CertifiedKey`] lives behind an [`ArcSwap`]: the TLS handshake
/// path reads it lock-free on every `resolve`, and [`ServerCertReloader`] swaps
/// in a freshly-loaded cert with a single atomic store. Already-negotiated TLS
/// sessions are unaffected (rustls captured their keys at handshake time), so a
/// rotation drops zero live connections — only new handshakes see the new cert.
#[derive(Debug)]
pub struct DynamicCertResolver {
current: ArcSwap<CertifiedKey>,
}
impl DynamicCertResolver {
/// Build a resolver serving `initial` until the first rotation.
#[must_use]
pub fn new(initial: Arc<CertifiedKey>) -> Self {
Self {
current: ArcSwap::from(initial),
}
}
/// Atomically replace the served certificate. The next handshake uses it;
/// in-flight sessions are untouched.
pub fn store(&self, key: Arc<CertifiedKey>) {
self.current.store(key);
}
}
impl ResolvesServerCert for DynamicCertResolver {
fn resolve(&self, _client_hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
// The cluster presents one identity to every peer (SNI-independent): the
// resolver ignores the ClientHello and always serves the current cert.
Some(self.current.load_full())
}
}
/// Build the rustls [`ServerConfig`] for the inbound gRPC server (m11p7).
///
/// Enforces mutual TLS exactly as tonic's `ServerTlsConfig` did — a
/// [`WebPkiClientVerifier`] built over the cluster CA roots with NO
/// `allow_unauthenticated`, so the handshake REQUIRES and verifies a client cert
/// chained to the cluster CA. The server identity is the supplied
/// [`DynamicCertResolver`] (hot-swappable), and `h2` is advertised over ALPN so
/// HTTP/2 negotiates.
///
/// # Errors
///
/// Returns [`GrpcTransportError::TlsConfig`] if the CA cert cannot be read/parsed,
/// a CA certificate is not a valid trust anchor, or the client verifier cannot be
/// built.
pub fn build_server_config(
tls: &TlsConfig,
resolver: Arc<DynamicCertResolver>,
) -> Result<Arc<ServerConfig>, GrpcTransportError> {
crate::transport::ensure_crypto_provider();
let ca_chain = read_cert_chain(&tls.ca_cert)?;
let mut roots = RootCertStore::empty();
for ca in ca_chain {
roots.add(ca).map_err(|e| {
GrpcTransportError::TlsConfig(format!(
"CA cert {} is not a valid trust anchor: {e}",
tls.ca_cert.display()
))
})?;
}
let verifier = WebPkiClientVerifier::builder(Arc::new(roots))
.build()
.map_err(|e| GrpcTransportError::TlsConfig(format!("build mTLS client verifier: {e}")))?;
let mut config = ServerConfig::builder()
.with_client_cert_verifier(verifier)
.with_cert_resolver(resolver);
config.alpn_protocols = vec![ALPN_H2.to_vec()];
Ok(Arc::new(config))
}
/// Build a rustls [`ServerConfig`] for the inter-node HTTP listener (m11p7).
///
/// Server-authenticated TLS only — NO client-cert verifier — because the HTTP
/// listener also serves EXTERNAL clients (apps with a bearer token, not cluster
/// certs); per-node identity on this plane is the signed internal token, not a
/// client cert. The server identity is the same hot-swappable
/// [`DynamicCertResolver`] the gRPC server uses, so a cert rotation covers both
/// planes. ALPN advertises `h2` then `http/1.1` so HTTP/2 and HTTP/1.1 clients
/// both negotiate (reqwest forwards use h2; curl/probes may use 1.1).
#[must_use]
pub fn build_http_server_config(resolver: Arc<DynamicCertResolver>) -> Arc<ServerConfig> {
crate::transport::ensure_crypto_provider();
let mut config = ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(resolver);
config.alpn_protocols = vec![ALPN_H2.to_vec(), ALPN_HTTP11.to_vec()];
Arc::new(config)
}
/// A content fingerprint over the configured TLS material (m11p7 rotation).
///
/// Hashes the bytes of every present cert/key file. A change in ANY of them
/// (CA, server cert/key, client cert/key) yields a new fingerprint and triggers
/// a reload + peer-channel rebuild. Content hashing (not mtime/inotify) is the
/// robust signal for Kubernetes secret rotation, which swaps a `..data` symlink
/// atomically — a mode inotify watchers routinely miss.
///
/// # Errors
///
/// Returns the underlying [`std::io::Error`] if a configured file cannot be read.
fn cert_fingerprint(tls: &TlsConfig) -> std::io::Result<u64> {
let mut hasher = DefaultHasher::new();
let paths = [
Some(tls.ca_cert.as_path()),
Some(tls.server_cert.as_path()),
Some(tls.server_key.as_path()),
tls.client_cert.as_deref(),
tls.client_key.as_deref(),
];
for path in paths.into_iter().flatten() {
// Hash the path too so swapping which file is empty is still detected.
hasher.write(path.to_string_lossy().as_bytes());
hasher.write(&fs::read(path)?);
}
Ok(hasher.finish())
}
/// Watches the TLS files and hot-swaps the [`DynamicCertResolver`] when they
/// change (m11p7 cert rotation without restart).
///
/// One per transport, polled on a timer by [`crate::transport`]. The poll is
/// content-hash based (see [`cert_fingerprint`]); the reloader holds the last
/// fingerprint so an unchanged poll is a single set of file reads and no work.
pub struct ServerCertReloader {
resolver: Arc<DynamicCertResolver>,
tls: TlsConfig,
last_fingerprint: Mutex<u64>,
}
impl ServerCertReloader {
/// Build a reloader seeded with the fingerprint of the material already
/// loaded into `resolver`, so the first changed poll — not the first poll —
/// is what triggers a reload.
#[must_use]
pub const fn new(
resolver: Arc<DynamicCertResolver>,
tls: TlsConfig,
initial_fingerprint: u64,
) -> Self {
Self {
resolver,
tls,
last_fingerprint: Mutex::new(initial_fingerprint),
}
}
/// The current fingerprint of the configured TLS files, for seeding
/// [`new`](Self::new). Returns 0 if any file is unreadable at construction
/// (the first poll then reloads, surfacing the real error there).
#[must_use]
pub fn fingerprint(tls: &TlsConfig) -> u64 {
cert_fingerprint(tls).unwrap_or(0)
}
/// Poll the TLS files once. If their content changed since the last poll,
/// reload the server identity into the resolver and return `Ok(true)`.
///
/// A reload failure (a half-written cert file mid-rotation, a transient read
/// error) leaves the current cert in place and is surfaced as `Err` — the
/// caller logs it and keeps serving the old identity rather than going dark.
///
/// # Errors
///
/// Returns [`GrpcTransportError::TlsConfig`] if the files cannot be read or
/// the new material is not a valid identity.
pub fn poll_once(&self) -> Result<bool, GrpcTransportError> {
let fingerprint = cert_fingerprint(&self.tls).map_err(|e| {
GrpcTransportError::TlsConfig(format!("read TLS files for rotation: {e}"))
})?;
let mut last = self
.last_fingerprint
.lock()
.unwrap_or_else(PoisonError::into_inner);
if fingerprint == *last {
return Ok(false);
}
// Build the new identity BEFORE committing the fingerprint: if the files
// are mid-write (cert updated, key not yet) the load fails and we retry
// next poll against the still-changed fingerprint.
let key = load_certified_key(&self.tls.server_cert, &self.tls.server_key)?;
self.resolver.store(key);
*last = fingerprint;
drop(last);
Ok(true)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] // test assertions on known-good fixtures
mod tests {
use std::io::Write;
use super::*;
/// Generate a self-signed CA + a server leaf signed by it, written as PEM
/// files into `dir`. Returns a [`TlsConfig`] pointing at them (server cert/
/// key + CA; client identity reuses the server leaf for the round-trip).
fn write_test_certs(dir: &std::path::Path) -> TlsConfig {
let mut ca_params = rcgen::CertificateParams::new(vec!["tidal-ca".to_string()]).unwrap();
ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
let ca_key = rcgen::KeyPair::generate().unwrap();
let ca = ca_params.self_signed(&ca_key).unwrap();
let leaf_params = rcgen::CertificateParams::new(vec!["tidal-node".to_string()]).unwrap();
let leaf_key = rcgen::KeyPair::generate().unwrap();
let leaf = leaf_params.signed_by(&leaf_key, &ca, &ca_key).unwrap();
let ca_path = dir.join("ca.pem");
let cert_path = dir.join("server.pem");
let key_path = dir.join("server-key.pem");
std::fs::File::create(&ca_path)
.unwrap()
.write_all(ca.pem().as_bytes())
.unwrap();
std::fs::File::create(&cert_path)
.unwrap()
.write_all(leaf.pem().as_bytes())
.unwrap();
std::fs::File::create(&key_path)
.unwrap()
.write_all(leaf_key.serialize_pem().as_bytes())
.unwrap();
TlsConfig {
ca_cert: ca_path,
server_cert: cert_path,
server_key: key_path,
client_cert: None,
client_key: None,
}
}
#[test]
fn server_config_builds_and_enforces_mtls() {
let dir = tempfile::tempdir().unwrap();
let tls = write_test_certs(dir.path());
let key = load_certified_key(&tls.server_cert, &tls.server_key).unwrap();
let resolver = Arc::new(DynamicCertResolver::new(key));
let config = build_server_config(&tls, resolver).unwrap();
// ALPN must advertise h2 or gRPC handshakes are rejected.
assert_eq!(config.alpn_protocols, vec![ALPN_H2.to_vec()]);
}
#[test]
fn reloader_detects_content_change_and_swaps() {
let dir = tempfile::tempdir().unwrap();
let tls = write_test_certs(dir.path());
let key = load_certified_key(&tls.server_cert, &tls.server_key).unwrap();
let resolver = Arc::new(DynamicCertResolver::new(key));
let initial = ServerCertReloader::fingerprint(&tls);
let reloader = ServerCertReloader::new(Arc::clone(&resolver), tls.clone(), initial);
// No change yet.
assert!(
!reloader.poll_once().unwrap(),
"unchanged files must not reload"
);
// Rotate the server identity to a brand-new leaf, written into a SEPARATE
// subdir so its filenames do not collide with the watched paths, then
// overwrite the watched cert/key with the rotated content (the k8s
// secret-swap shape: same path, new bytes).
let rotated_dir = dir.path().join("rotated");
std::fs::create_dir_all(&rotated_dir).unwrap();
let rotated = write_test_certs(&rotated_dir);
std::fs::copy(&rotated.server_cert, &tls.server_cert).unwrap();
std::fs::copy(&rotated.server_key, &tls.server_key).unwrap();
assert!(
reloader.poll_once().unwrap(),
"a content change must trigger a reload"
);
assert!(
!reloader.poll_once().unwrap(),
"a second poll with no further change must not reload again"
);
}
#[test]
fn missing_cert_file_is_a_typed_error_not_a_panic() {
let dir = tempfile::tempdir().unwrap();
let err = load_certified_key(
&dir.path().join("absent.pem"),
&dir.path().join("absent-key.pem"),
)
.expect_err("absent cert must be a typed error");
assert!(matches!(err, GrpcTransportError::TlsConfig(_)));
}
}