Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion doc/bin/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ carries a token), and `--broadcast <name>` 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)
Expand Down
4 changes: 3 additions & 1 deletion doc/bin/relay/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,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
Expand Down
2 changes: 1 addition & 1 deletion doc/bin/relay/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions doc/setup/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,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.
- **moq-tokio's `Transport` names WebTransport.** `moq_tokio::server::Transport`
is `moq_tokio::Transport`, with no re-export. A WebTransport session reports
`Transport::WebTransport` (`"webtransport"` in logs) instead of `Quic`, which
Expand Down
1 change: 0 additions & 1 deletion quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,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
- [Refuse ignored TLS config](/quest/m1/refuse-ignored-tls.md) - TLS options no listener would use fail at load instead of being ignored
- [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`
- [cpal loads libasound at runtime](/quest/m1/cpal-alsa-runtime.md) - condition: a cpal release whose Linux build carries no load-time libasound requirement
Expand Down
10 changes: 3 additions & 7 deletions quest/m1/cli-serve.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
37 changes: 0 additions & 37 deletions quest/m1/relay-auth-client-ca.md

This file was deleted.

10 changes: 3 additions & 7 deletions quest/m2/tls-listener-mtls.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,10 @@ leaving it open.
Keep the token path working on the same listener, and keep pinned peers and
CA roots meaning the same as on QUIC.

[Relay client-CA validation](/quest/m1/relay-auth-client-ca.md) refuses a
listener TLS client CA on a stream-only relay (the `NoBackend` case in
`rs/moq-relay/src/relay.rs`), since nothing checks it there. Once a `tls://`
[Relay client-CA validation](https://github.com/moq-dev/moq/pull/4912) refuses a
listener TLS client CA on a stream-only relay (the `MtlsUnsupported` case in
`rs/moq-tokio/src/server.rs`), since nothing checks it there. Once a `tls://`
listener verifies the certificate, this quest lifts that refusal for a relay
with a `tls://` listener and updates the refusal's test.

Public API: relay config may gain a listener option. Wire: none.

## Required

- [Relay client-CA validation](/quest/m1/relay-auth-client-ca.md) - adds the stream-only client-CA refusal this lifts for `tls://`
22 changes: 10 additions & 12 deletions rs/moq-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1319,7 +1317,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")
Expand Down
61 changes: 52 additions & 9 deletions rs/moq-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,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".
Expand Down Expand Up @@ -916,8 +917,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(),
Expand Down Expand Up @@ -951,9 +953,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");
Expand All @@ -964,6 +967,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,
Expand Down
Loading
Loading