From 4b942bfda0a3ed3e6994260dfabab800086e54c9 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:32:05 +0800 Subject: [PATCH] feat(native): add provisional fixed-high sender candidate (#61) --- .github/workflows/repository-hygiene.yml | 11 + .../0006-fixed-high-native-sender-canary.md | 27 +- docs/project-memory.md | 2 +- docs/research/native-shared-encode-sender.md | 34 +- docs/status.md | 7 +- native/sender/cmd/screener-sender/main.go | 55 ++ native/sender/go.mod | 32 + native/sender/go.sum | 56 ++ native/sender/internal/app/security.go | 59 ++ native/sender/internal/app/security_test.go | 60 ++ native/sender/internal/app/server.go | 496 ++++++++++ native/sender/internal/app/server_test.go | 383 ++++++++ native/sender/internal/app/ui/app.js | 528 +++++++++++ native/sender/internal/app/ui/index.html | 60 ++ native/sender/internal/app/ui/styles.css | 68 ++ native/sender/internal/media/fanout.go | 257 ++++++ native/sender/internal/media/fanout_test.go | 179 ++++ native/sender/internal/media/queue.go | 157 ++++ native/sender/internal/media/queue_test.go | 93 ++ native/sender/internal/remote/admission.go | 96 ++ .../sender/internal/remote/admission_test.go | 83 ++ native/sender/internal/remote/peer.go | 300 +++++++ native/sender/internal/remote/peer_test.go | 77 ++ native/sender/internal/remote/session.go | 848 ++++++++++++++++++ native/sender/internal/remote/session_test.go | 784 ++++++++++++++++ native/sender/internal/remote/wire.go | 429 +++++++++ native/sender/internal/remote/wire_test.go | 222 +++++ src/server/app.ts | 5 +- src/server/room-store.ts | 103 ++- src/server/signaling.ts | 2 +- src/shared/protocol.ts | 3 + tests/protocol.test.ts | 25 + tests/room-store.test.ts | 127 +++ tests/server-http.test.ts | 25 +- tests/server-signal.test.ts | 46 +- 35 files changed, 5699 insertions(+), 40 deletions(-) create mode 100644 native/sender/cmd/screener-sender/main.go create mode 100644 native/sender/go.mod create mode 100644 native/sender/go.sum create mode 100644 native/sender/internal/app/security.go create mode 100644 native/sender/internal/app/security_test.go create mode 100644 native/sender/internal/app/server.go create mode 100644 native/sender/internal/app/server_test.go create mode 100644 native/sender/internal/app/ui/app.js create mode 100644 native/sender/internal/app/ui/index.html create mode 100644 native/sender/internal/app/ui/styles.css create mode 100644 native/sender/internal/media/fanout.go create mode 100644 native/sender/internal/media/fanout_test.go create mode 100644 native/sender/internal/media/queue.go create mode 100644 native/sender/internal/media/queue_test.go create mode 100644 native/sender/internal/remote/admission.go create mode 100644 native/sender/internal/remote/admission_test.go create mode 100644 native/sender/internal/remote/peer.go create mode 100644 native/sender/internal/remote/peer_test.go create mode 100644 native/sender/internal/remote/session.go create mode 100644 native/sender/internal/remote/session_test.go create mode 100644 native/sender/internal/remote/wire.go create mode 100644 native/sender/internal/remote/wire_test.go diff --git a/.github/workflows/repository-hygiene.yml b/.github/workflows/repository-hygiene.yml index e91ba1e..571c243 100644 --- a/.github/workflows/repository-hygiene.yml +++ b/.github/workflows/repository-hygiene.yml @@ -21,6 +21,17 @@ jobs: run: sh scripts/check-project-state.sh env: SCREENER_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: 1.26.6 + cache-dependency-path: native/sender/go.sum + - name: Check native sender + working-directory: native/sender + run: | + go test ./... + go vet ./... + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o "$RUNNER_TEMP/screener-sender.exe" ./cmd/screener-sender - name: Set up Node.js uses: actions/setup-node@v6 with: diff --git a/docs/adr/0006-fixed-high-native-sender-canary.md b/docs/adr/0006-fixed-high-native-sender-canary.md index 2256993..88390c0 100644 --- a/docs/adr/0006-fixed-high-native-sender-canary.md +++ b/docs/adr/0006-fixed-high-native-sender-canary.md @@ -17,7 +17,7 @@ Any renewed product-wiring canary stays deliberately narrow: - one fixed VP8 `HIGH` representation at 1280x720, 30 fps, and a 3 Mbps ceiling; - one WebCodecs encoder object feeding at most two independent Pion WebRTC legs; - the current strict ordinary-P2P signaling wire and unmodified browser viewers; -- independent ICE/TURN, RTP/RTCP, congestion, and lifecycle state per leg; +- independent STUN-backed ICE, RTP/RTCP, congestion, and lifecycle state per leg; - read-only feedback, with PLI/FIR limited to requesting a shared keyframe; and - bounded local queues, fail-closed critical errors, and sanitized local-only diagnostics. @@ -36,6 +36,26 @@ failure. No viewer-1 acceptance means there is no two-viewer, third-viewer FIFO, or direct/TURN proof. The attempted native branch must not be opened or merged as product code from this state. +## 2026-08-20 Current-Wire Checkpoint + +A clean local delivery candidate now targets only `screener-v2`: it exchanges +`HOST_ADMISSION_PASSWORD` for the bounded HttpOnly Host-admission cookie, uses +that cookie to create an explicit `private-link` room, carries it on the Host +WebSocket upgrade, and strictly decodes the current ordinary authenticated and +read-only Viewer-evidence shapes. There is no v1 parser, translator, raw-grant +persistence, or Bearer room-creation shortcut. Its fixed 300-second provisional +request always creates a random transient room in memory, even when SQLite is +configured. A connected current Host suppresses that reclaim deadline; only its +generation-matched disconnect resets the five-minute window, while the ordinary +transient `ROOM_TTL_SECONDS` cap always remains. A pre-auth connection or +authentication failure gets one retry with the same room/token/client generation +and never another room POST. Server restart drops the room; SQLite stays at v2 +and ordinary Web rooms are unchanged. No claim-confirm message, durable native token, or recovery +subsystem is added. Focused Go/TypeScript tests and static checks pass. The unused +generic probe framework is deliberately not retained. +No runtime/browser revalidation has run, so the product gate remains no-go and +this local branch is not a product availability claim. + ## Staged Revalidation A new run requires separate authorization and must stop at the first failed @@ -52,8 +72,9 @@ stage while retaining a bounded final-negative snapshot: 5. Add viewer 3, prove no third edge and a host-side waiting observation while the viewer keeps its existing waiting state, then close viewer 1 and prove FIFO promotion plus decoding/rendering. -6. If the isolated environment has valid TURN configuration, prove one direct - and one forced-TURN leg; otherwise record the gap once without retrying. +6. Keep this candidate STUN-only. If a selected-edge TURN contract is later + accepted, prove it in a separate bounded gate rather than widening ordinary + native ICE or blocking the direct-path canary. Only after these stages pass may separate performance, quality, loss, reconnect, browser, audio/A-V-sync, packaging, and license gates begin. Do not diff --git a/docs/project-memory.md b/docs/project-memory.md index 381c948..47d9797 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -23,7 +23,7 @@ Last updated: 2026-08-20 - Use direct host P2P for one or two viewers. Keep ADR-0004 off broadly; use an isolated exact-room candidate until resource, quality, recovery, SFU/UDP, bounded-failure, and browser/mobile gates pass. - Browser relays resend remote `MediaStreamTrack` values and re-encode at each hop; WebRTC does not guarantee a shared encoder across peer connections, so measure the cost. - Keep experiments bounded: the standard representation sequence is below; native RTP relay, encoded-object striping, and FEC remain separate. -- ADR-0006 fixed-`HIGH` is no-go-unclassified after timing out before Viewer evidence; any revisit starts at its staged gate. +- ADR-0006 stays no-go. Its v2 room is memory-only, capped by `ROOM_TTL` and rearmed 300s after Host disconnect; SQLite/Web unchanged. Pre-auth retries once in-room without another POST. - Mobile Web Host is unsupported; feature-detect and fail clearly. Mobile Viewer stays leaf-only. After Windows native, gate Android 14+; iOS waits for stable iOS 27 ScreenCaptureKit. - ADR-0005 accepts SFU roots as the primary central fallback after direct/peer UDP. The candidate has no TURN consumer or credential wire; any future exceptional-edge grant is a complete separate change. PR #12 is superseded. - Keep the controller exact-room only: room `1` is the STUN/SFU smoke. Preserve sticky progressing P2P, mobile leaves and break-before-make. Recovery spends one attempt per layer (ICE restart, same-parent rebuild, alternate peer, then SFU), never three identical retries; active SFU gets one fresh grant before Peer failback. diff --git a/docs/research/native-shared-encode-sender.md b/docs/research/native-shared-encode-sender.md index 8cdc924..2b5e926 100644 --- a/docs/research/native-shared-encode-sender.md +++ b/docs/research/native-shared-encode-sender.md @@ -165,6 +165,24 @@ strict ordinary signaling wire, unmodified viewers, read-only feedback, and no audio or automatic `LOW`. This is a proposed test boundary, not current product behavior or hardware-encoder evidence. +The 2026-08-20 local delivery checkpoint rebases that candidate onto the sole +current access contract: `screener-v2`, Host-admission Cookie authentication, +explicit `private-link` creation, fragment-only Viewer grants returned to the +local Host, and STUN-only ordinary ICE. Native creation alone requests a fixed +300-second provisional room. It remains random and memory-only even with SQLite +configured: the current Host session suppresses the reclaim deadline, its +generation-matched disconnect resets the five-minute window, the ordinary +transient room TTL still caps the room, and restart drops it. A pre-auth +connection or authentication failure gets one same-room/token/client-generation +retry without a second room POST. SQLite remains v2 and ordinary Web creation is +unchanged. +HTTP statuses, Cookie attributes, create-room responses, Host authentication, +and ignored Viewer evidence are strict and bounded; errors retain only fixed categories. +Focused Go and TypeScript checks pass, but no Node/Chrome runtime gate has run. +The overbroad unused gate/probe framework was deleted; a future authorized run +must add only its executable minimum. This static checkpoint does not change the +no-go result below. + The sole authorized 2026-08-19 local product gate is `no-go-unclassified`: @@ -286,12 +304,13 @@ No stacked PR is connected to Screener's product path, capture path, or audio path. Before product consideration, a target native sender must still prove one physical encoder invocation on representative hardware, live PLI/FIR-to-encoder control, heterogeneous downstream estimates, bounded burst -and sustained loss, audio/A-V synchronization, mixed direct/TURN edges, -reconnect isolation, browser diversity, lifecycle, and sustained CPU/GPU, +and sustained loss, audio/A-V synchronization, reconnect isolation, browser +diversity, lifecycle, and sustained CPU/GPU, memory, latency, quality, and upload measurements. A third host edge, custom RTP/SRTP, or a custom congestion-control framework remains out of scope. The #28 minimum-of-two policy is also out of scope for product code; retaining its -evidence does not retain its policy. +evidence does not retain its policy. Selected-edge TURN is a separate future +contract and gate, not a prerequisite silently added to this STUN-only canary. ## Primary Sources And License Boundary @@ -331,6 +350,9 @@ evidence does not retain its policy. - [Apple required hardware encoder key](https://developer.apple.com/documentation/videotoolbox/kvtvideoencoderspecification_requirehardwareacceleratedvideoencoder) - [Apple hardware encoder readback](https://developer.apple.com/documentation/videotoolbox/kvtcompressionpropertykey_usinghardwareacceleratedvideoencoder) -libwebrtc uses a BSD-style license and Pion uses MIT, but distribution still -requires a full libwebrtc third-party notice review and an H.264 patent/licensing -review. No source code from these projects was copied into Screener. +The current Go candidate directly uses Pion (MIT) and coder/websocket (ISC). +Before distributing any sender executable, choose the project license and +generate and verify notices for those direct and all transitive dependencies. +libwebrtc/H.264 remains a separate future route requiring its own third-party +notice and patent/licensing review. No source code from these projects was +copied into Screener. diff --git a/docs/status.md b/docs/status.md index 177a441..6401e18 100644 --- a/docs/status.md +++ b/docs/status.md @@ -17,6 +17,7 @@ The v1 DB/old env pair is only for the deeper pre-access `9610032` rollback. - Capture precedes room creation; live source/quality changes preserve peers and picture pause keeps audio/connections. Quality defaults to `maintain-resolution` with balanced/fluid options. - Production requires `HOST_ADMISSION_PASSWORD` only for creation/Host role and uses `screener-v2`, default private fragment grants, explicit public-watch, and generation-bound rotate/revoke. Viewers never need the Host secret; there is no account/session table or old parser. - SQLite v2 keeps Host and nullable Viewer-grant digests in one checked `STRICT` row. The stopped migration retained four rooms and locked each old room private without minting a raw grant. +- Native v2 rooms are memory-only, reclaim 300s after Host disconnect, obey `ROOM_TTL`, and leave SQLite/Web unchanged. - PR #44's exact-room STUN-only/SFU-UDP router is enabled only for production room `1`: host/SFU roots <=2, browser relay <=1, bounded failure. HTTPS/WSS stays TLS/TCP. - Fallback prewarm is token-free and limited to room `1`; mobile/iPad viewers are leaves and healthy edges stay sticky. - After an answer, a generation-bound 15s initial-connect deadline enters ICE restart; success/replacement/disposal cancels it. It is deployed but not mobile-verified. @@ -43,7 +44,7 @@ The v1 DB/old env pair is only for the deeper pre-access `9610032` rollback. - Silent partitions can wait 30 to 60 seconds for heartbeat detection before the default 5-second grace; this remains unverified. - Real audio and heterogeneous clients remain unverified. Production reports poor film audio and voice-call self-echo under system capture; there is no app audio ceiling or Web process isolation. Diagnose A/B/C and sync, then gate Windows 11 game-process audio; Windows 10 stays unresolved without system fallback. - The former release reportedly sustained bandwidth-limited blur on a capable LAN; Host refresh recovered while Viewer refresh did not. The new deployment has not yet reproduced or cleared it. -- Browser relays re-encode. ADR-0006 is no-go-unclassified: downstream checkpoints and two-edge/FIFO/TURN gates are absent. Spikes prove one WebCodecs object, not hardware; GCC+RTX is no-go and no-RTX weakens stats. +- ADR-0006 stays no-go: its v2 memory-only candidate passed static checks only; render, two-edge/FIFO and later TURN remain unverified. - Browser fanout is host two/viewer one; any accepted endpoint relay stays capped at two downstream edges. - PR #49/#50 BWE is unverified/default-off: test zero-child leaves, then root impact. No local per-leaf UDP shaper; resume with Linux `tc` or public canary, never CDP. Web P2P/SVC shortcuts remain no-go. - The corrected standby smoke is localhost/headless/video-only; public DNS/TLS reuse, transport, audio, shaping, load, mobile, endurance, and sub-25 ms overlap remain open. @@ -58,8 +59,8 @@ The recovery target uses one ICE restart, one same-parent rebuild, one alternate SFU; it never runs three identical retries or abandons progressing P2P early. Next gate exactly-two/Dynacast-off BWE on a zero-child SFU leaf, then root -evacuation. In parallel, migrate the native staged gate to v2; -username/roster stays separate. Audio A/B/C may run without displacing P0. +evacuation. Native v2 remains no-go pending an authorized runtime gate; Audio +A/B/C may proceed without displacing P0. TURN remains a future selected-edge change, not this canary. ADR-0004 still requires a full-resolution 30-minute `1/3/5/8` network, diff --git a/native/sender/cmd/screener-sender/main.go b/native/sender/cmd/screener-sender/main.go new file mode 100644 index 0000000..2177cf6 --- /dev/null +++ b/native/sender/cmd/screener-sender/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "runtime" + "syscall" + + "github.com/TNTcraftHIM/Screener/native/sender/internal/app" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "Screener sender stopped:", err) + os.Exit(1) + } +} + +func run() error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + sender, err := app.New() + if err != nil { + return err + } + defer sender.Close() + launchURL, err := sender.Start() + if err != nil { + return err + } + + if err = openBrowser(launchURL); err != nil { + return fmt.Errorf("open the local sender UI: %w", err) + } + fmt.Println("Screener sender is running. Close this window to stop sharing.") + <-ctx.Done() + return nil +} + +func openBrowser(url string) error { + var command *exec.Cmd + switch runtime.GOOS { + case "windows": + command = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + case "darwin": + command = exec.Command("open", url) + default: + command = exec.Command("xdg-open", url) + } + return command.Start() +} diff --git a/native/sender/go.mod b/native/sender/go.mod new file mode 100644 index 0000000..f8bbc89 --- /dev/null +++ b/native/sender/go.mod @@ -0,0 +1,32 @@ +module github.com/TNTcraftHIM/Screener/native/sender + +go 1.24.0 + +require ( + github.com/coder/websocket v1.8.15 + github.com/pion/rtcp v1.2.17 + github.com/pion/rtp v1.10.5 + github.com/pion/webrtc/v4 v4.2.18 +) + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/pion/datachannel v1.6.2 // indirect + github.com/pion/dtls/v3 v3.1.5 // indirect + github.com/pion/ice/v4 v4.4.0 // indirect + github.com/pion/interceptor v0.1.47 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/mdns/v2 v2.1.0 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/sctp v1.11.1 // indirect + github.com/pion/sdp/v3 v3.0.19 // indirect + github.com/pion/srtp/v3 v3.0.12 // indirect + github.com/pion/stun/v3 v3.1.6 // indirect + github.com/pion/transport/v4 v4.0.2 // indirect + github.com/pion/turn/v5 v5.0.12 // indirect + github.com/wlynxg/anet v0.0.5 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/time v0.14.0 // indirect +) diff --git a/native/sender/go.sum b/native/sender/go.sum new file mode 100644 index 0000000..409adbe --- /dev/null +++ b/native/sender/go.sum @@ -0,0 +1,56 @@ +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc= +github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E= +github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc= +github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= +github.com/pion/ice/v4 v4.4.0 h1:wvHDDqimaC38Y7MVpD46Y63p246ChvXd87VKoLYS5b4= +github.com/pion/ice/v4 v4.4.0/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU= +github.com/pion/interceptor v0.1.47 h1:yw8t5pJ2f8t78NgU+8EmxhaqYLXS7uFCC/tAGOaSDBo= +github.com/pion/interceptor v0.1.47/go.mod h1:7yoRBzaIDETPC6cIN8Zj9EyGqHv1ImOpcTFPha6MuOM= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= +github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw= +github.com/pion/rtcp v1.2.17/go.mod h1:7kBpuBJaWwax4hzc/pgexY8vkOpvh8atgYDbaKZq0iU= +github.com/pion/rtp v1.10.5 h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE= +github.com/pion/rtp v1.10.5/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= +github.com/pion/sctp v1.11.1 h1:O4dIFyURw1KTST7w+gtD4gLeYXkhPa0xXLHMMoe/OSA= +github.com/pion/sctp v1.11.1/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0= +github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ= +github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU= +github.com/pion/srtp/v3 v3.0.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc= +github.com/pion/srtp/v3 v3.0.12/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns= +github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8= +github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= +github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= +github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= +github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= +github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= +github.com/pion/turn/v5 v5.0.12 h1:6+b69ivQQXSlyfkp2AKripqD2k3W32qXK8QzCzpJWPI= +github.com/pion/turn/v5 v5.0.12/go.mod h1:CQACsRDJtjQ+6RSrGHrS2PCIerLwbW3uqXRqOvtjAFg= +github.com/pion/webrtc/v4 v4.2.18 h1:smA/3g6Gy4RohM0VIZ5KKY/12TQbxv3XFgpUMyb2EUI= +github.com/pion/webrtc/v4 v4.2.18/go.mod h1:vmzi6s+rvhoIuT94DPqivB+0xJXs9rG4QRD+4MgBtlY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/native/sender/internal/app/security.go b/native/sender/internal/app/security.go new file mode 100644 index 0000000..7afea92 --- /dev/null +++ b/native/sender/internal/app/security.go @@ -0,0 +1,59 @@ +package app + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "net" + "net/http" + "net/url" + "strings" +) + +const processTokenBytes = 32 + +func newOpaqueToken() (string, error) { + buffer := make([]byte, processTokenBytes) + if _, err := rand.Read(buffer); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buffer), nil +} + +func tokenMatches(actual, expected string) bool { + if len(actual) != len(expected) { + return false + } + return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1 +} + +func normalizeRemoteBase(raw string) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return nil, errors.New("server URL is invalid") + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || + (parsed.Path != "" && parsed.Path != "/") || parsed.Hostname() == "" { + return nil, errors.New("server URL must contain only an origin") + } + if parsed.Scheme != "https" { + if parsed.Scheme != "http" || !isLoopbackHost(parsed.Hostname()) { + return nil, errors.New("server URL must use HTTPS (HTTP is allowed only for loopback development)") + } + } + parsed.Path = "" + return parsed, nil +} + +func isLoopbackHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func exactRequest(request *http.Request, expectedHost, expectedOrigin string) bool { + return request.Host == expectedHost && request.Header.Get("Origin") == expectedOrigin +} diff --git a/native/sender/internal/app/security_test.go b/native/sender/internal/app/security_test.go new file mode 100644 index 0000000..7c60e30 --- /dev/null +++ b/native/sender/internal/app/security_test.go @@ -0,0 +1,60 @@ +package app + +import ( + "net/http/httptest" + "testing" +) + +func TestNormalizeRemoteBaseAllowsHTTPSAndLoopbackHTTPOnly(t *testing.T) { + valid := []string{ + "https://share.example.test", + "http://127.0.0.1:3000", + "http://[::1]:3000", + "http://localhost:3000", + } + for _, value := range valid { + if _, err := normalizeRemoteBase(value); err != nil { + t.Fatalf("valid remote base %q: %v", value, err) + } + } + invalid := []string{ + "http://share.example.test", + "https://user@example.test", + "https://example.test/path", + "https://example.test/?query=1", + "https://example.test/#fragment", + } + for _, value := range invalid { + if _, err := normalizeRemoteBase(value); err == nil { + t.Fatalf("invalid remote base %q was accepted", value) + } + } +} + +func TestExactRequestRequiresBothHostAndOrigin(t *testing.T) { + request := httptest.NewRequest("GET", "http://127.0.0.1:9000/media", nil) + request.Host = "127.0.0.1:9000" + request.Header.Set("Origin", "http://127.0.0.1:9000") + if !exactRequest(request, "127.0.0.1:9000", "http://127.0.0.1:9000") { + t.Fatal("exact loopback request was rejected") + } + request.Header.Set("Origin", "http://localhost:9000") + if exactRequest(request, "127.0.0.1:9000", "http://127.0.0.1:9000") { + t.Fatal("alternate loopback origin was accepted") + } + request.Header.Set("Origin", "http://127.0.0.1:9000") + request.Host = "localhost:9000" + if exactRequest(request, "127.0.0.1:9000", "http://127.0.0.1:9000") { + t.Fatal("alternate Host was accepted") + } +} + +func TestProcessTokenComparisonIsExact(t *testing.T) { + token := "0123456789abcdef0123456789abcdef0123456789abcdef" + if !tokenMatches(token, token) { + t.Fatal("equal token did not match") + } + if tokenMatches(token+"x", token) || tokenMatches(token[:len(token)-1]+"x", token) { + t.Fatal("different token matched") + } +} diff --git a/native/sender/internal/app/server.go b/native/sender/internal/app/server.go new file mode 100644 index 0000000..9f80d99 --- /dev/null +++ b/native/sender/internal/app/server.go @@ -0,0 +1,496 @@ +package app + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "time" + + "github.com/TNTcraftHIM/Screener/native/sender/internal/media" + "github.com/TNTcraftHIM/Screener/native/sender/internal/remote" + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" +) + +const ( + processTokenHeader = "X-Screener-Process-Token" + maxLocalJSONBytes = 8 << 10 + localWriteTimeout = 2 * time.Second + mediaAttachTimeout = 10 * time.Second + mediaConfigTimeout = 5 * time.Second +) + +//go:embed ui/index.html ui/app.js ui/styles.css +var uiFiles embed.FS + +type App struct { + ctx context.Context + cancel context.CancelFunc + token string + attachTimeout time.Duration + + mu sync.Mutex + writeMu sync.Mutex + server *http.Server + listener net.Listener + expectedHost string + origin string + starting bool + generation uint64 + startCancel context.CancelFunc + startFailure string + session *remote.Session + sessionCancel context.CancelFunc + room remote.Room + mediaConn *websocket.Conn + mediaClaimed bool + mediaClaimID uint64 + nextClaimID uint64 + attachTimer *time.Timer + configTimeout time.Duration + activeViewers int + waitingViewers int +} + +type startRequest struct { + ServerURL string `json:"serverUrl"` + Password string `json:"password"` +} + +type encoderConfig struct { + Kind string `json:"kind"` + Codec string `json:"codec"` + Width int `json:"width"` + Height int `json:"height"` + FPS int `json:"fps"` + Bitrate int `json:"bitrate"` + EncoderInstances int `json:"encoderInstances"` +} + +func New() (*App, error) { + token, err := newOpaqueToken() + if err != nil { + return nil, fmt.Errorf("create process token: %w", err) + } + ctx, cancel := context.WithCancel(context.Background()) + return &App{ + ctx: ctx, cancel: cancel, token: token, + attachTimeout: mediaAttachTimeout, + configTimeout: mediaConfigTimeout, + }, nil +} + +func (app *App) Start() (string, error) { + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + return "", fmt.Errorf("listen on loopback: %w", err) + } + address, ok := listener.Addr().(*net.TCPAddr) + if !ok { + listener.Close() + return "", errors.New("loopback listener returned an unexpected address") + } + app.listener = listener + app.expectedHost = fmt.Sprintf("127.0.0.1:%d", address.Port) + app.origin = "http://" + app.expectedHost + app.server = &http.Server{ + Handler: app, + ReadHeaderTimeout: 5 * time.Second, + IdleTimeout: 30 * time.Second, + } + go func() { + _ = app.server.Serve(listener) + }() + return app.origin + "/#token=" + app.token, nil +} + +func (app *App) Close() { + app.stopActive() + app.cancel() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if app.server != nil { + _ = app.server.Shutdown(ctx) + } +} + +func (app *App) ServeHTTP(response http.ResponseWriter, request *http.Request) { + setSecurityHeaders(response) + if request.Host != app.expectedHost { + http.NotFound(response, request) + return + } + switch request.URL.Path { + case "/": + app.serveAsset(response, request, "ui/index.html", "text/html; charset=utf-8") + case "/app.js": + app.serveAsset(response, request, "ui/app.js", "text/javascript; charset=utf-8") + case "/styles.css": + app.serveAsset(response, request, "ui/styles.css", "text/css; charset=utf-8") + case "/api/start": + app.handleStart(response, request) + case "/api/stop": + app.handleStop(response, request) + case "/media": + app.handleMedia(response, request) + default: + http.NotFound(response, request) + } +} + +func (app *App) serveAsset(response http.ResponseWriter, request *http.Request, name, contentType string) { + if request.Method != http.MethodGet { + response.Header().Set("Allow", http.MethodGet) + http.Error(response, "method not allowed", http.StatusMethodNotAllowed) + return + } + contents, err := uiFiles.ReadFile(name) + if err != nil { + http.Error(response, "asset unavailable", http.StatusInternalServerError) + return + } + response.Header().Set("Content-Type", contentType) + response.Header().Set("Content-Length", fmt.Sprint(len(contents))) + response.WriteHeader(http.StatusOK) + _, _ = response.Write(contents) +} + +func (app *App) handleStart(response http.ResponseWriter, request *http.Request) { + if !app.authorizeAPI(response, request, http.MethodPost) { + return + } + var input startRequest + if err := decodeLocalJSON(request, &input); err != nil { + writeJSON(response, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + baseURL, err := normalizeRemoteBase(input.ServerURL) + if err != nil { + writeJSON(response, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + app.mu.Lock() + if app.starting || app.session != nil { + app.mu.Unlock() + writeJSON(response, http.StatusConflict, map[string]string{"error": "A sharing session is already active"}) + return + } + app.starting = true + app.generation++ + generation := app.generation + app.startFailure = "" + startContext, startCancel := context.WithCancel(app.ctx) + app.startCancel = startCancel + app.mu.Unlock() + + session, room, err := remote.Start(startContext, remote.StartOptions{ + BaseURL: baseURL, + HostAdmissionPassword: strings.TrimSpace(input.Password), + OnEvent: func(event remote.Event) { app.emit(generation, event) }, + }) + app.mu.Lock() + app.starting = false + app.startCancel = nil + startFailure := app.startFailure + app.startFailure = "" + startCanceled := startContext.Err() != nil + currentGeneration := app.generation == generation + if err == nil && currentGeneration && !startCanceled && startFailure == "" { + app.session = session + app.sessionCancel = startCancel + app.room = room + app.attachTimer = time.AfterFunc(app.attachTimeout, func() { + app.mu.Lock() + expired := app.generation == generation && app.session == session && app.mediaConn == nil + app.mu.Unlock() + if expired { + session.Fail(errors.New("local media did not attach before the bounded deadline")) + } + }) + } + app.mu.Unlock() + if err == nil && (!currentGeneration || startCanceled) { + err = errors.New("sharing start was canceled") + } + if err == nil && startFailure != "" { + err = errors.New(startFailure) + } + if err != nil { + startCancel() + if session != nil { + session.Close() + } + writeJSON(response, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + writeJSON(response, http.StatusCreated, room) +} + +func (app *App) handleStop(response http.ResponseWriter, request *http.Request) { + if !app.authorizeAPI(response, request, http.MethodPost) { + return + } + app.stopActive() + response.WriteHeader(http.StatusNoContent) +} + +func (app *App) handleMedia(response http.ResponseWriter, request *http.Request) { + if !exactRequest(request, app.expectedHost, app.origin) || request.Method != http.MethodGet { + http.Error(response, "forbidden", http.StatusForbidden) + return + } + protocol := "screener.token." + app.token + app.mu.Lock() + if app.session == nil || app.mediaClaimed { + app.mu.Unlock() + http.Error(response, "media session unavailable", http.StatusConflict) + return + } + session := app.session + generation := app.generation + app.nextClaimID++ + claimID := app.nextClaimID + app.mediaClaimed = true + app.mediaClaimID = claimID + app.mu.Unlock() + connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{ + Subprotocols: []string{protocol}, + }) + if err != nil { + app.releaseMediaClaim(generation, claimID, nil) + return + } + if connection.Subprotocol() != protocol { + connection.CloseNow() + app.releaseMediaClaim(generation, claimID, nil) + return + } + connection.SetReadLimit(media.MaxFrameBytes + media.FrameHeaderBytes) + app.mu.Lock() + if app.generation != generation || app.session != session || app.mediaClaimID != claimID { + app.mu.Unlock() + connection.CloseNow() + app.releaseMediaClaim(generation, claimID, nil) + return + } + app.mediaConn = connection + if app.attachTimer != nil { + app.attachTimer.Stop() + app.attachTimer = nil + } + room := app.room + activeViewers := app.activeViewers + waitingViewers := app.waitingViewers + app.mu.Unlock() + defer func() { + app.releaseMediaClaim(generation, claimID, connection) + app.stopActiveGeneration(generation) + }() + if session == nil { + return + } + if err = app.writeLocal(connection, map[string]any{ + "kind": "ready", + "roomId": room.ID, + "inviteUrl": room.InviteURL, + "activeViewers": activeViewers, + "waitingViewers": waitingViewers, + }); err != nil { + session.Fail(fmt.Errorf("write local media readiness: %w", err)) + return + } + + configContext, cancelConfig := context.WithTimeout(app.ctx, app.configTimeout) + messageType, payload, err := connection.Read(configContext) + cancelConfig() + if err != nil { + session.Fail(fmt.Errorf("read local encoder configuration: %w", err)) + return + } + if messageType != websocket.MessageText { + session.Fail(errors.New("local encoder configuration was not text")) + return + } + var config encoderConfig + if err = decodeLocalPayload(payload, &config); err != nil || !validEncoderConfig(config) { + session.Fail(errors.New("local encoder configuration is invalid")) + _ = connection.Close(websocket.StatusPolicyViolation, "invalid encoder config") + return + } + if err = app.writeLocal(connection, map[string]string{"kind": "config-accepted"}); err != nil { + session.Fail(fmt.Errorf("acknowledge local encoder configuration: %w", err)) + return + } + for { + messageType, payload, err = connection.Read(app.ctx) + if err != nil { + return + } + if messageType != websocket.MessageBinary { + session.Fail(errors.New("local media bridge received a non-binary frame")) + _ = connection.Close(websocket.StatusUnsupportedData, "binary frames required") + return + } + frame, decodeErr := media.DecodeFrame(payload) + if decodeErr != nil { + session.Fail(fmt.Errorf("decode local encoded frame: %w", decodeErr)) + _ = connection.Close(websocket.StatusPolicyViolation, "invalid encoded frame") + return + } + if err = session.WriteFrame(frame); err != nil { + return + } + } +} + +func (app *App) authorizeAPI(response http.ResponseWriter, request *http.Request, method string) bool { + if !exactRequest(request, app.expectedHost, app.origin) || + !tokenMatches(request.Header.Get(processTokenHeader), app.token) { + http.Error(response, "not found", http.StatusNotFound) + return false + } + if request.Method != method { + response.Header().Set("Allow", method) + http.Error(response, "method not allowed", http.StatusMethodNotAllowed) + return false + } + return true +} + +func (app *App) emit(generation uint64, event remote.Event) { + app.mu.Lock() + if app.generation != generation { + app.mu.Unlock() + return + } + if event.Kind == "viewer-count" { + app.activeViewers = event.ActiveViewers + app.waitingViewers = event.WaitingViewers + } + if event.Kind == "fatal" && app.starting && app.session == nil { + app.startFailure = event.Message + } + connection := app.mediaConn + session := app.session + app.mu.Unlock() + if connection != nil { + if err := app.writeLocal(connection, event); err != nil && event.Kind != "fatal" && session != nil { + go session.Fail(fmt.Errorf("write local sender event: %w", err)) + } + } + if event.Kind == "fatal" { + go app.stopActiveGeneration(generation) + } +} + +func (app *App) writeLocal(connection *websocket.Conn, value any) error { + app.writeMu.Lock() + defer app.writeMu.Unlock() + ctx, cancel := context.WithTimeout(app.ctx, localWriteTimeout) + defer cancel() + return wsjson.Write(ctx, connection, value) +} + +func (app *App) releaseMediaClaim(generation, claimID uint64, connection *websocket.Conn) { + app.mu.Lock() + defer app.mu.Unlock() + if app.generation == generation && app.mediaClaimed && app.mediaClaimID == claimID && + (connection == nil || app.mediaConn == connection) { + app.mediaConn = nil + app.mediaClaimed = false + app.mediaClaimID = 0 + } +} + +func (app *App) stopActive() { + app.stopActiveGeneration(0) +} + +func (app *App) stopActiveGeneration(expectedGeneration uint64) { + app.mu.Lock() + if expectedGeneration != 0 && app.generation != expectedGeneration { + app.mu.Unlock() + return + } + app.generation++ + startCancel := app.startCancel + app.startCancel = nil + app.startFailure = "" + session := app.session + app.session = nil + sessionCancel := app.sessionCancel + app.sessionCancel = nil + connection := app.mediaConn + app.mediaConn = nil + app.mediaClaimed = false + app.mediaClaimID = 0 + if app.attachTimer != nil { + app.attachTimer.Stop() + app.attachTimer = nil + } + app.room = remote.Room{} + app.activeViewers = 0 + app.waitingViewers = 0 + app.mu.Unlock() + if startCancel != nil { + startCancel() + } + if session != nil { + session.Close() + } + if sessionCancel != nil { + sessionCancel() + } + if connection != nil { + _ = connection.Close(websocket.StatusNormalClosure, "sharing stopped") + } +} + +func validEncoderConfig(config encoderConfig) bool { + return config.Kind == "config" && config.Codec == "vp8" && + config.Width == 1280 && config.Height == 720 && config.FPS == 30 && + config.Bitrate == 3_000_000 && config.EncoderInstances == 1 +} + +func decodeLocalJSON(request *http.Request, target any) error { + payload, err := io.ReadAll(io.LimitReader(request.Body, maxLocalJSONBytes+1)) + if err != nil || len(payload) > maxLocalJSONBytes { + return errors.New("request is too large") + } + return decodeLocalPayload(payload, target) +} + +func decodeLocalPayload(payload []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return errors.New("payload is not valid JSON") + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("payload contains multiple JSON values") + } + return nil +} + +func writeJSON(response http.ResponseWriter, status int, value any) { + payload, _ := json.Marshal(value) + response.Header().Set("Content-Type", "application/json; charset=utf-8") + response.WriteHeader(status) + _, _ = response.Write(payload) +} + +func setSecurityHeaders(response http.ResponseWriter) { + response.Header().Set("Cache-Control", "no-store") + response.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'none'; media-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") + response.Header().Set("Referrer-Policy", "no-referrer") + response.Header().Set("X-Content-Type-Options", "nosniff") + response.Header().Set("X-Frame-Options", "DENY") +} diff --git a/native/sender/internal/app/server_test.go b/native/sender/internal/app/server_test.go new file mode 100644 index 0000000..3e722ec --- /dev/null +++ b/native/sender/internal/app/server_test.go @@ -0,0 +1,383 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/TNTcraftHIM/Screener/native/sender/internal/remote" + "github.com/coder/websocket" +) + +func TestEncoderConfigUsesTheFixedHighContract(t *testing.T) { + config := encoderConfig{ + Kind: "config", Codec: "vp8", Width: 1280, Height: 720, + FPS: 30, Bitrate: 3_000_000, EncoderInstances: 1, + } + if !validEncoderConfig(config) { + t.Fatal("fixed 720p30/3 Mbps HIGH config was rejected") + } + config.Bitrate = 4_000_000 + if validEncoderConfig(config) { + t.Fatal("unowned 4 Mbps config was accepted") + } +} + +func TestDecodeLocalPayloadIsStrictAndSingleValued(t *testing.T) { + valid := []byte(`{"kind":"config","codec":"vp8","width":1280,"height":720,"fps":30,"bitrate":3000000,"encoderInstances":1}`) + var config encoderConfig + if err := decodeLocalPayload(valid, &config); err != nil { + t.Fatal(err) + } + invalid := [][]byte{ + []byte(`{"kind":"config","codec":"vp8","width":1280,"height":720,"fps":30,"bitrate":3000000,"encoderInstances":1,"extra":true}`), + append(append([]byte(nil), valid...), valid...), + } + for _, payload := range invalid { + if err := decodeLocalPayload(payload, &encoderConfig{}); err == nil { + t.Fatalf("invalid local config was accepted: %s", payload) + } + } +} + +func TestStaleSessionGenerationCannotMutateOrStopTheCurrentSession(t *testing.T) { + application := &App{generation: 7, activeViewers: 1, waitingViewers: 2} + application.emit(6, remote.Event{Kind: "viewer-count", ActiveViewers: 9, WaitingViewers: 9}) + application.emit(6, remote.Event{Kind: "fatal", Message: "stale failure"}) + application.stopActiveGeneration(6) + application.mu.Lock() + defer application.mu.Unlock() + if application.generation != 7 || application.activeViewers != 1 || application.waitingViewers != 2 { + t.Fatalf("stale generation mutated current state: generation=%d active=%d waiting=%d", + application.generation, application.activeViewers, application.waitingViewers) + } +} + +func TestStaleMediaClaimCannotReleaseTheCurrentClaim(t *testing.T) { + application := &App{generation: 7, mediaClaimed: true, mediaClaimID: 11} + application.releaseMediaClaim(6, 11, nil) + application.releaseMediaClaim(7, 10, nil) + application.mu.Lock() + if !application.mediaClaimed || application.mediaClaimID != 11 { + application.mu.Unlock() + t.Fatal("stale media claim released the current claim") + } + application.mu.Unlock() + application.releaseMediaClaim(7, 11, nil) + application.mu.Lock() + defer application.mu.Unlock() + if application.mediaClaimed || application.mediaClaimID != 0 { + t.Fatal("current media claim was not released") + } +} + +func TestMediaAttachDeadlineAbandonsTheFreshRoom(t *testing.T) { + remoteDone := make(chan []byte, 1) + remoteServer := newAppRemoteServer(t, remoteDone) + defer remoteServer.Close() + + application, err := New() + if err != nil { + t.Fatal(err) + } + application.attachTimeout = 25 * time.Millisecond + _, err = application.Start() + if err != nil { + t.Fatal(err) + } + defer application.Close() + + body, _ := json.Marshal(startRequest{ServerURL: remoteServer.URL}) + request, err := http.NewRequest(http.MethodPost, application.origin+"/api/start", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Origin", application.origin) + request.Header.Set(processTokenHeader, application.token) + request.Header.Set("Content-Type", "application/json") + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusCreated { + t.Fatalf("start status = %d", response.StatusCode) + } + + select { + case payload := <-remoteDone: + if string(payload) != `{"type":"abandon-room"}` { + t.Fatalf("deadline terminal wire = %q", payload) + } + case <-time.After(2 * time.Second): + t.Fatal("media attach deadline did not abandon the room") + } +} + +func TestSuccessfulStartKeepsTheSessionAliveUntilStop(t *testing.T) { + remoteDone := make(chan []byte, 1) + remoteServer := newAppRemoteServer(t, remoteDone) + defer remoteServer.Close() + + application := startTestApplication(t, remoteServer.URL, time.Second, time.Second) + select { + case payload := <-remoteDone: + t.Fatalf("live session ended before stop: %q", payload) + case <-time.After(50 * time.Millisecond): + } + stopTestApplication(t, application) + assertAbandonRoom(t, remoteDone) +} + +func TestInvalidMediaSubprotocolLeavesTheAttachDeadlineArmed(t *testing.T) { + remoteDone := make(chan []byte, 1) + remoteServer := newAppRemoteServer(t, remoteDone) + defer remoteServer.Close() + + application := startTestApplication(t, remoteServer.URL, 25*time.Millisecond, time.Second) + connection, _, err := websocket.Dial(context.Background(), application.origin+"/media", &websocket.DialOptions{ + HTTPHeader: http.Header{"Origin": []string{application.origin}}, + Subprotocols: []string{"screener.token.wrong"}, + }) + if err == nil { + connection.CloseNow() + } + assertAbandonRoom(t, remoteDone) +} + +func TestAcceptedMediaWithoutConfigFailsWithinTheHandshakeDeadline(t *testing.T) { + remoteDone := make(chan []byte, 1) + remoteServer := newAppRemoteServer(t, remoteDone) + defer remoteServer.Close() + + application := startTestApplication(t, remoteServer.URL, time.Second, 25*time.Millisecond) + connection, _, err := websocket.Dial(context.Background(), application.origin+"/media", &websocket.DialOptions{ + HTTPHeader: http.Header{"Origin": []string{application.origin}}, + Subprotocols: []string{"screener.token." + application.token}, + }) + if err != nil { + t.Fatal(err) + } + defer connection.CloseNow() + readContext, cancelRead := context.WithTimeout(context.Background(), time.Second) + defer cancelRead() + if _, _, err = connection.Read(readContext); err != nil { + t.Fatalf("read local ready message: %v", err) + } + assertAbandonRoom(t, remoteDone) +} + +func TestStopDuringDelayedStartCannotResurrectTheSession(t *testing.T) { + authReceived := make(chan struct{}) + releaseAuthentication := make(chan struct{}) + remoteServer := newDelayedAppRemoteServer(t, authReceived, releaseAuthentication) + defer remoteServer.Close() + + application, err := New() + if err != nil { + t.Fatal(err) + } + _, err = application.Start() + if err != nil { + t.Fatal(err) + } + defer application.Close() + + type requestResult struct { + status int + err error + } + startResult := make(chan requestResult, 1) + go func() { + status, requestErr := doLocalPost(application, "/api/start", startRequest{ServerURL: remoteServer.URL}) + startResult <- requestResult{status: status, err: requestErr} + }() + select { + case <-authReceived: + case <-time.After(2 * time.Second): + t.Fatal("remote signaling authentication was not received") + } + if status := postLocal(t, application, "/api/stop", nil); status != http.StatusNoContent { + t.Fatalf("stop status = %d", status) + } + close(releaseAuthentication) + select { + case result := <-startResult: + if result.err != nil { + t.Fatal(result.err) + } + if result.status == http.StatusCreated { + t.Fatal("a canceled delayed start returned success") + } + case <-time.After(2 * time.Second): + t.Fatal("canceled delayed start did not finish") + } + application.mu.Lock() + defer application.mu.Unlock() + if application.session != nil || application.sessionCancel != nil || application.starting { + t.Fatal("canceled delayed start resurrected an active session") + } +} + +func startTestApplication(t *testing.T, remoteURL string, attachTimeout, configTimeout time.Duration) *App { + t.Helper() + application, err := New() + if err != nil { + t.Fatal(err) + } + application.attachTimeout = attachTimeout + application.configTimeout = configTimeout + _, err = application.Start() + if err != nil { + t.Fatal(err) + } + t.Cleanup(application.Close) + if status := postLocal(t, application, "/api/start", startRequest{ServerURL: remoteURL}); status != http.StatusCreated { + t.Fatalf("start status = %d", status) + } + return application +} + +func stopTestApplication(t *testing.T, application *App) { + t.Helper() + if status := postLocal(t, application, "/api/stop", nil); status != http.StatusNoContent { + t.Fatalf("stop status = %d", status) + } +} + +func postLocal(t *testing.T, application *App, path string, body any) int { + t.Helper() + status, err := doLocalPost(application, path, body) + if err != nil { + t.Fatal(err) + } + return status +} + +func doLocalPost(application *App, path string, body any) (int, error) { + var payload []byte + if body != nil { + payload, _ = json.Marshal(body) + } + request, err := http.NewRequest(http.MethodPost, application.origin+path, bytes.NewReader(payload)) + if err != nil { + return 0, err + } + request.Header.Set("Origin", application.origin) + request.Header.Set(processTokenHeader, application.token) + request.Header.Set("Content-Type", "application/json") + response, err := http.DefaultClient.Do(request) + if err != nil { + return 0, err + } + defer response.Body.Close() + return response.StatusCode, nil +} + +func assertAbandonRoom(t *testing.T, terminal <-chan []byte) { + t.Helper() + select { + case payload := <-terminal: + if string(payload) != `{"type":"abandon-room"}` { + t.Fatalf("terminal wire = %q", payload) + } + case <-time.After(2 * time.Second): + t.Fatal("fresh room was not abandoned") + } +} + +func newAppRemoteServer(t *testing.T, terminal chan<- []byte) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + server := httptest.NewServer(mux) + mux.HandleFunc("/api/host-admission", func(response http.ResponseWriter, request *http.Request) { + _ = json.NewEncoder(response).Encode(map[string]any{"required": false, "authenticated": true}) + }) + mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) { + response.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(response).Encode(appRoomResponse(server.URL)) + }) + mux.HandleFunc("/signal", func(response http.ResponseWriter, request *http.Request) { + connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + return + } + defer connection.CloseNow() + if _, _, err = connection.Read(request.Context()); err != nil { + return + } + authenticated := []byte(`{"type":"authenticated","protocol":"screener-v2","role":"host","peerId":"host-peer","roomExpiresAt":null,"maxViewers":3,"hostOnline":true,"connectionId":null,"viewerPeerIds":[],"iceConfig":{"iceServers":[]},"viewerPolicy":"private-link","viewerAuthorizationGeneration":"viewer_generation_12345678"}`) + if err = connection.Write(request.Context(), websocket.MessageText, authenticated); err != nil { + return + } + for { + messageType, payload, readErr := connection.Read(request.Context()) + if readErr != nil { + return + } + if messageType != websocket.MessageText { + continue + } + var message struct { + Type string `json:"type"` + } + _ = json.Unmarshal(payload, &message) + if message.Type == "abandon-room" { + terminal <- append([]byte(nil), payload...) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + _ = connection.Write(ctx, websocket.MessageText, []byte(`{"type":"room-closed","reason":"host-ended"}`)) + cancel() + return + } + } + }) + return server +} + +func newDelayedAppRemoteServer( + t *testing.T, + authReceived chan<- struct{}, + releaseAuthentication <-chan struct{}, +) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + server := httptest.NewServer(mux) + mux.HandleFunc("/api/host-admission", func(response http.ResponseWriter, request *http.Request) { + _ = json.NewEncoder(response).Encode(map[string]any{"required": false, "authenticated": true}) + }) + mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) { + response.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(response).Encode(appRoomResponse(server.URL)) + }) + mux.HandleFunc("/signal", func(response http.ResponseWriter, request *http.Request) { + connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + return + } + defer connection.CloseNow() + if _, _, err = connection.Read(request.Context()); err != nil { + return + } + close(authReceived) + <-releaseAuthentication + writeContext, cancelWrite := context.WithTimeout(context.Background(), time.Second) + defer cancelWrite() + _ = connection.Write(writeContext, websocket.MessageText, []byte(`{"type":"authenticated","protocol":"screener-v2","role":"host","peerId":"host-peer","roomExpiresAt":null,"maxViewers":3,"hostOnline":true,"connectionId":null,"viewerPeerIds":[],"iceConfig":{"iceServers":[]},"viewerPolicy":"private-link","viewerAuthorizationGeneration":"viewer_generation_12345678"}`)) + }) + return server +} + +func appRoomResponse(origin string) map[string]any { + expiresAt := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Second) + grant := "g1.1." + strconv.FormatInt(expiresAt.Unix(), 10) + ".aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + return map[string]any{ + "roomId": "1", "hostToken": "0123456789abcdef0123456789abcdef", + "inviteUrl": origin + "/r/1#v=" + grant, + "viewerPolicy": "private-link", "viewerGrantExpiresAt": expiresAt.Format(time.RFC3339), "expiresAt": expiresAt.Format(time.RFC3339), + } +} diff --git a/native/sender/internal/app/ui/app.js b/native/sender/internal/app/ui/app.js new file mode 100644 index 0000000..a1c110e --- /dev/null +++ b/native/sender/internal/app/ui/app.js @@ -0,0 +1,528 @@ +const WIDTH = 1280; +const HEIGHT = 720; +const FPS = 30; +const BITRATE = 3_000_000; +const FRAME_DURATION = Math.round(1_000_000 / FPS); +const MAX_ENCODER_QUEUE = 2; +const MAX_SOCKET_BUFFER = 512 * 1024; +const MAX_FRAME_BYTES = 1 << 20; + +const fragment = new URLSearchParams(location.hash.slice(1)); +const processToken = fragment.get("token") || ""; +history.replaceState(null, "", location.pathname); + +const elements = { + serverURL: document.querySelector("#server-url"), + password: document.querySelector("#password"), + start: document.querySelector("#start"), + stop: document.querySelector("#stop"), + preview: document.querySelector("#preview"), + empty: document.querySelector("#empty-state"), + invite: document.querySelector("#invite"), + inviteLink: document.querySelector("#invite-link"), + copy: document.querySelector("#copy"), + status: document.querySelector("#status"), + viewerCount: document.querySelector("#viewer-count"), + error: document.querySelector("#error"), + captureDiagnostic: document.querySelector("#capture-diagnostic"), + encoderDiagnostic: document.querySelector("#encoder-diagnostic"), + bridgeDiagnostic: document.querySelector("#bridge-diagnostic"), + helperDiagnostic: document.querySelector("#helper-diagnostic"), + edgeDiagnostic: document.querySelector("#edge-diagnostic"), +}; + +let stream = null; +let processorReader = null; +let encoder = null; +let mediaSocket = null; +let sharing = false; +let stopping = false; +let startInFlight = false; +let roomGeneration = 0; +let forceKeyFrame = true; +let frameNumber = 0; +let captureSettings = null; +let nativeDiagnostics = null; +let metrics = emptyMetrics(); +let startGeneration = 0; +let diagnosticsTimer = null; +let pendingStartGeneration = 0; + +if (!processToken) { + showError("本地发送端令牌已失效,请重新启动 sender.exe"); + elements.start.disabled = true; +} + +elements.start.addEventListener("click", () => void startSharing()); +elements.stop.addEventListener("click", () => void stopSharing(startGeneration)); +elements.copy.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(elements.inviteLink.href); + elements.copy.textContent = "✓"; + setTimeout(() => { elements.copy.textContent = "⧉"; }, 1200); + } catch { + showError("无法复制链接,请直接打开邀请链接"); + } +}); + +async function startSharing() { + if (sharing || stopping || startInFlight) return; + showError(""); + if (!("VideoEncoder" in window) || !("MediaStreamTrackProcessor" in window)) { + showError("此浏览器缺少 WebCodecs 屏幕编码支持,请使用当前版 Chrome 或 Edge"); + return; + } + startInFlight = true; + elements.start.disabled = true; + const generation = ++startGeneration; + setStatus("选择屏幕"); + try { + const capturedStream = await navigator.mediaDevices.getDisplayMedia({ + video: { + width: { ideal: WIDTH, max: WIDTH }, + height: { ideal: HEIGHT, max: HEIGHT }, + frameRate: { ideal: FPS, max: FPS }, + }, + audio: false, + }); + assertCurrentStart(generation, mediaSocket, capturedStream); + stream = capturedStream; + const videoTrack = stream.getVideoTracks()[0]; + if (!videoTrack) throw new Error("没有可用的视频轨道"); + await videoTrack.applyConstraints({ width: WIDTH, height: HEIGHT, frameRate: FPS }); + assertCurrentStart(generation); + captureSettings = videoTrack.getSettings(); + updateDiagnostics(); + videoTrack.addEventListener("ended", () => void stopSharing(generation), { once: true }); + elements.preview.srcObject = stream; + elements.empty.hidden = true; + + setStatus("创建房间"); + pendingStartGeneration = generation; + const room = await localRequest("/api/start", { + serverUrl: elements.serverURL.value, + password: elements.password.value, + }); + assertCurrentStart(generation); + if (pendingStartGeneration === generation) pendingStartGeneration = 0; + roomGeneration = generation; + elements.password.value = ""; + elements.inviteLink.href = room.inviteUrl; + elements.inviteLink.textContent = room.inviteUrl; + elements.invite.hidden = false; + + await connectMediaSocket(generation); + assertCurrentStart(generation); + const activeEncoder = await startEncoder(generation); + assertCurrentStart(generation); + sharing = true; + elements.stop.hidden = false; + setStatus("正在分享"); + void readFrames(videoTrack, generation, activeEncoder, mediaSocket); + } catch (error) { + if (generation === startGeneration && !(error instanceof StaleStartError)) { + showError(readableError(error, "无法开始分享")); + } + await stopSharing(generation); + } finally { + startInFlight = false; + elements.start.disabled = sharing || stopping; + } +} + +async function connectMediaSocket(generation) { + const url = new URL("/media", location.href); + url.protocol = "ws:"; + const socket = new WebSocket(url, [`screener.token.${processToken}`]); + mediaSocket = socket; + socket.binaryType = "arraybuffer"; + socket.addEventListener("message", (event) => handleLocalEvent(generation, socket, event)); + socket.addEventListener("close", () => { + if (generation === startGeneration && !stopping && mediaSocket === socket) { + void failSharing(generation, "本地媒体连接意外断开"); + } + }); + + const ready = waitForLocalKind(socket, "ready", "等待本地媒体服务超时"); + await waitForSocketOpen(socket); + assertCurrentStart(generation, socket); + await ready; + assertCurrentStart(generation, socket); + const accepted = waitForLocalKind(socket, "config-accepted", "本地编码配置确认超时"); + socket.send(JSON.stringify({ + kind: "config", + codec: "vp8", + width: WIDTH, + height: HEIGHT, + fps: FPS, + bitrate: BITRATE, + encoderInstances: 1, + })); + await accepted; + assertCurrentStart(generation, socket); +} + +async function startEncoder(generation) { + const preferred = { + codec: "vp8", + width: WIDTH, + height: HEIGHT, + bitrate: BITRATE, + framerate: FPS, + latencyMode: "realtime", + bitrateMode: "variable", + hardwareAcceleration: "prefer-hardware", + }; + let support = await VideoEncoder.isConfigSupported(preferred); + assertCurrentStart(generation, mediaSocket); + metrics.hardwarePreference = "requested"; + if (!support.supported) { + const fallback = { ...preferred }; + delete fallback.hardwareAcceleration; + support = await VideoEncoder.isConfigSupported(fallback); + assertCurrentStart(generation, mediaSocket); + metrics.hardwarePreference = "not accepted by capability check"; + } + if (!support.supported) throw new Error("此设备不支持 VP8 720p30 编码"); + let activeEncoder; + activeEncoder = new VideoEncoder({ + output: (chunk) => sendEncodedChunk(generation, activeEncoder, chunk), + error: (error) => void failSharing(generation, `编码器失败:${error.message}`), + }); + encoder = activeEncoder; + activeEncoder.configure(support.config); + updateDiagnostics(); + return activeEncoder; +} + +async function readFrames(videoTrack, generation, activeEncoder, activeSocket) { + const processor = new MediaStreamTrackProcessor({ track: videoTrack }); + const reader = processor.readable.getReader(); + if (generation !== startGeneration || encoder !== activeEncoder || mediaSocket !== activeSocket) { + await reader.cancel(); + return; + } + processorReader = reader; + try { + while (generation === startGeneration && sharing && encoder === activeEncoder && + mediaSocket === activeSocket && activeSocket?.readyState === WebSocket.OPEN) { + const { value: frame, done } = await reader.read(); + if (done) { + if (generation === startGeneration && sharing && !stopping) { + await failSharing(generation, "屏幕画面读取意外结束"); + } + return; + } + if (generation !== startGeneration || !sharing || encoder !== activeEncoder || + mediaSocket !== activeSocket || processorReader !== reader) { + frame.close(); + return; + } + metrics.framesRead += 1; + try { + const queueSize = activeEncoder.encodeQueueSize; + metrics.encoderQueuePeak = Math.max(metrics.encoderQueuePeak, queueSize); + if (activeEncoder.state === "configured" && queueSize < MAX_ENCODER_QUEUE) { + const keyFrame = forceKeyFrame || frameNumber % (FPS * 2) === 0; + forceKeyFrame = false; + activeEncoder.encode(frame, { keyFrame }); + frameNumber += 1; + metrics.framesSubmitted += 1; + } else { + metrics.encoderQueueDrops += 1; + forceKeyFrame = true; + } + } finally { + frame.close(); + } + scheduleDiagnostics(); + } + } catch (error) { + if (generation === startGeneration && sharing && !stopping && encoder === activeEncoder) { + await failSharing(generation, readableError(error, "读取屏幕画面失败")); + } + } +} + +function sendEncodedChunk(generation, activeEncoder, chunk) { + if (generation !== startGeneration || stopping || !sharing || encoder !== activeEncoder) return; + try { + if (!mediaSocket || mediaSocket.readyState !== WebSocket.OPEN) { + if (!stopping) void failSharing(generation, "本地媒体连接不可用"); + return; + } + if (mediaSocket.bufferedAmount > MAX_SOCKET_BUFFER) { + void failSharing(generation, "本地媒体桥接积压超过安全上限"); + return; + } + if (chunk.byteLength < 1 || chunk.byteLength > MAX_FRAME_BYTES) { + void failSharing(generation, "编码帧大小超出本地媒体契约"); + return; + } + const data = new Uint8Array(chunk.byteLength); + chunk.copyTo(data); + const message = new ArrayBuffer(17 + data.byteLength); + const view = new DataView(message); + view.setUint8(0, chunk.type === "key" ? 1 : 2); + view.setBigUint64(1, BigInt(Math.max(0, chunk.timestamp)), false); + view.setBigUint64(9, BigInt(chunk.duration || FRAME_DURATION), false); + new Uint8Array(message, 17).set(data); + mediaSocket.send(message); + metrics.encoderOutputs += 1; + metrics.encodedBytes += data.byteLength; + metrics.socketBufferedPeak = Math.max(metrics.socketBufferedPeak, mediaSocket.bufferedAmount); + scheduleDiagnostics(); + } catch (error) { + void failSharing(generation, readableError(error, "发送编码帧失败")); + } +} + +function handleLocalEvent(generation, socket, event) { + if (generation !== startGeneration || socket !== mediaSocket) return; + if (typeof event.data !== "string") return; + const message = parseLocalMessage(event.data); + if (!message) return; + if (message.kind === "request-keyframe") forceKeyFrame = true; + if (message.kind === "viewer-count" || message.kind === "ready") { + showViewerCounts(message.activeViewers, message.waitingViewers); + } + if (message.kind === "diagnostics") { + nativeDiagnostics = message; + updateDiagnostics(); + } + if (message.kind === "warning" && message.message) showError(message.message); + if (message.kind === "fatal") { + void failSharing(generation, message.message || "分享会话已失败并停止"); + } +} + +async function failSharing(generation, message) { + if (generation !== startGeneration || stopping) return; + showError(message); + await stopSharing(generation); +} + +async function stopSharing(expectedGeneration = startGeneration) { + if (expectedGeneration !== startGeneration || stopping) return; + stopping = true; + startGeneration += 1; + sharing = false; + setStatus("正在停止"); + let stopError = null; + const rememberError = (error) => { + if (stopError === null) stopError = error; + }; + try { + const reader = processorReader; + processorReader = null; + try { + stream?.getTracks().forEach((track) => track.stop()); + } catch (error) { + rememberError(error); + } + stream = null; + elements.preview.srcObject = null; + try { + await reader?.cancel(); + } catch (error) { + rememberError(error); + } + try { + if (encoder && encoder.state !== "closed") encoder.close(); + } catch (error) { + rememberError(error); + } + encoder = null; + const socket = mediaSocket; + mediaSocket = null; + try { + if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "sender stopped"); + } catch (error) { + rememberError(error); + } + if (roomGeneration === expectedGeneration || pendingStartGeneration === expectedGeneration) { + try { + await stopLocalSession(expectedGeneration); + } catch (error) { + rememberError(error); + } + } + if (stopError !== null) showError(readableError(stopError, "停止分享失败")); + } finally { + frameNumber = 0; + forceKeyFrame = true; + captureSettings = null; + nativeDiagnostics = null; + metrics = emptyMetrics(); + if (diagnosticsTimer !== null) { + clearTimeout(diagnosticsTimer); + diagnosticsTimer = null; + } + updateDiagnostics(); + elements.empty.hidden = false; + elements.invite.hidden = true; + elements.stop.hidden = true; + elements.start.disabled = startInFlight; + showViewerCounts(0, 0); + setStatus("未开始"); + stopping = false; + } +} + +async function stopLocalSession(generation) { + if (roomGeneration === generation) roomGeneration = 0; + if (pendingStartGeneration === generation) pendingStartGeneration = 0; + await localRequest("/api/stop"); +} + +function waitForSocketOpen(socket) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("本地媒体连接超时")), 5000); + socket.addEventListener("open", () => { + clearTimeout(timeout); + resolve(); + }, { once: true }); + socket.addEventListener("error", () => { + clearTimeout(timeout); + reject(new Error("本地媒体连接失败")); + }, { once: true }); + }); +} + +function waitForLocalKind(socket, kind, timeoutMessage) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => finish(reject, new Error(timeoutMessage)), 5000); + const onMessage = (event) => { + if (typeof event.data !== "string") return; + const message = parseLocalMessage(event.data); + if (!message) return; + if (message.kind === kind) finish(resolve, message); + if (message.kind === "fatal") finish(reject, new Error(message.message || "分享会话已失败")); + }; + const onClose = () => finish(reject, new Error("本地媒体连接在确认前关闭")); + const finish = (callback, value) => { + clearTimeout(timeout); + socket.removeEventListener("message", onMessage); + socket.removeEventListener("close", onClose); + callback(value); + }; + socket.addEventListener("message", onMessage); + socket.addEventListener("close", onClose, { once: true }); + }); +} + +async function localRequest(path, body) { + const response = await fetch(path, { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "X-Screener-Process-Token": processToken, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (response.status === 204) return undefined; + let result; + try { + result = await response.json(); + } catch { + throw new Error(`本地发送端返回 HTTP ${response.status}`); + } + if (!response.ok) throw new Error(result.error || `本地发送端返回 HTTP ${response.status}`); + return result; +} + +function showViewerCounts(activeValue, waitingValue) { + const active = boundedCount(activeValue); + const waiting = boundedCount(waitingValue); + elements.viewerCount.textContent = waiting > 0 + ? `${active} 位已分配/连接中,${waiting} 位等待` + : `${active} 位已分配/连接中`; +} + +function updateDiagnostics() { + const width = captureSettings?.width ?? "未知"; + const height = captureSettings?.height ?? "未知"; + const rate = captureSettings?.frameRate ?? "未知"; + elements.captureDiagnostic.textContent = `${width} × ${height} · ${rate} FPS`; + elements.encoderDiagnostic.textContent = `VP8 · 3 Mbps 上限 · 单对象 · 硬件偏好 ${metrics.hardwarePreference}`; + elements.bridgeDiagnostic.textContent = `读取 ${metrics.framesRead} · 提交 ${metrics.framesSubmitted} · 输出 ${metrics.encoderOutputs} · 编码队列丢弃 ${metrics.encoderQueueDrops} · 编码队列峰值 ${metrics.encoderQueuePeak} 帧 · socket 峰值 ${metrics.socketBufferedPeak} B`; + const helper = nativeDiagnostics?.media; + const queue = helper?.queue; + elements.helperDiagnostic.textContent = helper && queue + ? `帧 ${helper.framesWritten} · 源 RTP ${helper.sourceRtpPacketsWritten} 包 / ${formatBytes(helper.sourceRtpBytesWritten)} · 队列 ${queue.depth}/${queue.capacity},峰值 ${queue.maxDepth} · 丢弃 ${queue.droppedFrames} · 过载 ${queue.overloadEvents} · 恢复 ${queue.completedRecoveries}` + : "尚无数据"; + const peers = Array.isArray(nativeDiagnostics?.peers) ? nativeDiagnostics.peers : []; + elements.edgeDiagnostic.textContent = peers.length === 0 + ? "无活动媒体边" + : peers.map((peer) => { + const sent = peer.bitrateBps === undefined ? "码率未知" : formatBitrate(peer.bitrateBps); + const rtt = peer.currentRoundTripTimeMs === undefined ? "RTT 未知" : `RTT ${peer.currentRoundTripTimeMs} ms`; + return `边 ${peer.slot}: ${peer.connectionState || "未知"} / ${peer.route || "未知路径"} / ${sent} / ${rtt} / PLI ${peer.pliRequests ?? 0} / FIR ${peer.firRequests ?? 0}`; + }).join(";"); +} + +function scheduleDiagnostics() { + if (diagnosticsTimer !== null) return; + diagnosticsTimer = setTimeout(() => { + diagnosticsTimer = null; + updateDiagnostics(); + }, 500); +} + +function formatBitrate(value) { + const bitrate = Number(value); + return Number.isFinite(bitrate) ? `${(bitrate / 1_000_000).toFixed(2)} Mbps` : "码率未知"; +} + +function formatBytes(value) { + const bytes = Number(value); + return Number.isFinite(bytes) ? `${(bytes / (1024 * 1024)).toFixed(2)} MiB` : "未知"; +} + +function parseLocalMessage(value) { + try { + const message = JSON.parse(value); + return message && typeof message === "object" ? message : null; + } catch { + return null; + } +} + +function boundedCount(value) { + const number = Number(value); + return Number.isSafeInteger(number) && number >= 0 ? number : 0; +} + +function emptyMetrics() { + return { + framesRead: 0, + framesSubmitted: 0, + encoderOutputs: 0, + encodedBytes: 0, + encoderQueueDrops: 0, + encoderQueuePeak: 0, + socketBufferedPeak: 0, + hardwarePreference: "未请求", + }; +} + +function readableError(error, fallback) { + return error instanceof Error && error.message ? error.message : fallback; +} + +function assertCurrentStart(generation, socket = mediaSocket, staleStream = null) { + if (!isCurrentStart(generation, socket)) { + staleStream?.getTracks().forEach((track) => track.stop()); + throw new StaleStartError(); + } +} + +function isCurrentStart(generation, socket = mediaSocket) { + return generation === startGeneration && !stopping && (!socket || socket === mediaSocket); +} + +class StaleStartError extends Error {} + +function setStatus(value) { elements.status.textContent = value; } +function showError(value) { elements.error.textContent = value; } diff --git a/native/sender/internal/app/ui/index.html b/native/sender/internal/app/ui/index.html new file mode 100644 index 0000000..0806c7c --- /dev/null +++ b/native/sender/internal/app/ui/index.html @@ -0,0 +1,60 @@ + + + + + + Screener Sender + + + +
+
SScreener Sender
+ 未开始 +
+
+
+
+
+

屏幕分享

+

0 位已分配/连接中

+
+ +
+
+ +
选择屏幕后,邀请链接会显示在这里
+
+ +
+ +
+ + + diff --git a/native/sender/internal/app/ui/styles.css b/native/sender/internal/app/ui/styles.css new file mode 100644 index 0000000..062d57a --- /dev/null +++ b/native/sender/internal/app/ui/styles.css @@ -0,0 +1,68 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #f1f3f1; + background: #101310; + letter-spacing: 0; + --surface: #171b18; + --surface-raised: #1d221e; + --border: #343a35; + --muted: #a8afa9; + --green: #45d483; + --green-strong: #2fbd70; + --red: #ff9797; +} + +* { box-sizing: border-box; } +html, body { min-width: 320px; min-height: 100%; margin: 0; } +body { min-height: 100vh; background: #101310; } +button, input { font: inherit; letter-spacing: 0; } +button:focus-visible, input:focus-visible, a:focus-visible, summary:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; } + +.app-header { + display: flex; + min-height: 58px; + align-items: center; + justify-content: space-between; + padding: 9px clamp(16px, 3vw, 40px); + border-bottom: 1px solid var(--border); +} +.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; } +.brand-mark { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid #326645; border-radius: 6px; color: var(--green); background: #14271b; } +.status { color: var(--muted); font-size: 13px; } + +main { display: grid; grid-template-columns: minmax(0, 1fr) 340px; width: min(1400px, 100%); min-height: calc(100vh - 58px); margin: 0 auto; } +.stage { min-width: 0; padding: 28px clamp(18px, 3vw, 42px) 40px; } +.stage-heading { display: flex; min-height: 58px; align-items: flex-start; justify-content: space-between; gap: 16px; } +h1 { margin: 0; font-size: 24px; } +.stage-heading p { margin: 7px 0 0; color: var(--muted); font-size: 13px; } +.preview-shell { position: relative; display: grid; width: 100%; margin-top: 18px; overflow: hidden; aspect-ratio: 16 / 9; place-items: center; border: 1px solid var(--border); border-radius: 6px; background: #080a08; } +video { display: block; width: 100%; height: 100%; object-fit: contain; } +.empty-state { position: absolute; color: var(--muted); font-size: 14px; } +.invite { display: grid; grid-template-columns: minmax(0, 1fr) 42px; gap: 8px; margin-top: 14px; } +.invite a { min-width: 0; overflow: hidden; padding: 11px 12px; border: 1px solid var(--border); border-radius: 6px; color: #c8f7d9; text-overflow: ellipsis; white-space: nowrap; } + +.controls { display: grid; align-content: start; gap: 18px; padding: 32px 24px; border-left: 1px solid var(--border); background: var(--surface); } +label { display: grid; gap: 8px; color: var(--muted); font-size: 13px; } +input { width: 100%; min-height: 44px; padding: 10px 11px; border: 1px solid var(--border); border-radius: 6px; color: #f1f3f1; background: #101310; } +.button, .icon-button { min-height: 44px; border: 1px solid transparent; border-radius: 6px; color: inherit; cursor: pointer; } +.button:disabled { cursor: not-allowed; opacity: .5; } +.primary { color: #07150c; font-weight: 700; background: var(--green); } +.primary:hover { background: var(--green-strong); } +.secondary, .icon-button { border-color: var(--border); background: var(--surface-raised); } +.icon-button { font-size: 20px; } +.notice { margin: -6px 0 0; color: #e9c979; font-size: 13px; line-height: 1.45; } +.error { min-height: 20px; margin: 0; color: var(--red); font-size: 13px; line-height: 1.45; } +details { border-top: 1px solid var(--border); padding-top: 16px; color: var(--muted); font-size: 13px; } +summary { cursor: pointer; } +dl { display: grid; gap: 10px; margin: 14px 0 0; } +dl div { display: flex; min-width: 0; align-items: flex-start; justify-content: space-between; gap: 12px; } +dt, dd { margin: 0; } +dt { flex: 0 0 auto; } +dd { min-width: 0; overflow-wrap: anywhere; color: #f1f3f1; text-align: right; } + +@media (max-width: 820px) { + main { grid-template-columns: 1fr; } + .controls { grid-row: 1; border-right: 0; border-bottom: 1px solid var(--border); border-left: 0; } + .stage { padding-top: 22px; } +} diff --git a/native/sender/internal/media/fanout.go b/native/sender/internal/media/fanout.go new file mode 100644 index 0000000..d331dcd --- /dev/null +++ b/native/sender/internal/media/fanout.go @@ -0,0 +1,257 @@ +package media + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "math" + "sync" + "time" + + "github.com/pion/rtp" + "github.com/pion/rtp/codecs" + "github.com/pion/webrtc/v4" +) + +const ( + FrameHeaderBytes = 17 + MaxFrameBytes = 1 << 20 + queueCapacity = 8 + videoClockRate = 90_000 +) + +type Frame struct { + KeyFrame bool + TimestampMicros uint64 + DurationMicros uint64 + Data []byte +} + +type Metrics struct { + Queue QueueMetrics `json:"queue"` + FramesWritten uint64 `json:"framesWritten"` + SourceRTPPacketsWritten uint64 `json:"sourceRtpPacketsWritten"` + SourceRTPBytesWritten uint64 `json:"sourceRtpBytesWritten"` + SourceTimestampGapMicros uint64 `json:"sourceTimestampGapMicros"` + LastSourceTimestampMicros uint64 `json:"lastSourceTimestampMicros"` +} + +type rtpWriter interface { + WriteRTP(*rtp.Packet) error +} + +type Fanout struct { + track *webrtc.TrackLocalStaticRTP + writer rtpWriter + queue *frameQueue + ctx context.Context + cancel context.CancelFunc + done chan struct{} + closeOnce sync.Once + fatalOnce sync.Once + requestKeyFrame func() + onFatal func(error) + metricsMu sync.Mutex + metrics Metrics +} + +func NewFanout(requestKeyFrame func(), onFatal func(error)) (*Fanout, error) { + track, err := webrtc.NewTrackLocalStaticRTP( + webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: videoClockRate}, + "screen", + "screener", + ) + if err != nil { + return nil, fmt.Errorf("create shared VP8 track: %w", err) + } + return newFanout(track, track, requestKeyFrame, onFatal), nil +} + +func newFanout(track *webrtc.TrackLocalStaticRTP, writer rtpWriter, requestKeyFrame func(), onFatal func(error)) *Fanout { + ctx, cancel := context.WithCancel(context.Background()) + fanout := &Fanout{ + track: track, + writer: writer, + queue: newFrameQueue(queueCapacity), + ctx: ctx, + cancel: cancel, + done: make(chan struct{}), + requestKeyFrame: requestKeyFrame, + onFatal: onFatal, + } + go fanout.writeLoop() + return fanout +} + +func (fanout *Fanout) Track() *webrtc.TrackLocalStaticRTP { + return fanout.track +} + +func (fanout *Fanout) Push(frame Frame) error { + if len(frame.Data) == 0 || len(frame.Data) > MaxFrameBytes || frame.DurationMicros == 0 { + return errors.New("encoded VP8 frame violates the bounded media contract") + } + result, err := fanout.queue.Push(frame) + if err != nil { + return err + } + if result.requestKeyFrame && fanout.requestKeyFrame != nil { + fanout.requestKeyFrame() + } + return nil +} + +func (fanout *Fanout) Snapshot() Metrics { + fanout.metricsMu.Lock() + metrics := fanout.metrics + fanout.metricsMu.Unlock() + metrics.Queue = fanout.queue.Snapshot() + return metrics +} + +func (fanout *Fanout) Close() { + fanout.stop() + <-fanout.done +} + +func (fanout *Fanout) writeLoop() { + defer close(fanout.done) + timeline := newFrameTimeline() + for { + frame, ok, err := fanout.queue.Pop(fanout.ctx) + if err != nil { + if !errors.Is(err, context.Canceled) { + fanout.fail(fmt.Errorf("read encoded media queue: %w", err)) + } + return + } + if !ok { + return + } + packets, gapMicros, err := timeline.Packetize(frame) + if err != nil { + fanout.fail(err) + return + } + var sourceBytes uint64 + for _, packet := range packets { + if err = fanout.writer.WriteRTP(packet); err != nil { + fanout.fail(errors.New("write shared RTP packet failed")) + return + } + sourceBytes += uint64(packet.MarshalSize()) + } + fanout.queue.MarkRecoveryWritten(frame.KeyFrame) + fanout.metricsMu.Lock() + fanout.metrics.FramesWritten++ + fanout.metrics.SourceRTPPacketsWritten += uint64(len(packets)) + fanout.metrics.SourceRTPBytesWritten += sourceBytes + fanout.metrics.SourceTimestampGapMicros += gapMicros + fanout.metrics.LastSourceTimestampMicros = frame.TimestampMicros + fanout.metricsMu.Unlock() + } +} + +func (fanout *Fanout) fail(err error) { + fanout.fatalOnce.Do(func() { + fanout.stop() + if fanout.onFatal != nil { + go func() { + <-fanout.done + fanout.onFatal(err) + }() + } + }) +} + +func (fanout *Fanout) stop() { + fanout.closeOnce.Do(func() { + fanout.cancel() + fanout.queue.Close() + }) +} + +type frameTimeline struct { + packetizer rtp.Packetizer + hasPrevious bool + previousTimestampMicros uint64 + previousDurationMicros uint64 +} + +func newFrameTimeline() *frameTimeline { + return &frameTimeline{packetizer: rtp.NewPacketizer( + 1200, + 96, + 0, + &codecs.VP8Payloader{}, + rtp.NewRandomSequencer(), + videoClockRate, + )} +} + +func (timeline *frameTimeline) Packetize(frame Frame) ([]*rtp.Packet, uint64, error) { + var gapMicros uint64 + if timeline.hasPrevious { + if frame.TimestampMicros <= timeline.previousTimestampMicros { + return nil, 0, errors.New("encoded frame timestamp did not increase") + } + if timeline.previousTimestampMicros > math.MaxUint64-timeline.previousDurationMicros { + return nil, 0, errors.New("encoded frame timestamp overflowed") + } + expected := timeline.previousTimestampMicros + timeline.previousDurationMicros + if frame.TimestampMicros < expected { + return nil, 0, errors.New("encoded frame timestamps overlap") + } + gapMicros = frame.TimestampMicros - expected + gapSamples, err := microsToSamples(gapMicros) + if err != nil { + return nil, 0, err + } + timeline.packetizer.SkipSamples(gapSamples) + } + durationSamples, err := microsToSamples(frame.DurationMicros) + if err != nil { + return nil, 0, err + } + if durationSamples == 0 { + durationSamples = uint32(videoClockRate / 30) + } + packets := timeline.packetizer.Packetize(frame.Data, durationSamples) + timeline.hasPrevious = true + timeline.previousTimestampMicros = frame.TimestampMicros + timeline.previousDurationMicros = frame.DurationMicros + return packets, gapMicros, nil +} + +func microsToSamples(micros uint64) (uint32, error) { + seconds := micros / 1_000_000 + remainder := micros % 1_000_000 + samples := seconds*videoClockRate + (remainder*videoClockRate+500_000)/1_000_000 + if samples > math.MaxUint32 { + return 0, errors.New("encoded frame timestamp gap is too large") + } + return uint32(samples), nil +} + +func DecodeFrame(payload []byte) (Frame, error) { + if len(payload) <= FrameHeaderBytes || len(payload)-FrameHeaderBytes > MaxFrameBytes { + return Frame{}, errors.New("encoded frame is empty, truncated, or too large") + } + frame := Frame{ + TimestampMicros: binary.BigEndian.Uint64(payload[1:9]), + DurationMicros: binary.BigEndian.Uint64(payload[9:17]), + Data: append([]byte(nil), payload[FrameHeaderBytes:]...), + } + switch payload[0] { + case 1: + frame.KeyFrame = true + case 2: + default: + return Frame{}, errors.New("encoded frame type is invalid") + } + if frame.DurationMicros == 0 || frame.DurationMicros > uint64(time.Second/time.Microsecond) { + return Frame{}, errors.New("encoded frame duration is invalid") + } + return frame, nil +} diff --git a/native/sender/internal/media/fanout_test.go b/native/sender/internal/media/fanout_test.go new file mode 100644 index 0000000..454d019 --- /dev/null +++ b/native/sender/internal/media/fanout_test.go @@ -0,0 +1,179 @@ +package media + +import ( + "encoding/binary" + "errors" + "runtime" + "sync" + "testing" + "time" + + "github.com/pion/rtp" +) + +func TestFrameTimelinePreservesDroppedSourceTime(t *testing.T) { + timeline := newFrameTimeline() + first, _, err := timeline.Packetize(Frame{ + KeyFrame: true, + TimestampMicros: 0, + DurationMicros: 33_333, + Data: []byte{1, 2, 3}, + }) + if err != nil { + t.Fatal(err) + } + second, gap, err := timeline.Packetize(Frame{ + TimestampMicros: 66_666, + DurationMicros: 33_333, + Data: []byte{4, 5, 6}, + }) + if err != nil { + t.Fatal(err) + } + if gap != 33_333 { + t.Fatalf("source gap = %d, want 33333 microseconds", gap) + } + if delta := second[0].Timestamp - first[0].Timestamp; delta != 6_000 { + t.Fatalf("RTP timestamp delta = %d, want 6000 samples", delta) + } +} + +func TestFrameTimelineRejectsOverlappingSourceTime(t *testing.T) { + timeline := newFrameTimeline() + _, _, err := timeline.Packetize(Frame{ + TimestampMicros: 100, + DurationMicros: 50, + Data: []byte{1}, + }) + if err != nil { + t.Fatal(err) + } + _, _, err = timeline.Packetize(Frame{ + TimestampMicros: 149, + DurationMicros: 50, + Data: []byte{2}, + }) + if err == nil { + t.Fatal("overlapping source timestamps were accepted") + } +} + +func TestFanoutPropagatesWriterFailure(t *testing.T) { + written := make(chan struct{}, 1) + fatal := make(chan error, 1) + fanout := newFanout(nil, rtpWriterFunc(func(*rtp.Packet) error { + written <- struct{}{} + return errors.New("write failed") + }), nil, func(err error) { fatal <- err }) + + if err := fanout.Push(Frame{ + KeyFrame: true, + TimestampMicros: 1, + DurationMicros: 33_333, + Data: []byte{1}, + }); err != nil { + t.Fatal(err) + } + select { + case <-written: + case <-time.After(time.Second): + t.Fatal("writer was not called") + } + select { + case err := <-fatal: + if err == nil || err.Error() != "write shared RTP packet failed" { + t.Fatalf("fatal error = %v", err) + } + case <-time.After(time.Second): + t.Fatal("writer failure was not propagated") + } + fanout.Close() +} + +func TestFanoutCloseDropsBacklogWithoutLateWritesOrFatal(t *testing.T) { + writer := &blockingWriter{started: make(chan struct{}), release: make(chan struct{})} + fatal := make(chan error, 1) + fanout := newFanout(nil, writer, nil, func(err error) { fatal <- err }) + + if err := fanout.Push(Frame{KeyFrame: true, TimestampMicros: 1, DurationMicros: 1, Data: []byte{1}}); err != nil { + t.Fatal(err) + } + <-writer.started + for timestamp := uint64(2); timestamp <= 5; timestamp++ { + if err := fanout.Push(Frame{TimestampMicros: timestamp, DurationMicros: 1, Data: []byte{1}}); err != nil { + t.Fatal(err) + } + } + + closed := make(chan struct{}) + go func() { + fanout.Close() + close(closed) + }() + deadline := time.Now().Add(time.Second) + for fanout.Snapshot().Queue.ShutdownDroppedFrames != 4 { + if time.Now().After(deadline) { + t.Fatal("fanout close did not discard the queued backlog") + } + runtime.Gosched() + } + close(writer.release) + select { + case <-closed: + case <-time.After(time.Second): + t.Fatal("fanout close did not wait for the writer loop") + } + if writes := writer.Writes(); writes != 1 { + t.Fatalf("writes after close = %d, want only the in-flight write", writes) + } + select { + case err := <-fatal: + t.Fatalf("close reported a fatal error: %v", err) + default: + } +} + +func TestDecodeFrame(t *testing.T) { + payload := make([]byte, FrameHeaderBytes+3) + payload[0] = 1 + binary.BigEndian.PutUint64(payload[1:9], 123) + binary.BigEndian.PutUint64(payload[9:17], 33_333) + copy(payload[17:], []byte{1, 2, 3}) + + frame, err := DecodeFrame(payload) + if err != nil { + t.Fatal(err) + } + if !frame.KeyFrame || frame.TimestampMicros != 123 || frame.DurationMicros != 33_333 { + t.Fatalf("decoded frame = %+v", frame) + } +} + +type rtpWriterFunc func(*rtp.Packet) error + +func (write rtpWriterFunc) WriteRTP(packet *rtp.Packet) error { + return write(packet) +} + +type blockingWriter struct { + mu sync.Mutex + started chan struct{} + release chan struct{} + writes int + once sync.Once +} + +func (writer *blockingWriter) WriteRTP(*rtp.Packet) error { + writer.once.Do(func() { close(writer.started) }) + <-writer.release + writer.mu.Lock() + writer.writes++ + writer.mu.Unlock() + return nil +} + +func (writer *blockingWriter) Writes() int { + writer.mu.Lock() + defer writer.mu.Unlock() + return writer.writes +} diff --git a/native/sender/internal/media/queue.go b/native/sender/internal/media/queue.go new file mode 100644 index 0000000..42ad48c --- /dev/null +++ b/native/sender/internal/media/queue.go @@ -0,0 +1,157 @@ +package media + +import ( + "context" + "errors" + "sync" +) + +type QueueMetrics struct { + Capacity int `json:"capacity"` + Depth int `json:"depth"` + MaxDepth int `json:"maxDepth"` + AcceptedFrames uint64 `json:"acceptedFrames"` + DroppedFrames uint64 `json:"droppedFrames"` + ShutdownDroppedFrames uint64 `json:"shutdownDroppedFrames"` + OverloadEvents uint64 `json:"overloadEvents"` + RecoveryKeyFrames uint64 `json:"recoveryKeyFrames"` + CompletedRecoveries uint64 `json:"completedRecoveries"` + UnrecoverableOverload uint64 `json:"unrecoverableOverload"` +} + +type queuePushResult struct { + accepted bool + requestKeyFrame bool +} + +// frameQueue owns the only helper-side media backlog. After an overload it +// discards dependent frames until a key frame restores a decodable boundary. +type frameQueue struct { + mu sync.Mutex + items []Frame + capacity int + droppingUntilKeyFrame bool + recoveryPending bool + closed bool + notify chan struct{} + metrics QueueMetrics +} + +func newFrameQueue(capacity int) *frameQueue { + if capacity < 1 { + panic("frame queue capacity must be positive") + } + return &frameQueue{ + capacity: capacity, + notify: make(chan struct{}, 1), + metrics: QueueMetrics{Capacity: capacity}, + } +} + +func (queue *frameQueue) Push(frame Frame) (queuePushResult, error) { + queue.mu.Lock() + defer queue.mu.Unlock() + if queue.closed { + return queuePushResult{}, errors.New("encoded media queue is closed") + } + + if queue.droppingUntilKeyFrame { + if !frame.KeyFrame { + queue.metrics.DroppedFrames++ + return queuePushResult{}, nil + } + queue.droppingUntilKeyFrame = false + } + + requestKeyFrame := false + if len(queue.items) >= queue.capacity { + if queue.recoveryPending { + queue.metrics.UnrecoverableOverload++ + return queuePushResult{}, errors.New("encoded media queue overloaded before key-frame recovery completed") + } + queue.metrics.DroppedFrames += uint64(len(queue.items)) + queue.metrics.OverloadEvents++ + queue.metrics.RecoveryKeyFrames++ + clear(queue.items) + queue.items = queue.items[:0] + queue.droppingUntilKeyFrame = true + queue.recoveryPending = true + requestKeyFrame = true + if !frame.KeyFrame { + queue.metrics.DroppedFrames++ + return queuePushResult{requestKeyFrame: true}, nil + } + queue.droppingUntilKeyFrame = false + } + + frame.Data = append([]byte(nil), frame.Data...) + queue.items = append(queue.items, frame) + queue.metrics.AcceptedFrames++ + if len(queue.items) > queue.metrics.MaxDepth { + queue.metrics.MaxDepth = len(queue.items) + } + queue.signal() + return queuePushResult{accepted: true, requestKeyFrame: requestKeyFrame}, nil +} + +func (queue *frameQueue) Pop(ctx context.Context) (Frame, bool, error) { + for { + queue.mu.Lock() + if len(queue.items) > 0 { + frame := queue.items[0] + queue.items[0] = Frame{} + queue.items = queue.items[1:] + queue.mu.Unlock() + return frame, true, nil + } + if queue.closed { + queue.mu.Unlock() + return Frame{}, false, nil + } + queue.mu.Unlock() + + select { + case <-queue.notify: + case <-ctx.Done(): + return Frame{}, false, ctx.Err() + } + } +} + +func (queue *frameQueue) MarkRecoveryWritten(keyFrame bool) { + if !keyFrame { + return + } + queue.mu.Lock() + defer queue.mu.Unlock() + if queue.recoveryPending { + queue.recoveryPending = false + queue.metrics.CompletedRecoveries++ + } +} + +func (queue *frameQueue) Close() { + queue.mu.Lock() + queue.metrics.ShutdownDroppedFrames += uint64(len(queue.items)) + queue.metrics.DroppedFrames += uint64(len(queue.items)) + clear(queue.items) + queue.items = nil + queue.closed = true + queue.mu.Unlock() + queue.signal() +} + +func (queue *frameQueue) Snapshot() QueueMetrics { + queue.mu.Lock() + defer queue.mu.Unlock() + metrics := queue.metrics + metrics.Depth = len(queue.items) + return metrics +} + +func (queue *frameQueue) signal() { + select { + case queue.notify <- struct{}{}: + default: + } +} diff --git a/native/sender/internal/media/queue_test.go b/native/sender/internal/media/queue_test.go new file mode 100644 index 0000000..340aefa --- /dev/null +++ b/native/sender/internal/media/queue_test.go @@ -0,0 +1,93 @@ +package media + +import ( + "context" + "strings" + "testing" +) + +func TestFrameQueueRecoversAtAKeyFrameAfterOneOverload(t *testing.T) { + queue := newFrameQueue(2) + defer queue.Close() + + pushFrame(t, queue, false, 1) + pushFrame(t, queue, false, 2) + result, err := queue.Push(testFrame(false, 3)) + if err != nil { + t.Fatalf("first overload returned an error: %v", err) + } + if result.accepted || !result.requestKeyFrame { + t.Fatalf("first overload result = %+v, want dropped frame and key-frame request", result) + } + result, err = queue.Push(testFrame(false, 4)) + if err != nil || result.accepted || result.requestKeyFrame { + t.Fatalf("recovery delta result = %+v, %v", result, err) + } + result, err = queue.Push(testFrame(true, 5)) + if err != nil || !result.accepted { + t.Fatalf("recovery key frame result = %+v, %v", result, err) + } + + frame, ok, err := queue.Pop(context.Background()) + if err != nil || !ok || !frame.KeyFrame || frame.TimestampMicros != 5 { + t.Fatalf("recovery pop = %+v, %t, %v", frame, ok, err) + } + queue.MarkRecoveryWritten(true) + + metrics := queue.Snapshot() + if metrics.OverloadEvents != 1 || metrics.RecoveryKeyFrames != 1 || + metrics.CompletedRecoveries != 1 || metrics.DroppedFrames != 4 { + t.Fatalf("unexpected recovery metrics: %+v", metrics) + } +} + +func TestFrameQueueFailsOnASecondOverloadBeforeRecoveryIsWritten(t *testing.T) { + queue := newFrameQueue(1) + defer queue.Close() + + pushFrame(t, queue, false, 1) + result, err := queue.Push(testFrame(true, 2)) + if err != nil || !result.accepted || !result.requestKeyFrame { + t.Fatalf("first overload result = %+v, %v", result, err) + } + _, err = queue.Push(testFrame(false, 3)) + if err == nil || !strings.Contains(err.Error(), "before key-frame recovery completed") { + t.Fatalf("second overload error = %v", err) + } + if queue.Snapshot().UnrecoverableOverload != 1 { + t.Fatalf("unrecoverable overload was not counted: %+v", queue.Snapshot()) + } +} + +func TestFrameQueueCloseDiscardsBacklog(t *testing.T) { + queue := newFrameQueue(2) + pushFrame(t, queue, true, 1) + pushFrame(t, queue, false, 2) + queue.Close() + + _, ok, err := queue.Pop(context.Background()) + if err != nil || ok { + t.Fatalf("closed queue pop = %t, %v; want no frame", ok, err) + } + metrics := queue.Snapshot() + if metrics.ShutdownDroppedFrames != 2 || metrics.DroppedFrames != 2 { + t.Fatalf("shutdown drops = %+v, want 2", metrics) + } +} + +func pushFrame(t *testing.T, queue *frameQueue, keyFrame bool, timestamp uint64) { + t.Helper() + result, err := queue.Push(testFrame(keyFrame, timestamp)) + if err != nil || !result.accepted { + t.Fatalf("push frame %d = %+v, %v", timestamp, result, err) + } +} + +func testFrame(keyFrame bool, timestamp uint64) Frame { + return Frame{ + KeyFrame: keyFrame, + TimestampMicros: timestamp, + DurationMicros: 1, + Data: []byte{byte(timestamp)}, + } +} diff --git a/native/sender/internal/remote/admission.go b/native/sender/internal/remote/admission.go new file mode 100644 index 0000000..beb6644 --- /dev/null +++ b/native/sender/internal/remote/admission.go @@ -0,0 +1,96 @@ +package remote + +type admissionAction uint8 + +const ( + admissionDuplicate admissionAction = iota + admissionActivate + admissionWait + admissionFull +) + +type viewerAdmission struct { + edgeLimit int + roomLimit int + active map[string]struct{} + waiting []string + waitSet map[string]struct{} +} + +func newViewerAdmission(maxViewers int) *viewerAdmission { + edgeLimit := maxHostEdges + if maxViewers < edgeLimit { + edgeLimit = maxViewers + } + return &viewerAdmission{ + edgeLimit: edgeLimit, + roomLimit: maxViewers, + active: make(map[string]struct{}), + waitSet: make(map[string]struct{}), + } +} + +func (admission *viewerAdmission) Join(peerID string) admissionAction { + if _, exists := admission.active[peerID]; exists { + return admissionDuplicate + } + if _, exists := admission.waitSet[peerID]; exists { + return admissionDuplicate + } + if len(admission.active) < admission.edgeLimit { + admission.active[peerID] = struct{}{} + return admissionActivate + } + if len(admission.active)+len(admission.waiting) >= admission.roomLimit { + return admissionFull + } + admission.waiting = append(admission.waiting, peerID) + admission.waitSet[peerID] = struct{}{} + return admissionWait +} + +func (admission *viewerAdmission) Leave(peerID string) (wasActive bool, promoted string) { + if _, exists := admission.active[peerID]; exists { + delete(admission.active, peerID) + return true, admission.promoteNext() + } + if _, exists := admission.waitSet[peerID]; !exists { + return false, "" + } + delete(admission.waitSet, peerID) + for index, waitingID := range admission.waiting { + if waitingID != peerID { + continue + } + copy(admission.waiting[index:], admission.waiting[index+1:]) + admission.waiting[len(admission.waiting)-1] = "" + admission.waiting = admission.waiting[:len(admission.waiting)-1] + break + } + return false, "" +} + +func (admission *viewerAdmission) AbortActivation(peerID string) { + delete(admission.active, peerID) +} + +func (admission *viewerAdmission) Counts() (active, waiting int) { + return len(admission.active), len(admission.waiting) +} + +func (admission *viewerAdmission) IsActive(peerID string) bool { + _, exists := admission.active[peerID] + return exists +} + +func (admission *viewerAdmission) promoteNext() string { + if len(admission.waiting) == 0 { + return "" + } + promoted := admission.waiting[0] + admission.waiting[0] = "" + admission.waiting = admission.waiting[1:] + delete(admission.waitSet, promoted) + admission.active[promoted] = struct{}{} + return promoted +} diff --git a/native/sender/internal/remote/admission_test.go b/native/sender/internal/remote/admission_test.go new file mode 100644 index 0000000..30c8763 --- /dev/null +++ b/native/sender/internal/remote/admission_test.go @@ -0,0 +1,83 @@ +package remote + +import "testing" + +func TestViewerAdmissionNeverExceedsTwoEdgesAndPromotesInJoinOrder(t *testing.T) { + admission := newViewerAdmission(4) + assertAdmission(t, admission, "viewer-a", admissionActivate) + assertAdmission(t, admission, "viewer-b", admissionActivate) + assertAdmission(t, admission, "viewer-c", admissionWait) + assertAdmission(t, admission, "viewer-d", admissionWait) + assertAdmission(t, admission, "viewer-c", admissionDuplicate) + assertAdmission(t, admission, "viewer-e", admissionFull) + + active, waiting := admission.Counts() + if active != 2 || waiting != 2 { + t.Fatalf("counts = %d active, %d waiting", active, waiting) + } + wasActive, promoted := admission.Leave("viewer-b") + if !wasActive || promoted != "viewer-c" { + t.Fatalf("leave promoted %q from active=%t", promoted, wasActive) + } + if admission.IsActive("viewer-b") || !admission.IsActive("viewer-c") { + t.Fatal("promotion did not atomically replace the freed edge") + } + active, waiting = admission.Counts() + if active != 2 || waiting != 1 { + t.Fatalf("post-promotion counts = %d active, %d waiting", active, waiting) + } +} + +func TestViewerAdmissionRemovesAWaitingViewerWithoutPromotion(t *testing.T) { + admission := newViewerAdmission(3) + assertAdmission(t, admission, "viewer-a", admissionActivate) + assertAdmission(t, admission, "viewer-b", admissionActivate) + assertAdmission(t, admission, "viewer-c", admissionWait) + + wasActive, promoted := admission.Leave("viewer-c") + if wasActive || promoted != "" { + t.Fatalf("waiting leave = active %t, promotion %q", wasActive, promoted) + } + active, waiting := admission.Counts() + if active != 2 || waiting != 0 { + t.Fatalf("counts = %d active, %d waiting", active, waiting) + } +} + +func TestViewerAdmissionUsesTheSmallerRoomLimit(t *testing.T) { + admission := newViewerAdmission(1) + assertAdmission(t, admission, "viewer-a", admissionActivate) + assertAdmission(t, admission, "viewer-b", admissionFull) + active, waiting := admission.Counts() + if active != 1 || waiting != 0 { + t.Fatalf("counts = %d active, %d waiting", active, waiting) + } +} + +func TestViewerAdmissionClearsFailedActivation(t *testing.T) { + admission := newViewerAdmission(4) + assertAdmission(t, admission, "viewer-a", admissionActivate) + assertAdmission(t, admission, "viewer-b", admissionActivate) + assertAdmission(t, admission, "viewer-c", admissionWait) + assertAdmission(t, admission, "viewer-d", admissionWait) + + _, promoted := admission.Leave("viewer-a") + if promoted != "viewer-c" { + t.Fatalf("first promotion = %q", promoted) + } + admission.AbortActivation(promoted) + if admission.IsActive("viewer-c") || admission.IsActive("viewer-d") { + t.Fatal("failed activation left a viewer in the active set") + } + active, waiting := admission.Counts() + if active != 1 || waiting != 1 { + t.Fatalf("counts = %d active, %d waiting", active, waiting) + } +} + +func assertAdmission(t *testing.T, admission *viewerAdmission, peerID string, want admissionAction) { + t.Helper() + if got := admission.Join(peerID); got != want { + t.Fatalf("join %q = %d, want %d", peerID, got, want) + } +} diff --git a/native/sender/internal/remote/peer.go b/native/sender/internal/remote/peer.go new file mode 100644 index 0000000..3d6a192 --- /dev/null +++ b/native/sender/internal/remote/peer.go @@ -0,0 +1,300 @@ +package remote + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/pion/rtcp" + "github.com/pion/webrtc/v4" +) + +type peer struct { + session *Session + peerID string + slot int + connectionID string + connection *webrtc.PeerConnection + + mu sync.Mutex + pendingCandidates []webrtc.ICECandidateInit + remoteDescription bool + closeOnce sync.Once + firstConnected sync.Once + closed atomic.Bool + pliRequests atomic.Uint64 + firRequests atomic.Uint64 + diagnosticsMu sync.Mutex + lastSampleAt time.Time + lastPacketsSent uint64 + lastBytesSent uint64 +} + +type PeerDiagnostics struct { + Slot int `json:"slot"` + ConnectionState string `json:"connectionState"` + ICEConnectionState string `json:"iceConnectionState"` + Route string `json:"route"` + PacketsSent uint64 `json:"packetsSent"` + BytesSent uint64 `json:"bytesSent"` + BitrateBPS *uint64 `json:"bitrateBps,omitempty"` + CurrentRoundTripTimeMS *uint64 `json:"currentRoundTripTimeMs,omitempty"` + AvailableOutgoingBitrate *uint64 `json:"availableOutgoingBitrate,omitempty"` + PLIRequests uint64 `json:"pliRequests"` + FIRRequests uint64 `json:"firRequests"` +} + +func newPeer(session *Session, peerID string, slot int, configuration webrtc.Configuration) (*peer, error) { + connectionID, err := opaqueID() + if err != nil { + return nil, fmt.Errorf("create connection identity: %w", err) + } + connection, err := webrtc.NewPeerConnection(configuration) + if err != nil { + return nil, errors.New("create viewer peer connection failed") + } + peer := &peer{ + session: session, + peerID: peerID, + slot: slot, + connectionID: connectionID, + connection: connection, + } + connection.OnICECandidate(func(candidate *webrtc.ICECandidate) { + if peer.closed.Load() { + return + } + payload := outboundCandidatePayload{Kind: "candidate", ConnectionID: connectionID} + if candidate != nil { + init := candidate.ToJSON() + payload.Candidate = &iceCandidate{ + Candidate: init.Candidate, + SDPMid: init.SDPMid, + SDPMLineIndex: init.SDPMLineIndex, + UsernameFragment: init.UsernameFragment, + } + } + if sendErr := session.sendSignal(peerID, payload); sendErr != nil && !session.closing.Load() { + go session.fail(errors.New("send viewer ICE candidate failed")) + } + }) + connection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { + if !peer.closed.Load() { + session.emit(Event{Kind: "peer-state", Message: fmt.Sprintf("Media edge %d is %s", slot, state.String())}) + if state == webrtc.PeerConnectionStateConnected { + peer.firstConnected.Do(func() { session.emit(Event{Kind: "request-keyframe"}) }) + } + } + }) + sender, err := connection.AddTrack(session.fanout.Track()) + if err != nil { + _ = connection.Close() + return nil, errors.New("bind shared VP8 track failed") + } + go peer.readRTCP(sender) + return peer, nil +} + +func (peer *peer) offer(restart bool) error { + offer, err := peer.connection.CreateOffer(&webrtc.OfferOptions{ICERestart: restart}) + if err != nil { + return errors.New("create viewer offer failed") + } + if err = peer.connection.SetLocalDescription(offer); err != nil { + return errors.New("set viewer offer failed") + } + local := peer.connection.LocalDescription() + if local == nil { + return errors.New("viewer offer is unavailable") + } + if err = peer.session.sendSignal(peer.peerID, outboundDescriptionPayload{ + Kind: "description", + ConnectionID: peer.connectionID, + Description: sessionDescription{ + Type: "offer", + SDP: local.SDP, + }, + }); err != nil { + return errors.New("send viewer offer failed") + } + return nil +} + +func (peer *peer) restartICE() error { + return peer.offer(true) +} + +func (peer *peer) acceptSignal(payload inboundSignalPayload) error { + if payload.ConnectionID != peer.connectionID { + return nil + } + switch payload.Kind { + case "description": + if payload.Description == nil || payload.Description.Type != "answer" || payload.Description.SDP == "" { + return errors.New("viewer returned an invalid answer") + } + if err := peer.connection.SetRemoteDescription(webrtc.SessionDescription{ + Type: webrtc.SDPTypeAnswer, + SDP: payload.Description.SDP, + }); err != nil { + return errors.New("apply viewer answer failed") + } + peer.mu.Lock() + peer.remoteDescription = true + pending := append([]webrtc.ICECandidateInit(nil), peer.pendingCandidates...) + peer.pendingCandidates = nil + peer.mu.Unlock() + for _, candidate := range pending { + if err := peer.connection.AddICECandidate(candidate); err != nil { + return errors.New("apply queued viewer ICE candidate failed") + } + } + return nil + case "candidate": + candidate := webrtc.ICECandidateInit{} + if payload.Candidate != nil { + candidate = webrtc.ICECandidateInit{ + Candidate: payload.Candidate.Candidate, + SDPMid: payload.Candidate.SDPMid, + SDPMLineIndex: payload.Candidate.SDPMLineIndex, + UsernameFragment: payload.Candidate.UsernameFragment, + } + } + peer.mu.Lock() + if !peer.remoteDescription { + if len(peer.pendingCandidates) >= 64 { + peer.mu.Unlock() + return errors.New("viewer ICE candidate queue exceeded its bound") + } + peer.pendingCandidates = append(peer.pendingCandidates, candidate) + peer.mu.Unlock() + return nil + } + peer.mu.Unlock() + if err := peer.connection.AddICECandidate(candidate); err != nil { + return errors.New("apply viewer ICE candidate failed") + } + return nil + default: + return errors.New("viewer signal kind is invalid") + } +} + +func (peer *peer) readRTCP(sender *webrtc.RTPSender) { + for { + packets, _, err := sender.ReadRTCP() + if err != nil { + return + } + for _, packet := range packets { + switch packet.(type) { + case *rtcp.PictureLossIndication: + peer.pliRequests.Add(1) + peer.session.emit(Event{Kind: "request-keyframe"}) + case *rtcp.FullIntraRequest: + peer.firRequests.Add(1) + peer.session.emit(Event{Kind: "request-keyframe"}) + } + } + } +} + +func (peer *peer) close() { + peer.closeOnce.Do(func() { + peer.closed.Store(true) + _ = peer.connection.Close() + }) +} + +func (peer *peer) snapshot(now time.Time) PeerDiagnostics { + diagnostics := PeerDiagnostics{ + Slot: peer.slot, + ConnectionState: peer.connection.ConnectionState().String(), + ICEConnectionState: peer.connection.ICEConnectionState().String(), + Route: "unknown", + PLIRequests: peer.pliRequests.Load(), + FIRRequests: peer.firRequests.Load(), + } + report := peer.connection.GetStats() + transportID := "" + for _, value := range report { + outbound, ok := value.(webrtc.OutboundRTPStreamStats) + if !ok || outbound.Kind != "video" { + continue + } + diagnostics.PacketsSent += uint64(outbound.PacketsSent) + diagnostics.BytesSent += outbound.BytesSent + if transportID == "" { + transportID = outbound.TransportID + } + } + if transport, ok := report[transportID].(webrtc.TransportStats); ok { + if pair, pairOK := report[transport.SelectedCandidatePairID].(webrtc.ICECandidatePairStats); pairOK { + diagnostics.Route = selectedRoute(report, pair) + if pair.CurrentRoundTripTime > 0 { + value := uint64(pair.CurrentRoundTripTime*1000 + 0.5) + diagnostics.CurrentRoundTripTimeMS = &value + } + if pair.AvailableOutgoingBitrate > 0 { + value := uint64(pair.AvailableOutgoingBitrate + 0.5) + diagnostics.AvailableOutgoingBitrate = &value + } + } + } + peer.diagnosticsMu.Lock() + if !peer.lastSampleAt.IsZero() && now.After(peer.lastSampleAt) && + diagnostics.BytesSent >= peer.lastBytesSent && diagnostics.PacketsSent >= peer.lastPacketsSent { + seconds := now.Sub(peer.lastSampleAt).Seconds() + value := uint64(float64(diagnostics.BytesSent-peer.lastBytesSent)*8/seconds + 0.5) + diagnostics.BitrateBPS = &value + } + peer.lastSampleAt = now + peer.lastPacketsSent = diagnostics.PacketsSent + peer.lastBytesSent = diagnostics.BytesSent + peer.diagnosticsMu.Unlock() + return diagnostics +} + +func selectedRoute(report webrtc.StatsReport, pair webrtc.ICECandidatePairStats) string { + local, localOK := report[pair.LocalCandidateID].(webrtc.ICECandidateStats) + remote, remoteOK := report[pair.RemoteCandidateID].(webrtc.ICECandidateStats) + if localOK && local.CandidateType == webrtc.ICECandidateTypeRelay { + return "turn-" + relayTransport(local) + } + if remoteOK && remote.CandidateType == webrtc.ICECandidateTypeRelay { + return "turn-unknown" + } + if !localOK || !remoteOK { + return "unknown" + } + return "direct-" + candidateProtocol(local) +} + +func relayTransport(candidate webrtc.ICECandidateStats) string { + switch strings.ToLower(candidate.RelayProtocol) { + case "udp", "tcp", "tls": + return strings.ToLower(candidate.RelayProtocol) + default: + return "unknown" + } +} + +func candidateProtocol(candidate webrtc.ICECandidateStats) string { + if protocol := strings.ToLower(candidate.Protocol); protocol == "udp" || protocol == "tcp" { + return protocol + } + return "unknown" +} + +func opaqueID() (string, error) { + value := make([]byte, 16) + if _, err := rand.Read(value); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(value), nil +} diff --git a/native/sender/internal/remote/peer_test.go b/native/sender/internal/remote/peer_test.go new file mode 100644 index 0000000..aefef73 --- /dev/null +++ b/native/sender/internal/remote/peer_test.go @@ -0,0 +1,77 @@ +package remote + +import ( + "testing" + + "github.com/pion/webrtc/v4" +) + +func TestSelectedRouteTreatsARemoteRelayCandidateAsTURN(t *testing.T) { + report := webrtc.StatsReport{ + "local": webrtc.ICECandidateStats{ + ID: "local", + Protocol: "udp", + CandidateType: webrtc.ICECandidateTypeHost, + }, + "remote": webrtc.ICECandidateStats{ + ID: "remote", + Protocol: "udp", + CandidateType: webrtc.ICECandidateTypeRelay, + }, + } + pair := webrtc.ICECandidatePairStats{LocalCandidateID: "local", RemoteCandidateID: "remote"} + if route := selectedRoute(report, pair); route != "turn-unknown" { + t.Fatalf("selected route = %q, want turn-unknown", route) + } +} + +func TestSelectedRouteUsesOnlyTheLocalRelayProtocol(t *testing.T) { + report := webrtc.StatsReport{ + "local": webrtc.ICECandidateStats{ + ID: "local", Protocol: "udp", CandidateType: webrtc.ICECandidateTypeRelay, + }, + "remote": webrtc.ICECandidateStats{ + ID: "remote", Protocol: "udp", CandidateType: webrtc.ICECandidateTypeHost, + }, + } + pair := webrtc.ICECandidatePairStats{LocalCandidateID: "local", RemoteCandidateID: "remote"} + if route := selectedRoute(report, pair); route != "turn-unknown" { + t.Fatalf("selected route without local relayProtocol = %q", route) + } + local := report["local"].(webrtc.ICECandidateStats) + local.RelayProtocol = "tcp" + report["local"] = local + if route := selectedRoute(report, pair); route != "turn-tcp" { + t.Fatalf("selected route with local relayProtocol = %q", route) + } +} + +func TestSelectedRouteRequiresBothCandidatesBeforeClaimingDirect(t *testing.T) { + report := webrtc.StatsReport{ + "local": webrtc.ICECandidateStats{ + ID: "local", + Protocol: "udp", + CandidateType: webrtc.ICECandidateTypeHost, + }, + } + pair := webrtc.ICECandidatePairStats{LocalCandidateID: "local", RemoteCandidateID: "missing"} + if route := selectedRoute(report, pair); route != "unknown" { + t.Fatalf("selected route = %q, want unknown", route) + } +} + +func TestPeerAnswerFailureIsSanitized(t *testing.T) { + connection, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + viewer := &peer{connection: connection, connectionID: "connection-1"} + err = viewer.acceptSignal(inboundSignalPayload{ + Kind: "description", ConnectionID: "connection-1", + Description: &sessionDescription{Type: "answer", SDP: "private-sdp-marker"}, + }) + if err == nil || err.Error() != "apply viewer answer failed" { + t.Fatalf("answer error = %q", err) + } +} diff --git a/native/sender/internal/remote/session.go b/native/sender/internal/remote/session.go new file mode 100644 index 0000000..543fde7 --- /dev/null +++ b/native/sender/internal/remote/session.go @@ -0,0 +1,848 @@ +package remote + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/TNTcraftHIM/Screener/native/sender/internal/media" + "github.com/coder/websocket" + "github.com/pion/webrtc/v4" +) + +const ( + maxHostEdges = 2 + maxRemoteResponse = 64 << 10 + remoteRequestTimeout = 10 * time.Second + signalWriteTimeout = 5 * time.Second + hostAdmissionTTL = 12 * time.Hour + nativeHostClaimTTL = 5 * time.Minute + privateViewerGrantTTL = 7 * 24 * time.Hour + privateViewerPolicy = "private-link" +) + +var ( + roomCodePattern = regexp.MustCompile(`^[1-9][0-9]{0,11}$`) + hostTokenPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{32,128}$`) + viewerGrantPattern = regexp.MustCompile(`^g1\.([1-9][0-9]{0,11})\.([1-9][0-9]{0,12})\.[A-Za-z0-9_-]{43}$`) + admissionCookiePattern = regexp.MustCompile(`^v1\.[1-9][0-9]{0,12}\.[A-Za-z0-9_-]{43}$`) +) + +type Event struct { + Kind string `json:"kind"` + Message string `json:"message,omitempty"` + ActiveViewers int `json:"activeViewers,omitempty"` + WaitingViewers int `json:"waitingViewers,omitempty"` + Media *media.Metrics `json:"media,omitempty"` + Peers []PeerDiagnostics `json:"peers,omitempty"` +} + +type StartOptions struct { + BaseURL *url.URL + HostAdmissionPassword string + OnEvent func(Event) +} + +type Room struct { + ID string `json:"roomId"` + InviteURL string `json:"inviteUrl"` +} + +type Session struct { + ctx context.Context + cancel context.CancelFunc + signalCtx context.Context + cancelSignal context.CancelFunc + baseURL *url.URL + httpClient *http.Client + room Room + hostToken string + clientID string + shareGeneration string + onEvent func(Event) + + mu sync.Mutex + writeMu sync.Mutex + conn *websocket.Conn + ice iceConfig + peers map[string]*peer + admission *viewerAdmission + fanout *media.Fanout + closeOnce sync.Once + fatalOnce sync.Once + terminalOnce sync.Once + readDone chan struct{} + terminalResult chan error + readerStarted atomic.Bool + closing atomic.Bool +} + +type createRoomResponse struct { + RoomID string `json:"roomId"` + HostToken string `json:"hostToken"` + InviteURL string `json:"inviteUrl"` + ViewerPolicy string `json:"viewerPolicy"` + ViewerGrantExpiresAt json.RawMessage `json:"viewerGrantExpiresAt"` + ExpiresAt json.RawMessage `json:"expiresAt"` +} + +type hostAdmissionStatus struct { + Required *bool `json:"required"` + Authenticated *bool `json:"authenticated"` +} + +type createRoomRequest struct { + ViewerPolicy string `json:"viewerPolicy"` + HostClaimTTLSeconds int `json:"hostClaimTtlSeconds"` +} + +type iceServer struct { + URLs json.RawMessage `json:"urls"` +} + +type iceConfig struct { + IceServers []iceServer `json:"iceServers"` +} + +func Start(parent context.Context, options StartOptions) (*Session, Room, error) { + if options.BaseURL == nil { + return nil, Room{}, errors.New("remote server URL is required") + } + jar, err := cookiejar.New(nil) + if err != nil { + return nil, Room{}, fmt.Errorf("create in-memory cookie jar: %w", err) + } + httpClient := &http.Client{ + Jar: jar, + Timeout: remoteRequestTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + if err = authenticateHTTP(parent, httpClient, options.BaseURL, options.HostAdmissionPassword); err != nil { + return nil, Room{}, err + } + created, err := createRoom(parent, httpClient, options.BaseURL) + if err != nil { + return nil, Room{}, err + } + clientID, err := opaqueID() + if err != nil { + return nil, Room{}, fmt.Errorf("create sender identity: %w", err) + } + shareGeneration, err := opaqueID() + if err != nil { + return nil, Room{}, fmt.Errorf("create sharing generation: %w", err) + } + ctx, cancel := context.WithCancel(parent) + signalCtx, cancelSignal := context.WithCancel(context.WithoutCancel(parent)) + session := &Session{ + ctx: ctx, + cancel: cancel, + signalCtx: signalCtx, + cancelSignal: cancelSignal, + baseURL: options.BaseURL, + httpClient: httpClient, + room: Room{ID: created.RoomID, InviteURL: created.InviteURL}, + hostToken: created.HostToken, + clientID: clientID, + shareGeneration: shareGeneration, + onEvent: options.OnEvent, + peers: make(map[string]*peer), + readDone: make(chan struct{}), + terminalResult: make(chan error, 1), + } + session.fanout, err = media.NewFanout( + func() { session.emit(Event{Kind: "request-keyframe"}) }, + func(fatalErr error) { session.fail(fatalErr) }, + ) + if err != nil { + cancel() + return nil, Room{}, err + } + if err = session.connectWithRetry(parent); err != nil { + session.close() + return nil, Room{}, err + } + session.readerStarted.Store(true) + go session.readLoop() + go session.diagnosticsLoop() + go session.closeWhenCanceled() + return session, session.room, nil +} + +func (session *Session) connectWithRetry(parent context.Context) error { + var err error + for attempt := 0; attempt < 2; attempt++ { + ctx, cancel := context.WithTimeout(parent, remoteRequestTimeout) + err = session.connect(ctx) + cancel() + if err == nil { + return nil + } + session.resetSignalingConnection() + if parent.Err() != nil { + break + } + } + return err +} + +func (session *Session) resetSignalingConnection() { + session.mu.Lock() + conn := session.conn + session.conn = nil + session.mu.Unlock() + if conn != nil { + conn.CloseNow() + } +} + +func (session *Session) WriteFrame(frame media.Frame) error { + if err := session.fanout.Push(frame); err != nil { + session.fail(fmt.Errorf("write encoded frame: %w", err)) + return err + } + return nil +} + +func (session *Session) Stop() { + session.close() +} + +func (session *Session) Close() { + session.close() +} + +func (session *Session) Fail(err error) { + session.fail(err) +} + +func (session *Session) connect(ctx context.Context) error { + signalURL := *session.baseURL + if signalURL.Scheme == "https" { + signalURL.Scheme = "wss" + } else { + signalURL.Scheme = "ws" + } + signalURL.Path = "/signal" + header := http.Header{} + header.Set("Origin", remoteOrigin(session.baseURL)) + conn, response, err := websocket.Dial(ctx, signalURL.String(), &websocket.DialOptions{ + HTTPClient: session.httpClient, + HTTPHeader: header, + }) + if err != nil { + if response != nil { + return fmt.Errorf("connect signaling: HTTP %d", response.StatusCode) + } + return errors.New("connect signaling failed") + } + conn.SetReadLimit(64 << 10) + authenticate := authenticateMessage{ + Type: "authenticate", + Protocol: signalingProtocol, + RoomID: session.room.ID, + Role: "host", + Token: session.hostToken, + ClientID: session.clientID, + ShareGeneration: session.shareGeneration, + } + writeContext, cancel := context.WithTimeout(ctx, signalWriteTimeout) + err = writeWebSocketJSON(writeContext, conn, authenticate) + cancel() + if err != nil { + conn.CloseNow() + return errors.New("authenticate signaling failed") + } + session.mu.Lock() + session.conn = conn + session.mu.Unlock() + message, err := readServerMessage(ctx, conn) + if err != nil { + return errors.New("read signaling authentication failed") + } + if message.Type != "authenticated" || message.Role != "host" { + return errors.New("signaling returned an invalid host authentication response") + } + if message.ViewerPolicy != privateViewerPolicy { + return errors.New("signaling returned an unexpected room access policy") + } + session.mu.Lock() + session.ice = message.IceConfig + session.admission = newViewerAdmission(message.MaxViewers) + session.mu.Unlock() + // viewerPeerIds is a reconciliation roster and can include offline grace + // members. Only ordered peer-joined messages allocate native media edges. + session.emitViewerCounts() + session.emit(Event{Kind: "remote-ready"}) + return nil +} + +func (session *Session) readLoop() { + defer close(session.readDone) + conn := session.connection() + for { + message, err := readServerMessage(session.signalCtx, conn) + if err != nil { + if !session.closing.Load() { + go session.fail(errors.New("remote signaling disconnected")) + } + return + } + if session.closing.Load() { + switch message.Type { + case "room-closed": + if message.Reason == "host-ended" { + session.completeTerminal(nil) + } else { + session.completeTerminal(errors.New("remote signaling returned an unexpected room closure")) + } + return + default: + continue + } + } + if err = session.handle(message); err != nil { + // The sole reader must remain available for the room-closed ACK while + // the asynchronous fatal path performs the bounded terminal write. + go session.fail(err) + } + } +} + +func (session *Session) closeWhenCanceled() { + select { + case <-session.ctx.Done(): + session.close() + case <-session.readDone: + } +} + +func (session *Session) completeTerminal(err error) { + session.terminalOnce.Do(func() { + session.terminalResult <- err + }) +} + +func (session *Session) handle(message serverMessage) error { + switch message.Type { + case "peer-joined": + return session.addViewer(message.PeerID) + case "peer-left": + return session.removeViewer(message.PeerID) + case "signal": + peer := session.peer(message.FromPeerID) + if peer == nil { + return nil + } + if err := peer.acceptSignal(message.Payload); err != nil { + return fmt.Errorf("apply viewer signal: %w", err) + } + case "restart-request": + peer := session.peer(message.FromPeerID) + if peer == nil || peer.connectionID != message.ConnectionID { + return nil + } + if message.Rebuild { + return session.rebuildPeer(message.FromPeerID) + } else if err := peer.restartICE(); err != nil { + return fmt.Errorf("restart viewer ICE: %w", err) + } + case "viewer-quality-evidence": + // Fixed-HIGH observes no viewer feedback; accepting this current-wire + // message must not turn it into a room-wide quality controller. + return nil + case "error": + return fmt.Errorf("remote signaling error %s", message.Code) + case "room-closed": + return fmt.Errorf("remote room closed: %s", message.Reason) + } + return nil +} + +func (session *Session) addViewer(peerID string) error { + session.mu.Lock() + if session.admission == nil { + session.mu.Unlock() + return errors.New("viewer joined before host authentication completed") + } + action := session.admission.Join(peerID) + session.mu.Unlock() + switch action { + case admissionDuplicate: + return nil + case admissionWait: + session.emitViewerCounts() + return nil + case admissionFull: + return errors.New("viewer presence exceeded the authenticated room capacity") + case admissionActivate: + if err := session.activatePeer(peerID); err != nil { + session.mu.Lock() + session.admission.AbortActivation(peerID) + session.mu.Unlock() + return err + } + session.emitViewerCounts() + return nil + default: + return errors.New("viewer admission returned an invalid action") + } +} + +func (session *Session) activatePeer(peerID string) error { + session.mu.Lock() + configuration, err := pionConfiguration(session.ice) + slot := session.availableSlotLocked() + session.mu.Unlock() + if err != nil { + return err + } + if slot == 0 { + return errors.New("native host edge budget is exhausted") + } + created, err := newPeer(session, peerID, slot, configuration) + if err != nil { + return err + } + session.mu.Lock() + session.peers[peerID] = created + session.mu.Unlock() + if err = created.offer(false); err != nil { + session.mu.Lock() + delete(session.peers, peerID) + session.mu.Unlock() + created.close() + return err + } + return nil +} + +func (session *Session) removeViewer(peerID string) error { + session.mu.Lock() + peer := session.peers[peerID] + delete(session.peers, peerID) + if session.admission == nil { + session.mu.Unlock() + if peer != nil { + peer.close() + } + return nil + } + _, promoted := session.admission.Leave(peerID) + session.mu.Unlock() + if peer != nil { + peer.close() + } + if promoted != "" { + if err := session.activatePeer(promoted); err != nil { + session.mu.Lock() + session.admission.AbortActivation(promoted) + session.mu.Unlock() + return fmt.Errorf("promote waiting viewer: %w", err) + } + } + session.emitViewerCounts() + return nil +} + +func (session *Session) rebuildPeer(peerID string) error { + session.mu.Lock() + if session.admission == nil || !session.admission.IsActive(peerID) { + session.mu.Unlock() + return nil + } + old := session.peers[peerID] + delete(session.peers, peerID) + session.mu.Unlock() + if old != nil { + old.close() + } + if err := session.activatePeer(peerID); err != nil { + session.mu.Lock() + session.admission.AbortActivation(peerID) + session.mu.Unlock() + return err + } + return nil +} + +func (session *Session) peer(peerID string) *peer { + session.mu.Lock() + defer session.mu.Unlock() + return session.peers[peerID] +} + +func (session *Session) connection() *websocket.Conn { + session.mu.Lock() + defer session.mu.Unlock() + return session.conn +} + +func (session *Session) send(value any) error { + ctx, cancel := context.WithTimeout(session.signalCtx, signalWriteTimeout) + defer cancel() + return session.write(ctx, value, false) +} + +func (session *Session) write(ctx context.Context, value any, terminal bool) error { + session.writeMu.Lock() + defer session.writeMu.Unlock() + if session.closing.Load() && !terminal { + return io.ErrClosedPipe + } + conn := session.connection() + if conn == nil { + return io.ErrClosedPipe + } + return writeWebSocketJSON(ctx, conn, value) +} + +func writeWebSocketJSON(ctx context.Context, conn *websocket.Conn, value any) error { + payload, err := json.Marshal(value) + if err != nil { + return err + } + return conn.Write(ctx, websocket.MessageText, payload) +} + +func (session *Session) sendSignal(targetPeerID string, payload any) error { + return session.send(outboundSignalMessage{ + Type: "signal", + TargetPeerID: targetPeerID, + Payload: payload, + }) +} + +func (session *Session) emit(event Event) { + if session.onEvent != nil { + session.onEvent(event) + } +} + +func (session *Session) emitViewerCounts() { + session.mu.Lock() + active, waiting := 0, 0 + if session.admission != nil { + active, waiting = session.admission.Counts() + } + session.mu.Unlock() + session.emit(Event{Kind: "viewer-count", ActiveViewers: active, WaitingViewers: waiting}) +} + +func (session *Session) diagnosticsLoop() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + select { + case <-session.ctx.Done(): + return + case now := <-ticker.C: + session.mu.Lock() + peers := make([]*peer, 0, len(session.peers)) + for _, activePeer := range session.peers { + peers = append(peers, activePeer) + } + session.mu.Unlock() + diagnostics := make([]PeerDiagnostics, 0, len(peers)) + for _, activePeer := range peers { + diagnostics = append(diagnostics, activePeer.snapshot(now)) + } + sort.Slice(diagnostics, func(left, right int) bool { + return diagnostics[left].Slot < diagnostics[right].Slot + }) + metrics := session.fanout.Snapshot() + session.emit(Event{Kind: "diagnostics", Media: &metrics, Peers: diagnostics}) + } + } +} + +func (session *Session) availableSlotLocked() int { + used := [maxHostEdges + 1]bool{} + for _, activePeer := range session.peers { + if activePeer.slot > 0 && activePeer.slot <= maxHostEdges { + used[activePeer.slot] = true + } + } + for slot := 1; slot <= maxHostEdges; slot++ { + if !used[slot] { + return slot + } + } + return 0 +} + +func (session *Session) fail(err error) { + if err == nil || session.closing.Load() { + return + } + session.fatalOnce.Do(func() { + session.emit(Event{Kind: "fatal", Message: err.Error()}) + session.close() + }) +} + +func (session *Session) close() { + session.closeOnce.Do(func() { + session.closing.Store(true) + if session.connection() != nil { + terminalContext, cancelTerminal := context.WithTimeout(context.Background(), signalWriteTimeout) + err := session.write(terminalContext, simpleMessage{Type: "abandon-room"}, true) + confirmed := false + if err == nil && session.readerStarted.Load() { + select { + case terminalErr := <-session.terminalResult: + err = terminalErr + confirmed = terminalErr == nil + case <-session.readDone: + select { + case terminalErr := <-session.terminalResult: + err = terminalErr + confirmed = terminalErr == nil + default: + err = errors.New("remote signaling closed before room abandonment was acknowledged") + } + case <-terminalContext.Done(): + select { + case terminalErr := <-session.terminalResult: + err = terminalErr + confirmed = terminalErr == nil + default: + err = terminalContext.Err() + } + } + } + cancelTerminal() + if err != nil || !confirmed { + session.emit(Event{Kind: "warning", Message: "The fresh room could not be removed before shutdown"}) + } + } + session.cancel() + session.cancelSignal() + session.mu.Lock() + conn := session.conn + session.conn = nil + peers := make([]*peer, 0, len(session.peers)) + for _, peer := range session.peers { + peers = append(peers, peer) + } + session.peers = make(map[string]*peer) + session.mu.Unlock() + session.fanout.Close() + for _, peer := range peers { + peer.close() + } + if conn != nil { + _ = conn.Close(websocket.StatusNormalClosure, "sender stopped") + } + }) +} + +func authenticateHTTP(ctx context.Context, client *http.Client, base *url.URL, password string) error { + endpoint := base.ResolveReference(&url.URL{Path: "/api/host-admission"}) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), nil) + if err != nil { + return errors.New("prepare host admission request failed") + } + request.Header.Set("Origin", remoteOrigin(base)) + request.Header.Set("Accept", "application/json") + if password != "" { + request.Header.Set("Authorization", "Bearer "+password) + } + var status hostAdmissionStatus + response, err := doJSON(client, request, &status) + if err != nil { + return fmt.Errorf("authenticate with the Screener site: %w", err) + } + if response.StatusCode != http.StatusOK { + return errors.New("authenticate with the Screener site: unexpected success status") + } + if status.Required == nil || status.Authenticated == nil { + return errors.New("authenticate with the Screener site: response is invalid") + } + if !*status.Authenticated { + return errors.New("the Screener site did not authenticate this sender") + } + if err = validateHostAdmissionCookie(response, client.Jar, base, *status.Required); err != nil { + return fmt.Errorf("authenticate with the Screener site: %w", err) + } + return nil +} + +func createRoom(ctx context.Context, client *http.Client, base *url.URL) (createRoomResponse, error) { + endpoint := base.ResolveReference(&url.URL{Path: "/api/rooms"}) + payload, err := json.Marshal(createRoomRequest{ + ViewerPolicy: privateViewerPolicy, + HostClaimTTLSeconds: int(nativeHostClaimTTL / time.Second), + }) + if err != nil { + return createRoomResponse{}, errors.New("prepare room request failed") + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload)) + if err != nil { + return createRoomResponse{}, errors.New("prepare room request failed") + } + request.Header.Set("Origin", remoteOrigin(base)) + request.Header.Set("Accept", "application/json") + request.Header.Set("Content-Type", "application/json") + var room createRoomResponse + response, err := doJSON(client, request, &room) + if err != nil { + return createRoomResponse{}, fmt.Errorf("create Screener room: %w", err) + } + if response.StatusCode != http.StatusCreated { + return createRoomResponse{}, errors.New("create Screener room: unexpected success status") + } + if err = validateCreatedRoom(room, base, time.Now()); err != nil { + return createRoomResponse{}, fmt.Errorf("create Screener room: %w", err) + } + return room, nil +} + +func doJSON(client *http.Client, request *http.Request, target any) (*http.Response, error) { + response, err := client.Do(request) + if err != nil { + return nil, errors.New("request failed") + } + defer response.Body.Close() + payload, err := io.ReadAll(io.LimitReader(response.Body, maxRemoteResponse+1)) + if err != nil { + return nil, errors.New("response read failed") + } + if len(payload) > maxRemoteResponse { + return nil, errors.New("response is too large") + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("HTTP %d", response.StatusCode) + } + if err = decodeStrict(payload, target); err != nil { + return nil, errors.New("response is not valid current JSON") + } + return response, nil +} + +func remoteOrigin(base *url.URL) string { + return (&url.URL{Scheme: base.Scheme, Host: base.Host}).String() +} + +func validateHostAdmissionCookie(response *http.Response, jar http.CookieJar, base *url.URL, required bool) error { + setCookies := response.Header.Values("Set-Cookie") + if !required { + if len(setCookies) != 0 { + return errors.New("unexpected host admission cookie") + } + return nil + } + if len(setCookies) != 1 { + return errors.New("host admission cookie is missing or ambiguous") + } + cookies := response.Cookies() + if len(cookies) != 1 { + return errors.New("host admission cookie is invalid") + } + cookie := cookies[0] + expectedName := "screener-host-admission" + if base.Scheme == "https" { + expectedName = "__Host-screener-host-admission" + } + if cookie.Name != expectedName || !admissionCookiePattern.MatchString(cookie.Value) || + cookie.Path != "/" || cookie.Domain != "" || cookie.MaxAge != int(hostAdmissionTTL/time.Second) || + !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode || cookie.Secure != (base.Scheme == "https") || + cookie.Quoted || cookie.Partitioned || cookie.RawExpires != "" || !cookie.Expires.IsZero() || len(cookie.Unparsed) != 0 { + return errors.New("host admission cookie is invalid") + } + stored := jar.Cookies(base) + if len(stored) != 1 || stored[0].Name != cookie.Name || stored[0].Value != cookie.Value { + return errors.New("host admission cookie was not retained") + } + return nil +} + +func validateCreatedRoom(room createRoomResponse, base *url.URL, now time.Time) error { + if !roomCodePattern.MatchString(room.RoomID) || !hostTokenPattern.MatchString(room.HostToken) || + room.ViewerPolicy != privateViewerPolicy || + len(room.InviteURL) < 1 || len(room.InviteURL) > 2048 { + return errors.New("response is invalid") + } + viewerGrantExpiresAt, isNull, err := parseResponseTimestamp(room.ViewerGrantExpiresAt) + if err != nil || isNull || !viewerGrantExpiresAt.After(now) || + viewerGrantExpiresAt.After(now.Add(privateViewerGrantTTL)) { + return errors.New("response is invalid") + } + expiresAt, expiresAtIsNull, err := parseResponseTimestamp(room.ExpiresAt) + if err != nil || expiresAtIsNull || !expiresAt.After(now) || viewerGrantExpiresAt.After(expiresAt) { + return errors.New("response is invalid") + } + invite, err := url.Parse(room.InviteURL) + if err != nil || invite.User != nil || invite.RawQuery != "" || + !strings.EqualFold(invite.Scheme, base.Scheme) || !strings.EqualFold(invite.Host, base.Host) || + invite.Path != "/r/"+room.RoomID || !strings.HasPrefix(invite.Fragment, "v=") { + return errors.New("response is invalid") + } + grant := strings.TrimPrefix(invite.Fragment, "v=") + match := viewerGrantPattern.FindStringSubmatch(grant) + if len(match) != 3 || match[1] != room.RoomID { + return errors.New("response is invalid") + } + grantExpiresAt, err := strconv.ParseInt(match[2], 10, 64) + if err != nil || grantExpiresAt != viewerGrantExpiresAt.Unix() { + return errors.New("response is invalid") + } + return nil +} + +func parseResponseTimestamp(raw json.RawMessage) (time.Time, bool, error) { + if len(raw) == 0 { + return time.Time{}, false, errors.New("response field is missing") + } + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return time.Time{}, true, nil + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return time.Time{}, false, errors.New("response timestamp is invalid") + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, false, errors.New("response timestamp is invalid") + } + return parsed, false, nil +} + +func pionConfiguration(config iceConfig) (webrtc.Configuration, error) { + servers := make([]webrtc.ICEServer, 0, len(config.IceServers)) + for _, server := range config.IceServers { + var urls []string + if err := json.Unmarshal(server.URLs, &urls); err != nil { + var single string + if secondErr := json.Unmarshal(server.URLs, &single); secondErr != nil || !validStunURL(single) { + return webrtc.Configuration{}, errors.New("ICE server URLs are invalid") + } + urls = []string{single} + } + for index, value := range urls { + if !validStunURL(value) { + return webrtc.Configuration{}, errors.New("ICE server URLs are invalid") + } + urls[index] = "stun:" + value[len("stun:"):] + } + servers = append(servers, webrtc.ICEServer{ + URLs: urls, + }) + } + return webrtc.Configuration{ICEServers: servers}, nil +} diff --git a/native/sender/internal/remote/session_test.go b/native/sender/internal/remote/session_test.go new file mode 100644 index 0000000..fb951fb --- /dev/null +++ b/native/sender/internal/remote/session_test.go @@ -0,0 +1,784 @@ +package remote + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/coder/websocket" +) + +func TestStartUsesHostAdmissionCookieAndPrivateV2Room(t *testing.T) { + const password = "host-admission-password" + harness := newRemoteHarnessWithPassword(t, []string{}, password) + defer harness.Close() + baseURL, err := url.Parse(harness.URL()) + if err != nil { + t.Fatal(err) + } + session, room, err := Start(context.Background(), StartOptions{ + BaseURL: baseURL, + HostAdmissionPassword: password, + }) + if err != nil { + t.Fatal(err) + } + defer session.Close() + + admission := <-harness.admission + if admission.Method != http.MethodPost || admission.Authorization != "Bearer "+password || + admission.Origin != harness.URL() || admission.Body != "" { + t.Fatalf("host admission request = %+v", admission) + } + creation := <-harness.roomCreation + if creation.Method != http.MethodPost || creation.Authorization != "" || creation.Origin != harness.URL() || + creation.ContentType != "application/json" || + creation.Body != `{"viewerPolicy":"private-link","hostClaimTtlSeconds":300}` || + !strings.HasPrefix(creation.Cookie, "screener-host-admission=v1.") { + t.Fatalf("room creation request = %+v", creation) + } + if signalCookie := <-harness.signalCookie; signalCookie != creation.Cookie { + t.Fatalf("signaling cookie did not match room cookie") + } + if !strings.Contains(room.InviteURL, "/r/1#v=g1.1.") { + t.Fatalf("private invite was not returned to the local Host UI") + } + assertAuthenticateShape(t, harness.Authentication()) +} + +func TestStartRetriesTheSameProvisionalRoomOnce(t *testing.T) { + var roomCreations atomic.Int32 + var signalAttempts atomic.Int32 + var authMu sync.Mutex + var authentications [][]byte + mux := http.NewServeMux() + var server *httptest.Server + mux.HandleFunc("/api/host-admission", func(response http.ResponseWriter, request *http.Request) { + writeHarnessJSON(response, map[string]any{"required": false, "authenticated": true}) + }) + mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) { + roomCreations.Add(1) + response.WriteHeader(http.StatusCreated) + _, _ = response.Write([]byte(validRoomResponseJSON(server.URL, "1", "1", nil))) + }) + mux.HandleFunc("/signal", func(response http.ResponseWriter, request *http.Request) { + connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + return + } + defer connection.CloseNow() + messageType, payload, err := connection.Read(request.Context()) + if err != nil || messageType != websocket.MessageText { + return + } + authMu.Lock() + authentications = append(authentications, append([]byte(nil), payload...)) + authMu.Unlock() + if signalAttempts.Add(1) == 1 { + return + } + expiresAt := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Second) + authenticated, _ := json.Marshal(map[string]any{ + "type": "authenticated", "protocol": signalingProtocol, "role": "host", "peerId": "host-peer", + "roomExpiresAt": expiresAt.Format(time.RFC3339), "maxViewers": 3, "hostOnline": true, + "connectionId": nil, "viewerPeerIds": []string{}, + "iceConfig": map[string]any{"iceServers": []any{}}, "viewerPolicy": privateViewerPolicy, + "viewerAuthorizationGeneration": "viewer_generation_12345678", + }) + writeErr := connection.Write(request.Context(), websocket.MessageText, authenticated) + if writeErr != nil { + return + } + _, payload, err = connection.Read(request.Context()) + if err != nil || string(payload) != `{"type":"abandon-room"}` { + return + } + _ = connection.Write(context.Background(), websocket.MessageText, []byte(`{"type":"room-closed","reason":"host-ended"}`)) + }) + server = httptest.NewServer(mux) + defer server.Close() + baseURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + session, _, err := Start(context.Background(), StartOptions{BaseURL: baseURL}) + if err != nil { + t.Fatal(err) + } + session.Close() + if roomCreations.Load() != 1 || signalAttempts.Load() != 2 { + t.Fatalf("room creations = %d, signaling attempts = %d", roomCreations.Load(), signalAttempts.Load()) + } + authMu.Lock() + defer authMu.Unlock() + if len(authentications) != 2 || !bytes.Equal(authentications[0], authentications[1]) { + t.Fatal("signaling retry did not reuse the exact room and Host generation") + } +} + +func TestHostAdmissionFailuresAreBoundedAndDoNotLeakSecrets(t *testing.T) { + const password = "never-report-this-password" + const grant = "g1.1.1893456000.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + tests := []struct { + name string + status int + body string + setCookie string + }{ + {name: "unauthorized", status: http.StatusUnauthorized, body: `{"error":"` + password + ` ` + grant + `"}`}, + {name: "rate-limited", status: http.StatusTooManyRequests, body: `{"error":"` + password + ` ` + grant + `"}`}, + {name: "wrong-success-status", status: http.StatusCreated, body: `{"required":false,"authenticated":true}`}, + {name: "missing-cookie", status: http.StatusOK, body: `{"required":true,"authenticated":true}`}, + {name: "missing-required", status: http.StatusOK, body: `{"authenticated":true}`}, + {name: "missing-authenticated", status: http.StatusOK, body: `{"required":false}`}, + {name: "unknown-field", status: http.StatusOK, body: `{"required":false,"authenticated":true,"url":"https://private.test/path"}`}, + { + name: "quoted-cookie", status: http.StatusOK, body: `{"required":true,"authenticated":true}`, + setCookie: `screener-host-admission="v1.1893456000.` + strings.Repeat("a", 43) + `"; Path=/; Max-Age=43200; HttpOnly; SameSite=Strict`, + }, + { + name: "partitioned-cookie", status: http.StatusOK, body: `{"required":true,"authenticated":true}`, + setCookie: `screener-host-admission=v1.1893456000.` + strings.Repeat("a", 43) + `; Path=/; Max-Age=43200; HttpOnly; SameSite=Strict; Partitioned`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/api/host-admission" { + http.NotFound(response, request) + return + } + if test.setCookie != "" { + response.Header().Set("Set-Cookie", test.setCookie) + } + response.WriteHeader(test.status) + _, _ = response.Write([]byte(test.body)) + })) + defer server.Close() + baseURL, parseErr := url.Parse(server.URL) + if parseErr != nil { + t.Fatal(parseErr) + } + _, _, startErr := Start(context.Background(), StartOptions{ + BaseURL: baseURL, + HostAdmissionPassword: password, + }) + if startErr == nil { + t.Fatal("invalid host admission response was accepted") + } + reported := startErr.Error() + if strings.Contains(reported, password) || strings.Contains(reported, grant) || strings.Contains(reported, server.URL) { + t.Fatalf("host admission error leaked untrusted data: %q", reported) + } + }) + } +} + +func TestCreateRoomFailuresAreStrictAndDoNotLeakGrant(t *testing.T) { + const grant = "g1.1.1893456000.BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + tests := []struct { + name string + status int + body func(string) string + }{ + {name: "unauthorized", status: http.StatusUnauthorized, body: func(string) string { return `{"error":"` + grant + `"}` }}, + {name: "wrong-success-status", status: http.StatusOK, body: func(origin string) string { + return validRoomResponseJSON(origin, "1", "1", nil) + }}, + {name: "unknown-field", status: http.StatusCreated, body: func(origin string) string { + return validRoomResponseJSON(origin, "1", "1", map[string]any{"rawGrant": grant}) + }}, + {name: "mismatched-grant-room", status: http.StatusCreated, body: func(origin string) string { + return validRoomResponseJSON(origin, "2", "1", nil) + }}, + {name: "missing-room-expiry", status: http.StatusCreated, body: func(origin string) string { + return validRoomResponseJSON(origin, "1", "1", map[string]any{"expiresAt": omittedJSONField{}}) + }}, + {name: "persistent-room", status: http.StatusCreated, body: func(origin string) string { + return validRoomResponseJSON(origin, "1", "1", map[string]any{"expiresAt": nil}) + }}, + {name: "grant-expiry-mismatch", status: http.StatusCreated, body: func(origin string) string { + return validRoomResponseJSON(origin, "1", "1", map[string]any{ + "viewerGrantExpiresAt": time.Now().UTC().Add(25 * time.Hour).Truncate(time.Second).Format(time.RFC3339), + }) + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mux := http.NewServeMux() + var server *httptest.Server + mux.HandleFunc("/api/host-admission", func(response http.ResponseWriter, request *http.Request) { + writeHarnessJSON(response, map[string]any{"required": false, "authenticated": true}) + }) + mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) { + response.WriteHeader(test.status) + _, _ = response.Write([]byte(test.body(server.URL))) + }) + server = httptest.NewServer(mux) + defer server.Close() + baseURL, parseErr := url.Parse(server.URL) + if parseErr != nil { + t.Fatal(parseErr) + } + _, _, startErr := Start(context.Background(), StartOptions{BaseURL: baseURL}) + if startErr == nil { + t.Fatal("invalid room response was accepted") + } + if strings.Contains(startErr.Error(), grant) || strings.Contains(startErr.Error(), server.URL) { + t.Fatalf("room creation error leaked untrusted data: %q", startErr) + } + }) + } +} + +func TestSessionTerminalPathsAbandonFreshRoomExactlyOnce(t *testing.T) { + tests := []struct { + name string + terminate func(*Session) + wantFatal bool + }{ + {name: "stop", terminate: func(session *Session) { session.Stop() }}, + {name: "process-close", terminate: func(session *Session) { session.Close() }}, + {name: "local-fatal", terminate: func(session *Session) { session.Fail(errors.New("local bridge failed")) }, wantFatal: true}, + {name: "concurrent", terminate: func(session *Session) { + var wait sync.WaitGroup + for index := 0; index < 8; index++ { + wait.Add(1) + go func(closeSession bool) { + defer wait.Done() + if closeSession { + session.Close() + } else { + session.Stop() + } + }(index%2 == 0) + } + wait.Wait() + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + harness := newRemoteHarness(t, []string{"viewer-old"}) + defer harness.Close() + baseURL, err := url.Parse(harness.URL()) + if err != nil { + t.Fatal(err) + } + var eventsMu sync.Mutex + var events []Event + session, _, err := Start(context.Background(), StartOptions{ + BaseURL: baseURL, + OnEvent: func(event Event) { + eventsMu.Lock() + events = append(events, event) + eventsMu.Unlock() + }, + }) + if err != nil { + t.Fatal(err) + } + assertAuthenticateShape(t, harness.Authentication()) + + session.mu.Lock() + active, waiting := session.admission.Counts() + peerCount := len(session.peers) + session.mu.Unlock() + if active != 0 || waiting != 0 || peerCount != 0 { + t.Fatalf("authentication roster allocated media: %d active, %d waiting, %d peers", active, waiting, peerCount) + } + + test.terminate(session) + select { + case <-harness.Done(): + case <-time.After(2 * time.Second): + t.Fatal("fake signaling server did not observe terminal shutdown") + } + if got := harness.AbandonCount(); got != 1 { + t.Fatalf("abandon-room count = %d, want 1", got) + } + if got := string(harness.TerminalMessage()); got != `{"type":"abandon-room"}` { + t.Fatalf("terminal wire = %q (length %d)", got, len(got)) + } + + eventsMu.Lock() + defer eventsMu.Unlock() + fatalCount := 0 + for _, event := range events { + if event.Kind == "fatal" { + fatalCount++ + } + } + if test.wantFatal && fatalCount != 1 { + t.Fatalf("fatal event count = %d, want 1", fatalCount) + } + if !test.wantFatal && fatalCount != 0 { + t.Fatalf("normal room closure emitted %d fatal events", fatalCount) + } + }) + } +} + +func TestSessionSendPropagatesAClosedSignalingConnection(t *testing.T) { + accepted := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + return + } + defer connection.CloseNow() + close(accepted) + _, _, _ = connection.Read(request.Context()) + })) + defer server.Close() + + websocketURL := "ws" + server.URL[len("http"):] + connection, _, err := websocket.Dial(context.Background(), websocketURL, nil) + if err != nil { + t.Fatal(err) + } + <-accepted + connection.CloseNow() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + session := &Session{ctx: ctx, signalCtx: ctx, conn: connection} + if err = session.send(simpleMessage{Type: "abandon-room"}); err == nil { + t.Fatal("signaling write to a closed connection returned success") + } +} + +func TestCanceledSessionContextStillAbandonsTheFreshRoomExactlyOnce(t *testing.T) { + harness := newRemoteHarness(t, []string{}) + defer harness.Close() + baseURL, err := url.Parse(harness.URL()) + if err != nil { + t.Fatal(err) + } + parent, cancelParent := context.WithCancel(context.Background()) + session, _, err := Start(parent, StartOptions{BaseURL: baseURL}) + if err != nil { + cancelParent() + t.Fatal(err) + } + assertAuthenticateShape(t, harness.Authentication()) + cancelParent() + session.Close() + select { + case <-harness.Done(): + case <-time.After(2 * time.Second): + t.Fatal("canceled session context prevented fresh-room abandonment") + } + if got := harness.AbandonCount(); got != 1 { + t.Fatalf("abandon-room count = %d, want 1", got) + } + if got := string(harness.TerminalMessage()); got != `{"type":"abandon-room"}` { + t.Fatalf("terminal wire = %q", got) + } +} + +func TestSessionCloseWaitsForRoomClosedAcknowledgement(t *testing.T) { + ackGate := make(chan struct{}) + var releaseOnce sync.Once + releaseACK := func() { releaseOnce.Do(func() { close(ackGate) }) } + + harness := newRemoteHarness(t, []string{}) + harness.ackGate = ackGate + defer harness.Close() + defer releaseACK() + baseURL, err := url.Parse(harness.URL()) + if err != nil { + t.Fatal(err) + } + var warningCount atomic.Uint64 + session, _, err := Start(context.Background(), StartOptions{ + BaseURL: baseURL, + OnEvent: func(event Event) { + if event.Kind == "warning" { + warningCount.Add(1) + } + }, + }) + if err != nil { + t.Fatal(err) + } + assertAuthenticateShape(t, harness.Authentication()) + + closed := make(chan struct{}) + go func() { + session.Close() + close(closed) + }() + select { + case <-harness.AbandonObserved(): + case <-time.After(2 * time.Second): + releaseACK() + t.Fatal("fake signaling server did not observe room abandonment") + } + select { + case <-closed: + releaseACK() + t.Fatal("session closed the signaling transport before terminal acknowledgement") + default: + } + + releaseACK() + select { + case <-closed: + case <-time.After(2 * time.Second): + t.Fatal("session did not close after terminal acknowledgement") + } + if got := warningCount.Load(); got != 0 { + t.Fatalf("acknowledged room abandonment emitted %d warnings", got) + } +} + +func TestSessionCloseBoundsMissingRoomClosedAcknowledgement(t *testing.T) { + ackGate := make(chan struct{}) + var releaseOnce sync.Once + releaseACK := func() { releaseOnce.Do(func() { close(ackGate) }) } + + harness := newRemoteHarness(t, []string{}) + harness.ackGate = ackGate + defer harness.Close() + defer releaseACK() + baseURL, err := url.Parse(harness.URL()) + if err != nil { + t.Fatal(err) + } + var warningCount atomic.Uint64 + session, _, err := Start(context.Background(), StartOptions{ + BaseURL: baseURL, + OnEvent: func(event Event) { + if event.Kind == "warning" { + warningCount.Add(1) + } + }, + }) + if err != nil { + t.Fatal(err) + } + assertAuthenticateShape(t, harness.Authentication()) + + started := time.Now() + session.Close() + elapsed := time.Since(started) + if elapsed < signalWriteTimeout-250*time.Millisecond { + t.Fatalf("session closed before its terminal deadline: %s", elapsed) + } + if elapsed > signalWriteTimeout+2*time.Second { + t.Fatalf("session exceeded its terminal deadline: %s", elapsed) + } + if got := harness.AbandonCount(); got != 1 { + t.Fatalf("abandon-room count = %d, want 1", got) + } + if got := warningCount.Load(); got != 1 { + t.Fatalf("missing acknowledgement emitted %d warnings, want 1", got) + } + releaseACK() +} + +func TestReaderOriginatedFatalAbandonsOnceWithoutDeadlock(t *testing.T) { + harness := newRemoteHarness(t, []string{}) + harness.afterAuth = [][]byte{[]byte(`{"type":"error","code":"SERVER_ERROR","message":"bounded failure"}`)} + defer harness.Close() + baseURL, err := url.Parse(harness.URL()) + if err != nil { + t.Fatal(err) + } + fatal := make(chan string, 1) + session, _, err := Start(context.Background(), StartOptions{ + BaseURL: baseURL, + OnEvent: func(event Event) { + if event.Kind == "fatal" { + select { + case fatal <- event.Message: + default: + } + } + }, + }) + if err != nil { + t.Fatal(err) + } + defer session.Close() + assertAuthenticateShape(t, harness.Authentication()) + + select { + case <-harness.Done(): + case <-time.After(2 * time.Second): + t.Fatal("reader-originated fatal did not complete terminal cleanup") + } + if got := harness.AbandonCount(); got != 1 { + t.Fatalf("abandon-room count = %d, want 1", got) + } + select { + case message := <-fatal: + if message == "" { + t.Fatal("reader-originated fatal message is empty") + } + default: + t.Fatal("reader-originated error did not emit a fatal event") + } +} + +type remoteHarness struct { + testing *testing.T + server *httptest.Server + authentication chan []byte + done chan struct{} + doneOnce sync.Once + abandonObserved chan struct{} + abandonOnce sync.Once + ackGate <-chan struct{} + afterAuth [][]byte + terminalMu sync.Mutex + terminal []byte + abandonCount atomic.Uint64 + password string + admission chan capturedRequest + roomCreation chan capturedRequest + signalCookie chan string +} + +type capturedRequest struct { + Method string + Authorization string + Cookie string + Origin string + ContentType string + Body string +} + +func newRemoteHarness(t *testing.T, roster []string) *remoteHarness { + return newRemoteHarnessWithPassword(t, roster, "") +} + +func newRemoteHarnessWithPassword(t *testing.T, roster []string, password string) *remoteHarness { + t.Helper() + harness := &remoteHarness{ + testing: t, + authentication: make(chan []byte, 1), + done: make(chan struct{}), + abandonObserved: make(chan struct{}), + password: password, + admission: make(chan capturedRequest, 1), + roomCreation: make(chan capturedRequest, 1), + signalCookie: make(chan string, 1), + } + mux := http.NewServeMux() + mux.HandleFunc("/api/host-admission", func(response http.ResponseWriter, request *http.Request) { + body, _ := io.ReadAll(request.Body) + harness.admission <- capturedRequest{ + Method: request.Method, + Authorization: request.Header.Get("Authorization"), + Origin: request.Header.Get("Origin"), + Body: string(body), + } + if password == "" { + writeHarnessJSON(response, map[string]any{"required": false, "authenticated": true}) + return + } + if request.Header.Get("Authorization") != "Bearer "+password { + response.WriteHeader(http.StatusUnauthorized) + writeHarnessJSON(response, map[string]string{"error": "secret must never be reflected"}) + return + } + response.Header().Set("Set-Cookie", "screener-host-admission=v1.1893456000."+strings.Repeat("a", 43)+"; Path=/; Max-Age=43200; HttpOnly; SameSite=Strict") + writeHarnessJSON(response, map[string]any{"required": true, "authenticated": true}) + }) + mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) { + body, _ := io.ReadAll(request.Body) + harness.roomCreation <- capturedRequest{ + Method: request.Method, + Authorization: request.Header.Get("Authorization"), + Cookie: request.Header.Get("Cookie"), + Origin: request.Header.Get("Origin"), + ContentType: request.Header.Get("Content-Type"), + Body: string(body), + } + if password != "" && request.Header.Get("Cookie") == "" { + response.WriteHeader(http.StatusUnauthorized) + return + } + response.WriteHeader(http.StatusCreated) + _, _ = response.Write([]byte(validRoomResponseJSON(harness.server.URL, "1", "1", nil))) + }) + mux.HandleFunc("/signal", func(response http.ResponseWriter, request *http.Request) { + harness.signalCookie <- request.Header.Get("Cookie") + if password != "" && request.Header.Get("Cookie") == "" { + http.Error(response, "unauthorized", http.StatusUnauthorized) + return + } + connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + harness.finish() + return + } + defer connection.CloseNow() + messageType, payload, err := connection.Read(request.Context()) + if err != nil || messageType != websocket.MessageText { + harness.finish() + return + } + harness.authentication <- append([]byte(nil), payload...) + authenticated, _ := json.Marshal(map[string]any{ + "type": "authenticated", "protocol": signalingProtocol, "role": "host", "peerId": "host-peer", + "roomExpiresAt": nil, "maxViewers": 3, "hostOnline": true, + "connectionId": nil, "viewerPeerIds": roster, + "iceConfig": map[string]any{"iceServers": []any{}}, + "viewerPolicy": privateViewerPolicy, "viewerAuthorizationGeneration": "viewer_generation_12345678", + }) + if err = connection.Write(request.Context(), websocket.MessageText, authenticated); err != nil { + harness.finish() + return + } + for _, message := range harness.afterAuth { + if err = connection.Write(request.Context(), websocket.MessageText, message); err != nil { + harness.finish() + return + } + } + for { + messageType, payload, err = connection.Read(request.Context()) + if err != nil { + harness.finish() + return + } + if messageType != websocket.MessageText { + continue + } + var discriminator struct { + Type string `json:"type"` + } + _ = json.Unmarshal(payload, &discriminator) + if discriminator.Type != "abandon-room" { + continue + } + harness.abandonCount.Add(1) + harness.terminalMu.Lock() + harness.terminal = append([]byte(nil), payload...) + harness.terminalMu.Unlock() + harness.abandonOnce.Do(func() { close(harness.abandonObserved) }) + if harness.ackGate != nil { + select { + case <-harness.ackGate: + case <-request.Context().Done(): + harness.finish() + return + } + } + closed := []byte(`{"type":"room-closed","reason":"host-ended"}`) + _ = connection.Write(context.Background(), websocket.MessageText, closed) + harness.finish() + return + } + }) + harness.server = httptest.NewServer(mux) + return harness +} + +func (harness *remoteHarness) URL() string { + return harness.server.URL +} + +func (harness *remoteHarness) Authentication() []byte { + select { + case payload := <-harness.authentication: + return payload + case <-time.After(2 * time.Second): + harness.testing.Fatal("fake signaling server did not receive authentication") + return nil + } +} + +func (harness *remoteHarness) TerminalMessage() []byte { + harness.terminalMu.Lock() + defer harness.terminalMu.Unlock() + return append([]byte(nil), harness.terminal...) +} + +func (harness *remoteHarness) AbandonCount() uint64 { + return harness.abandonCount.Load() +} + +func (harness *remoteHarness) Done() <-chan struct{} { + return harness.done +} + +func (harness *remoteHarness) AbandonObserved() <-chan struct{} { + return harness.abandonObserved +} + +func (harness *remoteHarness) Close() { + harness.server.Close() +} + +func (harness *remoteHarness) finish() { + harness.doneOnce.Do(func() { close(harness.done) }) +} + +func writeHarnessJSON(response http.ResponseWriter, value any) { + response.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(response).Encode(value) +} + +type omittedJSONField struct{} + +func validRoomResponseJSON(origin, roomID, grantRoomID string, overrides map[string]any) string { + expiresAt := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Second) + grant := "g1." + grantRoomID + "." + strconv.FormatInt(expiresAt.Unix(), 10) + "." + strings.Repeat("b", 43) + response := map[string]any{ + "roomId": roomID, "hostToken": "0123456789abcdef0123456789abcdef", + "inviteUrl": origin + "/r/" + roomID + "#v=" + grant, + "viewerPolicy": privateViewerPolicy, "viewerGrantExpiresAt": expiresAt.Format(time.RFC3339), "expiresAt": expiresAt.Format(time.RFC3339), + } + for key, value := range overrides { + if _, omitted := value.(omittedJSONField); omitted { + delete(response, key) + continue + } + response[key] = value + } + payload, err := json.Marshal(response) + if err != nil { + panic(err) + } + return string(payload) +} + +func assertAuthenticateShape(t *testing.T, payload []byte) { + t.Helper() + var message map[string]any + if err := json.Unmarshal(payload, &message); err != nil { + t.Fatal(err) + } + keys := make([]string, 0, len(message)) + for key := range message { + keys = append(keys, key) + } + sort.Strings(keys) + want := []string{"clientId", "protocol", "role", "roomId", "shareGeneration", "token", "type"} + if len(keys) != len(want) { + t.Fatalf("authentication keys = %v", keys) + } + for index := range want { + if keys[index] != want[index] { + t.Fatalf("authentication keys = %v, want %v", keys, want) + } + } + if message["type"] != "authenticate" || message["protocol"] != signalingProtocol || + message["role"] != "host" || message["roomId"] != "1" { + t.Fatalf("authentication = %v", message) + } +} diff --git a/native/sender/internal/remote/wire.go b/native/sender/internal/remote/wire.go new file mode 100644 index 0000000..e0e22fa --- /dev/null +++ b/native/sender/internal/remote/wire.go @@ -0,0 +1,429 @@ +package remote + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/coder/websocket" +) + +const ( + signalingProtocol = "screener-v2" + maxProtocolViewers = 16 + maxSignalSDPBytes = 48 << 10 + maxQualityEvidence = 2 << 10 +) + +var opaqueIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`) + +type sessionDescription struct { + Type string `json:"type"` + SDP string `json:"sdp"` +} + +type iceCandidate struct { + Candidate string `json:"candidate"` + SDPMid *string `json:"sdpMid,omitempty"` + SDPMLineIndex *uint16 `json:"sdpMLineIndex,omitempty"` + UsernameFragment *string `json:"usernameFragment,omitempty"` +} + +type inboundSignalPayload struct { + Kind string + ConnectionID string + Description *sessionDescription + Candidate *iceCandidate +} + +type outboundDescriptionPayload struct { + Kind string `json:"kind"` + ConnectionID string `json:"connectionId"` + Description sessionDescription `json:"description"` +} + +type outboundCandidatePayload struct { + Kind string `json:"kind"` + ConnectionID string `json:"connectionId"` + Candidate *iceCandidate `json:"candidate"` +} + +type outboundSignalMessage struct { + Type string `json:"type"` + TargetPeerID string `json:"targetPeerId"` + Payload any `json:"payload"` +} + +type authenticateMessage struct { + Type string `json:"type"` + Protocol string `json:"protocol"` + RoomID string `json:"roomId"` + Role string `json:"role"` + Token string `json:"token"` + ClientID string `json:"clientId"` + ShareGeneration string `json:"shareGeneration"` +} + +type simpleMessage struct { + Type string `json:"type"` +} + +type serverMessage struct { + Type string + Role string + PeerID string + FromPeerID string + ViewerPeerIDs []string + ConnectionID string + Rebuild bool + MaxViewers int + IceConfig iceConfig + ViewerPolicy string + Payload inboundSignalPayload + Code string + Message string + Reason string +} + +func readServerMessage(ctx context.Context, conn *websocket.Conn) (serverMessage, error) { + messageType, payload, err := conn.Read(ctx) + if err != nil { + return serverMessage{}, err + } + if messageType != websocket.MessageText { + return serverMessage{}, errors.New("signaling message is not text") + } + return decodeServerMessage(payload) +} + +func decodeServerMessage(payload []byte) (serverMessage, error) { + var discriminator struct { + Type string `json:"type"` + } + if err := json.Unmarshal(payload, &discriminator); err != nil || discriminator.Type == "" { + return serverMessage{}, errors.New("signaling message is not valid JSON") + } + switch discriminator.Type { + case "authenticated": + return decodeAuthenticatedMessage(payload) + case "peer-joined", "peer-left": + var wire struct { + Type string `json:"type"` + PeerID string `json:"peerId"` + } + if err := decodeStrict(payload, &wire); err != nil || !validOpaqueID(wire.PeerID) { + return serverMessage{}, errors.New("peer presence message is invalid") + } + return serverMessage{Type: wire.Type, PeerID: wire.PeerID}, nil + case "signal": + var wire struct { + Type string `json:"type"` + FromPeerID string `json:"fromPeerId"` + Payload json.RawMessage `json:"payload"` + } + if err := decodeStrict(payload, &wire); err != nil || !validOpaqueID(wire.FromPeerID) { + return serverMessage{}, errors.New("viewer signal envelope is invalid") + } + signal, err := decodeInboundSignalPayload(wire.Payload) + if err != nil { + return serverMessage{}, err + } + return serverMessage{Type: wire.Type, FromPeerID: wire.FromPeerID, Payload: signal}, nil + case "restart-request": + var wire struct { + Type string `json:"type"` + FromPeerID string `json:"fromPeerId"` + ConnectionID string `json:"connectionId"` + Rebuild *bool `json:"rebuild"` + } + if err := decodeStrict(payload, &wire); err != nil || wire.Rebuild == nil || + !validOpaqueID(wire.FromPeerID) || !validOpaqueID(wire.ConnectionID) { + return serverMessage{}, errors.New("viewer restart request is invalid") + } + return serverMessage{Type: wire.Type, FromPeerID: wire.FromPeerID, ConnectionID: wire.ConnectionID, Rebuild: *wire.Rebuild}, nil + case "viewer-quality-evidence": + if err := validateViewerQualityEvidence(payload); err != nil { + return serverMessage{}, err + } + return serverMessage{Type: discriminator.Type}, nil + case "error": + var wire struct { + Type string `json:"type"` + Code string `json:"code"` + Message string `json:"message"` + } + if err := decodeStrict(payload, &wire); err != nil || !validErrorCode(wire.Code) || len(wire.Message) < 1 || len(wire.Message) > 256 { + return serverMessage{}, errors.New("signaling error message is invalid") + } + return serverMessage{Type: wire.Type, Code: wire.Code, Message: wire.Message}, nil + case "room-closed": + var wire struct { + Type string `json:"type"` + Reason string `json:"reason"` + } + if err := decodeStrict(payload, &wire); err != nil || wire.Reason != "host-ended" && wire.Reason != "expired" { + return serverMessage{}, errors.New("room closure message is invalid") + } + return serverMessage{Type: wire.Type, Reason: wire.Reason}, nil + default: + return serverMessage{}, errors.New("signaling message type is unsupported") + } +} + +type viewerQualityEvidenceMetrics struct { + Width json.RawMessage `json:"width"` + Height json.RawMessage `json:"height"` + FramesPerSecond json.RawMessage `json:"framesPerSecond"` + BitrateKbps json.RawMessage `json:"bitrateKbps"` + PacketsReceivedDelta json.RawMessage `json:"packetsReceivedDelta"` + PacketsLostDelta json.RawMessage `json:"packetsLostDelta"` + JitterMs json.RawMessage `json:"jitterMs"` + FramesDecodedDelta json.RawMessage `json:"framesDecodedDelta"` + FramesDroppedDelta json.RawMessage `json:"framesDroppedDelta"` + DecodeMsPerFrame json.RawMessage `json:"decodeMsPerFrame"` + FreezeCountDelta json.RawMessage `json:"freezeCountDelta"` + FreezeDurationMsDelta json.RawMessage `json:"freezeDurationMsDelta"` + Codec json.RawMessage `json:"codec"` + CodecProfile json.RawMessage `json:"codecProfile"` + CodecParameters json.RawMessage `json:"codecParameters"` +} + +func validateViewerQualityEvidence(payload []byte) error { + if len(payload) > maxQualityEvidence { + return errors.New("viewer quality evidence is too large") + } + var wire struct { + Type string `json:"type"` + ViewerPeerID string `json:"viewerPeerId"` + ParentPeerID string `json:"parentPeerId"` + Guard struct { + ConnectionID string `json:"connectionId"` + RouteRevision json.RawMessage `json:"routeRevision"` + } `json:"guard"` + Sequence json.RawMessage `json:"sequence"` + WindowMS json.RawMessage `json:"windowMs"` + Metrics viewerQualityEvidenceMetrics `json:"metrics"` + } + if err := decodeStrict(payload, &wire); err != nil || wire.Type != "viewer-quality-evidence" || + !validOpaqueID(wire.ViewerPeerID) || !validOpaqueID(wire.ParentPeerID) || + !validOpaqueID(wire.Guard.ConnectionID) || + !allJSONFieldsPresent( + wire.Guard.RouteRevision, + wire.Sequence, + wire.WindowMS, + wire.Metrics.Width, + wire.Metrics.Height, + wire.Metrics.FramesPerSecond, + wire.Metrics.BitrateKbps, + wire.Metrics.PacketsReceivedDelta, + wire.Metrics.PacketsLostDelta, + wire.Metrics.JitterMs, + wire.Metrics.FramesDecodedDelta, + wire.Metrics.FramesDroppedDelta, + wire.Metrics.DecodeMsPerFrame, + wire.Metrics.FreezeCountDelta, + wire.Metrics.FreezeDurationMsDelta, + wire.Metrics.Codec, + wire.Metrics.CodecProfile, + wire.Metrics.CodecParameters, + ) { + return errors.New("viewer quality evidence envelope is invalid") + } + return nil +} + +func allJSONFieldsPresent(fields ...json.RawMessage) bool { + for _, field := range fields { + if len(field) == 0 { + return false + } + } + return true +} + +func decodeAuthenticatedMessage(payload []byte) (serverMessage, error) { + var wire struct { + Type string `json:"type"` + Protocol string `json:"protocol"` + Role string `json:"role"` + PeerID string `json:"peerId"` + RoomExpiresAt json.RawMessage `json:"roomExpiresAt"` + MaxViewers *int `json:"maxViewers"` + HostOnline *bool `json:"hostOnline"` + ConnectionID json.RawMessage `json:"connectionId"` + ViewerPeerIDs []string `json:"viewerPeerIds"` + IceConfig json.RawMessage `json:"iceConfig"` + ViewerPolicy string `json:"viewerPolicy"` + ViewerAuthorizationGeneration string `json:"viewerAuthorizationGeneration"` + } + if err := decodeStrict(payload, &wire); err != nil || wire.Type != "authenticated" || + wire.Protocol != signalingProtocol || + wire.Role != "host" || !validOpaqueID(wire.PeerID) || wire.MaxViewers == nil || + *wire.MaxViewers < 1 || *wire.MaxViewers > maxProtocolViewers || wire.HostOnline == nil || + !*wire.HostOnline || !bytes.Equal(bytes.TrimSpace(wire.ConnectionID), []byte("null")) || + wire.ViewerPeerIDs == nil || len(wire.ViewerPeerIDs) > *wire.MaxViewers || + (wire.ViewerPolicy != "private-link" && wire.ViewerPolicy != "public-watch") || + !validOpaqueID(wire.ViewerAuthorizationGeneration) { + return serverMessage{}, errors.New("host authentication response is invalid") + } + if err := validateNullableTime(wire.RoomExpiresAt); err != nil { + return serverMessage{}, errors.New("host room expiry is invalid") + } + for _, peerID := range wire.ViewerPeerIDs { + if !validOpaqueID(peerID) { + return serverMessage{}, errors.New("host viewer roster is invalid") + } + } + config, err := decodeICEConfig(wire.IceConfig) + if err != nil { + return serverMessage{}, err + } + return serverMessage{ + Type: wire.Type, + Role: wire.Role, + PeerID: wire.PeerID, + MaxViewers: *wire.MaxViewers, + ViewerPeerIDs: append([]string(nil), wire.ViewerPeerIDs...), + IceConfig: config, + ViewerPolicy: wire.ViewerPolicy, + }, nil +} + +func decodeInboundSignalPayload(payload []byte) (inboundSignalPayload, error) { + var discriminator struct { + Kind string `json:"kind"` + } + if err := json.Unmarshal(payload, &discriminator); err != nil { + return inboundSignalPayload{}, errors.New("viewer signal payload is invalid") + } + switch discriminator.Kind { + case "description": + var wire struct { + Kind string `json:"kind"` + ConnectionID string `json:"connectionId"` + Description sessionDescription `json:"description"` + } + if err := decodeStrict(payload, &wire); err != nil || !validOpaqueID(wire.ConnectionID) || + wire.Description.Type != "answer" || len(wire.Description.SDP) < 1 || len(wire.Description.SDP) > maxSignalSDPBytes { + return inboundSignalPayload{}, errors.New("viewer answer payload is invalid") + } + return inboundSignalPayload{Kind: wire.Kind, ConnectionID: wire.ConnectionID, Description: &wire.Description}, nil + case "candidate": + var wire struct { + Kind string `json:"kind"` + ConnectionID string `json:"connectionId"` + Candidate json.RawMessage `json:"candidate"` + } + if err := decodeStrict(payload, &wire); err != nil || !validOpaqueID(wire.ConnectionID) || len(wire.Candidate) == 0 { + return inboundSignalPayload{}, errors.New("viewer candidate payload is invalid") + } + if bytes.Equal(bytes.TrimSpace(wire.Candidate), []byte("null")) { + return inboundSignalPayload{Kind: wire.Kind, ConnectionID: wire.ConnectionID}, nil + } + var candidate iceCandidate + if err := decodeStrict(wire.Candidate, &candidate); err != nil || len(candidate.Candidate) > 4096 || + candidate.SDPMid != nil && len(*candidate.SDPMid) > 128 || + candidate.SDPMLineIndex != nil && *candidate.SDPMLineIndex > 255 || + candidate.UsernameFragment != nil && len(*candidate.UsernameFragment) > 256 { + return inboundSignalPayload{}, errors.New("viewer ICE candidate is invalid") + } + return inboundSignalPayload{Kind: wire.Kind, ConnectionID: wire.ConnectionID, Candidate: &candidate}, nil + default: + return inboundSignalPayload{}, errors.New("viewer signal kind is invalid") + } +} + +func decodeICEConfig(payload []byte) (iceConfig, error) { + var wire struct { + IceServers []iceServer `json:"iceServers"` + } + if err := decodeStrict(payload, &wire); err != nil || wire.IceServers == nil || len(wire.IceServers) > 8 { + return iceConfig{}, errors.New("ICE configuration is invalid") + } + for _, server := range wire.IceServers { + var urls []string + if err := json.Unmarshal(server.URLs, &urls); err != nil { + var single string + if secondErr := json.Unmarshal(server.URLs, &single); secondErr != nil || !validStunURL(single) { + return iceConfig{}, errors.New("ICE server URLs are invalid") + } + continue + } + if len(urls) < 1 || len(urls) > 8 { + return iceConfig{}, errors.New("ICE server URLs are invalid") + } + for _, value := range urls { + if !validStunURL(value) { + return iceConfig{}, errors.New("ICE server URLs are invalid") + } + } + } + return iceConfig{IceServers: wire.IceServers}, nil +} + +func validStunURL(value string) bool { + if len(value) < len("stun:a") || len(value) > 512 || !strings.EqualFold(value[:len("stun:")], "stun:") { + return false + } + authority := value[len("stun:"):] + if authority == "" || strings.ContainsAny(authority, "/\\\t\r\n ?#") || strings.HasSuffix(authority, ":") { + return false + } + parsed, err := url.Parse("//" + authority) + if err != nil || parsed.Hostname() == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return false + } + if port := parsed.Port(); port != "" { + value, portErr := strconv.Atoi(port) + return portErr == nil && value > 0 && value <= 65535 + } + return true +} + +func decodeStrict(payload []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("multiple JSON values") + } + return nil +} + +func validateNullableTime(payload json.RawMessage) error { + trimmed := bytes.TrimSpace(payload) + if len(trimmed) == 0 { + return errors.New("missing time") + } + if bytes.Equal(trimmed, []byte("null")) { + return nil + } + var value string + if err := json.Unmarshal(trimmed, &value); err != nil { + return err + } + _, err := time.Parse(time.RFC3339, value) + return err +} + +func validOpaqueID(value string) bool { + return opaqueIDPattern.MatchString(value) +} + +func validErrorCode(value string) bool { + switch value { + case "AUTH_REQUIRED", "INVALID_MESSAGE", "INVALID_TOKEN", "ROOM_EXPIRED", "ROOM_FULL", "HOST_ALREADY_CONNECTED", "PEER_NOT_FOUND", "FORBIDDEN", "SERVER_ERROR": + return true + default: + return false + } +} diff --git a/native/sender/internal/remote/wire_test.go b/native/sender/internal/remote/wire_test.go new file mode 100644 index 0000000..7da0589 --- /dev/null +++ b/native/sender/internal/remote/wire_test.go @@ -0,0 +1,222 @@ +package remote + +import ( + "encoding/json" + "strings" + "testing" +) + +const validHostAuthenticated = `{"type":"authenticated","protocol":"screener-v2","role":"host","peerId":"host-peer","roomExpiresAt":null,"maxViewers":3,"hostOnline":true,"connectionId":null,"viewerPeerIds":[],"iceConfig":{"iceServers":[{"urls":["stun:example.test"]}]},"viewerPolicy":"private-link","viewerAuthorizationGeneration":"viewer_generation_12345678"}` + +func TestMarshalHostSignalShapes(t *testing.T) { + mid := "0" + line := uint16(0) + tests := []struct { + name string + payload any + want string + }{ + { + name: "offer", + payload: outboundDescriptionPayload{ + Kind: "description", ConnectionID: "connection-1", + Description: sessionDescription{Type: "offer", SDP: "v=0"}, + }, + want: `{"type":"signal","targetPeerId":"viewer-1","payload":{"kind":"description","connectionId":"connection-1","description":{"type":"offer","sdp":"v=0"}}}`, + }, + { + name: "candidate", + payload: outboundCandidatePayload{ + Kind: "candidate", ConnectionID: "connection-1", + Candidate: &iceCandidate{Candidate: "candidate:1", SDPMid: &mid, SDPMLineIndex: &line}, + }, + want: `{"type":"signal","targetPeerId":"viewer-1","payload":{"kind":"candidate","connectionId":"connection-1","candidate":{"candidate":"candidate:1","sdpMid":"0","sdpMLineIndex":0}}}`, + }, + { + name: "end-of-candidates", + payload: outboundCandidatePayload{Kind: "candidate", ConnectionID: "connection-1", Candidate: nil}, + want: `{"type":"signal","targetPeerId":"viewer-1","payload":{"kind":"candidate","connectionId":"connection-1","candidate":null}}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + encoded, err := json.Marshal(outboundSignalMessage{Type: "signal", TargetPeerID: "viewer-1", Payload: test.payload}) + if err != nil { + t.Fatal(err) + } + if string(encoded) != test.want { + t.Fatalf("signal JSON = %s\nwant = %s", encoded, test.want) + } + }) + } +} + +func TestDecodeOrdinaryHostAuthentication(t *testing.T) { + message, err := decodeServerMessage([]byte(validHostAuthenticated)) + if err != nil { + t.Fatal(err) + } + if message.MaxViewers != 3 || message.Type != "authenticated" || message.Role != "host" || + message.ViewerPolicy != "private-link" { + t.Fatalf("authentication = %+v", message) + } +} + +func TestDecodeOrdinaryHostAuthenticationRejectsInvalidVariants(t *testing.T) { + tests := map[string]func(map[string]any){ + "missing-protocol": func(message map[string]any) { delete(message, "protocol") }, + "wrong-protocol": func(message map[string]any) { message["protocol"] = "legacy-protocol" }, + "missing-max": func(message map[string]any) { delete(message, "maxViewers") }, + "zero-max": func(message map[string]any) { message["maxViewers"] = 0 }, + "too-large-max": func(message map[string]any) { message["maxViewers"] = 17 }, + "peer-mode-extra": func(message map[string]any) { message["mediaMode"] = "peer-assisted" }, + "wrong-role": func(message map[string]any) { message["role"] = "viewer" }, + "legacy-ice-shape": func(message map[string]any) { + message["iceConfig"].(map[string]any)["expiresAt"] = nil + }, + "turn-url": func(message map[string]any) { + message["iceConfig"] = map[string]any{"iceServers": []any{map[string]any{"urls": "turn:example.test"}}} + }, + "missing-viewer-policy": func(message map[string]any) { delete(message, "viewerPolicy") }, + "wrong-viewer-policy": func(message map[string]any) { message["viewerPolicy"] = "friends" }, + "missing-viewer-generation": func(message map[string]any) { delete(message, "viewerAuthorizationGeneration") }, + "invalid-viewer-generation": func(message map[string]any) { message["viewerAuthorizationGeneration"] = "short" }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + var candidate map[string]any + if err := json.Unmarshal([]byte(validHostAuthenticated), &candidate); err != nil { + t.Fatal(err) + } + mutate(candidate) + payload, err := json.Marshal(candidate) + if err != nil { + t.Fatal(err) + } + if _, err = decodeServerMessage(payload); err == nil { + t.Fatal("invalid authentication was accepted") + } + }) + } +} + +func TestDecodeServerMessageRejectsRemovedICERefresh(t *testing.T) { + payload := []byte(`{"type":"ice-config","iceConfig":{"iceServers":[]}}`) + if _, err := decodeServerMessage(payload); err == nil { + t.Fatal("removed ICE refresh message was accepted") + } +} + +func TestDecodeUnknownServerMessageDoesNotReflectUntrustedType(t *testing.T) { + const secret = "password=g1.1.1893456000.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@https://private.test/path" + _, err := decodeServerMessage([]byte(`{"type":"` + secret + `"}`)) + if err == nil || err.Error() != "signaling message type is unsupported" { + t.Fatalf("unknown message error = %v", err) + } + if strings.Contains(err.Error(), secret) { + t.Fatal("unknown message error reflected untrusted input") + } +} + +func TestDecodeAndIgnoreBoundedViewerQualityEvidence(t *testing.T) { + const valid = `{"type":"viewer-quality-evidence","viewerPeerId":"viewer-1","parentPeerId":"host-peer","guard":{"connectionId":"connection-1","routeRevision":0},"sequence":1,"windowMs":2000,"metrics":{"width":1280,"height":720,"framesPerSecond":30,"bitrateKbps":3000,"packetsReceivedDelta":100,"packetsLostDelta":0,"jitterMs":2.5,"framesDecodedDelta":60,"framesDroppedDelta":0,"decodeMsPerFrame":1.5,"freezeCountDelta":0,"freezeDurationMsDelta":0,"codec":"video/VP8","codecProfile":null,"codecParameters":"max-fr=30; max-fs=3600"}}` + message, err := decodeServerMessage([]byte(valid)) + if err != nil { + t.Fatal(err) + } + if message.Type != "viewer-quality-evidence" { + t.Fatalf("message type = %q", message.Type) + } + if err = (&Session{}).handle(message); err != nil { + t.Fatalf("fixed-HIGH rejected read-only viewer evidence: %v", err) + } +} + +func TestDecodeViewerQualityEvidenceRejectsInvalidVariants(t *testing.T) { + const valid = `{"type":"viewer-quality-evidence","viewerPeerId":"viewer-1","parentPeerId":"host-peer","guard":{"connectionId":"connection-1","routeRevision":0},"sequence":1,"windowMs":2000,"metrics":{"width":1280,"height":720,"framesPerSecond":30,"bitrateKbps":3000,"packetsReceivedDelta":100,"packetsLostDelta":0,"jitterMs":2.5,"framesDecodedDelta":60,"framesDroppedDelta":0,"decodeMsPerFrame":1.5,"freezeCountDelta":0,"freezeDurationMsDelta":0,"codec":"video/VP8","codecProfile":null,"codecParameters":"max-fr=30; max-fs=3600"}}` + tests := map[string]func(map[string]any){ + "extra-envelope-field": func(message map[string]any) { message["roomId"] = "1" }, + "missing-metric": func(message map[string]any) { + delete(message["metrics"].(map[string]any), "bitrateKbps") + }, + "extra-metric": func(message map[string]any) { + message["metrics"].(map[string]any)["decoderImplementation"] = "private" + }, + "extra-guard-field": func(message map[string]any) { + message["guard"].(map[string]any)["peerId"] = "viewer-1" + }, + "missing-guard-field": func(message map[string]any) { + delete(message["guard"].(map[string]any), "routeRevision") + }, + "invalid-viewer-id": func(message map[string]any) { + message["viewerPeerId"] = "short" + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + var candidate map[string]any + if err := json.Unmarshal([]byte(valid), &candidate); err != nil { + t.Fatal(err) + } + mutate(candidate) + payload, err := json.Marshal(candidate) + if err != nil { + t.Fatal(err) + } + if _, err = decodeServerMessage(payload); err == nil { + t.Fatal("invalid viewer quality evidence was accepted") + } + }) + } +} + +func TestDecodeViewerQualityEvidenceRejectsOversizedPayload(t *testing.T) { + payload := make([]byte, maxQualityEvidence+1) + if err := validateViewerQualityEvidence(payload); err == nil || err.Error() != "viewer quality evidence is too large" { + t.Fatalf("oversized viewer quality evidence error = %v", err) + } +} + +func TestPionConfigurationNormalizesUppercaseStunScheme(t *testing.T) { + config, err := decodeICEConfig([]byte(`{"iceServers":[{"urls":["STUN:[2001:db8::1]:3478"]}]}`)) + if err != nil { + t.Fatal(err) + } + pionConfig, err := pionConfiguration(config) + if err != nil { + t.Fatal(err) + } + if got := pionConfig.ICEServers[0].URLs[0]; got != "stun:[2001:db8::1]:3478" { + t.Fatalf("Pion STUN URL = %q", got) + } +} + +func TestDecodeInboundSignalRejectsCrossVariantFields(t *testing.T) { + tests := []string{ + `{"kind":"description","connectionId":"connection-1","description":{"type":"answer","sdp":"v=0"},"candidate":null}`, + `{"kind":"candidate","connectionId":"connection-1"}`, + `{"kind":"description","connectionId":"connection-1","description":{"type":"offer","sdp":"v=0"}}`, + } + for _, payload := range tests { + if _, err := decodeInboundSignalPayload([]byte(payload)); err == nil { + t.Fatalf("invalid payload was accepted: %s", payload) + } + } +} + +func TestDecodeInboundSignalRejectsCandidateLineAboveProtocolBound(t *testing.T) { + payload := []byte(`{"kind":"candidate","connectionId":"connection-1","candidate":{"candidate":"candidate:1","sdpMLineIndex":256}}`) + if _, err := decodeInboundSignalPayload(payload); err == nil { + t.Fatal("candidate line 256 was accepted") + } +} + +func TestMarshalAbandonRoomShape(t *testing.T) { + encoded, err := json.Marshal(simpleMessage{Type: "abandon-room"}) + if err != nil { + t.Fatal(err) + } + if string(encoded) != `{"type":"abandon-room"}` { + t.Fatalf("abandon JSON = %s", encoded) + } +} diff --git a/src/server/app.ts b/src/server/app.ts index e9cd487..2680cc3 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -274,7 +274,10 @@ async function handleRequest( } try { - const room = roomStore.createRoom(parsedRequest.data.viewerPolicy); + const room = roomStore.createRoom( + parsedRequest.data.viewerPolicy, + parsedRequest.data.hostClaimTtlSeconds, + ); const inviteUrl = new URL(`/r/${room.roomId}`, config.publicBaseUrl); if (room.viewerGrant) { inviteUrl.hash = `v=${room.viewerGrant}`; diff --git a/src/server/room-store.ts b/src/server/room-store.ts index a847a01..4afe8de 100644 --- a/src/server/room-store.ts +++ b/src/server/room-store.ts @@ -1,6 +1,7 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { + MAX_HOST_CLAIM_TTL_SECONDS, MAX_VIEWERS_PER_ROOM_LIMIT, ROOM_CODE_LENGTH, viewerGrantSchema, @@ -37,6 +38,7 @@ interface Room { viewerGrantDigest: Buffer | null; viewerAuthorizationGeneration: string; expiresAtMs: number | null; + provisionalHostExpiresAtMs: number | null; host?: Participant; viewers: Map; } @@ -157,6 +159,7 @@ export class RoomStore { : Buffer.from(storedRoom.viewerGrantDigest), viewerAuthorizationGeneration: this.newAuthorizationGeneration(), expiresAtMs: null, + provisionalHostExpiresAtMs: null, viewers: new Map(), }); } @@ -166,7 +169,18 @@ export class RoomStore { } } - createRoom(viewerPolicy: ViewerAccessPolicy = "private-link"): CreatedRoom { + createRoom( + viewerPolicy: ViewerAccessPolicy = "private-link", + hostClaimTtlSeconds?: number, + ): CreatedRoom { + if ( + hostClaimTtlSeconds !== undefined && + hostClaimTtlSeconds !== MAX_HOST_CLAIM_TTL_SECONDS + ) { + throw new Error( + `Provisional Host TTL must be ${MAX_HOST_CLAIM_TTL_SECONDS} seconds`, + ); + } if (this.rooms.size >= this.options.maxRooms) { throw new RoomStoreError("ROOM_LIMIT"); } @@ -175,22 +189,38 @@ export class RoomStore { const hostTokenDigest = digest(hostToken); const viewerAuthorizationGeneration = this.newAuthorizationGeneration(); const createdAtMs = this.now(); + const provisionalHostExpiresAtMs = + hostClaimTtlSeconds === undefined + ? null + : createdAtMs + hostClaimTtlSeconds * 1_000; + if ( + provisionalHostExpiresAtMs !== null && + !Number.isSafeInteger(provisionalHostExpiresAtMs) + ) { + throw new Error("Provisional Host expiry exceeds the supported time range"); + } let viewerGrant: string | null = null; let viewerGrantExpiresAtMs: number | null = null; - const storedRoom = this.options.database?.createRoom( - hostTokenDigest, - (roomId) => { - if (viewerPolicy === "public-watch") { - return null; - } - const persistentGrantExpiresAtMs = toEpochSecondsMs( - createdAtMs + PERSISTENT_VIEWER_GRANT_TTL_MS, - ); - viewerGrantExpiresAtMs = persistentGrantExpiresAtMs; - viewerGrant = this.createViewerGrant(roomId, persistentGrantExpiresAtMs); - return digest(viewerGrant); - }, - ); + const storedRoom = + provisionalHostExpiresAtMs === null + ? this.options.database?.createRoom( + hostTokenDigest, + (roomId) => { + if (viewerPolicy === "public-watch") { + return null; + } + const persistentGrantExpiresAtMs = toEpochSecondsMs( + createdAtMs + PERSISTENT_VIEWER_GRANT_TTL_MS, + ); + viewerGrantExpiresAtMs = persistentGrantExpiresAtMs; + viewerGrant = this.createViewerGrant( + roomId, + persistentGrantExpiresAtMs, + ); + return digest(viewerGrant); + }, + ) + : undefined; const roomId = storedRoom?.roomId ?? this.uniqueRoomCode(); const expiresAtMs = storedRoom ? null : createdAtMs + this.options.ttlMs; if (!storedRoom && viewerPolicy === "private-link") { @@ -210,6 +240,7 @@ export class RoomStore { viewerGrantDigest, viewerAuthorizationGeneration, expiresAtMs, + provisionalHostExpiresAtMs, viewers: new Map(), }); @@ -255,7 +286,9 @@ export class RoomStore { room.viewerAuthorizationGeneration; if (action === "public-watch") { - this.options.database?.updateViewerGrantDigest(roomId, null); + if (room.expiresAtMs === null) { + this.options.database?.updateViewerGrantDigest(roomId, null); + } room.viewerGrantDigest = null; return { viewerPolicy: "public-watch", @@ -282,9 +315,11 @@ export class RoomStore { const nextViewerAuthorizationGeneration = this.newAuthorizationGeneration(); - // Persistence is the commit point. No in-memory authorization or topology - // state changes before this write succeeds. - this.options.database?.updateViewerGrantDigest(roomId, nextDigest); + // Persistence is the commit point for durable rooms. Provisional native + // rooms deliberately never enter the configured database. + if (room.expiresAtMs === null) { + this.options.database?.updateViewerGrantDigest(roomId, nextDigest); + } const revokedViewers = [...room.viewers.values()].map((viewer) => ({ peerId: viewer.peerId, @@ -318,6 +353,10 @@ export class RoomStore { return undefined; } room.host.sessionId = undefined; + if (room.provisionalHostExpiresAtMs !== null) { + room.provisionalHostExpiresAtMs = + this.now() + MAX_HOST_CLAIM_TTL_SECONDS * 1_000; + } return { roomId, role: "host", peerId }; } @@ -371,7 +410,11 @@ export class RoomStore { if (!room) { return undefined; } - if (this.options.database && !this.options.database.deleteRoom(roomId)) { + if ( + room.expiresAtMs === null && + this.options.database && + !this.options.database.deleteRoom(roomId) + ) { throw new Error("Persistent room is missing from the room database"); } const sessionIds = connectedSessionIds(room); @@ -382,9 +425,16 @@ export class RoomStore { expireRooms(nowMs = this.now()): ClosedRoom[] { const expired: ClosedRoom[] = []; for (const [roomId, room] of this.rooms) { - if (room.expiresAtMs === null || room.expiresAtMs > nowMs) { + if (!roomIsExpired(room, nowMs)) { continue; } + if ( + room.expiresAtMs === null && + this.options.database && + !this.options.database.deleteRoom(roomId) + ) { + throw new Error("Persistent room is missing from the room database"); + } expired.push({ roomId, sessionIds: connectedSessionIds(room) }); this.rooms.delete(roomId); } @@ -476,7 +526,7 @@ export class RoomStore { // A single response avoids turning room IDs into an enumeration oracle. throw new RoomStoreError("INVALID_TOKEN"); } - if (room.expiresAtMs !== null && room.expiresAtMs <= this.now()) { + if (roomIsExpired(room, this.now())) { throw new RoomStoreError("ROOM_EXPIRED"); } return room; @@ -562,6 +612,15 @@ export class RoomStore { } } +function roomIsExpired(room: Room, nowMs: number): boolean { + return ( + (room.expiresAtMs !== null && room.expiresAtMs <= nowMs) || + (!room.host?.sessionId && + room.provisionalHostExpiresAtMs !== null && + room.provisionalHostExpiresAtMs <= nowMs) + ); +} + function isRoomCode(value: string): boolean { return ( value.length <= ROOM_CODE_LENGTH && diff --git a/src/server/signaling.ts b/src/server/signaling.ts index 8512ef2..7801440 100644 --- a/src/server/signaling.ts +++ b/src/server/signaling.ts @@ -362,7 +362,7 @@ export class SignalingServer { ); } catch (error) { if (!(error instanceof RoomStoreError)) { - console.error("Signaling authentication failed unexpectedly", error); + console.error("Signaling authentication failed unexpectedly"); } const code = error instanceof RoomStoreError && error.code !== "ROOM_LIMIT" diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 028fd88..0298af1 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -618,9 +618,12 @@ export const createRoomResponseSchema = z .strict(); export type CreateRoomResponse = z.infer; +export const MAX_HOST_CLAIM_TTL_SECONDS = 5 * 60; + export const createRoomRequestSchema = z .object({ viewerPolicy: viewerAccessPolicySchema, + hostClaimTtlSeconds: z.literal(MAX_HOST_CLAIM_TTL_SECONDS).optional(), }) .strict(); export type CreateRoomRequest = z.infer; diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 62a9cec..64a7304 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + MAX_HOST_CLAIM_TTL_SECONDS, MAX_MEDIA_ROUTE_REVISION, MAX_PARENT_EDGE_QUALITY_EVIDENCE_BYTES, MAX_SFU_TOKEN_LENGTH, @@ -8,6 +9,7 @@ import { MAX_VIEWERS_PER_ROOM_LIMIT, SIGNALING_PROTOCOL, clientMessageSchema, + createRoomRequestSchema, decodeClientMessage, participantRouteAssignmentSchema, serverMessageSchema, @@ -60,6 +62,29 @@ const parentEdgeQualityEvidence = { } as const; describe("client signaling protocol", () => { + it("accepts only the fixed provisional Host lease", () => { + expect( + createRoomRequestSchema.parse({ viewerPolicy: "private-link" }), + ).toEqual({ viewerPolicy: "private-link" }); + expect( + createRoomRequestSchema.parse({ + viewerPolicy: "private-link", + hostClaimTtlSeconds: MAX_HOST_CLAIM_TTL_SECONDS, + }), + ).toEqual({ + viewerPolicy: "private-link", + hostClaimTtlSeconds: MAX_HOST_CLAIM_TTL_SECONDS, + }); + for (const hostClaimTtlSeconds of [0, 1, 299, 301, 1.5]) { + expect( + createRoomRequestSchema.safeParse({ + viewerPolicy: "private-link", + hostClaimTtlSeconds, + }).success, + ).toBe(false); + } + }); + it("rejects the removed all-room ICE refresh request", () => { expect(clientMessageSchema.safeParse({ type: "refresh-ice" }).success).toBe( false, diff --git a/tests/room-store.test.ts b/tests/room-store.test.ts index b78883f..d8745eb 100644 --- a/tests/room-store.test.ts +++ b/tests/room-store.test.ts @@ -313,6 +313,133 @@ describe("RoomStore", () => { } }); + it("keeps provisional native rooms in memory when persistence is configured", () => { + let now = Date.UTC(2026, 7, 18, 12); + const directory = mkdtempSync(join(tmpdir(), "screener-provisional-room-")); + const databasePath = join(directory, "rooms.sqlite"); + const database = new RoomDatabase(databasePath); + const store = new RoomStore({ + ttlMs: 14_400_000, + maxRooms: 3, + maxViewersPerRoom: 3, + now: () => now, + database, + }); + try { + const provisional = store.createRoom("private-link", 300); + expect(provisional.expiresAt).not.toBeNull(); + expect(database.loadRooms()).toEqual([]); + + const persistent = store.createRoom(); + expect(persistent).toMatchObject({ roomId: "1", expiresAt: null }); + expect(database.loadRooms()).toHaveLength(1); + + now += 300_001; + expect(store.expireRooms()).toEqual([ + { roomId: provisional.roomId, sessionIds: [] }, + ]); + expect(database.loadRooms()).toHaveLength(1); + } finally { + store.close(); + rmSync(directory, { recursive: true, force: true }); + } + }); + + it("reclaims only the disconnected current Host after the provisional lease", () => { + let now = Date.UTC(2026, 7, 18, 12); + const store = new RoomStore({ + ttlMs: 14_400_000, + maxRooms: 2, + maxViewersPerRoom: 3, + now: () => now, + }); + const room = store.createRoom("private-link", 300); + const first = store.connectParticipant({ + roomId: room.roomId, + role: "host", + token: room.hostToken, + clientId: "native-host", + sessionId: "native-session-1", + }); + + now += 301_000; + expect(store.expireRooms()).toEqual([]); + const replacement = store.connectParticipant({ + roomId: room.roomId, + role: "host", + token: room.hostToken, + clientId: "native-host", + sessionId: "native-session-2", + }); + expect(replacement.replacedSessionId).toBe("native-session-1"); + expect( + store.disconnectParticipant(room.roomId, first.peerId, "native-session-1"), + ).toBeUndefined(); + + now += 301_000; + expect(store.expireRooms()).toEqual([]); + expect( + store.disconnectParticipant( + room.roomId, + replacement.peerId, + "native-session-2", + ), + ).toMatchObject({ role: "host" }); + expect(store.expireRooms(now + 299_999)).toEqual([]); + expect(store.expireRooms(now + 300_001)).toEqual([ + { roomId: room.roomId, sessionIds: [] }, + ]); + + const ttlRoom = store.createRoom("private-link", 300); + const ttlHost = store.connectParticipant({ + roomId: ttlRoom.roomId, + role: "host", + token: ttlRoom.hostToken, + clientId: "native-ttl-host", + sessionId: "native-ttl-session", + }); + expect(store.expireRooms(now + 14_400_001)).toEqual([ + { roomId: ttlRoom.roomId, sessionIds: ["native-ttl-session"] }, + ]); + expect(ttlHost.hostOnline).toBe(true); + }); + + it("never restores a provisional native room from SQLite after restart", () => { + const directory = mkdtempSync(join(tmpdir(), "screener-provisional-restart-")); + const databasePath = join(directory, "rooms.sqlite"); + let store: RoomStore | undefined; + try { + store = new RoomStore({ + ttlMs: 14_400_000, + maxRooms: 2, + maxViewersPerRoom: 3, + database: new RoomDatabase(databasePath), + }); + const room = store.createRoom("private-link", 300); + store.close(); + store = new RoomStore({ + ttlMs: 14_400_000, + maxRooms: 2, + maxViewersPerRoom: 3, + database: new RoomDatabase(databasePath), + }); + expectRoomError( + () => + store!.connectParticipant({ + roomId: room.roomId, + role: "host", + token: room.hostToken, + clientId: "native-after-restart", + sessionId: "native-after-restart", + }), + "INVALID_TOKEN", + ); + } finally { + store?.close(); + rmSync(directory, { recursive: true, force: true }); + } + }); + it("fails private Viewer access closed and scopes grants to one room", () => { const now = Date.UTC(2026, 7, 18, 12); const store = new RoomStore({ diff --git a/tests/server-http.test.ts b/tests/server-http.test.ts index ba4134c..c495104 100644 --- a/tests/server-http.test.ts +++ b/tests/server-http.test.ts @@ -86,6 +86,7 @@ async function createRoom( baseUrl: string, cookie?: string, viewerPolicy: "private-link" | "public-watch" = "private-link", + hostClaimTtlSeconds?: number, ): Promise { return fetch(`${baseUrl}/api/rooms`, { method: "POST", @@ -94,7 +95,10 @@ async function createRoom( Origin: allowedOrigin, ...(cookie ? { Cookie: cookie } : {}), }, - body: JSON.stringify({ viewerPolicy }), + body: JSON.stringify({ + viewerPolicy, + ...(hostClaimTtlSeconds === undefined ? {} : { hostClaimTtlSeconds }), + }), }); } @@ -277,6 +281,25 @@ describe("room HTTP API", () => { expect("iceConfig" in body).toBe(false); }); + it("accepts only the admitted fixed provisional Host lease", async () => { + const baseUrl = await start(); + expect((await createRoom(baseUrl, undefined, "private-link", 300)).status).toBe( + 401, + ); + + const authenticated = await login(baseUrl); + const cookie = cookiePair(authenticated); + expect((await createRoom(baseUrl, cookie, "private-link", 300)).status).toBe( + 201, + ); + expect((await createRoom(baseUrl, cookie, "private-link", 301)).status).toBe( + 400, + ); + expect((await createRoom(baseUrl, cookie, "private-link", 299)).status).toBe( + 400, + ); + }); + it("allows explicit public-watch creation without Host admission in local mode", async () => { const baseUrl = await start( testConfig({ hostAdmissionPassword: undefined }), diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts index 9536026..1b4120d 100644 --- a/tests/server-signal.test.ts +++ b/tests/server-signal.test.ts @@ -137,6 +137,7 @@ interface SignalHarness { webSocketUrl: string; roomStore: RoomStore; room: CreatedRoom; + database?: RoomDatabase; peerAssistedRoomIds?: Set; } @@ -164,6 +165,7 @@ async function startHarness( maxUnauthenticatedSignalConnections?: number; hostAdmissionPassword?: string; persistent?: boolean; + provisionalHostClaimSeconds?: number; peerAssistedMedia?: boolean; stunUrls?: readonly string[]; now?: () => number; @@ -175,13 +177,18 @@ async function startHarness( config.stunUrls = overrides.stunUrls ?? []; const maxViewersPerRoom = overrides.maxViewersPerRoom ?? 8; config.maxViewersPerRoom = maxViewersPerRoom; + const database = overrides.persistent ? new RoomDatabase(":memory:") : undefined; const roomStore = new RoomStore({ ttlMs: config.roomTtlMs, maxRooms: config.maxRooms, maxViewersPerRoom, - database: overrides.persistent ? new RoomDatabase(":memory:") : undefined, + database, + now: overrides.now, }); - const room = roomStore.createRoom(); + const room = roomStore.createRoom( + "private-link", + overrides.provisionalHostClaimSeconds, + ); const peerAssistedRoomIds = config.peerAssistedMedia ? new Set([room.roomId]) : undefined; @@ -205,6 +212,7 @@ async function startHarness( webSocketUrl: `ws://127.0.0.1:${port}/signal`, roomStore, room, + database, peerAssistedRoomIds, }; } @@ -750,6 +758,40 @@ describe("WebSocket signaling", () => { ).resolves.toMatchObject({ role: "host" }); }); + it("rearms an in-memory provisional room lease only after Host disconnect", async () => { + let now = Date.UTC(2026, 7, 20, 12); + const harness = await startHarness({ + persistent: true, + provisionalHostClaimSeconds: 300, + now: () => now, + }); + expect(harness.database!.loadRooms()).toEqual([]); + const host = await openClient(harness.webSocketUrl); + const authenticated = await authenticate( + host, + harness.room, + "host", + "provisional-host", + ); + const viewer = await openClient(harness.webSocketUrl); + await authenticate(viewer, harness.room, "viewer", "provisional-viewer"); + await host.inbox.next("peer-joined"); + + now += 301_000; + expect(harness.roomStore.expireRooms()).toEqual([]); + await closeClient(host); + await viewer.inbox.next("host-status"); + expect(harness.roomStore.getConnectedHost(harness.room.roomId)).toBeUndefined(); + expect(harness.roomStore.expireRooms(now + 299_999)).toEqual([]); + const expired = harness.roomStore.expireRooms(now + 300_001); + expect(expired).toHaveLength(1); + expect(expired[0]).toMatchObject({ roomId: harness.room.roomId }); + expect(expired[0]?.sessionIds).toHaveLength(1); + await closeClient(viewer); + expect(authenticated.role).toBe("host"); + expect(harness.database!.loadRooms()).toEqual([]); + }); + it("leaves established authorization and media untouched when access persistence fails", async () => { const harness = await startHarness({ persistent: true }); const host = await openClient(harness.webSocketUrl);