diff --git a/Cargo.lock b/Cargo.lock index 7ce0681dc7..cff9fef571 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9864,9 +9864,9 @@ dependencies = [ [[package]] name = "web-transport-iroh" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e70872b1e0cb791a06373859105570e78759dd3286b1f42bccd31f5b812539c" +checksum = "12f6120c66f3ef235e063e716fd0e3014c9ba07901c3a31a6b3cde20f318fa0a" dependencies = [ "bytes", "http", diff --git a/Cargo.toml b/Cargo.toml index 3b0fe9f1fe..5714b75ed8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -230,7 +230,7 @@ url = "2" usage = { package = "usage-rs", version = "6.11.1" } uuid = { version = "1.26", features = ["v7"] } web-async = { version = "0.1.5", features = ["tracing"] } -web-transport-iroh = "0.8" +web-transport-iroh = "0.8.1" # default-features off so the QUIC crypto provider is chosen by moq-tokio's # aws-lc-rs / ring features. # The WebTransport adapter released with moq-noq, from the same repository. diff --git a/quest/m1/README.md b/quest/m1/README.md index dcfea05123..cbf57da6d7 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -36,7 +36,6 @@ QUIC studies there on that rule. - [Merge queue](/quest/m1/merge-queue.md) - the required checks run on `merge_group`, so a stale green check can no longer break main - [Binding audio delay](/quest/m1/binding-surface.md) - moq-ffi and every wrapper configure and observe audio playout delay - [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, flate, audio, and video namespaces built from the handle below -- [Raw stream codes](/quest/m1/raw-stream-codes.md) - raw QUIC stream resets and stops carry the application's code, not an HTTP/3-mapped one - [moq-net owns its transport seam](/quest/m1/transport-seam.md) - moq-net names only its own transport traits, so a web-transport-trait or qmux major bump is a patch for it - [IETF drain before close](/quest/m1/ietf-drain-before-close.md) - moq-transport sessions deliver finished tracks before a graceful close, as moq-lite does - [Close waits for the tail](/quest/m1/close-tail.md) - on lite-07, `close()` returns `Ok` only after each subscriber FINs its Subscribe Stream, having read the track to its end diff --git a/quest/m1/raw-stream-codes.md b/quest/m1/raw-stream-codes.md deleted file mode 100644 index 4ba1a415bc..0000000000 --- a/quest/m1/raw-stream-codes.md +++ /dev/null @@ -1,39 +0,0 @@ -# [M] Raw QUIC stream codes stay raw - -## Goal - -On a raw QUIC session (`moqt://`, `moql://`, raw iroh), RESET_STREAM and -STOP_SENDING carry the application's code as-is, not mapped through the -HTTP/3 WebTransport code space, so moq agrees with other raw QUIC MoQ stacks -on stream errors. WebTransport sessions keep the mapping they need. - -## Plan - -- `web-transport-moq` (moq-dev/noq) runs every stream code through - `web_transport_proto::error_to_http3` and `error_from_http3` in `send.rs` - and `recv.rs`, whether or not the session came from `Session::raw`. - `web-transport-iroh` and `web-transport-quinn` (moq-dev/web-transport) do - the same. A raw peer's code 5 reads as `None` or another value, and ours - reaches it as a large HTTP/3 code. -- Close codes (#4262, merged 09-28) fixed the same mix-up for - `ApplicationClosed` and brought `web-transport-trait` 0.5, which is - unreleased. Let each stream know whether its - session is raw and skip the mapping there, in all three adapters, with a - round-trip test per adapter against a plain QUIC peer. -- Release the fixed crates and bump the pins here in the same quest; published - crates depend on crates.io releases, never a patch. A moq-tokio test over - `moqt://` asserts a reset code arrives verbatim, beside `close_code.rs`. -- Mixed versions: two moq peers on raw QUIC agree today because both map, and - wire changes must stay compatible with published versions. An older peer - still sends `error_to_http3(code)`, so a fixed peer that only skips the - mapping reports the large HTTP/3 value, not the code. The legacy form is - detectable, since it lands in the HTTP/3 WebTransport range that no moq - code reaches (`error_from_http3` returns `None` outside it), so the raw - receive path unmaps a code in that range and passes the rest through, with - a legacy-sender test per adapter. The other direction has no fix at the - receiver: an older peer misreads a fixed peer's raw codes. Check which codes - moq-net acts on (group stream resets, subscribe STOP_SENDING): if any drives - behaviour beyond reporting, this is a wire break as well. - -Public API: none expected. Wire: raw QUIC stream error codes become the -application's own values; compatible only if older peers merely report them. diff --git a/quest/m1/transport-seam.md b/quest/m1/transport-seam.md index 28e757d907..8fc0515d82 100644 --- a/quest/m1/transport-seam.md +++ b/quest/m1/transport-seam.md @@ -48,4 +48,3 @@ Wire: none. ## Related - [Sans-IO moq-net](/quest/m1/rs2ts/sans-io/README.md) - builds on the same seam -- [Raw stream codes](/quest/m1/raw-stream-codes.md) - the trait bump that exposed this diff --git a/rs/moq-tokio/tests/stream_code.rs b/rs/moq-tokio/tests/stream_code.rs new file mode 100644 index 0000000000..23775dc55f --- /dev/null +++ b/rs/moq-tokio/tests/stream_code.rs @@ -0,0 +1,107 @@ +//! Over raw QUIC, a stream code reaches the peer as the protocol's own value, not +//! mapped into the HTTP/3 WebTransport code space, so other raw QUIC MoQ stacks +//! read it. +//! +//! The peer here is a plain QUIC client. A moq-lite server refuses a uni stream opened +//! ahead of the SETUP with STOP_SENDING, and the client reads that code off the wire. +//! moq-transport holds such a stream instead, but its codes take the same raw session. + +#![cfg(feature = "noq")] + +use std::{sync::Arc, time::Duration}; + +use web_transport_moq::noq; + +/// moq-lite sends INTERNAL_ERROR (0x0) for a refused stream. +const REFUSED: u32 = 0x0; + +struct Server { + port: u16, + cert: rustls::pki_types::CertificateDer<'static>, + _dir: tempfile::TempDir, +} + +async fn serve() -> Server { + use std::io::Write; + + let dir = tempfile::tempdir().expect("tempdir"); + let key = rcgen::KeyPair::generate().expect("key"); + let cert = rcgen::CertificateParams::new(vec!["localhost".to_string()]) + .expect("params") + .self_signed(&key) + .expect("cert"); + let write = |name: &str, contents: String| { + let path = dir.path().join(name); + std::fs::File::create(&path) + .and_then(|mut file| file.write_all(contents.as_bytes())) + .expect("write pem file"); + path + }; + + let mut config = moq_tokio::server::Config::default(); + config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); + config.listen.tls.cert = vec![write("server.pem", cert.pem())]; + config.listen.tls.key = vec![write("server.key", key.serialize_pem())]; + let mut listener = config + .init() + .expect("failed to init server") + .listen() + .await + .expect("failed to listen"); + let port = listener.local_addr().expect("no quic addr").port(); + + tokio::spawn(async move { + while let Some(request) = listener.accept().await { + tokio::spawn(async move { + let _session = request.ok().await; + std::future::pending::<()>().await; + }); + } + }); + + Server { + port, + cert: cert.der().clone(), + _dir: dir, + } +} + +/// Dial the server over raw QUIC with `alpn`, open a uni stream that is not a SETUP, +/// and return the code the server stopped it with. +async fn refused_code(server: &Server, alpn: &str) -> Option { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + let mut roots = rustls::RootCertStore::empty(); + roots.add(server.cert.clone()).expect("root"); + let mut tls = rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + tls.alpn_protocols = vec![alpn.as_bytes().to_vec()]; + let quic = noq::crypto::rustls::QuicClientConfig::try_from(tls).expect("quic config"); + + let endpoint = noq::Endpoint::client("127.0.0.1:0".parse().unwrap()).expect("endpoint"); + let conn = endpoint + .connect_with( + noq::ClientConfig::new(Arc::new(quic)), + ([127, 0, 0, 1], server.port).into(), + "localhost", + ) + .expect("connect") + .await + .expect("handshake"); + + // A group stream, which moq-lite refuses before the SETUP. + let mut send = conn.open_uni().await.expect("open"); + send.write_all(&[0x00]).await.expect("write"); + + tokio::time::timeout(Duration::from_secs(10), send.stopped()) + .await + .expect("the stream was not stopped") + .expect("stopped") +} + +#[tokio::test] +async fn a_refused_stream_code_is_not_mapped() { + let server = serve().await; + assert_eq!(refused_code(&server, "moq-lite-06").await, Some(REFUSED.into())); +} diff --git a/rs/moq-uring/src/quic/web.rs b/rs/moq-uring/src/quic/web.rs index 957490ab4b..c150b4c5d7 100644 --- a/rs/moq-uring/src/quic/web.rs +++ b/rs/moq-uring/src/quic/web.rs @@ -12,7 +12,8 @@ //! WebTransport layering disabled, so native peers and browsers run the same //! machinery. Stream and session error codes map through the HTTP/3 error //! space ([`web_transport_proto::error_to_http3`]) in web mode and pass -//! through untouched in raw mode. +//! through untouched in raw mode, except that a received stream code in the +//! HTTP/3 range is unmapped, since older raw peers send that form. use std::cell::RefCell; use std::collections::VecDeque; @@ -811,7 +812,7 @@ impl SendStream { fn map(&self, err: Error) -> Error { match self.web { true => unmap_err(err), - false => err, + false => unmap_legacy(err), } } } @@ -953,7 +954,7 @@ impl RecvStream { fn map(&self, err: Error) -> Error { match self.web { true => unmap_err(err), - false => err, + false => unmap_legacy(err), } } } @@ -998,6 +999,21 @@ impl std::fmt::Debug for RecvStream { } } +/// Unmap a raw-QUIC stream code that arrived in the HTTP/3 code space. +/// +/// Older `web-transport-moq` raw sessions map RESET_STREAM and STOP_SENDING +/// codes as if they were WebTransport. No `u32` application code reaches that +/// range, so a code in it can only be the mapped form. Anything else is the +/// peer's own code and passes through. +fn unmap_legacy(err: Error) -> Error { + let unmap = |code: u64| proto::error_from_http3(code).map_or(code, u64::from); + match err { + Error::Reset(code) => Error::Reset(unmap(code)), + Error::Stop(code) => Error::Stop(unmap(code)), + err => err, + } +} + /// Rewrite HTTP/3-mapped error codes back into WebTransport code space. /// /// Only a sparse range of HTTP/3 codes names a WebTransport error. Anything @@ -1475,6 +1491,31 @@ mod tests { )); } + /// A raw session reads a plain stream code as is, and an older raw peer's + /// HTTP/3-mapped one as the code it stands for. Session closes are left alone. + #[test] + fn a_raw_session_unmaps_legacy_stream_codes() { + assert!(matches!(unmap_legacy(Error::Reset(5)), Error::Reset(5))); + assert!(matches!(unmap_legacy(Error::Stop(5)), Error::Stop(5))); + assert!(matches!( + unmap_legacy(Error::Reset(proto::error_to_http3(5))), + Error::Reset(5) + )); + assert!(matches!( + unmap_legacy(Error::Stop(proto::error_to_http3(5))), + Error::Stop(5) + )); + + let code = proto::error_to_http3(5); + assert!(matches!( + unmap_legacy(Error::App { + code, + reason: String::new() + }), + Error::App { code: c, .. } if c == code + )); + } + /// Wrap `payload` in an HTTP/3 frame of type `typ`. fn frame(typ: u64, payload: &[u8]) -> Vec { let mut buf = Vec::new();