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
135 changes: 68 additions & 67 deletions Cargo.lock

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ loom = { version = "0.7.2", features = ["futures"] }
mdns-sd = { version = "0.20", features = ["async"] }
moq-audio = { version = "0.0.17", path = "rs/moq-audio" }
moq-flate = { version = "0.1.1", path = "rs/moq-flate" }
moq-hls = { version = "0.4.7", path = "rs/moq-hls", default-features = false }
moq-hls = { version = "0.4.8", path = "rs/moq-hls", default-features = false }
moq-json = { version = "0.3.3", path = "rs/moq-json" }
moq-loc = { version = "0.2", path = "rs/moq-loc" }
moq-msf = { version = "0.4", path = "rs/moq-msf" }
Expand All @@ -97,7 +97,7 @@ moq-net = { version = "0.2", path = "rs/moq-net" }
# driver at runtime. Compiles on any platform (macOS included) but only actually
# used by moq-video on Linux.
moq-nvenc = { version = "0.0.3", path = "rs/moq-nvenc" }
moq-rtc = { version = "0.2.3", path = "rs/moq-rtc" }
moq-rtc = { version = "0.2.4", path = "rs/moq-rtc" }
moq-rtmp = { version = "0.2.4", path = "rs/moq-rtmp" }
moq-srt = { version = "0.2.3", path = "rs/moq-srt" }
moq-stats = { version = "0.1.4", path = "rs/moq-stats" }
Expand All @@ -108,7 +108,7 @@ moq-token-cli = { version = "0.5", path = "rs/moq-token-cli" }
# can leave them off to drop the CUDA dependencies. Both crates still default
# them on when depended on directly. VAAPI is opt-in everywhere, since that
# backend has never been validated on real hardware.
moq-transcode = { version = "0.0.10", path = "rs/moq-transcode", default-features = false }
moq-transcode = { version = "0.0.11", path = "rs/moq-transcode", default-features = false }
# Standalone crate (moq-dev/vaapi); vendored from cros-libva + cros-codecs.
# dlopen's libva at runtime (no libva-dev at build, no NEEDED libva in the binary).
moq-vaapi = "0.0.3"
Expand All @@ -117,7 +117,7 @@ moq-vaapi = "0.0.3"
# builds, which want the codecs and not the device stack. Adding
# `features = ["capture"]` to a consumer that ships in those bindings pulls the
# whole device graph into every one of them.
moq-video = { version = "0.0.16", path = "rs/moq-video", default-features = false }
moq-video = { version = "0.0.17", path = "rs/moq-video", default-features = false }
percent-encoding = "2"
qmux = { version = "0.5.0", default-features = false }
serde = { version = "1", features = ["derive"] }
Expand Down
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 19 additions & 15 deletions cpp/obs/src/moq-source.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,9 @@ struct subscription_ref {
subscription_ref &operator=(const subscription_ref &) = delete;
};

