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
10 changes: 9 additions & 1 deletion doc/bin/relay/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ mark another relay and an [upstream link](/bin/relay/cluster#upstream-links).
The fields are documented on `moq_auth::Request` and `moq_auth::Grant`
([Rust](/lib/rs/moq-auth), [TypeScript](/lib/js/auth)).

`server_name` is the SNI that selected the relay's certificate. It is absent
over plaintext, on the `tls://` TCP listener, and for an IP-literal dial, and
never filled from a `Host` header or a WebTransport authority. The client still picks it among the names
the relay serves, so a hostname-scoped server binds the tenant to the
credential, not to `server_name` alone.

**Connect.** A 2xx with a grant admits. A 403 can carry JSON such as
`{"reason":"expired","root":"rooms/123","tier":"edge"}`. The reason is
`refused`, `forbidden`, or `expired`; unknown reason strings read as `refused`.
Expand Down Expand Up @@ -264,7 +270,9 @@ Cluster peers are admitted the same way, so a mesh runs
`moq auth serve --mtls-publish '**' --mtls-subscribe '**' --mtls-peer` (or a
server granting its cluster CA everything with `peer: true`); see
[Clustering](/bin/relay/cluster). `--mtls-peer` marks every certificate as
another relay; leave it off when certificates identify clients.
another relay; leave it off when certificates identify clients. Without
`peer`, a certificate is a client's: it routes like one and sees
[hidden](/concept/moq-lite#hidden-broadcasts) routes only by asking.
`--mtls-upstream`, which needs `--mtls-peer`, also marks them
[upstream](/bin/relay/cluster#upstream-links). Changing either flag ends each
live mTLS session at its next re-check (the `--revalidate` cadence or a
Expand Down
7 changes: 5 additions & 2 deletions doc/bin/relay/cluster.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,11 @@ needs an auth server that grants the cluster CA, such as
`moq auth serve --mtls-publish '**' --mtls-subscribe '**' --mtls-peer`. An
accepted peer counts as a cluster peer only when its grant sets `peer: true`,
as `--mtls-peer` does; otherwise what it announces counts as ingest here, like
a client's. Dials retry forever with jittered backoff capped at 30s, so a rejected
peer is loud in the logs rather than fatal. See [Authentication](/bin/relay/auth#mtls).
a client's, and it lists [hidden](/concept/moq-lite#hidden-broadcasts) routes
only by asking, which a relay below moq-lite-07-wip, or on moq-transport
without the hidden extension, cannot. Dials retry forever with jittered backoff
capped at 30s, so a rejected peer is loud in the logs rather than fatal. See
[Authentication](/bin/relay/auth#mtls).

The `/nodes` [internal endpoint](/bin/relay/http#get-nodes) lists the peers
this relay dialed and holds a session with.
12 changes: 10 additions & 2 deletions js/auth/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,11 @@ const BaseRequestSchema = z.object({
remote: z.optional(z.string()),
/** The relay's socket address the session arrived on, absent likewise. */
local: z.optional(z.string()),
/** The SNI the client presented, when the transport carried TLS. */
/**
* The SNI that selected the relay's certificate, absent without TLS or SNI.
* The client picks it among the names the relay serves, so a hostname-scoped
* server binds the tenant to the credential, not to this alone.
*/
server_name: z.optional(z.string()),
/** The negotiated application protocol, including the moq version. */
alpn: z.optional(z.string()),
Expand Down Expand Up @@ -176,7 +180,11 @@ export const GrantSchema = z
tier: z.optional(z.string()),
/** The literal path this session's stats record under, independent of its root. Absent records under the empty label. */
label: z.optional(PathSchema),
/** The session is a cluster peer (another relay): what it announces entered the cluster elsewhere. */
/**
* The session is a cluster peer (another relay): what it announces entered the cluster elsewhere,
* and it discovers hidden routes without asking, for peers that predate the opt-in.
* A client certificate alone is not.
*/
peer: z.optional(z.boolean()),
/** The peer is upstream: the relay never offers it a route learned from another upstream. Requires `peer`. */
upstream: z.optional(z.boolean()),
Expand Down
1 change: 0 additions & 1 deletion quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,6 @@ tracks, so per-group stats stop costing cluster announces.
- [Artifact size audit](/quest/m1/audit-size.md) - binary, FFI, WASM, and bundle growth since the m0 run is explained, fixed, or planned
- [Compile time audit](/quest/m1/audit-compile-time.md) - build-time hotspots are measured against the m0 baselines and the worst are fixed or planned
- [Caption store bounds](/quest/m1/caption-store-bounds.md) - a hostile caption track cannot grow a viewer's cue and region store or its per-frame work without bound
- [Auth contract](/quest/m1/auth-contract.md) - the auth server receives the certificate-selecting SNI, documented as client-chosen, and a certificate grants nothing alone
- [Peers advertise their implementation and version](/quest/m1/peer-version.md) - moq-net and `@moq/net` send a `name/version` SETUP parameter both ways, and the auth request and session span carry the client's
- [Binding peer version](/quest/m1/binding-peer-version.md) - every binding names itself ahead of the Rust core in that parameter
- [Nightly benchmark regressions](/quest/m1/bench-ci.md) - each nightly compares the Rust Criterion benches against the previous nightly's commit on the same hosted runner, publishes it to the git.moq.dev report, and alerts on a regression
Expand Down
29 changes: 0 additions & 29 deletions quest/m1/auth-contract.md

This file was deleted.

2 changes: 1 addition & 1 deletion quest/m1/peer-version.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,4 @@ Implementation value changes text.
## Related

- [Binding peer version](/quest/m1/binding-peer-version.md) - each binding names itself on top of this
- [Auth contract](/quest/m1/auth-contract.md) - also changes what the auth request reports
- [moq-dev/moq#6509](https://github.com/moq-dev/moq/pull/6509) - also changes what the auth request reports
64 changes: 62 additions & 2 deletions rs/moq-auth/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ use crate::{Bytes, Error, Event, Grant, Request};
/// Every request is bounded so a hung server refuses rather than parks the session.
const TIMEOUT: Duration = Duration::from_secs(10);

/// The largest reply read from the auth server. Each one is held whole before it is
/// parsed, once per admission in flight, so the size bounds that memory; a grant
/// with thousands of patterns still fits.
const MAX_REPLY: usize = 1 << 20;

/// The HTTP side of the contract: one JSON POST per event to an auth server.
///
/// `connect` admits a session and hands back the [`lease::Consumer`] it holds; a task
Expand Down Expand Up @@ -273,10 +278,15 @@ impl Post for Client {
let response = self.http.post(self.url.clone()).json(request).send().await?;
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Err(Error::Refused(response.json().await.unwrap_or_default()));
let refusal = reply(response)
.await
.ok()
.and_then(|body| serde_json::from_slice(&body).ok());
return Err(Error::Refused(refusal.unwrap_or_default()));
}
check(status)?;
Ok(response.json().await?)
serde_json::from_slice(&reply(response).await?)
.map_err(|err| Error::Unavailable(format!("auth server answered an invalid grant: {err}")))
}

async fn post_end(&self, request: &Request) -> crate::Result<()> {
Expand All @@ -298,6 +308,20 @@ fn check(status: reqwest::StatusCode) -> crate::Result<()> {
Ok(())
}

/// Read a reply body of at most [`MAX_REPLY`] bytes, refusing a longer one as it arrives.
async fn reply(mut response: reqwest::Response) -> crate::Result<Vec<u8>> {
let mut body = Vec::new();
while let Some(chunk) = response.chunk().await? {
if body.len() + chunk.len() > MAX_REPLY {
return Err(Error::Unavailable(format!(
"auth server reply exceeds {MAX_REPLY} bytes"
)));
}
body.extend_from_slice(&chunk);
}
Ok(body)
}

/// Admit a session through `server` and spawn the driver behind its lease.
///
/// The connection's own `connect` runs under the caller's deadline; an attached
Expand Down Expand Up @@ -1004,6 +1028,42 @@ mod tests {
assert!(matches!(err, Error::Refused(_)), "{err}");
}

/// A reply of exactly `MAX_REPLY` bytes admits; one byte more is an outage, or a
/// plain refusal on a 403, never read whole.
#[tokio::test]
async fn replies_are_bounded() {
let padded = |status: u16, body: &str, len: usize| {
// JSON allows trailing whitespace, so the padded reply still parses.
let body = format!("{body}{}", " ".repeat(len - body.len()));
ResponseTemplate::new(status).set_body_raw(body, "application/json")
};
let grant = serde_json::to_string(&grant(None, None)).unwrap();
let refusal = r#"{"reason":"expired","root":"tenant"}"#;

let fits = server(Log::default(), {
let grant = grant.clone();
move |_| padded(200, &grant, MAX_REPLY)
})
.await;
client(&fits)
.connect(request())
.await
.expect("a reply at the bound admits");

let over = server(Log::default(), move |_| padded(200, &grant, MAX_REPLY + 1)).await;
let err = client(&over).connect(request()).await.unwrap_err();
assert!(
matches!(&err, Error::Unavailable(message) if message.contains("exceeds")),
"{err}"
);

let refused = server(Log::default(), move |_| padded(403, refusal, MAX_REPLY + 1)).await;
let Error::Refused(actual) = client(&refused).connect(request()).await.unwrap_err() else {
panic!("expected refusal")
};
assert_eq!(actual, crate::Refusal::default());
}

#[tokio::test]
async fn typed_refusal_preserves_reason_root_and_tier() {
use crate::refusal::Reason;
Expand Down
3 changes: 2 additions & 1 deletion rs/moq-auth/src/grant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ pub struct Grant {
pub label: Option<String>,

/// The session is a cluster peer (another relay): what it announces entered
/// the cluster elsewhere, not here.
/// the cluster elsewhere, not here, and it discovers hidden routes without
/// asking, for peers that predate the opt-in. A client certificate alone is not.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub peer: bool,

Expand Down
5 changes: 4 additions & 1 deletion rs/moq-auth/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ pub struct Request {
/// The relay's socket address the session arrived on, absent likewise.
pub local: Option<SocketAddr>,

/// The SNI the client presented, when the transport carried TLS.
/// The SNI that selected the relay's certificate, absent without TLS or SNI.
///
/// The client picks it among the names the relay serves, so a hostname-scoped
/// server binds the tenant to the credential, not to this alone.
pub server_name: Option<String>,

/// The negotiated application protocol, including the moq version.
Expand Down
10 changes: 10 additions & 0 deletions rs/moq-relay/MIGRATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ Rolling a cluster across a release also follows the [upgrade guide](https://moq.

## Unreleased

### Auth `server_name` is the SNI only (#6509)

The auth request's `server_name` no longer falls back to the HTTP `Host` header, `:authority`, or the WebTransport CONNECT host: it is absent over plain `http://` and `ws://` and for an IP-literal dial, while HTTPS and `wss://` report the SNI.
An auth server that scopes tenants by hostname must see them over TLS, and still binds the tenant to the credential.

### A client certificate alone lists no hidden routes (#6509)

Only a grant with `peer: true` lists `.`-named routes to a session that did not ask for them.
Have the auth server mark cluster peers as `peer` (`moq auth serve --mtls-peer`) before upgrading, or a peer below moq-lite-07-wip loses those paths.

### `stats.interval` is a duration (#6472)

`stats.interval`, `--stats-interval`, and `MOQ_STATS_INTERVAL` take a duration such as `"1s"` or `"500ms"` instead of a bare number of seconds, and a zero interval refuses to start.
Expand Down
7 changes: 3 additions & 4 deletions rs/moq-relay/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1052,10 +1052,9 @@ pub fn request_for(auth: &Auth, request: &moq_tokio::server::Request) -> Request
});
out.remote = request.remote_addr();
out.local = request.local_addr();
out.server_name = request
.server_name()
.map(str::to_owned)
.or_else(|| request.authority().map(str::to_owned));
// Only the SNI: a WebTransport CONNECT authority or WebSocket `Host` is client-chosen
// without having selected a certificate, so a session without SNI reports none.
out.server_name = request.server_name().map(str::to_owned);
out.alpn = request.alpn().map(str::to_owned);
out.role = request.role().and_then(role);
out.tls = request.peer_identity().as_ref().and_then(peer);
Expand Down
49 changes: 42 additions & 7 deletions rs/moq-relay/src/cluster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1306,7 +1306,7 @@ impl Cluster {
admitted.subscriber = self.subscriber(&bound).map(|origin| {
origin
.consume()
.with_hidden(request.tls.is_some() || Self::is_lan_path(&request.path))
.with_hidden(bound.peer)
.with_stats(admitted.stats.clone())
});
}
Expand Down Expand Up @@ -1344,15 +1344,15 @@ impl Cluster {
Some(moq_auth::Role::Subscriber) => None,
_ => publisher.map(|origin| origin.with_stats(stats.clone())),
};
// An authenticated cluster peer (a verified client certificate or the LAN
// credential) discovers hidden routes whether or not it asks. A peer that
// predates the hidden opt-in (below moq-lite-07-wip, or moq-transport without
// MoQ Hidden) would otherwise lose every dot path during a rolling upgrade.
// A cluster peer (a `peer` grant, which the LAN credential also gets) discovers
// hidden routes whether or not it asks. A peer that predates the hidden opt-in
// (below moq-lite-07-wip, or moq-transport without MoQ Hidden) would otherwise
// lose every dot path during a rolling upgrade. A client certificate alone is a
// fact for the auth server, never this exemption.
// TODO: drop the exemption once deployed peers all opt in.
let cluster_peer = request.tls.is_some() || Self::is_lan_path(&request.path);
let subscriber = match request.role {
Some(moq_auth::Role::Publisher) => None,
_ => subscriber.map(|origin| origin.consume().with_hidden(cluster_peer).with_stats(stats.clone())),
_ => subscriber.map(|origin| origin.consume().with_hidden(token.peer).with_stats(stats.clone())),
};
Ok(Admitted {
lease: lease.with_stats(stats.clone()),
Expand Down Expand Up @@ -2362,6 +2362,41 @@ mod tests {
}
}

/// A verified certificate is a fact for the auth server: only a `peer` grant
/// discovers hidden routes without asking, on both scoping paths.
#[tokio::test]
async fn only_a_peer_grant_discovers_hidden_routes() {
let cluster = new_cluster(Config::default()).expect("cluster");
let _hidden = cluster.origin.publish(".stats/node", origin::Route::default());
let _visible = cluster.origin.publish("room", origin::Route::default());
let mut request = moq_auth::Request::new("test", moq_auth::Transport::Quic, "/");
request.tls = Some(moq_auth::Peer {
name: "edge0".into(),
fingerprint: "ab".repeat(32),
expires: None,
issuer: "CN=cluster".into(),
});
for peer in [false, true] {
for session in [false, true] {
let mut grant = moq_auth::Grant::new(moq_auth::Pattern::all(), moq_auth::Pattern::all());
grant.peer = peer;
let lease = auth::Lease::new("/", moq_auth::lease::Consumer::fixed(grant));
let admitted = match session {
true => cluster.scope_session(lease, &request),
false => cluster.scope(lease, &request),
}
.expect("admitted");
let mut announced = admitted.subscriber.expect("subscribe grant").announced();
let mut seen = Vec::new();
while let Some(route) = try_next_announced(&mut announced) {
seen.push(route.prefix.to_string());
}
let expected: &[&str] = if peer { &[".stats/node", "room"] } else { &["room"] };
assert_eq!(seen, expected, "peer={peer}, session={session}");
}
}
}

/// A millisecond track: the frames written here carry timestamps.
fn timed() -> moq_net::track::Info {
moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI)
Expand Down
4 changes: 2 additions & 2 deletions rs/moq-relay/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ pub struct Filter {
pub remote: Option<Remote>,
/// The relay's local socket, exact including port.
pub local: Option<SocketAddr>,
/// SNI or the host the client addressed.
/// The SNI that selected the relay's certificate; sessions without one never match.
pub server_name: Option<String>,
/// Negotiated application protocol.
pub alpn: Option<String>,
Expand Down Expand Up @@ -428,7 +428,7 @@ pub struct View {
pub remote: Option<SocketAddr>,
/// The relay's socket address the session arrived on.
pub local: Option<SocketAddr>,
/// The SNI the client presented.
/// The SNI that selected the relay's certificate.
pub server_name: Option<String>,
/// The negotiated application protocol.
pub alpn: Option<String>,
Expand Down
9 changes: 2 additions & 7 deletions rs/moq-relay/src/uring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -797,13 +797,8 @@ async fn serve_request(
}
auth_request.remote = Some(remote);
auth_request.local = Some(serve.local);
// Like the tokio listener: the SNI, else the host a WebTransport client addressed.
auth_request.server_name = sni.or_else(|| {
url.as_ref()
.and_then(|url| url.host_str())
.filter(|host| !host.is_empty())
.map(str::to_owned)
});
// Only the SNI, like the tokio listener: the CONNECT host selected no certificate.
auth_request.server_name = sni;
auth_request.alpn = alpn.clone();
auth_request.tls = identity.as_ref().and_then(crate::auth::peer);
let admission = lifecycle.clone();
Expand Down
Loading
Loading