From 5b3d324bf21064aa0ab675e281627826a85c181f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 5 Oct 2026 23:48:28 -0700 Subject: [PATCH 1/4] quest: claim relay-auth-client-ca Co-Authored-By: Claude Opus 5.5 From 07050f8eba5ea5edc66fcc41e43232ebb57c3eda Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 6 Oct 2026 00:08:59 -0700 Subject: [PATCH 2/4] fix(relay): auth validate and init take the client-CA answer Fold validate_client_ca into auth::Config::validate(client_ca) and make init require the same answer, so no caller can skip the check. The moq CLI refuses sessions only for a LAN-only mesh with no auth source; any other invalid auth config stops startup. A client CA on a stream-only listener is refused, since only the QUIC listener verifies it. Co-Authored-By: Claude Opus 5.5 --- doc/bin/cli.md | 3 +- doc/bin/relay/auth.md | 4 +- doc/bin/relay/config.md | 2 +- doc/setup/upgrade.md | 7 +++ quest/m1/README.md | 1 - quest/m1/cli-serve.md | 10 +--- quest/m1/relay-auth-client-ca.md | 37 ------------ rs/moq-cli/src/args.rs | 22 ++++--- rs/moq-cli/src/main.rs | 61 ++++++++++++++++--- rs/moq-relay/src/auth.rs | 74 +++++++++++++----------- rs/moq-relay/src/config.rs | 2 +- rs/moq-relay/src/relay.rs | 8 +-- rs/moq-relay/src/web.rs | 6 +- rs/moq-relay/tests/auth_lifetime.rs | 2 +- rs/moq-relay/tests/goaway_cluster.rs | 2 +- rs/moq-relay/tests/public_rules.rs | 17 +++++- rs/moq-relay/tests/session_revalidate.rs | 2 +- rs/moq-relay/tests/smoke.rs | 4 +- rs/moq-tokio/src/error.rs | 4 +- rs/moq-tokio/src/server.rs | 33 +++++++---- 20 files changed, 172 insertions(+), 129 deletions(-) delete mode 100644 quest/m1/relay-auth-client-ca.md diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 84a4c4cc26..8ec6e33862 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -45,7 +45,8 @@ carries a token), and `--broadcast ` names the broadcast. A process can instead host sessions with `--listen`, or both at once. A listener admits clients by `--auth-url` or `--auth-public`, as the relay does (see [Authentication](/bin/relay/auth)); public rules ignore certificates, so -`--auth-public` refuses to start with `--listen-tls-root`. `moq import --help` lists the sources and `moq import rtmp --help` a specific one. +`--auth-public` refuses to start with `--listen-tls-root`, and only `--listen` +(QUIC) verifies one. `moq import --help` lists the sources and `moq import rtmp --help` a specific one. ```bash # Publish a file (remux to MPEG-TS without re-encoding) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 5b624bc874..1801a5f9ed 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -242,7 +242,9 @@ decision: the relay reports its facts in the request's `tls` and enforces the grant it gets back. `moq auth serve` grants a certificate only what `--mtls-publish` and `--mtls-subscribe` name, empty by default. Public rules ignore certificates, so a relay or `moq --listen` on `--auth-public` refuses -to start with `listen.tls.root` or `web.https.root`. +to start with `listen.tls.root` or `web.https.root`. Only the QUIC listener +verifies `listen.tls.root`, so a stream-only relay (no `listen.bind`) refuses +to start with it too. Cluster peers are admitted the same way, so a mesh runs `moq auth serve --mtls-publish '**' --mtls-subscribe '**'` (or a server diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index be3b7bd702..b2337553fc 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -25,7 +25,7 @@ timeout = "10s" # Handshake deadline. "0" waits forever. cert = "cert.pem" # Certificate chain and key. Reloaded on change. key = "key.pem" generate = ["localhost"] # Or: a self-signed cert for development. -root = ["peer-ca.pem"] # Optional: CAs for client certs (mTLS), reported to the auth server. +root = ["peer-ca.pem"] # Optional: CAs for client certs (mTLS), reported to the auth server. Needs QUIC. [listen.tcp] # Plaintext qmux over TCP for trusted local workers. bind = "127.0.0.1:4444" diff --git a/doc/setup/upgrade.md b/doc/setup/upgrade.md index 010f616fbf..f6e2ac436d 100644 --- a/doc/setup/upgrade.md +++ b/doc/setup/upgrade.md @@ -78,6 +78,13 @@ These land with the next breaking release, not the 2026-09-23 train. broadcast that ends with media queued behind an undescribed track is an error rather than an empty `Ok(None)`. Restart the export to pick up a new rendition. +- **moq-relay auth takes the client-CA answer.** `auth::Config::validate` and + `init` take `client_ca: bool`, whether any listener verifies client + certificates, and `validate_client_ca` is gone. `moq --listen` with an + invalid auth config stops at startup instead of refusing every session. +- **A client CA needs a QUIC listener.** A stream-only relay or `moq` listener + (TCP or Unix, no `--listen`) refuses to start with `listen.tls.root`, which + nothing verified; moq-tokio returns `Error::MtlsUnsupported` for it. ## Wire diff --git a/quest/m1/README.md b/quest/m1/README.md index 5cd69305a1..5edbd4136f 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -71,7 +71,6 @@ blocks. The quests that gated m0 lines moved under them. - [Subscriber max-delay](/quest/m1/subscriber-max-delay.md) - subscriber staleness becomes `max_delay`; publisher retention stays `max_age` - [Wire compatibility](/quest/m1/wire-compat.md) - a nightly run tests this checkout against the last published release for tokens, session wire, and catalog/container - [Same-epoch importers](/quest/m1/hop-aligned-import.md) - importers sharing one `--epoch` and fed one stream publish identical groups and timestamps, so failover between a redundant pair survives -- [Auth client CA](/quest/m1/relay-auth-client-ca.md) - `auth::Config::validate` and `init` take the client-CA flag, so no caller can skip the check - [TS stats module](/quest/m1/ts-stats-module.md) - the TS stats types move under `ts::stats` as `Snapshot` and `Stream`, with an owned `track` - [Audio capture without ALSA link](/quest/m1/capture-alsa-link.md) - moq-audio capture and playback build on Linux without linking libasound - [Capture by default](/quest/m1/capture-default.md) - moq-video and moq-audio build `capture` by default, so pre-merge checks test it and the capture gate goes away diff --git a/quest/m1/cli-serve.md b/quest/m1/cli-serve.md index 994b594686..3a15f77bce 100644 --- a/quest/m1/cli-serve.md +++ b/quest/m1/cli-serve.md @@ -13,9 +13,9 @@ minimal binary; this does not make `moq` the relay. ## Plan -Decided in the 2026-10-05 audit: re-scoped to what remains, after -[Auth client CA](/quest/m1/relay-auth-client-ca.md), which reworks the same -`spawn_server` auth path. +Decided in the 2026-10-05 audit: re-scoped to what remains. The `spawn_server` +auth path now refuses only a LAN-only mesh with no auth source and stops +startup on any other invalid auth config. - Drain: on SIGTERM the listener sends GOAWAY and waits `drain_timeout` like the relay, through a real `shutdown::Observer` instead of the disabled one. @@ -31,10 +31,6 @@ Decided in the 2026-10-05 audit: re-scoped to what remains, after Decided in the 2026-09-30 audit: no longer waits on [`moq relay`](/quest/m3/moq-relay-subcommand.md). -## Required - -- [Auth client CA](/quest/m1/relay-auth-client-ca.md) - reworks the same `spawn_server` auth path first - ## Related - [`moq relay`](/quest/m3/moq-relay-subcommand.md) - the CLI later hosts the whole relay diff --git a/quest/m1/relay-auth-client-ca.md b/quest/m1/relay-auth-client-ca.md deleted file mode 100644 index d020ee67f9..0000000000 --- a/quest/m1/relay-auth-client-ca.md +++ /dev/null @@ -1,37 +0,0 @@ -# [S] moq-relay auth validate takes the client-CA flag - -## Goal - -No caller of `moq_relay::auth::Config` can start with `--auth-public` -rules alongside a listener TLS client CA by forgetting a check. -`Config::validate` takes the client-CA flag, `init` requires it too, and -`validate_client_ca` is gone. The `moq` CLI fails loud on an invalid auth -config instead of quietly refusing every session. - -## Plan - -- [#4364](https://github.com/moq-dev/moq/pull/4364) (merged 09-28, and - now on `main`) added an additive - `Config::validate_client_ca(&self, client_ca: bool)` that `Relay::load` and - the CLI must each remember to call; the CLI missing the original check is - the bug it fixes. Fold it into `validate(&self, client_ca: bool)` so every - caller has to answer, and have `init` take the same answer so a caller that - skips `validate` still cannot start. `init` today only receives the - outbound auth TLS, so the listener's client-CA answer is a new input; its - shape (a bool, or the listener TLS config) is open. Prefer whatever makes - the wrong call unrepresentable. -- `spawn_server` in `rs/moq-cli/src/main.rs` maps any `auth.validate()` error - to `Auth::refuse`. That fallback is only right for a LAN-only mesh with no - auth configured, which `MoqSide::validate` permits and whose peers admit - through the cluster. Make that case explicit and let any other error stop - startup. -- A listener TLS client CA on a stream-only relay (no QUIC owner at all, - the `NoBackend` case in `rs/moq-relay/src/relay.rs`) is never checked, so - refuse that config at load instead of silently ignoring the CA. Worker-owned - QUIC (`quic_owned_elsewhere`) still enforces the CA, so keep accepting it. -- Update every caller, the tests #4364 added in both `moq-cli` and - `moq-relay`, and `doc/bin/relay/auth.md` or `doc/lib/rs` wherever they name - the methods. - -Public API: breaks `moq_relay::auth::Config::validate` and `init`, removes -`validate_client_ca`. Wire: none. diff --git a/rs/moq-cli/src/args.rs b/rs/moq-cli/src/args.rs index ce86586fcc..ffb0003a32 100644 --- a/rs/moq-cli/src/args.rs +++ b/rs/moq-cli/src/args.rs @@ -30,7 +30,6 @@ //! an `import hls` playlist path starting with `-`, which `./-name` covers, so //! the separator stays unconditional rather than context-sensitive. -use anyhow::Context as _; use std::ffi::{OsStr, OsString}; use std::time::Duration; @@ -512,20 +511,19 @@ impl MoqSide { } // A listener for ordinary clients admits nobody without a decision; a mesh // listener alone admits its peers by their LAN credential. - if self.server.has_explicit_bind() { - self.auth - .validate() - .context("--listen needs --auth-url or --auth-public")?; - } else if self.auth.url.is_some() || self.auth_public() { - self.auth.validate()?; + anyhow::ensure!( + !(self.server.has_explicit_bind() && self.auth.is_empty()), + "--listen needs --auth-url or --auth-public" + ); + if !self.auth.is_empty() { + self.auth.validate(self.client_ca())?; } - self.auth.validate_client_ca(!self.server.tls.root.is_empty())?; Ok(()) } - /// Whether any public pattern was passed. - fn auth_public(&self) -> bool { - !(self.auth.public.is_empty() && self.auth.public_subscribe.is_empty() && self.auth.public_publish.is_empty()) + /// Whether the listener verifies client certificates (`--listen-tls-root`). + pub fn client_ca(&self) -> bool { + !self.server.tls.root.is_empty() } /// The MoQ-side flags one chunk of a command line typed, each once, in order. @@ -1313,7 +1311,7 @@ mod tests { #[test] fn a_client_ca_needs_an_auth_server() { let parse = |auth: [&str; 2]| { - let mut argv = vec!["moq", "--listen-tcp-bind", "127.0.0.1:0", "--listen-tls-root", "ca.pem"]; + let mut argv = vec!["moq", "--listen", "127.0.0.1:0", "--listen-tls-root", "ca.pem"]; argv.extend(auth); argv.extend(["import", "ts"]); Invocation::try_parse_from(argv).expect("parse") diff --git a/rs/moq-cli/src/main.rs b/rs/moq-cli/src/main.rs index 1044b44aa6..09876f1ee6 100644 --- a/rs/moq-cli/src/main.rs +++ b/rs/moq-cli/src/main.rs @@ -88,12 +88,13 @@ async fn spawn_server( let server = net.server(moq.server_config())?; let certificates = server.certificates(); let cluster = attach_lan(cluster.clone(), moq, &server)?; - // The auth server or public grant a `--listen` endpoint admits through. A mesh - // listener with neither admits its peers alone. + // The auth server or public grant a `--listen` endpoint admits through. A + // LAN-mesh listener with neither admits its peers alone; any other config + // `init` refuses stops startup. let node = moq.cluster.node.clone().unwrap_or_default(); - let auth = match moq.auth.validate() { - Ok(()) => moq.auth.init(node, &moq.client.tls)?, - Err(_) => moq_relay::auth::Auth::refuse(node), + let auth = match moq.auth.is_empty() && !moq.server.has_explicit_bind() { + true => moq_relay::auth::Auth::refuse(node), + false => moq.auth.init(node, &moq.client.tls, moq.client_ca())?, }; // Advertise before accepting, so a `/.cluster` dial is verified against a // live credential rather than refused as "LAN discovery is not enabled". @@ -902,8 +903,9 @@ mod tests { async fn a_stream_bind_failure_prevents_readiness() { let occupied = std::net::TcpListener::bind("127.0.0.1:0").expect("occupy a port"); let addr = occupied.local_addr().expect("occupied address").to_string(); - let invocation = Invocation::try_parse_from(["moq", "--listen-tcp-bind", &addr, "import", "ts"]) - .expect("parse stream-only invocation"); + let invocation = + Invocation::try_parse_from(["moq", "--listen-tcp-bind", &addr, "--auth-public", "**", "import", "ts"]) + .expect("parse stream-only invocation"); let cluster = invocation.moq.cluster().expect("create cluster"); let net = Net { quic: invocation.moq.quic.clone(), @@ -937,9 +939,10 @@ mod tests { assert!(tasks.is_empty(), "nothing should be spawned after a bind failure"); } - /// A raw TCP bind is a complete server side even when no QUIC bind is set. + /// An explicit listener with no auth source stops startup instead of refusing + /// every session, even for a caller that skipped `MoqSide::validate`. #[tokio::test] - async fn tcp_only_moq_side_starts_a_server() { + async fn a_listener_without_auth_stops_startup() { let invocation = Invocation::try_parse_from(["moq", "--listen-tcp-bind", "127.0.0.1:0", "import", "ts"]) .expect("parse TCP-only invocation"); let cluster = invocation.moq.cluster().expect("create cluster"); @@ -950,6 +953,46 @@ mod tests { }; let mut tasks = JoinSet::new(); + let err = match spawn_server( + &mut tasks, + &invocation.moq, + &cluster, + &net, + Directions { + publish: true, + consume: false, + }, + ) + .await + { + Ok(_) => panic!("a listener without auth must not start"), + Err(err) => err, + }; + assert!(err.to_string().contains("nobody can authenticate"), "{err:#}"); + assert!(tasks.is_empty(), "nothing should be spawned without auth"); + } + + /// A raw TCP bind is a complete server side even when no QUIC bind is set. + #[tokio::test] + async fn tcp_only_moq_side_starts_a_server() { + let invocation = Invocation::try_parse_from([ + "moq", + "--listen-tcp-bind", + "127.0.0.1:0", + "--auth-public", + "**", + "import", + "ts", + ]) + .expect("parse TCP-only invocation"); + let cluster = invocation.moq.cluster().expect("create cluster"); + let net = Net { + quic: invocation.moq.quic.clone(), + #[cfg(feature = "iroh")] + iroh: None, + }; + let mut tasks = JoinSet::new(); + if let Err(err) = spawn_server( &mut tasks, &invocation.moq, diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index f3cee4285c..a3e580b8b5 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -241,13 +241,15 @@ impl Config { self.url.is_none() && self.public_grant().is_none() } - /// Refuse a configuration that admits nobody, or that names both a server and - /// a static grant, so the question of who decides has one answer. + /// Refuse a configuration that admits nobody, that names both a server and a + /// static grant, or that pairs public rules with a client CA, so the question + /// of who decides has one answer. `client_ca` is whether any listener verifies + /// client certificates, which public rules would grant what they grant anyone. /// /// A public pattern without a wildcard is refused too. 0.14 read `anon` as the /// prefix `anon/`, and a pattern reads it as exactly the broadcast `anon`, so /// either silent reading would mislead someone upgrading. - pub fn validate(&self) -> anyhow::Result<()> { + pub fn validate(&self, client_ca: bool) -> anyhow::Result<()> { let flags = [ ("--auth-public", &self.public), ("--auth-public-publish", &self.public_publish), @@ -269,27 +271,19 @@ impl Config { (None, None) => anyhow::bail!( "no --auth-url or --auth-public configured; nobody can authenticate (a client certificate admits nothing on its own)" ), - _ => Ok(()), - } - } - - /// Refuse a client CA under public rules, which grant a certificate what they - /// grant anyone. `client_ca` is whether any listener verifies client certificates. - pub fn validate_client_ca(&self, client_ca: bool) -> anyhow::Result<()> { - if client_ca && self.url.is_none() && self.public_grant().is_some() { - anyhow::bail!( + (None, Some(_)) if client_ca => anyhow::bail!( "a client CA (--listen-tls-root, --web-https-root) verifies client certificates, which --auth-public ignores; remove it, or grant certificates with --auth-url to `moq auth serve --mtls-*`" - ); + ), + _ => Ok(()), } - Ok(()) } - /// Build the [`Auth`] this configuration describes. `tls` is the client - /// identity an `https://` server is dialed with; `node` names this relay in - /// every request. Must be called within a Tokio runtime, which drives the - /// admission decider. - pub fn init(&self, node: impl Into, tls: &moq_tokio::tls::Connect) -> anyhow::Result { - self.validate()?; + /// Build the [`Auth`] this configuration describes, after [`validate`](Self::validate) + /// with the same `client_ca`. `tls` is the client identity an `https://` server + /// is dialed with; `node` names this relay in every request. Must be called + /// within a Tokio runtime, which drives the admission decider. + pub fn init(&self, node: impl Into, tls: &moq_tokio::tls::Connect, client_ca: bool) -> anyhow::Result { + self.validate(client_ca)?; let decider = match (&self.url, self.public_grant()) { (Some(url), _) => { let tls = tls.build()?; @@ -785,21 +779,35 @@ mod tests { #[test] fn exactly_one_source() { - assert!(config(None, &[]).validate().is_err()); - assert!(config(Some("http://127.0.0.1:4440/"), &["**"]).validate().is_err()); - assert!(config(Some("http://127.0.0.1:4440/"), &[]).validate().is_ok()); - assert!(config(None, &["anon/**"]).validate().is_ok()); + assert!(config(None, &[]).validate(false).is_err()); + assert!(config(Some("http://127.0.0.1:4440/"), &["**"]).validate(false).is_err()); + assert!(config(Some("http://127.0.0.1:4440/"), &[]).validate(false).is_ok()); + assert!(config(None, &["anon/**"]).validate(false).is_ok()); let split = Config { public_subscribe: patterns(&["anon/**"]).into_iter().collect(), ..Default::default() }; - assert!(split.validate().is_ok()); + assert!(split.validate(false).is_ok()); let grant = split.public_grant().unwrap(); assert_eq!(grant.subscribe, patterns(&["anon/**"])); assert!(grant.publish.is_empty()); } + /// Public rules grant a certificate what they grant anyone, so a client CA + /// needs an auth server, and `init` refuses it like `validate` does. + #[test] + fn a_client_ca_needs_an_auth_server() { + let public = config(None, &["anon/**"]); + let err = public.validate(true).unwrap_err().to_string(); + assert!(err.contains("--auth-public ignores"), "{err}"); + let Err(err) = public.init("relay-1", &moq_tokio::tls::Connect::default(), true) else { + panic!("init accepted a client CA under public rules"); + }; + assert!(err.to_string().contains("--auth-public ignores"), "{err}"); + assert!(config(Some("http://127.0.0.1:4440/"), &[]).validate(true).is_ok()); + } + /// The public rules are rooted at `/`, like a token with an empty root. Bare `**` /// reads the same either way, which is how rooting them at the dialed path went /// unnoticed: `anon/**` at `/rooms/123` granted `rooms/123/anon/**`. @@ -810,7 +818,7 @@ mod tests { public_subscribe: patterns(&["anon/**", "*/chat"]).into_iter().collect(), ..Default::default() } - .init("relay-1", &moq_tokio::tls::Connect::default()) + .init("relay-1", &moq_tokio::tls::Connect::default(), false) .unwrap(); for (path, root, publish, subscribe) in [ @@ -840,7 +848,7 @@ mod tests { #[tokio::test] async fn public_rules_refuse_a_token() { let auth = config(None, &["**"]) - .init("relay-1", &moq_tokio::tls::Connect::default()) + .init("relay-1", &moq_tokio::tls::Connect::default(), false) .unwrap(); let mut query = auth.request(moq_auth::Transport::WebSocket, "/"); query.query = Some("a=1&jwt=eyJ".into()); @@ -868,7 +876,7 @@ mod tests { #[tokio::test] async fn a_public_config_admits_anonymous_and_certificate_alike() { let auth = config(None, &["anon/**"]) - .init("relay-1", &moq_tokio::tls::Connect::default()) + .init("relay-1", &moq_tokio::tls::Connect::default(), false) .unwrap(); let anonymous = auth.request(moq_auth::Transport::Quic, "/anon/room"); let mut certificate = anonymous.clone(); @@ -891,27 +899,27 @@ mod tests { #[test] fn a_public_pattern_without_a_wildcard_refuses_to_start() { for (public, hint) in [("anon", "anon/**"), ("anon/room", "anon/room/**"), ("", "**")] { - let err = config(None, &[public]).validate().unwrap_err().to_string(); + let err = config(None, &[public]).validate(false).unwrap_err().to_string(); assert!(err.contains(&format!("`{hint}`")), "{public}: {err}"); } let split = Config { public_subscribe: patterns(&["anon/**", "live"]).into_iter().collect(), ..Default::default() }; - let err = split.validate().unwrap_err().to_string(); + let err = split.validate(false).unwrap_err().to_string(); assert!(err.starts_with("--auth-public-subscribe `live`"), "{err}"); - assert!(config(None, &["anon/*", "*/chat", "**"]).validate().is_ok()); + assert!(config(None, &["anon/*", "*/chat", "**"]).validate(false).is_ok()); // Nothing sits beneath a literal at the maximum depth, so it is its own subtree. let deepest = vec!["a"; Pattern::MAX_SEGMENTS].join("/"); - assert!(config(None, &[&deepest]).validate().is_ok()); + assert!(config(None, &[&deepest]).validate(false).is_ok()); } #[test] fn config_init_requires_a_runtime() { let result = std::panic::catch_unwind(|| { let _ = config(None, &["anon/**"]) - .init("relay-1", &moq_tokio::tls::Connect::default()) + .init("relay-1", &moq_tokio::tls::Connect::default(), false) .unwrap(); }); assert!(result.is_err()); diff --git a/rs/moq-relay/src/config.rs b/rs/moq-relay/src/config.rs index e4448b2abc..356b67508d 100644 --- a/rs/moq-relay/src/config.rs +++ b/rs/moq-relay/src/config.rs @@ -1349,7 +1349,7 @@ uid = [1001] .expect("config load"); assert!( - config.auth.validate().is_ok(), + config.auth.validate(false).is_ok(), "CLI public flags must admit anonymous sessions" ); assert_eq!(config.auth.public_subscribe, vec!["demo/**".parse().unwrap()]); diff --git a/rs/moq-relay/src/relay.rs b/rs/moq-relay/src/relay.rs index c10c65d457..494466321e 100644 --- a/rs/moq-relay/src/relay.rs +++ b/rs/moq-relay/src/relay.rs @@ -126,9 +126,6 @@ impl Relay { .clone() .or_else(|| config.cluster.node.clone()) .unwrap_or_default(); - config - .auth - .validate_client_ca(!(config.listen.tls.root.is_empty() && config.web.https.root.is_empty()))?; // No `[auth]` source means the embedder decides: it takes the admissions // before `run`, which refuses to start if nobody did. let (auth, admissions) = match config.auth.is_empty() { @@ -136,7 +133,10 @@ impl Relay { let (auth, admissions) = auth::Auth::embedded(node); (auth, Some(admissions)) } - false => (config.auth.init(node, &config.connect.tls)?, None), + false => { + let client_ca = !(config.listen.tls.root.is_empty() && config.web.https.root.is_empty()); + (config.auth.init(node, &config.connect.tls, client_ca)?, None) + } }; #[cfg(feature = "cluster-lan")] diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index 6a878d0b0f..ea0b9e53b4 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -1386,7 +1386,7 @@ mod tests { public_subscribe: vec![moq_auth::Pattern::all()], ..Default::default() }; - let auth = auth_config.init("test", &moq_tokio::tls::Connect::default()).unwrap(); + let auth = auth_config.init("test", &moq_tokio::tls::Connect::default(), false).unwrap(); let cluster = cluster::Cluster::new(crate::cluster::Options::default()).unwrap(); let certificates = moq_tokio::tls::Certificates::from_pem(&std::fs::read(&cert).unwrap()).unwrap(); @@ -1426,7 +1426,7 @@ mod tests { public_subscribe: vec![moq_auth::Pattern::all()], ..Default::default() }; - let auth = auth_config.init("test", &moq_tokio::tls::Connect::default()).unwrap(); + let auth = auth_config.init("test", &moq_tokio::tls::Connect::default(), false).unwrap(); let cluster = cluster::Cluster::new(crate::cluster::Options::default()).unwrap(); let dir = TempDir::new().unwrap(); let (_, cert, _) = make_certs(&dir); @@ -1449,7 +1449,7 @@ mod tests { public_subscribe: vec![moq_auth::Pattern::all()], ..Default::default() }; - let auth = auth_config.init("test", &moq_tokio::tls::Connect::default()).unwrap(); + let auth = auth_config.init("test", &moq_tokio::tls::Connect::default(), false).unwrap(); let cluster = cluster::Cluster::new(crate::cluster::Options::default()).unwrap(); let dir = TempDir::new().unwrap(); let (_, cert, _) = make_certs(&dir); diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index 0c35d93acc..598e640538 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -121,7 +121,7 @@ fn build_auth(url: url::Url) -> moq_relay::auth::Auth { let mut config = auth::Config::default(); config.url = Some(url); config - .init("test-relay", &moq_tokio::tls::Connect::default()) + .init("test-relay", &moq_tokio::tls::Connect::default(), false) .expect("auth init") } diff --git a/rs/moq-relay/tests/goaway_cluster.rs b/rs/moq-relay/tests/goaway_cluster.rs index 2bacd26e84..611ca27931 100644 --- a/rs/moq-relay/tests/goaway_cluster.rs +++ b/rs/moq-relay/tests/goaway_cluster.rs @@ -389,7 +389,7 @@ async fn spawn_relay_with_upstream( let mut auth_config = auth::Config::default(); auth_config.public = vec![moq_auth::Pattern::all()]; let auth = auth_config - .init("test", &moq_tokio::tls::Connect::default()) + .init("test", &moq_tokio::tls::Connect::default(), false) .expect("auth init"); let mut cluster_config = cluster::Config::default(); diff --git a/rs/moq-relay/tests/public_rules.rs b/rs/moq-relay/tests/public_rules.rs index cb7be07523..3d854a1567 100644 --- a/rs/moq-relay/tests/public_rules.rs +++ b/rs/moq-relay/tests/public_rules.rs @@ -26,7 +26,7 @@ fn client() -> moq_tokio::Client { async fn spawn_relay(auth: auth::Config) -> (u16, tokio::task::JoinHandle<()>) { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let auth = auth - .init("test", &moq_tokio::tls::Connect::default()) + .init("test", &moq_tokio::tls::Connect::default(), false) .expect("auth init"); let cluster = cluster::Cluster::new(cluster::Options::default()).expect("cluster init"); @@ -273,3 +273,18 @@ async fn a_client_ca_needs_an_auth_server() { assert!(error.contains("--auth-public ignores"), "{error}"); } } + +/// A stream-only relay has no QUIC listener to verify a client CA, and its stream +/// listeners carry no TLS, so the CA is refused rather than silently ignored. +#[tokio::test] +async fn a_client_ca_needs_a_quic_listener() { + let mut config = moq_relay::Config::default(); + config.auth.url = Some("http://127.0.0.1:4440/".parse().expect("parse url")); + config.listen.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); + config.listen.tls.root = vec!["ca.pem".into()]; + let error = match moq_relay::Relay::load(config).await { + Ok(_) => panic!("a stream-only relay accepted a client CA"), + Err(error) => error.to_string(), + }; + assert!(error.contains("needs a QUIC listener"), "{error}"); +} diff --git a/rs/moq-relay/tests/session_revalidate.rs b/rs/moq-relay/tests/session_revalidate.rs index 77225c7802..c6a42a575c 100644 --- a/rs/moq-relay/tests/session_revalidate.rs +++ b/rs/moq-relay/tests/session_revalidate.rs @@ -108,7 +108,7 @@ fn build_auth(url: url::Url) -> moq_relay::auth::Auth { let mut config = auth::Config::default(); config.url = Some(url); config - .init("test-relay", &moq_tokio::tls::Connect::default()) + .init("test-relay", &moq_tokio::tls::Connect::default(), false) .expect("auth init") } diff --git a/rs/moq-relay/tests/smoke.rs b/rs/moq-relay/tests/smoke.rs index 1f468466f3..d789b991bd 100644 --- a/rs/moq-relay/tests/smoke.rs +++ b/rs/moq-relay/tests/smoke.rs @@ -50,7 +50,7 @@ async fn build_web_with(web_config: web::Config) -> web::Web { let mut auth_config = auth::Config::default(); auth_config.public = vec![moq_auth::Pattern::all()]; let auth = auth_config - .init("test", &moq_tokio::tls::Connect::default()) + .init("test", &moq_tokio::tls::Connect::default(), false) .expect("auth init"); let cluster = cluster::Cluster::new(cluster::Options::default()).expect("cluster init"); @@ -596,7 +596,7 @@ async fn spawn_accept_relay( let server = config.init(Default::default()).expect("server init"); let auth = auth_config - .init("test", &moq_tokio::tls::Connect::default()) + .init("test", &moq_tokio::tls::Connect::default(), false) .expect("auth init"); let cluster = cluster::Cluster::new(cluster::Options::default()).expect("cluster init"); diff --git a/rs/moq-tokio/src/error.rs b/rs/moq-tokio/src/error.rs index f603fc1307..4e11e80b66 100644 --- a/rs/moq-tokio/src/error.rs +++ b/rs/moq-tokio/src/error.rs @@ -138,8 +138,8 @@ pub enum Error { #[error("Iroh support is not enabled")] IrohDisabled, - /// A client certificate was configured, but this QUIC backend can't do mTLS. - #[error("tls.root (mTLS) is not supported by the selected QUIC backend")] + /// A client CA was configured, but no QUIC listener is built to verify it. + #[error("tls.root (mTLS) needs a QUIC listener; the TCP, Unix, and WebSocket listeners carry no TLS to verify a client certificate")] MtlsUnsupported, /// A worker group was asked for more members than the connection ID's one-byte diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index 1a360703b8..33f61f99e5 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -301,17 +301,11 @@ impl Server { return Err(Error::NoBackend("--listen requires the noq feature")); } - if build_quic && !config.tls.root.is_empty() { - // Only the QUIC backend validates client certificates; the qmux listeners - // (tcp/unix/websocket) carry no TLS of their own. - #[cfg(feature = "noq")] - let mtls_supported = true; - #[cfg(not(feature = "noq"))] - let mtls_supported = false; - - if !mtls_supported { - return Err(Error::MtlsUnsupported); - } + // Only the QUIC backend verifies client certificates; the qmux listeners + // (tcp/unix/websocket) carry no TLS of their own, so a stream-only server + // would ignore the CA. A caller opening only the streams owns QUIC elsewhere. + if parts.quic() && !config.tls.root.is_empty() && !(build_quic && cfg!(feature = "noq")) { + return Err(Error::MtlsUnsupported); } // The member is a serving handle released by a complete reuseport group, @@ -2307,6 +2301,23 @@ mod tests { drop((session, connection)); } + /// A client CA on a stream-only server is refused: no QUIC listener would + /// verify it, and the stream listeners carry no TLS. + #[cfg(feature = "tcp")] + #[tokio::test] + async fn client_ca_without_a_quic_listener_is_rejected() { + let mut config = crate::listen::Config::default(); + config.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); + config.tls.root = vec!["ca.pem".into()]; + + assert!(matches!( + config.clone().init(Default::default()), + Err(Error::MtlsUnsupported) + )); + // A worker group owning QUIC verifies it, so the streams alone accept it. + config.init_streams().expect("streams beside worker-owned QUIC"); + } + /// An explicit QUIC bind cannot be honored without a QUIC backend. #[cfg(not(feature = "noq"))] #[test] From d5b371bc6212fb7566168efc0217f727745b1ac5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 6 Oct 2026 00:16:11 -0700 Subject: [PATCH 3/4] style: rustfmt Co-Authored-By: Claude Opus 5.5 --- rs/moq-relay/src/auth.rs | 7 ++++++- rs/moq-relay/src/web.rs | 12 +++++++++--- rs/moq-tokio/src/error.rs | 4 +++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index a3e580b8b5..8bcedfee9d 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -282,7 +282,12 @@ impl Config { /// with the same `client_ca`. `tls` is the client identity an `https://` server /// is dialed with; `node` names this relay in every request. Must be called /// within a Tokio runtime, which drives the admission decider. - pub fn init(&self, node: impl Into, tls: &moq_tokio::tls::Connect, client_ca: bool) -> anyhow::Result { + pub fn init( + &self, + node: impl Into, + tls: &moq_tokio::tls::Connect, + client_ca: bool, + ) -> anyhow::Result { self.validate(client_ca)?; let decider = match (&self.url, self.public_grant()) { (Some(url), _) => { diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index ea0b9e53b4..a443e0cd7b 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -1386,7 +1386,9 @@ mod tests { public_subscribe: vec![moq_auth::Pattern::all()], ..Default::default() }; - let auth = auth_config.init("test", &moq_tokio::tls::Connect::default(), false).unwrap(); + let auth = auth_config + .init("test", &moq_tokio::tls::Connect::default(), false) + .unwrap(); let cluster = cluster::Cluster::new(crate::cluster::Options::default()).unwrap(); let certificates = moq_tokio::tls::Certificates::from_pem(&std::fs::read(&cert).unwrap()).unwrap(); @@ -1426,7 +1428,9 @@ mod tests { public_subscribe: vec![moq_auth::Pattern::all()], ..Default::default() }; - let auth = auth_config.init("test", &moq_tokio::tls::Connect::default(), false).unwrap(); + let auth = auth_config + .init("test", &moq_tokio::tls::Connect::default(), false) + .unwrap(); let cluster = cluster::Cluster::new(crate::cluster::Options::default()).unwrap(); let dir = TempDir::new().unwrap(); let (_, cert, _) = make_certs(&dir); @@ -1449,7 +1453,9 @@ mod tests { public_subscribe: vec![moq_auth::Pattern::all()], ..Default::default() }; - let auth = auth_config.init("test", &moq_tokio::tls::Connect::default(), false).unwrap(); + let auth = auth_config + .init("test", &moq_tokio::tls::Connect::default(), false) + .unwrap(); let cluster = cluster::Cluster::new(crate::cluster::Options::default()).unwrap(); let dir = TempDir::new().unwrap(); let (_, cert, _) = make_certs(&dir); diff --git a/rs/moq-tokio/src/error.rs b/rs/moq-tokio/src/error.rs index 4e11e80b66..4ba55dab86 100644 --- a/rs/moq-tokio/src/error.rs +++ b/rs/moq-tokio/src/error.rs @@ -139,7 +139,9 @@ pub enum Error { IrohDisabled, /// A client CA was configured, but no QUIC listener is built to verify it. - #[error("tls.root (mTLS) needs a QUIC listener; the TCP, Unix, and WebSocket listeners carry no TLS to verify a client certificate")] + #[error( + "tls.root (mTLS) needs a QUIC listener; the TCP, Unix, and WebSocket listeners carry no TLS to verify a client certificate" + )] MtlsUnsupported, /// A worker group was asked for more members than the connection ID's one-byte From 1976205d54164f12aa9fe7903fc21b0c55f2dcda Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 6 Oct 2026 01:51:19 -0700 Subject: [PATCH 4/4] fix(tokio): name the missing backend, and give the tls:// test a QUIC listener A client CA with no QUIC backend compiled in now returns NoBackend instead of the stream-only MtlsUnsupported message, and MtlsUnsupported names the flags (--listen-tls-root, --listen). The tls:// test from #4816 set a client CA on a TCP-only server, which this PR refuses; it now adds the QUIC listener that verifies the CA, and still shows the TCP listener asks for no certificate. Co-Authored-By: Claude Opus 5.5 --- rs/moq-relay/tests/public_rules.rs | 3 ++- rs/moq-tokio/src/error.rs | 4 ++-- rs/moq-tokio/src/server.rs | 37 ++++++++++++++++++++++++++---- rs/moq-tokio/src/tcp.rs | 15 +++++++++++- 4 files changed, 50 insertions(+), 9 deletions(-) diff --git a/rs/moq-relay/tests/public_rules.rs b/rs/moq-relay/tests/public_rules.rs index 3d854a1567..0a3e4fe7f5 100644 --- a/rs/moq-relay/tests/public_rules.rs +++ b/rs/moq-relay/tests/public_rules.rs @@ -275,7 +275,8 @@ async fn a_client_ca_needs_an_auth_server() { } /// A stream-only relay has no QUIC listener to verify a client CA, and its stream -/// listeners carry no TLS, so the CA is refused rather than silently ignored. +/// listeners never ask for a client certificate, so the CA is refused rather than +/// silently ignored. #[tokio::test] async fn a_client_ca_needs_a_quic_listener() { let mut config = moq_relay::Config::default(); diff --git a/rs/moq-tokio/src/error.rs b/rs/moq-tokio/src/error.rs index 4ba55dab86..baa74cdc85 100644 --- a/rs/moq-tokio/src/error.rs +++ b/rs/moq-tokio/src/error.rs @@ -138,9 +138,9 @@ pub enum Error { #[error("Iroh support is not enabled")] IrohDisabled, - /// A client CA was configured, but no QUIC listener is built to verify it. + /// A client CA was configured on a stream-only server, which has no QUIC listener to verify it. #[error( - "tls.root (mTLS) needs a QUIC listener; the TCP, Unix, and WebSocket listeners carry no TLS to verify a client certificate" + "--listen-tls-root (mTLS) needs a QUIC listener (--listen); the TCP and Unix listeners never ask for a client certificate" )] MtlsUnsupported, diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index 5f72fb102f..5bdb0156d5 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -302,10 +302,14 @@ impl Server { } // Only the QUIC backend verifies client certificates; the qmux listeners - // (tcp/unix/websocket) carry no TLS of their own, so a stream-only server - // would ignore the CA. A caller opening only the streams owns QUIC elsewhere. - if parts.quic() && !config.tls.root.is_empty() && !(build_quic && cfg!(feature = "noq")) { - return Err(Error::MtlsUnsupported); + // (tcp/unix/websocket) never ask for one, even over `tls://`, so a + // stream-only server would ignore the CA. A caller opening only the streams owns QUIC elsewhere. + if parts.quic() && !config.tls.root.is_empty() { + if !build_quic { + return Err(Error::MtlsUnsupported); + } + #[cfg(not(feature = "noq"))] + return Err(Error::NoBackend("--listen-tls-root requires the noq feature")); } // The member is a serving handle released by a complete reuseport group, @@ -2371,7 +2375,7 @@ mod tests { } /// A client CA on a stream-only server is refused: no QUIC listener would - /// verify it, and the stream listeners carry no TLS. + /// verify it, and the stream listeners never ask for a client certificate. #[cfg(feature = "tcp")] #[tokio::test] async fn client_ca_without_a_quic_listener_is_rejected() { @@ -2406,6 +2410,29 @@ mod tests { )); } + /// A client CA on the default QUIC listener names the missing backend, not a + /// stream-only server the caller never configured. + #[cfg(not(feature = "noq"))] + #[test] + fn client_ca_without_a_quic_backend_is_rejected() { + let config = crate::listen::Config { + tls: crate::tls::Listen { + root: vec!["ca.pem".into()], + ..Default::default() + }, + ..Default::default() + }; + + assert!(matches!( + Config { + listen: config, + ..Default::default() + } + .init(), + Err(Error::NoBackend(_)) + )); + } + /// A QUIC-only server reports nothing. It multiplexes over one UDP socket and /// never calls `accept`, so a zero counter would be a watch that cannot fire. #[cfg(all(feature = "noq", not(feature = "tcp")))] diff --git a/rs/moq-tokio/src/tcp.rs b/rs/moq-tokio/src/tcp.rs index 853dc883b8..2d9f45a0f0 100644 --- a/rs/moq-tokio/src/tcp.rs +++ b/rs/moq-tokio/src/tcp.rs @@ -364,6 +364,7 @@ mod tests { #[tokio::test] async fn tls_carries_qmux_and_refuses_an_untrusted_certificate() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let dir = tempfile::tempdir().unwrap(); for pinned in [false, true] { let mut listen = crate::listen::Config::default(); listen.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); @@ -371,7 +372,19 @@ mod tests { listen.tls.generate = vec!["localhost".into()]; match pinned { true => listen.tls.peers = Some(crate::tls::Peers::new()), - false => listen.tls.root = vec!["/nonexistent/client-ca.pem".into()], + // A client CA is refused without a QUIC listener to verify it. + false => { + if !cfg!(feature = "noq") { + continue; + } + let ca = dir.path().join("client-ca.pem"); + let cert = rcgen::generate_simple_self_signed(["client-ca".to_string()]) + .unwrap() + .cert; + std::fs::write(&ca, cert.pem()).unwrap(); + listen.bind = Some("127.0.0.1:0".parse().unwrap()); + listen.tls.root = vec![ca]; + } } tls_round_trip(listen).await; }