tidaldb/tidal-server/src/cluster/http_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

178 lines
7.9 KiB
Rust

//! m11p7 inter-node HTTP TLS: serve the axum surface over a `tokio-rustls`
//! acceptor reusing tidal-net's hot-swappable cert resolver, so ONE cert
//! rotation covers both the gRPC replication plane and the HTTP gateway plane.
//!
//! Server-authenticated TLS only (no client-cert requirement): the same listener
//! also serves external bearer clients, so per-node identity on this plane is the
//! signed internal token ([`super::security`]), not a client cert. The encryption
//! this provides is what closes the exit gate's "zero plaintext inter-node links"
//! — forwards/broadcasts/seed-join dial `https://` with the cluster CA.
//!
//! [`TlsListener`] implements axum 0.8's [`axum::serve::Listener`] so the existing
//! `axum::serve(...).with_graceful_shutdown(...)` flow — and its deterministic
//! post-serve `Arc` reclaim — is preserved unchanged; only the listener type
//! differs. Handshakes run off the accept path (one task each) so a slow or
//! foreign handshake never head-of-line-blocks the next connection; a failed
//! handshake is dropped before axum ever sees the connection.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tidal_net::{DynamicCertResolver, TlsConfig};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Semaphore;
use tokio_rustls::TlsAcceptor;
use tokio_rustls::rustls::ServerConfig;
use tokio_rustls::server::TlsStream;
use crate::error::{Result, ServerError};
/// Maximum wall-clock time a single inbound TLS handshake may take before it is
/// abandoned. The TCP accept precedes any auth, so a peer that stalls the
/// `ClientHello` (slowloris) would otherwise pin a task + socket indefinitely.
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
/// Maximum inbound TLS handshakes allowed in flight at once. Bounds the task/fd
/// cost of a connection flood against the HTTP control plane: excess connections
/// are load-shed before their handshake is attempted.
const MAX_CONCURRENT_HANDSHAKES: usize = 256;
/// The TLS material for the inter-node HTTP listener: the rustls server config
/// to serve with, plus the hot-swappable resolver + cert file paths the rotation
/// poller re-reads.
pub struct HttpTls {
/// The rustls server config (server-auth, ALPN h2 + http/1.1) handed to the
/// [`TlsListener`]'s acceptor.
pub server_config: Arc<ServerConfig>,
/// The cert resolver, swapped on rotation so new handshakes use the new cert
/// (in-flight TLS sessions are unaffected — zero drop).
pub resolver: Arc<DynamicCertResolver>,
/// The cert file paths the rotation poller watches.
pub files: TlsConfig,
}
impl HttpTls {
/// Build the HTTP TLS material from the cluster's TLS config (the same
/// `grpc_tls` cert files the replication transport uses).
///
/// # Errors
///
/// Returns [`ServerError::Cluster`] if the initial server cert/key cannot be
/// loaded into a valid identity.
pub fn from_tls_config(files: TlsConfig) -> Result<Self> {
let initial = tidal_net::load_certified_key(&files.server_cert, &files.server_key)
.map_err(|e| ServerError::Cluster(format!("load HTTP server cert: {e}")))?;
let resolver = Arc::new(DynamicCertResolver::new(initial));
let server_config = tidal_net::build_http_server_config(Arc::clone(&resolver));
Ok(Self {
server_config,
resolver,
files,
})
}
}
/// An axum [`Listener`](axum::serve::Listener) that yields TLS streams.
///
/// A background task accepts TCP connections, runs each TLS handshake in its own
/// task, and forwards ONLY successfully-handshaken streams over a bounded channel
/// — so a foreign/slow handshake is dropped before axum and never blocks the
/// accept loop. `accept` pulls the next ready TLS stream from that channel.
pub struct TlsListener {
rx: tokio::sync::mpsc::Receiver<(TlsStream<TcpStream>, SocketAddr)>,
local_addr: SocketAddr,
}
impl TlsListener {
/// Bind `addr` and start the accept+handshake background task.
///
/// # Errors
///
/// Returns the bind [`std::io::Error`] if the address cannot be bound.
pub async fn bind(addr: SocketAddr, config: Arc<ServerConfig>) -> Result<Self> {
let listener = TcpListener::bind(addr)
.await
.map_err(ServerError::Network)?;
let local_addr = listener.local_addr().map_err(ServerError::Network)?;
let acceptor = TlsAcceptor::from(config);
let (tx, rx) = tokio::sync::mpsc::channel(128);
// Bound in-flight handshakes so a connection flood against the (pre-auth)
// accept path cannot spawn unbounded tasks / exhaust fds.
let handshake_limiter = Arc::new(Semaphore::new(MAX_CONCURRENT_HANDSHAKES));
tokio::spawn(async move {
loop {
let (tcp, peer) = match listener.accept().await {
Ok(pair) => pair,
Err(e) => {
// A transient accept error (fd pressure): brief backoff so
// we never hot-loop, then keep accepting.
tracing::debug!(error = %e, "HTTP TLS accept error; continuing");
tokio::time::sleep(Duration::from_millis(50)).await;
continue;
}
};
// Load-shed under a handshake flood rather than queue an unbounded
// task behind a saturated limiter.
let Ok(permit) = Arc::clone(&handshake_limiter).try_acquire_owned() else {
tracing::debug!(%peer, "HTTP TLS handshake limiter saturated; dropping connection");
drop(tcp);
continue;
};
let acceptor = acceptor.clone();
let tx = tx.clone();
tokio::spawn(async move {
// Held for the handshake; released the instant this task ends.
let _permit = permit;
match tokio::time::timeout(HANDSHAKE_TIMEOUT, acceptor.accept(tcp)).await {
Ok(Ok(stream)) => {
// Send failure = the listener was dropped (server
// draining); drop the connection.
let _ = tx.send((stream, peer)).await;
}
Ok(Err(e)) => {
// A foreign client with no/invalid cert chain, or a
// plaintext probe against the TLS port: rejected at the
// handshake, never reaches axum.
tracing::debug!(%peer, error = %e, "HTTP TLS handshake rejected");
}
Err(_elapsed) => {
// A peer that connected but stalled the ClientHello.
tracing::debug!(%peer, "HTTP TLS handshake timed out; dropping connection");
}
}
});
}
});
Ok(Self { rx, local_addr })
}
/// The bound local address (inherent, infallible — the address is captured at
/// bind). Shadows the trait's `local_addr` for direct call sites that want the
/// address without the `io::Result` wrapper.
#[must_use]
pub const fn local_addr(&self) -> SocketAddr {
self.local_addr
}
}
impl axum::serve::Listener for TlsListener {
type Io = TlsStream<TcpStream>;
type Addr = SocketAddr;
async fn accept(&mut self) -> (Self::Io, Self::Addr) {
loop {
match self.rx.recv().await {
Some(pair) => return pair,
// The accept task ended (only on runtime teardown); park so axum's
// accept loop quiesces rather than spinning on a closed channel.
None => std::future::pending().await,
}
}
}
fn local_addr(&self) -> std::io::Result<Self::Addr> {
Ok(self.local_addr)
}
}