From 71fbfe5a854c55d707c27b5311fcb57bbd500b4f Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 03:03:00 -0400 Subject: [PATCH 1/9] joule-moq-relay: room authorities, a door trust field, and session limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth.authorities: a WRG1 grant signed by a listed authority is honoured as live-rooms §10.2 maps it (its own media, catalog and events namespaces to publish, the room to subscribe), only beside a proof of possession and within its window; joule-moq's GrantHead reads it under the authority's key. - A field "*" in a grant rule's or a door rule's prefix matches any field; a door rule's trust_field names the namespace field whose key it trusts there. - limits.max_session_ms, max_ingress_kbps (with ingress_burst_ms) and max_resident_mb; a session also ends when its authority grant expires. - The binary stops on SIGTERM as on Ctrl-C. Co-Authored-By: Claude Opus 5.5 --- .../joule-moq-ingest/tests/common/mod.rs | 2 +- .../crates/joule-moq-relay/src/auth.rs | 200 ++++++++++++++++-- .../crates/joule-moq-relay/src/config.rs | 53 ++++- .../crates/joule-moq-relay/src/door.rs | 100 ++++++++- .../crates/joule-moq-relay/src/main.rs | 50 ++++- .../crates/joule-moq-relay/src/relay.rs | 43 +++- .../crates/joule-moq-relay/src/session.rs | 46 +++- .../joule-moq-relay/tests/common/mod.rs | 2 +- .../joule-moq-relay/tests/room_authority.rs | 186 ++++++++++++++++ .../crates/joule-moq/src/grant_proof.rs | 145 ++++++++++++- 10 files changed, 782 insertions(+), 45 deletions(-) create mode 100644 joule-code/joule-code-rs/crates/joule-moq-relay/tests/room_authority.rs diff --git a/joule-code/joule-code-rs/crates/joule-moq-ingest/tests/common/mod.rs b/joule-code/joule-code-rs/crates/joule-moq-ingest/tests/common/mod.rs index 83cf688b3..e4b8a2a68 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-ingest/tests/common/mod.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-ingest/tests/common/mod.rs @@ -65,7 +65,7 @@ impl World { let room = descriptor.room_id(); let gk = hex::encode(gateway_key.verifying_key().to_bytes()); let mut rc = RelayConfig::loopback(); - rc.receipts.door = vec![joule_moq_relay::config::DoorRule { prefix: vec![hex::encode(room)], trust: vec![gk], hold_ms: 10_000 }]; + rc.receipts.door = vec![joule_moq_relay::config::DoorRule { prefix: vec![hex::encode(room)], trust: vec![gk], trust_field: None, hold_ms: 10_000 }]; let relay = RelayServer::start(rc).await.expect("the relay starts"); let mut gc = GatewayConfig::loopback(relay.quic_addr.expect("a UDP endpoint")); gc.relay.trust_der = vec![relay.identity.chain[0].to_vec()]; diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/auth.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/auth.rs index 3f74337f4..3c89b5829 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/auth.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/auth.rs @@ -7,10 +7,15 @@ //! is negotiated out-of-band"). A grant rule with `proof` names a room grant's record, //! which every participant of the room holds: it gives its rights only on a session whose //! SETUP proved possession of the grant's principal's key (WAI live-rooms §10.5). +//! +//! A relay that serves a room whose authority issues grants as visitors arrive cannot name +//! each in advance: it is given the authority's key instead, and honours every grant that +//! key signed, mapped onto rights as live-rooms §10.2 maps it, beside its proof and within +//! its window. A field `*` in a rule's prefix matches any one field. use crate::config::AuthConfig; -use joule_moq::draft22::names::prefix_matches; use joule_moq::draft22::{SessionError, Token}; +use joule_moq::grant_proof::GrantHead; use joule_moq::TrackNamespace; use std::collections::{HashMap, HashSet}; @@ -114,60 +119,130 @@ struct GrantRule { proof: bool, } +struct AuthorityRule { + key: [u8; 32], + /// Empty: every room the authority grants. + rooms: Vec<[u8; 32]>, +} + /// The relay's grants. pub struct Authorizer { required: bool, grants: Vec, + authorities: Vec, } pub fn sha256(b: &[u8]) -> [u8; 32] { crate::cert::sha256(b) } +/// Whether `prefix` matches the first fields of `ns` (MOQT §2.4.2), a field `*` of +/// `prefix` matching any one field. +pub fn pattern_matches(prefix: &TrackNamespace, ns: &TrackNamespace) -> bool { + prefix.0.len() <= ns.0.len() && prefix.0.iter().zip(&ns.0).all(|(p, n)| p.as_slice() == b"*" || p == n) +} + +fn key32(what: &str, h: &str) -> Result<[u8; 32], String> { + hex::decode(h).map_err(|e| format!("{what}: {e}"))?.try_into().map_err(|_| format!("{what} is not 32 bytes")) +} + +fn unix_s() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()) +} + +/// Whether a room grant's head gives `right` over `ns` (WAI live-rooms §10.2, with the +/// room id `R` and the principal `P` as 64 lowercase hexadecimal digits): PUBLISH under +/// `[R, P, m]` for each media kind `m` it may publish, of `[R, P]` (its catalog track) +/// when it may publish any, and under `[R, P, events]` when its policy permits any event +/// kind; SUBSCRIBE and FETCH under `[R]` when it may subscribe. The relay's rights are per +/// namespace, so §10.2's track matches (the layers up to `max_layer`) are not narrowed +/// here. +pub fn room_grant_allows(g: &GrantHead, ns: &TrackNamespace, right: Right) -> bool { + let r = hex::encode(g.room).into_bytes(); + let f = &ns.0; + if f.first() != Some(&r) { + return false; + } + match right { + Right::Subscribe => g.subscribe, + Right::Publish => { + if f.len() < 2 || f[1] != hex::encode(g.principal).into_bytes() { + return false; + } + match f.get(2).map(Vec::as_slice) { + None => g.publish & 0b111 != 0, + Some(b"events") => g.sends_events, + Some(b"audio") => g.publishes(1), + Some(b"video") => g.publishes(2), + Some(b"screen") => g.publishes(3), + Some(_) => false, + } + } + } +} + impl Authorizer { pub fn from_config(c: &AuthConfig) -> Result { let mut grants = Vec::new(); for g in &c.grants { - let h: [u8; 32] = hex::decode(&g.token_sha256) - .map_err(|e| format!("a grant's token_sha256: {e}"))? - .try_into() - .map_err(|_| "a grant's token_sha256 is not 32 bytes".to_string())?; + let h = key32("a grant's token_sha256", &g.token_sha256)?; grants.push(GrantRule { prefix: TrackNamespace::from_strs(g.prefix.iter().map(String::as_str)), token_sha256: h, publish: g.publish, subscribe: g.subscribe, proof: g.proof }); } - Ok(Authorizer { required: c.required, grants }) + let mut authorities = Vec::new(); + for a in &c.authorities { + let key = key32("an authority's key", &a.key)?; + ed25519_dalek::VerifyingKey::from_bytes(&key).map_err(|e| format!("an authority's key: {e}"))?; + let rooms = a.rooms.iter().map(|r| key32("an authority's room", r)).collect::>()?; + authorities.push(AuthorityRule { key, rooms }); + } + Ok(Authorizer { required: c.required, grants, authorities }) } pub fn open() -> Self { - Authorizer { required: false, grants: Vec::new() } + Authorizer { required: false, grants: Vec::new(), authorities: Vec::new() } + } + + /// The head of `value` when it is a room grant one of the configured authorities + /// signed, for a room that authority is honoured for. + pub fn authority_grant(&self, value: &[u8]) -> Option { + if self.authorities.is_empty() { + return None; + } + self.authorities.iter().find_map(|a| GrantHead::verified(value, &a.key).filter(|g| a.rooms.is_empty() || a.rooms.contains(&g.room))) } /// Whether `tokens` (the request's, and the session's from SETUP) give `right` over /// `ns`. A rule with `proof` counts only for a token whose SHA-256 `proven` holds: one - /// the session's SETUP proved possession of. + /// the session's SETUP proved possession of. An authority's grant counts only when + /// proven, and only within its window. pub fn allows(&self, ns: &TrackNamespace, right: Right, tokens: &[Resolved], proven: &HashSet<[u8; 32]>) -> bool { if !self.required { return true; } + let now = unix_s(); tokens.iter().any(|(ty, value)| { + if *ty != 0 { + return false; + } let h = sha256(value); - *ty == 0 - && self.grants.iter().any(|g| { - g.token_sha256 == h - && (!g.proof || proven.contains(&h)) - && prefix_matches(&g.prefix, ns) - && match right { - Right::Publish => g.publish, - Right::Subscribe => g.subscribe, - } - }) + let by_rule = self.grants.iter().any(|g| { + g.token_sha256 == h + && (!g.proof || proven.contains(&h)) + && pattern_matches(&g.prefix, ns) + && match right { + Right::Publish => g.publish, + Right::Subscribe => g.subscribe, + } + }); + by_rule || (proven.contains(&h) && self.authority_grant(value).is_some_and(|g| g.good_at(now) && room_grant_allows(&g, ns, right))) }) } - /// Whether a token of type 0 with this value is a room grant a rule honours only beside - /// a proof of possession. + /// Whether a token of type 0 with this value is a room grant honoured only beside a + /// proof of possession: one a `proof` rule names, or one a configured authority signed. pub fn requires_proof(&self, value: &[u8]) -> bool { let h = sha256(value); - self.required && self.grants.iter().any(|g| g.proof && g.token_sha256 == h) + self.required && (self.grants.iter().any(|g| g.proof && g.token_sha256 == h) || self.authority_grant(value).is_some()) } } @@ -198,6 +273,7 @@ mod tests { fn grants_cover_a_prefix_and_a_right() { let cfg = AuthConfig { required: true, + authorities: vec![], grants: vec![ crate::config::Grant { prefix: vec!["live".into()], token_sha256: hex::encode(sha256(b"t")), publish: false, subscribe: true, proof: false }, crate::config::Grant { prefix: vec!["live".into()], token_sha256: hex::encode(sha256(b"g")), publish: true, subscribe: false, proof: true }, @@ -216,4 +292,86 @@ mod tests { assert!(!a.allows(&ns, Right::Publish, &[(0, b"g".to_vec())], &none)); assert!(a.allows(&ns, Right::Publish, &[(0, b"g".to_vec())], &HashSet::from([sha256(b"g")]))); } + + /// A grant laid out as WAI live-rooms §4.1 lays it out, signed by `authority`. + pub(crate) fn grant(authority: &ed25519_dalek::SigningKey, room: [u8; 32], principal: [u8; 32], nbf: u64, exp: u64, publish: u8, kinds: bool) -> Vec { + use ed25519_dalek::Signer; + let mut g = b"WRG1\x01".to_vec(); + g.extend_from_slice(&room); + g.extend_from_slice(&principal); + g.extend_from_slice(&[0; 40]); // prev, effective + g.extend_from_slice(&nbf.to_le_bytes()); + g.extend_from_slice(&exp.to_le_bytes()); + g.extend_from_slice(&[0, publish, 1, 0, 0, 0]); + g.extend_from_slice(&[0; 32]); + g.extend_from_slice(b"WEP1\x01"); + let mut k = [0u8; 32]; + if kinds { + k[0] = 1 << 3; + } + g.extend_from_slice(&k); + g.extend_from_slice(&[0; 28]); + g.extend_from_slice(&[0xff; 16]); + g.extend_from_slice(&[0; 3]); + g.extend_from_slice(&[0, 0, 0, 0]); + let mut m = joule_moq::grant_proof::GRANT_DOMAIN.to_vec(); + m.extend_from_slice(&g); + g.extend_from_slice(&authority.sign(&m).to_bytes()); + g + } + + #[test] + fn a_field_star_matches_any_field() { + let p = TrackNamespace::from_strs(["*", "k"]); + assert!(pattern_matches(&p, &TrackNamespace::from_strs(["r1", "k", "events"]))); + assert!(pattern_matches(&p, &TrackNamespace::from_strs(["r2", "k"]))); + assert!(!pattern_matches(&p, &TrackNamespace::from_strs(["r1", "j"]))); + assert!(!pattern_matches(&p, &TrackNamespace::from_strs(["r1"]))); + assert!(pattern_matches(&TrackNamespace::from_strs(Vec::<&str>::new()), &TrackNamespace::from_strs(["any"]))); + } + + #[test] + fn an_authoritys_grant_maps_onto_rights_beside_its_proof() { + let authority = ed25519_dalek::SigningKey::from_bytes(&[7; 32]); + let stranger = ed25519_dalek::SigningKey::from_bytes(&[8; 32]); + let (room, p) = ([0x11; 32], [0x22; 32]); + let cfg = |rooms: Vec| AuthConfig { + required: true, + grants: vec![], + authorities: vec![crate::config::Authority { key: hex::encode(authority.verifying_key().to_bytes()), rooms }], + }; + let a = Authorizer::from_config(&cfg(vec![])).unwrap(); + let now = unix_s(); + let g = grant(&authority, room, p, now - 60, now + 600, 0b011, true); + let tok = vec![(0u64, g.clone())]; + let proven = HashSet::from([sha256(&g)]); + let (r, ph) = (hex::encode(room), hex::encode(p)); + let ns = |f: &[&str]| TrackNamespace::from_strs(f.iter().copied()); + assert!(a.requires_proof(&g)); + // Without its proof, nothing. + assert!(!a.allows(&ns(&[&r, &ph, "video"]), Right::Publish, &tok, &HashSet::new())); + // With it: its own media, catalog and events; every track of the room to subscribe. + for f in [vec![r.as_str(), ph.as_str(), "video"], vec![&r, &ph, "audio"], vec![&r, &ph], vec![&r, &ph, "events"]] { + assert!(a.allows(&ns(&f), Right::Publish, &tok, &proven), "{f:?}"); + } + assert!(!a.allows(&ns(&[&r, &ph, "screen"]), Right::Publish, &tok, &proven)); + assert!(!a.allows(&ns(&[&r, &"33".repeat(32), "video"]), Right::Publish, &tok, &proven), "another principal's"); + assert!(!a.allows(&ns(&[&"44".repeat(32), &ph, "video"]), Right::Publish, &tok, &proven), "another room"); + assert!(a.allows(&ns(&[&r, &"33".repeat(32), "video"]), Right::Subscribe, &tok, &proven)); + assert!(!a.allows(&ns(&[&"44".repeat(32)]), Right::Subscribe, &tok, &proven)); + // Outside its window, signed by another key, or for a room the rule does not name. + let old = grant(&authority, room, p, now - 600, now - 60, 0b011, true); + assert!(!a.allows(&ns(&[&r]), Right::Subscribe, &[(0, old.clone())], &HashSet::from([sha256(&old)]))); + let forged = grant(&stranger, room, p, now - 60, now + 600, 0b011, true); + assert!(!a.requires_proof(&forged)); + assert!(!a.allows(&ns(&[&r]), Right::Subscribe, &[(0, forged.clone())], &HashSet::from([sha256(&forged)]))); + let narrow = Authorizer::from_config(&cfg(vec!["55".repeat(32)])).unwrap(); + assert!(!narrow.allows(&ns(&[&r]), Right::Subscribe, &tok, &proven) && !narrow.requires_proof(&g)); + // A viewer's grant: no media, no catalog, no events; the room to subscribe. + let v = grant(&authority, room, p, now - 60, now + 600, 0, false); + let vt = vec![(0u64, v.clone())]; + let vp = HashSet::from([sha256(&v)]); + assert!(!a.allows(&ns(&[&r, &ph]), Right::Publish, &vt, &vp) && !a.allows(&ns(&[&r, &ph, "events"]), Right::Publish, &vt, &vp)); + assert!(a.allows(&ns(&[&r]), Right::Subscribe, &vt, &vp)); + } } diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/config.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/config.rs index db37995e8..3182be65e 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/config.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/config.rs @@ -133,6 +133,24 @@ pub struct LimitsConfig { /// still apply (§5.2), rather than in the transport's buffers. #[serde(default = "d_send_window")] pub quic_send_window_bytes: u64, + /// The longest a session may last, in milliseconds; past it the relay closes it + /// (`NO_ERROR`, with the limit as its reason). 0: no limit. + #[serde(default)] + pub max_session_ms: u64, + /// The object bytes a session may send the relay, in kilobits a second, averaged over + /// `ingress_burst_ms`; past it the relay closes the session (`INTERNAL_ERROR`, with + /// the limit as its reason). 0: no limit. + #[serde(default)] + pub max_ingress_kbps: u64, + /// The window `max_ingress_kbps` is averaged over: a session may send that rate's + /// worth of this many milliseconds at once. + #[serde(default = "d_ingress_burst")] + pub ingress_burst_ms: u64, + /// The resident memory, in MiB, past which the relay binary exits with status 70, for + /// the supervisor that runs it to start it again. The library does not read it. 0: + /// no limit. + #[serde(default)] + pub max_resident_mb: u64, } fn d_sessions() -> usize { @@ -180,6 +198,9 @@ fn d_idle() -> u64 { fn d_send_window() -> u64 { 1 << 20 } +fn d_ingress_burst() -> u64 { + 4_000 +} impl Default for LimitsConfig { fn default() -> Self { @@ -199,6 +220,10 @@ impl Default for LimitsConfig { websocket_buffer_bytes: d_ws_buffer(), idle_timeout_ms: d_idle(), quic_send_window_bytes: d_send_window(), + max_session_ms: 0, + max_ingress_kbps: 0, + ingress_burst_ms: d_ingress_burst(), + max_resident_mb: 0, } } } @@ -231,12 +256,19 @@ impl Default for ReceiptsConfig { #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct DoorRule { - /// The Track Namespace prefix, one entry per field. + /// The Track Namespace prefix, one entry per field; a field `*` matches any one field. pub prefix: Vec, /// Publisher keys (Ed25519, hex) whose group receipts the door accepts. Empty: any key /// whose signature verifies, the subscriber binding trust itself. #[serde(default)] pub trust: Vec, + /// The namespace field (from 0) that names the one key the door accepts there, as 64 + /// hexadecimal digits: for a room's media namespaces `[room, principal, kind]` + /// (WAI live-rooms §6.1), field 1, so each publisher's groups verify under its own key + /// alone. A namespace whose field is not a key is refused at the door. With `trust` + /// empty only. + #[serde(default)] + pub trust_field: Option, /// How long an object waits at the door for its group's receipts. #[serde(default = "d_hold")] pub hold_ms: u64, @@ -266,12 +298,29 @@ pub struct AuthConfig { pub required: bool, #[serde(default)] pub grants: Vec, + /// Room authorities whose grants the relay honours as they are presented, without a + /// rule naming each (WAI live-rooms §10.2): a token of type 0 that is a `WRG1` record + /// whose signature verifies under one of these keys, presented in SETUP with a proof of + /// possession that passes (§10.5.2), within its window. + #[serde(default)] + pub authorities: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Authority { + /// The authority's Ed25519 key, hex: a room descriptor's `authority` (live-rooms §3.1). + pub key: String, + /// The room ids (hex) its grants are honoured for; empty: every room it grants. + #[serde(default)] + pub rooms: Vec, } #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct Grant { - /// The namespace prefix the grant covers, one entry per field. + /// The namespace prefix the grant covers, one entry per field; a field `*` matches any + /// one field. pub prefix: Vec, /// SHA-256 of the token value, hex. The token itself is never in the configuration. pub token_sha256: String, diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/door.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/door.rs index dbb353e20..e4b7767f9 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/door.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/door.rs @@ -21,10 +21,15 @@ //! - receipt bytes are forwarded unaltered; the relay re-signs nothing. //! //! The door reuses `joule-moq`'s verifier ([`ReceiptVerifier`]) and bundle reader. +//! +//! A rule names the keys it trusts, or (`trust_field`) the namespace field that names the +//! one key trusted there: a room's media namespaces are `[room, principal, kind]` (WAI +//! live-rooms §6.1), so a rule over `["*", "*", "video"]` with field 1 holds each +//! publisher's video to its own key, whoever joins. use crate::config::DoorRule; use joule_moq::carriage::ReceiptBundle; -use joule_moq::draft22::names::prefix_matches; +use crate::auth::pattern_matches; use joule_moq::receipt::{ObjectReceipt, ReceiptVerifier, VerifiedGroup}; use joule_moq::{GroupId, ObjectId, TrackName, TrackNamespace}; use std::collections::{HashMap, VecDeque}; @@ -35,12 +40,56 @@ use tokio::sync::Notify; /// How many groups of each track the door remembers. const GROUPS_HELD: usize = 4096; +enum Trust { + /// The rule's keys, or any key. + Fixed(ReceiptVerifier), + /// The key a namespace field names, hex. + Field(usize), +} + struct Rule { prefix: TrackNamespace, - verifier: ReceiptVerifier, + trust: Trust, hold: Duration, } +/// A rule's verifier, or one made for a namespace. +enum Held<'a> { + Borrowed(&'a ReceiptVerifier), + Owned(ReceiptVerifier), +} + +impl std::ops::Deref for Held<'_> { + type Target = ReceiptVerifier; + fn deref(&self) -> &ReceiptVerifier { + match self { + Held::Borrowed(v) => v, + Held::Owned(v) => v, + } + } +} + +impl Rule { + /// The verifier for `ns`, or why there is none. + fn verifier(&self, ns: &TrackNamespace) -> Result, String> { + match &self.trust { + Trust::Fixed(v) => Ok(Held::Borrowed(v)), + Trust::Field(i) => { + let key = ns + .0 + .get(*i) + .and_then(|f| std::str::from_utf8(f).ok()) + .filter(|f| f.len() == 64) + .and_then(|f| hex::decode(f).ok()) + .and_then(|b| <[u8; 32]>::try_from(b).ok()) + .and_then(|b| ed25519_dalek::VerifyingKey::from_bytes(&b).ok()) + .ok_or_else(|| format!("namespace field {i} names no publisher key"))?; + Ok(Held::Owned(ReceiptVerifier::with_anchors(vec![key]))) + } + } + } +} + enum Entry { Verified { group: VerifiedGroup, objects: HashMap }, Refused(String), @@ -80,14 +129,19 @@ impl Door { let b: [u8; 32] = hex::decode(k).map_err(|e| format!("a door key: {e}"))?.try_into().map_err(|_| "a door key is not 32 bytes".to_string())?; anchors.push(ed25519_dalek::VerifyingKey::from_bytes(&b).map_err(|e| format!("a door key: {e}"))?); } - let verifier = if anchors.is_empty() { ReceiptVerifier::new() } else { ReceiptVerifier::with_anchors(anchors) }; - out.push(Rule { prefix: TrackNamespace::from_strs(r.prefix.iter().map(String::as_str)), verifier, hold: Duration::from_millis(r.hold_ms) }); + let trust = match r.trust_field { + Some(_) if !anchors.is_empty() => return Err("a door rule names keys or a trust_field, not both".into()), + Some(i) => Trust::Field(i), + None if anchors.is_empty() => Trust::Fixed(ReceiptVerifier::new()), + None => Trust::Fixed(ReceiptVerifier::with_anchors(anchors)), + }; + out.push(Rule { prefix: TrackNamespace::from_strs(r.prefix.iter().map(String::as_str)), trust, hold: Duration::from_millis(r.hold_ms) }); } Ok(Door { rules: out, suffix: suffix.as_bytes().to_vec(), groups: Mutex::new(Groups::default()), arrived: Notify::new() }) } fn rule(&self, ns: &TrackNamespace) -> Option<&Rule> { - self.rules.iter().filter(|r| prefix_matches(&r.prefix, ns)).max_by_key(|r| r.prefix.0.len()) + self.rules.iter().filter(|r| pattern_matches(&r.prefix, ns)).max_by_key(|r| r.prefix.0.len()) } /// Whether `ns` is checked at the door. @@ -134,11 +188,12 @@ impl Door { if b.group.group_id != GroupId(group) { return Err("moqt_mismatch: the group receipt names another group".to_string()); } - let vg = rule.verifier.verify_group(&b.group).map_err(|e| e.to_string())?; + let verifier = rule.verifier(ns)?; + let vg = verifier.verify_group(&b.group).map_err(|e| e.to_string())?; let mut objects = HashMap::new(); for o in b.objects { o.check_moqt(b.group.track_alias, GroupId(group), o.object_id).map_err(|_| "moqt_mismatch: an object receipt of another track or group".to_string())?; - rule.verifier.verify_object(&o, &vg).map_err(|e| format!("an object receipt: {e}"))?; + verifier.verify_object(&o, &vg).map_err(|e| format!("an object receipt: {e}"))?; objects.insert(o.object_id.0, o); } Ok((vg, objects)) @@ -181,7 +236,11 @@ impl Door { { return Verdict::Refuse("its receipt property is not the receipt its group signed".into()); } - return match rule.verifier.verify_payload(payload, r, vg) { + let verifier = match rule.verifier(ns) { + Ok(v) => v, + Err(why) => return Verdict::Refuse(why), + }; + return match verifier.verify_payload(payload, r, vg) { Ok(()) => Verdict::Pass { joules_micro: r.joules_micro, class: r.energy_class.clone() }, Err(e) => Verdict::Refuse(format!("its payload does not verify: {e}")), }; @@ -226,7 +285,7 @@ mod tests { #[tokio::test] async fn the_door_passes_what_its_group_signed() { let key = ed25519_dalek::SigningKey::from_bytes(&[5; 32]); - let rule = DoorRule { prefix: vec!["live".into()], trust: vec![hex::encode(key.verifying_key().to_bytes())], hold_ms: 100 }; + let rule = DoorRule { prefix: vec!["live".into()], trust: vec![hex::encode(key.verifying_key().to_bytes())], trust_field: None, hold_ms: 100 }; let door = Door::new(&[rule], "_receipts").unwrap(); let ns = TrackNamespace::from_strs(["live", "a"]); let media = TrackName::from_str("cam"); @@ -253,7 +312,7 @@ mod tests { #[tokio::test] async fn an_object_waits_for_its_bundle() { let key = ed25519_dalek::SigningKey::from_bytes(&[5; 32]); - let rule = DoorRule { prefix: vec![], trust: vec![], hold_ms: 2_000 }; + let rule = DoorRule { prefix: vec![], trust: vec![], trust_field: None, hold_ms: 2_000 }; let door = std::sync::Arc::new(Door::new(&[rule], "_receipts").unwrap()); let ns = TrackNamespace::from_strs(["any"]); let media = TrackName::from_str("m"); @@ -265,4 +324,25 @@ mod tests { let v = tokio::time::timeout(Duration::from_secs(5), waiter).await.unwrap().unwrap(); assert!(matches!(v, Verdict::Pass { .. }), "{v:?}"); } + + #[tokio::test] + async fn a_trust_field_holds_each_namespace_to_the_key_it_names() { + let a = ed25519_dalek::SigningKey::from_bytes(&[5; 32]); + let b = ed25519_dalek::SigningKey::from_bytes(&[6; 32]); + let rule = DoorRule { prefix: vec!["*".into(), "*".into(), "video".into()], trust: vec![], trust_field: Some(1), hold_ms: 100 }; + let door = Door::new(&[rule], "_receipts").unwrap(); + let ns_a = TrackNamespace::from_strs(["room", &hex::encode(a.verifying_key().to_bytes()), "video"]); + let media = TrackName::from_str("0"); + assert!(door.checks(&ns_a) && !door.checks(&TrackNamespace::from_strs(["room", "x", "events"]))); + // a's groups verify in a's namespace; b's signature there drops its group. + door.bundle(&ns_a, &media, 0, &bundle(&a, 0, &[b"x"]).to_payload().unwrap()).unwrap(); + assert!(matches!(door.object(&ns_a, &media, 0, 0, b"x", None).await, Verdict::Pass { .. })); + assert!(door.bundle(&ns_a, &media, 1, &bundle(&b, 1, &[b"y"]).to_payload().unwrap()).is_err()); + // A namespace whose field is not a key: refused. + let odd = TrackNamespace::from_strs(["room", "not-a-key", "video"]); + assert!(door.bundle(&odd, &media, 0, &bundle(&a, 0, &[b"x"]).to_payload().unwrap()).is_err()); + // Keys and a trust field together are not a rule. + let both = DoorRule { prefix: vec![], trust: vec![hex::encode(a.verifying_key().to_bytes())], trust_field: Some(1), hold_ms: 1 }; + assert!(Door::new(&[both], "_receipts").is_err()); + } } diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/main.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/main.rs index a324f04da..d36ea473b 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/main.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/main.rs @@ -6,8 +6,10 @@ //! ``` //! //! It logs where it listens, the certificate's SHA-256 (for a page's -//! `serverCertificateHashes`), and the key it signs session summaries with. On Ctrl-C it -//! sends GOAWAY to every session and waits for them to leave. +//! `serverCertificateHashes`), and the key it signs session summaries with. On Ctrl-C or +//! SIGTERM (a supervisor stopping it) it sends GOAWAY to every session and waits for them +//! to leave. With `limits.max_resident_mb` set it exits with status 70 once its resident +//! memory passes that, for its supervisor to start it again. use joule_moq_relay::{RelayConfig, RelayServer}; use std::process::ExitCode; @@ -50,9 +52,51 @@ async fn main() -> ExitCode { tracing::info!("{line}"); } tracing::info!("session summaries signed by {}", hex::encode(server.relay().summary_key().to_bytes())); - let _ = tokio::signal::ctrl_c().await; + let max_mb = server.relay().config().limits.max_resident_mb; + if max_mb != 0 { + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + if let Some(kb) = resident_kb() + && kb / 1024 > max_mb + { + tracing::error!("resident memory {} MiB, past the limit of {max_mb} MiB: exiting", kb / 1024); + std::process::exit(70); + } + } + }); + } + stopped().await; tracing::info!("shutting down: GOAWAY to every session"); let closed = server.shutdown().await; tracing::info!("stopped; {closed} session(s) closed at the GOAWAY timeout"); ExitCode::SUCCESS } + +/// Ctrl-C, or on Unix SIGTERM. +async fn stopped() { + #[cfg(unix)] + { + let mut term = match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(t) => t, + Err(_) => { + let _ = tokio::signal::ctrl_c().await; + return; + } + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = term.recv() => {} + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} + +/// This process's resident set, in KiB, as `ps` reports it. +fn resident_kb() -> Option { + let out = std::process::Command::new("ps").args(["-o", "rss=", "-p", &std::process::id().to_string()]).output().ok()?; + String::from_utf8_lossy(&out.stdout).trim().parse().ok() +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs index 96274d8b3..b4b73817e 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs @@ -17,7 +17,7 @@ use ed25519_dalek::SigningKey; use joule_moq::draft22::data::{ObjectDatagram, SubgroupHeader, SubgroupId, SubgroupObject}; use joule_moq::draft22::kvp::{prop, Properties}; use joule_moq::draft22::names::{prefix_matches, render}; -use joule_moq::draft22::{GroupOrder, Location, PeerRequestIds, PublishDoneCode, RequestIds}; +use joule_moq::draft22::{GroupOrder, Location, PeerRequestIds, PublishDoneCode, RequestIds, SessionError}; use joule_moq::{TrackName, TrackNamespace}; use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -57,9 +57,50 @@ pub struct SessionCtx { pub opened_unix_ms: u64, pub close_code: Mutex>, pub jwp: bool, + /// The session's ingress allowance, where `limits.max_ingress_kbps` sets one. + pub ingress: Option>, +} + +/// A session's allowance of object bytes in (`limits.max_ingress_kbps`): a bucket that +/// holds `ingress_burst_ms` of the rate and refills at the rate. +#[derive(Debug)] +pub struct Ingress { + per_ms: f64, + cap: f64, + level: f64, + at: std::time::Instant, +} + +impl Ingress { + pub fn new(kbps: u64, burst_ms: u64) -> Self { + let per_ms = kbps as f64 * 1000.0 / 8.0 / 1000.0; + let cap = per_ms * burst_ms.max(1) as f64; + Ingress { per_ms, cap, level: cap, at: std::time::Instant::now() } + } + + /// Take `n` bytes; `false` when the bucket does not hold them. + pub fn take(&mut self, n: u64) -> bool { + let now = std::time::Instant::now(); + self.level = (self.level + now.duration_since(self.at).as_secs_f64() * 1000.0 * self.per_ms).min(self.cap); + self.at = now; + self.level -= n as f64; + self.level >= 0.0 + } } impl SessionCtx { + /// Account `n` object bytes received against the session's ingress allowance; when it + /// is spent, the session is closed and `false` returned. + pub fn take_ingress(&self, n: u64, kbps: u64) -> bool { + let Some(i) = &self.ingress else { return true }; + if i.lock().unwrap().take(n) { + return true; + } + tracing::info!(session = self.id, remote = %self.remote, kbps, "a session past the ingress limit: closed"); + self.close(SessionError::INTERNAL_ERROR.0, &format!("past the relay's ingress limit of {kbps} kbit/s")); + false + } + pub fn next_request_id(&self) -> Option { self.my_ids.lock().unwrap().next() } diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs index 5e9d2fbaa..95b70c57a 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs @@ -238,6 +238,24 @@ pub async fn serve(relay: Relay, conn: SharedConnection) { } } } + // When the session must end: at the relay's session limit, or when the earliest room + // grant an authority signed that it proved expires (live-rooms §10.2: with no + // re-validation, expiry alone ends a token). + let mut ends: Option<(Instant, u64, String)> = None; + let mut end_at = |at: Instant, code: SessionError, why: String| { + if ends.as_ref().is_none_or(|(t, _, _)| at < *t) { + ends = Some((at, code.0, why)); + } + }; + if cfg.limits.max_session_ms != 0 { + end_at(Instant::now() + Duration::from_millis(cfg.limits.max_session_ms), SessionError::NO_ERROR, format!("the relay's session limit of {} s", cfg.limits.max_session_ms / 1000)); + } + for (_, value) in setup_tokens.iter().filter(|(ty, _)| *ty == 0) { + if let Some(g) = relay.inner.auth.authority_grant(value).filter(|_| proven.contains(&crate::auth::sha256(value))) { + let left = g.exp.saturating_mul(1000).saturating_sub(opened); + end_at(Instant::now() + Duration::from_millis(left), SessionError::EXPIRED_AUTH_TOKEN, "the room grant expired".into()); + } + } let opening = Tally { room_grant: relay.metered_grant(&setup_tokens), ..Tally::default() }; let tally = Arc::new(Mutex::new(opening)); let sched = sched::spawn(conn.clone(), cfg.limits.write_concurrency, tally.clone()); @@ -261,6 +279,7 @@ pub async fn serve(relay: Relay, conn: SharedConnection) { opened_unix_ms: opened, close_code: Mutex::new(None), jwp, + ingress: (cfg.limits.max_ingress_kbps != 0).then(|| Mutex::new(crate::relay::Ingress::new(cfg.limits.max_ingress_kbps, cfg.limits.ingress_burst_ms))), }); relay.inner.state.lock().unwrap().sessions.insert(id, ctx.clone()); relay.inner.change.notify_waiters(); @@ -268,12 +287,20 @@ pub async fn serve(relay: Relay, conn: SharedConnection) { send_goaway(&ctx, cfg.goaway_uri.as_bytes(), cfg.limits.goaway_timeout_ms).await; } - let tasks = vec![ + let mut tasks = vec![ tokio::spawn(control_reader(ctx.clone(), ctl_rx)), tokio::spawn(bi_loop(relay.clone(), ctx.clone())), tokio::spawn(uni_loop(relay.clone(), ctx.clone(), early)), tokio::spawn(datagram_loop(relay.clone(), ctx.clone())), ]; + if let Some((at, code, why)) = ends { + let c = ctx.clone(); + tasks.push(tokio::spawn(async move { + tokio::time::sleep_until(at.into()).await; + tracing::info!(session = c.id, remote = %c.remote, reason = %why, "a session at its limit: closed"); + c.close(code, &why); + })); + } let why = conn.closed().await; for t in tasks { t.abort(); @@ -352,11 +379,11 @@ async fn datagram_loop(relay: Relay, ctx: Arc) { // §3.1.3.1: a datagram whose alias is not yet known MAY be dropped. None => continue, }; + let len = match &d.content { + joule_moq::draft22::data::DatagramContent::Payload(p) => p.len() as u64, + _ => 0, + }; { - let len = match &d.content { - joule_moq::draft22::data::DatagramContent::Payload(p) => p.len() as u64, - _ => 0, - }; let mut t = ctx.tally.lock().unwrap(); t.publisher.objects_received += 1; t.publisher.object_bytes_received += len; @@ -364,6 +391,9 @@ async fn datagram_loop(relay: Relay, ctx: Arc) { tr.objects_received += 1; tr.object_bytes_received += len; } + if !ctx.take_ingress(len, relay.config().limits.max_ingress_kbps) { + return; + } relay.inner.stats.objects_in.fetch_add(1, Ordering::Relaxed); if relay.inner.door.checks(&key.0) && relay.inner.door.media_of(&key.1).is_none() { let relay = relay.clone(); @@ -497,6 +527,12 @@ async fn upstream_subgroup(relay: Relay, ctx: Arc, h: joule_moq::dra tr.objects_received += 1; tr.object_bytes_received += o.payload.len() as u64; } + if !ctx.take_ingress(o.payload.len() as u64, relay.config().limits.max_ingress_kbps) { + if let Some(sid) = subgroup_id { + relay.end_subgroup(&key, h.group_id, sid, 0, &started, Some(StreamResetCode::SESSION_CLOSED.0)); + } + return; + } relay.inner.stats.objects_in.fetch_add(1, Ordering::Relaxed); let mut signed = None; if door_on && o.status == ObjectStatus::Normal { diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/common/mod.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/common/mod.rs index 9a79aba60..f1f023f78 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/common/mod.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/common/mod.rs @@ -354,7 +354,7 @@ pub async fn two_publishers_three_subscribers(via: Via) { let key_b = SigningKey::from_bytes(&[0xb2; 32]); let (ka, kb) = (hex::encode(key_a.verifying_key().to_bytes()), hex::encode(key_b.verifying_key().to_bytes())); let server = relay(|c| { - c.receipts.door = vec![joule_moq_relay::config::DoorRule { prefix: vec!["live".into()], trust: vec![ka, kb], hold_ms: 5_000 }]; + c.receipts.door = vec![joule_moq_relay::config::DoorRule { prefix: vec!["live".into()], trust: vec![ka, kb], trust_field: None, hold_ms: 5_000 }]; }) .await; const GROUPS: u64 = 12; diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/room_authority.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/room_authority.rs new file mode 100644 index 000000000..c0564fa8f --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/room_authority.rs @@ -0,0 +1,186 @@ +//! A relay that serves a room whose authority issues grants as visitors arrive (WAI +//! live-rooms §10.2, §10.5): it holds the authority's key, not a rule for each grant. +//! +//! - A grant the authority signed, presented in SETUP with its proof, gives the rights +//! §10.2 maps it onto: a publisher its own media namespaces, a viewer the room to +//! subscribe to; the same grant with no proof closes the session, and a grant signed by +//! another key gives nothing. +//! - The door, with a rule over `["*", "*", "video"]` and `trust_field = 1`, holds each +//! publisher's video to the key its namespace names: a group signed by another key is +//! dropped there. +//! - The relay's limits end a session: at its session limit (`NO_ERROR`), when its grant +//! expires (`EXPIRED_AUTH_TOKEN`), and past its ingress limit (`INTERNAL_ERROR`). +//! +//! Over the WebSocket binding, the transport a deployment behind an HTTP proxy uses. + +mod common; + +use common::*; +use ed25519_dalek::{Signer, SigningKey}; +use joule_moq::draft22::{Parameters, RequestErrorCode, SessionError, Token}; +use joule_moq_relay::client::{Client, ClientOptions}; +use joule_moq_relay::config::{Authority, DoorRule}; +use joule_moq_relay::transport::TransportError; +use joule_moq_relay::RelayServer; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::time::timeout; + +fn now_s() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() +} + +/// A grant laid out as live-rooms §4.1 lays it out: `publish` the media bits, a policy that +/// permits the chat kind when `events`, signed by `authority`. +fn grant(authority: &SigningKey, room: [u8; 32], principal: &SigningKey, nbf: u64, exp: u64, publish: u8, events: bool) -> Vec { + let mut g = b"WRG1\x01".to_vec(); + g.extend_from_slice(&room); + g.extend_from_slice(&principal.verifying_key().to_bytes()); + g.extend_from_slice(&[0; 40]); + g.extend_from_slice(&nbf.to_le_bytes()); + g.extend_from_slice(&exp.to_le_bytes()); + g.extend_from_slice(&[0, publish, 1, 0, 0, 0]); + g.extend_from_slice(&[0; 32]); + g.extend_from_slice(b"WEP1\x01"); + let mut kinds = [0u8; 32]; + if events { + kinds[0] = 1 << 3; + } + g.extend_from_slice(&kinds); + g.extend_from_slice(&[0; 28]); + g.extend_from_slice(&[0xff; 16]); + g.extend_from_slice(&[0; 3]); + g.extend_from_slice(&[0, 0, 0, 0]); + let mut m = joule_moq::grant_proof::GRANT_DOMAIN.to_vec(); + m.extend_from_slice(&g); + g.extend_from_slice(&authority.sign(&m).to_bytes()); + g +} + +fn target(server: &RelayServer) -> String { + format!("//{}{}", server.websocket_addr.unwrap(), server.relay().config().path) +} + +fn with(server: &RelayServer, record: &[u8], key: &SigningKey) -> ClientOptions { + ClientOptions { grants: vec![(record.to_vec(), key.clone())], target: target(server), ..ClientOptions::default() } +} + +async fn closed_with(c: &Client, code: SessionError, within: Duration, what: &str) -> String { + match timeout(within, c.conn.closed()).await.unwrap_or_else(|_| panic!("{what}: not closed in time")) { + TransportError::Closed { code: got, reason, .. } => { + assert_eq!(got, code.0, "{what}: {reason}"); + reason + } + other => panic!("{what}: {other:?}"), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn an_authoritys_grants_their_proofs_the_door_and_the_limits() { + timeout(Duration::from_secs(180), run()).await.expect("the test finishes in time"); +} + +async fn run() { + let authority = SigningKey::from_bytes(&[0x61; 32]); + let stranger = SigningKey::from_bytes(&[0x62; 32]); + let (alice, bob, viewer) = (SigningKey::from_bytes(&[0x63; 32]), SigningKey::from_bytes(&[0x64; 32]), SigningKey::from_bytes(&[0x65; 32])); + let room = [0x7e; 32]; + let r = hex::encode(room); + let server = relay(|c| { + c.auth.required = true; + c.auth.authorities = vec![Authority { key: hex::encode(authority.verifying_key().to_bytes()), rooms: vec![] }]; + c.receipts.door = vec![DoorRule { prefix: vec!["*".into(), "*".into(), "video".into()], trust: vec![], trust_field: Some(1), hold_ms: 2_000 }]; + }) + .await; + let now = now_s(); + let ga = grant(&authority, room, &alice, now - 60, now + 600, 0b011, true); + let gb = grant(&authority, room, &bob, now - 60, now + 600, 0b011, true); + let gv = grant(&authority, room, &viewer, now - 60, now + 600, 0, false); + let ta = Tracks::new(ns(&[&r, &hex::encode(alice.verifying_key().to_bytes()), "video"]), "0", 10, 5, 128); + let tb = Tracks::new(ns(&[&r, &hex::encode(bob.verifying_key().to_bytes()), "video"]), "0", 10, 5, 128); + + // Alice publishes her video; the viewer subscribes to it and every object verifies. + let (a, _) = connect(&server, Via::WebSocket, with(&server, &ga, &alice)).await; + let _pa = publish_tracks(&a, &ta, None).await; + let (v, _) = connect(&server, Via::WebSocket, with(&server, &gv, &viewer)).await; + let sub = subscribe_both(&v, &ta, Parameters::new()).await; + for g in 0..5 { + publish_group(&a, &alice, &ta, g, &payload("a", g, 300)).await; + } + let got = collect_concurrent(&v, STEP, Duration::from_millis(500), |g| g.iter().filter(|x| x.header.track_alias == sub.media_alias).count() >= 5).await; + let (objects, verified, _, _) = verify(&got, &sub, &alice); + assert!(objects >= 5 && verified == objects, "{verified} of {objects} verified"); + + // Alice may not publish under Bob's namespace, the viewer may publish nothing of media, + // and neither may publish in another room. + let refused = a.publish(tb.ns.clone(), tb.media.clone(), 30, Parameters::new(), track_props(128, None)).await.expect_err("another principal's namespace"); + assert!(refused.contains(&RequestErrorCode::UNAUTHORIZED.to_string()), "{refused}"); + let refused = v.publish(ns(&[&r, &hex::encode(viewer.verifying_key().to_bytes()), "video"]), name("0"), 30, Parameters::new(), track_props(128, None)).await.expect_err("a viewer's media"); + assert!(refused.contains(&RequestErrorCode::UNAUTHORIZED.to_string()), "{refused}"); + + // The door: Bob publishes in his own namespace with Alice's key; his group is dropped. + let (b, _) = connect(&server, Via::WebSocket, with(&server, &gb, &bob)).await; + let _pb = publish_tracks(&b, &tb, None).await; + let sb = subscribe_both(&v, &tb, Parameters::new()).await; + publish_group(&b, &alice, &tb, 0, &payload("b", 0, 300)).await; + let got = collect_concurrent(&v, Duration::from_secs(4), Duration::from_millis(500), |_| false).await; + assert!(got.iter().all(|x| x.header.track_alias != sb.media_alias || x.reset.is_some() || x.objects.is_empty()), "a group signed by another key went past the door"); + assert!(server.relay().stats().refused_at_door >= 1 || server.relay().stats().groups_dropped_at_door >= 1, "the door refused it"); + + // The grant with no proof: the session is refused. A grant another key signed is no + // grant here: its requests are refused, its session stands. + let bare = ClientOptions { tokens: vec![Token::UseValue { token_type: 0, value: ga.clone() }], ..ClientOptions::default() }; + let (c, _) = connect(&server, Via::WebSocket, bare).await; + closed_with(&c, SessionError::UNAUTHORIZED, STEP, "a grant with no proof").await; + let forged = grant(&stranger, room, &alice, now - 60, now + 600, 0b011, true); + let (c, _) = connect(&server, Via::WebSocket, with(&server, &forged, &alice)).await; + let refused = c.subscribe(ta.ns.clone(), ta.media.clone(), Parameters::new()).await.expect_err("a grant another key signed"); + assert!(refused.contains(&RequestErrorCode::UNAUTHORIZED.to_string()), "{refused}"); + for c in [&a, &b, &v, &c] { + c.close(0, "done"); + } + server.shutdown().await; + + // The limits, each on a relay of its own. + let limited = |f: fn(&mut joule_moq_relay::RelayConfig)| { + let key = hex::encode(authority.verifying_key().to_bytes()); + relay(move |c| { + c.auth.required = true; + c.auth.authorities = vec![Authority { key, rooms: vec![] }]; + f(c); + }) + }; + // At its session limit. + let s = limited(|c| c.limits.max_session_ms = 1_500).await; + let (c, _) = connect(&s, Via::WebSocket, with(&s, &gv, &viewer)).await; + let why = closed_with(&c, SessionError::NO_ERROR, Duration::from_secs(10), "the session limit").await; + assert!(why.contains("session limit"), "{why}"); + s.shutdown().await; + // When its grant expires. + let s = limited(|_| {}).await; + let short = grant(&authority, room, &viewer, now - 60, now_s() + 2, 0, false); + let (c, _) = connect(&s, Via::WebSocket, with(&s, &short, &viewer)).await; + closed_with(&c, SessionError::EXPIRED_AUTH_TOKEN, Duration::from_secs(10), "the grant's expiry").await; + s.shutdown().await; + // Past its ingress limit: 64 kbit/s over a second is 8 kB; ten 4 kB groups pass it. + let s = limited(|c| { + c.limits.max_ingress_kbps = 64; + c.limits.ingress_burst_ms = 1_000; + }) + .await; + let (c, _) = connect(&s, Via::WebSocket, with(&s, &ga, &alice)).await; + let _p = publish_tracks(&c, &ta, None).await; + for g in 0..10 { + if c.send_subgroup( + joule_moq::draft22::data::SubgroupHeader { track_alias: ta.media_alias, group_id: g, subgroup_id: joule_moq::draft22::data::SubgroupId::Zero, publisher_priority: Some(128), properties: false, end_of_group: true, first_object: true }, + &[joule_moq::draft22::data::SubgroupObject::normal(0, payload("x", g, 4_000))], + ) + .await + .is_err() + { + break; + } + } + let why = closed_with(&c, SessionError::INTERNAL_ERROR, Duration::from_secs(10), "the ingress limit").await; + assert!(why.contains("ingress limit"), "{why}"); + s.shutdown().await; +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/grant_proof.rs b/joule-code/joule-code-rs/crates/joule-moq/src/grant_proof.rs index d179289d1..008bf67fd 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/grant_proof.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/grant_proof.rs @@ -19,7 +19,10 @@ //! from the caller, so a verifier that reads grants with its own reader (a gateway held to //! WAI's rooms corpus) uses it as it is; [`grant_digest_and_principal`] reads the two from //! a `WRG1` record for a verifier that holds no reader, such as the relay, whose rules name -//! each grant they honour by the SHA-256 of its record. +//! each grant they honour by the SHA-256 of its record. [`GrantHead::verified`] reads the +//! fields §10.2 maps onto MOQT rights from a grant whose signature verifies under an +//! authority the verifier trusts, for a relay that honours every grant that authority +//! issues rather than a list named in advance. use crate::draft22::kvp::{opt, KeyValuePair, SetupOptions}; use crate::draft22::Token; @@ -214,6 +217,87 @@ pub fn grant_digest_and_principal(record: &[u8]) -> Option<([u8; 32], [u8; 32])> Some((digest, record[37..69].try_into().expect("32 bytes"))) } +/// What a room grant's signature covers before its record (live-rooms §4.2): the 19 +/// bytes `wai:live-room-grant` and 0x01. +pub const GRANT_DOMAIN: &[u8] = b"wai:live-room-grant\x01"; + +/// The shortest `WRG1` record: its fixed fields through `currency` (247 bytes), an empty +/// `profiles` and `origins` (a count each), an empty `attrs` (its u16 length) and `sig`. +const GRANT_MIN_LEN: usize = 247 + 1 + 1 + 2 + 64; + +/// The fields of a room grant (live-rooms §4.1) that a relay maps onto rights (§10.2): the +/// room, the principal, the window and the capability set, read from a `WRG1` record whose +/// signature verified under the authority the relay trusts. +/// +/// It reads the record's fixed head alone. The rest (its policy's bounds, its profiles, +/// origins and attributes) is the room's to check (§4.3): a relay acts on what the +/// authority signed, and an authority signs only grants it made. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GrantHead { + /// The grant's digest (§4.2): what a proof names. + pub digest: [u8; 32], + pub room: [u8; 32], + pub principal: [u8; 32], + /// Seconds since 1970-01-01T00:00:00Z: good from `nbf` until before `exp`. + pub nbf: u64, + pub exp: u64, + /// The media kinds it may publish, a bit each: bit 0 audio, bit 1 video, bit 2 screen. + pub publish: u8, + pub subscribe: bool, + pub max_layer: u8, + /// Its policy permits some event kind (§10.2 item 4: its events track). + pub sends_events: bool, +} + +impl GrantHead { + /// The head of `record`, a `WRG1` record whose signature verifies strictly under + /// `authority` (§4.2); `None` for anything else: another magic or version, a record + /// shorter than any grant, a `subscribe` that is not 0 or 1, or a signature that does + /// not verify. + pub fn verified(record: &[u8], authority: &[u8; 32]) -> Option { + if record.len() < GRANT_MIN_LEN || &record[..4] != GRANT_MAGIC || record[4] != 1 { + return None; + } + let (body, sig) = record.split_at(record.len() - 64); + let sig: [u8; 64] = sig.try_into().ok()?; + let mut m = Vec::with_capacity(GRANT_DOMAIN.len() + body.len()); + m.extend_from_slice(GRANT_DOMAIN); + m.extend_from_slice(body); + crate::receipt::verify_strict(authority, &m, &sig).ok()?; + let at32 = |at: usize| -> [u8; 32] { record[at..at + 32].try_into().expect("32 bytes") }; + let at64 = |at: usize| u64::from_le_bytes(record[at..at + 8].try_into().expect("8 bytes")); + let subscribe = match record[127] { + 0 => false, + 1 => true, + _ => return None, + }; + Some(GrantHead { + digest: Sha256::digest(body).into(), + room: at32(5), + principal: at32(37), + nbf: at64(109), + exp: at64(117), + publish: record[126], + subscribe, + max_layer: record[128], + // The policy (a WEP1 record, live-events §5) is at 163; its kinds bitmap + // follows its magic and version. + sends_events: record[168..200].iter().any(|&b| b != 0), + }) + } + + /// Whether the grant is good at `unix_s`, seconds since the epoch: `nbf` ≤ it < `exp`. + pub fn good_at(&self, unix_s: u64) -> bool { + self.nbf <= unix_s && unix_s < self.exp + } + + /// Whether it may publish media kind `media` (live-rooms §6.1: 1 audio, 2 video, + /// 3 screen). + pub fn publishes(&self, media: u8) -> bool { + (1..=3).contains(&media) && self.publish & (1 << (media - 1)) != 0 + } +} + // ── In SETUP (§10.5.2) ────────────────────────────────────────────────────── /// Add the session's challenge to the relay's Setup Options. @@ -321,6 +405,65 @@ mod tests { assert!(GrantProof::sign(d, &k, [9; 32], "SETUP //a\nb").is_none()); } + /// A grant laid out as live-rooms §4.1 lays it out, signed by `authority`. + fn signed_grant(authority: &SigningKey, principal: &[u8; 32], nbf: u64, exp: u64, publish: u8, subscribe: u8, kinds: &[u8; 32]) -> Vec { + let mut g = GRANT_MAGIC.to_vec(); + g.push(1); + g.extend_from_slice(&[0x5a; 32]); // room + g.extend_from_slice(principal); + g.extend_from_slice(&[0; 32]); // prev + g.extend_from_slice(&0u64.to_le_bytes()); // effective + g.extend_from_slice(&nbf.to_le_bytes()); + g.extend_from_slice(&exp.to_le_bytes()); + g.extend_from_slice(&[0, publish, subscribe, 0, 0, 0]); // role, caps, precedence + g.extend_from_slice(&[0; 32]); // once, viewer + g.extend_from_slice(b"WEP1\x01"); + g.extend_from_slice(kinds); + g.extend_from_slice(&[0; 28]); // the policy's bounds + g.extend_from_slice(&u64::MAX.to_le_bytes()); + g.extend_from_slice(&u64::MAX.to_le_bytes()); + g.extend_from_slice(&[0; 3]); + g.extend_from_slice(&[2, 0, 1, 0, 0, 0]); // two profiles, no origin, no attrs + assert_eq!(g.len(), 247 + 3 + 1 + 2); + let mut m = GRANT_DOMAIN.to_vec(); + m.extend_from_slice(&g); + let sig = authority.sign(&m).to_bytes(); + g.extend_from_slice(&sig); + g + } + + #[test] + fn a_grant_head_is_read_under_its_authority_alone() { + let authority = SigningKey::from_bytes(&[3; 32]); + let stranger = SigningKey::from_bytes(&[4; 32]); + let p = SigningKey::from_bytes(&[5; 32]).verifying_key().to_bytes(); + let mut kinds = [0u8; 32]; + kinds[0] = 1 << 3; + let g = signed_grant(&authority, &p, 1_000, 2_000, 0b011, 1, &kinds); + let h = GrantHead::verified(&g, &authority.verifying_key().to_bytes()).expect("its authority's grant"); + assert_eq!((h.room, h.principal, h.nbf, h.exp), ([0x5a; 32], p, 1_000, 2_000)); + assert!(h.publishes(1) && h.publishes(2) && !h.publishes(3) && !h.publishes(0) && h.subscribe && h.sends_events); + // The digest is the one a proof names. + assert_eq!(grant_digest_and_principal(&g), Some((h.digest, p))); + assert!(h.good_at(1_000) && h.good_at(1_999) && !h.good_at(2_000) && !h.good_at(999)); + // Another key's signature, a changed byte, another version: nothing. + assert!(GrantHead::verified(&g, &stranger.verifying_key().to_bytes()).is_none()); + let mut t = g.clone(); + t[126] = 0b111; + assert!(GrantHead::verified(&t, &authority.verifying_key().to_bytes()).is_none()); + let mut v = g.clone(); + v[4] = 2; + assert!(GrantHead::verified(&v, &authority.verifying_key().to_bytes()).is_none()); + assert!(GrantHead::verified(&g[..200], &authority.verifying_key().to_bytes()).is_none()); + // A viewer: no media, no events. + let viewer = signed_grant(&authority, &p, 1_000, 2_000, 0, 1, &[0; 32]); + let h = GrantHead::verified(&viewer, &authority.verifying_key().to_bytes()).unwrap(); + assert!(!h.publishes(1) && !h.publishes(2) && !h.sends_events && h.subscribe); + // `subscribe` other than 0 or 1 is no grant. + let odd = signed_grant(&authority, &p, 1_000, 2_000, 0, 2, &[0; 32]); + assert!(GrantHead::verified(&odd, &authority.verifying_key().to_bytes()).is_none()); + } + #[test] fn base64url_round_trips_strictly() { for n in 0..10u8 { -- 2.54.0 (Apple Git-157) From f801f58acbd0f90db14643243c2ad97976c7e68c Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 03:13:32 -0400 Subject: [PATCH 2/9] joule-moq-room: a room service that issues grants and authors the lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It holds a room's authority and clock keys (a 0600 file outside every repository). POST /grant signs a short-lived WRG1 grant for a key a page made, a publisher's or a viewer's, bounded per client and per room; the relay honours it only beside the key's proof (live-rooms §10.5). The clock's session on the relay watches each publisher's video track and signs join, publish and leave on its events track (§7.2, live-events §8.1); GET /room serves the signed descriptor, the composition descriptor and the grants. A new room starts when the last publisher has left. A workspace of its own: it builds on wai-rs, whose serde_json features would otherwise reach every crate of the joule-code workspace. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-room/Cargo.lock | 2701 +++++++++++++++++ .../crates/joule-moq-room/Cargo.toml | 44 + .../crates/joule-moq-room/README.md | 85 + .../crates/joule-moq-room/room.example.toml | 32 + .../crates/joule-moq-room/src/epoch.rs | 411 +++ .../crates/joule-moq-room/src/http.rs | 139 + .../crates/joule-moq-room/src/keys.rs | 138 + .../crates/joule-moq-room/src/lib.rs | 382 +++ .../crates/joule-moq-room/src/limiter.rs | 88 + .../crates/joule-moq-room/src/main.rs | 121 + .../joule-moq-room/tests/room_service.rs | 240 ++ 11 files changed, 4381 insertions(+) create mode 100644 joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock create mode 100644 joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml create mode 100644 joule-code/joule-code-rs/crates/joule-moq-room/README.md create mode 100644 joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml create mode 100644 joule-code/joule-code-rs/crates/joule-moq-room/src/epoch.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq-room/src/limiter.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock new file mode 100644 index 000000000..5a24449a6 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock @@ -0,0 +1,2701 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cbindgen" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" +dependencies = [ + "heck", + "indexmap", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.119", + "tempfile", + "toml", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "serde", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastbloom" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" +dependencies = [ + "foldhash", + "libm", + "portable-atomic", + "siphasher", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "httlib-huffman" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "joule-moq" +version = "0.3.0" +dependencies = [ + "async-trait", + "blake3", + "bytes", + "chrono", + "ed25519-dalek", + "futures", + "jwp", + "rand_core 0.6.4", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "joule-moq-relay" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64", + "blake3", + "bytes", + "ed25519-dalek", + "futures", + "hex", + "joule-moq", + "quinn", + "rand_core 0.6.4", + "ring", + "rustls", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-tungstenite", + "toml", + "tracing", + "tracing-subscriber", + "wtransport", +] + +[[package]] +name = "joule-moq-room" +version = "0.1.0" +dependencies = [ + "bytes", + "ed25519-dalek", + "hex", + "http-body-util", + "hyper", + "hyper-util", + "joule-moq", + "joule-moq-relay", + "rand_core 0.6.4", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "wai", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jwp" +version = "0.1.0" +dependencies = [ + "blake3", + "bytes", + "ciborium", + "futures-util", + "lz4_flex", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lz4_flex" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "octets" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d59d30d3ad7f7e1c9a66e51c9cb71ae1c794b28ffe7053fd785defb0d180069" + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "fastbloom", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rcgen" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +dependencies = [ + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", + "sha2-asm", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha2-asm" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b845214d6175804686b2bd482bcffe96651bb2d1200742b712003504a2dac1ab" +dependencies = [ + "cc", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror", +] + +[[package]] +name = "twox-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wai" +version = "0.3.0" +dependencies = [ + "blake3", + "cbindgen", + "curve25519-dalek", + "ed25519-dalek", + "num-bigint", + "pkg-config", + "serde", + "serde_json", + "sha2 0.10.9", + "wai-ops", +] + +[[package]] +name = "wai-ops" +version = "0.1.0" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wtransport" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4273ce3157a3262a68665f8d3f20a0ac0c5b8a69ffd67f05ae986832ebec036" +dependencies = [ + "bytes", + "pem", + "quinn", + "rcgen", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "sha2 0.11.1", + "socket2", + "thiserror", + "time", + "tokio", + "tracing", + "url", + "wtransport-proto", + "x509-parser", +] + +[[package]] +name = "wtransport-proto" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aad9059572c7dbd6901ccef37f3b7321678cd708dcf58a64b1921dbeab7bfede" +dependencies = [ + "httlib-huffman", + "octets", + "thiserror", + "url", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", + "synstructure 0.14.0", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", + "synstructure 0.14.0", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml new file mode 100644 index 000000000..fc70ad04e --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "joule-moq-room" +version = "0.1.0" +description = "A room service for WAI live rooms over joule-moq-relay: it holds a room's authority and clock keys, issues short-lived WRG1 grants to keys visitors make in their browsers (each useless without that key's proof of possession), authors the room's lifecycle on the clock's events track from what the relay carries, and serves the signed room descriptor." +edition = "2024" +rust-version = "1.97" +license = "Apache-2.0" +repository = "https://github.com/transaction-science/open-standards" + +# A workspace of its own, with its own lock file: it builds on wai-rs (the room's records +# are WAI's), whose serde_json features would otherwise be unified into every crate of the +# joule-code workspace. +[workspace] + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "joule-moq-room" +path = "src/main.rs" + +[dependencies] +joule-moq = { path = "../joule-moq" } +joule-moq-relay = { path = "../joule-moq-relay" } +# The room's records (live-rooms, live-events, live-compose): no codec chain. +wai = { path = "../../../../wai/wai-rs", default-features = false, features = ["live_compose", "timed_text"] } +# The HTTP service (MIT), the versions joule-moq-ingest pins. +hyper = { version = "=1.11.1", features = ["server", "http1"] } +hyper-util = { version = "=0.1.20", default-features = false, features = ["tokio"] } +http-body-util = "=0.1.5" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal", "fs"] } +bytes = "1" +tracing = "0.1" +tracing-subscriber = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.9" +ed25519-dalek = { version = "2", features = ["rand_core"] } +rand_core_06 = { package = "rand_core", version = "0.6", features = ["getrandom"] } +sha2 = "0.10" +hex = "0.4" + +[dev-dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/README.md b/joule-code/joule-code-rs/crates/joule-moq-room/README.md new file mode 100644 index 000000000..dd4f74682 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-room/README.md @@ -0,0 +1,85 @@ +# joule-moq-room + +A room service for WAI live rooms (`wai/extensions/live-rooms.md`) over +`joule-moq-relay`. It holds one room's authority and clock keys and: + +- **issues grants.** A visitor's page makes an Ed25519 key and asks for a grant for it + (`POST /grant`). The service signs a short-lived `WRG1` grant: a publisher's (its own + audio and video, one of the room's places) or a viewer's (the room to watch). A grant is + public, so it is useless without its key: the relay, configured with the authority's key + (`[[auth.authorities]]`), honours it only beside a proof of possession over the relay's + own challenge (§10.5), which only the page that holds the key can make. Grants are + bounded per client address and overall, and publishers per room. +- **authors the lifecycle** (§7.2). The service's clock is a session on the relay. It + watches each publisher's video track (a SUBSCRIBE that asks for no objects): when the + track appears the clock signs `join` and `publish` for the publisher on its events track + (live-events §8.1), and when the relay ends the track (the publisher's session closed) it + signs `leave`. Pages author none of it. +- **serves the room.** `GET /room` gives the signed descriptor, the composition descriptor + and the publishers' grants, which a page verifies the room with (live-rooms §9). + +A room lasts while anyone publishes in it. When the last publisher has left, the next one +starts a new room: a new descriptor at a new `t0`, under the same authority and clock, so +each room's grants and chain hold one stretch of company. + +## Running it + +```text +joule-moq-room keygen new keys (mode 0600, never over a file); prints the + relay configuration lines that trust them +joule-moq-room relay-lines those lines again +joule-moq-room --config serve (room.example.toml shows every field) +``` + +The keys file must be readable by its owner alone; the service refuses it otherwise. +`Keys` has no `Debug`, so that no log prints a seed. + +The relay needs, beside its listener and limits, the lines `relay-lines` prints: + +```toml +[[auth.authorities]] +key = "" + +[[auth.grants]] # the clock's events track, in any room +prefix = ["*", "", "events"] +token_sha256 = "" +publish = true + +[[auth.grants]] # the clock watches the publishers +prefix = [] +token_sha256 = "" +subscribe = true +``` + +and a door that holds each publisher's media to its own key: + +```toml +[[receipts.door]] +prefix = ["*", "*", "video"] +trust_field = 1 +``` + +## HTTP + +| Request | Answer | +|---|---| +| `GET /`, `GET /health` | `{"ok", "relay": the clock's session is open, "room"}` | +| `GET /room` | the room: `room`, `t0_us`, `descriptor`, `compose`, `grants` (hex), `present`, `publishers`, `max_publishers` | +| `POST /grant` `{"principal": <64 hex>, "role": "publisher" \| "viewer"}` | `{"grant", "digest", "exp", "role", "room": }`; `409` `full` or `busy`; `429` `rate` with `retry_after_s`; `503` `relay` while the clock has no session | + +Every answer is JSON with `Cache-Control: no-store`. The client's address is +`CF-Connecting-IP`, else the first `X-Forwarded-For`, else the connection's: the service +listens on loopback behind the front that sets them. + +## Tests + +`cargo test` (this crate is a workspace of its own, with its own lock file, since it builds +on `wai-rs`): the keys file, the grants checked against the descriptor and read by the +relay's reader, the places, the limiter, and `tests/room_service.rs`, the service against a +relay on loopback: a grant and its proof open a session and the clock announces the +publisher, the grant alone opens none, the room verifies, a third publisher is refused, +clients are bounded, and a new room starts when the last publisher has left. + +## Licence + +Apache-2.0. diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml b/joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml new file mode 100644 index 000000000..5e759a2af --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml @@ -0,0 +1,32 @@ +# joule-moq-room: an example configuration. Nothing secret belongs here: the keys are a +# file of their own (`joule-moq-room keygen ` makes it, mode 0600), outside every +# repository, and the relay knows the clock's token by its SHA-256 alone. + +# The HTTP service, on loopback behind the front that serves the room's page. +listen = "127.0.0.1:9682" +keys = "/path/to/keys.toml" +# The relay's WebSocket endpoint, for the clock's own session (loopback). +relay_url = "ws://127.0.0.1:9681/moq" +# Exit with status 70 past this resident memory (MiB), for the supervisor to restart it. +max_resident_mb = 256 + +[room] +# Publishers at once, those whose grants are still waited for among them; the composition +# lays out this many tiles. +max_publishers = 4 +max_present = 16 +# A grant's life, in seconds. +publisher_grant_s = 1200 +viewer_grant_s = 1800 +# How long a publisher's grant holds its place before its tracks appear. +join_wait_s = 90 +# Grants one room takes; then a new room starts once it is empty. +max_grants = 64 + +[rate] +# Grants per client address: this many at once, then one more every this many seconds. +client_burst = 6 +client_every_s = 20 +# And for everyone together. +global_burst = 120 +global_every_s = 10 diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/epoch.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/epoch.rs new file mode 100644 index 000000000..bdfac1462 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/epoch.rs @@ -0,0 +1,411 @@ +//! One room, from its descriptor to its last lifecycle event: the records WAI live-rooms +//! defines, made with `wai-rs`. +//! +//! A room is kept while anyone publishes in it. When the last publisher has left (or a +//! room has taken its share of grants and is empty), the next visitor who asks to publish +//! starts a new one: a new descriptor at a new `t0`, so a new room id, with the same +//! authority and clock. Each room's grants and clock chain hold one stretch of company, and +//! stay small. + +use crate::keys::Keys; +use serde_json::{json, Value}; +use std::collections::HashMap; +use wai::live_compose::layout::{aspect, fill, kind as lkind, Params}; +use wai::live_compose::{Compose, NO_COMPOSITOR}; +use wai::live_events::{kind, Event, Payload, Policy}; +use wai::live_room::{layering, media, registered_profile, Caps, Descriptor, Detail, Grant, Layer, Limits, Profile, Recording, Review, NO_CEILING}; + +/// The kinds a participant may send: chats, reactions, markers and captions. +pub const PARTICIPANT_KINDS: [u8; 4] = [kind::CHAT, kind::REACTION, kind::MARKER, kind::CAPTION]; +/// The descriptor's profiles, by index: the registered `speech`, then the room's camera. +pub const PROFILE_AUDIO: u8 = 0; +pub const PROFILE_VIDEO: u8 = 1; +/// Roles, by index. +pub const ROLE_PARTICIPANT: u8 = 0; +pub const ROLE_VIEWER: u8 = 1; +/// `leave` reasons (live-rooms §7.2). +pub const LEFT: u8 = 1; +pub const GRANT_ENDED: u8 = 3; + +/// What a room allows, from the service's configuration. +#[derive(Clone, Debug)] +pub struct Settings { + /// Principals publishing at once, those whose grants are waiting to be used among them. + pub max_publishers: u16, + /// Principals present at once (the descriptor's `max_present`). + pub max_present: u32, + pub publisher_grant_s: u64, + pub viewer_grant_s: u64, + /// How long a publisher's grant holds a place before its tracks appear. + pub join_wait_s: u64, + /// Grants one room takes; past it, a new room starts once this one is empty. + pub max_grants: usize, +} + +/// The room's camera: one layer of 320 × 240 at 15 frames a second, VP9 first and AV1 +/// second, so a page's WebCodecs encoder takes the first it supports. +pub fn camera_profile() -> Profile { + Profile { + name: "camera-room".into(), + media: media::VIDEO, + codecs: vec!["vp09.00.10.08".into(), "av01.0.04M.08".into()], + layering: layering::SINGLE, + mode: String::new(), + layers: vec![Layer { width: 320, height: 240, fps: 15, min_kbps: 100, max_kbps: 600 }], + sample_rate: 0, + channels: 0, + processing: 0, + } +} + +/// The composition: a 640 × 360 grid at 15 frames a second, 16:9 tiles with an 8-pixel +/// gap, ordered by each grant's `order` attribute, stereo at 48 kHz. +pub fn grid(max: u32) -> Params { + Params { + width: 640, + height: 360, + fps: 15, + layout: lkind::GRID, + gap: 8, + aspect: aspect::VIDEO, + fill: fill::DEFAULT, + featured_attr: "featured".into(), + order_attr: "order".into(), + omit_stopped: false, + pip_attr: String::new(), + corner: 0, + pip_width: 0, + pip_height: 0, + offset: 0, + behaviour: 0, + channels: 2, + sample_rate: 48_000, + gain_attr: "gain".into(), + pan_attr: "pan".into(), + max_tiles: max, + max_voices: max, + } +} + +fn bits(ks: &[u8]) -> [u8; 32] { + let mut b = [0u8; 32]; + for &k in ks { + b[(k / 8) as usize] |= 1 << (k % 8); + } + b +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Role { + Publisher, + Viewer, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MemberState { + /// Granted; its tracks have not appeared by `until` (unix seconds). + Pending { until: u64 }, + Present, + Left, +} + +#[derive(Clone, Debug)] +pub struct Member { + pub digest: [u8; 32], + pub exp: u64, + pub order: u32, + pub state: MemberState, +} + +/// A grant issued. +#[derive(Clone, Debug)] +pub struct Issued { + pub record: Vec, + pub digest: [u8; 32], + pub room: [u8; 32], + pub exp: u64, + pub role: Role, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Refusal { + /// The room has as many publishers as it takes. + Full, + /// The room has taken its grants and is not yet empty. + Busy, +} + +/// The clock's chain on its events track (live-events §3): its records in `seq` order. +#[derive(Clone, Debug, Default)] +pub struct Chain { + pub seq: u64, + pub prev: [u8; 32], + pub last_t: u64, + pub records: Vec>, +} + +pub struct Epoch { + /// Counts the rooms this service has kept since it started. + pub generation: u64, + pub id: [u8; 32], + pub t0_us: u64, + pub descriptor: Vec, + pub compose: Vec, + /// The publishers' grants, in the order issued: what a page verifies the room with. + pub grants: Vec>, + /// Viewers' grants issued (not listed: a viewer sends nothing the room verifies). + pub viewers: usize, + pub members: HashMap<[u8; 32], Member>, + next_order: u32, + pub joined_ever: bool, + pub settings: Settings, +} + +impl Epoch { + /// A new room at `now_us`. + pub fn new(keys: &Keys, settings: Settings, generation: u64, now_us: u64) -> Epoch { + let d = Descriptor { + authority: keys.authority.verifying_key().to_bytes(), + clock: keys.clock.verifying_key().to_bytes(), + t0_us: now_us, + tick_us: 1_000_000, + origin: String::new(), + limits: Limits { max_present: settings.max_present, max_publishers: settings.max_publishers, reval_s: 0, ceiling: Policy::default() }, + roles: vec![ + Role_ { name: "participant", caps: Caps { publish: 0b011, subscribe: true, max_layer: 0, powers: 0 }, events: bits(&PARTICIPANT_KINDS) }.into(), + Role_ { name: "viewer", caps: Caps { publish: 0, subscribe: true, max_layer: 0, powers: 0 }, events: bits(&[kind::CHAT]) }.into(), + ], + slots: vec![], + profiles: vec![registered_profile("speech").expect("a registered profile"), camera_profile()], + review: Review { kinds: [0; 32], deadline_ms: 0, fallback: 0 }, + recording: Recording { mode: 0, retention_days: 0, commit_interval_ms: 0, jitter_ms: 0, regions: vec![] }, + sig: [0; 64], + } + .sign(&keys.authority); + let id = d.room_id(); + let compose = Compose { + room: id, + signer: d.authority, + effective: 0, + compositor: NO_COMPOSITOR, + background: [16, 18, 24], + layer: 0, + params: grid(u32::from(settings.max_publishers).max(1)), + sig: [0; 64], + } + .sign(&keys.authority); + Epoch { + generation, + id, + t0_us: now_us, + descriptor: d.to_bytes(), + compose: compose.to_bytes(), + grants: Vec::new(), + viewers: 0, + members: HashMap::new(), + next_order: 0, + joined_ever: false, + settings, + } + } + + /// Publishers holding a place at `now_s`: present, or granted and still waited for. + pub fn publishers(&self, now_s: u64) -> usize { + self.members.values().filter(|m| matches!(m.state, MemberState::Present) || matches!(m.state, MemberState::Pending { until } if now_s < until)).count() + } + + pub fn present(&self) -> usize { + self.members.values().filter(|m| m.state == MemberState::Present).count() + } + + /// Whether a new room should start before a publisher is let in: nobody holds a place + /// here, and someone has been and gone, or the room has taken its grants. + pub fn spent(&self, now_s: u64) -> bool { + self.publishers(now_s) == 0 && (self.joined_ever || self.grants.len() >= self.settings.max_grants) + } + + /// A grant for `principal` in `role` at `now_s`, signed by the authority. + pub fn grant(&mut self, keys: &Keys, principal: [u8; 32], role: Role, now_s: u64) -> Result { + if role == Role::Publisher { + if self.publishers(now_s) >= usize::from(self.settings.max_publishers) { + return Err(Refusal::Full); + } + if self.grants.len() >= self.settings.max_grants { + return Err(Refusal::Busy); + } + } + let (r, caps, policy, profiles, ttl) = match role { + Role::Publisher => (ROLE_PARTICIPANT, Caps { publish: 0b011, subscribe: true, max_layer: 0, powers: 0 }, Policy::with_kinds(&PARTICIPANT_KINDS), vec![PROFILE_AUDIO, PROFILE_VIDEO], self.settings.publisher_grant_s), + Role::Viewer => (ROLE_VIEWER, Caps { publish: 0, subscribe: true, max_layer: 0, powers: 0 }, Policy::with_kinds(&[kind::CHAT]), vec![], self.settings.viewer_grant_s), + }; + let order = self.next_order; + let attrs = if role == Role::Publisher { vec![("order".to_string(), order.to_string())] } else { vec![] }; + // `nbf` a minute back, for a browser whose clock runs behind. + let (nbf, exp) = (now_s.saturating_sub(60), now_s + ttl); + let g = Grant { + room: self.id, + principal, + prev: [0; 32], + effective: 0, + nbf, + exp, + role: r, + caps, + precedence: 0, + once: [0; 16], + viewer: [0; 16], + policy, + joules_ceiling: NO_CEILING, + funds_ceiling: NO_CEILING, + currency: [0; 3], + profiles, + origins: vec![], + attrs, + sig: [0; 64], + } + .sign(&keys.authority); + let record = g.to_bytes(); + let digest = g.digest(); + match role { + Role::Publisher => { + self.next_order += 1; + self.grants.push(record.clone()); + self.members.insert(principal, Member { digest, exp, order, state: MemberState::Pending { until: now_s + self.settings.join_wait_s } }); + } + Role::Viewer => self.viewers += 1, + } + Ok(Issued { record, digest, room: self.id, exp, role }) + } + + /// The room as a page reads it before it enters. + pub fn view(&self, now_s: u64) -> Value { + json!({ + "room": hex::encode(self.id), + "generation": self.generation, + "t0_us": self.t0_us, + "descriptor": hex::encode(&self.descriptor), + "compose": hex::encode(&self.compose), + "grants": self.grants.iter().map(hex::encode).collect::>(), + "present": self.present(), + "publishers": self.publishers(now_s), + "max_publishers": self.settings.max_publishers, + }) + } +} + +/// [`Role`] of the descriptor, by name. +struct Role_ { + name: &'static str, + caps: Caps, + events: [u8; 32], +} + +impl From for wai::live_room::Role { + fn from(r: Role_) -> Self { + wai::live_room::Role { name: r.name.into(), caps: r.caps, events: r.events } + } +} + +/// A lifecycle event (live-rooms §7.2) for `subject`. +#[derive(Clone, Debug)] +pub enum Lifecycle { + Join { grant: [u8; 32] }, + Publish { media: u8, profile: u8 }, + Leave { reason: u8 }, +} + +impl Chain { + /// The clock's next record at room-clock `t` (never before its last), signed. + pub fn sign(&mut self, keys: &Keys, room: [u8; 32], t: u64, subject: [u8; 32], what: &Lifecycle) -> Vec { + let detail = match what { + Lifecycle::Join { grant } => Detail::Join { grant: *grant }, + Lifecycle::Publish { media, profile } => Detail::Publish { slot: String::new(), media: *media, profile: *profile, layers: 1 }, + Lifecycle::Leave { reason } => Detail::Leave { reason: *reason }, + }; + let k = detail.kind(); + let payload = Payload::Lifecycle { kind: k, subject, detail: detail.encode() }.encode(); + let t = t.max(self.last_t); + let ev = Event::sign(&keys.clock, room, self.seq, self.prev, t, None, k, payload); + self.prev = ev.digest(); + self.seq += 1; + self.last_t = t; + let b = ev.to_bytes(); + self.records.push(b.clone()); + b + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings() -> Settings { + Settings { max_publishers: 2, max_present: 16, publisher_grant_s: 600, viewer_grant_s: 900, join_wait_s: 60, max_grants: 3 } + } + + #[test] + fn grants_verify_as_a_room_and_places_are_counted() { + let keys = Keys::generate(); + let now_s = 1_800_000_000; + let mut e = Epoch::new(&keys, settings(), 1, now_s * 1_000_000); + let p = |i: u8| ed25519_dalek::SigningKey::from_bytes(&[i; 32]).verifying_key().to_bytes(); + let a = e.grant(&keys, p(1), Role::Publisher, now_s).unwrap(); + let b = e.grant(&keys, p(2), Role::Publisher, now_s).unwrap(); + assert_eq!(e.grant(&keys, p(3), Role::Publisher, now_s).unwrap_err(), Refusal::Full); + let v = e.grant(&keys, p(4), Role::Viewer, now_s).unwrap(); + assert_eq!((v.role, e.grants.len(), e.viewers), (Role::Viewer, 2, 1)); + // Every grant checks against the descriptor (live-rooms §4.3), the viewer's too. + let d = wai::live_room::Descriptor::parse(&e.descriptor).unwrap(); + wai::live_room::check_grants(&d, &e.id, &[a.record.clone(), b.record.clone(), v.record.clone()]).expect("the grants check"); + // The relay's reading of the same records, under the authority's key. + let h = joule_moq::grant_proof::GrantHead::verified(&a.record, &keys.authority.verifying_key().to_bytes()).unwrap(); + assert_eq!((h.room, h.principal, h.digest, h.exp), (e.id, p(1), a.digest, a.exp)); + assert!(h.publishes(1) && h.publishes(2) && h.sends_events); + let hv = joule_moq::grant_proof::GrantHead::verified(&v.record, &keys.authority.verifying_key().to_bytes()).unwrap(); + assert!(!hv.publishes(1) && hv.subscribe); + // A place waited for is given up at its deadline. + assert_eq!(e.publishers(now_s + 59), 2); + assert_eq!(e.publishers(now_s + 60), 0); + assert!(!e.spent(now_s + 59) && !e.spent(now_s + 60), "nobody has joined, and it has grants left"); + // The room's lifecycle verifies (live-rooms §9): two join, publish, and one leaves. + let mut c = Chain::default(); + let mut t = 1_000_000; + for (who, g) in [(p(1), a.digest), (p(2), b.digest)] { + c.sign(&keys, e.id, t, who, &Lifecycle::Join { grant: g }); + c.sign(&keys, e.id, t, who, &Lifecycle::Publish { media: media::AUDIO, profile: PROFILE_AUDIO }); + c.sign(&keys, e.id, t, who, &Lifecycle::Publish { media: media::VIDEO, profile: PROFILE_VIDEO }); + t += 1_000_000; + } + c.sign(&keys, e.id, t, p(1), &Lifecycle::Leave { reason: LEFT }); + let run = wai::live_room::verify_room(&e.descriptor, &e.grants, None, &[c.records.clone()]); + assert!(matches!(run.outcome, wai::live_room::RoomOutcome::Accept), "{:?}", run.outcome); + assert_eq!(run.state.present.len(), 1); + // The composition descriptor reads. + assert!(wai::live_compose::Compose::parse(&e.compose).is_ok()); + } + + #[test] + fn a_room_is_spent_once_its_company_has_gone() { + let keys = Keys::generate(); + let now_s = 1_800_000_000; + let mut e = Epoch::new(&keys, settings(), 1, now_s * 1_000_000); + let p = ed25519_dalek::SigningKey::from_bytes(&[9; 32]).verifying_key().to_bytes(); + e.grant(&keys, p, Role::Publisher, now_s).unwrap(); + e.members.get_mut(&p).unwrap().state = MemberState::Present; + e.joined_ever = true; + assert!(!e.spent(now_s)); + e.members.get_mut(&p).unwrap().state = MemberState::Left; + assert!(e.spent(now_s)); + // Past its share of grants, a room takes no more publishers until it is spent. + let mut f = Epoch::new(&keys, settings(), 2, now_s * 1_000_000); + for i in 0..3u8 { + let q = ed25519_dalek::SigningKey::from_bytes(&[20 + i; 32]).verifying_key().to_bytes(); + if let Ok(_) = f.grant(&keys, q, Role::Publisher, now_s) { + f.members.get_mut(&q).unwrap().state = MemberState::Left; + } + } + let q = ed25519_dalek::SigningKey::from_bytes(&[30; 32]).verifying_key().to_bytes(); + assert_eq!(f.grant(&keys, q, Role::Publisher, now_s).unwrap_err(), Refusal::Busy); + assert!(f.spent(now_s)); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs new file mode 100644 index 000000000..27a0ce416 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs @@ -0,0 +1,139 @@ +//! The service's HTTP endpoints, on loopback behind the front that serves the page (which +//! strips its own prefix, so the paths here are the service's own): +//! +//! | Request | Answer | +//! |---|---| +//! | `GET /`, `GET /health` | `{"ok": true, "relay": , "room": }` | +//! | `GET /room` | the room: its id, `t0_us`, the signed descriptor and composition descriptor (hex), the publishers' grants (hex), how many publish and may | +//! | `POST /grant` `{"principal": <64 hex>, "role": "publisher" \| "viewer"}` | `{"grant": , "digest", "exp", "role", "room": }`; `409` `{"error": "full"}` when the room has its publishers, `409` `{"error": "busy"}` when it has taken its grants, `429` `{"error": "rate", "retry_after_s"}` | +//! +//! Every answer is JSON and `Cache-Control: no-store`. A grant request's body is at most +//! 1 KiB. + +use crate::epoch::{Refusal, Role}; +use crate::{now_s, Service}; +use bytes::Bytes; +use http_body_util::{BodyExt, Full, Limited}; +use hyper::body::Incoming; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::{Method, Request, Response, StatusCode}; +use hyper_util::rt::TokioIo; +use serde_json::{json, Value}; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; + +const MAX_BODY: usize = 1024; + +/// Serve `listener` until the task is dropped. +pub async fn serve(svc: Arc, listener: tokio::net::TcpListener) { + loop { + let Ok((tcp, peer)) = listener.accept().await else { + tokio::time::sleep(Duration::from_millis(50)).await; + continue; + }; + let svc = svc.clone(); + tokio::spawn(async move { + let io = TokioIo::new(tcp); + let f = service_fn(move |req| handle(svc.clone(), peer, req)); + let conn = http1::Builder::new().timer(hyper_util::rt::TokioTimer::new()).header_read_timeout(Duration::from_secs(10)).serve_connection(io, f); + let _ = tokio::time::timeout(Duration::from_secs(60), conn).await; + }); + } +} + +fn reply(status: StatusCode, v: Value) -> Response> { + Response::builder() + .status(status) + .header("content-type", "application/json") + .header("cache-control", "no-store") + .header("x-content-type-options", "nosniff") + .body(Full::new(Bytes::from(v.to_string()))) + .expect("a response") +} + +/// The client's address, as the front reports it. +fn client_of(req: &Request, peer: SocketAddr) -> String { + let h = |n: &str| req.headers().get(n).and_then(|v| v.to_str().ok()).map(str::trim).filter(|v| !v.is_empty()); + h("cf-connecting-ip") + .map(String::from) + .or_else(|| h("x-forwarded-for").and_then(|v| v.split(',').next()).map(|v| v.trim().to_string())) + .unwrap_or_else(|| peer.ip().to_string()) +} + +async fn handle(svc: Arc, peer: SocketAddr, req: Request) -> Result>, Infallible> { + let path = req.uri().path().to_string(); + Ok(match (req.method().clone(), path.as_str()) { + (Method::GET, "/") | (Method::GET, "/health") => { + let room = hex::encode(svc.state.lock().unwrap().epoch.id); + reply(StatusCode::OK, json!({"ok": true, "relay": svc.connected.load(Ordering::Acquire), "room": room})) + } + (Method::GET, "/room") => { + let v = svc.state.lock().unwrap().epoch.view(now_s()); + reply(StatusCode::OK, v) + } + (Method::POST, "/grant") => { + let client = client_of(&req, peer); + let body = match Limited::new(req.into_body(), MAX_BODY).collect().await { + Ok(b) => b.to_bytes(), + Err(_) => return Ok(reply(StatusCode::PAYLOAD_TOO_LARGE, json!({"error": "body"}))), + }; + grant(&svc, &client, &body) + } + (_, "/") | (_, "/health") | (_, "/room") | (_, "/grant") => reply(StatusCode::METHOD_NOT_ALLOWED, json!({"error": "method"})), + _ => reply(StatusCode::NOT_FOUND, json!({"error": "not found"})), + }) +} + +/// A grant for the key and role a request names. +pub fn grant(svc: &Arc, client: &str, body: &[u8]) -> Response> { + let bad = |why: &str| reply(StatusCode::BAD_REQUEST, json!({"error": why})); + let Ok(v) = serde_json::from_slice::(body) else { return bad("not JSON") }; + let Some(principal) = v["principal"].as_str().filter(|p| p.len() == 64).and_then(|p| hex::decode(p).ok()).and_then(|b| <[u8; 32]>::try_from(b).ok()) else { + return bad("principal: 64 hexadecimal digits"); + }; + if ed25519_dalek::VerifyingKey::from_bytes(&principal).is_err() { + return bad("principal: not an Ed25519 key"); + } + let role = match v["role"].as_str() { + Some("publisher") => Role::Publisher, + Some("viewer") => Role::Viewer, + _ => return bad("role: publisher or viewer"), + }; + if !svc.connected.load(Ordering::Acquire) { + return reply(StatusCode::SERVICE_UNAVAILABLE, json!({"error": "relay"})); + } + let now = now_s(); + let (issued, view, generation, until) = { + let mut st = svc.state.lock().unwrap(); + if let Err(wait) = st.limiter.take(client) { + return reply(StatusCode::TOO_MANY_REQUESTS, json!({"error": "rate", "retry_after_s": wait})); + } + if role == Role::Publisher && st.epoch.spent(now) { + svc.new_room(&mut st); + } + let issued = match st.epoch.grant(&svc.keys, principal, role, now) { + Ok(i) => i, + Err(Refusal::Full) => return reply(StatusCode::CONFLICT, json!({"error": "full", "max_publishers": svc.settings.max_publishers})), + Err(Refusal::Busy) => return reply(StatusCode::CONFLICT, json!({"error": "busy"})), + }; + (issued, st.epoch.view(now), st.epoch.generation, now + svc.settings.join_wait_s) + }; + if role == Role::Publisher { + tokio::spawn(svc.clone().watch(generation, principal, until)); + } + tracing::info!(role = ?role, room = %hex::encode(issued.room), principal = %hex::encode(&principal[..6]), "a grant"); + reply( + StatusCode::OK, + json!({ + "grant": hex::encode(&issued.record), + "digest": hex::encode(issued.digest), + "exp": issued.exp, + "role": match role { Role::Publisher => "publisher", Role::Viewer => "viewer" }, + "room": view, + }), + ) +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs new file mode 100644 index 000000000..035384f81 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs @@ -0,0 +1,138 @@ +//! The room's keys: the authority's, which signs the descriptor, the composition and every +//! grant (WAI live-rooms §3.2, §4.2), the clock's, which signs the lifecycle (§7.2), and +//! the clock's token, which the relay knows by its SHA-256 alone. +//! +//! They live in one TOML file outside every repository, readable by the service's user +//! alone: [`Keys::load`] refuses a file that its group or others can read or write, and +//! [`Keys::create`] makes it with mode 0600 and never over an existing file. + +use ed25519_dalek::SigningKey; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::io::Write; +use std::path::Path; + +pub struct Keys { + pub authority: SigningKey, + pub clock: SigningKey, + /// The clock's authorization token: 32 random bytes the relay names by their SHA-256. + pub clock_token: [u8; 32], +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct KeysFile { + authority_seed: String, + clock_seed: String, + clock_token: String, +} + +fn h32(what: &str, s: &str) -> Result<[u8; 32], String> { + hex::decode(s.trim()).map_err(|e| format!("{what}: {e}"))?.try_into().map_err(|_| format!("{what} is not 32 bytes")) +} + +fn random32() -> [u8; 32] { + let mut b = [0u8; 32]; + rand_core_06::RngCore::fill_bytes(&mut rand_core_06::OsRng, &mut b); + b +} + +impl Keys { + pub fn generate() -> Keys { + Keys { authority: SigningKey::from_bytes(&random32()), clock: SigningKey::from_bytes(&random32()), clock_token: random32() } + } + + pub fn from_toml(s: &str) -> Result { + let f: KeysFile = toml::from_str(s).map_err(|e| e.to_string())?; + Ok(Keys { + authority: SigningKey::from_bytes(&h32("authority_seed", &f.authority_seed)?), + clock: SigningKey::from_bytes(&h32("clock_seed", &f.clock_seed)?), + clock_token: h32("clock_token", &f.clock_token)?, + }) + } + + pub fn to_toml(&self) -> String { + format!( + "# joule-moq-room's keys. Secret: mode 0600, outside every repository.\nauthority_seed = \"{}\"\nclock_seed = \"{}\"\nclock_token = \"{}\"\n", + hex::encode(self.authority.to_bytes()), + hex::encode(self.clock.to_bytes()), + hex::encode(self.clock_token) + ) + } + + /// Read the keys from `path`, refusing a file its group or others may read or write. + pub fn load(path: &Path) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let m = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; + if m.permissions().mode() & 0o077 != 0 { + return Err(format!("{}: mode {:o}; the keys must be readable by their owner alone (chmod 600)", path.display(), m.permissions().mode() & 0o777)); + } + } + let s = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; + Keys::from_toml(&s).map_err(|e| format!("{}: {e}", path.display())) + } + + /// Make new keys at `path` (mode 0600, its folder 0700 when it is made), never over a + /// file that exists. + pub fn create(path: &Path) -> Result { + if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty() && !d.exists()) { + let mut b = std::fs::DirBuilder::new(); + b.recursive(true); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700); + b.create(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + let keys = Keys::generate(); + let mut o = std::fs::OpenOptions::new(); + o.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600); + let mut f = o.open(path).map_err(|e| format!("{}: {e}", path.display()))?; + f.write_all(keys.to_toml().as_bytes()).map_err(|e| format!("{}: {e}", path.display()))?; + Ok(keys) + } + + /// The public halves, and the lines of the relay's configuration that trust them: the + /// authority's key and the rules for the clock's token, by its SHA-256. + pub fn relay_lines(&self) -> String { + let a = hex::encode(self.authority.verifying_key().to_bytes()); + let c = hex::encode(self.clock.verifying_key().to_bytes()); + let t = hex::encode(Sha256::digest(self.clock_token)); + format!( + "# authority {a}\n# clock {c}\n[[auth.authorities]]\nkey = \"{a}\"\n\n# The room's clock publishes its events track in every room it keeps.\n[[auth.grants]]\nprefix = [\"*\", \"{c}\", \"events\"]\ntoken_sha256 = \"{t}\"\npublish = true\n\n# It watches each publisher's tracks to author the lifecycle.\n[[auth.grants]]\nprefix = []\ntoken_sha256 = \"{t}\"\nsubscribe = true\n" + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_round_trip_and_a_shared_file_is_refused() { + let dir = std::env::temp_dir().join(format!("joule-moq-room-keys-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let p = dir.join("sub/keys.toml"); + let k = Keys::create(&p).expect("made"); + assert!(Keys::create(&p).is_err(), "never over a file that exists"); + let back = Keys::load(&p).expect("read back"); + assert_eq!(back.authority.to_bytes(), k.authority.to_bytes()); + assert_eq!(back.clock_token, k.clock_token); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap(); + // (Keys has no Debug, so that no log ever prints a seed.) + match Keys::load(&p) { + Err(e) => assert!(e.contains("chmod 600"), "{e}"), + Ok(_) => panic!("a file others can read was taken"), + } + } + assert!(k.relay_lines().contains(&hex::encode(k.authority.verifying_key().to_bytes()))); + assert!(!k.relay_lines().contains(&hex::encode(k.clock_token)), "the token itself is never printed"); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs new file mode 100644 index 000000000..9f58ac726 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs @@ -0,0 +1,382 @@ +//! **joule-moq-room**: a room service for WAI live rooms over `joule-moq-relay`. +//! +//! It holds one room's authority and clock keys ([`keys`]) and does three things: +//! +//! - **Grants** (WAI live-rooms §4). A visitor's page makes an Ed25519 key and asks for a +//! grant for it: `POST /grant` with the key and a role. The service signs a short-lived +//! `WRG1` grant, a publisher's (its own audio and video, a place in the room) or a +//! viewer's (the room to watch). The grant is public, so it is useless without the key: +//! the relay honours it only beside a proof of possession over its own challenge +//! (§10.5), which only the page that holds the key can make. Grants are bounded per +//! client address and overall ([`limiter`]), and publishers per room. +//! - **Lifecycle** (§7.2). The service's clock is a session on the relay that publishes the +//! clock's events track, and that watches each publisher's video track: when it appears +//! the clock signs `join` and `publish` for the publisher, and when it ends (the +//! publisher's session closed) `leave`. Pages author nothing of the lifecycle. +//! - **The room**. `GET /room` gives the signed descriptor, the composition descriptor and +//! the publishers' grants a page verifies the room with. +//! +//! Every room the service keeps is an [`epoch::Epoch`]: when everyone has left, the next +//! publisher starts a new one. + +pub mod epoch; +pub mod http; +pub mod keys; +pub mod limiter; + +use epoch::{Chain, Epoch, Lifecycle, MemberState, Settings}; +use joule_moq::draft22::data::{SubgroupCodec, SubgroupHeader, SubgroupId, SubgroupObject}; +use joule_moq::draft22::kvp::{prop, KeyValuePair, Properties}; +use joule_moq::draft22::{ControlMessage, Parameter, Parameters, Token}; +use joule_moq::{TrackName, TrackNamespace}; +use joule_moq_relay::client::{Client, ClientOptions}; +use joule_moq_relay::transport::mux::MuxConfig; +use joule_moq_relay::transport::{websocket, BoxSend}; +use joule_moq_relay::wire::{ReqRx, ReqTx}; +use keys::Keys; +use limiter::{Limiter, Rate}; +use serde::Deserialize; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::sync::Notify; + +/// The clock's events track's publisher priority (MOQT §5.1.1): before media. +const EVENTS_PRIORITY: u8 = 16; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + /// The HTTP listener: loopback, behind the front that serves the page. + pub listen: SocketAddr, + /// The keys file ([`keys::Keys::load`]). + pub keys: PathBuf, + /// The relay's WebSocket endpoint, for the clock's own session. + pub relay_url: String, + #[serde(default)] + pub room: RoomConfig, + #[serde(default)] + pub rate: RateConfig, + /// Exit with status 70 past this resident memory, in MiB, for the supervisor to start + /// the service again. 0: no limit. + #[serde(default)] + pub max_resident_mb: u64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct RoomConfig { + pub max_publishers: u16, + pub max_present: u32, + pub publisher_grant_s: u64, + pub viewer_grant_s: u64, + pub join_wait_s: u64, + pub max_grants: usize, +} + +impl Default for RoomConfig { + fn default() -> Self { + RoomConfig { max_publishers: 4, max_present: 16, publisher_grant_s: 1_200, viewer_grant_s: 1_800, join_wait_s: 90, max_grants: 64 } + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct RateConfig { + pub client_burst: u32, + pub client_every_s: u32, + pub global_burst: u32, + pub global_every_s: u32, +} + +impl Default for RateConfig { + fn default() -> Self { + RateConfig { client_burst: 6, client_every_s: 20, global_burst: 120, global_every_s: 10 } + } +} + +impl Config { + pub fn from_toml(s: &str) -> Result { + toml::from_str(s).map_err(|e| e.to_string()) + } + + pub fn settings(&self) -> Settings { + let r = &self.room; + Settings { max_publishers: r.max_publishers, max_present: r.max_present, publisher_grant_s: r.publisher_grant_s, viewer_grant_s: r.viewer_grant_s, join_wait_s: r.join_wait_s, max_grants: r.max_grants } + } +} + +pub fn now_us() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_micros() as u64) +} + +pub fn now_s() -> u64 { + now_us() / 1_000_000 +} + +pub struct State { + pub epoch: Epoch, + pub limiter: Limiter, +} + +/// The clock's events track in one room. +struct Track { + generation: u64, + room: [u8; 32], + t0_us: u64, + alias: u64, + _publish: (ReqTx, ReqRx), + chain: Chain, + group: Option, + stream: Option<(BoxSend, SubgroupCodec)>, + next_object: u64, +} + +#[derive(Default)] +struct Clock { + client: Option>, + track: Option, +} + +pub struct Service { + pub keys: Keys, + pub settings: Settings, + pub relay_url: String, + pub state: Mutex, + clock: tokio::sync::Mutex, + pub connected: AtomicBool, + next_alias: AtomicU64, + next_generation: AtomicU64, + /// Signalled after each lifecycle event, for tests. + pub changed: Notify, +} + +impl Service { + pub fn new(keys: Keys, cfg: &Config) -> Arc { + let settings = cfg.settings(); + let r = &cfg.rate; + let limiter = Limiter::new(Rate { burst: r.client_burst as f64, every_s: r.client_every_s as f64 }, Rate { burst: r.global_burst as f64, every_s: r.global_every_s as f64 }); + let epoch = Epoch::new(&keys, settings.clone(), 1, now_us()); + Arc::new(Service { + keys, + settings, + relay_url: cfg.relay_url.clone(), + state: Mutex::new(State { epoch, limiter }), + clock: tokio::sync::Mutex::new(Clock::default()), + connected: AtomicBool::new(false), + next_alias: AtomicU64::new(1), + next_generation: AtomicU64::new(2), + changed: Notify::new(), + }) + } + + fn clock_hex(&self) -> String { + hex::encode(self.keys.clock.verifying_key().to_bytes()) + } + + /// Start a new room in place of the current one. + pub fn new_room(&self, st: &mut State) { + let g = self.next_generation.fetch_add(1, Ordering::Relaxed); + st.epoch = Epoch::new(&self.keys, self.settings.clone(), g, now_us()); + tracing::info!(room = %hex::encode(st.epoch.id), generation = g, "a new room"); + } + + /// Run the clock: a session on the relay, made again whenever it ends. A room whose + /// clock lost its session is spent: everyone in it lost theirs too, or will be asked + /// to enter again. + pub async fn run_clock(self: Arc) { + let mut backoff = Duration::from_millis(500); + loop { + match self.connect().await { + Ok(c) => { + backoff = Duration::from_millis(500); + let c = Arc::new(c); + { + let mut clock = self.clock.lock().await; + clock.client = Some(c.clone()); + clock.track = None; + } + self.connected.store(true, Ordering::Release); + tracing::info!(relay = %self.relay_url, "the clock's session is open"); + let why = c.conn.closed().await; + self.connected.store(false, Ordering::Release); + tracing::warn!(%why, "the clock's session ended"); + { + let mut clock = self.clock.lock().await; + clock.client = None; + clock.track = None; + } + let mut st = self.state.lock().unwrap(); + for m in st.epoch.members.values_mut() { + m.state = MemberState::Left; + } + st.epoch.joined_ever = true; + } + Err(e) => tracing::warn!(relay = %self.relay_url, error = %e, "the clock could not open a session"), + } + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(Duration::from_secs(10)); + } + } + + async fn connect(&self) -> Result { + let conn = tokio::time::timeout(Duration::from_secs(10), websocket::connect(&self.relay_url, MuxConfig::default())).await.map_err(|_| "no session in time".to_string())?.map_err(|e| e.to_string())?; + let opts = ClientOptions { tokens: vec![Token::UseValue { token_type: 0, value: self.keys.clock_token.to_vec() }], ..ClientOptions::default() }; + Client::connect(Arc::new(conn), opts).await + } + + /// Sign and send lifecycle events for `subject` in the room of `generation`, in order. + pub async fn emit(&self, generation: u64, subject: [u8; 32], items: &[Lifecycle]) -> Result<(), String> { + let mut clock = self.clock.lock().await; + let client = clock.client.clone().ok_or("the clock has no session")?; + let (room, t0_us) = { + let st = self.state.lock().unwrap(); + if st.epoch.generation != generation { + return Err("a room since replaced".into()); + } + (st.epoch.id, st.epoch.t0_us) + }; + if clock.track.as_ref().is_none_or(|t| t.generation != generation) { + let alias = self.next_alias.fetch_add(1, Ordering::Relaxed); + let ns = TrackNamespace::from_strs([hex::encode(room).as_str(), self.clock_hex().as_str(), "events"]); + let mut props = Properties::new(); + props.push(KeyValuePair::varint(prop::DEFAULT_PUBLISHER_PRIORITY, EVENTS_PRIORITY as u64)); + let publish = client.publish(ns, TrackName::from_str("0"), alias, Parameters::new(), props).await?; + clock.track = Some(Track { generation, room, t0_us, alias, _publish: publish, chain: Chain::default(), group: None, stream: None, next_object: 0 }); + } + let track = clock.track.as_mut().expect("made above"); + for what in items { + let t = now_us().saturating_sub(track.t0_us); + let rec = track.chain.sign(&self.keys, track.room, t, subject, what); + if let Err(e) = write(&client, track, &rec).await { + // The track is no longer whole: the next event opens it again. + clock.track = None; + return Err(e); + } + tracing::info!(room = %hex::encode(room), subject = %hex::encode(&subject[..6]), event = ?what, "lifecycle"); + } + drop(clock); + self.changed.notify_waiters(); + Ok(()) + } + + /// Watch the publisher `principal` of the room of `generation` until `until_s`: when + /// its video track appears it has joined, and when the track ends it has left. + pub async fn watch(self: Arc, generation: u64, principal: [u8; 32], until_s: u64) { + let room = { self.state.lock().unwrap().epoch.id }; + let ns = TrackNamespace::from_strs([hex::encode(room).as_str(), hex::encode(principal).as_str(), "video"]); + loop { + let now = now_s(); + if now >= until_s || self.state.lock().unwrap().epoch.generation != generation { + break; + } + let Some(client) = self.clock.lock().await.client.clone() else { + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + }; + // The client waits ten seconds for an answer; the relay holds the request until + // the track appears, for at most this. + let wait = ((until_s - now) * 1000).min(8_000); + let params = Parameters::new().with(Parameter::RendezvousTimeout(wait)).with(Parameter::Forward(false)); + let (_ok, tx, mut rx) = match client.subscribe(ns.clone(), TrackName::from_str("0"), params).await { + Ok(x) => x, + Err(_) => { + tokio::time::sleep(Duration::from_millis(300)).await; + continue; + } + }; + let digest = { + let mut st = self.state.lock().unwrap(); + if st.epoch.generation != generation { + return; + } + st.epoch.joined_ever = true; + match st.epoch.members.get_mut(&principal) { + Some(m) if matches!(m.state, MemberState::Pending { .. }) => { + m.state = MemberState::Present; + m.digest + } + _ => return, + } + }; + let join = [Lifecycle::Join { grant: digest }, Lifecycle::Publish { media: 1, profile: epoch::PROFILE_AUDIO }, Lifecycle::Publish { media: 2, profile: epoch::PROFILE_VIDEO }]; + if let Err(e) = self.emit(generation, principal, &join).await { + tracing::warn!(error = %e, "the join could not be sent"); + } + // Until the relay ends the track: PUBLISH_DONE, or the request's end. + loop { + match rx.next().await { + Ok(Some(ControlMessage::PublishDone(_))) | Ok(None) | Err(_) => break, + Ok(Some(_)) => {} + } + } + drop(tx); + let reason = { + let mut st = self.state.lock().unwrap(); + if st.epoch.generation != generation { + return; + } + match st.epoch.members.get_mut(&principal) { + Some(m) if m.state == MemberState::Present => { + m.state = MemberState::Left; + if now_s() >= m.exp { epoch::GRANT_ENDED } else { epoch::LEFT } + } + _ => return, + } + }; + if let Err(e) = self.emit(generation, principal, &[Lifecycle::Leave { reason }]).await { + tracing::warn!(error = %e, "the leave could not be sent"); + } + return; + } + // Never seen: its place is given up. + let mut st = self.state.lock().unwrap(); + if st.epoch.generation == generation + && let Some(m) = st.epoch.members.get_mut(&principal) + && matches!(m.state, MemberState::Pending { .. }) + { + m.state = MemberState::Left; + } + } +} + +/// Write `rec`, the track's last record, on the clock's events track (live-events §8.1): +/// a group per room-clock minute, each on a stream of its own; object 0 of a group holds +/// every earlier record of the chain (its join set, §8.2, with every other record +/// repeated), and each later object one record. +async fn write(client: &Client, track: &mut Track, rec: &[u8]) -> Result<(), String> { + let t0 = track.t0_us; + let msf = move |r: &[u8]| wai::live_events::to_msf_record(r, t0).map_err(|e| e.code().to_string()); + let g = wai::live_events::moq_group(track.chain.last_t); + if track.group != Some(g) || track.stream.is_none() { + if let Some((mut s, _)) = track.stream.take() { + let _ = s.finish(); + } + let header = SubgroupHeader { track_alias: track.alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(EVENTS_PRIORITY), properties: false, end_of_group: false, first_object: true }; + let mut b = Vec::new(); + header.encode(&mut b); + let mut codec = SubgroupCodec::new(header); + let n = track.chain.records.len(); + let earlier = track.chain.records[..n - 1].iter().map(|r| msf(r)).collect::, _>>()?; + codec.encode_object(&SubgroupObject::normal(0, format!("[{}]", earlier.join(",")).into_bytes()), &mut b).map_err(|e| e.to_string())?; + let mut s = tokio::time::timeout(Duration::from_secs(10), client.conn.open_uni()).await.map_err(|_| "no stream in time".to_string())?.map_err(|e| e.to_string())?; + s.write_all(&b).await.map_err(|e| e.to_string())?; + track.stream = Some((s, codec)); + track.group = Some(g); + track.next_object = 1; + } + let (s, codec) = track.stream.as_mut().expect("opened above"); + let mut b = Vec::new(); + codec.encode_object(&SubgroupObject::normal(track.next_object, format!("[{}]", msf(rec)?).into_bytes()), &mut b).map_err(|e| e.to_string())?; + s.write_all(&b).await.map_err(|e| e.to_string())?; + track.next_object += 1; + Ok(()) +} + +/// This process's resident set, in KiB, as `ps` reports it. +pub fn resident_kb() -> Option { + let out = std::process::Command::new("ps").args(["-o", "rss=", "-p", &std::process::id().to_string()]).output().ok()?; + String::from_utf8_lossy(&out.stdout).trim().parse().ok() +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/limiter.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/limiter.rs new file mode 100644 index 000000000..061af359e --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/limiter.rs @@ -0,0 +1,88 @@ +//! How often grants are given: a bucket per client address and one for everyone. +//! +//! The address is the one the front reports: `CF-Connecting-IP` from the CDN, else the +//! first `X-Forwarded-For`, else the connection's own. The service listens on loopback +//! behind that front, so nothing else reaches it to set those headers. + +use std::collections::HashMap; +use std::time::Instant; + +#[derive(Clone, Copy, Debug)] +pub struct Rate { + /// Grants at once. + pub burst: f64, + /// Seconds for one more. + pub every_s: f64, +} + +#[derive(Clone, Copy, Debug)] +struct Bucket { + level: f64, + at: Instant, +} + +impl Bucket { + fn full(r: Rate, now: Instant) -> Self { + Bucket { level: r.burst, at: now } + } + + fn fill(&mut self, r: Rate, now: Instant) { + self.level = (self.level + now.duration_since(self.at).as_secs_f64() / r.every_s).min(r.burst); + self.at = now; + } +} + +pub struct Limiter { + per_client: Rate, + global: Rate, + clients: HashMap, + all: Bucket, +} + +impl Limiter { + pub fn new(per_client: Rate, global: Rate) -> Self { + let now = Instant::now(); + Limiter { per_client, global, clients: HashMap::new(), all: Bucket::full(global, now) } + } + + /// Take one grant for `client`: `Err` with the seconds until one is free. + pub fn take(&mut self, client: &str) -> Result<(), u64> { + let now = Instant::now(); + // Buckets that have filled up again hold nothing worth keeping. + if self.clients.len() > 4096 { + let r = self.per_client; + self.clients.retain(|_, b| { + b.fill(r, now); + b.level < r.burst + }); + } + let (pr, gr) = (self.per_client, self.global); + let b = self.clients.entry(client.to_string()).or_insert_with(|| Bucket::full(pr, now)); + b.fill(pr, now); + self.all.fill(gr, now); + if b.level < 1.0 { + return Err(((1.0 - b.level) * pr.every_s).ceil() as u64); + } + if self.all.level < 1.0 { + return Err(((1.0 - self.all.level) * gr.every_s).ceil() as u64); + } + b.level -= 1.0; + self.all.level -= 1.0; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_client_and_everyone_are_bounded() { + let mut l = Limiter::new(Rate { burst: 2.0, every_s: 60.0 }, Rate { burst: 3.0, every_s: 60.0 }); + assert!(l.take("a").is_ok() && l.take("a").is_ok()); + let wait = l.take("a").unwrap_err(); + assert!((1..=60).contains(&wait), "{wait}"); + assert!(l.take("b").is_ok()); + assert!(l.take("c").is_err(), "everyone's bucket is spent"); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs new file mode 100644 index 000000000..8bcdc8127 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs @@ -0,0 +1,121 @@ +//! `joule-moq-room`: run the room service. +//! +//! ```text +//! joule-moq-room keygen new keys (mode 0600, never over a file), and the +//! relay configuration lines that trust them +//! joule-moq-room relay-lines those lines again, for keys that exist +//! joule-moq-room --config serve +//! ``` +//! +//! It stops on Ctrl-C or SIGTERM. With `max_resident_mb` set it exits with status 70 once +//! its resident memory passes that, for its supervisor to start it again. + +use joule_moq_room::keys::Keys; +use joule_moq_room::{http, resident_kb, Config, Service}; +use std::path::Path; +use std::process::ExitCode; + +fn usage() -> ExitCode { + eprintln!("usage: joule-moq-room (keygen | relay-lines | --config )"); + ExitCode::from(2) +} + +#[tokio::main] +async fn main() -> ExitCode { + let level = std::env::var("JOULE_MOQ_ROOM_LOG").ok().and_then(|l| l.parse().ok()).unwrap_or(tracing::Level::INFO); + tracing_subscriber::fmt().with_max_level(level).with_target(false).init(); + let args: Vec = std::env::args().skip(1).collect(); + match args.iter().map(String::as_str).collect::>().as_slice() { + ["keygen", path] => match Keys::create(Path::new(path)) { + Ok(k) => { + println!("{}", k.relay_lines()); + eprintln!("joule-moq-room: keys written to {path} (mode 0600)"); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("joule-moq-room: {e}"); + ExitCode::FAILURE + } + }, + ["relay-lines", path] => match Keys::load(Path::new(path)) { + Ok(k) => { + println!("{}", k.relay_lines()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("joule-moq-room: {e}"); + ExitCode::FAILURE + } + }, + ["--config", path] => { + let cfg = match std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|s| Config::from_toml(&s)) { + Ok(c) => c, + Err(e) => { + eprintln!("joule-moq-room: {path}: {e}"); + return ExitCode::FAILURE; + } + }; + let keys = match Keys::load(&cfg.keys) { + Ok(k) => k, + Err(e) => { + eprintln!("joule-moq-room: {e}"); + return ExitCode::FAILURE; + } + }; + serve(cfg, keys).await + } + _ => usage(), + } +} + +async fn serve(cfg: Config, keys: Keys) -> ExitCode { + let listener = match tokio::net::TcpListener::bind(cfg.listen).await { + Ok(l) => l, + Err(e) => { + eprintln!("joule-moq-room: binding {}: {e}", cfg.listen); + return ExitCode::FAILURE; + } + }; + let svc = Service::new(keys, &cfg); + tracing::info!( + "room service on http://{}; authority {}, clock {}; relay {}", + cfg.listen, + hex::encode(svc.keys.authority.verifying_key().to_bytes()), + hex::encode(svc.keys.clock.verifying_key().to_bytes()), + cfg.relay_url + ); + tokio::spawn(svc.clone().run_clock()); + tokio::spawn(http::serve(svc.clone(), listener)); + if cfg.max_resident_mb != 0 { + let max_mb = cfg.max_resident_mb; + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + if let Some(kb) = resident_kb() + && kb / 1024 > max_mb + { + tracing::error!("resident memory {} MiB, past the limit of {max_mb} MiB: exiting", kb / 1024); + std::process::exit(70); + } + } + }); + } + stopped().await; + tracing::info!("stopped"); + ExitCode::SUCCESS +} + +/// Ctrl-C, or on Unix SIGTERM. +async fn stopped() { + #[cfg(unix)] + { + if let Ok(mut term) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = term.recv() => {} + } + return; + } + } + let _ = tokio::signal::ctrl_c().await; +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs b/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs new file mode 100644 index 000000000..19138ddf1 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs @@ -0,0 +1,240 @@ +//! The room service against a relay on loopback that trusts its authority (the relay's +//! `[[auth.authorities]]`) and its clock's token: +//! +//! - a page's key gets a publisher's grant over HTTP; with the grant and a proof the +//! publisher's session publishes its tracks, and the clock signs `join` and two +//! `publish` for it on the clock's events track; when the session closes, `leave`; +//! - the room (its descriptor, its grants and the clock's chain as the relay serves it) +//! verifies as WAI live-rooms §9 verifies a room; +//! - the grant alone, without the proof, opens no session; +//! - publishers are bounded, a viewer is not counted among them, grants are bounded per +//! client, and a new room starts once the last publisher has left. + +use ed25519_dalek::SigningKey; +use joule_moq::draft22::data::FetchEntry; +use joule_moq::draft22::kvp::{prop, KeyValuePair, Properties}; +use joule_moq::draft22::{Parameters, SessionError, Token}; +use joule_moq::{TrackName, TrackNamespace}; +use joule_moq_relay::client::{Client, ClientOptions}; +use joule_moq_relay::config::{Authority, DoorRule, Grant}; +use joule_moq_relay::transport::mux::MuxConfig; +use joule_moq_relay::transport::{websocket, TransportError}; +use joule_moq_relay::{RelayConfig, RelayServer}; +use joule_moq_room::keys::Keys; +use joule_moq_room::{http, Config, Service}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::time::timeout; + +const STEP: Duration = Duration::from_secs(20); + +async fn relay(keys: &Keys) -> RelayServer { + let mut c = RelayConfig::loopback(); + c.quic_listen = None; + c.auth.required = true; + c.auth.authorities = vec![Authority { key: hex::encode(keys.authority.verifying_key().to_bytes()), rooms: vec![] }]; + let t = hex::encode(Sha256::digest(keys.clock_token)); + let clock = hex::encode(keys.clock.verifying_key().to_bytes()); + c.auth.grants = vec![ + Grant { prefix: vec!["*".into(), clock, "events".into()], token_sha256: t.clone(), publish: true, subscribe: false, proof: false }, + Grant { prefix: vec![], token_sha256: t, publish: false, subscribe: true, proof: false }, + ]; + c.receipts.door = ["audio", "video"].iter().map(|m| DoorRule { prefix: vec!["*".into(), "*".into(), m.to_string()], trust: vec![], trust_field: Some(1), hold_ms: 2_000 }).collect(); + RelayServer::start(c).await.expect("the relay starts") +} + +/// One HTTP/1.1 request to the service: its status and its JSON. +async fn call(addr: std::net::SocketAddr, method: &str, path: &str, body: Option<&Value>, client: &str) -> (u16, Value) { + let mut s = tokio::net::TcpStream::connect(addr).await.unwrap(); + let b = body.map(|v| v.to_string()).unwrap_or_default(); + let req = format!("{method} {path} HTTP/1.1\r\nHost: room.test\r\nCF-Connecting-IP: {client}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{b}", b.len()); + s.write_all(req.as_bytes()).await.unwrap(); + let mut out = Vec::new(); + timeout(STEP, s.read_to_end(&mut out)).await.expect("an answer in time").unwrap(); + let text = String::from_utf8(out).unwrap(); + let (head, body) = text.split_once("\r\n\r\n").expect("a response"); + assert!(head.to_ascii_lowercase().contains("cache-control: no-store"), "{head}"); + let status: u16 = head.split(' ').nth(1).unwrap().parse().unwrap(); + (status, serde_json::from_str(body).unwrap_or(Value::Null)) +} + +fn key(i: u8) -> SigningKey { + SigningKey::from_bytes(&[i; 32]) +} + +fn pubhex(k: &SigningKey) -> String { + hex::encode(k.verifying_key().to_bytes()) +} + +async fn ask(addr: std::net::SocketAddr, k: &SigningKey, role: &str, client: &str) -> (u16, Value) { + call(addr, "POST", "/grant", Some(&serde_json::json!({"principal": pubhex(k), "role": role})), client).await +} + +async fn session(server: &RelayServer, opts: ClientOptions) -> Client { + let url = format!("ws://{}{}", server.websocket_addr.unwrap(), server.relay().config().path); + let conn = websocket::connect(&url, MuxConfig::default()).await.expect("a WebSocket session"); + Client::connect(Arc::new(conn), opts).await.expect("SETUP") +} + +fn with_grant(server: &RelayServer, grant: &[u8], k: &SigningKey) -> ClientOptions { + ClientOptions { grants: vec![(grant.to_vec(), k.clone())], target: format!("//{}{}", server.websocket_addr.unwrap(), server.relay().config().path), ..ClientOptions::default() } +} + +async fn publish_media(c: &Client, room: &str, k: &SigningKey) -> Vec<(joule_moq_relay::wire::ReqTx, joule_moq_relay::wire::ReqRx)> { + let mut held = Vec::new(); + let mut alias = 10; + for m in ["audio", "video"] { + let ns = TrackNamespace::from_strs([room, pubhex(k).as_str(), m]); + for name in ["0_receipts", "0"] { + let mut p = Properties::new(); + p.push(KeyValuePair::varint(prop::DEFAULT_PUBLISHER_PRIORITY, 64)); + held.push(c.publish(ns.clone(), TrackName::from_str(name), alias, Parameters::new(), p).await.expect("PUBLISH")); + alias += 1; + } + } + held +} + +/// The clock's chain as the relay serves its events track: every object of every group, +/// read as live-events §8.1 reads them, each record once, in `seq` order. +async fn chain(c: &Client, room: &str, clock: &str, t0_us: u64) -> Vec> { + let ns = TrackNamespace::from_strs([room, clock, "events"]); + let (_, entries) = c.fetch(ns, TrackName::from_str("0"), Parameters::new()).await.expect("FETCH the events track"); + let mut by_seq = std::collections::BTreeMap::new(); + for e in entries { + let FetchEntry::Object(o) = e else { continue }; + let arr: Vec = serde_json::from_slice(&o.payload).expect("an object is a JSON array"); + for r in arr { + let rec = wai::live_events::from_msf_record(&r.to_string(), t0_us).expect("an MSF record"); + let ev = wai::live_events::Event::parse(&rec).expect("a WEV1 record"); + by_seq.insert(ev.seq, rec); + } + } + by_seq.into_values().collect() +} + +fn kinds(chain: &[Vec]) -> Vec { + chain.iter().map(|r| wai::live_events::kind_name(wai::live_events::Event::parse(r).unwrap().kind).unwrap().to_string()).collect() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_room_service_grants_and_authors_the_lifecycle() { + timeout(Duration::from_secs(240), run()).await.expect("the test finishes in time"); +} + +async fn run() { + let keys = Keys::generate(); + let server = relay(&keys).await; + let cfg = Config::from_toml(&format!( + "listen = \"127.0.0.1:0\"\nkeys = \"/nonexistent\"\nrelay_url = \"ws://{}{}\"\n[room]\nmax_publishers = 2\njoin_wait_s = 30\n[rate]\nclient_burst = 4\nclient_every_s = 600\n", + server.websocket_addr.unwrap(), + server.relay().config().path + )) + .unwrap(); + let svc = Service::new(Keys::from_toml(&keys.to_toml()).unwrap(), &cfg); + let listener = tokio::net::TcpListener::bind(cfg.listen).await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(svc.clone().run_clock()); + tokio::spawn(http::serve(svc.clone(), listener)); + let up = timeout(STEP, async { + while !svc.connected.load(Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await; + assert!(up.is_ok(), "the clock's session opens"); + let (st, health) = call(addr, "GET", "/health", None, "1.1.1.1").await; + assert_eq!((st, health["relay"].as_bool()), (200, Some(true))); + + // Alice asks to publish and enters with her grant and its proof. + let alice = key(0x31); + let (st, a) = ask(addr, &alice, "publisher", "10.0.0.1").await; + assert_eq!(st, 200, "{a}"); + let room = a["room"]["room"].as_str().unwrap().to_string(); + let t0 = a["room"]["t0_us"].as_u64().unwrap(); + let ga = hex::decode(a["grant"].as_str().unwrap()).unwrap(); + // The grant alone opens no session. + let bare = session(&server, ClientOptions { tokens: vec![Token::UseValue { token_type: 0, value: ga.clone() }], ..ClientOptions::default() }).await; + match timeout(STEP, bare.conn.closed()).await.expect("closed") { + TransportError::Closed { code, .. } => assert_eq!(code, SessionError::UNAUTHORIZED.0), + other => panic!("{other:?}"), + } + let ca = session(&server, with_grant(&server, &ga, &alice)).await; + let held = publish_media(&ca, &room, &alice).await; + + // The clock announces her: join, then publish for each media kind. + let viewer = key(0x33); + let (st, v) = ask(addr, &viewer, "viewer", "10.0.0.3").await; + assert_eq!((st, v["role"].as_str()), (200, Some("viewer"))); + assert_eq!(v["room"]["room"].as_str(), Some(room.as_str()), "a viewer enters the room as it is"); + let cv = session(&server, with_grant(&server, &hex::decode(v["grant"].as_str().unwrap()).unwrap(), &viewer)).await; + let clock = pubhex(&keys.clock); + let mut c = Vec::new(); + for _ in 0..100 { + c = chain(&cv, &room, &clock, t0).await.into_iter().collect(); + if c.len() >= 3 { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert_eq!(kinds(&c), ["join", "publish", "publish"]); + + // Bob too; a third publisher is refused, a viewer is not. + let bob = key(0x32); + let (st, b) = ask(addr, &bob, "publisher", "10.0.0.2").await; + assert_eq!(st, 200, "{b}"); + let cb = session(&server, with_grant(&server, &hex::decode(b["grant"].as_str().unwrap()).unwrap(), &bob)).await; + let held_b = publish_media(&cb, &room, &bob).await; + let (st, full) = ask(addr, &key(0x34), "publisher", "10.0.0.4").await; + assert_eq!((st, full["error"].as_str()), (409, Some("full"))); + let (st, _) = ask(addr, &key(0x35), "viewer", "10.0.0.4").await; + assert_eq!(st, 200); + + // Alice leaves; the clock says so. + drop(held); + ca.close(0, "done"); + for _ in 0..100 { + c = chain(&cv, &room, &clock, t0).await; + if c.len() >= 7 { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert_eq!(kinds(&c), ["join", "publish", "publish", "join", "publish", "publish", "leave"]); + + // The room verifies: its descriptor, the grants the service lists, the clock's chain. + let (_, r) = call(addr, "GET", "/room", None, "10.0.0.9").await; + let descriptor = hex::decode(r["descriptor"].as_str().unwrap()).unwrap(); + let grants: Vec> = r["grants"].as_array().unwrap().iter().map(|g| hex::decode(g.as_str().unwrap()).unwrap()).collect(); + assert_eq!(grants.len(), 2, "the publishers' grants"); + let run = wai::live_room::verify_room(&descriptor, &grants, None, &[c.clone()]); + assert!(matches!(run.outcome, wai::live_room::RoomOutcome::Accept), "{:?}", run.outcome); + assert_eq!(run.state.present.len(), 1); + + // Grants are bounded per client. + let mut statuses = Vec::new(); + for i in 0..6 { + statuses.push(ask(addr, &key(0x40 + i), "viewer", "10.9.9.9").await.0); + } + assert_eq!(statuses.iter().filter(|s| **s == 429).count(), 2, "{statuses:?}"); + + // Bob leaves too; the next publisher starts a new room. + drop(held_b); + cb.close(0, "done"); + for _ in 0..100 { + if chain(&cv, &room, &clock, t0).await.len() >= 8 { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + let (st, n) = ask(addr, &key(0x50), "publisher", "10.0.0.5").await; + assert_eq!(st, 200, "{n}"); + assert_ne!(n["room"]["room"].as_str(), Some(room.as_str()), "a new room"); + assert_eq!(n["room"]["grants"].as_array().unwrap().len(), 1); + cv.close(0, "done"); + server.shutdown().await; +} -- 2.54.0 (Apple Git-157) From 72d60453c5b1d04a2ddd7f1c4c67d537e2771fac Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 06:02:52 -0400 Subject: [PATCH 3/9] joule-moq-web: a room service mode, and its end-to-end scenario The page makes its own Ed25519 key, asks the room service for a grant for it (a publisher's or a viewer's) from its Join and Watch buttons, presents the grant with a proof by that key over the WebSocket binding (WebTransport not tried), and takes the lifecycle from the service's clock, reading the room's grants again when someone joins under a grant it did not hold (wai-live-web's Room.setGrants). Leave, a typed human caption, the session's end, and a proof by another key (tryWrongKey) for the tests. e2e: the service scenario runs the relay (WebSocket only, trusting the service's authority, its door holding each publisher to its own key), the room service and a same-origin proxy on loopback; with --base it runs against a deployment. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-web/e2e/lib.mjs | 90 +++++- .../crates/joule-moq-web/e2e/room.e2e.mjs | 174 +++++++++++- .../crates/joule-moq-web/e2e/run.sh | 10 +- .../crates/joule-moq-web/web/room/app.js | 266 ++++++++++++++++-- .../crates/joule-moq-web/web/room/index.html | 14 + wai/wai-live-web/src/lib.rs | 10 + 6 files changed, 528 insertions(+), 36 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs index 41a2f1022..f7459c1f0 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs @@ -4,6 +4,7 @@ // Every address is loopback, and every wait has a hard bound. import http from "node:http"; +import net from "node:net"; import fs from "node:fs"; import path from "node:path"; import os from "node:os"; @@ -72,6 +73,7 @@ export async function startRelay(bin, cfg, dir, name) { const q = lines.map((l) => l.match(/on udp 127\.0\.0\.1:(\d+)/)).find(Boolean); const w = lines.map((l) => l.match(/on tcp 127\.0\.0\.1:(\d+)/)).find(Boolean); const h = lines.map((l) => l.match(/certificate sha-256 \(hex\):\s+([0-9a-f]{64})/)).find(Boolean); + if (cfg.no_quic) return w && lines.some((l) => l.includes("session summaries signed by")) && { quic: null, ws: Number(w[1]), hash: null }; return q && w && h && { quic: Number(q[1]), ws: Number(w[1]), hash: h[1] }; }, 100); const path_ = cfg.path || "/moq"; @@ -98,11 +100,14 @@ export async function startRelay(bin, cfg, dir, name) { function relayToml(c) { const q = (s) => JSON.stringify(s); const arr = (a) => `[${a.map(q).join(", ")}]`; - let t = `quic_listen = "127.0.0.1:0"\nwebsocket_listen = "127.0.0.1:0"\npath = ${q(c.path || "/moq")}\ngoaway_uri = ${q(c.goaway_uri || "")}\n\n`; + let t = `${c.no_quic ? "" : 'quic_listen = "127.0.0.1:0"\n'}websocket_listen = "127.0.0.1:0"\npath = ${q(c.path || "/moq")}\ngoaway_uri = ${q(c.goaway_uri || "")}\n\n`; t += `[tls]\ndev_names = ["localhost", "127.0.0.1"]\n\n[limits]\ngoaway_timeout_ms = ${c.goaway_timeout_ms || 8000}\n\n`; t += `[receipts]\nreceipts_track_suffix = "_receipts"\n\n`; - for (const d of c.door || []) t += `[[receipts.door]]\nprefix = ${arr(d.prefix)}\ntrust = ${arr(d.trust)}\nhold_ms = ${d.hold_ms}\n\n`; + for (const d of c.door || []) t += `[[receipts.door]]\nprefix = ${arr(d.prefix)}\ntrust = ${arr(d.trust || [])}\n${d.trust_field != null ? `trust_field = ${d.trust_field}\n` : ""}hold_ms = ${d.hold_ms}\n\n`; + if (c.limits) for (const [k, v] of Object.entries(c.limits)) t = t.replace("[limits]\n", `[limits]\n${k} = ${v}\n`); t += `[summary]\nout_path = ${q(c.summary_out)}\n\n[auth]\nrequired = ${c.auth ? "true" : "false"}\n\n`; + // Lines a room service prints for the relay that trusts it (`joule-moq-room relay-lines`). + if (c.extra) t += c.extra + "\n"; for (const g of c.grants || []) t += `[[auth.grants]]\nprefix = ${arr(g.prefix)}\ntoken_sha256 = ${q(g.token_sha256)}\npublish = ${g.publish}\nsubscribe = ${g.subscribe}\nproof = ${g.proof === true}\n\n`; return t; } @@ -159,3 +164,84 @@ export function killStrays() { export function tmpdir(label) { return fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), `moq-web-${label}-`)); } + +// A room service (joule-moq-room) for a relay: its keys made in `dir` (mode 0600), the +// relay's lines that trust them, then, once the relay listens, the service itself. +export function roomKeys(bin, dir) { + const keys = path.join(dir, "room-keys.toml"); + const lines = execFileSync(bin, ["keygen", keys], { stdio: ["ignore", "pipe", "pipe"] }).toString(); + return { keys, lines, authority: lines.match(/^# authority ([0-9a-f]{64})/m)[1], clock: lines.match(/^# clock\s+([0-9a-f]{64})/m)[1] }; +} + +export async function startRoomService(bin, { keys, relayWs, room = {}, rate = {} }, dir) { + const port = await freePort(); + const toml = path.join(dir, "room.toml"); + let t = `listen = "127.0.0.1:${port}"\nkeys = ${JSON.stringify(keys)}\nrelay_url = ${JSON.stringify(relayWs)}\n\n[room]\n`; + for (const [k, v] of Object.entries(room)) t += `${k} = ${v}\n`; + t += "\n[rate]\n"; + for (const [k, v] of Object.entries(rate)) t += `${k} = ${v}\n`; + fs.writeFileSync(toml, t); + const log = path.join(dir, "room-service.log"); + const out = fs.openSync(log, "w"); + const proc = spawn(bin, ["--config", toml], { stdio: ["ignore", out, out], env: { ...process.env, NO_COLOR: "1" } }); + const exited = new Promise((r) => proc.on("exit", (code, signal) => r({ code, signal }))); + const url = `http://127.0.0.1:${port}`; + await until("the room service's clock on the relay", 30000, async () => { + if (proc.exitCode != null) throw new Error(`the room service exited: ${fs.readFileSync(log, "utf8").slice(-400)}`); + try { const r = await fetch(`${url}/health`); return r.ok && (await r.json()).relay === true; } catch { return false; } + }, 200); + return { + url, proc, log, exited, + lines: () => fs.readFileSync(log, "utf8").split("\n"), + async stop() { + if (proc.exitCode == null) proc.kill("SIGTERM"); + const e = await Promise.race([exited, sleep(10000).then(() => null)]); + if (!e) { proc.kill("SIGKILL"); await exited; } + fs.closeSync(out); + return e; + }, + }; +} + +function freePort() { + return new Promise((ok) => { const s = net.createServer(); s.listen(0, "127.0.0.1", () => { const p = s.address().port; s.close(() => ok(p)); }); }); +} + +// The pages, the room service and the relay on one origin, as the production front serves +// them: `root` at /, `/room-api/*` to the service with the prefix stripped, and the +// WebSocket upgrade of `/moq` to the relay, its request (Host and all) passed as it came. +export function serveSameOrigin(root, { service, relayPort }) { + const files = serve(root); + return files.then(({ server: fileServer, close: closeFiles }) => { + const server = http.createServer((req, res) => { + if (req.url.startsWith("/room-api/")) { + const u = new URL(service); + const up = http.request({ host: u.hostname, port: u.port, method: req.method, path: req.url.slice("/room-api".length), headers: { ...req.headers, "cf-connecting-ip": "127.0.0.1" } }, (r) => { + res.writeHead(r.statusCode, r.headers); + r.pipe(res); + }); + up.on("error", () => { res.writeHead(502); res.end(); }); + req.pipe(up); + return; + } + fileServer.emit("request", req, res); + }); + server.on("upgrade", (req, socket, head) => { + if (!req.url.startsWith("/moq")) return socket.destroy(); + const up = net.connect(relayPort, "127.0.0.1", () => { + let raw = `${req.method} ${req.url} HTTP/1.1\r\n`; + for (let i = 0; i < req.rawHeaders.length; i += 2) raw += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`; + up.write(raw + "\r\n"); + if (head && head.length) up.write(head); + up.pipe(socket); + socket.pipe(up); + }); + up.on("error", () => socket.destroy()); + socket.on("error", () => up.destroy()); + }); + return new Promise((ok) => server.listen(0, "127.0.0.1", () => ok({ + server, url: `http://127.0.0.1:${server.address().port}`, + close: () => { server.closeAllConnections && server.closeAllConnections(); return Promise.all([new Promise((r) => server.close(r)), closeFiles()]); }, + }))); + }); +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs index 0a6bd8660..a2a1e5582 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs @@ -20,7 +20,21 @@ // websocket the same, with the WebSocket binding forced on every page; // goaway the relay is shut down with GOAWAY naming a second relay: every page // moves there, closes its old session itself before the timeout, and media -// flows again. +// flows again; +// service a room service (joule-moq-room) keeps the room, as in production: each +// page makes its own key and enters from its buttons, the service grants and +// its clock authors the lifecycle, the relay (WebSocket only, behind a +// same-origin proxy as behind the production front) honours the service's +// grants by its authority's key. Two participants publish and see each +// other, every object verifies, the layout equals the host's, a machine and a +// typed human caption show their labels, a late joiner gets earlier groups, a +// viewer watches, a grant with no proof or a proof by another key is refused +// the session, a publisher past the room's places is told it is full, and the +// room records a participant leaving. +// +// With `--base ` the service scenario runs against a deployment (the page at +// /room/, its service and relay behind the same origin): nothing is started here, +// and only `--wai` is needed beside the browser. // // It prints one line per check and `room e2e: of runs passed`, and exits // non-zero when any check fails. @@ -28,12 +42,15 @@ import fs from "node:fs"; import path from "node:path"; import { createRequire } from "node:module"; -import { serve, startRelay, makeFixture, hostFrame, browserArgs, memguardedBrowser, killStrays, tmpdir, until, sleep } from "./lib.mjs"; +import { serve, serveSameOrigin, startRelay, roomKeys, startRoomService, makeFixture, hostFrame, browserArgs, memguardedBrowser, killStrays, tmpdir, until, sleep } from "./lib.mjs"; const args = Object.fromEntries(process.argv.slice(2).reduce((a, x, i, all) => (x.startsWith("--") ? [...a, [x.slice(2), all[i + 1] && !all[i + 1].startsWith("--") ? all[i + 1] : "1"]] : a), [])); const need = (k) => { if (!args[k]) { console.error(`room e2e: --${k} is required`); process.exit(2); } return path.resolve(args[k]); }; -const RELAY = need("relay"), WAI = need("wai"), WEB = need("web"), CORPUS = need("corpus"); -const ONLY = (args.only || "compose,webtransport,websocket,goaway").split(","); +const BASE = args.base ? args.base.replace(/\/$/, "") : null; +const WAI = need("wai"); +const [RELAY, WEB, CORPUS] = BASE ? [null, null, null] : [need("relay"), need("web"), need("corpus")]; +const ROOM = args.room ? path.resolve(args.room) : null; +const ONLY = (args.only || (BASE ? "service" : `compose,webtransport,websocket,goaway${ROOM ? ",service" : ""}`)).split(","); const RUNS = Number(args.runs || 1); const OUT = args.out ? path.resolve(args.out) : tmpdir("out"); fs.mkdirSync(OUT, { recursive: true }); @@ -152,7 +169,8 @@ async function compareLayout(label, p, fx, dir, expectTiles) { const snap = await p.snap(); const f = snap.frame; const roomJson = await p.evaluate(() => window.roomTest.roomJson()); - const host = hostFrame(WAI, roomJson, fx.compose, f.t, dir); + const compose = fx ? fx.compose : await p.evaluate(() => window.roomTest.compose()); + const host = hostFrame(WAI, roomJson, compose, f.t, dir); const hf = host.frames && host.frames[0]; const same = hf && JSON.stringify({ o: hf.order, t: hf.tiles, v: hf.voices, s: hf.set_digest }) === JSON.stringify({ o: f.order, t: f.tiles, v: f.voices, s: f.set_digest }); check(`${label}: the page's layout at t=${f.t} equals the host's for the same descriptor and set`, !!same && f.tiles.length === expectTiles, @@ -364,6 +382,150 @@ async function scenarioGoaway(run) { } } +// --------------------------------------------------------------------------------- service + +async function scenarioService(run) { + const label = BASE ? "production" : "service"; + const dir = path.join(OUT, `run${run}-${label}`); + fs.mkdirSync(dir, { recursive: true }); + let relay = null, svc = null, srv = null, base; + if (BASE) { + base = `${BASE}/room/`; + } else { + if (!ROOM) throw new Error("--room is needed for the service scenario"); + const k = roomKeys(ROOM, dir); + const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 })); + relay = await startRelay(RELAY, { no_quic: true, auth: true, door, extra: k.lines, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000, max_ingress_kbps: 3000 } }, dir, "relay"); + svc = await startRoomService(ROOM, { keys: k.keys, relayWs: relay.wsUrl, room: { max_publishers: 3 } }, dir); + srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws }); + base = `${srv.url}/room/?service=/room-api`; + } + const url = (extra = {}) => { const u = new URL(base); for (const [k, v] of Object.entries(extra)) u.searchParams.set(k, v); return u.toString(); }; + const browser = await launch(dir); + const pages = []; + const enter = async (name, button, extra, phase) => { + const p = await page(browser, url(extra), name, dir); + pages.push(p); + await until(`${name}'s page ready`, STEP, () => p.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200); + await p.click(button); + const s = await waitPhase(p, phase); + return { p, principal: s.me.principal, room: s.room }; + }; + try { + const A = await enter("alice", "#join", {}, "publishing"); + const B = await enter("bob", "#join", {}, "publishing"); + const [alice, bob] = [A.p, B.p]; + check(`${label}: both participants are in one room`, A.room === B.room, `${A.room.slice(0, 16)} / ${B.room.slice(0, 16)}`); + const s0 = await alice.snap(); + check(`${label}: the session runs over the WebSocket binding, with a grant the service issued for the page's own key`, s0.transport.kind === "websocket" && s0.grant.role === "publisher" && /^[0-9a-f]{64}$/.test(s0.grant.digest), `${s0.transport.kind}, grant ${s0.grant.digest.slice(0, 16)}, expires ${new Date(s0.grant.exp * 1000).toISOString()}`); + for (const [p, other] of [[alice, B.principal], [bob, A.principal]]) { + await until(`${p.name} decodes video from the other`, STEP * 2, async () => { + const s = await p.snap(); + if (s.errors.length) throw new Error(s.errors[0]); + const t = s.subs[other]; + return t && t.video.decoded >= 30 && t.audio.decoded >= 50 && s.mixed.voiced_blocks > 0; + }, 500); + } + for (const [p, other] of [[alice, B.principal], [bob, A.principal]]) mediaChecks(`${label} ${p.name}`, await p.snap(), other, { minVideo: 30, minAudio: 50 }); + const sa = await alice.snap(); + check(`${label}: the mix reaches the AudioWorklet`, sa.mixed.voiced_blocks > 0 && sa.worklet.played > 0, `${sa.mixed.voiced_blocks} voiced blocks, ${sa.worklet.played} frames played`); + const present = (s) => (s.roomState && s.roomState.present ? s.roomState.present.length : 0); + await until(`${label} alice lays out the room`, STEP, async () => { const s = await alice.snap(); return s.frame && s.frame.tiles.length === present(s) && present(s) >= 2; }, 500); + const sl = await alice.snap(); + await compareLayout(`${label} alice`, alice, null, dir, present(sl)); + const tl = (await bob.snap()).timeline; + const byRoom = tl.filter((e) => e.who === "room").map((e) => e.kind); + check(`${label}: the room's clock, not a page, authors the lifecycle`, ["join", "publish"].every((k) => byRoom.includes(k)) && !tl.some((e) => ["join", "publish", "leave"].includes(e.kind) && e.who !== "room"), `${byRoom.length} lifecycle events by the room's clock`); + + // Captions: a machine caption with its model, and one typed by a person in the page. + const model = "9d".repeat(32); + await alice.evaluate((m) => window.roomTest.sendCaption({ text: "the machine heard this", origin: 2, model: m, lang: "en" }), model); + const cap = await until(`${label} bob shows alice's caption`, STEP, async () => { + const dom = await bob.evaluate(() => ({ label: document.querySelector("#caption .origin")?.textContent, text: document.querySelector("#caption .text")?.textContent })); + return dom.text === "the machine heard this" && dom; + }); + check(`${label}: a machine caption shows with its origin label`, /Machine-generated/.test(cap.label), `${cap.label}: "${cap.text}"`); + await bob.fill("#caption-text", "typed by a person"); + await bob.press("#caption-text", "Enter"); + const cap2 = await until(`${label} alice shows bob's typed caption`, STEP, async () => { + const dom = await alice.evaluate(() => ({ label: document.querySelector("#caption .origin")?.textContent, text: document.querySelector("#caption .text")?.textContent })); + return dom.text === "typed by a person" && dom; + }); + check(`${label}: a caption typed in the page shows as a human caption`, cap2.label === "Human captioner", cap2.label); + + // A late joiner gets the earlier groups of both by FETCH. + const C = await enter("carol", "#join", { fetch: "1" }, "publishing"); + for (const other of [A.principal, B.principal]) { + const t = await until(`carol fetches the earlier groups of ${other.slice(0, 8)}`, STEP * 2, async () => { + const s = await C.p.snap(); + if (s.errors.length) throw new Error(s.errors[0]); + const v = s.subs[other] && s.subs[other].video; + return v && v.fetched > 0 && v.fetched_verified === v.fetched && v.decoded > 0 && v.fetched_decoded >= Math.min(10, v.fetched) && v; + }, 500); + check(`${label}: a late joiner gets earlier groups by FETCH, each verified`, t.first_fetched_group === 0 && t.fetched_verified === t.fetched && t.fetched >= 15, + `${t.fetched} fetched (groups ${t.first_fetched_group}..${t.last_fetched_group}), ${t.fetched_verified} verified, ${t.fetched_decoded} decoded`); + } + await until(`${label} alice re-lays out for the late joiner`, STEP, async () => { const s = await alice.snap(); return s.frame && s.frame.tiles.length === present(s) && s.frame.tiles.some((x) => x.principal === C.principal); }, 500); + const s3 = await alice.snap(); + await compareLayout(`${label} alice after the late join`, alice, null, dir, present(s3)); + + // A viewer watches: every publisher, verified, with no camera of its own. + const V = await enter("viewer", "#watch", {}, "watching"); + await until(`${label} the viewer receives every publisher`, STEP * 2, async () => { + const s = await V.p.snap(); + if (s.errors.length) throw new Error(s.errors[0]); + return [A.principal, B.principal, C.principal].every((P) => s.subs[P] && s.subs[P].video.verified >= 10 && s.subs[P].video.decoded >= 10); + }, 500); + const sv = await V.p.snap(); + check(`${label}: a viewer receives and verifies every publisher, and publishes nothing`, sv.published.video === 0 && sv.published.audio === 0 && [A.principal, B.principal, C.principal].every((P) => sv.subs[P].video.refused === 0), + [A.principal, B.principal, C.principal].map((P) => `${P.slice(0, 6)} ${sv.subs[P].video.verified} verified`).join(", ")); + + // Proof of possession: the grant with no proof, and with a proof by another key. + const bare = await alice.evaluate(() => window.roomTest.tryBareGrant()); + check(`${label}: a grant presented without its proof is refused the session`, !!bare.closed && bare.closed.code === 2 && /pop_missing/.test(bare.closed.reason), JSON.stringify(bare.closed)); + const wrong = await alice.evaluate(() => window.roomTest.tryWrongKey()); + check(`${label}: a grant presented with a proof by another key is refused the session`, !!wrong.closed && wrong.closed.code === 2 && /pop_wrong_key/.test(wrong.closed.reason), JSON.stringify(wrong.closed)); + + if (!BASE) { + // Past the room's places (three here), a publisher is told the room is full. + const d = await page(browser, url(), "dave", dir); + pages.push(d); + await until("dave's page ready", STEP, () => d.evaluate(() => window.roomTest && window.roomTest.state.phase === "idle"), 200); + await d.click("#join"); + const full = await until("dave told the room is full", STEP, async () => { const s = await d.snap(); return s.phase === "full" && s; }, 200); + const msg = await d.evaluate(() => document.getElementById("status").textContent); + check(`${label}: a publisher past the room's places is told it is full, and may watch`, full.phase === "full" && /publishers/.test(msg) && await d.evaluate(() => !document.getElementById("watch").disabled), msg); + } + + // Alice leaves: the room records it, and everyone re-lays out. + await alice.click("#leave"); + const left = await until(`${label} bob sees alice leave`, STEP, async () => { + const s = await bob.snap(); + return s.timeline.some((e) => e.kind === "leave" && e.who === "room") && s.frame && !s.frame.tiles.some((x) => x.principal === A.principal) && s; + }, 500); + check(`${label}: a participant leaving is recorded by the room, and the others re-lay out`, true, `${left.frame.tiles.length} tile(s) after the leave`); + + for (const p of pages) { + const s = await p.snap(); + fs.writeFileSync(path.join(dir, `${p.name}.snapshot.json`), JSON.stringify(s, null, 1)); + check(`${label}: ${p.name} reports no error`, s.errors.length === 0, s.errors.slice(0, 2).join(" | ")); + } + if (relay) { + const refusedAtDoor = relay.lines.filter((l) => l.includes("refused at the door")).length; + check(`${label}: the relay's door verified every media object and refused none`, refusedAtDoor === 0, `${refusedAtDoor} refused`); + } + } finally { + for (const p of pages) { try { p.dump(); } catch { /* closed */ } } + // Every page leaves, so the room it used is left as it was found. + for (const p of pages) { try { await p.evaluate(() => window.roomTest.leave && window.roomTest.leave()); } catch { /* closed */ } } + await browser.close().catch(() => {}); + if (srv) await srv.close(); + if (svc) await svc.stop(); + if (relay) await relay.stop(); + killStrays(); + } +} + // ------------------------------------------------------------------------------------- main let passedRuns = 0; @@ -371,7 +533,7 @@ for (let run = 1; run <= RUNS; run++) { const before = failed; const t = Date.now(); console.log(`== run ${run} of ${RUNS}`); - for (const [name, f] of [["compose", scenarioCompose], ["webtransport", (r) => scenarioRoom(r, "webtransport")], ["websocket", (r) => scenarioRoom(r, "websocket")], ["goaway", scenarioGoaway]]) { + for (const [name, f] of [["compose", scenarioCompose], ["webtransport", (r) => scenarioRoom(r, "webtransport")], ["websocket", (r) => scenarioRoom(r, "websocket")], ["goaway", scenarioGoaway], ["service", scenarioService]]) { if (!ONLY.includes(name)) continue; try { await f(run); diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh index b261dec51..5af3673d2 100755 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh @@ -1,10 +1,10 @@ #!/bin/bash # Build what the browser MoQ client's tests need, and run them. # -# e2e/run.sh [--only compose,webtransport,websocket,goaway] [--runs N] +# e2e/run.sh [--only compose,webtransport,websocket,goaway,service] [--runs N] # -# 1. joule-moq-relay (native, release) and wai_live_web (native, release: the test room -# and the host's composition); +# 1. joule-moq-relay, joule-moq-room (its own workspace) and wai_live_web (native, +# release: the test room and the host's composition); # 2. joule-moq-web and wai-live-web for wasm32-unknown-unknown, through wasm-bindgen's web # target into web/pkg/, each with its own --out-name; # 3. the browser-automation package from e2e/package-lock.json (`npm ci --ignore-scripts`: @@ -33,8 +33,9 @@ mkdir -p "$PKG" step() { echo "== $*"; } -step "native: joule-moq-relay, wai_live_web" +step "native: joule-moq-relay, joule-moq-room, wai_live_web" (cd "$JC" && cargo build $LOCKED --release -p joule-moq-relay --bin joule-moq-relay) +(cd "$CRATE/../joule-moq-room" && cargo build $LOCKED --release --bin joule-moq-room) (cd "$WLW" && cargo build $LOCKED --release --bin wai_live_web) step "wasm: joule-moq-web, wai-live-web" @@ -64,6 +65,7 @@ browser=() [ -n "$BROWSER_BIN" ] && browser=(--browser "$BROWSER_BIN") node "$HERE/room.e2e.mjs" \ --relay "$TARGET/release/joule-moq-relay" \ + --room "$TARGET/release/joule-moq-room" \ --wai "$TARGET/release/wai_live_web" \ --web "$CRATE/web" \ --corpus "$ROOT/wai/live-conformance/compose/vectors.json" \ diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js index 92f661e40..5e3b66e3e 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js @@ -1,7 +1,19 @@ // The room client: a participant publishes its camera and microphone through the relay // and subscribes to everyone else; the room's clock (a room key) publishes the lifecycle. // +// Two ways in: +// - a room service (joule-moq-room): the page makes its own Ed25519 key, asks the +// service for a grant for it (a publisher's or a viewer's), and presents the grant with +// a proof by that key (WAI live-rooms §10.5); the service's clock authors the lifecycle. +// The service's base URL is the `service` parameter, or `data-service` on #room-app; +// the page enters when `enter("publisher" | "viewer")` is called (the #join and #watch +// buttons). The relay is the WebSocket at `/moq` on the page's own origin unless `ws` +// names another, and WebTransport is not tried; +// - a test room (`fixture`): every key from the fixture, and a page with `role=clock` +// authors the lifecycle, driven by the test. +// // Query parameters: +// service= the room service, as above // fixture= the test room (descriptor, grants, composition descriptor, keys) // who= this participant, by name; `role=clock` for the room's clock // wt= the relay's WebTransport URL (https://127.0.0.1:/moq) @@ -63,6 +75,8 @@ const state = { captions: [], goaways: [], sessions: 0, + mode: null, + ended: null, }; window.roomTest = { state, logLines }; @@ -70,6 +84,13 @@ function fail(e) { const m = (e && e.stack) || String(e); state.errors.push(m); log(`error: ${m}`); + status(`Something went wrong: ${(e && e.message) || e}`); +} + +// What the page tells its reader, where it has a place for it. +function status(text) { + const el = $("status"); + if (el) el.textContent = text; } window.addEventListener("error", (e) => fail(e.error || e.message)); window.addEventListener("unhandledrejection", (e) => fail(e.reason)); @@ -139,7 +160,7 @@ class EventsTrack { // --------------------------------------------------------------------------------- room -let fx, me, roomId, t0, room, compositor, session, transportOpts, eventsTrack; +let fx, me, roomId, t0, room, compositor, session, transportOpts, eventsTrack, service; const subscribed = new Map(); // principal -> subscription state const signers = {}; const published = []; // the PUBLISH requests held open @@ -148,42 +169,136 @@ function roomNow() { return Math.max(0, Math.round(Date.now() * 1000 - t0)); } +// A principal as the page names it: the clock, this page, a fixture's participant, or a +// publisher by its grant's `order` attribute. +function nameOf(P) { + if (fx.clock && P === fx.clock.principal) return fx.participants.length ? "clock" : "room"; + if (me && P === me.principal) return fx.participants.length ? me.name : "you"; + const p = fx.participants.find((x) => x.principal === P); + if (p) return p.name; + const g = (state.grants || []).find((x) => x.principal === P); + const order = g && (g.attrs.find((a) => a[0] === "order") || [])[1]; + return order != null ? `participant ${Number(order) + 1}` : P.slice(0, 8); +} + +// The room's grants as the page holds them, read. +function readGrants() { + const gv = JSON.parse(room.grants()); + if (gv.outcome !== "read") throw new Error(`the room's grants: ${gv.reason}`); + state.grants = gv.grants; +} + async function boot() { await initMoq(); await initWai(); fx = await (await fetch(q.get("fixture"))).json(); - roomId = unhex(fx.room); - t0 = fx.t0_us; const role = q.get("role") || "participant"; me = role === "clock" ? { name: "clock", ...fx.clock } : fx.participants.find((p) => p.name === q.get("who")); if (!me) throw new Error(`no participant "${q.get("who")}" in the fixture`); - $("who").textContent = `${me.name} · ${me.principal.slice(0, 12)}`; - room = new wai.Room(unhex(fx.descriptor), JSON.stringify(fx.grants)); - const gv = JSON.parse(room.grants()); - if (gv.outcome !== "read") throw new Error(`the room's grants: ${gv.reason}`); - state.descriptor = JSON.parse(wai.roomDescriptor(unhex(fx.descriptor))); - eventsTrack = new EventsTrack(unhex(me.seed), roomId, t0); + state.mode = "fixture"; transportOpts = { webtransport: q.get("wt"), websocket: q.get("ws"), hashes: (q.get("hashes") || "").split(",").filter(Boolean).map(unhex), force: q.get("transport") || "", }; + window.roomTest.role = role; + await begin(role); +} + +// The room service's answer, or why there is none. +async function api(path, body) { + const r = await fetch(`${service}${path}`, body ? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), cache: "no-store" } : { cache: "no-store" }); + let j = null; + try { j = await r.json(); } catch { /* not JSON */ } + return { status: r.status, body: j }; +} + +const ROLE_OF = { publisher: "participant", viewer: "viewer" }; + +// Enter the room the service keeps: as a publisher (camera and microphone) or a viewer. +async function enter(role) { + if (state.mode) throw new Error("this page has entered already"); + state.mode = "service"; + state.phase = "entering"; + await initMoq(); + await initWai(); + let r = await api("/room"); + if (r.status !== 200) throw new Error(`the room service answered ${r.status}`); + let stream = null; + if (role === "publisher") { + // The camera and microphone first: a room place is asked for only once they are on. + status("Asking for your camera and microphone…"); + const d = JSON.parse(wai.roomDescriptor(unhex(r.body.descriptor))); + stream = await navigator.mediaDevices.getUserMedia(constraintsOf(d)); + } + // The key this page signs with: made here, and never sent anywhere. + const seed = crypto.getRandomValues(new Uint8Array(32)); + const principal = hex(moq.publicKeyOf(seed)); + status("Asking the room for a grant…"); + r = await api("/grant", { principal, role }); + if (r.status === 409 && role === "publisher") { + for (const t of stream.getTracks()) t.stop(); + state.mode = null; + state.phase = "full"; + status(r.body && r.body.error === "full" ? `The room has its ${r.body.max_publishers} publishers. You can watch, or try again later.` : "The room is busy. You can watch, or try again later."); + return state.phase; + } + if (r.status === 429) throw new Error(`too many grants from this address; try again in ${r.body.retry_after_s} s`); + if (r.status !== 200) throw new Error(`the room service answered ${r.status}${r.body && r.body.error ? ` (${r.body.error})` : ""}`); + const v = r.body.room; + const d = JSON.parse(wai.roomDescriptor(unhex(v.descriptor))); + fx = { room: v.room, t0_us: v.t0_us, descriptor: v.descriptor, compose: v.compose, grants: v.grants, clock: { principal: d.clock }, participants: [] }; + me = { name: "you", seed: hex(seed), principal, grant: r.body.grant, grant_digest: r.body.digest, role }; + state.grant = { role, exp: r.body.exp, digest: r.body.digest }; + const ws = q.get("ws") || `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/moq`; + transportOpts = { webtransport: null, websocket: ws, hashes: [], force: "websocket" }; + window.roomTest.role = ROLE_OF[role]; + await begin(ROLE_OF[role], stream); + pollRoom(); + return state.phase; +} + +function constraintsOf(d) { + const vp = d.profiles.find((p) => p.media === 2); + const ap = d.profiles.find((p) => p.media === 1); + const vl = vp.layers[0]; + return { + audio: { channelCount: ap.channels, sampleRate: ap.sample_rate, echoCancellation: (ap.processing & 1) !== 0, noiseSuppression: (ap.processing & 2) !== 0, autoGainControl: (ap.processing & 4) !== 0 }, + video: { width: vl.width, height: vl.height, frameRate: vl.fps }, + }; +} + +// The room, the composition and the session, once the page knows who it is. +async function begin(role, stream) { + roomId = unhex(fx.room); + t0 = fx.t0_us; + const who = $("who"); + if (who) who.textContent = `${me.name} · ${me.principal.slice(0, 12)}`; + room = new wai.Room(unhex(fx.descriptor), JSON.stringify(fx.grants)); + readGrants(); + state.descriptor = JSON.parse(wai.roomDescriptor(unhex(fx.descriptor))); + eventsTrack = new EventsTrack(unhex(me.seed), roomId, t0); if (role !== "clock") { compositor = new Compositor(wai, $("canvas"), unhex(fx.compose), { log }); await compositor.startAudio(new URL("./worklet.js", import.meta.url)); setInterval(() => { try { compositor.draw(); } catch (e) { fail(e); } }, Math.round(1000 / compositor.desc.params.fps)); setInterval(sweepMissing, 1000); } - window.roomTest.role = role; + status("Connecting to the relay…"); await openSession(transportOpts); if (role === "clock") { window.roomTest.clockEmit = clockEmit; state.phase = "ready"; + } else if (role === "viewer") { + await subscribeEvents(fx.clock.principal); + state.phase = "watching"; + status("Watching. Everything you receive is verified before it is shown."); } else { - await startPublishing(); + await startPublishing(stream); await subscribeEvents(fx.clock.principal); state.phase = "publishing"; + status("You are in the room. Everything you receive is verified before it is shown."); } log(`ready as ${role} ${me.name}`); } @@ -199,6 +314,7 @@ async function openSession(opts) { s.onGoaway = (g) => onGoaway(s, g); session = s; window.roomTest.session = s; + s.closed.then((i) => onClosed(s, i)); // The events track is published on every session. const ns = [fx.room, me.principal, "events"]; published.push(await s.publish(ns, "0", ALIAS.events, { publisher_priority: PRIORITY.events })); @@ -206,6 +322,40 @@ async function openSession(opts) { return s; } +// The session ended: by this page, by a move after GOAWAY, or by the relay (its session +// limit, the grant's expiry, a refusal). +function onClosed(s, info) { + if (s !== session || state.goaways.some((g) => !g.moved)) return; + state.ended = { code: info && info.code, reason: info && info.reason }; + if (state.phase === "left") return; + state.phase = "ended"; + log(`the session ended: ${JSON.stringify(state.ended)}`); + if (state.mode === "service") status(`The session ended${info && info.reason ? `: ${info.reason}` : ""}. Reload the page to enter again.`); +} + +// Leave: the session closes, and the relay ends this page's tracks, which the room's clock +// records as the publisher leaving. +async function leave() { + state.phase = "left"; + if (state.capture && state.capture.stream) for (const t of state.capture.stream.getTracks()) t.stop(); + if (session) session.close(0, "left"); + status("You left the room. Reload the page to enter again."); +} + +// A new room may start while this page watches an empty one: the page says so. +function pollRoom() { + setInterval(async () => { + if (state.phase !== "watching" && state.phase !== "publishing") return; + try { + const r = await api("/room"); + if (r.status === 200 && r.body.room !== fx.room) { + state.roomMoved = r.body.room; + if (state.phase === "watching") status("A new room has started. Reload the page to watch it."); + } + } catch { /* the next poll */ } + }, 20000); +} + // ------------------------------------------------------------------------------- the clock async function clockEmit(desc) { @@ -228,7 +378,7 @@ async function pickVideoCodec(profile) { throw new Error(`no video codec of the profile is supported: ${profile.codecs.join(", ")}`); } -async function startPublishing() { +async function startPublishing(given) { const d = state.descriptor; const vp = d.profiles.find((p) => p.media === 2); const ap = d.profiles.find((p) => p.media === 1); @@ -268,12 +418,9 @@ async function startPublishing() { signers.audio = new moq.TrackSigner(unhex(me.seed), BigInt(RECEIPT_ALIAS.audio), BigInt(ALIAS.audio), BigInt(ALIAS.audioReceipts), PRIORITY.audio, me.name, BigInt(AUDIO_TIMEOUT_MS)); await publishTracks(session); - const constraints = { - audio: { channelCount: ap.channels, sampleRate: ap.sample_rate, echoCancellation: (ap.processing & 1) !== 0, noiseSuppression: (ap.processing & 2) !== 0, autoGainControl: (ap.processing & 4) !== 0 }, - video: { width: vl.width, height: vl.height, frameRate: vl.fps }, - }; - const stream = await navigator.mediaDevices.getUserMedia(constraints); + const stream = given || await navigator.mediaDevices.getUserMedia(constraintsOf(d)); state.capture = { video: stream.getVideoTracks()[0].getSettings(), audio: stream.getAudioTracks()[0].getSettings() }; + Object.defineProperty(state.capture, "stream", { value: stream, enumerable: false }); let vgroup = 0, agroup = 0, vframes = 0; const venc = new VideoEncoder({ output: (chunk) => { @@ -401,7 +548,7 @@ async function subscribeEvents(principal) { for (const rec of arr) { let bytes; try { bytes = wai.msfRecordRead(JSON.stringify(rec), BigInt(t0)); } catch (e) { log(`an event record refused: ${e.message || e}`); continue; } - onRecord(bytes); + records = records.then(() => onRecord(bytes)).catch(fail); } }; const handler = { @@ -414,11 +561,18 @@ async function subscribeEvents(principal) { }, handler); } -function onRecord(bytes) { +// Records are taken one at a time, in the order they arrive: a join under a grant the page +// does not hold waits while the page asks the service for the room's grants again. +let records = Promise.resolve(); + +async function onRecord(bytes) { const v = JSON.parse(room.offer(bytes)); if (!v.ok) return log(`an event refused: ${v.reason}`); if (!v.new) return; - const who = v.principal === fx.clock.principal ? "clock" : (fx.participants.find((p) => p.principal === v.principal) || { name: v.principal.slice(0, 8) }).name; + if (service && v.kind_name === "join" && v.payload && v.payload.detail && !state.grants.some((g) => g.digest === v.payload.detail.grant)) { + await refreshGrants(); + } + const who = nameOf(v.principal); const entry = { t: v.t, who, kind: v.kind_name, payload: v.payload, digest: v.digest }; state.timeline.push(entry); renderTimeline(entry); @@ -429,6 +583,15 @@ function onRecord(bytes) { if (v.kind >= 0x10) onLifecycle(); } +async function refreshGrants() { + const r = await api("/room"); + if (r.status !== 200 || r.body.room !== fx.room) return log("the room's grants could not be read again"); + fx.grants = r.body.grants; + room.setGrants(JSON.stringify(fx.grants)); + readGrants(); + log(`the room's grants read again: ${fx.grants.length}`); +} + function onLifecycle() { const st = JSON.parse(room.state()); state.room = st; @@ -736,16 +899,21 @@ function renderCaption() { window.roomTest.sendCaption = async ({ text, origin = 1, model = null, lang = "en", duration_ms = 3000 }) => { const p = wai.captionPayload(duration_ms, origin, model || undefined, lang, text); const r = await eventsTrack.emit(0x0a, p); - onRecord(r.record); + await (records = records.then(() => onRecord(r.record))); return r.view; }; window.roomTest.sendChat = async (text) => { const r = await eventsTrack.emit(0x03, wai.chatPayload(text)); - onRecord(r.record); + await (records = records.then(() => onRecord(r.record))); return r.view; }; window.roomTest.snapshot = () => ({ phase: state.phase, + mode: state.mode, + me: me && { principal: me.principal, role: me.role || window.roomTest.role }, + room: fx && fx.room, + grant: state.grant, + ended: state.ended, errors: state.errors, transport: state.transport, sessions: state.sessions, @@ -756,7 +924,7 @@ window.roomTest.snapshot = () => ({ captions: state.captions, timeline: state.timeline.map((e) => ({ t: e.t, who: e.who, kind: e.kind })), goaways: state.goaways, - room: state.room, + roomState: state.room, frame: compositor && compositor.frame, layouts: compositor && compositor.layouts, drawn: compositor && Object.fromEntries(compositor.drawn), @@ -782,8 +950,58 @@ window.roomTest.tryBareGrant = async () => { return { transport: t.kind, setup, closed }; }; +// This page's grant presented with a proof made by another key: refused the session too +// (§10.5.4 step 4, pop_wrong_key). +window.roomTest.tryWrongKey = async () => { + const t = await connect({ ...transportOpts, log: () => {} }); + let setup = "exchanged"; + try { + await MoqSession.connect(t, moq, { grant: { record: unhex(me.grant), seed: crypto.getRandomValues(new Uint8Array(32)) }, implementation: "joule-moq-web/room-wrong-key", log: () => {} }); + } catch (e) { + setup = String(e.message || e); + } + const closed = await Promise.race([t.closed, new Promise((r) => setTimeout(() => r(null), 8000))]); + return { transport: t.kind, setup, closed }; +}; + window.roomTest.roomJson = () => room.roomJson(); +window.roomTest.compose = () => fx && fx.compose; window.roomTest.frameAt = (t) => JSON.parse(room.frame(unhex(fx.compose), BigInt(t))); window.roomTest.now = () => roomNow(); -boot().catch(fail); +// A human caption, typed by this participant. +async function sendTypedCaption(e) { + e.preventDefault(); + const el = $("caption-text"); + const text = el.value.trim(); + if (!text || state.phase !== "publishing") return; + el.value = ""; + try { await window.roomTest.sendCaption({ text, origin: 1, lang: document.documentElement.lang || "en" }); } catch (err) { fail(err); } +} + +service = q.get("service") || ($("room-app") && $("room-app").dataset.service) || ""; +if (service) { + window.roomTest.enter = (role) => enter(role); + window.roomTest.leave = leave; + const buttons = ["join", "watch"].map($).filter(Boolean); + const go = (role) => async () => { + for (const b of buttons) b.disabled = true; + try { + const phase = await enter(role); + if (phase === "full") for (const b of buttons) b.disabled = b.id === "join"; + else if ($("leave")) $("leave").hidden = false; + if (phase === "publishing" && $("caption-form")) $("caption-form").hidden = false; + } catch (e) { + fail(e); + state.mode = state.mode === "service" && state.phase === "entering" ? null : state.mode; + for (const b of buttons) b.disabled = false; + } + }; + if ($("join")) $("join").addEventListener("click", go("publisher")); + if ($("watch")) $("watch").addEventListener("click", go("viewer")); + if ($("leave")) $("leave").addEventListener("click", () => { $("leave").disabled = true; if ($("caption-form")) $("caption-form").hidden = true; leave(); }); + if ($("caption-form")) $("caption-form").addEventListener("submit", sendTypedCaption); + state.phase = "idle"; +} else { + boot().catch(fail); +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/index.html b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/index.html index a687a46a8..7a6d9e453 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/index.html +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/index.html @@ -25,10 +25,20 @@ #timeline li { padding: 2px 0; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; } #log { white-space: pre-wrap; font-family: ui-monospace, monospace; font-size: 11px; color: var(--muted); max-height: 220px; overflow: auto; margin: 0; overflow-wrap: anywhere; } h2 { font-size: 13px; margin: 0 0 6px; color: var(--muted); font-weight: 600; } + .controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; } + #status { color: var(--muted); }