// user_data for a single moq_origin_request. The generation must travel with the
// request rather than live on ctx: a reconnect can issue a new request while an
// older one still has a delivery in flight, and a single slot on ctx would let
// user_data for a single moq_origin_consume_announced. The generation must travel
// with the request rather than live on ctx: a reconnect can issue a new request while
// an older one still has a delivery in flight, and a single slot on ctx would let
// that stale delivery read the new generation and pass the staleness check.
// Allocated before the request exists and freed by its terminal on_broadcast.
struct broadcast_request {
Expand Down Expand Up @@ -698,11 +698,14 @@ static void moq_source_start_consume(struct moq_source *ctx, uint32_t expected_g
req->ctx = ctx;
req->gen = expected_gen;

// Resolve the broadcast by path against what is announced now plus any
// dynamic fallback, failing if neither can serve it. libmoq copies the path,
// Wait for the broadcast to be announced. This runs off the session-connected
// callback, and announcements arrive over the session after it connects, so
// resolving against only what is announced *now* (moq_origin_request) would race
// them and blank the source for a broadcast that is live. libmoq copies the path,
// so it need not outlive this call, and delivers the broadcast handle
// asynchronously to on_broadcast.
int32_t request = moq_origin_request(origin, broadcast_copy, strlen(broadcast_copy), on_broadcast, req);
int32_t request =
moq_origin_consume_announced(origin, broadcast_copy, strlen(broadcast_copy), on_broadcast, req);
if (request < 0) {
LOG_ERROR("Failed to request broadcast '%s': %d", broadcast_copy, request);
bfree(broadcast_copy);
Expand Down Expand Up @@ -730,15 +733,15 @@ static void moq_source_start_consume(struct moq_source *ctx, uint32_t expected_g
} else {
// Stale or shutting down: close it; its terminal releases the reference.
pthread_mutex_unlock(&ctx->mutex);
moq_origin_request_close(request);
moq_origin_consume_announced_close(request);
}
}

// Receives the broadcast resolved by moq_origin_request: a positive handle once
// served, then exactly once more with a terminal code (0 = finished, including
// after moq_origin_request_close; < 0 = could not be served). The terminal is the
// last touch of user_data, so it both frees the request context and releases the
// request's lifetime reference via subscription_ref.
// Receives the announced broadcast: a positive handle once announced, then exactly
// once more with a terminal code (0 = finished, including after
// moq_origin_consume_announced_close; < 0 = error). The terminal is the last touch
// of user_data, so it both frees the request context and releases the request's
// lifetime reference via subscription_ref.
static void on_broadcast(void *user_data, int32_t broadcast)
{
struct broadcast_request *req = (struct broadcast_request *)user_data;
Expand Down Expand Up @@ -843,10 +846,11 @@ static void moq_source_disconnect_locked(struct moq_source *ctx)
ctx->catalog_handle = -1;
}

// An unresolved request still owes a terminal on_broadcast; closing it makes
// that fire (with 0) instead of leaving it pending until the source dies.
// An unresolved wait still owes a terminal on_broadcast; closing it makes that
// fire (with 0) instead of leaving it pending until the source dies. This is the
// path that ends a wait for a broadcast that is never announced.
if (ctx->request >= 0) {
moq_origin_request_close(ctx->request);
moq_origin_consume_announced_close(ctx->request);
ctx->request = -1;
}

Expand Down
5 changes: 3 additions & 2 deletions doc/bin/relay/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,9 @@ advertises its own identity by setting `--cluster-mesh` to its
externally-reachable URL, which it publishes on the cluster origin for other
peers to discover and dial.

The `quinn` and `noq` QUIC backends support mTLS; configuring `tls.root` with a
backend that does not (e.g. `quiche`) is a startup error.
The `quinn`, `noq`, and `quiche` QUIC backends support mTLS. Quinn and noq hot
reload the trusted roots for new handshakes. Quiche currently requires a relay
restart after rotating inbound `server.tls.root` files.

## Stream Listeners

Expand Down
12 changes: 6 additions & 6 deletions doc/bin/relay/cluster.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,25 +132,25 @@ connect_api = "https://api.example.com/cluster/connect"
node = "us-west.example.com:4443"
```

The source returns a bare JSON array of peer hostnames:
The source returns a JSON array of peer URLs. Legacy bare hosts remain accepted:

```json
["eu-west.example.com:4443", "us-east.example.com:4443"]
["https://eu-west.example.com/?cost=10", "us-east.example.com:4443"]
```

The relay reconciles that list against its live dials: new entries are dialed, entries that disappear are dropped. It composes with `connect` (static seeds that are never reconciled away) and `mesh` (gossip). The relay's own `node` value, when set, is sent as a `?node=` query parameter so the endpoint can return the peers for that specific node; for mTLS-gated endpoints the cluster client certificate identifies the caller as well.
The relay reconciles that list against its live dials: new entries are dialed, entries that disappear are dropped, and a changed URL for a `connect_api`-owned peer replaces its session. That includes dial-side inputs such as `?cost=` and an inline `?jwt=`. An identical render is a no-op. It composes with `connect` (static seeds that are never reconciled away) and `mesh` (gossip). If another source already owns a peer's session, the API entry remains its updated fallback until that source disappears. The relay's own `node` value, when set, is sent as a `?node=` query parameter so the endpoint can return the peers for that specific node; for mTLS-gated endpoints the cluster client certificate identifies the caller as well.

- **HTTP(S) URL**: re-checked every 30s, but freshness is delegated to a standard HTTP cache (`http-cache`), so the response's `Cache-Control` controls how often a check turns into a real fetch. While the cached list is still fresh (`max-age`), the re-check is served from cache with no network round-trip; once it's stale the cache issues a conditional GET (`ETag` / `Last-Modified`) and falls back to the last cached body if revalidation fails (stale-if-error). Set a longer `max-age` to reduce load on your endpoint, or `no-cache` to force a conditional GET on every tick. Transient endpoint blips don't churn the dial set.
- **Local file** (a path or `file://` URL): watched via OS filesystem notifications (inotify / FSEvents / kqueue), with a periodic re-check as a safety net.

If a fetch fails or returns garbage, the relay logs and keeps the last good list rather than tearing the cluster down. This keeps the moq-relay binary generic: all routing decisions (which node connects where) live in whatever service answers the endpoint.
If a fetch fails, an entry is invalid, or one identity has conflicting entries, the relay logs and keeps the entire last good list rather than applying a partial topology. This keeps the moq-relay binary generic: all routing decisions (which node connects where) live in whatever service answers the endpoint.

## Authentication

Cluster peers must authenticate to each other:

- **mTLS** (recommended). Set `tls.root` to the CA that signed the cluster certificates. Inbound connections presenting a valid client cert are granted full access; outbound dials use `connect.tls.cert` / `connect.tls.key`.
- **JWT**. For static `connect` peers, supply the token inline as a `?jwt=` query parameter on the URL. For gossip- and `connect_api`-discovered peers (whose addresses can't carry an inline token), set `cluster.token` to a file holding the JWT; it's presented on any dial whose URL has no inline `?jwt=` (so an inline token wins per-peer). Either way the token needs broad enough scope to cover whatever paths the cluster carries.
- **JWT**. Supply a per-peer token inline as a `?jwt=` query parameter on a static or `connect_api` URL. Alternatively, set `cluster.token` to a file holding the shared JWT; it is presented on any dial whose URL has no inline token. Gossip must use the shared token or mTLS: never put a JWT in `cluster.node`, because that URL is advertised to the mesh and written to logs. Either way the token needs broad enough scope to cover whatever paths the cluster carries.

See [Authentication](/bin/relay/auth) for the full setup.

Expand All @@ -164,7 +164,7 @@ peer stays loudly visible in the logs and a returning one is picked up within se

`cluster.root` was removed. To dial cluster peers use `cluster.connect`; to advertise this relay's own address set `cluster.node` and enable `cluster.mesh`. `cluster.mesh` is now a boolean gossip toggle (it used to take this relay's URL); the URL moved to `cluster.node`. The old `mesh = "<url>"` form still works for backwards compatibility: it enables gossip and is treated as `cluster.node`, with a deprecation warning (or an error if it conflicts with an explicit `cluster.node`).

`cluster.connect` entries are now full URLs; a bare host or `host:port` still works but logs a deprecation warning. A JWT for a static peer belongs inline as a `?jwt=` query parameter (the `cluster.token` file remains for gossip / `connect_api` peers, which can't carry an inline token).
`cluster.connect` entries are now full URLs; a bare host or `host:port` still works but logs a deprecation warning. A per-peer JWT belongs inline as a `?jwt=` query parameter on a static or `connect_api` URL. The `cluster.token` file remains the shared fallback and is required for JWT-authenticated gossip; never put a JWT in the advertised `cluster.node` URL.

| Old | New |
|---|---|
Expand Down
30 changes: 25 additions & 5 deletions doc/bin/relay/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ certificate, and Unix sockets add optional peer-credential gating.
# is configured below.
bind = "[::]:443"

# MoQ versions accepted by QUIC, WebTransport, and WebSocket listeners.
# TCP and Unix stream listeners also accept moq-lite-05 because it carries
# their request path in SETUP. Omit to accept every supported version.
version = ["moq-transport-16"]

# Plaintext qmux over TCP (no TLS, carries no peer identity). Trusted networks
# only; a non-loopback bind logs a warning. Requires the `tcp` build feature.
[listen.tcp]
Expand Down Expand Up @@ -96,11 +101,14 @@ generate = ["localhost", "127.0.0.1"]
# Optional: root CAs to accept for mTLS peer authentication.
# Clients that present a cert signed by one of these CAs are granted
# full access (publish/subscribe/cluster). Intended for relay clustering.
# Supported by the quinn and noq backends.
root = ["/path/to/peer-ca.pem"]
```

For production, use certificates from Let's Encrypt or another CA.
For production, use certificates from Let's Encrypt or another CA. The Quinn
and Noq backends watch certificate, key, and root CA files and reload them for
new connections. Existing connections keep the identity established by their
original handshake. The Quiche backend reloads outbound client roots but
requires a relay restart after rotating its inbound TLS files.

### \[web.http]

Expand Down Expand Up @@ -142,8 +150,14 @@ listen = "0.0.0.0:443"
# TLS certificates (can be the same as listen.tls)
cert = "cert.pem"
key = "key.pem"

# Optional root CAs for HTTPS/WSS client certificate authentication.
root = ["/path/to/peer-ca.pem"]
```

HTTPS/WSS certificate, key, and root CA files are watched and reloaded for new
connections. A failed reload retains the last valid configuration.

### \[auth]

Authentication configuration.
Expand Down Expand Up @@ -182,12 +196,14 @@ node = "us-west.example.com:4443"
mesh = true

# Optional. Fetch the peer list from an HTTP(S) endpoint or local file (a JSON
# array of hostnames) and reconcile it at runtime, no restart needed.
# array of peer URLs) and reconcile it at runtime, replacing sessions when URL
# configuration such as ?cost= or ?jwt= changes.
connect_api = "https://api.example.com/cluster/connect"

# JWT for outbound cluster dials (alternative to mTLS), applied to any peer
# whose URL has no inline ?jwt=. Required to authenticate gossip / connect_api
# discovered peers; for static `connect` peers, prefer an inline ?jwt=.
# whose URL has no inline ?jwt=. An inline token works for static and
# connect_api-discovered peers. Gossip must use this shared token or mTLS because
# the advertised cluster.node URL is public.
token = "cluster.jwt"

[cluster.lan]
Expand Down Expand Up @@ -254,6 +270,10 @@ goaway.handover = "10s"
# resolution_delay = "50ms"
```

Custom client root files are watched and reloaded for new outbound connections.
If a changed file is temporarily missing, empty, or invalid, the relay retains
the last valid roots.

The connect timeout is also available as `--connect-timeout` or
`MOQ_CONNECT_TIMEOUT`, the address race as `--connect-race` or
`MOQ_CONNECT_RACE`, and the resolution delay as `--connect-resolution-delay` or
Expand Down
11 changes: 7 additions & 4 deletions doc/concept/layer/hang.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,17 +89,20 @@ The `role` field is `subtitle` (spoken dialogue) or `caption` (all audio, includ
The frame timestamp carries the cue's start time on the same media clock as audio and video, so cues schedule against the same playhead with no separate pacing.
The section is omitted entirely when a broadcast publishes no captions.

A publisher can set a rendition's optional `stalled` field to recommend temporarily avoiding it without removing or closing the track.
Players prefer decoder-supported unstalled renditions and can fall back to a stalled rendition when none remain.

### Cross-broadcast renditions

A rendition may set an optional `broadcast` field: a path relative to the broadcast that served the catalog (e.g. `"../source"`), pointing at another broadcast that publishes the actual track.
A consumer resolves the reference against the path it reached the catalog broadcast at (`..` pops a segment, other segments append) and subscribes to the track on the resolved broadcast over the same connection.
That is the path the consumer asked for, not one the publisher declares, so a reference can only ever name a broadcast the consumer could have named itself.
A rendition may set an optional `broadcast` field: a path relative to the broadcast that served the catalog (e.g. `"./source"`), pointing at another broadcast that publishes the actual track.
A consumer resolves a non-empty reference like a relative URL: it replaces the catalog broadcast's last path segment, then applies `.` and `..` segments. An empty reference names the catalog broadcast itself.
It resolves against the path it reached the catalog broadcast at, not one the publisher declares, so a reference can only ever name a broadcast the consumer could have named itself, and subscribes to the track there over the same connection.
When the field is absent, the track lives in the same broadcast as the catalog.
A reference that walks above the root (more `..` than the catalog path has segments) names no broadcast, so the whole catalog is rejected rather than pointed at whatever path the walk stops on.
The root is the consumer's authorized subtree, so such a reference is an attempt to name content it cannot reach: a publisher emitting one has a bug, and quietly serving the remaining renditions would hide that.

This lets a transcoder publish a sidecar catalog that adds new renditions while pointing unchanged ones at the original broadcast, instead of re-publishing those bytes through the transcoder.
For example, a transcoder consuming `room/source` can publish `room/transcode` whose catalog contains a downscaled `480p` rendition plus the original `1080p` rendition marked `"broadcast": "../source"`.
For example, a transcoder consuming `room/source` can publish `room/transcode` whose catalog contains a downscaled `480p` rendition plus the original `1080p` rendition marked `"broadcast": "./source"`.
A viewer of `room/transcode` then pulls `480p` from the transcoder and `1080p` directly from the source, and the relay dedupes the source subscription with the transcoder's own.

Rejection happens where the catalog is read, not where a track is subscribed: the rendition set drives track layouts, playlists, codec lists, and quality selectors, so a reference caught any later would already have been offered and chosen. In Rust the `moq-mux` catalog stream rejects it and every exporter reads through that stream; `@moq/watch` rejects the catalog it would otherwise publish.
Expand Down
Loading
Loading