feat(serve_tls): NodeLayAcceptor + serve_tls_with_nodelay (deploy-C §5.4)
axum_server 0.8's Server builder exposes no tcp_nodelay method (verified in source: src/axum_server/server.rs:32-360). Plan A made TCP_NODELAY unconditional on the plaintext path; this slice extends parity to the TLS path via a custom Accept<TcpStream, S> impl that calls set_nodelay(true) BEFORE the inner RustlsAcceptor runs the TLS handshake. TLS does not undo the option (kernel persists it on the fd across the bytes the TLS layer writes); axum-server's own examples/rustls_session.rs documents this wrapping pattern for a different reason. serve_tls_with_nodelay is the production TLS serve path: axum_server::bind + NodeLayAcceptor + Handle::graceful_shutdown. main.rs switches to it when RUTSTER_TLS_CERT + RUTSTER_TLS_KEY are both set (Task 4). Hot-reload via RustlsConfig::reload_from_pem_file lands in Task 4's e2e reload test. Signed-off-by: Aaron D. Lee <himself@adlee.work>
This commit is contained in:
105
Cargo.lock
generated
105
Cargo.lock
generated
@@ -32,6 +32,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
@@ -205,6 +214,28 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-server"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bytes",
|
||||
"either",
|
||||
"fs-err",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
@@ -515,6 +546,12 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -549,6 +586,12 @@ version = "0.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -558,6 +601,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-err"
|
||||
version = "3.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
@@ -665,6 +718,25 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
@@ -732,6 +804,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -1070,6 +1143,16 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
@@ -1300,6 +1383,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"pem",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"time",
|
||||
"x509-parser",
|
||||
@@ -1418,6 +1503,7 @@ version = "0.23.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -1442,6 +1528,7 @@ version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted 0.9.0",
|
||||
@@ -1459,9 +1546,12 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"axum-server",
|
||||
"dashmap",
|
||||
"futures-util",
|
||||
"ipnet",
|
||||
"rcgen",
|
||||
"rustls",
|
||||
"rutster-brain-realtime",
|
||||
"rutster-call-model",
|
||||
"rutster-media",
|
||||
@@ -1472,6 +1562,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-tungstenite",
|
||||
"tower",
|
||||
"tracing",
|
||||
@@ -2029,6 +2120,19 @@ dependencies = [
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
@@ -2529,6 +2633,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"nom 7.1.3",
|
||||
"oid-registry",
|
||||
"ring",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
|
||||
@@ -80,6 +80,15 @@ ipnet = "2"
|
||||
# at 0.23.41 / 0.26.4 — verified). The tls-rustls feature enables
|
||||
# axum_server::tls_rustls::{RustlsConfig, RustlsAcceptor}. MIT license — cargo-deny-clean.
|
||||
axum-server = { version = "0.8", features = ["tls-rustls"] }
|
||||
# rustls 0.23: TLS implementation consumed transitively by axum-server's tls-rustls
|
||||
# feature. Already in Cargo.lock at 0.23.41 via str0m → aws-lc-rs; this surfaces the
|
||||
# direct edge for the integration test's ClientConfig. Apache-2.0 / MIT / ISC,
|
||||
# cargo-deny clean.
|
||||
rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs", "ring"] }
|
||||
# tokio-rustls 0.26: tokio integration for rustls. Already in Cargo.lock at 0.26.4
|
||||
# (transitive once Task 1's Cargo.lock resolves). Used by the serve_tls integration
|
||||
# test's TLS client handshake.
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["aws-lc-rs", "ring"] }
|
||||
# rcgen NOTE: rcgen 0.14.8 bumped MSRV to Rust 1.88, breaking the 1.85
|
||||
# toolchain matrix. rcgen is a transitive dep via dimpl → str0m 0.21 →
|
||||
# rutster-media. Cargo.lock pins it to 0.14.7 (the minimum satisfying
|
||||
|
||||
@@ -19,6 +19,10 @@ rutster-tap = { path = "../rutster-tap" }
|
||||
# re-exported through the workspace root / never sent over the tap WS protocol.
|
||||
rutster-trunk = { path = "../rutster-trunk" }
|
||||
axum = { workspace = true }
|
||||
# axum-server 0.8 (workspace-pinned): in-process TLS listener for
|
||||
# rustls Phase 1 (deploy spec §5.4). Used by serve_tls::serve_tls_with_nodelay
|
||||
# + tls_acceptor::NodeLayAcceptor.
|
||||
axum-server = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
@@ -36,6 +40,14 @@ ipnet = { workspace = true }
|
||||
[dev-dependencies]
|
||||
tower = { workspace = true }
|
||||
rutster-tap-echo = { path = "../rutster-tap-echo" }
|
||||
# Self-signed cert generation for the serve_tls_nodelay integration test.
|
||||
# Pinned to 0.14 to match the existing Cargo.lock transitive pin (rcgen
|
||||
# 0.14.8 bumped MSRV to 1.88 — the workspace notes this in Cargo.toml's
|
||||
# root comments).
|
||||
rcgen = "0.14"
|
||||
# rustls/tokio-rustls for the test client's TLS handshake + cert inspection.
|
||||
rustls = { workspace = true }
|
||||
tokio-rustls = { workspace = true }
|
||||
# slice-3 §7.4: integration test spins up the in-process MockRealtimeBrain
|
||||
# + drives the brain's `run_openai_pump` directly (replicating the brain
|
||||
# process's accept loop in the test, no subprocess). The `mock` feature
|
||||
|
||||
@@ -27,6 +27,8 @@ pub mod event_sink;
|
||||
pub mod media_thread;
|
||||
pub mod routes;
|
||||
pub mod serve;
|
||||
pub mod serve_tls;
|
||||
pub mod session_map;
|
||||
pub mod tap_engine;
|
||||
pub mod tls_acceptor;
|
||||
pub mod tool_registry;
|
||||
|
||||
82
crates/rutster/src/serve_tls.rs
Normal file
82
crates/rutster/src/serve_tls.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
//! # serve_tls — production in-process TLS listener (deploy spec §5.4)
|
||||
//!
|
||||
//! rustls Phase 1: BYO-cert via `RUTSTER_TLS_CERT` / `RUTSTER_TLS_KEY`
|
||||
//! (paths to PEM files). When BOTH are set, `main.rs` switches from
|
||||
//! `rutster::serve::serve_with_nodelay` (plan A's plaintext path) to
|
||||
//! this helper. Hot-reload: `RustlsConfig::reload_from_pem_file` is
|
||||
//! called by the operator's SIGHUP signal (Task 4); a `notify`-crate
|
||||
//! file-watcher is explicitly deferred (would add a 3rd new dep to
|
||||
//! this slice — out of scope). `RustlsConfig::reload_from_pem_file` is
|
||||
//! verified atomic (arc-swap) so live WS connections are NEVER dropped
|
||||
//! during reload.
|
||||
//!
|
||||
//! ## Why a TLS path at all when Caddy is the default edge?
|
||||
//!
|
||||
//! The shipped artifact default IS Caddy (deploy spec §3.2). The TLS
|
||||
//! path exists for two operators: the VPS-forwarder topology where the
|
||||
//! engine terminates TLS at home (deploy spec §3.5 Tier 3 — the
|
||||
//! forwarder is a dumb TCP relay and cannot terminate TLS), and the
|
||||
//! operator who already runs nginx/HAProxy. Both have the same
|
||||
//! requirement: the engine MUST terminate TLS itself, OR it must accept
|
||||
//! plaintext behind a TLS-terminating edge. See the full topology
|
||||
//! decision brief at
|
||||
//! docs/superpowers/specs/2026-07-05-tls-edge-decision-brief.md.
|
||||
//!
|
||||
//! ## Why axum-server and not axum-0.8-native TLS?
|
||||
//!
|
||||
//! Axum 0.7.9 (locked) does not ship a TLS listener. The successor
|
||||
//! axum 0.8 has `axum::serve(...).tls_config(...)` but bumping axum
|
||||
//! 0.7.9 → 0.8 is a workspace-wide migration across `axum::extract::*`,
|
||||
//! `axum::routing::*`, and `axum::Server` — out of scope for this
|
||||
//! deploy slice. `axum-server 0.8` composes with `axum::Router` (its
|
||||
//! `serve` takes `app.into_make_service()`) without forcing an axum
|
||||
//! version bump. This keeps the slice contained.
|
||||
//!
|
||||
//! ## External references
|
||||
//!
|
||||
//! - axum-server `Handle`: <https://docs.rs/axum-server/0.8.0/axum_server/handle/struct.Handle.html>
|
||||
//! - axum-server `RustlsConfig`: <https://docs.rs/axum-server/0.8.0/axum_server/tls_rustls/struct.RustlsConfig.html>
|
||||
//! - axum-server rustls example: <https://github.com/programatik29/axum-server/blob/v0.8.0/examples/rustls_session.rs>
|
||||
|
||||
use std::future::Future;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Router;
|
||||
use axum_server::Handle;
|
||||
use axum_server::tls_rustls::{RustlsAcceptor, RustlsConfig};
|
||||
|
||||
use crate::tls_acceptor::NodeLayAcceptor;
|
||||
|
||||
/// Serve `app` bound to `addr` with rustls TLS using `rustls_config`,
|
||||
/// TCP_NODELAY on every accepted socket (via `NodeLayAcceptor`), and
|
||||
/// graceful shutdown via the `shutdown` future (driving
|
||||
/// `Handle::graceful_shutdown` with a 60 s cap).
|
||||
pub async fn serve_tls_with_nodelay<F>(
|
||||
addr: SocketAddr,
|
||||
rustls_config: RustlsConfig,
|
||||
app: Router,
|
||||
shutdown: F,
|
||||
) -> std::io::Result<()>
|
||||
where
|
||||
F: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
let handle = Handle::new();
|
||||
let handle_for_shutdown = handle.clone();
|
||||
tokio::spawn(async move {
|
||||
shutdown.await;
|
||||
// Same drain posture as plan A's `with_graceful_shutdown` — give
|
||||
// in-flight calls up to 60 s to finish (well above any one
|
||||
// webhook's 15 s budget and below the production drain deadline).
|
||||
// The hard cap matches the hot-path "drop + observe" policy:
|
||||
// graceful is best-effort, the hard stop is the floor.
|
||||
handle_for_shutdown.graceful_shutdown(Some(Duration::from_secs(60)));
|
||||
});
|
||||
|
||||
let acceptor = NodeLayAcceptor::new(RustlsAcceptor::new(rustls_config));
|
||||
axum_server::bind(addr)
|
||||
.acceptor(acceptor)
|
||||
.handle(handle)
|
||||
.serve(app.into_make_service())
|
||||
.await
|
||||
}
|
||||
76
crates/rutster/src/tls_acceptor.rs
Normal file
76
crates/rutster/src/tls_acceptor.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! # tls_acceptor — TCP_NODELAY parity for the TLS accept path
|
||||
//! (deploy slice C §5.4)
|
||||
//!
|
||||
//! `axum_server::bind_rustls(addr, config)` exposes NO `tcp_nodelay`
|
||||
//! setting — neither on the `Server` builder nor on `bind_rustls`
|
||||
//! (verified in `axum-server 0.8/src/axum_server/server.rs:32-360` and
|
||||
//! documented at <https://docs.rs/axum-server/0.8.0/axum_server/>).
|
||||
//! Plan A made TCP_NODELAY unconditional on the plaintext listener
|
||||
//! (`axum::serve(...).tcp_nodelay(true)`); the same option must apply
|
||||
//! to the TLS listen path, or a VPS-forwarder topology serving WSS
|
||||
//! directly to Twilio would re-introduce the Nagle + delayed-ACK stalls
|
||||
//! that the plaintext NODELAY closed (axum #2521 / #1961).
|
||||
//!
|
||||
//! The canonical pattern is a custom `Accept` impl — axum-server's own
|
||||
//! `examples/rustls_session.rs` documents this wrapping shape, although
|
||||
//! for session resumption rather than NODELAY. `Accept<TcpStream, S>`
|
||||
//! runs BEFORE the TLS handshake, so that is the moment to call
|
||||
//! `stream.set_nodelay(true)`. The wrapper delegates everything else to
|
||||
//! the inner `RustlsAcceptor`.
|
||||
|
||||
use axum_server::accept::{Accept, DefaultAcceptor};
|
||||
use axum_server::tls_rustls::RustlsAcceptor;
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
/// `Accept<TcpStream, S>` wrapper over `RustlsAcceptor` that sets
|
||||
/// `TCP_NODELAY=true` on every accepted socket before the TLS handshake.
|
||||
///
|
||||
/// The generic parameter `A` is the *inner* acceptor wrapped by
|
||||
/// `RustlsAcceptor`. `RustlsAcceptor<A = DefaultAcceptor>` is the
|
||||
/// concrete type produced by `RustlsAcceptor::new(config)`; keeping the
|
||||
/// generic here matches axum-server's API and makes the newtype usable
|
||||
/// with any inner acceptor if we later need to stack another layer.
|
||||
///
|
||||
/// `Clone` is required by `axum_server::Server::serve`'s bounds
|
||||
/// (`Acc: Accept<...> + Clone + Send + Sync + 'static`).
|
||||
/// `RustlsAcceptor` is `Clone` (wraps an `Arc` internally), so this
|
||||
/// newtype derives `Clone` for free.
|
||||
#[derive(Clone)]
|
||||
pub struct NodeLayAcceptor<A = DefaultAcceptor>(RustlsAcceptor<A>);
|
||||
|
||||
impl<A> NodeLayAcceptor<A> {
|
||||
/// Wrap a `RustlsAcceptor`. Construct via
|
||||
/// `RustlsAcceptor::new(rustls_config)` then `NodeLayAcceptor::new(inner)`.
|
||||
pub fn new(inner: RustlsAcceptor<A>) -> Self {
|
||||
Self(inner)
|
||||
}
|
||||
}
|
||||
|
||||
/// `Accept` for the `TcpStream`-bound server path (the only path we
|
||||
/// register this acceptor on — `axum_server::bind(SocketAddr)` whose
|
||||
/// `Address::Stream = TcpStream`).
|
||||
///
|
||||
/// The `set_nodelay` call happens here (before the inner
|
||||
/// `RustlsAcceptor::accept` runs the TLS handshake). TLS does not undo
|
||||
/// the option: the kernel persists `TCP_NODELAY` on the file descriptor
|
||||
/// across the bytes the TLS layer writes.
|
||||
impl<A, S> Accept<TcpStream, S> for NodeLayAcceptor<A>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
RustlsAcceptor<A>: Accept<TcpStream, S>,
|
||||
{
|
||||
type Stream = <RustlsAcceptor<A> as Accept<TcpStream, S>>::Stream;
|
||||
type Service = <RustlsAcceptor<A> as Accept<TcpStream, S>>::Service;
|
||||
type Future = <RustlsAcceptor<A> as Accept<TcpStream, S>>::Future;
|
||||
|
||||
fn accept(&self, stream: TcpStream, service: S) -> Self::Future {
|
||||
// Parity with plan A's serve_with_nodelay (deploy spec §5.1). Best
|
||||
// effort: if the option fails (rare — the kernel never rejects it),
|
||||
// log + continue. Do NOT short-circuit the TLS handshake for a
|
||||
// NODELAY failure; it's a latency hint, not a security invariant.
|
||||
if let Err(e) = stream.set_nodelay(true) {
|
||||
tracing::warn!(error = %e, "set_nodelay on TLS accept failed; continuing");
|
||||
}
|
||||
self.0.accept(stream, service)
|
||||
}
|
||||
}
|
||||
186
crates/rutster/tests/serve_tls_nodelay.rs
Normal file
186
crates/rutster/tests/serve_tls_nodelay.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
//! Integration test for `rutster::serve_tls::serve_tls_with_nodelay`
|
||||
//! (deploy slice C §5.4): the production TLS serve path binds, accepts a TLS
|
||||
//! handshake (using a self-signed cert generated at test time), serves an
|
||||
//! HTTP request, and stays up.
|
||||
//!
|
||||
//! TCP_NODELAY parity is implicit: `NodeLayAcceptor` sets it on every accepted
|
||||
//! socket before the TLS handshake. A regression would surface instead in plan
|
||||
//! A's sim-bench NODELAY assertion; this test only proves the TLS path is
|
||||
//! functional.
|
||||
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||
use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
/// A `ServerCertVerifier` that accepts any cert — test-only, for connecting
|
||||
/// to a server presenting a self-signed cert outside the public trust store.
|
||||
/// Do NOT use in production code paths (it would defeat TLS authentication).
|
||||
#[derive(Debug)]
|
||||
struct InsecureVerifier;
|
||||
|
||||
impl ServerCertVerifier for InsecureVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<ServerCertVerified, rustls::Error> {
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
vec![
|
||||
SignatureScheme::RSA_PKCS1_SHA256,
|
||||
SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
SignatureScheme::ED25519,
|
||||
SignatureScheme::RSA_PSS_SHA256,
|
||||
SignatureScheme::RSA_PKCS1_SHA384,
|
||||
SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||
SignatureScheme::RSA_PSS_SHA384,
|
||||
SignatureScheme::RSA_PKCS1_SHA512,
|
||||
SignatureScheme::RSA_PSS_SHA512,
|
||||
SignatureScheme::ECDSA_NISTP521_SHA512,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
fn client_config() -> Arc<ClientConfig> {
|
||||
Arc::new(
|
||||
ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(InsecureVerifier))
|
||||
.with_no_client_auth(),
|
||||
)
|
||||
}
|
||||
|
||||
fn write_self_signed_cert(
|
||||
cert_path: &std::path::Path,
|
||||
key_path: &std::path::Path,
|
||||
common_name: &str,
|
||||
) {
|
||||
let rcgen::CertifiedKey { cert, signing_key } =
|
||||
rcgen::generate_simple_self_signed(vec![common_name.to_string()]).unwrap();
|
||||
std::fs::write(cert_path, cert.pem()).unwrap();
|
||||
std::fs::write(key_path, signing_key.serialize_pem()).unwrap();
|
||||
}
|
||||
|
||||
/// Pick a currently-free IPv4 loopback address.
|
||||
///
|
||||
/// We bind a throw-away listener, read the ephemeral port, and drop it so the
|
||||
/// server can bind the same address. The race window is tiny and acceptable
|
||||
/// for a single-threaded test; a failure would surface as a bind error from
|
||||
/// the server task.
|
||||
async fn free_ipv4_addr() -> io::Result<std::net::SocketAddr> {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
|
||||
let addr = listener.local_addr()?;
|
||||
drop(listener);
|
||||
Ok(addr)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn serve_tls_binds_and_serves_healthz() {
|
||||
// rustls is compiled with both `aws-lc-rs` and `ring` features, so there
|
||||
// is no single automatic process-level CryptoProvider. Install one up
|
||||
// front; both the test client and axum-server's RustlsConfig need it.
|
||||
rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider())
|
||||
.expect("install rustls crypto provider");
|
||||
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"rutster-serve-tls-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let cert_path = dir.join("cert.pem");
|
||||
let key_path = dir.join("key.pem");
|
||||
write_self_signed_cert(&cert_path, &key_path, "round-trip-test");
|
||||
|
||||
let config = RustlsConfig::from_pem_file(&cert_path, &key_path)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let app = rutster::routes::router(rutster::session_map::AppState::default());
|
||||
let addr = free_ipv4_addr().await.unwrap();
|
||||
|
||||
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let mut server = tokio::spawn(rutster::serve_tls::serve_tls_with_nodelay(
|
||||
addr,
|
||||
config,
|
||||
app,
|
||||
async {
|
||||
let _ = stop_rx.await;
|
||||
},
|
||||
));
|
||||
|
||||
// Give the server a moment to bind + enter the accept loop.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Poll the server task: it must NOT have exited (a bind failure would
|
||||
// surface as Ok(()) or Err(...)) — Pending means it's running.
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(50), &mut server)
|
||||
.await
|
||||
.is_err(),
|
||||
"server task unexpectedly exited (bind failure?)"
|
||||
);
|
||||
|
||||
// Dial with a tokio-rustls client using the test-only verifier.
|
||||
let stream = TcpStream::connect(addr).await.unwrap();
|
||||
let connector = tokio_rustls::TlsConnector::from(client_config());
|
||||
let server_name = ServerName::try_from("round-trip-test").unwrap();
|
||||
let mut tls = connector.connect(server_name, stream).await.unwrap();
|
||||
|
||||
tls.write_all(b"GET /healthz HTTP/1.1\r\nHost: round-trip-test\r\nConnection: close\r\n\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = tls.read(&mut buf).await.unwrap();
|
||||
let response = String::from_utf8_lossy(&buf[..n]);
|
||||
assert!(
|
||||
response.contains("200 OK"),
|
||||
"expected 200 OK from /healthz, got: {response}"
|
||||
);
|
||||
|
||||
// The server should still be running after the request.
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(50), &mut server)
|
||||
.await
|
||||
.is_err(),
|
||||
"server task exited after serving the request"
|
||||
);
|
||||
|
||||
let _ = stop_tx.send(());
|
||||
server.abort();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
Reference in New Issue
Block a user