From 1b2bc89b68c36e835ff70f7952dad83213a47ef5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 30 Jul 2026 16:23:21 -0700 Subject: [PATCH 1/4] fix(native): send the raw QUIC request path and query in the SETUP A `moqt://` / `moql://` dial dropped the URL path and query. moq-native only derived a SETUP path for the `tcp://` and `unix://` schemes, so a raw QUIC client reached the server's default path with no `?jwt=`. `setup_path` now keys off the URL scheme and covers `moqt`/`moql` alongside `tcp`/`unix`, carrying the query as well: path-abempty plus `?` and the query, per draft-ietf-moq-transport-19 section 10.3.1.2. That also fixes `tcp://`, which dropped the query the same way. Schemes whose transport carries its own request URI still send nothing, where a SETUP path is a protocol violation. Co-Authored-By: Claude Opus 5 --- doc/bin/cli.md | 1 + doc/lib/rs/env/native.md | 3 + drafts/draft-lcurley-moq-lite.md | 9 +- js/net/src/lite/setup.ts | 7 +- rs/moq-native/src/client.rs | 137 ++++++++++++++++++++----------- rs/moq-native/tests/backend.rs | 76 ++++++++++++++++- rs/moq-net/src/client.rs | 2 + rs/moq-net/src/lite/setup.rs | 9 +- rs/moq-relay/tests/smoke.rs | 70 ++++++++++++++-- 9 files changed, 247 insertions(+), 67 deletions(-) diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 26f2d36104..b916b499e4 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -152,6 +152,7 @@ incumbent rather than waiting behind it, so a reconnect is live again without waiting for the relay's transport to time out the connection it replaced. The last publisher to announce a path owns it, so use authorization to decide who may publish where. + ### Capture a Webcam The `capture` subcommand captures and encodes from local devices directly, no diff --git a/doc/lib/rs/env/native.md b/doc/lib/rs/env/native.md index 074977007b..f67f2da5fd 100644 --- a/doc/lib/rs/env/native.md +++ b/doc/lib/rs/env/native.md @@ -37,6 +37,9 @@ The client supports several URL schemes: - `moqt://` — Raw QUIC with the MoQ IETF ALPN (no WebTransport overhead) - `moql://` — Raw QUIC with the moq-lite ALPN +The URL path and query mean the same thing on every scheme. +Raw QUIC has no request URI to put them in, so the client sends them in the MoQ SETUP instead; the server sees the same request path either way. + ### Transport Racing `client.connect()` automatically races QUIC and WebSocket connections. diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index ec144c3a17..5a6525cf8d 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -636,11 +636,13 @@ A subscriber MUST consult the publisher's advertised level before relying on a P - At `Increase`, the subscriber MAY request a target bitrate and expect the publisher to actively probe up to it. ### Path Parameter {#path-parameter} -The Path Parameter carries the request path the client wishes to reach, equivalent to the path component of a moq-lite URI. +The Path Parameter carries the request target the client wishes to reach, equivalent to the path and query components of a moq-lite URI. A server uses it to route the session to the correct origin, relay, or virtual host before any broadcasts are exchanged; its interpretation is otherwise application-defined and opaque to moq-lite. Unlike the capability-style Setup Parameters, it is per-hop setup metadata that rides along in SETUP because that is the first client-to-server message of the session. -The Parameter Value is a UTF-8 string using the path syntax of a URI [RFC3986]; a non-empty value begins with `/`. +The Parameter Value is a UTF-8 string holding the `path-abempty` component of the URI [RFC3986]; when the URI carries a query, the client MUST append `?` followed by the `query` component. +A value whose path component is non-empty therefore begins with `/`. +Carrying the query matters because a deployment commonly puts the session credential there, so a client that dropped it would arrive unauthenticated. The value MAY be empty, which is equivalent to omitting the parameter: both mean the client requests the server's default path. A client that wants the default therefore need not special-case the parameter, and a server MUST treat the two forms identically. @@ -650,7 +652,7 @@ The remaining bindings convey the path in their own handshake. - A client using a binding without a request URI (binding 1 or 3) SHOULD send one Path Parameter in its SETUP. Omitting it requests the server's default path. - The Path Parameter MUST NOT be sent on a binding that carries a request URI. The WebTransport (binding 2) and Qmux-over-WebSocket (binding 4) bindings convey the path in their handshake URI (the CONNECT request path and the WebSocket request URI, respectively). A server that receives a Path Parameter on either of these bindings MUST close the session with a PROTOCOL_VIOLATION. - A server MUST NOT send a Path Parameter. SETUP is bidirectional, but the path is meaningful only from client to server; a client that receives a Path Parameter MUST close the session with a PROTOCOL_VIOLATION. -- A server that receives a Path that is not a valid URI path MUST close the session with a PROTOCOL_VIOLATION. A server that does not recognize or support the requested path MUST close the session. +- A server that receives a Path that is not a valid URI path, optionally followed by `?` and a valid query, MUST close the session with a PROTOCOL_VIOLATION. A server that does not recognize or support the requested path MUST close the session. A relay MUST NOT forward the Path Parameter; like other per-hop setup metadata it applies only to this hop (see [Session](#session)). @@ -1189,6 +1191,7 @@ The `Message Length` describes the payload size on the wire. # Appendix A: Changelog ## moq-lite-06 +- Extended the SETUP `Path` parameter to carry the URI query: a client appends `?` and the query component after the path, matching moq-transport's PATH option. The credential a deployment puts in the query was previously unrepresentable on a binding with no request URI. - Allowed an empty SETUP `Path` parameter, equivalent to omitting it; both request the server's default path. Previously an empty value was a protocol violation, which made the two ways of asking for the default disagree. - Corrected SUBSCRIBE_END `Group` to an exclusive bound: the first sequence that will never be delivered, with 0 meaning no groups were produced. It was previously specified as the inclusive last group, which could not distinguish an empty track from one whose only group was 0. - Split ANNOUNCE_BROADCAST into three typed messages: ANNOUNCE_START (0x0), ANNOUNCE_END (0x1), and ANNOUNCE_RESTART (0x2), each prefixed with a Type discriminator like the subscribe stream's responses. diff --git a/js/net/src/lite/setup.ts b/js/net/src/lite/setup.ts index 372d66ece3..c7d18574d3 100644 --- a/js/net/src/lite/setup.ts +++ b/js/net/src/lite/setup.ts @@ -186,9 +186,10 @@ export class Setup { /** * The request path, for transports that carry no request URI (native QUIC, qmux over - * TCP/TLS, unix sockets). Sent only by the client; a server never sends one and a relay - * never forwards it. `undefined` on URI-carrying bindings such as WebTransport, where - * sending one is a protocol violation. An empty string means the same as `undefined`. + * TCP/TLS, unix sockets), with `?` and the URI query appended when there is one. Sent + * only by the client; a server never sends one and a relay never forwards it. + * `undefined` on URI-carrying bindings such as WebTransport, where sending one is a + * protocol violation. An empty string means the same as `undefined`. */ path?: string; diff --git a/rs/moq-native/src/client.rs b/rs/moq-native/src/client.rs index 7c99b8fc9b..5af716d6c1 100644 --- a/rs/moq-native/src/client.rs +++ b/rs/moq-native/src/client.rs @@ -327,15 +327,6 @@ impl Client { Ok(crate::spawn_session(pair)) } - /// The moq client builder, with `path` advertised in the SETUP if present. - #[cfg(any(feature = "tcp", feature = "uds"))] - fn moq_with_path(&self, path: Option) -> moq_net::Client { - match path { - Some(path) => self.moq.clone().with_path(path), - None => self.moq.clone(), - } - } - #[cfg(any( feature = "noq", feature = "quinn", @@ -346,35 +337,35 @@ impl Client { feature = "uds" ))] async fn connect_inner(&self, url: Url) -> crate::Result<(moq_net::Session, moq_net::Driver)> { + // Transports with no request URI of their own advertise the resource path in the + // SETUP instead; `setup_path` returns `None` for the ones that carry a URI, where + // sending it again is a protocol violation. + let moq = match setup_path(&url) { + Some(path) => self.moq.clone().with_path(path), + None => self.moq.clone(), + }; + // Plain TCP (qmux, no TLS). Explicit opt-in scheme; never raced against // QUIC, which can't speak it. Use only on a trusted network. - // - // qmux carries no request URI, so the resource path travels in the lite-05 - // SETUP. The URL path is the resource for `tcp://`. #[cfg(feature = "tcp")] if url.scheme() == "tcp" { - let path = setup_path(&url, false); let session = crate::tcp::connect(url, &self.versions.alpns()).await?; - return Ok(self.moq_with_path(path).connect(session).await?); + return Ok(moq.connect(session).await?); } // Unix domain socket (qmux, no TLS). Same-host only; the server can // authenticate us by uid/gid via SO_PEERCRED. - // - // The URL path is the socket location, so the resource path rides in the - // `?path=` query and travels in the lite-05 SETUP. #[cfg(all(feature = "uds", unix))] if url.scheme() == "unix" { - let path = setup_path(&url, true); let session = crate::unix::connect(url, &self.versions.alpns()).await?; - return Ok(self.moq_with_path(path).connect(session).await?); + return Ok(moq.connect(session).await?); } #[cfg(feature = "iroh")] if url.scheme() == "iroh" { let endpoint = self.iroh.as_ref().ok_or(Error::IrohDisabled)?; let session = crate::iroh::connect(endpoint, url, self.iroh_addrs.iter().copied()).await?; - let session = self.moq.connect(session).await?; + let session = moq.connect(session).await?; return Ok(session); } @@ -386,13 +377,13 @@ impl Client { #[cfg(feature = "websocket")] { - return self.race_moq_connect(url, quic_handle).await; + return self.race_moq_connect(&moq, url, quic_handle).await; } #[cfg(not(feature = "websocket"))] { let session = quic_handle.await?; - return Ok(self.moq.connect(session).await?); + return Ok(moq.connect(session).await?); } } @@ -404,13 +395,13 @@ impl Client { #[cfg(feature = "websocket")] { - return self.race_moq_connect(url, quic_handle).await; + return self.race_moq_connect(&moq, url, quic_handle).await; } #[cfg(not(feature = "websocket"))] { let session = quic_handle.await?; - return Ok(self.moq.connect(session).await?); + return Ok(moq.connect(session).await?); } } @@ -421,13 +412,13 @@ impl Client { #[cfg(feature = "websocket")] { - return self.race_moq_connect(url, quic_handle).await; + return self.race_moq_connect(&moq, url, quic_handle).await; } #[cfg(not(feature = "websocket"))] { let session = quic_handle.await?; - return Ok(self.moq.connect(session).await?); + return Ok(moq.connect(session).await?); } } @@ -435,15 +426,25 @@ impl Client { { let alpns = self.versions.alpns(); let session = crate::websocket::connect(&self.websocket, &self.tls, url, &alpns).await?; - return Ok(self.moq.connect(session).await?); + return Ok(moq.connect(session).await?); } #[cfg(not(feature = "websocket"))] return Err(Error::NoBackend("no QUIC backend matched; this should not happen")); } + /// Race the QUIC dial against the WebSocket fallback, handshaking whichever wins. + /// + /// `moq` is the QUIC-side builder, which carries the SETUP path for a raw QUIC dial. + /// The WebSocket fallback uses the plain builder: qmux over WebSocket carries the + /// path in its request URI, so repeating it in the SETUP is a protocol violation. #[cfg(feature = "websocket")] - async fn race_moq_connect(&self, url: Url, quic: Q) -> crate::Result<(moq_net::Session, moq_net::Driver)> + async fn race_moq_connect( + &self, + moq: &moq_net::Client, + url: Url, + quic: Q, + ) -> crate::Result<(moq_net::Session, moq_net::Driver)> where Q: Future>, S: web_transport_trait::Session, @@ -458,7 +459,7 @@ impl Client { }; match race_transport_connect(quic, websocket).await? { - TransportRace::Quic(quic) => Ok(self.moq.connect(quic).await?), + TransportRace::Quic(quic) => Ok(moq.connect(quic).await?), TransportRace::WebSocket(websocket) => Ok(self.moq.connect(websocket).await?), } } @@ -466,16 +467,37 @@ impl Client { /// The resource path to advertise in the SETUP, derived from the dial URL. /// -/// When `path_is_address` (Unix sockets, whose URL path is the socket file), the -/// resource path rides in the `?path=` query; otherwise the URL path is it. -#[cfg(any(feature = "tcp", feature = "uds"))] -fn setup_path(url: &Url, path_is_address: bool) -> Option { - let path = if path_is_address { - url.query_pairs() +/// `None` for the schemes whose transport carries a request URI of its own +/// (WebTransport, qmux over WebSocket): they convey the path there, and a SETUP path +/// on top of it is a protocol violation. `iroh` is one of those too whenever it lands +/// on H3, and the binding is picked by ALPN negotiation rather than by the scheme, so +/// it can't be decided here. +#[cfg(any( + feature = "noq", + feature = "quinn", + feature = "quiche", + feature = "iroh", + feature = "websocket", + feature = "tcp", + feature = "uds" +))] +fn setup_path(url: &Url) -> Option { + let path = match url.scheme() { + // A Unix socket URL's path is the socket file, so the resource path rides in + // the `?path=` query, query string and all. + "unix" => url + .query_pairs() .find(|(k, _)| k == "path") - .map(|(_, v)| v.into_owned()) - } else { - Some(url.path().to_string()) + .map(|(_, v)| v.into_owned()), + // Raw QUIC and qmux over TCP negotiate an ALPN and nothing else, so the whole + // request target travels in the SETUP: the path, plus `?` and the query when + // there is one (draft-ietf-moq-transport-19, section 10.3.1.2). That query is + // how `?jwt=` reaches a relay. + "moqt" | "moql" | "tcp" => Some(match url.query() { + Some(query) => format!("{}?{}", url.path(), query), + None => url.path().to_owned(), + }), + _ => None, }; // An empty path means the same as omitting the parameter, so send neither. A peer @@ -554,22 +576,43 @@ mod tests { use super::*; use clap::Parser; - #[cfg(any(feature = "tcp", feature = "uds"))] + #[cfg(any( + feature = "noq", + feature = "quinn", + feature = "quiche", + feature = "iroh", + feature = "websocket", + feature = "tcp", + feature = "uds" + ))] #[test] - fn setup_path_omits_an_empty_path() { + fn setup_path_covers_the_uri_less_transports() { // An empty path and an absent one both mean the server's default, so we send // neither. A peer on published lite-05 rejects an empty value outright. let cases = [ - ("unix:///run/moq.sock?path=/room", true, Some("/room")), - ("unix:///run/moq.sock?path=", true, None), - ("unix:///run/moq.sock", true, None), - ("tcp://localhost:4443/room", false, Some("/room")), - ("tcp://localhost:4443", false, None), + ("unix:///run/moq.sock?path=/room", Some("/room")), + ("unix:///run/moq.sock?path=", None), + ("unix:///run/moq.sock", None), + ("tcp://localhost:4443/room", Some("/room")), + ("tcp://localhost:4443/room?jwt=abc", Some("/room?jwt=abc")), + ("tcp://localhost:4443", None), + // Raw QUIC: the URL is ours alone, so the path and query have to ride the + // SETUP or the server never sees them. + ("moqt://relay.example.com/anon", Some("/anon")), + ("moqt://relay.example.com/anon?jwt=abc", Some("/anon?jwt=abc")), + ("moql://relay.example.com/anon?jwt=abc", Some("/anon?jwt=abc")), + ("moqt://relay.example.com", None), + // The transport's own request URI carries the path, so sending one here + // would be a protocol violation. + ("https://relay.example.com/anon?jwt=abc", None), + ("http://relay.example.com/anon", None), + ("wss://relay.example.com/anon?jwt=abc", None), + ("iroh://k5lnrlndqpqcgh4d5nhbnbnhcyrgvw6ttxwrsvsu4nlt6foorxaa/anon", None), ]; - for (url, path_is_address, want) in cases { + for (url, want) in cases { let url = Url::parse(url).unwrap(); - let got = setup_path(&url, path_is_address); + let got = setup_path(&url); assert_eq!(got.as_deref(), want, "{url}"); } } diff --git a/rs/moq-native/tests/backend.rs b/rs/moq-native/tests/backend.rs index d83f51fa10..f18abdc5df 100644 --- a/rs/moq-native/tests/backend.rs +++ b/rs/moq-native/tests/backend.rs @@ -20,6 +20,10 @@ struct ConnectTest<'a> { client_bind: Option<&'a str>, /// Authority the client dials: a DNS name (sends SNI) or a bare IP (no SNI). authority: &'a str, + /// Appended to the dial URL, e.g. `/room?jwt=abc`. + path: &'a str, + /// The request path the server must observe, when the test cares. + expect_path: Option<&'a str>, backend: moq_native::QuicBackend, /// Capture qlog traces from both ends into this directory. qlog: Option<&'a std::path::Path>, @@ -37,6 +41,36 @@ async fn backend_test(scheme: &str, backend: moq_native::QuicBackend) { bind: "[::]:0", client_bind: None, authority: "localhost", + path: "", + expect_path: None, + backend, + qlog: None, + }) + .await; +} + +/// Dial a URL with a path and a query and assert the server sees both. +/// +/// Raw QUIC (`moqt`/`moql`) has no request URI, so the whole request target has to +/// ride the SETUP; WebTransport carries it in the CONNECT URL instead. Either way the +/// server reports the same thing through [`moq_native::Request::path`]. +#[cfg(any(feature = "quinn", feature = "quiche", feature = "noq"))] +async fn path_test(scheme: &str, backend: moq_native::QuicBackend) { + // A relay reads `?jwt=` off this, so dropping the query silently unauthenticates. + let expect_path = match scheme { + "moqt" | "moql" => Some("/room?jwt=abc"), + // WebTransport splits the two: the path is the request target, the query stays + // on the URL. + _ => Some("/room"), + }; + + connect_test(ConnectTest { + scheme, + bind: "[::]:0", + client_bind: None, + authority: "localhost", + path: "/room?jwt=abc", + expect_path, backend, qlog: None, }) @@ -54,6 +88,8 @@ async fn no_sni_test(scheme: &str, backend: moq_native::QuicBackend) { bind: "127.0.0.1:0", client_bind: None, authority: "127.0.0.1", + path: "", + expect_path: None, backend, qlog: None, }) @@ -69,6 +105,8 @@ async fn connect_test(config: ConnectTest<'_>) { bind, client_bind, authority, + path, + expect_path, backend, qlog, } = config; @@ -108,15 +146,19 @@ async fn connect_test(config: ConnectTest<'_>) { client_config.bind = client_bind.unwrap_or(bind).parse().expect("invalid bind address"); let client = client_config.init().expect("failed to init client"); - let url: url::Url = format!("{scheme}://{authority}:{}", addr.port()).parse().unwrap(); + let url: url::Url = format!("{scheme}://{authority}:{}{path}", addr.port()).parse().unwrap(); // ── run server and client concurrently ────────────────────────── + let expect_path = expect_path.map(str::to_string); let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("no incoming connection"); // The client wired only a subscriber, so its advertised role reaches the server // over every transport, now that the SETUP is read before the caller authorizes // rather than deferred to `ok()`. assert_eq!(request.role(), Some(moq_native::moq_net::Role::Subscriber)); + if let Some(expect_path) = expect_path { + assert_eq!(request.path(), expect_path); + } let session = request.with_publisher(&pub_origin).ok().await?; let _broadcast = broadcast; @@ -326,6 +368,20 @@ async fn quinn_raw_quic_no_sni() { no_sni_test("moqt", moq_native::QuicBackend::Quinn).await; } +#[cfg(feature = "quinn")] +#[tracing_test::traced_test] +#[tokio::test] +async fn quinn_raw_quic_path() { + path_test("moqt", moq_native::QuicBackend::Quinn).await; +} + +#[cfg(feature = "quinn")] +#[tracing_test::traced_test] +#[tokio::test] +async fn quinn_webtransport_path() { + path_test("https", moq_native::QuicBackend::Quinn).await; +} + #[cfg(feature = "quinn")] #[tracing_test::traced_test] #[tokio::test] @@ -349,6 +405,13 @@ async fn quiche_raw_quic() { backend_test("moqt", moq_native::QuicBackend::Quiche).await; } +#[cfg(feature = "quiche")] +#[tracing_test::traced_test] +#[tokio::test] +async fn quiche_raw_quic_path() { + path_test("moqt", moq_native::QuicBackend::Quiche).await; +} + #[cfg(feature = "quiche")] #[tracing_test::traced_test] #[tokio::test] @@ -358,6 +421,8 @@ async fn quiche_dual_stack_ipv4() { bind: "[::]:0", client_bind: Some("0.0.0.0:0"), authority: "127.0.0.1", + path: "", + expect_path: None, backend: moq_native::QuicBackend::Quiche, qlog: None, }) @@ -531,6 +596,13 @@ async fn noq_raw_quic_no_sni() { no_sni_test("moqt", moq_native::QuicBackend::Noq).await; } +#[cfg(feature = "noq")] +#[tracing_test::traced_test] +#[tokio::test] +async fn noq_raw_quic_path() { + path_test("moqt", moq_native::QuicBackend::Noq).await; +} + #[cfg(feature = "noq")] #[tracing_test::traced_test] #[tokio::test] @@ -562,6 +634,8 @@ async fn qlog_test(scheme: &str, backend: moq_native::QuicBackend) -> Vec, /// The single direction the client intends to use, or `None` for a bidirectional /// session. `None` is sent as the absence of the parameter, which is also how a diff --git a/rs/moq-relay/tests/smoke.rs b/rs/moq-relay/tests/smoke.rs index 8cc35d5c78..4aa1497245 100644 --- a/rs/moq-relay/tests/smoke.rs +++ b/rs/moq-relay/tests/smoke.rs @@ -125,6 +125,9 @@ fn client_version(version: Option) -> moq_native::Client { config.tls.disable_verify = Some(true); // Zero head start so the WebSocket path runs immediately. config.websocket.delay = None; + // Every relay in this file listens on IPv4 loopback, so bind the same family + // rather than egressing a QUIC dial from a dual-stack IPv6 socket. + config.bind = "127.0.0.1:0".parse().expect("parse bind"); if let Some(version) = version { config.version = vec![version]; } @@ -379,13 +382,20 @@ async fn two_publish_only_clients_coexist() { web_handle.abort(); } -/// Run the relay's accept loop over a stream-only server (no QUIC), the same path +/// Run the relay's accept loop over the given server config, the same path /// `main.rs` uses. Authenticates through the shared [`Auth`], here with fully -/// public access (`--auth-public ""`) so no-JWT stream clients get the root. -async fn spawn_stream_relay(config: moq_native::ServerConfig, auth_config: AuthConfig) -> tokio::task::JoinHandle<()> { +/// public access (`--auth-public ""`) so no-JWT clients get the root. +/// +/// Returns the QUIC socket the server bound, when it has one, so a caller that +/// asked for an ephemeral port can dial it. +async fn spawn_accept_relay( + config: moq_native::ServerConfig, + auth_config: AuthConfig, +) -> (Option, tokio::task::JoinHandle<()>) { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let mut server = config.init().expect("server init"); + let addr = server.local_addr().ok(); let auth = auth_config .init(&moq_native::tls::Client::default()) @@ -394,7 +404,7 @@ async fn spawn_stream_relay(config: moq_native::ServerConfig, auth_config: AuthC let cluster = Cluster::new(ClusterConfig::default()).expect("cluster init"); - tokio::spawn(async move { + let handle = tokio::spawn(async move { let mut id = 0; while let Some(request) = server.accept().await { let conn = Connection { @@ -408,7 +418,9 @@ async fn spawn_stream_relay(config: moq_native::ServerConfig, auth_config: AuthC let _ = conn.run().await; }); } - }) + }); + + (addr, handle) } /// Stand up the relay listening only on a plain-TCP qmux `--server-bind` on a @@ -430,7 +442,7 @@ async fn spawn_internal_relay() -> (u16, tokio::task::JoinHandle<()>) { let mut auth_config = AuthConfig::default(); auth_config.public = Some(public); - let handle = spawn_stream_relay(config, auth_config).await; + let (_, handle) = spawn_accept_relay(config, auth_config).await; let deadline = std::time::Instant::now() + Duration::from_secs(5); loop { @@ -540,7 +552,7 @@ async fn spawn_internal_unix_relay() -> (std::path::PathBuf, tokio::task::JoinHa let mut auth_config = AuthConfig::default(); auth_config.public = Some(public); - let handle = spawn_stream_relay(config, auth_config).await; + let (_, handle) = spawn_accept_relay(config, auth_config).await; // Wait for the socket file to appear. let deadline = std::time::Instant::now() + Duration::from_secs(5); @@ -740,6 +752,46 @@ async fn internal_unix_path_reaches_server() { handle.abort(); } +/// Stand up the relay listening only on a QUIC `--server-bind` on an ephemeral +/// loopback port, with fully public auth (no-JWT => whole root). Returns the bound +/// address and an abort handle. +async fn spawn_quic_relay() -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { + let mut config = moq_native::ServerConfig::default(); + config.bind = Some("127.0.0.1:0".to_string()); + config.tls.generate = vec!["localhost".into()]; + + #[allow(deprecated)] + let public = PublicConfig::Simple(vec![String::new()]); + let mut auth_config = AuthConfig::default(); + auth_config.public = Some(public); + + let (addr, handle) = spawn_accept_relay(config, auth_config).await; + (addr.expect("relay bound no QUIC socket"), handle) +} + +/// Raw QUIC has no request URI either, so `moqt://host:port/` only reaches the +/// relay if the client puts it in the SETUP. Same assertion as TCP: the relay scopes +/// the publisher's grant to that root, across every version whose SETUP carries a path. +#[tokio::test] +async fn raw_quic_path_reaches_server() { + let (addr, handle) = spawn_quic_relay().await; + + // Dialing an IP literal sends no SNI, so the SETUP is the only thing the server + // has to go on. + let pub_url: url::Url = format!("moqt://{addr}/room").parse().expect("parse url"); + let sub_url: url::Url = format!("moqt://{addr}").parse().expect("parse url"); + + for version in path_versions() { + let announced = path_round_trip(version, pub_url.clone(), sub_url.clone(), "test").await; + assert_eq!( + announced, "room/test", + "the SETUP path should scope the publisher's grant ({version})" + ); + } + + handle.abort(); +} + /// `/health` is a liveness probe that always returns `200 ok`. #[tokio::test] async fn health_endpoint_reports_ok() { @@ -774,7 +826,7 @@ async fn spawn_subscribe_only_relay() -> (u16, tokio::task::JoinHandle<()>) { let mut auth_config = AuthConfig::default(); auth_config.public_subscribe = Some(public_subscribe); - let handle = spawn_stream_relay(config, auth_config).await; + let (_, handle) = spawn_accept_relay(config, auth_config).await; let deadline = std::time::Instant::now() + Duration::from_secs(5); loop { @@ -860,7 +912,7 @@ async fn spawn_publish_only_relay() -> (u16, tokio::task::JoinHandle<()>) { let mut auth_config = AuthConfig::default(); auth_config.public_publish = Some(public_publish); - let handle = spawn_stream_relay(config, auth_config).await; + let (_, handle) = spawn_accept_relay(config, auth_config).await; let deadline = std::time::Instant::now() + Duration::from_secs(5); loop { From 89ad5083871b4fe70158dfb6b275c72dc4382e24 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 30 Jul 2026 20:47:49 -0700 Subject: [PATCH 2/4] test(native): cover moql:// and a percent-encoded unix path `moql://` derives its SETUP path through the same arm as `moqt://`, so pin it end-to-end rather than only in the unit table. The unix `?path=` value is one form-encoded value, so document that a resource path carrying its own query percent-encodes it. Co-Authored-By: Claude Opus 5 --- rs/moq-native/src/client.rs | 6 +++++- rs/moq-native/tests/backend.rs | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/rs/moq-native/src/client.rs b/rs/moq-native/src/client.rs index 5af716d6c1..e375a7efc7 100644 --- a/rs/moq-native/src/client.rs +++ b/rs/moq-native/src/client.rs @@ -484,7 +484,8 @@ impl Client { fn setup_path(url: &Url) -> Option { let path = match url.scheme() { // A Unix socket URL's path is the socket file, so the resource path rides in - // the `?path=` query, query string and all. + // the `?path=` query, query string and all. It is one form-encoded value, so a + // resource path that carries its own `?query` percent-encodes it. "unix" => url .query_pairs() .find(|(k, _)| k == "path") @@ -591,6 +592,9 @@ mod tests { // neither. A peer on published lite-05 rejects an empty value outright. let cases = [ ("unix:///run/moq.sock?path=/room", Some("/room")), + // The whole resource path is one form-encoded value, so a `?query` inside it + // arrives percent-encoded and comes back out whole. + ("unix:///run/moq.sock?path=/room%3Fjwt%3Dabc", Some("/room?jwt=abc")), ("unix:///run/moq.sock?path=", None), ("unix:///run/moq.sock", None), ("tcp://localhost:4443/room", Some("/room")), diff --git a/rs/moq-native/tests/backend.rs b/rs/moq-native/tests/backend.rs index f18abdc5df..a9f1a58bd9 100644 --- a/rs/moq-native/tests/backend.rs +++ b/rs/moq-native/tests/backend.rs @@ -375,6 +375,14 @@ async fn quinn_raw_quic_path() { path_test("moqt", moq_native::QuicBackend::Quinn).await; } +/// `moql://` derives its SETUP path exactly like `moqt://`; only the scheme differs. +#[cfg(feature = "quinn")] +#[tracing_test::traced_test] +#[tokio::test] +async fn quinn_raw_quic_moql_path() { + path_test("moql", moq_native::QuicBackend::Quinn).await; +} + #[cfg(feature = "quinn")] #[tracing_test::traced_test] #[tokio::test] From 450c517f5eecade5155d06c5f94b017f2f964cd2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 31 Jul 2026 11:22:23 -0700 Subject: [PATCH 3/4] fix(native): send the request target on raw iroh too iroh offers the moq ALPNs ahead of H3, so two moq endpoints normally land on raw QUIC. That binding carries no request URI, and the client sent no SETUP path either, so `iroh://peer/room?jwt=` reached the server's default path with no credential. The H3 fallback scheme-swapped the whole URL into its CONNECT, so the same URL addressed two different targets depending on which ALPN won. The scheme can't decide this the way `moqt://` can, so `iroh::connect` now reports the negotiated `Binding` and only the raw one advertises the target. `setup_path` splits into the scheme dispatch and the `request_target` join the iroh dial reuses. Co-Authored-By: Claude Opus 5 --- rs/moq-native/src/client.rs | 128 +++++++++++++++++++++++++-------- rs/moq-native/src/iroh.rs | 22 +++++- rs/moq-native/tests/backend.rs | 6 +- 3 files changed, 123 insertions(+), 33 deletions(-) diff --git a/rs/moq-native/src/client.rs b/rs/moq-native/src/client.rs index e375a7efc7..6556833b4f 100644 --- a/rs/moq-native/src/client.rs +++ b/rs/moq-native/src/client.rs @@ -327,6 +327,23 @@ impl Client { Ok(crate::spawn_session(pair)) } + /// The moq client builder, advertising `path` in the SETUP when there is one. + #[cfg(any( + feature = "noq", + feature = "quinn", + feature = "quiche", + feature = "iroh", + feature = "websocket", + feature = "tcp", + feature = "uds" + ))] + fn moq_with_path(&self, path: Option) -> moq_net::Client { + match path { + Some(path) => self.moq.clone().with_path(path), + None => self.moq.clone(), + } + } + #[cfg(any( feature = "noq", feature = "quinn", @@ -337,13 +354,10 @@ impl Client { feature = "uds" ))] async fn connect_inner(&self, url: Url) -> crate::Result<(moq_net::Session, moq_net::Driver)> { - // Transports with no request URI of their own advertise the resource path in the + // Transports with no request URI of their own advertise the request target in the // SETUP instead; `setup_path` returns `None` for the ones that carry a URI, where // sending it again is a protocol violation. - let moq = match setup_path(&url) { - Some(path) => self.moq.clone().with_path(path), - None => self.moq.clone(), - }; + let moq = self.moq_with_path(setup_path(&url)); // Plain TCP (qmux, no TLS). Explicit opt-in scheme; never raced against // QUIC, which can't speak it. Use only on a trusted network. @@ -361,12 +375,22 @@ impl Client { return Ok(moq.connect(session).await?); } + // iroh offers the moq ALPNs ahead of H3, so two moq endpoints normally land on raw + // QUIC, which carries no request URI. The scheme can't tell us which we got, so the + // request target waits on the negotiated binding: the SETUP for raw QUIC, the + // CONNECT URL for H3 (where a SETUP path would be a protocol violation). #[cfg(feature = "iroh")] if url.scheme() == "iroh" { let endpoint = self.iroh.as_ref().ok_or(Error::IrohDisabled)?; - let session = crate::iroh::connect(endpoint, url, self.iroh_addrs.iter().copied()).await?; - let session = moq.connect(session).await?; - return Ok(session); + let target = request_target(&url); + let (session, binding) = crate::iroh::connect(endpoint, url, self.iroh_addrs.iter().copied()).await?; + + let moq = match binding { + crate::iroh::Binding::Raw => self.moq_with_path(target), + crate::iroh::Binding::H3 => self.moq.clone(), + }; + + return Ok(moq.connect(session).await?); } #[cfg(feature = "noq")] @@ -465,13 +489,37 @@ impl Client { } } -/// The resource path to advertise in the SETUP, derived from the dial URL. +/// The request target a URI-less transport advertises in its SETUP: the URL path, plus +/// `?` and the query when there is one (draft-ietf-moq-transport-19, section 10.3.1.2). +/// That query is how `?jwt=` reaches a relay. +/// +/// `None` when the result is empty, which means the same as omitting the parameter: the +/// server's default path. A peer on published lite-05 rejects an empty value outright. +#[cfg(any( + feature = "noq", + feature = "quinn", + feature = "quiche", + feature = "iroh", + feature = "websocket", + feature = "tcp", + feature = "uds" +))] +fn request_target(url: &Url) -> Option { + let target = match url.query() { + Some(query) => format!("{}?{}", url.path(), query), + None => url.path().to_owned(), + }; + + (!target.is_empty()).then_some(target) +} + +/// The request target to advertise in the SETUP, chosen by the dial URL's scheme. /// /// `None` for the schemes whose transport carries a request URI of its own -/// (WebTransport, qmux over WebSocket): they convey the path there, and a SETUP path -/// on top of it is a protocol violation. `iroh` is one of those too whenever it lands -/// on H3, and the binding is picked by ALPN negotiation rather than by the scheme, so -/// it can't be decided here. +/// (WebTransport, qmux over WebSocket): they convey the target there, and a SETUP path +/// on top of it is a protocol violation. `iroh` is `None` here because its binding is +/// picked by ALPN negotiation rather than by the scheme; that dial reads the negotiated +/// [`crate::iroh::Binding`] and calls [`request_target`] itself. #[cfg(any( feature = "noq", feature = "quinn", @@ -482,28 +530,20 @@ impl Client { feature = "uds" ))] fn setup_path(url: &Url) -> Option { - let path = match url.scheme() { - // A Unix socket URL's path is the socket file, so the resource path rides in + match url.scheme() { + // A Unix socket URL's path is the socket file, so the request target rides in // the `?path=` query, query string and all. It is one form-encoded value, so a - // resource path that carries its own `?query` percent-encodes it. + // target that carries its own `?query` percent-encodes it. "unix" => url .query_pairs() .find(|(k, _)| k == "path") - .map(|(_, v)| v.into_owned()), + .map(|(_, v)| v.into_owned()) + .filter(|path| !path.is_empty()), // Raw QUIC and qmux over TCP negotiate an ALPN and nothing else, so the whole - // request target travels in the SETUP: the path, plus `?` and the query when - // there is one (draft-ietf-moq-transport-19, section 10.3.1.2). That query is - // how `?jwt=` reaches a relay. - "moqt" | "moql" | "tcp" => Some(match url.query() { - Some(query) => format!("{}?{}", url.path(), query), - None => url.path().to_owned(), - }), + // request target travels in the SETUP. + "moqt" | "moql" | "tcp" => request_target(url), _ => None, - }; - - // An empty path means the same as omitting the parameter, so send neither. A peer - // on published lite-05 rejects an empty value outright, and `?path=` yields one. - path.filter(|path| !path.is_empty()) + } } #[cfg(feature = "websocket")] @@ -611,6 +651,7 @@ mod tests { ("https://relay.example.com/anon?jwt=abc", None), ("http://relay.example.com/anon", None), ("wss://relay.example.com/anon?jwt=abc", None), + // Decided after the ALPN is negotiated, not here. ("iroh://k5lnrlndqpqcgh4d5nhbnbnhcyrgvw6ttxwrsvsu4nlt6foorxaa/anon", None), ]; @@ -621,6 +662,35 @@ mod tests { } } + /// The iroh dial derives its target here rather than through [`setup_path`], since + /// only the negotiated binding says whether to send one. + #[cfg(any( + feature = "noq", + feature = "quinn", + feature = "quiche", + feature = "iroh", + feature = "websocket", + feature = "tcp", + feature = "uds" + ))] + #[test] + fn request_target_joins_the_path_and_query() { + const PEER: &str = "k5lnrlndqpqcgh4d5nhbnbnhcyrgvw6ttxwrsvsu4nlt6foorxaa"; + + let cases = [ + (format!("iroh://{PEER}/room?jwt=abc"), Some("/room?jwt=abc")), + (format!("iroh://{PEER}/room"), Some("/room")), + (format!("iroh://{PEER}"), None), + (format!("iroh://{PEER}/"), Some("/")), + ]; + + for (url, want) in cases { + let url = Url::parse(&url).unwrap(); + let got = request_target(&url); + assert_eq!(got.as_deref(), want, "{url}"); + } + } + #[test] fn test_toml_disable_verify_survives_update_from() { let toml = r#" diff --git a/rs/moq-native/src/iroh.rs b/rs/moq-native/src/iroh.rs index 8492ea971d..872eda047d 100644 --- a/rs/moq-native/src/iroh.rs +++ b/rs/moq-native/src/iroh.rs @@ -273,11 +273,24 @@ pub(crate) async fn accept( } } +/// Which transport binding an `iroh://` dial negotiated. +/// +/// Unlike the other schemes, this isn't known until the ALPN is chosen, and it decides +/// where the request target travels: H3 puts it in the CONNECT URL, raw QUIC has nowhere +/// to put it but the SETUP. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Binding { + /// Raw QUIC over an iroh connection, carrying no request URI. + Raw, + /// WebTransport over HTTP/3, carrying the request URI in its CONNECT. + H3, +} + pub(crate) async fn connect( endpoint: &Endpoint, url: Url, addrs: impl IntoIterator, -) -> Result { +) -> Result<(web_transport_iroh::Session, Binding)> { let host = url.host().ok_or(Error::MissingHost)?.to_string(); let endpoint_id: iroh::EndpointId = host.parse().map_err(Error::InvalidEndpointId)?; @@ -311,11 +324,14 @@ pub(crate) async fn connect( request = request.with_protocol(alpn.to_string()); } - web_transport_iroh::Session::connect_h3(conn, request).await? + ( + web_transport_iroh::Session::connect_h3(conn, request).await?, + Binding::H3, + ) } alpn if moq_net::ALPNS.contains(&alpn) => { let conn = connecting.await?; - web_transport_iroh::Session::raw(conn) + (web_transport_iroh::Session::raw(conn), Binding::Raw) } _ => return Err(Error::UnsupportedAlpn(alpn)), }; diff --git a/rs/moq-native/tests/backend.rs b/rs/moq-native/tests/backend.rs index a9f1a58bd9..78477aaa53 100644 --- a/rs/moq-native/tests/backend.rs +++ b/rs/moq-native/tests/backend.rs @@ -527,7 +527,7 @@ async fn iroh_connect() { .with_iroh(client_endpoint) .with_iroh_addrs(server_addrs); - let url: url::Url = format!("iroh://{server_endpoint_id}").parse().unwrap(); + let url: url::Url = format!("iroh://{server_endpoint_id}/room?jwt=abc").parse().unwrap(); // ── run server and client concurrently ────────────────────────── let server_handle = tokio::spawn(async move { @@ -536,6 +536,10 @@ async fn iroh_connect() { // over every transport, now that the SETUP is read before the caller authorizes // rather than deferred to `ok()`. assert_eq!(request.role(), Some(moq_native::moq_net::Role::Subscriber)); + // iroh offers the moq ALPNs ahead of H3, so this lands on raw QUIC, whose only + // place for the request target is the SETUP. + assert_eq!(request.transport(), moq_native::Transport::Iroh); + assert_eq!(request.path(), "/room?jwt=abc"); let session = request.with_publisher(&pub_origin).ok().await?; let _broadcast = broadcast; From 0ce428fb26167ca44a4b7917b270bb8a872938c4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 31 Jul 2026 11:32:44 -0700 Subject: [PATCH 4/4] fix(native): treat a trailing `?` as no query in the request target `Url::query` reports an empty query for a trailing `?`, so `moqt://host/room?` spelled the same target as `/room?` rather than `/room`, and `moqt://host?` produced a bare `?`: neither empty nor beginning with `/`, which the Path Parameter requires. Co-Authored-By: Claude Opus 5 --- rs/moq-native/src/client.rs | 11 ++++++++++- rs/moq-native/tests/backend.rs | 5 +++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/rs/moq-native/src/client.rs b/rs/moq-native/src/client.rs index 6556833b4f..fa6aee58aa 100644 --- a/rs/moq-native/src/client.rs +++ b/rs/moq-native/src/client.rs @@ -505,7 +505,10 @@ impl Client { feature = "uds" ))] fn request_target(url: &Url) -> Option { - let target = match url.query() { + // A trailing `?` parses as an empty query, which is not a query: appending it would + // spell one target two ways, and `moqt://host?` would yield a bare "?" rather than + // the empty value that means the default path. + let target = match url.query().filter(|query| !query.is_empty()) { Some(query) => format!("{}?{}", url.path(), query), None => url.path().to_owned(), }; @@ -646,6 +649,12 @@ mod tests { ("moqt://relay.example.com/anon?jwt=abc", Some("/anon?jwt=abc")), ("moql://relay.example.com/anon?jwt=abc", Some("/anon?jwt=abc")), ("moqt://relay.example.com", None), + // The fragment is processed by the client and never sent (draft-19 3.1.2). + ("moqt://relay.example.com/anon?jwt=abc#pos:12", Some("/anon?jwt=abc")), + ("moqt://relay.example.com/anon#pos:12", Some("/anon")), + // A trailing `?` is an empty query, not a query. + ("moqt://relay.example.com/anon?", Some("/anon")), + ("moqt://relay.example.com?", None), // The transport's own request URI carries the path, so sending one here // would be a protocol violation. ("https://relay.example.com/anon?jwt=abc", None), diff --git a/rs/moq-native/tests/backend.rs b/rs/moq-native/tests/backend.rs index 78477aaa53..9717e64d43 100644 --- a/rs/moq-native/tests/backend.rs +++ b/rs/moq-native/tests/backend.rs @@ -536,9 +536,10 @@ async fn iroh_connect() { // over every transport, now that the SETUP is read before the caller authorizes // rather than deferred to `ok()`. assert_eq!(request.role(), Some(moq_native::moq_net::Role::Subscriber)); - // iroh offers the moq ALPNs ahead of H3, so this lands on raw QUIC, whose only - // place for the request target is the SETUP. + // iroh offers the moq ALPNs ahead of H3, so this lands on raw QUIC: no request + // URL, leaving the SETUP as the only place for the request target. assert_eq!(request.transport(), moq_native::Transport::Iroh); + assert_eq!(request.url(), None); assert_eq!(request.path(), "/room?jwt=abc"); let session = request.with_publisher(&pub_origin).ok().await?;