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..fa6aee58aa 100644 --- a/rs/moq-native/src/client.rs +++ b/rs/moq-native/src/client.rs @@ -327,8 +327,16 @@ 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"))] + /// 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), @@ -346,36 +354,43 @@ 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 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 = 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. - // - // 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?); } + // 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 = self.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")] @@ -386,13 +401,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 +419,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 +436,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 +450,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,29 +483,70 @@ 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?), } } } -/// 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. /// -/// 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() - .find(|(k, _)| k == "path") - .map(|(_, v)| v.into_owned()) - } else { - Some(url.path().to_string()) +/// `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 { + // 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(), }; - // 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()) + (!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 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", + feature = "quiche", + feature = "iroh", + feature = "websocket", + feature = "tcp", + feature = "uds" +))] +fn setup_path(url: &Url) -> Option { + 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 + // target that carries its own `?query` percent-encodes it. + "unix" => url + .query_pairs() + .find(|(k, _)| k == "path") + .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. + "moqt" | "moql" | "tcp" => request_target(url), + _ => None, + } } #[cfg(feature = "websocket")] @@ -554,22 +620,82 @@ 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")), + // 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")), + ("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 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), + ("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), ]; - 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}"); + } + } + + /// 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}"); } } 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 d83f51fa10..9717e64d43 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,28 @@ 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; +} + +/// `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] +async fn quinn_webtransport_path() { + path_test("https", moq_native::QuicBackend::Quinn).await; +} + #[cfg(feature = "quinn")] #[tracing_test::traced_test] #[tokio::test] @@ -349,6 +413,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 +429,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, }) @@ -454,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 { @@ -463,6 +536,11 @@ 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: 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?; let _broadcast = broadcast; @@ -531,6 +609,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 +647,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 {