From 1832ca8a84fd33ba80d69d9a767d56f8f3303e45 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:34:00 +0800 Subject: [PATCH] fix(lan): support mobile viewers over local HTTP (#5) --- .env.example | 7 ++--- README.md | 16 ++++++++---- docs/deployment.md | 9 ++++--- docs/project-memory.md | 4 +-- docs/research/webrtc-p2p-screen-sharing.md | 1 + docs/status.md | 5 ++-- src/client/lib/session.ts | 6 ++++- src/server/config.ts | 2 +- tests/client-state.test.ts | 30 +++++++++++++++++++++- tests/server-config.test.ts | 8 +++--- 10 files changed, 65 insertions(+), 23 deletions(-) diff --git a/.env.example b/.env.example index ea895cf..f1c3e67 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,10 @@ # Application -# Keep the default loopback binding behind a local reverse proxy. Set -# LISTEN_HOST=0.0.0.0 explicitly only for a container or trusted LAN listener. -LISTEN_HOST=127.0.0.1 +# Use 127.0.0.1 instead when only a same-host reverse proxy should connect. +LISTEN_HOST=0.0.0.0 PORT=8787 +# For LAN viewers, replace localhost with this computer's LAN address. 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= ROOM_TTL_SECONDS=14400 diff --git a/README.md b/README.md index ea3601c..1138bbd 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,18 @@ npm ci npm run dev ``` -Open `http://localhost:8787`. The default development configuration uses a localhost origin and has no STUN or TURN server, so it is suitable only for same-machine checks. Multi-device or cross-network use requires HTTPS and the production-shaped ICE configuration documented separately. +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 +`localhost` capture, set `PUBLIC_BASE_URL` to that LAN URL and include both +origins in `ALLOWED_ORIGINS`. LAN viewers can join over HTTP; the sharing page +must remain on `localhost` or HTTPS because screen capture requires a secure +context. The default has no STUN or TURN, so cross-network use still requires +HTTPS and the production ICE configuration documented separately. -The server binds `127.0.0.1` by default. Set `LISTEN_HOST=0.0.0.0` explicitly -only when a container or trusted LAN deployment requires it. Production startup -also requires separate TURN/UDP, TURN/TCP, and TURN/TLS-on-TCP-443 URLs; this is -a configuration preflight, not evidence that the external relay path works. +Production startup requires separate TURN/UDP, TURN/TCP, and +TURN/TLS-on-TCP-443 URLs; this is a configuration preflight, not evidence that +the external relay path works. Run the complete automated validation with: diff --git a/docs/deployment.md b/docs/deployment.md index 4fedcc3..2665024 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -42,10 +42,11 @@ Run `npm start` under a service supervisor that injects the environment, restart on failure, and applies bounded logs. For a simple untracked environment file, the equivalent direct launch is `node --env-file=.env.production dist/server/server/index.js`. Do not expose port -8787 to the Internet. The application binds `127.0.0.1` by default. Set -`LISTEN_HOST=0.0.0.0` explicitly only when a container runtime or trusted LAN -path requires an externally reachable listener, then enforce the intended -network boundary with container publishing rules or a host firewall. +8787 to the Internet. The application default is `0.0.0.0` for LAN development +and container compatibility, while the bare-metal production baseline below +explicitly sets `LISTEN_HOST=127.0.0.1` because only the same-host reverse proxy +should connect. A container can instead use `0.0.0.0` and enforce the intended +boundary with port publishing rules or a host firewall. ## Production application environment diff --git a/docs/project-memory.md b/docs/project-memory.md index e748271..10f7b93 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -31,8 +31,8 @@ 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 binds to loopback by default, provides a process-only `/healthz` liveness endpoint, and requires the full TURN/UDP, TURN/TCP, and TURN/TLS-on-TCP-443 URL mix before production startup; these 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, explicit ICE restart or peer rebuild, host generation isolation, short-lived TURN credentials, three manual quality profiles, and local WebRTC statistics. +- 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 the full TURN/UDP, TURN/TCP, and TURN/TLS-on-TCP-443 URL mix before production startup; these 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. - 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, and video/audio source changes on an existing connection. Real screen/game audio, public TURN/NAT behavior, mobile lifecycle handling, and latency or quality targets remain unverified. - The next milestone is a staging coturn deployment and the manual matrix in `docs/status.md`, not additional product surface or a native sender. diff --git a/docs/research/webrtc-p2p-screen-sharing.md b/docs/research/webrtc-p2p-screen-sharing.md index 9f3f1c8..9f81019 100644 --- a/docs/research/webrtc-p2p-screen-sharing.md +++ b/docs/research/webrtc-p2p-screen-sharing.md @@ -187,6 +187,7 @@ Electron 可以固定 Chromium 版本,枚举屏幕/窗口,改善选源、热 观看端不需要屏幕捕获权限,做成响应式 Web 页面比绑定桌面客户端更符合本项目的便利性目标。首版至少要实测当前 Android Chrome 和 iOS Safari,并处理: +- Web Crypto Level 2 将 `crypto.randomUUID()` 限定在安全上下文,但没有这样限制 `crypto.getRandomValues()`。因此,通过 `http://` 打开的局域网观看端不能在启动阶段依赖 `randomUUID()`,可改用 `getRandomValues()` 编码 128-bit 不透明标识。该例外只支持本地观看,并不放宽分享端或生产部署的 HTTPS 要求。([W3C Web Cryptography Level 2](https://www.w3.org/TR/WebCryptoAPI/),访问于 2026-08-18) - 浏览器自动播放策略:先显示明确的“进入并播放”操作,再启动含声音的媒体。 - `playsinline`、横竖屏切换、全屏和安全区域,避免视频被强制弹出或裁切。 - 页面进入后台、锁屏后恢复,以及 Wi-Fi/蜂窝网络切换导致的 ICE restart。 diff --git a/docs/status.md b/docs/status.md index 9e89ed6..1872ab7 100644 --- a/docs/status.md +++ b/docs/status.md @@ -18,7 +18,7 @@ The first measurable WebRTC Web proof of concept is implemented. Automated check ## Verified In This Revision -- `npm run check` passes type checking, 54 Vitest tests, the client production build, and the server production build. +- `npm run check` passes type checking, 55 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. @@ -26,7 +26,8 @@ The first measurable WebRTC Web proof of concept is implemented. Automated check - Desktop and 390 px responsive layouts were smoke-checked without horizontal overflow. - Production configuration fails closed when HTTPS, TURN, or sufficiently strong secrets are missing. - Production startup rejects malformed ICE URLs and requires explicit TURN/UDP, TURN/TCP, and TURN/TLS-on-TCP-443 entries. This checks configuration only; external relay reachability remains unverified. -- The application binds to loopback by default and exposes a process-only `GET /healthz` liveness response; containers and trusted LAN deployments must opt in to `LISTEN_HOST=0.0.0.0`. +- 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. +- Viewer session identities use `crypto.getRandomValues()` rather than the secure-context-only `crypto.randomUUID()`, so a phone can initialize the viewer over trusted LAN HTTP while the host keeps screen capture on `localhost`. - coturn is not installed on this workstation; its configuration has only been checked against upstream documentation and local application tests. ## Next Milestone diff --git a/src/client/lib/session.ts b/src/client/lib/session.ts index d656b82..e69f393 100644 --- a/src/client/lib/session.ts +++ b/src/client/lib/session.ts @@ -63,7 +63,11 @@ export function getStableClientId(role: "host" | "viewer", roomId: string): stri return existing; } - const clientId = crypto.randomUUID(); + // randomUUID is unavailable to LAN viewers on HTTP; getRandomValues is not. + const randomBytes = globalThis.crypto.getRandomValues(new Uint8Array(16)); + const clientId = Array.from(randomBytes, (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); writeSessionValue(storageKey, clientId); return clientId; } diff --git a/src/server/config.ts b/src/server/config.ts index ca4140b..b7f5a55 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -216,7 +216,7 @@ export function loadConfig( if (port > 65_535) { throw new Error("PORT must be at most 65535"); } - const listenHost = environment.LISTEN_HOST?.trim() || "127.0.0.1"; + const listenHost = environment.LISTEN_HOST?.trim() || "0.0.0.0"; const publicBaseUrl = new URL( environment.PUBLIC_BASE_URL ?? `http://localhost:${port}`, diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts index 043784d..203bc3b 100644 --- a/tests/client-state.test.ts +++ b/tests/client-state.test.ts @@ -1,8 +1,36 @@ -import { describe, expect, it } from "vitest"; +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 { createStatsAccumulator, collectConnectionMetrics } from "../src/client/webrtc/stats.ts"; +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("client session identity", () => { + it("works without secure-context-only crypto.randomUUID", () => { + const values = new Map(); + vi.stubGlobal("window", { + sessionStorage: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }, + }); + vi.stubGlobal("crypto", { + getRandomValues: (bytes: Uint8Array) => { + bytes.fill(0xab); + return bytes; + }, + }); + + const first = getStableClientId("viewer", "room-id-1234"); + + expect(first).toBe("ab".repeat(16)); + expect(getStableClientId("viewer", "room-id-1234")).toBe(first); + }); +}); + describe("client signaling recovery policy", () => { it("does not reconnect a session that another tab replaced", () => { expect(shouldReconnectSignaling(4001)).toBe(false); diff --git a/tests/server-config.test.ts b/tests/server-config.test.ts index 4f7457b..3571f2d 100644 --- a/tests/server-config.test.ts +++ b/tests/server-config.test.ts @@ -9,16 +9,16 @@ describe("server configuration", () => { it("allows development without TURN and defaults the origin", () => { const config = loadConfig({ NODE_ENV: "development", PORT: "9123" }); - expect(config.listenHost).toBe("127.0.0.1"); + expect(config.listenHost).toBe("0.0.0.0"); expect(config.publicBaseUrl.href).toBe("http://localhost:9123/"); expect(config.allowedOrigins).toEqual(new Set(["http://localhost:9123"])); expect(config.turnUrls).toEqual([]); }); - it("allows an explicit listen host", () => { - const config = loadConfig({ LISTEN_HOST: " 0.0.0.0 " }); + it("allows an explicit loopback listen host", () => { + const config = loadConfig({ LISTEN_HOST: " 127.0.0.1 " }); - expect(config.listenHost).toBe("0.0.0.0"); + expect(config.listenHost).toBe("127.0.0.1"); }); it("requires room creation authorization and TURN in production", () => {