From 1e11e09261d3c9f9ccafc77e525e472818cd3bd6 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:37:02 +0800 Subject: [PATCH] feat: add exact-room peer ICE TURN fallback (#66) --- .env.example | 12 + AGENTS.md | 4 +- docs/README.md | 3 +- .../0005-automatic-hybrid-media-routing.md | 143 +++++----- .../0006-fixed-high-native-sender-canary.md | 6 +- docs/deployment.md | 75 +++-- docs/project-memory.md | 18 +- docs/research/advanced-peer-distribution.md | 2 +- docs/research/built-in-peer-ice-turn.md | 265 ++++++++++++++++++ docs/research/low-server-media-routes.md | 90 +++--- docs/research/webrtc-p2p-screen-sharing.md | 2 +- docs/status.md | 4 +- docs/方案设计.md | 76 +++-- docs/需求理解.md | 15 +- native/sender/internal/remote/wire_test.go | 22 +- src/client/lib/signaling.ts | 31 ++ src/client/pages/HostPage.tsx | 5 + src/client/pages/ViewerPage.tsx | 6 + src/client/webrtc/host-peer.ts | 2 + src/client/webrtc/viewer-peer.ts | 2 + src/server/app.ts | 1 + src/server/config.ts | 83 +++++- src/server/ice.ts | 31 +- src/server/peer-ice-turn.ts | 58 ++++ src/server/signaling.ts | 77 ++++- src/shared/protocol.ts | 106 ++++++- tests/client-state.test.ts | 105 +++++++ tests/host-peer-tracks.test.ts | 41 ++- tests/ice.test.ts | 24 ++ tests/peer-ice-turn.test.ts | 63 +++++ tests/protocol.test.ts | 50 +++- tests/server-config.test.ts | 90 ++++++ tests/server-signal.test.ts | 145 +++++++++- tests/viewer-peer-generation.test.ts | 42 ++- 34 files changed, 1492 insertions(+), 207 deletions(-) create mode 100644 docs/research/built-in-peer-ice-turn.md create mode 100644 src/server/peer-ice-turn.ts create mode 100644 tests/peer-ice-turn.test.ts diff --git a/.env.example b/.env.example index 5e10814..63d71d2 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,14 @@ MAX_SFU_ROOTS_PER_ROOM=2 # Separate multiple URLs with commas. STUN_URLS= +# Optional exact-room Web peer ICE compatibility. All three values are required +# together and also require PEER_ASSISTED_MEDIA=true. The canary accepts one +# explicit UDP TURN URI; the shared secret must be independent and 32-128 bytes. +# Credential TTL is bounded to 300-1800 seconds. Leave absent/empty to disable. +PEER_ICE_TURN_URLS= +PEER_ICE_TURN_SHARED_SECRET= +PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS= + # Minimal production example (use independent untracked secrets): # HOST_ADMISSION_PASSWORD=replace-with-an-independent-access-key # ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite @@ -46,3 +54,7 @@ STUN_URLS= # LIVEKIT_API_KEY=replace-with-a-livekit-api-key # LIVEKIT_API_SECRET=replace-with-at-least-32-independent-random-bytes # MAX_SFU_ROOTS_PER_ROOM=2 +# Optional built-in Peer ICE TURN canary for the same exact rooms: +# PEER_ICE_TURN_URLS=turn:turn.example.com:3478?transport=udp +# PEER_ICE_TURN_SHARED_SECRET=replace-with-32-to-128-independent-random-bytes +# PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS=600 diff --git a/AGENTS.md b/AGENTS.md index b713ef9..8abf755 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,9 +4,9 @@ - Build private, low-latency game screen sharing for one broadcaster and a small group of trusted friends. - This is not a public or large-scale streaming product; users can use OBS/Twitch-class services for that workload. -- Keep media distributed and automatic. Use centralized services for rooms, authentication, signaling, STUN, observability, and bounded SFU-root capacity. TURN is an optional authenticated selected-edge compatibility transport, disabled unless explicitly configured; it is not a media topology. +- Keep media distributed and automatic. Use centralized services for rooms, authentication, signaling, STUN, observability, and bounded SFU-root capacity. TURN is an optional authenticated built-in ICE compatibility transport for explicit exact-room canaries, disabled unless its complete tuple is configured; it is not a media topology. - Let viewers join from a normal desktop or mobile browser without installing the sender application. -- STUN is required. The target ladder is direct/peer UDP, then an SFU/UDP virtual parent feeding at most two roots, then optional authenticated selected-edge TURN, followed by a clear bounded failure. Ordinary peer edges must not receive TURN candidates by default. HTTPS/WSS remains TLS/TCP and is independent of this media-transport policy. +- STUN is required. In an explicitly allowlisted canary, each capable Web peer connection may start with STUN plus short-lived authenticated TURN/UDP under `iceTransportPolicy: "all"`; standard ICE preference keeps direct candidates first and may select relay without a second application transport state machine. A failed peer connection still follows one restart, one same-parent rebuild, one alternate peer, then an SFU/UDP virtual parent feeding at most two roots, followed by clear bounded failure. Non-allowlisted rooms and clients without the capability remain STUN-only. HTTPS/WSS remains TLS/TCP and is independent of this media-transport policy. - Every non-server endpoint has at most two active downstream media edges; an upstream receive edge does not consume this upload budget. Browser relays remain stricter at one downstream edge until their re-encode/resource gates pass. Central media normally serves at most two roots; extra server-fed exceptional viewers require an explicit deployment egress/admission cap and must never create unbounded fanout. - Treat a web client as the initial delivery target. A packaged desktop sender or native capture helper is a later optimization, not an assumed requirement. - Do not silently change the product into an always-SFU conferencing system. An SFU is a virtual parent for one or two necessary roots whose peer descendants remain distributed; record any broader topology change as an ADR with measured justification. diff --git a/docs/README.md b/docs/README.md index 90987f2..fc518b4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,8 @@ - [Maintenance guide](./maintenance.md): Git workflow, context hygiene, document lifecycle, research policy, and automation. - [P2P WebRTC research](./research/webrtc-p2p-screen-sharing.md): evidence, bandwidth model, browser constraints, reference implementations, and feasibility assessment. - [Peer-assisted media research](./research/peer-assisted-media.md): browser shared-encode limits, standard relay re-encoding, deterministic sticky topology, runtime relay-capacity extension, and abandon gates. -- [Low-server-cost media routes](./research/low-server-media-routes.md): SFU-root/peer distribution, optional selected-edge TURN, hop-level accounting, privacy-safe ICE evidence, and the bounded transport canary. +- [Low-server-cost media routes](./research/low-server-media-routes.md): SFU-root/peer distribution, built-in Peer ICE TURN, hop-level accounting, privacy-safe ICE evidence, and the bounded transport canary. +- [Built-in peer ICE TURN candidate](./research/built-in-peer-ice-turn.md): implemented default-off direct-first TURN fallback, short-lived participant-session bearer, refresh ownership, idle-allocation cost, and deployment gates. - [Advanced peer distribution](./research/advanced-peer-distribution.md): multi-tree/SVC, encoded-object relay, native RTP forwarding, FEC/network-coding, MoQ, and measurable go/no-go gates. - [Native shared-encode sender](./research/native-shared-encode-sender.md): consolidated Draft #16/#18/#22/#23/#25/#28 ladder, unclassified product-gate failure, staged revalidation, and stop line. - [Realtime quality adaptation](./research/realtime-quality-adaptation.md): correlated capture/send/receive diagnosis, production generation-rebuild gate, path-isolated dual representations, and bounded simulcast/Dynacast/SVC checks. diff --git a/docs/adr/0005-automatic-hybrid-media-routing.md b/docs/adr/0005-automatic-hybrid-media-routing.md index c9bb759..c202341 100644 --- a/docs/adr/0005-automatic-hybrid-media-routing.md +++ b/docs/adr/0005-automatic-hybrid-media-routing.md @@ -13,17 +13,20 @@ does not require users to choose a media topology. The ordered preference remains: -1. direct P2P edges; -2. peer-assisted forwarding; +1. a direct-selected P2P edge, with standard ICE allowed to select authenticated + TURN/UDP inside that same peer connection only in an explicit canary; +2. peer-assisted forwarding through an alternate eligible parent; 3. an SFU virtual parent supplied by the flagship deployment; and 4. bounded waiting or failure when no route can satisfy the budgets. -STUN is required. The accepted target keeps ordinary direct and peer edges on -ICE/UDP, then assigns one or two SFU roots when no peer path can satisfy the -route and fanout budgets. Authenticated TURN is optional and, when configured, -is a final transport compatibility layer for a selected exceptional edge; it -is not advertised to every ordinary peer edge by default. HTTPS/WSS remains TLS/TCP and -is outside this media policy. Every non-server endpoint has at most two active +STUN is required. A complete default-off Peer ICE TURN tuple lets only capable +Web sessions in exact allowlisted rooms create peer connections with STUN plus +short-lived authenticated TURN/UDP and `iceTransportPolicy: "all"`. Standard +ICE type priority prefers direct candidates and can nominate relay without an +application route transition. Non-allowlisted rooms and clients without the +capability remain STUN-only. After peer recovery and alternate-parent options +are exhausted, the controller assigns one or two SFU roots. HTTPS/WSS remains +TLS/TCP and is outside this media policy. Every non-server endpoint has at most two active downstream media edges; its upstream receive edge does not consume that upload budget. The current browser relay remains stricter at one child until its re-encode and resource gates pass. @@ -31,8 +34,8 @@ re-encode and resource gates pass. TURN and SFU occupy different layers. A selected TURN candidate continuously relays media for that ICE edge; it is not a handshake helper or a topology decision. LiveKit TURN cannot rescue an unavailable SFU. Independent coturn may -transport a separately selected ordinary peer edge around that outage, but only -after the controller authorizes that peer topology. An SFU is a virtual topology parent and may feed only one +transport an authorized participant's ordinary peer connection around a NAT +failure without changing the assigned peer topology. An SFU is a virtual topology parent and may feed only one or two necessary roots; after media reaches a reliable root, the existing bounded peer subtree remains the preferred distribution path. A zero-descendant SFU root is allowed only when no relay root satisfies @@ -68,18 +71,22 @@ The flagship deployment target supplies SFU capacity with a complete LiveKit endpoint/key/secret tuple. There is no user-facing topology selector and no process-wide `MEDIA_MODE` union. A deployment without that tuple ends at peer assistance and bounded waiting/failure and is not the final flagship route -configuration. Optional TURN is a separate selected-edge transport choice. +configuration. Optional Peer ICE TURN is a separate exact-room deployment +tuple and is never a topology choice. ## Implementation Status Merged PR #17 implements this controller on top of merged PR #13, and PR #20 -adds the bounded standby prewarm below. Production `d6c8aa0` now enables them -only for persistent room `1`; other rooms stay ordinary P2P, while ordinary ICE -is process-wide STUN-only. +adds the bounded standby prewarm below. Production enables them only for +persistent room `1`; other rooms stay ordinary P2P, and production remains +STUN-only. The current source adds a default-off Peer ICE TURN canary but does +not deploy it or modify coturn. The current runtime removes the old all-room coturn contract. Production -requires STUN, ordinary authenticated ICE snapshots contain only STUN servers, -and the protocol has no TURN credential expiry or refresh messages. The tracked +requires STUN and authenticated ICE snapshots contain only STUN servers. Source +now has a capability-gated optional TURN group plus expiry in `iceConfig`, and +strict `refresh-ice`/`ice-config` messages. They have no effect unless the new +complete tuple and exact-room allowlist both match. The tracked LiveKit sample explicitly sets `tcp_port: 0`, disables TCP fallback, supplies the deployment-owned STUN server, and configures no TURN service. The SFU controller still activates only after a peer edge exhausts recovery. Public participant entry has @@ -89,9 +96,9 @@ permanent compatibility branch or advertised current transport. The repository also requires a strict, non-empty `PEER_ASSISTED_ROOM_IDS` deployment allowlist whenever `PEER_ASSISTED_MEDIA=true`. Only exact listed room -IDs enter the controller or receive optional LiveKit standby and grants. All -other rooms retain the current ordinary P2P authentication shape and -signaling/quality/lifecycle behavior. Missing, blank, malformed, or duplicate +IDs enter the controller or receive optional LiveKit standby, grants, or Peer +ICE TURN configuration. All other rooms retain the current ordinary P2P +authentication shape, STUN-only ICE, and signaling/quality/lifecycle behavior. Missing, blank, malformed, or duplicate entries fail startup; there is no configuration state that enables all rooms. The boundary is a temporary deployment-only validation gate with no browser selector, percentage framework, or second router. @@ -103,12 +110,13 @@ and only allowlisted branch roots may subscribe. A necessary viewer with no descendants is still a root under the same authorization and total cap. The server currently retries a failed edge through the deterministic peer topology; only an exhausted peer route can request an SFU branch root. The target keeps -healthy UDP routes sticky but may select SFU directly when admission has no -eligible peer path. Optional TURN has two distinct future boundaries: an -exceptional ordinary host-to-root or root-to-viewer edge needs a new -generation-bound coturn grant, while a LiveKit publisher/subscriber may receive -the pinned participant-wide LiveKit TURN config. Neither is implemented as a -controller-selected TURN edge today; other routes remain unchanged. +healthy routes sticky but may select SFU directly when admission has no eligible +peer path. Optional Peer ICE TURN is not controller-selected: a capable Web +participant in the exact canary receives a short-lived participant-session +bearer and every bounded peer PC uses the same `all` configuration. Coturn +validates HMAC and expiry, not room, edge, parent, revision, or connection +generation, and the holder may reuse the bearer until expiry. LiveKit +publisher/subscriber ICE remains a separate participant-wide domain. Every viewer starts with zero relay capacity for each authenticated session. A peer-assisted Web client explicitly advertises either zero or one downstream @@ -195,19 +203,24 @@ Minimal protocol additions: - `sfu-config { revision, url, token }`; - `route-ready { revision, phase: "prepare" | "active" }`; - `route-failed { revision, phase, connectionId }`; and -- `refresh-sfu { revision }`. +- `refresh-sfu { revision }`; +- optional Web authentication capability `peerIceTurn: true`; +- `refresh-ice {}` from the current capable authenticated session; and +- `ice-config { iceConfig }` with the replacement short-lived config. -The optional selected-edge TURN target is not present in this protocol. A -future migration PR must add strict, session/assignment/generation-bound coturn -grant and refresh messages for ordinary peer edges. Pinned LiveKit embedded -TURN remains participant-wide ICE configuration for a LiveKit publisher or -subscriber and is not a Screener per-edge grant. +Native v2 does not advertise the optional capability and therefore retains its +strict STUN-only authenticated shape; the server never sends it `ice-config`. +Pinned LiveKit embedded TURN remains participant-wide ICE configuration for a +LiveKit publisher or subscriber and is not the ordinary Peer ICE bearer. -The ordinary authenticated `iceConfig` now contains only `iceServers` populated -from `STUN_URLS`. It has no username, credential, expiry, or relay-availability -flag. The removed `refresh-ice` and `ice-config` messages must not be reintroduced -as a shortcut for room-wide TURN. A signaling reconnect obtains a new static -STUN snapshot through the next authenticated message. +The ordinary authenticated `iceConfig` contains `iceServers` populated from +`STUN_URLS`. Only an opted-in exact-room Web session receives one additional +TURN group and `turnCredentialsExpiresAt`. Its signaling client is the sole +refresh-timer owner, requests two minutes early, and updates existing PCs with +`setConfiguration()` without proactively restarting them. The server reuses +the current in-memory session grant until that window, then rotates it. A +signaling reconnect obtains a new participant-session bearer. No credential is +placed in URL, log, browser persistence, room data, or SQLite. Authentication carries the current participant assignment and room revision, plus the non-secret standby URL when fallback is configured. It never carries a @@ -234,14 +247,12 @@ complete LiveKit tuple. by a newer connection generation cancels it; expiry enters the existing staged recovery at one ICE restart. It does not reuse or change the existing three-second recovery deadline. The 15-second boundary is unit-tested - but remains subject to mobile-network measurement. When - admission has no eligible peer path, or recovery is - exhausted, select the SFU root plan without first advertising TURN to every - peer edge. A future ordinary coturn transport, if a separate complete change - accepts it, may issue a grant only to the selected exceptional peer edge after - UDP is exhausted. Stock LiveKit participant-wide TURN remains a distinct - candidate and does not satisfy this selected-edge issuance target unless the - integration isolates or extends it. + but remains subject to mobile-network measurement. In an enabled exact-room + canary, the peer connection has already gathered + direct and relay candidates and standard ICE may select relay during these + attempts. When admission has no eligible peer path, or recovery is exhausted, + select the SFU root plan. Stock LiveKit participant ICE remains a distinct + domain. 3. The server computes revision `R+1` without mutating the active topology. It sends a prepare plan only to the host and required fallback roots. 4. When fallback is configured, host and viewer clients have already made one @@ -381,17 +392,17 @@ Sub-second failure recovery is a target after failure detection. The current plane needs a small 100-200 ms liveness/queue signal or an equivalent native transport event. This signal must be measured before its interval is fixed. -## SFU-First UDP Transport Migration +## Built-In Peer ICE TURN And SFU Migration The accepted product direction supersedes the earlier dual-TURN shadow candidate as the preferred central comparison. Healthy direct/peer UDP stays distributed. When no such path can satisfy admission or recovery, one SFU publication feeds one or two roots, which keep their bounded peer descendants. -The current executable ladder ends in bounded failure and has no TURN config or -wire. Optional selected-edge TURN remains a future conditional PR, not an unused -runtime tuple. LiveKit participant-wide embedded/external TURN is likewise not -configured by current production and would not be assignment-level -issuance. +Production still ends in bounded failure and has no TURN config. Current source +adds the default-off exact-room built-in Peer ICE tuple and wire described +above. It is participant-session bearer issuance, not assignment-level coturn +enforcement. LiveKit participant-wide embedded/external TURN is likewise not +configured by current production and remains a separate ICE domain. The bounded cost model, privacy-safe ICE fields, and exact-room A/B sequence live in [Low-Server-Cost Media Routes](../research/low-server-media-routes.md). @@ -406,14 +417,14 @@ ingress/egress, root NIC, and exception NIC hop remains real traffic. Migration is gated, not optional design debate. Production has entered a shared-IP exact-room smoke while the old release remains a separate rollback -instance. Room `1` must still verify direct/peer UDP, SFU/UDP, and bounded failure -with all UDP blocked; the current process cannot host rooms that require the old -all-room TURN wire. The matrix covers CGNAT, double NAT, mobile hotspot, +instance. Room `1` must still verify direct-selected peer, relay-selected peer, +SFU/UDP, and bounded failure with all UDP blocked; the current process never +restores the old all-room TURN wire. The matrix covers CGNAT, double NAT, mobile hotspot, ordinary home networks, root departure, reconnect, SFU unavailable, and rollback. It records CPU seconds/GiB, NIC bytes/pps, RSS, host upload, p95/p99 forwarding latency, loss/recovery, final quality, host edges at most two, SFU roots at most two, and -unchanged healthy subtrees. Config parsing and wire are already STUN-only; only -after that gate may the controller broaden beyond room `1` or retire rollback relay listeners. +unchanged healthy subtrees. Non-allowlisted and capability-absent sessions stay +STUN-only; only after that gate may the controller broaden beyond room `1`. A peer root re-publishing its received stream to the SFU while also feeding peer descendants remains a separate bounded candidate. The current browser @@ -445,8 +456,9 @@ record which boundary is actually configured and the UI must not claim E2EE. 5. Integrate host/viewer first-frame switching while retaining relay children. 6. Extend the tracked `1/3/5/8` benchmark with automatic fallback, peer/SFU UDP, bounded UDP-blocked failure, source/profile/pause/stop, and server-egress - measurements; only after those pass, add the 20-viewer gate before changing - the room default. Any future selected-edge TURN has its own separate gate. + measurements; separately measure idle TURN allocation/RSS/ports and selected + relay bandwidth. Only after those pass, add the 20-viewer gate before changing + the room default. ## Acceptance Gates @@ -468,12 +480,13 @@ record which boundary is actually configured and the UI must not claim E2EE. but is not the final flagship deployment target. - In one process, non-allowlisted rooms preserve ordinary P2P route fields, directed signaling, quality rejection, stop/reconnect/delete semantics, and - remain isolated from allowlisted peer/SFU state. All rooms receive the same - process-wide STUN-only ordinary ICE contract; no old-release TURN branch exists. + remain isolated from allowlisted peer/SFU state and receive STUN-only ICE. + Capability-absent clients, including Native v2, also remain STUN-only. Only a + capable session in an allowlisted room may receive TURN credentials and expiry. - LiveKit/SFU UDP must pass CGNAT, double-NAT, hotspot, home-network, loss, rollback, and SFU-unavailable gates. Blocked UDP fails clearly within a - bounded window. A future selected-edge TURN or media TCP implementation needs - a separate ADR/PR and never becomes a quality claim. + bounded window even when TURN/UDP is enabled. Media TCP remains a separate + decision and TURN availability never becomes a quality claim. ## Consequences @@ -482,8 +495,9 @@ Positive: - Users do not select or understand a topology. - Host fanout remains bounded while server media egress is paid only for fallback roots and separately admitted exceptional viewers. -- The ordinary peer path is direct/UDP with STUN discovery; current production - has no TURN media cost or fallback. +- The ordinary peer path remains direct-first under standard ICE; current + production has no TURN media cost, and source confines the optional relay + candidate to an exact-room capability gate. - Revisioned prepare/commit isolates stale asynchronous results without a continuous optimizer. @@ -498,6 +512,9 @@ Negative: An unconfigured client pays neither cost. - A media liveness signal and versioned cross-client transition expand the state space and require real failure-injection tests. +- Enabling built-in Peer ICE TURN creates unselected allocations for configured + peer connections; relay ports, coturn memory, refresh traffic, quotas, and any + selected media bandwidth must be measured on the 1-GiB host. ## Relationship To Existing ADRs diff --git a/docs/adr/0006-fixed-high-native-sender-canary.md b/docs/adr/0006-fixed-high-native-sender-canary.md index 88390c0..f2b7225 100644 --- a/docs/adr/0006-fixed-high-native-sender-canary.md +++ b/docs/adr/0006-fixed-high-native-sender-canary.md @@ -72,9 +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. 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. +6. Keep this candidate STUN-only and capability-absent. Built-in Peer ICE TURN + is a Web exact-room canary; any Native opt-in requires its own 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/deployment.md b/docs/deployment.md index af83fea..5c46d75 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -11,8 +11,9 @@ through direct or peer edges whenever those paths work. A deployment may additionally provide one single-node LiveKit process as the current controller's automatic final media fallback. This capacity is dormant unless the complete `LIVEKIT_URL`/key/secret tuple is configured. It does not replace the P2P path, -the peer-assisted experiment, or required STUN discovery. It is configured with -ICE/UDP only; the candidate has no ordinary TURN or media-TCP configuration. +the peer-assisted experiment, or required STUN discovery. LiveKit remains +ICE/UDP only. Source also contains a separate default-off built-in Peer ICE +TURN/UDP canary; production and the tracked coturn example remain STUN-only. Production `d4bc421828c4` runs this candidate for exact room `1` on the existing shared public IP. Immediate rollback to `05f98d10ecd1` uses the current v2 @@ -85,29 +86,34 @@ direct/peer UDP first, then an SFU virtual parent feeding normally one or two roots, whose peer descendants continue carrying media. Multiple exceptional viewers that cannot attach behind a healthy root may consume additional server egress only under a separate explicit cap. -The repository candidate requires STUN but has no TURN URL/secret/TTL variables, -credential issuer, expiry refresh protocol, LiveKit ICE/TCP listener, or -embedded/external LiveKit TURN. Ordinary authenticated ICE contains only STUN -servers. LiveKit participants receive only revision-bound `sfu-config` URL/token +The repository requires STUN. It optionally accepts a complete +`PEER_ICE_TURN_*` tuple and issues short-lived coturn REST bearer credentials +only to capability-enabled Web sessions in an exact allowlisted room. Their +peer connections use `iceTransportPolicy: "all"`, so standard ICE candidate +priority prefers direct paths and can select TURN/UDP within the same PC. A PC +failure still owns the existing restart, rebuild, alternate-peer, then SFU +ladder. LiveKit participants receive only revision-bound `sfu-config` URL/token messages and negotiate within LiveKit's separate ICE domain. -`PEER_ASSISTED_ROOM_IDS` remains an exact-room topology boundary, but ordinary -STUN-only ICE is process-wide. An unlisted room keeps ordinary P2P routing, not -the old TURN wire. Run candidate rooms on an isolated instance/hostname and keep +`PEER_ASSISTED_ROOM_IDS` is also the Peer ICE TURN boundary. An unlisted room or +a client without the explicit capability gets STUN-only ICE and keeps ordinary +P2P routing. Run candidate rooms on an isolated instance/hostname and keep the old release unchanged for rollback. If the candidate fails, roll back the release or instance; do not add a permanent dual-transport branch. A shared-public-IP instance can test candidate behavior while the old TURN service stays live, but it cannot prove the clean-port boundary or approve broad migration. The room-1 smoke uses this shape: nginx owns 443, LiveKit 1.13.5 owns -UDP 7882, ordinary ICE is STUN-only, and LiveKit is fail-closed under a 192 MiB +UDP 7882, Peer ICE TURN is disabled, and LiveKit is fail-closed under a 192 MiB high/256 MiB hard cgroup limit with restart disabled. Prefer a separate VM/IP for the full gate and verify representative external networks and devices. -Optional selected-edge TURN is not supported by this candidate. If later network -evidence justifies it, configuration, generation-bound grants, client wire, -tests, and deployment must arrive together in a separate PR. HTTPS/WSS always -remains TLS/TCP independently. +The application-side TURN slice does not change coturn or production by itself. +TURN REST authentication proves only HMAC and expiry to coturn; the temporary +credential is an App-authorized participant-session bearer and may be reused by +its holder until expiry. Coturn cannot verify a room, route, edge, or connection +generation. Never describe it as a cryptographic selected-edge restriction. +HTTPS/WSS always remains TLS/TCP independently. ## Production application environment @@ -148,6 +154,26 @@ LIVEKIT_API_SECRET= MAX_SFU_ROOTS_PER_ROOM=2 ``` +To make built-in Peer ICE TURN available to capability-enabled Web clients in +the same exact rooms, provision authenticated coturn separately and add the +complete independent tuple: + +```dotenv +PEER_ICE_TURN_URLS=turn:turn.example.com:3478?transport=udp +PEER_ICE_TURN_SHARED_SECRET= +PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS=600 +``` + +All three values must be present together, require +`PEER_ASSISTED_MEDIA=true`, and accept a TTL from 300 through 1800 seconds. +The first canary accepts exactly one explicit `turn:` UDP URI with +`transport=udp`. The secret +must not reuse Host admission or LiveKit credentials. Omitting the tuple keeps +authenticated ICE snapshots and every peer STUN-only. The Web client refreshes two minutes +before expiry with a 30-second skew floor; the server reuses a current session's +grant until that window and rate-limits refresh requests to one per five seconds. +Credentials never enter URLs, logs, browser persistence, room rows, or SQLite. + `PEER_ASSISTED_ROOM_IDS` is the required deployment canary boundary whenever `PEER_ASSISTED_MEDIA=true`. Its non-empty value is a comma-separated set of exact positive numeric room IDs, using the same @@ -156,8 +182,8 @@ leading zeroes, and malformed IDs fail startup. A non-empty allowlist requires `PEER_ASSISTED_MEDIA=true`. Only listed rooms receive peer-assisted authentication, routing, room quality state, or optional LiveKit fallback; every other room keeps ordinary P2P route fields and signaling behavior. The -ordinary STUN-only ICE snapshot is process-wide; unlisted rooms do not retain -the old TURN contract. Omitting or blanking the variable fails startup rather +optional Peer ICE TURN snapshot is confined to capable sessions in listed +rooms; unlisted rooms remain STUN-only. Omitting or blanking the variable fails startup rather than enabling every room. There is no browser control, percentage rollout, or all-room fail-open. @@ -189,10 +215,14 @@ the application, and verify that its authenticated message contains `mediaMode: "peer-assisted"` while a second non-allowlisted room contains none of `mediaMode`, `qualitySettings`, `routeRevision`, `routeAssignment`, or `sfuStandbyUrl`. Exercise join, offer and -answer, stop, reconnect, and room deletion in both rooms. Both rooms receive -STUN-only ordinary ICE. Roll back by directing traffic to the unchanged old -release; disabling `PEER_ASSISTED_MEDIA` only disables topology/SFU routing and -does not restore the removed TURN wire. After acceptance, retire or replace the +answer, stop, reconnect, and room deletion in both rooms. With the optional +tuple configured, only the capable Web session in the listed room receives a +TURN group and expiry; the other room and Native-shaped clients remain +STUN-only. Roll back by removing the complete Peer ICE TURN tuple before +restoring coturn `stun-only`, or direct traffic to the unchanged old +release. Do not treat disabling `PEER_ASSISTED_MEDIA` alone as rollback: remove +its dependent TURN tuple and exact-room allowlist together, and no removed old +TURN wire is restored. After acceptance, retire or replace the temporary exact-room gate in a separate coherent change; never clear the value to trigger an implicit all-room rollout. @@ -335,6 +365,11 @@ any key is present, including with an empty value. Remove those obsolete keys from the candidate environment; startup fails instead of pretending they enable compatibility. +The replacement names `PEER_ICE_TURN_URLS`, +`PEER_ICE_TURN_SHARED_SECRET`, and +`PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS` form a distinct complete tuple; the old +keys above remain rejected and are not aliases. + ## HTTPS and WSS ingress Terminate public TLS at Caddy or nginx and proxy every application path to the diff --git a/docs/project-memory.md b/docs/project-memory.md index c2e099f..3202619 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -6,8 +6,8 @@ Last updated: 2026-08-20 - Build low-latency game sharing for one broadcaster and trusted friends; public or large broadcasts belong on OBS/Twitch-class services. - Viewers join from normal desktop/mobile browsers. A later native sender may share encoding and improve capture/audio without changing the Web viewer requirement. -- Use a small central service for access, rooms, signaling, deterministic topology, STUN, observability, and bounded SFU-root capacity; TURN is optional extreme-network transport. -- Minimize server bandwidth. The accepted invisible ladder is direct/peer UDP, then SFU/UDP roots, then optional TURN for a selected exceptional edge, then bounded failure. TURN is transport, not topology. +- Use a small central service for access, rooms, signaling, deterministic topology, STUN, observability, and bounded SFU-root capacity. +- Minimize server bandwidth. Peer ICE is direct-first; an exact-room, default-off tuple adds expiring authenticated TURN/UDP to capable Web PCs. Failure tries restart, rebuild, alternate peer, SFU, then bounded failure. TURN is transport, not topology. - Non-server nodes have at most two downstream edges; browser relays stay at one until resource gates pass. SFU normally feeds one or two roots that retain peer descendants; separately capped server edges may serve exceptional viewers that cannot attach behind a healthy root. - Two-tree packet/layer striping may reduce endpoint upload toward one stream bitrate, but needs a bounded multi-parent, loss, sync, churn, and latency experiment; it is not in the current full-stream chains. - Direct/peer keeps per-PC stock GCC. SFU paths start at a `HIGH` ceiling and share at most one `LOW`; next test LiveKit exactly-two built-in BWE on zero-descendant leaves. If it passes, no app media selector is built. Explicit quality, manual activation, then custom/native are later fallbacks; always-on `LOW` needs resource gates. @@ -25,25 +25,25 @@ Last updated: 2026-08-20 - Keep experiments bounded: the standard representation sequence is below; native RTP relay, encoded-object striping, and FEC remain separate. - 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. +- ADR-0005 accepts direct-first Peer ICE and bounded SFU roots. Its default-off exact-room slice is undeployed; production/coturn stay STUN-only. PR #12/selected-edge 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. - Quality reparenting needs three correlated C+B hard-bad windows and reuses the local-subtree peer/SFU/failure intent. Single-sided reports are diagnostic; one room cooldown blocks churn. No score, timer, or global parent penalty. - Flagship media is UDP; HTTPS/WSS stays TLS/TCP; the old release remains rollback-only. - Treat settings as ceilings and degradation as unclassified. Use correlated Host A+B/Viewer C and one-variable evidence; never force AV1, infer by UA, or create a composite score. - Production reports poor film audio and self-echo when system capture includes voice software. Diagnose audio A/B/C and sync; Web cannot isolate arbitrary processes and `maxBitrate` is not quality-up. A Windows 11 native candidate defaults to game-process-tree audio and never widens silently; Windows 10 remains unresolved/unsupported. See `docs/research/browser-screen-audio-quality.md`. -- Candidate UI includes Viewer-local names and an opt-in Web Host online roster without changing media fanout. Host names, Viewer-side roster, endpoint details, and RTP loss remain pending. +- Candidate UI has Viewer-local names and an opt-in Web Host roster without media changes. Host names, Viewer roster, endpoint details, and RTP loss remain pending. - Do not add scene detection, dynamic-FPS control, or forced AV1 without negotiation, encode, game, CPU/GPU, and sender evidence. -- ADR-0002 access is deployed. Candidate names/presence are session-only and add no account, member, session, or roster table. +- ADR-0002 access is deployed; names/presence are session-only with no account, member, session, or roster table. - Deferred architecture audit: `docs/maintenance.md`. ## Current Implementation - The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn. - Production `d4bc421828c4b74f55195723aace290ffc0e5f9d` deployed at 2026-08-20 11:41:22 +08 to `https://share.bonfire.icu`. Persistent room `1` alone enables peer/SFU routing and C+B-correlated local quality reparenting; unlisted rooms remain ordinary P2P. Immediate rollback is `05f98d10ecd1` with the current v2 DB/env. -- Ordinary ICE is process-wide STUN-only. Room `1` may use one LiveKit publication feeding at most two roots while retaining peer descendants; real 1/2-root media and game quality remain unverified. +- Production ICE is STUN-only. Source adds expiring STUN+TURN only for capable Web sessions in exact rooms; other rooms/Native stay STUN-only. Room `1` may feed two LiveKit roots with peer descendants; real relay/SFU media and game quality remain unverified. - Production requires an independent `HOST_ADMISSION_PASSWORD` only for creation/Host role. Default private fragment grants and explicit public-watch authorize Viewers; four anonymous Chrome routes stayed neutral until authorization. There are no accounts/JWT/session rows; SQLite v2 still contains all four rooms, including room `1`. - Production uses nginx, Node.js 24.19.0, coturn 4.17.2 for STUN/rollback, and pinned LiveKit 1.13.5 on UDP 7882 with TCP fallback disabled. Screener/LiveKit `NRestarts` remain 0/0; health and `index-YdNLg2E8.js` return 200. They used 39,198,720/79,269,888 bytes with zero cgroup high/max/OOM events at 11:44:15. No real room triggered quality reparenting. Public TCP 7880/7881 stays blocked; LiveKit keeps its 192/256 MiB cgroup boundary and restart disabled. -- Old TURN artifacts remain absent. Rolling back admission policy requires `89e6d7649169` plus the recorded environment backup; only pre-access `9610032` pairs with v1 SQLite. +- Old all-room TURN artifacts remain absent. The independent, default-off, undeployed `PEER_ICE_TURN_*` tuple is not an alias. Admission rollback requires `89e6d7649169` plus its environment backup; only pre-access `9610032` pairs with v1 SQLite. - ADR-0005 is configured with exact `PEER_ASSISTED_ROOM_IDS=1`; unlisted rooms use ordinary P2P. `screener-v2` is the only deployed signaling literal; there is no v1 parser or translator. - Production logs show two root participants and two short Host participants with no service restart or retained track. Their timing is consistent with, but does not directly prove, one fresh-grant retry and Peer failback. The Host exposes only the local current-revision failure stage (`connect|source|video-publish|sender-config|audio-publish|transport`) and uploads no raw error or endpoint data. Browser relays still re-encode; native capture/hardware remains outside validated production behavior. - The retained v1 backup is the only schema rollback path. A failed artifact attempt exposed unsafe hard-linked dependency reuse; the successful immutable release has zero shared regular-file inodes with rollback, and future releases preserve that boundary. @@ -52,7 +52,7 @@ Last updated: 2026-08-20 - Chrome 151 synthetic topology/quality-control runs kept fanout 2/1 and all viewers decoding; one relay close recovered in 5.32 seconds. This is control evidence only. - Room `1` publishes `HIGH+LOW` with Dynacast off and subscriber `HIGH` ceilings, and now has bounded local quality reparenting. Both remain unverified on real media; test zero-child leaves and calibrated C+B loss before broad rollout. - Generation-bound C+B needs three hard-bad windows; parent samples are one-use per connection. Pending SFU binds intent/guard. Stale/successful peer work releases quality exclusion; real failure may take it over. It uploads no raw endpoint metadata and computes no score. -- The undeployed Web-only name/presence candidate leaves Native wire unchanged and keeps online Viewer sessions separate from Host media diagnostics; focused checks pass. +- Undeployed Web name/presence leaves Native wire/media unchanged and separates Viewer roster from Host diagnostics; focused checks pass. ## Provisional Quality Targets @@ -67,7 +67,7 @@ These are measurement gates, not performance claims. - Sustainable count by hardware, quality, network, and route: finish instrumented `1/3/5/8`, then pass a 20-viewer matrix before changing the accepted target default to 20. - Whether ADR-0004 passes fanout, re-encoding, depth-four latency, reparenting, silent-partition, and mobile-leaf gates. -- Whether measured restrictive-network demand justifies a future selected-edge TURN grant at all, and which UDP transport/port it would use. The current candidate does not retain dormant TURN or media-TCP configuration. +- Whether Peer ICE TURN passes direct/forced-relay, mobile, expiry/reconnect, 1/3/5/8 allocation/resource, 1/2 relay-bandwidth, and 1-GiB gates. Coturn deployment/ports remain unselected; media TCP is out of scope. - Exact mobile lifecycle behavior and whether Windows per-application audio is required for the first release. - Project license and distribution model, which determines whether GPL/AGPL sources can move beyond study-only use. - Initial deployment regions and expected mainland China, Hong Kong, and overseas network mix. diff --git a/docs/research/advanced-peer-distribution.md b/docs/research/advanced-peer-distribution.md index fbb83aa..05000b3 100644 --- a/docs/research/advanced-peer-distribution.md +++ b/docs/research/advanced-peer-distribution.md @@ -342,7 +342,7 @@ The enabled SFU is normally a virtual parent for at most `R=2` roots, which retain bounded peer descendants. A viewer that cannot attach behind any healthy root may be separately admitted only under the explicit `E`/central-egress cap; this is an exceptional compatibility budget, not unbounded whole-room fanout. -The SFU/UDP and optional selected-edge transport accounting lives in +The SFU/UDP and optional built-in Peer ICE TURN transport accounting lives in [Low-Server-Cost Media Routes](./low-server-media-routes.md). ADR-0005's current controller remains failure-only, while its accepted target also handles admission with no eligible peer path; neither changes the sticky local diff --git a/docs/research/built-in-peer-ice-turn.md b/docs/research/built-in-peer-ice-turn.md new file mode 100644 index 0000000..c2c9515 --- /dev/null +++ b/docs/research/built-in-peer-ice-turn.md @@ -0,0 +1,265 @@ +# Built-In Peer ICE TURN Candidate + +- Research date: 2026-08-20 +- Scope: optional authenticated TURN on ordinary peer `RTCPeerConnection` + values in one exact-room canary +- Status: default-off source candidate implemented; no coturn rollout or deployment + +## Result + +The smallest standard-WebRTC fallback is to give each participant a short-lived +STUN-plus-TURN `RTCConfiguration` when it authenticates. Each peer connection +then gathers host, server-reflexive, and relayed candidates with +`iceTransportPolicy: "all"`. ICE prefers a working direct candidate pair and +can select the relay pair without an application-owned transport transition. +Only after that peer connection fails does Screener need its existing bounded +recovery ladder: one ICE restart, one same-parent rebuild, one alternate peer, +then the SFU virtual parent. + +This is substantially smaller than an application-selected edge grant. The +owning requirements and ADR now accept it only behind the existing exact-room +allowlist and a complete default-off deployment tuple. It does not add another +router state machine or broaden TURN to non-allowlisted rooms. + +## Current-Code Boundary + +At `f806689896db9719509b0e0695d741daa6ecb364`: + +- `authenticated.iceConfig` is STUN-only. Its schema accepts neither TURN URLs + nor usernames, credentials, or expiry. +- `HostPeer`, `ViewerPeer`, and `ViewerRelay` already support + `RTCPeerConnection.setConfiguration()` through `updateIceConfig()`. +- Host and Viewer authentication handlers already replace the retained ICE + config and update every live peer after signaling reconnect. +- `ViewerPeer` owns the 15-second initial checking deadline, one ICE restart, + and one same-parent rebuild. `HybridMediaRouter` then owns alternate-parent + selection and SFU preparation. +- `TURN_URLS`, `TURN_SHARED_SECRET`, and + `TURN_CREDENTIAL_TTL_SECONDS` are rejected removed configuration. They must + not be reused for a new contract. +- The tracked coturn service is `stun-only`, UDP-only, and has no auth secret, + allocation quota, or relay port range. + +The built-in candidate therefore needs no new router state, route revision, +assignment, parent/viewer synchronization, or connection-ID protocol. + +## Standards Behavior + +RFC 8445 defines candidate gathering from configured STUN and TURN servers. A +TURN Allocate request creates a relayed candidate and consumes a relay address +even when that candidate is never selected. Its recommended type preferences +are host `126`, peer-reflexive `110`, server-reflexive `100`, and relay `0`, so +the ordinary checklist prefers direct candidates over a relay candidate. +Actual browser nomination and timing still require a canary measurement. + +The WebRTC specification says that replacing `iceServers` with +`setConfiguration()` takes effect at the next gathering phase; an application +that needs it immediately performs an ICE restart. Consequently, a credential +refresh should update healthy peer connections without restarting them. Their +next existing recovery attempt will gather with the fresh credentials. + +Coturn implements the TURN REST credential convention: + +```text +username = : +credential = base64(HMAC-SHA1(shared-secret, username)) +``` + +The browser receives only the temporary username and credential. The shared +secret remains in the application and coturn service configuration. Credential +expiry prevents a new allocation with that credential; it is not immediate +revocation of an allocation that already exists. Allocation quotas and +application admission remain necessary cost boundaries. + +## Minimal Config And Wire + +Use new names that describe the actual all-peer-ICE behavior and cannot be +confused with the deleted all-room contract: + +```dotenv +PEER_ICE_TURN_URLS=turn:turn.example.com:3478?transport=udp +PEER_ICE_TURN_SHARED_SECRET= +PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS=600 +``` + +The tuple is optional and default-off, but partial configuration fails startup. +The shared secret is at least 32 independent bytes and must differ from Host +admission and LiveKit credentials. The first canary accepts exactly one explicit +TURN UDP URI without embedded username, password, fragment, or unrelated query +parameters. It issues TURN credentials only for explicitly allowlisted +peer-assisted rooms; every other room retains STUN-only ICE. + +The initial authenticated message remains the primary config response: + +```ts +type PeerIceConfig = { + iceServers: Array< + | { urls: string | string[] } + | { + urls: string | string[]; + username: string; + credential: string; + } + >; + turnCredentialsExpiresAt?: string; +}; +``` + +Add exactly one refresh request and response: + +```ts +type RefreshIce = { type: "refresh-ice" }; +type IceConfigUpdated = { type: "ice-config"; iceConfig: PeerIceConfig }; +``` + +`refresh-ice` has no room, participant, edge, or expiry supplied by the client. +The current authenticated WebSocket session is the sole authority. The server +rejects it unless that session opted into the capability and belongs to an +exact allowlisted room with the tuple enabled. Its initial authenticated +snapshot remains STUN-only otherwise. + +The participant session, not the media router, owns issuance of the bearer. +`SocketState` retains only its latest in-memory config, expiry, and refresh +window. The opaque username tag is derived from current room, peer, role, and +session identity with domain-separated HMAC; none of those values appears in +the username. No credential, username, or tag enters a URL, log, SQLite, room +row, or browser persistence. + +`SignalingClient` is the one refresh-timer owner. It requests a refresh two +minutes before a ten-minute expiry, cancels that timer on disconnect, and gets +a fresh config through normal reauthentication after reconnect. Early or +duplicate requests in the same issuance window reuse the current grant rather +than minting unbounded credentials; the server also enforces a five-second +per-session request interval. A 30-second client timer floor prevents clock +skew from creating an immediate response loop. Pages handle `ice-config` +through the same retained config and `updateIceConfig()` path already used by +`authenticated`. + +Ten minutes with a two-minute lead is a canary value, not a security optimum. +The accepted parser should keep a narrow five-to-thirty-minute range and the +deployment must keep application and coturn clocks synchronized. + +## Scope Limit + +TURN REST authentication does not cryptographically bind a credential to a +remote peer, route revision, or one `RTCPeerConnection`. Coturn validates the +expiry, HMAC, realm, and quotas. The simplest honest scope is therefore one +opaque credential per authenticated participant session, reused by that +participant's bounded peer connections. + +Per-edge cryptographic or application binding requires separate grant/rebuild +state and cannot be claimed by this built-in design. A client that receives the +bearer can reuse it until expiry; coturn cannot tell which room, edge, or peer +connection caused an allocation. App-side issuance is still bounded by short +expiry, exact-room authorization, the current WebSocket session, endpoint +fanout, and coturn quotas. + +## Resource Bound + +An `N`-viewer peer tree has `N` peer connections. If both endpoints gather one +TURN allocation for one BUNDLE/RTCP-mux component and one TURN URI, the ideal +upper bound is about `2N`, or 16 allocations for eight viewers. Multiple local +interfaces, address families, TURN URIs, or browser gathering choices can make +the real count higher. This must be measured rather than encoded as a promise. + +An unselected allocation still consumes a relay port, coturn memory, quota, and +small Allocate/Refresh/connectivity-check traffic. It does not carry continuous +media. If one `B`-bit/s media edge selects TURN, the server sees roughly `B` +ingress plus `B` egress, before headers and retransmission. At the current +8-Mbit/s recommendation that is about 16 Mbit/s of useful-payload NIC traffic; +at the 12-Mbit/s ceiling it is about 24 Mbit/s. + +For the first exact-room test, one TURN UDP URI, a 64-port relay range, and +reviewed starting quotas such as four allocations per username and 32 total +allocations give a bounded failure instead of an allocation storm. These are +test starting points, not production capacity claims. Measure coturn RSS, CPU, +allocation count, occupied relay ports, RX/TX bytes, packets per second, and +connection latency on the actual 1-GiB host before accepting them. Coturn does +not encode media, but that fact alone does not prove its memory or network +budget. + +## Coturn Deployment And Rollback + +The existing coturn process could be the first canary owner instead of adding a +second daemon. Remove `stun-only`, retain UDP listener 3478, and enable +`use-auth-secret`, an explicit realm, the untracked static auth secret, a small +relay range, and allocation/bandwidth quotas. Keep `no-tcp` and `no-tls` for the +first UDP-only measurement. Verify that unauthenticated STUN Binding still +works and that an authenticated temporary allocation succeeds before the +application issues any credentials. + +This source slice deliberately does not change or deploy coturn. A later +deployment acceptance boundary follows this order: + +1. Open only the reviewed UDP relay range and deploy authenticated coturn. +2. Verify STUN and a temporary TURN allocation without printing credentials. +3. Deploy the application tuple and exact-room issuance. + +Rollback reverses the ownership: + +1. Disable application issuance first. +2. Restore coturn `stun-only` and close the relay range. + +Existing relay allocations are intentionally interrupted by this rollback. +Healthy direct connections remain independent. TURN/UDP can work around NATs +that cannot form a peer pair while still reaching the server over UDP; it does +not help a network that blocks all UDP. TURN/TCP or TURN/TLS, especially on +TCP 443, needs its own port, certificate, shared-IP, and mobile-network gate. + +## Failure Surface Comparison + +| Boundary | Built-in participant ICE | Application-selected edge | +| --- | --- | --- | +| Core owner | signaling session and client refresh timer | router plus both edge endpoints | +| Route state | unchanged | attempt generation and old/new connection binding | +| New runtime wire | refresh request and config response | grant, parent rebuild, readiness/failure | +| Direct preference | standard ICE priority | application first exhausts a STUN-only PC | +| Idle server cost | allocations for configured peer connections | none until selection | +| Credential scope | participant session | application edge attempt | +| Main failure risks | allocation count, refresh/clock skew, unreachable TURN delaying ICE | stale grants, split endpoint state, rebuild timeout, revision races | +| Estimated core TypeScript | about 250-400 lines | about 600-1,000 lines | + +The estimates are review-planning ranges, not delivery commitments. Tests and +documentation are additional. + +## Validation Layers + +P0/P1 automated gates: + +- default-off and strict complete-tuple parsing; +- TURN URI and TTL bounds plus secret independence; +- exact-room/session-only issuance and refresh rate limiting; +- deterministic expiry/HMAC vectors without snapshotting real credentials; +- no credential in URLs, logs, persistence, errors, or non-allowlisted rooms; +- authenticated refresh updates Host, Viewer, and relay peer configs; +- stale/replaced sessions cannot refresh; +- a next ICE restart uses refreshed config while a healthy PC is not restarted; +- current restart/rebuild/alternate-peer/SFU order remains unchanged after a + peer connection reports failure. + +Acceptance canary: + +- Chrome/Edge direct selection with TURN configured and relay candidates + present; +- forced direct failure selecting TURN without an application route change; +- unreachable coturn while a direct pair remains possible; +- expired credential, refresh, reconnect, and coturn restart; +- `1/3/5/8` direct-selected allocation/RSS/CPU/latency comparison; +- `1/2` forced-relay media bandwidth, packet loss, latency, and recovery; +- Android Chrome and iOS Safari on home, hotspot, carrier, and UDP-blocked + networks, with UDP-blocked failure expected in the first UDP-only slice. + +No broad rollout or 1-GiB capacity claim follows from unit tests. + +## Sources + +Primary sources accessed 2026-08-20: + +- [ICE, RFC 8445](https://www.rfc-editor.org/rfc/rfc8445.html) +- [TURN, RFC 8656](https://www.rfc-editor.org/rfc/rfc8656.html) +- [WebRTC configuration and ICE restart](https://www.w3.org/TR/webrtc/) +- [Coturn turnserver documentation](https://github.com/coturn/coturn/blob/master/README.turnserver) +- [Coturn TURN REST API implementation](https://github.com/coturn/coturn/wiki/turnserver) +- [TURN REST API draft](https://datatracker.ietf.org/doc/html/draft-uberti-behave-turn-rest-00) + +No source code was copied. diff --git a/docs/research/low-server-media-routes.md b/docs/research/low-server-media-routes.md index 9c85123..03b5d54 100644 --- a/docs/research/low-server-media-routes.md +++ b/docs/research/low-server-media-routes.md @@ -3,10 +3,10 @@ - Research date: 2026-08-19 - Scope: one broadcaster, at most eight trusted viewers, low latency, and host media fanout at most two -- Status: ADR-0005 accepts SFU/UDP roots as the primary central fallback and - optional TURN only for exceptional edges; the repository candidate now has - STUN-only ordinary ICE plus separate LiveKit SFU/UDP, while its public - transport behavior and every non-browser data plane remain unverified +- Status: ADR-0005 accepts standard direct-first Peer ICE with optional + authenticated TURN/UDP only for capable Web sessions in exact-room canaries, + then alternate peer and bounded SFU/UDP roots; production remains STUN-only, + and public transport behavior plus every non-browser data plane is unverified ## Current Route Ladder @@ -158,51 +158,51 @@ old connection or ICE-restart history from being attributed to a new route. Use opaque generations only. This is a bounded diagnostic manifest, not a backend telemetry schema or controller input. -## SFU-First UDP And Optional Selected-Edge TURN +## Built-In Peer ICE TURN And SFU/UDP -The accepted target is `direct/peer UDP -> SFU/UDP roots -> optional -selected-edge TURN -> bounded failure`. It keeps distribution after central -fallback: the SFU normally emits only one or two root copies, and roots keep -their peer descendants. Every endpoint has at most two downstream edges; -browser relays remain at one until re-encode/resource gates pass. Multiple -exceptional viewers that cannot attach behind any healthy root may require -additional server-fed edges, but only under a separate explicit central egress -and admission cap. +The accepted target gives each bounded peer `RTCPeerConnection` standard ICE. +By default it has STUN only. In a complete default-off exact-room canary, a +capable Web participant gets STUN plus short-lived authenticated TURN/UDP under +`iceTransportPolicy: "all"`; standard candidate priority prefers direct and may +nominate relay inside the same PC. PC failure then follows one restart, one +same-parent rebuild, one alternate peer, SFU/UDP roots, and bounded failure. +The SFU normally emits only one or two root copies, and roots keep their peer +descendants. Endpoint and central egress caps remain unchanged. STUN/ICE discovers and checks paths. RFC 8656 TURN allocates a relayed address -and continuously carries media; it is not a handshake helper. The target -therefore does not advertise coturn to every ordinary peer edge. If that -compatibility service is configured, a future controller may issue short-lived, -assignment/generation-bound credentials only for an exceptional ordinary -host-to-root or root-to-viewer transport after UDP is exhausted. Other healthy -UDP paths stay unchanged. +and continuously carries media when selected; it is not a handshake helper or +topology. Configuring it can also create unselected allocations. Exact-room, +complete-tuple, and Web capability gates prevent a process-wide rollout, while +standard ICE priority leaves working direct paths selected. -Pinned LiveKit 1.13.5 includes authenticated embedded TURN, but those -credentials and relays belong to LiveKit participants and are advertised -participant-wide when configured. They can cover a publisher or subscriber -connection to the SFU; they are not Screener assignment/generation grants and -do not transparently serve an ordinary peer `RTCPeerConnection`. A selected -host/root-to-viewer TURN edge still needs independent coturn plus a new per-edge -grant/wire path. -Participant-wide LiveKit TURN is therefore only an isolated canary candidate; -unless the integration can isolate or extend it to satisfy the accepted -exceptional-edge scope, reject it rather than silently broadening the target. -LiveKit TURN cannot rescue an unavailable SFU. Independent coturn can carry a -separately authorized ordinary peer edge that bypasses the SFU, but TURN itself -does not choose or create that topology. +The application issues an opaque coturn REST bearer only from the current +authenticated participant session. Coturn validates its HMAC and expiry, not +the originating room, peer edge, revision, parent, or connection generation; +the holder can reuse it until expiry. This is App-side authorization plus TTL, +fanout, and quota containment, not cryptographic selected-edge enforcement. +The detailed wire, refresh, allocation, and rollback analysis is in +[Built-In Peer ICE TURN Candidate](./built-in-peer-ice-turn.md). -Production `769de201f7cc` remains the old baseline with coturn UDP/TCP. The -repository candidate has crossed a clean config/wire boundary: production -requires STUN, ordinary ICE snapshots contain only STUN servers, and no TURN -credential or refresh message remains. The tracked coturn example is UDP +Pinned LiveKit 1.13.5 can advertise authenticated TURN to LiveKit participants, +but that separate ICE domain covers publisher/subscriber connections to the +SFU, not ordinary peer PCs. Screener's built-in Peer ICE uses independent +coturn configuration and credentials. LiveKit TURN cannot rescue an unavailable +SFU; ordinary coturn may carry the already-assigned peer topology but does not +choose or create it. + +Production remains STUN-only. Current source accepts only the new complete +`PEER_ICE_TURN_*` tuple, not the removed all-room keys, and uses capability- +gated `iceConfig` expiry plus `refresh-ice`/`ice-config`. Non-allowlisted rooms +and Native-shaped clients remain STUN-only. The tracked coturn example is UDP `stun-only`; the LiveKit example exposes only ICE/UDP mux 7882, explicitly sets `tcp_port: 0` and `allow_tcp_fallback: false`, supplies the self-hosted STUN endpoint, and configures no external or embedded TURN. Candidate validation and rollback use isolated instances rather than a process-wide old-release compatibility branch. HTTPS/WSS remains TLS/TCP. Coturn 4.17.2 documents `stun-only` as ignoring TURN requests and provides `no-tcp` and `no-tls`; it marks `no-dtls` deprecated, so the tracked candidate -does not use that switch. Coturn remains the explicit self-hosted STUN owner -without a TURN allocation surface or reliance on public Google STUN. +does not use that switch. Coturn remains the production STUN owner without a +TURN allocation surface; the application source slice alone does not change or +deploy it. No public port is selected by this decision. LiveKit documents ICE/UDP mux as optional and its pinned sample recommends a multi-port UDP mux range at least @@ -235,13 +235,13 @@ One bounded exact-room gate owns rollout evidence: 8 and 12 Mbps with one and two roots. Record CPU seconds/GiB, RX/TX bytes, packets/s, RSS, host upload, p95/p99 forwarding latency, loss/recovery, and final decoded quality. -2. Cover representative consumer networks on an isolated candidate with - `rtc.tcp_port: 0`, `allow_tcp_fallback: false`, and no external or embedded TURN. Verify ordinary peers and - SFU roots use UDP. Keep the old release on a separate rollback instance; - `PEER_ASSISTED_ROOM_IDS` does not preserve old TURN wire for unlisted rooms. -3. Block all UDP and show a bounded explicit failure rather than a long - pseudo-connection. Any later selected-edge TURN or media TCP implementation - is a separate complete PR and cannot be marketed as a quality path. +2. Cover representative consumer networks first with Peer ICE TURN disabled, + then with the complete exact-room tuple. Verify direct-selected peer, + forced relay-selected peer, and SFU/UDP separately. Unlisted rooms and + capability-absent clients must remain STUN-only. +3. Measure idle allocation count/relay ports/RSS/CPU/latency at 1/3/5/8 + participants and selected relay RX/TX/loss/latency at one and two edges. + Block all UDP and show a bounded explicit failure; TURN/UDP is not media TCP. 4. Exercise root departure, reconnect, SFU unavailable, and rollback. Endpoint downstream edges stay at most two, normal SFU roots at most two, separately capped exceptional server edges stay bounded, and unaffected peer subtrees diff --git a/docs/research/webrtc-p2p-screen-sharing.md b/docs/research/webrtc-p2p-screen-sharing.md index bc67727..ead5fc0 100644 --- a/docs/research/webrtc-p2p-screen-sharing.md +++ b/docs/research/webrtc-p2p-screen-sharing.md @@ -348,7 +348,7 @@ WebRTC 标准没有承诺固定毫秒延迟。工程目标必须带网络条件 1. 首版坚持 P2P-first,但明确只服务小房间;默认接入上限为八名、可配置 1 至 16,真实 1:8 测量完成前不把它写成性能承诺。 2. Web 先行,目标 Windows Chrome/Edge;把 1080p60 写成 best effort,同时提供降档。 -3. 保留当前 coturn 部署直到 ADR-0005 迁移 gate 通过。旗舰默认验证 direct/peer UDP 与 SFU/UDP roots;ordinary peer coturn 不再向房间常驻广告。LiveKit participant TURN 单独 canary,pinned participant-wide 形态不满足 selected-edge 目标,无法隔离或扩展时即拒绝。 +3. 保留当前 STUN-only coturn 部署直到 ADR-0005 迁移 gate 通过。旗舰默认验证 direct/peer UDP 与 SFU/UDP roots;可选 Web exact-room canary 从 PeerConnection 创建时下发短期 authenticated TURN candidate,由标准 ICE direct-first/relay fallback 选路。未命中房间及 Native 仍为 STUN-only;coturn 只验证 credential 与 expiry,不能验证 room 或 edge。 4. 观看端优先做成免安装响应式 Web;分享端先 Web 验证,再按捕获/音频实测升级 Electron。 5. 产品代码优先直接使用浏览器 WebRTC API;借鉴 MiroTalk BRO 和 Screego,不在许可证未定前直接 fork GPL/AGPL 代码。 6. 分享端 hard fanout 为二;一至两名 viewer 走直接 P2P,第三名及以后只通过 ADR-0004 的 bounded peer-assisted spike 验证,不得隐藏回退为更多 host 连接。 diff --git a/docs/status.md b/docs/status.md index fac7c84..96b2211 100644 --- a/docs/status.md +++ b/docs/status.md @@ -36,7 +36,7 @@ also contains a no-go Native v2 candidate, but production does not run it. - A mobile-network room-1 run produced two root participants for about 4.6 seconds and two short Host participants (about 0.46/0.27 seconds), all client-requested leaves, with zero service restarts and no retained track. This is consistent with the code's one fresh-grant retry and Peer failback, but logs cannot prove those transitions. The deployed Host now shows only the local failure stage. - Host A+B and authenticated P2P Viewer C are sanitized, generation-bound, read-only, and fail closed for stale, ambiguous, or SFU-fed evidence; no raw media metadata is retained. - SVC is `no-go-web-svc-cross-path-hardware-contract`: no direct/peer selection, cross-PC shared encode, or portable hardware proof; pinned screen share is `L1T3`. No browser run was warranted. -- HTTPS/WSS, room/WebSocket authorization, renewal, and public STUN pass. Current media has no TURN credential wire. +- Source TURN tests pass; production/coturn stay STUN-only and untested. - Draft #16/#18/#22/#23/#25/#28 passed isolated experiments only. ADR-0006 reached host setup/one encoder output, then timed out before viewer 1 decoded/rendered. - Access focused tests pass protocol/config/HTTP/storage/RoomStore/SQLite/signaling, including commit-first teardown, persistence-failure continuity, relay/SFU retirement, grant bounds, and v1 migration rollback. @@ -64,7 +64,7 @@ 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. 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. +Peer ICE TURN supersedes selected-edge but remains source-only pending an isolated coturn canary. ADR-0004 still requires a full-resolution 30-minute `1/3/5/8` network, resource, quality, latency, recovery, and browser/mobile-leaf matrix. A separate diff --git a/docs/方案设计.md b/docs/方案设计.md index 46a1c36..a39c6cd 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -2,7 +2,7 @@ ## 2026-08-18 14:45 -状态:首个 PoC 基线及其已接受扩展的当前设计。Production `d6c8aa0` 已使用 STUN-only ordinary ICE,并只为 room `1` 启用 exact-room peer-assisted 与 LiveKit SFU/UDP;`769de201f7cc` 与 coturn relay 仅保留为隔离回滚路径,二者不是同进程兼容分支。 +状态:首个 PoC 基线及其已接受扩展的当前设计。Production 仍使用 STUN-only ordinary ICE,并只为 room `1` 启用 exact-room peer-assisted 与 LiveKit SFU/UDP;current source 新增默认关闭的 exact-room built-in Peer ICE TURN/UDP canary,但未修改 coturn 或部署。旧 release 与 coturn relay 仅保留为隔离回滚路径,不是同进程兼容分支。 ## 目标与边界 @@ -30,7 +30,7 @@ | 可选房间持久化 | Node.js 内置 `node:sqlite` | 单文件、无额外依赖;只保存房间 ID、Host token 摘要与 nullable Viewer grant 摘要 | | 运行时校验 | Zod | WebSocket 和 HTTP 输入不可信,需要与 TypeScript 类型一致的运行时边界 | | 静态文件 | `sirv` | 生产环境由同一 Node 进程提供前端构建结果,避免引入完整 Web 框架 | -| 地址发现 | coturn 4.17.2 或更新补丁版本的 `stun-only` 模式 | 自托管 UDP STUN,不启用 relay、credential 或媒体 TCP | +| 地址发现/可选兼容 | coturn 4.17.2 或更新补丁版本 | tracked/production 保持 `stun-only`;独立部署验收后,exact-room canary 才可启用 authenticated TURN REST、UDP relay range 与 quotas,不启用媒体 TCP | | 测试 | Vitest | 覆盖纯逻辑和 Node 集成测试;真实媒体能力另做浏览器/网络矩阵 | | TLS 入口 | Caddy 或 nginx,由部署者选择 | 统一 HTTPS/WSS;不把证书管理写进应用进程 | @@ -151,7 +151,7 @@ Zod 在服务边界拒绝未知类型、超长字符串、非法 SDP/candidate 浏览器可返回更低设置,界面以 `track.getSettings()` 显示实际捕获结果。Current production 保留有界手动质量控制:视频 track 使用 `contentHint = "motion"`,默认采用清晰优先的 `degradationPreference = "maintain-resolution"`,并允许显式选择 balanced 或 fluid。折叠高级区将设置严格限制为 720p/1080p/1440p、15 至 60 整数 FPS、2 至 12 Mbps,以及 `maintain-resolution`、`balanced`、`maintain-framerate` 三种偏好。它们只是 ceiling 和浏览器偏好,不保证实际分辨率、帧率或码率。 -每次 `setParameters()` 都必须从当次 `getParameters()` 派生更新,然后读回 requested/applied `maxBitrate`、`maxFramerate`、`scaleResolutionDownBy` 和 degradation preference。拒绝或浏览器改写必须进入可见状态,不能只写 `console.warn`。线上已是 clarity-first bounded controls,但用户报告的严重降级尚未完成受控分类,ADR-0007 双层发布与拓扑分类也尚未实现;不得据此宣称画质提升。下一次真实质量验收仍需在同一高动态场景采样 1、2、3 名观看者的 direct/peer 与 SFU/UDP 路径;TURN 只有未来 selected-edge 实现后才单独验收。不得只提高 ceiling、强制 codec 或修改 SDP。 +每次 `setParameters()` 都必须从当次 `getParameters()` 派生更新,然后读回 requested/applied `maxBitrate`、`maxFramerate`、`scaleResolutionDownBy` 和 degradation preference。拒绝或浏览器改写必须进入可见状态,不能只写 `console.warn`。线上已是 clarity-first bounded controls,但用户报告的严重降级尚未完成受控分类,ADR-0007 双层发布与拓扑分类也尚未实现;不得据此宣称画质提升。下一次真实质量验收仍需在同一高动态场景采样 1、2、3 名观看者的 direct-selected peer、relay-selected peer 与 SFU/UDP 路径;不得只提高 ceiling、强制 codec 或修改 SDP。 PoC 不强制 codec,也不做 SDP munging。先通过 stats 记录实际协商 codec、`encoderImplementation` 和 `powerEfficientEncoder`,再依据目标机器测量决定是否调整 H.264、VP8、VP9 或 AV1 优先级。`getCapabilities()` 只证明可协商,Media Capabilities 的 supported/smooth/power-efficient 只提供候选证据;最终必须同时检查实际 outbound codec、区间 encode time、游戏 FPS、CPU/GPU encode 引擎和 sender 数量。AV1 压缩效率不能证明目标机器具有 WebRTC 硬件编码,强制后可能落入更重的软件路径。 @@ -229,16 +229,31 @@ fixed-`HIGH` -> 现有 WebRTC/一至两名 Web Viewer 的 P2 窄实验。硬件 ## 媒体传输配置 -当前仓库候选已经删除旧全房 TURN contract。`ServerConfig` 只接受 -`STUN_URLS`;已删除的 `TURN_URLS`、`TURN_SHARED_SECRET` 或 -`TURN_CREDENTIAL_TTL_SECONDS` 任一存在(包括空值)都会启动失败,避免部署 -误以为旧兼容配置仍生效。生产缺失 STUN 也启动失败。`authenticated.iceConfig` 是静态 -STUN snapshot,只含 `iceServers`,不含 TURN URL、username、credential、 -expiry 或 `relayAvailable`。信令协议也不再接受 `refresh-ice` 或发送 -`ice-config`;WebSocket 重连后的新 `authenticated` 自然取得当前 snapshot。 +当前仓库候选已经删除旧全房 TURN contract。`ServerConfig` 接受 `STUN_URLS` +以及独立的新 tuple:`PEER_ICE_TURN_URLS`、 +`PEER_ICE_TURN_SHARED_SECRET`、 +`PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS`。新 tuple 全空/缺失即默认关闭,部分 +配置、超过一个或非 UDP TURN URI、少于 32 或多于 128 visible ASCII bytes 的 secret、 +300 至 1800 秒以外的 TTL、secret 复用或未开启 exact-room peer assistance 都 +启动失败。旧 `TURN_URLS`、`TURN_SHARED_SECRET`、 +`TURN_CREDENTIAL_TTL_SECONDS` 仍 fail-fast,绝不作为 alias。生产缺失 STUN 也 +启动失败。 + +Web authenticate 可选声明严格 capability `{ peerIceTurn: true }`。只有当前 +session 命中 exact allowlist 且新 tuple 完整时,`authenticated.iceConfig` 才在 +STUN group 后增加一个带 temporary username/credential 的 TURN/UDP group 与 +`turnCredentialsExpiresAt`;其他 Web 房间和未声明 capability 的 Native v2 均 +保持原 STUN-only shape。`refresh-ice` 不接受 room、edge、revision 或 expiry, +服务端只按当前 authenticated socket 授权;窗口前复用同一 config,进入到期前 +两分钟后签发新 bearer 并发送 `ice-config`,每个 session 的请求最短间隔五秒。 +客户端 `SignalingClient` 是唯一 timer owner,并用 30 秒最小延迟避免时钟偏差造成 +立即循环,断线取消;Host/Viewer 页面只更新 retained config 并对 existing PCs 调 +`setConfiguration()`,不主动 restart,后续新 PC 或现有 staged recovery 自然使用 +fresh config。凭据不进入 URL、日志、browser storage、room row 或 SQLite。 普通 direct/peer `RTCPeerConnection` 与 LiveKit participant 是两个明确的 -ICE 域。普通边只消费 `authenticated.iceConfig`;SFU publisher/subscriber +ICE 域。普通边只消费 `authenticated.iceConfig` 并显式使用 +`iceTransportPolicy: "all"`,由标准 candidate priority direct-first;SFU publisher/subscriber 只消费 revision-bound `sfu-config {url, token}`,再由 LiveKit SDK 与服务端 协商自己的 candidates。tracked LiveKit 配置只开放 UDP mux,显式使用 `tcp_port: 0`、`allow_tcp_fallback: false` 和部署自托管 `stun_servers`,不配置 @@ -252,17 +267,19 @@ reparent,再在 root budget 内准备 SFU,最终没有可行 route 时返回 `PEER_NOT_FOUND`,不长时间维持伪连接。HTTPS/WSS 始终使用 TLS/TCP,不 属于媒体 TCP;clarity/balanced/fluid 只改变编码策略。 -旧 release `769de201f7cc` 需要 coturn TURN/UDP+TCP,目前仅作为 -独立回滚实例保留;current production 不根据 room allowlist -保留旧 release TURN 兼容分支。`PEER_ASSISTED_ROOM_IDS` 只灰度 topology/SFU route -wire,进程内所有房间的 ordinary ICE 都是 STUN-only。因此验证与回滚必须 -使用隔离 candidate/旧实例,不能假设同进程未命中房间仍有旧 transport。 +旧 release 需要 coturn TURN/UDP+TCP,目前仅作为独立回滚实例保留;current +production 不根据 room allowlist 保留旧 release TURN 兼容分支。 +`PEER_ASSISTED_ROOM_IDS` 同时灰度 topology/SFU route 与可选 Peer ICE TURN; +未命中房间及 capability-absent 客户端始终 STUN-only。因此验证与回滚必须使用 +隔离 candidate/旧实例,不能假设同进程未命中房间仍有旧 transport。 -optional selected-edge TURN 仍是未来候选,不是当前能力。只有真实受限网络 -证据证明需要时,才以单独 PR 一次性加入配置校验、短期鉴权、assignment/ -connection generation grant、限频、客户端消费、部署和负向测试。该 PR -不得恢复房间鉴权时广播的全房 credentials。LiveKit participant-wide TURN -也不能冒充这项 edge grant。 +Peer ICE TURN 是 built-in WebRTC candidate,不增加 selected-edge router wire。 +应用只向当前 exact-room participant session 授权下发短期 bearer;username 的 +opaque tag 不暴露 room/peer/session,但 coturn 只验证 HMAC 与 expiry,无法验证 +room、edge、parent、route revision 或 connection generation,也不能阻止持有者 +在 TTL 内复用。依靠短 TTL、session 授权、endpoint fanout 与 coturn allocation/ +bandwidth quota 限界,不宣称密码学 edge binding。LiveKit participant-wide TURN +仍是独立 ICE 域,不能冒充 ordinary Peer ICE。 SFU 正常只作为 virtual parent 向一至两个必要 roots 发布;可靠 root 收到 媒体后继续服务 sticky descendants。host/native root 最多两条下游媒体边, @@ -314,8 +331,8 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) - Fragment 只避免其进入 HTTP/WebSocket request-target,不能把 capability 变成非敏感数据。客户端首次读取后立即清理地址栏,只在 room-scoped `sessionStorage` 留存;服务端和代理日志不得记录 WebSocket payload、Authorization、cookie、raw grant 或响应 body,异常只能记录安全路径、状态码和分类。 - HTTP body、WebSocket payload、总连接数、未鉴权连接数、输出缓冲、房间总数、观看人数和 candidate 缓存都有硬上限;无法解析的 upgrade request-target 在未鉴权边界返回 HTTP 400,不允许异常逃逸到进程顶层。 - 应用日志不得主动记录 IP;反向代理、STUN 和 LiveKit 为提供网络服务必然可见源地址,部署时需设置最短必要留存与访问控制。 -- 自托管 coturn 必须启用 `stun-only` 及 `no-tcp`/`no-tls`,不得配置 relay port range、TURN auth secret、证书或匿名 TURN;coturn 4.17.2 已弃用 `no-dtls`,tracked 配置不再使用它。未来 selected-edge TURN 由独立设计取代这份 STUN-only 配置,而不是在当前 wire 上加回全房 credential。 -- 分享者直连会向可信观看者暴露网络地址。当前 candidate 不保留 force-relay 产品分支;未来 selected-edge TURN 若被证据接受,配置、授权、UI 和测试必须原子引入。 +- tracked/production 自托管 coturn 仍必须启用 `stun-only` 及 `no-tcp`/`no-tls`;当前源码 slice 不修改它。未来 exact-room 部署验收可在隔离配置中移除 `stun-only`,启用 `use-auth-secret`、独立 realm/secret、小 relay UDP range 与 allocation/bandwidth quotas,并先验证 STUN 与临时 credential;仍不启用匿名 TURN、TCP 或 TLS。coturn 4.17.2 已弃用 `no-dtls`,tracked 配置不使用它。 +- 分享者直连会向可信观看者暴露网络地址。当前 candidate 不提供 force-relay 产品分支;即使配置 Peer ICE TURN,`all` policy 仍由标准 ICE 优先直连。UI 和后台不得显示 bearer 或把 relay 宣称为匿名/密码学 edge 隔离。 ## 验证计划 @@ -324,7 +341,7 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) - HostAdmission 的 production 必配、8 至 128 visible-ASCII bytes/独立 secret、local-dev 显式关闭、12 小时签名 cookie、过期、篡改、cookie 属性、旧 `ACCESS_PASSWORD` fail-fast 和无服务端 session 状态; - RoomStore 的随机临时房间、TTL、Host token/Viewer grant 摘要验证、private/public policy、grant 到期/跨房拒绝、rotate/revoke 强撤销、停止分享等待、Viewer 上限和超额拒绝; - SQLite 房间库从 `1` 自增、重启恢复、v1 -> v2 单事务 locked-private 迁移、nullable 32-byte CHECK、畸形值 fail-closed、rotate/revoke 写失败原子性和数据最小化;数据库路径无 Host admission 时启动失败; -- STUN URL 严格校验、生产 STUN 必需、ordinary ICE 不含 username/credential、旧 `refresh-ice`/`ice-config` 被协议拒绝,以及 LiveKit `sfu-config` 独立边界; +- STUN/TURN UDP URL 严格校验、生产 STUN 必需、新 Peer ICE tuple 完整性/TTL/secret 独立性、issuer HMAC/expiry/opaque tag、`refresh-ice` 当前 session 授权/窗口复用与 `ice-config` strict schema,以及未命中房间和 Native-shaped capability-absent session 的 STUN-only isolation;LiveKit `sfu-config` 保持独立边界; - 单一 `screener-v2` 信令 literal、v1 旧页面 mismatch 的一次性刷新终态,以及协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制; - 质量协议的 host 授权、严格字段与数值范围、last-wins、当前/未来 relay、已配置 SFU 和普通 P2P wire 兼容;sender 的四字段 requested/applied 读回、拒绝与持续受限提示; - HTTP/WS 集成:nginx per-source admission `limit_req` 与统一错误不泄露 secret,应用不建立 IP limiter 状态;Viewer 无 Host cookie 可在 Origin/容量门下 upgrade;Host 首条鉴权同时要求 upgrade-captured admission 与 Host token,Viewer 只按 room policy;Host cookie 不绕过 private grant,grant 不能建房/Host/跨房。覆盖 private fragment/sessionStorage/replaceState、独立无 grant 访问、不含 query/Referrer/log、public code-only、public-to-private 一次性 grant,以及 rotate/revoke 后 grace/route 清理、旧 Viewer/上游媒体边关闭和 reconnect 拒绝; @@ -335,7 +352,7 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) - 同机双标签页与同 LAN direct; - 真实跨网络、同房间 direct/peer UDP + SFU/UDP root; -- CGNAT、双 NAT、移动热点和普通家宽下使用隔离 candidate,LiveKit 显式关闭 TCP fallback 且不配置 TURN,验证 host 与 roots 使用 SFU/UDP;人为封锁 UDP 时必须有界明确失败。旧 production release 留在独立实例作为回滚,不在同进程保留旧 transport; +- CGNAT、双 NAT、移动热点和普通家宽下使用隔离 candidate,分别验证 TURN 未配置时的 direct/peer 与 SFU/UDP、TURN 配置后 direct-selected 与 forced relay-selected peer;记录 1/3/5/8 participants 的空闲 allocation/relay port/RSS/CPU/latency,以及 1/2 relayed edges 的 RX/TX/loss/latency。人为封锁全部 UDP 时仍必须有界明确失败。旧 production release 留在独立实例作为回滚,不在同进程保留旧 transport; - Windows Chrome/Edge 的 tab/window/monitor 音轨有/无、同时语音通话和 video-only;Windows 11 原生候选另测 game process tree、通知、无渲染流与进程重启,Windows 10 记录明确不支持/未解决; - Android Chrome 与 iOS Safari 的播放手势、旋转、前后台和网络切换; - 仅在 Windows native/deploy P1 完成后,Android 14/API 34+ 原生 sender 才运行一至两名 Web Viewer 真机 gate:整屏、授权拒绝与撤销、旋转、锁屏停止、Wi-Fi/蜂窝切换、持续热降频、硬件 codec 读回/实测、目标 UID 已知/未知的 audio capture policy、secure/protected content、A/V sync 与资源释放;单应用窗口只在 Android 14 QPR2+ 测试; @@ -352,7 +369,7 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) 3. 完成屏幕捕获、逐观看者 PeerConnection、观看播放和手动质量档。 4. 完成连接恢复和 stats 面板;随后以 STUN-only snapshot 替换旧全房 TURN credential/refresh wire。 5. 完成 automatic peer/SFU controller 与 exact-room canary 边界。 -6. 用有界网络矩阵验收 direct/peer UDP、SFU/UDP 和明确失败;selected-edge 兼容 transport 只有出现真实缺口后才另立完整 PR。 +6. 在同一 exact-room 边界增加默认关闭的 built-in Peer ICE TURN tuple、capability、短期 bearer 与单一 refresh owner;用有界网络矩阵验收 direct-selected peer、relay-selected peer、SFU/UDP、资源成本和明确失败,coturn/production rollout 保持独立验收边界。 7. 在媒体主线阶段验收后,用一个原子 PR 迁移 Host admission、`screener-v2`、room-scoped Viewer grant 和 SQLite v2;同一部署切换客户端/服务端/环境和数据库备份,随后删除 v1 配置、wire 与测试。用户名/roster 保持独立。 8. P1 继续完成 Windows native fixed-`HIGH` staged gate 与 deployment;移动 Host 不阻断或先于它。 9. P2 只在上述门槛后运行 Android 14/API 34+ fixed-`HIGH` 原生 sender spike;失败即停,不扩展为跨平台 framework。 @@ -369,10 +386,11 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) room code 规则且不重复的 ID;缺失、空值或非法配置都启动失败,不存在全房间 fail-open。未命中房间不得进入 `HybridMediaRouter`、房间质量同步或 LiveKit 兜底,route 字段、定向信令、停止、 -重连和删除保持普通 P2P 行为;ordinary STUN-only ICE snapshot 是进程级契约, -不为未命中房间保留旧 TURN wire。该边界不增加 UI、用户选择或百分比框架。 +重连和删除保持普通 P2P 行为;未命中房间的 ordinary ICE 始终 STUN-only, +不接收可选 Peer ICE TURN config。命中房间仍要求完整 tuple 与 Web capability, +Native v2 保持 STUN-only。该边界不增加 UI、用户选择或百分比框架。 -产品硬目标是任一非服务器节点任何时刻最多两条下游媒体边。系统按一个或两个观看者直接 P2P、更多观看者 peer-assisted、部署 SFU 中央兜底的顺序自动分配和恢复,分享者与观看者不选择拓扑。默认媒体 transport 只使用 UDP;SFU 作为 virtual parent 正常只送一至两个 roots,roots 之后仍优先沿受限 peer descendants 扩散;多个无法挂到健康 root 的极端 viewers 只可使用另设上限的中央 edge。Production `d6c8aa0` 已把普通 ICE 收敛为 STUN-only、删除全房 TURN wire并只为 room `1` 开启 failure-only controller;exact-room 公网媒体仍未验收,旧 release/coturn 仅作回滚。未来可选 TURN 只能按需给异常 assignment/connection generation,由单独完整 PR 实现。ADR-0004/0005 的 broad-rollout 验收尚未完成。 +产品硬目标是任一非服务器节点任何时刻最多两条下游媒体边。系统按一个或两个观看者直接 P2P、更多观看者 peer-assisted、部署 SFU 中央兜底的顺序自动分配和恢复,分享者与观看者不选择拓扑。默认媒体 transport 只使用 UDP;SFU 作为 virtual parent 正常只送一至两个 roots,roots 之后仍优先沿受限 peer descendants 扩散;多个无法挂到健康 root 的极端 viewers 只可使用另设上限的中央 edge。Production 已把普通 ICE 收敛为 STUN-only、删除旧全房 TURN wire并只为 room `1` 开启 failure-only controller;current source 的 optional built-in Peer ICE TURN 默认关闭,只给 exact-room capable Web sessions,以标准 ICE direct-first/relay fallback,不改变拓扑。exact-room 公网媒体、coturn allocation/resource 和 forced relay 仍未验收,旧 release/coturn 仅作回滚。ADR-0004/0005 的 broad-rollout 验收尚未完成。 恢复目标按证据和成本逐级升级:短暂 `disconnected` 先观察同代 bytes/stats,`failed` 才执行一次 ICE restart;仍失败时重建一次相同 parent,再尝试不同的合格 peer parent,peer 候选耗尽后才申请 SFU。每个动作最多一次且成功即停,不实现“三次相同重试”。初始 `connecting`/`checking` 的软提示和硬窗口尚未实现;硬窗口必须先用移动网络实测确定,不能直接复用现有 3 秒恢复常量。已实现的 SFU active 失败只刷新一次短期 grant,第二次失败回到新 peer baseline。Host 只保留当前 route revision 的 `connect | source | video-publish | sender-config | audio-publish | transport` 枚举阶段作为本地事件提示,不上传原始错误、URL、token、candidate 或地址。 diff --git a/docs/需求理解.md b/docs/需求理解.md index a905ea7..8776329 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -4,7 +4,7 @@ ## 需求概述 -构建一个面向熟人游戏场景的低延迟屏幕共享工具。分享者可以在私密房间内分享游戏、应用窗口或显示器画面及声音,多名朋友可以即时观看。系统采用类似 Photon Host Mode 的产品抽象:中心服务负责房间、身份、确定性拓扑和建立连接,媒体优先级依次为小规模直接 P2P、peer-assisted 转发、部署提供的 SFU virtual parent;STUN 协助打洞,SFU 只向一至两个必要 roots 送流,roots 继续分布式服务其 peer descendants。TURN 是部署可选的极端网络 transport 兼容层,不是普通 peer 边的默认候选或独立拓扑。系统必须自动、无感地完成分配、故障恢复和必要的路线迁移,分享者与观看者不手动选择拓扑。分享端 Web 优先并允许后续 Electron/原生增强;观看端必须可通过桌面或手机浏览器打开邀请链接,不绑定专用客户端。 +构建一个面向熟人游戏场景的低延迟屏幕共享工具。分享者可以在私密房间内分享游戏、应用窗口或显示器画面及声音,多名朋友可以即时观看。系统采用类似 Photon Host Mode 的产品抽象:中心服务负责房间、身份、确定性拓扑和建立连接,媒体优先级依次为小规模直接 P2P、peer-assisted 转发、部署提供的 SFU virtual parent;STUN 协助打洞,SFU 只向一至两个必要 roots 送流,roots 继续分布式服务其 peer descendants。TURN 是部署可选、默认关闭并只在 exact-room canary 下提供的 Web peer ICE transport 兼容层,不是独立拓扑;标准 ICE 仍优先直连候选。系统必须自动、无感地完成分配、故障恢复和必要的路线迁移,分享者与观看者不手动选择拓扑。分享端 Web 优先并允许后续 Electron/原生增强;观看端必须可通过桌面或手机浏览器打开邀请链接,不绑定专用客户端。 当前 PoC 默认允许一名分享者和最多八名观看者,部署者可在 1 至 16 之间配置上限,超额观看者会被明确拒绝。已接受的自动分布式拓扑目标把默认值和可配置上限提高到 20,但只能与通过 20 人资源、质量、深度和恢复门禁的路由 release 原子启用;在此之前不得只改常量,让普通逐观看者 P2P 建立 20 条 Host 边。产品仍不承担公开直播或大规模观看,相关需求交给 OBS/Twitch 等成熟直播服务。 @@ -22,7 +22,7 @@ - [ ] 一对多观看:当前安全默认上限为 8、可配置范围为 1 至 16;自动分布式拓扑验收后,同一 release 将默认值和上限改为 20。当前 PoC 的逐观看者连接是测量基线,不再是超过两名观看者时的目标拓扑;未通过 20 人门禁时继续明确拒绝超额加入。 - [ ] 端点 fanout 硬上限:任一非服务器端点任何时刻最多维护两条 active downstream media edges,包括加入、断线、换父和恢复过程;上游接收不计入这项上传预算。当前 browser relay 因逐跳重编码仍保守限制为一个 child,只有资源矩阵通过后才可升到二。第三名及后续观看者由系统自动分配给通过验收的 peer-assisted 拓扑,不能隐藏地回退为 Host 第三条连接。没有可行 peer path 时,SFU 作为主要中央兜底 virtual parent 正常向一至两个必要 roots 送流;媒体到达 root 后其余可行观看者仍优先沿受限 peer descendants 扩散。多个无法挂到健康 root 的极端 viewer 可成为额外 server-fed 例外,但必须受显式中央 egress/admission 上限约束,不能无界整房 fanout。TURN 只作为可选的极端网络 transport 兼容层,不定义房间拓扑。 - [ ] P2P 优先:每条逻辑媒体边的 ICE 必须先尝试直接 UDP 路径,服务器不得默认中转可直连的媒体。一个或两个观看者优先直接连接分享端;更多观看者的 peer-assisted 行为须先通过 ADR-0004 的实验门槛。 -- [ ] NAT 与传输兜底:STUN 必须可用。普通 direct/peer 边只接收 STUN ICE servers;其路径不可用时改由 SFU virtual parent 承担必要 root,而不是给每条 peer edge 常驻 TURN candidates。当前可执行梯级为 `direct/peer UDP -> SFU ICE/UDP -> 明确失败`。应用配置、信令协议和客户端不得保留无消费者的全房 TURN URL、credential 或续期 wire。未来只有经受限网络证据证明必要时,才可由独立 PR 新增绑定当前 assignment/connection generation 的 selected-edge coturn grant;一个 TURN seed root 收到媒体后仍优先服务 bounded peer descendants。LiveKit publisher/subscriber 到 SFU 是另一 ICE 域,pinned 1.13.5 的 embedded/external TURN 当前按 LiveKit participant 广告,不是 Screener selected-edge grant。两者必须分开配置、鉴权和验收。当前候选不配置 LiveKit TURN、媒体 ICE/TCP、TURN/TCP 或 TURN/TLS;HTTPS/WSS 仍使用 TLS/TCP。 +- [ ] NAT 与传输兜底:STUN 必须可用。默认情况下以及所有未命中 allowlist 的房间只接收 STUN ICE servers。仅当完整、独立的 `PEER_ICE_TURN_*` tuple 与 exact room 同时启用时,声明 `peerIceTurn` capability 的 Web session 才在每条 bounded peer `RTCPeerConnection` 创建时收到短期 authenticated TURN/UDP,并固定 `iceTransportPolicy: "all"`,由标准 ICE candidate priority 自动 direct-first/relay fallback;Native v2 未声明 capability,必须继续获得严格 STUN-only wire。PC 失败后仍按一次 ICE restart、一次同 parent rebuild、一个 alternate peer、再 SFU virtual parent 的顺序恢复,任一步成功即停止。`SignalingClient` 是唯一续期 owner,提前两分钟请求;服务端只认当前 authenticated session、在窗口前复用同一 bearer,凭据不进入 URL、日志、浏览器持久化或 SQLite。TURN REST bearer 只能由应用按 room/role/peer/session 授权下发,coturn 本身只验证 HMAC 与 expiry,无法验证 room、edge、revision、connection generation,也不能阻止持有者在 TTL 内复用。当前 slice 只支持 TURN/UDP,不配置 LiveKit TURN、媒体 ICE/TCP、TURN/TCP 或 TURN/TLS;HTTPS/WSS 仍使用 TLS/TCP。 - [ ] 质量控制:保留 1080p60(8 Mbps)、1080p30(5 Mbps)、720p30(3 Mbps)三档推荐上限,默认采用清晰优先;分享期间可直接换档或应用有界高级设置,不重新选择来源或重建健康连接。Peer-assisted relay 与已配置的 SFU 必须接收同一个严格校验的房间级权威设置,覆盖当前和以后建立的 sender;普通 P2P 信令结构保持不变。任何设置都只是浏览器上限和偏好,必须读回实际 sender 参数并显示参数拒绝、改写和持续受限原因。不做自定义动态 FPS、综合评分或周期性全房间调档。 - [ ] 路径隔离双表示:direct/peer 继续使用每条 PeerConnection 独立的 stock WebRTC GCC,不把任何 viewer 反馈合成全房目标。每条 SFU path 从 `HIGH` ceiling 开始;优先用 pinned LiveKit 2.22.0/1.13.5 的标准两层 screen-share simulcast,在 Dynacast off 下常驻 exactly `HIGH+LOW`,让内建 per-subscriber BWE 独立降层和恢复。若该 gate 通过,Screener 不实现媒体层 selector。首 gate 使用 zero-descendant SFU leaf;root-with-children 的自动 downshift 与有界迁移是 default-on 前独立硬门。只有内建选择不满足门槛时才用标准 subscriber quality 显式限制,常驻成本不合格时再测标准 sender 手动启停。`LOW` 停编只是资源优化;active representation/layer 硬上限为二,禁止按 viewer 增长。必须实测实际接收层、硬件编码证据、游戏 FPS/p1 low、CPU/GPU、区间 encode cost、上传和分层 bytes。当前 Web P2P simulcast 缺少 receiver 选 RID 和跨 PeerConnection shared-encode 契约;当前 Web/LiveKit SVC 也缺少 direct/peer 独立选层与可移植硬件保证,且 pinned screen share 强制三层 temporal-only `L1T3`,二者按现契约静态 no-go。全部标准路径失败后才考虑 custom/native dual encode。没有合格 hardware/power-efficient 路径或额外表示破坏游戏/上传预算时 fail closed:保护健康路径并明确显示弱路径降级或不可用。 - [ ] 路径级质量状态:应用的 `HIGH`/`FALLBACK` 非对称窗口只决定拓扑资格和诊断,不命令正常的媒体层切换,也不计算综合分数。SFU BWE downshift 先进入 `suspect`;多个连续窗口的 sender/viewer 关联证据确认 `FALLBACK` 后,应用不得稳定保留该 parent,并把 downstream capacity 降为 0。计划或显式质量 fallback 仅是内建路径失败后的候选:已有 children 时先在 connection/assignment generation guard 下全部迁走,失败或代次过期则保持原 assignment/quality。Autonomous downshift 无法 packet-level 预先迁移,default-on 前必须验收 descendants 的短暂影响。只有更长的稳定恢复窗口和 cooldown 后才能重新声明 relay capacity。Viewer 请求必须鉴权、限频、去重,并由 sender 的 GCC/Transport-CC、实际码率、RTT、丢包、重传、编码限制、协商 codec/profile/parameters 与 viewer 解码、丢帧、冻结证据交叉验证;不能仅凭自报生效,也不得上传 raw SDP、candidate 地址或原始设备/网络标识。 @@ -37,7 +37,7 @@ - [ ] 私密房间双入口:在不引入账号、密码找回或通用服务端 session 的前提下,private room 同时接受“房间号 + 该房间观看密码”和现有 `/r/{code}#v={grant}` 私密邀请;有效 grant 直接进入目标房间,没有 grant 的 `/join` 或 `/r/{code}` 只显示中性的房间密码输入。Host 可设置或轮换房间观看密码,并可选择复制仅含房间号的入口或默认附带 room-scoped access token 的完整邀请。房间密码、grant 与 verifier 的权限都只能是该房间 Viewer,不得进入 URL query、`localStorage`、日志或 SQLite 明文,也不能取得 Host admission、建房、发布或跨房权限。失败响应不区分房间不存在与密码错误,并复用有界 admission 限制;确切 verifier/KDF 参数必须在目标 1 GiB 部署机基准后选择。该项排在当前自动路由、TURN 兜底和 Windows Native 主线之后,不得提前改写生产能力声明。 -- [ ] 实验路由按房间灰度:部署开启 `PEER_ASSISTED_MEDIA` 时必须同时提供非空、严格的房间 ID allowlist,只有指定房间进入 peer-assisted 与已配置的 SFU 路径;缺失、空值、非法或重复配置启动即失败,绝不 fail-open 为全房。未命中房间继续使用当前 ordinary P2P 拓扑及其质量拒绝、停止、重连和删除语义,但 STUN-only ordinary ICE 契约是进程级迁移,不为未命中房间保留旧 TURN wire。旧 production release 与新 candidate 必须用隔离实例验证和回滚,不能在产品代码建立长期双 transport 分支。exact-room 只服务本阶段验证;验收后以独立 coherent change 退役灰度边界,不能靠清空变量暗中放量。该能力不增加 UI、用户选择、百分比发布或第二套路由器。 +- [ ] 实验路由按房间灰度:部署开启 `PEER_ASSISTED_MEDIA` 时必须同时提供非空、严格的房间 ID allowlist,只有指定房间进入 peer-assisted、已配置的 SFU 路径与可选 Peer ICE TURN;缺失、空值、非法或重复配置启动即失败,绝不 fail-open 为全房。未命中房间继续使用当前 ordinary P2P 拓扑及其质量拒绝、停止、重连和删除语义,并保持 STUN-only。Peer ICE TURN 还要求完整新 tuple 与显式 Web capability;无 capability 的 Native/旧客户端同样 STUN-only。旧 production release 与新 candidate 必须用隔离实例验证和回滚,不能在产品代码建立长期双 transport 分支。exact-room 只服务本阶段验证;验收后以独立 coherent change 退役灰度边界,不能靠清空变量暗中放量。该能力不增加 UI、用户选择、百分比发布或第二套路由器。 - [ ] 质量控制扩展:推荐档位仍是默认入口;折叠高级区只允许 720p/1080p/1440p 分辨率 ceiling、15 至 60 的整数 FPS ceiling、2 至 12 Mbps 视频码率 ceiling,以及清晰/平衡/流畅三种偏好。直播中显式应用后才生效,避免滑杆产生连续控制流量。当前 Web 发送端不提供音频声道、采样率、Opus 码率、stereo、DTX 或 FEC 控件:display capture 未把声道和采样率定义为可移植控制,标准音频 `maxBitrate` 只是可能导致更低质量或停播的发送上限,其他项依赖 codec/fmtp 协商而没有可移植 sender setter。除非真实音频矩阵证明用户问题、标准 API 能应用并读回且实际结果可独立观测,否则不得为这些控件新增应用层 SDP munging 或用默认值制造伪控制。实际质量持续低于所选上限时必须显示可解释的限制原因;在真实拥塞、编码器和接收端证据完成前,不把任一组合称为“完美平衡”。 - [ ] 本地预览性能候选:在相同动态场景中 A/B 测量 Host 动态预览开/关的 CPU、GPU、encode time 和实际 FPS。只有稳定收益且捕获、编码和远端画面不受影响时,才允许直播开始后默认显示静止缩略图或隐藏动态预览并提供按需展开;未测前不宣称收益。 @@ -49,8 +49,8 @@ - [ ] SFU-root 中央兜底:产品部署目标必须提供经 canary 验收的单节点 SFU 资源;系统在 direct/peer UDP 不可用或明确拓扑预算无法接纳时自动把它作为主要中央兜底,不要求用户选模式。它只向总计一至两个必要 roots 出口;root 优先承担受限 peer descendants,没有可靠 relay root 时必要 viewer 只作为同一总上限内的零 descendants root。已关闭 PR #12 的显式整房 SFU 模式已被取代。Production room `1` 已开启 failure-only exact-room smoke,但尚无 retained SFU media;通过 ADR-0005 的公网媒体、资源和恢复门禁后才能扩大到默认梯级。 - [ ] 分享端桌面封装:用 Electron 固定 Chromium 版本、改善选源/热键/系统音频体验,同时保留 Web 观看端。 - [ ] Windows 应用音频:Windows 11 原生候选默认仅捕获所选游戏及其子进程的音频,避免把语音聊天或通知声一并分享;“全部系统声音”只能由分享者显式选择。普通 Windows 10 不具备当前 process-loopback API 门槛,必须明确标为未解决/不支持而不是伪兼容。浏览器无法证明或隔离任意进程音频时必须如实提示,原生捕获失败、无声或进程退出不得静默扩大为整机捕获。 -- [ ] 连接自检:在开始分享前验证信令、STUN 和 SFU ICE/UDP 可达性,并给出不暴露地址或凭据的诊断结果。当前没有 selected-edge TURN 或媒体 TCP 配置面;未来兼容层只有在完整实现后才加入自检。 -- [ ] 受限网络增强:部署者仅在明确选择覆盖 UDP 受限网络且 canary 证明收益时,才可启用短期鉴权 TURN 或媒体 TCP;它只能用于控制器选中的异常 transport,不能成为所有普通 peer 边的默认候选,也不能被描述为更高清的传输方式。 +- [ ] 连接自检:在开始分享前验证信令、STUN 和 SFU ICE/UDP 可达性,并给出不暴露地址或凭据的诊断结果。启用 Peer ICE TURN canary 时只报告脱敏的配置/可达性与 selected candidate type,不输出 URL、username、credential、地址或原始 candidate;媒体 TCP 不在当前配置面。 +- [ ] 受限网络增强:短期鉴权 TURN 只能由完整、默认关闭的 `PEER_ICE_TURN_*` tuple 与 exact-room allowlist 启用;capable Web peer PC 从创建起使用 `all` 配置,由标准 ICE direct-first 选路,未命中房间和 Native 保持 STUN-only。媒体 TCP 仍需独立 canary;两者都不能被描述为更高清的传输方式。 - [ ] 运行时编解码选择:先保留浏览器默认协商;只有端点能力相交、Media Capabilities 对目标分辨率/FPS/码率报告 supported/smooth/power-efficient,且实际 outbound codec、区间 encode time、游戏 FPS、CPU/GPU 与 sender 数量共同通过时,才调整 H.264、VP8、VP9 或 AV1 优先级。AV1 不得因压缩率推断而默认启用,任何候选都不得静默落入高负载软件编码。 - [ ] 诊断页面:用户可导出不含敏感地址和凭据的连接诊断信息。 @@ -106,8 +106,9 @@ - Given 两端网络允许 UDP 打洞,When 观看者加入,Then `getStats()` 显示选中的候选对不是 `relay`,且服务器没有承载媒体流量。 - Given direct/peer UDP 无法建立但 LiveKit SFU/UDP 可达,When 观看者加入或现有 edge 恢复耗尽,Then 系统只把必要 root 切到 SFU,保留可行 peer descendants 和未受影响路径,无需用户选择模式。 - Given 未配置 TURN 且所有媒体 UDP 路径均不可用,When 有界连接窗口耗尽,Then 界面快速显示通用连接失败,不静默黑屏、不长时间假连接,也不自动尝试媒体 TCP。 -- Given 当前 candidate 的 ordinary peer 或 SFU/UDP 路径耗尽,When 有界恢复窗口结束,Then 返回通用连接失败;应用不接受 TURN 配置、不签发 credential,也不发送 `refresh-ice`/`ice-config`。未来 selected-edge TURN 必须作为包含配置、授权、generation wire、限频、测试和部署证据的完整 PR,不能复活全房 TURN snapshot。 -- Given 部署者为 LiveKit 启用了 embedded/external TURN,When publisher 或 subscriber 的 SFU/UDP 路径耗尽,Then 只把它作为 LiveKit participant-to-SFU 兼容 transport 验收;pinned participant-wide ICE 广告不满足 Screener selected-edge issuance,除非集成可隔离/扩展到该边界,否则拒绝而非放宽目标。 +- Given 未配置完整 `PEER_ICE_TURN_*` tuple、房间未命中 exact allowlist 或客户端未声明 capability,When 该会话鉴权或请求 `refresh-ice`,Then authenticated snapshot 必须保持 STUN-only,续期请求 fail closed;不得把 TURN credential 发给 Native v2、普通房间或未鉴权会话。 +- Given exact-room Web session 已获临时 TURN bearer,When expiry 进入提前两分钟窗口,Then client 只发送无参数 `refresh-ice`,服务端用当前 session 授权并轮换;窗口前重复请求复用同一 config,现有 PC 仅 `setConfiguration` 而不主动 restart,新 PC 与后续既有恢复使用 fresh config。Given bearer 已下发,Then 明确接受 coturn 无法验证实际 room/edge 且持有者可在 TTL 内复用,依靠短 TTL、应用授权、fanout 和 coturn quota 限界。 +- Given 部署者为 LiveKit 启用了 embedded/external TURN,When publisher 或 subscriber 的 SFU/UDP 路径耗尽,Then 只把它作为 LiveKit participant-to-SFU 兼容 transport 验收;该 ICE 域与 Screener ordinary Peer ICE TURN 分开配置、鉴权和验收。 - Given 部署者另行启用并验收媒体 TCP/TLS 兼容模式,When UDP 被封锁,Then 对应路径建立或返回可诊断失败;未启用该能力不阻塞面向消费网络的旗舰验收,HTTPS/WSS 仍正常使用 TLS/TCP。 - Given 同一房间中一名观看者可以 direct/peer UDP、另一名只能通过 SFU root 接收,When 两者同时观看,Then 前者保持分布式 P2P,后者使用 SFU,双方互不迁移且 host/SFU root 上限不变。 diff --git a/native/sender/internal/remote/wire_test.go b/native/sender/internal/remote/wire_test.go index 7da0589..914bd8d 100644 --- a/native/sender/internal/remote/wire_test.go +++ b/native/sender/internal/remote/wire_test.go @@ -51,6 +51,24 @@ func TestMarshalHostSignalShapes(t *testing.T) { } } +func TestMarshalNativeAuthenticationDoesNotOptIntoWebPeerICETURN(t *testing.T) { + encoded, err := json.Marshal(authenticateMessage{ + Type: "authenticate", + Protocol: signalingProtocol, + RoomID: "1", + Role: "host", + Token: strings.Repeat("a", 32), + ClientID: "native-client", + ShareGeneration: "native-generation", + }) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "capabilities") || strings.Contains(string(encoded), "peerIceTurn") { + t.Fatalf("native authentication opted into Web-only Peer ICE TURN: %s", encoded) + } +} + func TestDecodeOrdinaryHostAuthentication(t *testing.T) { message, err := decodeServerMessage([]byte(validHostAuthenticated)) if err != nil { @@ -100,10 +118,10 @@ func TestDecodeOrdinaryHostAuthenticationRejectsInvalidVariants(t *testing.T) { } } -func TestDecodeServerMessageRejectsRemovedICERefresh(t *testing.T) { +func TestDecodeServerMessageRejectsWebOnlyICERefresh(t *testing.T) { payload := []byte(`{"type":"ice-config","iceConfig":{"iceServers":[]}}`) if _, err := decodeServerMessage(payload); err == nil { - t.Fatal("removed ICE refresh message was accepted") + t.Fatal("Web-only ICE refresh message was accepted") } } diff --git a/src/client/lib/signaling.ts b/src/client/lib/signaling.ts index 9959518..dc9a3e1 100644 --- a/src/client/lib/signaling.ts +++ b/src/client/lib/signaling.ts @@ -36,6 +36,8 @@ const INVALID_MESSAGE_CLOSE_CODE = 1008; const VIEWER_ACCESS_REVOKED_CLOSE_CODE = 4004; const PROTOCOL_REFRESH_MESSAGE = "页面版本已更新,请刷新后重试"; const TERMINAL_SEND_TIMEOUT_MS = 15_000; +const PEER_ICE_TURN_REFRESH_LEAD_MS = 2 * 60 * 1_000; +const PEER_ICE_TURN_REFRESH_MIN_DELAY_MS = 30_000; export function shouldReconnectSignaling(code: number): boolean { return ( @@ -59,6 +61,7 @@ export class SignalingClient { private reconnectTimer: number | null = null; private authenticationTimer: number | null = null; private terminalTimer: number | null = null; + private iceRefreshTimer: number | null = null; private terminalMessage: ClientMessage | null = null; constructor( @@ -141,6 +144,7 @@ export class SignalingClient { type: "authenticate", protocol: SIGNALING_PROTOCOL, ...this.identity, + capabilities: { peerIceTurn: true }, }; socket.send(JSON.stringify(authenticate)); this.authenticationTimer = window.setTimeout(() => { @@ -194,6 +198,9 @@ export class SignalingClient { } this.events.onStatus("connected"); } + if (message.type === "authenticated" || message.type === "ice-config") { + this.scheduleIceRefresh(message.iceConfig.turnCredentialsExpiresAt); + } this.events.onMessage(message); }); @@ -204,6 +211,7 @@ export class SignalingClient { this.socket = null; this.authenticated = false; this.clearAuthenticationTimer(); + this.clearIceRefreshTimer(); if (!this.stopped && shouldReconnectSignaling(event.code)) { this.scheduleReconnect(); } else if (!this.stopped) { @@ -241,6 +249,28 @@ export class SignalingClient { this.events.onTerminated(PROTOCOL_REFRESH_MESSAGE); } + private scheduleIceRefresh(expiresAt: string | undefined): void { + this.clearIceRefreshTimer(); + if (!expiresAt) { + return; + } + const refreshAtMs = Date.parse(expiresAt) - PEER_ICE_TURN_REFRESH_LEAD_MS; + this.iceRefreshTimer = window.setTimeout( + () => { + this.iceRefreshTimer = null; + this.send({ type: "refresh-ice" }); + }, + Math.max(PEER_ICE_TURN_REFRESH_MIN_DELAY_MS, refreshAtMs - Date.now()), + ); + } + + private clearIceRefreshTimer(): void { + if (this.iceRefreshTimer !== null) { + window.clearTimeout(this.iceRefreshTimer); + this.iceRefreshTimer = null; + } + } + private clearAuthenticationTimer(): void { if (this.authenticationTimer !== null) { window.clearTimeout(this.authenticationTimer); @@ -250,6 +280,7 @@ export class SignalingClient { private clearTimers(): void { this.clearAuthenticationTimer(); + this.clearIceRefreshTimer(); if (this.reconnectTimer !== null) { window.clearTimeout(this.reconnectTimer); this.reconnectTimer = null; diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index 596c3dc..a22ca36 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -1078,6 +1078,11 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { expiresAt: message.roomExpiresAt, }); setPhase("live"); + } else if (message.type === "ice-config") { + iceConfigRef.current = message.iceConfig; + peersRef.current.forEach((peer) => + peer.updateIceConfig(message.iceConfig), + ); } handleSignalMessage(message, generation, activeRoom.roomId); }, diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index 8bb9ed0..bf9eaf9 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -596,6 +596,12 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { } return; } + if (message.type === "ice-config") { + currentIceConfig = message.iceConfig; + peerRef.current?.updateIceConfig(message.iceConfig); + viewerRelay?.updateIceConfig(message.iceConfig); + return; + } if (message.type === "route-update") { if (peerAssisted) { if (message.phase === "active") { diff --git a/src/client/webrtc/host-peer.ts b/src/client/webrtc/host-peer.ts index 9c04ac3..27abcc9 100644 --- a/src/client/webrtc/host-peer.ts +++ b/src/client/webrtc/host-peer.ts @@ -86,6 +86,7 @@ export class HostPeer { ) { this.connection = new RTCPeerConnection({ iceServers: iceConfig.iceServers, + iceTransportPolicy: "all", }); this.snapshot = { peerId, @@ -256,6 +257,7 @@ export class HostPeer { try { this.connection.setConfiguration({ iceServers: iceConfig.iceServers, + iceTransportPolicy: "all", }); } catch (error) { this.setError(error, "更新 ICE 配置失败"); diff --git a/src/client/webrtc/viewer-peer.ts b/src/client/webrtc/viewer-peer.ts index a4d4d7c..27f9099 100644 --- a/src/client/webrtc/viewer-peer.ts +++ b/src/client/webrtc/viewer-peer.ts @@ -174,6 +174,7 @@ export class ViewerPeer { try { this.connection.setConfiguration({ iceServers: iceConfig.iceServers, + iceTransportPolicy: "all", }); } catch (error) { this.setError(error, "更新 ICE 配置失败"); @@ -237,6 +238,7 @@ export class ViewerPeer { const connection = new RTCPeerConnection({ iceServers: this.currentIceConfig.iceServers, + iceTransportPolicy: "all", }); this.connection = connection; this.snapshot = { diff --git a/src/server/app.ts b/src/server/app.ts index 2680cc3..60e5d2f 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -113,6 +113,7 @@ export async function createScreenerServer( peerAssistedMedia: config.peerAssistedMedia, peerAssistedRoomIds: config.peerAssistedRoomIds, ...(sfuFallback ? { sfuFallback } : {}), + ...(config.peerIceTurn ? { peerIceTurn: config.peerIceTurn } : {}), ice: iceOptions, allowedOrigins: config.allowedOrigins, hostAdmissionAtUpgrade: (request) => diff --git a/src/server/config.ts b/src/server/config.ts index 51f700c..8b774e0 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -3,6 +3,7 @@ import { MAX_VIEWERS_PER_ROOM_LIMIT, roomCodeSchema, stunUrlSchema, + turnUrlSchema, } from "../shared/protocol.js"; export type RuntimeEnvironment = "development" | "test" | "production"; @@ -10,6 +11,11 @@ export type RuntimeEnvironment = "development" | "test" | "production"; const MIN_HOST_ADMISSION_PASSWORD_BYTES = 8; const MAX_HOST_ADMISSION_PASSWORD_BYTES = 128; const MIN_LIVEKIT_API_SECRET_BYTES = 32; +const MIN_PEER_ICE_TURN_SECRET_BYTES = 32; +const MAX_PEER_ICE_TURN_SECRET_BYTES = 128; +const MIN_PEER_ICE_TURN_TTL_SECONDS = 5 * 60; +const MAX_PEER_ICE_TURN_TTL_SECONDS = 30 * 60; +const MAX_PEER_ICE_TURN_URLS = 1; const MAX_PEER_ASSISTED_VIEWERS = 8; const DEFAULT_MAX_VIEWERS_PER_ROOM = 8; const DEFAULT_MAX_SFU_ROOTS_PER_ROOM = 2; @@ -28,6 +34,12 @@ export interface LiveKitFallbackConfig { maxSfuRootsPerRoom: number; } +export interface PeerIceTurnConfig { + urls: readonly string[]; + sharedSecret: string; + credentialTtlSeconds: number; +} + export interface ServerConfig { nodeEnv: RuntimeEnvironment; port: number; @@ -42,6 +54,7 @@ export interface ServerConfig { peerAssistedMedia: boolean; peerAssistedRoomIds?: ReadonlySet; livekitFallback?: LiveKitFallbackConfig; + peerIceTurn?: PeerIceTurnConfig; stunUrls: readonly string[]; } @@ -187,6 +200,66 @@ function parseStunUrlList(value: string | undefined): string[] { }); } +function parsePeerIceTurn( + environment: NodeJS.ProcessEnv, +): PeerIceTurnConfig | undefined { + const names = [ + "PEER_ICE_TURN_URLS", + "PEER_ICE_TURN_SHARED_SECRET", + "PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS", + ] as const; + const configuredNames = names.filter((name) => environment[name]?.trim()); + if (configuredNames.length === 0) { + return undefined; + } + if (configuredNames.length !== names.length) { + throw new Error( + "PEER_ICE_TURN_URLS, PEER_ICE_TURN_SHARED_SECRET, and PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS must be configured together", + ); + } + + const urls = parseUrlList(environment.PEER_ICE_TURN_URLS, "PEER_ICE_TURN_URLS"); + if (urls.length > MAX_PEER_ICE_TURN_URLS) { + throw new Error( + `PEER_ICE_TURN_URLS must contain at most ${MAX_PEER_ICE_TURN_URLS} URL`, + ); + } + if (new Set(urls).size !== urls.length) { + throw new Error("PEER_ICE_TURN_URLS must not contain duplicate URLs"); + } + for (const url of urls) { + if (!turnUrlSchema.safeParse(url).success) { + throw new Error( + "PEER_ICE_TURN_URLS must contain UDP TURN URLs with transport=udp", + ); + } + } + + const sharedSecret = environment.PEER_ICE_TURN_SHARED_SECRET!; + const sharedSecretBytes = Buffer.byteLength(sharedSecret); + if ( + !VISIBLE_ASCII_PATTERN.test(sharedSecret) || + sharedSecretBytes < MIN_PEER_ICE_TURN_SECRET_BYTES || + sharedSecretBytes > MAX_PEER_ICE_TURN_SECRET_BYTES + ) { + throw new Error( + "PEER_ICE_TURN_SHARED_SECRET must contain 32 to 128 visible ASCII bytes", + ); + } + + return { + urls, + sharedSecret, + credentialTtlSeconds: parseBoundedInteger( + environment.PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS, + MIN_PEER_ICE_TURN_TTL_SECONDS, + "PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS", + MIN_PEER_ICE_TURN_TTL_SECONDS, + MAX_PEER_ICE_TURN_TTL_SECONDS, + ), + }; +} + function parseOrigins(value: string | undefined, fallback: string): Set { const origins = parseUrlList(value, "ALLOWED_ORIGINS"); return new Set((origins.length > 0 ? origins : [fallback]).map(toOrigin)); @@ -296,6 +369,7 @@ export function loadConfig( environment.PEER_ASSISTED_ROOM_IDS, ); const livekitFallback = parseLiveKitFallback(environment, nodeEnv); + const peerIceTurn = parsePeerIceTurn(environment); if (peerAssistedRoomIds && !peerAssistedMedia) { throw new Error( @@ -310,14 +384,20 @@ export function loadConfig( if (livekitFallback && !peerAssistedMedia) { throw new Error("LiveKit fallback requires PEER_ASSISTED_MEDIA=true"); } + if (peerIceTurn && !peerAssistedMedia) { + throw new Error("Peer ICE TURN requires PEER_ASSISTED_MEDIA=true"); + } const configuredSecrets = [ hostAdmissionPassword, livekitFallback?.apiKey, livekitFallback?.apiSecret, + peerIceTurn?.sharedSecret, ].filter((secret): secret is string => secret !== undefined); if (new Set(configuredSecrets).size !== configuredSecrets.length) { throw new Error( - "HOST_ADMISSION_PASSWORD, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must use independent values", + peerIceTurn + ? "HOST_ADMISSION_PASSWORD, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, and PEER_ICE_TURN_SHARED_SECRET must use independent values" + : "HOST_ADMISSION_PASSWORD, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must use independent values", ); } if ( @@ -369,6 +449,7 @@ export function loadConfig( peerAssistedMedia, peerAssistedRoomIds, livekitFallback, + peerIceTurn, stunUrls, }; } diff --git a/src/server/ice.ts b/src/server/ice.ts index 484375a..642fd4a 100644 --- a/src/server/ice.ts +++ b/src/server/ice.ts @@ -4,11 +4,34 @@ export interface IceConfigOptions { stunUrls: readonly string[]; } -export function createIceConfig(options: IceConfigOptions): IceConfig { +export interface PeerIceTurnGrantOptions { + urls: readonly string[]; + username: string; + credential: string; + expiresAt: string; +} + +export function createIceConfig( + options: IceConfigOptions, + peerIceTurn?: PeerIceTurnGrantOptions, +): IceConfig { return { - iceServers: - options.stunUrls.length > 0 + iceServers: [ + ...(options.stunUrls.length > 0 ? [{ urls: [...options.stunUrls] }] - : [], + : []), + ...(peerIceTurn + ? [ + { + urls: [...peerIceTurn.urls], + username: peerIceTurn.username, + credential: peerIceTurn.credential, + }, + ] + : []), + ], + ...(peerIceTurn + ? { turnCredentialsExpiresAt: peerIceTurn.expiresAt } + : {}), }; } diff --git a/src/server/peer-ice-turn.ts b/src/server/peer-ice-turn.ts new file mode 100644 index 0000000..6d029ac --- /dev/null +++ b/src/server/peer-ice-turn.ts @@ -0,0 +1,58 @@ +import { createHmac } from "node:crypto"; + +import type { Role } from "../shared/protocol.js"; +import type { PeerIceTurnConfig } from "./config.js"; + +export interface PeerIceTurnIdentity { + roomId: string; + role: Role; + peerId: string; + sessionId: string; +} + +export interface PeerIceTurnGrant { + urls: readonly string[]; + username: string; + credential: string; + expiresAt: string; + expiresAtMs: number; +} + +export function issuePeerIceTurnGrant( + config: PeerIceTurnConfig, + identity: PeerIceTurnIdentity, + nowMs: number, +): PeerIceTurnGrant { + if (!Number.isFinite(nowMs) || nowMs < 0) { + throw new Error("Peer ICE TURN grant time is invalid"); + } + + const expiresAtSeconds = + Math.floor(nowMs / 1_000) + config.credentialTtlSeconds; + const opaqueIdentity = createHmac("sha256", config.sharedSecret) + .update( + JSON.stringify([ + "screener-peer-ice-turn-v1", + identity.roomId, + identity.role, + identity.peerId, + identity.sessionId, + expiresAtSeconds, + ]), + ) + .digest("base64url") + .slice(0, 32); + const username = `${expiresAtSeconds}:${opaqueIdentity}`; + const credential = createHmac("sha1", config.sharedSecret) + .update(username) + .digest("base64"); + const expiresAtMs = expiresAtSeconds * 1_000; + + return { + urls: [...config.urls], + username, + credential, + expiresAt: new Date(expiresAtMs).toISOString(), + expiresAtMs, + }; +} diff --git a/src/server/signaling.ts b/src/server/signaling.ts index 5b090a5..aa6a1f3 100644 --- a/src/server/signaling.ts +++ b/src/server/signaling.ts @@ -30,12 +30,19 @@ import { type SfuFallbackOptions, } from "./hybrid-media-router.js"; import { createIceConfig, type IceConfigOptions } from "./ice.js"; +import type { PeerIceTurnConfig } from "./config.js"; +import { + issuePeerIceTurnGrant, + type PeerIceTurnGrant, +} from "./peer-ice-turn.js"; type ErrorCode = Extract["code"]; const MAX_BUFFERED_SIGNAL_BYTES = 256 * 1024; const DEFAULT_MAX_SIGNAL_CONNECTIONS = 2_048; const DEFAULT_MAX_UNAUTHENTICATED_CONNECTIONS = 256; +const PEER_ICE_TURN_REFRESH_LEAD_MS = 2 * 60 * 1_000; +const PEER_ICE_TURN_REFRESH_MIN_INTERVAL_MS = 5_000; interface AuthenticatedSession { roomId: string; @@ -45,6 +52,7 @@ interface AuthenticatedSession { shareGeneration: string | null; displayName: string | null; viewerPresence: boolean; + peerIceTurnCapable: boolean; } interface SocketState { @@ -54,6 +62,8 @@ interface SocketState { hostAdmissionAuthenticated: boolean; revoked?: boolean; authenticated?: AuthenticatedSession; + peerIceTurnGrant?: PeerIceTurnGrant; + lastPeerIceTurnRefreshAtMs?: number; } interface ViewerQualityEvidenceGate { @@ -73,6 +83,7 @@ export interface SignalingOptions { peerAssistedMedia: boolean; peerAssistedRoomIds?: ReadonlySet; sfuFallback?: SfuFallbackOptions; + peerIceTurn?: PeerIceTurnConfig; ice: IceConfigOptions; allowedOrigins: ReadonlySet; hostAdmissionAtUpgrade: (request: IncomingMessage) => boolean; @@ -136,6 +147,9 @@ export class SignalingServer { if (options.sfuFallback && !options.peerAssistedMedia) { throw new Error("SFU fallback requires peer-assisted media"); } + if (options.peerIceTurn && !options.peerAssistedMedia) { + throw new Error("Peer ICE TURN requires peer-assisted media"); + } if (options.peerAssistedRoomIds && !options.peerAssistedMedia) { throw new Error("Peer-assisted room IDs require peer-assisted media"); } @@ -410,6 +424,7 @@ export class SignalingServer { ? (message.displayName ?? DEFAULT_VIEWER_DISPLAY_NAME) : null, viewerPresence: false, + peerIceTurnCapable: message.capabilities?.peerIceTurn === true, }; if (participant.role === "viewer") { this.viewerQualityEvidenceGates.delete( @@ -455,7 +470,7 @@ export class SignalingServer { hostOnline: participant.hostOnline, connectionId, viewerPeerIds: [...participant.viewerPeerIds], - iceConfig: this.iceConfig(), + iceConfig: this.iceConfig(state), viewerPolicy: participant.viewerPolicy, viewerAuthorizationGeneration: participant.viewerAuthorizationGeneration, @@ -639,6 +654,32 @@ export class SignalingServer { message.revision, ); return; + case "refresh-ice": { + const state = this.socketStates.get(socket); + if ( + !state || + state.authenticated !== authenticated || + !this.isPeerIceTurnEligible(authenticated) + ) { + this.sendError(socket, "FORBIDDEN", "Peer ICE refresh is not enabled"); + return; + } + const nowMs = this.now(); + if ( + state.lastPeerIceTurnRefreshAtMs !== undefined && + nowMs - state.lastPeerIceTurnRefreshAtMs < + PEER_ICE_TURN_REFRESH_MIN_INTERVAL_MS + ) { + this.sendError(socket, "FORBIDDEN", "Peer ICE refresh is rate limited"); + return; + } + state.lastPeerIceTurnRefreshAtMs = nowMs; + this.send(socket, { + type: "ice-config", + iceConfig: this.iceConfig(state), + }); + return; + } case "viewer-quality-evidence": this.handleViewerQualityEvidence(socket, authenticated, message); return; @@ -1288,8 +1329,38 @@ export class SignalingServer { ); } - private iceConfig() { - return createIceConfig(this.options.ice); + private iceConfig(state: SocketState) { + const authenticated = state.authenticated; + if (!authenticated || !this.isPeerIceTurnEligible(authenticated)) { + return createIceConfig(this.options.ice); + } + + const nowMs = this.now(); + let grant = state.peerIceTurnGrant; + if (!grant || nowMs >= grant.expiresAtMs - PEER_ICE_TURN_REFRESH_LEAD_MS) { + grant = issuePeerIceTurnGrant( + this.options.peerIceTurn!, + { + roomId: authenticated.roomId, + role: authenticated.role, + peerId: authenticated.peerId, + sessionId: state.sessionId, + }, + nowMs, + ); + state.peerIceTurnGrant = grant; + } + return createIceConfig(this.options.ice, grant); + } + + private isPeerIceTurnEligible( + authenticated: AuthenticatedSession, + ): boolean { + return Boolean( + this.options.peerIceTurn && + authenticated.peerIceTurnCapable && + this.isPeerAssistedRoom(authenticated.roomId), + ); } private viewerInviteUrl(roomId: string, viewerGrant: string | null): string { diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index eda76f1..23a3dec 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -9,6 +9,7 @@ export const ROOM_CODE_LENGTH = 12; export const MAX_MEDIA_ROUTE_REVISION = Number.MAX_SAFE_INTEGER; export const MAX_SFU_TOKEN_LENGTH = 8 * 1024; export const MAX_ICE_SERVER_URLS = 8; +export const MAX_TURN_CREDENTIAL_LENGTH = 256; export const MAX_VIEWER_QUALITY_EVIDENCE_BYTES = 2 * 1024; export const MAX_PARENT_EDGE_QUALITY_EVIDENCE_BYTES = 2 * 1024; export const VIEWER_QUALITY_EVIDENCE_INTERVAL_MS = 2_000; @@ -192,7 +193,53 @@ export const stunUrlSchema = z .max(512) .refine(isValidStunUrl, { message: "Invalid STUN URL" }); -const iceServerSchema = z +function isValidTurnUrl(value: string): boolean { + const schemeSeparator = value.indexOf(":"); + if ( + schemeSeparator <= 0 || + value.slice(0, schemeSeparator).toLowerCase() !== "turn" + ) { + return false; + } + + const turnUri = value.slice(schemeSeparator + 1); + const querySeparator = turnUri.indexOf("?"); + const authorityText = + querySeparator === -1 ? turnUri : turnUri.slice(0, querySeparator); + const query = querySeparator === -1 ? "" : turnUri.slice(querySeparator + 1); + if ( + !authorityText || + /[\\/\s#]/.test(authorityText) || + authorityText.endsWith(":") || + query !== "transport=udp" + ) { + return false; + } + + let authority: URL; + try { + authority = new URL(`http://${authorityText}`); + } catch { + return false; + } + return Boolean( + authority.hostname && + !authority.username && + !authority.password && + authority.pathname === "/" && + !authority.search && + !authority.hash && + (!authority.port || Number(authority.port) > 0), + ); +} + +export const turnUrlSchema = z + .string() + .min(1) + .max(512) + .refine(isValidTurnUrl, { message: "Invalid TURN URL" }); + +const stunIceServerSchema = z .object({ urls: z.union([ stunUrlSchema, @@ -201,12 +248,58 @@ const iceServerSchema = z }) .strict(); +const turnIceServerSchema = z + .object({ + urls: z.union([ + turnUrlSchema, + z.array(turnUrlSchema).min(1).max(MAX_ICE_SERVER_URLS), + ]), + username: z + .string() + .min(1) + .max(128) + .regex(/^[1-9]\d{0,12}:[A-Za-z0-9_-]{16,64}$/), + credential: z.string().min(1).max(MAX_TURN_CREDENTIAL_LENGTH), + }) + .strict(); + +const iceServerSchema = z.union([stunIceServerSchema, turnIceServerSchema]); + export const iceConfigSchema = z .object({ iceServers: z.array(iceServerSchema).max(8), + turnCredentialsExpiresAt: z.string().datetime().optional(), + }) + .strict() + .superRefine((config, context) => { + const turnServers = config.iceServers.filter( + (server) => "username" in server, + ); + if (turnServers.length > 1) { + context.addIssue({ + code: "custom", + message: "ICE configuration has more than one TURN server group", + path: ["iceServers"], + }); + } + if ( + (turnServers.length === 1) !== + (config.turnCredentialsExpiresAt !== undefined) + ) { + context.addIssue({ + code: "custom", + message: "TURN credentials and expiry must be present together", + path: ["turnCredentialsExpiresAt"], + }); + } + }); +export type IceConfig = z.infer; + +const webClientCapabilitiesSchema = z + .object({ + peerIceTurn: z.literal(true), }) .strict(); -export type IceConfig = z.infer; const sessionDescriptionSchema = z .object({ @@ -425,6 +518,7 @@ const authenticateMessageSchema = z.discriminatedUnion("role", [ clientId: opaqueIdSchema, shareGeneration: opaqueIdSchema.optional(), viewerPresence: z.literal(true).optional(), + capabilities: webClientCapabilitiesSchema.optional(), }) .strict(), z @@ -436,6 +530,7 @@ const authenticateMessageSchema = z.discriminatedUnion("role", [ clientId: opaqueIdSchema, viewerGrant: viewerGrantSchema.optional(), displayName: displayNameSchema.optional(), + capabilities: webClientCapabilitiesSchema.optional(), }) .strict(), ]); @@ -490,6 +585,7 @@ export const clientMessageSchema = z.union([ revision: mediaRouteRevisionSchema, }) .strict(), + z.object({ type: z.literal("refresh-ice") }).strict(), viewerQualityEvidenceMessageSchema, parentEdgeQualityEvidenceMessageSchema, z @@ -607,6 +703,12 @@ export const serverMessageSchema = z.union([ token: z.string().min(1).max(MAX_SFU_TOKEN_LENGTH), }) .strict(), + z + .object({ + type: z.literal("ice-config"), + iceConfig: iceConfigSchema, + }) + .strict(), z .object({ type: z.literal("quality-settings"), diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts index 14a3e56..3c05e34 100644 --- a/tests/client-state.test.ts +++ b/tests/client-state.test.ts @@ -30,6 +30,7 @@ import { qualityEvidenceWindowFromMetrics } from "../src/client/media/viewer-qua import { createStatsAccumulator, collectConnectionMetrics } from "../src/client/webrtc/stats.ts"; afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -439,6 +440,110 @@ describe("client signaling recovery policy", () => { expect(shouldReconnectSignaling(1006)).toBe(true); }); + it("opts into Peer ICE TURN and refreshes credentials two minutes before expiry", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-20T12:00:00.000Z")); + const sockets: FakeWebSocket[] = []; + class FakeWebSocket extends EventTarget { + static readonly OPEN = 1; + static readonly CLOSING = 2; + readyState = FakeWebSocket.OPEN; + readonly send = vi.fn(); + readonly close = vi.fn(); + + constructor(readonly url: string) { + super(); + sockets.push(this); + } + } + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("window", { + location: new URL("https://share.test/r/123456789012"), + setTimeout, + clearTimeout, + }); + const signal = new SignalingClient( + { + roomId: "123456789012", + role: "viewer", + clientId: "viewer-client", + }, + { + onMessage: () => undefined, + onStatus: () => undefined, + onTerminated: () => undefined, + onAccessRequired: () => undefined, + }, + ); + + signal.start(); + sockets[0]!.dispatchEvent(new Event("open")); + expect(JSON.parse(String(sockets[0]!.send.mock.calls[0]![0]))).toMatchObject({ + type: "authenticate", + capabilities: { peerIceTurn: true }, + }); + const authenticated = new Event("message"); + Object.defineProperty(authenticated, "data", { + value: JSON.stringify({ + type: "authenticated", + protocol: "screener-v2", + role: "viewer", + peerId: "viewer_12345678", + roomExpiresAt: null, + maxViewers: 8, + hostOnline: true, + connectionId: null, + viewerPeerIds: [], + iceConfig: { + iceServers: [ + { + urls: "turn:relay.test:3478?transport=udp", + username: `1787076000:${"a".repeat(32)}`, + credential: "temporary-credential", + }, + ], + turnCredentialsExpiresAt: "2026-08-20T12:05:00.000Z", + }, + viewerPolicy: "private-link", + viewerAuthorizationGeneration: "viewer_generation_12345678", + }), + }); + sockets[0]!.dispatchEvent(authenticated); + + vi.advanceTimersByTime(179_999); + expect(sockets[0]!.send).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(1); + expect(JSON.parse(String(sockets[0]!.send.mock.calls[1]![0]))).toEqual({ + type: "refresh-ice", + }); + + const refreshed = new Event("message"); + Object.defineProperty(refreshed, "data", { + value: JSON.stringify({ + type: "ice-config", + iceConfig: { + iceServers: [ + { + urls: "turn:relay.test:3478?transport=udp", + username: `1787076180:${"b".repeat(32)}`, + credential: "next-temporary-credential", + }, + ], + turnCredentialsExpiresAt: "2026-08-20T12:08:00.000Z", + }, + }), + }); + sockets[0]!.dispatchEvent(refreshed); + vi.advanceTimersByTime(180_000); + expect(JSON.parse(String(sockets[0]!.send.mock.calls[2]![0]))).toEqual({ + type: "refresh-ice", + }); + + signal.stop(); + vi.advanceTimersByTime(300_000); + expect(sockets[0]!.send).toHaveBeenCalledTimes(3); + }); + it.each([ JSON.stringify({ type: "error", diff --git a/tests/host-peer-tracks.test.ts b/tests/host-peer-tracks.test.ts index 6e92261..8ecdefb 100644 --- a/tests/host-peer-tracks.test.ts +++ b/tests/host-peer-tracks.test.ts @@ -69,6 +69,7 @@ class FakePeerConnection { static latest: FakePeerConnection | null = null; static offersFailing = 0; + readonly configurations: RTCConfiguration[] = []; readonly senders: FakeSender[] = []; readonly transceiverInputs: Array<{ trackOrKind: MediaStreamTrack | string; @@ -81,10 +82,17 @@ class FakePeerConnection { remoteDescription: RTCSessionDescription | null = null; readonly statsReports: Array> = []; - constructor() { + constructor(configuration?: RTCConfiguration) { FakePeerConnection.latest = this; + if (configuration) { + this.configurations.push(configuration); + } } + readonly setConfiguration = vi.fn((configuration: RTCConfiguration) => { + this.configurations.push(configuration); + }); + addTransceiver( trackOrKind: MediaStreamTrack | string, init?: RTCRtpTransceiverInit, @@ -297,6 +305,37 @@ afterEach(() => { }); describe("HostPeer source replacement", () => { + it("keeps standard ICE direct-first while refreshing future gathering", () => { + const peer = createPeer(createStream(createTrack("video", "video"), null)); + const connection = FakePeerConnection.latest!; + + expect(connection.configurations).toEqual([ + { iceServers: [], iceTransportPolicy: "all" }, + ]); + peer.updateIceConfig({ + iceServers: [ + { + urls: ["turn:relay.test:3478?transport=udp"], + username: `1787076000:${"a".repeat(32)}`, + credential: "temporary-credential", + }, + ], + turnCredentialsExpiresAt: "2026-08-20T12:00:00.000Z", + }); + + expect(connection.setConfiguration).toHaveBeenCalledOnce(); + expect(connection.configurations[1]).toEqual({ + iceServers: [ + { + urls: ["turn:relay.test:3478?transport=udp"], + username: `1787076000:${"a".repeat(32)}`, + credential: "temporary-credential", + }, + ], + iceTransportPolicy: "all", + }); + }); + it("reserves send-only video and audio senders and replaces both tracks", async () => { const oldVideo = createTrack("video", "old-video"); const oldAudio = createTrack("audio", "old-audio"); diff --git a/tests/ice.test.ts b/tests/ice.test.ts index 5634706..4c80b8c 100644 --- a/tests/ice.test.ts +++ b/tests/ice.test.ts @@ -26,4 +26,28 @@ describe("ordinary ICE configuration", () => { it("keeps local development explicit when STUN is absent", () => { expect(createIceConfig({ stunUrls: [] })).toEqual({ iceServers: [] }); }); + + it("adds one authenticated TURN group and its bounded expiry", () => { + expect( + createIceConfig( + { stunUrls: ["stun:stun.example.test:3478"] }, + { + urls: ["turn:relay.example.test:3478?transport=udp"], + username: `1787076000:${"a".repeat(32)}`, + credential: "temporary-credential", + expiresAt: "2026-08-20T12:00:00.000Z", + }, + ), + ).toEqual({ + iceServers: [ + { urls: ["stun:stun.example.test:3478"] }, + { + urls: ["turn:relay.example.test:3478?transport=udp"], + username: `1787076000:${"a".repeat(32)}`, + credential: "temporary-credential", + }, + ], + turnCredentialsExpiresAt: "2026-08-20T12:00:00.000Z", + }); + }); }); diff --git a/tests/peer-ice-turn.test.ts b/tests/peer-ice-turn.test.ts new file mode 100644 index 0000000..70e4d50 --- /dev/null +++ b/tests/peer-ice-turn.test.ts @@ -0,0 +1,63 @@ +import { createHmac } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { issuePeerIceTurnGrant } from "../src/server/peer-ice-turn.ts"; + +const config = { + urls: ["turn:relay.example.test:3478?transport=udp"], + sharedSecret: "turn-test-secret-that-is-at-least-32-bytes", + credentialTtlSeconds: 300, +} as const; + +describe("Peer ICE TURN credential issuer", () => { + it("issues coturn REST credentials with an opaque participant-session bearer", () => { + const nowMs = Date.parse("2026-08-20T12:00:00.500Z"); + const grant = issuePeerIceTurnGrant( + config, + { + roomId: "123456789012", + role: "viewer", + peerId: "viewer_12345678", + sessionId: "session_12345678", + }, + nowMs, + ); + + expect(grant.urls).toEqual(config.urls); + expect(grant.expiresAtMs).toBe( + Math.floor(nowMs / 1_000) * 1_000 + 300_000, + ); + expect(grant.expiresAt).toBe(new Date(grant.expiresAtMs).toISOString()); + expect(grant.username).toMatch(/^[1-9]\d{9}:[A-Za-z0-9_-]{32}$/); + expect(grant.username).not.toContain("123456789012"); + expect(grant.username).not.toContain("viewer_12345678"); + expect(grant.credential).toBe( + createHmac("sha1", config.sharedSecret) + .update(grant.username) + .digest("base64"), + ); + }); + + it("changes the opaque bearer across sessions and issuance windows", () => { + const identity = { + roomId: "1", + role: "host" as const, + peerId: "host_12345678", + sessionId: "session_12345678", + }; + const first = issuePeerIceTurnGrant(config, identity, 1_000_000); + const otherSession = issuePeerIceTurnGrant( + config, + { ...identity, sessionId: "session_87654321" }, + 1_000_000, + ); + const later = issuePeerIceTurnGrant(config, identity, 1_001_000); + + expect(otherSession.username).not.toBe(first.username); + expect(later.username).not.toBe(first.username); + expect(() => issuePeerIceTurnGrant(config, identity, Number.NaN)).toThrow( + "grant time is invalid", + ); + }); +}); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 813d9da..6fd38d0 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -88,10 +88,13 @@ describe("client signaling protocol", () => { } }); - it("rejects the removed all-room ICE refresh request", () => { + it("accepts the capability-gated ICE refresh request", () => { expect(clientMessageSchema.safeParse({ type: "refresh-ice" }).success).toBe( - false, + true, ); + expect( + clientMessageSchema.safeParse({ type: "refresh-ice", roomId }).success, + ).toBe(false); }); it("accepts a bounded authentication message", () => { @@ -118,6 +121,28 @@ describe("client signaling protocol", () => { }), ), ).toMatchObject({ viewerGrant }); + expect( + decodeClientMessage( + JSON.stringify({ + type: "authenticate", + protocol: SIGNALING_PROTOCOL, + roomId, + role: "viewer", + clientId: "client_12345678", + capabilities: { peerIceTurn: true }, + }), + ), + ).toMatchObject({ capabilities: { peerIceTurn: true } }); + expect( + clientMessageSchema.safeParse({ + type: "authenticate", + protocol: SIGNALING_PROTOCOL, + roomId, + role: "viewer", + clientId: "client_12345678", + capabilities: { peerIceTurn: false }, + }).success, + ).toBe(false); expect( clientMessageSchema.safeParse({ type: "authenticate", @@ -590,21 +615,30 @@ describe("client signaling protocol", () => { }); describe("server signaling protocol", () => { - it("rejects the removed all-room TURN credential wire", () => { + it("accepts only authenticated UDP TURN credentials paired with expiry", () => { expect( serverMessageSchema.safeParse({ type: "ice-config", - iceConfig: { iceServers: [] }, + iceConfig: { + iceServers: [ + { + urls: "turn:relay.example.test:3478?transport=udp", + username: `1787076000:${"a".repeat(32)}`, + credential: "credential", + }, + ], + turnCredentialsExpiresAt: "2026-08-20T12:00:00.000Z", + }, }).success, - ).toBe(false); + ).toBe(true); expect( serverMessageSchema.safeParse({ ...authenticatedMessage(8), iceConfig: { iceServers: [ { - urls: "turn:relay.example.test:3478", - username: "expires:viewer_12345678", + urls: "turn:relay.example.test:3478?transport=udp", + username: `1787076000:${"a".repeat(32)}`, credential: "credential", }, ], @@ -615,7 +649,7 @@ describe("server signaling protocol", () => { serverMessageSchema.safeParse({ ...authenticatedMessage(8), iceConfig: { - iceServers: [{ urls: "turn:relay.example.test:3478" }], + iceServers: [{ urls: "turn:relay.example.test:3478?transport=udp" }], }, }).success, ).toBe(false); diff --git a/tests/server-config.test.ts b/tests/server-config.test.ts index 8a7b167..19e419d 100644 --- a/tests/server-config.test.ts +++ b/tests/server-config.test.ts @@ -14,6 +14,96 @@ describe("server configuration", () => { expect(config.peerAssistedMedia).toBe(false); expect(config.peerAssistedRoomIds).toBeUndefined(); expect(config.livekitFallback).toBeUndefined(); + expect(config.peerIceTurn).toBeUndefined(); + expect( + loadConfig({ + PEER_ICE_TURN_URLS: "", + PEER_ICE_TURN_SHARED_SECRET: "", + PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS: "", + }).peerIceTurn, + ).toBeUndefined(); + }); + + it("enables Peer ICE TURN only from a complete exact-room tuple", () => { + const config = loadConfig({ + PEER_ASSISTED_MEDIA: "true", + PEER_ASSISTED_ROOM_IDS: "1", + PEER_ICE_TURN_URLS: "turn:relay-a.test:3478?transport=udp", + PEER_ICE_TURN_SHARED_SECRET: "t".repeat(32), + PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS: "300", + }); + + expect(config.peerIceTurn).toEqual({ + urls: ["turn:relay-a.test:3478?transport=udp"], + sharedSecret: "t".repeat(32), + credentialTtlSeconds: 300, + }); + }); + + it.each([ + { PEER_ICE_TURN_URLS: "turn:relay.test:3478?transport=udp" }, + { PEER_ICE_TURN_SHARED_SECRET: "t".repeat(32) }, + { PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS: "300" }, + { + PEER_ICE_TURN_URLS: "", + PEER_ICE_TURN_SHARED_SECRET: "t".repeat(32), + PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS: "300", + }, + ])("rejects a partial Peer ICE TURN tuple", (partial) => { + expect(() => loadConfig(partial)).toThrow( + "PEER_ICE_TURN_URLS, PEER_ICE_TURN_SHARED_SECRET, and PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS must be configured together", + ); + }); + + it("bounds Peer ICE TURN transport, secret, TTL, and feature ownership", () => { + const tuple = { + PEER_ICE_TURN_URLS: "turn:relay.test:3478?transport=udp", + PEER_ICE_TURN_SHARED_SECRET: "t".repeat(32), + PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS: "300", + }; + expect(() => loadConfig(tuple)).toThrow( + "Peer ICE TURN requires PEER_ASSISTED_MEDIA=true", + ); + for (const url of [ + "turn:relay.test:3478", + "turn:relay.test:3478?transport=tcp", + "turns:relay.test:5349?transport=udp", + ]) { + expect(() => + loadConfig({ ...tuple, PEER_ICE_TURN_URLS: url }), + ).toThrow("PEER_ICE_TURN_URLS"); + } + for (const ttl of ["299", "1801", "1.5"]) { + expect(() => + loadConfig({ + ...tuple, + PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS: ttl, + }), + ).toThrow("PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS"); + } + expect(() => + loadConfig({ ...tuple, PEER_ICE_TURN_SHARED_SECRET: "short" }), + ).toThrow("PEER_ICE_TURN_SHARED_SECRET"); + expect(() => + loadConfig({ + ...tuple, + PEER_ICE_TURN_URLS: + "turn:relay-a.test:3478?transport=udp,turn:relay-b.test:3478?transport=udp", + }), + ).toThrow("PEER_ICE_TURN_URLS must contain at most 1 URL"); + }); + + it("requires an independent Peer ICE TURN shared secret", () => { + expect(() => + loadConfig({ + HOST_ADMISSION_PASSWORD: "x".repeat(32), + PEER_ASSISTED_MEDIA: "true", + PEER_ASSISTED_ROOM_IDS: "1", + PEER_ICE_TURN_URLS: "turn:relay.test:3478?transport=udp", + PEER_ICE_TURN_SHARED_SECRET: "x".repeat(32), + PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS: "300", + }), + ).toThrow("PEER_ICE_TURN_SHARED_SECRET must use independent values"); }); it("enables LiveKit fallback only for a complete credential tuple", () => { diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts index 4157dd4..59bd574 100644 --- a/tests/server-signal.test.ts +++ b/tests/server-signal.test.ts @@ -19,7 +19,10 @@ import { createScreenerServer, type ScreenerServer, } from "../src/server/app.ts"; -import type { ServerConfig } from "../src/server/config.ts"; +import type { + PeerIceTurnConfig, + ServerConfig, +} from "../src/server/config.ts"; import { RoomDatabase } from "../src/server/room-database.ts"; import { RoomStore, type CreatedRoom } from "../src/server/room-store.ts"; import { SignalingServer } from "../src/server/signaling.ts"; @@ -167,6 +170,7 @@ async function startHarness( persistent?: boolean; provisionalHostClaimSeconds?: number; peerAssistedMedia?: boolean; + peerIceTurn?: PeerIceTurnConfig; stunUrls?: readonly string[]; now?: () => number; } = {}, @@ -174,6 +178,7 @@ async function startHarness( const config = testConfig(); config.hostAdmissionPassword = overrides.hostAdmissionPassword; config.peerAssistedMedia = overrides.peerAssistedMedia ?? false; + config.peerIceTurn = overrides.peerIceTurn; config.stunUrls = overrides.stunUrls ?? []; const maxViewersPerRoom = overrides.maxViewersPerRoom ?? 8; config.maxViewersPerRoom = maxViewersPerRoom; @@ -434,6 +439,7 @@ async function authenticate( relayCapacity: 0 | 1 | null = 1, shareGeneration?: string, presence: { displayName?: string; viewerPresence?: true } = {}, + peerIceTurnCapable = false, ) { client.socket.send( JSON.stringify( @@ -447,6 +453,9 @@ async function authenticate( clientId, ...(shareGeneration ? { shareGeneration } : {}), ...(presence.viewerPresence ? { viewerPresence: true } : {}), + ...(peerIceTurnCapable + ? { capabilities: { peerIceTurn: true } } + : {}), } : { type: "authenticate", @@ -458,6 +467,9 @@ async function authenticate( ...(presence.displayName ? { displayName: presence.displayName } : {}), + ...(peerIceTurnCapable + ? { capabilities: { peerIceTurn: true } } + : {}), }, ), ); @@ -1469,6 +1481,137 @@ describe("WebSocket signaling", () => { await viewer.inbox.expectNone(30); }); + it("scopes Peer ICE TURN to capable sessions in the exact room and refreshes once per window", async () => { + let nowMs = Date.parse("2026-08-20T12:00:00.000Z"); + const harness = await startHarness({ + peerAssistedMedia: true, + stunUrls: ["stun:stun.example.test:3478"], + peerIceTurn: { + urls: ["turn:relay.example.test:3478?transport=udp"], + sharedSecret: "turn-test-secret-that-is-at-least-32-bytes", + credentialTtlSeconds: 300, + }, + now: () => nowMs, + }); + + const host = await openClient(harness.webSocketUrl); + const hostAuth = peerAssisted( + await authenticate( + host, + harness.room, + "host", + "peer-turn-host", + 1, + undefined, + {}, + true, + ), + ); + const firstTurn = hostAuth.iceConfig.iceServers.find( + (server) => "username" in server, + ); + expect(firstTurn).toBeDefined(); + expect(hostAuth.iceConfig.turnCredentialsExpiresAt).toBe( + "2026-08-20T12:05:00.000Z", + ); + + host.socket.send(JSON.stringify({ type: "refresh-ice" })); + const reused = await host.inbox.next("ice-config"); + expect(reused.iceConfig).toEqual(hostAuth.iceConfig); + host.socket.send(JSON.stringify({ type: "refresh-ice" })); + expect(await host.inbox.next("error")).toMatchObject({ + code: "FORBIDDEN", + message: "Peer ICE refresh is rate limited", + }); + + nowMs += 180_000; + host.socket.send(JSON.stringify({ type: "refresh-ice" })); + const rotated = await host.inbox.next("ice-config"); + expect(rotated.iceConfig.turnCredentialsExpiresAt).toBe( + "2026-08-20T12:08:00.000Z", + ); + expect( + rotated.iceConfig.iceServers.find((server) => "username" in server), + ).not.toEqual(firstTurn); + + const legacyViewer = await openClient(harness.webSocketUrl); + const legacyAuth = await authenticate( + legacyViewer, + harness.room, + "viewer", + "native-shaped-viewer", + ); + expect( + legacyAuth.iceConfig.iceServers.some((server) => "username" in server), + ).toBe(false); + expect(legacyAuth.iceConfig.turnCredentialsExpiresAt).toBeUndefined(); + legacyViewer.socket.send(JSON.stringify({ type: "refresh-ice" })); + expect((await legacyViewer.inbox.next("error")).code).toBe("FORBIDDEN"); + + const ordinaryRoom = harness.roomStore.createRoom(); + const ordinaryHost = await openClient(harness.webSocketUrl); + const ordinaryAuth = await authenticate( + ordinaryHost, + ordinaryRoom, + "host", + "ordinary-capable-host", + 1, + undefined, + {}, + true, + ); + expect( + ordinaryAuth.iceConfig.iceServers.some((server) => "username" in server), + ).toBe(false); + ordinaryHost.socket.send(JSON.stringify({ type: "refresh-ice" })); + expect((await ordinaryHost.inbox.next("error")).code).toBe("FORBIDDEN"); + }); + + it("issues a new participant-session bearer after session replacement", async () => { + const harness = await startHarness({ + peerAssistedMedia: true, + peerIceTurn: { + urls: ["turn:relay.example.test:3478?transport=udp"], + sharedSecret: "turn-test-secret-that-is-at-least-32-bytes", + credentialTtlSeconds: 300, + }, + now: () => Date.parse("2026-08-20T12:00:00.000Z"), + }); + const first = await openClient(harness.webSocketUrl); + const firstAuth = await authenticate( + first, + harness.room, + "viewer", + "replaceable-turn-viewer", + 1, + undefined, + {}, + true, + ); + const replaced = new Promise((resolve) => { + first.socket.once("close", (code) => resolve(code)); + }); + + const second = await openClient(harness.webSocketUrl); + const secondAuth = await authenticate( + second, + harness.room, + "viewer", + "replaceable-turn-viewer", + 1, + undefined, + {}, + true, + ); + + expect(await replaced).toBe(4001); + expect( + secondAuth.iceConfig.iceServers.find((server) => "username" in server), + ).not.toEqual( + firstAuth.iceConfig.iceServers.find((server) => "username" in server), + ); + }); + it("isolates the routing allowlist while ordinary ICE stays STUN-only", async () => { const harness = await startSfuHarness({ tokenIssuer: { issueToken: async () => "unused-test-token" }, diff --git a/tests/viewer-peer-generation.test.ts b/tests/viewer-peer-generation.test.ts index a2a1761..f75f798 100644 --- a/tests/viewer-peer-generation.test.ts +++ b/tests/viewer-peer-generation.test.ts @@ -39,6 +39,7 @@ class FakePeerConnection extends EventTarget { static readonly instances: FakePeerConnection[] = []; static readonly plans: ConnectionPlan[] = []; + readonly configurations: RTCConfiguration[] = []; connectionState: RTCPeerConnectionState = "new"; iceConnectionState: RTCIceConnectionState = "new"; remoteDescription: RTCSessionDescription | null = null; @@ -70,8 +71,11 @@ class FakePeerConnection extends EventTarget { }, ); - constructor() { + constructor(configuration?: RTCConfiguration) { super(); + if (configuration) { + this.configurations.push(configuration); + } const plan = FakePeerConnection.plans.shift() ?? {}; this.candidateGates = [...(plan.candidateGates ?? [])]; this.localDescriptionGate = plan.localDescriptionGate ?? null; @@ -95,7 +99,9 @@ class FakePeerConnection extends EventTarget { }; } - setConfiguration(): void {} + readonly setConfiguration = vi.fn((configuration: RTCConfiguration) => { + this.configurations.push(configuration); + }); close(): void { this.connectionState = "closed"; @@ -187,6 +193,38 @@ afterEach(() => { }); describe("ViewerPeer connection generations", () => { + it("keeps standard ICE direct-first while refreshing future gathering", async () => { + const peer = createPeer([], []); + await peer.acceptSignal("host", offer("turn-config")); + const connection = FakePeerConnection.instances[0]!; + + expect(connection.configurations).toEqual([ + { iceServers: [], iceTransportPolicy: "all" }, + ]); + peer.updateIceConfig({ + iceServers: [ + { + urls: ["turn:relay.test:3478?transport=udp"], + username: `1787076000:${"a".repeat(32)}`, + credential: "temporary-credential", + }, + ], + turnCredentialsExpiresAt: "2026-08-20T12:00:00.000Z", + }); + + expect(connection.setConfiguration).toHaveBeenCalledOnce(); + expect(connection.configurations[1]).toEqual({ + iceServers: [ + { + urls: ["turn:relay.test:3478?transport=udp"], + username: `1787076000:${"a".repeat(32)}`, + credential: "temporary-credential", + }, + ], + iceTransportPolicy: "all", + }); + }); + it("restarts ICE when an answered initial connection stays stuck", async () => { const restartRequests: Array<{ peerId: string;