//! 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, /// The cert resolver, swapped on rotation so new handshakes use the new cert /// (in-flight TLS sessions are unaffected — zero drop). pub resolver: Arc, /// 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 { 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, 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) -> Result { 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; 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 { Ok(self.local_addr) } }