From 08aac89202e1e5246253163b00984732f38a2dd0 Mon Sep 17 00:00:00 2001 From: marcorivm Date: Thu, 30 Jul 2026 15:44:05 -0600 Subject: [PATCH] feat(gateway): onecli-gateway relay mode (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `relay` subcommand to the gateway binary: a plain HTTP-proxy → mTLS byte-tunnel that lets local agents reach a REMOTE gateway. Agents point HTTPS_PROXY at the relay (they can't reliably present a client cert to an HTTPS proxy); the relay holds the client cert and carries their traffic to the gateway's mTLS listener. The relay is a BLIND BYTE-SPLICE: it accepts the agent's CONNECT / absolute-form request, opens one mTLS connection to the remote gateway presenting its client cert, and copy_bidirectionals bytes. It does NOT parse or rewrite the agent's CONNECT line or Proxy-Authorization, so the agent's aoc_ token reaches the gateway verbatim and the gateway authenticates + MITMs exactly as today. Both identities arrive together: the relay's host cert (mTLS handshake) and the agent's token (header). - New relay.rs + relay/{enroll,renew,tunnel}.rs. No MITM, DB, crypto, or policy in the relay — it only reuses client_ca PEM loaders + shutdown. - Enrollment: the relay generates its own keypair + CSR (private key never leaves it), enrolls via Phase 2 POST /v1/gateway/client-cert with an oc_ API key, and renews before expiry (ArcSwap hot-swap, reusing the stored hostId). - SECURITY: always verifies the remote server cert via out-of-band --gateway-server-ca (never an accept-any verifier); fails closed on every error path (dial/handshake/timeout/expired-cert/unreachable enrollment → 502 + close, never direct egress or plaintext). Private key file written O_CREAT 0600 atomically, hard-error on failure. - clap restructured so bare `onecli-gateway` / `--port N` still runs the SERVER unchanged; `relay` is an added subcommand. Stacked on Phase 2 (cert issuance). 646 tests (28 relay), clippy --all-targets -D warnings + fmt clean. cargo build --features cloud unchanged (27 pre-existing ee/*.rs errors, none from this change). Claude-Session: https://claude.ai/code/session_01BgJuqEJqf7ZiUdqHWxi6bt --- apps/gateway/Cargo.lock | 1 + apps/gateway/Cargo.toml | 11 +- apps/gateway/src/client_ca.rs | 2 +- apps/gateway/src/main.rs | 124 ++++++++ apps/gateway/src/relay.rs | 527 +++++++++++++++++++++++++++++++ apps/gateway/src/relay/enroll.rs | 392 +++++++++++++++++++++++ apps/gateway/src/relay/renew.rs | 401 +++++++++++++++++++++++ apps/gateway/src/relay/tunnel.rs | 522 ++++++++++++++++++++++++++++++ 8 files changed, 1977 insertions(+), 3 deletions(-) create mode 100644 apps/gateway/src/relay.rs create mode 100644 apps/gateway/src/relay/enroll.rs create mode 100644 apps/gateway/src/relay/renew.rs create mode 100644 apps/gateway/src/relay/tunnel.rs diff --git a/apps/gateway/Cargo.lock b/apps/gateway/Cargo.lock index 7f528f49..052747ae 100644 --- a/apps/gateway/Cargo.lock +++ b/apps/gateway/Cargo.lock @@ -2415,6 +2415,7 @@ dependencies = [ "ap-noise", "ap-proxy-client", "ap-proxy-protocol", + "arc-swap", "async-trait", "aws-config", "aws-credential-types", diff --git a/apps/gateway/Cargo.toml b/apps/gateway/Cargo.toml index 4e59f9f1..def10715 100644 --- a/apps/gateway/Cargo.toml +++ b/apps/gateway/Cargo.toml @@ -43,8 +43,10 @@ ring = "0.17" # HTTP client (for upstream forwarding in MITM mode) reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"], default-features = false } -# CLI -clap = { version = "4", features = ["derive"] } +# CLI. `env` (relay mode): every relay CLI flag falls back to an environment +# variable so it can run unattended in a container with no generated command +# line. +clap = { version = "4", features = ["derive", "env"] } # Logging tracing = "0.1" @@ -70,6 +72,11 @@ base64 = "0.22" # UUID (for approval IDs) uuid = { version = "1", features = ["v4"] } +# Lock-free config swap (relay mode): the mTLS `ClientConfig` is rebuilt on +# certificate renewal and swapped in without disrupting in-flight tunnels, +# each of which holds its own `Arc` snapshot taken at dial time. +arc-swap = "1" + # Stream combinators futures-util = "0.3" diff --git a/apps/gateway/src/client_ca.rs b/apps/gateway/src/client_ca.rs index fccccedd..447c5243 100644 --- a/apps/gateway/src/client_ca.rs +++ b/apps/gateway/src/client_ca.rs @@ -131,7 +131,7 @@ fn sanitize_identity_component(s: &str) -> Option { /// from Secrets Manager); anything else is treated as a filesystem path (OSS /// mounts files). Empty or unset input is `Ok(None)` — the caller decides /// whether that's fatal. -fn pem_from_value(var_name: &str, value: &str) -> Result> { +pub(crate) fn pem_from_value(var_name: &str, value: &str) -> Result> { let trimmed = value.trim(); if trimmed.is_empty() { return Ok(None); diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index 1499a0e2..747d5af5 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -69,6 +69,7 @@ mod edition; mod gateway; mod inject; mod policy; +mod relay; mod secret_inject; mod shutdown; mod summary; @@ -162,6 +163,29 @@ struct Cli { /// Data directory for CA certificates and persistent state. #[arg(long, default_value = default_data_dir())] data_dir: PathBuf, + + /// Optional subcommand. With none given, `onecli-gateway` (optionally + /// with `--port`/`--data-dir`) parses exactly as it always has and runs + /// the MITM gateway server — see the module doc on `Command` below for + /// why that byte-for-byte compatibility matters. + #[command(subcommand)] + command: Option, +} + +/// Subcommands layered onto the historically flag-only `onecli-gateway` CLI. +/// +/// `command` on [`Cli`] is `Option`, not `Command`, specifically so +/// that omitting it entirely — the only way this binary has ever been +/// invoked before this change — continues to select the server, not a clap +/// error demanding a subcommand. `main` dispatches on it before any of the +/// server's own CA/DB/crypto/vault bootstrapping runs, so `relay` never pays +/// for (or requires) any of that. +#[derive(clap::Subcommand, Debug)] +enum Command { + /// Run a local mTLS relay: a blind byte-splice between a plain + /// HTTP-proxy agent and a remote OneCLI gateway. See `relay.rs`'s module + /// doc for the security property this preserves. + Relay(relay::RelayArgs), } /// Cap on the final telemetry flush, inside the overall shutdown budget. @@ -212,6 +236,15 @@ async fn main() -> Result<()> { // first poll. shutdown::install(); + // Relay mode is an entirely separate program sharing only the process's + // signal handling and rustls crypto provider install above: no CA, no + // database, no crypto service, no vault. Dispatched here, before any of + // that server-only bootstrapping below runs, so it never pays for (or + // requires) any of it. + if let Some(Command::Relay(args)) = cli.command { + return relay::run(args).await; + } + // Expand ~ in data dir let data_dir = expand_tilde(&cli.data_dir); @@ -393,3 +426,94 @@ fn expand_tilde(path: &Path) -> PathBuf { } path.to_path_buf() } + +#[cfg(test)] +mod tests { + use super::*; + + /// The default invocation — no subcommand at all — must keep parsing to + /// the server arm exactly as it did before `Command` existed. This is + /// the regression guard for every existing deployment's `onecli-gateway` + /// (with no args) or `onecli-gateway --port N` invocation. + #[test] + fn no_subcommand_parses_to_the_server_arm() { + let cli = Cli::try_parse_from(["onecli-gateway"]).expect("parses with no args"); + assert_eq!(cli.port, 10255); + assert!(cli.command.is_none()); + } + + #[test] + fn port_flag_alone_still_parses_to_the_server_arm() { + let cli = Cli::try_parse_from(["onecli-gateway", "--port", "9999"]).expect("parses --port"); + assert_eq!(cli.port, 9999); + assert!(cli.command.is_none()); + } + + #[test] + fn data_dir_flag_alone_still_parses_to_the_server_arm() { + let cli = Cli::try_parse_from(["onecli-gateway", "--data-dir", "/tmp/onecli-data"]) + .expect("parses --data-dir"); + assert_eq!(cli.data_dir, PathBuf::from("/tmp/onecli-data")); + assert!(cli.command.is_none()); + } + + /// The new `relay` subcommand parses into `Command::Relay` with its + /// required fields populated — proving the subcommand addition didn't + /// break argument routing in either direction. + #[test] + fn relay_subcommand_parses_its_required_args() { + let cli = Cli::try_parse_from([ + "onecli-gateway", + "relay", + "--gateway-addr", + "gateway.example.com:8443", + "--gateway-server-ca", + "/etc/onecli/server-ca.pem", + "--api-url", + "https://api.example.com", + "--api-key", + "oc_test_key", + ]) + .expect("parses relay subcommand"); + + match cli.command { + Some(Command::Relay(args)) => { + assert_eq!(args.gateway_addr, "gateway.example.com:8443"); + assert_eq!(args.api_url, "https://api.example.com"); + assert_eq!(args.api_key, "oc_test_key"); + assert_eq!( + args.bind, + "127.0.0.1:10255".parse::().unwrap() + ); + } + None => panic!("expected Command::Relay"), + } + } + + /// Missing a required relay flag (with its env var also unset) must + /// fail to parse rather than silently default — `--gateway-addr` has no + /// default and nothing here sets `RELAY_GATEWAY_ADDR`. + #[test] + fn relay_subcommand_requires_gateway_addr() { + // Isolated from whatever the test process's real environment holds: + // if `RELAY_GATEWAY_ADDR` happened to be set, this would spuriously + // pass. There is no portable safe env-mutation in a parallel test + // binary, so this only asserts what it can control directly: the + // flag form is absent, and if the env var were also absent this + // must fail. + if std::env::var("RELAY_GATEWAY_ADDR").is_ok() { + return; + } + let result = Cli::try_parse_from([ + "onecli-gateway", + "relay", + "--gateway-server-ca", + "/etc/onecli/server-ca.pem", + "--api-url", + "https://api.example.com", + "--api-key", + "oc_test_key", + ]); + assert!(result.is_err(), "must require --gateway-addr"); + } +} diff --git a/apps/gateway/src/relay.rs b/apps/gateway/src/relay.rs new file mode 100644 index 00000000..93ee7461 --- /dev/null +++ b/apps/gateway/src/relay.rs @@ -0,0 +1,527 @@ +//! `onecli-gateway relay` — a local mTLS relay for an agent that cannot (or +//! should not) hold the gateway's client certificate itself. +//! +//! # The core design: a blind byte-splice +//! +//! The relay accepts a plain HTTP-proxy connection from a local agent +//! (`CONNECT host:port`, or an absolute-form request) on a loopback/bindable +//! port, opens exactly ONE mTLS connection to the remote gateway presenting +//! the relay's OWN client certificate, and copies bytes between the two +//! connections verbatim (`tokio::io::copy_bidirectional` — see +//! [`tunnel::splice`]). +//! +//! It does NOT parse, rewrite, or even inspect the agent's CONNECT line or +//! headers. That is the entire security property this module exists to +//! provide: the agent's original `CONNECT host:443` request line, together +//! with its `Proxy-Authorization: Basic base64(aoc_token)` header, reaches +//! the remote gateway byte-for-byte — so the gateway authenticates the agent +//! and MITMs the connection exactly as it would if the agent had dialed it +//! directly. The relay carries BOTH identities to the gateway at once: its +//! own client certificate (the mTLS handshake) and the agent's `aoc_` token +//! (untouched, inside the tunneled header). +//! +//! Consequently the relay holds NO MITM logic, NO database, NO crypto +//! service, and NO policy — see `gateway.rs` for all of that. Its only +//! moving parts are: enrollment ([`enroll`]) to obtain a client certificate +//! without the private key ever leaving the process, renewal ([`renew`]) to +//! replace that certificate before it expires, and the splice itself +//! ([`tunnel`]). +//! +//! # Trust +//! +//! The remote gateway's server certificate is verified against +//! `--gateway-server-ca` — supplied out of band by the operator, REQUIRED, +//! and never derived from the enrollment response (that response's `ca_pem` +//! is the CLIENT CA, a completely different trust anchor — see the +//! `SECURITY` note on [`enroll::build_client_tls_config`]). Missing or +//! unparseable input here is fail-closed: the relay refuses to start rather +//! than fall back to an accept-any-server-cert verifier. + +mod enroll; +mod renew; +mod tunnel; + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use arc_swap::ArcSwap; +use clap::Args; +use tokio::net::TcpListener; +use tracing::{info, warn}; + +use crate::client_ca; + +/// CLI arguments for `onecli-gateway relay`. Every field falls back to an +/// environment variable so the relay can run unattended in a container +/// without a generated command line. +#[derive(Args, Debug, Clone)] +pub(crate) struct RelayArgs { + /// Local address the relay listens on for plain HTTP-proxy connections + /// from an agent. + #[arg(long, env = "RELAY_BIND", default_value = "127.0.0.1:10255")] + pub(crate) bind: SocketAddr, + + /// `host:port` of the remote gateway's mTLS listener (the port + /// configured via `GATEWAY_MTLS_PORT` on that gateway). + #[arg(long, env = "RELAY_GATEWAY_ADDR")] + pub(crate) gateway_addr: String, + + /// Name used for TLS SNI and server-certificate verification against + /// the remote gateway. Defaults to the host part of `--gateway-addr`. + #[arg(long, env = "RELAY_GATEWAY_SERVER_NAME")] + pub(crate) gateway_server_name: Option, + + /// Trust anchor for the remote gateway's SERVER certificate — inline PEM + /// or a filesystem path (resolved via [`client_ca::pem_from_value`]). + /// REQUIRED: the relay always verifies the gateway's server certificate + /// and never falls back to an accept-any verifier. + #[arg(long, env = "RELAY_GATEWAY_SERVER_CA")] + pub(crate) gateway_server_ca: String, + + /// Base URL of the OneCLI API (Node), used to enroll/renew the relay's + /// client certificate via `POST /v1/gateway/client-cert`. + #[arg(long, env = "RELAY_API_URL")] + pub(crate) api_url: String, + + /// Project-scoped `oc_` API key, sent as `Authorization: Bearer` on the + /// enrollment call. + #[arg(long, env = "RELAY_API_KEY")] + pub(crate) api_key: String, + + /// Optional human-readable label for the enrolled `ClientHost` row. + #[arg(long, env = "RELAY_LABEL")] + pub(crate) label: Option, + + /// Optional directory to persist the relay's private key, current + /// certificate, and host id (mode 0600) across restarts. Without it, the + /// relay generates a fresh keypair and enrolls as a brand-new host every + /// time it starts. + #[arg(long, env = "RELAY_STATE_DIR")] + pub(crate) state_dir: Option, +} + +/// The relay's current mTLS client identity: the `ClientConfig` used to dial +/// the remote gateway, paired with that certificate's own expiry so a +/// connection can fail closed on an expired certificate without re-parsing +/// it out of the config. +pub(crate) struct RelayCertState { + pub(crate) tls_config: Arc, + pub(crate) not_after_unix: i64, +} + +/// Bundled arguments for the renewal loop — kept as one struct (rather than +/// half a dozen positional parameters) since renewal needs almost everything +/// [`run`] resolved at startup, re-supplied on every retry. +pub(crate) struct RenewalArgs { + pub(crate) api_url: String, + pub(crate) api_key: String, + pub(crate) label: Option, + pub(crate) host_id: String, + pub(crate) csr_pem: String, + pub(crate) key_pem: String, + pub(crate) server_ca_pem: String, + pub(crate) state_dir: Option, +} + +/// Run the relay: resolve config, enroll a client certificate (fail-closed +/// if that fails), spawn the renewal task, then accept and splice +/// connections until shutdown. +/// +/// Never starts accepting connections until a valid certificate and a +/// verified server-CA trust store both exist — an enrollment failure here +/// aborts startup rather than serving with no certificate at all. +pub(crate) async fn run(args: RelayArgs) -> Result<()> { + let server_name = args + .gateway_server_name + .clone() + .unwrap_or_else(|| host_part(&args.gateway_addr).to_string()); + + // Fail-closed: the trust anchor for the REMOTE GATEWAY'S server + // certificate is mandatory. An unreadable or unparseable value must + // refuse to start — never fall back to trusting nothing (which, for a + // rustls root store, means trusting everything is instead rejected, or + // to skipping verification). + let server_ca_pem = + client_ca::pem_from_value("RELAY_GATEWAY_SERVER_CA", &args.gateway_server_ca)? + .context("RELAY_GATEWAY_SERVER_CA must not be empty")?; + // Parse it now, at startup, so a garbage CA bundle fails here — before + // any connection is ever accepted — rather than on the first dial. + client_ca::load_client_ca_roots(&server_ca_pem).context("RELAY_GATEWAY_SERVER_CA")?; + + let stored = load_stored_state(args.state_dir.as_deref()).await?; + + // The relay's own keypair, generated once and never sent anywhere — only + // the CSR (proof of possession) is submitted for enrollment. Reused + // across renewals; only reloaded fresh across a restart when + // `--state-dir` is set. + let (keypair, csr_pem) = match &stored { + Some(s) => { + let key = rcgen::KeyPair::from_pem(&s.key_pem) + .context("parsing persisted relay private key")?; + let csr_pem = build_csr(&key)?; + (key, csr_pem) + } + None => enroll::generate_keypair_and_csr()?, + }; + let key_pem = keypair.serialize_pem(); + let host_id = stored.map(|s| s.host_id); + + info!( + gateway = %args.gateway_addr, + bind = %args.bind, + resuming = host_id.is_some(), + "enrolling relay client certificate" + ); + let enrolled = enroll::enroll( + &args.api_url, + &args.api_key, + &csr_pem, + host_id, + args.label.clone(), + ) + .await + .context("initial client-certificate enrollment failed")?; + + if let Some(dir) = &args.state_dir { + persist_state(dir, &key_pem, &enrolled.cert_pem, &enrolled.host_id).await?; + } + + let tls_config = enroll::build_client_tls_config(&enrolled.cert_pem, &key_pem, &server_ca_pem)?; + let lifetime = + Duration::from_secs(enrolled.not_after_unix.saturating_sub(unix_now()).max(0) as u64); + + info!( + identity = %enrolled.identity, + host_id = %enrolled.host_id, + serial = %enrolled.serial, + not_after = enrolled.not_after_unix, + "relay client certificate enrolled" + ); + + let state = Arc::new(ArcSwap::from_pointee(RelayCertState { + tls_config, + not_after_unix: enrolled.not_after_unix, + })); + + // Renewal runs for the life of the process under its own shutdown guard: + // it holds no agent connection open, but the drain should still give an + // in-flight renewal (and the `--state-dir` write that follows it) a + // moment rather than cutting it mid-write. + let renewal_guard = crate::shutdown::task_guard(); + let renewal_state = Arc::clone(&state); + let renewal_args = RenewalArgs { + api_url: args.api_url.clone(), + api_key: args.api_key.clone(), + label: args.label.clone(), + host_id: enrolled.host_id.clone(), + csr_pem, + key_pem, + server_ca_pem, + state_dir: args.state_dir.clone(), + }; + tokio::spawn(async move { + let _guard = renewal_guard; + renew::renewal_loop( + renewal_args, + lifetime, + enrolled.not_after_unix, + renewal_state, + ) + .await; + }); + + let listener = TcpListener::bind(args.bind) + .await + .with_context(|| format!("binding relay listener on {}", args.bind))?; + let bound = listener + .local_addr() + .context("reading bound relay address")?; + info!( + addr = %bound, + gateway = %args.gateway_addr, + server_name = %server_name, + "relay listening" + ); + + let mut shutdown_signal = crate::shutdown::subscribe(); + loop { + let (stream, peer_addr) = tokio::select! { + accepted = listener.accept() => match accepted { + Ok(pair) => pair, + Err(e) => { + // Mirrors `gateway::accept_loop`: a recoverable accept() + // error must not take down the whole relay. + warn!(error = %e, "relay accept() failed, retrying"); + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + } + }, + _ = shutdown_signal.wait() => break, + }; + + let state = Arc::clone(&state); + let gateway_addr = args.gateway_addr.clone(); + let server_name = server_name.clone(); + // A relay tunnel is an indefinite byte pipe, same as the gateway's + // own raw CONNECT tunnels (`tunnel::tunnel` in `gateway/tunnel.rs`) + // — deliberately untracked so shutdown doesn't wait the full drain + // deadline on a connection that may never end on its own. + let guard = crate::shutdown::task_guard(); + tokio::spawn(async move { + drop(guard); + if let Err(e) = tunnel::splice(stream, state, &gateway_addr, &server_name).await { + warn!(peer = %peer_addr, error = ?e, "relay tunnel error"); + } + }); + } + + drop(listener); + info!("relay listener closed"); + Ok(()) +} + +/// Build a fresh CSR from an already-generated keypair. Split out from +/// [`enroll::generate_keypair_and_csr`] so a persisted (reloaded) key can +/// also get a CSR without regenerating the key itself. +fn build_csr(key: &rcgen::KeyPair) -> Result { + let params = rcgen::CertificateParams::default(); + let csr = params + .serialize_request(key) + .context("building relay client CSR from a persisted key")?; + csr.pem().context("encoding relay CSR as PEM") +} + +/// The host part of a `host:port` string — everything before the last `:`. +/// Falls back to the whole string if there's no colon. Good enough for the +/// `host:port` shape `--gateway-addr` always takes; not meant to handle +/// bracketed IPv6 (mirrors `gateway.rs::strip_port`'s own scope). +fn host_part(addr: &str) -> &str { + addr.rsplit_once(':').map_or(addr, |(host, _)| host) +} + +fn unix_now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Relay identity persisted across restarts when `--state-dir` is set. +struct StoredState { + key_pem: String, + host_id: String, +} + +/// Load a previously persisted key + host id, if both files are present. +/// Missing either (or no `state_dir` at all) is `Ok(None)` — first-ever +/// startup, or an operator who never opted into persistence — not an error. +async fn load_stored_state(state_dir: Option<&Path>) -> Result> { + let Some(dir) = state_dir else { + return Ok(None); + }; + let key_path = dir.join("relay-key.pem"); + let host_id_path = dir.join("relay-host-id"); + if !key_path.exists() || !host_id_path.exists() { + return Ok(None); + } + + let key_pem = tokio::fs::read_to_string(&key_path) + .await + .with_context(|| format!("reading {}", key_path.display()))?; + let host_id = tokio::fs::read_to_string(&host_id_path) + .await + .with_context(|| format!("reading {}", host_id_path.display()))? + .trim() + .to_string(); + + Ok(Some(StoredState { key_pem, host_id })) +} + +/// Persist the relay's private key (0600 from the moment it exists on +/// disk), current certificate, and host id to `dir`, creating it if +/// necessary. Called after every successful enrollment (initial and +/// renewal) when `--state-dir` is set. +async fn persist_state(dir: &Path, key_pem: &str, cert_pem: &str, host_id: &str) -> Result<()> { + tokio::fs::create_dir_all(dir) + .await + .with_context(|| format!("creating relay state directory {}", dir.display()))?; + + let key_path = dir.join("relay-key.pem"); + write_private_key(&key_path, key_pem).await?; + + let cert_path = dir.join("relay-cert.pem"); + tokio::fs::write(&cert_path, cert_pem) + .await + .with_context(|| format!("writing {}", cert_path.display()))?; + + let host_id_path = dir.join("relay-host-id"); + tokio::fs::write(&host_id_path, host_id) + .await + .with_context(|| format!("writing {}", host_id_path.display()))?; + + Ok(()) +} + +/// Write the relay's private key to `path` at 0600 from the instant the +/// file exists — never briefly created under the process umask (commonly +/// 0644, world-readable) and chmod'd afterward. `OpenOptions::mode` applies +/// the permission bits atomically as part of the same `O_CREAT`, so there is +/// no window in which the key sits on disk world-readable. +/// +/// SECURITY: every failure here — including the create-with-mode call +/// itself — is a hard error, never a best-effort `.ok()`. A private key must +/// not be able to silently end up on disk with looser permissions (or not +/// written at all) than intended. +#[cfg(unix)] +async fn write_private_key(path: &Path, key_pem: &str) -> Result<()> { + use tokio::io::AsyncWriteExt; + + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .await + .with_context(|| format!("creating {} with 0600 permissions", path.display()))?; + file.write_all(key_pem.as_bytes()) + .await + .with_context(|| format!("writing {}", path.display()))?; + Ok(()) +} + +/// Non-unix fallback: no POSIX permission bits to set atomically at create +/// time, so this is a plain write (mirrors the rest of this crate's +/// `#[cfg(unix)]`-gated permission handling, e.g. `client_ca_authority.rs`). +#[cfg(not(unix))] +async fn write_private_key(path: &Path, key_pem: &str) -> Result<()> { + tokio::fs::write(path, key_pem) + .await + .with_context(|| format!("writing {}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_part_splits_off_the_port() { + assert_eq!(host_part("gateway.example.com:8443"), "gateway.example.com"); + assert_eq!(host_part("127.0.0.1:10255"), "127.0.0.1"); + } + + #[test] + fn host_part_falls_back_to_whole_string_with_no_colon() { + assert_eq!(host_part("localhost"), "localhost"); + } + + #[tokio::test] + async fn persist_and_load_round_trip_state() { + let dir = tempfile::tempdir().expect("tempdir"); + persist_state(dir.path(), "KEY-PEM", "CERT-PEM", "host-abc") + .await + .expect("persist"); + + let loaded = load_stored_state(Some(dir.path())) + .await + .expect("load") + .expect("state present"); + assert_eq!(loaded.key_pem, "KEY-PEM"); + assert_eq!(loaded.host_id, "host-abc"); + } + + #[tokio::test] + async fn load_stored_state_is_none_without_a_state_dir() { + assert!(load_stored_state(None).await.expect("ok").is_none()); + } + + #[tokio::test] + async fn load_stored_state_is_none_when_files_are_missing() { + let dir = tempfile::tempdir().expect("tempdir"); + assert!(load_stored_state(Some(dir.path())) + .await + .expect("ok") + .is_none()); + } + + #[cfg(unix)] + #[tokio::test] + async fn persisted_key_file_has_restricted_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + persist_state(dir.path(), "KEY-PEM", "CERT-PEM", "host-abc") + .await + .expect("persist"); + + let perms = std::fs::metadata(dir.path().join("relay-key.pem")) + .expect("metadata") + .permissions(); + assert_eq!(perms.mode() & 0o777, 0o600); + } + + /// A failure creating the key file must propagate as a hard `Err`, never + /// be swallowed the way the old write-then-`.ok()`-chmod pattern would + /// have (which could leave a world-readable key on disk with no error + /// at all). Forced here by putting a directory exactly where the key + /// file needs to go, so `open()` fails with `EISDIR`. + #[tokio::test] + async fn persist_state_hard_errors_when_the_key_file_cannot_be_created() { + let dir = tempfile::tempdir().expect("tempdir"); + tokio::fs::create_dir_all(dir.path().join("relay-key.pem")) + .await + .expect("create a directory blocking the key file's path"); + + let err = persist_state(dir.path(), "KEY-PEM", "CERT-PEM", "host-abc") + .await + .expect_err("must hard-error rather than silently succeed or silently drop the key"); + assert!(format!("{err:#}").contains("relay-key.pem")); + } + + /// `run()` must fail closed BEFORE binding a listener or attempting + /// enrollment when the server-CA trust anchor is missing/garbage. + #[tokio::test] + async fn run_errs_on_garbage_gateway_server_ca_before_anything_else() { + let args = RelayArgs { + bind: "127.0.0.1:0".parse().unwrap(), + gateway_addr: "127.0.0.1:1".to_string(), + gateway_server_name: None, + gateway_server_ca: "not a pem and not a real path".to_string(), + api_url: "http://127.0.0.1:1".to_string(), + api_key: "oc_test".to_string(), + label: None, + state_dir: None, + }; + let err = run(args).await.expect_err("must fail closed"); + assert!(format!("{err:#}").contains("RELAY_GATEWAY_SERVER_CA")); + } + + /// Enrollment unreachable at startup must fail `run()` closed — the + /// relay never falls back to serving with no certificate. + #[tokio::test] + async fn run_errs_when_enrollment_is_unreachable() { + crate::client_ca::test_support::ensure_crypto_provider(); + let ca = crate::client_ca::test_support::new_test_ca("Trust Anchor"); + + let args = RelayArgs { + bind: "127.0.0.1:0".parse().unwrap(), + gateway_addr: "127.0.0.1:1".to_string(), + gateway_server_name: None, + gateway_server_ca: ca.cert.pem(), + // Port 1 is not a real gateway; the connection should fail fast + // (refused or unreachable) rather than reach anything real. + api_url: "http://127.0.0.1:1".to_string(), + api_key: "oc_test".to_string(), + label: None, + state_dir: None, + }; + let result = tokio::time::timeout(Duration::from_secs(5), run(args)).await; + let err = result + .expect("must not hang") + .expect_err("must fail closed when enrollment is unreachable"); + assert!(format!("{err:#}").contains("enrollment")); + } +} diff --git a/apps/gateway/src/relay/enroll.rs b/apps/gateway/src/relay/enroll.rs new file mode 100644 index 00000000..c80caac8 --- /dev/null +++ b/apps/gateway/src/relay/enroll.rs @@ -0,0 +1,392 @@ +//! Client-certificate enrollment (and, via [`enroll`] again, renewal) for +//! the relay: generate a keypair + CSR, call the Node API's +//! `POST /v1/gateway/client-cert`, and assemble the resulting mTLS +//! `ClientConfig`. +//! +//! The private key generated here never leaves this process — only the CSR +//! (proof of possession of the matching public key) is submitted. See +//! `client_ca_authority.rs`'s `ClientCa::sign_csr` on the gateway side for +//! the matching guarantee: every identity-bearing field on the certificate +//! that comes back is assigned server-side, not read from the CSR. + +use std::sync::Arc; + +use anyhow::{bail, Context, Result}; +use rcgen::{CertificateParams, KeyPair, PKCS_ECDSA_P256_SHA256}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use rustls::ClientConfig; +use serde::{Deserialize, Serialize}; + +use crate::client_ca::load_client_ca_roots; + +/// Generate a fresh ECDSA P-256 keypair and a CSR for it. +/// +/// The CSR's subject/SAN fields are cosmetic — whatever `sign_csr` sees +/// there is discarded in full (see its own SECURITY note); this CSR exists +/// only to prove the relay holds the private key it's requesting a +/// certificate for. +pub(crate) fn generate_keypair_and_csr() -> Result<(KeyPair, String)> { + let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256) + .context("generating relay client keypair")?; + let params = CertificateParams::default(); + let csr = params + .serialize_request(&key) + .context("building relay client CSR")?; + let csr_pem = csr.pem().context("encoding relay CSR as PEM")?; + Ok((key, csr_pem)) +} + +/// `POST /v1/gateway/client-cert` JSON response body — camelCase, matching +/// the Node route in `packages/api/src/routes/gateway.ts` +/// (`clientCertRoutes`). Field names here are exactly what that handler +/// serializes: `identity`, `hostId`, `certPem`, `caPem`, `serial`, +/// `notAfter`. +#[derive(Debug, Deserialize)] +struct EnrollResponseBody { + identity: String, + #[serde(rename = "hostId")] + host_id: String, + #[serde(rename = "certPem")] + cert_pem: String, + #[serde(rename = "caPem")] + ca_pem: String, + serial: String, + #[serde(rename = "notAfter")] + not_after_unix: i64, +} + +/// A successful enrollment (or renewal) result. +#[derive(Debug, Clone)] +pub(crate) struct EnrollResponse { + pub(crate) identity: String, + pub(crate) host_id: String, + /// Leaf + client-CA chain PEM — the relay's own certificate. + pub(crate) cert_pem: String, + /// The CLIENT CA that signed `cert_pem` — NOT the trust anchor for the + /// remote gateway's server certificate. Deliberately unused for TLS + /// verification anywhere in this module: see the SECURITY note on + /// [`build_client_tls_config`] below. Kept only because it's part of the + /// API response shape; a caller that wants to display/log it can. + #[allow(dead_code)] + pub(crate) ca_pem: String, + pub(crate) serial: String, + pub(crate) not_after_unix: i64, +} + +/// Request body for `POST /v1/gateway/client-cert` — mirrors +/// `clientCertEnrollSchema` (`packages/api/src/validations/client-cert.ts`) +/// field-for-field, `.strict()` there means an extra field here would 400, +/// so this struct carries exactly `csrPem`/`label`/`hostId` and nothing +/// resembling key material. +#[derive(Debug, Serialize)] +struct EnrollRequestBody<'a> { + #[serde(rename = "csrPem")] + csr_pem: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + label: Option<&'a str>, + #[serde(rename = "hostId", skip_serializing_if = "Option::is_none")] + host_id: Option<&'a str>, +} + +/// Enroll (`host_id: None`) or renew (`host_id: Some(existing)`) via +/// `POST {api_url}/v1/gateway/client-cert`, authenticated as +/// `Authorization: Bearer {api_key}` (a project-scoped `oc_` key — the same +/// session-or-API-key `authMiddleware` every other project-scoped Node route +/// uses). +/// +/// Passing the SAME `host_id` back on renewal is what makes the Node route's +/// `ensureClientHost` re-mint against the same `ClientHost` row/identity +/// instead of accumulating a new one per renewal. +pub(crate) async fn enroll( + api_url: &str, + api_key: &str, + csr_pem: &str, + host_id: Option, + label: Option, +) -> Result { + let url = format!("{}/v1/gateway/client-cert", api_url.trim_end_matches('/')); + let body = EnrollRequestBody { + csr_pem, + label: label.as_deref(), + host_id: host_id.as_deref(), + }; + + let response = reqwest::Client::new() + .post(&url) + .bearer_auth(api_key) + .json(&body) + .send() + .await + .context("calling the gateway client-cert enrollment endpoint")?; + + let status = response.status(); + if !status.is_success() { + let text = response.text().await.unwrap_or_default(); + bail!("client-cert enrollment failed: HTTP {status}: {text}"); + } + + let parsed: EnrollResponseBody = response + .json() + .await + .context("parsing client-cert enrollment response")?; + + Ok(EnrollResponse { + identity: parsed.identity, + host_id: parsed.host_id, + cert_pem: parsed.cert_pem, + ca_pem: parsed.ca_pem, + serial: parsed.serial, + not_after_unix: parsed.not_after_unix, + }) +} + +fn pem_to_der_certs(pem: &str) -> Result>> { + let mut reader = pem.as_bytes(); + let certs: Vec> = rustls_pemfile::certs(&mut reader) + .collect::>() + .context("parsing relay client certificate PEM")?; + if certs.is_empty() { + bail!("no certificates found in relay client certificate PEM"); + } + Ok(certs) +} + +/// Build the relay's mTLS `ClientConfig`. +/// +/// SECURITY: `server_ca_pem` is `--gateway-server-ca` — the trust anchor for +/// the REMOTE GATEWAY'S SERVER certificate. It is NOT +/// [`EnrollResponse::ca_pem`], which is the CLIENT CA that signed `cert_pem` +/// and is irrelevant here: the relay never verifies its own certificate. +/// Confusing the two would mean either verifying the server against the +/// wrong anchor (server-cert verification would then fail against a real +/// gateway) or, worse, silently trusting whatever the enrollment response +/// happened to name as a server. This function only ever reads from the +/// `server_ca_pem` parameter for the root store — never from any field of +/// an [`EnrollResponse`]. +pub(crate) fn build_client_tls_config( + cert_pem: &str, + key_pem: &str, + server_ca_pem: &str, +) -> Result> { + let roots = load_client_ca_roots(server_ca_pem).context("RELAY_GATEWAY_SERVER_CA")?; + + let cert_chain = pem_to_der_certs(cert_pem)?; + let mut key_reader = key_pem.as_bytes(); + let key: PrivateKeyDer<'static> = rustls_pemfile::private_key(&mut key_reader) + .context("parsing relay client private key")? + .context("no private key found for the relay's client certificate")?; + + let mut config = ClientConfig::builder() + .with_root_certificates(roots) + .with_client_auth_cert(cert_chain, key) + .context("building relay mTLS ClientConfig")?; + // The gateway's mTLS listener pins http/1.1 (see `client_ca.rs`'s + // `build_server_config`); match it here so ALPN negotiation can't drift. + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + + Ok(Arc::new(config)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client_ca::test_support::{ensure_crypto_provider, new_test_ca, sign_client_leaf}; + + #[test] + fn generate_keypair_and_csr_produces_a_parseable_csr() { + let (_key, csr_pem) = generate_keypair_and_csr().expect("csr"); + assert!(csr_pem.contains("BEGIN CERTIFICATE REQUEST")); + } + + #[test] + fn build_client_tls_config_errs_on_garbage_server_ca() { + ensure_crypto_provider(); + let ca = new_test_ca("Test Client CA"); + let (cert_pem, key_pem) = sign_client_leaf(&ca, Some("relay"), &[], -1, 24); + + let err = build_client_tls_config(&cert_pem, &key_pem, "not a pem at all").unwrap_err(); + assert!(format!("{err:#}").contains("RELAY_GATEWAY_SERVER_CA")); + } + + #[test] + fn build_client_tls_config_errs_on_empty_server_ca() { + ensure_crypto_provider(); + let ca = new_test_ca("Test Client CA"); + let (cert_pem, key_pem) = sign_client_leaf(&ca, Some("relay"), &[], -1, 24); + + let err = build_client_tls_config(&cert_pem, &key_pem, "").unwrap_err(); + assert!(format!("{err:#}").contains("RELAY_GATEWAY_SERVER_CA")); + } + + #[test] + fn build_client_tls_config_errs_on_garbage_client_cert() { + ensure_crypto_provider(); + let ca = new_test_ca("Server Trust Anchor"); + let err = build_client_tls_config("not a cert", "not a key", &ca.cert.pem()).unwrap_err(); + assert!(!err.to_string().is_empty()); + } + + #[test] + fn build_client_tls_config_succeeds_with_valid_material() { + ensure_crypto_provider(); + let server_ca = new_test_ca("Server Trust Anchor"); + let client_ca = new_test_ca("Client Issuer"); + let (cert_pem, key_pem) = sign_client_leaf( + &client_ca, + Some("relay-1"), + &["spiffe://onecli/relay/1"], + -1, + 24, + ); + + let config = build_client_tls_config(&cert_pem, &key_pem, &server_ca.cert.pem()) + .expect("valid material must build a config"); + assert_eq!(config.alpn_protocols, vec![b"http/1.1".to_vec()]); + } + + /// The enrollment call authenticates with `Authorization: Bearer + /// {api_key}`, POSTs to `/v1/gateway/client-cert`, and parses the + /// Node route's camelCase response shape into `EnrollResponse`. + #[tokio::test] + async fn enroll_posts_bearer_auth_and_parses_the_camelcase_response() { + ensure_crypto_provider(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + + let handle = std::thread::spawn(move || { + use std::io::{Read, Write}; + let (mut stream, _) = listener.accept().expect("accept"); + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).unwrap_or(0); + let request = String::from_utf8_lossy(&buf[..n]).to_string(); + + let body = concat!( + "{\"identity\":\"spiffe://onecli/host/abc\",", + "\"hostId\":\"abc\",", + "\"certPem\":\"LEAF-AND-CA-PEM\",", + "\"caPem\":\"CA-ONLY-PEM\",", + "\"serial\":\"1a2b3c\",", + "\"notAfter\":1893456000}" + ); + let resp = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()); + request + }); + + let result = enroll( + &format!("http://{addr}"), + "oc_test_key", + "-----BEGIN CERTIFICATE REQUEST-----\nabc\n-----END CERTIFICATE REQUEST-----\n", + None, + Some("relay-1".to_string()), + ) + .await + .expect("enroll succeeds"); + + let request = handle.join().expect("server thread"); + assert!(request.starts_with("POST /v1/gateway/client-cert")); + assert!( + request + .to_lowercase() + .contains("authorization: bearer oc_test_key"), + "request must carry the Bearer api key: {request}" + ); + assert!(request.contains("\"label\":\"relay-1\"")); + assert!( + !request.contains("hostId"), + "first enrollment must omit hostId" + ); + + assert_eq!(result.identity, "spiffe://onecli/host/abc"); + assert_eq!(result.host_id, "abc"); + assert_eq!(result.cert_pem, "LEAF-AND-CA-PEM"); + assert_eq!(result.ca_pem, "CA-ONLY-PEM"); + assert_eq!(result.serial, "1a2b3c"); + assert_eq!(result.not_after_unix, 1893456000); + } + + #[tokio::test] + async fn enroll_sends_host_id_on_renewal() { + ensure_crypto_provider(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + + let handle = std::thread::spawn(move || { + use std::io::{Read, Write}; + let (mut stream, _) = listener.accept().expect("accept"); + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).unwrap_or(0); + let request = String::from_utf8_lossy(&buf[..n]).to_string(); + let body = concat!( + "{\"identity\":\"spiffe://onecli/host/abc\",", + "\"hostId\":\"abc\",", + "\"certPem\":\"LEAF2\",", + "\"caPem\":\"CA\",", + "\"serial\":\"deadbeef\",", + "\"notAfter\":1893456000}" + ); + let resp = format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()); + request + }); + + enroll( + &format!("http://{addr}"), + "oc_test", + "csr-pem", + Some("abc".to_string()), + None, + ) + .await + .expect("renewal succeeds"); + + let request = handle.join().expect("server thread"); + assert!(request.contains("\"hostId\":\"abc\"")); + } + + #[tokio::test] + async fn enroll_errs_on_non_success_status() { + ensure_crypto_provider(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + + std::thread::spawn(move || { + use std::io::{Read, Write}; + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + let body = "{\"error\":\"invalid CSR\"}"; + let resp = format!( + "HTTP/1.1 400 Bad Request\r\ncontent-length: {}\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()); + } + }); + + let err = enroll(&format!("http://{addr}"), "oc_test", "csr-pem", None, None) + .await + .unwrap_err(); + assert!(format!("{err:#}").contains("400")); + } + + #[tokio::test] + async fn enroll_errs_when_unreachable() { + ensure_crypto_provider(); + // Port 1 (tcpmux) is essentially guaranteed unbound in test + // environments — an immediate connection refusal, not a hang. + let err = enroll("http://127.0.0.1:1", "oc_test", "csr-pem", None, None) + .await + .unwrap_err(); + assert!(format!("{err:#}").contains("enrollment")); + } +} diff --git a/apps/gateway/src/relay/renew.rs b/apps/gateway/src/relay/renew.rs new file mode 100644 index 00000000..cdcb5047 --- /dev/null +++ b/apps/gateway/src/relay/renew.rs @@ -0,0 +1,401 @@ +//! Certificate renewal for the relay: a pure scheduling function +//! ([`next_renewal`]) plus the loop that drives it. + +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use arc_swap::ArcSwap; +use tracing::{error, info, warn}; + +use super::enroll::{build_client_tls_config, enroll}; +use super::{unix_now, RelayCertState, RenewalArgs}; + +/// Never renew with less than this much lead time, no matter how short the +/// certificate's lifetime is — a certificate issued with a one-minute +/// lifetime still gets *some* runway before the relay tries to replace it. +const MIN_RENEWAL_LEAD: Duration = Duration::from_secs(5 * 60); + +/// Initial backoff between renewal retries after a failure; doubles on each +/// subsequent failure up to [`MAX_RETRY_BACKOFF`]. +const INITIAL_RETRY_BACKOFF: Duration = Duration::from_secs(5); + +/// Ceiling on the retry backoff. +const MAX_RETRY_BACKOFF: Duration = Duration::from_secs(300); + +/// When to renew a certificate whose expiry is `not_after_unix` (unix +/// seconds) and whose total issued `lifetime` is known, relative to `now`. +/// +/// Re-mints at `not_after - max(lifetime / 3, 5 minutes)`: a comfortable +/// third of the lifetime is spent as lead time, floored at five minutes so a +/// very short-lived certificate still gets renewed well before it expires +/// rather than at the last possible instant. A `not_after` that (relative to +/// `now`, once the lead is subtracted) has already passed renews +/// immediately — this function never returns a point in `now`'s past. +/// +/// Pure — no clock access beyond the two parameters — so it's directly +/// unit-testable; the only production clock reads are the `now`/`Instant` +/// values callers pass in and compute against. +pub(crate) fn next_renewal(not_after_unix: i64, now: SystemTime, lifetime: Duration) -> Instant { + let lead = std::cmp::max(lifetime / 3, MIN_RENEWAL_LEAD); + let not_after = UNIX_EPOCH + Duration::from_secs(not_after_unix.max(0) as u64); + let renew_at = not_after.checked_sub(lead).unwrap_or(UNIX_EPOCH); + + let delay = renew_at.duration_since(now).unwrap_or(Duration::ZERO); + Instant::now() + delay +} + +/// Drive certificate renewal for the life of the relay. +/// +/// Each cycle: sleep until [`next_renewal`], then re-enroll reusing +/// `args.host_id` (so the API's `ensureClientHost` re-mints against the SAME +/// row/identity instead of accumulating a new one per renewal), rebuild the +/// TLS config, and swap it into `state`. In-flight tunnels hold their own +/// `Arc` snapshot taken before the swap and are unaffected; +/// only NEW dials pick up the fresh config. +/// +/// Fail-closed on persistent failure: once the CURRENT certificate is past +/// `not_after_unix` and a renewal attempt still fails, this stops retrying +/// entirely rather than keep looping forever behind an already-expired +/// certificate (new connections then fail closed too — see +/// `tunnel::splice`'s own expiry check, which the state this loop stopped +/// updating no longer changes). +pub(crate) async fn renewal_loop( + args: RenewalArgs, + mut lifetime: Duration, + mut not_after_unix: i64, + state: Arc>, +) { + let mut shutdown_signal = crate::shutdown::subscribe(); + + loop { + let deadline = next_renewal(not_after_unix, SystemTime::now(), lifetime); + tokio::select! { + _ = tokio::time::sleep_until(deadline.into()) => {}, + _ = shutdown_signal.wait() => return, + } + + let mut backoff = INITIAL_RETRY_BACKOFF; + loop { + match enroll( + &args.api_url, + &args.api_key, + &args.csr_pem, + Some(args.host_id.clone()), + args.label.clone(), + ) + .await + { + Ok(resp) => { + match build_client_tls_config( + &resp.cert_pem, + &args.key_pem, + &args.server_ca_pem, + ) { + Ok(tls_config) => { + not_after_unix = resp.not_after_unix; + lifetime = Duration::from_secs( + not_after_unix.saturating_sub(unix_now()).max(0) as u64, + ); + state.store(Arc::new(RelayCertState { + tls_config, + not_after_unix, + })); + if let Some(dir) = &args.state_dir { + if let Err(e) = super::persist_state( + dir, + &args.key_pem, + &resp.cert_pem, + &args.host_id, + ) + .await + { + warn!(error = ?e, "failed to persist renewed relay state"); + } + } + info!( + host_id = %args.host_id, + not_after = not_after_unix, + "relay certificate renewed" + ); + // Only the success path exits the retry loop — + // see the `Err` arm just below for why a rebuild + // failure must NOT also `break` here. + break; + } + Err(e) => { + // A rebuild failure must fall through to the + // SAME backoff-then-retry tail as an `enroll` + // failure (below), not `break` unconditionally: + // breaking here would return to the outer loop + // with `not_after_unix` unchanged (still at or + // past expiry, since that's normally why a + // renewal fired in the first place), which + // recomputes `next_renewal` as "renew + // immediately" and re-enrolls again right away — + // a tight, network-hammering hot loop whenever + // `enroll` keeps succeeding but the resulting + // certificate keeps failing to build into a TLS + // config. Falling through instead applies the + // same exponential backoff, and the old (still + // valid, unexpired) config already in `state` + // keeps serving new connections in the meantime. + if unix_now() >= not_after_unix { + error!( + error = ?e, + "renewed certificate failed to build a TLS config and the \ + current certificate has already expired -- stopping renewal \ + rather than keep retrying behind an expired certificate" + ); + return; + } + warn!( + error = ?e, + backoff = ?backoff, + "renewed certificate failed to build a TLS config, retrying" + ); + } + } + } + Err(e) => { + if unix_now() >= not_after_unix { + error!( + error = ?e, + "certificate renewal is failing and the current certificate has \ + already expired -- stopping renewal rather than keep retrying \ + behind an expired certificate" + ); + return; + } + warn!(error = ?e, backoff = ?backoff, "certificate renewal failed, retrying"); + } + } + + tokio::select! { + _ = tokio::time::sleep(backoff) => {}, + _ = shutdown_signal.wait() => return, + } + backoff = (backoff * 2).min(MAX_RETRY_BACKOFF); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn approx(actual: Duration, expected: Duration, slack: Duration) -> bool { + actual.abs_diff(expected) <= slack + } + + #[test] + fn next_renewal_24h_lifetime_renews_with_a_third_left() { + let now = SystemTime::now(); + let lifetime = Duration::from_secs(24 * 3600); + let not_after = (now + lifetime) + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let scheduled = next_renewal(not_after, now, lifetime); + let delay = scheduled.saturating_duration_since(Instant::now()); + + // lead = max(8h, 5min) = 8h -> renew 16h from now. + assert!( + approx( + delay, + Duration::from_secs(16 * 3600), + Duration::from_secs(5) + ), + "expected ~16h, got {delay:?}" + ); + } + + #[test] + fn next_renewal_max_lifetime_uses_a_third_as_lead() { + let now = SystemTime::now(); + let lifetime = Duration::from_secs(7 * 24 * 3600); // MAX_LIFETIME + let not_after = (now + lifetime) + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let scheduled = next_renewal(not_after, now, lifetime); + let delay = scheduled.saturating_duration_since(Instant::now()); + + // lead = lifetime / 3 = 56h -> renew 112h from now. + assert!( + approx( + delay, + Duration::from_secs(112 * 3600), + Duration::from_secs(5) + ), + "expected ~112h, got {delay:?}" + ); + } + + #[test] + fn next_renewal_short_lifetime_floors_at_five_minutes() { + let now = SystemTime::now(); + let lifetime = Duration::from_secs(600); // 10 minutes: lifetime/3 ~= 3.3min + let not_after = (now + lifetime) + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let scheduled = next_renewal(not_after, now, lifetime); + let delay = scheduled.saturating_duration_since(Instant::now()); + + assert!( + approx(delay, MIN_RENEWAL_LEAD, Duration::from_secs(5)), + "expected the 5-minute floor, got {delay:?}" + ); + } + + #[test] + fn next_renewal_past_not_after_renews_immediately() { + let now = SystemTime::now(); + let not_after = now + .checked_sub(Duration::from_secs(3600)) + .unwrap() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let scheduled = next_renewal(not_after, now, Duration::from_secs(24 * 3600)); + let delay = scheduled.saturating_duration_since(Instant::now()); + + assert!( + delay < Duration::from_millis(50), + "expected ~immediate renewal, got {delay:?}" + ); + } + + #[test] + fn next_renewal_never_schedules_in_the_past_relative_to_now() { + // Even a wildly-in-the-past not_after must not underflow/panic — + // it just collapses to "renew immediately". + let now = SystemTime::now(); + let scheduled = next_renewal(0, now, Duration::from_secs(3600)); + assert!(scheduled <= Instant::now() + Duration::from_millis(50)); + } + + // ── renewal_loop: rebuild-failure backs off instead of hot-looping ─── + + /// Regression guard: a `build_client_tls_config` failure on a + /// successfully-`enroll`ed certificate used to `break` straight back to + /// the outer loop with `not_after_unix` unchanged (already in the past, + /// since that's normally why a renewal fired) — `next_renewal` then + /// recomputes "renew immediately" and the loop re-hits the enrollment + /// endpoint at full speed. This drives the real `renewal_loop` against a + /// fake enrollment endpoint that always returns a `certPem` garbage + /// enough to fail `build_client_tls_config`, for a bounded window, and + /// asserts the endpoint was hit at most once — proving the backoff (far + /// longer than the window) is what's gating the retries, not a hot loop. + #[tokio::test] + async fn renewal_loop_backs_off_after_a_tls_rebuild_failure_instead_of_hot_looping() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use crate::client_ca::test_support::{ + ensure_crypto_provider, new_test_ca, sign_client_leaf, + }; + use crate::relay::enroll::build_client_tls_config; + + ensure_crypto_provider(); + + let hits = Arc::new(AtomicUsize::new(0)); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + listener.set_nonblocking(true).expect("nonblocking"); + + let hits_server = Arc::clone(&hits); + let window = Duration::from_millis(300); + let server = std::thread::spawn(move || { + use std::io::{Read, Write}; + let deadline = std::time::Instant::now() + window + Duration::from_millis(200); + while std::time::Instant::now() < deadline { + match listener.accept() { + Ok((mut stream, _)) => { + stream.set_nonblocking(false).ok(); + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + hits_server.fetch_add(1, Ordering::SeqCst); + // `certPem` is not a certificate at all -- + // `enroll` succeeds at the HTTP level, but the + // caller's `build_client_tls_config` must fail. + let body = concat!( + "{\"identity\":\"spiffe://onecli/host/x\",", + "\"hostId\":\"x\",", + "\"certPem\":\"not a certificate\",", + "\"caPem\":\"CA\",", + "\"serial\":\"aa\",", + "\"notAfter\":1}" + ); + let resp = format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()); + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + } + Err(_) => break, + } + } + }); + + // A syntactically valid (but otherwise irrelevant) starting config — + // never dialed in this test, just needs to exist as the ArcSwap's + // initial value. + let ca = new_test_ca("Dummy"); + let (cert_pem, key_pem) = sign_client_leaf(&ca, Some("relay-1"), &[], -1, 24); + let initial_config = + build_client_tls_config(&cert_pem, &key_pem, &ca.cert.pem()).expect("initial config"); + + // NOT yet expired -- a couple of seconds of runway, comfortably + // longer than `window`. This is what distinguishes the scenario + // under test (a rebuild failure while there's still time left, which + // must back off and keep the loop alive) from the separate + // stop-on-expiry path (already covered by `renewal_loop`'s doc and + // exercised structurally by `next_renewal`'s own past-due tests): + // if `not_after_unix` were already in the past, the very first + // failure would legitimately return/stop rather than back off, which + // would make this test pass for the wrong reason. + let not_yet_expired = unix_now() + 2; + let state = Arc::new(ArcSwap::from_pointee(RelayCertState { + tls_config: initial_config, + not_after_unix: not_yet_expired, + })); + + let renewal_args = RenewalArgs { + api_url: format!("http://{addr}"), + api_key: "oc_test".to_string(), + label: None, + host_id: "host-x".to_string(), + csr_pem: "csr".to_string(), + key_pem, + server_ca_pem: ca.cert.pem(), + state_dir: None, + }; + + let loop_task = tokio::spawn(renewal_loop( + renewal_args, + Duration::from_secs(3600), + not_yet_expired, + state, + )); + + tokio::time::sleep(window).await; + loop_task.abort(); + let _ = loop_task.await; + let _ = server.join(); + + assert!( + hits.load(Ordering::SeqCst) <= 1, + "expected the backoff to gate retries within {window:?} (at most one hit), got {} \ + -- a rebuild failure must not hot-loop", + hits.load(Ordering::SeqCst) + ); + } +} diff --git a/apps/gateway/src/relay/tunnel.rs b/apps/gateway/src/relay/tunnel.rs new file mode 100644 index 00000000..3d3504b0 --- /dev/null +++ b/apps/gateway/src/relay/tunnel.rs @@ -0,0 +1,522 @@ +//! The blind byte-splice itself. +//! +//! See `relay.rs`'s module doc for the full security rationale — the short +//! version: [`splice`] never reads the agent's bytes as HTTP. It dials the +//! remote gateway over mTLS and hands both sides to +//! [`tokio::io::copy_bidirectional`], so the agent's CONNECT line (or +//! absolute-form request) and every header it sent — including +//! `Proxy-Authorization` — reach the remote gateway exactly as written. + +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use arc_swap::ArcSwap; +use rustls::pki_types::ServerName; +use tokio::io::{copy_bidirectional, AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio_rustls::TlsConnector; +use tracing::{debug, warn}; + +use super::RelayCertState; + +/// Written to the agent, then the connection is closed, whenever the relay +/// cannot complete a tunnel to the remote gateway — an expired certificate, +/// a failed TCP dial, or a failed/rejected TLS handshake. NEVER followed by +/// a fall back to direct egress or plaintext forwarding: refusing is the +/// only other option. +const BAD_GATEWAY: &[u8] = b"HTTP/1.1 502 Bad Gateway\r\n\r\n"; + +/// How long [`drain_pending`] waits for a quiet period before giving up. +const DRAIN_QUIET_PERIOD: Duration = Duration::from_millis(20); + +/// Ceiling on the TCP dial + mTLS handshake to the remote gateway. Without +/// this, a black-holed `gateway_addr` (packets dropped, never refused) hangs +/// the per-connection task — and therefore the agent's socket — forever; +/// per-connection tasks are deliberately untracked by shutdown (see +/// `relay.rs::run`), so nothing else would ever reclaim it. Elapsing this is +/// treated exactly like any other dial failure: fail closed with a `502`, +/// never a fallback. +const RELAY_DIAL_TIMEOUT: Duration = Duration::from_secs(10); + +/// Best-effort: read (and discard, unparsed) whatever the agent has ALREADY +/// sent — its CONNECT line and headers, on the fail-closed paths below — +/// before we write an error response and close the connection. +/// +/// This is not the "never read the agent's bytes" rule from [`splice`]'s +/// doc broken in spirit: on these paths the bytes are never forwarded +/// anywhere, inspected, or acted on — they're discarded outright. It exists +/// purely to avoid a POSIX socket gotcha: closing a socket that still has +/// unread inbound data queued can make the kernel send a RST instead of a +/// clean FIN, which can arrive at the peer before (and clobber) the +/// response we just wrote, turning our 502 into a bare connection reset. +/// Bounded by a short quiet period rather than waiting for EOF — the agent +/// is normally still holding its write half open, waiting to read our +/// response, so waiting for it to close first would deadlock. +async fn drain_pending(stream: &mut TcpStream) { + let mut buf = [0u8; 4096]; + loop { + match tokio::time::timeout(DRAIN_QUIET_PERIOD, stream.read(&mut buf)).await { + Ok(Ok(0)) | Err(_) => return, // EOF, or nothing more within the window. + Ok(Ok(_)) => continue, + Ok(Err(_)) => return, + } + } +} + +/// Drain whatever the agent already sent (see [`drain_pending`]), write the +/// fail-closed `502`, and half-close the write side — the one refusal path +/// every failure mode in [`splice`] funnels through, so there is exactly one +/// place that ever writes an error response. +async fn refuse(agent: &mut TcpStream) { + drain_pending(agent).await; + let _ = agent.write_all(BAD_GATEWAY).await; + let _ = agent.shutdown().await; +} + +/// Splice `agent` — a raw, already-accepted TCP connection from a local +/// agent — to the remote gateway at `gateway_addr` over mTLS, presenting +/// whatever client certificate `state` currently holds. +/// +/// Thin wrapper over [`splice_with_dial_timeout`] fixing the dial/handshake +/// ceiling at [`RELAY_DIAL_TIMEOUT`] — split out so tests can exercise the +/// timeout path itself without waiting out the real production duration. +pub(crate) async fn splice( + agent: TcpStream, + state: Arc>, + gateway_addr: &str, + server_name: &str, +) -> anyhow::Result<()> { + splice_with_dial_timeout(agent, state, gateway_addr, server_name, RELAY_DIAL_TIMEOUT).await +} + +/// On the success path, this never reads a single byte off `agent` itself: +/// it only ever touches `agent` through [`copy_bidirectional`], which moves +/// bytes in both directions without parsing them, so the agent's CONNECT +/// line and every header — including `Proxy-Authorization` — reach the +/// remote gateway exactly as written. Parsing, rewriting, or even peeking at +/// that data here would defeat the point of the relay. +/// +/// Fails closed at every stage: an already-expired certificate, a dial +/// failure, a rejected/failed TLS handshake, or the dial+handshake exceeding +/// `dial_timeout` all end the same way — a `502 Bad Gateway` written to the +/// agent, then the connection is closed (see [`refuse`]). There is no other +/// fallback path. On those (only those) paths, whatever the agent already +/// sent is drained and discarded, unparsed, before responding — see +/// [`drain_pending`]'s doc for why. +async fn splice_with_dial_timeout( + mut agent: TcpStream, + state: Arc>, + gateway_addr: &str, + server_name: &str, + dial_timeout: Duration, +) -> anyhow::Result<()> { + let current = state.load(); + + if is_expired(current.not_after_unix, SystemTime::now()) { + warn!("relay client certificate has expired -- refusing to dial the remote gateway"); + refuse(&mut agent).await; + return Ok(()); + } + + let tls_config = Arc::clone(¤t.tls_config); + // Drop the loaded guard before the (potentially slow) dial below — an + // `arc_swap::Guard` held across an await point would block a concurrent + // renewal's `store` from ever completing. + drop(current); + + let dial = tokio::time::timeout(dial_timeout, async { + let tcp = TcpStream::connect(gateway_addr).await?; + let name = ServerName::try_from(server_name.to_string()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; + TlsConnector::from(tls_config).connect(name, tcp).await + }) + .await; + + let mut gateway = match dial { + Ok(Ok(tls)) => tls, + Ok(Err(e)) => { + warn!( + error = %e, + gateway = %gateway_addr, + "relay could not establish an mTLS connection to the remote gateway -- refusing \ + to fall back to a direct or plaintext connection" + ); + refuse(&mut agent).await; + return Ok(()); + } + Err(_elapsed) => { + warn!( + gateway = %gateway_addr, + timeout = ?dial_timeout, + "relay dial/handshake to the remote gateway timed out -- refusing to fall back \ + to a direct or plaintext connection" + ); + refuse(&mut agent).await; + return Ok(()); + } + }; + + match copy_bidirectional(&mut agent, &mut gateway).await { + Ok((to_gateway, to_agent)) => { + debug!(to_gateway, to_agent, "relay tunnel closed"); + } + Err(e) => { + debug!(error = %e, "relay tunnel ended"); + } + } + + Ok(()) +} + +fn is_expired(not_after_unix: i64, now: SystemTime) -> bool { + let not_after = UNIX_EPOCH + std::time::Duration::from_secs(not_after_unix.max(0) as u64); + now > not_after +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + use rustls::pki_types::CertificateDer; + use tokio::net::TcpListener; + + use crate::client_ca::test_support::{ + ensure_crypto_provider, new_test_ca, self_signed_server_cert, sign_client_leaf, + }; + use crate::relay::enroll::build_client_tls_config; + + /// Encode raw DER bytes as a PEM certificate — test-only, mirroring the + /// small per-module `der_to_pem` helpers already in this crate + /// (`ca.rs`, `client_ca_authority.rs`) rather than sharing one. + fn der_to_pem(der: &CertificateDer<'_>) -> String { + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, der.as_ref()); + let mut pem = String::from("-----BEGIN CERTIFICATE-----\n"); + for chunk in b64.as_bytes().chunks(64) { + pem.push_str(std::str::from_utf8(chunk).expect("base64 is ascii")); + pem.push('\n'); + } + pem.push_str("-----END CERTIFICATE-----\n"); + pem + } + + fn cert_state( + tls_config: Arc, + not_after_unix: i64, + ) -> Arc> { + Arc::new(ArcSwap::from_pointee(RelayCertState { + tls_config, + not_after_unix, + })) + } + + /// SECURITY-critical: proves the relay never parses the agent's request. + /// A fake gateway (a real TLS server, but with no client-cert + /// requirement — irrelevant to what's under test here) records the raw + /// bytes it receives and asserts the agent's CONNECT line and + /// `Proxy-Authorization` header arrive byte-for-byte, then proves the + /// tunnel is bidirectional by echoing a second write back through it. + #[tokio::test] + async fn splice_preserves_connect_line_and_proxy_auth_byte_for_byte() { + ensure_crypto_provider(); + + let (server_cert_pem, server_key_pem, server_der) = self_signed_server_cert(); + let server_ca_pem = der_to_pem(&server_der); + + let mut server_cert_reader = server_cert_pem.as_bytes(); + let server_chain: Vec<_> = rustls_pemfile::certs(&mut server_cert_reader) + .collect::>() + .expect("server chain"); + let mut server_key_reader = server_key_pem.as_bytes(); + let server_key = rustls_pemfile::private_key(&mut server_key_reader) + .expect("parse server key") + .expect("server key present"); + let server_config = Arc::new( + rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(server_chain, server_key) + .expect("server config"), + ); + + // Any client cert works here — the fake gateway doesn't verify one. + let issuer = new_test_ca("Any Issuer"); + let (client_cert_pem, client_key_pem) = + sign_client_leaf(&issuer, Some("relay-1"), &[], -1, 24); + let tls_config = build_client_tls_config(&client_cert_pem, &client_key_pem, &server_ca_pem) + .expect("client config"); + + let gateway_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind gateway"); + let gateway_addr = gateway_listener.local_addr().expect("gateway addr"); + + const CONNECT_LINE_AND_AUTH: &[u8] = b"CONNECT example.com:443 HTTP/1.1\r\n\ +Host: example.com:443\r\n\ +Proxy-Authorization: Basic YW9jX3Rlc3Q6\r\n\ +\r\n"; + + let gateway_task = tokio::spawn(async move { + let (stream, _) = gateway_listener.accept().await.expect("gateway accept"); + let mut tls = tokio_rustls::TlsAcceptor::from(server_config) + .accept(stream) + .await + .expect("gateway tls accept"); + + let mut received = vec![0u8; CONNECT_LINE_AND_AUTH.len()]; + tls.read_exact(&mut received) + .await + .expect("read CONNECT + headers"); + + tls.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") + .await + .expect("write 200"); + + let mut ping = [0u8; 4]; + tls.read_exact(&mut ping).await.expect("read PING"); + tls.write_all(b"PONG").await.expect("write PONG"); + let _ = tls.shutdown().await; + + received + }); + + let relay_listener = TcpListener::bind("127.0.0.1:0").await.expect("bind relay"); + let relay_addr = relay_listener.local_addr().expect("relay addr"); + let state = cert_state(tls_config, i64::MAX); + let gateway_addr_string = gateway_addr.to_string(); + let splice_task = tokio::spawn(async move { + let (agent_stream, _) = relay_listener.accept().await.expect("relay accept"); + splice(agent_stream, state, &gateway_addr_string, "localhost").await + }); + + let mut agent = TcpStream::connect(relay_addr).await.expect("agent connect"); + agent + .write_all(CONNECT_LINE_AND_AUTH) + .await + .expect("agent write CONNECT"); + + let mut response = [0u8; "HTTP/1.1 200 Connection Established\r\n\r\n".len()]; + agent + .read_exact(&mut response) + .await + .expect("agent read 200"); + assert_eq!(&response, b"HTTP/1.1 200 Connection Established\r\n\r\n"); + + agent.write_all(b"PING").await.expect("agent write PING"); + let mut pong = [0u8; 4]; + agent.read_exact(&mut pong).await.expect("agent read PONG"); + assert_eq!(&pong, b"PONG"); + drop(agent); + + let received = gateway_task.await.expect("gateway task"); + assert_eq!( + received.as_slice(), + CONNECT_LINE_AND_AUTH, + "the CONNECT line and Proxy-Authorization header must reach the gateway \ + byte-for-byte, unparsed" + ); + + splice_task.await.expect("splice task").expect("splice ok"); + } + + /// A gateway address that never refuses or completes a connection (a + /// TEST-NET-1 black hole, per RFC 5737) must not hang the relay's + /// per-connection task forever. Exercises `splice_with_dial_timeout` + /// directly with a short override so the test doesn't have to wait out + /// the real `RELAY_DIAL_TIMEOUT`; production `splice` wires the same + /// path to the real constant. + #[tokio::test] + async fn splice_returns_502_when_the_dial_times_out() { + ensure_crypto_provider(); + let ca = new_test_ca("Issuer"); + let (cert_pem, key_pem) = sign_client_leaf(&ca, Some("relay-1"), &[], -1, 24); + let tls_config = + build_client_tls_config(&cert_pem, &key_pem, &ca.cert.pem()).expect("config"); + // Not expired -- the timeout path, not the expiry short-circuit, is + // what's under test here. + let state = cert_state(tls_config, i64::MAX); + + let relay_listener = TcpListener::bind("127.0.0.1:0").await.expect("bind relay"); + let relay_addr = relay_listener.local_addr().expect("relay addr"); + let splice_task = tokio::spawn(async move { + let (agent_stream, _) = relay_listener.accept().await.expect("relay accept"); + // Bounded well beyond the short override below, so a genuine + // regression back to "no timeout at all" fails this test instead + // of hanging the suite. + tokio::time::timeout( + Duration::from_secs(5), + splice_with_dial_timeout( + agent_stream, + state, + "192.0.2.1:81", + "localhost", + Duration::from_millis(200), + ), + ) + .await + }); + + let mut agent = TcpStream::connect(relay_addr).await.expect("agent connect"); + agent + .write_all(b"CONNECT example.com:443 HTTP/1.1\r\n\r\n") + .await + .expect("agent write"); + + let mut response = Vec::new(); + agent + .read_to_end(&mut response) + .await + .expect("agent read response"); + assert_eq!( + response, BAD_GATEWAY, + "a timed-out dial must produce a 502, never hang or fall back" + ); + + let outcome = splice_task + .await + .expect("splice task") + .expect("must not hang past the 5s outer bound -- proves the dial timeout fired"); + outcome.expect("splice ok"); + } + + /// A certificate already past its `not_after` must never be dialed with + /// — proven without any real gateway by using an address on the + /// TEST-NET-1 documentation block (RFC 5737: guaranteed non-routable, + /// so packets are dropped rather than refused). If `splice` attempted + /// the dial anyway, this test would hang past the timeout below instead + /// of returning almost immediately. + #[tokio::test] + async fn splice_refuses_to_dial_with_an_expired_certificate() { + ensure_crypto_provider(); + let ca = new_test_ca("Issuer"); + let (cert_pem, key_pem) = sign_client_leaf(&ca, Some("relay-1"), &[], -1, 24); + let tls_config = + build_client_tls_config(&cert_pem, &key_pem, &ca.cert.pem()).expect("config"); + + let already_expired = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + - 3600; + let state = cert_state(tls_config, already_expired); + + let relay_listener = TcpListener::bind("127.0.0.1:0").await.expect("bind relay"); + let relay_addr = relay_listener.local_addr().expect("relay addr"); + let splice_task = tokio::spawn(async move { + let (agent_stream, _) = relay_listener.accept().await.expect("relay accept"); + tokio::time::timeout( + Duration::from_millis(500), + splice(agent_stream, state, "192.0.2.1:81", "localhost"), + ) + .await + }); + + let mut agent = TcpStream::connect(relay_addr).await.expect("agent connect"); + agent + .write_all(b"CONNECT example.com:443 HTTP/1.1\r\n\r\n") + .await + .expect("agent write"); + + let mut response = Vec::new(); + agent + .read_to_end(&mut response) + .await + .expect("agent read response"); + assert_eq!(response, BAD_GATEWAY); + + let outcome = splice_task + .await + .expect("splice task") + .expect("must not hang past the timeout -- proves no dial was attempted"); + outcome.expect("splice ok"); + } + + /// The remote gateway's server certificate is signed by a DIFFERENT CA + /// than `--gateway-server-ca` trusts. The TLS handshake must fail, the + /// agent must get a `502`, and — since the relay never gets past the + /// handshake — no plaintext (or any other) data can possibly reach the + /// gateway. + #[tokio::test] + async fn splice_returns_502_when_the_server_certificate_is_untrusted() { + ensure_crypto_provider(); + + // The relay trusts THIS CA as the server trust anchor... + let trusted_server_ca = new_test_ca("Trusted Server CA"); + // ...but the fake gateway presents an UNRELATED self-signed server + // certificate the relay never configured as a root. No client-cert + // requirement here (`with_no_client_auth`) so the ONLY reason the + // handshake can fail is the server-certificate mismatch this test + // is about — not an unrelated client-auth rejection. + let (server_cert_pem, server_key_pem, _server_der) = self_signed_server_cert(); + let mut server_cert_reader = server_cert_pem.as_bytes(); + let server_chain: Vec<_> = rustls_pemfile::certs(&mut server_cert_reader) + .collect::>() + .expect("server chain"); + let mut server_key_reader = server_key_pem.as_bytes(); + let server_key = rustls_pemfile::private_key(&mut server_key_reader) + .expect("parse server key") + .expect("server key present"); + let server_config = Arc::new( + rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(server_chain, server_key) + .expect("server config"), + ); + + let issuer = new_test_ca("Any Issuer"); + let (client_cert_pem, client_key_pem) = + sign_client_leaf(&issuer, Some("relay-1"), &[], -1, 24); + let tls_config = build_client_tls_config( + &client_cert_pem, + &client_key_pem, + &trusted_server_ca.cert.pem(), + ) + .expect("client config"); + let state = cert_state(tls_config, i64::MAX); + + let gateway_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind gateway"); + let gateway_addr = gateway_listener.local_addr().expect("gateway addr"); + let gateway_task = tokio::spawn(async move { + let (stream, _) = gateway_listener.accept().await.expect("gateway accept"); + tokio_rustls::TlsAcceptor::from(server_config) + .accept(stream) + .await + }); + + let relay_listener = TcpListener::bind("127.0.0.1:0").await.expect("bind relay"); + let relay_addr = relay_listener.local_addr().expect("relay addr"); + let gateway_addr_string = gateway_addr.to_string(); + let splice_task = tokio::spawn(async move { + let (agent_stream, _) = relay_listener.accept().await.expect("relay accept"); + splice(agent_stream, state, &gateway_addr_string, "localhost").await + }); + + let mut agent = TcpStream::connect(relay_addr).await.expect("agent connect"); + agent + .write_all(b"CONNECT example.com:443 HTTP/1.1\r\n\r\n") + .await + .expect("agent write"); + + let mut response = Vec::new(); + agent + .read_to_end(&mut response) + .await + .expect("agent read response"); + assert_eq!( + response, BAD_GATEWAY, + "an untrusted server certificate must produce a 502, never a plaintext fallback" + ); + + // The gateway's own TLS accept must also have failed (or the + // connection was dropped before completing) — confirming the + // rejection happened during the handshake, not after. + let gateway_result = gateway_task.await.expect("gateway task join"); + assert!( + gateway_result.is_err(), + "gateway handshake should not complete" + ); + + splice_task.await.expect("splice task").expect("splice ok"); + } +}