From de967fbc61126fa6778a8a62aaafdd86e713c354 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:42:22 +0800 Subject: [PATCH] feat(access): scope viewer invitations to rooms (#56) --- .env.example | 10 +- AGENTS.md | 1 + README.md | 24 +- deploy/nginx/share.bonfire.icu.conf.example | 13 + docs/adr/0002-persistent-protected-rooms.md | 16 +- docs/deployment.md | 102 ++++-- docs/project-memory.md | 19 +- docs/research/webrtc-p2p-screen-sharing.md | 2 +- docs/status.md | 19 +- docs/方案设计.md | 2 +- scripts/peer-assisted-benchmark.ts | 1 - src/client/App.tsx | 43 +-- src/client/lib/api.ts | 29 +- src/client/lib/session.ts | 181 ++++++++- src/client/lib/signaling.ts | 37 +- src/client/media/viewer-message-authority.ts | 1 + src/client/pages/HostPage.tsx | 208 +++++++++-- src/client/pages/ViewerPage.tsx | 37 +- src/client/styles.css | 23 ++ src/server/access-session.ts | 10 +- src/server/app.ts | 135 ++++--- src/server/config.ts | 39 +- src/server/room-database.ts | 147 +++++++- src/server/room-store.ts | 209 ++++++++++- src/server/signaling.ts | 143 +++++++- src/shared/protocol.ts | 47 ++- tests/client-state.test.ts | 257 +++++++++++-- tests/protocol.test.ts | 49 +++ tests/room-store.test.ts | 365 ++++++++++++++++++- tests/server-config.test.ts | 50 ++- tests/server-http.test.ts | 141 +++---- tests/server-signal.test.ts | 296 ++++++++++++++- tests/viewer-message-authority.test.ts | 9 + 33 files changed, 2258 insertions(+), 407 deletions(-) diff --git a/.env.example b/.env.example index 3f6b6aa..d2b516a 100644 --- a/.env.example +++ b/.env.example @@ -6,10 +6,10 @@ 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 -# Optional whole-site gate. A non-empty value must contain 1-128 visible ASCII -# characters (0x21-0x7e). When set, every browser signs in before sharing or watching. -ACCESS_PASSWORD= -# Optional SQLite file for persistent protected rooms. Requires ACCESS_PASSWORD. +# Production-required Host admission secret. Local development/tests may leave it +# empty. A non-empty value must contain 16-128 visible ASCII bytes (0x21-0x7e). +HOST_ADMISSION_PASSWORD= +# Optional SQLite file for persistent rooms. Requires HOST_ADMISSION_PASSWORD. # The systemd example creates /var/lib/screener for this production path. ROOM_DATABASE_PATH= # Applies only to temporary rooms when ROOM_DATABASE_PATH is empty. @@ -36,7 +36,7 @@ MAX_SFU_ROOTS_PER_ROOM=2 STUN_URLS= # Minimal production example (use independent untracked secrets): -# ACCESS_PASSWORD=replace-with-a-strong-passphrase +# HOST_ADMISSION_PASSWORD=replace-with-an-independent-32-byte-secret # ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite # STUN_URLS=stun:stun.example.com:3478 # Automatic SFU fallback (requires PEER_ASSISTED_MEDIA=true and does not diff --git a/AGENTS.md b/AGENTS.md index e77ed8d..7bff4cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,7 @@ - Prefer the smallest proven extension point; do not add speculative frameworks, hooks, or abstractions without a current consumer. - Add automated tests in proportion to behavioral risk. Block the current phase on P0 security, privacy, irreversible data, authorization, generation, bounded-resource, and rollback failures, plus P1 failures of the milestone's core user path. Record or cover P2/P3 rare environments, diagnostic detail, and minor UI edges cheaply without turning them into canary blockers unless they can cause P0 harm. Use high-repeat race runs only for a reproduced concurrency failure. For realtime media changes, also document the manual network and browser matrix used. - Batch validation by risk and phase: run narrow checks while iterating, and run expensive full suites, browser matrices, endurance tests, or deployment checks only at an acceptance boundary or when a relevant change invalidates prior evidence. Documentation-only or unrelated changes do not invalidate media-path evidence. +- A dedicated deployment subagent owns each staged production deployment and verification while root/mainline continues; root supplies the exact merged SHA, artifact hash, rollback target, and acceptance list, and the deployer must not expand scope. - Use comments for non-obvious rationale, invariants, protocol constraints, and workarounds; do not narrate obvious code. - Use WebRTC statistics and reproducible measurements for latency, bitrate, candidate type, packet loss, encode time, and quality limitations. Do not claim performance from assumptions alone. - Keep repository scripts and hooks cross-platform, deterministic, fast, and runnable in CI. A hook must call a tracked script rather than hide project logic in machine-local configuration. diff --git a/README.md b/README.md index 8e98f82..97ba1ba 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Screener is a private, low-latency screen-sharing project for one game player and a small group of friends. Its accepted media target is WebRTC P2P-first, with an SFU virtual parent feeding only the necessary roots and optional authenticated TURN for explicitly supported restrictive networks. Roots continue distributing to bounded peer descendants; this is not an always-SFU conferencing design. Viewers should be able to join from a desktop or mobile browser without installing the sender application. -The first measurable Web proof of concept is implemented. A host captures a screen, window, or browser tab and shares a numeric room code; friends can open `/r/{code}` directly or enter the code at `/join`, with no separate viewer token. During a share, the host can change between three ceiling profiles without reopening the source picker: 1080p60 at 8 Mbps, 1080p30 at 5 Mbps, and 720p30 at 3 Mbps. The host can also temporarily pause the picture or switch sources. Rooms are random and temporary by default. A password-protected deployment can optionally use SQLite-backed room numbers starting at `1`, reusable links, and a waiting state after sharing stops. Default routing gives each viewer an independent direct WebRTC connection; the exact-room controller can instead use bounded peer descendants and LiveKit SFU roots. The default room capacity is eight viewers and deployments may configure 1 through 16, but that admission limit is not a performance promise. +The first measurable Web proof of concept is implemented. A host captures a screen, window, or browser tab and shares a numeric room code. Rooms default to a private fragment invitation whose capability authorizes only one room; a Host may explicitly choose public watching by room code. During a share, the host can change between three ceiling profiles without reopening the source picker: 1080p60 at 8 Mbps, 1080p30 at 5 Mbps, and 720p30 at 3 Mbps. The host can also temporarily pause the picture or switch sources. Rooms are random and temporary by default. A Host-protected deployment can optionally use SQLite-backed room numbers starting at `1`, reusable links, and a waiting state after sharing stops. Default routing gives each viewer an independent direct WebRTC connection; the exact-room controller can instead use bounded peer descendants and LiveKit SFU roots. The default room capacity is eight viewers and deployments may configure 1 through 16, but that admission limit is not a performance promise. -This is not yet a production release. The currently deployed old release verified public TURN/UDP and TURN/TCP relay paths, but the repository candidate now uses self-hosted STUN-only ordinary ICE plus a separate LiveKit SFU/UDP domain. Real cross-network Screener media, game audio, a sustained 1:8 room, mobile browser lifecycle behavior, and latency targets still require the documented manual test matrix. +This is not yet a production release. Production runs the bounded SFU failure-stage diagnostic from `9610032`; the repository uses self-hosted STUN-only ordinary ICE plus a separate LiveKit SFU/UDP domain. Real cross-network 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,7 +15,7 @@ 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. +Production requires an independent 16-128 byte visible-ASCII `HOST_ADMISSION_PASSWORD`. It authorizes room creation and Host role only; invited Viewers never need it. Local development and tests may leave it empty. Private rooms use an expiring room-scoped fragment grant, while explicit public-watch rooms accept only the numeric room code. 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 @@ -26,15 +26,15 @@ must remain on `localhost` or HTTPS because screen capture requires a secure context. The local default has no STUN, so cross-network use still requires HTTPS and the production ICE configuration documented separately. -The current production release still requires STUN plus separate TURN/UDP and -TURN/TCP URLs; keep that old release and configuration intact for rollback. The -repository candidate deliberately replaces that contract: ordinary authenticated -ICE contains only self-hosted STUN, LiveKit separately serves normally one or two -SFU/UDP roots, and exhausted paths fail clearly after bounded recovery. Run it on -an isolated instance for exact-room acceptance rather than retaining a same-process -old-release compatibility branch. Optional generation-bound TURN for a selected exceptional -edge is not implemented and requires a separate evidence-backed change. HTTPS/WSS -continues to use TLS/TCP independently. +Before deploying this access migration, take the stopped v1 SQLite backup and +rehearse its restore as documented in [Deployment](./docs/deployment.md). After +deployment, `HOST_ADMISSION_PASSWORD` authorizes only creation and Host role; +private Viewers use an expiring room-scoped fragment grant, while explicit +public-watch accepts a room code. The release has no v1 parser, old cookie, or +dual-schema runtime. Ordinary ICE contains only self-hosted STUN, LiveKit serves +normally one or two SFU/UDP roots, and exhausted paths fail clearly after bounded +recovery. Optional generation-bound TURN for a selected exceptional edge is not +implemented; HTTPS/WSS remains TLS/TCP independently. Run the complete automated validation with: diff --git a/deploy/nginx/share.bonfire.icu.conf.example b/deploy/nginx/share.bonfire.icu.conf.example index 402d745..8b05ba9 100644 --- a/deploy/nginx/share.bonfire.icu.conf.example +++ b/deploy/nginx/share.bonfire.icu.conf.example @@ -3,6 +3,8 @@ map $http_upgrade $screener_connection_upgrade { '' close; } +limit_req_zone $binary_remote_addr zone=screener_host_admission:10m rate=5r/m; + server { listen 80; listen [::]:80; @@ -82,6 +84,17 @@ server { return 404; } + location = /api/host-admission { + limit_req zone=screener_host_admission burst=5 nodelay; + limit_req_status 429; + proxy_pass http://127.0.0.1:8787; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_buffering off; + } + location / { proxy_pass http://127.0.0.1:8787; proxy_http_version 1.1; diff --git a/docs/adr/0002-persistent-protected-rooms.md b/docs/adr/0002-persistent-protected-rooms.md index 97e8815..3acb4f5 100644 --- a/docs/adr/0002-persistent-protected-rooms.md +++ b/docs/adr/0002-persistent-protected-rooms.md @@ -1,6 +1,6 @@ # ADR-0002: Persistent Rooms And Scoped Viewer Access -- Status: Accepted design; runtime migration pending +- Status: Accepted; runtime candidate implemented, deployment pending - Date: 2026-08-19 ## Context @@ -11,7 +11,8 @@ so the deployment owner must control Host admission. Watching should remain a normal-browser, link-first action and should not reveal the deployment's Host password to every invited friend. -The current runtime uses one optional `ACCESS_PASSWORD`, one stateless cookie, +The superseded runtime and current production deployment use one optional +`ACCESS_PASSWORD`, one stateless cookie, and the same WebSocket upgrade gate for both roles. A Viewer then authenticates with only a numeric room code. This is simple but conflates deployment admission with room privacy: sharing the site password grants broader access than a Viewer @@ -190,3 +191,14 @@ SQLite or participates in authorization, routing, or quality decisions. - A production-copy v1 `STRICT` database migrates transactionally to checked, locked-private v2; malformed non-null values fail closed, rollback restores its backup, and v1 tabs terminate without compatibility code. + +## Implementation Status + +The 2026-08-20 repository candidate implements the single-version v2 runtime, +same-row SQLite migration, private/public room policy, fragment consumption, +Host admission, and commit-first generation teardown. Focused automated tests +cover protocol bounds, exact-room grants, persistent-write failure, active +peer/SFU edge retirement, old-generation socket rejection, and v1 migration +rollback. The tracked nginx limiter and backup/restore procedure are present. +Production ingress loading, browser storage/request/log leak inspection, a +production-copy migration rehearsal, and deployment remain acceptance work. diff --git a/docs/deployment.md b/docs/deployment.md index d810900..e4ebc8a 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,6 +1,6 @@ # Minimal Deployment -Last verified against upstream documentation: 2026-08-19. +Last verified against upstream documentation: 2026-08-20. This section documents the repository's UDP-only deployment candidate: one Node.js process provides the built Web client, room API, and WebSocket signaling @@ -113,7 +113,7 @@ LISTEN_HOST=127.0.0.1 PORT=8787 PUBLIC_BASE_URL=https://share.example.com ALLOWED_ORIGINS=https://share.example.com -ACCESS_PASSWORD= +HOST_ADMISSION_PASSWORD= ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite ROOM_TTL_SECONDS=14400 MAX_ROOMS=1000 @@ -147,15 +147,14 @@ than enabling every room. There is no browser control, percentage rollout, or all-room fail-open. `ALLOWED_ORIGINS` must list exact `http` or `https` origins, never `*`. -`ACCESS_PASSWORD` is optional: omit it or leave it empty for a public site. A -non-empty value must contain 1 through 128 visible ASCII characters (`0x21` -through `0x7e`). For an Internet deployment intended to stay private, use an -independent value that is not reused elsewhere. -`ROOM_DATABASE_PATH` is optional but requires `ACCESS_PASSWORD`; startup fails -if a database path is supplied without the whole-site gate. The baseline above -enables persistent protected rooms. Omit `ROOM_DATABASE_PATH` to keep random -temporary rooms, and omit both values for public mode. `ROOM_TTL_SECONDS` applies -only to temporary rooms. +`HOST_ADMISSION_PASSWORD` is required in production and must contain 16 through +128 visible ASCII bytes (`0x21` through `0x7e`). It must not be reused for +LiveKit, TLS, TURN, or another service. It authorizes room creation and Host +role only; it is not a Viewer password. Local development and tests may omit it. +Supplying the removed `ACCESS_PASSWORD` key, even blank, fails startup. +`ROOM_DATABASE_PATH` is optional but requires `HOST_ADMISSION_PASSWORD`. +Omit the database path to keep random temporary rooms; `ROOM_TTL_SECONDS` +applies only to those rooms. `MAX_VIEWERS_PER_ROOM` defaults to 8 and accepts 1 through 16. It is an admission limit, not evidence that the publisher can sustain that many streams. @@ -164,7 +163,7 @@ complete tuple requires `PEER_ASSISTED_MEDIA=true`. An empty tuple keeps the optional SDK and server path dormant. `LIVEKIT_URL` must be a plain `ws:` or `wss:` origin with no `/rtc` suffix; production requires `wss:`. `LIVEKIT_API_SECRET` must contain at least 32 bytes and must not reuse -`ACCESS_PASSWORD`. `MAX_SFU_ROOTS_PER_ROOM` defaults to +`HOST_ADMISSION_PASSWORD`. `MAX_SFU_ROOTS_PER_ROOM` defaults to 2 and accepts only 1 or 2; it is ignored when LiveKit is not configured. These credentials authorize short-lived LiveKit room tokens and do not provide E2EE: the LiveKit operator can access ordinary SFU media. @@ -182,37 +181,48 @@ does not restore the removed TURN wire. After acceptance, retire or replace the temporary exact-room gate in a separate coherent change; never clear the value to trigger an implicit all-room rollout. -When `ACCESS_PASSWORD` is configured, both host and viewer routes first show the -same login gate. Only `POST /api/session` accepts the password in an -`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 -account database, JWT, session map, or logout endpoint; expiry or clearing site -cookies ends access. The optional room database is unrelated to access sessions. +Only `POST /api/host-admission` accepts the Host admission secret in an +`Authorization: Bearer` header from an exact allowed Origin. Success returns a +12-hour stateless HMAC-SHA256 cookie with `HttpOnly`, `SameSite=Strict`, +`Path=/`, bounded `Max-Age`, and, under production HTTPS, `Secure` plus an +`__Host-` name. The cookie contains no account or server-side session ID. There +is no account database, JWT, session map, or logout endpoint. -`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. Viewers open `/r/{code}` or enter only the numeric code -at `/join`; there is no viewer token or URL fragment, while the host token remains -internal to the host page. +nginx limits this exact endpoint per source at `5r/m` with `burst=5 nodelay` and +returns 429 when exhausted. The limiter uses nginx shared memory; do not add the +secret, Authorization header, request body, or a new source-address field to +logs. `POST /api/rooms` accepts only the Host-admission cookie and strict JSON +with an explicit Viewer policy; a Bearer header is not an alternate creation +path. `/signal` still admits a cookie-free browser after Origin and capacity +checks, records the cookie state at upgrade, and requires it only when the first +v2 message requests Host role. Viewer role never uses this deployment secret. + +Private rooms are the default. Their invitation is +`/r/{code}#v={room-scoped-grant}`; the fragment does not enter HTTP or WebSocket +request targets. The page validates it, writes it only to that room's +`sessionStorage`, and immediately replaces the visible URL with `/r/{code}`. +An independent tab without the fragment fails closed. `public-watch` must be an +explicit Host choice and accepts the numeric room code alone. Neither policy +lets a Viewer create a room or authenticate as Host. Room allocation has two deliberately small policies: - With `ROOM_DATABASE_PATH`, SQLite allocates positive decimal IDs starting at - `1`. These protected rooms and links do not expire. An explicit stop or capture + `1`. These rooms do not expire, while each private Viewer invitation expires + after seven days unless rotated earlier. An explicit stop or capture track ending stops the current publication and leaves viewers waiting; it does not delete the room. A brief signaling disconnect does not stop otherwise healthy P2P media. - Without `ROOM_DATABASE_PATH`, rooms use random numeric IDs and expire according - to `ROOM_TTL_SECONDS`. Stopping sharing does not immediately delete them. Public - mode always uses this policy, so anonymous clients cannot simply walk a - sequential namespace. + to `ROOM_TTL_SECONDS`; a private grant cannot outlive its room. Stopping sharing + does not immediately delete it. Public-watch remains available but explicit. -The built-in `node:sqlite` database stores only each room ID (the table rowid) -and SHA-256 host-token digest. It must not contain plaintext tokens, passwords, -access cookies, SDP, ICE candidates, IP addresses, viewer state, TURN -credentials, or media. Protect and back up the file as service state. The tracked +The built-in `node:sqlite` schema v2 stores only each room ID, a SHA-256 Host +token digest, and one nullable SHA-256 Viewer-grant digest in the same `STRICT` +row. `NULL` means public-watch; a checked 32-byte BLOB means private-link. It +must not contain plaintext tokens or grants, passwords, cookies, names, +participants, SDP, ICE candidates, IP addresses, TURN credentials, or media. +Protect and back up the file as service state. The tracked systemd unit creates `/var/lib/screener` with `StateDirectory=screener` and mode `0700`; the production path above is writable despite `ProtectSystem=strict`. The database is designed for one application process, not shared storage across @@ -225,14 +235,27 @@ ownership to the service account and mode `0600`, run SQLite use SQLite's [backup API](https://www.sqlite.org/backup.html) or `VACUUM INTO`; do not copy only the live main file while it may have an active journal. +The access release changes schema v1 to v2 and has no dual-schema runtime. For +an existing deployment, stop the service and take a named, immutable v1 copy +before installing or starting the new binary. Confirm the stopped source reports +`PRAGMA user_version = 1`, retain the backup outside the release directory, and +record its checksum. On first v2 startup, one `BEGIN IMMEDIATE` transaction adds +the checked nullable Viewer digest, writes a different fresh locked-private +digest to every old room, and advances `user_version` to `2`. Old code-only +Viewer links intentionally stop working; each existing Host must use rotate once +to produce a private invitation. + +After startup, verify `PRAGMA integrity_check`, `user_version = 2`, service +health, Host reclaim, rotation, and a new Viewer join before removing the +maintenance boundary. If rollback is required, stop the v2 service, preserve the +v2 database separately for diagnosis, restore the exact v1 backup with service +ownership and mode `0600`, verify integrity and `user_version = 1`, and only then +start the old binary. Never point the old binary at the migrated v2 file. + Enabling persistence does not migrate rooms that existed only in memory. The deployment restart invalidates those temporary links; the first subsequently created persistent room receives ID `1`. -With no `ACCESS_PASSWORD`, the random room code is the sole viewing capability -and does not provide strong privacy, so public mode is suitable only when public -access is acceptable or another trusted access layer exists. - Production startup requires one to eight syntactically valid `stun:` URLs before the server listens. This validates shape only; it does not prove DNS, firewall, NAT mappings, or external STUN reachability. The current application @@ -282,8 +305,9 @@ the first certificate is installed. This hook reloads only the Web ingress. Keep the proxy's access-log retention bounded and access controlled. Requests to `/r/{code}` put the room code in the path, so access logs can contain room codes -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. +as well as network metadata. They must not be treated as public artifacts. For +a private room, the grant remains in the fragment and is not part of that +request target; for public-watch, the current code is intentionally sufficient. For a process-level liveness probe, send `GET /healthz`. A running process returns HTTP 200 with `{"status":"ok"}` and `Cache-Control: no-store`; other diff --git a/docs/project-memory.md b/docs/project-memory.md index bbfb544..38373ef 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -28,25 +28,26 @@ Last updated: 2026-08-20 - ADR-0005 accepts SFU roots as the primary central fallback after direct/peer UDP. The candidate has no TURN consumer or credential wire; any future exceptional-edge grant is a complete separate change. PR #12 is superseded. - Keep the controller exact-room only: room `1` is the STUN/SFU smoke. Preserve sticky progressing P2P, mobile leaves and break-before-make. Recovery spends one attempt per layer (ICE restart, same-parent rebuild, alternate peer, then SFU), never three identical retries; active SFU gets one fresh grant before Peer failback. - Local reparenting is a later candidate: start with unassigned relay admission rescue; do not block current work. -- Flagship media is UDP; HTTPS/WSS stays TLS/TCP. Production `d6c8aa0` uses STUN-only ordinary ICE and separate LiveKit SFU/UDP; old coturn TURN and `769de201f7cc` remain rollback-only. +- Flagship media is UDP; HTTPS/WSS stays TLS/TCP; the old release remains rollback-only. - Treat settings as ceilings and degradation as unclassified. Use correlated Host A+B/Viewer C and one-variable evidence; never force AV1, infer by UA, or create a composite score. - Production reports poor film audio and self-echo when system capture includes voice software. Diagnose audio A/B/C and sync; Web cannot isolate arbitrary processes and `maxBitrate` is not quality-up. A Windows 11 native candidate defaults to game-process-tree audio and never widens silently; Windows 10 remains unresolved/unsupported. See `docs/research/browser-screen-audio-quality.md`. - Candidate UI has local Viewer volume/mute, copyable room codes, and a favicon. Names/roster, endpoint details, and RTP loss remain pending; no wire, media, or routing effects. -- Do not add scene detection, dynamic FPS or forced AV1 without stats/hardware evidence. Codec gates require actual negotiation, encode cost, game FPS, CPU/GPU and sender count. -- After the current media acceptance boundary, migrate access atomically under amended ADR-0002: `HOST_ADMISSION_PASSWORD`, `screener-v2`, default private fragment grants, optional public-watch, rotate/revoke, and one nullable SQLite digest column. Do not mix username/roster runtime into that PR. +- Do not add scene detection, dynamic-FPS control, or forced AV1 without negotiation, encode, game, CPU/GPU, and sender evidence. +- ADR-0002 access passed independent review/full check and awaits PR/CI before native work: `HOST_ADMISSION_PASSWORD`, `screener-v2`, private grants/public-watch, rotate/revoke, and one nullable digest. No username/roster runtime. - Deferred architecture audit: `docs/maintenance.md`. ## Current Implementation - The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn. -- Production `d6c8aa0` at `https://share.bonfire.icu` keeps the current Web UI and enables peer/SFU routing only for persistent room `1`; unlisted rooms remain ordinary P2P. +- Production `9610032fc5f55a946853f3687c6ffd15f89a2105` deployed at 2026-08-20 02:26 +08 to `https://share.bonfire.icu` and enables peer/SFU routing only for persistent room `1`; unlisted rooms remain ordinary P2P. Its bounded SFU failure-stage diagnostic is active, while the old release is retained only for rollback. - Ordinary ICE is process-wide STUN-only. Room `1` may use one LiveKit publication feeding at most two roots while retaining peer descendants; real 1/2-root media and game quality remain unverified. -- Current production/repository runtime still uses optional `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly Strict cookie for both roles, internal Host token, role-bound signaling, Origin/payload checks, and no accounts/JWT/session map. -- Current SQLite still stores only room ID and Host-token digest; room `1` survived deployment. The accepted Viewer-grant digest/schema v2 design is documentation-only and not deployed. -- Production uses nginx, Node.js 24.19.0, coturn 4.17.2 for STUN/rollback, and pinned LiveKit 1.13.5 on UDP 7882 with TCP fallback disabled. Public TCP 7880/7881 is blocked; LiveKit has a 192/256 MiB cgroup boundary and no automatic restart. -- Old TURN parser/signer/refresh/UI/SNI artifacts are absent from `d6c8aa0`; the unchanged coturn relay and old release are operational rollback, not advertised candidate media. -- ADR-0005 is configured with exact `PEER_ASSISTED_ROOM_IDS=1`; unlisted rooms use ordinary P2P. `screener-v1` remains the deployed signaling literal until the separately reviewed access migration. +- Production still uses optional `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly Strict cookie for both roles, internal Host token, role-bound signaling, Origin/payload checks, and no accounts/JWT/session map. Its SQLite database stores only the room ID and Host-token digest; room `1` survived deployment. +- Production uses nginx, Node.js 24.19.0, coturn 4.17.2 for STUN/rollback, and pinned LiveKit 1.13.5 on UDP 7882 with TCP fallback disabled. Screener and LiveKit report `NRestarts=0`; local/public health return 200, the deployed asset is `index-DbGEy9hr.js`, and `/rtc`, `/rtc/validate`, and `/rtc/v1` return 404. Public TCP 7880/7881 is blocked; LiveKit has a 192/256 MiB cgroup boundary and no automatic restart. +- Old TURN parser/signer/refresh/UI/SNI artifacts are absent; coturn and the old release are rollback-only, not candidate media. +- ADR-0005 is configured with exact `PEER_ASSISTED_ROOM_IDS=1`; unlisted rooms use ordinary P2P. `screener-v1` remains the deployed signaling literal until this separately reviewed access migration is deployed. - Production logs show two root participants and two short Host participants with no service restart or retained track. Their timing is consistent with, but does not directly prove, the code's one fresh-grant retry and Peer failback. The current candidate exposes only the Host-local current-revision failure stage (`connect|source|video-publish|sender-config|audio-publish|transport`) and uploads no raw error or endpoint data. Browser relays still re-encode; native sharing remains outside product code. +- The repository access candidate requires `HOST_ADMISSION_PASSWORD` in production for creation/Host role only; private Viewer admission is room-scoped, with no accounts/JWT/session map. SQLite v2 stores one nullable Viewer-grant digest beside the Host digest. Its v1 migration locks every prior room private and requires a stopped v1 backup before deployment. +- Candidate signaling is only `screener-v2`; an incompatible first message receives one refresh outcome before room lookup. It has no old parser or translator. Candidate media remains STUN-only ordinary ICE plus separate LiveKit UDP; it does not change the deployed room-1 smoke until the atomic access release is deployed. - Chrome 151/LiveKit localhost A/B cut failure-to-active/render from 1.481/2.257 seconds to 0.200/0.320; 31 new frames and 25 ms sampling kept host edges at two. It is headless synthetic 720p30 and includes SDK/network prewarm, not public-network evidence. - Native drafts remain experiments: one WebCodecs object is not hardware proof; stock GCC+RTX is no-go. - Chrome 151 synthetic topology/quality-control runs kept fanout 2/1 and all viewers decoding; one relay close recovered in 5.32 seconds. This is control evidence only. diff --git a/docs/research/webrtc-p2p-screen-sharing.md b/docs/research/webrtc-p2p-screen-sharing.md index 5a0cf73..bc67727 100644 --- a/docs/research/webrtc-p2p-screen-sharing.md +++ b/docs/research/webrtc-p2p-screen-sharing.md @@ -228,7 +228,7 @@ Electron 可以固定 Chromium 版本,枚举屏幕/窗口,改善选源、热 ### 最小访问模型 -当前 runtime 的 `ACCESS_PASSWORD`、同一 Host/Viewer cookie gate 和 code-only Viewer 是已部署事实,不再是接受的目标。目标把“谁可以建立/发布房间”和“谁可以看这一间房”分开:production 必配的 `HOST_ADMISSION_PASSWORD` 无状态 cookie 只允许建房及尝试 Host role,房间 Host token 仍独立验证;默认 private-link 使用一个 room-scoped Viewer bearer grant,public-watch 才接受 code-only Viewer。它们不需要账号、JWT、服务端 session Map、逐人 ACL 或人类房间密码。WebSocket upgrade 不知道未来 role,因此只能保留 Origin/容量门并记录 Host cookie 状态;首条 Host 鉴权再同时要求该状态和 Host token,Viewer 只走 room policy。RFC 6455 明确允许服务端用 handshake `Origin` 作接纳判断,但这不是 Viewer 授权本身。 +旧 production 的 `ACCESS_PASSWORD`、同一 Host/Viewer cookie gate 和 code-only Viewer 是已部署事实,不再是接受的目标;仓库 access candidate 已把“谁可以建立/发布房间”和“谁可以看这一间房”分开。production 必配的 `HOST_ADMISSION_PASSWORD` 无状态 cookie 只允许建房及尝试 Host role,房间 Host token 仍独立验证;默认 private-link 使用一个 room-scoped Viewer bearer grant,public-watch 才接受 code-only Viewer。它们不需要账号、JWT、服务端 session Map、逐人 ACL 或人类房间密码。WebSocket upgrade 不知道未来 role,因此只能保留 Origin/容量门并记录 Host cookie 状态;首条 Host 鉴权再同时要求该状态和 Host token,Viewer 只走 room policy。RFC 6455 明确允许服务端用 handshake `Origin` 作接纳判断,但这不是 Viewer 授权本身。 RFC 3986 的规范事实是 fragment 在 URI dereference 前由 user agent 分离;WHATWG WebSockets 进一步规定含 fragment 的 constructor URL 必须抛 `SyntaxError`。因此把 256-bit room grant 放在 `/r/{code}#v=...`,再由页面在首个 WSS application message 发送,可以使它不进入 HTTP 或 WebSocket request-target。RFC 6750 对 OAuth bearer query 的警告并不直接规定本产品,但它提供了适用的安全类比:URI query 高概率被日志记录,不应承载此 grant。W3C Referrer Policy 的算法会从 referrer URL 移除 fragment,production 的 `no-referrer` header 再禁止整个 header;这是传输边界,不是“不会泄漏”的保证。 diff --git a/docs/status.md b/docs/status.md index c996f8f..55e9c12 100644 --- a/docs/status.md +++ b/docs/status.md @@ -4,7 +4,9 @@ Last updated: 2026-08-20 ## Phase -`https://share.bonfire.icu` runs `d6c8aa06dbd` as a shared-IP production smoke. +`https://share.bonfire.icu` runs `9610032fc5f55a946853f3687c6ffd15f89a2105` +since 2026-08-20 02:26 +08 as a shared-IP production smoke. It includes the +bounded SFU failure-stage diagnostic. Ordinary ICE is STUN-only process-wide; persistent room `1` alone enables the automatic peer/SFU-UDP controller with at most two SFU roots. The old `769de201f7cc` release and coturn relay remain an isolated rollback path. @@ -14,8 +16,9 @@ one full gate, CI and rollback preflight; protocol/database changes stay atomic. ## Current Snapshot - Capture precedes room creation; live source/quality changes preserve peers and picture pause keeps audio/connections. Quality defaults to `maintain-resolution` with balanced/fluid options. -- Current runtime still uses optional `ACCESS_PASSWORD` and one stateless 12-hour HMAC HttpOnly Strict cookie for both roles; Host auth stays internal and signaling role-bound. -- Current SQLite still stores only room ID and Host-token digest. Amended ADR-0002 accepts a later atomic `HOST_ADMISSION_PASSWORD`/`screener-v2` migration with default room-scoped private Viewer grants, explicit public-watch, rotate/revoke, and one nullable digest column; no runtime is implemented. +- Production still uses optional `ACCESS_PASSWORD` and one stateless 12-hour HMAC HttpOnly Strict cookie for both roles; Host auth stays internal and signaling role-bound. Its SQLite database stores only the room ID and Host-token digest. +- This repository candidate atomically replaces that access contract with production-required `HOST_ADMISSION_PASSWORD` for creation/Host role, `screener-v2`, default private fragment grants, explicit public-watch, and generation-bound rotate/revoke. Independent review and the 357-test full check pass; it is ready for PR/CI but remains unmerged and undeployed. +- Candidate SQLite v2 keeps Host and nullable Viewer-grant digests in one checked `STRICT` row; transactional v1 migration locks each old room private without minting a raw grant. Deployment requires a stopped v1 backup and restore rehearsal. - PR #44's exact-room STUN-only/SFU-UDP router is enabled only for production room `1`: host/SFU roots <=2, browser relay <=1, bounded failure. HTTPS/WSS stays TLS/TCP. - Fallback prewarm is token-free and limited to room `1`; mobile/iPad viewers are leaves and healthy edges stay sticky. - Room `1` can publish exactly `HIGH+LOW` with Dynacast/backup codec off and subscriber `HIGH` ceilings; it has no retained real media frame. @@ -23,7 +26,7 @@ one full gate, CI and rollback preflight; protocol/database changes stay atomic. ## Verified Evidence -- `d6c8aa0` passed typecheck, 330 tests and both builds, then an atomic production switch with SQLite integrity/room-1 preservation. Node, nginx, coturn and pinned LiveKit 1.13.5 are active with zero restarts; health is 200, retired `/rtc*` is 404, and public TCP 7880/7881 is blocked. Rollback is ready. +- `9610032` deployed without changing environment, SQLite, nginx, nftables, LiveKit, or coturn. Screener and LiveKit have `NRestarts=0`; local/public health are 200, `index-DbGEy9hr.js` is active, `/rtc`, `/rtc/validate`, and `/rtc/v1` are 404, and public TCP 7880/7881 is blocked. The preceding release remains rollback-only. - On the 960 MiB host, idle LiveKit peaked near 16 MiB with no cgroup high/max/OOM event under `MemoryHigh=192M`, `MemoryMax=256M`, swap disabled and restart disabled. This is containment/idle evidence, not media capacity. - Chrome 151 synthetic `1/3/5/8` and 720p30 quality-change runs kept 2/1 fanout and decoding; slowest first frame was 1.05 seconds and one relay close recovered in 5.32 seconds. This is control evidence only. - A mobile-network room-1 run produced two root participants for about 4.6 seconds and two short Host participants (about 0.46/0.27 seconds), all client-requested leaves, with zero service restarts and no retained track. This is consistent with the code's one fresh-grant retry and Peer failback, but logs cannot prove those transitions. The next candidate shows only the local failure stage. @@ -31,6 +34,7 @@ one full gate, CI and rollback preflight; protocol/database changes stay atomic. - SVC is `no-go-web-svc-cross-path-hardware-contract`: no direct/peer selection, cross-PC shared encode, or portable hardware proof; pinned screen share is `L1T3`. No browser run was warranted. - HTTPS/WSS, access and room/WebSocket auth, renewal, public STUN, and authenticated TURN/UDP/TCP relay-only paths pass; TURN/TLS is off. - Draft #16/#18/#22/#23/#25/#28 passed isolated experiments only. ADR-0006 reached host setup/one encoder output, then timed out before viewer 1 decoded/rendered. +- Access focused tests pass protocol/config/HTTP/storage/RoomStore/SQLite/signaling, including commit-first teardown, persistence-failure continuity, relay/SFU retirement, grant bounds, and v1 migration rollback. ## Unverified Boundaries @@ -41,8 +45,9 @@ one full gate, CI and rollback preflight; protocol/database changes stay atomic. - The former release reportedly sustained bandwidth-limited blur on a capable LAN; Host refresh recovered while Viewer refresh did not. The new deployment has not yet reproduced or cleared it. - Browser relays re-encode. ADR-0006 is no-go-unclassified: downstream checkpoints and two-edge/FIFO/TURN gates are absent. Spikes prove one WebCodecs object, not hardware; GCC+RTX is no-go and no-RTX weakens stats. - Browser fanout is host two/viewer one; any accepted endpoint relay stays capped at two downstream edges. -- Room-1 BWE is unverified: test zero-child leaves, then root impact. Use Linux `tc` or public canary, never CDP; Web P2P/SVC shortcuts remain no-go. -- Scoped Viewer access runtime is under final review and is not deployed. Human passwords/accounts stay out. +- PR #49/#50 BWE is unverified/default-off: test zero-child leaves, then root impact. No local per-leaf UDP shaper; resume with Linux `tc` or public canary, never CDP. Web P2P/SVC shortcuts remain no-go. +- The corrected standby smoke is localhost/headless/video-only; public DNS/TLS reuse, transport, audio, shaping, load, mobile, endurance, and sub-25 ms overlap remain open. +- Scoped Viewer access is implemented but unmerged/undeployed. Remaining deployment gates are nginx load/rate limit, browser storage/request/log leak inspection, and production-copy v1 backup/migration/restore rehearsal. Accounts stay out. ## Next Milestone @@ -53,7 +58,7 @@ The recovery target uses one ICE restart, one same-parent rebuild, one alternate SFU; it never runs three identical retries or abandons progressing P2P early. Next gate exactly-two/Dynacast-off BWE on a zero-child SFU leaf, then root -evacuation. In parallel, finish the atomic ADR-0002 access PR and native staged +evacuation. In parallel, merge this atomic ADR-0002 access PR after CI and then begin the native staged gate; username/roster stays separate. Audio A/B/C may run without displacing P0. TURN remains a future selected-edge change, not this canary. diff --git a/docs/方案设计.md b/docs/方案设计.md index 01401b4..f0576ca 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -315,7 +315,7 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) 自动测试: -- HostAdmissionSession 的 production 必配、16 至 128 visible-ASCII bytes/独立 secret、local-dev 显式关闭、12 小时签名 cookie、过期、篡改、cookie 属性、旧 `ACCESS_PASSWORD` fail-fast 和无服务端 session 状态; +- HostAdmission 的 production 必配、16 至 128 visible-ASCII bytes/独立 secret、local-dev 显式关闭、12 小时签名 cookie、过期、篡改、cookie 属性、旧 `ACCESS_PASSWORD` fail-fast 和无服务端 session 状态; - RoomStore 的随机临时房间、TTL、Host token/Viewer grant 摘要验证、private/public policy、grant 到期/跨房拒绝、rotate/revoke 强撤销、停止分享等待、Viewer 上限和超额拒绝; - SQLite 房间库从 `1` 自增、重启恢复、v1 -> v2 单事务 locked-private 迁移、nullable 32-byte CHECK、畸形值 fail-closed、rotate/revoke 写失败原子性和数据最小化;数据库路径无 Host admission 时启动失败; - STUN URL 严格校验、生产 STUN 必需、ordinary ICE 不含 username/credential、旧 `refresh-ice`/`ice-config` 被协议拒绝,以及 LiveKit `sfu-config` 独立边界; diff --git a/scripts/peer-assisted-benchmark.ts b/scripts/peer-assisted-benchmark.ts index 40cde80..44ea5b9 100644 --- a/scripts/peer-assisted-benchmark.ts +++ b/scripts/peer-assisted-benchmark.ts @@ -2169,7 +2169,6 @@ export async function main(): Promise { LISTEN_HOST: "127.0.0.1", PUBLIC_BASE_URL: baseUrl, ALLOWED_ORIGINS: baseUrl, - ACCESS_PASSWORD: "", ROOM_DATABASE_PATH: "", PEER_ASSISTED_MEDIA: "true", PEER_ASSISTED_ROOM_IDS: "1", diff --git a/src/client/App.tsx b/src/client/App.tsx index 80974d8..d32cd54 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -2,9 +2,9 @@ import { KeyRound, LoaderCircle } from "lucide-react"; import { useEffect, useState, type FormEvent } from "react"; import { ApiError, - authenticate, - getSession, - type SessionStatus, + authenticateHost, + getHostAdmission, + type HostAdmissionStatus, } from "./lib/api"; import { readViewerRoute } from "./lib/session"; import { HostPage } from "./pages/HostPage"; @@ -21,7 +21,7 @@ type AccessState = | { kind: "required"; error: string | null } | { kind: "unavailable"; message: string }; -function stateFromStatus(status: SessionStatus): AccessState { +function stateFromStatus(status: HostAdmissionStatus): AccessState { return !status.required || status.authenticated ? { kind: "ready" } : { kind: "required", error: null }; @@ -34,13 +34,23 @@ function readableError(error: unknown): string { } export function App() { + if (viewerRoute) { + return ; + } + if (isJoinRoute || !isHostRoute) { + return ; + } + return ; +} + +function HostAdmissionGate() { const [access, setAccess] = useState({ kind: "checking" }); const [password, setPassword] = useState(""); const [submitting, setSubmitting] = useState(false); useEffect(() => { let active = true; - void getSession().then( + void getHostAdmission().then( (status) => active && setAccess(stateFromStatus(status)), (error: unknown) => active && @@ -54,7 +64,7 @@ export function App() { async function retry(): Promise { setAccess({ kind: "checking" }); try { - setAccess(stateFromStatus(await getSession())); + setAccess(stateFromStatus(await getHostAdmission())); } catch (error) { setAccess({ kind: "unavailable", message: readableError(error) }); } @@ -71,7 +81,7 @@ export function App() { setSubmitting(true); try { - setAccess(stateFromStatus(await authenticate(submittedPassword))); + setAccess(stateFromStatus(await authenticateHost(submittedPassword))); } catch (error) { setAccess({ kind: "required", @@ -86,19 +96,6 @@ export function App() { } if (access.kind === "ready") { - if (viewerRoute) { - return ( - - setAccess({ kind: "required", error: "验证已失效,请重新登录" }) - } - /> - ); - } - if (isJoinRoute || !isHostRoute) { - return ; - } return ( @@ -133,11 +130,11 @@ export function App() { ) : (
void submit(event)}>
-

访问验证

-

请输入此站点的访问密码

+

分享权限

+

请输入分享准入密码