diff --git a/.env.example b/.env.example
index 7de12c5..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
@@ -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/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/README.md b/README.md
index d959f13..8e98f82 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
+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.
Run the complete automated validation with:
diff --git a/deploy/coturn/turnserver.conf.example b/deploy/coturn/turnserver.conf.example
index 2d205a4..cb8a9e6 100644
--- a/deploy/coturn/turnserver.conf.example
+++ b/deploy/coturn/turnserver.conf.example
@@ -1,73 +1,24 @@
-# 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
-# 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..15b44bd 100644
--- a/deploy/livekit/livekit.yaml.example
+++ b/deploy/livekit/livekit.yaml.example
@@ -9,8 +9,13 @@ 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
+ 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:
@@ -23,5 +28,6 @@ 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.
+# 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..402d745 100644
--- a/deploy/nginx/share.bonfire.icu.conf.example
+++ b/deploy/nginx/share.bonfire.icu.conf.example
@@ -37,9 +37,26 @@ 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 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;
+ return 404;
+ }
+
+ # 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;
+ return 404;
+ }
+
+ # 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.
access_log off;
@@ -58,24 +75,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/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/0004-peer-assisted-media-experiment.md b/docs/adr/0004-peer-assisted-media-experiment.md
index d4ba47a..9502d5f 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 c40df33..5767b32 100644
--- a/docs/adr/0005-automatic-hybrid-media-routing.md
+++ b/docs/adr/0005-automatic-hybrid-media-routing.md
@@ -78,23 +78,24 @@ 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 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 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
@@ -177,6 +178,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 +203,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 +311,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 +331,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,9 +371,9 @@ 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
+ bounded UDP-blocked failure, source/profile/pause/stop, and server-egress
measurements; only after those pass, add the 20-viewer gate before changing
- the room default.
+ the room default. Any future selected-edge TURN has its own separate gate.
## Acceptance Gates
@@ -384,13 +393,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 old-release 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
@@ -399,8 +409,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..89eba2d 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
@@ -58,47 +58,34 @@ 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 TCP
-7881 or 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.
-## 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:
@@ -142,23 +126,24 @@ 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
`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
-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.
+every other room keeps ordinary P2P route fields and signaling behavior. The
+ordinary STUN-only ICE snapshot is process-wide; unlisted rooms do not retain
+the old TURN contract. Omitting or blanking the variable fails startup rather
+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
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,29 @@ 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, 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
@@ -241,16 +226,13 @@ 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
+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
@@ -281,18 +263,15 @@ 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
-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
`/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 +281,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.
@@ -318,7 +297,10 @@ from its official release assets, generate an independent key pair with
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.
+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.
@@ -328,125 +310,72 @@ 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 set `rtc.tcp_port` above zero, enable TCP fallback, or add 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 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.
-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. 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.
+ 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 6be7740..8ec4342 100644
--- a/docs/project-memory.md
+++ b/docs/project-memory.md
@@ -4,7 +4,7 @@ Last updated: 2026-08-19
## Confirmed Intent
-- Build low-latency game screen sharing for one broadcaster and a small trusted group; public or large broadcasts belong on OBS/Twitch-class services.
+- Build low-latency game sharing for one broadcaster and trusted friends; public or large broadcasts belong on OBS/Twitch-class services.
- Viewers join from normal desktop/mobile browsers. A later native sender may share encoding and improve capture/audio without changing the Web viewer requirement.
- Use a small central service for access, rooms, signaling, deterministic topology, STUN, observability, and bounded SFU-root capacity; TURN is optional extreme-network transport.
- Minimize server bandwidth. The accepted invisible ladder is direct/peer UDP, then SFU/UDP roots, then optional TURN for a selected exceptional edge, then bounded failure. TURN is transport, not topology.
@@ -17,14 +17,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 experiments bounded: the standard representation sequence is below; native RTP relay, encoded-object striping, and FEC remain separate.
- ADR-0006's fixed-`HIGH` canary reached host setup and one encoder output but retained no downstream checkpoint before the first-viewer timeout. It is no-go-unclassified; no native product code is accepted, and any revisit starts at its 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. The candidate has no TURN consumer or credential wire; any future exceptional-edge grant is a complete separate change. PR #12 is superseded.
+- Keep the failure-only controller default-off until the isolated STUN/SFU 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 STUN-only ordinary ICE and separate LiveKit SFU/UDP, with no TURN/media-TCP wire.
- Treat settings as ceilings and deployed degradation as unclassified. Use correlated Host A+B/Viewer C to compare exact `769de201f7cc` against current `main` for Host-only recovery, game FPS/load, preview cost, actual codec/encoder and one-variable rebuilds; then test standard simulcast/LiveKit/SVC before custom `LOW`. Never use UA or a composite score.
- Production reports poor film audio and self-echo when system capture includes voice software. Diagnose audio A/B/C and sync; Web cannot isolate arbitrary processes and `maxBitrate` is not quality-up. A Windows 11 native candidate defaults to game-process-tree audio and never widens silently; Windows 10 remains unresolved/unsupported. See `docs/research/browser-screen-audio-quality.md`.
- Post-gate UI: local volume/mute, display name, transient roster, local-only candidate endpoints, and RTP loss rate; no accounts, database, telemetry, media, or routing effects.
@@ -40,7 +40,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.
-- 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.
@@ -64,7 +65,7 @@ These are measurement gates, not performance claims.
- Sustainable count by hardware, quality, network, and route: finish instrumented `1/3/5/8`, then pass a 20-viewer matrix before changing the accepted target default to 20.
- Whether ADR-0004 passes fanout, re-encoding, depth-four latency, reparenting, silent-partition, and mobile-leaf gates.
-- 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..9c85123 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,18 @@ 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, 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
@@ -229,15 +235,13 @@ One bounded exact-room gate owns rollout evidence:
8 and 12 Mbps with one and two roots. Record CPU seconds/GiB, RX/TX bytes,
packets/s, RSS, host upload, p95/p99 forwarding latency, loss/recovery, and
final decoded quality.
-2. Cover representative consumer networks 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
+ `rtc.tcp_port: 0`, `allow_tcp_fallback: false`, and no external or embedded TURN. Verify ordinary peers and
+ SFU roots use UDP. Keep the old release on a separate rollback instance;
+ `PEER_ASSISTED_ROOM_IDS` does not preserve old TURN wire for unlisted rooms.
+3. Block all UDP and show a bounded explicit failure rather than a long
+ pseudo-connection. Any later selected-edge TURN or media TCP implementation
+ is a separate complete PR and cannot be marketed as a quality path.
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 +363,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 23f88de..aeacd43 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. 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 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; 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.
@@ -56,9 +56,10 @@ After that reproduction, test simulcast/LiveKit/SVC in order and stop at the
first fit before custom `LOW`, independently of ADR-0006. Diagnose audio A/B/C,
sync, and voice-source leakage in parallel when it does not displace that P0;
`maxBitrate` is not a quality fix. WebRTC/LiveKit owns congestion/layers; the app
-owns two-state policy. An exact room later gates peer/SFU UDP and coturn changes.
-After media gates, UI adds local volume/mute, names/roster, local endpoint
-details, and RTP loss rate.
+owns two-state policy. An isolated exact room later gates peer/SFU UDP and
+bounded failure before replacing the old release. Selected-edge TURN, if later
+justified, is a separate complete change rather than part of this canary.
+Post-gate UI adds local volume/mute, names/roster, endpoint details, and RTP loss.
ADR-0004 still requires a full-resolution 30-minute `1/3/5/8` network,
resource, quality, latency, recovery, and browser/mobile-leaf matrix. A separate
diff --git a/docs/方案设计.md b/docs/方案设计.md
index 4447ce7..890f5de 100644
--- a/docs/方案设计.md
+++ b/docs/方案设计.md
@@ -2,23 +2,23 @@
## 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;自动分布式拓扑通过 20 人门禁后,目标 release 才把默认值和上限原子改为 20。容量配置只是接入边界,不是性能声明。
+产品面向一名分享者和小范围可信朋友的低延迟游戏画面分享。观看端使用普通桌面或手机浏览器;每条媒体边独立 ICE。旧 production 能直连时走 P2P、失败边使用 coturn;repository candidate 的普通边只接收自托管 STUN,peer 恢复耗尽后由 LiveKit ICE/UDP virtual parent 服务一至两个必要 roots,再失败则有界明确结束。当前房间默认最多八名观看者、可配置范围为 1 至 16;自动分布式拓扑通过 20 人门禁后,目标 release 才把默认值和上限原子改为 20。容量配置只是接入边界,不是性能声明。
本阶段必须形成完整的最小纵向链路:
- 分享者主动选择屏幕、窗口或标签页,并请求可用音频;
- 创建数字房间,并用可选全站密码统一保护分享和观看入口;受保护部署可选择最小 SQLite 持久化,使房间和链接在停止分享及进程重启后仍有效;
- 每名观看者拥有独立的 `RTCPeerConnection`;
-- 生产基线支持 STUN、TURN/UDP 和 TURN/TCP,并允许可选 TURN/TLS 配置;
+- 当前仓库候选要求 STUN,普通 peer ICE 只发 STUN servers;SFU candidate 只开放 LiveKit ICE/UDP;
- 展示连接状态、direct/relay、RTT、实际码率、帧率和丢包等数据;
- 超过部署配置上限的观看者被明确拒绝,不静默超载;
- 在代码、测试和真实浏览器验证完成前,不宣称达到延迟或画质目标。
-本基线不实现账号数据库、通用业务数据库、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 扩展,不引入第二套路由器或通用优化框架。
## 技术栈决策
@@ -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;不把证书管理写进应用进程 |
@@ -43,21 +43,23 @@ 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 路径上转发。
## 代码布局
```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。
@@ -104,7 +106,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 发送。
@@ -183,25 +184,49 @@ SVC 是 simulcast 与 LiveKit/Dynacast 均未满足后的第三个标准候选
标准表示能力排在完整 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`;已删除的 `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。
-```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,显式使用
+`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。
-`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
+保留旧 release 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)逐项计量。
## 可观测性
@@ -242,11 +267,11 @@ 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 时才提供强制 relay 诊断选项,并明确其服务器带宽成本。
+- 应用日志不得主动记录 IP;反向代理、STUN 和 LiveKit 为提供网络服务必然可见源地址,部署时需设置最短必要留存与访问控制。
+- 自托管 coturn 必须启用 `stun-only` 及 `no-tcp`/`no-tls`,不得配置 relay port range、TURN auth secret、证书或匿名 TURN;coturn 4.17.2 已弃用 `no-dtls`,tracked 配置不再使用它。未来 selected-edge TURN 由独立设计取代这份 STUN-only 配置,而不是在当前 wire 上加回全房 credential。
+- 分享者直连会向可信观看者暴露网络地址。当前 candidate 不保留 force-relay 产品分支;未来 selected-edge TURN 若被证据接受,配置、授权、UI 和测试必须原子引入。
## 验证计划
@@ -255,8 +280,8 @@ SFU 是主要中央兜底,但正常只作为 virtual parent 向一至两个必
- AccessSession 的密码开关、12 小时签名 cookie、过期、篡改、cookie 属性和无服务端 session 状态;
- RoomStore 的随机临时房间、TTL、host token 摘要验证、停止分享等待、当前默认八人/1 至 16 边界、目标 20 人 gate 和超额拒绝;
- SQLite 房间库从 `1` 自增、重启恢复、持久房间无过期时间、数据最小化、数据库路径无全站密码时启动失败;
-- 现行 TURN HMAC 固定向量、TTL 与生产 TURN/UDP+TCP 强校验保持回归覆盖;目标配置迁移另测 STUN 必需、TURN 全空正常禁用、部分配置 fail-fast、ordinary peer 的 per-edge coturn grant/wire,以及 LiveKit participant TURN 独立边界;
-- 协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制;
+- STUN URL 严格校验、生产 STUN 必需、ordinary ICE 不含 username/credential、旧 `refresh-ice`/`ice-config` 被协议拒绝,以及 LiveKit `sfu-config` 独立边界;
+- 单一 `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 保持未知;
@@ -266,7 +291,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 显式关闭 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 未完成前不得把默认上限写成性能承诺。
@@ -279,9 +304,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 预建抽象层。
@@ -289,14 +314,15 @@ SFU 是主要中央兜底,但正常只作为 virtual parent 向一至两个必
状态:Proposed / Experiment Only。该扩展不改变上面的已部署 PoC,也不表示正式采用。
-部署可在 `PEER_ASSISTED_MEDIA=true` 时用可选的
-`PEER_ASSISTED_ROOM_IDS` 精确限制实验房间。非空值只接受逗号分隔、符合现有
-room code 规则且不重复的 ID;非法配置启动即失败。未配置或空值为兼容旧行为而
-表示全部房间,因此隔离灰度必须填写至少一个精确 ID。未命中房间不得进入
-`HybridMediaRouter`、房间质量同步或 LiveKit 兜底,鉴权 wire、定向信令、停止、
-重连和删除必须逐项保持普通 P2P 行为。该边界不增加 UI、用户选择或百分比框架。
+部署设置 `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、用户选择或百分比框架。
-产品硬目标是任一非服务器节点任何时刻最多两条下游媒体边。系统按一个或两个观看者直接 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 711fa8a..f699282 100644
--- a/docs/需求理解.md
+++ b/docs/需求理解.md
@@ -22,7 +22,7 @@
- [ ] 一对多观看:当前安全默认上限为 8、可配置范围为 1 至 16;自动分布式拓扑验收后,同一 release 将默认值和上限改为 20。当前 PoC 的逐观看者连接是测量基线,不再是超过两名观看者时的目标拓扑;未通过 20 人门禁时继续明确拒绝超额加入。
- [ ] 端点 fanout 硬上限:任一非服务器端点任何时刻最多维护两条 active downstream media edges,包括加入、断线、换父和恢复过程;上游接收不计入这项上传预算。当前 browser relay 因逐跳重编码仍保守限制为一个 child,只有资源矩阵通过后才可升到二。第三名及后续观看者由系统自动分配给通过验收的 peer-assisted 拓扑,不能隐藏地回退为 Host 第三条连接。没有可行 peer path 时,SFU 作为主要中央兜底 virtual parent 正常向一至两个必要 roots 送流;媒体到达 root 后其余可行观看者仍优先沿受限 peer descendants 扩散。多个无法挂到健康 root 的极端 viewer 可成为额外 server-fed 例外,但必须受显式中央 egress/admission 上限约束,不能无界整房 fanout。TURN 只作为可选的极端网络 transport 兼容层,不定义房间拓扑。
- [ ] P2P 优先:每条逻辑媒体边的 ICE 必须先尝试直接 UDP 路径,服务器不得默认中转可直连的媒体。一个或两个观看者优先直接连接分享端;更多观看者的 peer-assisted 行为须先通过 ADR-0004 的实验门槛。
-- [ ] NAT 与传输兜底:STUN 必须可用。普通 direct/peer 边默认只使用 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 套,也禁止把最差路径的目标应用到整个房间。实现必须依次验证标准 WebRTC simulcast、LiveKit layer selection/Dynacast 和 SVC;任一方案满足按需停启、独立选层与资源门槛即停止,不继续自研媒体层。标准浏览器不保证跨 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。
@@ -34,7 +34,7 @@
### P1 - 重要功能
-- [ ] 实验路由按房间灰度:部署开启 `PEER_ASSISTED_MEDIA` 时可用严格的房间 ID allowlist 只让指定房间进入 peer-assisted 与已配置的 SFU 路径;同一进程内未命中房间的鉴权 wire、P2P 信令、质量拒绝、停止、重连和删除行为必须保持不变。缺失或空 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。只有稳定收益且捕获、编码和远端画面不受影响时,才允许直播开始后默认显示静止缩略图或隐藏动态预览并提供按需展开;未测前不宣称收益。
@@ -46,7 +46,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 应用音频:Windows 11 原生候选默认仅捕获所选游戏及其子进程的音频,避免把语音聊天或通知声一并分享;“全部系统声音”只能由分享者显式选择。普通 Windows 10 不具备当前 process-loopback API 门槛,必须明确标为未解决/不支持而不是伪兼容。浏览器无法证明或隔离任意进程音频时必须如实提示,原生捕获失败、无声或进程退出不得静默扩大为整机捕获。
-- [ ] 连接自检:在开始分享前验证信令、STUN、SFU ICE/UDP 及部署实际启用的可选 TURN 或媒体 TCP 可达性,并给出不暴露凭据的诊断结果。未配置可选兼容层不是告警。
+- [ ] 连接自检:在开始分享前验证信令、STUN 和 SFU ICE/UDP 可达性,并给出不暴露地址或凭据的诊断结果。当前没有 selected-edge TURN 或媒体 TCP 配置面;未来兼容层只有在完整实现后才加入自检。
- [ ] 受限网络增强:部署者仅在明确选择覆盖 UDP 受限网络且 canary 证明收益时,才可启用短期鉴权 TURN 或媒体 TCP;它只能用于控制器选中的异常 transport,不能成为所有普通 peer 边的默认候选,也不能被描述为更高清的传输方式。
- [ ] 运行时编解码选择:先保留浏览器默认协商;只有端点能力相交、Media Capabilities 对目标分辨率/FPS/码率报告 supported/smooth/power-efficient,且实际 outbound codec、区间 encode time、游戏 FPS、CPU/GPU 与 sender 数量共同通过时,才调整 H.264、VP8、VP9 或 AV1 优先级。AV1 不得因压缩率推断而默认启用,任何候选都不得静默落入高负载软件编码。
- [ ] 诊断页面:用户可导出不含敏感地址和凭据的连接诊断信息。
@@ -95,7 +95,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..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,21 +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: "",
- TURN_URLS: "",
- TURN_SHARED_SECRET: "",
- }),
+ 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 0a9fc85..cf1ef1e 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 {
@@ -46,7 +55,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;
@@ -121,6 +129,7 @@ export class SignalingClient {
}
const authenticate: ClientMessage = {
type: "authenticate",
+ protocol: SIGNALING_PROTOCOL,
...this.identity,
};
socket.send(JSON.stringify(authenticate));
@@ -139,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;
}
@@ -161,10 +179,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);
});
@@ -188,7 +203,9 @@ export class SignalingClient {
this.events.onTerminated(
event.reason === "Session replaced"
? "此页面的会话已被另一个标签页接管"
- : "信令会话已终止,请刷新后重试",
+ : event.code === INVALID_MESSAGE_CLOSE_CODE
+ ? PROTOCOL_REFRESH_MESSAGE
+ : "信令会话已终止,请刷新后重试",
);
}
});
@@ -209,6 +226,11 @@ export class SignalingClient {
}, delay);
}
+ private terminateForProtocolMismatch(): void {
+ this.stop();
+ this.events.onTerminated(PROTOCOL_REFRESH_MESSAGE);
+ }
+
private checkAccess(): void {
if (this.accessCheck) {
return;
@@ -233,26 +255,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 +268,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 7150390..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,
);
@@ -144,7 +139,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
- {showConnectionDetails && forceRelay && (
- 强制中继
- )}
{(phase === "live" || phase === "starting") && (
{phase === "live" && (
@@ -1327,12 +1304,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 d6d5e87..bd2b85a 100644
--- a/src/client/pages/ViewerPage.tsx
+++ b/src/client/pages/ViewerPage.tsx
@@ -60,15 +60,10 @@ 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("正在连接");
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);
@@ -135,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;
@@ -235,7 +223,6 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
}
},
},
- forceRelay,
);
viewerRelay.setChild(currentAssignment.childPeerIds[0] ?? null);
return viewerRelay;
@@ -444,10 +431,14 @@ 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,
);
peerRef.current = peer;
return peer;
@@ -487,7 +478,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;
@@ -615,13 +605,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);
@@ -692,7 +675,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
viewerRelay?.dispose();
viewerRelay = null;
};
- }, [forceRelay, onAuthorizationRequired, roomId]);
+ }, [onAuthorizationRequired, roomId]);
useEffect(() => {
const video = videoRef.current;
@@ -774,9 +757,6 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
房间 {roomId}
- {showConnectionDetails && forceRelay && (
-
强制中继
- )}
{showConnectionDetails && (
@@ -852,12 +832,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..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,10 +252,9 @@ export class HostPeer {
try {
this.connection.setConfiguration({
iceServers: iceConfig.iceServers,
- 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..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,10 +171,9 @@ export class ViewerPeer {
try {
this.connection.setConfiguration({
iceServers: iceConfig.iceServers,
- iceTransportPolicy: this.forceRelay ? "relay" : "all",
});
} catch (error) {
- this.setError(error, "更新 TURN 配置失败");
+ 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/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..5a061a4 100644
--- a/src/server/config.ts
+++ b/src/server/config.ts
@@ -1,19 +1,24 @@
import {
+ MAX_ICE_SERVER_URLS,
MAX_VIEWERS_PER_ROOM_LIMIT,
roomCodeSchema,
+ stunUrlSchema,
} 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;
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;
@@ -37,9 +42,6 @@ export interface ServerConfig {
peerAssistedRoomIds?: ReadonlySet;
livekitFallback?: LiveKitFallbackConfig;
stunUrls: readonly string[];
- turnUrls: readonly string[];
- turnSharedSecret?: string;
- turnCredentialTtlSeconds: number;
}
function parseBoolean(
@@ -59,12 +61,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,23 +172,15 @@ function parseUrlList(value: string | undefined, name: string): string[] {
});
}
-function parseIceUrlList(
- value: string | undefined,
- name: string,
- allowedProtocols: ReadonlySet,
-): string[] {
- return parseUrlList(value, name).map((value) => {
- let protocol: string;
- try {
- protocol = new URL(value).protocol;
- } catch {
- throw new Error(`${name} contains an invalid URL`);
- }
- if (!allowedProtocols.has(protocol)) {
- throw new Error(`${name} contains an unsupported URL scheme`);
- }
- if (!parseIceEndpoint(value)) {
- throw new Error(`${name} contains an invalid ICE URL`);
+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) => {
+ if (!stunUrlSchema.safeParse(value).success) {
+ throw new Error(`${name} contains an invalid STUN URL`);
}
return value;
});
@@ -229,96 +217,6 @@ function parsePeerAssistedRoomIds(
return roomIds;
}
-function parseIceEndpoint(value: string): IceEndpoint | undefined {
- const schemeSeparator = value.indexOf(":");
- if (schemeSeparator <= 0) {
- return undefined;
- }
-
- const scheme = value.slice(0, schemeSeparator).toLowerCase();
- if (
- scheme !== "stun" &&
- scheme !== "stuns" &&
- scheme !== "turn" &&
- scheme !== "turns"
- ) {
- return undefined;
- }
-
- const remainder = value.slice(schemeSeparator + 1);
- if (!remainder || remainder.includes("#")) {
- return undefined;
- }
-
- 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 (
- !authorityText ||
- /[\\/\s]/.test(authorityText) ||
- authorityText.endsWith(":")
- ) {
- return undefined;
- }
-
- let authority: URL;
- try {
- authority = new URL(`http://${authorityText}`);
- } catch {
- return undefined;
- }
- if (
- !authority.hostname ||
- authority.username ||
- authority.password ||
- authority.pathname !== "/" ||
- authority.search ||
- authority.hash
- ) {
- return undefined;
- }
-
- const port = authority.port ? Number(authority.port) : undefined;
- if (port === 0) {
- return undefined;
- }
-
- 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",
- );
- }
-}
-
function toOrigin(value: string): string {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
@@ -330,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) {
@@ -364,17 +270,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,
@@ -397,23 +293,22 @@ 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");
}
- 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 +326,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 +355,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 ef84c99..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,
@@ -24,7 +25,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"];
@@ -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,
@@ -408,14 +422,8 @@ 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(),
+ } satisfies Extract;
if (hybridState) {
this.send(socket, {
...authenticatedMessage,
@@ -504,16 +512,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) ||
@@ -605,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;
@@ -614,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");
@@ -1066,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 {
@@ -1078,17 +1073,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 15c94ff..bbb0742 100644
--- a/src/shared/protocol.ts
+++ b/src/shared/protocol.ts
@@ -4,9 +4,11 @@ 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;
+export const MAX_ICE_SERVER_URLS = 8;
export const MAX_VIEWER_QUALITY_EVIDENCE_BYTES = 2 * 1024;
export const VIEWER_QUALITY_EVIDENCE_INTERVAL_MS = 2_000;
export const VIEWER_QUALITY_EVIDENCE_EXPIRY_MS = 5_000;
@@ -87,22 +89,59 @@ export const relayDownstreamEdgesSchema = z.union([
]);
export type RelayDownstreamEdges = z.infer;
+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)
+ .refine(isValidStunUrl, { message: "Invalid STUN URL" });
+
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;
@@ -278,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,
@@ -288,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,
@@ -312,7 +353,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"),
@@ -353,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;
@@ -373,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(),
@@ -467,12 +506,6 @@ export const serverMessageSchema = z.union([
.refine(freezeFitsEvidenceWindow, {
message: "Viewer freeze duration exceeds its evidence window",
}),
- z
- .object({
- type: z.literal("ice-config"),
- iceConfig: iceConfigSchema,
- })
- .strict(),
z
.object({
type: z.literal("host-status"),
diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts
index d6a0963..bb9aa6c 100644
--- a/tests/client-state.test.ts
+++ b/tests/client-state.test.ts
@@ -203,9 +203,90 @@ 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);
+ 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);
+ },
+ );
+
it("returns to the access gate after an unauthorized upgrade", async () => {
const sockets: FakeWebSocket[] = [];
class FakeWebSocket extends EventTarget {
@@ -243,7 +324,6 @@ describe("client signaling recovery policy", () => {
{
onMessage: () => undefined,
onStatus: (status) => statuses.push(status),
- onProtocolError: () => undefined,
onTerminated: () => undefined,
onAccessRequired,
},
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/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 e262ba2..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,
@@ -49,22 +50,47 @@ const qualityEvidence = {
} 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(
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",
@@ -79,6 +105,7 @@ describe("client signaling protocol", () => {
expect(
clientMessageSchema.safeParse({
type: "authenticate",
+ protocol: SIGNALING_PROTOCOL,
roomId,
role: "host",
token,
@@ -88,6 +115,7 @@ describe("client signaling protocol", () => {
expect(
clientMessageSchema.safeParse({
type: "authenticate",
+ protocol: SIGNALING_PROTOCOL,
roomId,
role: "viewer",
token,
@@ -97,6 +125,7 @@ describe("client signaling protocol", () => {
expect(
clientMessageSchema.safeParse({
type: "authenticate",
+ protocol: SIGNALING_PROTOCOL,
roomId: "1",
role: "viewer",
clientId: "client_12345678",
@@ -105,6 +134,7 @@ describe("client signaling protocol", () => {
expect(
clientMessageSchema.safeParse({
type: "authenticate",
+ protocol: SIGNALING_PROTOCOL,
roomId: "0123",
role: "viewer",
clientId: "client_12345678",
@@ -113,6 +143,7 @@ describe("client signaling protocol", () => {
expect(
clientMessageSchema.safeParse({
type: "authenticate",
+ protocol: SIGNALING_PROTOCOL,
roomId: "1".repeat(13),
role: "viewer",
clientId: "client_12345678",
@@ -159,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,
);
@@ -172,6 +203,7 @@ describe("client signaling protocol", () => {
expect(
clientMessageSchema.safeParse({
type: "authenticate",
+ protocol: SIGNALING_PROTOCOL,
roomId,
role: "host",
token,
@@ -179,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,
);
@@ -377,9 +406,49 @@ 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",
+ protocol: SIGNALING_PROTOCOL,
role: "host",
peerId: "host_12345678",
roomExpiresAt: "2026-08-18T18:00:00.000Z",
@@ -389,8 +458,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..59c70c3 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();
@@ -22,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)} `,
@@ -40,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),
@@ -104,6 +103,9 @@ describe("server configuration", () => {
loadConfig({
NODE_ENV: "production",
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),
@@ -131,56 +133,35 @@ 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",
+ 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),
},
])("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",
);
});
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,
);
@@ -190,6 +171,7 @@ describe("server configuration", () => {
expect(() =>
loadConfig({
PEER_ASSISTED_MEDIA: "true",
+ PEER_ASSISTED_ROOM_IDS: "1",
MAX_VIEWERS_PER_ROOM: "9",
}),
).toThrow(
@@ -197,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", () => {
@@ -221,26 +209,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 +238,41 @@ 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",
+ PEER_ASSISTED_ROOM_IDS: "1",
+ 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 +285,54 @@ 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: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 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 invalid STUN URL",
+ );
+ expect(() => loadConfig({ STUN_URLS: "stuns:stun.test:5349" })).toThrow(
+ "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`,
+ );
+ });
+
+ 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 +340,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 +364,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 +377,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..2712e3e 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,
};
}
@@ -342,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 24c7743..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 {
@@ -146,8 +148,6 @@ function testConfig(): ServerConfig {
maxViewersPerRoom: 8,
peerAssistedMedia: false,
stunUrls: [],
- turnUrls: [],
- turnCredentialTtlSeconds: 3_600,
};
}
@@ -161,13 +161,14 @@ async function startHarness(
accessPassword?: string;
persistent?: boolean;
peerAssistedMedia?: boolean;
- peerAssistedPrimaryRoomOnly?: boolean;
+ stunUrls?: readonly string[];
now?: () => number;
} = {},
): 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({
@@ -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,7 @@ async function startSfuHarness(options: {
prepareTimeoutMs?: number;
maxRoots?: number;
viewerDisconnectGraceMs?: number;
- peerAssistedPrimaryRoomOnly?: boolean;
+ stunUrls?: readonly string[];
}): Promise {
const roomStore = new RoomStore({
ttlMs: 14_400_000,
@@ -247,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();
@@ -255,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,
@@ -265,9 +267,7 @@ async function startSfuHarness(options: {
prepareTimeoutMs: options.prepareTimeoutMs,
},
ice: {
- stunUrls: [],
- turnUrls: [],
- credentialTtlSeconds: 3_600,
+ stunUrls: options.stunUrls ?? [],
},
allowedOrigins: new Set([allowedOrigin]),
authorizeUpgrade: () => true,
@@ -306,6 +306,7 @@ async function startSfuHarness(options: {
webSocketUrl: `ws://127.0.0.1:${port}/signal`,
roomStore,
room,
+ peerAssistedRoomIds,
};
}
@@ -383,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,
@@ -393,6 +395,7 @@ async function authenticate(
}
: {
type: "authenticate",
+ protocol: SIGNALING_PROTOCOL,
roomId: room.roomId,
role,
clientId,
@@ -913,12 +916,12 @@ 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();
+ const ordinaryRoom = harness.roomStore.createRoom();
const hybridHost = await openClient(harness.webSocketUrl);
const hybridHostAuth = peerAssisted(
@@ -930,103 +933,112 @@ 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(
- 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(ordinaryHostAuth.iceConfig).toEqual(hybridHostAuth.iceConfig);
+ expect(
+ 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(
@@ -1050,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(
@@ -1092,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(
@@ -1117,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" }));
@@ -1133,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);
});
@@ -1539,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"),
@@ -2627,6 +2640,7 @@ describe("WebSocket signaling", () => {
});
const replacementRoom = harness.roomStore.createRoom();
+ harness.peerAssistedRoomIds!.add(replacementRoom.roomId);
const replacing = await prepareFallbackForTwoViewers(
harness.webSocketUrl,
replacementRoom,
@@ -2697,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,
@@ -3520,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}`,
@@ -3540,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);
@@ -3617,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, "//[");
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);