cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

Merge pull request #128 from FelineStateMachine/1-n-sigls

FelineStateMachine committed 4633e03
Add bounded multi-viewer sessions with exclusive input focus
commit
4633e0357f88d47bfe498d983e098ee0776f1d7a
parent
ca1d29428223b3b90222cfdc49eecd148bcc99c0
parent
a1491719ab74e0b7b4384ada3939210a5dd563e9
author
FelineStateMachine <dami.etoile@gmail.com>
95 files changed, +16813 -527
M.github/workflows/ci.yml5+
MAGENTS.md25+-
MCargo.lock1+
MREADME.md38+-
Mcrates/sigil-host/Cargo.toml4+
Mcrates/sigil-host/src/appliance.rs314++++-
Mcrates/sigil-host/src/audio.rs2+
Mcrates/sigil-host/src/authorization.rs845+++++++++++-
Acrates/sigil-host/src/authorization_admin.rs623++++++++
Mcrates/sigil-host/src/bin/moq_catalog.rs69+-
Mcrates/sigil-host/src/bin/sigil-probe.rs911++++++++++++-
Acrates/sigil-host/src/bin/sigil-relay-spike.rs292++++
Mcrates/sigil-host/src/config.rs69+
Mcrates/sigil-host/src/main.rs240+++-
Mcrates/sigil-host/src/moq_catalog.rs113+-
Mcrates/sigil-host/src/secure_state.rs57+
Mcrates/sigil-host/src/server/adaptive.rs1047++++++++++++++-
Acrates/sigil-host/src/server/focus.rs564++++++++
Acrates/sigil-host/src/server/generation.rs516+++++++
Mcrates/sigil-host/src/server/handlers.rs352+++++-
Mcrates/sigil-host/src/server/mod.rs37+-
Mcrates/sigil-host/src/server/moq.rs798++++++++++-
Mcrates/sigil-host/src/server/session.rs2222+++++++++++++++++++++++++++++-
Mcrates/sigil-host/src/source/mod.rs2+
Mcrates/sigil-protocol/Cargo.toml1+
Acrates/sigil-protocol/src/control_v2.rs949+++++++++++++
Acrates/sigil-protocol/src/input_v2.rs376+++++
Mcrates/sigil-protocol/src/lib.rs66+-
Acrates/sigil-protocol/src/media_auth.rs695+++++++++
Mcrates/sigil-protocol/src/moq_catalog.rs269++++-
Acrates/sigil-protocol/src/subscription.rs440++++++
Mdocs/fresh-bazzite-host.md50+-
Mdocs/hardware-uat/MATRIX.md8+-
Adocs/multi-viewer-relay-design.md102+
Mdocs/portal-platform-support.md16+-
Mdocs/public-alpha-uat.md62+-
Mdocs/sigil-appliance-config-v1.md41+
Mdocs/sigil-appliance-enrollment-reset-v1.md42+-
Mdocs/sigil-appliance-status-v2.md33+-
Mportal/adaptive-feedback.mjs11+-
Mportal/adaptive-feedback.test.mjs5+
Mportal/connection-state.mjs17+
Mportal/control-runtime.mjs20+-
Mportal/control-runtime.test.mjs11+-
Mportal/enrollment-ui.test.mjs5+
Mportal/enrollment.mjs18+-
Mportal/enrollment.test.mjs17+
Aportal/focus-overlay.mjs51+
Aportal/focus-overlay.test.mjs72+
Aportal/focus-runtime.mjs164++
Aportal/focus-runtime.test.mjs130++
Mportal/frame-stats.test.mjs27+-
Mportal/index.html30+
Mportal/main.js208+++-
Mportal/network-diagnostics.mjs46+-
Mportal/network-diagnostics.test.mjs55+-
Aportal/roster-ui.mjs93+
Aportal/roster-ui.test.mjs82+
Mportal/runtime-files.txt5+
Aportal/session-accessibility.test.mjs32+
Mportal/session-controls-layout.test.mjs13+
Aportal/session-diagnostics.mjs79+
Aportal/session-diagnostics.test.mjs65+
Aportal/session-state.mjs120++
Aportal/session-state.test.mjs99+
Mportal/stream-metrics.mjs32+
Mportal/stream-metrics.test.mjs44+
Mportal/style.css84+
Mscripts/loopback-proof.sh523+++++++-
Mscripts/public-alpha-uat.sh65+-
Ascripts/relay-spike-proof.sh80+
Mscripts/run-bazzite-hardware-uat.sh64+-
Mscripts/run-shell-tests.sh7+-
Mscripts/tests/authenticated-probe-proof.sh93+-
Ascripts/tests/bazzite-hardware-uat-multi-viewer.sh34+
Mscripts/tests/public-alpha-uat.sh23+-
Mscripts/tests/sigil-appliance-status.sh4+
Mscripts/verify-demo-build.sh7+
Msrc-tauri/src/commands/enrollment.rs37+-
Msrc-tauri/src/commands/network.rs112+-
Msrc-tauri/src/commands/state.rs20+-
Msrc-tauri/src/main.rs6+
Msrc-tauri/src/media/adaptive_feedback.rs53+
Msrc-tauri/src/media/audio_delivery.rs179++
Msrc-tauri/src/media/connect.rs339+++-
Msrc-tauri/src/media/input_delivery.rs317++++-
Msrc-tauri/src/media/media_control.rs31+-
Msrc-tauri/src/media/metrics.rs6+-
Msrc-tauri/src/media/mod.rs1+
Msrc-tauri/src/media/moq_catalog.rs180++-
Msrc-tauri/src/media/moq_receiver.rs444++++++-
Msrc-tauri/src/media/network_diagnostics.rs373+++++-
Asrc-tauri/src/media/session_control.rs242+++
Msrc-tauri/src/media/transport.rs159++-
Msrc-tauri/src/media/video_delivery.rs10+-
M .github/workflows/ci.yml +5 -0
M AGENTS.md +19 -6
@@ -16,7 +16,14 @@ Sigil host.
1616 identity, configuration, or Linux input-injection assets.1717 - Iroh owns native connectivity, endpoint identity, encryption, direct-path1818 discovery, and relay fallback.19-- Exactly one media session and one matching client are active at a time.19+- Exactly one media generation is active at a time. Native MoQ control v220+ shares that single generation with up to `max_viewers` authenticated clients21+ (default 3, hard ceiling 8); control v1 and grouped v3 stay single-client22+ exclusive and reject a second client. No capture, encoder, publisher, or23+ actuator scales with viewer count.24+- At most one viewer holds slot-0 input focus. Every other viewer is a25+ spectator, and a former holder is fully neutralized before a successor is26+ granted.2027 - Controller is the primary interaction model. Keyboard and relative mouse are2128 secondary inputs.2229
@@ -58,12 +65,18 @@ Sigil host.
5865 Linux binary was explicitly built with that feature and its runtime5966 GStreamer elements are available. The opt-in backend currently requires CBR;6067 CQP remains on the external compatibility backend.61-- The preferred media path authenticates and claims the single client over62- `sigil/control/1`, then admits that exact peer to a session-scoped upstream63- `iroh-moq` broadcast and static H.264 track. One configured GOP maps to one68+- The preferred media path authenticates each viewer over `sigil/control/2`,69+ then admits that exact peer to one host-owned upstream `iroh-moq` broadcast70+ and static H.264 track shared by every viewer. One configured GOP maps to one6471 bounded native MoQ group; a newer independently decodable group cancels its65- predecessor and provides the latest-frame barrier. The custom media v3, v2,66- and v1 protocols remain explicit compatibility fallbacks.72+ predecessor and provides the latest-frame barrier. `sigil/control/1` and the73+ custom media v3, v2, and v1 protocols remain explicit single-client74+ compatibility fallbacks.75+- Media objects on the shared generation are Ed25519-authenticated against a76+ host-signed generation certificate, and each viewer's MoQ attachment carries77+ an endpoint-bound, expiring subscription capability. The host advertises the78+ authorization revision it minted that capability against in the same accepted79+ control-v2 hello, so a client never has to assume an enrollment revision.6780 - Portal crosses Rust-to-webview video and audio through bounded binary Tauri6881 channels. It reports transport, frontend, decoder, presentation, and audio6982 queue/drop timing separately.
M Cargo.lock +1 -0
M README.md +24 -14
@@ -10,8 +10,10 @@ sitting at the machine, not the latency of a screen-sharing tool.
1010 goq is not a multi-tenant streaming service and not a desktop-sharing tool.1111 It streams a private [Gamescope](https://github.com/ValveSoftware/gamescope)1212 session that exists only for your games: the host needs no monitor, no13-desktop environment, and no interactive login. Exactly one client is admitted14-to exactly one session at a time, authenticated by a hardware security key.13+desktop environment, and no interactive login. Native MoQ admits three viewers14+by default, up to a hard ceiling of eight. Every viewer may watch according to15+its grants, while exactly one viewer at a time may hold slot-0 input focus.16+Legacy control/media clients remain strictly exclusive.1517 1618 ## The two programs1719
@@ -40,13 +42,14 @@ Sigil · bare-metal Linux host Portal · installed desktop client
4042 buffered, so a slow receiver never accumulates a playable backlog. Media4143 travels as bounded MoQ groups: one GOP is one group, and a newer4244 independently decodable group cancels its predecessor.43-- **Input is independent.** Controller, keyboard, and relative mouse travel45+- **Input is independent and focused.** Controller, keyboard, and relative mouse travel4446 on their own iroh connection, unaffected by media backpressure. Session45- teardown always releases held keys and emits a neutral gamepad state, even46- after an error or disconnect.47+ focus is a revocable token rather than a viewing right. Every handoff first48+ invalidates the former focus generation, then releases held keys and emits a49+ neutral gamepad state before a successor can inject.4750 - **Fail closed.** Portal derives its stable iroh identity from a FIDO2 key4851 with `hmac-secret`. A signed, short-lived, peer-bound invitation from Sigil49- enrolls that one client once, with independently granted view,52+ enrolls each bounded viewer once, with independently granted view,5053 pointer/keyboard, and gamepad permissions and durable replay protection.5154 Every ordinary launch after that is **PIN -> tap -> play**.5255 - **Strict product boundary.** Sigil never depends on Tauri or a webview.
@@ -64,9 +67,10 @@ Working today:
6467 - The pure Rust Sigil daemon: Gamescope PipeWire capture at the display's6568 native mode, AMD GstVA H.264 encoding, and bounded PipeWire/Opus audio.6669 - Authenticated session admission over a control connection, then a67- session-scoped upstream `iroh-moq` broadcast with a strictly validated68- catalog, with earlier custom media protocols kept as explicit compatibility69- fallbacks.70+ shared, authenticated upstream `iroh-moq` generation with a strictly71+ validated catalog. Multiple viewers share one video/audio producer and one72+ adaptive actuator; earlier custom media protocols remain exclusive73+ compatibility fallbacks.7074 - Adaptive bitrate and motion-sensitive resolution on the opt-in in-process7175 encoder backend: session-authenticated receiver feedback, hysteresis, and7276 changes that commit only on exact encoder readback or a target-size IDR.
@@ -75,7 +79,8 @@ Working today:
7579 - Linux `uinput` keyboard, relative mouse, and an Xbox-style virtual gamepad,7680 with strict device preflight and end-of-session neutralization.7781 - One-time FIDO-bound enrollment, replay protection, and controller-usable78- onboarding in Portal.82+ onboarding in Portal, with bounded multi-peer authorization and live view or83+ input revocation.7984 - A deterministic, checksum-bound Bazzite runtime package with serialized8085 install/upgrade and tamper-checked rollback, plus the foundation of a8186 controller-first Decky Loader management plugin over a redacted local
@@ -137,7 +142,7 @@ hardware report; add `--smoke` to exercise a bounded 1280x800/60 VA-API
137142 encode, or `--cold-boot` on the first login after a physically headless boot138143 for a strict readiness gate.139144 140-### Enroll one Portal145+### Enroll a Portal141146 142147 On Portal's first launch, enter the security-key PIN and choose **show portal143148 id**. On the Sigil host, create an invitation for that exact peer:
@@ -152,7 +157,10 @@ sigil invitation create \
152157 ```153158 154159 Move the owner-only invitation file to the client and open it with Portal.155-Sigil consumes it once; every later launch is **PIN -> tap -> play**. To move160+Sigil consumes it once; every later launch is **PIN -> tap -> play**. Repeat161+with a distinct invitation for each viewer. A view-only invitation creates a162+spectator with no focus action; pointer/keyboard or gamepad grants make a163+viewer eligible to request slot-0 focus but do not grant possession. To move156164 Portal to a different Sigil host, revoke the enrollment on the old host and157165 use **client -> reset enrollment**. A new invitation never silently replaces158166 a working enrollment.
@@ -166,8 +174,10 @@ Run the complete repository gate before sharing work:
166174 ```167175 168176 It covers Rust format, tests, and clippy, the Linux cross-build when169-available, frontend syntax and tests, ShellCheck, package tests, and loopback170-transport proof. Website and installer changes go through177+available, frontend syntax and tests, ShellCheck, package tests, a three-viewer178+native-MoQ loopback proof, and a separate legacy-exclusive proof. The explicit179+eight-viewer release stress gate remains a local resource test, and neither180+loopback mode substitutes for exact-candidate hardware UAT. Website and installer changes go through171181 `./scripts/verify-website.sh`. Hardware acceptance evidence lives under172182 [`docs/hardware-uat/`](docs/hardware-uat/README.md), separate from local173183 tests.
M crates/sigil-host/Cargo.toml +4 -0
M crates/sigil-host/src/appliance.rs +284 -30
@@ -13,7 +13,7 @@ use crate::authorization::{AuthorizationInspection, AuthorizationStore};
1313 use crate::config::{ConfigRevision, HostConfig};1414 use crate::config_management::ConfigTransactionSummaryV1;1515 use crate::secure_state;16-use crate::server::SessionRegistry;16+use crate::server::{MediaGenerationManager, SessionRegistry};1717 1818 const LIFECYCLE_LOCK_FILE: &str = "daemon-v1.lock";1919 const GLOBAL_LIFECYCLE_LOCK_FILE: &str = "daemon-global-v1.lock";
@@ -21,7 +21,7 @@ const RUNTIME_STATUS_FILE: &str = "daemon-status-v1.json";
2121 // Frozen local-management ABI consumed by Sigil commands and the Decky2222 // plugin. A successor needs an explicit migration; never rename this in place.2323 const RUNTIME_DIRECTORY_COMPONENT: &str = "sigil-spark";24-const RUNTIME_STATUS_VERSION: u16 = 2;24+const RUNTIME_STATUS_VERSION: u16 = 3;2525 const MAX_RUNTIME_STATUS_BYTES: u64 = 16 * 1024;2626 const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2);2727 const STALE_AFTER: Duration = Duration::from_secs(10);
@@ -164,6 +164,29 @@ struct RuntimeStatusV2 {
164164 last_error: Option<RuntimeErrorV1>,165165 }166166 167+#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]168+#[serde(deny_unknown_fields)]169+struct RuntimeStatusV3 {170+ version: u16,171+ instance_id: String,172+ host_node_id: String,173+ loaded_config_revision: Option<ConfigRevision>,174+ reached_ready: bool,175+ updated_at_unix_ms: u64,176+ uptime_ms: u64,177+ daemon: DaemonState,178+ session_active: bool,179+ session_mode: String,180+ active_viewer_count: usize,181+ media_generation_id: Option<u64>,182+ roster_revision: Option<u64>,183+ focus_occupied: Option<bool>,184+ recovering_viewer_count: usize,185+ configured_viewer_capacity: usize,186+ authorization_revision: u64,187+ last_error: Option<RuntimeErrorV1>,188+}189+167190 #[derive(Debug, Serialize)]168191 pub struct ApplianceStatusV2 {169192 schema_version: u16,
@@ -207,6 +230,17 @@ struct EnrollmentStatus {
207230 grants: Vec<&'static str>,208231 epoch: u64,209232 enrolled_at_unix: Option<u64>,233+ enrollment_count: usize,234+ authorization_revision: u64,235+ viewers: Vec<EnrollmentViewerStatus>,236+}237+238+#[derive(Debug, Serialize)]239+struct EnrollmentViewerStatus {240+ handle: String,241+ grants: Vec<&'static str>,242+ enrolled_at_unix: u64,243+ authorization_revision: u64,210244 }211245 212246 #[derive(Debug, Serialize)]
@@ -219,6 +253,14 @@ struct RuntimeStatus {
219253 instance_id: Option<String>,220254 loaded_config_revision: Option<ConfigRevision>,221255 reached_ready: bool,256+ mode: Option<String>,257+ active_viewer_count: Option<usize>,258+ media_generation_id: Option<u64>,259+ roster_revision: Option<u64>,260+ focus_occupied: Option<bool>,261+ recovering_viewer_count: Option<usize>,262+ configured_viewer_capacity: Option<usize>,263+ authorization_revision: Option<u64>,222264 last_error: Option<RuntimeErrorV1>,223265 }224266
@@ -232,6 +274,7 @@ pub struct RuntimePublisher {
232274 reached_ready: Arc<AtomicBool>,233275 last_error: Arc<Mutex<Option<RuntimeErrorV1>>>,234276 sessions: Arc<SessionRegistry>,277+ generations: Arc<MediaGenerationManager>,235278 write_lock: Arc<tokio::sync::Mutex<()>>,236279 task: Option<tokio::task::JoinHandle<()>>,237280 }
@@ -240,6 +283,7 @@ impl RuntimePublisher {
240283 pub fn start(241284 host: EndpointId,242285 sessions: Arc<SessionRegistry>,286+ generations: Arc<MediaGenerationManager>,243287 require_runtime: bool,244288 loaded_config_revision: Option<ConfigRevision>,245289 ) -> Result<Option<Self>> {
@@ -256,13 +300,21 @@ impl RuntimePublisher {
256300 !require_runtime || loaded_config_revision.is_some(),257301 "configured Sigil service requires a loaded config revision"258302 );259- Self::start_at(&state_directory, host, sessions, loaded_config_revision).map(Some)303+ Self::start_at(304+ &state_directory,305+ host,306+ sessions,307+ generations,308+ loaded_config_revision,309+ )310+ .map(Some)260311 }261312 262313 fn start_at(263314 state_directory: &Path,264315 host: EndpointId,265316 sessions: Arc<SessionRegistry>,317+ generations: Arc<MediaGenerationManager>,266318 loaded_config_revision: Option<ConfigRevision>,267319 ) -> Result<Self> {268320 let instance_id = random_instance_id()?;
@@ -280,6 +332,7 @@ impl RuntimePublisher {
280332 reached_ready,281333 last_error,282334 sessions,335+ generations,283336 write_lock,284337 task: None,285338 };
@@ -294,6 +347,7 @@ impl RuntimePublisher {
294347 let reached_ready = Arc::clone(&publisher.reached_ready);295348 let last_error = Arc::clone(&publisher.last_error);296349 let sessions = Arc::clone(&publisher.sessions);350+ let generations = Arc::clone(&publisher.generations);297351 let write_lock = Arc::clone(&publisher.write_lock);298352 let task = tokio::spawn(async move {299353 let mut interval = tokio::time::interval(HEARTBEAT_INTERVAL);
@@ -307,7 +361,8 @@ impl RuntimePublisher {
307361 let loaded_config_revision = loaded_config_revision.clone();308362 let daemon = decode_daemon_state(daemon_state.load(Ordering::Relaxed));309363 let reached_ready = reached_ready.load(Ordering::Relaxed);310- let session_active = sessions.has_session();364+ let session = sessions.runtime_status();365+ let recovering_viewer_count = generations.recovering_viewer_count();311366 let last_error = last_error312367 .lock()313368 .expect("runtime status error state poisoned")
@@ -321,7 +376,8 @@ impl RuntimePublisher {
321376 started,322377 daemon,323378 reached_ready,324- session_active,379+ session,380+ recovering_viewer_count,325381 last_error,326382 })327383 })
@@ -379,7 +435,8 @@ impl RuntimePublisher {
379435 let loaded_config_revision = self.loaded_config_revision.clone();380436 let started = self.started;381437 let reached_ready = self.reached_ready.load(Ordering::Relaxed);382- let session_active = self.sessions.has_session();438+ let session = self.sessions.runtime_status();439+ let recovering_viewer_count = self.generations.recovering_viewer_count();383440 let last_error = self384441 .last_error385442 .lock()
@@ -394,7 +451,8 @@ impl RuntimePublisher {
394451 started,395452 daemon: DaemonState::Stopped,396453 reached_ready,397- session_active,454+ session,455+ recovering_viewer_count,398456 last_error,399457 })400458 })
@@ -411,7 +469,8 @@ impl RuntimePublisher {
411469 started: self.started,412470 daemon: decode_daemon_state(self.daemon_state.load(Ordering::Relaxed)),413471 reached_ready: self.reached_ready.load(Ordering::Relaxed),414- session_active: self.sessions.has_session(),472+ session: self.sessions.runtime_status(),473+ recovering_viewer_count: self.generations.recovering_viewer_count(),415474 last_error: self416475 .last_error417476 .lock()
@@ -430,7 +489,8 @@ impl RuntimePublisher {
430489 let started = self.started;431490 let daemon = decode_daemon_state(self.daemon_state.load(Ordering::Relaxed));432491 let reached_ready = self.reached_ready.load(Ordering::Relaxed);433- let session_active = self.sessions.has_session();492+ let session = self.sessions.runtime_status();493+ let recovering_viewer_count = self.generations.recovering_viewer_count();434494 let last_error = self435495 .last_error436496 .lock()
@@ -445,7 +505,8 @@ impl RuntimePublisher {
445505 started,446506 daemon,447507 reached_ready,448- session_active,508+ session,509+ recovering_viewer_count,449510 last_error,450511 })451512 })
@@ -533,6 +594,20 @@ pub fn status_json(status: &ApplianceStatusV2, schema_version: u16) -> Result<se
533594 runtime.remove("instance_id");534595 runtime.remove("loaded_config_revision");535596 runtime.remove("reached_ready");597+ runtime.remove("mode");598+ runtime.remove("active_viewer_count");599+ runtime.remove("media_generation_id");600+ runtime.remove("roster_revision");601+ runtime.remove("focus_occupied");602+ runtime.remove("recovering_viewer_count");603+ runtime.remove("configured_viewer_capacity");604+ runtime.remove("authorization_revision");605+ let enrollment = value["enrollment"]606+ .as_object_mut()607+ .context("appliance enrollment status is not an object")?;608+ enrollment.remove("enrollment_count");609+ enrollment.remove("authorization_revision");610+ enrollment.remove("viewers");536611 }537612 2 => {}538613 _ => anyhow::bail!("unsupported appliance status schema"),
@@ -586,7 +661,22 @@ fn assemble_status(
586661 ) -> ApplianceStatusV2 {587662 let snapshot = authorization.snapshot;588663 let grants = snapshot.grants.map(grant_list).unwrap_or_default();589- let enrollment_active = snapshot.peer.is_some();664+ let enrollment_active = !snapshot.viewers.is_empty() || snapshot.peer.is_some();665+ let enrollment_count = if snapshot.viewers.is_empty() {666+ usize::from(snapshot.peer.is_some())667+ } else {668+ snapshot.viewers.len()669+ };670+ let viewers = snapshot671+ .viewers672+ .iter()673+ .map(|viewer| EnrollmentViewerStatus {674+ handle: viewer.handle.clone(),675+ grants: grant_list(viewer.grants),676+ enrolled_at_unix: viewer.enrolled_at_unix,677+ authorization_revision: viewer.authorization_revision,678+ })679+ .collect();590680 let overall = match (runtime.daemon, runtime.session) {591681 (DaemonState::Ready, SessionState::Active) => OverallState::Active,592682 (DaemonState::Ready, _) => OverallState::Ready,
@@ -617,6 +707,9 @@ fn assemble_status(
617707 grants,618708 epoch: snapshot.epoch,619709 enrolled_at_unix: snapshot.enrolled_at_unix,710+ enrollment_count,711+ authorization_revision: snapshot.committed_revision,712+ viewers,620713 },621714 runtime,622715 }
@@ -693,11 +786,19 @@ fn read_runtime_status(
693786 instance_id: Some(persisted.instance_id),694787 loaded_config_revision: persisted.loaded_config_revision,695788 reached_ready: persisted.reached_ready,789+ mode: Some(persisted.session_mode),790+ active_viewer_count: Some(persisted.active_viewer_count),791+ media_generation_id: persisted.media_generation_id,792+ roster_revision: persisted.roster_revision,793+ focus_occupied: persisted.focus_occupied,794+ recovering_viewer_count: Some(persisted.recovering_viewer_count),795+ configured_viewer_capacity: Some(persisted.configured_viewer_capacity),796+ authorization_revision: Some(persisted.authorization_revision),696797 last_error: persisted.last_error,697798 })698799 }699800 700-fn read_persisted_runtime(state_directory: &Path) -> Result<Option<RuntimeStatusV2>> {801+fn read_persisted_runtime(state_directory: &Path) -> Result<Option<RuntimeStatusV3>> {701802 let Some(bytes) = secure_state::read_bounded(702803 state_directory,703804 RUNTIME_STATUS_FILE,
@@ -716,8 +817,8 @@ fn read_persisted_runtime(state_directory: &Path) -> Result<Option<RuntimeStatus
716817 1 => {717818 let legacy: RuntimeStatusV1 =718819 serde_json::from_value(value).context("parsing legacy Sigil runtime status")?;719- Ok(Some(RuntimeStatusV2 {720- version: legacy.version,820+ Ok(Some(RuntimeStatusV3 {821+ version: RUNTIME_STATUS_VERSION,721822 instance_id: legacy.instance_id,722823 host_node_id: legacy.host_node_id,723824 loaded_config_revision: None,
@@ -726,22 +827,110 @@ fn read_persisted_runtime(state_directory: &Path) -> Result<Option<RuntimeStatus
726827 uptime_ms: legacy.uptime_ms,727828 daemon: legacy.daemon,728829 session_active: legacy.session_active,830+ session_mode: if legacy.session_active {831+ "legacy_exclusive".to_owned()832+ } else {833+ "inactive".to_owned()834+ },835+ active_viewer_count: usize::from(legacy.session_active),836+ media_generation_id: None,837+ roster_revision: None,838+ focus_occupied: None,839+ recovering_viewer_count: 0,840+ configured_viewer_capacity: usize::from(crate::config::DEFAULT_MAX_VIEWERS),841+ authorization_revision: 1,729842 last_error: legacy.last_error,730843 }))731844 }732845 2 => {733- let current: RuntimeStatusV2 =846+ let legacy: RuntimeStatusV2 =734847 serde_json::from_value(value).context("parsing Sigil runtime status v2")?;735- ensure!(736- current.version == RUNTIME_STATUS_VERSION,737- "unsupported Sigil runtime status version"738- );848+ Ok(Some(RuntimeStatusV3 {849+ version: RUNTIME_STATUS_VERSION,850+ instance_id: legacy.instance_id,851+ host_node_id: legacy.host_node_id,852+ loaded_config_revision: legacy.loaded_config_revision,853+ reached_ready: legacy.reached_ready,854+ updated_at_unix_ms: legacy.updated_at_unix_ms,855+ uptime_ms: legacy.uptime_ms,856+ daemon: legacy.daemon,857+ session_active: legacy.session_active,858+ session_mode: if legacy.session_active {859+ "legacy_exclusive".to_owned()860+ } else {861+ "inactive".to_owned()862+ },863+ active_viewer_count: usize::from(legacy.session_active),864+ media_generation_id: None,865+ roster_revision: None,866+ focus_occupied: None,867+ recovering_viewer_count: 0,868+ configured_viewer_capacity: usize::from(crate::config::DEFAULT_MAX_VIEWERS),869+ authorization_revision: 1,870+ last_error: legacy.last_error,871+ }))872+ }873+ 3 => {874+ let current: RuntimeStatusV3 =875+ serde_json::from_value(value).context("parsing Sigil runtime status v3")?;876+ validate_runtime_session(&current)?;739877 Ok(Some(current))740878 }741879 _ => anyhow::bail!("unsupported Sigil runtime status version"),742880 }743881 }744882 883+fn validate_runtime_session(status: &RuntimeStatusV3) -> Result<()> {884+ ensure!(885+ (1..=usize::from(crate::config::MAX_VIEWERS)).contains(&status.configured_viewer_capacity),886+ "runtime viewer capacity is out of bounds"887+ );888+ ensure!(889+ status.active_viewer_count <= status.configured_viewer_capacity,890+ "runtime active viewer count exceeds configured capacity"891+ );892+ ensure!(893+ status.recovering_viewer_count <= status.active_viewer_count,894+ "runtime recovering viewer count exceeds active viewers"895+ );896+ ensure!(897+ status.authorization_revision != 0,898+ "runtime authorization revision must be non-zero"899+ );900+ ensure!(901+ status.media_generation_id.is_none_or(|value| value != 0)902+ && status.roster_revision.is_none_or(|value| value != 0),903+ "runtime generation and roster revisions must be non-zero when present"904+ );905+ match status.session_mode.as_str() {906+ "inactive" => ensure!(907+ !status.session_active908+ && status.active_viewer_count == 0909+ && status.media_generation_id.is_none()910+ && status.roster_revision.is_none()911+ && status.focus_occupied.is_none(),912+ "inactive runtime session fields are inconsistent"913+ ),914+ "legacy_exclusive" => ensure!(915+ status.session_active916+ && status.active_viewer_count == 1917+ && status.roster_revision.is_none()918+ && status.focus_occupied.is_none(),919+ "legacy runtime session fields are inconsistent"920+ ),921+ "moq_multi_viewer" => ensure!(922+ status.session_active923+ && status.active_viewer_count > 0924+ && status.media_generation_id.is_some()925+ && status.roster_revision.is_some()926+ && status.focus_occupied.is_some(),927+ "multi-viewer runtime session fields are inconsistent"928+ ),929+ _ => anyhow::bail!("runtime session mode is unsupported"),930+ }931+ Ok(())932+}933+745934 pub(crate) fn latest_runtime_instance(746935 state_directory: &Path,747936 expected_host: EndpointId,
@@ -769,7 +958,7 @@ pub(crate) fn latest_runtime_instance(
769958 Ok(Some(persisted.instance_id))770959 }771960 772-fn stale_runtime(persisted: RuntimeStatusV2, heartbeat_age_ms: Option<u64>) -> RuntimeStatus {961+fn stale_runtime(persisted: RuntimeStatusV3, heartbeat_age_ms: Option<u64>) -> RuntimeStatus {773962 let stopped = persisted.daemon == DaemonState::Stopped;774963 RuntimeStatus {775964 state: RuntimeState::Stale,
@@ -788,6 +977,14 @@ fn stale_runtime(persisted: RuntimeStatusV2, heartbeat_age_ms: Option<u64>) -> R
788977 instance_id: None,789978 loaded_config_revision: None,790979 reached_ready: false,980+ mode: None,981+ active_viewer_count: None,982+ media_generation_id: None,983+ roster_revision: None,984+ focus_occupied: None,985+ recovering_viewer_count: None,986+ configured_viewer_capacity: None,987+ authorization_revision: None,791988 last_error: persisted.last_error,792989 }793990 }
@@ -802,6 +999,14 @@ fn absent_runtime() -> RuntimeStatus {
802999 instance_id: None,8031000 loaded_config_revision: None,8041001 reached_ready: false,1002+ mode: None,1003+ active_viewer_count: None,1004+ media_generation_id: None,1005+ roster_revision: None,1006+ focus_occupied: None,1007+ recovering_viewer_count: None,1008+ configured_viewer_capacity: None,1009+ authorization_revision: None,8051010 last_error: None,8061011 }8071012 }
@@ -816,6 +1021,14 @@ fn unavailable_runtime() -> RuntimeStatus {
8161021 instance_id: None,8171022 loaded_config_revision: None,8181023 reached_ready: false,1024+ mode: None,1025+ active_viewer_count: None,1026+ media_generation_id: None,1027+ roster_revision: None,1028+ focus_occupied: None,1029+ recovering_viewer_count: None,1030+ configured_viewer_capacity: None,1031+ authorization_revision: None,8191032 last_error: None,8201033 }8211034 }
@@ -875,12 +1088,14 @@ struct RuntimeSnapshotInput {
8751088 started: Instant,8761089 daemon: DaemonState,8771090 reached_ready: bool,878- session_active: bool,1091+ session: crate::server::SessionRuntimeStatus,1092+ recovering_viewer_count: usize,8791093 last_error: Option<RuntimeErrorV1>,8801094 }8811095 8821096 fn write_runtime_snapshot(input: RuntimeSnapshotInput) -> Result<()> {883- let status = RuntimeStatusV2 {1097+ let session_active = input.session.active_viewers != 0;1098+ let status = RuntimeStatusV3 {8841099 version: RUNTIME_STATUS_VERSION,8851100 instance_id: input.instance_id,8861101 host_node_id: input.host.to_string(),
@@ -889,9 +1104,20 @@ fn write_runtime_snapshot(input: RuntimeSnapshotInput) -> Result<()> {
8891104 updated_at_unix_ms: unix_timestamp_millis()?,8901105 uptime_ms: u64::try_from(input.started.elapsed().as_millis()).unwrap_or(u64::MAX),8911106 daemon: input.daemon,892- session_active: input.session_active,1107+ session_active,1108+ session_mode: input.session.mode.to_owned(),1109+ active_viewer_count: input.session.active_viewers,1110+ media_generation_id: input.session.media_generation_id,1111+ roster_revision: input.session.roster_revision,1112+ focus_occupied: input.session.focus_occupied,1113+ recovering_viewer_count: input1114+ .recovering_viewer_count1115+ .min(input.session.active_viewers),1116+ configured_viewer_capacity: input.session.configured_capacity,1117+ authorization_revision: input.session.authorization_revision,8931118 last_error: input.last_error,8941119 };1120+ validate_runtime_session(&status)?;8951121 let bytes = serde_json::to_vec(&status)?;8961122 secure_state::atomic_write(8971123 &input.state_directory,
@@ -981,9 +1207,9 @@ mod tests {
9811207 fn current_runtime_document(9821208 updated_at_unix_ms: u64,9831209 revision: ConfigRevision,984- ) -> RuntimeStatusV2 {1210+ ) -> RuntimeStatusV3 {9851211 let host = SecretKey::from_bytes(&[7; 32]).public();986- RuntimeStatusV2 {1212+ RuntimeStatusV3 {9871213 version: RUNTIME_STATUS_VERSION,9881214 instance_id: "0123456789abcdef0123456789abcdef".to_owned(),9891215 host_node_id: host.to_string(),
@@ -993,6 +1219,14 @@ mod tests {
9931219 uptime_ms: 1_000,9941220 daemon: DaemonState::Stopped,9951221 session_active: false,1222+ session_mode: "inactive".to_owned(),1223+ active_viewer_count: 0,1224+ media_generation_id: None,1225+ roster_revision: None,1226+ focus_occupied: None,1227+ recovering_viewer_count: 0,1228+ configured_viewer_capacity: usize::from(crate::config::DEFAULT_MAX_VIEWERS),1229+ authorization_revision: 1,9961230 last_error: None,9971231 }9981232 }
@@ -1014,8 +1248,9 @@ mod tests {
10141248 let second_state = private_directory();10151249 let first = LifecycleGuard::acquire_at(first_state.path(), None).unwrap();10161250 assert!(LifecycleGuard::acquire_at(first_state.path(), None).is_err());1017- LifecycleGuard::acquire_at(second_state.path(), None).unwrap();1251+ let second = LifecycleGuard::acquire_at(second_state.path(), None).unwrap();10181252 drop(first);1253+ drop(second);10191254 LifecycleGuard::acquire_at(first_state.path(), None).unwrap();10201255 }10211256
@@ -1145,7 +1380,7 @@ mod tests {
11451380 let revision = ConfigRevision::from_bytes(b"candidate");11461381 let instance = "0123456789abcdef0123456789abcdef";11471382 let valid = current_runtime_document(20_000, revision.clone());1148- let write = |document: &RuntimeStatusV2| {1383+ let write = |document: &RuntimeStatusV3| {11491384 secure_state::atomic_write(11501385 directory.path(),11511386 RUNTIME_STATUS_FILE,
@@ -1212,6 +1447,8 @@ mod tests {
12121447 peer: Some(peer),12131448 grants: Some(InvitationGrants::ALL),12141449 enrolled_at_unix: Some(123),1450+ committed_revision: 1,1451+ viewers: Vec::new(),12151452 },12161453 storage_present: true,12171454 },
@@ -1224,6 +1461,14 @@ mod tests {
12241461 instance_id: Some("0123456789abcdef0123456789abcdef".to_owned()),12251462 loaded_config_revision: Some(ConfigRevision::from_bytes(b"config")),12261463 reached_ready: true,1464+ mode: Some("moq_multi_viewer".to_owned()),1465+ active_viewer_count: Some(3),1466+ media_generation_id: Some(7),1467+ roster_revision: Some(9),1468+ focus_occupied: Some(true),1469+ recovering_viewer_count: Some(1),1470+ configured_viewer_capacity: Some(3),1471+ authorization_revision: Some(4),12271472 last_error: None,12281473 },12291474 ConfigRevision::from_bytes(b"config"),
@@ -1249,6 +1494,8 @@ mod tests {
12491494 peer: None,12501495 grants: None,12511496 enrolled_at_unix: None,1497+ committed_revision: 1,1498+ viewers: Vec::new(),12521499 },12531500 storage_present: false,12541501 },
@@ -1268,17 +1515,24 @@ mod tests {
12681515 assert_eq!(current["schema_version"], 2);12691516 assert!(current["config"].get("revision").is_some());12701517 assert!(current["runtime"].get("reached_ready").is_some());1518+ assert!(current["runtime"].get("active_viewer_count").is_some());12711519 }12721520 12731521 #[tokio::test]12741522 async fn publisher_writes_transitions_and_retains_an_explicit_stopped_state() {12751523 let directory = private_directory();12761524 let sessions = Arc::new(SessionRegistry::default());1525+ let generations = MediaGenerationManager::new(crate::server::moq_test_config(), [7; 32]);12771526 let host = SecretKey::from_bytes(&[7; 32]).public();12781527 let revision = ConfigRevision::from_bytes(b"config");1279- let publisher =1280- RuntimePublisher::start_at(directory.path(), host, sessions, Some(revision.clone()))1281- .unwrap();1528+ let publisher = RuntimePublisher::start_at(1529+ directory.path(),1530+ host,1531+ sessions,1532+ generations,1533+ Some(revision.clone()),1534+ )1535+ .unwrap();12821536 publisher.mark_ready().await.unwrap();12831537 publisher.write_snapshot().unwrap();12841538 let status =
M crates/sigil-host/src/audio.rs +2 -0
M crates/sigil-host/src/authorization.rs +789 -56
@@ -1,3 +1,4 @@
1+use std::collections::HashSet;12 use std::fs::{self, File, OpenOptions};23 use std::io::{Read, Write};34 use std::path::{Path, PathBuf};
@@ -7,6 +8,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
78 use anyhow::{Context, Result, ensure};89 use iroh::EndpointId;910 use serde::{Deserialize, Serialize};11+use sha2::{Digest, Sha256};1012 use sigil_protocol::{1113 INVITATION_CLOCK_SKEW_SECS, InvitationClaims, InvitationGrants, MAX_INVITATION_TTL_SECS,1214 SignedInvitation,
@@ -15,11 +17,16 @@ use sigil_protocol::{
1517 #[cfg(unix)]1618 use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};1719 18-const STATE_VERSION: u16 = 1;19-const STATE_FILE_NAME: &str = "authorization-v1.json";20+const STATE_VERSION: u16 = 2;21+const STATE_FILE_NAME: &str = "authorization-v2.json";22+const LEGACY_STATE_VERSION: u16 = 1;23+const LEGACY_STATE_FILE_NAME: &str = "authorization-v1.json";2024 const LOCK_FILE_NAME: &str = "authorization-v1.lock";2125 const MAX_STATE_FILE_LEN: u64 = 64 * 1024;2226 const MAX_CONSUMED_INVITATIONS: usize = 64;27+pub const MAX_ENROLLED_VIEWERS: usize = 32;28+const MAX_SECURITY_AUDIT_ENTRIES: usize = 64;29+const VIEWER_HANDLE_RANDOM_BYTES: usize = 8;2330 const WRITER_LOCK_TIMEOUT: Duration = Duration::from_millis(250);2431 const WRITER_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(10);2532
@@ -55,15 +62,17 @@ pub enum AuthorizationPolicy {
5562 DevelopmentBypass,5663 }5764 58-#[derive(Clone, Copy, Debug, PartialEq, Eq)]65+#[derive(Clone, Debug, PartialEq, Eq)]5966 pub struct AuthorizationSnapshot {6067 pub epoch: u64,6168 pub peer: Option<EndpointId>,6269 pub grants: Option<InvitationGrants>,6370 pub enrolled_at_unix: Option<u64>,71+ pub committed_revision: u64,72+ pub viewers: Vec<EnrolledViewer>,6473 }6574 66-#[derive(Clone, Copy, Debug, PartialEq, Eq)]75+#[derive(Clone, Debug, PartialEq, Eq)]6776 pub struct AuthorizationInspection {6877 pub snapshot: AuthorizationSnapshot,6978 pub storage_present: bool,
@@ -76,21 +85,49 @@ pub struct RevocationOutcome {
7685 pub current_epoch: u64,7786 }7887 88+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]89+pub struct EnrolledViewer {90+ pub handle: String,91+ #[serde(skip_serializing)]92+ pub peer: EndpointId,93+ pub grants: InvitationGrants,94+ pub enrolled_at_unix: u64,95+ pub authorization_revision: u64,96+}97+98+#[derive(Clone, Debug, PartialEq, Eq)]99+pub struct AuthorizedViewer {100+ pub handle: String,101+ pub grants: InvitationGrants,102+ pub authorization_revision: u64,103+ pub committed_revision: u64,104+}105+106+#[derive(Clone, Debug, PartialEq, Eq)]107+pub struct AuthorizationMutation {108+ pub handle: String,109+ pub peer: EndpointId,110+ pub previous_grants: InvitationGrants,111+ pub current_grants: Option<InvitationGrants>,112+ pub authorization_revision: u64,113+ pub committed_revision: u64,114+}115+79116 impl AuthorizationSnapshot {80117 fn from_state(state: &AuthorizationState) -> Result<Self> {81- let (peer, grants, enrolled_at_unix) = match &state.enrollment {82- Some(enrollment) => (83- Some(enrollment.peer_node_id.parse::<EndpointId>()?),84- Some(enrollment.grants),85- Some(enrollment.enrolled_at_unix),86- ),87- None => (None, None, None),88- };118+ let viewers = state119+ .enrollments120+ .iter()121+ .map(Enrollment::as_public)122+ .collect::<Result<Vec<_>>>()?;123+ let first = viewers.first();89124 Ok(Self {90125 epoch: state.enrollment_epoch,91- peer,92- grants,93- enrolled_at_unix,126+ peer: first.map(|viewer| viewer.peer),127+ grants: first.map(|viewer| viewer.grants),128+ enrolled_at_unix: first.map(|viewer| viewer.enrolled_at_unix),129+ committed_revision: state.committed_revision,130+ viewers,94131 })95132 }96133 }
@@ -101,13 +138,68 @@ struct AuthorizationState {
101138 version: u16,102139 host_node_id: String,103140 enrollment_epoch: u64,104- enrollment: Option<Enrollment>,141+ committed_revision: u64,142+ enrollments: Vec<Enrollment>,105143 consumed_invitations: Vec<ConsumedInvitation>,144+ security_audit: Vec<SecurityAuditEntry>,106145 }107146 108147 #[derive(Clone, Debug, Serialize, Deserialize)]109148 #[serde(deny_unknown_fields)]110149 struct Enrollment {150+ peer_node_id: String,151+ handle: String,152+ grants: InvitationGrants,153+ enrolled_at_unix: u64,154+ authorization_revision: u64,155+}156+157+impl Enrollment {158+ fn as_public(&self) -> Result<EnrolledViewer> {159+ Ok(EnrolledViewer {160+ handle: self.handle.clone(),161+ peer: self.peer_node_id.parse::<EndpointId>()?,162+ grants: self.grants,163+ enrolled_at_unix: self.enrolled_at_unix,164+ authorization_revision: self.authorization_revision,165+ })166+ }167+}168+169+#[derive(Clone, Debug, Serialize, Deserialize)]170+#[serde(deny_unknown_fields)]171+struct SecurityAuditEntry {172+ revision: u64,173+ occurred_at_unix: u64,174+ action: SecurityAuditAction,175+ handle: String,176+ previous_grants: Option<InvitationGrants>,177+ current_grants: Option<InvitationGrants>,178+}179+180+#[derive(Clone, Copy, Debug, Serialize, Deserialize)]181+#[serde(rename_all = "snake_case")]182+enum SecurityAuditAction {183+ Enrolled,184+ GrantsReplaced,185+ Revoked,186+ ResetAll,187+ MigratedV1,188+}189+190+#[derive(Clone, Debug, Serialize, Deserialize)]191+#[serde(deny_unknown_fields)]192+struct LegacyAuthorizationState {193+ version: u16,194+ host_node_id: String,195+ enrollment_epoch: u64,196+ enrollment: Option<LegacyEnrollment>,197+ consumed_invitations: Vec<ConsumedInvitation>,198+}199+200+#[derive(Clone, Debug, Serialize, Deserialize)]201+#[serde(deny_unknown_fields)]202+struct LegacyEnrollment {111203 peer_node_id: String,112204 grants: InvitationGrants,113205 enrolled_at_unix: u64,
@@ -126,11 +218,64 @@ impl AuthorizationState {
126218 version: STATE_VERSION,127219 host_node_id: host.to_string(),128220 enrollment_epoch: 1,129- enrollment: None,221+ committed_revision: 1,222+ enrollments: Vec::new(),130223 consumed_invitations: Vec::new(),224+ security_audit: Vec::new(),131225 }132226 }133227 228+ fn from_legacy(legacy: LegacyAuthorizationState, expected_host: EndpointId) -> Result<Self> {229+ ensure!(230+ legacy.version == LEGACY_STATE_VERSION,231+ "unsupported legacy authorization state version"232+ );233+ ensure!(234+ legacy.host_node_id.parse::<EndpointId>().ok() == Some(expected_host),235+ "authorization state belongs to a different Sigil host"236+ );237+ ensure!(238+ legacy.enrollment_epoch != 0,239+ "authorization epoch must be non-zero"240+ );241+ ensure!(242+ legacy.consumed_invitations.len() <= MAX_CONSUMED_INVITATIONS,243+ "authorization replay ledger exceeds its fixed bound"244+ );245+ let mut state = Self::empty(expected_host);246+ state.enrollment_epoch = legacy.enrollment_epoch;247+ state.consumed_invitations = legacy.consumed_invitations;248+ if let Some(enrollment) = legacy.enrollment {249+ let peer = enrollment250+ .peer_node_id251+ .parse::<EndpointId>()252+ .context("legacy authorization enrollment contains an invalid peer node ID")?;253+ enrollment.grants.validate()?;254+ ensure!(255+ enrollment.grants.contains(InvitationGrants::VIEW),256+ "legacy authorization enrollment must include view permission"257+ );258+ let handle = random_viewer_handle(&[])?;259+ state.enrollments.push(Enrollment {260+ peer_node_id: peer.to_string(),261+ handle: handle.clone(),262+ grants: enrollment.grants,263+ enrolled_at_unix: enrollment.enrolled_at_unix,264+ authorization_revision: 1,265+ });266+ state.push_audit(SecurityAuditEntry {267+ revision: 1,268+ occurred_at_unix: enrollment.enrolled_at_unix,269+ action: SecurityAuditAction::MigratedV1,270+ handle,271+ previous_grants: Some(enrollment.grants),272+ current_grants: Some(enrollment.grants),273+ });274+ }275+ state.validate(expected_host)?;276+ Ok(state)277+ }278+134279 fn validate(&self, expected_host: EndpointId) -> Result<()> {135280 ensure!(136281 self.version == STATE_VERSION,
@@ -144,20 +289,48 @@ impl AuthorizationState {
144289 self.enrollment_epoch != 0,145290 "authorization epoch must be non-zero"146291 );292+ ensure!(293+ self.committed_revision != 0,294+ "authorization committed revision must be non-zero"295+ );296+ ensure!(297+ self.enrollments.len() <= MAX_ENROLLED_VIEWERS,298+ "authorization enrollment map exceeds its fixed bound"299+ );147300 ensure!(148301 self.consumed_invitations.len() <= MAX_CONSUMED_INVITATIONS,149302 "authorization replay ledger exceeds its fixed bound"150303 );151- if let Some(enrollment) = &self.enrollment {152- enrollment304+ ensure!(305+ self.security_audit.len() <= MAX_SECURITY_AUDIT_ENTRIES,306+ "authorization security audit exceeds its fixed bound"307+ );308+ let mut peers = HashSet::with_capacity(self.enrollments.len());309+ let mut handles = HashSet::with_capacity(self.enrollments.len());310+ for enrollment in &self.enrollments {311+ let peer = enrollment153312 .peer_node_id154313 .parse::<EndpointId>()155314 .context("authorization enrollment contains an invalid peer node ID")?;315+ ensure!(316+ peers.insert(peer),317+ "authorization contains a duplicate peer"318+ );319+ validate_viewer_handle(&enrollment.handle)?;320+ ensure!(321+ handles.insert(enrollment.handle.as_str()),322+ "authorization contains a duplicate viewer handle"323+ );156324 enrollment.grants.validate()?;157325 ensure!(158326 enrollment.grants.contains(InvitationGrants::VIEW),159327 "authorization enrollment must include view permission"160328 );329+ ensure!(330+ enrollment.authorization_revision != 0331+ && enrollment.authorization_revision <= self.committed_revision,332+ "authorization enrollment revision is invalid"333+ );161334 }162335 for (index, consumed) in self.consumed_invitations.iter().enumerate() {163336 ensure!(
@@ -171,6 +344,23 @@ impl AuthorizationState {
171344 "authorization replay ledger contains a duplicate nonce"172345 );173346 }347+ let mut last_audit_revision = 0;348+ for audit in &self.security_audit {349+ ensure!(350+ audit.revision != 0351+ && audit.revision <= self.committed_revision352+ && audit.revision >= last_audit_revision,353+ "authorization security audit revision is invalid"354+ );355+ validate_viewer_handle(&audit.handle)?;356+ if let Some(grants) = audit.previous_grants {357+ grants.validate()?;358+ }359+ if let Some(grants) = audit.current_grants {360+ grants.validate()?;361+ }362+ last_audit_revision = audit.revision;363+ }174364 Ok(())175365 }176366
@@ -184,6 +374,43 @@ impl AuthorizationState {
184374 });185375 before != self.consumed_invitations.len()186376 }377+378+ fn next_revision(&self) -> Result<u64> {379+ self.committed_revision380+ .checked_add(1)381+ .context("authorization committed revision exhausted")382+ }383+384+ fn push_audit(&mut self, entry: SecurityAuditEntry) {385+ if self.security_audit.len() == MAX_SECURITY_AUDIT_ENTRIES {386+ self.security_audit.remove(0);387+ }388+ self.security_audit.push(entry);389+ }390+}391+392+fn validate_viewer_handle(handle: &str) -> Result<()> {393+ ensure!(394+ handle.len() == "viewer-".len() + VIEWER_HANDLE_RANDOM_BYTES * 2395+ && handle.starts_with("viewer-")396+ && handle["viewer-".len()..]397+ .bytes()398+ .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),399+ "authorization viewer handle is invalid"400+ );401+ Ok(())402+}403+404+fn random_viewer_handle(existing: &[Enrollment]) -> Result<String> {405+ for _ in 0..16 {406+ let mut random = [0_u8; VIEWER_HANDLE_RANDOM_BYTES];407+ getrandom::fill(&mut random).context("generating opaque viewer handle")?;408+ let handle = format!("viewer-{:016x}", u64::from_be_bytes(random));409+ if !existing.iter().any(|viewer| viewer.handle == handle) {410+ return Ok(handle);411+ }412+ }413+ anyhow::bail!("could not allocate a unique opaque viewer handle")187414 }188415 189416 impl AuthorizationStore {
@@ -195,8 +422,12 @@ impl AuthorizationStore {
195422 location.ensure_state_directory()?;196423 let lock = location.open_lock()?;197424 location.lock_exclusive_bounded(&lock, WRITER_LOCK_TIMEOUT)?;198- let state = location.load_state()?;425+ let (state, migrated) = location.load_state()?;199426 state.validate(host)?;427+ if migrated {428+ location.write_state(&state)?;429+ location.remove_legacy_state()?;430+ }200431 Ok(Self {201432 location,202433 writer: Arc::new(AuthorizationWriter {
@@ -249,8 +480,12 @@ impl AuthorizationStore {
249480 "invitation TTL must be between 1 and {MAX_INVITATION_TTL_SECS} seconds"250481 );251482 ensure!(252- snapshot.peer.is_none() && snapshot.grants.is_none(),253- "a Portal peer is already enrolled; revoke it before issuing another invitation"483+ snapshot.viewers.len() < MAX_ENROLLED_VIEWERS,484+ "authorization enrollment map is full"485+ );486+ ensure!(487+ !snapshot.viewers.iter().any(|viewer| viewer.peer == peer),488+ "Portal peer is already enrolled"254489 );255490 InvitationClaims::new(256491 *host.as_bytes(),
@@ -265,12 +500,12 @@ impl AuthorizationStore {
265500 .map_err(Into::into)266501 }267502 268- pub fn authorize_or_redeem(503+ pub fn authorize_or_redeem_viewer(269504 &self,270505 remote: EndpointId,271506 invitation_token: Option<&str>,272507 now: u64,273- ) -> Result<InvitationGrants> {508+ ) -> Result<AuthorizedViewer> {274509 // Admission never waits for another in-process authorization operation.275510 // A concurrent redemption or durable write fails this handshake closed276511 // and lets the client retry instead of growing accept-path latency.
@@ -282,19 +517,29 @@ impl AuthorizationStore {
282517 let result = match invitation_token {283518 None => {284519 let enrollment = candidate285- .enrollment286- .as_ref()520+ .enrollments521+ .iter()522+ .find(|enrollment| {523+ enrollment.peer_node_id.parse::<EndpointId>().ok() == Some(remote)524+ })287525 .context("Portal peer is not enrolled")?;288- ensure!(289- enrollment.peer_node_id.parse::<EndpointId>()? == remote,290- "Portal peer is not enrolled"291- );292- enrollment.grants526+ AuthorizedViewer {527+ handle: enrollment.handle.clone(),528+ grants: enrollment.grants,529+ authorization_revision: enrollment.authorization_revision,530+ committed_revision: candidate.committed_revision,531+ }293532 }294533 Some(token) => {295534 ensure!(296- candidate.enrollment.is_none(),297- "an invitation cannot be used after enrollment"535+ !candidate.enrollments.iter().any(|enrollment| {536+ enrollment.peer_node_id.parse::<EndpointId>().ok() == Some(remote)537+ }),538+ "an invitation cannot be used after enrollment: Portal peer is already enrolled"539+ );540+ ensure!(541+ candidate.enrollments.len() < MAX_ENROLLED_VIEWERS,542+ "authorization enrollment map is full"298543 );299544 let invitation = SignedInvitation::decode(token)300545 .context("decoding and verifying enrollment invitation")?;
@@ -331,17 +576,35 @@ impl AuthorizationStore {
331576 candidate.consumed_invitations.len() < MAX_CONSUMED_INVITATIONS,332577 "authorization replay ledger is full"333578 );334- candidate.enrollment = Some(Enrollment {579+ let revision = candidate.next_revision()?;580+ let handle = random_viewer_handle(&candidate.enrollments)?;581+ candidate.enrollments.push(Enrollment {335582 peer_node_id: remote.to_string(),583+ handle: handle.clone(),336584 grants: claims.grants,337585 enrolled_at_unix: now,586+ authorization_revision: revision,338587 });339588 candidate.consumed_invitations.push(ConsumedInvitation {340589 nonce: claims.nonce,341590 expires_at_unix: claims.expires_at_unix,342591 });592+ candidate.committed_revision = revision;593+ candidate.push_audit(SecurityAuditEntry {594+ revision,595+ occurred_at_unix: now,596+ action: SecurityAuditAction::Enrolled,597+ handle: handle.clone(),598+ previous_grants: None,599+ current_grants: Some(claims.grants),600+ });343601 changed = true;344- claims.grants602+ AuthorizedViewer {603+ handle,604+ grants: claims.grants,605+ authorization_revision: revision,606+ committed_revision: revision,607+ }345608 }346609 };347610
@@ -352,6 +615,17 @@ impl AuthorizationStore {
352615 Ok(result)353616 }354617 618+ #[allow(dead_code)]619+ pub fn authorize_or_redeem(620+ &self,621+ remote: EndpointId,622+ invitation_token: Option<&str>,623+ now: u64,624+ ) -> Result<InvitationGrants> {625+ self.authorize_or_redeem_viewer(remote, invitation_token, now)626+ .map(|viewer| viewer.grants)627+ }628+355629 #[cfg(test)]356630 fn snapshot(&self) -> Result<AuthorizationSnapshot> {357631 let state = self.lock_state()?;
@@ -379,6 +653,8 @@ impl AuthorizationStore {
379653 peer: None,380654 grants: None,381655 enrolled_at_unix: None,656+ committed_revision: 1,657+ viewers: Vec::new(),382658 },383659 storage_present: false,384660 });
@@ -405,7 +681,7 @@ impl AuthorizationStore {
405681 );406682 }407683 408- let path = location.state_path();684+ let mut path = location.state_path();409685 let mut options = OpenOptions::new();410686 options.read(true);411687 #[cfg(unix)]
@@ -413,15 +689,26 @@ impl AuthorizationStore {
413689 let mut file = match options.open(&path) {414690 Ok(file) => file,415691 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {416- return Ok(AuthorizationInspection {417- snapshot: AuthorizationSnapshot {418- epoch: 1,419- peer: None,420- grants: None,421- enrolled_at_unix: None,422- },423- storage_present: false,424- });692+ path = location.legacy_state_path();693+ match options.open(&path) {694+ Ok(file) => file,695+ Err(legacy_error) if legacy_error.kind() == std::io::ErrorKind::NotFound => {696+ return Ok(AuthorizationInspection {697+ snapshot: AuthorizationSnapshot {698+ epoch: 1,699+ peer: None,700+ grants: None,701+ enrolled_at_unix: None,702+ committed_revision: 1,703+ viewers: Vec::new(),704+ },705+ storage_present: false,706+ });707+ }708+ Err(legacy_error) => {709+ return Err(legacy_error).context("opening legacy authorization state");710+ }711+ }425712 }426713 Err(error) => return Err(error).context("opening authorization state"),427714 };
@@ -440,8 +727,13 @@ impl AuthorizationStore {
440727 "authorization state exceeds its fixed size bound"441728 );442729 ensure!(!bytes.is_empty(), "authorization state is empty");443- let state: AuthorizationState =444- serde_json::from_slice(&bytes).context("parsing authorization state")?;730+ let state: AuthorizationState = if path == location.state_path() {731+ serde_json::from_slice(&bytes).context("parsing authorization state")?732+ } else {733+ let legacy: LegacyAuthorizationState =734+ serde_json::from_slice(&bytes).context("parsing legacy authorization state")?;735+ AuthorizationState::from_legacy(legacy, host)?736+ };445737 state.validate(host)?;446738 let snapshot = AuthorizationSnapshot::from_state(&state)?;447739 Ok(AuthorizationInspection {
@@ -455,12 +747,24 @@ impl AuthorizationStore {
455747 state.validate(self.location.host)?;456748 let mut candidate = state.clone();457749 let previous_epoch = candidate.enrollment_epoch;458- let had_enrollment = candidate.enrollment.take().is_some();750+ let had_enrollment = !candidate.enrollments.is_empty();751+ let removed = std::mem::take(&mut candidate.enrollments);459752 candidate.enrollment_epoch = candidate460753 .enrollment_epoch461754 .checked_add(1)462755 .context("authorization epoch exhausted")?;463756 candidate.prune_expired(now);757+ candidate.committed_revision = candidate.next_revision()?;758+ for enrollment in removed {759+ candidate.push_audit(SecurityAuditEntry {760+ revision: candidate.committed_revision,761+ occurred_at_unix: now,762+ action: SecurityAuditAction::ResetAll,763+ handle: enrollment.handle,764+ previous_grants: Some(enrollment.grants),765+ current_grants: None,766+ });767+ }464768 self.location.write_state(&candidate)?;465769 *state = candidate;466770 Ok(RevocationOutcome {
@@ -470,6 +774,106 @@ impl AuthorizationStore {
470774 })471775 }472776 777+ pub fn list_viewers(&self) -> Result<AuthorizationSnapshot> {778+ let state = self.lock_state()?;779+ state.validate(self.location.host)?;780+ AuthorizationSnapshot::from_state(&state)781+ }782+783+ pub fn revoke_viewer(&self, handle: &str, now: u64) -> Result<AuthorizationMutation> {784+ validate_viewer_handle(handle)?;785+ let mut state = self.lock_state()?;786+ state.validate(self.location.host)?;787+ let mut candidate = state.clone();788+ let index = candidate789+ .enrollments790+ .iter()791+ .position(|viewer| viewer.handle == handle)792+ .context("viewer handle is not enrolled")?;793+ let removed = candidate.enrollments.remove(index);794+ let revision = candidate.next_revision()?;795+ candidate.committed_revision = revision;796+ candidate.prune_expired(now);797+ candidate.push_audit(SecurityAuditEntry {798+ revision,799+ occurred_at_unix: now,800+ action: SecurityAuditAction::Revoked,801+ handle: removed.handle.clone(),802+ previous_grants: Some(removed.grants),803+ current_grants: None,804+ });805+ self.location.write_state(&candidate)?;806+ *state = candidate;807+ Ok(AuthorizationMutation {808+ handle: removed.handle,809+ peer: removed.peer_node_id.parse()?,810+ previous_grants: removed.grants,811+ current_grants: None,812+ authorization_revision: revision,813+ committed_revision: revision,814+ })815+ }816+817+ pub fn replace_viewer_grants(818+ &self,819+ handle: &str,820+ grants: Option<InvitationGrants>,821+ now: u64,822+ ) -> Result<AuthorizationMutation> {823+ validate_viewer_handle(handle)?;824+ if let Some(grants) = grants {825+ grants.validate()?;826+ ensure!(827+ grants.contains(InvitationGrants::VIEW),828+ "a persisted viewer grant must include view permission"829+ );830+ }831+ let mut state = self.lock_state()?;832+ state.validate(self.location.host)?;833+ let mut candidate = state.clone();834+ let index = candidate835+ .enrollments836+ .iter()837+ .position(|viewer| viewer.handle == handle)838+ .context("viewer handle is not enrolled")?;839+ let previous = candidate.enrollments[index].clone();840+ ensure!(841+ grants != Some(previous.grants),842+ "viewer already has the requested grants"843+ );844+ let revision = candidate.next_revision()?;845+ if let Some(grants) = grants {846+ candidate.enrollments[index].grants = grants;847+ candidate.enrollments[index].authorization_revision = revision;848+ } else {849+ candidate.enrollments.remove(index);850+ }851+ candidate.committed_revision = revision;852+ candidate.prune_expired(now);853+ candidate.push_audit(SecurityAuditEntry {854+ revision,855+ occurred_at_unix: now,856+ action: if grants.is_some() {857+ SecurityAuditAction::GrantsReplaced858+ } else {859+ SecurityAuditAction::Revoked860+ },861+ handle: previous.handle.clone(),862+ previous_grants: Some(previous.grants),863+ current_grants: grants,864+ });865+ self.location.write_state(&candidate)?;866+ *state = candidate;867+ Ok(AuthorizationMutation {868+ handle: previous.handle,869+ peer: previous.peer_node_id.parse()?,870+ previous_grants: previous.grants,871+ current_grants: grants,872+ authorization_revision: revision,873+ committed_revision: revision,874+ })875+ }876+473877 fn lock_state(&self) -> Result<MutexGuard<'_, AuthorizationState>> {474878 match self.writer.state.try_lock() {475879 Ok(state) => Ok(state),
@@ -488,6 +892,10 @@ impl AuthorizationLocation {
488892 self.state_directory.join(STATE_FILE_NAME)489893 }490894 895+ fn legacy_state_path(&self) -> PathBuf {896+ self.state_directory.join(LEGACY_STATE_FILE_NAME)897+ }898+491899 fn lock_path(&self) -> PathBuf {492900 self.state_directory.join(LOCK_FILE_NAME)493901 }
@@ -572,8 +980,8 @@ impl AuthorizationLocation {
572980 Ok(())573981 }574982 575- fn load_state(&self) -> Result<AuthorizationState> {576- let path = self.state_path();983+ fn load_state(&self) -> Result<(AuthorizationState, bool)> {984+ let mut path = self.state_path();577985 let mut options = OpenOptions::new();578986 options.read(true);579987 #[cfg(unix)]
@@ -581,7 +989,16 @@ impl AuthorizationLocation {
581989 let mut file = match options.open(&path) {582990 Ok(file) => file,583991 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {584- return Ok(AuthorizationState::empty(self.host));992+ path = self.legacy_state_path();993+ match options.open(&path) {994+ Ok(file) => file,995+ Err(legacy_error) if legacy_error.kind() == std::io::ErrorKind::NotFound => {996+ return Ok((AuthorizationState::empty(self.host), false));997+ }998+ Err(legacy_error) => {999+ return Err(legacy_error).context("opening legacy authorization state");1000+ }1001+ }5851002 }5861003 Err(error) => return Err(error).context("opening authorization state"),5871004 };
@@ -600,7 +1017,37 @@ impl AuthorizationLocation {
6001017 "authorization state exceeds its fixed size bound"6011018 );6021019 ensure!(!bytes.is_empty(), "authorization state is empty");603- serde_json::from_slice(&bytes).context("parsing authorization state")1020+ if path == self.state_path() {1021+ serde_json::from_slice(&bytes)1022+ .context("parsing authorization state")1023+ .map(|state| (state, false))1024+ } else {1025+ let legacy: LegacyAuthorizationState =1026+ serde_json::from_slice(&bytes).context("parsing legacy authorization state")?;1027+ Ok((AuthorizationState::from_legacy(legacy, self.host)?, true))1028+ }1029+ }1030+1031+ fn remove_legacy_state(&self) -> Result<()> {1032+ let path = self.legacy_state_path();1033+ match fs::symlink_metadata(&path) {1034+ Ok(metadata) => {1035+ ensure!(1036+ metadata.is_file() && !metadata.file_type().is_symlink(),1037+ "legacy authorization state is not a regular file"1038+ );1039+ #[cfg(unix)]1040+ ensure!(1041+ metadata.uid() == unsafe { libc::geteuid() } && metadata.mode() & 0o077 == 0,1042+ "legacy authorization state has unsafe ownership or permissions"1043+ );1044+ fs::remove_file(&path)?;1045+ File::open(&self.state_directory)?.sync_all()?;1046+ Ok(())1047+ }1048+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),1049+ Err(error) => Err(error).context("inspecting legacy authorization state"),1050+ }6041051 }6051052 6061053 fn write_state(&self, state: &AuthorizationState) -> Result<()> {
@@ -664,23 +1111,50 @@ impl AuthorizationLocation {
6641111 }6651112 6661113 impl AuthorizationPolicy {667- pub fn authorize_or_redeem(1114+ pub fn authorize_or_redeem_viewer(6681115 &self,6691116 remote: EndpointId,6701117 invitation_token: Option<&str>,6711118 now: u64,672- ) -> Result<InvitationGrants> {1119+ ) -> Result<AuthorizedViewer> {6731120 match self {674- Self::Required(store) => store.authorize_or_redeem(remote, invitation_token, now),1121+ Self::Required(store) => {1122+ store.authorize_or_redeem_viewer(remote, invitation_token, now)1123+ }6751124 Self::TestPatternProof | Self::DevelopmentBypass => {6761125 ensure!(6771126 invitation_token.is_none(),6781127 "unrestricted development modes do not accept invitations"6791128 );680- Ok(InvitationGrants::ALL)1129+ Ok(AuthorizedViewer {1130+ handle: development_viewer_handle(remote),1131+ grants: InvitationGrants::ALL,1132+ authorization_revision: 1,1133+ committed_revision: 1,1134+ })6811135 }6821136 }6831137 }1138+1139+ pub fn authorize_or_redeem(1140+ &self,1141+ remote: EndpointId,1142+ invitation_token: Option<&str>,1143+ now: u64,1144+ ) -> Result<InvitationGrants> {1145+ self.authorize_or_redeem_viewer(remote, invitation_token, now)1146+ .map(|viewer| viewer.grants)1147+ }1148+}1149+1150+fn development_viewer_handle(remote: EndpointId) -> String {1151+ let digest =1152+ Sha256::digest([b"goq-development-viewer-v1\0".as_slice(), remote.as_bytes()].concat());1153+ let suffix = digest[..VIEWER_HANDLE_RANDOM_BYTES]1154+ .iter()1155+ .map(|byte| format!("{byte:02x}"))1156+ .collect::<String>();1157+ format!("viewer-{suffix}")6841158 }6851159 6861160 pub fn unix_timestamp_now() -> Result<u64> {
@@ -772,6 +1246,265 @@ mod tests {
7721246 );7731247 }7741248 1249+ // Regression: every automated proof runs under TestPatternProof or1250+ // DevelopmentBypass, which pin `authorization_revision` to 1. A real1251+ // `Required` host mints enrollment revisions starting at 2 and rising with1252+ // every grant change, so any client that assumes 1 rejects its own1253+ // authenticated media. Compose the exact host issuance path with the exact1254+ // client verification path across several enrollments and mutations.1255+ #[test]1256+ fn enrolled_viewers_verify_their_capability_at_the_advertised_revision() {1257+ use sigil_protocol::{1258+ Capability, HostHelloV2, SignedSubscriptionCapability, SubscriptionClaims,1259+ SubscriptionTracks,1260+ };1261+1262+ let (_directory, host, first_peer, store) = store();1263+ let second_peer = SecretKey::from_bytes(&[23; 32]);1264+ let generation_id = 11_u64;1265+ let now = 5_000_u64;1266+1267+ let first_token = token(1268+ &store,1269+ &host,1270+ first_peer.public(),1271+ 1_000,1272+ [41; 32],1273+ InvitationGrants::ALL,1274+ );1275+ let second_token = token(1276+ &store,1277+ &host,1278+ second_peer.public(),1279+ 1_002,1280+ [42; 32],1281+ InvitationGrants::ALL,1282+ );1283+ let mut admissions = vec![1284+ store1285+ .authorize_or_redeem_viewer(first_peer.public(), Some(&first_token), 1_001)1286+ .unwrap(),1287+ store1288+ .authorize_or_redeem_viewer(second_peer.public(), Some(&second_token), 1_003)1289+ .unwrap(),1290+ ];1291+ // A grant change bumps the revision again; re-admit to pick it up.1292+ let handle = admissions[0].handle.clone();1293+ store1294+ .replace_viewer_grants(&handle, Some(InvitationGrants::VIEW), 1_004)1295+ .unwrap();1296+ admissions.push(1297+ store1298+ .authorize_or_redeem_viewer(first_peer.public(), None, 1_005)1299+ .unwrap(),1300+ );1301+1302+ // No enrolled viewer lands on revision 1, which is exactly why the1303+ // client cannot assume it.1304+ assert!(1305+ admissions1306+ .iter()1307+ .all(|viewer| viewer.authorization_revision > 1),1308+ "enrolled revisions must not collide with the development-bypass constant"1309+ );1310+1311+ for (index, authorized) in admissions.iter().enumerate() {1312+ let peer = if index == 1 {1313+ second_peer.public()1314+ } else {1315+ first_peer.public()1316+ };1317+1318+ // Host: server/moq.rs mints the capability at the lease revision1319+ // and advertises that revision in the same accepted hello.1320+ let capability = SignedSubscriptionCapability::issue(1321+ SubscriptionClaims::new(1322+ *host.public().as_bytes(),1323+ generation_id,1324+ *peer.as_bytes(),1325+ SubscriptionTracks::VIDEO_H264,1326+ authorized.authorization_revision,1327+ now,1328+ now + 900,1329+ [u8::try_from(index + 1).unwrap(); 32],1330+ 1,1331+ )1332+ .unwrap(),1333+ &host.to_bytes(),1334+ )1335+ .unwrap();1336+ let hello = HostHelloV2::accepted(1337+ 7,1338+ vec![Capability::VideoH264],1339+ control_v2_snapshot(generation_id),1340+ )1341+ .with_media_subscription_capability(1342+ capability.encode(),1343+ authorized.authorization_revision,1344+ );1345+ hello1346+ .validate()1347+ .expect("host hello must bind its capability to the advertised revision");1348+1349+ // Client: Portal and sigil-probe take the revision from the hello.1350+ let advertised = hello1351+ .media_authorization_revision1352+ .expect("accepted hello advertises its authorization revision");1353+ SignedSubscriptionCapability::decode(1354+ hello1355+ .media_subscription_capability1356+ .as_deref()1357+ .expect("accepted hello carries a capability"),1358+ )1359+ .unwrap()1360+ .verify_binding(1361+ *host.public().as_bytes(),1362+ generation_id,1363+ *peer.as_bytes(),1364+ SubscriptionTracks::VIDEO_H264,1365+ advertised,1366+ now + 1,1367+ )1368+ .unwrap_or_else(|error| {1369+ panic!(1370+ "enrolled viewer at revision {} must verify its own capability: {error}",1371+ authorized.authorization_revision1372+ )1373+ });1374+1375+ // The shipped defect, pinned: a client that assumes the1376+ // development-bypass constant rejects a real enrolled viewer.1377+ assert!(1378+ capability1379+ .verify_binding(1380+ *host.public().as_bytes(),1381+ generation_id,1382+ *peer.as_bytes(),1383+ SubscriptionTracks::VIDEO_H264,1384+ 1,1385+ now + 1,1386+ )1387+ .is_err(),1388+ "a hardcoded revision must not accept an enrolled viewer's capability"1389+ );1390+ }1391+ }1392+1393+ fn control_v2_snapshot(generation_id: u64) -> sigil_protocol::SessionSnapshotV2 {1394+ use sigil_protocol::{1395+ ControllerSlot, FocusStateV2, FocusTransitionReasonV2, MediaGenerationDescriptorV2,1396+ SessionSnapshotV2, ViewerPresenceId, ViewerPresenceV2,1397+ };1398+ let presence = ViewerPresenceId::new("viewer-0001").unwrap();1399+ SessionSnapshotV2 {1400+ revision: 1,1401+ self_presence_id: presence.clone(),1402+ viewers: vec![ViewerPresenceV2 {1403+ presence_id: presence,1404+ session_id: 7,1405+ input_capable: true,1406+ you: true,1407+ }],1408+ focus: FocusStateV2::Vacant {1409+ slot: ControllerSlot::ZERO,1410+ },1411+ focus_proposal: None,1412+ self_is_focus_owner: false,1413+ transition_reason: FocusTransitionReasonV2::Initial,1414+ media: MediaGenerationDescriptorV2 {1415+ generation_id,1416+ broadcast_name: sigil_protocol::media_generation_moq_broadcast_name(generation_id)1417+ .unwrap(),1418+ },1419+ }1420+ }1421+1422+ #[test]1423+ fn multiple_peers_enroll_with_unique_stable_handles() {1424+ let (_directory, host, first_peer, store) = store();1425+ let second_peer = SecretKey::from_bytes(&[19; 32]);1426+ let first_token = token(1427+ &store,1428+ &host,1429+ first_peer.public(),1430+ 1_000,1431+ [21; 32],1432+ InvitationGrants::ALL,1433+ );1434+ let first = store1435+ .authorize_or_redeem_viewer(first_peer.public(), Some(&first_token), 1_001)1436+ .unwrap();1437+ let second_token = token(1438+ &store,1439+ &host,1440+ second_peer.public(),1441+ 1_002,1442+ [22; 32],1443+ InvitationGrants::VIEW,1444+ );1445+ let second = store1446+ .authorize_or_redeem_viewer(second_peer.public(), Some(&second_token), 1_003)1447+ .unwrap();1448+1449+ assert_ne!(first.handle, second.handle);1450+ validate_viewer_handle(&first.handle).unwrap();1451+ validate_viewer_handle(&second.handle).unwrap();1452+ assert!(second.committed_revision > first.committed_revision);1453+ let snapshot = store.list_viewers().unwrap();1454+ assert_eq!(snapshot.viewers.len(), 2);1455+ assert_eq!(1456+ store1457+ .authorize_or_redeem_viewer(first_peer.public(), None, 1_004)1458+ .unwrap()1459+ .handle,1460+ first.handle1461+ );1462+ }1463+1464+ #[test]1465+ fn sole_writer_atomically_converts_v1_and_never_prefers_it_over_v2() {1466+ let directory = tempfile::tempdir().unwrap();1467+ #[cfg(unix)]1468+ fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();1469+ let host = SecretKey::from_bytes(&[27; 32]);1470+ let peer = SecretKey::from_bytes(&[28; 32]);1471+ let legacy = serde_json::json!({1472+ "version": 1,1473+ "host_node_id": host.public().to_string(),1474+ "enrollment_epoch": 4,1475+ "enrollment": {1476+ "peer_node_id": peer.public().to_string(),1477+ "grants": InvitationGrants::ALL.bits(),1478+ "enrolled_at_unix": 12341479+ },1480+ "consumed_invitations": [{1481+ "nonce": vec![31_u8; 32],1482+ "expires_at_unix": 20001483+ }]1484+ });1485+ let legacy_path = directory.path().join(LEGACY_STATE_FILE_NAME);1486+ fs::write(&legacy_path, serde_json::to_vec_pretty(&legacy).unwrap()).unwrap();1487+ #[cfg(unix)]1488+ fs::set_permissions(&legacy_path, fs::Permissions::from_mode(0o600)).unwrap();1489+1490+ let store = AuthorizationStore::open(directory.path(), host.public()).unwrap();1491+ let snapshot = store.list_viewers().unwrap();1492+ assert_eq!(snapshot.epoch, 4);1493+ assert_eq!(snapshot.viewers.len(), 1);1494+ assert_eq!(snapshot.viewers[0].peer, peer.public());1495+ assert!(directory.path().join(STATE_FILE_NAME).is_file());1496+ assert!(!legacy_path.exists());1497+1498+ // A stale legacy file appearing after conversion is ignored; v2 is the1499+ // only authority once its atomic replacement is present.1500+ fs::write(&legacy_path, b"{not valid json").unwrap();1501+ #[cfg(unix)]1502+ fs::set_permissions(&legacy_path, fs::Permissions::from_mode(0o600)).unwrap();1503+ drop(store);1504+ let reopened = AuthorizationStore::open(directory.path(), host.public()).unwrap();1505+ assert_eq!(reopened.list_viewers().unwrap().viewers.len(), 1);1506+ }1507+7751508 #[test]7761509 fn read_only_inspection_never_creates_state_and_retains_enrollment_time() {7771510 let root = tempfile::tempdir().unwrap();
A crates/sigil-host/src/authorization_admin.rs +623 -0
@@ -0,0 +1,623 @@
1+use std::path::{Path, PathBuf};2+use std::sync::Arc;3+use std::time::Duration;4+5+use anyhow::{Context, Result, ensure};6+use iroh::EndpointId;7+use serde::{Deserialize, Serialize};8+use sigil_protocol::{InvitationGrants, MAX_INVITATION_TTL_SECS, SignedInvitation};9+use tokio::io::{AsyncReadExt, AsyncWriteExt};10+11+use crate::authorization::{12+ AuthorizationMutation, AuthorizationStore, EnrolledViewer, grant_names, unix_timestamp_now,13+};14+use crate::secure_state;15+use crate::server::{InputOperations, SessionRegistry};16+17+pub const ADMIN_SOCKET_FILE: &str = "authorization-admin-v1.sock";18+const MAX_ADMIN_REQUEST_BYTES: u64 = 16 * 1024;19+const MAX_ADMIN_RESPONSE_BYTES: usize = 64 * 1024;20+const ADMIN_IO_TIMEOUT: Duration = Duration::from_secs(5);21+22+#[derive(Clone, Debug, Deserialize, Serialize)]23+#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)]24+pub enum AuthorizationAdminRequest {25+ List,26+ InspectRevision,27+ CreateInvitation {28+ peer_node_id: String,29+ grants: u8,30+ expires_in_seconds: u64,31+ },32+ RevokeViewer {33+ handle: String,34+ },35+ ReplaceGrants {36+ handle: String,37+ grants: Option<u8>,38+ },39+}40+41+#[derive(Clone, Debug, Deserialize, Serialize)]42+#[serde(tag = "result", rename_all = "snake_case", deny_unknown_fields)]43+pub enum AuthorizationAdminResult {44+ Viewers {45+ committed_revision: u64,46+ enrollment_epoch: u64,47+ viewers: Vec<AdminViewer>,48+ },49+ Revision {50+ committed_revision: u64,51+ },52+ Invitation {53+ token: String,54+ expires_at_unix: u64,55+ committed_revision: u64,56+ },57+ Mutation {58+ handle: String,59+ committed_revision: u64,60+ disconnected: bool,61+ input_neutralized: bool,62+ },63+}64+65+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]66+#[serde(deny_unknown_fields)]67+pub struct AdminViewer {68+ pub handle: String,69+ pub grants: Vec<String>,70+ pub enrolled_at_unix: u64,71+ pub authorization_revision: u64,72+}73+74+#[derive(Clone, Debug, Deserialize, Serialize)]75+#[serde(deny_unknown_fields)]76+struct AuthorizationAdminResponse {77+ ok: bool,78+ #[serde(skip_serializing_if = "Option::is_none")]79+ result: Option<AuthorizationAdminResult>,80+ #[serde(skip_serializing_if = "Option::is_none")]81+ error: Option<String>,82+}83+84+pub struct AuthorizationAdminServer {85+ socket_path: PathBuf,86+ shutdown: Option<tokio::sync::oneshot::Sender<()>>,87+ task: Option<tokio::task::JoinHandle<()>>,88+}89+90+impl AuthorizationAdminServer {91+ pub fn start(92+ state_directory: &Path,93+ store: AuthorizationStore,94+ sessions: Arc<SessionRegistry>,95+ input_operations: Arc<InputOperations>,96+ host_secret: [u8; 32],97+ ) -> Result<Self> {98+ #[cfg(not(unix))]99+ {100+ let _ = (101+ state_directory,102+ store,103+ sessions,104+ input_operations,105+ host_secret,106+ );107+ anyhow::bail!("authorization administration requires a Unix host");108+ }109+ #[cfg(unix)]110+ {111+ let socket_path =112+ secure_state::prepare_private_unix_socket(state_directory, ADMIN_SOCKET_FILE)?;113+ let listener = tokio::net::UnixListener::bind(&socket_path).with_context(|| {114+ format!(115+ "binding authorization administration socket {}",116+ socket_path.display()117+ )118+ })?;119+ secure_state::secure_private_unix_socket(&socket_path)?;120+ let (shutdown, mut shutdown_rx) = tokio::sync::oneshot::channel();121+ let task_path = socket_path.clone();122+ let task = tokio::spawn(async move {123+ loop {124+ tokio::select! {125+ _ = &mut shutdown_rx => break,126+ accepted = listener.accept() => {127+ match accepted {128+ Ok((stream, _address)) => {129+ if let Err(error) = serve_request(130+ stream,131+ &store,132+ &sessions,133+ &input_operations,134+ host_secret,135+ ).await {136+ tracing::warn!(%error, "authorization administration request failed");137+ }138+ }139+ Err(error) => {140+ tracing::warn!(%error, "authorization administration listener failed");141+ break;142+ }143+ }144+ }145+ }146+ }147+ let _ = std::fs::remove_file(&task_path);148+ });149+ Ok(Self {150+ socket_path,151+ shutdown: Some(shutdown),152+ task: Some(task),153+ })154+ }155+ }156+157+ pub async fn shutdown(mut self) {158+ if let Some(shutdown) = self.shutdown.take() {159+ let _ = shutdown.send(());160+ }161+ if let Some(task) = self.task.take() {162+ let _ = task.await;163+ }164+ }165+}166+167+impl Drop for AuthorizationAdminServer {168+ fn drop(&mut self) {169+ if let Some(shutdown) = self.shutdown.take() {170+ let _ = shutdown.send(());171+ }172+ if let Some(task) = self.task.take() {173+ task.abort();174+ }175+ let _ = std::fs::remove_file(&self.socket_path);176+ }177+}178+179+#[cfg(unix)]180+async fn serve_request(181+ mut stream: tokio::net::UnixStream,182+ store: &AuthorizationStore,183+ sessions: &SessionRegistry,184+ input_operations: &InputOperations,185+ host_secret: [u8; 32],186+) -> Result<()> {187+ let response = match tokio::time::timeout(ADMIN_IO_TIMEOUT, async {188+ let mut bytes = Vec::new();189+ (&mut stream)190+ .take(MAX_ADMIN_REQUEST_BYTES + 1)191+ .read_to_end(&mut bytes)192+ .await?;193+ ensure!(194+ !bytes.is_empty() && bytes.len() as u64 <= MAX_ADMIN_REQUEST_BYTES,195+ "authorization administration request is empty or oversized"196+ );197+ let request: AuthorizationAdminRequest = serde_json::from_slice(&bytes)198+ .context("parsing authorization administration request")?;199+ handle_request(request, store, sessions, input_operations, host_secret).await200+ })201+ .await202+ {203+ Ok(Ok(result)) => AuthorizationAdminResponse {204+ ok: true,205+ result: Some(result),206+ error: None,207+ },208+ Ok(Err(error)) => AuthorizationAdminResponse {209+ ok: false,210+ result: None,211+ error: Some(format!("{error:#}")),212+ },213+ Err(_) => AuthorizationAdminResponse {214+ ok: false,215+ result: None,216+ error: Some("authorization administration request timed out".to_owned()),217+ },218+ };219+ let encoded = serde_json::to_vec(&response)?;220+ ensure!(221+ encoded.len() <= MAX_ADMIN_RESPONSE_BYTES,222+ "authorization administration response exceeds its fixed bound"223+ );224+ tokio::time::timeout(ADMIN_IO_TIMEOUT, async {225+ stream.write_all(&encoded).await?;226+ stream.shutdown().await227+ })228+ .await229+ .context("timed out writing authorization administration response")??;230+ Ok(())231+}232+233+async fn handle_request(234+ request: AuthorizationAdminRequest,235+ store: &AuthorizationStore,236+ sessions: &SessionRegistry,237+ input_operations: &InputOperations,238+ host_secret: [u8; 32],239+) -> Result<AuthorizationAdminResult> {240+ match request {241+ AuthorizationAdminRequest::List => {242+ let snapshot = store.list_viewers()?;243+ Ok(AuthorizationAdminResult::Viewers {244+ committed_revision: snapshot.committed_revision,245+ enrollment_epoch: snapshot.epoch,246+ viewers: snapshot.viewers.iter().map(AdminViewer::from).collect(),247+ })248+ }249+ AuthorizationAdminRequest::InspectRevision => {250+ let snapshot = store.list_viewers()?;251+ Ok(AuthorizationAdminResult::Revision {252+ committed_revision: snapshot.committed_revision,253+ })254+ }255+ AuthorizationAdminRequest::CreateInvitation {256+ peer_node_id,257+ grants,258+ expires_in_seconds,259+ } => {260+ let peer = peer_node_id261+ .parse::<EndpointId>()262+ .context("invitation peer node ID is invalid")?;263+ let grants = InvitationGrants::new(grants)?;264+ ensure!(265+ grants.contains(InvitationGrants::VIEW),266+ "invitations must grant view permission"267+ );268+ ensure!(269+ (1..=MAX_INVITATION_TTL_SECS).contains(&expires_in_seconds),270+ "invitation TTL is outside the fixed bound"271+ );272+ let snapshot = store.list_viewers()?;273+ let mut nonce = [0_u8; 32];274+ getrandom::fill(&mut nonce).context("generating invitation nonce")?;275+ let now = unix_timestamp_now()?;276+ let host = iroh::SecretKey::from_bytes(&host_secret).public();277+ let claims = AuthorizationStore::issue_claims_from_snapshot(278+ host,279+ snapshot.clone(),280+ peer,281+ grants,282+ expires_in_seconds,283+ now,284+ nonce,285+ )?;286+ let expires_at_unix = claims.expires_at_unix;287+ let token = SignedInvitation::issue(claims, &host_secret)?.encode();288+ Ok(AuthorizationAdminResult::Invitation {289+ token,290+ expires_at_unix,291+ committed_revision: snapshot.committed_revision,292+ })293+ }294+ AuthorizationAdminRequest::RevokeViewer { handle } => {295+ let mutation = store.revoke_viewer(&handle, unix_timestamp_now()?)?;296+ apply_mutation(mutation, sessions, input_operations)297+ }298+ AuthorizationAdminRequest::ReplaceGrants { handle, grants } => {299+ let grants = grants.map(InvitationGrants::new).transpose()?;300+ let mutation = store.replace_viewer_grants(&handle, grants, unix_timestamp_now()?)?;301+ apply_mutation(mutation, sessions, input_operations)302+ }303+ }304+}305+306+fn apply_mutation(307+ mutation: AuthorizationMutation,308+ sessions: &SessionRegistry,309+ input_operations: &InputOperations,310+) -> Result<AuthorizationAdminResult> {311+ let effect = sessions.apply_authorization_mutation(&mutation)?;312+ if effect.neutralize_input {313+ if let Some(transition_id) = effect.focus_transition_id {314+ input_operations.neutralize_focus_transition(315+ sessions,316+ transition_id,317+ sigil_protocol::FocusTransitionReasonV2::Revoked,318+ )?;319+ } else {320+ input_operations.reset()?;321+ }322+ sessions.complete_authorization_neutralization(323+ mutation.peer,324+ mutation.authorization_revision,325+ None,326+ )?;327+ }328+ Ok(AuthorizationAdminResult::Mutation {329+ handle: mutation.handle,330+ committed_revision: mutation.committed_revision,331+ disconnected: effect.disconnected,332+ input_neutralized: effect.neutralize_input,333+ })334+}335+336+impl From<&EnrolledViewer> for AdminViewer {337+ fn from(viewer: &EnrolledViewer) -> Self {338+ Self {339+ handle: viewer.handle.clone(),340+ grants: grant_names(viewer.grants)341+ .split(',')342+ .map(str::to_owned)343+ .collect(),344+ enrolled_at_unix: viewer.enrolled_at_unix,345+ authorization_revision: viewer.authorization_revision,346+ }347+ }348+}349+350+#[cfg(unix)]351+pub async fn request(352+ state_directory: &Path,353+ request: &AuthorizationAdminRequest,354+) -> Result<AuthorizationAdminResult> {355+ secure_state::validate_private_directory(state_directory)?;356+ let socket_path = state_directory.join(ADMIN_SOCKET_FILE);357+ let metadata = std::fs::symlink_metadata(&socket_path)358+ .with_context(|| format!("inspecting administration socket {}", socket_path.display()))?;359+ use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _};360+ ensure!(361+ metadata.file_type().is_socket()362+ && !metadata.file_type().is_symlink()363+ && metadata.uid() == unsafe { libc::geteuid() }364+ && metadata.mode() & 0o077 == 0,365+ "authorization administration socket is unsafe"366+ );367+ let encoded = serde_json::to_vec(request)?;368+ ensure!(369+ encoded.len() as u64 <= MAX_ADMIN_REQUEST_BYTES,370+ "authorization administration request exceeds its fixed bound"371+ );372+ let mut stream = tokio::time::timeout(373+ ADMIN_IO_TIMEOUT,374+ tokio::net::UnixStream::connect(&socket_path),375+ )376+ .await377+ .context("timed out connecting to authorization administration socket")??;378+ tokio::time::timeout(ADMIN_IO_TIMEOUT, async {379+ stream.write_all(&encoded).await?;380+ stream.shutdown().await381+ })382+ .await383+ .context("timed out writing authorization administration request")??;384+ let mut response = Vec::new();385+ tokio::time::timeout(386+ ADMIN_IO_TIMEOUT,387+ (&mut stream)388+ .take(MAX_ADMIN_RESPONSE_BYTES as u64 + 1)389+ .read_to_end(&mut response),390+ )391+ .await392+ .context("timed out reading authorization administration response")??;393+ ensure!(394+ !response.is_empty() && response.len() <= MAX_ADMIN_RESPONSE_BYTES,395+ "authorization administration response is empty or oversized"396+ );397+ let response: AuthorizationAdminResponse = serde_json::from_slice(&response)398+ .context("parsing authorization administration response")?;399+ if response.ok {400+ response401+ .result402+ .context("successful administration response has no result")403+ } else {404+ anyhow::bail!(405+ "{}",406+ response407+ .error408+ .unwrap_or_else(|| "authorization administration request failed".to_owned())409+ )410+ }411+}412+413+#[cfg(all(test, unix))]414+mod tests {415+ use super::*;416+ use crate::config::{HostConfig, InputMode, VideoSource};417+ use crate::input::InputBackend;418+ use sigil_protocol::{ControllerSlot, FocusCommandActionV2, FocusCommandV2};419+ use std::fs;420+ use std::os::unix::fs::PermissionsExt;421+422+ fn disabled_input_backend(state_path: &Path) -> InputBackend {423+ InputBackend::initialize(&HostConfig {424+ identity_path: state_path.join("unused-identity"),425+ state_path: state_path.to_path_buf(),426+ max_viewers: crate::config::DEFAULT_MAX_VIEWERS,427+ focus_owner: None,428+ source: VideoSource::TestPattern,429+ width: Some(1280),430+ height: Some(800),431+ framerate: 60,432+ codec: "h264".to_owned(),433+ input_mode: InputMode::Disabled,434+ uinput: None,435+ ffmpeg_path: PathBuf::from("ffmpeg"),436+ gamescope_pipewire: None,437+ audio: None,438+ })439+ .unwrap()440+ }441+442+ async fn issue_and_redeem(443+ directory: &Path,444+ store: &AuthorizationStore,445+ peer: EndpointId,446+ grants: InvitationGrants,447+ ) -> crate::authorization::AuthorizedViewer {448+ let result = request(449+ directory,450+ &AuthorizationAdminRequest::CreateInvitation {451+ peer_node_id: peer.to_string(),452+ grants: grants.bits(),453+ expires_in_seconds: 600,454+ },455+ )456+ .await457+ .unwrap();458+ let AuthorizationAdminResult::Invitation { token, .. } = result else {459+ panic!("unexpected invitation response");460+ };461+ store462+ .authorize_or_redeem_viewer(peer, Some(&token), unix_timestamp_now().unwrap())463+ .unwrap()464+ }465+466+ #[tokio::test]467+ async fn socket_administers_multiple_viewers_and_persists_mutations() {468+ let directory = tempfile::tempdir().unwrap();469+ fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();470+ let host = iroh::SecretKey::from_bytes(&[7; 32]);471+ let peer_one = iroh::SecretKey::from_bytes(&[8; 32]).public();472+ let peer_two = iroh::SecretKey::from_bytes(&[9; 32]).public();473+ let store = AuthorizationStore::open(directory.path(), host.public()).unwrap();474+ let sessions = Arc::new(SessionRegistry::default());475+ let operations = Arc::new(InputOperations::new(disabled_input_backend(476+ directory.path(),477+ )));478+ let server = AuthorizationAdminServer::start(479+ directory.path(),480+ store.clone(),481+ Arc::clone(&sessions),482+ operations,483+ host.to_bytes(),484+ )485+ .unwrap();486+487+ let first =488+ issue_and_redeem(directory.path(), &store, peer_one, InvitationGrants::ALL).await;489+ let second =490+ issue_and_redeem(directory.path(), &store, peer_two, InvitationGrants::VIEW).await;491+ assert_ne!(first.handle, second.handle);492+493+ let AuthorizationAdminResult::Viewers {494+ viewers,495+ committed_revision,496+ ..497+ } = request(directory.path(), &AuthorizationAdminRequest::List)498+ .await499+ .unwrap()500+ else {501+ panic!("unexpected list response");502+ };503+ assert_eq!(viewers.len(), 2);504+ assert!(committed_revision >= 3);505+ assert!(506+ viewers507+ .iter()508+ .all(|viewer| !viewer.handle.contains(&peer_one.to_string()))509+ );510+511+ request(512+ directory.path(),513+ &AuthorizationAdminRequest::ReplaceGrants {514+ handle: first.handle.clone(),515+ grants: Some(InvitationGrants::VIEW.bits()),516+ },517+ )518+ .await519+ .unwrap();520+ assert!(521+ sessions522+ .claim_v2_authorized(peer_one, [9; 16], first.clone())523+ .is_err(),524+ "an admission decision from before the committed mutation must be stale"525+ );526+ request(527+ directory.path(),528+ &AuthorizationAdminRequest::RevokeViewer {529+ handle: second.handle,530+ },531+ )532+ .await533+ .unwrap();534+535+ server.shutdown().await;536+ let snapshot = store.list_viewers().unwrap();537+ assert_eq!(snapshot.viewers.len(), 1);538+ assert_eq!(snapshot.viewers[0].handle, first.handle);539+ assert_eq!(snapshot.viewers[0].grants, InvitationGrants::VIEW);540+ assert!(directory.path().join("authorization-v2.json").is_file());541+ assert!(!directory.path().join(ADMIN_SOCKET_FILE).exists());542+ }543+544+ #[tokio::test]545+ async fn committed_reduction_defocuses_then_view_revocation_disconnects() {546+ let directory = tempfile::tempdir().unwrap();547+ fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();548+ let host = iroh::SecretKey::from_bytes(&[17; 32]);549+ let peer = iroh::SecretKey::from_bytes(&[18; 32]).public();550+ let store = AuthorizationStore::open(directory.path(), host.public()).unwrap();551+ let sessions = Arc::new(SessionRegistry::default());552+ let operations = Arc::new(InputOperations::new(disabled_input_backend(553+ directory.path(),554+ )));555+ let server = AuthorizationAdminServer::start(556+ directory.path(),557+ store.clone(),558+ Arc::clone(&sessions),559+ Arc::clone(&operations),560+ host.to_bytes(),561+ )562+ .unwrap();563+ let authorized =564+ issue_and_redeem(directory.path(), &store, peer, InvitationGrants::ALL).await;565+ let handle = authorized.handle.clone();566+ let media = sessions567+ .claim_v2_authorized(peer, [3; 16], authorized)568+ .unwrap();569+ let granted = sessions570+ .apply_focus_command(571+ peer,572+ media.session_id,573+ &FocusCommandV2 {574+ request_id: 1,575+ action: FocusCommandActionV2::Request,576+ slot: ControllerSlot::ZERO,577+ expected_revision: 1,578+ expected_focus_generation: None,579+ expected_proposal_id: None,580+ },581+ )582+ .unwrap();583+ let focus_generation = granted.snapshot.self_focus_generation().unwrap();584+585+ let AuthorizationAdminResult::Mutation {586+ input_neutralized,587+ disconnected,588+ ..589+ } = request(590+ directory.path(),591+ &AuthorizationAdminRequest::ReplaceGrants {592+ handle: handle.clone(),593+ grants: Some(InvitationGrants::VIEW.bits()),594+ },595+ )596+ .await597+ .unwrap()598+ else {599+ panic!("unexpected grant mutation response");600+ };601+ assert!(input_neutralized);602+ assert!(!disconnected);603+ assert!(!sessions.is_v2_focus_owner(604+ peer,605+ media.session_id,606+ ControllerSlot::ZERO,607+ focus_generation608+ ));609+ assert!(sessions.is_active(peer, media.session_id));610+611+ let AuthorizationAdminResult::Mutation { disconnected, .. } = request(612+ directory.path(),613+ &AuthorizationAdminRequest::RevokeViewer { handle },614+ )615+ .await616+ .unwrap() else {617+ panic!("unexpected revoke response");618+ };619+ assert!(disconnected);620+ assert!(!sessions.is_active(peer, media.session_id));621+ server.shutdown().await;622+ }623+}
M crates/sigil-host/src/bin/moq_catalog.rs +68 -1
@@ -2,10 +2,14 @@ use std::time::Duration;
22 33 use anyhow::{Context, Result};44 use moq_net::{BroadcastConsumer, Error as MoqError, Track, TrackConsumer};5-use sigil_protocol::{GoqCatalogDocument, MAX_MOQ_CATALOG_BYTES};5+use sigil_protocol::{6+ GoqCatalogDocument, GoqCatalogDocumentV2, MAX_MOQ_CATALOG_BYTES,7+ SignedMediaGenerationCertificate,8+};69 710 #[derive(Clone, Copy, Debug, PartialEq, Eq)]811 pub(crate) enum MoqCatalogMode {12+ GoqV2Authenticated,913 GoqV1,1014 AbsentStaticTrackCompat,1115 }
@@ -13,6 +17,7 @@ pub(crate) enum MoqCatalogMode {
1317 impl MoqCatalogMode {1418 pub(crate) const fn label(self) -> &'static str {1519 match self {20+ Self::GoqV2Authenticated => "goq-v2-authenticated",1621 Self::GoqV1 => "goq-v1",1722 Self::AbsentStaticTrackCompat => "absent-static-track-compat",1823 }
@@ -22,6 +27,7 @@ impl MoqCatalogMode {
2227 pub(crate) struct MoqCatalogSelection {2328 pub(crate) track: TrackConsumer,2429 pub(crate) mode: MoqCatalogMode,30+ pub(crate) certificate: Option<SignedMediaGenerationCertificate>,2531 }2632 2733 fn is_track_not_found(error: &MoqError) -> bool {
@@ -36,6 +42,7 @@ fn subscribe_static_video_track(broadcast: &BroadcastConsumer) -> Result<MoqCata
3642 Ok(MoqCatalogSelection {3743 track,3844 mode: MoqCatalogMode::AbsentStaticTrackCompat,45+ certificate: None,3946 })4047 }4148
@@ -98,6 +105,66 @@ pub(crate) async fn subscribe_goq_video_track(
98105 Ok(MoqCatalogSelection {99106 track,100107 mode: MoqCatalogMode::GoqV1,108+ certificate: None,109+ })110+}111+112+pub(crate) async fn subscribe_authenticated_goq_video_track(113+ broadcast: &BroadcastConsumer,114+ timeout: Duration,115+ expected_host: [u8; 32],116+ expected_generation: u64,117+ now_unix: u64,118+) -> Result<MoqCatalogSelection> {119+ let mut catalog_track = broadcast120+ .subscribe_track(&hang::Catalog::default_track())121+ .context("authenticated v2 media requires catalog.json")?;122+ let deadline = tokio::time::Instant::now() + timeout;123+ let mut group = tokio::time::timeout_at(deadline, catalog_track.next_group())124+ .await125+ .context("timed out waiting for authenticated Goq catalog")?126+ .context("reading authenticated Goq catalog")?127+ .context("authenticated Goq catalog ended before its snapshot")?;128+ anyhow::ensure!(129+ group.sequence == 0,130+ "authenticated Goq catalog must be immutable group 0"131+ );132+ let frame_count = tokio::time::timeout_at(deadline, group.finished())133+ .await134+ .context("timed out finishing authenticated Goq catalog")?135+ .context("finishing authenticated Goq catalog")?;136+ anyhow::ensure!(137+ frame_count == 1,138+ "authenticated Goq catalog must contain one frame"139+ );140+ let mut frame = tokio::time::timeout_at(deadline, group.get_frame(0))141+ .await142+ .context("timed out opening authenticated Goq catalog")?143+ .context("opening authenticated Goq catalog")?144+ .context("authenticated Goq catalog has no frame 0")?;145+ anyhow::ensure!(146+ frame.size <= MAX_MOQ_CATALOG_BYTES as u64,147+ "authenticated Goq catalog exceeds {MAX_MOQ_CATALOG_BYTES} bytes"148+ );149+ let snapshot = tokio::time::timeout_at(deadline, frame.read_all())150+ .await151+ .context("timed out reading authenticated Goq catalog")?152+ .context("reading authenticated Goq catalog")?;153+ let document: GoqCatalogDocumentV2 =154+ serde_json::from_slice(&snapshot).context("decoding authenticated Goq catalog")?;155+ let certificate = document156+ .validate()157+ .context("validating authenticated Goq catalog")?;158+ certificate159+ .verify_binding(expected_host, expected_generation, now_unix)160+ .context("verifying media generation certificate binding")?;161+ let track = broadcast162+ .subscribe_track(&Track::new(document.goq.video.track.name))163+ .context("subscribing to authenticated Goq video track")?;164+ Ok(MoqCatalogSelection {165+ track,166+ mode: MoqCatalogMode::GoqV2Authenticated,167+ certificate: Some(certificate),101168 })102169 }103170
M crates/sigil-host/src/bin/sigil-probe.rs +896 -15
@@ -8,20 +8,27 @@ use std::{
88 };99 1010 use anyhow::{Context, Result, bail, ensure};11-use clap::Parser;11+use clap::{Parser, ValueEnum};1212 use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, endpoint::presets};1313 use iroh_moq::{Moq, MoqSession};1414 use moq_net::{BroadcastConsumer, GroupConsumer, TrackConsumer};1515 use sigil_protocol::{1616 AdaptiveBitrateDecisionV1, AdaptiveBitrateReasonFlagsV1, AdaptiveBitrateStateV1,17- CONTROL_ALPN_V1, Capability, ClientHello, FrameFlags, GAMEPAD_AXIS_MAX, GAMEPAD_AXIS_MIN,18- GAMEPAD_TRIGGER_MAX, GamepadState, INPUT_ALPN_V1, INVITATION_CLOCK_SKEW_SECS, InputAck,19- InputEvent, InvitationGrants, KeyframeRequestReasonV3, MAX_INVITATION_TOKEN_LEN,20- MAX_MEDIA_GROUP_BYTES_V3, MAX_MEDIA_OBJECT_ID_V3, MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1,21- MediaCodec, MediaControlRequestV3, MediaFeedbackFlags, MediaFeedbackReportV1, MediaFrame,22- MediaObjectV3, PointerPosition, PointerSurfaceDimensions, ProtocolError, SignedInvitation,23- decode_media_frame_object, media_moq_broadcast_name, read_adaptive_bitrate_decision_v1,24- read_host_hello, read_input_ack, read_media_object_v3, write_client_hello, write_input_event,17+ AuthenticatedMediaObject, CONTROL_ALPN_V1, CONTROL_ALPN_V2, Capability,18+ ClientControlEnvelopeV2, ClientHello, ClientHelloV2, ControllerSlot, FocusCommandActionV2,19+ FocusCommandV2, FocusStateV2, FrameFlags, GAMEPAD_AXIS_MAX, GAMEPAD_AXIS_MIN,20+ GAMEPAD_TRIGGER_MAX, GamepadState, INPUT_ALPN_V1, INPUT_ALPN_V2, INVITATION_CLOCK_SKEW_SECS,21+ InputAck, InputClientHelloV2, InputEvent, InputEventV2, InvitationGrants,22+ KeyframeRequestReasonV3, MAX_INVITATION_TOKEN_LEN, MAX_MEDIA_GROUP_BYTES_V3,23+ MAX_MEDIA_OBJECT_ID_V3, MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1, MediaCodec,24+ MediaControlRequestV3, MediaFeedbackFlags, MediaFeedbackReportV1, MediaFrame,25+ MediaObjectCoordinates, MediaObjectV3, MediaTrack, PointerPosition, PointerSurfaceDimensions,26+ ProtocolError, ServerControlEnvelopeV2, SignedInvitation, SignedMediaGenerationCertificate,27+ SignedSubscriptionCapability, SubscriptionTracks, decode_media_frame_object,28+ media_moq_broadcast_name, read_adaptive_bitrate_decision_v1, read_host_hello,29+ read_host_hello_v2, read_input_ack, read_input_ack_v2, read_input_host_hello_v2,30+ read_media_object_v3, read_server_control_v2, write_client_control_v2, write_client_hello,31+ write_client_hello_v2, write_input_client_hello_v2, write_input_event, write_input_event_v2,2532 write_media_control_request_v3, write_media_feedback_report_v1,2633 };2734
@@ -34,10 +41,19 @@ mod identity;
3441 3542 mod moq_catalog;3643 37-use moq_catalog::{MoqCatalogMode, subscribe_goq_video_track};44+use moq_catalog::{45+ MoqCatalogMode, subscribe_authenticated_goq_video_track, subscribe_goq_video_track,46+};3847 3948 const MEDIA_OBJECT_CAPACITY: usize = 4;4049 50+#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]51+enum AdaptiveProfile {52+ Clean,53+ FloorBreach,54+ Stale,55+}56+4157 #[derive(Debug, Parser)]4258 #[command(name = "sigil-probe", version, about = "Bounded Sigil transport probe")]4359 struct Args {
@@ -70,6 +86,29 @@ struct Args {
7086 /// for compatibility validation.7187 #[arg(long)]7288 media_v3: bool,89+ /// Exercise the explicit revisioned control/2 and focus-bound input/2 path.90+ #[arg(long, conflicts_with = "media_v3")]91+ control_v2: bool,92+ /// Join control/2 as a view-only exercise without requesting slot-0 focus.93+ #[arg(long, requires = "control_v2")]94+ spectator: bool,95+ /// End focused v2 proof by disconnecting instead of issuing a release96+ /// against a roster revision that may advance while spectators join.97+ #[arg(long, requires = "control_v2", conflicts_with = "spectator")]98+ disconnect_with_focus: bool,99+ /// Participate in bounded holder-approved slot-0 handoffs and stop after100+ /// observing this many successor generations.101+ #[arg(long, default_value_t = 0, requires = "control_v2")]102+ focus_handoffs: u32,103+ /// Drive a bounded per-viewer adaptive profile on control/2. Clean viewers104+ /// prove survivor recovery; floor-breach viewers prove isolated detach.105+ #[arg(106+ long,107+ value_enum,108+ requires = "control_v2",109+ conflicts_with = "focus_handoffs"110+ )]111+ adaptive_profile: Option<AdaptiveProfile>,73112 /// Request a configured recovery keyframe after three accepted frames,74113 /// then prove no delta history is delivered before the recovery barrier.75114 #[arg(long)]
@@ -494,6 +533,14 @@ struct MoqProbeReceiver {
494533 maximum_group_objects: usize,495534 maximum_group_bytes: usize,496535 historical_suffix_frames: u64,536+ certificate: Option<SignedMediaGenerationCertificate>,537+}538+539+struct MoqProbeAuthentication {540+ expected_host: [u8; 32],541+ subscription_capability: String,542+ authorization_revision: u64,543+ now_unix: u64,497544 }498545 499546 impl MoqProbeReceiver {
@@ -501,10 +548,16 @@ impl MoqProbeReceiver {
501548 endpoint: &Endpoint,502549 address: EndpointAddr,503550 session_id: u64,551+ generation_id: u64,552+ advertised_broadcast_name: Option<&str>,504553 timeout: Duration,554+ authentication: Option<MoqProbeAuthentication>,505555 ) -> Result<Self> {506- let broadcast_name = media_moq_broadcast_name(session_id)507- .context("deriving session-scoped MoQ broadcast name")?;556+ let broadcast_name = match advertised_broadcast_name {557+ Some(name) => name.to_owned(),558+ None => media_moq_broadcast_name(session_id)559+ .context("deriving session-scoped MoQ broadcast name")?,560+ };508561 let moq = Moq::new(endpoint.clone());509562 let mut session = tokio::time::timeout(timeout, moq.connect(address.clone()))510563 .await
@@ -522,9 +575,30 @@ impl MoqProbeReceiver {
522575 format!("timed out subscribing to upstream MoQ broadcast {broadcast_name}")523576 })?524577 .with_context(|| format!("subscribing to upstream MoQ broadcast {broadcast_name}"))?;525- let catalog = subscribe_goq_video_track(&broadcast, timeout)578+ let catalog = if let Some(authentication) = authentication {579+ SignedSubscriptionCapability::decode(&authentication.subscription_capability)?580+ .verify_binding(581+ authentication.expected_host,582+ generation_id,583+ *endpoint.id().as_bytes(),584+ SubscriptionTracks::VIDEO_H264,585+ authentication.authorization_revision,586+ authentication.now_unix,587+ )?;588+ subscribe_authenticated_goq_video_track(589+ &broadcast,590+ timeout,591+ authentication.expected_host,592+ generation_id,593+ authentication.now_unix,594+ )526595 .await527- .context("resolving Goq MoQ catalog")?;596+ .context("resolving authenticated Goq MoQ catalog")?597+ } else {598+ subscribe_goq_video_track(&broadcast, timeout)599+ .await600+ .context("resolving Goq MoQ catalog")?601+ };528602 Ok(Self {529603 lifetime: MoqProbeLifetime {530604 _moq: moq,
@@ -543,6 +617,7 @@ impl MoqProbeReceiver {
543617 maximum_group_objects: 0,544618 maximum_group_bytes: 0,545619 historical_suffix_frames: 0,620+ certificate: catalog.certificate,546621 })547622 }548623
@@ -629,12 +704,53 @@ impl MoqProbeReceiver {
629704 cursor.object_bytes,630705 object.len(),631706 )?;632- let frame = decode_media_frame_object(&object).with_context(|| {707+ let authenticated_coordinates = self708+ .certificate709+ .as_ref()710+ .map(|_| AuthenticatedMediaObject::coordinates(&object))711+ .transpose()712+ .with_context(|| {713+ format!(714+ "decoding authenticated upstream MoQ group {} object {}",715+ cursor.sequence, cursor.object_count716+ )717+ })?;718+ let payload = if let (Some(certificate), Some(coordinates)) =719+ (&self.certificate, authenticated_coordinates)720+ {721+ AuthenticatedMediaObject::verify(722+ &object,723+ certificate,724+ MediaObjectCoordinates {725+ generation_id: certificate.claims.generation_id,726+ track: MediaTrack::VideoH264,727+ group_id: cursor.sequence,728+ object_id: u32::try_from(cursor.object_count)729+ .context("authenticated MoQ object index overflow")?,730+ flags: coordinates.flags,731+ },732+ )733+ .with_context(|| {734+ format!(735+ "verifying authenticated upstream MoQ group {} object {}",736+ cursor.sequence, cursor.object_count737+ )738+ })?739+ } else {740+ object.as_ref()741+ };742+ let frame = decode_media_frame_object(payload).with_context(|| {633743 format!(634744 "decoding upstream MoQ group {} object {}",635745 cursor.sequence, cursor.object_count636746 )637747 })?;748+ ensure!(749+ authenticated_coordinates.is_none_or(|coordinates| {750+ coordinates.flags == u16::from(frame.header.flags.bits())751+ }),752+ "authenticated media flags do not match the decoded media envelope"753+ );638754 let first_object = cursor.object_count == 0;639755 let contiguous = validate_moq_probe_frame(640756 cursor.sequence,
@@ -667,6 +783,624 @@ impl MoqProbeReceiver {
667783 }668784 }669785 786+#[allow(clippy::too_many_arguments)]787+async fn run_control_v2_smoke(788+ endpoint: &Endpoint,789+ address: EndpointAddr,790+ nonce: [u8; 16],791+ invitation: Option<&str>,792+ frames: u32,793+ timeout_seconds: u64,794+ expected_size: Option<(u16, u16)>,795+ spectator: bool,796+ disconnect_with_focus: bool,797+ focus_handoffs: u32,798+ adaptive_profile: Option<AdaptiveProfile>,799+) -> Result<()> {800+ let control_connection = endpoint801+ .connect(address.clone(), CONTROL_ALPN_V2)802+ .await803+ .context("connecting control v2 protocol")?;804+ let (mut control_send, mut control_recv) = control_connection805+ .open_bi()806+ .await807+ .context("opening control v2 stream")?;808+ let mut hello = ClientHelloV2::new("sigil-probe/0.1.0", nonce, vec![Capability::VideoH264]);809+ if let Some(invitation) = invitation {810+ hello = hello.with_invitation(invitation);811+ }812+ write_client_hello_v2(&mut control_send, &hello)813+ .await814+ .context("writing control v2 hello")?;815+ let host = tokio::time::timeout(816+ Duration::from_secs(timeout_seconds),817+ read_host_hello_v2(&mut control_recv),818+ )819+ .await820+ .context("timed out waiting for control v2 hello")??821+ .context("host closed during control v2 hello")?;822+ if !host.accepted {823+ bail!(824+ "host rejected control v2 stream: {}",825+ host.message.as_deref().unwrap_or("unspecified reason")826+ );827+ }828+ let session_id = host829+ .session_id830+ .context("host omitted control v2 session id")?;831+ let subscription_capability = host832+ .media_subscription_capability833+ .context("host omitted control v2 media subscription capability")?;834+ let media_authorization_revision = host835+ .media_authorization_revision836+ .context("host omitted control v2 media authorization revision")?;837+ let initial = host838+ .snapshot839+ .context("host omitted initial control v2 snapshot")?;840+ ensure!(841+ initial.revision > 0,842+ "initial control v2 snapshot was not revisioned"843+ );844+ if !spectator && focus_handoffs == 0 {845+ ensure!(846+ matches!(initial.focus, FocusStateV2::Vacant { .. }),847+ "initial control v2 snapshot did not advertise a vacant slot"848+ );849+ }850+ let generation_id = initial.media.generation_id;851+ let broadcast_name = initial.media.broadcast_name.clone();852+853+ let mut media = MoqProbeReceiver::connect(854+ endpoint,855+ address.clone(),856+ session_id,857+ generation_id,858+ Some(&broadcast_name),859+ Duration::from_secs(timeout_seconds),860+ Some(MoqProbeAuthentication {861+ expected_host: *address.id.as_bytes(),862+ subscription_capability,863+ authorization_revision: media_authorization_revision,864+ now_unix: SystemTime::now()865+ .duration_since(UNIX_EPOCH)866+ .context("system clock is before the Unix epoch")?867+ .as_secs(),868+ }),869+ )870+ .await?;871+ if focus_handoffs > 0 {872+ return run_focus_handoff_participant(873+ control_connection,874+ control_send,875+ control_recv,876+ media,877+ initial,878+ focus_handoffs,879+ frames,880+ timeout_seconds,881+ )882+ .await;883+ }884+ if spectator {885+ if let Some(profile) = adaptive_profile {886+ let mut accepted = 0_u32;887+ let mut last_sequence = None;888+ let feedback =889+ run_v2_adaptive_profile(endpoint, address.clone(), nonce, session_id, profile);890+ tokio::pin!(feedback);891+ let mut media_open = true;892+ let evidence = loop {893+ tokio::select! {894+ evidence = &mut feedback => break evidence?,895+ outcome = media.next(), if media_open => {896+ let Some(outcome) = outcome? else {897+ if profile == AdaptiveProfile::FloorBreach {898+ media_open = false;899+ continue;900+ }901+ bail!("adaptive-profile spectator media ended early");902+ };903+ let MoqProbeOutcome::Frame { frame, .. } = outcome else {904+ continue;905+ };906+ if let Some((width, height)) = expected_size {907+ ensure!(908+ frame.header.width == width && frame.header.height == height,909+ "adaptive-profile media dimensions did not match {width}x{height}"910+ );911+ }912+ if let Some(previous) = last_sequence {913+ ensure!(914+ frame.header.sequence > previous,915+ "adaptive-profile media sequence regressed"916+ );917+ }918+ last_sequence = Some(frame.header.sequence);919+ accepted += 1;920+ }921+ }922+ };923+ ensure!(924+ accepted >= frames.min(60),925+ "adaptive profile did not preserve media progress"926+ );927+ control_connection.close(0_u32.into(), b"adaptive profile complete");928+ println!("probe=ok");929+ println!("transport=iroh-moq");930+ println!("control_alpn=sigil/control/2");931+ println!("media_generation_id={generation_id}");932+ println!("media_broadcast_name={broadcast_name}");933+ println!("roster_viewers={}", initial.viewers.len());934+ println!("frames={accepted}");935+ println!("spectator=ok");936+ println!("adaptive_profile={}", profile.label());937+ println!("adaptive_minimum_kbps={}", evidence.minimum_kbps);938+ println!("adaptive_final_kbps={}", evidence.final_kbps);939+ println!(940+ "adaptive_survivor_recovery={}",941+ if evidence.survivor_recovery {942+ "ok"943+ } else {944+ "not-applicable"945+ }946+ );947+ println!(948+ "adaptive_floor_detach={}",949+ if evidence.floor_detach {950+ "ok"951+ } else {952+ "not-applicable"953+ }954+ );955+ println!("resource_summary_version=1");956+ println!("maximum_roster_viewers={}", initial.viewers.len());957+ println!("media_progress=monotonic");958+ println!("probe_media_object_capacity={MEDIA_OBJECT_CAPACITY}");959+ return Ok(());960+ }961+ let mut accepted = 0_u32;962+ let mut last_sequence = None;963+ // The roster at hello time is one instantaneous sample: a peer that has964+ // not finished attaching yet, or one that has already left, makes it965+ // disagree with the configured viewer count for reasons that say966+ // nothing about admission. Track the maximum across the snapshot967+ // stream, exactly as the focus participant does, so the cardinality968+ // assertion is about what the host actually admitted.969+ let mut maximum_roster_viewers = initial.viewers.len();970+ let mut roster_revision = initial.revision;971+ while accepted < frames {972+ let outcome = tokio::select! {973+ envelope = read_server_control_v2(&mut control_recv) => {974+ match envelope?.context("host closed during spectator proof")? {975+ ServerControlEnvelopeV2::Snapshot { snapshot: next } => {976+ ensure!(977+ next.revision >= roster_revision,978+ "spectator snapshot revision regressed"979+ );980+ roster_revision = next.revision;981+ maximum_roster_viewers =982+ maximum_roster_viewers.max(next.viewers.len());983+ continue;984+ }985+ _ => continue,986+ }987+ }988+ outcome = tokio::time::timeout(Duration::from_secs(timeout_seconds), media.next()) => {989+ outcome990+ .context("timed out waiting for spectator media")??991+ .context("spectator media ended before requested frames")?992+ }993+ };994+ let MoqProbeOutcome::Frame { frame, .. } = outcome else {995+ continue;996+ };997+ if let Some((width, height)) = expected_size {998+ ensure!(999+ frame.header.width == width && frame.header.height == height,1000+ "spectator media dimensions did not match {width}x{height}"1001+ );1002+ }1003+ if let Some(previous) = last_sequence {1004+ ensure!(1005+ frame.header.sequence > previous,1006+ "spectator media sequence regressed"1007+ );1008+ }1009+ last_sequence = Some(frame.header.sequence);1010+ accepted += 1;1011+ }1012+ control_connection.close(0_u32.into(), b"spectator probe complete");1013+ println!("probe=ok");1014+ println!("transport=iroh-moq");1015+ println!("control_alpn=sigil/control/2");1016+ println!("media_generation_id={generation_id}");1017+ println!("media_broadcast_name={broadcast_name}");1018+ println!("roster_viewers={maximum_roster_viewers}");1019+ println!("frames={accepted}");1020+ println!("spectator=ok");1021+ println!("resource_summary_version=1");1022+ println!("maximum_roster_viewers={maximum_roster_viewers}");1023+ println!("media_progress=monotonic");1024+ println!("probe_media_object_capacity={MEDIA_OBJECT_CAPACITY}");1025+ return Ok(());1026+ }1027+ let focus_request = FocusCommandV2 {1028+ request_id: 1,1029+ action: FocusCommandActionV2::Request,1030+ slot: ControllerSlot::ZERO,1031+ expected_revision: initial.revision,1032+ expected_focus_generation: None,1033+ expected_proposal_id: None,1034+ };1035+ write_client_control_v2(1036+ &mut control_send,1037+ &ClientControlEnvelopeV2::Focus {1038+ command: focus_request,1039+ },1040+ )1041+ .await1042+ .context("requesting control v2 focus")?;1043+ let focused = read_v2_snapshot_after(1044+ &mut control_recv,1045+ initial.revision,1046+ timeout_seconds,1047+ |snapshot| snapshot.self_focus_generation().is_some(),1048+ )1049+ .await?;1050+ let focus_generation = focused1051+ .self_focus_generation()1052+ .context("focus grant did not identify the requesting viewer")?;1053+1054+ let input_connection = endpoint1055+ .connect(address, INPUT_ALPN_V2)1056+ .await1057+ .context("connecting input v2 protocol")?;1058+ let (mut input_send, mut input_recv) = input_connection1059+ .open_bi()1060+ .await1061+ .context("opening input v2 stream")?;1062+ let input_hello = InputClientHelloV2::new(1063+ "sigil-probe/0.1.0",1064+ nonce,1065+ session_id,1066+ ControllerSlot::ZERO,1067+ focus_generation,1068+ vec![Capability::InputAck],1069+ );1070+ write_input_client_hello_v2(&mut input_send, &input_hello)1071+ .await1072+ .context("writing input v2 hello")?;1073+ let input_host = tokio::time::timeout(1074+ Duration::from_secs(timeout_seconds),1075+ read_input_host_hello_v2(&mut input_recv),1076+ )1077+ .await1078+ .context("timed out waiting for input v2 hello")??1079+ .context("host closed during input v2 hello")?;1080+ ensure!(input_host.accepted, "host rejected focused input v2 stream");1081+ ensure!(1082+ input_host.session_id == Some(session_id)1083+ && input_host.slot == Some(ControllerSlot::ZERO)1084+ && input_host.focus_generation == Some(focus_generation),1085+ "input v2 host hello changed its focus binding"1086+ );1087+ write_input_event_v2(1088+ &mut input_send,1089+ &InputEventV2 {1090+ session_id,1091+ slot: ControllerSlot::ZERO,1092+ focus_generation,1093+ event: InputEvent::Probe,1094+ },1095+ )1096+ .await1097+ .context("writing slot-0 input v2 probe")?;1098+ let input_ack = tokio::time::timeout(1099+ Duration::from_secs(timeout_seconds.min(5)),1100+ read_input_ack_v2(&mut input_recv),1101+ )1102+ .await1103+ .context("timed out waiting for input v2 acknowledgement")??1104+ .context("host closed before input v2 acknowledgement")?;1105+ ensure!(1106+ input_ack.session_id == session_id1107+ && input_ack.slot == ControllerSlot::ZERO1108+ && input_ack.focus_generation == focus_generation1109+ && input_ack.ack.sequence == 1,1110+ "input v2 acknowledgement changed its focus binding"1111+ );1112+1113+ let mut accepted = 0_u32;1114+ let mut last_sequence = None;1115+ while accepted < frames {1116+ let outcome = tokio::time::timeout(Duration::from_secs(timeout_seconds), media.next())1117+ .await1118+ .context("timed out waiting for control v2 media")??1119+ .context("control v2 media ended before requested frames")?;1120+ let MoqProbeOutcome::Frame { frame, .. } = outcome else {1121+ continue;1122+ };1123+ if let Some((width, height)) = expected_size {1124+ ensure!(1125+ frame.header.width == width && frame.header.height == height,1126+ "control v2 media dimensions did not match {width}x{height}"1127+ );1128+ }1129+ if let Some(previous) = last_sequence {1130+ ensure!(1131+ frame.header.sequence > previous,1132+ "control v2 media sequence regressed"1133+ );1134+ }1135+ last_sequence = Some(frame.header.sequence);1136+ accepted += 1;1137+ }1138+1139+ let released = if disconnect_with_focus {1140+ None1141+ } else {1142+ let release = FocusCommandV2 {1143+ request_id: 2,1144+ action: FocusCommandActionV2::Release,1145+ slot: ControllerSlot::ZERO,1146+ expected_revision: focused.revision,1147+ expected_focus_generation: Some(focus_generation),1148+ expected_proposal_id: None,1149+ };1150+ write_client_control_v2(1151+ &mut control_send,1152+ &ClientControlEnvelopeV2::Focus { command: release },1153+ )1154+ .await1155+ .context("releasing control v2 focus")?;1156+ Some(1157+ read_v2_snapshot_after(1158+ &mut control_recv,1159+ focused.revision,1160+ timeout_seconds,1161+ |snapshot| matches!(snapshot.focus, FocusStateV2::Vacant { .. }),1162+ )1163+ .await?,1164+ )1165+ };1166+ input_send.finish().context("finishing input v2 stream")?;1167+ input_connection.close(0_u32.into(), b"focus released");1168+ control_connection.close(0_u32.into(), b"probe complete");1169+1170+ println!("probe=ok");1171+ println!("transport=iroh-moq");1172+ println!("control_alpn=sigil/control/2");1173+ println!("input_alpn=sigil/input/2");1174+ println!("media_authentication=ed25519-v1");1175+ println!("media_generation_certified=ok");1176+ println!("media_generation_id={generation_id}");1177+ println!("media_broadcast_name={broadcast_name}");1178+ println!("media_generation_scope=host");1179+ println!("subscription_endpoint_binding=ok");1180+ println!("roster_viewers={}", initial.viewers.len());1181+ println!("frames={accepted}");1182+ println!("initial_snapshot_revision={}", initial.revision);1183+ println!("focus_grant_revision={}", focused.revision);1184+ println!("focus_generation={focus_generation}");1185+ println!("slot_0_input=ok");1186+ if let Some(released) = released {1187+ println!("focus_release_revision={}", released.revision);1188+ println!("focus_release=ok");1189+ } else {1190+ println!("focus_release_revision=disconnect");1191+ println!("focus_release=disconnect");1192+ }1193+ println!("resource_summary_version=1");1194+ println!("maximum_roster_viewers={}", initial.viewers.len());1195+ println!("media_progress=monotonic");1196+ println!("probe_media_object_capacity={MEDIA_OBJECT_CAPACITY}");1197+ Ok(())1198+}1199+1200+fn focus_holder_identity(1201+ snapshot: &sigil_protocol::SessionSnapshotV2,1202+) -> Option<(String, u64, u64)> {1203+ match &snapshot.focus {1204+ FocusStateV2::Held {1205+ holder,1206+ session_id,1207+ focus_generation,1208+ ..1209+ } => Some((holder.as_str().to_owned(), *session_id, *focus_generation)),1210+ FocusStateV2::Vacant { .. } | FocusStateV2::Neutralizing { .. } => None,1211+ }1212+}1213+1214+fn next_handoff_command(1215+ snapshot: &sigil_protocol::SessionSnapshotV2,1216+ request_id: u64,1217+) -> Option<FocusCommandV2> {1218+ let self_viewer = snapshot.self_viewer();1219+ match &snapshot.focus {1220+ FocusStateV2::Vacant { slot } => Some(FocusCommandV2 {1221+ request_id,1222+ action: FocusCommandActionV2::Request,1223+ slot: *slot,1224+ expected_revision: snapshot.revision,1225+ expected_focus_generation: None,1226+ expected_proposal_id: None,1227+ }),1228+ FocusStateV2::Held {1229+ slot,1230+ holder,1231+ session_id,1232+ focus_generation,1233+ } if holder == &snapshot.self_presence_id && *session_id == self_viewer.session_id => {1234+ snapshot1235+ .focus_proposal1236+ .as_ref()1237+ .map(|proposal| FocusCommandV2 {1238+ request_id,1239+ action: FocusCommandActionV2::Approve,1240+ slot: *slot,1241+ expected_revision: snapshot.revision,1242+ expected_focus_generation: Some(*focus_generation),1243+ expected_proposal_id: Some(proposal.proposal_id),1244+ })1245+ }1246+ FocusStateV2::Held { slot, .. } if snapshot.focus_proposal.is_none() => {1247+ Some(FocusCommandV2 {1248+ request_id,1249+ action: FocusCommandActionV2::Request,1250+ slot: *slot,1251+ expected_revision: snapshot.revision,1252+ expected_focus_generation: None,1253+ expected_proposal_id: None,1254+ })1255+ }1256+ FocusStateV2::Held { .. } | FocusStateV2::Neutralizing { .. } => None,1257+ }1258+}1259+1260+#[allow(clippy::too_many_arguments)]1261+async fn run_focus_handoff_participant(1262+ control_connection: iroh::endpoint::Connection,1263+ mut control_send: iroh::endpoint::SendStream,1264+ mut control_recv: iroh::endpoint::RecvStream,1265+ mut media: MoqProbeReceiver,1266+ mut snapshot: sigil_protocol::SessionSnapshotV2,1267+ required_handoffs: u32,1268+ frames: u32,1269+ timeout_seconds: u64,1270+) -> Result<()> {1271+ let mut request_id = 1_u64;1272+ let mut command_in_flight = false;1273+ let mut observed_handoffs = 0_u32;1274+ let mut accepted_frames = 0_u32;1275+ let mut maximum_roster_viewers = snapshot.viewers.len();1276+ let mut last_holder = focus_holder_identity(&snapshot);1277+ let mut next_command_at = tokio::time::Instant::now();1278+ // Two timeout windows for the handoff exchange, plus the wall time the1279+ // frame budget itself needs at a conservative 30fps floor.1280+ let deadline = tokio::time::Instant::now()1281+ + Duration::from_secs(timeout_seconds.max(1).saturating_mul(2))1282+ + Duration::from_secs(u64::from(frames) / 30 + 1);1283+1284+ // Leaving as soon as the handoffs land would shrink the roster underneath1285+ // any concurrent same-peer replacement cycle, which then fails on viewer1286+ // cardinality for reasons that have nothing to do with admission. Hold the1287+ // session for the full frame budget, exactly like a spectator.1288+ while observed_handoffs < required_handoffs || accepted_frames < frames {1289+ ensure!(1290+ tokio::time::Instant::now() < deadline,1291+ "timed out after observing {observed_handoffs} of {required_handoffs} focus handoffs \1292+ and {accepted_frames} of {frames} frames"1293+ );1294+ if observed_handoffs < required_handoffs1295+ && !command_in_flight1296+ && tokio::time::Instant::now() >= next_command_at1297+ && let Some(command) = next_handoff_command(&snapshot, request_id)1298+ {1299+ write_client_control_v2(1300+ &mut control_send,1301+ &ClientControlEnvelopeV2::Focus { command },1302+ )1303+ .await1304+ .context("writing bounded focus handoff command")?;1305+ request_id = request_id1306+ .checked_add(1)1307+ .context("focus handoff request id exhausted")?;1308+ command_in_flight = true;1309+ next_command_at = tokio::time::Instant::now() + Duration::from_millis(300);1310+ }1311+1312+ tokio::select! {1313+ envelope = read_server_control_v2(&mut control_recv) => {1314+ let envelope = envelope?1315+ .context("host closed during bounded focus handoff proof")?;1316+ match envelope {1317+ ServerControlEnvelopeV2::Snapshot { snapshot: next } => {1318+ ensure!(1319+ next.revision >= snapshot.revision,1320+ "focus handoff snapshot revision regressed"1321+ );1322+ if next.revision == snapshot.revision {1323+ continue;1324+ }1325+ if let Some(holder) = focus_holder_identity(&next) {1326+ if last_holder.as_ref().is_some_and(|previous| previous != &holder) {1327+ observed_handoffs = observed_handoffs.saturating_add(1);1328+ }1329+ last_holder = Some(holder);1330+ }1331+ maximum_roster_viewers = maximum_roster_viewers.max(next.viewers.len());1332+ snapshot = next;1333+ command_in_flight = false;1334+ }1335+ ServerControlEnvelopeV2::FocusResult { result } => {1336+ if !result.accepted {1337+ command_in_flight = false;1338+ next_command_at = tokio::time::Instant::now() + Duration::from_millis(300);1339+ }1340+ }1341+ }1342+ }1343+ outcome = media.next() => {1344+ if matches!(outcome?, Some(MoqProbeOutcome::Frame { .. })) {1345+ accepted_frames = accepted_frames.saturating_add(1);1346+ }1347+ }1348+ _ = tokio::time::sleep_until(deadline) => {1349+ bail!(1350+ "timed out after observing {observed_handoffs} of {required_handoffs} focus \1351+ handoffs and {accepted_frames} of {frames} frames"1352+ );1353+ }1354+ }1355+ }1356+1357+ control_connection.close(0_u32.into(), b"focus handoff proof complete");1358+ println!("probe=ok");1359+ println!("control_alpn=sigil/control/2");1360+ println!("focus_handoffs_observed={observed_handoffs}");1361+ println!("roster_viewers={maximum_roster_viewers}");1362+ println!("focus_handoff_media_frames={accepted_frames}");1363+ println!("reset_before_successor=host-ordered");1364+ println!("resource_summary_version=1");1365+ println!("maximum_roster_viewers={maximum_roster_viewers}");1366+ println!("media_progress=monotonic");1367+ println!("probe_media_object_capacity={MEDIA_OBJECT_CAPACITY}");1368+ Ok(())1369+}1370+1371+async fn read_v2_snapshot_after(1372+ recv: &mut iroh::endpoint::RecvStream,1373+ previous_revision: u64,1374+ timeout_seconds: u64,1375+ predicate: impl Fn(&sigil_protocol::SessionSnapshotV2) -> bool,1376+) -> Result<sigil_protocol::SessionSnapshotV2> {1377+ let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_seconds);1378+ loop {1379+ let envelope = tokio::time::timeout_at(deadline, read_server_control_v2(recv))1380+ .await1381+ .context("timed out waiting for control v2 state")??1382+ .context("host closed before control v2 state arrived")?;1383+ match envelope {1384+ ServerControlEnvelopeV2::Snapshot { snapshot } => {1385+ ensure!(1386+ snapshot.revision > previous_revision,1387+ "control v2 snapshot revision did not advance"1388+ );1389+ if predicate(&snapshot) {1390+ return Ok(snapshot);1391+ }1392+ }1393+ ServerControlEnvelopeV2::FocusResult { result } => {1394+ ensure!(1395+ result.accepted,1396+ "host rejected control v2 focus command: {}",1397+ result.message.as_deref().unwrap_or("unspecified reason")1398+ );1399+ }1400+ }1401+ }1402+}1403+6701404 fn classify_moq_probe_group(previous: Option<u64>, current: u64) -> Result<bool> {6711405 let Some(previous) = previous else {6721406 return Ok(false);
@@ -936,6 +1670,134 @@ async fn run_feedback_smoke(
9361670 Ok((decision, path_mode, path_rtt_ms))9371671 }9381672 1673+impl AdaptiveProfile {1674+ const fn label(self) -> &'static str {1675+ match self {1676+ Self::Clean => "clean",1677+ Self::FloorBreach => "floor-breach",1678+ Self::Stale => "stale",1679+ }1680+ }1681+}1682+1683+struct AdaptiveProfileEvidence {1684+ minimum_kbps: u32,1685+ final_kbps: u32,1686+ survivor_recovery: bool,1687+ floor_detach: bool,1688+}1689+1690+async fn run_v2_adaptive_profile(1691+ endpoint: &Endpoint,1692+ address: EndpointAddr,1693+ nonce: [u8; 16],1694+ session_id: u64,1695+ profile: AdaptiveProfile,1696+) -> Result<AdaptiveProfileEvidence> {1697+ let connection = endpoint1698+ .connect(address, MEDIA_FEEDBACK_ALPN_V1)1699+ .await1700+ .context("connecting control-v2 adaptive feedback profile")?;1701+ let (mut send, mut recv) = connection1702+ .open_bi()1703+ .await1704+ .context("opening control-v2 adaptive feedback stream")?;1705+ let negotiation = negotiate(1706+ &mut send,1707+ &mut recv,1708+ nonce,1709+ vec![Capability::VideoH264],1710+ Capability::VideoH264,1711+ "control-v2 adaptive feedback",1712+ None,1713+ )1714+ .await?;1715+ ensure!(1716+ negotiation.session_id == session_id,1717+ "control-v2 media and adaptive feedback session IDs differ"1718+ );1719+1720+ let report_count = match profile {1721+ AdaptiveProfile::Clean => 36,1722+ AdaptiveProfile::FloorBreach => 32,1723+ AdaptiveProfile::Stale => 5,1724+ };1725+ let mut minimum_kbps = u32::MAX;1726+ let mut maximum_kbps = 0_u32;1727+ let mut final_kbps = 0_u32;1728+ let mut saw_stale = false;1729+ let mut floor_detach = false;1730+ let mut final_reason_bits = 0_u16;1731+ for report_id in 1..=report_count {1732+ let mut report = complete_feedback_report(report_id, MediaFeedbackFlags::NONE, report_id);1733+ match profile {1734+ AdaptiveProfile::Clean => {}1735+ AdaptiveProfile::FloorBreach => {1736+ report.flags = MediaFeedbackFlags::RESYNC_ACTIVE;1737+ report.transport_dropped_delta = 8;1738+ report.frontend_dropped_delta = 8;1739+ report.decoder_dropped_delta = 8;1740+ report.presenter_dropped_delta = 8;1741+ report.frontend_queue_depth = report.frontend_queue_capacity;1742+ report.decode_queue_depth = report.decode_queue_capacity;1743+ report.presenter_queue_depth = report.presenter_queue_capacity;1744+ report.transport_delivery_p95_ms = Some(250);1745+ report.decode_p95_ms = Some(125);1746+ report.presentation_p95_ms = Some(125);1747+ }1748+ AdaptiveProfile::Stale => {1749+ report.interval_ms = 5_000;1750+ }1751+ }1752+ let decision = match exchange_feedback_report(&mut send, &mut recv, &report).await {1753+ Ok(decision) => decision,1754+ Err(error) if profile == AdaptiveProfile::FloorBreach && minimum_kbps <= 1_000 => {1755+ floor_detach = true;1756+ eprintln!("[probe] floor-breach feedback stream detached as expected: {error}");1757+ break;1758+ }1759+ Err(error) => return Err(error).context("running control-v2 adaptive profile"),1760+ };1761+ minimum_kbps = minimum_kbps.min(decision.target_kbps);1762+ maximum_kbps = maximum_kbps.max(decision.target_kbps);1763+ final_kbps = decision.target_kbps;1764+ final_reason_bits = decision.reasons.bits();1765+ saw_stale |= decision1766+ .reasons1767+ .contains(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE);1768+ tokio::time::sleep(Duration::from_millis(800)).await;1769+ }1770+ ensure!(1771+ minimum_kbps != u32::MAX,1772+ "adaptive profile received no decisions"1773+ );1774+ let survivor_recovery = profile == AdaptiveProfile::Clean1775+ && minimum_kbps < maximum_kbps1776+ && final_kbps > minimum_kbps;1777+ match profile {1778+ AdaptiveProfile::Clean => ensure!(1779+ survivor_recovery,1780+ "clean survivor did not observe aggregate downshift and recovery: min={minimum_kbps}, max={maximum_kbps}, final={final_kbps}, reasons={final_reason_bits}"1781+ ),1782+ AdaptiveProfile::FloorBreach => ensure!(1783+ floor_detach && minimum_kbps <= 1_000,1784+ "floor-breach viewer was not detached after bounded recovery"1785+ ),1786+ AdaptiveProfile::Stale => ensure!(saw_stale, "stale profile was not classified stale"),1787+ }1788+ if profile != AdaptiveProfile::FloorBreach {1789+ send.finish()1790+ .context("finishing control-v2 adaptive feedback profile")?;1791+ connection.close(0_u32.into(), b"adaptive profile complete");1792+ }1793+ Ok(AdaptiveProfileEvidence {1794+ minimum_kbps,1795+ final_kbps,1796+ survivor_recovery,1797+ floor_detach,1798+ })1799+}1800+9391801 async fn next_resolution_stall_moq_frame(receiver: &mut MoqProbeReceiver) -> Result<AcceptedMedia> {9401802 loop {9411803 match receiver.next().await? {
@@ -1336,6 +2198,22 @@ async fn main() -> Result<()> {
13362198 .context("binding probe endpoint")?;13372199 let _ = tokio::time::timeout(Duration::from_secs(10), endpoint.online()).await;13382200 let address = EndpointAddr::new(args.node_id);2201+ if args.control_v2 {2202+ return run_control_v2_smoke(2203+ &endpoint,2204+ address,2205+ nonce,2206+ invitation.as_deref(),2207+ args.frames,2208+ args.timeout_seconds,2209+ args.expect_size,2210+ args.spectator,2211+ args.disconnect_with_focus,2212+ args.focus_handoffs,2213+ args.adaptive_profile,2214+ )2215+ .await;2216+ }13392217 let media_transport = if args.media_v3 {13402218 MediaTransport::GroupedV313412219 } else {
@@ -1376,7 +2254,10 @@ async fn main() -> Result<()> {
13762254 &endpoint,13772255 address.clone(),13782256 session_id,2257+ session_id,2258+ None,13792259 Duration::from_secs(args.timeout_seconds),2260+ None,13802261 )13812262 .await?,13822263 )
A crates/sigil-host/src/bin/sigil-relay-spike.rs +292 -0
@@ -0,0 +1,292 @@
1+use std::collections::VecDeque;2+use std::sync::Arc;3+use std::time::Instant;4+5+use anyhow::{Context, Result, ensure};6+use clap::Parser;7+use iroh::SecretKey;8+use sigil_protocol::{9+ AuthenticatedMediaObject, FrameFlags, MediaFrame, MediaFrameHeader, MediaGenerationSigningKey,10+ MediaObjectCoordinates, MediaTrack, SignedSubscriptionCapability, SubscriptionClaims,11+ SubscriptionTracks, encode_media_frame_object,12+};13+14+const VIDEO_FPS: u64 = 60;15+const AUDIO_OBJECTS_PER_SECOND: u64 = 50;16+const VIDEO_PAYLOAD_BYTES: usize = 64 * 1024;17+const AUDIO_PAYLOAD_BYTES: usize = 200;18+const RELAY_QUEUE_CAPACITY: usize = 4;19+const AUTHORIZATION_REVISION: u64 = 1;20+21+#[derive(Debug, Parser)]22+#[command(23+ name = "sigil-relay-spike",24+ about = "Bounded relay-ready media authentication spike"25+)]26+struct Args {27+ #[arg(long, default_value = "1280x800@60")]28+ video: String,29+ #[arg(long, default_value = "opus-48k-stereo")]30+ audio: String,31+ #[arg(long, default_value_t = 300, value_parser = clap::value_parser!(u64).range(1..=300))]32+ duration_seconds: u64,33+}34+35+#[derive(Default)]36+struct BenchmarkEvidence {37+ host_bytes: u64,38+ downstream_bytes: u64,39+ sign_nanos: u128,40+ verification_micros: Vec<u64>,41+ verification_nanos: u128,42+ maximum_relay_queue: usize,43+ first_object: Option<(Vec<u8>, MediaObjectCoordinates)>,44+}45+46+fn main() -> Result<()> {47+ let args = Args::parse();48+ ensure!(49+ args.video == "1280x800@60",50+ "only the fixed 1280x800@60 target is supported"51+ );52+ ensure!(53+ args.audio == "opus-48k-stereo",54+ "only Opus 48 kHz stereo is supported"55+ );56+57+ let host = SecretKey::from_bytes(&[0x31; 32]);58+ let portal_a = SecretKey::from_bytes(&[0x41; 32]);59+ let portal_b = SecretKey::from_bytes(&[0x42; 32]);60+ let generation_id = 7_u64;61+ let issued_at = 1_700_000_000_u64;62+ let generation = MediaGenerationSigningKey::from_bytes(&[0x51; 32]);63+ let certificate = generation.certify(64+ *host.public().as_bytes(),65+ &host.to_bytes(),66+ generation_id,67+ issued_at,68+ issued_at + 600,69+ )?;70+ certificate.verify_binding(*host.public().as_bytes(), generation_id, issued_at + 1)?;71+ let subscription = SignedSubscriptionCapability::issue(72+ SubscriptionClaims::new(73+ *host.public().as_bytes(),74+ generation_id,75+ *portal_b.public().as_bytes(),76+ SubscriptionTracks::VIDEO_H264.union(SubscriptionTracks::AUDIO_OPUS),77+ AUTHORIZATION_REVISION,78+ issued_at,79+ issued_at + 300,80+ [0x61; 32],81+ 1,82+ )?,83+ &host.to_bytes(),84+ )?;85+ subscription.verify_binding(86+ *host.public().as_bytes(),87+ generation_id,88+ *portal_b.public().as_bytes(),89+ SubscriptionTracks::VIDEO_H264.union(SubscriptionTracks::AUDIO_OPUS),90+ AUTHORIZATION_REVISION,91+ issued_at + 1,92+ )?;93+94+ let mut evidence = BenchmarkEvidence {95+ verification_micros: Vec::with_capacity(96+ usize::try_from(args.duration_seconds * (VIDEO_FPS + AUDIO_OBJECTS_PER_SECOND))97+ .context("benchmark sample capacity overflow")?,98+ ),99+ ..BenchmarkEvidence::default()100+ };101+ let video_payload: Arc<[u8]> = Arc::from(vec![0x65; VIDEO_PAYLOAD_BYTES]);102+ let audio_payload = vec![0x7a; AUDIO_PAYLOAD_BYTES];103+ let mut relay_queue = VecDeque::with_capacity(RELAY_QUEUE_CAPACITY);104+105+ for sequence in 0..args.duration_seconds * VIDEO_FPS {106+ let group_id = sequence / VIDEO_FPS;107+ let object_id = u32::try_from(sequence % VIDEO_FPS).expect("video object id is bounded");108+ let flags = if object_id == 0 {109+ FrameFlags::KEYFRAME.union(FrameFlags::CODEC_CONFIG)110+ } else {111+ FrameFlags::NONE112+ };113+ let frame = MediaFrame::new(114+ MediaFrameHeader::h264(115+ 1280,116+ 800,117+ video_payload.len(),118+ sequence,119+ sequence * 1_000_000 / VIDEO_FPS,120+ i64::try_from(sequence * 1_000_000 / VIDEO_FPS).expect("bounded timestamp"),121+ flags,122+ )?,123+ Arc::clone(&video_payload),124+ )?;125+ let payload = encode_media_frame_object(&frame)?;126+ authenticate_relay_verify(127+ &generation,128+ &certificate,129+ MediaObjectCoordinates {130+ generation_id,131+ track: MediaTrack::VideoH264,132+ group_id,133+ object_id,134+ flags: u16::from(flags.bits()),135+ },136+ &payload,137+ &mut relay_queue,138+ &mut evidence,139+ )?;140+ }141+142+ for sequence in 0..args.duration_seconds * AUDIO_OBJECTS_PER_SECOND {143+ authenticate_relay_verify(144+ &generation,145+ &certificate,146+ MediaObjectCoordinates {147+ generation_id,148+ track: MediaTrack::AudioOpus,149+ group_id: sequence / AUDIO_OBJECTS_PER_SECOND,150+ object_id: u32::try_from(sequence % AUDIO_OBJECTS_PER_SECOND)151+ .expect("audio object id is bounded"),152+ flags: 0,153+ },154+ &audio_payload,155+ &mut relay_queue,156+ &mut evidence,157+ )?;158+ }159+160+ ensure!(relay_queue.is_empty(), "relay queue did not drain");161+ let (first_object, first_coordinates) = evidence162+ .first_object163+ .as_ref()164+ .context("benchmark did not produce an authenticated object")?;165+ let mut tampered = first_object.clone();166+ *tampered167+ .last_mut()168+ .context("authenticated object was empty")? ^= 1;169+ ensure!(170+ AuthenticatedMediaObject::verify(&tampered, &certificate, *first_coordinates).is_err(),171+ "tampered relayed media was trusted"172+ );173+ ensure!(174+ subscription175+ .verify_binding(176+ *host.public().as_bytes(),177+ generation_id,178+ *portal_a.public().as_bytes(),179+ SubscriptionTracks::VIDEO_H264,180+ AUTHORIZATION_REVISION,181+ issued_at + 1,182+ )183+ .is_err(),184+ "wrong subscriber was authorized"185+ );186+ ensure!(187+ subscription188+ .verify_binding(189+ *host.public().as_bytes(),190+ generation_id,191+ *portal_b.public().as_bytes(),192+ SubscriptionTracks::VIDEO_H264,193+ AUTHORIZATION_REVISION,194+ issued_at + 301,195+ )196+ .is_err(),197+ "expired subscription was authorized"198+ );199+ AuthenticatedMediaObject::verify(first_object, &certificate, *first_coordinates)200+ .context("direct-host fallback could not verify the relayed object")?;201+202+ evidence.verification_micros.sort_unstable();203+ let p95_index = evidence204+ .verification_micros205+ .len()206+ .saturating_mul(95)207+ .div_ceil(100)208+ - 1;209+ let verify_p95_us = evidence.verification_micros[p95_index];210+ let host_direct_bytes = evidence.host_bytes.saturating_mul(2);211+ let host_share_percent = evidence212+ .host_bytes213+ .saturating_mul(100)214+ .checked_div(host_direct_bytes)215+ .unwrap_or(0);216+ let savings_percent = 100_u64.saturating_sub(host_share_percent);217+ let sign_cpu_percent =218+ evidence.sign_nanos as f64 / (args.duration_seconds as f64 * 1_000_000_000_f64) * 100.0;219+ let verify_cpu_percent = evidence.verification_nanos as f64220+ / (args.duration_seconds as f64 * 1_000_000_000_f64)221+ * 100.0;222+223+ println!("relay_spike=ok");224+ println!("authentication_mode=ed25519-v1");225+ println!("decision=defer-production-mesh");226+ println!("virtual_duration_seconds={}", args.duration_seconds);227+ println!("video_objects={}", args.duration_seconds * VIDEO_FPS);228+ println!(229+ "audio_objects={}",230+ args.duration_seconds * AUDIO_OBJECTS_PER_SECOND231+ );232+ println!("host_upload_bytes={}", evidence.host_bytes);233+ println!("direct_two_viewer_host_upload_bytes={host_direct_bytes}");234+ println!("host_upload_savings_percent={savings_percent}");235+ println!("relay_downstream_bytes={}", evidence.downstream_bytes);236+ println!("sign_cpu_percent_of_virtual_realtime={sign_cpu_percent:.4}");237+ println!("verify_cpu_percent_of_virtual_realtime={verify_cpu_percent:.4}");238+ println!("verify_p95_us={verify_p95_us}");239+ println!("added_local_latency_p95_us={verify_p95_us}");240+ println!("maximum_relay_queue={}", evidence.maximum_relay_queue);241+ println!("tamper_rejected=ok");242+ println!("wrong_subscriber_rejected=ok");243+ println!("expired_subscription_rejected=ok");244+ println!("relay_loss_direct_fallback=ok");245+ println!("withheld_media_trusted=0");246+ Ok(())247+}248+249+fn authenticate_relay_verify(250+ generation: &MediaGenerationSigningKey,251+ certificate: &sigil_protocol::SignedMediaGenerationCertificate,252+ coordinates: MediaObjectCoordinates,253+ payload: &[u8],254+ relay_queue: &mut VecDeque<Vec<u8>>,255+ evidence: &mut BenchmarkEvidence,256+) -> Result<()> {257+ ensure!(258+ relay_queue.len() < RELAY_QUEUE_CAPACITY,259+ "relay queue exceeded its bound"260+ );261+ let sign_started = Instant::now();262+ let object = generation.authenticate(coordinates, payload)?.into_bytes();263+ evidence.sign_nanos = evidence264+ .sign_nanos265+ .saturating_add(sign_started.elapsed().as_nanos());266+ evidence.host_bytes = evidence267+ .host_bytes268+ .saturating_add(u64::try_from(object.len()).unwrap_or(u64::MAX));269+ if evidence.first_object.is_none() {270+ evidence.first_object = Some((object.clone(), coordinates));271+ }272+ relay_queue.push_back(object);273+ evidence.maximum_relay_queue = evidence.maximum_relay_queue.max(relay_queue.len());274+275+ let relayed = relay_queue276+ .pop_front()277+ .context("relay queue lost an object")?;278+ evidence.downstream_bytes = evidence279+ .downstream_bytes280+ .saturating_add(u64::try_from(relayed.len()).unwrap_or(u64::MAX));281+ let verify_started = Instant::now();282+ let verified = AuthenticatedMediaObject::verify(&relayed, certificate, coordinates)?;283+ let verify_elapsed = verify_started.elapsed();284+ evidence.verification_nanos = evidence285+ .verification_nanos286+ .saturating_add(verify_elapsed.as_nanos());287+ evidence288+ .verification_micros289+ .push(u64::try_from(verify_elapsed.as_micros()).unwrap_or(u64::MAX));290+ ensure!(verified == payload, "verified relay payload changed bytes");291+ Ok(())292+}
M crates/sigil-host/src/config.rs +69 -0
@@ -12,6 +12,8 @@ use sha2::{Digest, Sha256};
1212 use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};1313 1414 pub const MAX_CONFIG_BYTES: u64 = 64 * 1024;15+pub const DEFAULT_MAX_VIEWERS: u8 = 3;16+pub const MAX_VIEWERS: u8 = 8;1517 const REVISION_PREFIX: &str = "sha256:";1618 1719 #[derive(Clone, Debug, PartialEq, Eq, Serialize)]
@@ -130,6 +132,10 @@ fn default_input_mode() -> InputMode {
130132 InputMode::Disabled131133 }132134 135+const fn default_max_viewers() -> u8 {136+ DEFAULT_MAX_VIEWERS137+}138+133139 fn default_ffmpeg() -> PathBuf {134140 PathBuf::from("ffmpeg")135141 }
@@ -383,6 +389,14 @@ fn validate_pipewire_property(name: &str, value: &str) -> Result<()> {
383389 pub struct HostConfig {384390 pub identity_path: PathBuf,385391 pub state_path: PathBuf,392+ /// Concurrent native-MoQ v2 viewers. Durable enrollment has a separate393+ /// fixed bound and legacy transports remain exclusive.394+ #[serde(default = "default_max_viewers")]395+ pub max_viewers: u8,396+ /// Optional stable opaque authorization handle allowed to preempt slot 0.397+ /// Other viewers can request a holder-approved handoff but cannot preempt.398+ #[serde(default)]399+ pub focus_owner: Option<String>,386400 #[serde(default = "default_source")]387401 pub source: VideoSource,388402 /// Optional encoded-size override. Gamescope uses its advertised native
@@ -488,6 +502,20 @@ impl HostConfig {
488502 !self.state_path.as_os_str().is_empty(),489503 "state_path is required"490504 );505+ ensure!(506+ (1..=MAX_VIEWERS).contains(&self.max_viewers),507+ "max_viewers must be between 1 and {MAX_VIEWERS}"508+ );509+ if let Some(handle) = &self.focus_owner {510+ ensure!(511+ handle.len() == 23512+ && handle.starts_with("viewer-")513+ && handle["viewer-".len()..]514+ .bytes()515+ .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),516+ "focus_owner must be a stable opaque viewer- followed by 16 lowercase hexadecimal digits"517+ );518+ }491519 match (self.width, self.height) {492520 (Some(width), Some(height)) => validate_video_dimensions(width, height)?,493521 (None, None) => {}
@@ -725,10 +753,51 @@ state_path = "/tmp/state"
725753 )726754 .unwrap();727755 config.validate().unwrap();756+ assert_eq!(config.max_viewers, DEFAULT_MAX_VIEWERS);728757 assert_eq!(config.configured_dimensions(), None);729758 assert_eq!(config.test_pattern_dimensions().unwrap(), (1_280, 800));730759 }731760 761+ #[test]762+ fn max_viewers_defaults_to_three_and_rejects_values_outside_one_through_eight() {763+ let mut config: HostConfig = toml::from_str(764+ r#"765+identity_path = "/tmp/host.key"766+state_path = "/tmp/state"767+"#,768+ )769+ .unwrap();770+ assert_eq!(config.max_viewers, 3);771+ config.validate().unwrap();772+773+ config.max_viewers = 1;774+ config.validate().unwrap();775+ config.max_viewers = 8;776+ config.validate().unwrap();777+ config.max_viewers = 0;778+ assert!(config.validate().is_err());779+ config.max_viewers = 9;780+ assert!(config.validate().is_err());781+ }782+783+ #[test]784+ fn focus_owner_accepts_only_stable_opaque_authorization_handles() {785+ let mut config: HostConfig = toml::from_str(786+ r#"787+identity_path = "/tmp/host.key"788+state_path = "/tmp/state"789+focus_owner = "viewer-0123456789abcdef"790+"#,791+ )792+ .unwrap();793+ config.validate().unwrap();794+795+ config.focus_owner = Some("viewer-owner".into());796+ assert!(config.validate().is_err());797+ config.focus_owner = Some("viewer-0123456789ABCDEF".into());798+ assert!(config.validate().is_err());799+ }800+732801 #[test]733802 fn validates_strict_gamescope_pipewire_configuration() {734803 let input = r#"
M crates/sigil-host/src/main.rs +202 -38
@@ -1,6 +1,7 @@
11 mod appliance;22 mod audio;33 mod authorization;4+mod authorization_admin;45 mod clock;56 mod config;67 mod config_management;
@@ -29,21 +30,20 @@ use iroh::protocol::Router;
2930 use iroh::{Endpoint, EndpointId};3031 use moq_net::Origin;3132 use sigil_protocol::{32- AUDIO_ALPN_V1, CONTROL_ALPN_V1, INPUT_ALPN_V1, InvitationGrants, MAX_INVITATION_TTL_SECS,33- MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1, SignedInvitation,33+ AUDIO_ALPN_V1, CONTROL_ALPN_V1, CONTROL_ALPN_V2, INPUT_ALPN_V1, INPUT_ALPN_V2,34+ InvitationGrants, MAX_INVITATION_TTL_SECS, MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1,3435 };3536 use tracing::{info, warn};3637 use tracing_subscriber::EnvFilter;3738 38-use crate::authorization::{39- AuthorizationPolicy, AuthorizationStore, grant_names, unix_timestamp_now,40-};39+use crate::authorization::{AuthorizationPolicy, AuthorizationStore, grant_names};4140 use crate::config::{ConfigRevision, HostConfig, InputMode, VideoSource};4241 use crate::cursor::PointerPositionTracker;4342 use crate::input::InputBackend;4443 use crate::server::{45- AudioHandler, AuthorizedMoqHandler, ControlHandler, InputHandler, MediaFeedbackHandler,46- MediaV3Handler, SessionRegistry,44+ AudioHandler, AuthorizedMoqHandler, ControlHandler, ControlV2Handler, InputHandler,45+ InputOperations, InputV2Handler, MediaFeedbackHandler, MediaGenerationManager, MediaV3Handler,46+ SessionRegistry,4747 };4848 4949 const CONNECTION_IDLE_TIMEOUT: Duration = Duration::from_secs(5);
@@ -83,7 +83,7 @@ enum Command {
8383 #[command(subcommand)]8484 command: InvitationCommand,8585 },86- /// Inspect or revoke the one enrolled Portal peer.86+ /// Inspect or administer enrolled Portal viewers.8787 Enrollment {8888 #[command(subcommand)]8989 command: EnrollmentCommand,
@@ -235,6 +235,9 @@ enum InvitationCommand {
235235 pointer_keyboard: bool,236236 #[arg(long)]237237 gamepad: bool,238+ /// Explicitly issue a view-only invitation.239+ #[arg(long, conflicts_with_all = ["pointer_keyboard", "gamepad"])]240+ view_only: bool,238241 #[arg(long)]239242 output: PathBuf,240243 /// Also print the short-lived invitation as a goq:// deep link.
@@ -250,6 +253,11 @@ enum EnrollmentCommand {
250253 #[arg(long)]251254 config: PathBuf,252255 },256+ /// List all enrolled viewers by stable opaque handle.257+ List {258+ #[arg(long)]259+ config: PathBuf,260+ },253261 /// Remove the enrolled peer and invalidate every outstanding invitation.254262 Revoke {255263 #[arg(long)]
@@ -260,6 +268,24 @@ enum EnrollmentCommand {
260268 /// Emit the stable machine-readable schema.261269 #[arg(long, required = true)]262270 json: bool,271+ /// Revoke one viewer through the running daemon. Without this option,272+ /// the command retains the stopped-service reset-all compatibility path.273+ #[arg(long)]274+ handle: Option<String>,275+ },276+ /// Atomically replace one viewer's grants through the running daemon.277+ SetCapabilities {278+ #[arg(long)]279+ config: PathBuf,280+ #[arg(long)]281+ handle: String,282+ /// Remove view permission and revoke this viewer completely.283+ #[arg(long, conflicts_with_all = ["pointer_keyboard", "gamepad"])]284+ revoke_view: bool,285+ #[arg(long)]286+ pointer_keyboard: bool,287+ #[arg(long)]288+ gamepad: bool,263289 },264290 }265291
@@ -322,6 +348,14 @@ struct ServeArgs {
322348 height: u32,323349 #[arg(long, default_value_t = 60)]324350 framerate: u32,351+ /// Concurrent native-MoQ viewers in direct proof mode.352+ #[arg(353+ long,354+ default_value_t = config::DEFAULT_MAX_VIEWERS,355+ value_parser = clap::value_parser!(u8).range(1..=8),356+ requires = "identity"357+ )]358+ max_viewers: u8,325359 #[arg(long, default_value = "ffmpeg")]326360 ffmpeg: PathBuf,327361 /// Exit after this many seconds; intended for bounded automation.
@@ -375,8 +409,8 @@ async fn run() -> CliResult<()> {
375409 Command::Identity { command } => identity_command(command).map_err(Into::into),376410 Command::Config { command } => config_command(command).await.map_err(Into::into),377411 Command::Capture { command } => capture_command(command).await.map_err(Into::into),378- Command::Invitation { command } => invitation_command(command).map_err(Into::into),379- Command::Enrollment { command } => enrollment_command(command).map_err(Into::into),412+ Command::Invitation { command } => invitation_command(command).await.map_err(Into::into),413+ Command::Enrollment { command } => enrollment_command(command).await.map_err(Into::into),380414 Command::Serve(args) => serve_command(args).await.map_err(Into::into),381415 }382416 }
@@ -474,7 +508,7 @@ fn authorization_snapshot_from_config(
474508 Ok((config, secret, inspection.snapshot))475509 }476510 477-fn invitation_command(command: InvitationCommand) -> Result<()> {511+async fn invitation_command(command: InvitationCommand) -> Result<()> {478512 match command {479513 InvitationCommand::Create {480514 config,
@@ -482,10 +516,13 @@ fn invitation_command(command: InvitationCommand) -> Result<()> {
482516 expires_in_seconds,483517 pointer_keyboard,484518 gamepad,519+ view_only: _,485520 output,486521 print_deep_link,487522 } => {488- let (_config, secret, snapshot) = authorization_snapshot_from_config(&config)?;523+ let config = HostConfig::load(&config)?;524+ config.ensure_runtime_directory()?;525+ let secret = identity::load(&config.identity_path)?;489526 let mut grants = InvitationGrants::VIEW;490527 if pointer_keyboard {491528 grants = grants.union(InvitationGrants::POINTER_KEYBOARD);
@@ -493,21 +530,23 @@ fn invitation_command(command: InvitationCommand) -> Result<()> {
493530 if gamepad {494531 grants = grants.union(InvitationGrants::GAMEPAD);495532 }496- let mut nonce = [0_u8; 32];497- getrandom::fill(&mut nonce).context("generating invitation nonce")?;498- let now = unix_timestamp_now()?;499- let claims = AuthorizationStore::issue_claims_from_snapshot(500- secret.public(),501- snapshot,502- peer,503- grants,504- expires_in_seconds,505- now,506- nonce,507- )?;508- let expires_at = claims.expires_at_unix;509- let invitation = SignedInvitation::issue(claims, &secret.to_bytes())?;510- let token = invitation.encode();533+ let result = authorization_admin::request(534+ &config.state_path,535+ &authorization_admin::AuthorizationAdminRequest::CreateInvitation {536+ peer_node_id: peer.to_string(),537+ grants: grants.bits(),538+ expires_in_seconds,539+ },540+ )541+ .await?;542+ let authorization_admin::AuthorizationAdminResult::Invitation {543+ token,544+ expires_at_unix: expires_at,545+ ..546+ } = result547+ else {548+ anyhow::bail!("authorization daemon returned an unexpected invitation response");549+ };511550 write_invitation_file(&output, &token)?;512551 println!("invitation={}", output.display());513552 println!("host_node_id={}", secret.public());
@@ -522,27 +561,105 @@ fn invitation_command(command: InvitationCommand) -> Result<()> {
522561 Ok(())523562 }524563 525-fn enrollment_command(command: EnrollmentCommand) -> Result<()> {564+async fn enrollment_command(command: EnrollmentCommand) -> Result<()> {526565 match command {527566 EnrollmentCommand::Show { config } => {528567 let (_config, _secret, snapshot) = authorization_snapshot_from_config(&config)?;529568 println!("enrollment_epoch={}", snapshot.epoch);530- match (snapshot.peer, snapshot.grants) {531- (Some(peer), Some(grants)) => {532- println!("enrollment=active");533- println!("peer_node_id={peer}");534- println!("grants={}", grant_names(grants));569+ println!("committed_revision={}", snapshot.committed_revision);570+ println!("viewer_count={}", snapshot.viewers.len());571+ if snapshot.viewers.is_empty() {572+ println!("enrollment=none");573+ } else {574+ println!("enrollment=active");575+ for viewer in snapshot.viewers {576+ println!(577+ "viewer={} grants={} authorization_revision={} enrolled_at_unix={}",578+ viewer.handle,579+ grant_names(viewer.grants),580+ viewer.authorization_revision,581+ viewer.enrolled_at_unix582+ );535583 }536- (None, None) => println!("enrollment=none"),537- _ => unreachable!("validated authorization state is internally consistent"),584+ }585+ }586+ EnrollmentCommand::List { config } => {587+ let config = HostConfig::load(&config)?;588+ let result = authorization_admin::request(589+ &config.state_path,590+ &authorization_admin::AuthorizationAdminRequest::List,591+ )592+ .await?;593+ let authorization_admin::AuthorizationAdminResult::Viewers {594+ committed_revision,595+ enrollment_epoch,596+ viewers,597+ } = result598+ else {599+ anyhow::bail!("authorization daemon returned an unexpected list response");600+ };601+ println!("committed_revision={committed_revision}");602+ println!("enrollment_epoch={enrollment_epoch}");603+ println!("viewer_count={}", viewers.len());604+ for viewer in viewers {605+ println!(606+ "viewer={} grants={} authorization_revision={} enrolled_at_unix={}",607+ viewer.handle,608+ viewer.grants.join(","),609+ viewer.authorization_revision,610+ viewer.enrolled_at_unix611+ );538612 }539613 }540614 EnrollmentCommand::Revoke {541615 config,542616 expected_host_fingerprint,543617 json: _,618+ handle,544619 } => {545- let result = appliance::reset_enrollment(&config, &expected_host_fingerprint)?;620+ if let Some(handle) = handle {621+ let config = HostConfig::load(&config)?;622+ let secret = identity::load(&config.identity_path)?;623+ ensure!(624+ expected_host_fingerprint == appliance::fingerprint(secret.public()),625+ "expected host fingerprint does not match this Sigil appliance"626+ );627+ let result = authorization_admin::request(628+ &config.state_path,629+ &authorization_admin::AuthorizationAdminRequest::RevokeViewer { handle },630+ )631+ .await?;632+ println!("{}", serde_json::to_string(&result)?);633+ } else {634+ let result = appliance::reset_enrollment(&config, &expected_host_fingerprint)?;635+ println!("{}", serde_json::to_string(&result)?);636+ }637+ }638+ EnrollmentCommand::SetCapabilities {639+ config,640+ handle,641+ revoke_view,642+ pointer_keyboard,643+ gamepad,644+ } => {645+ let config = HostConfig::load(&config)?;646+ let grants = if revoke_view {647+ None648+ } else {649+ let mut grants = InvitationGrants::VIEW;650+ if pointer_keyboard {651+ grants = grants.union(InvitationGrants::POINTER_KEYBOARD);652+ }653+ if gamepad {654+ grants = grants.union(InvitationGrants::GAMEPAD);655+ }656+ Some(grants.bits())657+ };658+ let result = authorization_admin::request(659+ &config.state_path,660+ &authorization_admin::AuthorizationAdminRequest::ReplaceGrants { handle, grants },661+ )662+ .await?;546663 println!("{}", serde_json::to_string(&result)?);547664 }548665 }
@@ -660,6 +777,8 @@ async fn probe_test_pattern(args: CaptureProbeArgs) -> Result<()> {
660777 let config = HostConfig {661778 identity_path: PathBuf::from("unused-by-capture-probe"),662779 state_path: PathBuf::from("."),780+ max_viewers: config::DEFAULT_MAX_VIEWERS,781+ focus_owner: None,663782 source: VideoSource::TestPattern,664783 width: Some(width),665784 height: Some(height),
@@ -869,10 +988,16 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
869988 (loaded.config, loaded.revision, lifecycle)870989 };871990 let secret = identity::load(&config.identity_path)?;872- let sessions = Arc::new(SessionRegistry::default());991+ let host_secret = secret.to_bytes();992+ let sessions = Arc::new(SessionRegistry::new_with_focus_owner(993+ config.max_viewers,994+ config.focus_owner.clone(),995+ ));996+ let media_generations = MediaGenerationManager::new(config.clone(), host_secret);873997 let publisher = appliance::RuntimePublisher::start(874998 secret.public(),875999 Arc::clone(&sessions),1000+ Arc::clone(&media_generations),8761001 configured_service,8771002 loaded_config_revision,8781003 )?;
@@ -1001,7 +1126,31 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
10011126 }10021127 10031128 let moq_origin = Origin::random();1129+ let input_operations = Arc::new(InputOperations::new(input_backend.clone()));1130+ let authorization_admin = match &authorization {1131+ AuthorizationPolicy::Required(store) => Some(1132+ authorization_admin::AuthorizationAdminServer::start(1133+ &config.state_path,1134+ store.clone(),1135+ Arc::clone(&sessions),1136+ Arc::clone(&input_operations),1137+ host_secret,1138+ )1139+ .context("starting local authorization administration listener")?,1140+ ),1141+ AuthorizationPolicy::TestPatternProof | AuthorizationPolicy::DevelopmentBypass => None,1142+ };10041143 let router = Router::builder(endpoint)1144+ .accept(1145+ CONTROL_ALPN_V2,1146+ ControlV2Handler {1147+ sessions: Arc::clone(&sessions),1148+ generations: Arc::clone(&media_generations),1149+ authorization: authorization.clone(),1150+ input_operations: Arc::clone(&input_operations),1151+ host_secret,1152+ },1153+ )10051154 .accept(10061155 CONTROL_ALPN_V1,10071156 ControlHandler {
@@ -1030,7 +1179,17 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
10301179 MediaFeedbackHandler {10311180 config: config.clone(),10321181 sessions: Arc::clone(&sessions),1033- authorization,1182+ generations: Arc::clone(&media_generations),1183+ authorization: authorization.clone(),1184+ input_operations: Arc::clone(&input_operations),1185+ },1186+ )1187+ .accept(1188+ INPUT_ALPN_V2,1189+ InputV2Handler {1190+ operations: Arc::clone(&input_operations),1191+ pointer_positions: pointer_positions.clone(),1192+ sessions: Arc::clone(&sessions),10341193 },10351194 )10361195 .accept(
@@ -1078,6 +1237,9 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
10781237 {10791238 warn!(%error, "publishing stopping appliance status failed; continuing shutdown");10801239 }1240+ if let Some(administration) = authorization_admin {1241+ administration.shutdown().await;1242+ }10811243 degrade_on_error(10821244 publisher.as_ref(),10831245 appliance::RuntimeErrorCode::RouterShutdown,
@@ -1185,6 +1347,8 @@ fn load_serve_config(args: &ServeArgs) -> Result<LoadedServeConfig> {
11851347 config: HostConfig {11861348 identity_path,11871349 state_path,1350+ max_viewers: args.max_viewers,1351+ focus_owner: None,11881352 source: source.into(),11891353 width: Some(args.width),11901354 height: Some(args.height),
M crates/sigil-host/src/moq_catalog.rs +105 -8
@@ -1,16 +1,22 @@
11 use anyhow::{Context, Result, ensure};22 use moq_net::BroadcastProducer;3-use sigil_protocol::{GoqCatalogDocument, MAX_MOQ_CATALOG_BYTES};3+use sigil_protocol::{4+ GoqCatalogDocument, GoqCatalogDocumentV2, MAX_MOQ_CATALOG_BYTES,5+ SignedMediaGenerationCertificate,6+};47 5-pub(crate) struct GoqCatalogProducer {6- producer: moq_json::Producer<GoqCatalogDocument>,8+pub(crate) enum GoqCatalogProducer {9+ V1(moq_json::Producer<GoqCatalogDocument>),10+ V2(moq_json::Producer<GoqCatalogDocumentV2>),711 }812 913 impl GoqCatalogProducer {10- pub(crate) fn finish(mut self) -> Result<()> {11- self.producer12- .finish()13- .context("finishing Goq catalog track")14+ pub(crate) fn finish(self) -> Result<()> {15+ match self {16+ Self::V1(mut producer) => producer.finish(),17+ Self::V2(mut producer) => producer.finish(),18+ }19+ .context("finishing Goq catalog track")1420 }1521 }1622
@@ -31,7 +37,38 @@ pub(crate) fn publish_goq_catalog(broadcast: &mut BroadcastProducer) -> Result<G
3137 producer3238 .update(&document)3339 .context("publishing immutable Goq catalog snapshot")?;34- Ok(GoqCatalogProducer { producer })40+ Ok(GoqCatalogProducer::V1(producer))41+}42+43+pub(crate) fn publish_goq_catalog_v2(44+ broadcast: &mut BroadcastProducer,45+ generation_id: u64,46+ certificate: &SignedMediaGenerationCertificate,47+ audio_enabled: bool,48+) -> Result<GoqCatalogProducer> {49+ let track = broadcast50+ .create_track(hang::Catalog::default_track())51+ .context("creating authenticated catalog.json track")?;52+ let mut producer = moq_json::Producer::new(track, moq_json::Config::default());53+ let document = if audio_enabled {54+ GoqCatalogDocumentV2::video_h264_opus(generation_id, certificate)55+ } else {56+ GoqCatalogDocumentV2::video_h264(generation_id, certificate)57+ }58+ .context("constructing authenticated Goq catalog")?;59+ document60+ .validate()61+ .context("validating authenticated Goq catalog document")?;62+ let snapshot =63+ serde_json::to_vec(&document).context("serializing authenticated Goq catalog snapshot")?;64+ ensure!(65+ snapshot.len() <= MAX_MOQ_CATALOG_BYTES,66+ "authenticated Goq catalog snapshot exceeds {MAX_MOQ_CATALOG_BYTES} bytes"67+ );68+ producer69+ .update(&document)70+ .context("publishing immutable authenticated Goq catalog snapshot")?;71+ Ok(GoqCatalogProducer::V2(producer))3572 }3673 3774 #[cfg(test)]
@@ -40,6 +77,20 @@ mod tests {
4077 use moq_net::{Broadcast, Track};4178 use std::time::Duration;4279 80+ fn certificate() -> SignedMediaGenerationCertificate {81+ let host = iroh::SecretKey::from_bytes(&[7; 32]);82+ let generation = sigil_protocol::MediaGenerationSigningKey::from_bytes(&[9; 32]);83+ generation84+ .certify(85+ *host.public().as_bytes(),86+ &host.to_bytes(),87+ 42,88+ 1_700_000_000,89+ 1_700_000_600,90+ )91+ .unwrap()92+ }93+4394 #[tokio::test]4495 async fn catalog_snapshot_is_late_subscribable_and_hang_compatible() {4596 let mut broadcast = Broadcast::new().produce();
@@ -77,4 +128,50 @@ mod tests {
77128 assert_eq!(document, GoqCatalogDocument::video_h264());78129 catalog.finish().unwrap();79130 }131+132+ #[tokio::test]133+ async fn authenticated_catalog_snapshot_carries_the_certified_generation() {134+ let mut broadcast = Broadcast::new().produce();135+ let _video = broadcast136+ .create_track(Track {137+ name: sigil_protocol::MOQ_VIDEO_H264_TRACK.to_owned(),138+ priority: sigil_protocol::MOQ_VIDEO_TRACK_PRIORITY,139+ })140+ .unwrap();141+ let certificate = certificate();142+ let catalog = publish_goq_catalog_v2(&mut broadcast, 42, &certificate, false).unwrap();143+ let document_track = broadcast144+ .consume()145+ .subscribe_track(&hang::Catalog::default_track())146+ .unwrap();147+ let mut consumer = moq_json::Consumer::<GoqCatalogDocumentV2>::new(document_track);148+ let document = tokio::time::timeout(Duration::from_millis(100), consumer.next())149+ .await150+ .unwrap()151+ .unwrap()152+ .unwrap();153+ assert_eq!(document.validate().unwrap(), certificate);154+ catalog.finish().unwrap();155+ }156+157+ #[tokio::test]158+ async fn authenticated_catalog_can_advertise_video_and_audio_tracks() {159+ let mut broadcast = Broadcast::new().produce();160+ let _video = broadcast161+ .create_track(Track::new(sigil_protocol::MOQ_VIDEO_H264_TRACK))162+ .unwrap();163+ let _audio = broadcast164+ .create_track(Track::new(sigil_protocol::MOQ_AUDIO_OPUS_TRACK))165+ .unwrap();166+ let certificate = certificate();167+ let catalog = publish_goq_catalog_v2(&mut broadcast, 42, &certificate, true).unwrap();168+ let track = broadcast169+ .consume()170+ .subscribe_track(&hang::Catalog::default_track())171+ .unwrap();172+ let mut consumer = moq_json::Consumer::<GoqCatalogDocumentV2>::new(track);173+ let document = consumer.next().await.unwrap().unwrap();174+ assert!(document.goq.audio.is_some());175+ catalog.finish().unwrap();176+ }80177 }
M crates/sigil-host/src/secure_state.rs +57 -0
@@ -6,6 +6,8 @@ use anyhow::{Context, Result, ensure};
66 77 #[cfg(unix)]88 use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};9+#[cfg(unix)]10+use std::os::unix::fs::{FileTypeExt, PermissionsExt};911 1012 pub fn ensure_private_directory(path: &Path) -> Result<()> {1113 match fs::symlink_metadata(path) {
@@ -273,6 +275,61 @@ pub fn remove_file_if_exists(directory: &Path, file_name: &str) -> Result<bool>
273275 }274276 }275277 278+#[cfg(unix)]279+pub fn prepare_private_unix_socket(280+ directory: &Path,281+ file_name: &str,282+) -> Result<std::path::PathBuf> {283+ validate_private_directory(directory)?;284+ let path = directory.join(file_name);285+ match fs::symlink_metadata(&path) {286+ Ok(metadata) => {287+ ensure!(288+ metadata.file_type().is_socket() && !metadata.file_type().is_symlink(),289+ "private administration socket path is not a Unix socket"290+ );291+ ensure!(292+ metadata.uid() == unsafe { libc::geteuid() },293+ "private administration socket has the wrong owner"294+ );295+ ensure!(296+ metadata.mode() & 0o077 == 0,297+ "private administration socket is accessible by group or others"298+ );299+ fs::remove_file(&path).with_context(|| {300+ format!("removing stale administration socket {}", path.display())301+ })?;302+ }303+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}304+ Err(error) => {305+ return Err(error)306+ .with_context(|| format!("inspecting administration socket {}", path.display()));307+ }308+ }309+ Ok(path)310+}311+312+#[cfg(unix)]313+pub fn secure_private_unix_socket(path: &Path) -> Result<()> {314+ fs::set_permissions(path, fs::Permissions::from_mode(0o600))315+ .with_context(|| format!("securing administration socket {}", path.display()))?;316+ let metadata = fs::symlink_metadata(path)317+ .with_context(|| format!("inspecting administration socket {}", path.display()))?;318+ ensure!(319+ metadata.file_type().is_socket() && !metadata.file_type().is_symlink(),320+ "private administration socket path is not a Unix socket"321+ );322+ ensure!(323+ metadata.uid() == unsafe { libc::geteuid() },324+ "private administration socket has the wrong owner"325+ );326+ ensure!(327+ metadata.mode() & 0o777 == 0o600,328+ "private administration socket must have mode 0600"329+ );330+ Ok(())331+}332+276333 fn validate_private_file(file: &File, path: &Path) -> Result<()> {277334 let metadata = file.metadata()?;278335 ensure!(
M crates/sigil-host/src/server/adaptive.rs +1044 -3
@@ -1,6 +1,10 @@
1+use std::collections::HashMap;2+use std::sync::atomic::{AtomicUsize, Ordering};3+use std::sync::{Arc, Mutex};4+15 use super::session::{2- AdaptiveEncoderProposal, FeedbackLease, MediaV3TelemetrySnapshot, ResolutionEncoderProposal,3- SessionRegistry,6+ AdaptiveEncoderProposal, FeedbackLease, FeedbackScope, MediaV3TelemetrySnapshot,7+ ResolutionEncoderProposal, SessionRegistry,48 };59 use super::*;610
@@ -25,6 +29,15 @@ const FEEDBACK_WRITE_TIMEOUT: Duration = Duration::from_secs(1);
2529 // consumer observes a stale 5-second window instead of a deceptively fresh2630 // latest report.2731 const MEDIA_FEEDBACK_INTERVAL_MAX_MS: u64 = 5_000;32+const AGGREGATE_TICK_INTERVAL: Duration = Duration::from_millis(250);33+const FLOOR_BREACH_WINDOWS: u8 = 3;34+const ISOLATED_RECOVERY_CLEAN_WINDOWS: u8 = 3;35+const ISOLATED_RECOVERY_PRESSURE_WINDOWS: u8 = 3;36+const ISOLATED_RECOVERY_MAX_ATTEMPTS: u8 = 2;37+const ISOLATED_RECOVERY_BACKOFF: Duration = Duration::from_secs(2);38+// All policy state is one fixed record per hard-bounded viewer. Reports use a39+// latest cumulative ingress slot and the generation owns one actuator/queued40+// plan, so eight viewers cannot multiply encoder work or queue history.2841 2942 #[derive(Clone, Copy, Debug, PartialEq, Eq)]3043 enum FeedbackSeverity {
@@ -440,6 +453,567 @@ fn cumulative_feedback_delta(total: u64, baseline: u64) -> u32 {
440453 .expect("feedback delta is clamped to u32")441454 }442455 456+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]457+struct GenerationContributorKey {458+ remote: EndpointId,459+ session_id: u64,460+}461+462+#[derive(Clone, Copy, Debug, PartialEq, Eq)]463+enum ContributorRecoveryState {464+ Active {465+ floor_breach_windows: u8,466+ },467+ Recovering {468+ attempt: u8,469+ retry_at: Instant,470+ clean_windows: u8,471+ pressure_windows: u8,472+ },473+ Detaching,474+}475+476+impl Default for ContributorRecoveryState {477+ fn default() -> Self {478+ Self::Active {479+ floor_breach_windows: 0,480+ }481+ }482+}483+484+impl ContributorRecoveryState {485+ fn contributes(self) -> bool {486+ matches!(self, Self::Active { .. })487+ }488+489+ fn observe(490+ &mut self,491+ evaluation: &AdaptiveBitrateEvaluation,492+ now: Instant,493+ ) -> RecoveryMutation {494+ let pressured = evaluation.fresh495+ && matches!(496+ evaluation.pressure_severity,497+ FeedbackSeverity::Moderate | FeedbackSeverity::Severe498+ );499+ let severe = evaluation.fresh && evaluation.pressure_severity == FeedbackSeverity::Severe;500+ let clean = evaluation.fresh501+ && evaluation.pressure_severity == FeedbackSeverity::Clean502+ && evaluation.clean_recovery_evidence;503+ match *self {504+ Self::Active {505+ ref mut floor_breach_windows,506+ } => {507+ if severe && evaluation.decision.target_kbps == evaluation.decision.floor_kbps {508+ *floor_breach_windows = floor_breach_windows.saturating_add(1);509+ } else {510+ *floor_breach_windows = 0;511+ }512+ if *floor_breach_windows < FLOOR_BREACH_WINDOWS {513+ return RecoveryMutation::None;514+ }515+ *self = Self::Recovering {516+ attempt: 1,517+ retry_at: now + ISOLATED_RECOVERY_BACKOFF,518+ clean_windows: 0,519+ pressure_windows: 0,520+ };521+ RecoveryMutation::RequestRecovery522+ }523+ Self::Recovering {524+ attempt,525+ retry_at,526+ ref mut clean_windows,527+ ref mut pressure_windows,528+ } => {529+ if now < retry_at {530+ return RecoveryMutation::None;531+ }532+ if clean {533+ *clean_windows = clean_windows.saturating_add(1);534+ *pressure_windows = 0;535+ if *clean_windows >= ISOLATED_RECOVERY_CLEAN_WINDOWS {536+ *self = Self::default();537+ return RecoveryMutation::Recovered;538+ }539+ } else if pressured {540+ *pressure_windows = pressure_windows.saturating_add(1);541+ *clean_windows = 0;542+ if *pressure_windows >= ISOLATED_RECOVERY_PRESSURE_WINDOWS {543+ if attempt >= ISOLATED_RECOVERY_MAX_ATTEMPTS {544+ *self = Self::Detaching;545+ return RecoveryMutation::Detach;546+ }547+ let next_attempt = attempt.saturating_add(1);548+ *self = Self::Recovering {549+ attempt: next_attempt,550+ retry_at: now551+ + ISOLATED_RECOVERY_BACKOFF.saturating_mul(u32::from(next_attempt)),552+ clean_windows: 0,553+ pressure_windows: 0,554+ };555+ return RecoveryMutation::RequestRecovery;556+ }557+ } else {558+ *clean_windows = 0;559+ *pressure_windows = 0;560+ }561+ RecoveryMutation::None562+ }563+ Self::Detaching => RecoveryMutation::None,564+ }565+ }566+}567+568+#[derive(Clone, Copy, Debug, PartialEq, Eq)]569+enum RecoveryMutation {570+ None,571+ RequestRecovery,572+ Recovered,573+ Detach,574+}575+576+struct GenerationContributor {577+ authorization_revision: u64,578+ ingress: CumulativeFeedbackIngress,579+ cursor: FeedbackIngressCursor,580+ controller: ShadowBitrateController,581+ latest_report: Option<MediaFeedbackReportV1>,582+ latest_evaluation: Option<AdaptiveBitrateEvaluation>,583+ last_received_at: Option<Instant>,584+ recovery: ContributorRecoveryState,585+ telemetry: Arc<super::session::MediaV3Telemetry>,586+ decisions: tokio::sync::watch::Sender<Option<AdaptiveBitrateDecisionV1>>,587+ detach: tokio::sync::watch::Sender<bool>,588+}589+590+struct GenerationAggregateState {591+ contributors: HashMap<GenerationContributorKey, GenerationContributor>,592+ next_decision_id: u64,593+ target_kbps: u32,594+}595+596+pub(super) struct GenerationAdaptiveCoordinator {597+ generation_id: u64,598+ ceiling_kbps: u32,599+ state: Mutex<GenerationAggregateState>,600+ changed: tokio::sync::Notify,601+ recovering_viewers: Arc<AtomicUsize>,602+}603+604+impl std::fmt::Debug for GenerationAdaptiveCoordinator {605+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {606+ formatter607+ .debug_struct("GenerationAdaptiveCoordinator")608+ .field("generation_id", &self.generation_id)609+ .field("ceiling_kbps", &self.ceiling_kbps)610+ .finish_non_exhaustive()611+ }612+}613+614+pub(super) struct GenerationFeedbackRegistration {615+ coordinator: Arc<GenerationAdaptiveCoordinator>,616+ key: GenerationContributorKey,617+ authorization_revision: u64,618+ pub(super) decisions: tokio::sync::watch::Receiver<Option<AdaptiveBitrateDecisionV1>>,619+ pub(super) detach: tokio::sync::watch::Receiver<bool>,620+}621+622+impl GenerationFeedbackRegistration {623+ pub(super) fn observe(&self, report: MediaFeedbackReportV1) -> Result<()> {624+ report.validate()?;625+ let mut state = self626+ .coordinator627+ .state628+ .lock()629+ .map_err(|_| anyhow::anyhow!("generation adaptive state poisoned"))?;630+ let contributor = state631+ .contributors632+ .get_mut(&self.key)633+ .filter(|contributor| contributor.authorization_revision == self.authorization_revision)634+ .context("feedback contributor no longer matches the active viewer generation")?;635+ contributor.ingress.observe(report);636+ drop(state);637+ self.coordinator.changed.notify_one();638+ Ok(())639+ }640+}641+642+impl Drop for GenerationFeedbackRegistration {643+ fn drop(&mut self) {644+ self.coordinator645+ .unregister(self.key, self.authorization_revision);646+ }647+}648+649+impl GenerationAdaptiveCoordinator {650+ pub(super) fn start(651+ config: HostConfig,652+ generation_id: u64,653+ encoder_control: Option<EncoderControl>,654+ producer_telemetry: Arc<super::session::MediaV3Telemetry>,655+ keyframe_requests: tokio::sync::watch::Sender<Option<MediaControlRequestV3>>,656+ shutdown: tokio::sync::watch::Receiver<bool>,657+ recovering_viewers: Arc<AtomicUsize>,658+ ) -> Result<(Arc<Self>, tokio::task::JoinHandle<Result<()>>)> {659+ let ceiling_kbps = adaptive_bitrate_ceiling_kbps(&config)?;660+ let coordinator = Arc::new(Self {661+ generation_id,662+ ceiling_kbps,663+ state: Mutex::new(GenerationAggregateState {664+ contributors: HashMap::with_capacity(crate::config::MAX_VIEWERS.into()),665+ next_decision_id: 1,666+ target_kbps: ceiling_kbps,667+ }),668+ changed: tokio::sync::Notify::new(),669+ recovering_viewers,670+ });671+ let task_coordinator = Arc::clone(&coordinator);672+ let task = tokio::spawn(async move {673+ task_coordinator674+ .run(675+ config,676+ encoder_control,677+ producer_telemetry,678+ keyframe_requests,679+ shutdown,680+ )681+ .await682+ });683+ Ok((coordinator, task))684+ }685+686+ pub(super) fn register(687+ self: &Arc<Self>,688+ remote: EndpointId,689+ session_id: u64,690+ authorization_revision: u64,691+ media_generation_id: u64,692+ telemetry: Arc<super::session::MediaV3Telemetry>,693+ ) -> Result<GenerationFeedbackRegistration> {694+ ensure!(695+ media_generation_id == self.generation_id,696+ "feedback media generation does not match the shared generation"697+ );698+ ensure!(699+ authorization_revision != 0,700+ "feedback authorization revision must be non-zero"701+ );702+ let key = GenerationContributorKey { remote, session_id };703+ let (decisions, decision_rx) = tokio::sync::watch::channel(None);704+ let (detach, detach_rx) = tokio::sync::watch::channel(false);705+ let mut state = self706+ .state707+ .lock()708+ .map_err(|_| anyhow::anyhow!("generation adaptive state poisoned"))?;709+ ensure!(710+ state.contributors.len() < usize::from(crate::config::MAX_VIEWERS)711+ || state.contributors.contains_key(&key),712+ "generation feedback contributor capacity is full"713+ );714+ state.contributors.insert(715+ key,716+ GenerationContributor {717+ authorization_revision,718+ ingress: CumulativeFeedbackIngress::default(),719+ cursor: FeedbackIngressCursor::default(),720+ controller: ShadowBitrateController::new(self.ceiling_kbps, telemetry.snapshot()),721+ latest_report: None,722+ latest_evaluation: None,723+ last_received_at: None,724+ recovery: ContributorRecoveryState::default(),725+ telemetry,726+ decisions,727+ detach,728+ },729+ );730+ self.update_recovering_count(&state);731+ drop(state);732+ self.changed.notify_one();733+ Ok(GenerationFeedbackRegistration {734+ coordinator: Arc::clone(self),735+ key,736+ authorization_revision,737+ decisions: decision_rx,738+ detach: detach_rx,739+ })740+ }741+742+ fn unregister(&self, key: GenerationContributorKey, authorization_revision: u64) {743+ let Ok(mut state) = self.state.lock() else {744+ return;745+ };746+ if state747+ .contributors748+ .get(&key)749+ .is_some_and(|contributor| contributor.authorization_revision == authorization_revision)750+ {751+ state.contributors.remove(&key);752+ self.update_recovering_count(&state);753+ drop(state);754+ self.changed.notify_one();755+ }756+ }757+758+ async fn run(759+ self: Arc<Self>,760+ config: HostConfig,761+ encoder_control: Option<EncoderControl>,762+ producer_telemetry: Arc<super::session::MediaV3Telemetry>,763+ keyframe_requests: tokio::sync::watch::Sender<Option<MediaControlRequestV3>>,764+ mut shutdown: tokio::sync::watch::Receiver<bool>,765+ ) -> Result<()> {766+ let actuation_enabled =767+ adaptive_bitrate_actuation_enabled(&config) && encoder_control.is_some();768+ let mut resolution_controller = if actuation_enabled {769+ encoder_control770+ .as_ref()771+ .map(motion_resolution_policy_for_encoder)772+ .transpose()?773+ .flatten()774+ } else {775+ None776+ };777+ let initial_resolution = resolution_controller778+ .as_ref()779+ .map(MotionResolutionPolicy::target);780+ let mut actuator = GenerationAdaptiveActuator::new(781+ encoder_control,782+ actuation_enabled,783+ self.ceiling_kbps,784+ initial_resolution,785+ );786+ let mut tick = tokio::time::interval(AGGREGATE_TICK_INTERVAL);787+ tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);788+ loop {789+ tokio::select! {790+ biased;791+ changed = shutdown.changed() => {792+ if changed.is_err() || *shutdown.borrow_and_update() {793+ break;794+ }795+ }796+ acknowledgement = actuator.acknowledgements.join_next(), if actuator.pending => {797+ actuator.complete(acknowledgement)?;798+ actuator.start_queued();799+ }800+ _ = self.changed.notified() => {}801+ _ = tick.tick() => {}802+ }803+ if let Some(update) = self.evaluate(804+ Instant::now(),805+ producer_telemetry.snapshot(),806+ actuator.bitrate_is_applied(),807+ resolution_controller.as_mut(),808+ )? {809+ if update.request_recovery {810+ let request_id = update.decision_id;811+ keyframe_requests.send_replace(Some(MediaControlRequestV3::request_keyframe(812+ request_id,813+ update.last_sequence,814+ KeyframeRequestReasonV3::FrontendBackpressure,815+ )));816+ }817+ actuator.submit(update.plan);818+ }819+ }820+ actuator.abort_and_drain().await;821+ Ok(())822+ }823+824+ fn evaluate(825+ &self,826+ now: Instant,827+ producer_telemetry: MediaV3TelemetrySnapshot,828+ bitrate_applied: bool,829+ resolution_controller: Option<&mut MotionResolutionPolicy>,830+ ) -> Result<Option<GenerationAggregateUpdate>> {831+ let mut state = self832+ .state833+ .lock()834+ .map_err(|_| anyhow::anyhow!("generation adaptive state poisoned"))?;835+ let mut changed_keys = Vec::new();836+ let mut request_recovery = false;837+ for (key, contributor) in &mut state.contributors {838+ let Some((report, next_cursor)) =839+ contributor.ingress.report_since(contributor.cursor)?840+ else {841+ continue;842+ };843+ contributor.cursor = next_cursor;844+ // Configured GOP supersession and producer-wide send state are not845+ // charged to every viewer as local transport pressure. The shared846+ // values remain available to generation diagnostics and actuation.847+ let telemetry = contributor.telemetry.snapshot();848+ let evaluation = contributor.controller.evaluate(&report, telemetry, now)?;849+ match contributor.recovery.observe(&evaluation, now) {850+ RecoveryMutation::RequestRecovery => request_recovery = true,851+ RecoveryMutation::Detach => {852+ contributor.detach.send_replace(true);853+ }854+ RecoveryMutation::None | RecoveryMutation::Recovered => {}855+ }856+ contributor.latest_report = Some(report);857+ contributor.latest_evaluation = Some(evaluation);858+ contributor.last_received_at = Some(now);859+ changed_keys.push(*key);860+ }861+ self.update_recovering_count(&state);862+863+ let active = state864+ .contributors865+ .values()866+ .filter(|contributor| {867+ contributor.recovery.contributes()868+ && contributor.last_received_at.is_some_and(|received| {869+ now.saturating_duration_since(received) <= FEEDBACK_STALE_ARRIVAL_GAP870+ })871+ && contributor872+ .latest_evaluation873+ .is_some_and(|evaluation| evaluation.fresh)874+ })875+ .filter_map(|contributor| contributor.latest_evaluation.zip(contributor.latest_report))876+ .collect::<Vec<_>>();877+ if changed_keys.is_empty() && active.is_empty() {878+ return Ok(None);879+ }880+ let (target_kbps, all_clean) =881+ aggregate_target_and_clean(&active, state.target_kbps, self.ceiling_kbps);882+ let previous_target = state.target_kbps;883+ if changed_keys.is_empty() && target_kbps == previous_target {884+ return Ok(None);885+ }886+ state.target_kbps = target_kbps;887+ let global_state = match target_kbps.cmp(&previous_target) {888+ std::cmp::Ordering::Less => AdaptiveBitrateStateV1::Decrease,889+ std::cmp::Ordering::Greater => AdaptiveBitrateStateV1::Increase,890+ std::cmp::Ordering::Equal => AdaptiveBitrateStateV1::Hold,891+ };892+ let worst = active893+ .iter()894+ .max_by_key(|(evaluation, _)| feedback_severity_rank(evaluation.pressure_severity));895+ let (aggregate_evaluation, aggregate_report, aggregate_reasons) = match worst {896+ Some((evaluation, report)) => {897+ let mut aggregate = *evaluation;898+ aggregate.clean_recovery_evidence = all_clean;899+ (Some(aggregate), Some(*report), evaluation.decision.reasons)900+ }901+ None => (None, None, AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE),902+ };903+ let resolution_decision = resolution_controller.and_then(|policy| {904+ aggregate_evaluation905+ .zip(aggregate_report)906+ .map(|(evaluation, report)| policy.observe_window(&report, &evaluation, now))907+ });908+ let plan_decision_id = state.next_decision_id;909+ let plan_report_id = aggregate_report.map_or(1, |report| report.report_id);910+ let plan = AdaptiveCommitPlan {911+ decision: AdaptiveBitrateDecisionV1 {912+ decision_id: plan_decision_id,913+ report_id: plan_report_id,914+ target_kbps,915+ floor_kbps: ADAPTIVE_BITRATE_FLOOR_KBPS,916+ ceiling_kbps: self.ceiling_kbps,917+ state: global_state,918+ reasons: aggregate_reasons,919+ applied: bitrate_applied,920+ },921+ telemetry: producer_telemetry,922+ resolution_decision,923+ force_keyframe: request_recovery924+ || aggregate_report925+ .is_some_and(|report| report.flags.contains(MediaFeedbackFlags::RESYNC_ACTIVE)),926+ };927+928+ for key in changed_keys {929+ let Some((evaluation, decisions)) =930+ state.contributors.get(&key).and_then(|contributor| {931+ contributor932+ .latest_evaluation933+ .map(|evaluation| (evaluation, contributor.decisions.clone()))934+ })935+ else {936+ continue;937+ };938+ let decision_id = state.next_decision_id;939+ state.next_decision_id = state940+ .next_decision_id941+ .checked_add(1)942+ .context("generation adaptive decision ID exhausted")?;943+ let decision = AdaptiveBitrateDecisionV1 {944+ decision_id,945+ report_id: evaluation.decision.report_id,946+ target_kbps,947+ floor_kbps: ADAPTIVE_BITRATE_FLOOR_KBPS,948+ ceiling_kbps: self.ceiling_kbps,949+ state: global_state,950+ reasons: evaluation.decision.reasons,951+ applied: bitrate_applied,952+ };953+ decision.validate()?;954+ decisions.send_replace(Some(decision));955+ }956+ plan.decision.validate()?;957+ Ok(Some(GenerationAggregateUpdate {958+ plan,959+ request_recovery,960+ decision_id: plan_decision_id,961+ last_sequence: aggregate_report.and_then(|report| report.last_sequence),962+ }))963+ }964+965+ fn update_recovering_count(&self, state: &GenerationAggregateState) {966+ let count = state967+ .contributors968+ .values()969+ .filter(|contributor| {970+ !matches!(971+ contributor.recovery,972+ ContributorRecoveryState::Active { .. }973+ )974+ })975+ .count();976+ debug_assert!(count <= usize::from(crate::config::MAX_VIEWERS));977+ self.recovering_viewers.store(count, Ordering::Relaxed);978+ }979+}980+981+fn feedback_severity_rank(severity: FeedbackSeverity) -> u8 {982+ match severity {983+ FeedbackSeverity::Clean => 0,984+ FeedbackSeverity::InsufficientEvidence => 1,985+ FeedbackSeverity::Moderate => 2,986+ FeedbackSeverity::Severe => 3,987+ FeedbackSeverity::Stale => 4,988+ }989+}990+991+fn aggregate_target_and_clean(992+ active: &[(AdaptiveBitrateEvaluation, MediaFeedbackReportV1)],993+ current_target_kbps: u32,994+ ceiling_kbps: u32,995+) -> (u32, bool) {996+ let target = active997+ .iter()998+ .map(|(evaluation, _)| evaluation.decision.target_kbps)999+ .min()1000+ .unwrap_or(current_target_kbps)1001+ .clamp(ADAPTIVE_BITRATE_FLOOR_KBPS, ceiling_kbps);1002+ let all_clean = !active.is_empty()1003+ && active.iter().all(|(evaluation, _)| {1004+ evaluation.pressure_severity == FeedbackSeverity::Clean1005+ && evaluation.clean_recovery_evidence1006+ });1007+ (target, all_clean)1008+}1009+1010+struct GenerationAggregateUpdate {1011+ plan: AdaptiveCommitPlan,1012+ request_recovery: bool,1013+ decision_id: u64,1014+ last_sequence: Option<u64>,1015+}1016+4431017 #[derive(Clone, Debug)]4441018 struct ShadowBitrateController {4451019 floor_kbps: u32,
@@ -681,7 +1255,7 @@ impl ShadowBitrateController {
6811255 FeedbackSeverity::Severe,6821256 AdaptiveBitrateReasonFlagsV1::RTT_INFLATION,6831257 );684- } else if rtt >= baseline.saturating_mul(3) / 2 {1258+ } else if rtt >= (baseline.saturating_mul(3) / 2).max(40_000) {6851259 add_feedback_signal(6861260 &mut severity,6871261 &mut reasons,
@@ -897,6 +1471,198 @@ struct AdaptiveCommitCoordinator {
8971471 acknowledgements: tokio::task::JoinSet<AdaptiveCommitAcknowledgement>,8981472 }8991473 1474+#[derive(Debug)]1475+struct GenerationAdaptiveAcknowledgement {1476+ bitrate_kbps: Option<u32>,1477+ resolution: Option<VideoDimensions>,1478+}1479+1480+struct GenerationAdaptiveActuator {1481+ control: Option<EncoderControl>,1482+ actuation_enabled: bool,1483+ desired_bitrate_kbps: u32,1484+ requested_bitrate_kbps: u32,1485+ committed_bitrate_kbps: u32,1486+ desired_resolution: Option<VideoDimensions>,1487+ requested_resolution: Option<VideoDimensions>,1488+ committed_resolution: Option<VideoDimensions>,1489+ pending: bool,1490+ queued: Option<AdaptiveCommitPlan>,1491+ acknowledgements: tokio::task::JoinSet<Result<GenerationAdaptiveAcknowledgement>>,1492+}1493+1494+impl GenerationAdaptiveActuator {1495+ fn new(1496+ control: Option<EncoderControl>,1497+ actuation_enabled: bool,1498+ bitrate_kbps: u32,1499+ resolution: Option<VideoDimensions>,1500+ ) -> Self {1501+ Self {1502+ control,1503+ actuation_enabled,1504+ desired_bitrate_kbps: bitrate_kbps,1505+ requested_bitrate_kbps: bitrate_kbps,1506+ committed_bitrate_kbps: bitrate_kbps,1507+ desired_resolution: resolution,1508+ requested_resolution: resolution,1509+ committed_resolution: resolution,1510+ pending: false,1511+ queued: None,1512+ acknowledgements: tokio::task::JoinSet::new(),1513+ }1514+ }1515+1516+ fn bitrate_is_applied(&self) -> bool {1517+ self.actuation_enabled1518+ && !self.pending1519+ && self.desired_bitrate_kbps == self.committed_bitrate_kbps1520+ }1521+1522+ fn submit(&mut self, mut plan: AdaptiveCommitPlan) {1523+ self.desired_bitrate_kbps = plan.decision.target_kbps;1524+ if let Some(resolution) = plan.resolution_decision {1525+ self.desired_resolution = Some(resolution.target);1526+ }1527+ if self.pending {1528+ if let Some(queued) = self.queued {1529+ plan.force_keyframe |= queued.force_keyframe;1530+ }1531+ self.queued = Some(plan);1532+ return;1533+ }1534+ self.start(plan);1535+ }1536+1537+ fn start_queued(&mut self) {1538+ if let Some(plan) = self.queued.take() {1539+ self.start(plan);1540+ }1541+ }1542+1543+ fn start(&mut self, plan: AdaptiveCommitPlan) {1544+ if !self.actuation_enabled {1545+ return;1546+ }1547+ let Some(control) = self.control.clone() else {1548+ return;1549+ };1550+ let bitrate_target = plan.decision.target_kbps;1551+ let bitrate_revision = if bitrate_target != self.requested_bitrate_kbps1552+ || bitrate_target != self.committed_bitrate_kbps1553+ {1554+ match control.request_bitrate_kbps(bitrate_target) {1555+ Ok(revision) => {1556+ self.requested_bitrate_kbps = bitrate_target;1557+ Some(revision)1558+ }1559+ Err(error) => {1560+ warn!(%error, bitrate_target, "generation adaptive bitrate request failed");1561+ None1562+ }1563+ }1564+ } else {1565+ None1566+ };1567+ let resolution_target = plan.resolution_decision.map(|decision| decision.target);1568+ let resolution_revision = if let Some(target) = resolution_target.filter(|target| {1569+ Some(*target) != self.requested_resolution || Some(*target) != self.committed_resolution1570+ }) {1571+ match control.request_resolution(target.width, target.height) {1572+ Ok(revision) => {1573+ self.requested_resolution = Some(target);1574+ Some((revision, target))1575+ }1576+ Err(error) => {1577+ warn!(%error, width = target.width, height = target.height, "generation adaptive resolution request failed");1578+ None1579+ }1580+ }1581+ } else {1582+ None1583+ };1584+ if bitrate_revision.is_none() && resolution_revision.is_none() {1585+ return;1586+ }1587+ self.pending = true;1588+ self.acknowledgements.spawn(async move {1589+ let bitrate_kbps = if let Some(revision) = bitrate_revision {1590+ let status = control1591+ .wait_for_bitrate_applied(revision, ENCODER_CONTROL_COMMIT_TIMEOUT)1592+ .await?;1593+ ensure!(1594+ status.applied_bitrate_revision == Some(revision)1595+ && status.applied_bitrate_kbps == Some(bitrate_target),1596+ "generation bitrate acknowledgement did not match revision {revision} at {bitrate_target} kbps"1597+ );1598+ Some(bitrate_target)1599+ } else {1600+ None1601+ };1602+ let resolution = if let Some((revision, target)) = resolution_revision {1603+ let status = control1604+ .wait_for_resolution_applied(1605+ revision,1606+ target.width,1607+ target.height,1608+ ENCODER_CONTROL_COMMIT_TIMEOUT,1609+ )1610+ .await?;1611+ ensure!(1612+ status.applied_resolution_revision == Some(revision)1613+ && status.applied_width == Some(target.width)1614+ && status.applied_height == Some(target.height),1615+ "generation resolution acknowledgement did not match revision {revision} at {}x{}",1616+ target.width,1617+ target.height1618+ );1619+ Some(target)1620+ } else {1621+ None1622+ };1623+ Ok(GenerationAdaptiveAcknowledgement {1624+ bitrate_kbps,1625+ resolution,1626+ })1627+ });1628+ }1629+1630+ fn complete(1631+ &mut self,1632+ result: Option<Result<Result<GenerationAdaptiveAcknowledgement>, tokio::task::JoinError>>,1633+ ) -> Result<()> {1634+ ensure!(1635+ self.pending,1636+ "generation adaptive acknowledgement was not pending"1637+ );1638+ self.pending = false;1639+ match result1640+ .context("generation adaptive task ended without a result")?1641+ .context("generation adaptive task failed")?1642+ {1643+ Ok(acknowledgement) => {1644+ if let Some(bitrate_kbps) = acknowledgement.bitrate_kbps {1645+ self.committed_bitrate_kbps = bitrate_kbps;1646+ }1647+ if let Some(resolution) = acknowledgement.resolution {1648+ self.committed_resolution = Some(resolution);1649+ }1650+ }1651+ Err(error) => {1652+ warn!(%error, "generation adaptive commit was not applied");1653+ }1654+ }1655+ Ok(())1656+ }1657+1658+ async fn abort_and_drain(&mut self) {1659+ self.pending = false;1660+ self.queued = None;1661+ self.acknowledgements.abort_all();1662+ while self.acknowledgements.join_next().await.is_some() {}1663+ }1664+}1665+9001666 impl AdaptiveCommitCoordinator {9011667 fn new(9021668 sessions: Arc<SessionRegistry>,
@@ -1106,7 +1872,9 @@ pub(super) async fn serve_media_feedback(
11061872 connection: Connection,11071873 config: &HostConfig,11081874 sessions: &Arc<SessionRegistry>,1875+ generations: &Arc<MediaGenerationManager>,11091876 authorization: &AuthorizationPolicy,1877+ input_operations: &Arc<InputOperations>,11101878 ) -> Result<()> {11111879 let remote = connection.remote_id();11121880 let handshake_permit = sessions
@@ -1181,9 +1949,44 @@ pub(super) async fn serve_media_feedback(
11811949 ),11821950 )11831951 .await?;1952+ if lease.scope == FeedbackScope::SharedGeneration {1953+ let adaptive = generations.adaptive(lease.media_generation_id).await?;1954+ let registration = adaptive.register(1955+ remote,1956+ lease.session_id,1957+ lease.authorization_revision,1958+ lease.media_generation_id,1959+ Arc::clone(&lease.telemetry),1960+ )?;1961+ info!(1962+ %remote,1963+ session_id = lease.session_id,1964+ media_generation_id = lease.media_generation_id,1965+ authorization_revision = lease.authorization_revision,1966+ ceiling_kbps,1967+ mode = if encoder_actuation_available { "aggregate-active" } else { "aggregate-shadow" },1968+ "media feedback contributor attached to shared generation"1969+ );1970+ let result = run_generation_feedback_session(1971+ &connection,1972+ &mut send,1973+ recv,1974+ remote,1975+ sessions,1976+ input_operations,1977+ &lease,1978+ registration,1979+ )1980+ .await;1981+ let _ = send.finish();1982+ drop(lease);1983+ info!(%remote, "shared-generation media feedback contributor released");1984+ return result;1985+ }11841986 info!(11851987 %remote,11861988 session_id = lease.session_id,1989+ authorization_revision = lease.authorization_revision,11871990 ceiling_kbps,11881991 applied = false,11891992 mode = if encoder_actuation_available { "active" } else { "shadow" },
@@ -1215,6 +2018,99 @@ pub(super) async fn serve_media_feedback(
12152018 result12162019 }12172020 2021+#[allow(clippy::too_many_arguments)]2022+async fn run_generation_feedback_session<W>(2023+ connection: &Connection,2024+ send: &mut W,2025+ recv: iroh::endpoint::RecvStream,2026+ remote: EndpointId,2027+ sessions: &Arc<SessionRegistry>,2028+ input_operations: &Arc<InputOperations>,2029+ lease: &FeedbackLease,2030+ mut registration: GenerationFeedbackRegistration,2031+) -> Result<()>2032+where2033+ W: tokio::io::AsyncWrite + Unpin,2034+{2035+ let (feedback_sender, mut feedback_receiver) =2036+ tokio::sync::watch::channel(CumulativeFeedbackIngress::default());2037+ let mut reader_task = tokio::spawn(forward_media_feedback_reports(recv, feedback_sender));2038+ let mut feedback_cursor = FeedbackIngressCursor::default();2039+ let result = loop {2040+ let session_changed = sessions.session_changed.notified();2041+ tokio::pin!(session_changed);2042+ session_changed.as_mut().enable();2043+ if !sessions.is_active(remote, lease.session_id) {2044+ break Ok(());2045+ }2046+ tokio::select! {2047+ reader_result = &mut reader_task => {2048+ break reader_result2049+ .context("shared-generation feedback reader task failed")?2050+ .context("reading shared-generation feedback reports");2051+ }2052+ changed = feedback_receiver.changed() => {2053+ if changed.is_err() {2054+ break Ok(());2055+ }2056+ let ingress = *feedback_receiver.borrow_and_update();2057+ let Some((report, next_cursor)) = ingress.report_since(feedback_cursor)? else {2058+ continue;2059+ };2060+ feedback_cursor = next_cursor;2061+ registration.observe(report)?;2062+ }2063+ changed = registration.decisions.changed() => {2064+ changed.context("shared-generation adaptive decision publisher stopped")?;2065+ let Some(decision) = *registration.decisions.borrow_and_update() else {2066+ continue;2067+ };2068+ tokio::time::timeout(2069+ FEEDBACK_WRITE_TIMEOUT,2070+ write_adaptive_bitrate_decision_v1(send, &decision),2071+ )2072+ .await2073+ .context("timed out writing shared-generation adaptive decision")??;2074+ }2075+ changed = registration.detach.changed() => {2076+ changed.context("shared-generation recovery state publisher stopped")?;2077+ if !*registration.detach.borrow_and_update() {2078+ continue;2079+ }2080+ if let Some(transition) = sessions.invalidate_v2_focus(2081+ remote,2082+ lease.session_id,2083+ FocusTransitionReasonV2::Disconnected,2084+ )? {2085+ input_operations.neutralize_focus_transition(2086+ sessions,2087+ transition.transition_id,2088+ FocusTransitionReasonV2::Disconnected,2089+ )?;2090+ }2091+ sessions.disconnect_v2_viewer(remote, lease.session_id)?;2092+ info!(2093+ %remote,2094+ session_id = lease.session_id,2095+ media_generation_id = lease.media_generation_id,2096+ "detached persistently below-floor viewer after bounded isolated recovery"2097+ );2098+ break Ok(());2099+ }2100+ _ = &mut session_changed => {}2101+ reason = connection.closed() => {2102+ debug!(%remote, ?reason, "shared-generation feedback connection closed");2103+ break Ok(());2104+ }2105+ }2106+ };2107+ if !reader_task.is_finished() {2108+ reader_task.abort();2109+ let _ = reader_task.await;2110+ }2111+ result2112+}2113+12182114 #[allow(clippy::too_many_arguments)]12192115 async fn run_media_feedback_loop<W, C>(12202116 send: &mut W,
@@ -1781,6 +2677,151 @@ mod tests {
17812677 }17822678 }17832679 2680+ fn aggregate_evaluation(2681+ report_id: u64,2682+ target_kbps: u32,2683+ severity: FeedbackSeverity,2684+ clean: bool,2685+ ) -> AdaptiveBitrateEvaluation {2686+ AdaptiveBitrateEvaluation {2687+ decision: AdaptiveBitrateDecisionV1 {2688+ decision_id: report_id,2689+ report_id,2690+ target_kbps,2691+ floor_kbps: ADAPTIVE_BITRATE_FLOOR_KBPS,2692+ ceiling_kbps: 12_000,2693+ state: AdaptiveBitrateStateV1::Hold,2694+ reasons: if severity == FeedbackSeverity::Clean {2695+ AdaptiveBitrateReasonFlagsV1::CLEAN_RECOVERY2696+ } else {2697+ AdaptiveBitrateReasonFlagsV1::RECEIVER_QUEUE2698+ },2699+ applied: false,2700+ },2701+ pressure_severity: severity,2702+ clean_recovery_evidence: clean,2703+ fresh: severity != FeedbackSeverity::Stale,2704+ }2705+ }2706+2707+ #[test]2708+ fn aggregate_uses_worst_fresh_target_and_requires_every_contributor_clean() {2709+ let clean = aggregate_evaluation(1, 10_000, FeedbackSeverity::Clean, true);2710+ let pressured = aggregate_evaluation(2, 6_000, FeedbackSeverity::Moderate, false);2711+ let active = vec![(clean, clean_feedback(1)), (pressured, clean_feedback(2))];2712+ assert_eq!(2713+ aggregate_target_and_clean(&active, 12_000, 12_000),2714+ (6_000, false)2715+ );2716+2717+ let recovered = vec![2718+ (clean, clean_feedback(3)),2719+ (2720+ aggregate_evaluation(4, 8_000, FeedbackSeverity::Clean, true),2721+ clean_feedback(4),2722+ ),2723+ ];2724+ assert_eq!(2725+ aggregate_target_and_clean(&recovered, 6_000, 12_000),2726+ (8_000, true)2727+ );2728+ }2729+2730+ #[test]2731+ fn sub_millisecond_rtt_jitter_does_not_become_permanent_moderate_pressure() {2732+ let now = Instant::now();2733+ let baseline = MediaV3TelemetrySnapshot {2734+ selected_path_rtt_micros: 500,2735+ ..Default::default()2736+ };2737+ let mut controller = ShadowBitrateController::new(12_000, baseline);2738+ controller2739+ .evaluate(&clean_feedback(1), baseline, now)2740+ .unwrap();2741+ let jitter = MediaV3TelemetrySnapshot {2742+ selected_path_rtt_micros: 1_000,2743+ ..Default::default()2744+ };2745+ let evaluation = controller2746+ .evaluate(&clean_feedback(2), jitter, now + Duration::from_secs(1))2747+ .unwrap();2748+ assert!(2749+ !evaluation2750+ .decision2751+ .reasons2752+ .contains(AdaptiveBitrateReasonFlagsV1::RTT_INFLATION)2753+ );2754+ assert_eq!(evaluation.pressure_severity, FeedbackSeverity::Clean);2755+ }2756+2757+ #[test]2758+ fn persistent_floor_breach_retries_then_detaches_without_holding_survivors_down() {2759+ let now = Instant::now();2760+ let severe = aggregate_evaluation(2761+ 1,2762+ ADAPTIVE_BITRATE_FLOOR_KBPS,2763+ FeedbackSeverity::Severe,2764+ false,2765+ );2766+ let mut recovery = ContributorRecoveryState::default();2767+ assert_eq!(recovery.observe(&severe, now), RecoveryMutation::None);2768+ assert_eq!(recovery.observe(&severe, now), RecoveryMutation::None);2769+ assert_eq!(2770+ recovery.observe(&severe, now),2771+ RecoveryMutation::RequestRecovery2772+ );2773+ assert!(!recovery.contributes());2774+2775+ let first_retry = now + ISOLATED_RECOVERY_BACKOFF;2776+ assert_eq!(2777+ recovery.observe(&severe, first_retry),2778+ RecoveryMutation::None2779+ );2780+ assert_eq!(2781+ recovery.observe(&severe, first_retry),2782+ RecoveryMutation::None2783+ );2784+ assert_eq!(2785+ recovery.observe(&severe, first_retry),2786+ RecoveryMutation::RequestRecovery2787+ );2788+ let second_retry = first_retry + ISOLATED_RECOVERY_BACKOFF.saturating_mul(2);2789+ assert_eq!(2790+ recovery.observe(&severe, second_retry),2791+ RecoveryMutation::None2792+ );2793+ assert_eq!(2794+ recovery.observe(&severe, second_retry),2795+ RecoveryMutation::None2796+ );2797+ assert_eq!(2798+ recovery.observe(&severe, second_retry),2799+ RecoveryMutation::Detach2800+ );2801+ assert_eq!(recovery, ContributorRecoveryState::Detaching);2802+ }2803+2804+ #[test]2805+ fn isolated_viewer_rejoins_only_after_bounded_clean_recovery() {2806+ let now = Instant::now();2807+ let severe = aggregate_evaluation(2808+ 1,2809+ ADAPTIVE_BITRATE_FLOOR_KBPS,2810+ FeedbackSeverity::Severe,2811+ false,2812+ );2813+ let clean = aggregate_evaluation(2, 2_000, FeedbackSeverity::Clean, true);2814+ let mut recovery = ContributorRecoveryState::default();2815+ for _ in 0..FLOOR_BREACH_WINDOWS {2816+ let _ = recovery.observe(&severe, now);2817+ }2818+ let retry = now + ISOLATED_RECOVERY_BACKOFF;2819+ assert_eq!(recovery.observe(&clean, retry), RecoveryMutation::None);2820+ assert_eq!(recovery.observe(&clean, retry), RecoveryMutation::None);2821+ assert_eq!(recovery.observe(&clean, retry), RecoveryMutation::Recovered);2822+ assert!(recovery.contributes());2823+ }2824+17842825 fn controller_decide(17852826 controller: &mut ShadowBitrateController,17862827 report: &MediaFeedbackReportV1,
A crates/sigil-host/src/server/focus.rs +564 -0
@@ -0,0 +1,564 @@
1+use std::collections::{HashMap, VecDeque};2+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};3+4+use anyhow::{Context, Result, bail, ensure};5+use sigil_protocol::{6+ ControllerSlot, FocusProposalV2, FocusStateV2, FocusTransitionReasonV2, ViewerPresenceId,7+};8+9+const FOCUS_PROPOSAL_TTL: Duration = Duration::from_secs(15);10+const FOCUS_ACTIVATION_TTL: Duration = Duration::from_secs(10);11+const FOCUS_COMMAND_RATE_WINDOW: Duration = Duration::from_secs(2);12+// Eight commands per two-second window permits one complete request/approve/13+// activate/release exchange with retry headroom for each of the hard maximum14+// eight viewers, while each viewer retains only its own bounded timestamps.15+const MAX_FOCUS_COMMANDS_PER_WINDOW: usize = 8;16+17+#[derive(Clone, Debug, PartialEq, Eq)]18+pub(super) struct FocusCandidate {19+ pub(super) presence_id: ViewerPresenceId,20+ pub(super) session_id: u64,21+}22+23+#[derive(Clone, Copy, Debug, PartialEq, Eq)]24+pub(crate) struct FocusNeutralization {25+ pub(crate) transition_id: u64,26+ pub(crate) former_session_id: u64,27+}28+29+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]30+pub(super) struct FocusMutation {31+ pub(super) changed: bool,32+ pub(super) neutralization: Option<FocusNeutralization>,33+}34+35+#[derive(Debug)]36+struct FocusSuccessor {37+ candidate: FocusCandidate,38+}39+40+#[derive(Debug)]41+struct ActivationDeadline {42+ presence_id: ViewerPresenceId,43+ session_id: u64,44+ focus_generation: u64,45+ deadline: Instant,46+}47+48+#[derive(Debug)]49+pub(super) struct FocusArbiter {50+ state: FocusStateV2,51+ proposal: Option<FocusProposalV2>,52+ proposal_deadline: Option<Instant>,53+ transition_reason: FocusTransitionReasonV2,54+ configured_owner: Option<ViewerPresenceId>,55+ successor: Option<FocusSuccessor>,56+ activation: Option<ActivationDeadline>,57+ next_focus_generation: u64,58+ next_transition_id: u64,59+ next_proposal_id: u64,60+ command_times: HashMap<(ViewerPresenceId, u64), VecDeque<Instant>>,61+}62+63+impl FocusArbiter {64+ pub(super) fn new(configured_owner: Option<ViewerPresenceId>) -> Self {65+ Self {66+ state: FocusStateV2::Vacant {67+ slot: ControllerSlot::ZERO,68+ },69+ proposal: None,70+ proposal_deadline: None,71+ transition_reason: FocusTransitionReasonV2::Initial,72+ configured_owner,73+ successor: None,74+ activation: None,75+ next_focus_generation: 0,76+ next_transition_id: 0,77+ next_proposal_id: 0,78+ command_times: HashMap::new(),79+ }80+ }81+82+ pub(super) fn state(&self) -> &FocusStateV2 {83+ &self.state84+ }85+86+ pub(super) fn proposal(&self) -> Option<&FocusProposalV2> {87+ self.proposal.as_ref()88+ }89+90+ pub(super) fn transition_reason(&self) -> FocusTransitionReasonV2 {91+ self.transition_reason92+ }93+94+ pub(super) fn is_configured_owner(&self, presence_id: &ViewerPresenceId) -> bool {95+ self.configured_owner.as_ref() == Some(presence_id)96+ }97+98+ pub(super) fn check_rate_limit(99+ &mut self,100+ requester: &FocusCandidate,101+ now: Instant,102+ ) -> Result<()> {103+ let key = (requester.presence_id.clone(), requester.session_id);104+ let entries = self.command_times.entry(key).or_default();105+ while entries106+ .front()107+ .is_some_and(|time| now.duration_since(*time) >= FOCUS_COMMAND_RATE_WINDOW)108+ {109+ entries.pop_front();110+ }111+ ensure!(112+ entries.len() < MAX_FOCUS_COMMANDS_PER_WINDOW,113+ "focus command rate limit exceeded"114+ );115+ entries.push_back(now);116+ Ok(())117+ }118+119+ #[cfg(test)]120+ pub(super) fn tracked_command_candidates(&self) -> usize {121+ self.command_times.len()122+ }123+124+ pub(super) fn retire_candidate(&mut self, candidate: &FocusCandidate) {125+ self.command_times126+ .remove(&(candidate.presence_id.clone(), candidate.session_id));127+ }128+129+ pub(super) fn expire_proposal(&mut self, now: Instant) -> bool {130+ if self.proposal_deadline.is_none_or(|deadline| deadline > now) {131+ return false;132+ }133+ self.clear_proposal();134+ self.transition_reason = FocusTransitionReasonV2::ProposalExpired;135+ true136+ }137+138+ pub(super) fn request(139+ &mut self,140+ requester: FocusCandidate,141+ now: Instant,142+ ) -> Result<FocusMutation> {143+ self.expire_proposal(now);144+ match &self.state {145+ FocusStateV2::Vacant { slot } => {146+ let slot = *slot;147+ self.grant(slot, requester, now)?;148+ self.transition_reason = FocusTransitionReasonV2::Requested;149+ Ok(FocusMutation {150+ changed: true,151+ neutralization: None,152+ })153+ }154+ FocusStateV2::Held {155+ slot,156+ holder,157+ session_id,158+ ..159+ } => {160+ ensure!(161+ holder != &requester.presence_id || *session_id != requester.session_id,162+ "current holder cannot request focus from itself"163+ );164+ ensure!(165+ self.proposal.is_none(),166+ "controller slot 0 already has a pending handoff proposal"167+ );168+ self.next_proposal_id = next_nonzero(self.next_proposal_id, "focus proposal id")?;169+ let expires_at_unix_ms = unix_millis_after(FOCUS_PROPOSAL_TTL)?;170+ self.proposal = Some(FocusProposalV2 {171+ proposal_id: self.next_proposal_id,172+ slot: *slot,173+ requester: requester.presence_id,174+ requester_session_id: requester.session_id,175+ holder: holder.clone(),176+ holder_session_id: *session_id,177+ expires_at_unix_ms,178+ });179+ self.proposal_deadline = Some(now + FOCUS_PROPOSAL_TTL);180+ self.transition_reason = FocusTransitionReasonV2::HandoffRequested;181+ Ok(FocusMutation {182+ changed: true,183+ neutralization: None,184+ })185+ }186+ FocusStateV2::Neutralizing { .. } => {187+ bail!("controller slot 0 is neutralizing")188+ }189+ }190+ }191+192+ pub(super) fn approve(193+ &mut self,194+ holder: &FocusCandidate,195+ expected_focus_generation: u64,196+ expected_proposal_id: u64,197+ ) -> Result<FocusMutation> {198+ self.ensure_holder(holder, expected_focus_generation)?;199+ let proposal = self.ensure_proposal(holder, expected_proposal_id)?.clone();200+ self.begin_neutralization(201+ FocusTransitionReasonV2::HandoffApproved,202+ Some(FocusCandidate {203+ presence_id: proposal.requester,204+ session_id: proposal.requester_session_id,205+ }),206+ )207+ }208+209+ pub(super) fn deny(210+ &mut self,211+ holder: &FocusCandidate,212+ expected_focus_generation: u64,213+ expected_proposal_id: u64,214+ ) -> Result<FocusMutation> {215+ self.ensure_holder(holder, expected_focus_generation)?;216+ self.ensure_proposal(holder, expected_proposal_id)?;217+ self.clear_proposal();218+ self.transition_reason = FocusTransitionReasonV2::HandoffDenied;219+ Ok(FocusMutation {220+ changed: true,221+ neutralization: None,222+ })223+ }224+225+ pub(super) fn release(226+ &mut self,227+ holder: &FocusCandidate,228+ expected_focus_generation: u64,229+ ) -> Result<FocusMutation> {230+ self.ensure_holder(holder, expected_focus_generation)?;231+ let successor = self.proposal.as_ref().map(|proposal| FocusCandidate {232+ presence_id: proposal.requester.clone(),233+ session_id: proposal.requester_session_id,234+ });235+ self.begin_neutralization(FocusTransitionReasonV2::Released, successor)236+ }237+238+ pub(super) fn preempt(239+ &mut self,240+ owner: FocusCandidate,241+ expected_focus_generation: u64,242+ ) -> Result<FocusMutation> {243+ ensure!(244+ self.is_configured_owner(&owner.presence_id),245+ "viewer is not the configured focus owner"246+ );247+ let FocusStateV2::Held {248+ holder,249+ session_id,250+ focus_generation,251+ ..252+ } = &self.state253+ else {254+ bail!("controller slot 0 is not held")255+ };256+ ensure!(257+ *focus_generation == expected_focus_generation,258+ "preemption expected a stale focus generation"259+ );260+ ensure!(261+ holder != &owner.presence_id || *session_id != owner.session_id,262+ "configured owner already holds focus"263+ );264+ self.begin_neutralization(FocusTransitionReasonV2::Preempted, Some(owner))265+ }266+267+ pub(super) fn begin_invalidation(268+ &mut self,269+ candidate: &FocusCandidate,270+ reason: FocusTransitionReasonV2,271+ ) -> Result<FocusMutation> {272+ let proposal_involved = self.proposal.as_ref().is_some_and(|proposal| {273+ proposal.requester == candidate.presence_id274+ && proposal.requester_session_id == candidate.session_id275+ || proposal.holder == candidate.presence_id276+ && proposal.holder_session_id == candidate.session_id277+ });278+ if proposal_involved {279+ self.clear_proposal();280+ }281+ let held = matches!(282+ &self.state,283+ FocusStateV2::Held { holder, session_id, .. }284+ if holder == &candidate.presence_id && *session_id == candidate.session_id285+ );286+ if held {287+ return self.begin_neutralization(reason, None);288+ }289+ if proposal_involved {290+ self.transition_reason = reason;291+ }292+ Ok(FocusMutation {293+ changed: proposal_involved,294+ neutralization: None,295+ })296+ }297+298+ pub(super) fn mark_activated(&mut self, candidate: &FocusCandidate, focus_generation: u64) {299+ if self.activation.as_ref().is_some_and(|activation| {300+ activation.presence_id == candidate.presence_id301+ && activation.session_id == candidate.session_id302+ && activation.focus_generation == focus_generation303+ }) {304+ self.activation = None;305+ }306+ }307+308+ pub(super) fn begin_activation_expiry(&mut self, now: Instant) -> Result<FocusMutation> {309+ let Some(activation) = self.activation.as_ref() else {310+ return Ok(FocusMutation::default());311+ };312+ if activation.deadline > now {313+ return Ok(FocusMutation::default());314+ }315+ let candidate = FocusCandidate {316+ presence_id: activation.presence_id.clone(),317+ session_id: activation.session_id,318+ };319+ let focus_generation = activation.focus_generation;320+ self.ensure_holder(&candidate, focus_generation)?;321+ self.begin_neutralization(FocusTransitionReasonV2::ActivationExpired, None)322+ }323+324+ pub(super) fn transition_successor(&self, transition_id: u64) -> Option<&FocusCandidate> {325+ match &self.state {326+ FocusStateV2::Neutralizing {327+ transition_id: current,328+ ..329+ } if *current == transition_id => self330+ .successor331+ .as_ref()332+ .map(|successor| &successor.candidate),333+ _ => None,334+ }335+ }336+337+ pub(super) fn complete_transition(338+ &mut self,339+ transition_id: u64,340+ successor_is_valid: bool,341+ now: Instant,342+ ) -> Result<bool> {343+ let FocusStateV2::Neutralizing {344+ slot,345+ transition_id: current,346+ ..347+ } = self.state348+ else {349+ return Ok(false);350+ };351+ if current != transition_id {352+ return Ok(false);353+ }354+ let successor = self.successor.take().filter(|_| successor_is_valid);355+ self.clear_proposal();356+ if let Some(successor) = successor {357+ self.grant(slot, successor.candidate, now)?;358+ if self.transition_reason == FocusTransitionReasonV2::Released {359+ self.transition_reason = FocusTransitionReasonV2::HandoffApproved;360+ }361+ } else {362+ self.state = FocusStateV2::Vacant { slot };363+ self.activation = None;364+ }365+ Ok(true)366+ }367+368+ fn ensure_holder(369+ &self,370+ candidate: &FocusCandidate,371+ expected_focus_generation: u64,372+ ) -> Result<()> {373+ ensure!(374+ matches!(375+ &self.state,376+ FocusStateV2::Held {377+ holder,378+ session_id,379+ focus_generation,380+ ..381+ } if holder == &candidate.presence_id382+ && *session_id == candidate.session_id383+ && *focus_generation == expected_focus_generation384+ ),385+ "focus command does not match the current holder generation"386+ );387+ Ok(())388+ }389+390+ fn ensure_proposal(391+ &self,392+ holder: &FocusCandidate,393+ expected_proposal_id: u64,394+ ) -> Result<&FocusProposalV2> {395+ self.proposal396+ .as_ref()397+ .filter(|proposal| {398+ proposal.proposal_id == expected_proposal_id399+ && proposal.holder == holder.presence_id400+ && proposal.holder_session_id == holder.session_id401+ })402+ .context("handoff decision does not match the pending proposal")403+ }404+405+ fn begin_neutralization(406+ &mut self,407+ reason: FocusTransitionReasonV2,408+ successor: Option<FocusCandidate>,409+ ) -> Result<FocusMutation> {410+ let FocusStateV2::Held {411+ slot,412+ holder,413+ session_id,414+ focus_generation,415+ } = self.state.clone()416+ else {417+ bail!("controller slot 0 is not held")418+ };419+ self.next_transition_id = next_nonzero(self.next_transition_id, "focus transition id")?;420+ self.state = FocusStateV2::Neutralizing {421+ slot,422+ former_holder: holder,423+ former_session_id: session_id,424+ former_focus_generation: focus_generation,425+ transition_id: self.next_transition_id,426+ };427+ self.transition_reason = reason;428+ self.successor = successor.map(|candidate| FocusSuccessor { candidate });429+ self.activation = None;430+ self.clear_proposal();431+ Ok(FocusMutation {432+ changed: true,433+ neutralization: Some(FocusNeutralization {434+ transition_id: self.next_transition_id,435+ former_session_id: session_id,436+ }),437+ })438+ }439+440+ fn grant(441+ &mut self,442+ slot: ControllerSlot,443+ candidate: FocusCandidate,444+ now: Instant,445+ ) -> Result<()> {446+ self.next_focus_generation = next_nonzero(self.next_focus_generation, "focus generation")?;447+ self.state = FocusStateV2::Held {448+ slot,449+ holder: candidate.presence_id.clone(),450+ session_id: candidate.session_id,451+ focus_generation: self.next_focus_generation,452+ };453+ self.activation = Some(ActivationDeadline {454+ presence_id: candidate.presence_id,455+ session_id: candidate.session_id,456+ focus_generation: self.next_focus_generation,457+ deadline: now + FOCUS_ACTIVATION_TTL,458+ });459+ Ok(())460+ }461+462+ fn clear_proposal(&mut self) {463+ self.proposal = None;464+ self.proposal_deadline = None;465+ }466+}467+468+fn next_nonzero(current: u64, name: &'static str) -> Result<u64> {469+ current470+ .checked_add(1)471+ .with_context(|| format!("{name} exhausted"))472+}473+474+fn unix_millis_after(duration: Duration) -> Result<u64> {475+ let now = SystemTime::now()476+ .duration_since(UNIX_EPOCH)477+ .context("system clock is before the Unix epoch")?;478+ u64::try_from((now + duration).as_millis()).context("focus proposal expiry overflowed")479+}480+481+#[cfg(test)]482+mod tests {483+ use super::*;484+485+ fn candidate(handle: &str, session_id: u64) -> FocusCandidate {486+ FocusCandidate {487+ presence_id: ViewerPresenceId::new(handle).unwrap(),488+ session_id,489+ }490+ }491+492+ #[test]493+ fn handoff_publishes_neutralizing_before_successor_generation() {494+ let now = Instant::now();495+ let first = candidate("viewer-0000000000000001", 1);496+ let second = candidate("viewer-0000000000000002", 2);497+ let mut focus = FocusArbiter::new(None);498+ focus.request(first.clone(), now).unwrap();499+ let generation = match focus.state() {500+ FocusStateV2::Held {501+ focus_generation, ..502+ } => *focus_generation,503+ _ => panic!("focus was not granted"),504+ };505+ focus.request(second.clone(), now).unwrap();506+ let proposal_id = focus.proposal().unwrap().proposal_id;507+ let mutation = focus.approve(&first, generation, proposal_id).unwrap();508+ let transition = mutation.neutralization.unwrap();509+ assert!(matches!(focus.state(), FocusStateV2::Neutralizing { .. }));510+ assert_eq!(511+ focus.transition_successor(transition.transition_id),512+ Some(&second)513+ );514+ focus515+ .complete_transition(transition.transition_id, true, now)516+ .unwrap();517+ assert!(matches!(518+ focus.state(),519+ FocusStateV2::Held { holder, session_id: 2, focus_generation, .. }520+ if holder == &second.presence_id && *focus_generation > generation521+ ));522+ }523+524+ #[test]525+ fn only_configured_owner_can_preempt_and_commands_are_rate_limited() {526+ let now = Instant::now();527+ let owner = candidate("viewer-0000000000000001", 1);528+ let holder = candidate("viewer-0000000000000002", 2);529+ let mut focus = FocusArbiter::new(Some(owner.presence_id.clone()));530+ focus.request(holder.clone(), now).unwrap();531+ let generation = match focus.state() {532+ FocusStateV2::Held {533+ focus_generation, ..534+ } => *focus_generation,535+ _ => unreachable!(),536+ };537+ assert!(538+ focus539+ .preempt(candidate("viewer-0000000000000003", 3), generation)540+ .is_err()541+ );542+ assert!(focus.preempt(owner.clone(), generation).is_ok());543+544+ let mut limited = FocusArbiter::new(None);545+ for _ in 0..MAX_FOCUS_COMMANDS_PER_WINDOW {546+ limited.check_rate_limit(&owner, now).unwrap();547+ }548+ assert!(limited.check_rate_limit(&owner, now).is_err());549+ }550+551+ #[test]552+ fn proposals_expire_without_creating_a_queue() {553+ let now = Instant::now();554+ let first = candidate("viewer-0000000000000001", 1);555+ let second = candidate("viewer-0000000000000002", 2);556+ let third = candidate("viewer-0000000000000003", 3);557+ let mut focus = FocusArbiter::new(None);558+ focus.request(first, now).unwrap();559+ focus.request(second, now).unwrap();560+ assert!(focus.request(third.clone(), now).is_err());561+ assert!(focus.expire_proposal(now + FOCUS_PROPOSAL_TTL));562+ assert!(focus.request(third, now + FOCUS_PROPOSAL_TTL).is_ok());563+ }564+}
A crates/sigil-host/src/server/generation.rs +516 -0

Not shown: this page already shows 6000 lines of diff.

M crates/sigil-host/src/server/handlers.rs +344 -8

Not shown: this page already shows 6000 lines of diff.

M crates/sigil-host/src/server/mod.rs +25 -12

Not shown: this page already shows 6000 lines of diff.

M crates/sigil-host/src/server/moq.rs +773 -25

Not shown: this page already shows 6000 lines of diff.

M crates/sigil-host/src/server/session.rs +2172 -50

Not shown: this page already shows 6000 lines of diff.

M crates/sigil-host/src/source/mod.rs +2 -0

Not shown: this page already shows 6000 lines of diff.

M crates/sigil-protocol/Cargo.toml +1 -0

Not shown: this page already shows 6000 lines of diff.

A crates/sigil-protocol/src/control_v2.rs +949 -0

Not shown: this page already shows 6000 lines of diff.

A crates/sigil-protocol/src/input_v2.rs +376 -0

Not shown: this page already shows 6000 lines of diff.

M crates/sigil-protocol/src/lib.rs +63 -3

Not shown: this page already shows 6000 lines of diff.

A crates/sigil-protocol/src/media_auth.rs +695 -0

Not shown: this page already shows 6000 lines of diff.

M crates/sigil-protocol/src/moq_catalog.rs +268 -1

Not shown: this page already shows 6000 lines of diff.

A crates/sigil-protocol/src/subscription.rs +440 -0

Not shown: this page already shows 6000 lines of diff.

M docs/fresh-bazzite-host.md +37 -13

Not shown: this page already shows 6000 lines of diff.

M docs/hardware-uat/MATRIX.md +6 -2

Not shown: this page already shows 6000 lines of diff.

A docs/multi-viewer-relay-design.md +102 -0

Not shown: this page already shows 6000 lines of diff.

M docs/portal-platform-support.md +15 -1

Not shown: this page already shows 6000 lines of diff.

M docs/public-alpha-uat.md +52 -10

Not shown: this page already shows 6000 lines of diff.

M docs/sigil-appliance-config-v1.md +41 -0

Not shown: this page already shows 6000 lines of diff.

M docs/sigil-appliance-enrollment-reset-v1.md +30 -12

Not shown: this page already shows 6000 lines of diff.

M docs/sigil-appliance-status-v2.md +26 -7

Not shown: this page already shows 6000 lines of diff.

M portal/adaptive-feedback.mjs +10 -1

Not shown: this page already shows 6000 lines of diff.

M portal/adaptive-feedback.test.mjs +5 -0

Not shown: this page already shows 6000 lines of diff.

M portal/connection-state.mjs +17 -0

Not shown: this page already shows 6000 lines of diff.

M portal/control-runtime.mjs +19 -1

Not shown: this page already shows 6000 lines of diff.

M portal/control-runtime.test.mjs +8 -3

Not shown: this page already shows 6000 lines of diff.

M portal/enrollment-ui.test.mjs +5 -0

Not shown: this page already shows 6000 lines of diff.

M portal/enrollment.mjs +17 -1

Not shown: this page already shows 6000 lines of diff.

M portal/enrollment.test.mjs +17 -0

Not shown: this page already shows 6000 lines of diff.

A portal/focus-overlay.mjs +51 -0

Not shown: this page already shows 6000 lines of diff.

A portal/focus-overlay.test.mjs +72 -0

Not shown: this page already shows 6000 lines of diff.

A portal/focus-runtime.mjs +164 -0

Not shown: this page already shows 6000 lines of diff.

A portal/focus-runtime.test.mjs +130 -0

Not shown: this page already shows 6000 lines of diff.

M portal/frame-stats.test.mjs +23 -4

Not shown: this page already shows 6000 lines of diff.

M portal/index.html +30 -0

Not shown: this page already shows 6000 lines of diff.

M portal/main.js +200 -8

Not shown: this page already shows 6000 lines of diff.

M portal/network-diagnostics.mjs +44 -2

Not shown: this page already shows 6000 lines of diff.

M portal/network-diagnostics.test.mjs +52 -3

Not shown: this page already shows 6000 lines of diff.

A portal/roster-ui.mjs +93 -0

Not shown: this page already shows 6000 lines of diff.

A portal/roster-ui.test.mjs +82 -0

Not shown: this page already shows 6000 lines of diff.

M portal/runtime-files.txt +5 -0

Not shown: this page already shows 6000 lines of diff.

A portal/session-accessibility.test.mjs +32 -0

Not shown: this page already shows 6000 lines of diff.

M portal/session-controls-layout.test.mjs +13 -0

Not shown: this page already shows 6000 lines of diff.

A portal/session-diagnostics.mjs +79 -0

Not shown: this page already shows 6000 lines of diff.

A portal/session-diagnostics.test.mjs +65 -0

Not shown: this page already shows 6000 lines of diff.

A portal/session-state.mjs +120 -0

Not shown: this page already shows 6000 lines of diff.

A portal/session-state.test.mjs +99 -0

Not shown: this page already shows 6000 lines of diff.

M portal/stream-metrics.mjs +32 -0

Not shown: this page already shows 6000 lines of diff.

M portal/stream-metrics.test.mjs +44 -0

Not shown: this page already shows 6000 lines of diff.

M portal/style.css +84 -0

Not shown: this page already shows 6000 lines of diff.

M scripts/loopback-proof.sh +522 -1

Not shown: this page already shows 6000 lines of diff.

M scripts/public-alpha-uat.sh +57 -8

Not shown: this page already shows 6000 lines of diff.

A scripts/relay-spike-proof.sh +80 -0

Not shown: this page already shows 6000 lines of diff.

M scripts/run-bazzite-hardware-uat.sh +63 -1

Not shown: this page already shows 6000 lines of diff.

M scripts/run-shell-tests.sh +4 -3

Not shown: this page already shows 6000 lines of diff.

M scripts/tests/authenticated-probe-proof.sh +89 -4

Not shown: this page already shows 6000 lines of diff.

A scripts/tests/bazzite-hardware-uat-multi-viewer.sh +34 -0

Not shown: this page already shows 6000 lines of diff.

M scripts/tests/public-alpha-uat.sh +18 -5

Not shown: this page already shows 6000 lines of diff.

M scripts/tests/sigil-appliance-status.sh +4 -0

Not shown: this page already shows 6000 lines of diff.

M scripts/verify-demo-build.sh +7 -0

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/commands/enrollment.rs +35 -2

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/commands/network.rs +111 -1

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/commands/state.rs +19 -1

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/main.rs +6 -0

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/adaptive_feedback.rs +53 -0

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/audio_delivery.rs +179 -0

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/connect.rs +239 -100

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/input_delivery.rs +305 -12

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/media_control.rs +28 -3

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/metrics.rs +3 -3

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/mod.rs +1 -0

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/moq_catalog.rs +159 -21

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/moq_receiver.rs +434 -10

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/network_diagnostics.rs +372 -1

Not shown: this page already shows 6000 lines of diff.

A src-tauri/src/media/session_control.rs +242 -0

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/transport.rs +152 -7

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/media/video_delivery.rs +4 -6

Not shown: this page already shows 6000 lines of diff.