From 38eeadd284372502d6446b99d44f974eed4612a7 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:40:30 +0800 Subject: [PATCH] feat: simplify site access and room joining (#7) * feat: simplify site access and room joining * docs: update access and room capacity design --- .env.example | 9 +- README.md | 6 +- deploy/coturn/turnserver.conf.example | 4 +- docs/adr/0001-p2p-first-media-topology.md | 9 +- docs/deployment.md | 67 ++++-- docs/project-memory.md | 10 +- docs/research/webrtc-p2p-screen-sharing.md | 31 ++- docs/status.md | 16 +- docs/方案设计.md | 61 ++--- docs/需求理解.md | 17 +- src/client/App.tsx | 163 ++++++++++++- src/client/components/StatsGrid.tsx | 62 +++-- src/client/lib/api.ts | 102 ++++++-- src/client/lib/session.ts | 43 +--- src/client/lib/signaling.ts | 42 +++- src/client/pages/HostPage.tsx | 65 +++--- src/client/pages/JoinPage.tsx | 59 +++++ src/client/pages/ViewerPage.tsx | 39 +--- src/client/styles.css | 67 +++++- src/server/access-session.ts | 102 ++++++++ src/server/app.ts | 148 ++++++++++-- src/server/config.ts | 57 +++-- src/server/room-store.ts | 73 ++++-- src/server/signaling.ts | 43 ++-- src/shared/protocol.ts | 34 ++- tests/client-state.test.ts | 155 ++++++++++++- tests/protocol.test.ts | 93 +++++++- tests/room-store.test.ts | 94 +++++--- tests/server-config.test.ts | 63 ++++- tests/server-http.test.ts | 258 +++++++++++++++++---- tests/server-signal.test.ts | 112 +++++++-- 31 files changed, 1669 insertions(+), 435 deletions(-) create mode 100644 src/client/pages/JoinPage.tsx create mode 100644 src/server/access-session.ts diff --git a/.env.example b/.env.example index 7190778..3527320 100644 --- a/.env.example +++ b/.env.example @@ -6,9 +6,12 @@ PORT=8787 PUBLIC_BASE_URL=http://localhost:8787 # Include both the host browser origin and PUBLIC_BASE_URL when they differ. ALLOWED_ORIGINS=http://localhost:8787 -ROOM_CREATION_TOKEN= +# Optional. Use 12-128 visible ASCII characters in production. When set, every +# browser must sign in before sharing or watching. +ACCESS_PASSWORD= ROOM_TTL_SECONDS=14400 MAX_ROOMS=1000 +MAX_VIEWERS_PER_ROOM=8 # ICE servers. Separate multiple URLs with commas. STUN_URLS= @@ -16,8 +19,8 @@ TURN_URLS= TURN_SHARED_SECRET= TURN_CREDENTIAL_TTL_SECONDS=3600 -# Minimal production example (use independent untracked secrets of at least 32 bytes): -# ROOM_CREATION_TOKEN=replace-with-at-least-32-random-bytes +# Minimal production example (use independent untracked secrets): +# ACCESS_PASSWORD=replace-with-a-strong-passphrase # 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 diff --git a/README.md b/README.md index 9ad1607..cb2a323 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 media path is WebRTC P2P-first, with authenticated TURN fallback for network pairs that cannot connect directly. 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 creates an expiring invitation for up to three viewers. The host can change the shared source without replacing healthy peer connections. Each viewer receives an independent WebRTC connection, so direct and TURN-relayed paths can coexist in one room. The Node.js service carries only room, authentication, ICE credential, and signaling traffic. +The first measurable Web proof of concept is implemented. A host captures a screen, window, or browser tab and creates an expiring room with a 12-digit numeric code. Friends can open `/r/{code}` directly or enter the code at `/join`; viewer links contain no separate token. Each viewer 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 a real 1:8 media session has not yet been validated and this setting is not a performance promise. The Node.js service carries only room, access, ICE credential, and signaling traffic. -This is not yet a production release. Real cross-network TURN behavior, game audio, mobile browser lifecycle behavior, and latency targets still require the documented manual test matrix. +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. ## Run locally @@ -15,6 +15,8 @@ npm ci npm run dev ``` +`ACCESS_PASSWORD` is optional. Leaving it empty makes the site public; setting it gates both hosting and viewing behind one site-wide password. Internet deployments intended to stay private should set it, because in public mode the numeric room code is the only viewing capability and does not provide strong privacy by itself. + Open `http://localhost:8787`. The server listens on `0.0.0.0` by default so a phone on the same LAN can load `http://:8787`. To generate an invite that the phone can open while the host keeps using secure-context diff --git a/deploy/coturn/turnserver.conf.example b/deploy/coturn/turnserver.conf.example index 6dc1f9e..2d205a4 100644 --- a/deploy/coturn/turnserver.conf.example +++ b/deploy/coturn/turnserver.conf.example @@ -41,7 +41,9 @@ 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. -user-quota=8 +# 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 diff --git a/docs/adr/0001-p2p-first-media-topology.md b/docs/adr/0001-p2p-first-media-topology.md index d6b939f..166ff0d 100644 --- a/docs/adr/0001-p2p-first-media-topology.md +++ b/docs/adr/0001-p2p-first-media-topology.md @@ -12,11 +12,11 @@ The product targets one game broadcaster and a small group of trusted friends. L Use separate control and media planes: - A small HTTPS/WSS service owns identity, rooms, invitations, presence, and WebRTC signaling. -- A viewer can join through an authenticated, expiring Web link on desktop or mobile without installing the sharing client. +- A viewer can join through a 12-digit room code on desktop or mobile without installing the sharing client. The viewer link has no separate token or fragment; the host publication token remains internal. A deployment may place one site-wide password gate in front of both hosting and viewing; access policy remains control-plane state and does not change the media topology. - ICE attempts a direct UDP path for every broadcaster-viewer pair, using STUN to discover candidates. - Only pairs that cannot connect directly use authenticated TURN. Production requires STUN plus TURN over UDP and TCP; TURN/TLS is an optional restrictive-network enhancement, using its standard TCP port 5349 by default. Port 443 is optional and requires a dedicated public IP or a validated layer-4/SNI route when HTTPS already owns that address and port. - Candidate selection is independent per pair. A room may simultaneously contain direct and relayed viewers without moving working peers onto the server. -- The broadcaster creates one peer connection per viewer. The PoC hard limit is three viewers; a later release may reconsider it only after publisher upload and encoder measurements. +- The broadcaster creates one peer connection per viewer. Rooms default to eight viewers and deployments may configure a limit from 1 through 16. Eight is an admission default, not a validated media-performance promise; the sustainable envelope is decided from publisher upload, encoder, latency, and stability measurements. - No SFU is planned for the normal product envelope. It is only a future reconsideration point if the small-room scope changes or measured relay/upload/encoder pressure makes the chosen envelope unworkable. ## Consequences @@ -33,6 +33,7 @@ Negative: - Broadcaster upload scales approximately as `viewer_count * stream_bitrate`. - Browser APIs do not guarantee that several peer connections share a single hardware encode. - Direct peers learn one another's network addresses; this is acceptable only for the initial trusted-friends threat model. +- If the site-wide password is disabled, the numeric room code is the sole viewing capability and does not provide a strong privacy guarantee. - A room-level P2P-to-SFU migration adds state, keyframe, and reconnection complexity and is not part of the first prototype. - TURN over TCP can suffer head-of-line blocking, and its `turn:` client-to-server transport is not TLS-wrapped. The WebRTC media remains protected by DTLS-SRTP independently of that TURN transport. - Optional TURN/TLS on port 5349 or 443 can improve compatibility but cannot guarantee success through every authenticated proxy or policy-controlled network. A normal Cloudflare HTTP proxy does not proxy TURN; using Cloudflare for TURN requires a compatible layer-4 product such as Spectrum. @@ -48,6 +49,6 @@ Negative: ## Revisit Triggers - More than 20% of successful viewer connections use TURN in representative telemetry. -- The publisher is CPU-limited or cannot sustain the requested aggregate upload for three viewers. -- Product requirements raise the normal room size above four viewers. +- The publisher is CPU-limited or cannot sustain the configured aggregate upload for representative rooms. +- Product requirements exceed the measured sustainable P2P room envelope. - Protecting peer IP addresses becomes a product requirement. diff --git a/docs/deployment.md b/docs/deployment.md index 60cc69b..3e71767 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -8,9 +8,10 @@ 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 workstation does not have coturn installed, so the coturn configuration and -public-network procedures below have not been run locally. Treat deployment as -unverified until the direct, relay, and mixed-network checks at the end pass. +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. ## Topology and prerequisites @@ -69,9 +70,10 @@ LISTEN_HOST=127.0.0.1 PORT=8787 PUBLIC_BASE_URL=https://share.example.com ALLOWED_ORIGINS=https://share.example.com -ROOM_CREATION_TOKEN= +ACCESS_PASSWORD= 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 @@ -80,13 +82,36 @@ TURN_CREDENTIAL_TTL_SECONDS=3600 ``` `ALLOWED_ORIGINS` must list exact `http` or `https` origins, never `*`. -`ROOM_CREATION_TOKEN` and `TURN_SHARED_SECRET` must each contain at least 32 -bytes. They must be independently generated rather than reused. +`ACCESS_PASSWORD` is optional: omit it or leave it empty for a public site. For +an Internet deployment intended to stay private, set a strong independent value; +production accepts 12 through 128 visible ASCII characters so the password can +be carried safely in the login header. `TURN_SHARED_SECRET` must +contain at least 32 bytes and must not reuse the access password. +`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. +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 +`Authorization: Bearer` header, then returns a 12-hour stateless HMAC-SHA256 +cookie with `HttpOnly`, `SameSite=Strict`, `Path=/`, a +bounded `Max-Age`, and, under production HTTPS, `Secure` plus an `__Host-` name. +The cookie contains no account or server-side session identifier. There is no +database, JWT, session map, or logout endpoint; expiry or clearing site cookies +ends access. + +`POST /api/rooms` and the `/signal` WebSocket upgrade use that cookie when the +gate is enabled. The room API does not accept a direct Bearer credential as an +alternate creation path. A created room has a random 12-digit numeric code; +viewers open `/r/{code}` or enter only the code at `/join`. There is no viewer +token or URL fragment, while the host token remains internal to the host page. +With no `ACCESS_PASSWORD`, the room code is the sole viewing capability and does +not provide strong privacy, so that 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 @@ -136,9 +161,10 @@ 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. -Keep the proxy's access-log retention bounded and access controlled. Viewer -tokens are URL fragments and therefore are not sent in HTTP requests, but logs -still contain network metadata and must not be treated as public artifacts. +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 +as well as network metadata. They must not be treated as public artifacts; in +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 @@ -199,9 +225,12 @@ Open only these public listeners: 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=8`, `total-quota=100`, and a per-allocation +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. +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, @@ -237,13 +266,17 @@ Run these checks from real external networks before calling the deployment usabl 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 with three viewers and a mixed direct/restrictive-network cohort. Record - the selected path, RTT, bitrate, frame rate, packet loss, and coturn egress for - each viewer. Verify a direct viewer does not start consuming relay bandwidth - merely because another viewer needs TURN. +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. 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), and -[Cloudflare network-port guidance](https://developers.cloudflare.com/fundamentals/reference/network-ports/). +[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/), +[RFC6265bis](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/), and +[Node.js Crypto](https://nodejs.org/api/crypto.html#cryptocreatehmacalgorithm-key-options). diff --git a/docs/project-memory.md b/docs/project-memory.md index bbafc56..fd79f28 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -21,10 +21,11 @@ Last updated: 2026-08-18 ## Current Recommendation - Start with a Windows Chrome/Edge sharing MVP and a responsive Web viewer using one `RTCPeerConnection` per viewer. Validate current Android Chrome and iOS Safari as viewing endpoints. -- The current PoC hard limit is one broadcaster and three viewers. Do not add a fourth viewer until publisher upload and encoder measurements justify changing the tested envelope. +- Rooms default to one broadcaster and at most eight viewers, with a deployment range of 1 through 16. Treat eight as an admission default rather than a validated performance claim; measure publisher upload, encoder load, latency, and stability before describing a supported 1:8 envelope. - Use HTTPS/WSS signaling, trickle ICE, STUN, and authenticated coturn candidates. Prefer direct UDP, then relay UDP, with TURN/TCP as the required non-UDP fallback. Optional TURN/TLS uses standard TCP 5349 by default; TCP 443 is reserved for deployments with a dedicated public IP or validated L4/SNI routing. - Allow mixed connectivity in one room: direct viewers stay direct while only incompatible network pairs consume TURN bandwidth. - Treat 1080p60 as a best-effort quality profile, not a universal guarantee. Provide 720p60 and 720p30 fallbacks. +- Do not add custom scene detection or dynamic-FPS control until WebRTC statistics and host resource measurements show that browser capture, encoding, and congestion behavior leave a material problem. - Leave codec order at the browser default in the first PoC and record the negotiated codec, encoder implementation, and power efficiency. Prefer H.264 only after target-machine measurements show that it is the hardware-efficient path; retain VP8 compatibility. - Add an Electron or native Windows sender only after browser measurements identify capture, application-audio, or encode bottlenecks. - Do not plan an SFU for the normal small-room product path. Reconsider it only if the product scope or real telemetry later invalidates the P2P envelope; never implement peer forwarding trees in the MVP. @@ -32,7 +33,9 @@ Last updated: 2026-08-18 ## Current Implementation - The repository contains a single npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and a separate coturn deployment. The server defaults to an all-interface listener for LAN development and containers; the bare-metal reverse-proxy baseline explicitly binds loopback. It provides a process-only `/healthz` endpoint and requires STUN plus explicit TURN/UDP and TURN/TCP URLs before production startup. TURN/TLS is accepted but optional; configuration checks do not establish public-network reachability. -- The Web PoC implements capture-before-room creation, live source replacement without renegotiating healthy peers, expiring role tokens, one independent peer connection per viewer, stable signaling reconnect identities that also work for LAN viewers on HTTP, explicit ICE restart or peer rebuild, host generation isolation, short-lived TURN credentials, three manual quality profiles, and local WebRTC statistics. +- The Web PoC implements capture-before-room creation, live source replacement without renegotiating healthy peers, one independent peer connection per viewer, stable signaling reconnect identities that also work for LAN viewers on HTTP, explicit ICE restart or peer rebuild, host generation isolation, short-lived TURN credentials, three manual quality profiles, and local WebRTC statistics. +- Access is deliberately small: `ACCESS_PASSWORD` is optional and site-wide. When configured, host and viewer routes first establish a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie; room creation and WebSocket upgrade accept that cookie and no direct room-creation Bearer bypass. There are no accounts, database, JWTs, server-side access-session map, or logout flow. +- A room uses a random 12-digit numeric code. `/r/{code}` carries no viewer token or fragment, `/join` accepts only the code, and the 256-bit host token stays internal to the host signaling session. In public mode the code is the sole viewing capability and is not a strong privacy guarantee, so private Internet deployments should configure `ACCESS_PASSWORD`. - The Web control plane is deployed at `https://share.bonfire.icu` behind nginx with Node.js 24.19.0, and `turn.bonfire.icu` runs authenticated coturn 4.17.2 on standard UDP/TCP 3478. HTTPS, WSS, room authentication, certificate renewal, public STUN, authenticated TURN/UDP and TURN/TCP allocations, and relay-only bidirectional data paths are verified. TURN/TLS is intentionally not enabled. - Automated checks and same-machine synthetic-media Chromium recovery tests pass, including lost offer/answer, one transient offer failure, signaling-only viewer reconnect, viewer-tab replacement with old-peer cleanup, cancelled-room cleanup, video/audio source changes, and public relay-only DataChannel tests. Real screen/game audio, full Screener media sessions across heterogeneous networks, mobile lifecycle handling, and latency or quality targets remain unverified. - No infrastructure blocker remains for the current staging deployment. The next milestone is the real-device and real-media matrix in `docs/status.md`, not additional product surface or a native sender. @@ -47,10 +50,9 @@ Last updated: 2026-08-18 ## Open Decisions -- Minimum supported publisher upload speed and whether a later release should remain capped at three viewers. +- The sustainable viewer count for each publisher hardware, quality profile, and network class; real 1:8 behavior is still unverified. - Exact mobile browser support matrix and the required behavior around autoplay, backgrounding, orientation changes, and network handoff. - Whether the first release is open source, source-available, or proprietary; this affects whether GPL/AGPL projects can be reused rather than only studied. -- Account model versus expiring room links, and whether friends require an allowlist. - Initial deployment regions and expected mainland China/Hong Kong/overseas network mix. - Whether Windows per-application audio is P0 or whether whole-system loopback is acceptable initially. - Whether voice chat is ever in scope or the product remains complementary to an existing voice application. diff --git a/docs/research/webrtc-p2p-screen-sharing.md b/docs/research/webrtc-p2p-screen-sharing.md index 9a3f60b..cbc7d3f 100644 --- a/docs/research/webrtc-p2p-screen-sharing.md +++ b/docs/research/webrtc-p2p-screen-sharing.md @@ -21,7 +21,7 @@ “让画面跑起来”难度不高;“像 Discord/TeamSpeak 一样在不同 GPU、浏览器、NAT、运营商和弱网中都保持清晰、60 fps、低延迟”难度高。建议把产品分层: -- 原型:Web、P2P、STUN、TURN,验证一至三名观看者。 +- 原型:Web、P2P、STUN、TURN,验证小范围多观看者;当前默认接入上限为八、可配置 1 至 16,但真实 1:8 仍待验证。 - 可用 MVP:房间鉴权、短期 TURN 凭据、质量降档、ICE restart、统计与 30 分钟稳定性测试。 - 产品化:Electron 分享端、Web/移动观看端、Windows 应用音频、硬件编码诊断和区域化 TURN。 - Discord 级:原生捕获/编码引擎、多个捕获后端、GPU 零复制、进程树音频、广泛兼容与持续遥测,属于长期工程。 @@ -100,6 +100,7 @@ ICE 是按分享者与每一名观看者的网络组合独立选路,而不是 | 3 | 24 Mbps | 10.8 GB | | 4 | 32 Mbps | 14.4 GB | | 5 | 40 Mbps | 18.0 GB | +| 8 | 64 Mbps | 28.8 GB | 实际高动态 1080p60 可以把首版目标区间设为约 6 至 10 Mbps,因此四名观看者需要约 24 至 40 Mbps 持续上行,再加协议和重传余量。对光纤用户这并非不可行,但不能假设所有用户都具备该条件。 @@ -111,15 +112,15 @@ IETF 对 mesh/SFU 的拓扑说明见 [RFC 7667](https://www.rfc-editor.org/rfc/r MVP(也是预期的正常产品形态): -- 一至三名观看者默认 P2P。 -- 当前 PoC 明确拒绝第四名观看者;收集可用上行、实际发送码率、`qualityLimitationReason`、编码耗时和发送队列后,再决定后续版本是否放宽。 +- 小范围观看者默认 P2P,每人一条独立连接。 +- 当前 PoC 默认允许八名观看者,部署者可配置 1 至 16,超额连接会被明确拒绝。该数值只控制接入,不代表 1:8 已通过性能验收;必须收集可用上行、实际发送码率、`qualityLimitationReason`、编码耗时和发送队列来确定真实可持续人数。 - 每条链路独立使用 ICE;只有失败的链路走 TURN。 - 若一开始就有多条 `relay`,应提示服务器带宽正在增加。 - 桌面和手机观看者使用同一个 Web 播放端;分享者不要求朋友安装完整客户端。 本项目不以 SFU 作为正常扩容路径;超过小房间上限时直接建议使用外部直播服务。只有以后改变产品范围时,才根据遥测重新评估房间级 SFU,观察项包括: -- 正常工作负载需要多于三名观看者。 +- 正常工作负载持续超过实测可承载的 P2P 人数。 - 第一名或多名观看者已使用 TURN,继续 P2P 会重复占用服务器上行。 - 分享者上行安全余量不足。 - 分享者因 CPU/encoder 限制降质。 @@ -155,11 +156,15 @@ parameters.degradationPreference = "maintain-framerate"; await sender.setParameters(parameters); ``` -这些参数是偏好或上限,不能绕过浏览器拥塞控制,也不能保证目标码率。 +这些参数是偏好或上限,不能绕过浏览器拥塞控制,也不能保证目标码率。Screen Capture 和 WebRTC 规范允许浏览器决定实际输出帧率、编码与拥塞行为;`contentHint = "motion"` 也只是内容提示。静态画面通常能降低编码数据量,但规范不保证所有浏览器主动降低捕获频率或 GPU 开销。 + +因此首版不实现画面差分检测、周期性 `applyConstraints()` 或自定义动态 FPS 状态机。先对静态桌面和高动态游戏分别记录 `framesEncoded`、发送码率、`totalEncodeTime / framesEncoded`、`qualityLimitationReason` 及主机 CPU/GPU 占用;只有测量显示浏览器行为留下显著问题时,再设计最小的控制策略。这避免用额外竞态、计时器和画质跳变解决一个可能已由捕获器、编码器和 WebRTC 拥塞控制处理的问题。 来源: - [W3C Screen Capture](https://www.w3.org/TR/screen-capture/) +- [W3C WebRTC](https://www.w3.org/TR/webrtc/) +- [W3C WebRTC Stats](https://www.w3.org/TR/webrtc-stats/) - [W3C RTCRtpSender.replaceTrack](https://www.w3.org/TR/webrtc/#dom-rtcrtpsender-replacetrack) - [MDN getDisplayMedia](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia) - [MediaStreamTrack content hints](https://www.w3.org/TR/mst-content-hint/) @@ -196,7 +201,15 @@ Electron 可以固定 Chromium 版本,枚举屏幕/窗口,改善选源、热 - 手机解码温度与耗电;观看端只接收单路,不启用不必要的视频处理。 - H.264/VP8 能力协商和降档,不能假设桌面分享者选中的高级 codec 在每部手机上都有高效解码。 -“私密”首先来自房间鉴权、高熵且过期的邀请、发布/观看权限分离和不录制。WebRTC 媒体本身使用 DTLS-SRTP 加密;但 direct P2P 仍会让这组可信好友看到彼此网络地址,若需要隐藏 IP,必须允许强制 TURN,这会增加服务器带宽。 +### 最小访问模型 + +当前需求只有一个部署级密码,不需要账号、数据库、JWT、服务端 session Map、逐人邀请或 logout。`ACCESS_PASSWORD` 为空时网站公开;生产模式下接受 12 至 128 位可见 ASCII 字符,避免把 Fetch 无法可靠编码的 Unicode 原文放进 Authorization header。配置时,host 和 viewer 都先通过同一登录 gate。成功登录得到 12 小时的无状态 HMAC-SHA256 cookie,使用 `HttpOnly`、`SameSite=Strict`、`Path=/`、有限 `Max-Age`,HTTPS 生产环境使用 `Secure` 和 `__Host-` 前缀。建房 HTTP 和 WebSocket upgrade 都只认 cookie,不允许直接给建房 API 传 Bearer 绕过 gate。异常 WebSocket 关闭会检查一次 access session;确认 cookie 失效时回到 gate,检查本身失败时继续正常网络重连。HMAC 与定长密码摘要比较使用 Node.js `crypto.createHmac()` 和 `crypto.timingSafeEqual()`;cookie 属性遵循 RFC6265bis 的语义。 + +房间使用 12 位随机纯数字 code。观看链接是 `/r/{code}`,也可在 `/join` 只输入 code;两者都没有 viewer token 或 fragment。256-bit host token 只留在 host 页面内存并用于房间级 host 信令鉴权。在密码模式中,code 不能绕过全站 gate;在公开模式中,code 是唯一观看 capability,约 40 bit 的命名空间不构成强隐私保证,面向互联网的私密实例应配置全站密码。 + +来源(访问于 2026-08-18):[Cookies: HTTP State Management Mechanism draft (RFC6265bis)](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/)、[Node.js Crypto](https://nodejs.org/api/crypto.html#cryptocreatehmacalgorithm-key-options) 与 [`crypto.timingSafeEqual()`](https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b)。 + +WebRTC 媒体本身使用 DTLS-SRTP 加密;但 direct P2P 仍会让这组可信好友看到彼此网络地址,若需要隐藏 IP,必须允许强制 TURN,这会增加服务器带宽。 ## 编解码策略 @@ -236,7 +249,7 @@ WebRTC 标准没有承诺固定毫秒延迟。工程目标必须带网络条件 - WebRTC 使用 DTLS-SRTP。TURN 只能看到加密后的媒体包,但仍能看到地址、房间时序和流量元数据。 - P2P 会让房间内双方得知网络地址。熟人首版可以接受,陌生人房间不能默认接受。 - TURN 必须使用短期凭据、速率限制、每用户/房间配额和出口告警,不能提供匿名公共 relay。 -- 房间链接应高熵、可过期;发布和观看权限分离。 +- 面向互联网的私密部署应启用全站密码。公开模式的 12 位房间码只是轻量 capability,不应宣传为强私密邀请;host token 始终只用于发布权限。 - 如果未来使用 SFU 且要求服务器看不到内容,再评估 SFrame/WebRTC Encoded Transform 和群组密钥管理。 ## 参考代码优先级 @@ -294,7 +307,7 @@ WebRTC 标准没有承诺固定毫秒延迟。工程目标必须带网络条件 | 阶段 | 难度 | 预估 | 交付物 | | --- | --- | --- | --- | -| 技术原型 | 低到中 | 3 至 7 个工作日 | Web 选源、1:3 P2P、基础信令、STUN/TURN、统计面板 | +| 技术原型 | 低到中 | 3 至 7 个工作日 | Web 选源、逐观看者 P2P、基础信令、STUN/TURN、统计面板 | | 可给朋友使用的 MVP | 中 | 4 至 8 周 | 私密房间、短期 TURN 凭据、质量档位、断线恢复、音频提示、测试矩阵和部署 | | 稳定产品化 | 高 | 2 至 4 个月以上 | Electron 分享端、应用音频、硬编诊断、多地区 TURN、遥测、升级/安全/兼容处理 | | Discord 级跨平台体验 | 很高 | 多人持续工程 | 原生 media engine、多捕获后端、GPU 零复制、广泛硬件与网络优化、SFU/E2EE | @@ -303,7 +316,7 @@ WebRTC 标准没有承诺固定毫秒延迟。工程目标必须带网络条件 ## 最终建议 -1. 首版坚持 P2P-first,但明确只服务小房间,硬上限为三名观看者;测量完成前不开放第四名。 +1. 首版坚持 P2P-first,但明确只服务小房间;默认接入上限为八名、可配置 1 至 16,真实 1:8 测量完成前不把它写成性能承诺。 2. Web 先行,目标 Windows Chrome/Edge;把 1080p60 写成 best effort,同时提供降档。 3. 从第一天部署 coturn,并验证 direct、TURN/UDP、TURN/TCP 以及移动网络切换;这是避免“部分好友永远看不了”的必要条件。TURN/TLS 按部署需要选配,默认 5349,443 只作为受限网络增强。 4. 观看端优先做成免安装响应式 Web;分享端先 Web 验证,再按捕获/音频实测升级 Electron。 diff --git a/docs/status.md b/docs/status.md index 4dfd70b..b19f77f 100644 --- a/docs/status.md +++ b/docs/status.md @@ -4,26 +4,26 @@ Last updated: 2026-08-18 ## Phase -The first measurable WebRTC Web proof of concept is implemented at `https://share.bonfire.icu`, including an authenticated public TURN fallback. Automated checks and same-machine Chromium tests with synthetic video and audio sources pass, including live source replacement and relay-only data paths. Real display capture, game audio, mobile lifecycle behavior, and performance targets remain unverified. +The first measurable WebRTC Web proof of concept is implemented at `https://share.bonfire.icu`, including an authenticated public TURN fallback. The current revision adds an optional whole-site password gate and simple numeric room joining. Automated checks and same-machine Chromium tests with synthetic video and audio sources pass, including live source replacement and relay-only data paths. Real display capture, game audio, a sustained 1:8 room, mobile lifecycle behavior, and performance targets remain unverified. ## Established Baseline - Runtime: Node.js 24, React, TypeScript, Vite, native browser WebRTC, `ws`, Zod, and a separate coturn deployment. -- Product scope: private game sharing for one broadcaster and a hard PoC limit of three viewers. -- Client behavior: screen capture precedes room creation; live source changes preserve healthy peer connections; expiring fragment-token invitations open in desktop or mobile browsers; every viewer has an independent `RTCPeerConnection`. -- Control plane: in-memory rooms, role-bound signaling, stable reconnect identities, short-lived coturn credentials, Origin and payload checks, and explicit room expiry/closure. +- Product scope: private game sharing for one broadcaster and a small friend group. Rooms default to eight viewers and accept a configured limit from 1 through 16; this is an admission limit, not a verified performance envelope. +- Client behavior: screen capture precedes room creation; live source changes preserve healthy peer connections; `/r/{code}` invitations and `/join` use a 12-digit numeric room code with no viewer token or URL fragment; every viewer has an independent `RTCPeerConnection`. +- Control plane: optional whole-site `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie, in-memory rooms, an internal host token, role-bound signaling, stable reconnect identities, short-lived coturn credentials, Origin and payload checks, and explicit room expiry/closure. There are no accounts, database, JWTs, server-side access-session map, or logout flow. In public mode, the room code is the sole viewing capability and is not a strong privacy guarantee. - Media topology: direct ICE is preferred per viewer, with authenticated TURN/UDP and TURN/TCP required in production. TURN/TLS is an optional deployment capability, using standard TCP 5349 by default; mixed direct and relay paths are supported by design. - Diagnostics: per-peer path, candidate types, ICE protocol, local TURN protocol, RTT, bitrate, frame rate, dimensions, loss, jitter, codec, and quality-limitation fields are read locally from WebRTC stats. - Large public broadcasts are out of scope and should use OBS/Twitch-class services. ## Verified In This Revision -- `npm run check` passes type checking, 55 Vitest tests, the client production build, and the server production build. +- `npm run check` passes type checking, 87 Vitest tests, the client production build, and the server production build. - Headless Chromium 151 created real local peer connections from animated canvas streams. The viewer reached `connected`, received a live video track, and reported a direct UDP path. - Browser fault injection recovered after dropping the first offer, dropping the first answer, and failing the first `createOffer`. A viewer-only signaling reconnect preserved the existing healthy media peer; replacing that viewer tab rebuilt media, released the old peer, and did not enter a reconnect loop. - Cancelling a delayed room A and immediately starting room B left B live, stopped only A's capture, and invalidated A's invitation after bounded cleanup. - Injecting a live picker-cancellation result preserved the old stream. Replacing synthetic video, adding synthetic audio, and removing it all kept the same connected host/viewer peer objects and did not create another offer; retired capture tracks stopped after each successful change. -- Desktop and 390 px responsive layouts were smoke-checked without horizontal overflow. +- Chrome 151 at 390 px completed the protected-site gate, login, host, and manual room-code join flow without horizontal overflow. The unauthenticated gate shows no application header or sharing controls. - Production configuration fails closed when HTTPS, STUN, TURN, or sufficiently strong secrets are missing. - Production startup rejects malformed ICE URLs and requires STUN plus explicit TURN/UDP and TURN/TCP entries. Optional TURN/TLS entries may use standard TCP 5349 or, when separately routed, TCP 443. Configuration validation remains distinct from the runtime relay checks below. - The application defaults to `LISTEN_HOST=0.0.0.0` for LAN development and containers, while the bare-metal reverse-proxy deployment explicitly uses loopback. It exposes a process-only `GET /healthz` liveness response. @@ -38,10 +38,10 @@ The first measurable WebRTC Web proof of concept is implemented at `https://shar Execute and record the manual browser/network matrix: - real screen/window capture and available game or system audio on Windows Chrome and Edge; -- one broadcaster with three heterogeneous viewers for 30 minutes; +- one broadcaster with eight heterogeneous viewers for 30 minutes, while also recording lower viewer counts to locate the sustainable envelope; - same-LAN direct, cross-network direct, a forced-relay Screener media session, mixed direct/relay, and a UDP-blocked TURN/TCP path; test TURN/TLS separately only when deployed; - Android Chrome and iOS Safari playback, orientation, backgrounding, and network handoff; -- actual codec implementation, encode load, publisher upload, bitrate, frame rate, first picture, and glass-to-glass latency. +- actual codec implementation, encode load, publisher upload, bitrate, frame rate, first picture, and glass-to-glass latency. Custom dynamic-FPS logic remains intentionally unimplemented until these measurements show a gap in browser capture, encoding, and congestion behavior. ## Current Blocker diff --git a/docs/方案设计.md b/docs/方案设计.md index 0989f0f..535986c 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -6,16 +6,16 @@ ## 目标与边界 -首个 PoC 验证一名分享者向最多三名朋友进行私密、低延迟游戏画面分享。观看端使用普通桌面或手机浏览器;媒体对每名观看者独立进行 ICE,能直连时走 P2P,只有失败的链路使用 coturn。 +首个 PoC 验证一名分享者向小范围朋友进行低延迟游戏画面分享。观看端使用普通桌面或手机浏览器;媒体对每名观看者独立进行 ICE,能直连时走 P2P,只有失败的链路使用 coturn。房间默认最多八名观看者,可配置范围为 1 至 16;八人是接入默认值,不是未经真实 1:8 测试即可宣称的性能能力。 本阶段必须形成完整的最小纵向链路: - 分享者主动选择屏幕、窗口或标签页,并请求可用音频; -- 创建有时限的私密房间,生成分离的分享者和观看者权限; +- 创建有时限的数字房间,并用可选全站密码统一保护分享和观看入口; - 每名观看者拥有独立的 `RTCPeerConnection`; - 生产基线支持 STUN、TURN/UDP 和 TURN/TCP,并允许可选 TURN/TLS 配置; - 展示连接状态、direct/relay、RTT、实际码率、帧率和丢包等数据; -- 第四名观看者被明确拒绝,不静默超载; +- 超过部署配置上限的观看者被明确拒绝,不静默超载; - 在代码、测试和真实浏览器验证完成前,不宣称达到延迟或画质目标。 本阶段不实现账号、数据库、Redis、SFU、simulcast/SVC、观看者转发树、语音聊天、Electron、原生捕获、PWA、录制、遥测平台或多地区调度。 @@ -41,14 +41,10 @@ flowchart LR H[分享者 Web] -->|HTTPS: 建房| A[Node 应用服务] H <-->|WSS: 鉴权与信令| A - V1[观看者 1 Web] <-->|WSS: 鉴权与信令| A - V2[观看者 2 Web] <-->|WSS: 鉴权与信令| A - V3[观看者 3 Web] <-->|WSS: 鉴权与信令| A - H <-->|优先 DTLS-SRTP P2P| V1 - H <-->|优先 DTLS-SRTP P2P| V2 - H <-->|优先 DTLS-SRTP P2P| V3 + V[观看者 Web 1..N] <-->|WSS: 鉴权与信令| A + H <-->|每人独立 DTLS-SRTP P2P| V H -.仅失败链路.-> T[coturn] - T -.加密媒体中继.-> V2 + T -.加密媒体中继.-> V ``` 每个观看者的候选检查和最终路径互不影响。同一房间可以同时存在 direct 和 relay 连接。中心房间服务不复制、解码或转发媒体。 @@ -67,20 +63,21 @@ tests/ RoomStore、TURN、协议和 WebSocket 集成测试 ## 房间与访问控制 -`POST /api/rooms` 创建内存房间。生产部署必须配置 `ROOM_CREATION_TOKEN`;分享者在首屏手工输入,前端只在当前页面内存中保存并通过 `Authorization: Bearer ...` 提交,开发模式允许省略。该 token 绝不能编入前端 bundle 或持久化到浏览器存储。服务生成: +`ACCESS_PASSWORD` 是可选的全站开关。为空时网站公开;生产配置接受 12 至 128 位可见 ASCII 字符。配置时,分享者访问根页面、观看者访问 `/join` 或 `/r/{code}` 都先进入同一个登录 gate。`POST /api/session` 校验密码后签发 12 小时的无状态 cookie:值只包含版本、到期时间和 HMAC-SHA256 签名,不创建账号、数据库、JWT 或服务端 session Map,也不提供 logout 流程。cookie 使用 `HttpOnly`、`SameSite=Strict`、`Path=/` 与有限 `Max-Age`;HTTPS 生产环境再使用 `Secure` 和 `__Host-` 前缀。WebSocket 异常断开后会用 `/api/session` 检查一次当前 cookie;仅在确认失效时停止重连并返回 gate,网络故障仍走原重连流程。 -- 128-bit 随机 `roomId`; -- 256-bit 随机 `hostToken`; -- 256-bit 随机、可由三名朋友复用的 `viewerToken`; +建房 `POST /api/rooms` 和 WebSocket upgrade 在启用密码时都只验证该 cookie。密码只以 `Authorization: Bearer` 提交给登录接口,建房 API 不接受相同的 Bearer 旁路。密码模式下 HTTP 建房还要求精确 Origin;公开模式也会拒绝不在 allowlist 中的已提供 Origin。WebSocket 在握手完成前同时校验 Origin、容量和 access cookie。 + +房间服务生成: + +- 12 位随机纯数字 `roomId`,并在当前内存房间集合中检查碰撞; +- 256-bit 随机 `hostToken`,仅用于当前 host 信令认证; - 默认四小时、由部署配置限制的房间过期时间。 -服务端只保存 token 的 SHA-256 摘要。邀请形式为 `/r/{roomId}#token={viewerToken}`;fragment 不进入 HTTP 请求和常规代理日志。客户端读取后写入 `sessionStorage`,立即用 `history.replaceState` 清除地址栏中的 token。分享者 token 只保留在当前标签页内存中,刷新即结束该次主持会话。 - -复用观看 token 是可信朋友 PoC 的有意简化:不能单独撤销某位朋友,泄露后需关闭并重建房间。账号、逐人邀请和撤销属于后续产品决策。 +服务端只保存 host token 的 SHA-256 摘要,host token 只保留在分享者当前页面内存中。观看者没有独立 token:邀请为 `/r/{roomId}`,也可访问 `/join` 并只输入房间码。在密码模式中,两种入口都必须先通过全站 gate;公开模式中,房间码是唯一观看 capability,因此不保证强隐私,面向互联网的私密部署应配置 `ACCESS_PASSWORD`。账号、逐人邀请、逐人撤销和持久会话不在当前需求内。 房间规则: -- 一名在线分享者、最多三名在线观看者; +- 一名在线分享者、默认最多八名在线观看者;部署配置只接受 1 至 16; - 房间过期、分享者主动关闭或捕获结束时拒绝新连接并通知现有观看者; - 定时清理过期房间,并设置全局房间上限防止匿名内存耗尽; - 不记录 token、SDP、ICE candidate、IP 地址或 TURN 密钥。 @@ -89,13 +86,13 @@ tests/ RoomStore、TURN、协议和 WebSocket 集成测试 ## 信令协议 -WebSocket 使用同源 `/signal`。连接后五秒内第一条消息必须是 `authenticate`,token 不放 query string。服务端设置 64 KiB `maxPayload`、关闭 `perMessageDeflate`,并以 ping/pong 清理失效连接。单进程固定限制 2048 条总连接和 256 条未鉴权连接,容量在 WebSocket upgrade 前预留,超限直接返回 HTTP 503 并销毁 socket;这只是 PoC 的资源边界,不是容量承诺。 +WebSocket 使用同源 `/signal`。启用全站密码时,upgrade 必须先携带有效 access cookie;连接后五秒内第一条消息仍必须是房间级 `authenticate`。host 消息携带内部 host token,viewer 只携带房间码、role 与 clientId,不存在 viewer token,也不把凭据放 query string。服务端设置 64 KiB `maxPayload`、关闭 `perMessageDeflate`,并以 ping/pong 清理失效连接。单进程固定限制 2048 条总连接和 256 条未鉴权连接,容量在 WebSocket upgrade 前预留,超限直接返回 HTTP 503 并销毁 socket;这只是 PoC 的资源边界,不是容量承诺。 主要消息: | 方向 | 消息 | 作用 | | --- | --- | --- | -| client -> server | `authenticate` | 以 room、role、token 和 clientId 建立会话 | +| client -> server | `authenticate` | host 以 room、role、内部 host token 和 clientId 建立会话;viewer 只使用 room、role 和 clientId | | server -> client | `authenticated` | 返回角色、peerId、房间期限、容量、host 状态、该 viewer 的历史 connectionId、host 的权威 viewer 列表和 ICE 配置 | | server -> host | `peer-joined` / `peer-left` | 建立或关闭该观看者的独立连接 | | host -> viewer | `offer` / `candidate` | 带 `peerId` 与 `connectionId` 的定向信令 | @@ -140,7 +137,9 @@ PoC 不强制 codec,也不做 SDP munging。先通过 stats 记录实际协商 直播中切换来源仍由明确的按钮手势重新调用 `getDisplayMedia()`,不改变房间、host generation 或媒体拓扑。选源取消或失败时继续发送旧流;成功后为每个现有 sender 调用 `replaceTrack()`,健康连接不重新协商。初始 offer 预留 send-only audio transceiver,使音频可以在 `null -> track -> null` 间切换。若某个 peer 的替换或回滚失败,只重建该 peer;新流提交后才停止旧流,并以当前 stream 身份保护旧 track 的迟到 `ended` 事件。统计码率累加器在换源后重置,避免把不同 track 的计数器混算。 -自动全房间降档需要可信的聚合策略,放在测量后的下一阶段;本 PoC 提供手动档位和明确的受限原因,不伪装成已经解决自适应问题。 +`frameRate` 约束和 sender 的 `maxFramerate` 都是目标或上限;浏览器仍控制实际捕获、重复帧处理、编码和拥塞降级。静态画面可能由浏览器减少实际编码数据,但标准没有保证每个实现以同一种方式节省采集或 GPU 开销。本 PoC 不添加画面差分、定时改约束或自定义动态 FPS 状态机,先用 `framesEncoded`、实际发送码率、`totalEncodeTime`、质量受限原因和主机资源占用测量真实问题。 + +自动全房间降档同样需要可信的聚合策略,放在测量后的下一阶段;本 PoC 提供手动档位和明确的受限原因,不伪装成已经解决自适应问题。 ## TURN 配置与轮换 @@ -179,8 +178,10 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC 首屏就是工具,不做营销页。视觉采用中性深灰工作台,绿色表示 direct、琥珀色表示 relay/警告、红色表示失败,避免单一蓝紫色主题。 -- 分享者:顶部房间状态;主区域为 16:9 本地预览;底部为开始/停止、质量档位和复制邀请;侧栏以紧凑列表显示最多三名观看者及统计。 -- 观看者:主区域最大化远端视频;顶部显示房间与连接状态;提供声音、全屏和重试控制。 +- 全站 gate:仅在配置 `ACCESS_PASSWORD` 且 cookie 无效时显示一个密码输入窗口;验证后返回原目标路径。 +- 分享者根页面:顶部房间状态;主区域为 16:9 本地预览;底部为开始/停止、质量档位和复制邀请;侧栏以紧凑列表显示配置范围内的观看者及统计。 +- `/join`:只输入 12 位数字房间码,成功后进入 `/r/{code}`。 +- `/r/{code}` 观看者:主区域最大化远端视频;顶部显示房间与连接状态;提供声音、全屏和重试控制,不要求额外邀请码或 viewer token。 - 移动端:视频保持稳定宽高比,控制与状态移到视频下方,任何文字和按钮不得溢出或遮挡。 按钮使用 `lucide-react` 图标并提供可访问名称和 tooltip。动态状态区域固定最小尺寸,避免连接状态变化导致布局跳动。 @@ -188,7 +189,7 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC ## 安全与故障边界 - 生产仅允许 HTTPS/WSS,并校验 WebSocket `Origin` allowlist。 -- 建房 token 使用常量时间比较,房间 token 只保存摘要并以常量时间验证;TURN secret 仅作为 HMAC key 存在于服务端;响应设置 `Cache-Control: no-store`。 +- 全站密码以定长摘要配合常量时间比较;access cookie 由服务端 HMAC 签名且不含账号或可查询的 session ID。host token 只保存摘要并以常量时间验证;TURN secret 仅作为 HMAC key 存在于服务端;认证和建房响应设置 `Cache-Control: no-store`。 - HTTP body、WebSocket payload、总连接数、未鉴权连接数、输出缓冲、房间总数、观看人数和 candidate 缓存都有硬上限;无法解析的 upgrade request-target 在未鉴权边界返回 HTTP 400,不允许异常逃逸到进程顶层。 - 应用日志不得主动记录 IP;反向代理和 coturn 为提供网络服务必然可见源地址,部署时需设置最短必要留存与访问控制。 - TURN 应配置 `use-auth-secret`、`realm`、`fingerprint`、`no-multicast-peers`、私网/loopback peer 拒绝、配额和出口监控;禁止匿名 relay。 @@ -198,10 +199,11 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC 自动测试: -- RoomStore 的创建、token 摘要验证、过期、关闭、三人上限和第四人拒绝; +- AccessSession 的密码开关、12 小时签名 cookie、过期、篡改、cookie 属性和无服务端 session 状态; +- RoomStore 的 12 位数字房间码、host token 摘要验证、过期、关闭、默认八人上限、自定义 1 至 16 边界和超额拒绝; - TURN HMAC 固定向量、TTL、生产环境缺少 STUN 或 TURN/UDP+TCP 覆盖时启动失败,并接受可选 TURN/TLS; - 协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制; -- WebSocket 集成:一名 host、三名 viewer、定向 offer/answer/candidate、连接上限、grace 内恢复、显式 restart/rebuild、离开和关闭房间; +- HTTP/WS 集成:密码模式下 host/viewer gate、建房和 upgrade 只认 cookie 且无建房 Bearer 旁路;一名 host、多名 viewer、定向 offer/answer/candidate、连接上限、grace 内恢复、显式 restart/rebuild、离开和关闭房间; - 客户端策略与 stats 解析:会话替换不重连,本地 TURN protocol 与 ICE protocol 分离,远端 TURN transport 保持未知; - TypeScript 类型检查、Vite production build 和仓库 hygiene。 @@ -212,7 +214,7 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC - 封 UDP 后验证必需的 TURN/TCP;若部署启用 TURN/TLS,再分别验证默认 5349 或可选 443; - Windows Chrome/Edge 的音轨有/无; - Android Chrome 与 iOS Safari 的播放手势、旋转、前后台和网络切换; -- 一名分享者、三名观看者连续 30 分钟,记录实际上行、编码负载和首帧时间。 +- 一名分享者、八名异构观看者连续 30 分钟,同时记录较低人数基线、实际上行、编码负载和首帧时间。真实 1:8 未完成前不得把默认上限写成性能承诺。 真实矩阵未完成前,状态文档必须把这些列为待验证,而不是把设计目标写成产品能力。 @@ -220,7 +222,7 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC 1. 建立单 package 工程、共享 schema、配置与 CI。 2. 完成 RoomStore、建房 API、WebSocket 鉴权和定向信令。 -3. 完成屏幕捕获、1:3 PeerConnection、观看播放和手动质量档。 +3. 完成屏幕捕获、逐观看者 PeerConnection、观看播放和手动质量档。 4. 完成 TURN 短期凭据、ICE 配置刷新、连接恢复和 stats 面板。 5. 完成自动测试、真实浏览器烟测、部署说明、状态与记忆更新。 @@ -236,12 +238,15 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC - [ws official README](https://github.com/websockets/ws/blob/master/README.md) - [Screen Capture specification](https://www.w3.org/TR/screen-capture/) - [WebRTC specification](https://www.w3.org/TR/webrtc/) +- [Media Capture Content Hints](https://www.w3.org/TR/mst-content-hint/) - [RTCRtpSender.replaceTrack](https://www.w3.org/TR/webrtc/#dom-rtcrtpsender-replacetrack) - [WebRTC Stats](https://www.w3.org/TR/webrtc-stats/) - [WebRTC official samples](https://webrtc.github.io/samples/) - [coturn TURN server documentation](https://github.com/coturn/coturn/blob/master/README.turnserver) - [coturn example configuration](https://github.com/coturn/coturn/blob/master/examples/etc/turnserver.conf) - [TURN REST API draft implemented by coturn](https://datatracker.ietf.org/doc/html/draft-uberti-behave-turn-rest-00) +- [Cookies: HTTP State Management Mechanism draft (RFC6265bis)](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/) +- [Node.js `crypto.createHmac()` and `crypto.timingSafeEqual()`](https://nodejs.org/api/crypto.html) MiroTalk BRO(AGPL-3.0)和 Screego(GPL-3.0)仅用于结构研究。Tailchat Meeting(Apache-2.0)用于捕获生命周期对照,但它采用 mediasoup/SFU,不作为本项目拓扑或依赖底座。项目许可证确定前不复制 GPL/AGPL 代码。 diff --git a/docs/需求理解.md b/docs/需求理解.md index ceb1ffe..3515a38 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -6,23 +6,23 @@ 构建一个面向熟人游戏场景的低延迟屏幕共享工具。分享者可以在私密房间内分享游戏、应用窗口或显示器画面及声音,多名朋友可以即时观看。系统采用类似 Photon Host Mode 的产品抽象:中心服务负责房间、身份和建立连接,媒体默认通过 WebRTC 在客户端间 P2P 传输;STUN 协助打洞,直连失败的单条链路才使用 TURN。分享端 Web 优先并允许后续 Electron/原生增强;观看端必须可通过桌面或手机浏览器打开邀请链接,不绑定专用客户端。 -当前 PoC 的硬上限为一名分享者和三名观看者,第四名观看者会被明确拒绝。只有在上行带宽和编码负载完成测量后,后续版本才重新评估是否放宽。产品不承担公开直播或大规模观看,相关需求交给 OBS/Twitch 等成熟直播服务。 +当前 PoC 默认允许一名分享者和最多八名观看者,部署者可在 1 至 16 之间配置上限,超额观看者会被明确拒绝。八人只是当前容量默认值,不是已验证的性能承诺;真实 1:8 上行、编码和稳定性仍须测量。产品不承担公开直播或大规模观看,相关需求交给 OBS/Twitch 等成熟直播服务。 ## 功能需求 ### P0 - 必须实现 -- [ ] 私密房间:用户可创建房间,并通过不可猜测且可过期的链接或邀请码让朋友加入。 +- [ ] 简单房间:分享者获得 12 位随机纯数字房间码和 `/r/{code}` 链接;观看者也可在 `/join` 只输入房间码加入,链接不携带额外观看 token 或 fragment。 - [ ] 免安装观看:桌面和手机观看者可直接通过浏览器链接进入、授权并播放,不依赖与分享端相同的桌面客户端。 - [ ] 屏幕采集:分享者可主动选择显示器或应用窗口,开始、停止和切换分享状态。 - [ ] 游戏声音:Windows Chrome/Edge 目标环境可请求系统音频;没有返回音轨时必须明确提示,而不是静默无声。 -- [ ] 一对多观看:一名分享者可同时向至少三名观看者发送画面,每名观看者拥有独立的 WebRTC 连接与状态。 +- [ ] 一对多观看:一名分享者可向配置允许的多名观看者发送画面,每名观看者拥有独立的 WebRTC 连接与状态;默认上限为 8,可配置范围为 1 至 16。 - [ ] P2P 优先:ICE 必须先尝试直接 UDP 路径,服务器不得默认中转可直连的媒体。 - [ ] NAT 兜底:每名观看者独立进行 ICE;生产环境必须提供 STUN、TURN/UDP 与 TURN/TCP candidates,通过优先级偏好直连和 UDP,同时允许无法使用 UDP 的网络选择 TCP,不能因为其网络与其他观看者不同而静默失败。 -- [ ] 质量档位:至少提供 1080p60、720p60、720p30 三档目标,并根据拥塞主动降档。 +- [ ] 质量档位:至少提供 1080p60、720p60、720p30 三档目标,并依赖 WebRTC 拥塞控制及明确的手动档位避免持续排队;自定义动态 FPS 策略必须在统计证明有必要后再实现。 - [ ] 断线恢复:临时网络变化时尝试 ICE restart 和重连,并向用户显示当前连接状态。 - [ ] 可观测性:采集候选类型、RTT、丢包、抖动、实际码率、帧率、编码耗时和质量受限原因。 -- [ ] 访问控制:未获授权的用户不能观看、发布或获取长期 TURN 凭据。 +- [ ] 访问控制:部署者可配置一个全站 `ACCESS_PASSWORD`;为空时网站公开,配置时分享者和观看者都必须先登录。登录成功签发 12 小时、无服务端会话状态的 HttpOnly、`SameSite=Strict` cookie,建房 HTTP 和 WebSocket 都只使用该 cookie,不提供建房 Bearer 旁路;不引入账号、数据库、JWT、服务端 session Map 或 logout。 ### P1 - 重要功能 @@ -52,7 +52,8 @@ ## 边界与约束 - 首版不包含远程键鼠控制、公开/大规模直播、录制、社区系统或完整语音聊天;大规模分享直接使用现有直播服务。 -- 首版不以超过三名观看者为正常工作负载,也不承诺跨洲网络下的固定低延迟。 +- 公开模式中,12 位数字房间码是唯一观看 capability,不提供强私密保证;面向互联网且要求私密的部署必须配置全站密码。 +- 默认八名观看者和可配置的 1 至 16 范围是接入容量边界,不承诺任意机器、档位或网络都能稳定承载该人数,也不承诺跨洲网络下的固定低延迟。 - 浏览器规范要求每次由用户主动选择分享源,权限不能永久保存;纯网页不能保证指定游戏窗口或始终获得系统音频。 - 1080p60 是能力目标,实际分辨率、帧率和硬件编码由浏览器、GPU、驱动、画面复杂度及网络共同决定。 - 首版不实现 SFU,不从零实现编解码器或自定义 UDP 视频协议;产品范围以后改变时重新立项,而不是预留复杂框架。 @@ -75,7 +76,7 @@ ### 一对多与带宽保护 -- Given 一名分享者和三名观看者,When 选择 1080p60,Then 三名观看者可独立加入和离开,任一观看者离开不会中断其他连接。 +- Given 一名分享者和八名观看者,When 在受控网络中运行已记录的质量档位,Then 八名观看者可独立加入和离开,任一观看者离开不会中断其他连接,并记录每路码率、候选类型、编码负载和分享者总上行;该条在真实矩阵完成前保持未验收。 - Given 新观看者会使预计总上行超过安全预算,When 其请求加入,Then 系统自动降低档位、拒绝该档位或建议改用外部直播服务,而不是持续堆积发送队列。 ### 质量和恢复 @@ -90,7 +91,7 @@ ## 待确认事项 -- 后续版本是否应在测量通过后放宽三名观看者的硬上限。 +- 哪些分享端硬件、质量档位和网络条件能稳定承载默认 1:8 工作负载,以及实测后是否需要降低默认值。 - 首版是否必须只捕获所选游戏的声音,还是允许整机系统音频。 - 项目许可证及是否计划闭源商用。 - 首批用户主要位于香港、内地还是跨地区;该信息决定 TURN 部署位置和测试矩阵。 diff --git a/src/client/App.tsx b/src/client/App.tsx index cc9c289..80974d8 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,9 +1,170 @@ +import { KeyRound, LoaderCircle } from "lucide-react"; +import { useEffect, useState, type FormEvent } from "react"; +import { + ApiError, + authenticate, + getSession, + type SessionStatus, +} from "./lib/api"; import { readViewerRoute } from "./lib/session"; import { HostPage } from "./pages/HostPage"; +import { JoinPage } from "./pages/JoinPage"; import { ViewerPage } from "./pages/ViewerPage"; const viewerRoute = readViewerRoute(); +const isJoinRoute = /^\/join\/?$/.test(window.location.pathname); +const isHostRoute = /^\/?$/.test(window.location.pathname); + +type AccessState = + | { kind: "checking" } + | { kind: "ready" } + | { kind: "required"; error: string | null } + | { kind: "unavailable"; message: string }; + +function stateFromStatus(status: SessionStatus): AccessState { + return !status.required || status.authenticated + ? { kind: "ready" } + : { kind: "required", error: null }; +} + +function readableError(error: unknown): string { + return error instanceof Error && error.message + ? error.message + : "无法连接验证服务,请重试"; +} export function App() { - return viewerRoute ? : ; + const [access, setAccess] = useState({ kind: "checking" }); + const [password, setPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + let active = true; + void getSession().then( + (status) => active && setAccess(stateFromStatus(status)), + (error: unknown) => + active && + setAccess({ kind: "unavailable", message: readableError(error) }), + ); + return () => { + active = false; + }; + }, []); + + async function retry(): Promise { + setAccess({ kind: "checking" }); + try { + setAccess(stateFromStatus(await getSession())); + } catch (error) { + setAccess({ kind: "unavailable", message: readableError(error) }); + } + } + + async function submit(event: FormEvent): Promise { + event.preventDefault(); + const submittedPassword = password; + setPassword(""); + if (!submittedPassword.trim()) { + setAccess({ kind: "required", error: "请输入访问密码" }); + return; + } + + setSubmitting(true); + try { + setAccess(stateFromStatus(await authenticate(submittedPassword))); + } catch (error) { + setAccess({ + kind: "required", + error: + error instanceof ApiError && error.status === 401 + ? "访问密码不正确,请重试" + : readableError(error), + }); + } finally { + setSubmitting(false); + } + } + + if (access.kind === "ready") { + if (viewerRoute) { + return ( + + setAccess({ kind: "required", error: "验证已失效,请重新登录" }) + } + /> + ); + } + if (isJoinRoute || !isHostRoute) { + return ; + } + return ( + + setAccess({ kind: "required", error: "验证已失效,请重新登录" }) + } + /> + ); + } + + return ( +
+
+ {access.kind === "checking" ? ( +
+
+ ) : access.kind === "unavailable" ? ( +
+

暂时无法验证

+

+ {access.message} +

+ +
+ ) : ( +
void submit(event)}> +
+

访问验证

+

请输入此站点的访问密码

+
+ + {access.error && ( +

+ {access.error} +

+ )} + +
+ )} +
+
+ ); } diff --git a/src/client/components/StatsGrid.tsx b/src/client/components/StatsGrid.tsx index b4a3f44..2a0ece9 100644 --- a/src/client/components/StatsGrid.tsx +++ b/src/client/components/StatsGrid.tsx @@ -26,11 +26,9 @@ function Metric({ label, value, title }: { label: string; value: string; title?: export function StatsGrid({ metrics, direction, - compact = false, }: { metrics: ConnectionMetrics; direction: "send" | "receive"; - compact?: boolean; }) { const encoder = metrics.encoderImplementation ? `${metrics.encoderImplementation}${ @@ -43,7 +41,7 @@ export function StatsGrid({ : "未知"; return ( -
+
- {!compact && ( + + {metrics.localCandidateType === "relay" && ( + + )} + + + {direction === "send" ? ( <> + - {metrics.localCandidateType === "relay" && ( - - )} + + + ) : ( + <> + - - {direction === "send" ? ( - <> - - - - - ) : ( - <> - - - - )} )}
diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index 77def67..6f4b527 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -3,6 +3,21 @@ import { type CreateRoomResponse, } from "../../shared/protocol"; +export interface SessionStatus { + required: boolean; + authenticated: boolean; +} + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "ApiError"; + } +} + function errorMessage(value: unknown): string | null { if (!value || typeof value !== "object") { return null; @@ -22,34 +37,89 @@ function errorMessage(value: unknown): string | null { return null; } -export async function createRoom( - creationToken: string, -): Promise { - const headers = new Headers({ Accept: "application/json" }); - const trimmedToken = creationToken.trim(); - if (trimmedToken) { - headers.set("Authorization", `Bearer ${trimmedToken}`); +async function responseBody(response: Response): Promise { + try { + return await response.json(); + } catch { + throw new ApiError("服务返回了无法识别的响应", response.status); } +} +function parseSessionStatus(value: unknown): SessionStatus { + if (!value || typeof value !== "object") { + throw new ApiError("验证服务返回的数据格式不正确", 502); + } + const record = value as Record; + if ( + typeof record.required !== "boolean" || + typeof record.authenticated !== "boolean" + ) { + throw new ApiError("验证服务返回的数据格式不正确", 502); + } + return { + required: record.required, + authenticated: record.authenticated, + }; +} + +export async function getSession(): Promise { + const response = await fetch("/api/session", { + headers: { Accept: "application/json" }, + }); + const body = await responseBody(response); + if (!response.ok) { + throw new ApiError( + response.status === 401 + ? "验证已失效,请重新登录" + : (errorMessage(body) ?? `验证失败 (${response.status})`), + response.status, + ); + } + return parseSessionStatus(body); +} + +export async function authenticate( + password: string, +): Promise { + const response = await fetch("/api/session", { + method: "POST", + headers: { + Accept: "application/json", + Authorization: `Bearer ${password.trim()}`, + }, + }); + const body = await responseBody(response); + if (!response.ok) { + throw new ApiError( + response.status === 401 + ? "访问密码不正确,请重试" + : (errorMessage(body) ?? `验证失败 (${response.status})`), + response.status, + ); + } + return parseSessionStatus(body); +} + +export async function createRoom(): Promise { const response = await fetch("/api/rooms", { method: "POST", - headers, + headers: { Accept: "application/json" }, }); - let body: unknown; - try { - body = await response.json(); - } catch { - throw new Error("建房服务返回了无法识别的响应"); - } + const body = await responseBody(response); if (!response.ok) { - throw new Error(errorMessage(body) ?? `建房失败 (${response.status})`); + throw new ApiError( + response.status === 401 + ? "验证已失效,请重新登录" + : (errorMessage(body) ?? `建房失败 (${response.status})`), + response.status, + ); } const parsed = createRoomResponseSchema.safeParse(body); if (!parsed.success) { - throw new Error("建房服务返回的数据格式不正确"); + throw new ApiError("建房服务返回的数据格式不正确", 502); } return parsed.data; } diff --git a/src/client/lib/session.ts b/src/client/lib/session.ts index e69f393..f63b0f2 100644 --- a/src/client/lib/session.ts +++ b/src/client/lib/session.ts @@ -1,9 +1,13 @@ -const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,128}$/; -const ROOM_PATTERN = /^[A-Za-z0-9_-]{8,128}$/; +import { roomCodeSchema } from "../../shared/protocol"; + +const CLIENT_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/; export interface ViewerRoute { roomId: string; - token: string | null; +} + +export function isValidRoomId(value: string): boolean { + return roomCodeSchema.safeParse(value).success; } function readSessionValue(key: string): string | null { @@ -23,43 +27,18 @@ function writeSessionValue(key: string, value: string): void { } export function readViewerRoute(): ViewerRoute | null { - const match = window.location.pathname.match(/^\/r\/([A-Za-z0-9_-]+)\/?$/); - if (!match || !ROOM_PATTERN.test(match[1])) { + const match = window.location.pathname.match(/^\/r\/(\d+)\/?$/); + if (!match || !isValidRoomId(match[1])) { return null; } - const roomId = match[1]; - const storageKey = `screener:viewer-token:${roomId}`; - const fragmentToken = new URLSearchParams(window.location.hash.slice(1)).get( - "token", - ); - const token = - fragmentToken && TOKEN_PATTERN.test(fragmentToken) - ? fragmentToken - : readSessionValue(storageKey); - - if (fragmentToken && TOKEN_PATTERN.test(fragmentToken)) { - writeSessionValue(storageKey, fragmentToken); - } - - if (window.location.hash) { - window.history.replaceState( - window.history.state, - "", - `${window.location.pathname}${window.location.search}`, - ); - } - - return { - roomId, - token: token && TOKEN_PATTERN.test(token) ? token : null, - }; + return { roomId: match[1] }; } export function getStableClientId(role: "host" | "viewer", roomId: string): string { const storageKey = `screener:client-id:${role}:${roomId}`; const existing = readSessionValue(storageKey); - if (existing && ROOM_PATTERN.test(existing)) { + if (existing && CLIENT_ID_PATTERN.test(existing)) { return existing; } diff --git a/src/client/lib/signaling.ts b/src/client/lib/signaling.ts index e330fbd..0a9fc85 100644 --- a/src/client/lib/signaling.ts +++ b/src/client/lib/signaling.ts @@ -1,29 +1,27 @@ import { decodeServerMessage, type ClientMessage, - type Role, type ServerMessage, } from "../../shared/protocol"; import type { SignalConnectionState } from "../types"; +import { getSession } from "./api"; -interface SignalingIdentity { - roomId: string; - role: Role; - token: string; - clientId: string; -} +type WithoutType = T extends { type: string } ? Omit : never; +type SignalingIdentity = WithoutType< + Extract +>; interface SignalingEvents { onMessage: (message: ServerMessage) => void; onStatus: (status: SignalConnectionState) => void; onProtocolError: (message: string) => void; onTerminated: (message: string) => void; + onAccessRequired: () => void; } const FATAL_SIGNAL_ERRORS = new Set([ "AUTH_REQUIRED", "INVALID_TOKEN", - "ROOM_CLOSED", "ROOM_EXPIRED", "ROOM_FULL", "HOST_ALREADY_CONNECTED", @@ -51,6 +49,7 @@ export class SignalingClient { private iceRefreshTimer: number | null = null; private terminalTimer: number | null = null; private terminalMessage: ClientMessage | null = null; + private accessCheck: Promise | null = null; constructor( private readonly identity: SignalingIdentity, @@ -179,6 +178,9 @@ export class SignalingClient { this.clearAuthenticationTimer(); if (!this.stopped && shouldReconnectSignaling(event.code)) { this.scheduleReconnect(); + if (event.code === 1006) { + this.checkAccess(); + } } else if (!this.stopped) { this.stopped = true; this.clearTimers(); @@ -207,6 +209,30 @@ export class SignalingClient { }, delay); } + private checkAccess(): void { + if (this.accessCheck) { + return; + } + this.accessCheck = getSession() + .then((status) => { + if ( + !this.stopped && + !this.authenticated && + status.required && + !status.authenticated + ) { + this.stop(); + this.events.onAccessRequired(); + } + }) + .catch(() => { + // Network errors keep the ordinary WebSocket reconnect loop active. + }) + .finally(() => { + this.accessCheck = null; + }); + } + private scheduleIceRefresh(expiresAt: string | null): void { if (this.iceRefreshTimer !== null) { window.clearTimeout(this.iceRefreshTimer); diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index 38bec3d..6d34dd6 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -1,7 +1,7 @@ import { Check, Copy, - KeyRound, + Hash, MonitorUp, RefreshCw, Square, @@ -21,7 +21,7 @@ import { WarningBanner, } from "../components/StatusBadge"; import { StatsGrid } from "../components/StatsGrid"; -import { createRoom } from "../lib/api"; +import { ApiError, createRoom } from "../lib/api"; import { getStableClientId } from "../lib/session"; import { SignalingClient } from "../lib/signaling"; import { @@ -76,6 +76,7 @@ function closeAbandonedRoom(room: CreateRoomResponse): void { onStatus: () => undefined, onProtocolError: () => undefined, onTerminated: () => undefined, + onAccessRequired: () => undefined, }, ); signal.start(); @@ -85,13 +86,16 @@ function closeAbandonedRoom(room: CreateRoomResponse): void { } } -export function HostPage() { +interface HostPageProps { + onAuthorizationRequired?: () => void; +} + +export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const forceRelay = useMemo( () => new URLSearchParams(window.location.search).get("relay") === "1", [], ); const [qualityId, setQualityId] = useState("1080p60"); - const [creationToken, setCreationToken] = useState(""); const [phase, setPhase] = useState("idle"); const [signalStatus, setSignalStatus] = useState("offline"); @@ -99,6 +103,7 @@ export function HostPage() { const [details, setDetails] = useState(null); const [room, setRoom] = useState(null); const [relayAvailable, setRelayAvailable] = useState(false); + const [maxViewers, setMaxViewers] = useState(null); const [peerSnapshots, setPeerSnapshots] = useState>( () => new Map(), ); @@ -170,6 +175,7 @@ export function HostPage() { iceConfigRef.current = null; setStream(null); setRoom(null); + setMaxViewers(null); setPeerSnapshots(new Map()); setSignalStatus("offline"); setSwitchingSource(false); @@ -353,6 +359,7 @@ export function HostPage() { return; } if (message.type === "authenticated" && message.role === "host") { + setMaxViewers(message.maxViewers); const currentViewerIds = new Set(message.viewerPeerIds); for (const peerId of peersRef.current.keys()) { if (!currentViewerIds.has(peerId)) { @@ -408,7 +415,6 @@ export function HostPage() { [ "AUTH_REQUIRED", "INVALID_TOKEN", - "ROOM_CLOSED", "ROOM_EXPIRED", "HOST_ALREADY_CONNECTED", ].includes(message.code) @@ -461,15 +467,12 @@ export function HostPage() { let createdRoom: CreateRoomResponse | null = null; try { - createdRoom = await createRoom(creationToken); + createdRoom = await createRoom(); if (!isCurrentGeneration(generation)) { captured.getTracks().forEach((track) => track.stop()); closeAbandonedRoom(createdRoom); return; } - iceConfigRef.current = createdRoom.iceConfig; - setRelayAvailable(createdRoom.iceConfig.relayAvailable); - const signal = new SignalingClient( { roomId: createdRoom.roomId, @@ -502,6 +505,15 @@ export function HostPage() { endSharing(message, false); } }, + onAccessRequired: () => { + if ( + isCurrentGeneration(generation) && + signalRef.current === signal + ) { + endSharing("验证已失效,请重新登录", false); + onAuthorizationRequired?.(); + } + }, onMessage: (message) => { if ( !isCurrentGeneration(generation) || @@ -512,7 +524,6 @@ export function HostPage() { if (message.type === "authenticated") { iceConfigRef.current = message.iceConfig; setRelayAvailable(message.iceConfig.relayAvailable); - setCreationToken(""); setRoom(createdRoom); setPhase("live"); } @@ -538,6 +549,14 @@ export function HostPage() { streamRef.current = null; setStream(null); } + if ( + error instanceof ApiError && + error.status === 401 && + onAuthorizationRequired + ) { + onAuthorizationRequired(); + return; + } setNotice(readableError(error)); setPhase("error"); } @@ -720,7 +739,7 @@ export function HostPage() {

屏幕分享

{phase === "live" - ? `${viewers.length}/3 人正在观看` + ? `${viewers.length}/${maxViewers ?? "-"} 人正在观看` : phase === "starting" ? "正在建立房间" : "尚未开始"} @@ -814,20 +833,6 @@ export function HostPage() { - - + + ) : ( room && (

- 邀请链接 · {expiresAt} 过期 + + 房间 {room.roomId} · {expiresAt} 过期 + {room.inviteUrl} @@ -865,7 +876,7 @@ export function HostPage() {

观看者

- {viewers.length}/3 + {viewers.length}/{maxViewers ?? "-"}
diff --git a/src/client/pages/JoinPage.tsx b/src/client/pages/JoinPage.tsx new file mode 100644 index 0000000..6e20a38 --- /dev/null +++ b/src/client/pages/JoinPage.tsx @@ -0,0 +1,59 @@ +import { Hash } from "lucide-react"; +import { useState, type FormEvent } from "react"; +import { ROOM_CODE_LENGTH } from "../../shared/protocol"; +import { AppHeader } from "../components/AppHeader"; +import { isValidRoomId } from "../lib/session"; + +export function JoinPage() { + const [roomId, setRoomId] = useState(""); + const [error, setError] = useState(null); + + function join(event: FormEvent): void { + event.preventDefault(); + const normalizedRoomId = roomId.trim(); + if (!isValidRoomId(normalizedRoomId)) { + setError("房间码格式不正确"); + return; + } + window.location.assign(`/r/${normalizedRoomId}`); + } + + return ( +
+ +
+
+
+

加入分享

+

输入分享者提供的房间码

+
+ + {error && ( +

+ {error} +

+ )} + +
+
+
+ ); +} diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index 767cad6..459a15f 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -26,24 +26,17 @@ import { ViewerPeer } from "../webrtc/viewer-peer"; interface ViewerPageProps { roomId: string; - token: string | null; + onAuthorizationRequired: () => void; } -type ViewerRoomState = "waiting" | "active" | "closed" | "error"; - -export function ViewerPage({ roomId, token }: ViewerPageProps) { +export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) { const forceRelay = useMemo( () => new URLSearchParams(window.location.search).get("relay") === "1", [], ); const [signalStatus, setSignalStatus] = useState("offline"); - const [roomState, setRoomState] = useState( - token ? "waiting" : "error", - ); - const [statusText, setStatusText] = useState( - token ? "正在进入房间" : "邀请链接无效或已失效", - ); + const [statusText, setStatusText] = useState("正在进入房间"); const [hostOnline, setHostOnline] = useState(false); const [relayAvailable, setRelayAvailable] = useState(false); const [remoteStream, setRemoteStream] = useState(null); @@ -53,13 +46,8 @@ export function ViewerPage({ roomId, token }: ViewerPageProps) { const videoRef = useRef(null); const peerRef = useRef(null); - const signalRef = useRef(null); useEffect(() => { - if (!token) { - return; - } - let active = true; let currentIceConfig: IceConfig | null = null; @@ -67,7 +55,6 @@ export function ViewerPage({ roomId, token }: ViewerPageProps) { { roomId, role: "viewer", - token, clientId: getStableClientId("viewer", roomId), }, { @@ -90,9 +77,13 @@ export function ViewerPage({ roomId, token }: ViewerPageProps) { setRemoteStream(null); setPeerSnapshot(null); setPlaybackBlocked(false); - setRoomState("error"); setStatusText(message); }, + onAccessRequired: () => { + if (active) { + onAuthorizationRequired(); + } + }, onMessage: (message) => { if (!active) { return; @@ -118,7 +109,6 @@ export function ViewerPage({ roomId, token }: ViewerPageProps) { onStream: (nextStream) => { if (active) { setRemoteStream(nextStream); - setRoomState("active"); setStatusText("正在播放"); } }, @@ -126,7 +116,6 @@ export function ViewerPage({ roomId, token }: ViewerPageProps) { if (active) { setPeerSnapshot(snapshot); if (snapshot.connectionState === "connected") { - setRoomState("active"); setStatusText("已连接"); } else if ( snapshot.connectionState === "failed" || @@ -148,7 +137,6 @@ export function ViewerPage({ roomId, token }: ViewerPageProps) { currentIceConfig = message.iceConfig; setRelayAvailable(message.iceConfig.relayAvailable); setHostOnline(message.hostOnline); - setRoomState(peerRef.current?.isConnected() ? "active" : "waiting"); setStatusText(message.hostOnline ? "等待分享画面" : "等待分享者上线"); const peer = peerRef.current; peer?.updateIceConfig(message.iceConfig); @@ -193,7 +181,6 @@ export function ViewerPage({ roomId, token }: ViewerPageProps) { peerRef.current = null; setRemoteStream(null); setPeerSnapshot(null); - setRoomState("closed"); setStatusText(message.reason === "expired" ? "房间已过期" : "分享已结束"); signal.stop(); return; @@ -203,7 +190,6 @@ export function ViewerPage({ roomId, token }: ViewerPageProps) { [ "AUTH_REQUIRED", "INVALID_TOKEN", - "ROOM_CLOSED", "ROOM_EXPIRED", "ROOM_FULL", ].includes(message.code) @@ -213,21 +199,18 @@ export function ViewerPage({ roomId, token }: ViewerPageProps) { setRemoteStream(null); setPeerSnapshot(null); } - setRoomState("error"); setStatusText(message.message); } } - signalRef.current = signal; signal.start(); return () => { active = false; signal.stop(); - signalRef.current = null; peerRef.current?.dispose(); peerRef.current = null; }; - }, [forceRelay, roomId, token]); + }, [forceRelay, onAuthorizationRequired, roomId]); useEffect(() => { const video = videoRef.current; @@ -300,7 +283,7 @@ export function ViewerPage({ roomId, token }: ViewerPageProps) {

好友屏幕

-

房间 {roomId.slice(0, 8)}

+

房间 {roomId}

{forceRelay && 强制中继} @@ -354,7 +337,7 @@ export function ViewerPage({ roomId, token }: ViewerPageProps) { className="icon-button" title="恢复连接" aria-label="恢复连接" - disabled={!peerSnapshot || roomState === "closed"} + disabled={!peerSnapshot} onClick={retryConnection} > diff --git a/src/client/styles.css b/src/client/styles.css index 0775ba1..3375fb1 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -112,6 +112,52 @@ button:disabled { justify-content: flex-end; } +.access-workspace { + display: grid; + min-height: calc(100dvh - 58px); + padding: 24px 16px; + place-items: center; +} + +.access-workspace-full { + min-height: 100vh; + min-height: 100dvh; +} + +.access-panel { + display: grid; + width: min(360px, 100%); + gap: 18px; + padding: 22px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); +} + +.access-loading { + display: flex; + gap: 9px; + align-items: center; + color: var(--muted); + font-size: 13px; +} + +.access-error { + color: #ff9797; + font-size: 13px; + line-height: 1.45; +} + +.spin { + animation: spin 0.9s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + .host-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 370px; @@ -246,12 +292,27 @@ h3 { .setup-controls { display: grid; - grid-template-columns: minmax(310px, 1fr) minmax(220px, 0.75fr) auto; + grid-template-columns: minmax(310px, 1fr) auto; gap: 18px; align-items: end; margin-top: 22px; } +.join-room-link { + display: inline-flex; + grid-column: 1 / -1; + width: fit-content; + gap: 7px; + align-items: center; + color: var(--muted); + font-size: 13px; + text-decoration: none; +} + +.join-room-link:hover { + color: var(--text); +} + .control-group { min-width: 0; margin: 0; @@ -664,11 +725,11 @@ h3 { } .setup-controls { - grid-template-columns: 1fr 1fr; + grid-template-columns: minmax(0, 1fr) auto; } .start-button { - grid-column: 1 / -1; + grid-column: auto; } .viewer-stats .stats-grid { diff --git a/src/server/access-session.ts b/src/server/access-session.ts new file mode 100644 index 0000000..b54c8ef --- /dev/null +++ b/src/server/access-session.ts @@ -0,0 +1,102 @@ +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; + +const DEFAULT_SESSION_TTL_SECONDS = 12 * 60 * 60; +const COOKIE_VERSION = "v1"; + +export interface AccessSessionOptions { + password?: string; + secure: boolean; + now?: () => number; + ttlSeconds?: number; +} + +export class AccessSession { + private readonly now: () => number; + private readonly ttlSeconds: number; + + constructor(private readonly options: AccessSessionOptions) { + this.now = options.now ?? Date.now; + this.ttlSeconds = options.ttlSeconds ?? DEFAULT_SESSION_TTL_SECONDS; + if (!Number.isSafeInteger(this.ttlSeconds) || this.ttlSeconds <= 0) { + throw new Error("Access session TTL must be a positive integer"); + } + } + + get required(): boolean { + return Boolean(this.options.password); + } + + passwordMatches(value: string | undefined): boolean { + return !this.required || Boolean(value && secretMatches(value, this.options.password!)); + } + + isAuthenticated(cookieHeader: string | undefined): boolean { + if (!this.required) { + return true; + } + const value = readCookie(cookieHeader, this.cookieName); + if (!value) { + return false; + } + const [version, expiresText, signature, ...extra] = value.split("."); + if ( + version !== COOKIE_VERSION || + extra.length > 0 || + !/^\d+$/.test(expiresText ?? "") || + !/^[A-Za-z0-9_-]{43}$/.test(signature ?? "") + ) { + return false; + } + const expiresAt = Number(expiresText); + if (!Number.isSafeInteger(expiresAt) || expiresAt <= Math.floor(this.now() / 1_000)) { + return false; + } + return secretMatches(signature!, this.sign(`${version}.${expiresText}`)); + } + + createCookie(): string | undefined { + if (!this.required) { + return undefined; + } + const expiresAt = Math.floor(this.now() / 1_000) + this.ttlSeconds; + const payload = `${COOKIE_VERSION}.${expiresAt}`; + const value = `${payload}.${this.sign(payload)}`; + const attributes = [ + `${this.cookieName}=${value}`, + "Path=/", + `Max-Age=${this.ttlSeconds}`, + "HttpOnly", + "SameSite=Strict", + ]; + if (this.options.secure) { + attributes.push("Secure"); + } + return attributes.join("; "); + } + + private get cookieName(): string { + return this.options.secure + ? "__Host-screener-session" + : "screener-session"; + } + + private sign(payload: string): string { + return createHmac("sha256", this.options.password!).update(payload).digest("base64url"); + } +} + +function readCookie(header: string | undefined, name: string): string | undefined { + for (const part of header?.split(";") ?? []) { + const separator = part.indexOf("="); + if (separator !== -1 && part.slice(0, separator).trim() === name) { + return part.slice(separator + 1).trim() || undefined; + } + } + return undefined; +} + +function secretMatches(actual: string, expected: string): boolean { + const actualDigest = createHash("sha256").update(actual).digest(); + const expectedDigest = createHash("sha256").update(expected).digest(); + return timingSafeEqual(actualDigest, expectedDigest); +} diff --git a/src/server/app.ts b/src/server/app.ts index 91c2dd8..0453c1f 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -5,10 +5,10 @@ import sirv from "sirv"; import type { ViteDevServer } from "vite"; import type { CreateRoomResponse } from "../shared/protocol.js"; -import { loadConfig, secretsEqual, type ServerConfig } from "./config.js"; +import { AccessSession } from "./access-session.js"; +import { loadConfig, type ServerConfig } from "./config.js"; import { RoomStore, RoomStoreError } from "./room-store.js"; import { SignalingServer } from "./signaling.js"; -import { createIceConfig } from "./turn.js"; export interface CreateServerOptions { config?: ServerConfig; @@ -22,6 +22,7 @@ export interface CreateServerOptions { cleanupIntervalMs?: number; maxSignalConnections?: number; maxUnauthenticatedSignalConnections?: number; + accessSessionTtlSeconds?: number; } export interface ScreenerServer { @@ -43,8 +44,17 @@ export async function createScreenerServer( new RoomStore({ ttlMs: config.roomTtlMs, maxRooms: config.maxRooms, + maxViewersPerRoom: config.maxViewersPerRoom, now, }); + const accessSession = new AccessSession({ + password: config.accessPassword, + secure: + config.nodeEnv === "production" && + config.publicBaseUrl.protocol === "https:", + now, + ttlSeconds: options.accessSessionTtlSeconds, + }); const iceOptions = { stunUrls: config.stunUrls, turnUrls: config.turnUrls, @@ -60,10 +70,14 @@ export async function createScreenerServer( response, config, roomStore, - iceOptions, - now, + accessSession, () => frontendHandler, - ).catch(() => { + ).catch((error: unknown) => { + console.error("HTTP request failed", { + method: request.method, + path: safePathname(request.url), + error, + }); if (!response.headersSent) { sendJson(response, 500, { error: "Internal server error" }); } else { @@ -77,6 +91,8 @@ export async function createScreenerServer( roomStore, ice: iceOptions, allowedOrigins: config.allowedOrigins, + authorizeUpgrade: (request) => + accessSession.isAuthenticated(request.headers.cookie), now, authenticationTimeoutMs: options.authenticationTimeoutMs, viewerDisconnectGraceMs: options.viewerDisconnectGraceMs, @@ -167,8 +183,7 @@ async function handleRequest( response: ServerResponse, config: ServerConfig, roomStore: RoomStore, - iceOptions: Parameters[0], - now: () => number, + accessSession: AccessSession, getFrontendHandler: () => FrontendHandler | undefined, ): Promise { const url = new URL(request.url ?? "/", config.publicBaseUrl); @@ -183,6 +198,11 @@ async function handleRequest( return; } + if (url.pathname === "/api/session") { + handleSessionRequest(request, response, config, accessSession); + return; + } + if (url.pathname === "/api/rooms") { response.setHeader("Cache-Control", "no-store"); response.setHeader("X-Content-Type-Options", "nosniff"); @@ -199,8 +219,16 @@ async function handleRequest( sendJson(response, 400, { error: "Request body is not accepted" }); return; } - if (!isRoomCreationAuthorized(request, config.roomCreationToken)) { - response.setHeader("WWW-Authenticate", "Bearer"); + if ( + !isRoomCreationAuthorized( + request, + accessSession, + isStrictlyAllowedRequestOrigin( + request.headers.origin, + config.allowedOrigins, + ), + ) + ) { sendJson(response, 401, { error: "Unauthorized" }); return; } @@ -208,18 +236,11 @@ async function handleRequest( try { const room = roomStore.createRoom(); const inviteUrl = new URL(`/r/${room.roomId}`, config.publicBaseUrl); - inviteUrl.hash = new URLSearchParams({ token: room.viewerToken }).toString(); const responseBody: CreateRoomResponse = { roomId: room.roomId, hostToken: room.hostToken, inviteUrl: inviteUrl.toString(), expiresAt: room.expiresAt, - iceConfig: createIceConfig( - iceOptions, - `${room.roomId}:host-bootstrap`, - now(), - Date.parse(room.expiresAt), - ), }; sendJson(response, 201, responseBody); } catch (error) { @@ -246,6 +267,64 @@ async function handleRequest( sendJson(response, 404, { error: "Not found" }); } +function handleSessionRequest( + request: IncomingMessage, + response: ServerResponse, + config: ServerConfig, + accessSession: AccessSession, +): void { + response.setHeader("Cache-Control", "no-store"); + response.setHeader("X-Content-Type-Options", "nosniff"); + + if (request.method === "GET") { + sendJson(response, 200, sessionStatus(request, accessSession)); + return; + } + + if (request.method !== "POST") { + response.setHeader("Allow", "GET, POST"); + sendJson(response, 405, { error: "Method not allowed" }); + return; + } + + if ( + !isStrictlyAllowedRequestOrigin( + request.headers.origin, + config.allowedOrigins, + ) + ) { + sendJson(response, 403, { error: "Forbidden" }); + return; + } + if (hasRequestBody(request)) { + sendJson(response, 400, { error: "Request body is not accepted" }); + return; + } + + if (!accessSession.required) { + sendJson(response, 200, { required: false, authenticated: true }); + return; + } + if (!isBearerAuthorized(request, accessSession)) { + response.setHeader("WWW-Authenticate", "Bearer"); + sendJson(response, 401, { error: "Unauthorized" }); + return; + } + + response.setHeader("Set-Cookie", accessSession.createCookie()!); + sendJson(response, 200, { required: true, authenticated: true }); +} + +function sessionStatus( + request: IncomingMessage, + accessSession: AccessSession, +): { required: boolean; authenticated: boolean } { + return { + required: accessSession.required, + authenticated: accessSession.isAuthenticated(request.headers.cookie), + }; +} + function hasRequestBody(request: IncomingMessage): boolean { const contentLength = request.headers["content-length"]; const parsedLength = contentLength === undefined ? 0 : Number(contentLength); @@ -270,19 +349,42 @@ function isAllowedRequestOrigin( } } +function isStrictlyAllowedRequestOrigin( + origin: string | undefined, + allowedOrigins: ReadonlySet, +): boolean { + if (!origin) { + return false; + } + try { + const parsed = new URL(origin); + return parsed.origin === origin && allowedOrigins.has(origin); + } catch { + return false; + } +} + function isRoomCreationAuthorized( request: IncomingMessage, - expectedToken: string | undefined, + accessSession: AccessSession, + hasAllowedOrigin: boolean, ): boolean { - if (!expectedToken) { + if (!accessSession.required) { return true; } + return hasAllowedOrigin && accessSession.isAuthenticated(request.headers.cookie); +} + +function isBearerAuthorized( + request: IncomingMessage, + accessSession: AccessSession, +): boolean { const authorization = request.headers.authorization; if (!authorization?.startsWith("Bearer ")) { return false; } const provided = authorization.slice("Bearer ".length); - return provided.length > 0 && secretsEqual(provided, expectedToken); + return provided.length > 0 && accessSession.passwordMatches(provided); } function sendJson(response: ServerResponse, status: number, body: unknown): void { @@ -292,3 +394,11 @@ function sendJson(response: ServerResponse, status: number, body: unknown): void response.setHeader("Content-Length", Buffer.byteLength(encoded)); response.end(encoded); } + +function safePathname(requestUrl: string | undefined): string { + try { + return new URL(requestUrl ?? "/", "http://localhost").pathname; + } catch { + return ""; + } +} diff --git a/src/server/config.ts b/src/server/config.ts index f76c22e..c10b4a5 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -1,9 +1,13 @@ -import { createHash, timingSafeEqual } from "node:crypto"; +import { MAX_VIEWERS_PER_ROOM_LIMIT } from "../shared/protocol.js"; export type RuntimeEnvironment = "development" | "test" | "production"; const MAX_TURN_CREDENTIAL_TTL_SECONDS = 3_600; -const MIN_PRODUCTION_SECRET_BYTES = 32; +const MIN_ACCESS_PASSWORD_BYTES = 12; +const MAX_ACCESS_PASSWORD_BYTES = 128; +const MIN_TURN_SECRET_BYTES = 32; +const DEFAULT_MAX_VIEWERS_PER_ROOM = 8; +const VISIBLE_ASCII_PATTERN = /^[\x21-\x7e]+$/; export interface ServerConfig { nodeEnv: RuntimeEnvironment; @@ -11,9 +15,10 @@ export interface ServerConfig { listenHost: string; publicBaseUrl: URL; allowedOrigins: ReadonlySet; - roomCreationToken?: string; + accessPassword?: string; roomTtlMs: number; maxRooms: number; + maxViewersPerRoom: number; stunUrls: readonly string[]; turnUrls: readonly string[]; turnSharedSecret?: string; @@ -222,11 +227,22 @@ export function loadConfig( ) { throw new Error("PUBLIC_BASE_URL must use http or https"); } + if ( + publicBaseUrl.username || + publicBaseUrl.password || + publicBaseUrl.pathname !== "/" || + publicBaseUrl.search || + publicBaseUrl.hash + ) { + throw new Error( + "PUBLIC_BASE_URL must be an origin without credentials, path, query, or fragment", + ); + } if (nodeEnv === "production" && publicBaseUrl.protocol !== "https:") { throw new Error("PUBLIC_BASE_URL must use https in production"); } - const roomCreationToken = environment.ROOM_CREATION_TOKEN?.trim() || undefined; + const accessPassword = environment.ACCESS_PASSWORD?.trim() || undefined; const turnSharedSecret = environment.TURN_SHARED_SECRET?.trim() || undefined; const stunUrls = parseIceUrlList( environment.STUN_URLS, @@ -244,15 +260,21 @@ export function loadConfig( "TURN_URLS and TURN_SHARED_SECRET must either both be configured or both be absent", ); } - if (nodeEnv === "production" && !roomCreationToken) { - throw new Error("ROOM_CREATION_TOKEN is required in production"); + if ( + accessPassword && + (!VISIBLE_ASCII_PATTERN.test(accessPassword) || + Buffer.byteLength(accessPassword) > MAX_ACCESS_PASSWORD_BYTES) + ) { + throw new Error( + "ACCESS_PASSWORD must contain at most 128 visible ASCII characters", + ); } if ( nodeEnv === "production" && - roomCreationToken && - Buffer.byteLength(roomCreationToken) < MIN_PRODUCTION_SECRET_BYTES + accessPassword && + Buffer.byteLength(accessPassword) < MIN_ACCESS_PASSWORD_BYTES ) { - throw new Error("ROOM_CREATION_TOKEN must contain at least 32 bytes in production"); + throw new Error("ACCESS_PASSWORD must contain at least 12 bytes in production"); } if (nodeEnv === "production" && turnUrls.length === 0) { throw new Error("TURN is required in production"); @@ -263,7 +285,7 @@ export function loadConfig( if ( nodeEnv === "production" && turnSharedSecret && - Buffer.byteLength(turnSharedSecret) < MIN_PRODUCTION_SECRET_BYTES + Buffer.byteLength(turnSharedSecret) < MIN_TURN_SECRET_BYTES ) { throw new Error("TURN_SHARED_SECRET must contain at least 32 bytes in production"); } @@ -280,11 +302,18 @@ export function loadConfig( environment.ALLOWED_ORIGINS, publicBaseUrl.origin, ), - roomCreationToken, + accessPassword, roomTtlMs: parsePositiveInteger(environment.ROOM_TTL_SECONDS, 14_400, "ROOM_TTL_SECONDS") * 1_000, maxRooms: parsePositiveInteger(environment.MAX_ROOMS, 1_000, "MAX_ROOMS"), + maxViewersPerRoom: parseBoundedInteger( + environment.MAX_VIEWERS_PER_ROOM, + DEFAULT_MAX_VIEWERS_PER_ROOM, + "MAX_VIEWERS_PER_ROOM", + 1, + MAX_VIEWERS_PER_ROOM_LIMIT, + ), stunUrls, turnUrls, turnSharedSecret, @@ -297,9 +326,3 @@ export function loadConfig( ), }; } - -export function secretsEqual(actual: string, expected: string): boolean { - const actualDigest = createHash("sha256").update(actual).digest(); - const expectedDigest = createHash("sha256").update(expected).digest(); - return timingSafeEqual(actualDigest, expectedDigest); -} diff --git a/src/server/room-store.ts b/src/server/room-store.ts index 083756d..62b350b 100644 --- a/src/server/room-store.ts +++ b/src/server/room-store.ts @@ -1,10 +1,13 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; -import { MAX_VIEWERS, type Role } from "../shared/protocol.js"; +import { + MAX_VIEWERS_PER_ROOM_LIMIT, + ROOM_CODE_LENGTH, + type Role, +} from "../shared/protocol.js"; export type RoomStoreErrorCode = | "INVALID_TOKEN" - | "ROOM_CLOSED" | "ROOM_EXPIRED" | "ROOM_FULL" | "HOST_ALREADY_CONNECTED" @@ -26,7 +29,6 @@ interface Participant { interface Room { roomId: string; hostTokenDigest: Buffer; - viewerTokenDigest: Buffer; expiresAtMs: number; host?: Participant; viewers: Map; @@ -35,17 +37,23 @@ interface Room { export interface CreatedRoom { roomId: string; hostToken: string; - viewerToken: string; expiresAt: string; } -export interface ConnectParticipantInput { - roomId: string; - role: Role; - token: string; - clientId: string; - sessionId: string; -} +export type ConnectParticipantInput = + | { + roomId: string; + role: "host"; + token: string; + clientId: string; + sessionId: string; + } + | { + roomId: string; + role: "viewer"; + clientId: string; + sessionId: string; + }; export interface ConnectedParticipant { roomId: string; @@ -53,7 +61,6 @@ export interface ConnectedParticipant { peerId: string; expiresAt: string; hostOnline: boolean; - isNewParticipant: boolean; replacedSessionId?: string; viewerPeerIds: readonly string[]; } @@ -77,6 +84,7 @@ export interface ClosedRoom { export interface RoomStoreOptions { ttlMs: number; maxRooms: number; + maxViewersPerRoom: number; now?: () => number; random?: (size: number) => Buffer; } @@ -85,6 +93,7 @@ export class RoomStore { private readonly rooms = new Map(); private readonly now: () => number; private readonly random: (size: number) => Buffer; + readonly maxViewersPerRoom: number; constructor(private readonly options: RoomStoreOptions) { if (!Number.isSafeInteger(options.ttlMs) || options.ttlMs <= 0) { @@ -93,8 +102,18 @@ export class RoomStore { if (!Number.isSafeInteger(options.maxRooms) || options.maxRooms <= 0) { throw new Error("Room limit must be a positive integer"); } + if ( + !Number.isSafeInteger(options.maxViewersPerRoom) || + options.maxViewersPerRoom <= 0 || + options.maxViewersPerRoom > MAX_VIEWERS_PER_ROOM_LIMIT + ) { + throw new Error( + `Room viewer limit must be an integer between 1 and ${MAX_VIEWERS_PER_ROOM_LIMIT}`, + ); + } this.now = options.now ?? Date.now; this.random = options.random ?? randomBytes; + this.maxViewersPerRoom = options.maxViewersPerRoom; } createRoom(): CreatedRoom { @@ -102,15 +121,13 @@ export class RoomStore { throw new RoomStoreError("ROOM_LIMIT"); } - const roomId = this.uniqueId(16, (candidate) => this.rooms.has(candidate)); + const roomId = this.uniqueRoomCode(); const hostToken = this.random(32).toString("base64url"); - const viewerToken = this.random(32).toString("base64url"); const expiresAtMs = this.now() + this.options.ttlMs; this.rooms.set(roomId, { roomId, hostTokenDigest: digest(hostToken), - viewerTokenDigest: digest(viewerToken), expiresAtMs, viewers: new Map(), }); @@ -118,16 +135,13 @@ export class RoomStore { return { roomId, hostToken, - viewerToken, expiresAt: new Date(expiresAtMs).toISOString(), }; } connectParticipant(input: ConnectParticipantInput): ConnectedParticipant { const room = this.getAvailableRoom(input.roomId); - const expectedDigest = - input.role === "host" ? room.hostTokenDigest : room.viewerTokenDigest; - if (!verifyDigest(input.token, expectedDigest)) { + if (input.role === "host" && !verifyDigest(input.token, room.hostTokenDigest)) { throw new RoomStoreError("INVALID_TOKEN"); } @@ -255,7 +269,6 @@ export class RoomStore { peerId: participant.peerId, expiresAt: new Date(room.expiresAtMs).toISOString(), hostOnline: true, - isNewParticipant, replacedSessionId: replacedSessionId === input.sessionId ? undefined : replacedSessionId, // Disconnected viewers remain room members until their grace period ends. @@ -268,7 +281,7 @@ export class RoomStore { input: ConnectParticipantInput, ): ConnectedParticipant { const current = room.viewers.get(input.clientId); - if (!current && room.viewers.size >= MAX_VIEWERS) { + if (!current && room.viewers.size >= this.maxViewersPerRoom) { throw new RoomStoreError("ROOM_FULL"); } @@ -288,7 +301,6 @@ export class RoomStore { peerId: participant.peerId, expiresAt: new Date(room.expiresAtMs).toISOString(), hostOnline: Boolean(room.host?.sessionId), - isNewParticipant: current === undefined, replacedSessionId: replacedSessionId === input.sessionId ? undefined : replacedSessionId, viewerPeerIds: [], @@ -316,6 +328,23 @@ export class RoomStore { ); } + private uniqueRoomCode(): string { + const range = 10n ** BigInt(ROOM_CODE_LENGTH); + for (let attempt = 0; attempt < 8; attempt += 1) { + const bytes = this.random(8); + if (bytes.length !== 8) { + throw new Error("Room code random source must return 8 bytes"); + } + const candidate = (bytes.readBigUInt64BE() % range) + .toString() + .padStart(ROOM_CODE_LENGTH, "0"); + if (!this.rooms.has(candidate)) { + return candidate; + } + } + throw new Error("Unable to allocate a unique room code"); + } + private uniqueId(size: number, exists: (candidate: string) => boolean): string { for (let attempt = 0; attempt < 8; attempt += 1) { const candidate = this.random(size).toString("base64url"); diff --git a/src/server/signaling.ts b/src/server/signaling.ts index 9981795..c099209 100644 --- a/src/server/signaling.ts +++ b/src/server/signaling.ts @@ -1,12 +1,11 @@ import { randomBytes } from "node:crypto"; -import type { Server as HttpServer } from "node:http"; +import type { IncomingMessage, Server as HttpServer } from "node:http"; import type { Duplex } from "node:stream"; import WebSocket, { WebSocketServer } from "ws"; import { MAX_SIGNAL_BYTES, - MAX_VIEWERS, decodeClientMessage, type ClientMessage, type Role, @@ -40,6 +39,7 @@ export interface SignalingOptions { roomStore: RoomStore; ice: IceConfigOptions; allowedOrigins: ReadonlySet; + authorizeUpgrade: (request: IncomingMessage) => boolean; now?: () => number; authenticationTimeoutMs?: number; viewerDisconnectGraceMs?: number; @@ -107,6 +107,10 @@ export class SignalingServer { rejectUpgrade(socket, 403, "Forbidden"); return; } + if (!options.authorizeUpgrade(request)) { + rejectUpgrade(socket, 401, "Unauthorized"); + return; + } if (!this.hasConnectionCapacity()) { rejectUpgrade(socket, 503, "Service Unavailable"); return; @@ -235,14 +239,26 @@ export class SignalingServer { ): void { let participant; try { - participant = this.options.roomStore.connectParticipant({ - roomId: message.roomId, - role: message.role, - token: message.token, - clientId: message.clientId, - sessionId: state.sessionId, - }); + participant = this.options.roomStore.connectParticipant( + message.role === "host" + ? { + roomId: message.roomId, + role: "host", + token: message.token, + clientId: message.clientId, + sessionId: state.sessionId, + } + : { + roomId: message.roomId, + role: "viewer", + clientId: message.clientId, + sessionId: state.sessionId, + }, + ); } catch (error) { + if (!(error instanceof RoomStoreError)) { + console.error("Signaling authentication failed unexpectedly", error); + } const code = error instanceof RoomStoreError && error.code !== "ROOM_LIMIT" ? error.code @@ -277,7 +293,7 @@ export class SignalingServer { role: participant.role, peerId: participant.peerId, roomExpiresAt: participant.expiresAt, - maxViewers: MAX_VIEWERS, + maxViewers: this.options.roomStore.maxViewersPerRoom, hostOnline: participant.hostOnline, connectionId, viewerPeerIds: [...participant.viewerPeerIds], @@ -539,7 +555,8 @@ export class SignalingServer { return false; } try { - return this.options.allowedOrigins.has(new URL(origin).origin); + const parsed = new URL(origin); + return parsed.origin === origin && this.options.allowedOrigins.has(origin); } catch { return false; } @@ -628,8 +645,6 @@ function viewerConnectionKey(roomId: string, peerId: string): string { function authenticationErrorMessage(code: ErrorCode): string { switch (code) { - case "ROOM_CLOSED": - return "Room is closed"; case "ROOM_EXPIRED": return "Room has expired"; case "ROOM_FULL": @@ -637,7 +652,7 @@ function authenticationErrorMessage(code: ErrorCode): string { case "HOST_ALREADY_CONNECTED": return "A host is already connected"; case "INVALID_TOKEN": - return "Room or token is invalid"; + return "Room is invalid or expired"; default: return "Authentication failed"; } diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 8893d3e..1bd9c14 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -1,7 +1,8 @@ import { z } from "zod"; -export const MAX_VIEWERS = 3; +export const MAX_VIEWERS_PER_ROOM_LIMIT = 16; export const MAX_SIGNAL_BYTES = 64 * 1024; +export const ROOM_CODE_LENGTH = 12; const opaqueIdSchema = z .string() @@ -15,6 +16,11 @@ const tokenSchema = z .max(128) .regex(/^[A-Za-z0-9_-]+$/); +export const roomCodeSchema = z + .string() + .length(ROOM_CODE_LENGTH) + .regex(/^\d+$/); + export const roleSchema = z.enum(["host", "viewer"]); export type Role = z.infer; @@ -72,16 +78,28 @@ export const signalPayloadSchema = z.discriminatedUnion("kind", [ ]); export type SignalPayload = z.infer; -export const clientMessageSchema = z.discriminatedUnion("type", [ +const authenticateMessageSchema = z.discriminatedUnion("role", [ z .object({ type: z.literal("authenticate"), - roomId: opaqueIdSchema, - role: roleSchema, + roomId: roomCodeSchema, + role: z.literal("host"), token: tokenSchema, clientId: opaqueIdSchema, }) .strict(), + z + .object({ + type: z.literal("authenticate"), + roomId: roomCodeSchema, + role: z.literal("viewer"), + clientId: opaqueIdSchema, + }) + .strict(), +]); + +export const clientMessageSchema = z.union([ + authenticateMessageSchema, z .object({ type: z.literal("signal"), @@ -105,7 +123,6 @@ const errorCodeSchema = z.enum([ "AUTH_REQUIRED", "INVALID_MESSAGE", "INVALID_TOKEN", - "ROOM_CLOSED", "ROOM_EXPIRED", "ROOM_FULL", "HOST_ALREADY_CONNECTED", @@ -121,10 +138,10 @@ export const serverMessageSchema = z.discriminatedUnion("type", [ role: roleSchema, peerId: opaqueIdSchema, roomExpiresAt: z.string().datetime(), - maxViewers: z.literal(MAX_VIEWERS), + maxViewers: z.number().int().min(1).max(MAX_VIEWERS_PER_ROOM_LIMIT), hostOnline: z.boolean(), connectionId: opaqueIdSchema.nullable(), - viewerPeerIds: z.array(opaqueIdSchema).max(MAX_VIEWERS), + viewerPeerIds: z.array(opaqueIdSchema).max(MAX_VIEWERS_PER_ROOM_LIMIT), iceConfig: iceConfigSchema, }) .strict(), @@ -185,11 +202,10 @@ export type ServerMessage = z.infer; export const createRoomResponseSchema = z .object({ - roomId: opaqueIdSchema, + roomId: roomCodeSchema, hostToken: tokenSchema, inviteUrl: z.string().url().max(2048), expiresAt: z.string().datetime(), - iceConfig: iceConfigSchema, }) .strict(); export type CreateRoomResponse = z.infer; diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts index 203bc3b..7d0fd22 100644 --- a/tests/client-state.test.ts +++ b/tests/client-state.test.ts @@ -1,7 +1,19 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getStableClientId } from "../src/client/lib/session.ts"; -import { shouldReconnectSignaling } from "../src/client/lib/signaling.ts"; +import { + ApiError, + authenticate, + createRoom, + getSession, +} from "../src/client/lib/api.ts"; +import { + getStableClientId, + isValidRoomId, +} from "../src/client/lib/session.ts"; +import { + shouldReconnectSignaling, + SignalingClient, +} from "../src/client/lib/signaling.ts"; import { createStatsAccumulator, collectConnectionMetrics } from "../src/client/webrtc/stats.ts"; afterEach(() => { @@ -31,11 +43,150 @@ describe("client session identity", () => { }); }); +describe("site access API", () => { + it("checks and authenticates a browser session without a request body", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ required: true, authenticated: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ required: true, authenticated: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect(getSession()).resolves.toEqual({ + required: true, + authenticated: false, + }); + await expect(authenticate(" instance-password ")).resolves.toEqual({ + required: true, + authenticated: true, + }); + + expect(fetchMock.mock.calls[0]).toEqual([ + "/api/session", + { headers: { Accept: "application/json" } }, + ]); + const post = fetchMock.mock.calls[1][1]; + expect(post?.method).toBe("POST"); + expect(post?.body).toBeUndefined(); + expect(new Headers(post?.headers).get("Authorization")).toBe( + "Bearer instance-password", + ); + }); + + it("uses a clear Chinese message for a rejected password", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + await expect(authenticate("wrong-password")).rejects.toMatchObject({ + status: 401, + message: "访问密码不正确,请重试", + }); + }); + + it("does not send a raw bearer token when creating a room by default", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "test response" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect(createRoom()).rejects.toBeInstanceOf(ApiError); + + const headers = new Headers(fetchMock.mock.calls[0][1]?.headers); + expect(headers.has("Authorization")).toBe(false); + }); +}); + +describe("room codes", () => { + it("accepts exactly 12 decimal digits", () => { + expect(isValidRoomId("123456789012")).toBe(true); + expect(isValidRoomId("12345678901")).toBe(false); + expect(isValidRoomId("12345678901a")).toBe(false); + }); +}); + describe("client signaling recovery policy", () => { it("does not reconnect a session that another tab replaced", () => { expect(shouldReconnectSignaling(4001)).toBe(false); expect(shouldReconnectSignaling(1006)).toBe(true); }); + + it("returns to the access gate after an unauthorized upgrade", async () => { + const sockets: FakeWebSocket[] = []; + class FakeWebSocket extends EventTarget { + static readonly CLOSING = 2; + readyState = 0; + readonly send = vi.fn(); + readonly close = vi.fn(); + + constructor(readonly url: string) { + super(); + sockets.push(this); + } + } + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ required: true, authenticated: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("window", { + location: new URL("https://share.test/r/123456789012"), + setTimeout, + clearTimeout, + }); + const onAccessRequired = vi.fn(); + const statuses: string[] = []; + const signal = new SignalingClient( + { + roomId: "123456789012", + role: "viewer", + clientId: "viewer-client", + }, + { + onMessage: () => undefined, + onStatus: (status) => statuses.push(status), + onProtocolError: () => undefined, + onTerminated: () => undefined, + onAccessRequired, + }, + ); + + signal.start(); + const closeEvent = new Event("close"); + Object.defineProperties(closeEvent, { + code: { value: 1006 }, + reason: { value: "" }, + }); + sockets[0]!.dispatchEvent(closeEvent); + + await vi.waitFor(() => expect(onAccessRequired).toHaveBeenCalledOnce()); + expect(fetchMock).toHaveBeenCalledWith("/api/session", { + headers: { Accept: "application/json" }, + }); + expect(statuses.at(-1)).toBe("offline"); + expect(sockets).toHaveLength(1); + }); }); describe("WebRTC stats parsing", () => { diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 6e2a74e..cb97008 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -1,13 +1,14 @@ import { describe, expect, it } from "vitest"; import { - MAX_VIEWERS, + MAX_VIEWERS_PER_ROOM_LIMIT, clientMessageSchema, decodeClientMessage, serverMessageSchema, } from "../src/shared/protocol.js"; const token = "a".repeat(43); +const roomId = "123456789012"; describe("client signaling protocol", () => { it("accepts a bounded authentication message", () => { @@ -15,9 +16,8 @@ describe("client signaling protocol", () => { decodeClientMessage( JSON.stringify({ type: "authenticate", - roomId: "room_12345678", + roomId, role: "viewer", - token, clientId: "client_12345678", }), ), @@ -27,16 +27,45 @@ describe("client signaling protocol", () => { it("rejects unknown fields and malformed tokens", () => { const result = clientMessageSchema.safeParse({ type: "authenticate", - roomId: "room_12345678", + roomId, role: "host", token: "short", clientId: "client_12345678", admin: true, - }); + }); expect(result.success).toBe(false); }); + it("requires a host token and rejects viewer tokens or malformed room codes", () => { + expect( + clientMessageSchema.safeParse({ + type: "authenticate", + roomId, + role: "host", + token, + clientId: "client_12345678", + }).success, + ).toBe(true); + expect( + clientMessageSchema.safeParse({ + type: "authenticate", + roomId, + role: "viewer", + token, + clientId: "client_12345678", + }).success, + ).toBe(false); + expect( + clientMessageSchema.safeParse({ + type: "authenticate", + roomId: "1234", + role: "viewer", + clientId: "client_12345678", + }).success, + ).toBe(false); + }); + it("bounds SDP before routing it", () => { const result = clientMessageSchema.safeParse({ type: "signal", @@ -72,23 +101,65 @@ describe("client signaling protocol", () => { }); describe("server signaling protocol", () => { - it("keeps the viewer limit in the wire contract", () => { - const result = serverMessageSchema.safeParse({ + function authenticatedMessage(maxViewers: number, viewerPeerIds: string[] = []) { + return { type: "authenticated", role: "host", peerId: "host_12345678", roomExpiresAt: "2026-08-18T18:00:00.000Z", - maxViewers: MAX_VIEWERS, + maxViewers, hostOnline: true, connectionId: null, - viewerPeerIds: [], + viewerPeerIds, iceConfig: { iceServers: [], expiresAt: null, relayAvailable: false, }, - }); + }; + } - expect(result.success).toBe(true); + it("accepts dynamic viewer limits within the protocol boundary", () => { + expect(serverMessageSchema.safeParse(authenticatedMessage(1)).success).toBe( + true, + ); + expect(serverMessageSchema.safeParse(authenticatedMessage(8)).success).toBe( + true, + ); + expect( + serverMessageSchema.safeParse( + authenticatedMessage(MAX_VIEWERS_PER_ROOM_LIMIT), + ).success, + ).toBe(true); + }); + + it.each([0, 1.5, MAX_VIEWERS_PER_ROOM_LIMIT + 1])( + "rejects viewer limit %s outside the protocol boundary", + (maxViewers) => { + expect( + serverMessageSchema.safeParse(authenticatedMessage(maxViewers)).success, + ).toBe(false); + }, + ); + + it("bounds authenticated viewer rosters independently of configured capacity", () => { + const viewerPeerIds = Array.from( + { length: MAX_VIEWERS_PER_ROOM_LIMIT }, + (_, index) => `viewer_${index.toString().padStart(8, "0")}`, + ); + + expect( + serverMessageSchema.safeParse( + authenticatedMessage(MAX_VIEWERS_PER_ROOM_LIMIT, viewerPeerIds), + ).success, + ).toBe(true); + expect( + serverMessageSchema.safeParse( + authenticatedMessage(MAX_VIEWERS_PER_ROOM_LIMIT, [ + ...viewerPeerIds, + "viewer_overflow", + ]), + ).success, + ).toBe(false); }); }); diff --git a/tests/room-store.test.ts b/tests/room-store.test.ts index b1e920d..e0a8a8f 100644 --- a/tests/room-store.test.ts +++ b/tests/room-store.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { MAX_VIEWERS_PER_ROOM_LIMIT } from "../src/shared/protocol.ts"; import { RoomStore, RoomStoreError, @@ -18,20 +19,25 @@ function expectRoomError(action: () => unknown, code: RoomStoreError["code"]): v describe("RoomStore", () => { it("creates a four-hour room and authenticates both roles", () => { const now = Date.UTC(2026, 7, 18, 12); - const store = new RoomStore({ ttlMs: 14_400_000, maxRooms: 10, now: () => now }); + const store = new RoomStore({ + ttlMs: 14_400_000, + maxRooms: 10, + maxViewersPerRoom: 3, + now: () => now, + }); const room = store.createRoom(); - expect(room.hostToken).not.toBe(room.viewerToken); + expect(room.roomId).toMatch(/^\d{12}$/); + expect(room.hostToken).toHaveLength(43); expect(room.expiresAt).toBe(new Date(now + 14_400_000).toISOString()); expect( store.connectParticipant({ roomId: room.roomId, role: "viewer", - token: room.viewerToken, clientId: "viewer-client-1", sessionId: "viewer-session-1", }), - ).toMatchObject({ role: "viewer", hostOnline: false, isNewParticipant: true }); + ).toMatchObject({ role: "viewer", hostOnline: false }); expect( store.connectParticipant({ roomId: room.roomId, @@ -47,8 +53,12 @@ describe("RoomStore", () => { }); }); - it("rejects the wrong role token without exposing which value failed", () => { - const store = new RoomStore({ ttlMs: 10_000, maxRooms: 10 }); + it("rejects an invalid host token without exposing which value failed", () => { + const store = new RoomStore({ + ttlMs: 10_000, + maxRooms: 10, + maxViewersPerRoom: 3, + }); const room = store.createRoom(); expectRoomError( @@ -56,7 +66,7 @@ describe("RoomStore", () => { store.connectParticipant({ roomId: room.roomId, role: "host", - token: room.viewerToken, + token: "wrong-host-token".padEnd(32, "x"), clientId: "host-client-1", sessionId: "host-session-1", }), @@ -65,25 +75,26 @@ describe("RoomStore", () => { }); it("keeps peer identity stable and ignores disconnect from a replaced socket", () => { - const store = new RoomStore({ ttlMs: 10_000, maxRooms: 10 }); + const store = new RoomStore({ + ttlMs: 10_000, + maxRooms: 10, + maxViewersPerRoom: 3, + }); const room = store.createRoom(); const first = store.connectParticipant({ roomId: room.roomId, role: "viewer", - token: room.viewerToken, clientId: "stable-viewer-client", sessionId: "viewer-session-old", }); const replacement = store.connectParticipant({ roomId: room.roomId, role: "viewer", - token: room.viewerToken, clientId: "stable-viewer-client", sessionId: "viewer-session-new", }); expect(replacement.peerId).toBe(first.peerId); - expect(replacement.isNewParticipant).toBe(false); expect(replacement.replacedSessionId).toBe("viewer-session-old"); expect( store.disconnectParticipant(room.roomId, first.peerId, "viewer-session-old"), @@ -93,14 +104,17 @@ describe("RoomStore", () => { ); }); - it("reserves at most three viewer slots until a disconnected viewer is released", () => { - const store = new RoomStore({ ttlMs: 10_000, maxRooms: 10 }); + it("reserves the configured viewer slots until a disconnected viewer is released", () => { + const store = new RoomStore({ + ttlMs: 10_000, + maxRooms: 10, + maxViewersPerRoom: 2, + }); const room = store.createRoom(); - const viewers = [1, 2, 3].map((number) => + const viewers = [1, 2].map((number) => store.connectParticipant({ roomId: room.roomId, role: "viewer", - token: room.viewerToken, clientId: `viewer-client-${number}`, sessionId: `viewer-session-${number}`, }), @@ -111,9 +125,8 @@ describe("RoomStore", () => { store.connectParticipant({ roomId: room.roomId, role: "viewer", - token: room.viewerToken, - clientId: "viewer-client-4", - sessionId: "viewer-session-4", + clientId: "viewer-client-3", + sessionId: "viewer-session-3", }), "ROOM_FULL", ); @@ -124,9 +137,8 @@ describe("RoomStore", () => { store.connectParticipant({ roomId: room.roomId, role: "viewer", - token: room.viewerToken, - clientId: "viewer-client-4", - sessionId: "viewer-session-4", + clientId: "viewer-client-3", + sessionId: "viewer-session-3", }), "ROOM_FULL", ); @@ -135,15 +147,19 @@ describe("RoomStore", () => { store.connectParticipant({ roomId: room.roomId, role: "viewer", - token: room.viewerToken, - clientId: "viewer-client-4", - sessionId: "viewer-session-4", - }).isNewParticipant, - ).toBe(true); + clientId: "viewer-client-3", + sessionId: "viewer-session-3", + }).role, + ).toBe("viewer"); + expect(store.maxViewersPerRoom).toBe(2); }); it("allows only the same host identity to replace an online host", () => { - const store = new RoomStore({ ttlMs: 10_000, maxRooms: 10 }); + const store = new RoomStore({ + ttlMs: 10_000, + maxRooms: 10, + maxViewersPerRoom: 3, + }); const room = store.createRoom(); const first = store.connectParticipant({ roomId: room.roomId, @@ -177,7 +193,12 @@ describe("RoomStore", () => { it("expires, closes, and globally bounds rooms", () => { let now = 1_000; - const expiringStore = new RoomStore({ ttlMs: 100, maxRooms: 1, now: () => now }); + const expiringStore = new RoomStore({ + ttlMs: 100, + maxRooms: 1, + maxViewersPerRoom: 3, + now: () => now, + }); const room = expiringStore.createRoom(); expectRoomError(() => expiringStore.createRoom(), "ROOM_LIMIT"); @@ -204,7 +225,6 @@ describe("RoomStore", () => { expiringStore.connectParticipant({ roomId: openRoom.roomId, role: "viewer", - token: openRoom.viewerToken, clientId: "viewer-client-1", sessionId: "viewer-session-1", }), @@ -212,4 +232,20 @@ describe("RoomStore", () => { ); expect(expiringStore.createRoom()).toBeDefined(); }); + + it.each([0, 1.5, MAX_VIEWERS_PER_ROOM_LIMIT + 1])( + "rejects invalid per-room viewer limit %s", + (maxViewersPerRoom) => { + expect( + () => + new RoomStore({ + ttlMs: 10_000, + maxRooms: 10, + maxViewersPerRoom, + }), + ).toThrow( + `Room viewer limit must be an integer between 1 and ${MAX_VIEWERS_PER_ROOM_LIMIT}`, + ); + }, + ); }); diff --git a/tests/server-config.test.ts b/tests/server-config.test.ts index d2f7bc4..3fac19b 100644 --- a/tests/server-config.test.ts +++ b/tests/server-config.test.ts @@ -13,6 +13,7 @@ describe("server configuration", () => { expect(config.publicBaseUrl.href).toBe("http://localhost:9123/"); expect(config.allowedOrigins).toEqual(new Set(["http://localhost:9123"])); expect(config.turnUrls).toEqual([]); + expect(config.maxViewersPerRoom).toBe(8); }); it("allows an explicit loopback listen host", () => { @@ -21,16 +22,27 @@ describe("server configuration", () => { expect(config.listenHost).toBe("127.0.0.1"); }); - it("requires room creation authorization and TURN in production", () => { + it.each([ + "https://user:pass@share.test", + "https://share.test/path", + "https://share.test?query=1", + "https://share.test#fragment", + ])("rejects a PUBLIC_BASE_URL that is not a plain origin: %s", (url) => { + expect(() => loadConfig({ PUBLIC_BASE_URL: url })).toThrow( + "PUBLIC_BASE_URL must be an origin", + ); + }); + + it("allows an optional access password but requires TURN in production", () => { expect(() => loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test" }), - ).toThrow("ROOM_CREATION_TOKEN"); + ).toThrow("TURN is required"); expect(() => loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test", - ROOM_CREATION_TOKEN: "c".repeat(32), + ACCESS_PASSWORD: "c".repeat(12), }), ).toThrow("TURN is required"); @@ -38,7 +50,7 @@ describe("server configuration", () => { loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test", - ROOM_CREATION_TOKEN: "c".repeat(32), + ACCESS_PASSWORD: "c".repeat(12), TURN_URLS: "turn:turn.test:3478", TURN_SHARED_SECRET: "t".repeat(32), }), @@ -49,23 +61,43 @@ describe("server configuration", () => { const config = loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test", - ROOM_CREATION_TOKEN: "c".repeat(32), STUN_URLS: "stun:turn.test:3478,stuns:turn.test:5349", TURN_URLS: requiredProductionTurnUrls, TURN_SHARED_SECRET: "t".repeat(32), }); + expect(config.accessPassword).toBeUndefined(); expect(config.turnUrls).toHaveLength(2); expect(config.turnSharedSecret).toBe("t".repeat(32)); }); + 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); + }, + ); + + it.each(["0", "17", "1.5"])( + "rejects invalid per-room viewer limit %s", + (maxViewersPerRoom) => { + expect(() => + loadConfig({ MAX_VIEWERS_PER_ROOM: maxViewersPerRoom }), + ).toThrow("MAX_VIEWERS_PER_ROOM"); + }, + ); + 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", - ROOM_CREATION_TOKEN: "c".repeat(32), + 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), @@ -115,7 +147,7 @@ describe("server configuration", () => { loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test", - ROOM_CREATION_TOKEN: "c".repeat(32), + ACCESS_PASSWORD: "c".repeat(12), STUN_URLS: "stun:turn.test:3478", TURN_URLS: `${requiredProductionTurnUrls},${invalidUrl}`, TURN_SHARED_SECRET: "t".repeat(32), @@ -150,7 +182,7 @@ describe("server configuration", () => { loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test", - ROOM_CREATION_TOKEN: "c".repeat(32), + ACCESS_PASSWORD: "c".repeat(12), STUN_URLS: "stun:turn.test:3478", TURN_URLS: urls, TURN_SHARED_SECRET: "t".repeat(32), @@ -165,18 +197,18 @@ describe("server configuration", () => { loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test", - ROOM_CREATION_TOKEN: "too-short", + ACCESS_PASSWORD: "too-short", STUN_URLS: "stun:turn.test:3478", TURN_URLS: "turn:turn.test:3478", TURN_SHARED_SECRET: "t".repeat(32), }), - ).toThrow("ROOM_CREATION_TOKEN must contain at least 32 bytes"); + ).toThrow("ACCESS_PASSWORD must contain at least 12 bytes"); expect(() => loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test", - ROOM_CREATION_TOKEN: "c".repeat(32), + ACCESS_PASSWORD: "c".repeat(12), STUN_URLS: "stun:turn.test:3478", TURN_URLS: "turn:turn.test:3478", TURN_SHARED_SECRET: "too-short", @@ -184,6 +216,15 @@ describe("server configuration", () => { ).toThrow("TURN_SHARED_SECRET must contain at least 32 bytes"); }); + it.each(["密码密码密码密码", "contains spaces", "x".repeat(129)])( + "rejects an access password that cannot be carried safely in a header", + (accessPassword) => { + expect(() => loadConfig({ ACCESS_PASSWORD: accessPassword })).toThrow( + "ACCESS_PASSWORD must contain at most 128 visible ASCII characters", + ); + }, + ); + it("rejects partial TURN settings and mismatched ICE schemes", () => { expect(() => loadConfig({ TURN_URLS: "turn:turn.test:3478" })).toThrow( "TURN_URLS and TURN_SHARED_SECRET", diff --git a/tests/server-http.test.ts b/tests/server-http.test.ts index fb11d79..3184991 100644 --- a/tests/server-http.test.ts +++ b/tests/server-http.test.ts @@ -3,11 +3,13 @@ import { afterEach, describe, expect, it } from "vitest"; import { createRoomResponseSchema } from "../src/shared/protocol.ts"; import { createScreenerServer, + type CreateServerOptions, type ScreenerServer, } from "../src/server/app.ts"; import type { ServerConfig } from "../src/server/config.ts"; const allowedOrigin = "http://allowed.test"; +const accessPassword = "instance-access-password"; let runningServer: ScreenerServer | undefined; afterEach(async () => { @@ -22,9 +24,10 @@ function testConfig(overrides: Partial = {}): ServerConfig { listenHost: "127.0.0.1", publicBaseUrl: new URL("https://share.example.test"), allowedOrigins: new Set([allowedOrigin]), - roomCreationToken: "create-secret", + accessPassword, roomTtlMs: 14_400_000, maxRooms: 10, + maxViewersPerRoom: 8, stunUrls: [], turnUrls: [], turnCredentialTtlSeconds: 3_600, @@ -32,19 +35,199 @@ function testConfig(overrides: Partial = {}): ServerConfig { }; } -async function start(config = testConfig()): Promise { - runningServer = await createScreenerServer({ config, serveFrontend: false }); +async function start( + config = testConfig(), + options: Pick = {}, +): Promise { + runningServer = await createScreenerServer({ + ...options, + config, + serveFrontend: false, + }); const port = await runningServer.listen(0, "127.0.0.1"); return `http://127.0.0.1:${port}`; } -describe("room HTTP API", () => { - it("creates a no-store room response with an invite fragment", async () => { +function cookiePair(response: Response): string { + const setCookie = response.headers.get("set-cookie"); + if (!setCookie) { + throw new Error("Expected a Set-Cookie header"); + } + return setCookie.split(";", 1)[0]!; +} + +async function login(baseUrl: string): Promise { + return fetch(`${baseUrl}/api/session`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessPassword}`, + Origin: allowedOrigin, + }, + }); +} + +describe("site access session", () => { + it("reports status and issues a stateless 12-hour cookie", async () => { const baseUrl = await start(); + const initial = await fetch(`${baseUrl}/api/session`); + + expect(initial.status).toBe(200); + expect(initial.headers.get("cache-control")).toBe("no-store"); + expect(await initial.json()).toEqual({ + required: true, + authenticated: false, + }); + + const denied = await fetch(`${baseUrl}/api/session`, { + method: "POST", + headers: { + Authorization: "Bearer wrong-password", + Origin: allowedOrigin, + }, + }); + expect(denied.status).toBe(401); + expect(denied.headers.get("set-cookie")).toBeNull(); + + const authenticated = await login(baseUrl); + const setCookie = authenticated.headers.get("set-cookie") ?? ""; + expect(authenticated.status).toBe(200); + expect(setCookie).toContain("screener-session=v1."); + expect(setCookie).toContain("Path=/"); + expect(setCookie).toContain("Max-Age=43200"); + expect(setCookie).toContain("HttpOnly"); + expect(setCookie).toContain("SameSite=Strict"); + expect(setCookie).not.toContain("Secure"); + expect(setCookie).not.toContain(accessPassword); + + const status = await fetch(`${baseUrl}/api/session`, { + headers: { Cookie: cookiePair(authenticated) }, + }); + expect(await status.json()).toEqual({ + required: true, + authenticated: true, + }); + }); + + it("uses a secure __Host- cookie for production HTTPS", async () => { + const baseUrl = await start(testConfig({ nodeEnv: "production" })); + const response = await login(baseUrl); + const setCookie = response.headers.get("set-cookie") ?? ""; + + expect(setCookie).toContain("__Host-screener-session="); + expect(setCookie).toContain("Secure"); + expect(setCookie).toContain("HttpOnly"); + expect(setCookie).toContain("SameSite=Strict"); + expect(setCookie).toContain("Path=/"); + expect(setCookie).not.toContain("Domain="); + }); + + it("requires an allowed Origin and an empty login body", async () => { + const baseUrl = await start(); + const authorization = { Authorization: `Bearer ${accessPassword}` }; + + expect( + ( + await fetch(`${baseUrl}/api/session`, { + method: "POST", + headers: authorization, + }) + ).status, + ).toBe(403); + expect( + ( + await fetch(`${baseUrl}/api/session`, { + method: "POST", + headers: { ...authorization, Origin: "https://foreign.test" }, + }) + ).status, + ).toBe(403); + expect( + ( + await fetch(`${baseUrl}/api/session`, { + method: "POST", + headers: { ...authorization, Origin: `${allowedOrigin}/path` }, + }) + ).status, + ).toBe(403); + expect( + ( + await fetch(`${baseUrl}/api/session`, { + method: "POST", + headers: { ...authorization, Origin: allowedOrigin }, + body: "{}", + }) + ).status, + ).toBe(400); + }); + + it("rejects an expired or modified cookie", async () => { + let now = 1_000; + const baseUrl = await start(testConfig(), { + now: () => now, + accessSessionTtlSeconds: 1, + }); + const authenticated = await login(baseUrl); + const cookie = cookiePair(authenticated); + + const modified = await fetch(`${baseUrl}/api/session`, { + headers: { Cookie: `${cookie}x` }, + }); + expect((await modified.json()).authenticated).toBe(false); + + now = 2_000; + const expired = await fetch(`${baseUrl}/api/session`, { + headers: { Cookie: cookie }, + }); + expect(await expired.json()).toEqual({ + required: true, + authenticated: false, + }); + }); + + it("is immediately authenticated when the access password is empty", async () => { + const baseUrl = await start(testConfig({ accessPassword: undefined })); + const status = await fetch(`${baseUrl}/api/session`); + + expect(await status.json()).toEqual({ + required: false, + authenticated: true, + }); + const post = await fetch(`${baseUrl}/api/session`, { + method: "POST", + headers: { Origin: allowedOrigin }, + }); + expect(post.status).toBe(200); + expect(post.headers.get("set-cookie")).toBeNull(); + }); +}); + +describe("room HTTP API", () => { + it("requires a logged-in browser when access protection is enabled", async () => { + const baseUrl = await start(); + const anonymous = await fetch(`${baseUrl}/api/rooms`, { + method: "POST", + headers: { Origin: allowedOrigin }, + }); + expect(anonymous.status).toBe(401); + expect(anonymous.headers.get("www-authenticate")).toBeNull(); + + const bearerBypass = await fetch(`${baseUrl}/api/rooms`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessPassword}`, + Origin: allowedOrigin, + }, + }); + expect(bearerBypass.status).toBe(401); + }); + + it("creates a numeric room and token-free invite for an authenticated browser", async () => { + const baseUrl = await start(); + const authenticated = await login(baseUrl); const response = await fetch(`${baseUrl}/api/rooms`, { method: "POST", headers: { - Authorization: "Bearer create-secret", + Cookie: cookiePair(authenticated), Origin: allowedOrigin, }, }); @@ -53,67 +236,55 @@ describe("room HTTP API", () => { expect(response.headers.get("cache-control")).toBe("no-store"); const body = createRoomResponseSchema.parse(await response.json()); const invite = new URL(body.inviteUrl); + expect(body.roomId).toMatch(/^\d{12}$/); expect(invite.origin).toBe("https://share.example.test"); expect(invite.pathname).toBe(`/r/${body.roomId}`); - expect(new URLSearchParams(invite.hash.slice(1)).get("token")).toHaveLength(43); + expect(invite.hash).toBe(""); expect(invite.search).toBe(""); - expect(body.iceConfig.relayAvailable).toBe(false); + expect("iceConfig" in body).toBe(false); }); - it("requires the configured creation token", async () => { - const baseUrl = await start(); + it("allows room creation without a session in public mode", async () => { + const baseUrl = await start(testConfig({ accessPassword: undefined })); const response = await fetch(`${baseUrl}/api/rooms`, { method: "POST", headers: { Origin: allowedOrigin }, }); - - expect(response.status).toBe(401); - expect(response.headers.get("www-authenticate")).toBe("Bearer"); - expect(await response.json()).toEqual({ error: "Unauthorized" }); + expect(response.status).toBe(201); }); - it("rejects a request body and a foreign browser origin", async () => { - const baseUrl = await start(); - const bodyResponse = await fetch(`${baseUrl}/api/rooms`, { + it("rejects request bodies and foreign browser origins", async () => { + const baseUrl = await start(testConfig({ accessPassword: undefined })); + const withBody = await fetch(`${baseUrl}/api/rooms`, { method: "POST", - headers: { - Authorization: "Bearer create-secret", - "Content-Type": "application/json", - Origin: allowedOrigin, - }, + headers: { Origin: allowedOrigin }, body: "{}", }); - expect(bodyResponse.status).toBe(400); + expect(withBody.status).toBe(400); - const originResponse = await fetch(`${baseUrl}/api/rooms`, { + const foreign = await fetch(`${baseUrl}/api/rooms`, { method: "POST", - headers: { - Authorization: "Bearer create-secret", - Origin: "https://foreign.example.test", - }, + headers: { Origin: "https://foreign.test" }, }); - expect(originResponse.status).toBe(403); + expect(foreign.status).toBe(403); }); it("returns service unavailable at the global room bound", async () => { - const baseUrl = await start(testConfig({ maxRooms: 1 })); + const baseUrl = await start( + testConfig({ accessPassword: undefined, maxRooms: 1 }), + ); const create = () => fetch(`${baseUrl}/api/rooms`, { method: "POST", - headers: { - Authorization: "Bearer create-secret", - Origin: allowedOrigin, - }, + headers: { Origin: allowedOrigin }, }); expect((await create()).status).toBe(201); - const full = await create(); - expect(full.status).toBe(503); - expect(await full.json()).toEqual({ error: "Room capacity reached" }); + expect((await create()).status).toBe(503); }); }); -describe("server HTTP listener", () => { +describe("server HTTP listener and health", () => { it("uses the configured listen host by default", async () => { runningServer = await createScreenerServer({ config: testConfig(), @@ -124,16 +295,13 @@ describe("server HTTP listener", () => { if (!address || typeof address === "string") { throw new Error("Expected a TCP server address"); } - expect(address.address).toBe("127.0.0.1"); }); -}); -describe("health HTTP endpoint", () => { - it("reports process liveness without caching or origin checks", async () => { + it("reports process liveness without access checks", async () => { const baseUrl = await start(); const response = await fetch(`${baseUrl}/healthz`, { - headers: { Origin: "https://foreign.example.test" }, + headers: { Origin: "https://foreign.test" }, }); expect(response.status).toBe(200); @@ -141,12 +309,10 @@ describe("health HTTP endpoint", () => { expect(await response.json()).toEqual({ status: "ok" }); }); - it("only accepts GET requests", async () => { + it("only accepts GET health checks", async () => { const baseUrl = await start(); const response = await fetch(`${baseUrl}/healthz`, { method: "POST" }); - expect(response.status).toBe(405); expect(response.headers.get("allow")).toBe("GET"); - expect(await response.json()).toEqual({ error: "Method not allowed" }); }); }); diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts index 48c8af5..f9c481b 100644 --- a/tests/server-signal.test.ts +++ b/tests/server-signal.test.ts @@ -92,6 +92,7 @@ interface TestClient { } interface SignalHarness { + baseUrl: string; webSocketUrl: string; roomStore: RoomStore; room: CreatedRoom; @@ -106,6 +107,7 @@ function testConfig(): ServerConfig { allowedOrigins: new Set([allowedOrigin]), roomTtlMs: 14_400_000, maxRooms: 10, + maxViewersPerRoom: 8, stunUrls: [], turnUrls: [], turnCredentialTtlSeconds: 3_600, @@ -116,14 +118,20 @@ async function startHarness( overrides: { authenticationTimeoutMs?: number; viewerDisconnectGraceMs?: number; + maxViewersPerRoom?: number; maxSignalConnections?: number; maxUnauthenticatedSignalConnections?: number; + accessPassword?: string; } = {}, ): Promise { const config = testConfig(); + config.accessPassword = overrides.accessPassword; + const maxViewersPerRoom = overrides.maxViewersPerRoom ?? 8; + config.maxViewersPerRoom = maxViewersPerRoom; const roomStore = new RoomStore({ ttlMs: config.roomTtlMs, maxRooms: config.maxRooms, + maxViewersPerRoom, }); const room = roomStore.createRoom(); runningServer = await createScreenerServer({ @@ -139,11 +147,22 @@ async function startHarness( overrides.maxUnauthenticatedSignalConnections, }); const port = await runningServer.listen(0, "127.0.0.1"); - return { webSocketUrl: `ws://127.0.0.1:${port}/signal`, roomStore, room }; + return { + baseUrl: `http://127.0.0.1:${port}`, + webSocketUrl: `ws://127.0.0.1:${port}/signal`, + roomStore, + room, + }; } -async function openClient(webSocketUrl: string): Promise { - const socket = new WebSocket(webSocketUrl, { origin: allowedOrigin }); +async function openClient( + webSocketUrl: string, + cookie?: string, +): Promise { + const socket = new WebSocket(webSocketUrl, { + origin: allowedOrigin, + headers: cookie ? { Cookie: cookie } : undefined, + }); await new Promise((resolve, reject) => { socket.once("open", resolve); socket.once("error", reject); @@ -151,8 +170,11 @@ async function openClient(webSocketUrl: string): Promise { return { socket, inbox: new MessageInbox(socket) }; } -async function rejectedUpgradeStatus(webSocketUrl: string): Promise { - const socket = new WebSocket(webSocketUrl, { origin: allowedOrigin }); +async function rejectedUpgradeStatus( + webSocketUrl: string, + origin = allowedOrigin, +): Promise { + const socket = new WebSocket(webSocketUrl, { origin }); socket.on("error", () => { // The HTTP status is asserted through unexpected-response below. }); @@ -203,13 +225,22 @@ async function authenticate( clientId: string, ) { client.socket.send( - JSON.stringify({ - type: "authenticate", - roomId: room.roomId, - role, - token: role === "host" ? room.hostToken : room.viewerToken, - clientId, - }), + JSON.stringify( + role === "host" + ? { + type: "authenticate", + roomId: room.roomId, + role, + token: room.hostToken, + clientId, + } + : { + type: "authenticate", + roomId: room.roomId, + role, + clientId, + }, + ), ); return client.inbox.next("authenticated"); } @@ -224,6 +255,28 @@ async function closeClient(client: TestClient): Promise { } describe("WebSocket signaling", () => { + it("requires the global access session when protection is enabled", async () => { + const accessPassword = "protected-instance-password"; + const harness = await startHarness({ accessPassword }); + + expect(await rejectedUpgradeStatus(harness.webSocketUrl)).toBe(401); + + const login = await fetch(`${harness.baseUrl}/api/session`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessPassword}`, + Origin: allowedOrigin, + }, + }); + const cookie = login.headers.get("set-cookie")?.split(";", 1)[0]; + expect(cookie).toBeTruthy(); + + const viewer = await openClient(harness.webSocketUrl, cookie); + await expect( + authenticate(viewer, harness.room, "viewer", "viewer-client-protected"), + ).resolves.toMatchObject({ role: "viewer" }); + }); + it("lets viewers arrive first and replays their snapshot when the host joins", async () => { const harness = await startHarness(); const viewer = await openClient(harness.webSocketUrl); @@ -587,29 +640,41 @@ describe("WebSocket signaling", () => { }); }); - it("rejects a fourth viewer and lets the host close the room", async () => { - const harness = await startHarness(); + it("advertises a non-default capacity and rejects viewer N+1", async () => { + const maxViewersPerRoom = 5; + const harness = await startHarness({ maxViewersPerRoom }); const host = await openClient(harness.webSocketUrl); - await authenticate(host, harness.room, "host", "host-client-stable"); + const hostAuth = await authenticate( + host, + harness.room, + "host", + "host-client-stable", + ); + expect(hostAuth.maxViewers).toBe(maxViewersPerRoom); const viewers: TestClient[] = []; - for (let index = 1; index <= 3; index += 1) { + for (let index = 1; index <= maxViewersPerRoom; index += 1) { const viewer = await openClient(harness.webSocketUrl); - await authenticate(viewer, harness.room, "viewer", `viewer-client-${index}`); + const viewerAuth = await authenticate( + viewer, + harness.room, + "viewer", + `viewer-client-${index}`, + ); + expect(viewerAuth.maxViewers).toBe(maxViewersPerRoom); await host.inbox.next("peer-joined"); viewers.push(viewer); } - const fourth = await openClient(harness.webSocketUrl); - fourth.socket.send( + const overflow = await openClient(harness.webSocketUrl); + overflow.socket.send( JSON.stringify({ type: "authenticate", roomId: harness.room.roomId, role: "viewer", - token: harness.room.viewerToken, - clientId: "viewer-client-4", + clientId: `viewer-client-${maxViewersPerRoom + 1}`, }), ); - expect((await fourth.inbox.next("error")).code).toBe("ROOM_FULL"); + expect((await overflow.inbox.next("error")).code).toBe("ROOM_FULL"); host.socket.send(JSON.stringify({ type: "close-room" })); expect(await host.inbox.next("room-closed")).toMatchObject({ reason: "host-ended" }); @@ -622,6 +687,9 @@ describe("WebSocket signaling", () => { it("requires an allowed Origin, timely authentication, and bounded payloads", async () => { const harness = await startHarness({ authenticationTimeoutMs: 30 }); + expect( + await rejectedUpgradeStatus(harness.webSocketUrl, `${allowedOrigin}/path`), + ).toBe(403); const foreign = new WebSocket(harness.webSocketUrl, { origin: "https://foreign.example.test", });