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.
178 lines
7.9 KiB
Rust
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)
|
|
}
|
|
}
|