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 #129 from FelineStateMachine/release/portal-packaging

FelineStateMachine committed 711718e
Package goq for the 0.1.0 release, and make the shipped encoder actually adaptive
commit
711718e9e8208560b3a1e550039867d57aa26ba5
parent
a3af7ab2d9a9b1d0411f7af7d9b6b2ade6350d90
parent
5fc8b5fb4f2d15e998c2fac85e859bd09c8a1aea
author
FelineStateMachine <dami.etoile@gmail.com>
108 files changed, +17825 -809
M.github/workflows/ci.yml33+
M.github/workflows/portal-release.yml217++-
M.github/workflows/sigil-release.yml10+-
MAGENTS.md61+-
MCargo.lock1+
MREADME.md50+-
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.rs1032++++++++++++++-
Acrates/sigil-host/src/bin/sigil-relay-spike.rs292++++
Mcrates/sigil-host/src/config.rs117+-
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.rs1073++++++++++++++-
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.md103+
Mdocs/portal-platform-support.md59+-
Mdocs/portal-release.md129+-
Mdocs/public-alpha-uat.md102+-
Mdocs/public-release-delivery.md58+-
Adocs/release-credentials.md195+++
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+
Drelease/portal-apple-team-id.txt1-
Mscripts/loopback-proof.sh523+++++++-
Mscripts/package-macos-client.sh162+-
Mscripts/public-alpha-uat.sh80+-
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.sh27+-
Mscripts/tests/sigil-appliance-status.sh4+
Mscripts/tests/test_portal_release.py25+-
Mscripts/verify-demo-build.sh7+
Mscripts/verify-macos-portal-signature.sh59+-
Mscripts/verify-portal-release.py20+-
Mscripts/verify-website.sh8+
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+-
Mwebsite/dither.js4+-
Mwebsite/index.html32+-
M .github/workflows/ci.yml +33 -0
M .github/workflows/portal-release.yml +161 -56
@@ -37,8 +37,163 @@ jobs:
3737 ./scripts/tests/portal-release.sh3838 ./scripts/verify-website.sh3939 40+ preview:41+ name: Build unpublished ${{ matrix.target }} preview42+ needs: policy43+ runs-on: ${{ matrix.os }}44+ timeout-minutes: 9045+ permissions:46+ attestations: write47+ contents: read48+ id-token: write49+ strategy:50+ fail-fast: false51+ matrix:52+ include:53+ - os: macos-2654+ target: x86_64-apple-darwin55+ platform: macos56+ bundles: app,dmg57+ - os: ubuntu-24.0458+ target: x86_64-unknown-linux-gnu59+ platform: linux60+ bundles: deb,rpm,appimage61+ - os: windows-202562+ target: x86_64-pc-windows-msvc63+ platform: windows64+ bundles: msi,nsis65+ env:66+ RELEASE_TAG: ${{ inputs.tag }}67+ steps:68+ - name: Check out exact tag69+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.170+ with:71+ ref: ${{ inputs.tag }}72+ fetch-depth: 073+74+ - name: Require workflow execution from the exact tag75+ shell: bash76+ run: |77+ test "$GITHUB_REF" = "refs/tags/$RELEASE_TAG"78+ test "$GITHUB_SHA" = "$(git rev-parse HEAD)"79+80+ - name: Install Linux build dependencies81+ if: matrix.platform == 'linux'82+ run: |83+ sudo apt-get update84+ sudo apt-get install --yes --no-install-recommends \85+ build-essential \86+ libayatana-appindicator3-dev \87+ librsvg2-dev \88+ libssl-dev \89+ libudev-dev \90+ libwebkit2gtk-4.1-dev \91+ libxdo-dev \92+ pkg-config93+94+ - name: Install pinned Tauri CLI95+ shell: bash96+ run: |97+ rustup target add "${{ matrix.target }}"98+ cargo install tauri-cli --version 2.11.3 --locked99+100+ # No optional features: a preview build must differ from the published101+ # macOS arm64 release only by target and by the absence of signing.102+ - name: Build unsigned preview bundles103+ shell: bash104+ run: |105+ cargo tauri build --locked --ci \106+ --target "${{ matrix.target }}" \107+ --bundles "${{ matrix.bundles }}"108+109+ - name: Collect preview artifacts and digests110+ id: collect111+ shell: bash112+ env:113+ TARGET: ${{ matrix.target }}114+ PLATFORM: ${{ matrix.platform }}115+ run: |116+ digest() {117+ if command -v sha256sum >/dev/null 2>&1; then118+ sha256sum "$1" | awk '{ print $1 }'119+ else120+ shasum -a 256 "$1" | awk '{ print $1 }'121+ fi122+ }123+124+ version="${RELEASE_TAG#v}"125+ staging="$RUNNER_TEMP/portal-preview"126+ mkdir -p "$staging"127+ bundle_root="target/$TARGET/release/bundle"128+ test -d "$bundle_root" || {129+ echo "the bundler produced no output at $bundle_root" >&2130+ exit 1131+ }132+133+ entries=""134+ count=0135+ while IFS= read -r artifact; do136+ name="$(basename "$artifact")"137+ # The manifest is assembled textually, so refuse any name that138+ # could alter its structure rather than escaping after the fact.139+ printf '%s' "$name" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]*$' || {140+ echo "unsafe preview artifact name: $name" >&2141+ exit 1142+ }143+ test ! -e "$staging/$name" || {144+ echo "duplicate preview artifact name: $name" >&2145+ exit 1146+ }147+ cp "$artifact" "$staging/$name"148+ sha256="$(digest "$staging/$name")"149+ printf '%s %s\n' "$sha256" "$name" >"$staging/$name.sha256"150+ test "$count" -eq 0 || entries="$entries,"151+ entries="$entries{\"name\":\"$name\",\"sha256\":\"$sha256\"}"152+ count=$((count + 1))153+ done < <(find "$bundle_root" -type f \154+ \( -name '*.dmg' -o -name '*.deb' -o -name '*.rpm' \155+ -o -name '*.AppImage' -o -name '*.msi' -o -name '*.exe' \) \156+ | sort)157+158+ test "$count" -gt 0 || {159+ echo "the bundler produced no recognised preview artifact" >&2160+ exit 1161+ }162+163+ cat >"$staging/preview-manifest.json" <<EOF164+ {165+ "artifacts": [$entries166+ ],167+ "format": 1,168+ "git_commit": "$(git rev-parse HEAD)",169+ "platform": "$PLATFORM",170+ "product": "portal-client-preview",171+ "published": false,172+ "release_tag": "$RELEASE_TAG",173+ "signed": false,174+ "target": "$TARGET",175+ "version": "$version"176+ }177+ EOF178+ echo "staging=$staging" >>"$GITHUB_OUTPUT"179+180+ - name: Attest preview build provenance181+ uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.1.0182+ with:183+ subject-path: ${{ steps.collect.outputs.staging }}/*184+185+ # Deliberately an Actions artifact, never a release asset. The published186+ # release contract stays exactly the macOS arm64 DMG set.187+ - name: Retain unpublished preview build188+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2189+ with:190+ name: portal-preview-${{ inputs.tag }}-${{ matrix.target }}191+ path: ${{ steps.collect.outputs.staging }}192+ if-no-files-found: error193+ retention-days: 14194+40195 publish:41- name: Sign, notarize, and publish macOS arm64196+ name: Build and publish ad-hoc macOS arm6442197 needs: policy43198 runs-on: macos-2644199 timeout-minutes: 90
@@ -72,53 +227,16 @@ jobs:
72227 - name: Install pinned Tauri CLI73228 run: cargo install tauri-cli --version 2.11.3 --locked74229 75- - name: Import Developer ID certificate76- env:77- APPLE_CERTIFICATE_BASE64: ${{ secrets.PORTAL_APPLE_CERTIFICATE_BASE64 }}78- APPLE_CERTIFICATE_PASSWORD: ${{ secrets.PORTAL_APPLE_CERTIFICATE_PASSWORD }}79- run: |80- certificate_path="$RUNNER_TEMP/portal-developer-id.p12"81- keychain_path="$RUNNER_TEMP/portal-signing.keychain-db"82- keychain_password="$(openssl rand -hex 32)"83- echo "::add-mask::$keychain_password"84- python3 - "$certificate_path" <<'PY'85- import base6486- import os87- import pathlib88- import sys89-90- encoded = os.environ.get("APPLE_CERTIFICATE_BASE64", "")91- if not encoded:92- raise SystemExit("PORTAL_APPLE_CERTIFICATE_BASE64 is not configured")93- pathlib.Path(sys.argv[1]).write_bytes(base64.b64decode(encoded, validate=True))94- PY95- chmod 0600 "$certificate_path"96- security create-keychain -p "$keychain_password" "$keychain_path"97- security set-keychain-settings -lut 21600 "$keychain_path"98- security unlock-keychain -p "$keychain_password" "$keychain_path"99- security default-keychain -s "$keychain_path"100- security import "$certificate_path" \101- -k "$keychain_path" \102- -P "$APPLE_CERTIFICATE_PASSWORD" \103- -T /usr/bin/codesign \104- -T /usr/bin/security105- security set-key-partition-list \106- -S apple-tool:,apple: \107- -s \108- -k "$keychain_password" \109- "$keychain_path" >/dev/null110-111- - name: Build and verify signed release assets112- env:113- APPLE_SIGNING_IDENTITY: ${{ secrets.PORTAL_APPLE_SIGNING_IDENTITY }}114- APPLE_ID: ${{ secrets.PORTAL_APPLE_ID }}115- APPLE_PASSWORD: ${{ secrets.PORTAL_APPLE_PASSWORD }}116- APPLE_TEAM_ID: ${{ secrets.PORTAL_APPLE_TEAM_ID }}230+ # Portal has no Apple Developer ID. The packaging gate refuses to run in231+ # ad-hoc mode if any Apple credential is present in the environment, so232+ # this job deliberately provides none.233+ - name: Build and verify ad-hoc release assets117234 run: |118235 release_dir="$RUNNER_TEMP/portal-release"119236 mkdir -m 0700 "$release_dir"120237 ./scripts/package-macos-client.sh \121238 --release-tag "$RELEASE_TAG" \239+ --signing adhoc \122240 --output-dir "$release_dir"123241 124242 - name: Attest exact Portal build provenance
@@ -186,16 +304,3 @@ jobs:
186304 --release-tag "$RELEASE_TAG" \187305 --names-json "$asset_names"188306 189- - name: Remove signing keychain190- if: always()191- run: |192- security delete-keychain "$RUNNER_TEMP/portal-signing.keychain-db" 2>/dev/null || true193- python3 - "$RUNNER_TEMP/portal-developer-id.p12" <<'PY'194- import pathlib195- import sys196-197- path = pathlib.Path(sys.argv[1])198- if path.exists():199- path.write_bytes(b"\0" * path.stat().st_size)200- path.unlink()201- PY
M .github/workflows/sigil-release.yml +3 -7
@@ -206,15 +206,11 @@ jobs:
206206 --source-commit "${{ steps.source.outputs.commit }}" \207207 --public-key-file "$GITHUB_WORKSPACE/release/sigil-minisign.pub"208208 209- - name: Verify signed Portal assets against the same source tag210- env:211- PORTAL_APPLE_TEAM_ID: ${{ secrets.PORTAL_APPLE_TEAM_ID }}209+ - name: Verify Portal assets against the same source tag212210 run: |213211 portal_dir="$RUNNER_TEMP/portal-release"214212 install -d -m 0700 "$portal_dir"215213 gh release download "$RELEASE_TAG" --dir "$portal_dir" --pattern 'Portal-*'216- expected_team_id="$(<release/portal-apple-team-id.txt)"217- test "$PORTAL_APPLE_TEAM_ID" = "$expected_team_id"218214 python3 scripts/verify-portal-release.py assets \219215 --repo-dir "$GITHUB_WORKSPACE" \220216 --release-tag "$RELEASE_TAG" \
@@ -230,13 +226,13 @@ jobs:
230226 ./scripts/verify-macos-portal-signature.sh \231227 --dmg "$portal_dir/Portal-${RELEASE_TAG#v}-arm64.dmg" \232228 --expected-version "${RELEASE_TAG#v}" \233- --expected-team-id "$expected_team_id"229+ --signing adhoc234230 235231 - name: Publish verified prerelease236232 run: |237233 gh release edit "$RELEASE_TAG" \238234 --draft=false \239235 --prerelease \240- --notes "Verified public alpha for $RELEASE_TAG: signed Linux glibc 2.17 x86_64 Sigil host package, validated on the supported Bazzite appliance, plus Developer ID signed, notarized, stapled macOS arm64 Portal. Hardware UAT remains tracked separately."236+ --notes "Verified public alpha for $RELEASE_TAG: Minisign-signed Linux glibc 2.17 x86_64 Sigil host package, validated on the supported Bazzite appliance, plus an ad-hoc signed macOS arm64 Portal DMG. Portal is NOT notarized: macOS will refuse it on first launch until you allow it under System Settings > Privacy & Security. Verify the DMG with its published SHA-256 and with 'gh attestation verify'. Hardware UAT remains tracked separately."241237 test "$(gh release view "$RELEASE_TAG" --json isDraft --jq '.isDraft')" = false242238 test "$(gh release view "$RELEASE_TAG" --json isPrerelease --jq '.isPrerelease')" = true
M AGENTS.md +41 -20
@@ -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.
@@ -72,7 +85,11 @@ Sigil host.
7285 keyboard, and gamepad state when a session ends.7386 - Relative pointer movement, scroll, keyboard, virtual gamepad reports, Opus7487 audio, reconnects, and second-client rejection have focused and loopback75- coverage. Hardware acceptance that remains incomplete is listed below.88+ coverage. So does the multi-viewer path: bounded admission, one shared89+ generation, slot-0 focus handoff with the former holder neutralized before a90+ successor is granted, same-peer replacement, slow-viewer isolation, and91+ admission against a configured host at real enrollment revisions. Hardware92+ acceptance that remains incomplete is listed below.7693 - Portal preserves the user's native window geometry across sessions and7794 relaunches. The stream surface scales and letterboxes within those bounds so7895 larger or differently shaped client windows never stretch the host image.
@@ -115,15 +132,17 @@ Sigil host.
115132 `release/sigil-target-contract.txt`. That name describes the binary ABI, not116133 validated host support; the public bootstrap remains Bazzite-only until117134 another environment passes its own packaging and hardware gates.118-- Portal is a compiled desktop download, never a shell install. The first119- public target is macOS arm64 and requires Developer ID signing, hardened120- runtime, notarization, stapling, and strict Gatekeeper verification. Its121- TeamIdentifier must equal `release/portal-apple-team-id.txt`, and every122- Portal asset must carry protected exact-tag GitHub build provenance. Do not123- advertise an unavailable platform/architecture as a download.124-- The Minisign secret, Apple certificate, notarization credentials, host125- identity, and FIDO-derived secrets must never enter the repository, release126- archive, logs, command line, or general website deployment environment.135+- Portal is a compiled desktop download, never a shell install. The only136+ published target is macOS arm64. It is ad-hoc signed and deliberately NOT137+ Developer ID signed or notarized, so trust comes from exact-tag GitHub build138+ provenance plus the published SHA-256, and every Portal asset must carry that139+ provenance. The packaging gate refuses to build in ad-hoc mode if any140+ `APPLE_*` credential is present, and no published surface may claim141+ notarization or Gatekeeper verification. Do not advertise an unavailable142+ platform/architecture as a download.143+- The Minisign secret, host identity, and FIDO-derived secrets must never enter144+ the repository, release archive, logs, command line, or general website145+ deployment environment.127146 128147 ## Latency and correctness invariants129148
@@ -152,8 +171,9 @@ Sigil host.
152171 - Issue #4: configure the offline Minisign trust root, publish the signed Sigil153172 asset set, and prove clean install plus upgrade/rollback from the public154173 command.155-- Issue #5: configure the committed Apple TeamIdentifier pin, then publish the156- signed/notarized/attested macOS arm64 Portal DMG, digest, and manifest.174+- Issue #5: publish the ad-hoc signed, attested macOS arm64 Portal DMG, digest,175+ and manifest, with the un-notarized first-launch consequence stated plainly on176+ every user-facing surface.157177 - Issue #6: prove physically headless cold boot, physical client controller158178 gameplay, mouse buttons consumed by Gamescope/an actual game, sustained A/V159179 and resource percentiles without latency growth, difficult-NAT relay
@@ -179,8 +199,9 @@ Sigil host.
179199 selected GstVA encoder plugin; never treat build headers as runtime payload.180200 - Run `./scripts/verify-demo-build.sh` for the complete repository gate. It181201 covers Rust format/tests/clippy, the Linux cross-build when available,182- frontend syntax/tests, ShellCheck, package tests, loopback transport, and183- release-profile containment of `--dev-connect`.202+ frontend syntax/tests, ShellCheck, package tests, loopback transport203+ including three-viewer admission and slot-0 focus handoff, legacy204+ second-client rejection, and release-profile containment of `--dev-connect`.184205 - Run `./scripts/verify-website.sh` for every website or public installer185206 change. Exercise interactive website changes in a real browser.186207 - Treat `docs/fresh-bazzite-host.md`, `docs/public-release-delivery.md`, and
M Cargo.lock +1 -0
M README.md +33 -17
@@ -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.
@@ -181,10 +191,16 @@ assets carry a single frozen build-target suffix, `linux-glibc2.17-x86_64`,
181191 pinned by `release/sigil-target-contract.txt`; that name describes the binary182192 ABI (glibc 2.17, x86-64), not a distribution, so one build runs across the183193 supported AMD hosts. Portal is a compiled, signed desktop application, never a184-shell install; the first public target is macOS arm64. The packaging, signing,185-and publication ceremonies are documented in194+shell install; the only published target is macOS arm64, which is ad-hoc signed195+rather than Apple-notarized, so macOS blocks its first launch until you allow it196+under System Settings, Privacy and Security. macOS x86_64,197+Linux x86_64, and Windows x86_64 are built and attested every release as198+unpublished preview artifacts. The packaging, signing, and publication199+ceremonies are documented in186200 [public release delivery](docs/public-release-delivery.md) and the187-[Portal release runbook](docs/portal-release.md).201+[Portal release runbook](docs/portal-release.md). The operator path from the202+committed `unconfigured` trust pins to a first published release is203+[release credential bring-up](docs/release-credentials.md).188204 189205 ## License190206
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 +1017 -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)]
@@ -120,6 +159,18 @@ struct Args {
120159 ]121160 )]122161 resolution_stall_smoke: Option<PathBuf>,162+ /// After the stall is released, submit this many clean, sequence-advancing163+ /// feedback windows and require the controller to climb back out of the164+ /// pressure trough. The host needs CLEAN_WINDOWS plus COOLDOWN_WINDOWS of165+ /// clean evidence, so fewer than about twelve cannot observe an increase.166+ /// 0 keeps the historical behaviour of ending at the resume barrier.167+ #[arg(168+ long,169+ value_name = "WINDOWS",170+ default_value_t = 0,171+ requires = "resolution_stall_smoke"172+ )]173+ resolution_recovery_windows: u64,123174 }124175 125176 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -482,6 +533,14 @@ struct MoqProbeReceiver {
482533 maximum_group_objects: usize,483534 maximum_group_bytes: usize,484535 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,485544 }486545 487546 impl MoqProbeReceiver {
@@ -489,10 +548,16 @@ impl MoqProbeReceiver {
489548 endpoint: &Endpoint,490549 address: EndpointAddr,491550 session_id: u64,551+ generation_id: u64,552+ advertised_broadcast_name: Option<&str>,492553 timeout: Duration,554+ authentication: Option<MoqProbeAuthentication>,493555 ) -> Result<Self> {494- let broadcast_name = media_moq_broadcast_name(session_id)495- .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+ };496561 let moq = Moq::new(endpoint.clone());497562 let mut session = tokio::time::timeout(timeout, moq.connect(address.clone()))498563 .await
@@ -510,9 +575,30 @@ impl MoqProbeReceiver {
510575 format!("timed out subscribing to upstream MoQ broadcast {broadcast_name}")511576 })?512577 .with_context(|| format!("subscribing to upstream MoQ broadcast {broadcast_name}"))?;513- 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+ )514595 .await515- .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+ };516602 Ok(Self {517603 lifetime: MoqProbeLifetime {518604 _moq: moq,
@@ -531,6 +617,7 @@ impl MoqProbeReceiver {
531617 maximum_group_objects: 0,532618 maximum_group_bytes: 0,533619 historical_suffix_frames: 0,620+ certificate: catalog.certificate,534621 })535622 }536623
@@ -617,12 +704,53 @@ impl MoqProbeReceiver {
617704 cursor.object_bytes,618705 object.len(),619706 )?;620- 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(|| {621743 format!(622744 "decoding upstream MoQ group {} object {}",623745 cursor.sequence, cursor.object_count624746 )625747 })?;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+ );626754 let first_object = cursor.object_count == 0;627755 let contiguous = validate_moq_probe_frame(628756 cursor.sequence,
@@ -655,6 +783,624 @@ impl MoqProbeReceiver {
655783 }656784 }657785 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+6581404 fn classify_moq_probe_group(previous: Option<u64>, current: u64) -> Result<bool> {6591405 let Some(previous) = previous else {6601406 return Ok(false);
@@ -764,6 +1510,17 @@ struct ResolutionStallEvidence {
7641510 resume_input_ack_micros: u64,7651511 resume_media_micros: u64,7661512 resume_sequence_advance: u64,1513+ recovery: Option<ResolutionRecoveryEvidence>,1514+}1515+1516+#[derive(Debug)]1517+struct ResolutionRecoveryEvidence {1518+ windows: u64,1519+ trough_kbps: u32,1520+ final_kbps: u32,1521+ increase_observed: bool,1522+ final_dimensions: (u16, u16),1523+ restored_native: bool,7671524 }7681525 7691526 struct ResolutionStallGate {
@@ -913,6 +1670,134 @@ async fn run_feedback_smoke(
9131670 Ok((decision, path_mode, path_rtt_ms))9141671 }9151672 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+9161801 async fn next_resolution_stall_moq_frame(receiver: &mut MoqProbeReceiver) -> Result<AcceptedMedia> {9171802 loop {9181803 match receiver.next().await? {
@@ -1016,6 +1901,7 @@ async fn run_resolution_stall_smoke(
10161901 expected_ack: &mut u64,10171902 expected_native: Option<(u16, u16)>,10181903 timeout: Duration,1904+ recovery_windows: u64,10191905 ) -> Result<ResolutionStallEvidence> {10201906 let gate = ResolutionStallGate::new(gate_directory)?;10211907 let initial = tokio::time::timeout(timeout, next_resolution_stall_moq_frame(receiver))
@@ -1052,6 +1938,7 @@ async fn run_resolution_stall_smoke(
10521938 );10531939 let pressure_decision =10541940 exchange_feedback_report(feedback_send, feedback_recv, &pressure).await?;1941+ let mut trough_kbps = pressure_decision.target_kbps;10551942 ensure!(10561943 pressure_decision10571944 .reasons
@@ -1119,6 +2006,7 @@ async fn run_resolution_stall_smoke(
11192006 let sent_at = Instant::now();11202007 fresh_started.get_or_insert(sent_at);11212008 let decision = exchange_feedback_report(feedback_send, feedback_recv, &report).await?;2009+ trough_kbps = trough_kbps.min(decision.target_kbps);11222010 ensure!(11232011 decision.state != AdaptiveBitrateStateV1::Increase,11242012 "no-progress feedback unexpectedly increased adaptive bitrate"
@@ -1186,6 +2074,72 @@ async fn run_resolution_stall_smoke(
11862074 let resume_media_micros =11872075 u64::try_from(media_resumed_at.elapsed().as_micros()).unwrap_or(u64::MAX);11882076 2077+ // Proving the descent is only half of the contract. Feed clean, genuinely2078+ // advancing evidence for long enough to clear the clean-window and2079+ // cooldown counters, then require the controller to climb back out.2080+ let recovery = if recovery_windows > 0 {2081+ let mut latest_sequence = resumed.sequence;2082+ let mut latest_dimensions = (resumed.width, resumed.height);2083+ let mut final_kbps = trough_kbps;2084+ let mut increase_observed = false;2085+ let mut last_feedback_at = Instant::now();2086+ for _ in 0..recovery_windows {2087+ // Drain as fast as frames arrive until the next feedback deadline.2088+ // Consuming a single frame per window would starve the receiver and2089+ // manufacture exactly the cancellation backpressure this loop2090+ // exists to prove has cleared, so the controller would correctly2091+ // refuse to climb and the test would be measuring its own harness.2092+ let deadline = last_feedback_at + RESOLUTION_STALL_FEEDBACK_INTERVAL;2093+ loop {2094+ let now = Instant::now();2095+ if now >= deadline {2096+ break;2097+ }2098+ match tokio::time::timeout(2099+ deadline - now,2100+ next_resolution_stall_moq_frame(receiver),2101+ )2102+ .await2103+ {2104+ Ok(Ok(frame)) => {2105+ latest_sequence = frame.sequence;2106+ latest_dimensions = (frame.width, frame.height);2107+ }2108+ Ok(Err(error)) => return Err(error),2109+ Err(_) => break,2110+ }2111+ }2112+ report_id += 1;2113+ let report =2114+ complete_feedback_report(report_id, MediaFeedbackFlags::NONE, latest_sequence);2115+ let sent_at = Instant::now();2116+ let decision = exchange_feedback_report(feedback_send, feedback_recv, &report).await?;2117+ last_feedback_at = sent_at;2118+ final_kbps = decision.target_kbps;2119+ if decision.state == AdaptiveBitrateStateV1::Increase {2120+ increase_observed = true;2121+ }2122+ }2123+ ensure!(2124+ increase_observed,2125+ "adaptive bitrate never increased across {recovery_windows} clean recovery windows (trough {trough_kbps} kbps, final {final_kbps} kbps)"2126+ );2127+ ensure!(2128+ final_kbps > trough_kbps,2129+ "adaptive bitrate recovered no ground: trough {trough_kbps} kbps, final {final_kbps} kbps"2130+ );2131+ Some(ResolutionRecoveryEvidence {2132+ windows: recovery_windows,2133+ trough_kbps,2134+ final_kbps,2135+ increase_observed,2136+ final_dimensions: latest_dimensions,2137+ restored_native: latest_dimensions == native_dimensions,2138+ })2139+ } else {2140+ None2141+ };2142+11892143 Ok(ResolutionStallEvidence {11902144 native_dimensions,11912145 reduced_dimensions,
@@ -1196,6 +2150,7 @@ async fn run_resolution_stall_smoke(
11962150 resume_input_ack_micros,11972151 resume_media_micros,11982152 resume_sequence_advance,2153+ recovery,11992154 })12002155 }12012156
@@ -1243,6 +2198,22 @@ async fn main() -> Result<()> {
12432198 .context("binding probe endpoint")?;12442199 let _ = tokio::time::timeout(Duration::from_secs(10), endpoint.online()).await;12452200 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+ }12462217 let media_transport = if args.media_v3 {12472218 MediaTransport::GroupedV312482219 } else {
@@ -1283,7 +2254,10 @@ async fn main() -> Result<()> {
12832254 &endpoint,12842255 address.clone(),12852256 session_id,2257+ session_id,2258+ None,12862259 Duration::from_secs(args.timeout_seconds),2260+ None,12872261 )12882262 .await?,12892263 )
@@ -1491,6 +2465,7 @@ async fn main() -> Result<()> {
14912465 &mut expected_ack,14922466 args.expect_size,14932467 Duration::from_secs(args.timeout_seconds),2468+ args.resolution_recovery_windows,14942469 )14952470 .await?;14962471
@@ -1534,6 +2509,33 @@ async fn main() -> Result<()> {
15342509 "resolution_stall_input_ack_micros={}",15352510 evidence.stall_input_ack_micros15362511 );2512+ match evidence.recovery.as_ref() {2513+ Some(recovery) => {2514+ println!("resolution_recovery_windows={}", recovery.windows);2515+ println!("resolution_recovery_trough_kbps={}", recovery.trough_kbps);2516+ println!("resolution_recovery_final_kbps={}", recovery.final_kbps);2517+ println!(2518+ "resolution_recovery_increase_observed={}",2519+ recovery.increase_observed2520+ );2521+ println!(2522+ "resolution_recovery_final_dimensions={}x{}",2523+ recovery.final_dimensions.0, recovery.final_dimensions.12524+ );2525+ println!(2526+ "resolution_recovery_restored_native={}",2527+ recovery.restored_native2528+ );2529+ }2530+ None => {2531+ println!("resolution_recovery_windows=not-requested");2532+ println!("resolution_recovery_trough_kbps=not-requested");2533+ println!("resolution_recovery_final_kbps=not-requested");2534+ println!("resolution_recovery_increase_observed=not-requested");2535+ println!("resolution_recovery_final_dimensions=not-requested");2536+ println!("resolution_recovery_restored_native=not-requested");2537+ }2538+ }15372539 println!(15382540 "resolution_resume_input_ack_micros={}",15392541 evidence.resume_input_ack_micros
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 +108 -9
@@ -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)]
@@ -95,12 +97,22 @@ pub enum VaapiRateControl {
9597 #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]9698 #[serde(rename_all = "kebab-case")]9799 pub enum GamescopeEncoderBackend {98- /// Preserve the proven child-process pipeline and its natural-IDR control99- /// limitation for existing configurations.100- #[default]100+ /// The child-process pipeline. It cannot change encoder properties while101+ /// playing, so adaptive bitrate and motion-sensitive resolution can only102+ /// run in shadow. Retained for CQP and as an explicit escape hatch, and it103+ /// is the default on builds that cannot run the in-process backend.104+ #[cfg_attr(105+ not(all(target_os = "linux", feature = "in-process-gstreamer")),106+ default107+ )]101108 ExternalGstLaunch,102109 /// Run the video pipeline in-process so Sigil can apply bounded encoder103- /// controls. This remains an explicit opt-in until hardware acceptance.110+ /// controls. This is the only backend where adaptive bitrate and111+ /// resolution actuation are live, so it is the default wherever it can112+ /// actually run. The default deliberately tracks build capability: a113+ /// binary without this feature would otherwise reject its own default114+ /// configuration.115+ #[cfg_attr(all(target_os = "linux", feature = "in-process-gstreamer"), default)]104116 InProcessGstreamer,105117 }106118
@@ -120,6 +132,10 @@ fn default_input_mode() -> InputMode {
120132 InputMode::Disabled121133 }122134 135+const fn default_max_viewers() -> u8 {136+ DEFAULT_MAX_VIEWERS137+}138+123139 fn default_ffmpeg() -> PathBuf {124140 PathBuf::from("ffmpeg")125141 }
@@ -144,8 +160,9 @@ pub struct GamescopePipewireConfig {
144160 pub pw_dump_path: PathBuf,145161 pub gst_launch_path: PathBuf,146162 pub gst_inspect_path: PathBuf,147- /// External gst-launch remains the compatibility default. The in-process148- /// backend is accepted only by Linux builds that contain its feature.163+ /// Defaults to the in-process backend on builds that contain its feature,164+ /// because that is the only backend where adaptive bitrate and resolution165+ /// actuation are live. Other builds default to external gst-launch.149166 #[serde(default)]150167 pub encoder_backend: GamescopeEncoderBackend,151168 /// Exact dynamically registered VA encoder factory, such as `vah264enc`.
@@ -268,10 +285,19 @@ impl GamescopePipewireConfig {
268285 || cfg!(all(target_os = "linux", feature = "in-process-gstreamer")),269286 "gamescope_pipewire.encoder_backend=in-process-gstreamer requires a Linux Sigil build with the in-process-gstreamer feature"270287 );288+ // Not a temporary gap: the in-process pipeline is built around a289+ // mutable encoder `bitrate` property, which constant-quantizer mode290+ // does not have. The two are contradictory rather than unfinished.271291 ensure!(272292 self.encoder_backend != GamescopeEncoderBackend::InProcessGstreamer273293 || self.rate_control == VaapiRateControl::Cbr,274- "gamescope_pipewire.encoder_backend=in-process-gstreamer currently requires CBR"294+ "gamescope_pipewire.encoder_backend=in-process-gstreamer requires \295+ rate_control=\"cbr\", because adaptive bitrate changes a live \296+ encoder bitrate that constant-quantizer mode does not expose. \297+ Either set rate_control=\"cbr\" to keep adaptive bitrate and \298+ motion-sensitive resolution, or set \299+ encoder_backend=\"external-gst-launch\" to keep CQP and accept \300+ that both adapt in shadow only."275301 );276302 ensure!(277303 self.vaapi_render_node
@@ -363,6 +389,14 @@ fn validate_pipewire_property(name: &str, value: &str) -> Result<()> {
363389 pub struct HostConfig {364390 pub identity_path: PathBuf,365391 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>,366400 #[serde(default = "default_source")]367401 pub source: VideoSource,368402 /// Optional encoded-size override. Gamescope uses its advertised native
@@ -468,6 +502,20 @@ impl HostConfig {
468502 !self.state_path.as_os_str().is_empty(),469503 "state_path is required"470504 );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+ }471519 match (self.width, self.height) {472520 (Some(width), Some(height)) => validate_video_dimensions(width, height)?,473521 (None, None) => {}
@@ -608,6 +656,16 @@ fn validate_file_security(path: &Path, metadata: &fs::Metadata) -> Result<()> {
608656 mod tests {609657 use super::*;610658 659+ /// The compiled default tracks build capability, so tests must expect the660+ /// backend this binary can actually run rather than a fixed variant.661+ fn default_encoder_backend() -> GamescopeEncoderBackend {662+ if cfg!(all(target_os = "linux", feature = "in-process-gstreamer")) {663+ GamescopeEncoderBackend::InProcessGstreamer664+ } else {665+ GamescopeEncoderBackend::ExternalGstLaunch666+ }667+ }668+611669 #[test]612670 fn config_revision_is_exact_bounded_and_strict() {613671 let first = ConfigRevision::from_bytes(b"a = 1\n");
@@ -695,10 +753,51 @@ state_path = "/tmp/state"
695753 )696754 .unwrap();697755 config.validate().unwrap();756+ assert_eq!(config.max_viewers, DEFAULT_MAX_VIEWERS);698757 assert_eq!(config.configured_dimensions(), None);699758 assert_eq!(config.test_pattern_dimensions().unwrap(), (1_280, 800));700759 }701760 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+702801 #[test]703802 fn validates_strict_gamescope_pipewire_configuration() {704803 let input = r#"
@@ -837,7 +936,7 @@ source = "gamescope-pipewire"
837936 }838937 839938 #[test]840- fn gamescope_encoder_backend_defaults_external() {939+ fn gamescope_encoder_backend_defaults_to_build_capability() {841940 let config: HostConfig = toml::from_str(842941 r#"843942 identity_path = "/tmp/host.key"
@@ -860,7 +959,7 @@ bitrate_kbps = 12000
860959 861960 assert_eq!(862961 config.gamescope_pipewire.unwrap().encoder_backend,863- GamescopeEncoderBackend::ExternalGstLaunch962+ default_encoder_backend()864963 );865964 }866965
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 +1070 -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
@@ -1146,6 +1914,32 @@ pub(super) async fn serve_media_feedback(
11461914 };11471915 let encoder_actuation_available =11481916 adaptive_bitrate_actuation_enabled(config) && lease.encoder_control.is_some();1917+ // Shadow mode computes every decision and applies none. Say why, or a host1918+ // silently never adapts and nothing in the logs explains it.1919+ if !encoder_actuation_available {1920+ let reason = if !adaptive_bitrate_actuation_enabled(config) {1921+ match config.gamescope_pipewire.as_ref() {1922+ Some(gamescope)1923+ if gamescope.encoder_backend != GamescopeEncoderBackend::InProcessGstreamer =>1924+ {1925+ "gamescope_pipewire.encoder_backend is not in-process-gstreamer"1926+ }1927+ Some(gamescope) if gamescope.rate_control != VaapiRateControl::Cbr => {1928+ "gamescope_pipewire.rate_control is not cbr"1929+ }1930+ Some(_) => "adaptive bitrate actuation is unavailable for this configuration",1931+ None => "the configured source is not gamescope-pipewire",1932+ }1933+ } else {1934+ "this session has no encoder control handle"1935+ };1936+ warn!(1937+ %remote,1938+ session_id = lease.session_id,1939+ reason,1940+ "adaptive bitrate and resolution run in shadow: decisions are computed but never applied"1941+ );1942+ }11491943 11501944 write_host_hello(11511945 &mut send,
@@ -1155,9 +1949,44 @@ pub(super) async fn serve_media_feedback(
11551949 ),11561950 )11571951 .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+ }11581986 info!(11591987 %remote,11601988 session_id = lease.session_id,1989+ authorization_revision = lease.authorization_revision,11611990 ceiling_kbps,11621991 applied = false,11631992 mode = if encoder_actuation_available { "active" } else { "shadow" },
@@ -1189,6 +2018,99 @@ pub(super) async fn serve_media_feedback(
11892018 result11902019 }11912020 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+11922114 #[allow(clippy::too_many_arguments)]11932115 async fn run_media_feedback_loop<W, C>(11942116 send: &mut W,
@@ -1755,6 +2677,151 @@ mod tests {
17552677 }17562678 }17572679 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+17582825 fn controller_decide(17592826 controller: &mut ShadowBitrateController,17602827 report: &MediaFeedbackReportV1,
A crates/sigil-host/src/server/focus.rs +564 -0

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

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 +103 -0

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

M docs/portal-platform-support.md +54 -5

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

M docs/portal-release.md +77 -52

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

M docs/public-alpha-uat.md +85 -17

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

M docs/public-release-delivery.md +42 -16

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

A docs/release-credentials.md +195 -0

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.

D release/portal-apple-team-id.txt +0 -1

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/package-macos-client.sh +109 -53

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

M scripts/public-alpha-uat.sh +60 -20

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 +19 -8

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/tests/test_portal_release.py +20 -5

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 scripts/verify-macos-portal-signature.sh +45 -14

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

M scripts/verify-portal-release.py +13 -7

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

M scripts/verify-website.sh +8 -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.

M website/dither.js +2 -2

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

M website/index.html +15 -17

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