fix(access): simplify admission and conceal anonymous routes (#60)

This commit is contained in:
AAA authored and GitHub committed 2026-08-20 10:53:50 +08:00
1 parent 37187ea0cd
commit 12c6d25732
11 files changed
+81 -27

No files matched your search

+2 -2
View File
@@ -7,7 +7,7 @@ PUBLIC_BASE_URL=http://localhost:8787
# Include both the host browser origin and PUBLIC_BASE_URL when they differ.
ALLOWED_ORIGINS=http://localhost:8787
# 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).
# empty. A non-empty value must contain 8-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.
@@ -36,7 +36,7 @@ MAX_SFU_ROOTS_PER_ROOM=2
STUN_URLS=
# Minimal production example (use independent untracked secrets):
# HOST_ADMISSION_PASSWORD=replace-with-an-independent-32-byte-secret
# HOST_ADMISSION_PASSWORD=replace-with-an-independent-access-key
# 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
+1 -1
View File
@@ -15,7 +15,7 @@ npm ci
npm run dev
```
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.
Production requires an independent 8-128 byte visible-ASCII `HOST_ADMISSION_PASSWORD`. It is a simple deployment access key for room creation and Host role, not an account password; 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://<computer-lan-ip>:8787`. To generate an
+2 -2
View File
@@ -30,7 +30,7 @@ Separate Host admission from Viewer authorization in one atomic release:
- Replace `ACCESS_PASSWORD` with `HOST_ADMISSION_PASSWORD`. Production startup
requires it; local development and tests may explicitly disable it. It accepts
16 through 128 visible ASCII bytes, must be independent from other deployment
8 through 128 visible ASCII bytes, must be independent from other deployment
secrets, and issues a 12-hour stateless
HMAC `HttpOnly`, `SameSite=Strict`, `Path=/` cookie (`Secure` and `__Host-` in
HTTPS production). It authorizes only room creation and an attempted Host
@@ -181,7 +181,7 @@ SQLite or participates in authorization, routing, or quality decisions.
## Acceptance Gates
- The auth matrix proves production Host admission, Viewer-without-Host-cookie,
16-byte minimum/independent secret, ingress rate limit, exact-room/role grant
8-byte minimum/independent secret, ingress rate limit, exact-room/role grant
scope, non-enumerating failures, and public-watch caps.
- Leak tests cover HTTP/WS targets, Referrer, browser storage, SQLite, application
and proxy logs, and errors; fragment consumption immediately clears the URL.
+2 -2
View File
@@ -128,7 +128,7 @@ LISTEN_HOST=127.0.0.1
PORT=8787
PUBLIC_BASE_URL=https://share.example.com
ALLOWED_ORIGINS=https://share.example.com
HOST_ADMISSION_PASSWORD=<INDEPENDENT_16_TO_128_BYTE_SECRET>
HOST_ADMISSION_PASSWORD=<INDEPENDENT_8_TO_128_BYTE_ACCESS_KEY>
ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite
ROOM_TTL_SECONDS=14400
MAX_ROOMS=1000
@@ -162,7 +162,7 @@ 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 `*`.
`HOST_ADMISSION_PASSWORD` is required in production and must contain 16 through
`HOST_ADMISSION_PASSWORD` is required in production and must contain 8 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.
+3 -3
View File
@@ -66,7 +66,7 @@ tests/ RoomStore、ICE 配置、协议和 WebSocket 集成测试
## 房间与访问控制
`HOST_ADMISSION_PASSWORD` 是分享准入,不是 Viewer 密码。production 启动时必须配置;本地开发/测试可显式关闭。非空值必须包含 16 至 128 个可见 ASCII bytes(`0x21` 至 `0x7e`),并作为不复用 TURN、LiveKit、TLS 或其他凭据的独立 secret。`POST /api/host-admission` 只在严格允许的 Origin 下接收 `Authorization: Bearer`,成功后签发 12 小时的无状态 Host-admission cookie:值只包含版本、到期时间和 HMAC-SHA256 签名,不创建账号、JWT 或服务端 session Map。cookie 使用 `HttpOnly`、`SameSite=Strict`、`Path=/`、有限 `Max-Age`;HTTPS 生产环境使用 `Secure` 和 `__Host-` 前缀。`POST /api/rooms` 只认该 cookie,不接受同一 Bearer 旁路。原子 deployment change 在 nginx 为该 admission endpoint 配置 per-source `limit_req`,初始边界为 `5r/m`、`burst=5 nodelay`;超限返回通用 429,允许到达 Node 的错误 secret 使用常量时间比较和统一 401。limiter 只使用 nginx shared memory,不新增 IP 持久化/日志字段;Node 不建立 IP key/LRU/session table,并保留现有全局/未鉴权连接上限。删除旧 `ACCESS_PASSWORD`、`/api/session` 与旧 cookie;旧环境变量出现时 fail-fast。
`HOST_ADMISSION_PASSWORD` 是简单的部署访问口令,不是 Viewer 密码或账号凭据。production 启动时必须配置;本地开发/测试可显式关闭。非空值必须包含 8 至 128 个可见 ASCII bytes(`0x21` 至 `0x7e`),并作为不复用 TURN、LiveKit、TLS 或其他凭据的独立 secret。`POST /api/host-admission` 只在严格允许的 Origin 下接收 `Authorization: Bearer`,成功后签发 12 小时的无状态 Host-admission cookie:值只包含版本、到期时间和 HMAC-SHA256 签名,不创建账号、JWT 或服务端 session Map。cookie 使用 `HttpOnly`、`SameSite=Strict`、`Path=/`、有限 `Max-Age`;HTTPS 生产环境使用 `Secure` 和 `__Host-` 前缀。`POST /api/rooms` 只认该 cookie,不接受同一 Bearer 旁路。原子 deployment change 在 nginx 为该 admission endpoint 配置 per-source `limit_req`,初始边界为 `5r/m`、`burst=5 nodelay`;超限返回通用 429,允许到达 Node 的错误 secret 使用常量时间比较和统一 401。limiter 只使用 nginx shared memory,不新增 IP 持久化/日志字段;Node 不建立 IP key/LRU/session table,并保留现有全局/未鉴权连接上限。删除旧 `ACCESS_PASSWORD`、`/api/session` 与旧 cookie;旧环境变量出现时 fail-fast。
WebSocket upgrade 无法预知后续 role,因此不能再用 Host cookie 拒绝全部 Viewer。它仍在握手前严格校验 `/signal` 无 query、Origin allowlist、总连接数和未鉴权连接数,并把当次 request 的 Host cookie 校验结果只保存到 socket state。五秒内的第一条 `authenticate` 才完成授权:Host 必须同时拥有 upgrade 时有效的 Host-admission 状态和当前房间 `hostToken`;Viewer 只按目标房间 policy 校验。Host cookie 不能替代私密 Viewer grant,Viewer grant 也不能用于 HTTP 建房、Host role、其他房间或其他操作。Host cookie 过期不踢掉已鉴权 socket,但其下一次 reconnect 必须重新通过 Host admission。
@@ -287,7 +287,7 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md)
首屏就是工具,不做营销页。视觉采用中性深灰工作台,绿色表示 direct、琥珀色表示 relay/警告、红色表示失败,避免单一蓝紫色主题;浏览器 favicon 复用页头 `MonitorUp` 的深绿底、绿色显示器和上行箭头语言。
- Host admission:只在用户进入分享/建房流程且 `HOST_ADMISSION_PASSWORD` cookie 无效时显示密码输入;`/join` 与 `/r/{code}` 不显示或泄露该密码。
- 访问入口:未鉴权根路径和 `/join` 只显示中性的“访问验证/访问口令”,未知路径只显示“无法访问”,HTML metadata 不说明站点用途。有效 `/r/{code}#v={grant}` 或 public-watch `/r/{code}` 先完成 Viewer 鉴权,成功后才渲染房间 UI;无效、过期或满员只显示通用拒绝。Viewer 页面不显示或泄露部署访问口令。这是降低偶然访客信息量的展示边界,不把浏览器可下载的 bundle 当成秘密。
- 分享者根页面:顶部显示“{分享者显示名} 的房间”和房间状态,并在标题旁显示可单独复制的紧凑房间号;主区域保持稳定的 16:9 舞台。未发布、已停止或可恢复错误状态时,舞台内以同等尺寸和权重显示“开始分享”和“加入房间”,中间只用“或”与分隔线表达发送/接收;前者直接开始现有分享流程,后者直接进入 `/join`,不增加首页、向导或拓扑选择。直播时舞台显示本地预览,底部保留停止、暂停、质量档位和复制完整邀请链接。
- `/join`:只输入正整数房间码并进入 `/r/{code}`,仅 public-watch 会通过;private-link 缺失 grant 时统一提示使用完整邀请链接。
- `/r/{code}` 观看者:主区域最大化远端视频;private-link 首次解析 fragment grant 后立即清理地址栏,public-watch 只需 code;标题旁显示可复制的紧凑房间号,顶部保留连接状态,并提供本地音量、静音、全屏和重试控制。
@@ -315,7 +315,7 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md)
自动测试:
- HostAdmission 的 production 必配、16 至 128 visible-ASCII bytes/独立 secret、local-dev 显式关闭、12 小时签名 cookie、过期、篡改、cookie 属性、旧 `ACCESS_PASSWORD` fail-fast 和无服务端 session 状态;
- HostAdmission 的 production 必配、8 至 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` 独立边界;
+2 -2
View File
@@ -29,7 +29,7 @@
- [ ] 断线恢复:临时网络变化时按不同层级做一次性恢复,而不是把同一动作机械重复三次。`failed` 边先做一次 ICE restart,再重建一次同 parent 的 `RTCPeerConnection`,随后尝试一个不同的合格 peer parent;只有 peer 路径耗尽才进入 SFU。`connecting`/`checking` 先显示不改变拓扑的慢连接状态,硬超时必须由真实移动网络证据确定,不能为了更快使用服务器而过早放弃最终可成功的 P2P。任一步恢复即停止后续动作;所有异步结果受 session、route revision、assignment/connection generation 约束。active SFU 只允许一次 fresh-token 重试,再失败即有界回到 peer baseline 或明确失败,并向 Host 显示不含 URL、地址、token 或原始错误的失败阶段。
- [ ] 可观测性:按目标 RTP transport 与不透明连接/ICE 代次关联 selected path、`iceGatheringState`/ICE/connection state、事件推导的建立时长、generation-baselined pair-change delta,以及 RTT、RTP 丢包率、抖动、码率、帧率、编码耗时和受限原因。候选与错误的 wire、日志和导出只使用[低服务器成本媒体路由](./research/low-server-media-routes.md)的脱敏字段和 code bucket,禁止 raw SDP、candidate、地址、URL、`errorText` 或网络标识。只有创建该 `RTCPeerConnection` 的本机可在折叠详情中显示浏览器对 selected local/remote candidate 实际暴露的地址与端口;该值不上传、不持久化、不用于地理/NAT 推断,缺失或被隐私保护时显示未知。
- [ ] 渐进式诊断:分享者和观看者默认只看到房间、画面、核心状态和常用控制;候选路径、RTT、丢包、编解码器、编码耗时等技术详情由一个明确的“显示连接详情”开关展开。隐藏诊断不能隐藏失败原因、音频缺失、持续画质受限或需要用户操作的告警。
- [ ] Host admission:生产部署必须配置独立的 `HOST_ADMISSION_PASSWORD` 严格控制建房和 Host 信令角色;只有本地开发/测试可显式关闭。非空值必须为 16 至 128 个可见 ASCII bytes,不能复用 TURN、LiveKit、TLS 或其他部署 secret。验证成功签发 12 小时、无服务端会话状态的 HttpOnly、`SameSite=Strict` Host-admission cookie。建房 HTTP 只认该 cookie且不提供 Bearer 旁路;Host admission endpoint 由部署层/nginx 做 per-source `limit_req`,失败只返回统一结果,不在 Node 新增 IP 身份库/LRU/session 表或持久化来源地址。WebSocket upgrade 继续校验 Origin 与全局/未鉴权容量并记录 cookie 状态,首条 Host `authenticate` 必须同时通过该状态与房间 `hostToken`。Viewer 不经过此 gate,Host cookie 也不能绕过私密房间的 Viewer grant。不引入账号、JWT、服务端 session Map 或通用 logout。
- [ ] Host admission:生产部署必须配置独立的 `HOST_ADMISSION_PASSWORD` 控制建房和 Host 信令角色;只有本地开发/测试可显式关闭。它只是防止随手滥用的部署访问口令,不是账号密码;非空值接受 8 至 128 个可见 ASCII bytes,不能复用 TURN、LiveKit、TLS 或其他部署 secret。验证成功签发 12 小时、无服务端会话状态的 HttpOnly、`SameSite=Strict` Host-admission cookie。未鉴权根路径、`/join` 和未知路径只显示中性的访问验证或通用拒绝,不说明站点用途;有效 private-link grant 或 public-watch 直链仍直接进入目标房间。这只是界面最小暴露,不把可下载的前端代码误当保密边界。建房 HTTP 只认该 cookie且不提供 Bearer 旁路;Host admission endpoint 由部署层/nginx 做 per-source `limit_req`,失败只返回统一结果,不在 Node 新增 IP 身份库/LRU/session 表或持久化来源地址。WebSocket upgrade 继续校验 Origin 与全局/未鉴权容量并记录 cookie 状态,首条 Host `authenticate` 必须同时通过该状态与房间 `hostToken`。Viewer 不经过此 gate,Host cookie 也不能绕过私密房间的 Viewer grant。不引入账号、JWT、服务端 session Map 或通用 logout。
- [ ] 房间级观看授权:新房间默认生成含 256-bit 随机量、限时且可撤销的 room-scoped Viewer grant;服务端只保存完整 grant 的 SHA-256 摘要。到期阻止以后鉴权/重连,但不为已鉴权 Viewer 设置踢出定时器;即时清退使用 rotate/revoke。浏览器从 URL fragment 严格读取 grant 后,只写该房间的 `sessionStorage` 并立即用 `history.replaceState` 清除 fragment;刷新和页面内重连可继续,独立且没有 grant 的访问必须 fail closed。grant 不得进入 `localStorage`、cookie、query、WebSocket URL、Referrer、日志或错误。Host 可 rotate 或 revoke;revoke 写入一个未生成对应 grant 的 locked-private digest,直到下一次 rotate 才产生新邀请。新摘要成功提交后必须清除 Viewer grace/路由、终止旧授权代次的信令和媒体并断开全部旧 Viewer,数据库写失败则不得改变连接。public-watch 是显式、可逆的 room policy,只放宽观看,不放宽建房或发布。
- [ ] 最小持久化:`ROOM_DATABASE_PATH` 可选且必须与 `HOST_ADMISSION_PASSWORD` 同时配置。两者都存在时,SQLite 只持久保存从 `1` 开始自增的房间 ID、Host token 摘要和同一 `rooms` 行上的 nullable Viewer grant 摘要;`NULL` 表示 public-watch,非空值必须由数据库 `CHECK` 约束为 32-byte BLOB,不新增用户、成员或 session 表。持久房间停止后仍显示等待状态;私密邀请按期限或 Host rotate 更新。旧 schema 行在原子迁移时必须 fail-closed 为没有可用明文 grant 的锁定私密状态,Host rotate 后才能生成新邀请。
@@ -90,7 +90,7 @@
- Given 持久房间正在分享,When 分享者明确停止或捕获轨结束,Then 房间和链接保持有效,分享端显示“已停止分享”,观看者显示“等待开始分享”;分享者重新发布后可继续在同一房间观看。
- Given 分享期间只有 host 信令短暂断开,When 现有 P2P 媒体仍健康,Then 客户端继续播放媒体并恢复信令,而不是错误显示“已停止分享”。
- Given 应用以 production 模式启动但 `HOST_ADMISSION_PASSWORD` 缺失、`ROOM_DATABASE_PATH` 已配置但该密码为空,或原子迁移后的配置仍提供已删除的 `ACCESS_PASSWORD`,When 应用启动,Then 配置校验明确失败,而不是兼容旧 gate 或公开建房。
- Given production Host admission secret 短于 16 bytes、长于 128 bytes 或含非可见 ASCII,When 新 release 启动,Then 必须 fail-fast。Given 同一来源连续提交错误 secret,When 超过 deployment `limit_req` 的 `5r/m`、`burst=5 nodelay` 边界,Then nginx 返回不含凭据的通用限频结果;允许请求的错误值仍使用常量时间比较和统一鉴权失败,limiter 只使用 nginx 内存且不新增 IP 持久化/日志字段。
- Given production Host admission secret 短于 8 bytes、长于 128 bytes 或含非可见 ASCII,When 新 release 启动,Then 必须 fail-fast。Given 同一来源连续提交错误 secret,When 超过 deployment `limit_req` 的 `5r/m`、`burst=5 nodelay` 边界,Then nginx 返回不含凭据的通用限频结果;允许请求的错误值仍使用常量时间比较和统一鉴权失败,limiter 只使用 nginx 内存且不新增 IP 持久化/日志字段。
- Given `ROOM_DATABASE_PATH` 为空,When 创建房间,Then 无论是否启用 Host admission 都使用随机临时房间码并遵守 `ROOM_TTL_SECONDS`;停止分享本身不立即销毁房间。
- Given 新建私密房间,When 服务端返回邀请并由 Host/Viewer 客户端读取,Then grant 含 256-bit 随机量、只授权该房间 Viewer、只在该页面会话的 room-scoped `sessionStorage` 留存,并在首次读取后从地址栏移除;HTTP request-target、WebSocket URL、Referrer、cookie、`localStorage`、SQLite 明文、日志和错误均不包含 grant。Given 独立页面既无 fragment 也无该 room key,When 尝试进入私密房间,Then 必须要求完整邀请而不是退回 code-only。
- Given Viewer 只有 Host admission cookie、另一个房间的 grant、缺失/过期 grant 或自行把 role 改为 Host,When 首条信令鉴权到达,Then 私密房间返回同一通用无效/过期邀请结果且不泄露房间存在性;不能建房、发布、跨房或绕过 Host token。
-1
View File
@@ -4,7 +4,6 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#121513" />
<meta name="description" content="Screener private low-latency game screen sharing" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Screener</title>
</head>
+25 -7
View File
@@ -37,13 +37,16 @@ export function App() {
if (viewerRoute) {
return <ViewerPage {...viewerRoute} />;
}
if (isJoinRoute || !isHostRoute) {
return <JoinPage />;
if (isHostRoute) {
return <HostAdmissionGate surface="host" />;
}
return <HostAdmissionGate />;
if (isJoinRoute) {
return <HostAdmissionGate surface="join" />;
}
return <UnavailableRoute />;
}
function HostAdmissionGate() {
function HostAdmissionGate({ surface }: { surface: "host" | "join" }) {
const [access, setAccess] = useState<AccessState>({ kind: "checking" });
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
@@ -96,6 +99,9 @@ function HostAdmissionGate() {
}
if (access.kind === "ready") {
if (surface === "join") {
return <JoinPage />;
}
return (
<HostPage
onAuthorizationRequired={() =>
@@ -130,11 +136,11 @@ function HostAdmissionGate() {
) : (
<form className="access-panel" onSubmit={(event) => void submit(event)}>
<div>
<h1>分享权限</h1>
<p className="section-meta">请输入分享准入密码</p>
<h1>访问验证</h1>
<p className="section-meta">请输入访问口令</p>
</div>
<label className="token-field">
<span>分享准入密码</span>
<span>访问口令</span>
<span className="input-with-icon">
<KeyRound size={16} aria-hidden="true" />
<input
@@ -165,3 +171,15 @@ function HostAdmissionGate() {
</div>
);
}
function UnavailableRoute() {
return (
<div className="app-shell">
<main className="access-workspace access-workspace-full">
<section className="access-panel">
<h1>无法访问</h1>
</section>
</main>
</div>
);
}
+37
View File
@@ -1,4 +1,5 @@
import {
LoaderCircle,
Maximize2,
Play,
RefreshCw,
@@ -67,6 +68,9 @@ type ViewerQualityEvidence = Extract<
>;
export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
const [accessState, setAccessState] = useState<
"checking" | "ready" | "denied"
>("checking");
const [signalStatus, setSignalStatus] =
useState<SignalConnectionState>("offline");
const [statusText, setStatusText] = useState("正在连接");
@@ -98,6 +102,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
useEffect(() => {
let active = true;
let viewerAuthenticated = false;
let currentIceConfig: IceConfig | null = null;
let currentHostOnline = false;
let peerAssisted = false;
@@ -150,6 +155,9 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
setSfuStandbyUrl(null);
clearViewerSfuRoute();
clearPeerState();
if (!viewerAuthenticated) {
setAccessState("denied");
}
setStatusText(message);
},
onAccessRequired: () => {
@@ -158,6 +166,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
setSfuStandbyUrl(null);
clearViewerSfuRoute();
clearPeerState();
viewerAuthenticated = false;
setAccessState("denied");
setStatusText("邀请无效或已失效");
}
},
@@ -463,6 +473,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
authorityToken: number,
): Promise<void> {
if (message.type === "authenticated") {
viewerAuthenticated = true;
setAccessState("ready");
clearRelayChildEvidence();
currentPeerId = message.peerId;
viewerAuthorizationGeneration =
@@ -648,6 +660,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
return;
}
viewerAuthorizationGeneration = null;
viewerAuthenticated = false;
setAccessState("denied");
clearViewerGrant(roomId);
setSfuStandbyUrl(null);
clearViewerSfuRoute();
@@ -657,6 +671,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
return;
}
if (message.type === "room-closed") {
viewerAuthenticated = false;
setAccessState("denied");
setSfuStandbyUrl(null);
clearViewerSfuRoute();
clearPeerState();
@@ -681,6 +697,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
setSfuStandbyUrl(null);
clearViewerSfuRoute();
clearPeerState();
viewerAuthenticated = false;
setAccessState("denied");
}
if (message.code === "INVALID_TOKEN") {
clearViewerGrant(roomId);
@@ -796,6 +814,25 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
}
}
if (accessState !== "ready") {
return (
<div className="app-shell">
<main className="access-workspace access-workspace-full">
{accessState === "checking" ? (
<div className="access-loading" role="status">
<LoaderCircle size={20} className="spin" aria-hidden="true" />
正在验证
</div>
) : (
<section className="access-panel">
<h1>无法访问</h1>
</section>
)}
</main>
</div>
);
}
return (
<div className="app-shell viewer-shell">
<AppHeader
+2 -2
View File
@@ -7,7 +7,7 @@ import {
export type RuntimeEnvironment = "development" | "test" | "production";
const MIN_HOST_ADMISSION_PASSWORD_BYTES = 16;
const MIN_HOST_ADMISSION_PASSWORD_BYTES = 8;
const MAX_HOST_ADMISSION_PASSWORD_BYTES = 128;
const MIN_LIVEKIT_API_SECRET_BYTES = 32;
const MAX_PEER_ASSISTED_VIEWERS = 8;
@@ -329,7 +329,7 @@ export function loadConfig(
MAX_HOST_ADMISSION_PASSWORD_BYTES)
) {
throw new Error(
"HOST_ADMISSION_PASSWORD must contain 16 to 128 visible ASCII bytes",
"HOST_ADMISSION_PASSWORD must contain 8 to 128 visible ASCII bytes",
);
}
if (nodeEnv === "production" && !hostAdmissionPassword) {
+5 -5
View File
@@ -250,7 +250,7 @@ describe("server configuration", () => {
const config = loadConfig({
NODE_ENV: "production",
PUBLIC_BASE_URL: "https://share.test",
HOST_ADMISSION_PASSWORD: "host-password-12",
HOST_ADMISSION_PASSWORD: "easy-key",
STUN_URLS: "stun:stun.test:3478",
});
expect(config.stunUrls).toEqual(["stun:stun.test:3478"]);
@@ -352,10 +352,10 @@ describe("server configuration", () => {
const config = loadConfig({
NODE_ENV: "production",
PUBLIC_BASE_URL: "https://share.test",
HOST_ADMISSION_PASSWORD: "host-password-12",
HOST_ADMISSION_PASSWORD: "easy-key",
STUN_URLS: "stun:stun.test:3478",
});
expect(config.hostAdmissionPassword).toBe("host-password-12");
expect(config.hostAdmissionPassword).toBe("easy-key");
});
it("requires Host admission protection for a persistent room database", () => {
@@ -382,13 +382,13 @@ describe("server configuration", () => {
).toThrow("ROOM_DATABASE_PATH must be file-backed in production");
});
it.each(["x".repeat(15), "密码密码密码密码", "contains spaces", "x".repeat(129)])(
it.each(["x".repeat(7), "密码密码密码密码", "contains spaces", "x".repeat(129)])(
"rejects a Host admission password outside the visible ASCII boundary",
(hostAdmissionPassword) => {
expect(() =>
loadConfig({ HOST_ADMISSION_PASSWORD: hostAdmissionPassword }),
).toThrow(
"HOST_ADMISSION_PASSWORD must contain 16 to 128 visible ASCII bytes",
"HOST_ADMISSION_PASSWORD must contain 8 to 128 visible ASCII bytes",
);
},
);