MoQ room client

loading
+ +
+ + + + + +

The room, composed

@@ -44,6 +54,10 @@

   
+ diff --git a/wai/wai-live-web/src/lib.rs b/wai/wai-live-web/src/lib.rs index 11c80c6ce..21e0c3a8d 100644 --- a/wai/wai-live-web/src/lib.rs +++ b/wai/wai-live-web/src/lib.rs @@ -692,6 +692,16 @@ impl Room { pub fn grants_json(&self) -> String { grants_view(&self.inner.descriptor, &self.inner.grants).to_string() } + + /// Replace the room's grants (a JSON array of hexadecimal records), as a room service + /// lists them when a principal joins under a grant the page did not hold. The records + /// already offered are kept; the room is verified again from them on the next `state`. + #[wasm_bindgen(js_name = setGrants)] + pub fn set_grants(&mut self, grants_json: &str) -> Result<(), JsError> { + let gs: Vec = serde_json::from_str(grants_json).map_err(js)?; + self.inner.grants = gs.iter().map(|g| hex::decode(g).map_err(js)).collect::, _>>()?; + Ok(()) + } } // --------------------------------------------------------------------------------------- -- 2.54.0 (Apple Git-157) From 7e15664de91f88247a7059a8423451b543f8d873 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 06:05:05 -0400 Subject: [PATCH 4/9] joule-moq-relay: unlimited grant rules; the room's clock exempt from session limits A session whose SETUP presents a token whose rule says unlimited is exempt from max_session_ms and max_ingress_kbps: the room service's clock is a long-lived session of the deployment's own, and the session limit closed it every ten minutes (seen in the service scenario). joule-moq-room's relay-lines mark its clock's rule so. joule-moq-web: grantProofBy and clientSetupWithProof, so a test presents a page's grant with a proof by another key (the page's own path signs with the principal's key alone); the service scenario's teardown is bounded and its proxy ends upgraded sockets. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-relay/src/auth.rs | 18 ++++++++++--- .../crates/joule-moq-relay/src/config.rs | 5 ++++ .../crates/joule-moq-relay/src/session.rs | 5 ++-- .../joule-moq-relay/tests/auth_tokens.rs | 2 +- .../joule-moq-relay/tests/grant_proof.rs | 2 +- .../joule-moq-relay/tests/room_authority.rs | 11 ++++++-- .../crates/joule-moq-room/README.md | 1 + .../crates/joule-moq-room/src/keys.rs | 2 +- .../joule-moq-room/tests/room_service.rs | 4 +-- .../crates/joule-moq-web/e2e/lib.mjs | 13 +++++++++- .../crates/joule-moq-web/e2e/room.e2e.mjs | 13 +++++----- .../crates/joule-moq-web/src/lib.rs | 25 +++++++++++++++++++ .../crates/joule-moq-web/web/moq/session.js | 7 +++++- .../crates/joule-moq-web/web/room/app.js | 2 +- 14 files changed, 89 insertions(+), 21 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/auth.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/auth.rs index 3c89b5829..8e7e58c93 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/auth.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/auth.rs @@ -117,6 +117,7 @@ struct GrantRule { publish: bool, subscribe: bool, proof: bool, + unlimited: bool, } struct AuthorityRule { @@ -186,7 +187,7 @@ impl Authorizer { let mut grants = Vec::new(); for g in &c.grants { let h = key32("a grant's token_sha256", &g.token_sha256)?; - grants.push(GrantRule { prefix: TrackNamespace::from_strs(g.prefix.iter().map(String::as_str)), token_sha256: h, publish: g.publish, subscribe: g.subscribe, proof: g.proof }); + grants.push(GrantRule { prefix: TrackNamespace::from_strs(g.prefix.iter().map(String::as_str)), token_sha256: h, publish: g.publish, subscribe: g.subscribe, proof: g.proof, unlimited: g.unlimited }); } let mut authorities = Vec::new(); for a in &c.authorities { @@ -238,6 +239,15 @@ impl Authorizer { }) } + /// Whether the session's SETUP tokens name a rule that exempts it from the session and + /// ingress limits. + pub fn unlimited(&self, tokens: &[Resolved], proven: &HashSet<[u8; 32]>) -> bool { + tokens.iter().any(|(ty, value)| { + let h = sha256(value); + *ty == 0 && self.grants.iter().any(|g| g.unlimited && g.token_sha256 == h && (!g.proof || proven.contains(&h))) + }) + } + /// Whether a token of type 0 with this value is a room grant honoured only beside a /// proof of possession: one a `proof` rule names, or one a configured authority signed. pub fn requires_proof(&self, value: &[u8]) -> bool { @@ -275,8 +285,8 @@ mod tests { required: true, authorities: vec![], grants: vec![ - crate::config::Grant { prefix: vec!["live".into()], token_sha256: hex::encode(sha256(b"t")), publish: false, subscribe: true, proof: false }, - crate::config::Grant { prefix: vec!["live".into()], token_sha256: hex::encode(sha256(b"g")), publish: true, subscribe: false, proof: true }, + crate::config::Grant { prefix: vec!["live".into()], token_sha256: hex::encode(sha256(b"t")), publish: false, subscribe: true, proof: false, unlimited: true }, + crate::config::Grant { prefix: vec!["live".into()], token_sha256: hex::encode(sha256(b"g")), publish: true, subscribe: false, proof: true, unlimited: false }, ], }; let a = Authorizer::from_config(&cfg).unwrap(); @@ -291,6 +301,8 @@ mod tests { assert!(a.requires_proof(b"g") && !a.requires_proof(b"t")); assert!(!a.allows(&ns, Right::Publish, &[(0, b"g".to_vec())], &none)); assert!(a.allows(&ns, Right::Publish, &[(0, b"g".to_vec())], &HashSet::from([sha256(b"g")]))); + // Exempt from the limits: a session presenting the token whose rule says so. + assert!(a.unlimited(&[(0, b"t".to_vec())], &none) && !a.unlimited(&[(0, b"g".to_vec())], &none) && !a.unlimited(&[(0, b"u".to_vec())], &none)); } /// A grant laid out as WAI live-rooms §4.1 lays it out, signed by `authority`. diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/config.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/config.rs index 3182be65e..1361e45ef 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/config.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/config.rs @@ -334,6 +334,11 @@ pub struct Grant { /// a proof that passes is closed with UNAUTHORIZED. #[serde(default)] pub proof: bool, + /// A session whose SETUP presents this token (proved, where `proof` asks for it) is + /// exempt from `limits.max_session_ms` and `limits.max_ingress_kbps`: for a deployment's + /// own long-lived sessions, such as a room service's clock. + #[serde(default)] + pub unlimited: bool, } /// The room grants the relay's tokens carry (WAI live-rooms §10.1), for its per-hop session diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs index 95b70c57a..79d6ffa41 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs @@ -247,7 +247,8 @@ pub async fn serve(relay: Relay, conn: SharedConnection) { ends = Some((at, code.0, why)); } }; - if cfg.limits.max_session_ms != 0 { + let unlimited = relay.inner.auth.unlimited(&setup_tokens, &proven); + if cfg.limits.max_session_ms != 0 && !unlimited { end_at(Instant::now() + Duration::from_millis(cfg.limits.max_session_ms), SessionError::NO_ERROR, format!("the relay's session limit of {} s", cfg.limits.max_session_ms / 1000)); } for (_, value) in setup_tokens.iter().filter(|(ty, _)| *ty == 0) { @@ -279,7 +280,7 @@ pub async fn serve(relay: Relay, conn: SharedConnection) { opened_unix_ms: opened, close_code: Mutex::new(None), jwp, - ingress: (cfg.limits.max_ingress_kbps != 0).then(|| Mutex::new(crate::relay::Ingress::new(cfg.limits.max_ingress_kbps, cfg.limits.ingress_burst_ms))), + ingress: (cfg.limits.max_ingress_kbps != 0 && !unlimited).then(|| Mutex::new(crate::relay::Ingress::new(cfg.limits.max_ingress_kbps, cfg.limits.ingress_burst_ms))), }); relay.inner.state.lock().unwrap().sessions.insert(id, ctx.clone()); relay.inner.change.notify_waiters(); diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/auth_tokens.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/auth_tokens.rs index 739e4170d..1944fdec7 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/auth_tokens.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/auth_tokens.rs @@ -24,7 +24,7 @@ async fn run() { let hash = hex::encode(joule_moq_relay::auth::sha256(&token)); let server = relay(|c| { c.auth.required = true; - c.auth.grants = vec![Grant { prefix: vec!["live".into()], token_sha256: hash, publish: true, subscribe: true, proof: false }]; + c.auth.grants = vec![Grant { prefix: vec!["live".into()], token_sha256: hash, publish: true, subscribe: true, proof: false, unlimited: false }]; c.limits.max_auth_token_cache_size = 1024; }) .await; diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/grant_proof.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/grant_proof.rs index c2e58dc4f..846c49602 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/grant_proof.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/grant_proof.rs @@ -59,7 +59,7 @@ async fn run() { let hash = hex::encode(joule_moq_relay::auth::sha256(&record)); let server = relay(|c| { c.auth.required = true; - c.auth.grants = vec![Grant { prefix: vec!["room".into()], token_sha256: hash, publish: true, subscribe: true, proof: true }]; + c.auth.grants = vec![Grant { prefix: vec!["room".into()], token_sha256: hash, publish: true, subscribe: true, proof: true, unlimited: false }]; }) .await; let t = Tracks::new(ns(&["room", "host"]), "0", 10, 5, 128); diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/room_authority.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/room_authority.rs index c0564fa8f..100bf9400 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/room_authority.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/room_authority.rs @@ -149,11 +149,18 @@ async fn run() { f(c); }) }; - // At its session limit. - let s = limited(|c| c.limits.max_session_ms = 1_500).await; + // At its session limit; a session whose token's rule is `unlimited` is not. + let s = limited(|c| { + c.limits.max_session_ms = 1_500; + c.auth.grants = vec![joule_moq_relay::config::Grant { prefix: vec![], token_sha256: hex::encode(joule_moq_relay::auth::sha256(b"service")), publish: false, subscribe: true, proof: false, unlimited: true }]; + }) + .await; let (c, _) = connect(&s, Via::WebSocket, with(&s, &gv, &viewer)).await; + let (svc, _) = connect(&s, Via::WebSocket, ClientOptions { tokens: vec![Token::UseValue { token_type: 0, value: b"service".to_vec() }], ..ClientOptions::default() }).await; let why = closed_with(&c, SessionError::NO_ERROR, Duration::from_secs(10), "the session limit").await; assert!(why.contains("session limit"), "{why}"); + assert!(timeout(Duration::from_secs(2), svc.conn.closed()).await.is_err(), "an unlimited session outlives the limit"); + svc.close(0, "done"); s.shutdown().await; // When its grant expires. let s = limited(|_| {}).await; diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/README.md b/joule-code/joule-code-rs/crates/joule-moq-room/README.md index dd4f74682..f14e79cee 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-room/README.md @@ -44,6 +44,7 @@ key = "" prefix = ["*", "", "events"] token_sha256 = "" publish = true +unlimited = true # no session or ingress limit for the clock's session [[auth.grants]] # the clock watches the publishers prefix = [] diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs index 035384f81..c1560cbe9 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs @@ -101,7 +101,7 @@ impl Keys { let c = hex::encode(self.clock.verifying_key().to_bytes()); let t = hex::encode(Sha256::digest(self.clock_token)); format!( - "# authority {a}\n# clock {c}\n[[auth.authorities]]\nkey = \"{a}\"\n\n# The room's clock publishes its events track in every room it keeps.\n[[auth.grants]]\nprefix = [\"*\", \"{c}\", \"events\"]\ntoken_sha256 = \"{t}\"\npublish = true\n\n# It watches each publisher's tracks to author the lifecycle.\n[[auth.grants]]\nprefix = []\ntoken_sha256 = \"{t}\"\nsubscribe = true\n" + "# authority {a}\n# clock {c}\n[[auth.authorities]]\nkey = \"{a}\"\n\n# The room's clock publishes its events track in every room it keeps; its session is\n# exempt from the relay's session and ingress limits.\n[[auth.grants]]\nprefix = [\"*\", \"{c}\", \"events\"]\ntoken_sha256 = \"{t}\"\npublish = true\nunlimited = true\n\n# It watches each publisher's tracks to author the lifecycle.\n[[auth.grants]]\nprefix = []\ntoken_sha256 = \"{t}\"\nsubscribe = true\n" ) } } diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs b/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs index 19138ddf1..a4ccf57a5 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs @@ -40,8 +40,8 @@ async fn relay(keys: &Keys) -> RelayServer { let t = hex::encode(Sha256::digest(keys.clock_token)); let clock = hex::encode(keys.clock.verifying_key().to_bytes()); c.auth.grants = vec![ - Grant { prefix: vec!["*".into(), clock, "events".into()], token_sha256: t.clone(), publish: true, subscribe: false, proof: false }, - Grant { prefix: vec![], token_sha256: t, publish: false, subscribe: true, proof: false }, + Grant { prefix: vec!["*".into(), clock, "events".into()], token_sha256: t.clone(), publish: true, subscribe: false, proof: false, unlimited: true }, + Grant { prefix: vec![], token_sha256: t, publish: false, subscribe: true, proof: false, unlimited: false }, ]; c.receipts.door = ["audio", "video"].iter().map(|m| DoorRule { prefix: vec!["*".into(), "*".into(), m.to_string()], trust: vec![], trust_field: Some(1), hold_ms: 2_000 }).collect(); RelayServer::start(c).await.expect("the relay starts") diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs index f7459c1f0..668464f2b 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs @@ -226,6 +226,11 @@ export function serveSameOrigin(root, { service, relayPort }) { } fileServer.emit("request", req, res); }); + // Every socket, the upgraded ones and their relay ends too, is ended when the server + // closes: an upgraded socket otherwise holds `close` open for as long as it lives. + const sockets = new Set(); + const keep = (x) => { sockets.add(x); x.on("close", () => sockets.delete(x)); }; + server.on("connection", keep); server.on("upgrade", (req, socket, head) => { if (!req.url.startsWith("/moq")) return socket.destroy(); const up = net.connect(relayPort, "127.0.0.1", () => { @@ -236,12 +241,18 @@ export function serveSameOrigin(root, { service, relayPort }) { up.pipe(socket); socket.pipe(up); }); + keep(up); up.on("error", () => socket.destroy()); socket.on("error", () => up.destroy()); + up.on("close", () => socket.destroy()); + socket.on("close", () => up.destroy()); }); return new Promise((ok) => server.listen(0, "127.0.0.1", () => ok({ server, url: `http://127.0.0.1:${server.address().port}`, - close: () => { server.closeAllConnections && server.closeAllConnections(); return Promise.all([new Promise((r) => server.close(r)), closeFiles()]); }, + close: () => { + for (const x of sockets) x.destroy(); + return Promise.race([Promise.all([new Promise((r) => server.close(r)), closeFiles()]), sleep(5000)]); + }, }))); }); } diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs index a2a1e5582..fbf433f9d 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs @@ -516,12 +516,13 @@ async function scenarioService(run) { } } finally { for (const p of pages) { try { p.dump(); } catch { /* closed */ } } - // Every page leaves, so the room it used is left as it was found. - for (const p of pages) { try { await p.evaluate(() => window.roomTest.leave && window.roomTest.leave()); } catch { /* closed */ } } - await browser.close().catch(() => {}); - if (srv) await srv.close(); - if (svc) await svc.stop(); - if (relay) await relay.stop(); + // Every page leaves, so the room it used is left as it was found. Each step is bounded. + const bounded = (x) => Promise.race([Promise.resolve(x).catch(() => {}), sleep(15000)]); + for (const p of pages) await bounded(p.evaluate(() => window.roomTest.leave && window.roomTest.leave())); + await bounded(browser.close()); + if (srv) await bounded(srv.close()); + if (svc) await bounded(svc.stop()); + if (relay) await bounded(relay.stop()); killStrays(); } } diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/src/lib.rs b/joule-code/joule-code-rs/crates/joule-moq-web/src/lib.rs index a0b870ca4..7dc8711c6 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/src/lib.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/src/lib.rs @@ -181,6 +181,12 @@ pub fn setup_with_grant_bytes(record: &[u8], seed: [u8; 32], challenge: [u8; 32] return Err("the seed is not the grant's principal's".into()); } let proof = GrantProof::sign(digest, &key, challenge, &format!("{} {target}", grant_proof::VERB_SETUP)).ok_or("a target a proof cannot carry")?; + setup_presenting(record, &proof.to_bytes(), implementation, receipts, max_request_updates) +} + +/// The client's SETUP presenting `record` beside the proof record `proof`, as given. +pub fn setup_presenting(record: &[u8], proof: &[u8], implementation: &str, receipts: bool, max_request_updates: u64) -> Result, String> { + let proof = GrantProof::read(proof).map_err(|r| r.code().to_string())?; let mut o = SetupOptions::new(); o.push(KeyValuePair::bytes(opt::MOQT_IMPLEMENTATION, implementation.as_bytes().to_vec())); if max_request_updates != 0 { @@ -202,6 +208,25 @@ pub fn client_setup_with_grant(record: &[u8], seed: &[u8], challenge: &[u8], tar setup_with_grant_bytes(record, seed, challenge, target, implementation, receipts, max_request_updates).map_err(js) } +/// A proof record (WAI live-rooms §10.5.1) for the grant `record`, by the key of `seed` +/// whatever key that is, over `challenge`, for `target`. For a test that a relay refuses a +/// proof by a key that is not the grant's principal (§10.5.4 step 4): a page proving its +/// own grant uses [`client_setup_with_grant`], which signs with the principal's key alone. +#[wasm_bindgen(js_name = grantProofBy)] +pub fn grant_proof_by(record: &[u8], seed: &[u8], challenge: &[u8], target: &str) -> Result, JsError> { + let seed: [u8; 32] = seed.try_into().map_err(|_| JsError::new("a seed is 32 bytes"))?; + let challenge: [u8; 32] = challenge.try_into().map_err(|_| JsError::new("a challenge is 32 bytes"))?; + let (digest, _) = grant_proof::grant_digest_and_principal(record).ok_or_else(|| JsError::new("not a WRG1 grant record"))?; + let p = GrantProof::sign(digest, &SigningKey::from_bytes(&seed), challenge, &format!("{} {target}", grant_proof::VERB_SETUP)).ok_or_else(|| JsError::new("a target a proof cannot carry"))?; + Ok(p.to_bytes()) +} + +/// [`setup_presenting`] for a page. +#[wasm_bindgen(js_name = clientSetupWithProof)] +pub fn client_setup_with_proof(record: &[u8], proof: &[u8], implementation: &str, receipts: bool, max_request_updates: u64) -> Result, JsError> { + setup_presenting(record, proof, implementation, receipts, max_request_updates).map_err(js) +} + /// The Setup Option type of the relay's challenge (WAI live-rooms §10.5.2). #[wasm_bindgen(js_name = grantChallengeOption)] pub fn grant_challenge_option() -> u64 { diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/moq/session.js b/joule-code/joule-code-rs/crates/joule-moq-web/web/moq/session.js index 8a6d5ace6..01a86a50a 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/web/moq/session.js +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/moq/session.js @@ -99,7 +99,12 @@ export class MoqSession { if (!opt) throw new Error("the relay's SETUP carries no challenge for a grant's proof"); const u = new URL(this.t.url); this.target = `//${u.host}${u.pathname}`; - await this.control.write(this.moq.clientSetupWithGrant(this.grant.record, this.grant.seed, hexToBytes(opt.bytes), this.target, this.implementation, true, 0n)); + const challenge = hexToBytes(opt.bytes); + // `proofBy`: a test's proof by another key than the principal's, which the relay refuses. + const setup = this.grant.proofBy + ? this.moq.clientSetupWithProof(this.grant.record, this.moq.grantProofBy(this.grant.record, this.grant.proofBy, challenge, this.target), this.implementation, true, 0n) + : this.moq.clientSetupWithGrant(this.grant.record, this.grant.seed, challenge, this.target, this.implementation, true, 0n); + await this.control.write(setup); this.log(`session: SETUP exchanged over ${this.t.kind}, the grant presented with its proof for ${this.target}`); } diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js index 5e3b66e3e..483ef9fa4 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js @@ -956,7 +956,7 @@ window.roomTest.tryWrongKey = async () => { const t = await connect({ ...transportOpts, log: () => {} }); let setup = "exchanged"; try { - await MoqSession.connect(t, moq, { grant: { record: unhex(me.grant), seed: crypto.getRandomValues(new Uint8Array(32)) }, implementation: "joule-moq-web/room-wrong-key", log: () => {} }); + await MoqSession.connect(t, moq, { grant: { record: unhex(me.grant), seed: unhex(me.seed), proofBy: crypto.getRandomValues(new Uint8Array(32)) }, implementation: "joule-moq-web/room-wrong-key", log: () => {} }); } catch (e) { setup = String(e.message || e); } -- 2.54.0 (Apple Git-157) From bbbb6b78291baac9bcee479f3b6d95528e07cfe5 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 06:11:07 -0400 Subject: [PATCH 5/9] joule-moq-room CI; READMEs, the example configuration and the changelog for L5c-2 A workflow of its own runs joule-moq-room's tests (its own workspace), on a change to it, the relay, joule-moq or wai-rs; joule-moq-web's browser suite runs on a change to the room service too, and gains the service scenario. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/joule-moq-room.yml | 75 +++++++++++++++++++ .github/workflows/joule-moq-web.yml | 8 +- joule-code/CHANGELOG.md | 21 ++++++ .../crates/joule-moq-relay/README.md | 35 ++++++++- .../crates/joule-moq-relay/relay.example.toml | 21 +++++- .../crates/joule-moq-web/README.md | 17 ++++- 6 files changed, 167 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/joule-moq-room.yml diff --git a/.github/workflows/joule-moq-room.yml b/.github/workflows/joule-moq-room.yml new file mode 100644 index 000000000..d97393977 --- /dev/null +++ b/.github/workflows/joule-moq-room.yml @@ -0,0 +1,75 @@ +name: joule-moq-room room service + +# The room service (joule-code/joule-code-rs/crates/joule-moq-room), a workspace of its own +# with its own lock file: its keys file, the grants it signs checked against its +# descriptor and read by the relay's reader, the places and the limiter, and +# tests/room_service.rs, the service against a relay on loopback that trusts its authority: +# a grant and its proof open a session and the clock announces the publisher, the grant +# alone opens none, the room verifies (live-rooms §9), a publisher past the room's places +# is refused, clients are bounded, and a new room starts when the last publisher has left. +# +# It builds on wai-rs's room, event and composition records and on the relay, so a change +# to either runs it. Push filters match the push's delta, so every path the job reads is +# listed. + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - 'joule-code/joule-code-rs/crates/joule-moq-room/**' + - 'joule-code/joule-code-rs/crates/joule-moq/**' + - 'joule-code/joule-code-rs/crates/joule-moq-relay/**' + - 'joule-code/joule-code-rs/crates/jwp/**' + - 'wai/wai-rs/**' + - 'wai/wai-ops/**' + - '.github/workflows/joule-moq-room.yml' + +jobs: + # Forgejo matches `paths:` against the push delta, so a rebase fires this workflow + # whatever the pull request changes. This job reads the pull request's own diff against + # the paths above (scripts/pr-touches-paths.py, which fails open). + relevant: + name: joule-moq-room · does this change touch it + runs-on: linux + outputs: + run: ${{ steps.gate.outputs.run }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - id: gate + run: | + set -euo pipefail + r=$(python3 scripts/pr-touches-paths.py .github/workflows/joule-moq-room.yml \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}") + echo "this change touches the room service: $r" + echo "run=$r" >> "$GITHUB_OUTPUT" + + test: + name: joule-moq-room, every target + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: linux-arm64 + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust 1.98.0 + shell: bash + run: | + export PATH="$HOME/.cargo/bin:$PATH" + command -v rustup >/dev/null 2>&1 || \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0 + rustup toolchain install 1.98.0 --profile minimal + rustup default 1.98.0 + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: joule-moq-room, every target + working-directory: joule-code/joule-code-rs/crates/joule-moq-room + shell: bash + run: cargo test --locked --all-targets diff --git a/.github/workflows/joule-moq-web.yml b/.github/workflows/joule-moq-web.yml index 9e8380afe..85f079ccb 100644 --- a/.github/workflows/joule-moq-web.yml +++ b/.github/workflows/joule-moq-web.yml @@ -15,7 +15,11 @@ name: joule-moq-web browser client # label, and a late joiner served earlier groups by FETCH; # websocket the same over the WebSocket binding, forced; # goaway the relay shut down with GOAWAY naming a second: every page moves and -# closes its old session before the timeout. +# closes its old session before the timeout; +# service a room service (joule-moq-room) keeps the room, as in production: pages +# make their own keys and enter from their buttons, the service grants and +# its clock authors the lifecycle, the relay (WebSocket only, behind a +# same-origin proxy) honours the grants by the service's authority key. # # The macos runner is the host with the browser installed; the tests drive that browser # (its executable named by BROWSER_BIN), not a bundled one. The automation package comes @@ -35,6 +39,7 @@ on: - 'joule-code/joule-code-rs/crates/joule-moq-web/**' - 'joule-code/joule-code-rs/crates/joule-moq/**' - 'joule-code/joule-code-rs/crates/joule-moq-relay/**' + - 'joule-code/joule-code-rs/crates/joule-moq-room/**' - 'joule-code/joule-code-rs/crates/jwp/**' - 'joule-code/joule-code-rs/Cargo.toml' - 'joule-code/joule-code-rs/Cargo.lock' @@ -48,6 +53,7 @@ on: - 'joule-code/joule-code-rs/crates/joule-moq-web/**' - 'joule-code/joule-code-rs/crates/joule-moq/**' - 'joule-code/joule-code-rs/crates/joule-moq-relay/**' + - 'joule-code/joule-code-rs/crates/joule-moq-room/**' - 'joule-code/joule-code-rs/crates/jwp/**' - 'joule-code/joule-code-rs/Cargo.toml' - 'joule-code/joule-code-rs/Cargo.lock' diff --git a/joule-code/CHANGELOG.md b/joule-code/CHANGELOG.md index 1d648c2af..4f74456f2 100644 --- a/joule-code/CHANGELOG.md +++ b/joule-code/CHANGELOG.md @@ -18,6 +18,27 @@ All notable changes to the Joule Code standard are recorded here. The format fol - **§6 / `spec/receipt.md` §2 — a vendor counter is a hardware reading only while it is demonstrably live.** A reading from a vendor energy counter (RAPL, NVML, Apple SMC/IOReport) MAY be tagged `HwShunt`, and supply `measured_uj`, only while the counter has been observed advancing under load for the domain the figure claims (for IOReport: the CPU energy rail itself, in at least 5 sample intervals, at least 4 of every 5, over at least 300 ms of CPU burn). A counter that is present but not live — frozen, or republished in lumps — MUST NOT produce either; the reading falls back to the estimator. A figure MUST NOT claim coverage of a domain its counter does not read. No wire or conformance-vector change. +### Added — the public room (L5c-2): `joule-moq-room`, room authorities at the relay + +- **`joule-moq-room`**, a room service (a workspace of its own, on `wai-rs`'s room, event + and composition records): it holds a room's authority and clock keys (a 0600 file it + refuses to read otherwise), signs short-lived `WRG1` grants for keys pages make + (`POST /grant`, a publisher's or a viewer's, bounded per client address, overall and per + room), authors `join`, `publish` and `leave` on the clock's events track from what the + relay carries (its clock watches each publisher's video track), and serves the signed + descriptor and grants (`GET /room`). A new room starts when the last publisher has left. +- **`joule-moq-relay`**: `[[auth.authorities]]` honours every grant an authority's key + signs, beside its proof, within its window, mapped as live-rooms §10.2 maps it + (`joule-moq`'s `GrantHead` reads it); a field `*` in a rule's prefix; a door rule's + `trust_field`; `limits.max_session_ms`, `max_ingress_kbps` and `max_resident_mb`; a rule's + `unlimited`; a session ends when its authority grant expires; SIGTERM stops it as Ctrl-C + does. +- **`joule-moq-web`**: the room page's service mode (its own key, a grant from the service, + the WebSocket binding, Join, Watch, Leave and a typed caption), `Room.setGrants` in + `wai-live-web`, `grantProofBy` and `clientSetupWithProof` for a test's proof by another + key, and the `service` end-to-end scenario, which also runs against a deployment + (`--base`). + ### Changed — `joule-moq`, `joule-moq-relay` and `joule-moq-web`: L5 hardening - **`ReceiptChains`: a join's fill from group 0.** A group fetched into the gap below a diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/README.md b/joule-code/joule-code-rs/crates/joule-moq-relay/README.md index 017214416..53bff8fff 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/README.md @@ -290,6 +290,32 @@ another session is refused as `pop_wrong_challenge`. A rule without `proof` is a token, for a key the relay's operator gives a party directly (a room's clock, an ingest gateway's link). +**A room's authority.** A room whose grants are issued as visitors arrive (a room service +such as `joule-moq-room`) cannot be listed rule by rule. `[[auth.authorities]]` names the +authority's key instead (and, optionally, the room ids it is honoured for): a token of type +0 that is a `WRG1` record whose signature verifies strictly under that key (live-rooms §4.2; +`joule-moq`'s `GrantHead`) is a room grant, honoured only beside a proof as above and only +within its window (`nbf` to `exp`), and mapped onto rights as live-rooms §10.2 maps it: +PUBLISH under `[R, P, m]` for each media kind `m` its `publish` bits allow, of `[R, P]` (its +catalog) when it may publish any, and under `[R, P, events]` when its policy permits an +event kind; SUBSCRIBE, FETCH and the rest under `[R]` when it may subscribe. The relay's +rights are per namespace, so §10.2's track matches (layers up to `max_layer`) are not +narrowed. A session ends, `EXPIRED_AUTH_TOKEN`, when the earliest such grant it proved +expires. + +**Patterns.** A field `*` in a rule's prefix, or a door rule's, matches any one field: a +room's clock token can be given `["*", "", "events"]`, its events track in every +room. A door rule's `trust_field` names the namespace field holding the one key it accepts +there: `prefix = ["*", "*", "video"]` with `trust_field = 1` holds each publisher's video in +a room to its own key (live-rooms §6.1), whoever joins. + +**Limits per session.** `limits.max_session_ms` closes a session at that age (`NO_ERROR`, +the limit as its reason); `limits.max_ingress_kbps` closes one that sends more object bytes +than that rate allows over `ingress_burst_ms` (`INTERNAL_ERROR`). A session whose SETUP +presents a token whose rule says `unlimited = true` is exempt from both: a deployment's own +long-lived sessions, such as a room service's clock. `limits.max_resident_mb` makes the +binary exit with status 70 past that resident memory, for its supervisor to start it again. + ## The session summary When a session ends the relay signs a summary of it with its own Ed25519 key (from @@ -358,8 +384,8 @@ development certificate. `relay.example.toml` shows every field: the listen addr path, the certificate files (or the names for a development certificate), the cache's byte budget, the limits (sessions, requests and streams in flight, MAX_REQUEST_UPDATES, the token cache, the rendezvous, upstream and fill timeouts, write concurrency, the QUIC send window, -the GOAWAY timeout), the receipts suffix and door rules, the summary's key and file, and the -grants. The configuration holds no secret: keys are files, and tokens are named by their +the GOAWAY timeout, the session, ingress and memory limits), the receipts suffix and door +rules, the summary's key and file, the grants and the room authorities. The configuration holds no secret: keys are files, and tokens are named by their SHA-256. `JOULE_MOQ_RELAY_LOG` sets the log level. ## Not yet implemented @@ -390,8 +416,9 @@ SHA-256. `JOULE_MOQ_RELAY_LOG` sets the log level. datagrams at the door are checked against their group's receipts, not a property. - **Energy:** no meter backs the relay, so its own figure is always the unmetered marker. Attributing a metered host's energy to sessions is not done here. -- **A public page.** The browser client is `joule-moq-web`; its end-to-end tests run it - against this relay on loopback. No public page or public endpoint uses it yet. +- **Re-validation** (`moqt-reval`, live-rooms §10.2): a grant is checked at SETUP and on + each request, and its expiry ends the session; revocation through the key log (§5) is not + read. ## Tests diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/relay.example.toml b/joule-code/joule-code-rs/crates/joule-moq-relay/relay.example.toml index c828f4aae..391ef36b3 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/relay.example.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/relay.example.toml @@ -33,6 +33,14 @@ write_concurrency = 8 max_object_bytes = 16777216 unknown_alias_wait_ms = 1000 goaway_timeout_ms = 5000 +# 0 for each of these: no limit. A session at max_session_ms is closed; one sending past +# max_ingress_kbps (averaged over ingress_burst_ms) is closed; the binary exits with status +# 70 past max_resident_mb. A rule with `unlimited = true` exempts its sessions from the first +# two. +max_session_ms = 0 +max_ingress_kbps = 0 +ingress_burst_ms = 4000 +max_resident_mb = 0 websocket_buffer_bytes = 67108864 idle_timeout_ms = 30000 quic_send_window_bytes = 1048576 @@ -41,7 +49,9 @@ quic_send_window_bytes = 1048576 receipts_track_suffix = "_receipts" # Check objects in this namespace against their receipts at the door (example; not a -# default). An empty trust list accepts any key whose signature verifies. +# default). An empty trust list accepts any key whose signature verifies. A field "*" +# matches any field; `trust_field = 1` with `prefix = ["*", "*", "video"]` would accept, in +# each room, the key its namespace's second field names (WAI live-rooms §6.1). [[receipts.door]] prefix = ["example", "live"] trust = [] @@ -72,3 +82,12 @@ subscribe = true # true: the token is a room grant's record (WAI live-rooms §4.1), honoured only on a # session whose SETUP proved possession of its principal's key (§10.5.2). proof = false +# true: a session presenting this token is exempt from max_session_ms and max_ingress_kbps. +unlimited = false + +# A room authority (an example; not a default): every WRG1 grant its key signs is honoured, +# beside its proof and within its window, with the rights live-rooms §10.2 gives it. `rooms` +# narrows it to these room ids; empty, every room it grants. +# [[auth.authorities]] +# key = "" +# rooms = [] diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/README.md b/joule-code/joule-code-rs/crates/joule-moq-web/README.md index 4eeff0885..dc2210251 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-web/README.md @@ -7,7 +7,10 @@ other participants of a room, verifies every object against its receipt, shows t room's events and captions, and composes the participants with WAI's integer layout and mixer (`wai/wai-live-web`, the other wasm build the page loads). -Nothing here is deployed. The pages run against a relay on loopback. +The room page runs two ways: against a test room, every key from a fixture, on loopback; +or against a room service (`joule-moq-room`), as the public room at +`wai.transaction.science/room/` does, where the page makes its own key and the service's +clock authors the room's lifecycle. ## The two wasm builds @@ -33,6 +36,7 @@ does not have, and one implementation keeps the page's verdicts the relay's. | Step | What it does | |---|---| +| Enter (room service) | `?service=` or `data-service` on `#room-app`: the page fetches the room (`GET /room`), turns the camera and microphone on for a publisher, makes an Ed25519 key with the browser's random generator, and asks for a grant for it (`POST /grant`, a publisher's or a viewer's; a full room offers watching). It connects over the WebSocket binding at `/moq` on its own origin (`ws` names another), WebTransport not tried, and presents the grant with a proof by its key. The lifecycle is the service's clock's: a join under a grant the page does not hold has it read the room's grants again (`Room.setGrants`). A viewer publishes nothing. Leave closes the session; the relay ends the tracks and the clock records it. | | Connect | WebTransport to the relay's URL with `protocols: ["moqt-22"]` (`WT-Available-Protocols`) and, for a development certificate, `serverCertificateHashes`; when the browser has no WebTransport or the session does not open, the WebSocket binding (subprotocol `moqt-22`). `transport=websocket` forces the binding. | | Session | SETUP with the receipt extension and the participant's room grant: the page waits for the relay's SETUP, which carries the session's challenge, then sends its own with the grant as its authorization token and a proof of the principal's key over that challenge and the URL's `//authority/path`, signed in wasm (WAI live-rooms §10.5.2); requests on bidirectional streams with even Request IDs; every data stream read by a task of its own as it arrives (the relay's README, "A subscriber that does not read"); GOAWAY: no new request, a new session to the URI it names, the tracks published and the subscriptions made again there, the old session closed by the page before the timeout. | | Publish | `getUserMedia` (the profile's processing as its constraints) into `VideoEncoder` and `AudioEncoder`, the codec the first of the room profile's `codecs` the encoder supports. Each encoded frame is one group of one object (moq-streaming-format §4.1): a `WAI1` envelope whose payload is in SPEC §5's framing for its capability (VP9 in IVF, AV1 in its five-field framing, Opus as an Ogg Opus stream). Its receipt is signed into a group receipt chained to the group before; the receipts-track object, in WAI's binary form (jwp-receipts §2.7.3), goes first, then the media object with its `WAI_RECEIPT` property. A catalog (MSF) names each track's codec, `wai_receipt_alias` and `wai_receipts_track`. | @@ -76,15 +80,20 @@ closed. Delivery timeouts still apply at the relay, before a write. | `webtransport` | two participants publish and receive each other: frames decode, every received object verifies against its receipt and the relay's door refuses none, the canvas's tiles equal the host's layout for the same descriptor and set and show video, the mix reaches the AudioWorklet; a machine caption and a human caption show with their labels; a late joiner gets the earlier groups by FETCH, each verified, its chains admitting them, group 0 among them, as history, and every page re-lays out for three; the receipts objects ride in the binary form, their sizes reported beside the JSON form's; a grant presented without its proof is refused the session | | `websocket` | the same over the binding, forced | | `goaway` | the relay is stopped with GOAWAY naming a second relay; every page moves, media is verified again there, and the first relay closes no session at its timeout | + | `service` | a room service keeps the room, as in production: the relay (WebSocket only, its door holding each publisher to its own key, trusting the service's authority) and the service behind a same-origin proxy; pages enter from their buttons with keys of their own. Two publish and see each other, every object verifies, the layout equals the host's, a machine caption and a typed human caption show their labels, the clock (not a page) authors the lifecycle, a late joiner gets earlier groups by FETCH, a viewer watches, a grant with no proof or with a proof by another key is refused the session, a publisher past the room's places is told it is full, and a leave is recorded | + + With `--base ` the `service` scenario runs against a deployment instead: nothing is + started, and the page at `/room/` is driven the same way (its production test). Every wait is bounded; each browser runs under the host's `memguard` where it exists. ## Not yet implemented -- A public room page and its production tests; a public relay endpoint. +- WebTransport for the public room: its relay is reached over the WebSocket binding + through an HTTP front, and offers no UDP endpoint. - Simulcast and staged layers; publishing a screen; datagrams. -- Live room lifecycle authored by the relay for the authority (here a page holding the - clock's key authors it, driven by the test). +- In the fixture's room, a page holding the clock's key authors the lifecycle, driven by + the test; with a room service, the service's clock does. ## Licence -- 2.54.0 (Apple Git-157) From 779597de0295a6cd72c8f77fd432ba0a11274830 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 06:28:06 -0400 Subject: [PATCH 6/9] joule-moq-web e2e: the service scenario compares the layout once every tile shows video; killStrays's pattern without its dashes Against the deployment, a late joiner's first frame reached the canvas after its tile was laid out, and the comparison ran in between. pkill on macOS read the pattern's leading -- as an option, so strays were never ended. Co-Authored-By: Claude Opus 5.5 --- joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs | 3 ++- .../joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs index 668464f2b..73635fe77 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs @@ -158,7 +158,8 @@ export function memguardedBrowser(bin, dir) { } export function killStrays() { - try { execFileSync("pkill", ["-f", `--moq-web-run=${RUN_ID}`]); } catch { /* none left */ } + // The pattern without its leading dashes: pkill would read them as an option. + try { execFileSync("pkill", ["-f", `moq-web-run=${RUN_ID}`]); } catch { /* none left */ } } export function tmpdir(label) { diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs index fbf433f9d..9ed89e2bd 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs @@ -430,7 +430,9 @@ async function scenarioService(run) { const sa = await alice.snap(); check(`${label}: the mix reaches the AudioWorklet`, sa.mixed.voiced_blocks > 0 && sa.worklet.played > 0, `${sa.mixed.voiced_blocks} voiced blocks, ${sa.worklet.played} frames played`); const present = (s) => (s.roomState && s.roomState.present ? s.roomState.present.length : 0); - await until(`${label} alice lays out the room`, STEP, async () => { const s = await alice.snap(); return s.frame && s.frame.tiles.length === present(s) && present(s) >= 2; }, 500); + // Laid out, and every tile showing video: a tile's first frame follows its layout. + const shown = (s) => s.frame && s.frame.tiles.length === present(s) && Object.keys(s.drawn || {}).length === s.frame.tiles.length && s.samples.every((x) => x.principal === "" || JSON.stringify(x.rgb) !== "[16,18,24]"); + await until(`${label} alice lays out the room`, STEP, async () => { const s = await alice.snap(); return present(s) >= 2 && shown(s); }, 500); const sl = await alice.snap(); await compareLayout(`${label} alice`, alice, null, dir, present(sl)); const tl = (await bob.snap()).timeline; @@ -465,7 +467,7 @@ async function scenarioService(run) { check(`${label}: a late joiner gets earlier groups by FETCH, each verified`, t.first_fetched_group === 0 && t.fetched_verified === t.fetched && t.fetched >= 15, `${t.fetched} fetched (groups ${t.first_fetched_group}..${t.last_fetched_group}), ${t.fetched_verified} verified, ${t.fetched_decoded} decoded`); } - await until(`${label} alice re-lays out for the late joiner`, STEP, async () => { const s = await alice.snap(); return s.frame && s.frame.tiles.length === present(s) && s.frame.tiles.some((x) => x.principal === C.principal); }, 500); + await until(`${label} alice re-lays out for the late joiner`, STEP, async () => { const s = await alice.snap(); return s.frame && s.frame.tiles.some((x) => x.principal === C.principal) && shown(s); }, 500); const s3 = await alice.snap(); await compareLayout(`${label} alice after the late join`, alice, null, dir, present(s3)); -- 2.54.0 (Apple Git-157) From 9c78299d5e0dbd8547f9d92bf86d5999aa9b039b Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 06:30:28 -0400 Subject: [PATCH 7/9] joule-moq-web e2e: --between, a pause between runs, for a deployment's grant rate per address Co-Authored-By: Claude Opus 5.5 --- .../joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs index 9ed89e2bd..7e75a27f1 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs @@ -4,7 +4,8 @@ // // node e2e/room.e2e.mjs --relay --wai --web // --corpus [--browser ] -// [--only compose,webtransport,websocket,goaway] [--runs N] [--out ] +// [--only compose,webtransport,websocket,goaway,service] [--runs N] [--between ] +// [--out ] [--room ] [--base ] // // Scenarios: // compose live-conformance/compose run in the page: every layout, mix, descriptor @@ -532,7 +533,12 @@ async function scenarioService(run) { // ------------------------------------------------------------------------------------- main let passedRuns = 0; +// `--between `: a pause before each run after the first. Against a deployment, the room +// service gives one client address a few grants at once and then one every so often, and +// each run takes four (three publishers and a viewer). +const BETWEEN = Number(args.between || 0); for (let run = 1; run <= RUNS; run++) { + if (run > 1 && BETWEEN) await sleep(BETWEEN * 1000); const before = failed; const t = Date.now(); console.log(`== run ${run} of ${RUNS}`); -- 2.54.0 (Apple Git-157) From dfb24a1a2b631a06ef9d26eb21ddeb3c304c3ccf Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 07:32:44 -0400 Subject: [PATCH 8/9] joule-moq-relay: a mux self-deadlock fixed; a FETCH waits for groups still arriving - transport::mux: a stream that arrived after the session closed (its peer not yet told) found nobody accepting, and its receiving half was dropped after the state was locked again: its Drop takes that lock, so the worker deadlocked, and every other worker after it. Any session the relay closes while its peer still opens streams (the session and ingress limits, a refused proof) could hang the relay. The half is now dropped before the lock is taken again; the regression test hangs without the fix and passes with it. - A FETCH the cache does not yet cover, of a track whose publisher is still sending, waits (within its fill budget, at most a second) for the groups still arriving before the relay asks upstream: a page's audio is a group per 20 ms, each on its own stream, and a late joiner's fetch from group 0 through an HTTP front met one in flight and was passed to the publisher, which takes no requests (seen on the deployed room). - room_authority: the ingress limit read from the relay's record when the client loses the race to see the close. joule-moq-web: a late joiner's refused FETCH is tried again (a refusal comes before any object). Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-relay/src/session.rs | 26 ++++++-- .../joule-moq-relay/src/transport/mux.rs | 55 ++++++++++++---- .../joule-moq-relay/tests/fetch_in_flight.rs | 64 +++++++++++++++++++ .../joule-moq-relay/tests/room_authority.rs | 14 +++- .../crates/joule-moq-web/web/room/app.js | 19 +++++- 5 files changed, 155 insertions(+), 23 deletions(-) create mode 100644 joule-code/joule-code-rs/crates/joule-moq-relay/tests/fetch_in_flight.rs diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs index 79d6ffa41..4432a2352 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs @@ -1187,20 +1187,34 @@ async fn fetch(relay: Relay, ctx: Arc, r: TrackRequest, tx: ReqTx, m Upstream { start: Location, end: Option }, Refuse(RequestErrorCode, &'static str), } - let plan = { + let plan_now = || { let st = relay.inner.state.lock().unwrap(); match st.tracks.get(&key) { - None => Plan::Upstream { start: Location::new(0, 0), end: None }, + None => (Plan::Upstream { start: Location::new(0, 0), end: None }, false), Some(t) => match t.largest { - None => Plan::Refuse(RequestErrorCode::INVALID_RANGE, "no object has been published on the track"), + None => (Plan::Refuse(RequestErrorCode::INVALID_RANGE, "no object has been published on the track"), false), Some(largest) => match fetch_range(r.params.location_filter(), largest) { - None => Plan::Refuse(RequestErrorCode::INVALID_RANGE, "the range starts after the Largest Object"), - Some((start, end)) if t.cache.covers(start) => Plan::Cache { start, end, eot: t.cache.end_of_track == Some(end), props: t.props.clone() }, - Some((start, end)) => Plan::Upstream { start, end: Some(end) }, + None => (Plan::Refuse(RequestErrorCode::INVALID_RANGE, "the range starts after the Largest Object"), false), + Some((start, end)) if t.cache.covers(start) => (Plan::Cache { start, end, eot: t.cache.end_of_track == Some(end), props: t.props.clone() }, false), + // Not covered while the track's publisher is still sending and nothing + // from `start` was evicted: the groups it lacks may be ones still + // arriving (each group its own stream, finishing out of order). + Some((start, end)) => (Plan::Upstream { start, end: Some(end) }, !t.upstreams.is_empty() && t.cache.evicted_through.is_none_or(|e| start.group > e)), }, }, } }; + // A fetch the cache does not yet cover, of a track still arriving, waits a little for + // its groups to finish (within the fetch's fill budget, at most a second) before the + // relay asks upstream: a publisher by PUBLISH need not answer a FETCH at all. + let wait_until = Instant::now() + Duration::from_millis(budget_ms.min(1_000)); + let plan = loop { + let (p, may_complete) = plan_now(); + if !(may_complete && Instant::now() < wait_until) { + break p; + } + tokio::time::sleep(Duration::from_millis(20)).await; + }; match plan { Plan::Refuse(code, why) => reject(&tx, code, why).await, Plan::Cache { start, end, eot, props } => { diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/transport/mux.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/transport/mux.rs index 4f201c2d3..7176750ae 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/transport/mux.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/transport/mux.rs @@ -321,19 +321,24 @@ impl Inner { }); let (accept_bi, accept_uni) = (st.accept_bi.clone(), st.accept_uni.clone()); drop(st); - // Built and handed over with the state unlocked: a stream half dropped here - // (nobody accepting) takes the lock in its own Drop. - let credit = Arc::new(Credit { inner: Arc::downgrade(self), bidi }); - let recv = MuxRecv { id, rx, inner: self.clone(), done: false, _credit: Some(credit.clone()) }; - match (shared, accept_bi, accept_uni) { - (Some(shared), Some(a), _) => { - let send = MuxSend { id, inner: self.clone(), shared, ended: false, _credit: Some(credit) }; - let _ = a.send((Box::new(send), Box::new(recv))); + // Built and handed over with the state unlocked, in a block of its own: a stream + // half dropped here (nobody accepting, as once the session has closed) takes the + // lock in its own Drop, so it must be gone before the lock is taken again below. + // Left to the end of the enclosing block, it deadlocked the runtime (seen + // 2026-10-19: a session the relay closed while its peer still opened streams). + { + let credit = Arc::new(Credit { inner: Arc::downgrade(self), bidi }); + let recv = MuxRecv { id, rx, inner: self.clone(), done: false, _credit: Some(credit.clone()) }; + match (shared, accept_bi, accept_uni) { + (Some(shared), Some(a), _) => { + let send = MuxSend { id, inner: self.clone(), shared, ended: false, _credit: Some(credit) }; + let _ = a.send((Box::new(send), Box::new(recv))); + } + (None, _, Some(a)) => { + let _ = a.send(Box::new(recv)); + } + (shared, _, _) => drop((shared, recv, credit)), } - (None, _, Some(a)) => { - let _ = a.send(Box::new(recv)); - } - _ => {} } st = self.state.lock().unwrap(); } @@ -811,6 +816,32 @@ mod tests { } } + // A stream the peer opens after this end closed the session (it has not yet heard of + // the close) finds nobody accepting: its half is dropped, and the session's state must + // not be held while it is, since its Drop takes the same lock. It was, and the runtime + // deadlocked: every worker waited on the lock (seen 2026-10-19, in the relay's ingress + // limit test). A regression hangs this test (the runtime's workers block for good) + // rather than failing it; CI's job timeout catches that. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_stream_that_arrives_after_the_close_leaves_the_state_unlocked() { + let (c, s, _) = memory_pair(MuxConfig::default(), false); + s.inner.set_closed(TransportError::Closed { code: 0, reason: "closed here".into(), by_peer: false }); + for i in 0..4u8 { + let mut w = c.open_uni().await.unwrap(); + w.write_all(&[i; 64]).await.unwrap(); + let _ = w.finish(); + } + tokio::time::sleep(Duration::from_millis(300)).await; + // A thread outside the runtime takes the server's lock at once. + let inner = s.inner.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _g = inner.state.lock().unwrap(); + let _ = tx.send(()); + }); + assert!(rx.recv_timeout(Duration::from_secs(5)).is_ok(), "the session's state is held: a self-deadlock"); + } + #[tokio::test] async fn the_stream_limit_holds_until_credit_returns() { let cfg = MuxConfig { max_bidi: 1, ..MuxConfig::default() }; diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/fetch_in_flight.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/fetch_in_flight.rs new file mode 100644 index 000000000..f6324fe93 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/fetch_in_flight.rs @@ -0,0 +1,64 @@ +//! A FETCH that arrives while an earlier group's stream is still arriving: the cache does +//! not yet cover the range (the group is not whole until its FIN), and the publisher, which +//! published by PUBLISH, takes no FETCH. The relay waits a moment for the group to finish +//! (within the fetch's fill budget) and serves the whole range from its cache, rather than +//! asking the publisher. +//! +//! A track of one group per object, each on its own stream, as a page's audio is (one +//! group every 20 ms), always has a stream or two in flight; through an HTTP front the +//! window is wider, and a late joiner's fetch from group 0 met it (seen on the deployed +//! room, 2026-10-19). + +mod common; + +use common::*; +use joule_moq::draft22::data::{FetchEntry, SubgroupCodec, SubgroupHeader, SubgroupId, SubgroupObject}; +use joule_moq::draft22::Parameters; +use joule_moq_relay::client::ClientOptions; +use std::time::Duration; +use tokio::time::timeout; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_fetch_waits_for_a_group_still_arriving() { + timeout(Duration::from_secs(60), run()).await.expect("the test finishes in time"); +} + +fn header(alias: u64, g: u64) -> SubgroupHeader { + SubgroupHeader { track_alias: alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(64), properties: false, end_of_group: true, first_object: true } +} + +async fn run() { + let server = relay(|_| {}).await; + let t = Tracks::new(ns(&["room", "p", "audio"]), "0", 10, 5, 64); + let (publisher, _ep) = connect(&server, Via::WebSocket, ClientOptions::default()).await; + let _held = publish_tracks(&publisher, &t, None).await; + for g in 0..4 { + publisher.send_subgroup(header(t.media_alias, g), &[SubgroupObject::normal(0, payload("a", g, 200))]).await.unwrap(); + } + // Group 4's stream opens and carries its object, and its FIN is late; group 5 is whole. + let mut b = Vec::new(); + let h4 = header(t.media_alias, 4); + h4.encode(&mut b); + SubgroupCodec::new(h4).encode_object(&SubgroupObject::normal(0, payload("a", 4, 200)), &mut b).unwrap(); + let mut open = publisher.conn.open_uni().await.unwrap(); + open.write_all(&b).await.unwrap(); + publisher.send_subgroup(header(t.media_alias, 5), &[SubgroupObject::normal(0, payload("a", 5, 200))]).await.unwrap(); + tokio::time::sleep(Duration::from_millis(200)).await; + + let (late, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await; + let fin = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(300)).await; + open.finish().unwrap(); + open + }); + let (ok, entries) = late.fetch(t.ns.clone(), t.media.clone(), Parameters::new()).await.expect("the fetch is served"); + let _open = fin.await.unwrap(); + let groups: Vec = entries.iter().filter_map(|e| if let FetchEntry::Object(o) = e { Some(o.group_id) } else { None }).collect(); + assert_eq!(groups, (0..6).collect::>(), "every group, from the cache"); + assert_eq!(ok.end_location.group, 5); + let s = server.relay().stats(); + assert_eq!((s.fetch_served_from_cache, s.fetch_served_upstream), (1, 0), "{s:?}"); + late.close(0, "done"); + publisher.close(0, "done"); + server.shutdown().await; +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/room_authority.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/room_authority.rs index 100bf9400..1b5cc6f7c 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/room_authority.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/room_authority.rs @@ -187,7 +187,17 @@ async fn run() { break; } } - let why = closed_with(&c, SessionError::INTERNAL_ERROR, Duration::from_secs(10), "the ingress limit").await; - assert!(why.contains("ingress limit"), "{why}"); + // The relay closes the session while the client may still be writing; then the client + // sees its pipe break before the close arrives, and the relay's own record says why. + match timeout(Duration::from_secs(10), c.conn.closed()).await.expect("closed in time") { + TransportError::Closed { code, reason, .. } => { + assert_eq!(code, SessionError::INTERNAL_ERROR.0, "{reason}"); + assert!(reason.contains("ingress limit"), "{reason}"); + } + other => { + let closed = until(Duration::from_secs(10), || s.relay().summaries().iter().any(|x| x.summary.close_code == Some(SessionError::INTERNAL_ERROR.0))).await; + assert!(closed, "the relay closed the session past its ingress limit (the client saw {other:?})"); + } + } s.shutdown().await; } diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js index 483ef9fa4..c0c65e031 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js @@ -29,7 +29,7 @@ import initMoq, * as moq from "../pkg/joule_moq_web.js"; import initWai, * as wai from "../pkg/wai_live_web.js"; import { connect } from "../moq/transport.js"; -import { MoqSession, withTimeout } from "../moq/session.js"; +import { MoqSession, RequestRefused, withTimeout } from "../moq/session.js"; import * as media from "../moq/media.js"; import { Compositor } from "./compose.js"; @@ -692,14 +692,27 @@ async function subscribeMedia(sub, tr) { // like any other, and the video decoded from its first key frame. async function lateJoin(sub, st, T, ns, tr) { const params = { group_order: 1, subscriber_priority: SUB_PRIORITY[st.m] + 1, location_filter: { type: "absolute", start: [0, 0] } }; + // A FETCH is refused before any object is sent, so one tried again counts nothing twice: + // a relay asked for groups still arriving may pass the request to the publisher, which + // takes no requests; a moment later its cache holds them. + const fetchAgain = async (name, onObject) => { + for (let i = 0; ; i++) { + try { return await session.fetch(ns, name, params, onObject); } catch (e) { + if (i >= 3 || !(e instanceof RequestRefused)) throw e; + log(`FETCH of ${st.m} ${name} of ${sub.principal.slice(0, 8)}: ${e.message}; again`); + T.fetch_retries = (T.fetch_retries || 0) + 1; + await new Promise((r) => setTimeout(r, 750)); + } + } + }; const rec = []; - const r1 = await session.fetch(ns, tr.wai_receipts_track, params, (j, p) => rec.push([j.group_id, p])); + const r1 = await fetchAgain(tr.wai_receipts_track, (j, p) => rec.push([j.group_id, p])); for (const [g, p] of rec) { const r = JSON.parse(st.verifier.receipts(BigInt(g), p, BigInt(Date.now()))); if (r.ok) { T.receipts++; T.chain[r.chain] = (T.chain[r.chain] || 0) + 1; } else T.receipts_refused++; } const objs = []; - const r2 = await session.fetch(ns, tr.name, params, (j, p) => objs.push([j.group_id, p])); + const r2 = await fetchAgain(tr.name, (j, p) => objs.push([j.group_id, p])); for (const [g, p] of objs) { T.fetched++; if (T.first_fetched_group == null) T.first_fetched_group = g; -- 2.54.0 (Apple Git-157) From aa6e920fdc7e8a10ec32d584bf450fc4a7fda1e5 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 07:38:46 -0400 Subject: [PATCH 9/9] joule-moq-relay README and the changelog: the fetch's wait for groups still arriving, and the mux fix Co-Authored-By: Claude Opus 5.5 --- joule-code/CHANGELOG.md | 6 ++++++ joule-code/joule-code-rs/crates/joule-moq-relay/README.md | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/joule-code/CHANGELOG.md b/joule-code/CHANGELOG.md index 4f74456f2..9bc63fce0 100644 --- a/joule-code/CHANGELOG.md +++ b/joule-code/CHANGELOG.md @@ -33,6 +33,12 @@ All notable changes to the Joule Code standard are recorded here. The format fol `trust_field`; `limits.max_session_ms`, `max_ingress_kbps` and `max_resident_mb`; a rule's `unlimited`; a session ends when its authority grant expires; SIGTERM stops it as Ctrl-C does. +- **`joule-moq-relay` fixes found on the deployed room**: the WebSocket binding's multiplexer + dropped the half of a stream that arrived after its session closed while it held its + state's lock, which that half's Drop takes, and the relay's runtime deadlocked (any session + the relay closed while its peer still opened streams could do it); and a FETCH not yet + covered only because groups are still arriving now waits for them, briefly, rather than + going to a publisher that takes no requests. - **`joule-moq-web`**: the room page's service mode (its own key, a grant from the service, the WebSocket binding, Join, Watch, Leave and a typed caption), `Room.setGrants` in `wai-live-web`, `grantProofBy` and `clientSetupWithProof` for a test's proof by another diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/README.md b/joule-code/joule-code-rs/crates/joule-moq-relay/README.md index 53bff8fff..e2a39eb0a 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/README.md @@ -173,6 +173,13 @@ whole; a group ID never seen is a gap the cache does not answer across. The rela tell a subgroup it has not yet seen from one that does not exist, so a group whose subgroups have all closed counts as whole. +A fetch that is not covered only because groups are still arriving waits for them: while +the track's publisher is still sending and nothing from the fetch's start was evicted, the +relay checks again every 20 ms, for up to the fetch's fill budget or one second, whichever +is less, before it asks upstream. A track of one group per object, each on its own stream +(a page's audio is a group every 20 ms), always has a stream or two in flight, and a +publisher by PUBLISH need not answer a FETCH at all. + ### Priorities and delivery timeouts Each downstream session has a scheduler. Every data stream it will send (a subgroup, a -- 2.54.0 (Apple Git-157)