From ae298aadd2fe387f78d2877b6749018219082b30 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:44:36 +0800 Subject: [PATCH 1/5] feat(media): migrate ordinary ICE to STUN-only --- .env.example | 17 +- README.md | 20 +- deploy/coturn/turnserver.conf.example | 76 +---- deploy/livekit/livekit.yaml.example | 5 +- deploy/nginx/tls-sni-router.conf.example | 30 -- deploy/systemd/coturn.service.example | 2 +- docs/README.md | 2 +- docs/adr/0001-p2p-first-media-topology.md | 6 +- .../0005-automatic-hybrid-media-routing.md | 81 ++--- docs/deployment.md | 277 ++++++----------- docs/project-memory.md | 11 +- docs/research/low-server-media-routes.md | 41 +-- docs/status.md | 10 +- docs/方案设计.md | 76 +++-- docs/需求理解.md | 8 +- scripts/peer-assisted-benchmark.ts | 2 - src/client/lib/signaling.ts | 28 -- src/client/media/viewer-message-authority.ts | 1 - src/client/pages/HostPage.tsx | 18 +- src/client/pages/ViewerPage.tsx | 24 +- src/client/webrtc/host-peer.ts | 2 +- src/client/webrtc/viewer-peer.ts | 2 +- src/server/app.ts | 3 - src/server/config.ts | 134 ++------ src/server/ice.ts | 14 + src/server/signaling.ts | 33 +- src/server/turn.ts | 108 ------- src/shared/protocol.ts | 22 +- tests/host-peer-tracks.test.ts | 18 +- tests/ice.test.ts | 29 ++ tests/protocol.test.ts | 47 ++- tests/server-config.test.ts | 291 +++++------------- tests/server-http.test.ts | 2 - tests/server-signal.test.ts | 21 +- tests/turn.test.ts | 63 ---- tests/viewer-message-authority.test.ts | 4 +- tests/viewer-peer-generation.test.ts | 12 +- 37 files changed, 515 insertions(+), 1025 deletions(-) delete mode 100644 deploy/nginx/tls-sni-router.conf.example create mode 100644 src/server/ice.ts delete mode 100644 src/server/turn.ts create mode 100644 tests/ice.test.ts delete mode 100644 tests/turn.test.ts diff --git a/.env.example b/.env.example index 7de12c5..a9336a7 100644 --- a/.env.example +++ b/.env.example @@ -31,25 +31,18 @@ LIVEKIT_API_SECRET= # Effective only when the complete LiveKit tuple is configured. Valid range: 1-2. MAX_SFU_ROOTS_PER_ROOM=2 -# ICE servers. Separate multiple URLs with commas. +# Ordinary peer ICE discovery. Production requires at least one STUN URL. +# Separate multiple URLs with commas. STUN_URLS= -TURN_URLS= -TURN_SHARED_SECRET= -TURN_CREDENTIAL_TTL_SECONDS=3600 # Minimal production example (use independent untracked secrets): # ACCESS_PASSWORD=replace-with-a-strong-passphrase # ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite -# STUN_URLS=stun:turn.example.com:3478 -# TURN_URLS=turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?transport=tcp -# TURN_SHARED_SECRET=replace-with-at-least-32-random-bytes -# Optional automatic SFU fallback (requires PEER_ASSISTED_MEDIA=true and does -# not replace STUN/TURN above): +# STUN_URLS=stun:stun.example.com:3478 +# Automatic SFU fallback (requires PEER_ASSISTED_MEDIA=true and does not +# replace STUN above): # PEER_ASSISTED_ROOM_IDS=1 # LIVEKIT_URL=wss://share.example.com # 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 TURN/TLS (standard port; append to TURN_URLS when configured): -# turns:turn.example.com:5349?transport=tcp -# Port 443 is an optional restrictive-network enhancement, not the default. diff --git a/README.md b/README.md index d959f13..e72945d 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Screener is a private, low-latency screen-sharing project for one game player and a small group of friends. Its accepted media target is WebRTC P2P-first, with an SFU virtual parent feeding only the necessary roots and optional authenticated TURN for explicitly supported restrictive networks. Roots continue distributing to bounded peer descendants; this is not an always-SFU conferencing design. Viewers should be able to join from a desktop or mobile browser without installing the sender application. -The first measurable Web proof of concept is implemented. A host captures a screen, window, or browser tab and shares a numeric room code; friends can open `/r/{code}` directly or enter the code at `/join`, with no separate viewer token. During a share, the host can change between three ceiling profiles without reopening the source picker: 1080p60 at 8 Mbps, 1080p30 at 5 Mbps, and 720p30 at 3 Mbps. The host can also temporarily pause the picture or switch sources. Rooms are random and temporary by default. A password-protected deployment can optionally use SQLite-backed room numbers starting at `1`, reusable links, and a waiting state after sharing stops. Each viewer currently receives an independent WebRTC connection, so direct and TURN-relayed paths can coexist in one room. The default room capacity is eight viewers and deployments may configure 1 through 16, but that admission limit is not a performance promise. +The first measurable Web proof of concept is implemented. A host captures a screen, window, or browser tab and shares a numeric room code; friends can open `/r/{code}` directly or enter the code at `/join`, with no separate viewer token. During a share, the host can change between three ceiling profiles without reopening the source picker: 1080p60 at 8 Mbps, 1080p30 at 5 Mbps, and 720p30 at 3 Mbps. The host can also temporarily pause the picture or switch sources. Rooms are random and temporary by default. A password-protected deployment can optionally use SQLite-backed room numbers starting at `1`, reusable links, and a waiting state after sharing stops. Default routing gives each viewer an independent direct WebRTC connection; the exact-room controller can instead use bounded peer descendants and LiveKit SFU roots. The default room capacity is eight viewers and deployments may configure 1 through 16, but that admission limit is not a performance promise. -This is not yet a production release. Public TURN/UDP and TURN/TCP relay paths are verified, but real cross-network Screener media, game audio, a sustained 1:8 room, mobile browser lifecycle behavior, and latency targets still require the documented manual test matrix. +This is not yet a production release. The currently deployed old release verified public TURN/UDP and TURN/TCP relay paths, but the repository candidate now uses self-hosted STUN-only ordinary ICE plus a separate LiveKit SFU/UDP domain. Real cross-network Screener media, game audio, a sustained 1:8 room, mobile browser lifecycle behavior, and latency targets still require the documented manual test matrix. ## Run locally @@ -23,16 +23,18 @@ invite that the phone can open while the host keeps using secure-context `localhost` capture, set `PUBLIC_BASE_URL` to that LAN URL and include both origins in `ALLOWED_ORIGINS`. LAN viewers can join over HTTP; the sharing page must remain on `localhost` or HTTPS because screen capture requires a secure -context. The default has no STUN or TURN, so cross-network use still requires +context. The local default has no STUN, so cross-network use still requires HTTPS and the production ICE configuration documented separately. The current production release still requires STUN plus separate TURN/UDP and -TURN/TCP URLs. Do not remove them from an existing deployment yet. The accepted -but unimplemented flagship target is direct/peer UDP, then one SFU publication -feeding normally one or two roots, then optional generation-bound TURN only for -a selected exceptional edge, followed by a clear bounded failure. The exact -optional media-TCP/TLS transport and port remain subject to the documented -exact-room canary; HTTPS/WSS continues to use TLS/TCP independently. +TURN/TCP URLs; keep that old release and configuration intact for rollback. The +repository candidate deliberately replaces that contract: ordinary authenticated +ICE contains only self-hosted STUN, LiveKit separately serves normally one or two +SFU/UDP roots, and exhausted paths fail clearly after bounded recovery. Run it on +an isolated instance for exact-room acceptance rather than retaining a same-process +legacy transport branch. Optional generation-bound TURN for a selected exceptional +edge is not implemented and requires a separate evidence-backed change. HTTPS/WSS +continues to use TLS/TCP independently. Run the complete automated validation with: diff --git a/deploy/coturn/turnserver.conf.example b/deploy/coturn/turnserver.conf.example index 2d205a4..2e78f89 100644 --- a/deploy/coturn/turnserver.conf.example +++ b/deploy/coturn/turnserver.conf.example @@ -1,73 +1,25 @@ -# Screener minimal production coturn configuration. -# Target: coturn 4.17.2 or newer patched release. -# Copy this file outside the repository, replace every <...> placeholder, and -# restrict the resulting file to the coturn service account. +# Screener self-hosted STUN-only configuration. +# Target: coturn 4.17.2 or a newer patched release. +# Copy this file outside the repository and restrict it to the coturn service +# account. No application secret or relay port range is required. -# Required client-facing TURN/STUN listeners. +# Ordinary peer ICE uses UDP STUN discovery only. Coturn documents `stun-only` +# as ignoring every TURN request, so this listener cannot allocate media relays. listening-port=3478 - -# TURN/TLS is optional. The minimal deployment keeps it disabled and needs no -# TURN certificate. To enable TLS over TCP, remove `no-tls`, uncomment the -# standard 5349 listener and certificate paths near the end of this file, then -# append turns::5349?transport=tcp to the application's TURN_URLS. +stun-only +no-tcp no-tls -#tls-listening-port=5349 +no-dtls -# Keep the automatic address selection on a single-homed public host. For a -# multi-homed host or a host behind 1:1 NAT, uncomment and replace these values. -# The NAT must preserve every relay port (external port equals internal port). -#listening-ip= -#relay-ip= -#external-ip=/ - -# A deliberately small relay range for the friend-group PoC. Open the entire -# range bidirectionally for UDP and monitor allocation failures before changing it. -min-port=49152 -max-port=49251 +# Keep automatic address selection on a single-homed public host. On a +# multi-homed host, bind only the intended public interface. +#listening-ip= fingerprint -realm= -server-name= - -# TURN REST API credentials. This value must exactly match TURN_SHARED_SECRET -# in the application environment. Never use a static user/password or no-auth. -use-auth-secret -static-auth-secret= -# Some packaged builds still consult SQLite after reading the shared secret. -# Keep that optional database writable and ephemeral under RuntimeDirectory. -userdb=/run/coturn/turndb -stale-nonce=600 -max-allocate-lifetime=3600 - -# Initial abuse controls. max-bps and bps-capacity use bytes per second, not bits. -# 2,000,000 bytes/s permits roughly 16 Mbit/s in each direction per allocation. -# The per-user quota leaves room for the host's independent peer connections -# and overlapping ICE restarts; it is not a verified media capacity. -user-quota=32 -total-quota=100 -max-bps=2000000 - -# Do not enable allow-loopback-peers: coturn 4.17.2 denies loopback by default. -# Prevent a TURN allocation from reaching common private or link-local targets. -denied-peer-ip=0.0.0.0-0.255.255.255 -denied-peer-ip=10.0.0.0-10.255.255.255 -denied-peer-ip=100.64.0.0-100.127.255.255 -denied-peer-ip=127.0.0.0-127.255.255.255 -denied-peer-ip=169.254.0.0-169.254.255.255 -denied-peer-ip=172.16.0.0-172.31.255.255 -denied-peer-ip=192.168.0.0-192.168.255.255 -denied-peer-ip=198.18.0.0-198.19.255.255 -denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff -denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff -no-multicast-peers - -# A certificate for TURN_FQDN is required only by turns: URLs. Use absolute -# paths in the deployed copy and reload/restart coturn after renewal. -#cert= -#pkey= +no-cli # Let the service supervisor apply bounded retention. Do not enable verbose or -# log-binding in normal production operation; coturn logs can contain client IPs. +# log-binding in normal production; STUN logs can contain client IP addresses. log-file=stdout log-min-level=info new-log-timestamp diff --git a/deploy/livekit/livekit.yaml.example b/deploy/livekit/livekit.yaml.example index fb3ddb5..d578a96 100644 --- a/deploy/livekit/livekit.yaml.example +++ b/deploy/livekit/livekit.yaml.example @@ -9,7 +9,6 @@ key_file: /etc/livekit/keys.yaml rtc: # These media ports must be reached directly by clients. Port 7880 is only # for the same-host reverse proxy and must be denied by every public firewall. - tcp_port: 7881 udp_port: 7882 use_external_ip: true @@ -23,5 +22,5 @@ logging: level: info pion_level: error -# Redis, embedded TURN, recording, ingress, egress, and webhooks are omitted on -# purpose. This first fallback deployment is one process on one node. +# ICE/TCP, external or embedded TURN, Redis, recording, ingress, egress, and +# webhooks are omitted on purpose. This candidate is one process on one node. diff --git a/deploy/nginx/tls-sni-router.conf.example b/deploy/nginx/tls-sni-router.conf.example deleted file mode 100644 index a8ff25e..0000000 --- a/deploy/nginx/tls-sni-router.conf.example +++ /dev/null @@ -1,30 +0,0 @@ -# OPTIONAL TURN/TLS-on-443 topology only. Do not include this file for the -# default deployment. It requires nginx's stream and ssl_preread modules. -# -# Before enabling it, move every HTTPS virtual host on this public address, -# including share.bonfire.icu and any existing blog, from public 443 to the -# loopback listener `127.0.0.1:8443 ssl`. Also enable coturn TLS on loopback -# 5349. The public 443 listener below then routes encrypted traffic by SNI -# without terminating TLS. A normal Cloudflare HTTP proxy cannot carry this -# TURN route. -map $ssl_preread_server_name $screener_tls_backend { - turn.bonfire.icu screener_turn_tls; - default screener_web_tls; -} - -upstream screener_web_tls { - server 127.0.0.1:8443; -} - -upstream screener_turn_tls { - server 127.0.0.1:5349; -} - -server { - listen 443; - listen [::]:443; - proxy_pass $screener_tls_backend; - ssl_preread on; - proxy_connect_timeout 5s; - proxy_timeout 5h; -} diff --git a/deploy/systemd/coturn.service.example b/deploy/systemd/coturn.service.example index 54cbbd2..9c4b945 100644 --- a/deploy/systemd/coturn.service.example +++ b/deploy/systemd/coturn.service.example @@ -1,5 +1,5 @@ [Unit] -Description=Screener coturn relay +Description=Screener STUN discovery service After=network-online.target Wants=network-online.target diff --git a/docs/README.md b/docs/README.md index 47dedd1..3069d72 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,7 @@ - [方案设计](./方案设计.md): accepted, code-ready design for the first measurable WebRTC proof of concept. - [Project memory](./project-memory.md): durable decisions and unresolved questions to read at the start of future work. - [Current status](./status.md): bounded snapshot of the current phase, completed baseline, next step, and blockers. -- [Deployment](./deployment.md): current Node/nginx/coturn/LiveKit contract plus the gated SFU/UDP-first, optional-TURN flagship migration. +- [Deployment](./deployment.md): old production rollback boundary and the isolated self-hosted-STUN plus LiveKit-SFU/UDP candidate contract. - [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. diff --git a/docs/adr/0001-p2p-first-media-topology.md b/docs/adr/0001-p2p-first-media-topology.md index d5e936d..60d404a 100644 --- a/docs/adr/0001-p2p-first-media-topology.md +++ b/docs/adr/0001-p2p-first-media-topology.md @@ -12,9 +12,9 @@ is governed by ADR-0005. Historical ADR-0003's explicit whole-room SFU mode is rejected/superseded. Proposed ADR-0004 and the default-off ADR-0005 implementation do not yet supersede this production baseline. ADR-0005 now accepts the target direct/peer -UDP -> SFU-root UDP -> optional exceptional-edge TURN ladder, but its config -migration and exact-room acceptance gates must pass before production topology -changes. +UDP -> SFU-root UDP -> optional exceptional-edge TURN ladder. Its config/wire +migration now exists only in the repository candidate; isolated exact-room +acceptance gates must still pass before production topology changes. ## Context diff --git a/docs/adr/0005-automatic-hybrid-media-routing.md b/docs/adr/0005-automatic-hybrid-media-routing.md index 38ab401..8178b43 100644 --- a/docs/adr/0005-automatic-hybrid-media-routing.md +++ b/docs/adr/0005-automatic-hybrid-media-routing.md @@ -78,14 +78,14 @@ production release `769de201f7cc`, but the deployment has neither `PEER_ASSISTED_MEDIA` nor a LiveKit tuple, so its active path remains ordinary one-host-peer-per-viewer P2P/TURN. -That implementation is not yet the accepted transport target. It advertises -coturn per P2P edge, production configuration requires TURN/UDP plus TURN/TCP, -and the SFU activates only after a peer edge exhausts recovery. The scheduled -change must make the complete TURN tuple optional, omit it from ordinary peer -ICE, make SFU/UDP the primary central fallback, and issue short-lived TURN only -for a controller-selected exceptional edge. Until its tests and exact-room -canary pass, the current config contract and production deployment remain -unchanged. +The repository candidate now 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 +LiveKit sample also omits ICE/TCP and every TURN service. The SFU controller still +activates only after a peer edge exhausts recovery, so public transport and route +admission remain unverified. Production `769de201f7cc` keeps its old coturn relay +until an isolated candidate canary passes; rollback is release/instance based, +not a permanent legacy branch in the new code. The repository also supports an optional strict `PEER_ASSISTED_ROOM_IDS` deployment allowlist. When non-empty, only exact listed room IDs enter the @@ -177,6 +177,12 @@ 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. +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. + Authentication carries the current participant assignment and room revision, plus the non-secret standby URL when fallback is configured. It never carries a standby JWT. An authenticated snapshot is the sole authority allowed to replace @@ -196,10 +202,11 @@ complete LiveKit tuple. 2. A failed edge first exhausts its bounded ICE/UDP restart/rebuild and peer reparent options. When admission has no eligible peer path, or recovery is exhausted, select the SFU root plan without first advertising TURN to every - peer edge. If ordinary coturn is configured, its grant is issued only to a - selected exceptional peer edge after UDP is exhausted. Stock LiveKit - participant-wide TURN remains a canary candidate and does not satisfy this - selected-edge issuance target unless the integration isolates or extends it. + 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. 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 @@ -303,14 +310,14 @@ transport event. This signal must be measured before its interval is fixed. ## SFU-First UDP Transport 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. Optional TURN is not room-wide: the -server may grant independent coturn only to a selected exceptional ordinary -host-to-root or root-to-viewer edge. A LiveKit publisher/subscriber connection -may instead receive LiveKit's participant-wide embedded/external TURN config; -that path is not assignment-level issuance. Absence of TURN is normal; -exhausted configured paths fail clearly. +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 the current UDP-only candidate and would not be assignment-level +issuance. 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). @@ -323,15 +330,16 @@ bitrate `B_exc,j`, SFU egress adds `sum(B_exc,j)`; every TURN leg additionally adds its own ingress and egress. Every host NIC, TURN ingress/egress, SFU ingress/egress, root NIC, and exception NIC hop remains real traffic. -Migration is gated, not optional design debate: first retain the current path -as a baseline, then use one exact room to verify direct/peer UDP, SFU/UDP, -one selected-edge compatibility transport when configured, and bounded failure with all UDP -blocked. The matrix covers CGNAT, double NAT, mobile hotspot, ordinary home -networks, root departure, reconnect, SFU unavailable, and rollback. It records +Migration is gated, not optional design debate: retain the deployed old release +as a separate baseline/rollback instance, then use exact test rooms on an +isolated candidate to verify direct/peer UDP, SFU/UDP, and bounded failure with +all UDP blocked. The candidate cannot share a process with rooms that require +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. Only after that gate may config parsing stop -requiring coturn and may production enable the new ladder. +unchanged healthy subtrees. Config parsing and wire are already STUN-only; only +after that gate may production activate the candidate and retire relay listeners. A peer root re-publishing its received stream to the SFU while also feeding peer descendants remains a separate bounded candidate. The current browser @@ -362,7 +370,7 @@ record which boundary is actually configured and the UI must not claim E2EE. 4. Implement server prepare/commit/abort and strict authorization. 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, - optional selected-edge TURN, source/profile/pause/stop, and server-egress measurements. + bounded UDP-blocked failure, source/profile/pause/stop, and server-egress measurements. ## Acceptance Gates @@ -382,13 +390,14 @@ record which boundary is actually configured and the UI must not claim E2EE. persistent-room stop/restart semantics survive the transition. - No LiveKit configuration preserves the existing P2P/peer behavior and wire, but is not the final flagship deployment target. -- In one process, non-allowlisted rooms preserve the legacy P2P wire, directed - signaling, quality rejection, stop/reconnect/delete semantics, and remain - isolated from allowlisted peer/SFU state. +- 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 legacy TURN branch exists. - LiveKit/SFU UDP must pass CGNAT, double-NAT, hotspot, home-network, loss, - rollback, and SFU-unavailable gates. With optional TURN absent, blocked UDP - fails clearly within a bounded window. Any configured selected-edge TURN or - media TCP mode is verified separately and never becomes a quality claim. + 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. ## Consequences @@ -397,8 +406,8 @@ 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 stays direct/UDP by default; optional TURN is paid only - for a selected exceptional edge. +- The ordinary peer path is direct/UDP with STUN discovery; the current candidate + has no TURN media cost or fallback. - Revisioned prepare/commit isolates stale asynchronous results without a continuous optimizer. diff --git a/docs/deployment.md b/docs/deployment.md index 632116b..d1a3304 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -2,38 +2,38 @@ Last verified against upstream documentation: 2026-08-19. -This section documents the currently deployable release contract: one Node.js -process provides the built Web client, room API, and WebSocket signaling behind -Caddy or nginx; coturn is the separate STUN/TURN service. Normal media remains -browser-to-browser. Only an ICE pair that cannot connect directly consumes TURN -bandwidth. +This section documents the repository's UDP-only deployment candidate: one +Node.js process provides the built Web client, room API, and WebSocket signaling +behind Caddy or nginx; coturn runs in `stun-only` mode for address discovery; +LiveKit supplies bounded SFU-root capacity. Normal media remains distributed +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 the required application STUN/TURN settings. +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 public staging infrastructure has already passed the relay checks recorded -in `docs/status.md`, but every new deployment must run the direct, relay, and -mixed-network procedures below. Configuration validation alone is not evidence -that its DNS, firewall, NAT, or relay path works. +Production `769de201f7cc` is still the old TURN-required release. Keep it on a +separate rollback instance while validating this candidate; do not reuse its +environment or coturn relay config. Every new candidate deployment must run the +direct, SFU/UDP, UDP-blocked failure, and mixed-network procedures below. ## Topology and prerequisites -Use two DNS names, for example `share.example.com` and `turn.example.com`. They -may resolve to the same public IP in the minimal deployment: the Web ingress -owns TCP 443 while coturn owns UDP and TCP 3478. TURN/TLS is optional and uses -its standard TCP port 5349 by default, which can also share that public IP. +Use two DNS names, for example `share.example.com` and `stun.example.com`. They +may resolve to the same public IP: the Web ingress owns TCP 443 while the +self-hosted STUN listener owns UDP 3478. ```text browser -- HTTPS/WSS --> Caddy or nginx :443 --> Node.js :8787 browser <------------ DTLS-SRTP P2P ------------> browser -browser -- TURN only for failed ICE pairs --> coturn +browser -- STUN binding/UDP --> coturn stun-only :3478 +browser <---------- DTLS-SRTP/UDP ----------> LiveKit :7882 ``` The minimum runtime is Node.js 24 LTS and coturn 4.17.2 or a newer patched -release. Provision a valid TLS certificate for the Web name and, only when -enabling TURN/TLS, for the TURN name. Enable operating system time +release. Provision a valid TLS certificate for the Web name. Enable operating system time synchronization and keep the Node application port reachable only from its reverse proxy. A single Node process is intentional. Active participants and signaling remain in memory; an optional protected SQLite file can restore room @@ -60,45 +60,32 @@ boundary with port publishing rules or a host firewall. The optional same-host LiveKit layout needs no additional public hostname: `LIVEKIT_URL=wss://share.example.com` and nginx forwards only the exact `/rtc` endpoint and paths below `/rtc/` to LiveKit. Its HTTP/WebSocket listener on TCP -7880 is private to nginx, while WebRTC media reaches LiveKit directly on TCP -7881 or UDP 7882. +7880 is private to nginx, while WebRTC media reaches LiveKit directly on UDP +7882. -## Accepted flagship target after transport migration +## Candidate boundary and rollback The accepted target for `share.bonfire.icu` remains distributed and automatic: direct/peer UDP first, then an SFU virtual parent feeding normally one or two -roots, whose peer descendants continue carrying media. TURN becomes an optional -extreme-network transport for a controller-selected edge rather than a room-wide -ICE default. Multiple exceptional viewers that cannot attach behind a healthy +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. -This is not deployable from the current configuration contract. Release -`769de201f7cc` still refuses production startup without STUN, coturn credentials, -and explicit TURN/UDP plus TURN/TCP URLs; it sends that ICE config to ordinary -P2P peers. The tracked LiveKit example enables ICE/TCP 7881 and ICE/UDP mux 7882, -omits embedded TURN, and pinned LiveKit 1.13.5 permits TCP fallback when the -corresponding transport is configured. Keep the current environment, firewall, -and services until a code PR changes validation/wire behavior and an exact-room -canary passes. +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 +messages and negotiate within LiveKit's separate ICE domain. -The migration must make an entirely absent ordinary-peer coturn tuple a normal -no-warning state, reject partial configuration, and add short-lived -assignment/connection-generation grants only for an authorized exceptional -ordinary edge. LiveKit participant transport is separate: the pinned -integration issues/advertises embedded or external TURN only to LiveKit -publishers/subscribers and does so participant-wide, not through Screener's -per-edge grant. It remains a canary candidate and must be isolated/extended to -the accepted exceptional-edge scope or rejected. The target default media path is UDP. Which optional TURN or -media-TCP compatibility transport and port, if any, remains enabled is decided -by the canary; HTTPS/WSS always remains TLS/TCP. +`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 +the old release unchanged for rollback. If the candidate fails, roll back the +release or instance; do not add a permanent dual-transport branch. -Do not collapse the public listener plan to `443/tcp` plus one UDP port yet. -LiveKit documents a single ICE/UDP mux port but its pinned sample recommends a -multi-port mux range at least as wide as the CPU count for performance. Embedded -TURN/UDP defaults to 3478 and recommends 443 only when HTTP/3/QUIC does not use -that UDP port. The current nginx template has no HTTP/3 listener, but port choice, -privileged binding, firewall, and one-port performance still require the bounded -network/load gate in the low-server route research. +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. ## Production application environment @@ -125,10 +112,7 @@ ROOM_TTL_SECONDS=14400 MAX_ROOMS=1000 MAX_VIEWERS_PER_ROOM=8 -STUN_URLS=stun:turn.example.com:3478 -TURN_URLS=turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?transport=tcp -TURN_SHARED_SECRET= -TURN_CREDENTIAL_TTL_SECONDS=3600 +STUN_URLS=stun:stun.example.com:3478 ``` To make automatic SFU fallback capacity available, add the complete tuple: @@ -148,7 +132,9 @@ is a comma-separated set of exact positive numeric room IDs, using the same 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 the legacy P2P wire and signaling behavior. Omitting the +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 the variable or leaving it empty deliberately preserves the earlier all-room behavior when peer assistance is enabled, so a canary deployment must set at least one exact ID. There is no browser control or percentage rollout. @@ -157,8 +143,7 @@ least one exact ID. There is no browser control or percentage rollout. `ACCESS_PASSWORD` is optional: omit it or leave it empty for a public site. A non-empty value must contain 1 through 128 visible ASCII characters (`0x21` through `0x7e`). For an Internet deployment intended to stay private, use an -independent value that is not reused elsewhere. `TURN_SHARED_SECRET` must -contain at least 32 bytes and must not reuse the access password. +independent value that is not reused elsewhere. `ROOM_DATABASE_PATH` is optional but requires `ACCESS_PASSWORD`; startup fails if a database path is supplied without the whole-site gate. The baseline above enables persistent protected rooms. Omit `ROOM_DATABASE_PATH` to keep random @@ -166,29 +151,28 @@ temporary rooms, and omit both values for public mode. `ROOM_TTL_SECONDS` applie only to temporary rooms. `MAX_VIEWERS_PER_ROOM` defaults to 8 and accepts 1 through 16. It is an admission limit, not evidence that the publisher can sustain that many streams. -`TURN_SHARED_SECRET` stays only in the Node environment and coturn configuration; -browsers receive HMAC-SHA1-derived, time-limited credentials after room -authentication. Keep host clocks synchronized because the credential username -contains its Unix expiry time. The three `LIVEKIT_*` values must either all be absent or all be present, and a complete tuple requires `PEER_ASSISTED_MEDIA=true`. An empty tuple keeps the optional SDK and server path dormant. `LIVEKIT_URL` must be a plain `ws:` or `wss:` origin with no `/rtc` suffix; production requires `wss:`. `LIVEKIT_API_SECRET` must contain at least 32 bytes and must not reuse -`ACCESS_PASSWORD` or `TURN_SHARED_SECRET`. `MAX_SFU_ROOTS_PER_ROOM` defaults to +`ACCESS_PASSWORD`. `MAX_SFU_ROOTS_PER_ROOM` defaults to 2 and accepts only 1 or 2; it is ignored when LiveKit is not configured. These credentials authorize short-lived LiveKit room tokens and do not provide E2EE: the LiveKit operator can access ordinary SFU media. -For the first canary, prefer an existing protected persistent room whose ID is -stable across restarts. Set only that ID, restart the application, and verify +For the first candidate canary, use an isolated instance and a protected +persistent room whose ID is stable across restarts. Set only that ID, restart +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. Roll back by setting -`PEER_ASSISTED_MEDIA=false` and removing the allowlist and LiveKit tuple, then -restart; no room database migration is involved. +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, remove the allowlist +to enable all rooms rather than retaining a permanent canary branch. When `ACCESS_PASSWORD` is configured, both host and viewer routes first show the same login gate. Only `POST /api/session` accepts the password in an @@ -241,16 +225,12 @@ With no `ACCESS_PASSWORD`, the random room code is the sole viewing capability and does not provide strong privacy, so public mode is suitable only when public access is acceptable or another trusted access layer exists. -Production startup validates the configured ICE transport mix before the -server listens. The baseline must contain STUN and separate `TURN_URLS` entries -for `turn:` with explicit `transport=udp` and `turn:` with explicit -`transport=tcp`. TURN/TLS is optional; when enabled, append -`turns:turn.example.com:5349?transport=tcp`. This startup check only validates -configuration shape; it does not prove that DNS, certificates, firewall rules, -NAT mappings, or the coturn listeners work from an external network. Every STUN -and TURN entry is also syntax-checked; use the exact lowercase TURN query forms -shown above because one malformed ICE URL can make a browser reject the whole -peer-connection configuration. +Production startup requires one to eight syntactically valid `stun:` URLs +before the server listens. This validates shape only; it does not prove DNS, +firewall, NAT mappings, or external STUN reachability. The current application +does not read `TURN_URLS`, `TURN_SHARED_SECRET`, or +`TURN_CREDENTIAL_TTL_SECONDS`; remove those obsolete values from the candidate +environment instead of assuming they still enable compatibility. ## HTTPS and WSS ingress @@ -281,7 +261,7 @@ location / { The tracked [`share.bonfire.icu.conf.example`](../deploy/nginx/share.bonfire.icu.conf.example) follows this simple model and listens directly on public TCP 443. It does not -depend on the optional SNI router. Its optional exact `/rtc` and `/rtc/` prefix +need an SNI transport router. Its optional exact `/rtc` and `/rtc/` prefix locations preserve the LiveKit request URI and disable both access logging and request-line error logging. Legacy `/rtc?token=...` and current versioned requests carry a short-lived LiveKit JWT in the query string, so neither request @@ -290,9 +270,7 @@ target may fall through to the site's ordinary logs. When Certbot manages the Web certificate, install the tracked [`reload-nginx.sh`](../deploy/certbot/reload-nginx.sh) as an executable under `/etc/letsencrypt/renewal-hooks/deploy/`. Run `certbot renew --dry-run` after -the first certificate is installed. This hook only reloads the Web ingress; -if optional TURN/TLS later uses a renewed certificate, configure and verify a -separate coturn reload or restart action. +the first certificate is installed. This hook reloads only the Web ingress. Keep the proxy's access-log retention bounded and access controlled. Requests to `/r/{code}` put the room code in the path, so access logs can contain room codes @@ -302,7 +280,7 @@ public mode a current code is itself the only viewing capability. For a process-level liveness probe, send `GET /healthz`. A running process returns HTTP 200 with `{"status":"ok"}` and `Cache-Control: no-store`; other methods return 405 with `Allow: GET`. The endpoint does not inspect room state, -TURN reachability, or any external dependency, so use it only to decide whether +STUN/SFU reachability, or any external dependency, so use it only to decide whether the Node process can accept HTTP requests. It is not a deployment-readiness or end-to-end media check. @@ -317,8 +295,9 @@ from its official release assets, generate an independent key pair with `/etc/livekit/keys.yaml`. Keep that file owned by the `livekit` service account with mode `0600`; put the same values in Screener's untracked process secrets. -The example deliberately omits Redis and every recording, ingress, egress, -webhook, and embedded-TURN service. Do not add Redis for this one-node workload. +The example deliberately omits Redis, ICE/TCP, and every recording, ingress, +egress, webhook, external-TURN, and embedded-TURN service. Do not add Redis for +this one-node workload. The service journal is the diagnostic log; keep its retention finite and access restricted. Never enable debug/Pion packet logging continuously or persist JWTs, SDP, ICE candidates, API secrets, or full `/rtc` and `/rtc/*` request targets. @@ -328,125 +307,69 @@ Configure both the host firewall and the provider firewall independently: | Destination | Protocol | Exposure | Purpose | | --- | --- | --- | --- | | `127.0.0.1:7880` | TCP | Private | nginx to LiveKit signaling/API | -| public host `:7881` | TCP | Public | WebRTC ICE/TCP fallback | | public host `:7882` | UDP | Public | WebRTC ICE/UDP mux | LiveKit may listen beyond loopback while it discovers and advertises public media addresses, so the public-firewall denial of TCP 7880 is mandatory; nginx -must be its only ingress. TCP 7881 and UDP 7882 are direct media listeners and -must not be placed behind the HTTP reverse proxy. If the host is behind NAT, -forward them without port translation and verify the advertised candidate from -an external network. LiveKit ICE/TCP is not equivalent to authenticated TURN; -strict-network SFU reachability remains unproven until separately tested. +must be its only ingress. UDP 7882 is a direct media listener and must not be +placed behind the HTTP reverse proxy. If the host is behind NAT, forward it +without port translation and verify the advertised candidate from an external +network. UDP-blocked networks are expected to fail clearly in this candidate; +do not add `rtc.tcp_port` or TURN to make a canary pass silently. -## coturn +## Self-hosted STUN Copy [`deploy/coturn/turnserver.conf.example`](../deploy/coturn/turnserver.conf.example) -to an untracked service-owned location. Replace the FQDN and secret. The -`static-auth-secret` value must exactly match the application's -`TURN_SHARED_SECRET`; do not configure `no-auth` or permanent browser users. -Certificate paths are needed only if the optional TURN/TLS block is enabled. +to an untracked service-owned location and use the tracked +[`coturn.service.example`](../deploy/systemd/coturn.service.example). The config +uses coturn's documented `stun-only` mode, ignores every TURN allocation request, +and disables TCP, TLS, and DTLS client listeners. It has no auth secret, user +database, relay port range, certificate, quota, or TURN REST credential. -When the application and coturn configs share a parent directory, both service -accounts must be able to traverse that directory. One minimal layout is a -`root:root` directory with mode `0751`, an application environment file owned -by `root:screener` with mode `0640`, and a coturn config owned by -`root:turnserver` with mode `0640`. Verify readability as the actual service -user before starting coturn. The tracked systemd unit deliberately fails its -pre-start check when the coturn config is missing or unreadable; coturn itself -may otherwise warn and continue with unsafe defaults. +On a single-homed public host, automatic listener selection is sufficient. On a +multi-homed host, set `listening-ip` to the intended public interface. Open only +UDP 3478 and keep TCP 3478/5349 plus every old relay range closed. The same public +IP may serve HTTPS/WSS on TCP 443 and LiveKit media on UDP 7882. -On a host with a public address directly on its interface, coturn can select the -single listen/relay address automatically. On a multi-homed host, set -`listening-ip` and `relay-ip` explicitly. Behind 1:1 NAT, also set -`external-ip=/` and forward the complete relay range -without port translation. A normal consumer NAT that changes relay port numbers -is not a supported TURN-host topology. - -The minimum deployment exposes TURN/UDP and TURN/TCP on 3478. `turn:` over TCP -does not add TLS between the client and coturn, but relayed WebRTC media remains -protected end to end by DTLS-SRTP. Optional TURN/TLS should listen directly on -standard TCP 5349 and use `turns:turn.example.com:5349?transport=tcp`. - -TCP 443 is an optional enhancement for networks that block uncommon ports. On -the same public IP, coturn cannot bind it while the Web ingress already owns it. -Use a second public IP or an explicitly validated layer-4/SNI router, such as -the optional -[`tls-sni-router.conf.example`](../deploy/nginx/tls-sni-router.conf.example). -That router requires moving the site's TLS virtual host to loopback TCP 8443; -the default nginx site template intentionally does not do this. A normal -Cloudflare HTTP proxy does not carry TURN even on port 443. Keep the TURN DNS -record DNS-only, or use a compatible layer-4 service such as Cloudflare -Spectrum; the Web hostname may remain HTTP-proxied independently. +The example writes informational logs to stdout. Apply finite supervisor +retention and restrict access because STUN sees client IP addresses. Do not +enable `verbose` or `log-binding` continuously. `stun-only` is a hard candidate +boundary, not a hidden unauthenticated TURN service. Open only these public listeners: | Destination | Protocol | Purpose | | --- | --- | --- | | `share.example.com:443` | TCP | HTTPS and WSS | -| `turn.example.com:3478` | UDP and TCP | STUN, TURN/UDP, and TURN/TCP | -| `turn.example.com:49152-49251` | UDP, bidirectional | Relayed media endpoints | -| `turn.example.com:5349` | TCP, optional | Standard TURN/TLS | -| `turn.example.com:443` | TCP, optional | TURN/TLS on a dedicated IP or validated L4/SNI route | -| host public address `:7881` | TCP, optional SFU | LiveKit WebRTC ICE/TCP | -| host public address `:7882` | UDP, optional SFU | LiveKit WebRTC ICE/UDP mux | - -The 100-port relay range is an initial small-room limit, not a universal sizing -rule. Monitor 508/allocation failures and concurrent allocations before widening -it. The example also sets `user-quota=32`, `total-quota=100`, and a per-allocation -`max-bps` of 2,000,000 bytes/s. Tune these from measured 1080p60 traffic and the -purchased egress capacity; never remove all quotas as a shortcut. The per-user -value leaves allocation headroom for the host's independent connections and ICE -restarts in an eight-viewer room; it is not a claim that the application or -relay can sustain 1:8 media. - -coturn 4.17.2 denies loopback peers unless `allow-loopback-peers` is enabled; the -example deliberately does not enable it and additionally denies common private, -carrier-grade NAT, link-local, and benchmark ranges. Keep `no-multicast-peers` and -`no-cli`. If the TURN host must intentionally reach an internal peer, narrow an -exception to that exact address instead of removing the deny list. - -The example writes informational logs to stdout. Configure the supervisor to -rotate by size and time with a finite retention, such as seven days initially, -then reduce or extend it only for a documented operational need. coturn and the -reverse proxy necessarily see client IP addresses; restrict log access, do not -enable `verbose` or `log-binding` continuously, and never log credentials, SDP, -ICE candidates, or the shared secret. When TURN/TLS is enabled, reload or -restart coturn after certificate renewal. Confirm time synchronization remains -healthy in every deployment. +| `stun.example.com:3478` | UDP | STUN binding only | +| host public address `:7882` | UDP | LiveKit WebRTC ICE/UDP mux | ## Verification Run these checks from real external networks before calling the deployment usable: 1. Open the official [Trickle ICE sample](https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/) - and test each configured TURN URL separately with a short-lived credential. - Each required UDP and TCP test must produce a `relay` candidate; test TLS too - when it is configured. A successful STUN-only `srflx` candidate does not - prove TURN works. + and test the configured STUN URL. It must produce an `srflx` candidate over + UDP and no `relay` candidate. Confirm TCP 3478/5349 and the old relay range + are closed externally. 2. Create a normal Screener room on two different networks. Confirm media flows and the selected-pair stats report a non-relay path when direct ICE succeeds. -3. Open the host at `https://share.example.com/?relay=1`, create a new room, and - connect a viewer. This diagnostic flag sets `iceTransportPolicy=relay`; media - must still flow and the stats panel must report `relay`. -4. Block UDP on a test client while leaving TURN/TCP reachable. Confirm a new - session succeeds through `turn:turn.example.com:3478?transport=tcp`. -5. If TURN/TLS is enabled, test its `turns:` URL separately on 5349 or the - explicitly configured 443 route. Failure of an optional endpoint must be - diagnosed, but an intentionally omitted endpoint is not a deployment failure. -6. Repeat at 1, 3, 5, and 8 viewers with a mixed direct/restrictive-network cohort. - Record the selected path, RTT, bitrate, frame rate, packet loss, publisher - upload/encode load, and coturn egress for each viewer. Verify a direct viewer - does not start consuming relay bandwidth merely because another viewer needs - TURN. A real 1:8 session is currently unverified and must not be inferred from - the configured admission limit. +3. On an exact allowlisted room, exhaust a peer route and verify the host plus at + most two necessary roots select LiveKit UDP 7882. Peer descendants stay on + ordinary direct UDP and host/relay downstream caps remain two/one. +4. Block all UDP on one test client. Verify bounded ICE recovery ends in a clear + connection failure, without an ICE/TCP, TURN/TCP, or long pseudo-connected + path. The `?relay=1` diagnostic is expected to fail in this candidate. +5. Exercise root departure, reconnect, SFU unavailable, route prepare rollback, + stop, and source/profile changes. Unaffected subtrees must not migrate. +6. Repeat at 1, 3, 5, and 8 viewers across representative consumer networks. + Record selected protocol, RTT, bitrate, frame rate, packet loss, publisher + upload/encode load, LiveKit ingress/egress, and final decoded quality. A real + 1:8 session remains unverified until this matrix passes. References: [coturn 4.17.2 release](https://github.com/coturn/coturn/releases/tag/4.17.2), -[turnserver documentation](https://github.com/coturn/coturn/blob/master/README.turnserver), -the [official example configuration](https://github.com/coturn/coturn/blob/master/examples/etc/turnserver.conf), -[TURN URI scheme RFC 7065](https://www.rfc-editor.org/rfc/rfc7065.html), -[Cloudflare network-port guidance](https://developers.cloudflare.com/fundamentals/reference/network-ports/), +[pinned turnserver documentation](https://github.com/coturn/coturn/blob/4.17.2/README.turnserver), +the [pinned example configuration](https://github.com/coturn/coturn/blob/4.17.2/examples/etc/turnserver.conf), [RFC6265bis](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/), -[Node.js Crypto](https://nodejs.org/api/crypto.html#cryptocreatehmacalgorithm-key-options), [LiveKit firewall guidance](https://docs.livekit.io/deploy/admin/firewall/), and -the [official LiveKit configuration sample](https://github.com/livekit/livekit/blob/master/config-sample.yaml). +the [pinned LiveKit configuration sample](https://github.com/livekit/livekit/blob/v1.13.5/config-sample.yaml). diff --git a/docs/project-memory.md b/docs/project-memory.md index d1f2a89..a174a37 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -16,14 +16,14 @@ Last updated: 2026-08-19 ## Current Recommendation - Keep desktop Chrome/Edge and the responsive Web viewer as the baseline. Validate Android Chrome and iOS Safari as leaves. -- Use direct host P2P for one or two viewers. Keep ADR-0004 off broadly; use an exact-room canary until resource, quality, recovery, SFU/UDP, optional-TURN, and browser/mobile gates pass. +- Use direct host P2P for one or two viewers. Keep ADR-0004 off broadly; use an isolated exact-room candidate until resource, quality, recovery, SFU/UDP, bounded-failure, and browser/mobile gates pass. - Browser relays resend remote `MediaStreamTrack` values and re-encode at each hop; WebRTC does not guarantee a shared encoder across peer connections, so measure the cost. - Keep browser experiments bounded; spike native RTP relay, encoded-object striping, SVC, and FEC separately. - ADR-0006 retains a fixed-`HIGH` native canary boundary, but its sole product-wiring run reached only host setup and one encoder output before the first-viewer decoded/rendered gate timed out. Downstream checkpoints were not retained, so the result is no-go-unclassified, not a diagnosed product bug. No native product code is accepted; revisit only through the staged evidence gate. -- ADR-0005 accepts SFU roots as the primary central fallback after direct/peer UDP, never default whole-room fanout. Optional TURN is issued only to an exceptional assigned edge; PR #12 is superseded. -- Keep the current failure-only controller default-off until config migration and exact-room gates pass. Preserve break-before-make, sticky healthy subtrees, mobile leaves, and PR #20 prewarm; public transport/load remains open. +- ADR-0005 accepts SFU roots as the primary central fallback after direct/peer UDP, never default whole-room fanout. The repository candidate has no TURN consumer or credential wire; a future exceptional assigned edge requires a complete generation-bound grant change. PR #12 is superseded. +- Keep the current failure-only controller default-off until the isolated STUN/SFU migration and exact-room gates pass. Preserve break-before-make, sticky healthy subtrees, mobile leaves, and PR #20 prewarm; public transport/load remains open. - Local reparenting is a later candidate: start with unassigned relay admission rescue; do not block current work. -- Flagship media defaults to UDP; HTTPS/WSS remains TLS/TCP. Current production still requires coturn UDP/TCP. Future TURN absence is normal, partial config fails, and any media-TCP/port compatibility mode is chosen only by canary. +- Flagship media is UDP; HTTPS/WSS stays TLS/TCP. Old production retains coturn TURN for rollback. The candidate uses self-hosted coturn STUN-only for ordinary ICE and separate LiveKit SFU/UDP, with no TURN/media-TCP config or wire. Future selected-edge TURN requires measured design and a complete consumer. - Treat video settings as ceilings and degradation as unclassified. Local host A+B now covers same-tick capture/outbound identity, windows, deltas, remote linkage, path, and nullable negotiated codec/profile token/allowlisted parameters/current stream `scalabilityMode`. Use it first to reproduce the reported Host-only recovery, then add minimal authenticated C and test standard simulcast/LiveKit/SVC before custom `LOW`; this need not wait for ADR-0006. Never use UA or a composite score. - Reported poor movie/video audio is unclassified. After video A+B, separately diagnose audio A/B/C and A/V sync across capture settings, codec/fmtp, actual bitrate, loss, jitter, concealment, and jitter buffer. `maxBitrate` is not quality-up; do not expand runtime before evidence. See `docs/research/browser-screen-audio-quality.md`. - Do not add custom scene detection or dynamic-FPS control until WebRTC statistics and host resource measurements prove a material gap. Keep browser codec order until target hardware proves a more efficient common codec. @@ -38,6 +38,7 @@ Last updated: 2026-08-19 - Access uses optional `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly Strict cookie, internal host token, role-bound signaling, Origin/payload checks, and no accounts/JWT/session map. - With `ROOM_DATABASE_PATH` and the password, SQLite stores room ID and host-token digest; links persist and room `1` survived deployment. Without it, rooms are temporary. - Production uses nginx, Node.js 24.19.0, and authenticated coturn 4.17.2 at `turn.bonfire.icu:3478`; public STUN, TURN/UDP, TURN/TCP, and relay-only traffic pass. TURN/TLS is off. +- Candidate migration is process-wide: ordinary ICE is STUN-only, coturn disables TCP/TLS/DTLS, LiveKit 1.13.5 uses separate UDP participant ICE, and old TURN parser/signer/refresh/UI/SNI artifacts are gone. Old production is an isolated rollback. - The default-off ADR-0005 controller has no production peer/SFU configuration. `PEER_ASSISTED_ROOM_IDS` restricts exact canary rooms; unlisted rooms stay P2P and empty/missing means all. - When enabled, peer recovery precedes allowlisted SFU; session revisions prepare/commit/abort and active SFU gets one token refresh before failback. Browser relays re-encode; native sharing is outside product code. - Chrome 151/LiveKit localhost A/B cut failure-to-active/render from 1.481/2.257 seconds to 0.200/0.320; 31 new frames and 25 ms sampling kept host edges at two. It is headless synthetic 720p30 and includes SDK/network prewarm, not public-network evidence. @@ -62,7 +63,7 @@ These are measurement gates, not performance claims. - Sustainable viewer count by broadcaster and relay hardware, quality profile, network class, and media route; use instrumented 1/3/5/8 comparisons rather than a 1:8 claim. - Whether ADR-0004 passes fanout, re-encoding, depth-four latency, reparenting, silent-partition, and mobile-leaf gates. -- Which optional TURN/media-TCP transport and port, if any, survives ADR-0005's public UDP-first canary; current LiveKit ICE/TCP/coturn stays until migration passes. +- 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. - 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/low-server-media-routes.md b/docs/research/low-server-media-routes.md index 82d043c..efab180 100644 --- a/docs/research/low-server-media-routes.md +++ b/docs/research/low-server-media-routes.md @@ -4,8 +4,9 @@ - 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 current failure-only, - TURN-required controller and every non-browser data plane remain unverified + 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 ## Current Route Ladder @@ -190,13 +191,16 @@ 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 tracked deployment is intentionally still the old baseline. It exposes -LiveKit ICE/TCP 7881 and ICE/UDP mux 7882, omits embedded TURN, configures -coturn UDP/TCP, and the application refuses production startup without both -TURN transports. LiveKit 1.13.5 also defaults `allow_tcp_fallback` to true when -TCP/TURN-TLS is configured. Do not remove those listeners or variables until -the config migration and exact-room gate land. The target disables media TCP -by default; HTTPS/WSS remains TLS/TCP. +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 +`stun-only`; the LiveKit example exposes only ICE/UDP mux 7882 and configures no +external or embedded TURN. Candidate validation and rollback use isolated +instances rather than a process-wide legacy branch. HTTPS/WSS remains TLS/TCP. +Coturn 4.17.2 documents `stun-only` as ignoring TURN requests and independently +provides `no-tcp`, `no-tls`, and `no-dtls` listener switches. This makes coturn +the explicit self-hosted STUN owner without retaining a relay allocation surface. 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 @@ -229,15 +233,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 with a temporary canary config that - removes `rtc.tcp_port`, closes public 7881, and configures no TURN/TCP or - TURN/TLS listener. Verify host and roots use SFU/UDP. In one separately - chosen restrictive-network case, prove an ordinary coturn grant affects only - its authorized generation. Test LiveKit participant-wide TURN separately and - do not label it selected-edge issuance. -3. Block all UDP. With the optional compatibility layer absent, show a bounded - explicit failure rather than a long pseudo-connection. Any later media TCP - mode is measured separately and cannot be marketed as a quality path. +2. Cover representative consumer networks on an isolated candidate with no + `rtc.tcp_port` 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. 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 @@ -359,6 +361,9 @@ Primary sources checked on 2026-08-19: under IETF Trust terms. - [TURN, RFC 8656](https://www.rfc-editor.org/rfc/rfc8656.html) - the relay is transport for client/peer traffic, not a room distribution topology. +- [coturn 4.17.2 release](https://github.com/coturn/coturn/releases/tag/4.17.2) + and [pinned turnserver documentation](https://github.com/coturn/coturn/blob/4.17.2/README.turnserver) + - `stun-only` and UDP-only listener configuration; BSD-3-Clause, no code copied. - [WebRTC transports, RFC 8835](https://www.rfc-editor.org/rfc/rfc8835.html) - browser transport capability requirements do not require an application to advertise every supported fallback on every connection. diff --git a/docs/status.md b/docs/status.md index c30f6ea..8581aa3 100644 --- a/docs/status.md +++ b/docs/status.md @@ -13,7 +13,7 @@ unconfigured, so production remains one host connection per viewer and P2P/TURN. - Capture precedes room creation; live source/quality changes preserve peers and picture pause keeps audio/connections. Quality defaults to `maintain-resolution` with balanced/fluid options. - Optional `ACCESS_PASSWORD` uses a stateless 12-hour HMAC HttpOnly Strict cookie; host auth stays internal and signaling role-bound. - Without `ROOM_DATABASE_PATH`, rooms are temporary. With it and the password, sequential links persist after stop; SQLite stores only room ID and host-token digest. -- Production still requires coturn UDP/TCP. Target media is peer UDP -> SFU/UDP roots -> optional edge TURN -> failure; HTTPS/WSS stays TLS/TCP. +- Old production keeps coturn UDP/TCP. The candidate uses self-hosted STUN-only ordinary ICE, separate SFU/UDP roots, and bounded failure; HTTPS/WSS stays TLS/TCP. Selected-edge TURN is future work, not dormant config. - SFU normally feeds one or two roots. Downstream cap is two, browser relay one; exceptional central edges need a separate cap. Production routing is off. - Configured fallback adds a non-secret standby URL and token-free DNS/TLS warmup; absent configuration adds no field, import, request, participant, or edge. - Each viewer advertises relay capacity 0/1; detected mobile/iPad clients are leaves. Withdrawal does not move a healthy edge. Browser relays stay one-child and host two-child. @@ -40,7 +40,7 @@ unconfigured, so production remains one host connection per viewer and P2P/TURN. - ADR-0007 remains incomplete. Host A+B codec fields exist, but browser support and the reported case are unverified; authenticated C, the controller, and `LOW` are absent. Weak paths must share one `LOW`; fail closed protects `HIGH` only exceptionally, and an unreliable supported cohort fails acceptance. - Browser relays re-encode. ADR-0006 is no-go-unclassified: downstream checkpoints and two-edge/FIFO/TURN gates are absent. Spikes prove one WebCodecs object, not hardware; GCC+RTX is no-go and no-RTX weakens stats. - Browser fanout is host two/viewer one; any accepted endpoint relay stays capped at two downstream edges. -- ADR-0005 accepts SFU-root fallback, but code is failure-only/TURN-required; UDP-first config, edge grants, bounded failure, cost/trust, and public canary are absent. +- ADR-0005's candidate is process-wide STUN-only ordinary ICE plus separate LiveKit SFU/UDP and bounded failure, with no all-room TURN wire. It is default-off and lacks public gates; the old release is rollback. - Pinned LiveKit 1.13.5 Dynacast enables all qualities at or below the room's maximum request, so a `HIGH` root is expected to keep `LOW` enabled. It remains a bounded rejection/verification spike, not evidence that on-demand `LOW` can stop. - The corrected standby smoke is localhost/headless/video-only; public DNS/TLS reuse, transport, audio, shaping, load, mobile, endurance, and sub-25 ms overlap remain open. @@ -57,8 +57,10 @@ raise ceilings blindly. After video A+B, run the audio A/B/C and sync diagnosis; `maxBitrate` is not a quality fix. Then add authenticated video C and test standard simulcast/LiveKit/SVC before custom `LOW`, independently of ADR-0006. The app -owns only the two-state policy; WebRTC/LiveKit owns congestion/layers. Later one exact room must pass peer/SFU UDP, optional -compatibility, and bounded-failure gates before changing coturn/TCP. +owns only the two-state policy; WebRTC/LiveKit owns congestion/layers. Later one +isolated exact-room candidate must pass peer/SFU UDP and bounded-failure gates +before replacing the old release. Optional selected-edge TURN, if evidence later +requires it, is a separate complete change rather than part of this canary. ADR-0004 still requires a full-resolution 30-minute `1/3/5/8` network, resource, quality, latency, recovery, and browser/mobile-leaf matrix. diff --git a/docs/方案设计.md b/docs/方案设计.md index 3fe30cf..fb72a8e 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -13,7 +13,7 @@ - 分享者主动选择屏幕、窗口或标签页,并请求可用音频; - 创建数字房间,并用可选全站密码统一保护分享和观看入口;受保护部署可选择最小 SQLite 持久化,使房间和链接在停止分享及进程重启后仍有效; - 每名观看者拥有独立的 `RTCPeerConnection`; -- 生产基线支持 STUN、TURN/UDP 和 TURN/TCP,并允许可选 TURN/TLS 配置; +- 当前仓库候选要求 STUN,普通 peer ICE 只发 STUN servers;SFU candidate 只开放 LiveKit ICE/UDP; - 展示连接状态、direct/relay、RTT、实际码率、帧率和丢包等数据; - 超过部署配置上限的观看者被明确拒绝,不静默超载; - 在代码、测试和真实浏览器验证完成前,不宣称达到延迟或画质目标。 @@ -30,7 +30,7 @@ | 可选房间持久化 | Node.js 内置 `node:sqlite` | 单文件、无额外依赖;只保存恢复持久房间所需的 ID 与 token 摘要 | | 运行时校验 | Zod | WebSocket 和 HTTP 输入不可信,需要与 TypeScript 类型一致的运行时边界 | | 静态文件 | `sirv` | 生产环境由同一 Node 进程提供前端构建结果,避免引入完整 Web 框架 | -| NAT 中继 | coturn 4.17.2 或更新补丁版本 | 成熟的 STUN/TURN 实现,支持 WebRTC 短期凭据 | +| 地址发现 | coturn 4.17.2 或更新补丁版本的 `stun-only` 模式 | 自托管 UDP STUN,不启用 relay、credential 或媒体 TCP | | 测试 | Vitest | 覆盖纯逻辑和 Node 集成测试;真实媒体能力另做浏览器/网络矩阵 | | TLS 入口 | Caddy 或 nginx,由部署者选择 | 统一 HTTPS/WSS;不把证书管理写进应用进程 | @@ -55,9 +55,9 @@ flowchart LR ```text src/ client/ React 页面、捕获、PeerConnection、stats 和样式 - server/ HTTP/WS 入口、配置、房间和 TURN 凭据 + server/ HTTP/WS 入口、配置、房间和静态 STUN ICE snapshot shared/ 信令 schema 与跨端类型 -tests/ RoomStore、TURN、协议和 WebSocket 集成测试 +tests/ RoomStore、ICE 配置、协议和 WebSocket 集成测试 ``` 保持单个 npm package。出现第二个可独立发布的程序前,不建立 monorepo 或 workspace。 @@ -102,7 +102,6 @@ WebSocket 使用同源 `/signal`。启用全站密码时,upgrade 必须先携 | host -> viewer | `offer` / `candidate` | 带 `peerId` 与 `connectionId` 的定向信令 | | viewer -> host | `answer` / `candidate` | 只能回给本房间分享者 | | viewer -> host | `restart-request` | 以 `rebuild` 标志请求 ICE restart 或完整重建该 peer | -| client -> server | `refresh-ice` | 鉴权会话内刷新即将到期的 TURN 凭据 | | host -> server | `stop-sharing` | 结束当前发布、清理本轮连接并让观看者等待;不删除房间或使邀请失效 | Zod 在服务边界拒绝未知类型、超长字符串、非法 SDP/candidate 结构和越权目标。服务端根据已认证会话决定路由:观看者不能给另一名观看者发信令,分享者也不能向本房间以外的 peerId 发送。 @@ -175,25 +174,45 @@ Viewer 可通过已鉴权、绑定当前房间/路径代次的控制面请求 `L 标准表示能力排在完整 A+B/C 证据之后、任何自定义双表示媒体实现之前,并可与 ADR-0006 native sender 复核并行:依次做同 sender simulcast、最多两个 LiveKit root 的 Dynacast 验证/否决和 SVC spike。具体语义与资源门槛由 ADR-0007 和[实时质量调研](./research/realtime-quality-adaptation.md)持有,任何方案都不得复用 PR #28 的 minimum-of-two 或绕过 stock WebRTC congestion control。 -## 媒体传输配置与可选 TURN +## 媒体传输配置 -当前已部署版本仍在房间鉴权后生成 coturn REST 凭据: +当前仓库候选已经删除旧全房 TURN contract。`ServerConfig` 只接受 +`STUN_URLS`;生产缺失 STUN 启动即失败。`authenticated.iceConfig` 是静态 +STUN snapshot,只含 `iceServers`,不含 TURN URL、username、credential、 +expiry 或 `relayAvailable`。信令协议也不再接受 `refresh-ice` 或发送 +`ice-config`;WebSocket 重连后的新 `authenticated` 自然取得当前 snapshot。 -```text -expiry = floor(unix_seconds) + ttl -username = expiry + ":" + opaque_room_and_peer_subject -credential = base64(HMAC-SHA1(shared_secret, username)) -``` +普通 direct/peer `RTCPeerConnection` 与 LiveKit participant 是两个明确的 +ICE 域。普通边只消费 `authenticated.iceConfig`;SFU publisher/subscriber +只消费 revision-bound `sfu-config {url, token}`,再由 LiveKit SDK 与服务端 +协商自己的 candidates。tracked LiveKit 配置只开放 UDP mux,不配置 +`rtc.tcp_port`、external TURN 或 embedded TURN。Node 应用不能替代部署 +验证来证明该 UDP listener 从公网可达。 -`TURN_SHARED_SECRET` 只存在于应用服务和 coturn。浏览器只收到短期 username/credential、STUN/TURN URLs 和 `expiresAt`。这是 release `769de201f7cc` 的现行实现和部署契约:生产缺少 STUN、TURN secret、显式 TURN/UDP URL 或显式 TURN/TCP URL 时启动失败。在新的传输策略代码和 exact-room canary 合入前,部署人员不得仅依据目标文档删除这些变量。 +当前可执行梯级是 `direct/peer UDP -> SFU ICE/UDP -> failure`。Viewer 的 +现有两次有界 ICE restart/rebuild 后报告 route failure;router 先尝试局部 +reparent,再在 root budget 内准备 SFU,最终没有可行 route 时返回明确的 +`PEER_NOT_FOUND`,不长时间维持伪连接。HTTPS/WSS 始终使用 TLS/TCP,不 +属于媒体 TCP;clarity/balanced/fluid 只改变编码策略。 -已接受的旗舰目标把 STUN 和 SFU/UDP 作为基线,把 TURN 改为默认未配置的极端网络兼容层。完整 TURN 配置缺失表示正常禁用且不显示提醒;任一部分存在但不完整必须启动即失败。ordinary Host-to-root 或 root-to-viewer 例外需要新 coturn per-edge grant/wire,短期凭据绑定 assignment/connection generation,不能在房间鉴权时广播给普通 peer edges。LiveKit publisher/subscriber 到 SFU 属于另一 ICE 域;pinned 1.13.5 embedded/external TURN 当前向 LiveKit participant 广告,不是 Screener selected-edge grant,也不能在 SFU 宕机时恢复媒体。它仅是 canary candidate,若不能隔离/扩展到 accepted exceptional-edge scope 就拒绝。两条兼容路径必须分开配置、鉴权和 canary,不能把 embedded TURN 写成 ordinary peer coturn 的替代。 +旧 production release `769de201f7cc` 仍需要 coturn TURN/UDP+TCP。它作为 +独立旧实例保留到新 candidate canary 通过;新代码不根据 room allowlist +保留 legacy TURN 分支。`PEER_ASSISTED_ROOM_IDS` 只灰度 topology/SFU route +wire,进程内所有房间的 ordinary ICE 都是 STUN-only。因此验证与回滚必须 +使用隔离 candidate/旧实例,不能假设同进程未命中房间仍有旧 transport。 -目标媒体梯级是 `direct/peer UDP -> SFU ICE/UDP -> optional selected-edge compatibility transport -> failure`。媒体 TCP 默认不启用;具体 ICE/TCP、TURN/UDP、TURN/TCP 或 TURN/TLS 组合只能由目标网络 canary 选择。HTTPS/WSS 始终使用 TLS/TCP,不属于媒体 TCP。clarity/balanced/fluid 只改变编码策略,不能选择 UDP/TCP;TCP 队头阻塞可能放大实时视频 RTT、冻结和 GCC 带宽限制,因此不得把媒体 TCP 描述为高清路径。当前 tracked LiveKit 样例仍配置 ICE/TCP 7881 且 pinned 1.13.5 允许 TCP fallback;在迁移 PR 前这仍是未验收的现行候选,不是假装已经关闭。 +optional selected-edge TURN 仍是未来候选,不是当前能力。只有真实受限网络 +证据证明需要时,才以单独 PR 一次性加入配置校验、短期鉴权、assignment/ +connection generation grant、限频、客户端消费、部署和负向测试。该 PR +不得恢复房间鉴权时广播的全房 credentials。LiveKit participant-wide TURN +也不能冒充这项 edge grant。 -候选由 ICE 按优先级和连通性检查选择。界面只报告实际 selected candidate type、ICE protocol,以及本地为 relay 时的 TURN protocol,不推测 NAT 类型或回退步骤。TURN 是单条 ICE 边的 transport,peer-assisted 与 SFU 才是拓扑;一个 selected relay pair 不改变其他边的父子关系。普通 TURN 保留 WebRTC endpoints 间的 DTLS-SRTP,而普通 SFU 终止两侧 DTLS-SRTP;没有应用层 E2EE 时 operator 可接触 SFU 媒体,这一产品取舍必须保持可见。 - -SFU 是主要中央兜底,但正常只作为 virtual parent 向一至两个必要 roots 发布;每个可靠 root 收到媒体后继续服务既有 sticky descendants。host/native root 最多两条下游媒体边,browser root/relay 最多一个 child。无法挂到健康 root 的多个极端 viewers 只能通过独立 `E`/central-egress cap 准入,不能藏进 normal-root 预算。host→SFU publisher 是独立 ICE 连接,所有 TURN/SFU/endpoint 物理 hop 都按[低服务器成本媒体路由](./research/low-server-media-routes.md)逐项计量。当前控制器仍是 default-off、failure-only;新的 UDP-first 梯级必须先通过 ADR-0005 exact-room canary,不能直接翻转生产开关。 +SFU 正常只作为 virtual parent 向一至两个必要 roots 发布;可靠 root 收到 +媒体后继续服务 sticky descendants。host/native root 最多两条下游媒体边, +browser root/relay 最多一个 child。无法挂到健康 root 的极端 viewers 只能 +通过独立 `E`/central-egress cap 准入。普通 SFU 终止两侧 DTLS-SRTP;没有 +应用层 E2EE 时 operator 可接触媒体,这一产品取舍必须保持可见。所有物理 +hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md)逐项计量。 ## 可观测性 @@ -232,10 +251,10 @@ SFU 是主要中央兜底,但正常只作为 virtual parent 向一至两个必 ## 安全与故障边界 - 生产仅允许 HTTPS/WSS,并校验 WebSocket `Origin` allowlist。 -- 全站密码以定长摘要配合常量时间比较;access cookie 由服务端 HMAC 签名且不含账号或可查询的 session ID。host token 只保存摘要并以常量时间验证;TURN secret 仅作为 HMAC key 存在于服务端;认证和建房响应设置 `Cache-Control: no-store`。 +- 全站密码以定长摘要配合常量时间比较;access cookie 由服务端 HMAC 签名且不含账号或可查询的 session ID。host token 只保存摘要并以常量时间验证;认证和建房响应设置 `Cache-Control: no-store`。 - HTTP body、WebSocket payload、总连接数、未鉴权连接数、输出缓冲、房间总数、观看人数和 candidate 缓存都有硬上限;无法解析的 upgrade request-target 在未鉴权边界返回 HTTP 400,不允许异常逃逸到进程顶层。 -- 应用日志不得主动记录 IP;反向代理和 coturn 为提供网络服务必然可见源地址,部署时需设置最短必要留存与访问控制。 -- 若部署启用独立 coturn,必须配置 `use-auth-secret`、`realm`、`fingerprint`、`no-multicast-peers`、私网/loopback peer 拒绝、配额和出口监控;禁止匿名 relay。未启用 TURN 是目标配置的正常状态。 +- 应用日志不得主动记录 IP;反向代理、STUN 和 LiveKit 为提供网络服务必然可见源地址,部署时需设置最短必要留存与访问控制。 +- 自托管 coturn 必须启用 `stun-only` 并关闭 TCP、TLS 和 DTLS listeners;不得配置 relay port range、TURN auth secret 或匿名 TURN。未来 selected-edge TURN 由独立设计取代这份 STUN-only 配置,而不是在当前 wire 上加回全房 credential。 - 分享者直连会向可信观看者暴露网络地址。需要隐藏 IP 时才提供强制 relay 诊断选项,并明确其服务器带宽成本。 ## 验证计划 @@ -245,7 +264,7 @@ SFU 是主要中央兜底,但正常只作为 virtual parent 向一至两个必 - AccessSession 的密码开关、12 小时签名 cookie、过期、篡改、cookie 属性和无服务端 session 状态; - RoomStore 的随机临时房间、TTL、host token 摘要验证、停止分享等待、默认八人上限、自定义 1 至 16 边界和超额拒绝; - SQLite 房间库从 `1` 自增、重启恢复、持久房间无过期时间、数据最小化、数据库路径无全站密码时启动失败; -- 现行 TURN HMAC 固定向量、TTL 与生产 TURN/UDP+TCP 强校验保持回归覆盖;目标配置迁移另测 STUN 必需、TURN 全空正常禁用、部分配置 fail-fast、ordinary peer 的 per-edge coturn grant/wire,以及 LiveKit participant TURN 独立边界; +- STUN URL 严格校验、生产 STUN 必需、ordinary ICE 不含 username/credential、旧 `refresh-ice`/`ice-config` 被协议拒绝,以及 LiveKit `sfu-config` 独立边界; - 协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制; - 质量协议的 host 授权、严格字段与数值范围、last-wins、当前/未来 relay、已配置 SFU 和普通 P2P wire 兼容;sender 的四字段 requested/applied 读回、拒绝与持续受限提示; - HTTP/WS 集成:密码模式下 host/viewer gate、建房和 upgrade 只认 cookie 且无建房 Bearer 旁路;一名 host、多名 viewer、定向 offer/answer/candidate、连接上限、grace 内恢复、显式 restart/rebuild、离开和停止分享后的等待状态; @@ -256,7 +275,7 @@ SFU 是主要中央兜底,但正常只作为 virtual parent 向一至两个必 - 同机双标签页与同 LAN direct; - 真实跨网络、同房间 direct/peer UDP + SFU/UDP root; -- CGNAT、双 NAT、移动热点和普通家宽下使用临时 canary 配置移除 `rtc.tcp_port`、关闭公网 7881 且不配置 TURN/TCP/TLS,验证 host 与 roots 使用 SFU/UDP;人为封锁 UDP 时必须有界明确失败。只在部署选择兼容层时另测 LiveKit participant TURN 或 ordinary peer coturn; +- CGNAT、双 NAT、移动热点和普通家宽下使用隔离 candidate,LiveKit 不配置 `rtc.tcp_port` 或任何 TURN,验证 host 与 roots 使用 SFU/UDP;人为封锁 UDP 时必须有界明确失败。旧 production release 留在独立实例作为回滚,不在同进程保留 legacy transport; - Windows Chrome/Edge 的音轨有/无; - Android Chrome 与 iOS Safari 的播放手势、旋转、前后台和网络切换; - 一名分享者、八名异构观看者连续 30 分钟,同时记录较低人数基线、实际上行、编码负载和首帧时间。真实 1:8 未完成前不得把默认上限写成性能承诺。 @@ -269,9 +288,9 @@ SFU 是主要中央兜底,但正常只作为 virtual parent 向一至两个必 1. 建立单 package 工程、共享 schema、配置与 CI。 2. 完成 RoomStore、建房 API、WebSocket 鉴权和定向信令。 3. 完成屏幕捕获、逐观看者 PeerConnection、观看播放和手动质量档。 -4. 完成现行 TURN 短期凭据、ICE 配置刷新、连接恢复和 stats 面板。 +4. 完成连接恢复和 stats 面板;随后以 STUN-only snapshot 替换旧全房 TURN credential/refresh wire。 5. 完成 automatic peer/SFU controller 与 exact-room canary 边界。 -6. 用有界网络矩阵把默认媒体梯级迁移到 direct/peer UDP、SFU/UDP、可选异常 edge 兼容 transport,再更新配置与部署。 +6. 用有界网络矩阵验收 direct/peer UDP、SFU/UDP 和明确失败;selected-edge 兼容 transport 只有出现真实缺口后才另立完整 PR。 任何一步只实现当前消费者需要的接口。不得为未来账号、第三种媒体拓扑或原生 sender 预建抽象层。 @@ -283,10 +302,11 @@ SFU 是主要中央兜底,但正常只作为 virtual parent 向一至两个必 `PEER_ASSISTED_ROOM_IDS` 精确限制实验房间。非空值只接受逗号分隔、符合现有 room code 规则且不重复的 ID;非法配置启动即失败。未配置或空值为兼容旧行为而 表示全部房间,因此隔离灰度必须填写至少一个精确 ID。未命中房间不得进入 -`HybridMediaRouter`、房间质量同步或 LiveKit 兜底,鉴权 wire、定向信令、停止、 -重连和删除必须逐项保持普通 P2P 行为。该边界不增加 UI、用户选择或百分比框架。 +`HybridMediaRouter`、房间质量同步或 LiveKit 兜底,route 字段、定向信令、停止、 +重连和删除保持普通 P2P 行为;ordinary STUN-only ICE snapshot 是进程级契约, +不为未命中房间保留旧 TURN wire。该边界不增加 UI、用户选择或百分比框架。 -产品硬目标是任一非服务器节点任何时刻最多两条下游媒体边。系统按一个或两个观看者直接 P2P、更多观看者 peer-assisted、部署 SFU 中央兜底的顺序自动分配和恢复,分享者与观看者不选择拓扑。默认媒体 transport 只使用 UDP;SFU 作为 virtual parent 正常只送一至两个 roots,roots 之后仍优先沿受限 peer descendants 扩散;多个无法挂到健康 root 的极端 viewers 只可使用另设上限的中央 edge。可选 TURN 只按需给异常 assignment/connection generation,不默认发给 ordinary peer edges。`main` 已包含 default-off、failure-only 的跨模式控制器,生产也仍使用现行 coturn 强配置;两者尚未迁移到目标梯级,ADR-0004/0005 尚未验收。 +产品硬目标是任一非服务器节点任何时刻最多两条下游媒体边。系统按一个或两个观看者直接 P2P、更多观看者 peer-assisted、部署 SFU 中央兜底的顺序自动分配和恢复,分享者与观看者不选择拓扑。默认媒体 transport 只使用 UDP;SFU 作为 virtual parent 正常只送一至两个 roots,roots 之后仍优先沿受限 peer descendants 扩散;多个无法挂到健康 root 的极端 viewers 只可使用另设上限的中央 edge。当前仓库已把普通 ICE 收敛为 STUN-only 并删除全房 TURN wire;default-off、failure-only 跨模式控制器仍需 exact-room 公网验收,生产旧 release 仍使用 coturn relay。未来可选 TURN 只能按需给异常 assignment/connection generation,由单独完整 PR 实现。ADR-0004/0005 的运行时验收尚未完成。 实验只实现一个由服务端确定的广度优先拓扑:host 最多两个 child,每名 viewer 最多一个 child,因此形成两条按加入顺序平衡的 sticky 链。父节点只按深度和 `serverJoinSequence` 选择第一个空 slot。新 viewer 加入时不搬动已有节点;parent 离开时只把失去 parent 的 subtree root 重新分配到第一个空 slot,其 descendants 保持连接。该规则不采集或组合 RTT、带宽、CPU、地理位置与质量评分,也不主动重平衡。没有 parent 时明确等待或拒绝,不能创建分享端第三条边。 diff --git a/docs/需求理解.md b/docs/需求理解.md index 749f621..3663e89 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -20,7 +20,7 @@ - [ ] 一对多观看:一名分享者可向配置允许的多名观看者发送画面;默认上限为 8,可配置范围为 1 至 16。当前 PoC 的逐观看者连接是测量基线,不再是超过两名观看者时的目标拓扑。 - [ ] 端点 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 边默认只使用 ICE/UDP;其路径不可用时改由 SFU virtual parent 承担必要 root,而不是给每条 peer edge 常驻 TURN candidates。旗舰默认媒体路径为 `direct/peer UDP -> SFU ICE/UDP -> 明确失败`。部署者可为已验证的受限网络额外启用短期鉴权 TURN。普通 Host 到 peer root 或 root 到 viewer 的异常边需要新增、绑定当前 assignment/connection generation 的独立 coturn grant/wire;一个 TURN seed root 收到媒体后仍优先服务 bounded peer descendants。LiveKit publisher/subscriber 到 SFU 是另一 ICE 域,pinned 1.13.5 的 embedded/external TURN 当前按 LiveKit participant 广告,不是 Screener selected-edge grant。两者必须分开配置、鉴权和验收。媒体 ICE/TCP、TURN/TCP 和 TURN/TLS 不是默认质量路径。HTTPS/WSS 仍使用 TLS/TCP。全部可选 TURN 配置缺失表示正常禁用且不提示,部分配置必须启动即失败。 +- [ ] 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。 - [ ] 质量控制:保留 1080p60(8 Mbps)、1080p30(5 Mbps)、720p30(3 Mbps)三档推荐上限,默认采用清晰优先;分享期间可直接换档或应用有界高级设置,不重新选择来源或重建健康连接。Peer-assisted relay 与已配置的 SFU 必须接收同一个严格校验的房间级权威设置,覆盖当前和以后建立的 sender;普通 P2P 信令结构保持不变。任何设置都只是浏览器上限和偏好,必须读回实际 sender 参数并显示参数拒绝、改写和持续受限原因。不做自定义动态 FPS、综合评分或周期性全房间调档。 - [ ] 按需双表示:健康房间只运行一套 active `HIGH` representation;第一条观看路径被同一时段的 sender 与 viewer 统计持续证明无法承受时,系统必须启动唯一一套由全部弱路径共用的 `LOW` 并迁移弱路径。健康路径继续使用 `HIGH`,全部弱路径稳定恢复后关闭 `LOW`。active representation 硬上限为二,禁止按观看者增长为 N 套,也禁止把最差路径的目标应用到整个房间。标准浏览器不保证跨 PeerConnection 共用一个物理编码器,inactive 层也不保证释放 GPU;物理 encoder instance、CPU/GPU 和释放行为必须实测。没有合格 hardware/power-efficient 路径或启动 `LOW` 造成不可接受负载时,当次必须 fail closed:保护 `HIGH` 并明确显示弱路径降级或不可用;这只是异常止损,supported sender cohort 若不能可靠按需启动 `LOW`,则自动质量控制验收失败,必须优化编码路径或明确标记不支持。 - [ ] 路径级质量状态:每条观看路径只使用 `HIGH` 与 `FALLBACK` 两态。进入 `FALLBACK` 需要多个连续窗口的带宽不足、冻结或解码跟不上证据,回到 `HIGH` 需要更长的稳定窗口;进入与退出使用不同门槛且不计算综合分数。Viewer 的低档请求必须经当前会话鉴权、限频和去重,并由 sender 的 GCC/Transport-CC、实际码率、RTT、丢包、重传、编码限制、派生后的实际协商视频 codec/profile/parameters(适用时含 `scalabilityMode`)与 viewer 的实际解码、丢帧、冻结证据交叉验证,不能仅凭自报生效;上报不得包含 raw SDP、candidate 地址或原始设备/网络标识,鉴权与关联只使用服务端签发的不透明路径/连接代次 ID。 @@ -32,7 +32,7 @@ ### P1 - 重要功能 -- [ ] 实验路由按房间灰度:部署开启 `PEER_ASSISTED_MEDIA` 时可用严格的房间 ID allowlist 只让指定房间进入 peer-assisted 与已配置的 SFU 路径;同一进程内未命中房间的鉴权 wire、P2P 信令、质量拒绝、停止、重连和删除行为必须保持不变。缺失或空 allowlist 为兼容已有部署而表示全部房间,因此隔离灰度必须显式填写至少一个合法 ID;非法或重复配置启动即失败。该能力仅由部署控制,不增加 UI、用户选择、百分比发布或第二套路由器。 +- [ ] 实验路由按房间灰度:部署开启 `PEER_ASSISTED_MEDIA` 时可用严格的房间 ID allowlist 只让指定房间进入 peer-assisted 与已配置的 SFU 路径;未命中房间继续使用普通 P2P 拓扑及其质量拒绝、停止、重连和删除语义,但 STUN-only ordinary ICE 契约是进程级迁移,不为未命中房间保留旧 TURN wire。旧 production release 与新 candidate 必须用隔离实例验证和回滚,不能在产品代码建立长期双 transport 分支。缺失或空 allowlist 为兼容已有路由实验而表示全部房间,因此隔离灰度必须显式填写至少一个合法 ID;非法或重复配置启动即失败。该能力仅由部署控制,不增加 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。只有稳定收益且捕获、编码和远端画面不受影响时,才允许直播开始后默认显示静止缩略图或隐藏动态预览并提供按需展开;未测前不宣称收益。 @@ -44,7 +44,7 @@ - [ ] SFU-root 中央兜底:产品部署目标必须提供经 canary 验收的单节点 SFU 资源;系统在 direct/peer UDP 不可用或明确拓扑预算无法接纳时自动把它作为主要中央兜底,不要求用户选模式。它只向总计一至两个必要 roots 出口;root 优先承担受限 peer descendants,没有可靠 relay root 时必要 viewer 只作为同一总上限内的零 descendants root。已关闭 PR #12 的显式整房 SFU 模式已被取代。当前 PR #17 实现仍是 default-off、failure-only 且生产未配置;按 ADR-0005 的 exact-room canary 验收并修改配置契约后,才能启用新的默认梯级。 - [ ] 分享端桌面封装:用 Electron 固定 Chromium 版本、改善选源/热键/系统音频体验,同时保留 Web 观看端。 - [ ] Windows 应用音频:仅捕获所选游戏及其子进程的音频,避免把语音聊天或通知声一并分享。 -- [ ] 连接自检:在开始分享前验证信令、STUN、SFU ICE/UDP 及部署实际启用的可选 TURN 或媒体 TCP 可达性,并给出不暴露凭据的诊断结果。未配置可选兼容层不是告警。 +- [ ] 连接自检:在开始分享前验证信令、STUN 和 SFU ICE/UDP 可达性,并给出不暴露地址或凭据的诊断结果。当前没有 selected-edge TURN 或媒体 TCP 配置面;未来兼容层只有在完整实现后才加入自检。 - [ ] 受限网络增强:部署者仅在明确选择覆盖 UDP 受限网络且 canary 证明收益时,才可启用短期鉴权 TURN 或媒体 TCP;它只能用于控制器选中的异常 transport,不能成为所有普通 peer 边的默认候选,也不能被描述为更高清的传输方式。 - [ ] 运行时编解码选择:根据端点能力和统计数据选择实际硬件编码的 H.264、VP8、VP9 或 AV1,并始终保留兼容回退。 - [ ] 诊断页面:用户可导出不含敏感地址和凭据的连接诊断信息。 @@ -91,7 +91,7 @@ - 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 部署者为 ordinary peer edge 明确启用了经 canary 选择的 coturn transport,When 控制器选中的 Host-to-root 或 root-to-viewer UDP 路径耗尽,Then 只有该 assignment/connection generation 通过新增 per-edge grant/wire 收到短期凭据;其他普通 peer edges 不收到 TURN candidates,健康 root 仍继续 bounded peer 分发。 +- 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 部署者另行启用并验收媒体 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/scripts/peer-assisted-benchmark.ts b/scripts/peer-assisted-benchmark.ts index ab1be0f..0a6f10e 100644 --- a/scripts/peer-assisted-benchmark.ts +++ b/scripts/peer-assisted-benchmark.ts @@ -1947,8 +1947,6 @@ export async function main(): Promise { PEER_ASSISTED_MEDIA: "true", MAX_VIEWERS_PER_ROOM: String(Math.max(...config.viewerCounts)), STUN_URLS: "", - TURN_URLS: "", - TURN_SHARED_SECRET: "", }), }); await server.listen(appPort, "127.0.0.1"); diff --git a/src/client/lib/signaling.ts b/src/client/lib/signaling.ts index 0a9fc85..5d59fe2 100644 --- a/src/client/lib/signaling.ts +++ b/src/client/lib/signaling.ts @@ -46,7 +46,6 @@ export class SignalingClient { private reconnectAttempt = 0; private reconnectTimer: number | null = null; private authenticationTimer: number | null = null; - private iceRefreshTimer: number | null = null; private terminalTimer: number | null = null; private terminalMessage: ClientMessage | null = null; private accessCheck: Promise | null = null; @@ -161,10 +160,7 @@ export class SignalingClient { return; } } - this.scheduleIceRefresh(message.iceConfig.expiresAt); this.events.onStatus("connected"); - } else if (message.type === "ice-config") { - this.scheduleIceRefresh(message.iceConfig.expiresAt); } this.events.onMessage(message); }); @@ -233,26 +229,6 @@ export class SignalingClient { }); } - private scheduleIceRefresh(expiresAt: string | null): void { - if (this.iceRefreshTimer !== null) { - window.clearTimeout(this.iceRefreshTimer); - this.iceRefreshTimer = null; - } - if (!expiresAt) { - return; - } - - const expiresAtMs = Date.parse(expiresAt); - if (!Number.isFinite(expiresAtMs)) { - return; - } - const delay = Math.max(5_000, expiresAtMs - Date.now() - 60_000); - this.iceRefreshTimer = window.setTimeout(() => { - this.iceRefreshTimer = null; - this.send({ type: "refresh-ice" }); - }, delay); - } - private clearAuthenticationTimer(): void { if (this.authenticationTimer !== null) { window.clearTimeout(this.authenticationTimer); @@ -266,10 +242,6 @@ export class SignalingClient { window.clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } - if (this.iceRefreshTimer !== null) { - window.clearTimeout(this.iceRefreshTimer); - this.iceRefreshTimer = null; - } if (this.terminalTimer !== null) { window.clearTimeout(this.terminalTimer); this.terminalTimer = null; diff --git a/src/client/media/viewer-message-authority.ts b/src/client/media/viewer-message-authority.ts index 3d990c3..2e71be1 100644 --- a/src/client/media/viewer-message-authority.ts +++ b/src/client/media/viewer-message-authority.ts @@ -2,7 +2,6 @@ import type { ServerMessage } from "../../shared/protocol"; const AUTHORITY_MESSAGE_TYPES = new Set([ "authenticated", - "ice-config", "host-status", "sharing-stopped", "room-closed", diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index ad8e9a7..9257697 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -137,7 +137,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const [stream, setStream] = useState(null); const [details, setDetails] = useState(null); const [room, setRoom] = useState(readHostRoom); - const [relayAvailable, setRelayAvailable] = useState(false); const [maxViewers, setMaxViewers] = useState(null); const [peerSnapshots, setPeerSnapshots] = useState>( () => new Map(), @@ -298,7 +297,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { peerAssistedRef.current = false; setStream(null); setDetails(null); - setRelayAvailable(false); setMaxViewers(null); setPeerSnapshots(new Map()); setSignalStatus("offline"); @@ -720,12 +718,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { }); return; } - if (message.type === "ice-config") { - iceConfigRef.current = message.iceConfig; - setRelayAvailable(message.iceConfig.relayAvailable); - peersRef.current.forEach((peer) => peer.updateIceConfig(message.iceConfig)); - return; - } if (message.type === "room-closed") { forgetRoom(); endSharing( @@ -874,7 +866,9 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { expiresAt: message.roomExpiresAt, }; iceConfigRef.current = message.iceConfig; - setRelayAvailable(message.iceConfig.relayAvailable); + peersRef.current.forEach((peer) => + peer.updateIceConfig(message.iceConfig), + ); writeHostRoom(authenticatedRoom); setRoom(authenticatedRoom); setPhase("live"); @@ -1222,12 +1216,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { {!details?.hasAudio && stream && ( 当前来源没有可共享音频 )} - {showConnectionDetails && - room && - signalStatus === "connected" && - !relayAvailable && ( - TURN 未配置,严格网络可能无法连接 - )} {qualityLimitation && ( {qualityLimitation} )} diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index 7b9f1d4..8cc9acd 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -57,7 +57,6 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) useState("offline"); const [statusText, setStatusText] = useState("正在连接"); const [hostOnline, setHostOnline] = useState(false); - const [relayAvailable, setRelayAvailable] = useState(false); const [remoteStream, setRemoteStream] = useState(null); const [peerSnapshot, setPeerSnapshot] = useState(null); const [relaySnapshot, setRelaySnapshot] = useState(null); @@ -381,8 +380,13 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) } } }, - onRecoveryExhausted: (parentPeerId, connectionId) => - viewerSfuRoute?.reportPeerFailure(parentPeerId, connectionId) ?? true, + onRecoveryExhausted: (parentPeerId, connectionId) => { + if (viewerSfuRoute) { + return viewerSfuRoute.reportPeerFailure(parentPeerId, connectionId); + } + setStatusText("无法建立媒体连接"); + return true; + }, }, forceRelay, ); @@ -414,7 +418,6 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) } currentIceConfig = message.iceConfig; currentHostOnline = message.hostOnline; - setRelayAvailable(message.iceConfig.relayAvailable); setHostOnline(message.hostOnline); if (nextPeerAssisted && "qualitySettings" in message) { currentQualitySettings = message.qualitySettings; @@ -532,13 +535,6 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) } return; } - if (message.type === "ice-config") { - currentIceConfig = message.iceConfig; - setRelayAvailable(message.iceConfig.relayAvailable); - peerRef.current?.updateIceConfig(message.iceConfig); - viewerRelay?.updateIceConfig(message.iceConfig); - return; - } if (message.type === "host-status") { currentHostOnline = message.online; setHostOnline(message.online); @@ -762,12 +758,6 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) onChange={setShowConnectionDetails} /> - {showConnectionDetails && - !relayAvailable && - signalStatus === "connected" && - (hostOnline || peerSnapshot) && ( - TURN 未配置,严格网络可能无法连接 - )} {peerSnapshot?.error && (
{peerSnapshot.error} diff --git a/src/client/webrtc/host-peer.ts b/src/client/webrtc/host-peer.ts index 3aa40cf..1dee7f2 100644 --- a/src/client/webrtc/host-peer.ts +++ b/src/client/webrtc/host-peer.ts @@ -257,7 +257,7 @@ export class HostPeer { iceTransportPolicy: this.forceRelay ? "relay" : "all", }); } catch (error) { - this.setError(error, "更新 TURN 配置失败"); + this.setError(error, "更新 ICE 配置失败"); } } diff --git a/src/client/webrtc/viewer-peer.ts b/src/client/webrtc/viewer-peer.ts index 54b2d81..53615bc 100644 --- a/src/client/webrtc/viewer-peer.ts +++ b/src/client/webrtc/viewer-peer.ts @@ -175,7 +175,7 @@ export class ViewerPeer { iceTransportPolicy: this.forceRelay ? "relay" : "all", }); } catch (error) { - this.setError(error, "更新 TURN 配置失败"); + this.setError(error, "更新 ICE 配置失败"); } } diff --git a/src/server/app.ts b/src/server/app.ts index 5be8b5d..4291d93 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -78,9 +78,6 @@ export async function createScreenerServer( }); const iceOptions = { stunUrls: config.stunUrls, - turnUrls: config.turnUrls, - turnSharedSecret: config.turnSharedSecret, - credentialTtlSeconds: config.turnCredentialTtlSeconds, }; let frontendHandler: FrontendHandler | undefined; diff --git a/src/server/config.ts b/src/server/config.ts index 8ff0acb..3fe86a3 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -1,13 +1,12 @@ import { + MAX_ICE_SERVER_URLS, MAX_VIEWERS_PER_ROOM_LIMIT, roomCodeSchema, } from "../shared/protocol.js"; export type RuntimeEnvironment = "development" | "test" | "production"; -const MAX_TURN_CREDENTIAL_TTL_SECONDS = 3_600; const MAX_ACCESS_PASSWORD_BYTES = 128; -const MIN_TURN_SECRET_BYTES = 32; const MIN_LIVEKIT_API_SECRET_BYTES = 32; const MAX_PEER_ASSISTED_VIEWERS = 8; const DEFAULT_MAX_VIEWERS_PER_ROOM = 8; @@ -37,9 +36,6 @@ export interface ServerConfig { peerAssistedRoomIds?: ReadonlySet; livekitFallback?: LiveKitFallbackConfig; stunUrls: readonly string[]; - turnUrls: readonly string[]; - turnSharedSecret?: string; - turnCredentialTtlSeconds: number; } function parseBoolean( @@ -59,12 +55,6 @@ function parseBoolean( throw new Error(`${name} must be true or false`); } -interface IceEndpoint { - scheme: "stun" | "stuns" | "turn" | "turns"; - port?: number; - transport?: "udp" | "tcp"; -} - function parsePositiveInteger( value: string | undefined, fallback: number, @@ -176,22 +166,23 @@ function parseUrlList(value: string | undefined, name: string): string[] { }); } -function parseIceUrlList( - value: string | undefined, - name: string, - allowedProtocols: ReadonlySet, -): string[] { - return parseUrlList(value, name).map((value) => { +function parseStunUrlList(value: string | undefined): string[] { + const name = "STUN_URLS"; + const values = parseUrlList(value, name); + if (values.length > MAX_ICE_SERVER_URLS) { + throw new Error(`${name} must contain at most ${MAX_ICE_SERVER_URLS} URLs`); + } + return values.map((value) => { let protocol: string; try { protocol = new URL(value).protocol; } catch { throw new Error(`${name} contains an invalid URL`); } - if (!allowedProtocols.has(protocol)) { + if (protocol !== "stun:") { throw new Error(`${name} contains an unsupported URL scheme`); } - if (!parseIceEndpoint(value)) { + if (!parseStunEndpoint(value)) { throw new Error(`${name} contains an invalid ICE URL`); } return value; @@ -229,57 +220,39 @@ function parsePeerAssistedRoomIds( return roomIds; } -function parseIceEndpoint(value: string): IceEndpoint | undefined { +function parseStunEndpoint(value: string): boolean { const schemeSeparator = value.indexOf(":"); if (schemeSeparator <= 0) { - return undefined; + return false; } const scheme = value.slice(0, schemeSeparator).toLowerCase(); - if ( - scheme !== "stun" && - scheme !== "stuns" && - scheme !== "turn" && - scheme !== "turns" - ) { - return undefined; + if (scheme !== "stun") { + return false; } const remainder = value.slice(schemeSeparator + 1); if (!remainder || remainder.includes("#")) { - return undefined; + return false; } - const querySeparator = remainder.indexOf("?"); - const authorityText = - querySeparator === -1 ? remainder : remainder.slice(0, querySeparator); - const query = - querySeparator === -1 ? undefined : remainder.slice(querySeparator + 1); - let transport: IceEndpoint["transport"]; - if (scheme === "stun" || scheme === "stuns") { - if (query !== undefined) { - return undefined; - } - } else if (query !== undefined) { - if (query === "transport=udp" || query === "transport=tcp") { - transport = query.slice("transport=".length) as "udp" | "tcp"; - } else { - return undefined; - } + if (remainder.includes("?")) { + return false; } + const authorityText = remainder; if ( !authorityText || /[\\/\s]/.test(authorityText) || authorityText.endsWith(":") ) { - return undefined; + return false; } let authority: URL; try { authority = new URL(`http://${authorityText}`); } catch { - return undefined; + return false; } if ( !authority.hostname || @@ -289,34 +262,15 @@ function parseIceEndpoint(value: string): IceEndpoint | undefined { authority.search || authority.hash ) { - return undefined; + return false; } const port = authority.port ? Number(authority.port) : undefined; if (port === 0) { - return undefined; + return false; } - return { - scheme, - port, - transport, - }; -} - -function requireProductionTurnCoverage(turnUrls: readonly string[]): void { - const endpoints = turnUrls.map((value) => parseIceEndpoint(value)!); - const hasTurnUdp = endpoints.some( - ({ scheme, transport }) => scheme === "turn" && transport === "udp", - ); - const hasTurnTcp = endpoints.some( - ({ scheme, transport }) => scheme === "turn" && transport === "tcp", - ); - if (!hasTurnUdp || !hasTurnTcp) { - throw new Error( - "TURN_URLS must include explicit TURN/UDP and TURN/TCP endpoints in production", - ); - } + return true; } function toOrigin(value: string): string { @@ -364,17 +318,7 @@ export function loadConfig( const accessPassword = environment.ACCESS_PASSWORD?.trim() || undefined; const roomDatabasePath = environment.ROOM_DATABASE_PATH?.trim() || undefined; - const turnSharedSecret = environment.TURN_SHARED_SECRET?.trim() || undefined; - const stunUrls = parseIceUrlList( - environment.STUN_URLS, - "STUN_URLS", - new Set(["stun:", "stuns:"]), - ); - const turnUrls = parseIceUrlList( - environment.TURN_URLS, - "TURN_URLS", - new Set(["turn:", "turns:"]), - ); + const stunUrls = parseStunUrlList(environment.STUN_URLS); const maxViewersPerRoom = parseBoundedInteger( environment.MAX_VIEWERS_PER_ROOM, DEFAULT_MAX_VIEWERS_PER_ROOM, @@ -400,20 +344,14 @@ export function loadConfig( if (livekitFallback && !peerAssistedMedia) { throw new Error("LiveKit fallback requires PEER_ASSISTED_MEDIA=true"); } - if ((turnUrls.length > 0) !== Boolean(turnSharedSecret)) { - throw new Error( - "TURN_URLS and TURN_SHARED_SECRET must either both be configured or both be absent", - ); - } const configuredSecrets = [ accessPassword, - turnSharedSecret, livekitFallback?.apiKey, livekitFallback?.apiSecret, ].filter((secret): secret is string => secret !== undefined); if (new Set(configuredSecrets).size !== configuredSecrets.length) { throw new Error( - "ACCESS_PASSWORD, TURN_SHARED_SECRET, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must use independent values", + "ACCESS_PASSWORD, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must use independent values", ); } if ( @@ -431,22 +369,9 @@ export function loadConfig( if (nodeEnv === "production" && roomDatabasePath === ":memory:") { throw new Error("ROOM_DATABASE_PATH must be file-backed in production"); } - if (nodeEnv === "production" && turnUrls.length === 0) { - throw new Error("TURN is required in production"); - } if (nodeEnv === "production" && stunUrls.length === 0) { throw new Error("STUN is required in production"); } - if ( - nodeEnv === "production" && - turnSharedSecret && - Buffer.byteLength(turnSharedSecret) < MIN_TURN_SECRET_BYTES - ) { - throw new Error("TURN_SHARED_SECRET must contain at least 32 bytes in production"); - } - if (nodeEnv === "production") { - requireProductionTurnCoverage(turnUrls); - } if (peerAssistedMedia && maxViewersPerRoom > MAX_PEER_ASSISTED_VIEWERS) { throw new Error( "PEER_ASSISTED_MEDIA currently supports at most 8 viewers per room", @@ -473,14 +398,5 @@ export function loadConfig( peerAssistedRoomIds, livekitFallback, stunUrls, - turnUrls, - turnSharedSecret, - turnCredentialTtlSeconds: parseBoundedInteger( - environment.TURN_CREDENTIAL_TTL_SECONDS, - 3_600, - "TURN_CREDENTIAL_TTL_SECONDS", - 60, - MAX_TURN_CREDENTIAL_TTL_SECONDS, - ), }; } diff --git a/src/server/ice.ts b/src/server/ice.ts new file mode 100644 index 0000000..484375a --- /dev/null +++ b/src/server/ice.ts @@ -0,0 +1,14 @@ +import type { IceConfig } from "../shared/protocol.js"; + +export interface IceConfigOptions { + stunUrls: readonly string[]; +} + +export function createIceConfig(options: IceConfigOptions): IceConfig { + return { + iceServers: + options.stunUrls.length > 0 + ? [{ urls: [...options.stunUrls] }] + : [], + }; +} diff --git a/src/server/signaling.ts b/src/server/signaling.ts index 41edf99..77c37b9 100644 --- a/src/server/signaling.ts +++ b/src/server/signaling.ts @@ -18,7 +18,7 @@ import { HybridMediaRouter, type SfuFallbackOptions, } from "./hybrid-media-router.js"; -import { createIceConfig, type IceConfigOptions } from "./turn.js"; +import { createIceConfig, type IceConfigOptions } from "./ice.js"; type ErrorCode = Extract["code"]; @@ -377,13 +377,7 @@ export class SignalingServer { hostOnline: participant.hostOnline, connectionId, viewerPeerIds: [...participant.viewerPeerIds], - iceConfig: this.iceConfig( - participant.roomId, - participant.peerId, - participant.expiresAt === null - ? null - : Date.parse(participant.expiresAt), - ), + iceConfig: this.iceConfig(), }; if (hybridState) { this.send(socket, { @@ -473,16 +467,6 @@ export class SignalingServer { rebuild: message.rebuild, }); return; - case "refresh-ice": - this.send(socket, { - type: "ice-config", - iceConfig: this.iceConfig( - authenticated.roomId, - authenticated.peerId, - authenticated.roomExpiresAtMs, - ), - }); - return; case "set-quality-settings": if ( !this.isPeerAssistedRoom(authenticated.roomId) || @@ -949,17 +933,8 @@ export class SignalingServer { ); } - private iceConfig( - roomId: string, - peerId: string, - roomExpiresAtMs: number | null, - ) { - return createIceConfig( - this.options.ice, - `${roomId}:${peerId}`, - this.now(), - roomExpiresAtMs ?? undefined, - ); + private iceConfig() { + return createIceConfig(this.options.ice); } private connectedPeer(roomId: string, peerId: string) { diff --git a/src/server/turn.ts b/src/server/turn.ts deleted file mode 100644 index 74fa854..0000000 --- a/src/server/turn.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { createHmac } from "node:crypto"; - -import type { IceConfig } from "../shared/protocol.js"; - -export interface IceConfigOptions { - stunUrls: readonly string[]; - turnUrls: readonly string[]; - turnSharedSecret?: string; - credentialTtlSeconds: number; -} - -export interface TurnCredentials { - username: string; - credential: string; - expiresAt: string; -} - -export function createTurnCredentials( - sharedSecret: string, - subject: string, - ttlSeconds: number, - nowMs = Date.now(), - notAfterMs?: number, -): TurnCredentials { - if (!sharedSecret) { - throw new Error("TURN shared secret is required"); - } - if (!subject || subject.includes("\n") || subject.includes("\r")) { - throw new Error("TURN credential subject is invalid"); - } - if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds <= 0) { - throw new Error("TURN credential TTL must be a positive integer"); - } - - const nowSeconds = Math.floor(nowMs / 1_000); - const requestedExpiry = nowSeconds + ttlSeconds; - const expiresAtSeconds = - notAfterMs === undefined - ? requestedExpiry - : Math.min(requestedExpiry, Math.floor(notAfterMs / 1_000)); - if (expiresAtSeconds <= nowSeconds) { - throw new Error("TURN credential lifetime has elapsed"); - } - const username = `${expiresAtSeconds}:${subject}`; - const credential = createHmac("sha1", sharedSecret) - .update(username) - .digest("base64"); - - return { - username, - credential, - expiresAt: new Date(expiresAtSeconds * 1_000).toISOString(), - }; -} - -export function createIceConfig( - options: IceConfigOptions, - subject: string, - nowMs = Date.now(), - notAfterMs?: number, -): IceConfig { - const iceServers: IceConfig["iceServers"] = []; - - if (options.stunUrls.length > 0) { - iceServers.push({ urls: [...options.stunUrls] }); - } - - if (options.turnUrls.length === 0) { - return { - iceServers, - expiresAt: null, - relayAvailable: false, - }; - } - if (!options.turnSharedSecret) { - throw new Error("TURN shared secret is required when TURN URLs are configured"); - } - - if ( - notAfterMs !== undefined && - Math.floor(notAfterMs / 1_000) <= Math.floor(nowMs / 1_000) - ) { - return { - iceServers, - expiresAt: null, - relayAvailable: false, - }; - } - - const credentials = createTurnCredentials( - options.turnSharedSecret, - subject, - options.credentialTtlSeconds, - nowMs, - notAfterMs, - ); - iceServers.push({ - urls: [...options.turnUrls], - username: credentials.username, - credential: credentials.credential, - }); - - return { - iceServers, - expiresAt: credentials.expiresAt, - relayAvailable: true, - }; -} diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 1c9db32..5179ad5 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -5,6 +5,7 @@ export const MAX_SIGNAL_BYTES = 64 * 1024; 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; const opaqueIdSchema = z .string() @@ -82,22 +83,24 @@ export const relayDownstreamEdgesSchema = z.union([ ]); export type RelayDownstreamEdges = z.infer; +const stunUrlSchema = z + .string() + .min(1) + .max(512) + .regex(/^stun:/i); + const iceServerSchema = z .object({ urls: z.union([ - z.string().min(1).max(512), - z.array(z.string().min(1).max(512)).min(1).max(8), + stunUrlSchema, + z.array(stunUrlSchema).min(1).max(MAX_ICE_SERVER_URLS), ]), - username: z.string().max(512).optional(), - credential: z.string().max(512).optional(), }) .strict(); export const iceConfigSchema = z .object({ iceServers: z.array(iceServerSchema).max(8), - expiresAt: z.string().datetime().nullable(), - relayAvailable: z.boolean(), }) .strict(); export type IceConfig = z.infer; @@ -219,7 +222,6 @@ export const clientMessageSchema = z.union([ rebuild: z.boolean(), }) .strict(), - z.object({ type: z.literal("refresh-ice") }).strict(), z .object({ type: z.literal("set-quality-settings"), @@ -361,12 +363,6 @@ export const serverMessageSchema = z.union([ qualitySettings: qualitySettingsSchema, }) .strict(), - z - .object({ - type: z.literal("ice-config"), - iceConfig: iceConfigSchema, - }) - .strict(), z .object({ type: z.literal("host-status"), diff --git a/tests/host-peer-tracks.test.ts b/tests/host-peer-tracks.test.ts index cacd8ae..6e92261 100644 --- a/tests/host-peer-tracks.test.ts +++ b/tests/host-peer-tracks.test.ts @@ -264,7 +264,7 @@ function createPeer( ): HostPeer { return new HostPeer( "viewer-peer", - { iceServers: [], expiresAt: null, relayAvailable: false }, + { iceServers: [] }, stream, QUALITY_PROFILES["720p30"], { @@ -780,7 +780,7 @@ describe("HostPeer source replacement", () => { describe("ViewerRelay downstream ownership", () => { it("exposes a defensive snapshot of current downstream send metrics", async () => { const relay = new ViewerRelay( - { iceServers: [], expiresAt: null, relayAvailable: false }, + { iceServers: [] }, QUALITY_PROFILES["720p30"], { sendSignal: () => true }, ); @@ -837,7 +837,7 @@ describe("ViewerRelay downstream ownership", () => { it("clears snapshots across child replacement, stop, and dispose", async () => { const relay = new ViewerRelay( - { iceServers: [], expiresAt: null, relayAvailable: false }, + { iceServers: [] }, QUALITY_PROFILES["720p30"], { sendSignal: () => true }, ); @@ -873,7 +873,7 @@ describe("ViewerRelay downstream ownership", () => { resolveOldStats = resolve; }); const relay = new ViewerRelay( - { iceServers: [], expiresAt: null, relayAvailable: false }, + { iceServers: [] }, QUALITY_PROFILES["720p30"], { sendSignal: () => true }, ); @@ -917,7 +917,7 @@ describe("ViewerRelay downstream ownership", () => { }); const targets: string[] = []; const relay = new ViewerRelay( - { iceServers: [], expiresAt: null, relayAvailable: false }, + { iceServers: [] }, QUALITY_PROFILES["720p30"], { sendSignal: (peerId) => { @@ -942,7 +942,7 @@ describe("ViewerRelay downstream ownership", () => { FakePeerConnection.offersFailing = 2; const targets: string[] = []; const relay = new ViewerRelay( - { iceServers: [], expiresAt: null, relayAvailable: false }, + { iceServers: [] }, QUALITY_PROFILES["720p30"], { sendSignal: (peerId) => { @@ -972,7 +972,7 @@ describe("ViewerRelay downstream ownership", () => { it("rebuilds a stalled peer but preserves a connected peer on reconciliation", async () => { const targets: string[] = []; const relay = new ViewerRelay( - { iceServers: [], expiresAt: null, relayAvailable: false }, + { iceServers: [] }, QUALITY_PROFILES["720p30"], { sendSignal: (peerId) => { @@ -1004,7 +1004,7 @@ describe("ViewerRelay downstream ownership", () => { it("keeps one downstream connection across upstream stream replacement", async () => { const targets: string[] = []; const relay = new ViewerRelay( - { iceServers: [], expiresAt: null, relayAvailable: false }, + { iceServers: [] }, QUALITY_PROFILES["1080p60"], { sendSignal: (peerId) => { @@ -1047,7 +1047,7 @@ describe("ViewerRelay downstream ownership", () => { degradationPreference: "balanced", } as const; const relay = new ViewerRelay( - { iceServers: [], expiresAt: null, relayAvailable: false }, + { iceServers: [] }, QUALITY_PROFILES["1080p60"], { sendSignal: () => true }, ); diff --git a/tests/ice.test.ts b/tests/ice.test.ts new file mode 100644 index 0000000..5634706 --- /dev/null +++ b/tests/ice.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { createIceConfig } from "../src/server/ice.ts"; + +describe("ordinary ICE configuration", () => { + it("returns only the configured STUN servers", () => { + expect( + createIceConfig({ + stunUrls: [ + "stun:stun-a.example.test:3478", + "stun:stun-b.example.test:3478", + ], + }), + ).toEqual({ + iceServers: [ + { + urls: [ + "stun:stun-a.example.test:3478", + "stun:stun-b.example.test:3478", + ], + }, + ], + }); + }); + + it("keeps local development explicit when STUN is absent", () => { + expect(createIceConfig({ stunUrls: [] })).toEqual({ iceServers: [] }); + }); +}); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 15845e7..9786219 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -20,6 +20,12 @@ const qualitySettings = { } as const; describe("client signaling protocol", () => { + it("rejects the removed all-room ICE refresh request", () => { + expect(clientMessageSchema.safeParse({ type: "refresh-ice" }).success).toBe( + false, + ); + }); + it("accepts a bounded authentication message", () => { expect( decodeClientMessage( @@ -267,6 +273,45 @@ describe("client signaling protocol", () => { }); describe("server signaling protocol", () => { + it("rejects the removed all-room TURN credential wire", () => { + expect( + serverMessageSchema.safeParse({ + type: "ice-config", + iceConfig: { iceServers: [] }, + }).success, + ).toBe(false); + expect( + serverMessageSchema.safeParse({ + ...authenticatedMessage(8), + iceConfig: { + iceServers: [ + { + urls: "turn:relay.example.test:3478", + username: "expires:viewer_12345678", + credential: "credential", + }, + ], + }, + }).success, + ).toBe(false); + expect( + serverMessageSchema.safeParse({ + ...authenticatedMessage(8), + iceConfig: { + iceServers: [{ urls: "turn:relay.example.test:3478" }], + }, + }).success, + ).toBe(false); + expect( + serverMessageSchema.safeParse({ + ...authenticatedMessage(8), + iceConfig: { + iceServers: [{ urls: "stuns:stun.example.test:5349" }], + }, + }).success, + ).toBe(false); + }); + function authenticatedMessage(maxViewers: number, viewerPeerIds: string[] = []) { return { type: "authenticated", @@ -279,8 +324,6 @@ describe("server signaling protocol", () => { viewerPeerIds, iceConfig: { iceServers: [], - expiresAt: null, - relayAvailable: false, }, }; } diff --git a/tests/server-config.test.ts b/tests/server-config.test.ts index 39a9ca1..bdd3578 100644 --- a/tests/server-config.test.ts +++ b/tests/server-config.test.ts @@ -2,17 +2,14 @@ import { describe, expect, it } from "vitest"; import { loadConfig } from "../src/server/config.ts"; -const requiredProductionTurnUrls = - "turn:turn.test:3478?transport=udp,turn:turn.test:3478?transport=tcp"; - describe("server configuration", () => { - it("allows development without TURN and defaults the origin", () => { + it("allows local development without STUN and defaults the origin", () => { const config = loadConfig({ NODE_ENV: "development", PORT: "9123" }); expect(config.listenHost).toBe("0.0.0.0"); expect(config.publicBaseUrl.href).toBe("http://localhost:9123/"); expect(config.allowedOrigins).toEqual(new Set(["http://localhost:9123"])); - expect(config.turnUrls).toEqual([]); + expect(config.stunUrls).toEqual([]); expect(config.maxViewersPerRoom).toBe(8); expect(config.peerAssistedMedia).toBe(false); expect(config.peerAssistedRoomIds).toBeUndefined(); @@ -104,6 +101,8 @@ describe("server configuration", () => { loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test", + STUN_URLS: "stun:stun.test:3478", + PEER_ASSISTED_MEDIA: "true", LIVEKIT_URL: "ws://livekit.test:7880", LIVEKIT_API_KEY: "test-key", LIVEKIT_API_SECRET: "s".repeat(32), @@ -131,35 +130,9 @@ describe("server configuration", () => { } }); - it("keeps production STUN and TURN mandatory with LiveKit configured", () => { - expect(() => - loadConfig({ - NODE_ENV: "production", - PUBLIC_BASE_URL: "https://share.test", - PEER_ASSISTED_MEDIA: "true", - LIVEKIT_URL: "wss://livekit.test", - LIVEKIT_API_KEY: "test-key", - LIVEKIT_API_SECRET: "s".repeat(32), - }), - ).toThrow("TURN is required in production"); - }); - it.each([ { ACCESS_PASSWORD: "x".repeat(32), - TURN_URLS: "turn:turn.test:3478", - TURN_SHARED_SECRET: "x".repeat(32), - }, - { - ACCESS_PASSWORD: "x".repeat(32), - PEER_ASSISTED_MEDIA: "true", - LIVEKIT_URL: "wss://livekit.test", - LIVEKIT_API_KEY: "test-key", - LIVEKIT_API_SECRET: "x".repeat(32), - }, - { - TURN_URLS: "turn:turn.test:3478", - TURN_SHARED_SECRET: "x".repeat(32), PEER_ASSISTED_MEDIA: "true", LIVEKIT_URL: "wss://livekit.test", LIVEKIT_API_KEY: "test-key", @@ -173,7 +146,7 @@ describe("server configuration", () => { }, ])("rejects reused infrastructure secrets", (environment) => { expect(() => loadConfig(environment)).toThrow( - "ACCESS_PASSWORD, TURN_SHARED_SECRET, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must use independent values", + "ACCESS_PASSWORD, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must use independent values", ); }); @@ -221,26 +194,22 @@ describe("server configuration", () => { ).toThrow("PEER_ASSISTED_ROOM_IDS requires PEER_ASSISTED_MEDIA=true"); }); - it.each([ - "0", - "01", - "1234567890123", - "room-1", - "1,,2", - "1,1", - ])("rejects an invalid peer-assisted room allowlist: %s", (roomIds) => { - expect(() => - loadConfig({ - PEER_ASSISTED_MEDIA: "true", - PEER_ASSISTED_ROOM_IDS: roomIds, - }), - ).toThrow("PEER_ASSISTED_ROOM_IDS"); - }); + it.each(["0", "01", "1234567890123", "room-1", "1,,2", "1,1"])( + "rejects an invalid peer-assisted room allowlist: %s", + (roomIds) => { + expect(() => + loadConfig({ + PEER_ASSISTED_MEDIA: "true", + PEER_ASSISTED_ROOM_IDS: roomIds, + }), + ).toThrow("PEER_ASSISTED_ROOM_IDS"); + }, + ); it("allows an explicit loopback listen host", () => { - const config = loadConfig({ LISTEN_HOST: " 127.0.0.1 " }); - - expect(config.listenHost).toBe("127.0.0.1"); + expect(loadConfig({ LISTEN_HOST: " 127.0.0.1 " }).listenHost).toBe( + "127.0.0.1", + ); }); it.each([ @@ -254,53 +223,40 @@ describe("server configuration", () => { ); }); - it("allows an optional access password but requires TURN in production", () => { + it("requires STUN but not TURN in production", () => { expect(() => loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test" }), - ).toThrow("TURN is required"); + ).toThrow("STUN is required in production"); - expect(() => - loadConfig({ - NODE_ENV: "production", - PUBLIC_BASE_URL: "https://share.test", - ACCESS_PASSWORD: "c".repeat(12), - }), - ).toThrow("TURN is required"); - - expect(() => - loadConfig({ - NODE_ENV: "production", - PUBLIC_BASE_URL: "https://share.test", - ACCESS_PASSWORD: "c".repeat(12), - TURN_URLS: "turn:turn.test:3478", - TURN_SHARED_SECRET: "t".repeat(32), - }), - ).toThrow("STUN is required"); - }); - - it("accepts complete production configuration", () => { const config = loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test", - STUN_URLS: "stun:turn.test:3478,stuns:turn.test:5349", - TURN_URLS: requiredProductionTurnUrls, - TURN_SHARED_SECRET: "t".repeat(32), + STUN_URLS: "stun:stun.test:3478", + }); + expect(config.stunUrls).toEqual(["stun:stun.test:3478"]); + }); + + it("allows STUN-only production with LiveKit configured", () => { + const config = loadConfig({ + NODE_ENV: "production", + PUBLIC_BASE_URL: "https://share.test", + STUN_URLS: "stun:stun.test:3478", + PEER_ASSISTED_MEDIA: "true", + LIVEKIT_URL: "wss://livekit.test", + LIVEKIT_API_KEY: "test-key", + LIVEKIT_API_SECRET: "s".repeat(32), }); - expect(config.accessPassword).toBeUndefined(); - expect(config.roomDatabasePath).toBeUndefined(); - expect(config.turnUrls).toHaveLength(2); - expect(config.turnSharedSecret).toBe("t".repeat(32)); + expect(config.livekitFallback?.url).toBe("wss://livekit.test"); }); it.each([1, 16])( "accepts a per-room viewer limit at boundary %i", (maxViewersPerRoom) => { - const config = loadConfig({ - MAX_VIEWERS_PER_ROOM: String(maxViewersPerRoom), - }); - - expect(config.maxViewersPerRoom).toBe(maxViewersPerRoom); + expect( + loadConfig({ MAX_VIEWERS_PER_ROOM: String(maxViewersPerRoom) }) + .maxViewersPerRoom, + ).toBe(maxViewersPerRoom); }, ); @@ -313,105 +269,43 @@ describe("server configuration", () => { }, ); - it.each([5349, 443])( - "accepts optional TURN/TLS over TCP on production port %i", - (tlsPort) => { - const config = loadConfig({ - NODE_ENV: "production", - PUBLIC_BASE_URL: "https://share.test", - ACCESS_PASSWORD: "c".repeat(12), - STUN_URLS: "stun:turn.test:3478", - TURN_URLS: `${requiredProductionTurnUrls},turns:turn.test:${tlsPort}?transport=tcp`, - TURN_SHARED_SECRET: "t".repeat(32), - }); - - expect(config.turnUrls.at(-1)).toBe( - `turns:turn.test:${tlsPort}?transport=tcp`, - ); - }, - ); - - it("does not require transport coverage outside production", () => { - const config = loadConfig({ - TURN_URLS: "turn:turn.test:3478", - TURN_SHARED_SECRET: "development-secret", - }); - - expect(config.turnUrls).toEqual(["turn:turn.test:3478"]); - }); - - it("accepts a valid uppercase scheme, IPv6 host, and secure UDP URL", () => { - const config = loadConfig({ - TURN_URLS: "TURNS:[2001:db8::1]:443?transport=udp", - TURN_SHARED_SECRET: "development-secret", - }); - - expect(config.turnUrls).toEqual([ - "TURNS:[2001:db8::1]:443?transport=udp", - ]); - }); - - it("rejects every malformed ICE URL, including extras after valid TURN URLs", () => { - const invalidTurnUrls = [ - "turn:turn.test/path?transport=udp", - "turn:turn.test:3478/?transport=udp", - "turn:turn.test:3478?transport=udp#", - "turn:turn.test:3478?transport=udp&", - "turn:turn.test:3478?", - "turn:turn.test:?transport=udp", - "turn:turn.test:0?transport=udp", - "turn:user@turn.test:3478?transport=udp", - "turn:turn.test:3478?transport=UDP", - "turn:turn.test:3478?transport=%75dp", - ]; - for (const invalidUrl of invalidTurnUrls) { - expect(() => - loadConfig({ - NODE_ENV: "production", - PUBLIC_BASE_URL: "https://share.test", - ACCESS_PASSWORD: "c".repeat(12), - STUN_URLS: "stun:turn.test:3478", - TURN_URLS: `${requiredProductionTurnUrls},${invalidUrl}`, - TURN_SHARED_SECRET: "t".repeat(32), - }), - ).toThrow("TURN_URLS contains an invalid ICE URL"); - } - - for (const invalidUrl of [ - "stun:turn.test/path", - "stun:turn.test?transport=udp", - "stun:turn.test#", - ]) { - expect(() => loadConfig({ STUN_URLS: invalidUrl })).toThrow( - "STUN_URLS contains an invalid ICE URL", - ); - } + it("accepts uppercase STUN schemes and IPv6 hosts", () => { + expect( + loadConfig({ STUN_URLS: "STUN:[2001:db8::1]:3478" }).stunUrls, + ).toEqual(["STUN:[2001:db8::1]:3478"]); }); it.each([ - { - missing: "TURN/UDP", - urls: - "turn:turn.test:3478,turn:turn.test:3478?transport=tcp,turns:turn.test:443?transport=tcp", - }, - { - missing: "TURN/TCP", - urls: - "turn:turn.test:3478?transport=udp,turn:turn.test:3478", - }, - ])("rejects production TURN URLs missing $missing", ({ urls }) => { + "stun:stun.test/path", + "stun:stun.test?transport=udp", + "stun:stun.test#fragment", + "stun:stun.test:", + "stun:stun.test:0", + "stun:user@stun.test:3478", + ])("rejects a malformed STUN URL: %s", (invalidUrl) => { + expect(() => loadConfig({ STUN_URLS: invalidUrl })).toThrow( + "STUN_URLS contains an invalid ICE URL", + ); + }); + + it("rejects non-STUN schemes in the ordinary ICE configuration", () => { + expect(() => loadConfig({ STUN_URLS: "turn:turn.test:3478" })).toThrow( + "STUN_URLS contains an unsupported URL scheme", + ); + expect(() => loadConfig({ STUN_URLS: "stuns:stun.test:5349" })).toThrow( + "STUN_URLS contains an unsupported URL scheme", + ); + }); + + it("rejects more STUN URLs than the authenticated wire can carry", () => { expect(() => loadConfig({ - NODE_ENV: "production", - PUBLIC_BASE_URL: "https://share.test", - ACCESS_PASSWORD: "c".repeat(12), - STUN_URLS: "stun:turn.test:3478", - TURN_URLS: urls, - TURN_SHARED_SECRET: "t".repeat(32), + STUN_URLS: Array.from( + { length: 9 }, + (_, index) => `stun:stun-${index}.test:3478`, + ).join(","), }), - ).toThrow( - "TURN_URLS must include explicit TURN/UDP and TURN/TCP endpoints in production", - ); + ).toThrow("STUN_URLS must contain at most 8 URLs"); }); it("accepts a short production access password", () => { @@ -419,31 +313,15 @@ describe("server configuration", () => { NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test", ACCESS_PASSWORD: "short-pass1", - STUN_URLS: "stun:turn.test:3478", - TURN_URLS: requiredProductionTurnUrls, - TURN_SHARED_SECRET: "t".repeat(32), + STUN_URLS: "stun:stun.test:3478", }); - expect(config.accessPassword).toBe("short-pass1"); }); - it("rejects a weak production TURN secret", () => { - expect(() => - loadConfig({ - NODE_ENV: "production", - PUBLIC_BASE_URL: "https://share.test", - ACCESS_PASSWORD: "x", - STUN_URLS: "stun:turn.test:3478", - TURN_URLS: requiredProductionTurnUrls, - TURN_SHARED_SECRET: "too-short", - }), - ).toThrow("TURN_SHARED_SECRET must contain at least 32 bytes"); - }); - it("requires access protection for a persistent room database", () => { - expect(() => - loadConfig({ ROOM_DATABASE_PATH: "rooms.sqlite" }), - ).toThrow("ROOM_DATABASE_PATH requires ACCESS_PASSWORD"); + expect(() => loadConfig({ ROOM_DATABASE_PATH: "rooms.sqlite" })).toThrow( + "ROOM_DATABASE_PATH requires ACCESS_PASSWORD", + ); const config = loadConfig({ ACCESS_PASSWORD: "x", @@ -459,6 +337,7 @@ describe("server configuration", () => { PUBLIC_BASE_URL: "https://share.test", ACCESS_PASSWORD: "x", ROOM_DATABASE_PATH: ":memory:", + STUN_URLS: "stun:stun.test:3478", }), ).toThrow("ROOM_DATABASE_PATH must be file-backed in production"); }); @@ -471,22 +350,4 @@ describe("server configuration", () => { ); }, ); - - it("rejects partial TURN settings and mismatched ICE schemes", () => { - expect(() => loadConfig({ TURN_URLS: "turn:turn.test:3478" })).toThrow( - "TURN_URLS and TURN_SHARED_SECRET", - ); - expect(() => loadConfig({ STUN_URLS: "https://stun.test" })).toThrow( - "STUN_URLS contains an unsupported URL scheme", - ); - expect(() => - loadConfig({ - TURN_URLS: "stun:turn.test:3478", - TURN_SHARED_SECRET: "turn-secret", - }), - ).toThrow("TURN_URLS contains an unsupported URL scheme"); - expect(() => loadConfig({ TURN_CREDENTIAL_TTL_SECONDS: "3601" })).toThrow( - "TURN_CREDENTIAL_TTL_SECONDS must be between 60 and 3600", - ); - }); }); diff --git a/tests/server-http.test.ts b/tests/server-http.test.ts index 8980ead..945c394 100644 --- a/tests/server-http.test.ts +++ b/tests/server-http.test.ts @@ -44,8 +44,6 @@ function testConfig(overrides: Partial = {}): ServerConfig { maxViewersPerRoom: 8, peerAssistedMedia: false, stunUrls: [], - turnUrls: [], - turnCredentialTtlSeconds: 3_600, ...overrides, }; } diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts index 64d8313..8cea02f 100644 --- a/tests/server-signal.test.ts +++ b/tests/server-signal.test.ts @@ -145,8 +145,6 @@ function testConfig(): ServerConfig { maxViewersPerRoom: 8, peerAssistedMedia: false, stunUrls: [], - turnUrls: [], - turnCredentialTtlSeconds: 3_600, }; } @@ -161,11 +159,13 @@ async function startHarness( persistent?: boolean; peerAssistedMedia?: boolean; peerAssistedPrimaryRoomOnly?: boolean; + stunUrls?: readonly string[]; } = {}, ): Promise { const config = testConfig(); config.accessPassword = overrides.accessPassword; config.peerAssistedMedia = overrides.peerAssistedMedia ?? false; + config.stunUrls = overrides.stunUrls ?? []; const maxViewersPerRoom = overrides.maxViewersPerRoom ?? 8; config.maxViewersPerRoom = maxViewersPerRoom; const roomStore = new RoomStore({ @@ -205,6 +205,7 @@ async function startSfuHarness(options: { maxRoots?: number; viewerDisconnectGraceMs?: number; peerAssistedPrimaryRoomOnly?: boolean; + stunUrls?: readonly string[]; }): Promise { const roomStore = new RoomStore({ ttlMs: 14_400_000, @@ -230,9 +231,7 @@ async function startSfuHarness(options: { prepareTimeoutMs: options.prepareTimeoutMs, }, ice: { - stunUrls: [], - turnUrls: [], - credentialTtlSeconds: 3_600, + stunUrls: options.stunUrls ?? [], }, allowedOrigins: new Set([allowedOrigin]), authorizeUpgrade: () => true, @@ -878,10 +877,11 @@ describe("WebSocket signaling", () => { await viewer.inbox.expectNone(30); }); - it("isolates allowlisted hybrid and legacy rooms in one process", async () => { + it("isolates the routing allowlist while ordinary ICE stays STUN-only", async () => { const harness = await startSfuHarness({ tokenIssuer: { issueToken: async () => "unused-test-token" }, peerAssistedPrimaryRoomOnly: true, + stunUrls: ["stun:stun.example.test:3478"], }); const legacyRoom = harness.roomStore.createRoom(); @@ -895,6 +895,9 @@ describe("WebSocket signaling", () => { ), ); expect(hybridHostAuth.sfuStandbyUrl).toBe("wss://sfu.example.test"); + expect(hybridHostAuth.iceConfig).toEqual({ + iceServers: [{ urls: ["stun:stun.example.test:3478"] }], + }); const legacyHost = await openClient(harness.webSocketUrl); const legacyHostAuth = await authenticate( @@ -916,6 +919,12 @@ describe("WebSocket signaling", () => { "viewerPeerIds", ].sort(), ); + expect(legacyHostAuth.iceConfig).toEqual(hybridHostAuth.iceConfig); + expect( + legacyHostAuth.iceConfig.iceServers.every( + (server) => !("username" in server) && !("credential" in server), + ), + ).toBe(true); const legacyViewer = await openClient(harness.webSocketUrl); const legacyViewerAuth = await authenticate( diff --git a/tests/turn.test.ts b/tests/turn.test.ts deleted file mode 100644 index acf9ecb..0000000 --- a/tests/turn.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { createIceConfig, createTurnCredentials } from "../src/server/turn.ts"; - -describe("TURN credentials", () => { - it("matches a fixed coturn REST HMAC-SHA1 vector", () => { - expect( - createTurnCredentials("test-secret", "room_peer", 3_600, 1_700_000_000_000), - ).toEqual({ - username: "1700003600:room_peer", - credential: "h3+0FAYKf7T7n49pOZborViEEVQ=", - expiresAt: "2023-11-14T23:13:20.000Z", - }); - }); - - it("does not issue credentials beyond room expiry", () => { - const now = 1_700_000_000_000; - const credentials = createTurnCredentials( - "test-secret", - "room_peer", - 3_600, - now, - now + 600_000, - ); - - expect(credentials.username).toBe("1700000600:room_peer"); - expect(Date.parse(credentials.expiresAt)).toBe(now + 600_000); - }); - - it("returns an explicit STUN-only configuration when TURN is absent", () => { - expect( - createIceConfig( - { - stunUrls: ["stun:stun.example.test:3478"], - turnUrls: [], - credentialTtlSeconds: 3_600, - }, - "subject", - ), - ).toEqual({ - iceServers: [{ urls: ["stun:stun.example.test:3478"] }], - expiresAt: null, - relayAvailable: false, - }); - }); - - it("does not advertise unusable relay credentials in the final room second", () => { - const now = 1_700_000_000_900; - expect( - createIceConfig( - { - stunUrls: [], - turnUrls: ["turn:turn.example.test:3478"], - turnSharedSecret: "test-secret", - credentialTtlSeconds: 3_600, - }, - "subject", - now, - now + 50, - ), - ).toEqual({ iceServers: [], expiresAt: null, relayAvailable: false }); - }); -}); diff --git a/tests/viewer-message-authority.test.ts b/tests/viewer-message-authority.test.ts index f602363..7d759e7 100644 --- a/tests/viewer-message-authority.test.ts +++ b/tests/viewer-message-authority.test.ts @@ -19,10 +19,10 @@ describe("ViewerMessageAuthority", () => { expect(authority.owns(authenticated)).toBe(false); }); - it("invalidates pending work on a newer auth, ICE snapshot, or termination", () => { + it("invalidates pending work on a newer auth or termination", () => { const authority = new ViewerMessageAuthority(); const first = authority.tokenFor(message("authenticated")); - authority.tokenFor(message("ice-config")); + authority.tokenFor(message("room-closed")); expect(authority.owns(first)).toBe(false); const second = authority.tokenFor(message("authenticated")); diff --git a/tests/viewer-peer-generation.test.ts b/tests/viewer-peer-generation.test.ts index 83f4a16..5870e31 100644 --- a/tests/viewer-peer-generation.test.ts +++ b/tests/viewer-peer-generation.test.ts @@ -133,7 +133,7 @@ function createPeer( signalPeers: string[] = [], ): ViewerPeer { return new ViewerPeer( - { iceServers: [], expiresAt: null, relayAvailable: false }, + { iceServers: [] }, { sendSignal: (peerId, payload) => { signalPeers.push(peerId); @@ -189,7 +189,7 @@ describe("ViewerPeer connection generations", () => { }> = []; const exhausted: Array<{ peerId: string; connectionId: string }> = []; const peer = new ViewerPeer( - { iceServers: [], expiresAt: null, relayAvailable: false }, + { iceServers: [] }, { sendSignal: () => true, sendRestartRequest: (peerId, connectionId, rebuild) => { @@ -236,7 +236,7 @@ describe("ViewerPeer connection generations", () => { let reportAvailable = false; const exhausted: string[] = []; const peer = new ViewerPeer( - { iceServers: [], expiresAt: null, relayAvailable: false }, + { iceServers: [] }, { sendSignal: () => true, sendRestartRequest: () => true, @@ -276,7 +276,7 @@ describe("ViewerPeer connection generations", () => { rebuild: boolean; }> = []; const peer = new ViewerPeer( - { iceServers: [], expiresAt: null, relayAvailable: false }, + { iceServers: [] }, { sendSignal: () => true, sendRestartRequest: (peerId, connectionId, rebuild) => { @@ -307,7 +307,7 @@ describe("ViewerPeer connection generations", () => { rebuild: boolean; }> = []; const peer = new ViewerPeer( - { iceServers: [], expiresAt: null, relayAvailable: false }, + { iceServers: [] }, { sendSignal: () => false, sendRestartRequest: (peerId, connectionId, rebuild) => { @@ -339,7 +339,7 @@ describe("ViewerPeer connection generations", () => { rebuild: boolean; }> = []; const peer = new ViewerPeer( - { iceServers: [], expiresAt: null, relayAvailable: false }, + { iceServers: [] }, { sendSignal: (peerId, payload) => { signalPeers.push(peerId); From ad10036d1b370f954dc5b2097d6d62183f39feba Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:08:49 +0800 Subject: [PATCH 2/5] fix(media): close the UDP candidate migration boundary --- .env.example | 4 +- README.md | 2 +- deploy/coturn/turnserver.conf.example | 1 - deploy/livekit/livekit.yaml.example | 11 +- deploy/nginx/share.bonfire.icu.conf.example | 37 +-- .../0004-peer-assisted-media-experiment.md | 20 +- .../0005-automatic-hybrid-media-routing.md | 23 +- docs/deployment.md | 58 +++-- docs/project-memory.md | 4 +- docs/research/low-server-media-routes.md | 18 +- docs/status.md | 2 +- docs/方案设计.md | 38 +-- docs/需求理解.md | 2 +- scripts/peer-assisted-benchmark.ts | 55 +++- src/client/lib/signaling.ts | 34 ++- src/client/pages/HostPage.tsx | 17 -- src/client/pages/ViewerPage.tsx | 18 +- src/client/webrtc/host-peer.ts | 3 - src/client/webrtc/viewer-peer.ts | 3 - src/client/webrtc/viewer-relay.ts | 2 - src/server/config.ts | 85 ++----- src/server/signaling.ts | 43 ++-- src/shared/protocol.ts | 45 +++- tests/client-state.test.ts | 71 +++++- tests/media-assignment.test.ts | 2 +- tests/protocol.test.ts | 32 ++- tests/server-config.test.ts | 53 +++- tests/server-http.test.ts | 1 + tests/server-signal.test.ts | 237 +++++++++--------- 29 files changed, 548 insertions(+), 373 deletions(-) diff --git a/.env.example b/.env.example index a9336a7..3f6b6aa 100644 --- a/.env.example +++ b/.env.example @@ -18,8 +18,8 @@ MAX_ROOMS=1000 MAX_VIEWERS_PER_ROOM=8 # Experimental standard-WebRTC relay chains. Keep false outside measured tests. PEER_ASSISTED_MEDIA=false -# Optional exact-room canary when peer assistance is true. Use comma-separated -# positive numeric room IDs. Missing or empty means every room for compatibility. +# Required exact-room canary when peer assistance is true. Use a non-empty, +# comma-separated list of positive numeric room IDs; missing or blank fails startup. PEER_ASSISTED_ROOM_IDS= # Optional automatic SFU fallback capacity. Set PEER_ASSISTED_MEDIA=true and diff --git a/README.md b/README.md index e72945d..8e98f82 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ repository candidate deliberately replaces that contract: ordinary authenticated ICE contains only self-hosted STUN, LiveKit separately serves normally one or two SFU/UDP roots, and exhausted paths fail clearly after bounded recovery. Run it on an isolated instance for exact-room acceptance rather than retaining a same-process -legacy transport branch. Optional generation-bound TURN for a selected exceptional +old-release compatibility branch. Optional generation-bound TURN for a selected exceptional edge is not implemented and requires a separate evidence-backed change. HTTPS/WSS continues to use TLS/TCP independently. diff --git a/deploy/coturn/turnserver.conf.example b/deploy/coturn/turnserver.conf.example index 2e78f89..cb8a9e6 100644 --- a/deploy/coturn/turnserver.conf.example +++ b/deploy/coturn/turnserver.conf.example @@ -9,7 +9,6 @@ listening-port=3478 stun-only no-tcp no-tls -no-dtls # Keep automatic address selection on a single-homed public host. On a # multi-homed host, bind only the intended public interface. diff --git a/deploy/livekit/livekit.yaml.example b/deploy/livekit/livekit.yaml.example index d578a96..15b44bd 100644 --- a/deploy/livekit/livekit.yaml.example +++ b/deploy/livekit/livekit.yaml.example @@ -10,6 +10,12 @@ rtc: # These media ports must be reached directly by clients. Port 7880 is only # for the same-host reverse proxy and must be denied by every public firewall. udp_port: 7882 + tcp_port: 0 + allow_tcp_fallback: false + # Do not inherit LiveKit's public Google STUN defaults. This is the same + # deployment-owned STUN-only listener advertised to ordinary peer edges. + stun_servers: + - stun.example.com:3478 use_external_ip: true room: @@ -22,5 +28,6 @@ logging: level: info pion_level: error -# ICE/TCP, external or embedded TURN, Redis, recording, ingress, egress, and -# webhooks are omitted on purpose. This candidate is one process on one node. +# External or embedded TURN, Redis, recording, ingress, egress, and webhooks are +# omitted on purpose. ICE/TCP fallback is explicitly disabled above. This +# candidate is one process on one node. diff --git a/deploy/nginx/share.bonfire.icu.conf.example b/deploy/nginx/share.bonfire.icu.conf.example index 8e27726..2f1c878 100644 --- a/deploy/nginx/share.bonfire.icu.conf.example +++ b/deploy/nginx/share.bonfire.icu.conf.example @@ -37,9 +37,25 @@ server { add_header X-Frame-Options "DENY" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - # Optional LiveKit signaling for automatic SFU fallback. Set LIVEKIT_URL to - # wss://share.bonfire.icu. Legacy clients use the exact /rtc endpoint. + # Reject the retired unversioned LiveKit endpoint. Keep this exact location + # out of logs because old URLs may still contain a short-lived join token. location = /rtc { + access_log off; + error_log /dev/null crit; + return 404; + } + + # LiveKit client v0 validates this retired path before joining. Reject it + # explicitly so the broader current-v1 proxy cannot accept an old SDK. + location = /rtc/validate { + access_log off; + error_log /dev/null crit; + return 404; + } + + # Current LiveKit clients use v1 paths only. Set + # LIVEKIT_URL to wss://share.bonfire.icu. + location ^~ /rtc/v1 { # The WebSocket query contains a short-lived LiveKit join token. Do not # persist the request target in either access or error logs. access_log off; @@ -58,24 +74,11 @@ server { proxy_buffering off; } - # Current clients use versioned paths below /rtc/. + # Reject any other retired or unknown LiveKit path without logging tokens. location ^~ /rtc/ { - # The WebSocket query contains a short-lived LiveKit join token. Do not - # persist the request target in either access or error logs. access_log off; error_log /dev/null crit; - proxy_pass http://127.0.0.1:7880; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Proto https; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $screener_connection_upgrade; - proxy_read_timeout 5h; - proxy_send_timeout 5h; - proxy_buffering off; + return 404; } location / { diff --git a/docs/adr/0004-peer-assisted-media-experiment.md b/docs/adr/0004-peer-assisted-media-experiment.md index 623bcdf..75afd60 100644 --- a/docs/adr/0004-peer-assisted-media-experiment.md +++ b/docs/adr/0004-peer-assisted-media-experiment.md @@ -44,13 +44,13 @@ cross-mode fallback as a product goal. The experiment is gated by `PEER_ASSISTED_MEDIA`, which defaults to `false`. Enabling it requires `MAX_VIEWERS_PER_ROOM` at or below eight; larger configured rooms fail configuration rather than silently running a different topology. -An optional non-empty `PEER_ASSISTED_ROOM_IDS` deployment allowlist restricts -the experiment to exact valid room IDs. Non-allowlisted rooms must use the -legacy P2P authenticated wire, signaling, quality behavior, and lifecycle and -must never enter `HybridMediaRouter`. Missing or empty retains the original -all-room behavior when the experiment is enabled; isolated canaries therefore -must provide at least one ID. Invalid, empty-list-entry, or duplicate values -fail startup. This adds no UI, percentage rollout, or routing score. +A required non-empty `PEER_ASSISTED_ROOM_IDS` deployment allowlist restricts +the experiment to exact valid room IDs. Non-allowlisted rooms use the current +ordinary P2P authenticated wire, signaling, quality behavior, and lifecycle and +must never enter `HybridMediaRouter`. Missing, blank, invalid, empty-list-entry, +or duplicate values fail startup when the experiment is enabled; there is no +all-room fail-open. This exact-room gate is temporary validation scope and adds +no UI, percentage rollout, or routing score. The signaling server assigns two sticky, balanced chains with a deterministic breadth-first walk. The host has capacity for at most two children and each @@ -66,8 +66,10 @@ host's two-child limit is a hard invariant across join, reconnect, reparent, and recovery paths. If the deterministic topology has no connected eligible parent, the viewer remains admitted but waits without media until a slot becomes reachable; it must not create a third host connection. -Each logical edge still uses its own ICE process and authenticated TURN fallback -when direct connectivity is unavailable. +Each logical edge still uses its own ICE process. In the repository candidate, +ordinary edges receive only STUN and route exhaustion proceeds to the bounded +SFU virtual parent or clear failure; the old production TURN behavior is not a +same-process compatibility branch. Every edge uses the existing standard WebRTC media path. A viewer receives the remote `MediaStream`, renders it, and sends those remote tracks through one new diff --git a/docs/adr/0005-automatic-hybrid-media-routing.md b/docs/adr/0005-automatic-hybrid-media-routing.md index 8178b43..4d25023 100644 --- a/docs/adr/0005-automatic-hybrid-media-routing.md +++ b/docs/adr/0005-automatic-hybrid-media-routing.md @@ -81,20 +81,21 @@ one-host-peer-per-viewer P2P/TURN. The repository candidate now 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 -LiveKit sample also omits ICE/TCP and every TURN service. The SFU controller still +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, so public transport and route admission remain unverified. Production `769de201f7cc` keeps its old coturn relay until an isolated candidate canary passes; rollback is release/instance based, -not a permanent legacy branch in the new code. +not a permanent old-release compatibility branch in the new code. -The repository also supports an optional strict `PEER_ASSISTED_ROOM_IDS` -deployment allowlist. When non-empty, only exact listed room IDs enter the -controller or receive optional LiveKit standby and grants. All other rooms -retain the legacy P2P authentication shape and signaling/quality/lifecycle -behavior. Missing or empty preserves the previous all-room behavior when -`PEER_ASSISTED_MEDIA=true`; malformed or duplicate entries fail startup. The -boundary is deployment-only and intentionally has no browser selector, -percentage framework, or second router. +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 +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. The implementation keeps the LiveKit dependency dormant unless the complete URL, API key, and API secret tuple is present together with `PEER_ASSISTED_MEDIA=true`. It @@ -393,7 +394,7 @@ record which boundary is actually configured and the UI must not claim E2EE. - 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 legacy TURN branch exists. + process-wide STUN-only ordinary ICE contract; no old-release TURN branch exists. - 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 diff --git a/docs/deployment.md b/docs/deployment.md index d1a3304..89eba2d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -58,10 +58,10 @@ should connect. A container can instead use `0.0.0.0` and enforce the intended boundary with port publishing rules or a host firewall. The optional same-host LiveKit layout needs no additional public hostname: -`LIVEKIT_URL=wss://share.example.com` and nginx forwards only the exact `/rtc` -endpoint and paths below `/rtc/` to LiveKit. Its HTTP/WebSocket listener on TCP -7880 is private to nginx, while WebRTC media reaches LiveKit directly on UDP -7882. +`LIVEKIT_URL=wss://share.example.com` and nginx proxies only current `/rtc/v1*` +requests to LiveKit. Retired `/rtc` and `/rtc/validate` endpoints return 404 +without request-target logging. LiveKit's HTTP/WebSocket listener on TCP 7880 is +private to nginx, while WebRTC media reaches LiveKit directly on UDP 7882. ## Candidate boundary and rollback @@ -126,7 +126,8 @@ LIVEKIT_API_SECRET= MAX_SFU_ROOTS_PER_ROOM=2 ``` -`PEER_ASSISTED_ROOM_IDS` is the deployment canary boundary. A non-empty value +`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 1-to-12-digit syntax as room authentication. Duplicate IDs, empty entries, leading zeroes, and malformed IDs fail startup. A non-empty allowlist requires @@ -134,10 +135,9 @@ leading zeroes, and malformed IDs fail startup. A non-empty allowlist requires 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 the -variable or leaving it empty deliberately preserves the earlier all-room -behavior when peer assistance is enabled, so a canary deployment must set at -least one exact ID. There is no browser control or percentage rollout. +the old TURN contract. Omitting or blanking the variable fails startup rather +than enabling every room. There is no browser control, percentage rollout, or +all-room fail-open. `ALLOWED_ORIGINS` must list exact `http` or `https` origins, never `*`. `ACCESS_PASSWORD` is optional: omit it or leave it empty for a public site. A @@ -171,8 +171,9 @@ second non-allowlisted room contains none of `mediaMode`, `qualitySettings`, 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, remove the allowlist -to enable all rooms rather than retaining a permanent canary branch. +does not restore the removed TURN wire. 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. When `ACCESS_PASSWORD` is configured, both host and viewer routes first show the same login gate. Only `POST /api/session` accepts the password in an @@ -228,9 +229,10 @@ access is acceptable or another trusted access layer exists. Production startup requires one to eight syntactically valid `stun:` URLs before the server listens. This validates shape only; it does not prove DNS, firewall, NAT mappings, or external STUN reachability. The current application -does not read `TURN_URLS`, `TURN_SHARED_SECRET`, or -`TURN_CREDENTIAL_TTL_SECONDS`; remove those obsolete values from the candidate -environment instead of assuming they still enable compatibility. +rejects `TURN_URLS`, `TURN_SHARED_SECRET`, or `TURN_CREDENTIAL_TTL_SECONDS` when +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. ## HTTPS and WSS ingress @@ -261,11 +263,10 @@ location / { The tracked [`share.bonfire.icu.conf.example`](../deploy/nginx/share.bonfire.icu.conf.example) follows this simple model and listens directly on public TCP 443. It does not -need an SNI transport router. Its optional exact `/rtc` and `/rtc/` prefix -locations preserve the LiveKit request URI and disable both access logging and -request-line error logging. Legacy `/rtc?token=...` and current versioned -requests carry a short-lived LiveKit JWT in the query string, so neither request -target may fall through to the site's ordinary logs. +need an SNI transport router. Exact `/rtc` and pinned-v0 `/rtc/validate` requests +return 404 with access and request-line error logging disabled; only `/rtc/v1*` +is proxied. Current versioned requests carry a short-lived LiveKit JWT in the +query string, so no `/rtc*` target may fall through to the site's ordinary logs. When Certbot manages the Web certificate, install the tracked [`reload-nginx.sh`](../deploy/certbot/reload-nginx.sh) as an executable under @@ -295,9 +296,11 @@ from its official release assets, generate an independent key pair with `/etc/livekit/keys.yaml`. Keep that file owned by the `livekit` service account with mode `0600`; put the same values in Screener's untracked process secrets. -The example deliberately omits Redis, ICE/TCP, and every recording, ingress, -egress, webhook, external-TURN, and embedded-TURN service. Do not add Redis for -this one-node workload. +The example deliberately omits Redis and every recording, ingress, egress, +webhook, external-TURN, and embedded-TURN service. It explicitly sets +`tcp_port: 0` and `allow_tcp_fallback: false`, and points `stun_servers` at the +deployment's self-hosted STUN listener so pinned LiveKit cannot inherit its +default public Google STUN servers. Do not add Redis for this one-node workload. The service journal is the diagnostic log; keep its retention finite and access restricted. Never enable debug/Pion packet logging continuously or persist JWTs, SDP, ICE candidates, API secrets, or full `/rtc` and `/rtc/*` request targets. @@ -315,7 +318,8 @@ must be its only ingress. UDP 7882 is a direct media listener and must not be placed behind the HTTP reverse proxy. If the host is behind NAT, forward it without port translation and verify the advertised candidate from an external network. UDP-blocked networks are expected to fail clearly in this candidate; -do not add `rtc.tcp_port` or TURN to make a canary pass silently. +do not set `rtc.tcp_port` above zero, enable TCP fallback, or add TURN to make a +canary pass silently. ## Self-hosted STUN @@ -323,8 +327,10 @@ Copy [`deploy/coturn/turnserver.conf.example`](../deploy/coturn/turnserver.conf. to an untracked service-owned location and use the tracked [`coturn.service.example`](../deploy/systemd/coturn.service.example). The config uses coturn's documented `stun-only` mode, ignores every TURN allocation request, -and disables TCP, TLS, and DTLS client listeners. It has no auth secret, user -database, relay port range, certificate, quota, or TURN REST credential. +and disables TCP and TLS client listeners. It has no auth secret, user database, +relay port range, certificate, quota, or TURN REST credential. Pinned coturn +4.17.2 deprecates `no-dtls`, so the tracked config does not use that obsolete +switch or claim a separate DTLS listener gate. On a single-homed public host, automatic listener selection is sufficient. On a multi-homed host, set `listening-ip` to the intended public interface. Open only @@ -359,7 +365,7 @@ Run these checks from real external networks before calling the deployment usabl ordinary direct UDP and host/relay downstream caps remain two/one. 4. Block all UDP on one test client. Verify bounded ICE recovery ends in a clear connection failure, without an ICE/TCP, TURN/TCP, or long pseudo-connected - path. The `?relay=1` diagnostic is expected to fail in this candidate. + path. No force-relay product branch exists in this candidate. 5. Exercise root departure, reconnect, SFU unavailable, route prepare rollback, stop, and source/profile changes. Unaffected subtrees must not migrate. 6. Repeat at 1, 3, 5, and 8 viewers across representative consumer networks. diff --git a/docs/project-memory.md b/docs/project-memory.md index 6688b8c..490c48f 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -39,8 +39,8 @@ Last updated: 2026-08-19 - Access uses optional `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly Strict cookie, internal host token, role-bound signaling, Origin/payload checks, and no accounts/JWT/session map. - With `ROOM_DATABASE_PATH` and the password, SQLite stores room ID and host-token digest; links persist and room `1` survived deployment. Without it, rooms are temporary. - Production uses nginx, Node.js 24.19.0, and authenticated coturn 4.17.2 at `turn.bonfire.icu:3478`; public STUN, TURN/UDP, TURN/TCP, and relay-only traffic pass. TURN/TLS is off. -- Candidate migration is process-wide: ordinary ICE is STUN-only, coturn disables TCP/TLS/DTLS, LiveKit 1.13.5 uses separate UDP participant ICE, and old TURN parser/signer/refresh/UI/SNI artifacts are gone. Old production is an isolated rollback. -- The default-off ADR-0005 controller has no production peer/SFU configuration. `PEER_ASSISTED_ROOM_IDS` restricts exact canary rooms; unlisted rooms stay P2P and empty/missing means all. +- Candidate migration is process-wide: ordinary ICE is STUN-only; coturn uses `stun-only`/`no-tcp`/`no-tls` without deprecated `no-dtls`; LiveKit 1.13.5 explicitly disables TCP fallback, uses deployment STUN and separate UDP participant ICE. Old TURN parser/signer/refresh/UI/SNI artifacts are gone; old production is isolated rollback. +- The default-off ADR-0005 controller has no production peer/SFU configuration. Enabling it requires non-empty exact `PEER_ASSISTED_ROOM_IDS`; missing/blank fails startup and unlisted rooms use current ordinary P2P. `screener-v1` is the single signaling literal; mismatch terminates once with a refresh prompt. - When enabled, peer recovery precedes allowlisted SFU; session revisions prepare/commit/abort and active SFU gets one token refresh before failback. Browser relays re-encode; native sharing is outside product code. - Chrome 151/LiveKit localhost A/B cut failure-to-active/render from 1.481/2.257 seconds to 0.200/0.320; 31 new frames and 25 ms sampling kept host edges at two. It is headless synthetic 720p30 and includes SDK/network prewarm, not public-network evidence. - Draft native ladder #16/#18/#22/#23/#25/#28 has one bounded Chrome 720p30 loop: one WebCodecs object feeds two Pion/browser legs, applies an experiment-only minimum stock-GCC target, and recovers one isolated loss. It does not prove one hardware encode. diff --git a/docs/research/low-server-media-routes.md b/docs/research/low-server-media-routes.md index efab180..9c85123 100644 --- a/docs/research/low-server-media-routes.md +++ b/docs/research/low-server-media-routes.md @@ -195,12 +195,14 @@ 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 -`stun-only`; the LiveKit example exposes only ICE/UDP mux 7882 and configures no -external or embedded TURN. Candidate validation and rollback use isolated -instances rather than a process-wide legacy branch. HTTPS/WSS remains TLS/TCP. -Coturn 4.17.2 documents `stun-only` as ignoring TURN requests and independently -provides `no-tcp`, `no-tls`, and `no-dtls` listener switches. This makes coturn -the explicit self-hosted STUN owner without retaining a relay allocation surface. +`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. 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 @@ -233,8 +235,8 @@ 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 no - `rtc.tcp_port` and no external or embedded TURN. Verify ordinary peers and +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 diff --git a/docs/status.md b/docs/status.md index 2ff3b0d..8e44fa6 100644 --- a/docs/status.md +++ b/docs/status.md @@ -40,7 +40,7 @@ unconfigured, so production remains one host connection per viewer and P2P/TURN. - ADR-0007 remains incomplete. A+B/C browser support and the reported case are unverified; the controller, `LOW`, and an authenticated SFU last-hop C generation are absent. Weak paths must share one `LOW`; fail closed protects `HIGH` only exceptionally, and an unreliable supported cohort fails acceptance. - Browser relays re-encode. ADR-0006 is no-go-unclassified: downstream checkpoints and two-edge/FIFO/TURN gates are absent. Spikes prove one WebCodecs object, not hardware; GCC+RTX is no-go and no-RTX weakens stats. - Browser fanout is host two/viewer one; any accepted endpoint relay stays capped at two downstream edges. -- ADR-0005's candidate is process-wide STUN-only ordinary ICE plus separate LiveKit SFU/UDP and bounded failure, with no all-room TURN wire. It is default-off and lacks public gates; the old release is rollback. +- ADR-0005's candidate is process-wide STUN-only ordinary ICE plus separate LiveKit SFU/UDP and bounded failure, with no all-room TURN wire. It is default-off; enabling it requires non-empty exact room IDs, and missing/blank fails startup. The old release is isolated rollback. - Pinned LiveKit 1.13.5 Dynacast enables all qualities at or below the room's maximum request, so a `HIGH` root is expected to keep `LOW` enabled. It remains a bounded rejection/verification spike, not evidence that on-demand `LOW` can stop. - The corrected standby smoke is localhost/headless/video-only; public DNS/TLS reuse, transport, audio, shaping, load, mobile, endurance, and sub-25 ms overlap remain open. diff --git a/docs/方案设计.md b/docs/方案设计.md index 5af8531..7bfcbcb 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -2,11 +2,11 @@ ## 2026-08-18 14:45 -状态:已接受的首个 PoC 基线。本文不接受后续媒体拓扑;peer-assisted 仅由 ADR-0004 提议为独立实验。 +状态:首个 PoC 基线及其已接受扩展的当前设计。`769de201f7cc` 是仍在运行的旧 production P2P/TURN 基线;下文明确标注的 repository candidate 是隔离验证中的 STUN-only ordinary ICE、exact-room peer-assisted 与 LiveKit SFU/UDP,二者不是同进程兼容路径。 ## 目标与边界 -首个 PoC 验证一名分享者向小范围朋友进行低延迟游戏画面分享。观看端使用普通桌面或手机浏览器;媒体对每名观看者独立进行 ICE,能直连时走 P2P,只有失败的链路使用 coturn。房间默认最多八名观看者,可配置范围为 1 至 16;八人是接入默认值,不是未经真实 1:8 测试即可宣称的性能能力。 +产品面向一名分享者和小范围可信朋友的低延迟游戏画面分享。观看端使用普通桌面或手机浏览器;每条媒体边独立 ICE。旧 production 能直连时走 P2P、失败边使用 coturn;repository candidate 的普通边只接收自托管 STUN,peer 恢复耗尽后由 LiveKit ICE/UDP virtual parent 服务一至两个必要 roots,再失败则有界明确结束。房间默认最多八名观看者,可配置范围为 1 至 16;八人是接入默认值,不是未经真实 1:8 测试即可宣称的性能能力。 本阶段必须形成完整的最小纵向链路: @@ -18,7 +18,7 @@ - 超过部署配置上限的观看者被明确拒绝,不静默超载; - 在代码、测试和真实浏览器验证完成前,不宣称达到延迟或画质目标。 -本基线不实现账号数据库、通用业务数据库、Redis、SFU、simulcast/SVC、观看者转发树、语音聊天、Electron、原生捕获、PWA、录制、遥测平台或多地区调度。唯一持久化例外是可选的 `node:sqlite` 房间身份表。后续 SFU 与 peer-assisted 工作必须留在各自分支和 ADR 中,不能反向把实验复杂度预埋进该基线。 +首个基线不实现账号数据库、通用业务数据库、Redis、simulcast/SVC、语音聊天、Electron、原生捕获、PWA、录制、遥测平台或多地区调度。唯一持久化例外是可选的 `node:sqlite` 房间身份表。后来接受的 peer-assisted/SFU candidate 只通过 ADR-0004/0005 的现有窄 owner 扩展,不引入第二套路由器或通用优化框架。 ## 技术栈决策 @@ -43,12 +43,14 @@ flowchart LR H[分享者 Web] -->|HTTPS: 建房| A[Node 应用服务] H <-->|WSS: 鉴权与信令| A V[观看者 Web 1..N] <-->|WSS: 鉴权与信令| A - H <-->|每人独立 DTLS-SRTP P2P| V - H -.仅失败链路.-> T[coturn] - T -.加密媒体中继.-> V + H <-->|ordinary direct/peer DTLS-SRTP UDP| V + H -.peer 路径耗尽.-> S[LiveKit SFU/UDP virtual parent] + S -.一至两个必要 roots.-> V + H -.地址发现.-> T[coturn STUN-only] + V -.地址发现.-> T ``` -每个观看者的候选检查和最终路径互不影响。同一房间可以同时存在 direct 和 relay 连接。中心房间服务不复制、解码或转发媒体。 +每个观看者的候选检查和最终路径互不影响。同一 candidate 房间可同时存在健康 direct/peer 子树与必要的 SFU roots;Node 房间服务不复制、解码或转发媒体,LiveKit 只在有界 root 路径上转发。 ## 代码布局 @@ -183,7 +185,9 @@ SVC 是 simulcast 与 LiveKit/Dynacast 均未满足后的第三个标准候选 ## 媒体传输配置 当前仓库候选已经删除旧全房 TURN contract。`ServerConfig` 只接受 -`STUN_URLS`;生产缺失 STUN 启动即失败。`authenticated.iceConfig` 是静态 +`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。 @@ -191,9 +195,11 @@ expiry 或 `relayAvailable`。信令协议也不再接受 `refresh-ice` 或发 普通 direct/peer `RTCPeerConnection` 与 LiveKit participant 是两个明确的 ICE 域。普通边只消费 `authenticated.iceConfig`;SFU publisher/subscriber 只消费 revision-bound `sfu-config {url, token}`,再由 LiveKit SDK 与服务端 -协商自己的 candidates。tracked LiveKit 配置只开放 UDP mux,不配置 -`rtc.tcp_port`、external TURN 或 embedded TURN。Node 应用不能替代部署 -验证来证明该 UDP listener 从公网可达。 +协商自己的 candidates。tracked LiveKit 配置只开放 UDP mux,显式使用 +`tcp_port: 0`、`allow_tcp_fallback: false` 和部署自托管 `stun_servers`,不配置 +external TURN 或 embedded TURN。Node 应用不能替代部署 +验证来证明该 UDP listener 从公网可达;自托管 `stun_servers` 也避免 pinned +LiveKit 1.13.5 继承默认公共 Google STUN。 当前可执行梯级是 `direct/peer UDP -> SFU ICE/UDP -> failure`。Viewer 的 现有两次有界 ICE restart/rebuild 后报告 route failure;router 先尝试局部 @@ -203,7 +209,7 @@ reparent,再在 root budget 内准备 SFU,最终没有可行 route 时返回 旧 production release `769de201f7cc` 仍需要 coturn TURN/UDP+TCP。它作为 独立旧实例保留到新 candidate canary 通过;新代码不根据 room allowlist -保留 legacy TURN 分支。`PEER_ASSISTED_ROOM_IDS` 只灰度 topology/SFU route +保留旧 release TURN 兼容分支。`PEER_ASSISTED_ROOM_IDS` 只灰度 topology/SFU route wire,进程内所有房间的 ordinary ICE 都是 STUN-only。因此验证与回滚必须 使用隔离 candidate/旧实例,不能假设同进程未命中房间仍有旧 transport。 @@ -260,8 +266,8 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) - 全站密码以定长摘要配合常量时间比较;access cookie 由服务端 HMAC 签名且不含账号或可查询的 session ID。host token 只保存摘要并以常量时间验证;认证和建房响应设置 `Cache-Control: no-store`。 - HTTP body、WebSocket payload、总连接数、未鉴权连接数、输出缓冲、房间总数、观看人数和 candidate 缓存都有硬上限;无法解析的 upgrade request-target 在未鉴权边界返回 HTTP 400,不允许异常逃逸到进程顶层。 - 应用日志不得主动记录 IP;反向代理、STUN 和 LiveKit 为提供网络服务必然可见源地址,部署时需设置最短必要留存与访问控制。 -- 自托管 coturn 必须启用 `stun-only` 并关闭 TCP、TLS 和 DTLS listeners;不得配置 relay port range、TURN auth secret 或匿名 TURN。未来 selected-edge TURN 由独立设计取代这份 STUN-only 配置,而不是在当前 wire 上加回全房 credential。 -- 分享者直连会向可信观看者暴露网络地址。需要隐藏 IP 时才提供强制 relay 诊断选项,并明确其服务器带宽成本。 +- 自托管 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 和测试必须原子引入。 ## 验证计划 @@ -271,7 +277,7 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) - RoomStore 的随机临时房间、TTL、host token 摘要验证、停止分享等待、默认八人上限、自定义 1 至 16 边界和超额拒绝; - SQLite 房间库从 `1` 自增、重启恢复、持久房间无过期时间、数据最小化、数据库路径无全站密码时启动失败; - STUN URL 严格校验、生产 STUN 必需、ordinary ICE 不含 username/credential、旧 `refresh-ice`/`ice-config` 被协议拒绝,以及 LiveKit `sfu-config` 独立边界; -- 协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制; +- 单一 `screener-v1` 信令 literal、旧页面/新服务或新页面/旧服务 mismatch 的一次性刷新终态,以及协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制; - 质量协议的 host 授权、严格字段与数值范围、last-wins、当前/未来 relay、已配置 SFU 和普通 P2P wire 兼容;sender 的四字段 requested/applied 读回、拒绝与持续受限提示; - HTTP/WS 集成:密码模式下 host/viewer gate、建房和 upgrade 只认 cookie 且无建房 Bearer 旁路;一名 host、多名 viewer、定向 offer/answer/candidate、连接上限、grace 内恢复、显式 restart/rebuild、离开和停止分享后的等待状态; - 客户端策略与 stats 解析:会话替换不重连,本地 TURN protocol 与 ICE protocol 分离,远端 TURN transport 保持未知; @@ -281,7 +287,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 留在独立实例作为回滚,不在同进程保留 legacy transport; +- CGNAT、双 NAT、移动热点和普通家宽下使用隔离 candidate,LiveKit 显式关闭 TCP fallback 且不配置 TURN,验证 host 与 roots 使用 SFU/UDP;人为封锁 UDP 时必须有界明确失败。旧 production release 留在独立实例作为回滚,不在同进程保留旧 transport; - Windows Chrome/Edge 的 tab/window/monitor 音轨有/无、同时语音通话和 video-only;Windows 11 原生候选另测 game process tree、通知、无渲染流与进程重启,Windows 10 记录明确不支持/未解决; - Android Chrome 与 iOS Safari 的播放手势、旋转、前后台和网络切换; - 一名分享者、八名异构观看者连续 30 分钟,同时记录较低人数基线、实际上行、编码负载和首帧时间。真实 1:8 未完成前不得把默认上限写成性能承诺。 diff --git a/docs/需求理解.md b/docs/需求理解.md index 71c2a8c..c405c9f 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -32,7 +32,7 @@ ### P1 - 重要功能 -- [ ] 实验路由按房间灰度:部署开启 `PEER_ASSISTED_MEDIA` 时可用严格的房间 ID allowlist 只让指定房间进入 peer-assisted 与已配置的 SFU 路径;未命中房间继续使用普通 P2P 拓扑及其质量拒绝、停止、重连和删除语义,但 STUN-only ordinary ICE 契约是进程级迁移,不为未命中房间保留旧 TURN wire。旧 production release 与新 candidate 必须用隔离实例验证和回滚,不能在产品代码建立长期双 transport 分支。缺失或空 allowlist 为兼容已有路由实验而表示全部房间,因此隔离灰度必须显式填写至少一个合法 ID;非法或重复配置启动即失败。该能力仅由部署控制,不增加 UI、用户选择、百分比发布或第二套路由器。 +- [ ] 实验路由按房间灰度:部署开启 `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、用户选择、百分比发布或第二套路由器。 - [ ] 质量控制扩展:推荐档位仍是默认入口;折叠高级区只允许 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。只有稳定收益且捕获、编码和远端画面不受影响时,才允许直播开始后默认显示静止缩略图或隐藏动态预览并提供按需展开;未测前不宣称收益。 diff --git a/scripts/peer-assisted-benchmark.ts b/scripts/peer-assisted-benchmark.ts index 0a6f10e..9da1f54 100644 --- a/scripts/peer-assisted-benchmark.ts +++ b/scripts/peer-assisted-benchmark.ts @@ -11,6 +11,10 @@ import { type ScreenerServer, } from "../src/server/app"; import { loadConfig } from "../src/server/config"; +import { + RoomStore, + type RoomStoreOptions, +} from "../src/server/room-store"; import { QUALITY_PROFILES, QUALITY_RESOLUTIONS, @@ -23,6 +27,21 @@ const PROFILE_SETTINGS = QUALITY_PROFILES; type ProfileId = QualityProfileId; type PageRole = "host" | "viewer"; +class BenchmarkRoomStore extends RoomStore { + constructor( + options: RoomStoreOptions, + private readonly exactPeerRooms: Set, + ) { + super(options); + } + + override createRoom() { + const room = super.createRoom(); + this.exactPeerRooms.add(room.roomId); + return room; + } +} + export interface BenchmarkConfig { chromePath: string; viewerCounts: number[]; @@ -1935,19 +1954,31 @@ export async function main(): Promise { const debugPort = await reservePort(); const baseUrl = `http://127.0.0.1:${appPort}`; profileDirectory = await mkdtemp(join(tmpdir(), "screener-peer-benchmark-")); + const exactPeerRooms = new Set(["1"]); + const serverConfig = loadConfig({ + NODE_ENV: "development", + PORT: String(appPort), + LISTEN_HOST: "127.0.0.1", + PUBLIC_BASE_URL: baseUrl, + ALLOWED_ORIGINS: baseUrl, + ACCESS_PASSWORD: "", + ROOM_DATABASE_PATH: "", + PEER_ASSISTED_MEDIA: "true", + PEER_ASSISTED_ROOM_IDS: "1", + MAX_VIEWERS_PER_ROOM: String(Math.max(...config.viewerCounts)), + STUN_URLS: "", + }); + serverConfig.peerAssistedRoomIds = exactPeerRooms; server = await createScreenerServer({ - config: loadConfig({ - NODE_ENV: "development", - PORT: String(appPort), - LISTEN_HOST: "127.0.0.1", - PUBLIC_BASE_URL: baseUrl, - ALLOWED_ORIGINS: baseUrl, - ACCESS_PASSWORD: "", - ROOM_DATABASE_PATH: "", - PEER_ASSISTED_MEDIA: "true", - MAX_VIEWERS_PER_ROOM: String(Math.max(...config.viewerCounts)), - STUN_URLS: "", - }), + config: serverConfig, + roomStore: new BenchmarkRoomStore( + { + ttlMs: serverConfig.roomTtlMs, + maxRooms: serverConfig.maxRooms, + maxViewersPerRoom: serverConfig.maxViewersPerRoom, + }, + exactPeerRooms, + ), }); await server.listen(appPort, "127.0.0.1"); diff --git a/src/client/lib/signaling.ts b/src/client/lib/signaling.ts index 5d59fe2..8977025 100644 --- a/src/client/lib/signaling.ts +++ b/src/client/lib/signaling.ts @@ -1,20 +1,25 @@ import { decodeServerMessage, + SIGNALING_PROTOCOL, type ClientMessage, type ServerMessage, } from "../../shared/protocol"; import type { SignalConnectionState } from "../types"; import { getSession } from "./api"; -type WithoutType = T extends { type: string } ? Omit : never; -type SignalingIdentity = WithoutType< +type WithoutProtocolEnvelope = T extends { + type: string; + protocol: string; +} + ? Omit + : never; +type SignalingIdentity = WithoutProtocolEnvelope< Extract >; interface SignalingEvents { onMessage: (message: ServerMessage) => void; onStatus: (status: SignalConnectionState) => void; - onProtocolError: (message: string) => void; onTerminated: (message: string) => void; onAccessRequired: () => void; } @@ -27,10 +32,14 @@ const FATAL_SIGNAL_ERRORS = new Set([ "HOST_ALREADY_CONNECTED", ]); const SESSION_REPLACED_CLOSE_CODE = 4001; +const INVALID_MESSAGE_CLOSE_CODE = 1008; +const PROTOCOL_REFRESH_MESSAGE = "页面版本已更新,请刷新后重试"; const TERMINAL_SEND_TIMEOUT_MS = 15_000; export function shouldReconnectSignaling(code: number): boolean { - return code !== SESSION_REPLACED_CLOSE_CODE; + return ( + code !== SESSION_REPLACED_CLOSE_CODE && code !== INVALID_MESSAGE_CLOSE_CODE + ); } function signalUrl(): string { @@ -120,6 +129,7 @@ export class SignalingClient { } const authenticate: ClientMessage = { type: "authenticate", + protocol: SIGNALING_PROTOCOL, ...this.identity, }; socket.send(JSON.stringify(authenticate)); @@ -138,7 +148,16 @@ export class SignalingClient { try { message = decodeServerMessage(event.data); } catch { - this.events.onProtocolError("信令服务返回了无法识别的消息"); + this.terminateForProtocolMismatch(); + return; + } + + if ( + message.type === "error" && + message.code === "INVALID_MESSAGE" && + !this.authenticated + ) { + this.terminateForProtocolMismatch(); return; } @@ -205,6 +224,11 @@ export class SignalingClient { }, delay); } + private terminateForProtocolMismatch(): void { + this.stop(); + this.events.onTerminated(PROTOCOL_REFRESH_MESSAGE); + } + private checkAccess(): void { if (this.accessCheck) { return; diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index f05886d..36e9724 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -110,7 +110,6 @@ function closeAbandonedRoom(room: CreateRoomResponse): void { { onMessage: () => undefined, onStatus: () => undefined, - onProtocolError: () => undefined, onTerminated: () => undefined, onAccessRequired: () => undefined, }, @@ -127,10 +126,6 @@ interface HostPageProps { } export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { - const forceRelay = useMemo( - () => new URLSearchParams(window.location.search).get("relay") === "1", - [], - ); const [qualitySettings, setQualitySettings] = useState( DEFAULT_QUALITY_SETTINGS, ); @@ -613,7 +608,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { } }, }, - forceRelay, ); peersRef.current.set(peerId, peer); let started: boolean; @@ -933,14 +927,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { setSignalStatus(status); } }, - onProtocolError: (message) => { - if ( - isCurrentGeneration(generation) && - signalRef.current === signal - ) { - setNotice(message); - } - }, onTerminated: (message) => { if ( isCurrentGeneration(generation) && @@ -1208,9 +1194,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { : "尚未开始"}

- {showConnectionDetails && forceRelay && ( - 强制中继 - )} {(phase === "live" || phase === "starting") && (
{phase === "live" && ( diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index 2e06f26..bd2b85a 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -60,10 +60,6 @@ type ViewerQualityEvidence = Extract< >; export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) { - const forceRelay = useMemo( - () => new URLSearchParams(window.location.search).get("relay") === "1", - [], - ); const [signalStatus, setSignalStatus] = useState("offline"); const [statusText, setStatusText] = useState("正在连接"); @@ -134,13 +130,6 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) setSignalStatus(status); } }, - onProtocolError: (message) => { - if (active) { - messageAuthority.invalidate(); - setSfuStandbyUrl(null); - setStatusText(message); - } - }, onTerminated: (message) => { if (!active) { return; @@ -234,7 +223,6 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) } }, }, - forceRelay, ); viewerRelay.setChild(currentAssignment.childPeerIds[0] ?? null); return viewerRelay; @@ -451,7 +439,6 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) return true; }, }, - forceRelay, ); peerRef.current = peer; return peer; @@ -688,7 +675,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) viewerRelay?.dispose(); viewerRelay = null; }; - }, [forceRelay, onAuthorizationRequired, roomId]); + }, [onAuthorizationRequired, roomId]); useEffect(() => { const video = videoRef.current; @@ -770,9 +757,6 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)

房间 {roomId}

- {showConnectionDetails && forceRelay && ( - 强制中继 - )} {showConnectionDetails && ( diff --git a/src/client/webrtc/host-peer.ts b/src/client/webrtc/host-peer.ts index 1dee7f2..c6a75f6 100644 --- a/src/client/webrtc/host-peer.ts +++ b/src/client/webrtc/host-peer.ts @@ -83,11 +83,9 @@ export class HostPeer { private stream: MediaStream, private desiredProfile: QualityProfile, private readonly events: HostPeerEvents, - private readonly forceRelay = false, ) { this.connection = new RTCPeerConnection({ iceServers: iceConfig.iceServers, - iceTransportPolicy: forceRelay ? "relay" : "all", }); this.snapshot = { peerId, @@ -254,7 +252,6 @@ export class HostPeer { try { this.connection.setConfiguration({ iceServers: iceConfig.iceServers, - iceTransportPolicy: this.forceRelay ? "relay" : "all", }); } catch (error) { this.setError(error, "更新 ICE 配置失败"); diff --git a/src/client/webrtc/viewer-peer.ts b/src/client/webrtc/viewer-peer.ts index 53615bc..1346503 100644 --- a/src/client/webrtc/viewer-peer.ts +++ b/src/client/webrtc/viewer-peer.ts @@ -56,7 +56,6 @@ export class ViewerPeer { constructor( iceConfig: IceConfig, private readonly events: ViewerPeerEvents, - private readonly forceRelay = false, ) { this.currentIceConfig = iceConfig; } @@ -172,7 +171,6 @@ export class ViewerPeer { try { this.connection.setConfiguration({ iceServers: iceConfig.iceServers, - iceTransportPolicy: this.forceRelay ? "relay" : "all", }); } catch (error) { this.setError(error, "更新 ICE 配置失败"); @@ -235,7 +233,6 @@ export class ViewerPeer { const connection = new RTCPeerConnection({ iceServers: this.currentIceConfig.iceServers, - iceTransportPolicy: this.forceRelay ? "relay" : "all", }); this.connection = connection; this.snapshot = { diff --git a/src/client/webrtc/viewer-relay.ts b/src/client/webrtc/viewer-relay.ts index 9a8c0a8..9fbfa81 100644 --- a/src/client/webrtc/viewer-relay.ts +++ b/src/client/webrtc/viewer-relay.ts @@ -20,7 +20,6 @@ export class ViewerRelay { private iceConfig: IceConfig, private desiredProfile: QualityProfile, private readonly events: ViewerRelayEvents, - private readonly forceRelay = false, ) {} getSnapshot(): PeerSnapshot | null { @@ -218,7 +217,6 @@ export class ViewerRelay { } }, }, - this.forceRelay, ); this.peer = peer; void peer diff --git a/src/server/config.ts b/src/server/config.ts index 3fe86a3..5a061a4 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -2,6 +2,7 @@ import { MAX_ICE_SERVER_URLS, MAX_VIEWERS_PER_ROOM_LIMIT, roomCodeSchema, + stunUrlSchema, } from "../shared/protocol.js"; export type RuntimeEnvironment = "development" | "test" | "production"; @@ -13,6 +14,11 @@ const DEFAULT_MAX_VIEWERS_PER_ROOM = 8; const DEFAULT_MAX_SFU_ROOTS_PER_ROOM = 2; const MAX_SFU_ROOTS_PER_ROOM = 2; const VISIBLE_ASCII_PATTERN = /^[\x21-\x7e]+$/; +const REMOVED_TURN_ENVIRONMENT_VARIABLES = [ + "TURN_URLS", + "TURN_SHARED_SECRET", + "TURN_CREDENTIAL_TTL_SECONDS", +] as const; export interface LiveKitFallbackConfig { url: string; @@ -173,17 +179,8 @@ function parseStunUrlList(value: string | undefined): string[] { throw new Error(`${name} must contain at most ${MAX_ICE_SERVER_URLS} URLs`); } return values.map((value) => { - let protocol: string; - try { - protocol = new URL(value).protocol; - } catch { - throw new Error(`${name} contains an invalid URL`); - } - if (protocol !== "stun:") { - throw new Error(`${name} contains an unsupported URL scheme`); - } - if (!parseStunEndpoint(value)) { - throw new Error(`${name} contains an invalid ICE URL`); + if (!stunUrlSchema.safeParse(value).success) { + throw new Error(`${name} contains an invalid STUN URL`); } return value; }); @@ -220,59 +217,6 @@ function parsePeerAssistedRoomIds( return roomIds; } -function parseStunEndpoint(value: string): boolean { - const schemeSeparator = value.indexOf(":"); - if (schemeSeparator <= 0) { - return false; - } - - const scheme = value.slice(0, schemeSeparator).toLowerCase(); - if (scheme !== "stun") { - return false; - } - - const remainder = value.slice(schemeSeparator + 1); - if (!remainder || remainder.includes("#")) { - return false; - } - - if (remainder.includes("?")) { - return false; - } - const authorityText = remainder; - if ( - !authorityText || - /[\\/\s]/.test(authorityText) || - authorityText.endsWith(":") - ) { - return false; - } - - let authority: URL; - try { - authority = new URL(`http://${authorityText}`); - } catch { - return false; - } - if ( - !authority.hostname || - authority.username || - authority.password || - authority.pathname !== "/" || - authority.search || - authority.hash - ) { - return false; - } - - const port = authority.port ? Number(authority.port) : undefined; - if (port === 0) { - return false; - } - - return true; -} - function toOrigin(value: string): string { const url = new URL(value); if (url.protocol !== "http:" && url.protocol !== "https:") { @@ -284,6 +228,14 @@ function toOrigin(value: string): string { export function loadConfig( environment: NodeJS.ProcessEnv = process.env, ): ServerConfig { + for (const name of REMOVED_TURN_ENVIRONMENT_VARIABLES) { + if (Object.prototype.hasOwnProperty.call(environment, name)) { + throw new Error( + `${name} is no longer supported; ordinary ICE accepts STUN_URLS only`, + ); + } + } + const nodeEnv = parseEnvironment(environment.NODE_ENV); const port = parsePositiveInteger(environment.PORT, 8787, "PORT"); if (port > 65_535) { @@ -341,6 +293,11 @@ export function loadConfig( "PEER_ASSISTED_ROOM_IDS requires PEER_ASSISTED_MEDIA=true", ); } + if (peerAssistedMedia && !peerAssistedRoomIds) { + throw new Error( + "PEER_ASSISTED_MEDIA=true requires non-empty PEER_ASSISTED_ROOM_IDS", + ); + } if (livekitFallback && !peerAssistedMedia) { throw new Error("LiveKit fallback requires PEER_ASSISTED_MEDIA=true"); } diff --git a/src/server/signaling.ts b/src/server/signaling.ts index 53db63a..eef4e35 100644 --- a/src/server/signaling.ts +++ b/src/server/signaling.ts @@ -8,6 +8,7 @@ import { DEFAULT_QUALITY_SETTINGS, MAX_SIGNAL_BYTES, MAX_VIEWER_QUALITY_EVIDENCE_BYTES, + SIGNALING_PROTOCOL, VIEWER_QUALITY_EVIDENCE_INTERVAL_MS, decodeClientMessage, type ClientMessage, @@ -124,13 +125,15 @@ export class SignalingServer { if (options.sfuFallback && !options.peerAssistedMedia) { throw new Error("SFU fallback requires peer-assisted media"); } - if ( - options.peerAssistedRoomIds && - options.peerAssistedRoomIds.size > 0 && - !options.peerAssistedMedia - ) { + if (options.peerAssistedRoomIds && !options.peerAssistedMedia) { throw new Error("Peer-assisted room IDs require peer-assisted media"); } + if ( + options.peerAssistedMedia && + (!options.peerAssistedRoomIds || options.peerAssistedRoomIds.size === 0) + ) { + throw new Error("Peer-assisted media requires exact room IDs"); + } if (options.peerAssistedMedia) { this.hybridMediaRouter = new HybridMediaRouter({ roomStore: options.roomStore, @@ -254,11 +257,25 @@ export class SignalingServer { } private handleMessage(socket: WebSocket, encoded: string): void { + const state = this.socketStates.get(socket); + if (!state) { + return; + } + let message: ClientMessage; try { message = decodeClientMessage(encoded); } catch { - this.rejectInvalidMessage(socket); + if (!state.authenticated) { + this.sendError( + socket, + "AUTH_REQUIRED", + "页面版本已更新,请刷新后重试", + ); + socket.close(4001, "Protocol mismatch"); + } else { + this.rejectInvalidMessage(socket); + } return; } if ( @@ -269,10 +286,6 @@ export class SignalingServer { return; } - const state = this.socketStates.get(socket); - if (!state) { - return; - } if (!state.authenticated) { if (message.type !== "authenticate") { this.sendError(socket, "AUTH_REQUIRED", "Authenticate before sending messages"); @@ -401,6 +414,7 @@ export class SignalingServer { const authenticatedMessage = { type: "authenticated" as const, + protocol: SIGNALING_PROTOCOL, role: participant.role, peerId: participant.peerId, roomExpiresAt: participant.expiresAt, @@ -409,7 +423,7 @@ export class SignalingServer { connectionId, viewerPeerIds: [...participant.viewerPeerIds], iceConfig: this.iceConfig(), - }; + } satisfies Extract; if (hybridState) { this.send(socket, { ...authenticatedMessage, @@ -589,7 +603,6 @@ export class SignalingServer { this.handleViewerQualityEvidence(socket, authenticated, message); return; case "stop-sharing": - case "close-room": if (authenticated.role !== "host") { this.sendError(socket, "FORBIDDEN", "Only the host may stop sharing"); return; @@ -598,8 +611,7 @@ export class SignalingServer { authenticated.shareGeneration === null || this.shareGenerationsByRoom.get(authenticated.roomId) !== authenticated.shareGeneration || - (message.type === "stop-sharing" && - message.shareGeneration !== undefined && + (message.shareGeneration !== undefined && message.shareGeneration !== authenticated.shareGeneration) ) { socket.close(4001, "Sharing generation replaced"); @@ -1050,8 +1062,7 @@ export class SignalingServer { if (!this.hybridMediaRouter) { return false; } - const allowlist = this.options.peerAssistedRoomIds; - return !allowlist || allowlist.size === 0 || allowlist.has(roomId); + return this.options.peerAssistedRoomIds?.has(roomId) ?? false; } private hasConnectionCapacity(): boolean { diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index ead74de..3585df5 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -4,6 +4,7 @@ import { isCanonicalVideoCodecEvidence } from "./video-codec-evidence.js"; export const MAX_VIEWERS_PER_ROOM_LIMIT = 16; export const MAX_SIGNAL_BYTES = 64 * 1024; +export const SIGNALING_PROTOCOL = "screener-v1"; export const ROOM_CODE_LENGTH = 12; export const MAX_MEDIA_ROUTE_REVISION = Number.MAX_SAFE_INTEGER; export const MAX_SFU_TOKEN_LENGTH = 8 * 1024; @@ -88,11 +89,46 @@ export const relayDownstreamEdgesSchema = z.union([ ]); export type RelayDownstreamEdges = z.infer; -const stunUrlSchema = z +function isValidStunUrl(value: string): boolean { + const schemeSeparator = value.indexOf(":"); + if ( + schemeSeparator <= 0 || + value.slice(0, schemeSeparator).toLowerCase() !== "stun" + ) { + return false; + } + + const authorityText = value.slice(schemeSeparator + 1); + if ( + !authorityText || + /[\\/\s?#]/.test(authorityText) || + authorityText.endsWith(":") + ) { + 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 stunUrlSchema = z .string() .min(1) .max(512) - .regex(/^stun:/i); + .refine(isValidStunUrl, { message: "Invalid STUN URL" }); const iceServerSchema = z .object({ @@ -281,6 +317,7 @@ const authenticateMessageSchema = z.discriminatedUnion("role", [ z .object({ type: z.literal("authenticate"), + protocol: z.literal(SIGNALING_PROTOCOL), roomId: roomCodeSchema, role: z.literal("host"), token: tokenSchema, @@ -291,6 +328,7 @@ const authenticateMessageSchema = z.discriminatedUnion("role", [ z .object({ type: z.literal("authenticate"), + protocol: z.literal(SIGNALING_PROTOCOL), roomId: roomCodeSchema, role: z.literal("viewer"), clientId: opaqueIdSchema, @@ -355,8 +393,6 @@ export const clientMessageSchema = z.union([ shareGeneration: opaqueIdSchema.optional(), }) .strict(), - // Kept as a compatibility alias while previously deployed clients age out. - z.object({ type: z.literal("close-room") }).strict(), z.object({ type: z.literal("abandon-room") }).strict(), ]); export type ClientMessage = z.infer; @@ -375,6 +411,7 @@ const errorCodeSchema = z.enum([ const authenticatedMessageShape = { type: z.literal("authenticated"), + protocol: z.literal(SIGNALING_PROTOCOL), role: roleSchema, peerId: opaqueIdSchema, roomExpiresAt: z.string().datetime().nullable(), diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts index d6a0963..0902a27 100644 --- a/tests/client-state.test.ts +++ b/tests/client-state.test.ts @@ -203,9 +203,79 @@ describe("room codes", () => { describe("client signaling recovery policy", () => { it("does not reconnect a session that another tab replaced", () => { expect(shouldReconnectSignaling(4001)).toBe(false); + expect(shouldReconnectSignaling(1008)).toBe(false); expect(shouldReconnectSignaling(1006)).toBe(true); }); + it.each([ + JSON.stringify({ + type: "error", + code: "INVALID_MESSAGE", + message: "Message is invalid", + }), + JSON.stringify({ + type: "authenticated", + role: "viewer", + peerId: "viewer_12345678", + roomExpiresAt: null, + maxViewers: 8, + hostOnline: true, + connectionId: null, + viewerPeerIds: [], + iceConfig: { iceServers: [] }, + }), + ])("terminates once when an old server uses an incompatible protocol", (payload) => { + const sockets: FakeWebSocket[] = []; + class FakeWebSocket extends EventTarget { + static readonly CLOSING = 2; + readyState = 1; + 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 onTerminated = vi.fn(); + const signal = new SignalingClient( + { + roomId: "123456789012", + role: "viewer", + clientId: "viewer-client", + }, + { + onMessage: () => undefined, + onStatus: () => undefined, + onTerminated, + onAccessRequired: () => undefined, + }, + ); + + signal.start(); + sockets[0]!.dispatchEvent(new Event("open")); + expect(JSON.parse(String(sockets[0]!.send.mock.calls[0]![0]))).toMatchObject({ + type: "authenticate", + protocol: "screener-v1", + }); + const message = new Event("message"); + Object.defineProperty(message, "data", { value: payload }); + sockets[0]!.dispatchEvent(message); + + expect(onTerminated).toHaveBeenCalledOnce(); + expect(onTerminated).toHaveBeenCalledWith( + "页面版本已更新,请刷新后重试", + ); + expect(sockets[0]!.close).toHaveBeenCalledOnce(); + expect(sockets).toHaveLength(1); + }); + it("returns to the access gate after an unauthorized upgrade", async () => { const sockets: FakeWebSocket[] = []; class FakeWebSocket extends EventTarget { @@ -243,7 +313,6 @@ describe("client signaling recovery policy", () => { { onMessage: () => undefined, onStatus: (status) => statuses.push(status), - onProtocolError: () => undefined, onTerminated: () => undefined, onAccessRequired, }, diff --git a/tests/media-assignment.test.ts b/tests/media-assignment.test.ts index acba0a3..eda386e 100644 --- a/tests/media-assignment.test.ts +++ b/tests/media-assignment.test.ts @@ -40,7 +40,7 @@ describe("peer-assisted client assignment", () => { }); }); - it("leaves legacy viewer signaling untargeted", () => { + it("leaves ordinary viewer signaling untargeted", () => { expect(viewerSignalMessage(false, "host_12345678", offer)).toEqual({ type: "signal", payload: offer, diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 837f9b7..a6103f5 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -5,6 +5,7 @@ import { MAX_SFU_TOKEN_LENGTH, MAX_VIEWER_QUALITY_EVIDENCE_BYTES, MAX_VIEWERS_PER_ROOM_LIMIT, + SIGNALING_PROTOCOL, clientMessageSchema, decodeClientMessage, participantRouteAssignmentSchema, @@ -60,17 +61,36 @@ describe("client signaling protocol", () => { decodeClientMessage( JSON.stringify({ type: "authenticate", + protocol: SIGNALING_PROTOCOL, roomId, role: "viewer", clientId: "client_12345678", }), ), ).toMatchObject({ type: "authenticate", role: "viewer" }); + expect( + clientMessageSchema.safeParse({ + type: "authenticate", + roomId, + role: "viewer", + clientId: "client_12345678", + }).success, + ).toBe(false); + expect( + clientMessageSchema.safeParse({ + type: "authenticate", + protocol: "screener-v0", + roomId, + role: "viewer", + clientId: "client_12345678", + }).success, + ).toBe(false); }); it("rejects unknown fields and malformed tokens", () => { const result = clientMessageSchema.safeParse({ type: "authenticate", + protocol: SIGNALING_PROTOCOL, roomId, role: "host", token: "short", @@ -85,6 +105,7 @@ describe("client signaling protocol", () => { expect( clientMessageSchema.safeParse({ type: "authenticate", + protocol: SIGNALING_PROTOCOL, roomId, role: "host", token, @@ -94,6 +115,7 @@ describe("client signaling protocol", () => { expect( clientMessageSchema.safeParse({ type: "authenticate", + protocol: SIGNALING_PROTOCOL, roomId, role: "viewer", token, @@ -103,6 +125,7 @@ describe("client signaling protocol", () => { expect( clientMessageSchema.safeParse({ type: "authenticate", + protocol: SIGNALING_PROTOCOL, roomId: "1", role: "viewer", clientId: "client_12345678", @@ -111,6 +134,7 @@ describe("client signaling protocol", () => { expect( clientMessageSchema.safeParse({ type: "authenticate", + protocol: SIGNALING_PROTOCOL, roomId: "0123", role: "viewer", clientId: "client_12345678", @@ -119,6 +143,7 @@ describe("client signaling protocol", () => { expect( clientMessageSchema.safeParse({ type: "authenticate", + protocol: SIGNALING_PROTOCOL, roomId: "1".repeat(13), role: "viewer", clientId: "client_12345678", @@ -165,7 +190,7 @@ describe("client signaling protocol", () => { ).toBe(false); }); - it("accepts explicit and legacy sharing-stop messages", () => { + it("accepts explicit sharing-stop and abandonment messages", () => { expect(clientMessageSchema.safeParse({ type: "stop-sharing" }).success).toBe( true, ); @@ -178,6 +203,7 @@ describe("client signaling protocol", () => { expect( clientMessageSchema.safeParse({ type: "authenticate", + protocol: SIGNALING_PROTOCOL, roomId, role: "host", token, @@ -185,9 +211,6 @@ describe("client signaling protocol", () => { shareGeneration: "share_generation_12345678", }).success, ).toBe(true); - expect(clientMessageSchema.safeParse({ type: "close-room" }).success).toBe( - true, - ); expect(clientMessageSchema.safeParse({ type: "abandon-room" }).success).toBe( true, ); @@ -425,6 +448,7 @@ describe("server signaling protocol", () => { function authenticatedMessage(maxViewers: number, viewerPeerIds: string[] = []) { return { type: "authenticated", + protocol: SIGNALING_PROTOCOL, role: "host", peerId: "host_12345678", roomExpiresAt: "2026-08-18T18:00:00.000Z", diff --git a/tests/server-config.test.ts b/tests/server-config.test.ts index bdd3578..59c70c3 100644 --- a/tests/server-config.test.ts +++ b/tests/server-config.test.ts @@ -19,6 +19,7 @@ describe("server configuration", () => { it("enables LiveKit fallback only for a complete credential tuple", () => { const config = loadConfig({ PEER_ASSISTED_MEDIA: "true", + PEER_ASSISTED_ROOM_IDS: "1", LIVEKIT_URL: " ws://livekit.test:7880 ", LIVEKIT_API_KEY: " test-key ", LIVEKIT_API_SECRET: ` ${"s".repeat(32)} `, @@ -37,6 +38,7 @@ describe("server configuration", () => { (maxSfuRootsPerRoom) => { const config = loadConfig({ PEER_ASSISTED_MEDIA: "true", + PEER_ASSISTED_ROOM_IDS: "1", LIVEKIT_URL: "wss://livekit.test", LIVEKIT_API_KEY: "test-key", LIVEKIT_API_SECRET: "s".repeat(32), @@ -103,6 +105,7 @@ describe("server configuration", () => { PUBLIC_BASE_URL: "https://share.test", STUN_URLS: "stun:stun.test:3478", PEER_ASSISTED_MEDIA: "true", + PEER_ASSISTED_ROOM_IDS: "1", LIVEKIT_URL: "ws://livekit.test:7880", LIVEKIT_API_KEY: "test-key", LIVEKIT_API_SECRET: "s".repeat(32), @@ -134,12 +137,14 @@ describe("server configuration", () => { { ACCESS_PASSWORD: "x".repeat(32), PEER_ASSISTED_MEDIA: "true", + PEER_ASSISTED_ROOM_IDS: "1", LIVEKIT_URL: "wss://livekit.test", LIVEKIT_API_KEY: "test-key", LIVEKIT_API_SECRET: "x".repeat(32), }, { PEER_ASSISTED_MEDIA: "true", + PEER_ASSISTED_ROOM_IDS: "1", LIVEKIT_URL: "wss://livekit.test", LIVEKIT_API_KEY: "x".repeat(32), LIVEKIT_API_SECRET: "x".repeat(32), @@ -151,9 +156,12 @@ describe("server configuration", () => { }); it("requires an explicit boolean to enable peer-assisted media", () => { - expect(loadConfig({ PEER_ASSISTED_MEDIA: "true" }).peerAssistedMedia).toBe( - true, - ); + expect( + loadConfig({ + PEER_ASSISTED_MEDIA: "true", + PEER_ASSISTED_ROOM_IDS: "1", + }).peerAssistedMedia, + ).toBe(true); expect(loadConfig({ PEER_ASSISTED_MEDIA: "false" }).peerAssistedMedia).toBe( false, ); @@ -163,6 +171,7 @@ describe("server configuration", () => { expect(() => loadConfig({ PEER_ASSISTED_MEDIA: "true", + PEER_ASSISTED_ROOM_IDS: "1", MAX_VIEWERS_PER_ROOM: "9", }), ).toThrow( @@ -170,19 +179,25 @@ describe("server configuration", () => { ); }); - it("parses an optional strict peer-assisted room allowlist", () => { + it("requires and parses a strict peer-assisted room allowlist", () => { expect( loadConfig({ PEER_ASSISTED_MEDIA: "true", PEER_ASSISTED_ROOM_IDS: " 1,123456789012 ", }).peerAssistedRoomIds, ).toEqual(new Set(["1", "123456789012"])); - expect( - loadConfig({ - PEER_ASSISTED_MEDIA: "true", - PEER_ASSISTED_ROOM_IDS: " ", - }).peerAssistedRoomIds, - ).toBeUndefined(); + for (const peerAssistedRoomIds of [undefined, " "]) { + expect(() => + loadConfig({ + PEER_ASSISTED_MEDIA: "true", + ...(peerAssistedRoomIds === undefined + ? {} + : { PEER_ASSISTED_ROOM_IDS: peerAssistedRoomIds }), + }), + ).toThrow( + "PEER_ASSISTED_MEDIA=true requires non-empty PEER_ASSISTED_ROOM_IDS", + ); + } }); it("requires peer-assisted media for a non-empty room allowlist", () => { @@ -242,6 +257,7 @@ describe("server configuration", () => { PUBLIC_BASE_URL: "https://share.test", STUN_URLS: "stun:stun.test:3478", PEER_ASSISTED_MEDIA: "true", + PEER_ASSISTED_ROOM_IDS: "1", LIVEKIT_URL: "wss://livekit.test", LIVEKIT_API_KEY: "test-key", LIVEKIT_API_SECRET: "s".repeat(32), @@ -279,21 +295,32 @@ describe("server configuration", () => { "stun:stun.test/path", "stun:stun.test?transport=udp", "stun:stun.test#fragment", + "stun:", "stun:stun.test:", "stun:stun.test:0", "stun:user@stun.test:3478", ])("rejects a malformed STUN URL: %s", (invalidUrl) => { expect(() => loadConfig({ STUN_URLS: invalidUrl })).toThrow( - "STUN_URLS contains an invalid ICE URL", + "STUN_URLS contains an invalid STUN URL", ); }); it("rejects non-STUN schemes in the ordinary ICE configuration", () => { expect(() => loadConfig({ STUN_URLS: "turn:turn.test:3478" })).toThrow( - "STUN_URLS contains an unsupported URL scheme", + "STUN_URLS contains an invalid STUN URL", ); expect(() => loadConfig({ STUN_URLS: "stuns:stun.test:5349" })).toThrow( - "STUN_URLS contains an unsupported URL scheme", + "STUN_URLS contains an invalid STUN URL", + ); + }); + + it.each([ + "TURN_URLS", + "TURN_SHARED_SECRET", + "TURN_CREDENTIAL_TTL_SECONDS", + ] as const)("rejects removed TURN configuration even when %s is blank", (name) => { + expect(() => loadConfig({ [name]: "" })).toThrow( + `${name} is no longer supported`, ); }); diff --git a/tests/server-http.test.ts b/tests/server-http.test.ts index 945c394..2712e3e 100644 --- a/tests/server-http.test.ts +++ b/tests/server-http.test.ts @@ -340,6 +340,7 @@ describe("server HTTP listener and health", () => { const baseUrl = await start( testConfig({ peerAssistedMedia: true, + peerAssistedRoomIds: new Set(["1"]), livekitFallback: { url: "ws://livekit.test:7880", apiKey: "test-key", diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts index 61a56be..79faeb8 100644 --- a/tests/server-signal.test.ts +++ b/tests/server-signal.test.ts @@ -7,6 +7,7 @@ import WebSocket from "ws"; import { MAX_SIGNAL_BYTES, MAX_VIEWER_QUALITY_EVIDENCE_BYTES, + SIGNALING_PROTOCOL, decodeServerMessage, type Role, type ServerMessage, @@ -132,6 +133,7 @@ interface SignalHarness { webSocketUrl: string; roomStore: RoomStore; room: CreatedRoom; + peerAssistedRoomIds?: Set; } function testConfig(): ServerConfig { @@ -159,7 +161,6 @@ async function startHarness( accessPassword?: string; persistent?: boolean; peerAssistedMedia?: boolean; - peerAssistedPrimaryRoomOnly?: boolean; stunUrls?: readonly string[]; now?: () => number; } = {}, @@ -177,9 +178,10 @@ async function startHarness( database: overrides.persistent ? new RoomDatabase(":memory:") : undefined, }); const room = roomStore.createRoom(); - if (overrides.peerAssistedPrimaryRoomOnly) { - config.peerAssistedRoomIds = new Set([room.roomId]); - } + const peerAssistedRoomIds = config.peerAssistedMedia + ? new Set([room.roomId]) + : undefined; + config.peerAssistedRoomIds = peerAssistedRoomIds; runningServer = await createScreenerServer({ config, roomStore, @@ -199,6 +201,7 @@ async function startHarness( webSocketUrl: `ws://127.0.0.1:${port}/signal`, roomStore, room, + peerAssistedRoomIds, }; } @@ -239,7 +242,6 @@ async function startSfuHarness(options: { prepareTimeoutMs?: number; maxRoots?: number; viewerDisconnectGraceMs?: number; - peerAssistedPrimaryRoomOnly?: boolean; stunUrls?: readonly string[]; }): Promise { const roomStore = new RoomStore({ @@ -248,6 +250,7 @@ async function startSfuHarness(options: { maxViewersPerRoom: 8, }); const room = roomStore.createRoom(); + const peerAssistedRoomIds = new Set([room.roomId]); const httpServer = createServer((_request, response) => { response.statusCode = 404; response.end(); @@ -256,9 +259,7 @@ async function startSfuHarness(options: { server: httpServer, roomStore, peerAssistedMedia: true, - ...(options.peerAssistedPrimaryRoomOnly - ? { peerAssistedRoomIds: new Set([room.roomId]) } - : {}), + peerAssistedRoomIds, sfuFallback: { url: "wss://sfu.example.test", tokenIssuer: options.tokenIssuer, @@ -305,6 +306,7 @@ async function startSfuHarness(options: { webSocketUrl: `ws://127.0.0.1:${port}/signal`, roomStore, room, + peerAssistedRoomIds, }; } @@ -382,8 +384,9 @@ async function authenticate( client.socket.send( JSON.stringify( role === "host" - ? { + ? { type: "authenticate", + protocol: SIGNALING_PROTOCOL, roomId: room.roomId, role, token: room.hostToken, @@ -392,6 +395,7 @@ async function authenticate( } : { type: "authenticate", + protocol: SIGNALING_PROTOCOL, roomId: room.roomId, role, clientId, @@ -915,10 +919,9 @@ describe("WebSocket signaling", () => { it("isolates the routing allowlist while ordinary ICE stays STUN-only", async () => { const harness = await startSfuHarness({ tokenIssuer: { issueToken: async () => "unused-test-token" }, - peerAssistedPrimaryRoomOnly: true, stunUrls: ["stun:stun.example.test:3478"], }); - const legacyRoom = harness.roomStore.createRoom(); + const ordinaryRoom = harness.roomStore.createRoom(); const hybridHost = await openClient(harness.webSocketUrl); const hybridHostAuth = peerAssisted( @@ -934,108 +937,108 @@ describe("WebSocket signaling", () => { iceServers: [{ urls: ["stun:stun.example.test:3478"] }], }); - const legacyHost = await openClient(harness.webSocketUrl); - const legacyHostAuth = await authenticate( - legacyHost, - legacyRoom, + const ordinaryHost = await openClient(harness.webSocketUrl); + const ordinaryHostAuth = await authenticate( + ordinaryHost, + ordinaryRoom, "host", - "legacy-host", + "ordinary-host", ); - expect(Object.keys(legacyHostAuth).sort()).toEqual( + expect(Object.keys(ordinaryHostAuth).sort()).toEqual( [ "connectionId", "hostOnline", "iceConfig", "maxViewers", "peerId", + "protocol", "role", "roomExpiresAt", "type", "viewerPeerIds", ].sort(), ); - expect(legacyHostAuth.iceConfig).toEqual(hybridHostAuth.iceConfig); + expect(ordinaryHostAuth.iceConfig).toEqual(hybridHostAuth.iceConfig); expect( - legacyHostAuth.iceConfig.iceServers.every( + ordinaryHostAuth.iceConfig.iceServers.every( (server) => !("username" in server) && !("credential" in server), ), ).toBe(true); - const legacyViewer = await openClient(harness.webSocketUrl); - const legacyViewerAuth = await authenticate( - legacyViewer, - legacyRoom, + const ordinaryViewer = await openClient(harness.webSocketUrl); + const ordinaryViewerAuth = await authenticate( + ordinaryViewer, + ordinaryRoom, "viewer", - "legacy-viewer", + "ordinary-viewer", ); - expect(Object.keys(legacyViewerAuth).sort()).toEqual( - Object.keys(legacyHostAuth).sort(), + expect(Object.keys(ordinaryViewerAuth).sort()).toEqual( + Object.keys(ordinaryHostAuth).sort(), ); - expect(await legacyHost.inbox.next("peer-joined")).toEqual({ + expect(await ordinaryHost.inbox.next("peer-joined")).toEqual({ type: "peer-joined", - peerId: legacyViewerAuth.peerId, + peerId: ordinaryViewerAuth.peerId, }); - legacyHost.socket.send( + ordinaryHost.socket.send( JSON.stringify({ type: "signal", - targetPeerId: legacyViewerAuth.peerId, + targetPeerId: ordinaryViewerAuth.peerId, payload: { kind: "description", - connectionId: "legacy-room-connection", + connectionId: "ordinary-room-connection", description: { type: "offer", sdp: "v=0\r\n" }, }, }), ); - expect(await legacyViewer.inbox.next("signal")).toMatchObject({ - fromPeerId: legacyHostAuth.peerId, - payload: { connectionId: "legacy-room-connection" }, + expect(await ordinaryViewer.inbox.next("signal")).toMatchObject({ + fromPeerId: ordinaryHostAuth.peerId, + payload: { connectionId: "ordinary-room-connection" }, }); - legacyViewer.socket.send( + ordinaryViewer.socket.send( JSON.stringify({ type: "signal", payload: { kind: "description", - connectionId: "legacy-room-connection", + connectionId: "ordinary-room-connection", description: { type: "answer", sdp: "v=0\r\n" }, }, }), ); - expect(await legacyHost.inbox.next("signal")).toMatchObject({ - fromPeerId: legacyViewerAuth.peerId, - payload: { connectionId: "legacy-room-connection" }, + expect(await ordinaryHost.inbox.next("signal")).toMatchObject({ + fromPeerId: ordinaryViewerAuth.peerId, + payload: { connectionId: "ordinary-room-connection" }, }); - legacyHost.socket.send( + ordinaryHost.socket.send( JSON.stringify({ type: "set-quality-settings", qualitySettings: balancedQualitySettings, }), ); - expect((await legacyHost.inbox.next("error")).code).toBe("FORBIDDEN"); - legacyViewer.socket.send( + expect((await ordinaryHost.inbox.next("error")).code).toBe("FORBIDDEN"); + ordinaryViewer.socket.send( JSON.stringify({ type: "relay-capacity", downstreamEdges: 1 }), ); - expect((await legacyViewer.inbox.next("error")).code).toBe("FORBIDDEN"); - legacyViewer.socket.send( + expect((await ordinaryViewer.inbox.next("error")).code).toBe("FORBIDDEN"); + ordinaryViewer.socket.send( JSON.stringify({ type: "refresh-sfu", revision: 0 }), ); - expect((await legacyViewer.inbox.next("error")).code).toBe("FORBIDDEN"); + expect((await ordinaryViewer.inbox.next("error")).code).toBe("FORBIDDEN"); - await closeClient(legacyViewer); - expect(await legacyHost.inbox.next("peer-left", 500)).toEqual({ + await closeClient(ordinaryViewer); + expect(await ordinaryHost.inbox.next("peer-left", 500)).toEqual({ type: "peer-left", - peerId: legacyViewerAuth.peerId, + peerId: ordinaryViewerAuth.peerId, }); }); - it("keeps allowlisted and legacy room lifecycles independent", async () => { + it("keeps allowlisted and ordinary room lifecycles independent", async () => { const harness = await startHarness({ peerAssistedMedia: true, - peerAssistedPrimaryRoomOnly: true, viewerDisconnectGraceMs: 500, }); - const legacyRoom = harness.roomStore.createRoom(); + const ordinaryRoom = harness.roomStore.createRoom(); const hybridHost = await openClient(harness.webSocketUrl); const hybridHostAuth = peerAssisted( @@ -1059,34 +1062,34 @@ describe("WebSocket signaling", () => { ); await hybridViewer.inbox.next("quality-settings"); - const legacyHost = await openClient(harness.webSocketUrl); - await authenticate(legacyHost, legacyRoom, "host", "lifecycle-legacy-host"); - const legacyViewer = await openClient(harness.webSocketUrl); - const legacyViewerAuth = await authenticate( - legacyViewer, - legacyRoom, + const ordinaryHost = await openClient(harness.webSocketUrl); + await authenticate(ordinaryHost, ordinaryRoom, "host", "lifecycle-ordinary-host"); + const ordinaryViewer = await openClient(harness.webSocketUrl); + const ordinaryViewerAuth = await authenticate( + ordinaryViewer, + ordinaryRoom, "viewer", - "lifecycle-legacy-viewer", + "lifecycle-ordinary-viewer", ); - await legacyHost.inbox.next("peer-joined"); + await ordinaryHost.inbox.next("peer-joined"); const hybridHostClosed = new Promise((resolve) => hybridHost.socket.once("close", (code) => resolve(code)), ); - const legacyHostClosed = new Promise((resolve) => - legacyHost.socket.once("close", (code) => resolve(code)), + const ordinaryHostClosed = new Promise((resolve) => + ordinaryHost.socket.once("close", (code) => resolve(code)), ); hybridHost.socket.send(JSON.stringify({ type: "stop-sharing" })); - legacyHost.socket.send(JSON.stringify({ type: "stop-sharing" })); + ordinaryHost.socket.send(JSON.stringify({ type: "stop-sharing" })); await hybridViewer.inbox.next("sharing-stopped"); await hybridViewer.inbox.next("host-status"); - await legacyViewer.inbox.next("sharing-stopped"); - await legacyViewer.inbox.next("host-status"); + await ordinaryViewer.inbox.next("sharing-stopped"); + await ordinaryViewer.inbox.next("host-status"); expect(await hybridHostClosed).toBe(1000); - expect(await legacyHostClosed).toBe(1000); + expect(await ordinaryHostClosed).toBe(1000); await closeClient(hybridViewer); - await closeClient(legacyViewer); + await closeClient(ordinaryViewer); const resumedHybridViewer = await openClient(harness.webSocketUrl); const resumedHybridViewerAuth = peerAssisted( await authenticate( @@ -1101,17 +1104,17 @@ describe("WebSocket signaling", () => { connectionId: null, qualitySettings: lowQualitySettings, }); - const resumedLegacyViewer = await openClient(harness.webSocketUrl); - const resumedLegacyViewerAuth = await authenticate( - resumedLegacyViewer, - legacyRoom, + const resumedOrdinaryViewer = await openClient(harness.webSocketUrl); + const resumedOrdinaryViewerAuth = await authenticate( + resumedOrdinaryViewer, + ordinaryRoom, "viewer", - "lifecycle-legacy-viewer", + "lifecycle-ordinary-viewer", ); - expect(resumedLegacyViewerAuth.peerId).toBe(legacyViewerAuth.peerId); - expect(resumedLegacyViewerAuth.connectionId).toBeNull(); - expect("mediaMode" in resumedLegacyViewerAuth).toBe(false); - expect("qualitySettings" in resumedLegacyViewerAuth).toBe(false); + expect(resumedOrdinaryViewerAuth.peerId).toBe(ordinaryViewerAuth.peerId); + expect(resumedOrdinaryViewerAuth.connectionId).toBeNull(); + expect("mediaMode" in resumedOrdinaryViewerAuth).toBe(false); + expect("qualitySettings" in resumedOrdinaryViewerAuth).toBe(false); const resumedHybridHost = await openClient(harness.webSocketUrl); const resumedHybridHostAuth = peerAssisted( @@ -1126,15 +1129,15 @@ describe("WebSocket signaling", () => { peerId: hybridHostAuth.peerId, qualitySettings: lowQualitySettings, }); - const resumedLegacyHost = await openClient(harness.webSocketUrl); - const resumedLegacyHostAuth = await authenticate( - resumedLegacyHost, - legacyRoom, + const resumedOrdinaryHost = await openClient(harness.webSocketUrl); + const resumedOrdinaryHostAuth = await authenticate( + resumedOrdinaryHost, + ordinaryRoom, "host", - "lifecycle-legacy-host", + "lifecycle-ordinary-host", ); - expect(await resumedLegacyHost.inbox.next("peer-joined")).toMatchObject({ - peerId: resumedLegacyViewerAuth.peerId, + expect(await resumedOrdinaryHost.inbox.next("peer-joined")).toMatchObject({ + peerId: resumedOrdinaryViewerAuth.peerId, }); resumedHybridHost.socket.send(JSON.stringify({ type: "abandon-room" })); @@ -1142,24 +1145,24 @@ describe("WebSocket signaling", () => { await resumedHybridViewer.inbox.next("room-closed"); expect(harness.roomStore.size).toBe(1); - resumedLegacyHost.socket.send( + resumedOrdinaryHost.socket.send( JSON.stringify({ type: "signal", - targetPeerId: resumedLegacyViewerAuth.peerId, + targetPeerId: resumedOrdinaryViewerAuth.peerId, payload: { kind: "description", - connectionId: "legacy-after-hybrid-delete", + connectionId: "ordinary-after-hybrid-delete", description: { type: "offer", sdp: "v=0\r\n" }, }, }), ); - expect(await resumedLegacyViewer.inbox.next("signal")).toMatchObject({ - fromPeerId: resumedLegacyHostAuth.peerId, - payload: { connectionId: "legacy-after-hybrid-delete" }, + expect(await resumedOrdinaryViewer.inbox.next("signal")).toMatchObject({ + fromPeerId: resumedOrdinaryHostAuth.peerId, + payload: { connectionId: "ordinary-after-hybrid-delete" }, }); - resumedLegacyHost.socket.send(JSON.stringify({ type: "abandon-room" })); - await resumedLegacyHost.inbox.next("room-closed"); - await resumedLegacyViewer.inbox.next("room-closed"); + resumedOrdinaryHost.socket.send(JSON.stringify({ type: "abandon-room" })); + await resumedOrdinaryHost.inbox.next("room-closed"); + await resumedOrdinaryViewer.inbox.next("room-closed"); expect(harness.roomStore.size).toBe(0); }); @@ -1548,6 +1551,7 @@ describe("WebSocket signaling", () => { it("assigns a bounded peer relay tree and authorizes only direct media edges", async () => { const harness = await startHarness({ peerAssistedMedia: true }); const secondRoom = harness.roomStore.createRoom(); + harness.peerAssistedRoomIds!.add(secondRoom.roomId); const host = await openClient(harness.webSocketUrl); const hostAuth = peerAssisted( await authenticate(host, harness.room, "host", "relay-host"), @@ -2636,6 +2640,7 @@ describe("WebSocket signaling", () => { }); const replacementRoom = harness.roomStore.createRoom(); + harness.peerAssistedRoomIds!.add(replacementRoom.roomId); const replacing = await prepareFallbackForTwoViewers( harness.webSocketUrl, replacementRoom, @@ -2706,6 +2711,7 @@ describe("WebSocket signaling", () => { expect(replacementRoot.socket.readyState).toBe(WebSocket.OPEN); const prepareFailureRoom = harness.roomStore.createRoom(); + harness.peerAssistedRoomIds!.add(prepareFailureRoom.roomId); const prepareFailure = await prepareFallbackForTwoViewers( harness.webSocketUrl, prepareFailureRoom, @@ -3529,6 +3535,7 @@ describe("WebSocket signaling", () => { overflow.socket.send( JSON.stringify({ type: "authenticate", + protocol: SIGNALING_PROTOCOL, roomId: harness.room.roomId, role: "viewer", clientId: `viewer-client-${maxViewersPerRoom + 1}`, @@ -3549,29 +3556,6 @@ describe("WebSocket signaling", () => { expect(await stoppedHostClosed).toBe(1000); expect(harness.roomStore.size).toBe(1); - const legacyHost = await openClient(harness.webSocketUrl); - await authenticate( - legacyHost, - harness.room, - "host", - "host-client-stable", - ); - for (const viewer of viewers) { - expect(await viewer.inbox.next("host-status")).toMatchObject({ online: true }); - } - const legacyHostClosed = new Promise((resolve) => - legacyHost.socket.once("close", (code) => resolve(code)), - ); - legacyHost.socket.send(JSON.stringify({ type: "close-room" })); - for (const viewer of viewers) { - expect(await viewer.inbox.next("sharing-stopped")).toEqual({ - type: "sharing-stopped", - }); - expect(await viewer.inbox.next("host-status")).toMatchObject({ online: false }); - } - expect(await legacyHostClosed).toBe(1000); - expect(harness.roomStore.size).toBe(1); - viewers[0]!.socket.send(JSON.stringify({ type: "abandon-room" })); expect((await viewers[0]!.inbox.next("error")).code).toBe("FORBIDDEN"); expect(harness.roomStore.size).toBe(1); @@ -3626,6 +3610,31 @@ describe("WebSocket signaling", () => { expect(await closeCode).toBe(1009); }); + it("terminates an old client protocol before authentication", async () => { + const harness = await startHarness(); + const oldClient = await openClient(harness.webSocketUrl); + const closed = new Promise<{ code: number; reason: string }>((resolve) => + oldClient.socket.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ), + ); + + oldClient.socket.send( + JSON.stringify({ + type: "authenticate", + roomId: harness.room.roomId, + role: "viewer", + clientId: "old-client", + }), + ); + + expect(await oldClient.inbox.next("error")).toMatchObject({ + code: "AUTH_REQUIRED", + message: "页面版本已更新,请刷新后重试", + }); + expect(await closed).toEqual({ code: 4001, reason: "Protocol mismatch" }); + }); + it("rejects an invalid upgrade request target without stopping the server", async () => { const harness = await startHarness(); const response = await rawUpgradeResponse(harness.webSocketUrl, "//["); From 7621f2766e554e31b554b34aa9f50ed40db7b6a1 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:11:54 +0800 Subject: [PATCH 3/5] fix(signaling): make protocol mismatch terminal --- src/client/lib/signaling.ts | 4 +- src/shared/protocol.ts | 12 ++--- tests/client-state.test.ts | 105 ++++++++++++++++++++---------------- 3 files changed, 67 insertions(+), 54 deletions(-) diff --git a/src/client/lib/signaling.ts b/src/client/lib/signaling.ts index 8977025..cf1ef1e 100644 --- a/src/client/lib/signaling.ts +++ b/src/client/lib/signaling.ts @@ -203,7 +203,9 @@ export class SignalingClient { this.events.onTerminated( event.reason === "Session replaced" ? "此页面的会话已被另一个标签页接管" - : "信令会话已终止,请刷新后重试", + : event.code === INVALID_MESSAGE_CLOSE_CODE + ? PROTOCOL_REFRESH_MESSAGE + : "信令会话已终止,请刷新后重试", ); } }); diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 3585df5..bbb0742 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -503,12 +503,12 @@ export const serverMessageSchema = z.union([ ...viewerQualityEvidenceWindowShape, }) .strict() - .refine(freezeFitsEvidenceWindow, { - message: "Viewer freeze duration exceeds its evidence window", - }), - z - .object({ - type: z.literal("host-status"), + .refine(freezeFitsEvidenceWindow, { + message: "Viewer freeze duration exceeds its evidence window", + }), + z + .object({ + type: z.literal("host-status"), online: z.boolean(), }) .strict(), diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts index 0902a27..bb9aa6c 100644 --- a/tests/client-state.test.ts +++ b/tests/client-state.test.ts @@ -224,57 +224,68 @@ describe("client signaling recovery policy", () => { viewerPeerIds: [], iceConfig: { iceServers: [] }, }), - ])("terminates once when an old server uses an incompatible protocol", (payload) => { - const sockets: FakeWebSocket[] = []; - class FakeWebSocket extends EventTarget { - static readonly CLOSING = 2; - readyState = 1; - readonly send = vi.fn(); - readonly close = vi.fn(); + ])( + "terminates once when an old server uses an incompatible protocol", + (payload) => { + const sockets: FakeWebSocket[] = []; + class FakeWebSocket extends EventTarget { + static readonly CLOSING = 2; + readyState = 1; + readonly send = vi.fn(); + readonly close = vi.fn(); - constructor(readonly url: string) { - super(); - sockets.push(this); + 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 onTerminated = vi.fn(); - const signal = new SignalingClient( - { - roomId: "123456789012", - role: "viewer", - clientId: "viewer-client", - }, - { - onMessage: () => undefined, - onStatus: () => undefined, - onTerminated, - onAccessRequired: () => undefined, - }, - ); + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("window", { + location: new URL("https://share.test/r/123456789012"), + setTimeout, + clearTimeout, + }); + const onTerminated = vi.fn(); + const signal = new SignalingClient( + { + roomId: "123456789012", + role: "viewer", + clientId: "viewer-client", + }, + { + onMessage: () => undefined, + onStatus: () => undefined, + onTerminated, + onAccessRequired: () => undefined, + }, + ); - signal.start(); - sockets[0]!.dispatchEvent(new Event("open")); - expect(JSON.parse(String(sockets[0]!.send.mock.calls[0]![0]))).toMatchObject({ - type: "authenticate", - protocol: "screener-v1", - }); - const message = new Event("message"); - Object.defineProperty(message, "data", { value: payload }); - sockets[0]!.dispatchEvent(message); + signal.start(); + sockets[0]!.dispatchEvent(new Event("open")); + expect( + JSON.parse(String(sockets[0]!.send.mock.calls[0]![0])), + ).toMatchObject({ + type: "authenticate", + protocol: "screener-v1", + }); + const message = new Event("message"); + Object.defineProperty(message, "data", { value: payload }); + sockets[0]!.dispatchEvent(message); + const close = new Event("close"); + Object.defineProperties(close, { + code: { value: 1008 }, + reason: { value: "Invalid message" }, + }); + sockets[0]!.dispatchEvent(close); - expect(onTerminated).toHaveBeenCalledOnce(); - expect(onTerminated).toHaveBeenCalledWith( - "页面版本已更新,请刷新后重试", - ); - expect(sockets[0]!.close).toHaveBeenCalledOnce(); - expect(sockets).toHaveLength(1); - }); + expect(onTerminated).toHaveBeenCalledOnce(); + expect(onTerminated).toHaveBeenCalledWith( + "页面版本已更新,请刷新后重试", + ); + expect(sockets[0]!.close).toHaveBeenCalledOnce(); + expect(sockets).toHaveLength(1); + }, + ); it("returns to the access gate after an unauthorized upgrade", async () => { const sockets: FakeWebSocket[] = []; From 25a988987c512c1f50d957623022473c1ad3127d Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:23:18 +0800 Subject: [PATCH 4/5] docs(media): finalize UDP migration constraints --- AGENTS.md | 4 ++-- deploy/nginx/share.bonfire.icu.conf.example | 5 +++-- docs/方案设计.md | 8 ++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2805ec0..3c5c0a2 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 the media path P2P-first. Use centralized services for rooms, authentication, signaling, STUN, observability, and bounded SFU-root capacity; TURN is an optional transport compatibility fallback. +- 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. - Let viewers join from a normal desktop or mobile browser without installing the sender application. -- STUN is required. The target automatic ladder is direct/peer ICE over UDP, then an SFU virtual parent feeding at most two roots, then optional authenticated TURN for deployments that explicitly cover restrictive networks, followed by a clear bounded failure. Ordinary peer edges must not receive TURN candidates by default merely to maximize rare-network coverage. HTTPS/WSS remains TLS/TCP and is independent of this media-transport policy. +- 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. - 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/deploy/nginx/share.bonfire.icu.conf.example b/deploy/nginx/share.bonfire.icu.conf.example index 2f1c878..b14407e 100644 --- a/deploy/nginx/share.bonfire.icu.conf.example +++ b/deploy/nginx/share.bonfire.icu.conf.example @@ -45,8 +45,9 @@ server { return 404; } - # LiveKit client v0 validates this retired path before joining. Reject it - # explicitly so the broader current-v1 proxy cannot accept an old SDK. + # Pinned livekit-client@2.22.0 still tries this v0 endpoint after a + # ServiceNotFound response. Reject that fallback with a no-log 404; + # /rtc/v1* below is the accepted SDK path. location = /rtc/validate { access_log off; error_log /dev/null crit; diff --git a/docs/方案设计.md b/docs/方案设计.md index 23947d4..b0a9489 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -312,10 +312,10 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) 状态:Proposed / Experiment Only。该扩展不改变上面的已部署 PoC,也不表示正式采用。 -部署可在 `PEER_ASSISTED_MEDIA=true` 时用可选的 -`PEER_ASSISTED_ROOM_IDS` 精确限制实验房间。非空值只接受逗号分隔、符合现有 -room code 规则且不重复的 ID;非法配置启动即失败。未配置或空值为兼容旧行为而 -表示全部房间,因此隔离灰度必须填写至少一个精确 ID。未命中房间不得进入 +部署设置 `PEER_ASSISTED_MEDIA=true` 时必须同时提供非空的 +`PEER_ASSISTED_ROOM_IDS` 精确限制实验房间。该值只接受逗号分隔、符合现有 +room code 规则且不重复的 ID;缺失、空值或非法配置都启动失败,不存在全房间 +fail-open。未命中房间不得进入 `HybridMediaRouter`、房间质量同步或 LiveKit 兜底,route 字段、定向信令、停止、 重连和删除保持普通 P2P 行为;ordinary STUN-only ICE snapshot 是进程级契约, 不为未命中房间保留旧 TURN wire。该边界不增加 UI、用户选择或百分比框架。 From 38d39e24e62daa8c7035f1ccf32367197bca5f23 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:29:52 +0800 Subject: [PATCH 5/5] docs(deploy): describe pinned LiveKit fallback --- deploy/nginx/share.bonfire.icu.conf.example | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/deploy/nginx/share.bonfire.icu.conf.example b/deploy/nginx/share.bonfire.icu.conf.example index b14407e..402d745 100644 --- a/deploy/nginx/share.bonfire.icu.conf.example +++ b/deploy/nginx/share.bonfire.icu.conf.example @@ -37,8 +37,8 @@ server { add_header X-Frame-Options "DENY" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - # Reject the retired unversioned LiveKit endpoint. Keep this exact location - # out of logs because old URLs may still contain a short-lived join token. + # Reject the unversioned fallback endpoint that pinned livekit-client@2.22.0 + # can still try. Keep it out of logs because the URL may contain a join token. location = /rtc { access_log off; error_log /dev/null crit; @@ -54,8 +54,8 @@ server { return 404; } - # Current LiveKit clients use v1 paths only. Set - # LIVEKIT_URL to wss://share.bonfire.icu. + # This integration accepts only the v1 path. Set LIVEKIT_URL to + # wss://share.bonfire.icu. location ^~ /rtc/v1 { # The WebSocket query contains a short-lived LiveKit join token. Do not # persist the request target in either access or error logs.