网络共享与 Tailscale 远程访问 — 设计规格¶
- 日期: 2026-05-30
- 状态: 已批准设计 — 待规范评审
- 取代: 社区 PR #125 中提出的原始
0.0.0.0默认翻转(因作为静默默认变更被拒绝;由这一明确的、用户驱动的功能取代) - 随版本发布: v0.3.0
1. 目标¶
让用户能够从其他机器查看并访问同一个正在运行的 VoiceStudio 实例 — 不会丢失已加载的模型或中断正在进行的任务,同时不削弱默认的本地优先策略。两种互补能力:
- A. LAN 共享 — 将同一个后端暴露给同一网络(Wi-Fi/以太网)中的设备,通过一个简短访问 PIN 保护,并提供一个精致的底部面板:显示所有局域网地址、复制/打开链接,以及供手机使用的 二维码。
- B. Tailscale 远程访问 — 用于从任何地方进行安全、私密的访问;Settings 中的一个开关驱动
tailscale serve,通过用户的 tailnet 在 HTTPS*.ts.netURL 上发布 WebUI(TLS + 身份验证由 Tailscale 处理;无需开放端口,无需 PIN)。
两者都使正在运行的模型和任务状态完全不受影响。
2. 非目标(明确排除在范围之外)¶
- WebRTC — 已评估并放弃;Tailscale 以远更小的风险且无需 STUN/TURN 基础设施的方式完全覆盖了“安全的私密远程访问”。如果实时 P2P 媒体成为需求,可能会在专门的规范中重新评估。
- LAN 路径的 TLS — LAN 监听器使用纯 HTTP(该应用目前就是 HTTP)。需要加密的用户使用 Tailscale 路径 (B),该路径会终止 TLS。
- 账户 / 每设备令牌 / 会话之外的 PIN 过期 — 只使用一个共享的会话 PIN。YAGNI。
- 更改默认绑定 — 每次启动时,主后端默认仍仅监听 loopback(即用户选择的“始终以 Local 模式启动”)。
3. 核心约束 → 机制¶
一个活动中的 socket 无法重新绑定,而且你不能同时绑定 127.0.0.1:P 和 0.0.0.0:P。通过重启 uvicorn 来更改主机地址会丢失已加载的模型并中断进行中的任务 — 因“保留活动工作”的要求而被否决。
机制:在专用共享端口上创建第二个进程内监听器。
后端已经在 uvicorn 下运行于 127.0.0.1:P(P = backend_port(),由 Tauri 启动)。当“启用 LAN 共享”时,后端会启动一个绑定到 0.0.0.0 共享端口的第二个 uvicorn.Server(默认 P+1,若被占用则自动递增),作为一个 asyncio 任务运行,服务于完全相同的 FastAPI app 对象。
由于它在同一个进程和事件循环中使用同一个 app:
- 已加载模型保持不变(不重新加载),
- 内存中的任务注册表和 SSE 流保持不变,
- 无需重启,也不会丢弃任何进行中的工作。
“禁用”会设置 server.should_exit = True 并等待任务退出 → 0.0.0.0 socket 被真正关闭(而不仅仅是被防火墙拦截)。默认状态 = 无共享监听器 = 没有任何服务绑定到 0.0.0.0。
4. 架构¶
不需要新的 Rust/Tauri 命令 — 该功能由后端驱动,footer/Settings UI 调用后端 HTTP 端点;打开链接时复用现有的 shell.open 能力。(从 PR #85 中保留的 Windows kill_orphan_on_port 在引导阶段端口冲突路径中仍然有用,但与本功能无关。)
4.1 后端¶
新模块 backend/services/network_share.py — 负责共享监听器的生命周期和 PIN:
ShareStatedataclass:enabled: bool,host: str,share_port: int | None,pin: str | None,lan_addresses: list[str],started_at.enable(app, base_port) -> ShareState:选择一个可用的共享端口(base_port+1,尝试一小段范围),生成一个 6 位 PIN(secrets.randbelow),构建uvicorn.Config(app, host="0.0.0.0", port=share_port)+uvicorn.Server,启动asyncio.create_task(server.serve()),等待server.started,将 server/task/state 存储到app.state.network_share上。disable() -> ShareState:发送should_exit信号,等待任务退出,清除状态。get_state() -> ShareState。lan_ipv4_addresses() -> list[str]:通过psutil.net_if_addrs()枚举,保留AF_INET,丢弃 loopback/link-local(127.、169.254.)。(psutil 已固定版本 — 不需要新的后端依赖。)
控制端点(backend/api/routers/system.py) — system 路由器已通过 Depends(require_loopback) 进行了 loopback 门控(在 #157 安全审查中已确认:它检查真实且不可伪造的 request.client.host)。在该路由器下新增的端点会继承这一限制 — 因此 LAN 客户端(即使通过共享监听器)也永远无法启用对外暴露、读取 PIN 或访问 /system/set-env(该端点可以设置可执行文件路径)。无需新的防护;复用现有依赖:
POST /system/network/enable→network_share.enable(...)→ 返回脱敏后的ShareState(包含pin、lan_addresses、share_port)。POST /system/network/disable→ 返回ShareState。GET /system/network/state→ 返回当前ShareState(因调用方为 loopback,所以包含pin)。
/system/info 新增字段: share_enabled、share_port、lan_addresses、pin_required 和 access_pin — access_pin 仅当 request.client.host 是 loopback 时才会包含(桌面应用可以看到它;远程设备永远不会从 API 收到它)。
认证中间件 NetworkAccessMiddleware(backend/main.py):
- 仅当设置了 PIN 时才激活(
app.state.network_share.pin)。未设置 PIN 时(默认情况以及 docker-compose0.0.0.0部署路径),中间件直接放行 → 完全向后兼容,不会对服务器部署造成回归。 - loopback 客户端始终绕过(
127.0.0.1、::1)。这包括 Tailscaleserve代理的请求(它们从 loopback 到达)— 因此 Tailscale 路径自然无需 PIN。 - SPA 外壳始终无需 PIN 即可提供,以便门控 UI 加载:
GET /、/assets/*、/favicon*、/index.html以及/health健康检查。 - 所有来自非 loopback 客户端的其他路由都需要 PIN,可通过
X-VoiceStudio-Pin请求头、?pin=查询参数或ov_pincookie 提供。有效的 PIN 响应会设置ov_pincookie,这样后续无法发送自定义请求头的媒体/SSE/<audio>/<img>GET 请求就会自动认证。无效/缺失 →401。
4.2 Tailscale(backend/services/tailscale.py + 端点)¶
status() -> {installed, running, magic_dns_name, tailnet_ips}— 调用tailscale status --json(以及tailscale version);所有调用都被包装,因此缺少 CLI 时能优雅降级为installed: false。serve_enable(port) -> {url}—tailscale serve --bg --https=443 http://127.0.0.1:<port>(代理主回环后端;无需 LAN 监听器,也不需重启模型)。解析并返回https://<machine>.<tailnet>.ts.netURL。serve_disable()—tailscale serve reset(或针对性的--https=443 off)。- 端点(仅回环):
GET /system/tailscale/status、POST /system/tailscale/enable、POST /system/tailscale/disable。
4.3 前端¶
frontend/src/components/NetworkToggle.jsx(挂载在LogsFooter.jsx中):一个显示当前状态(● Local/● Network)的胶囊按钮和一个开关。- 本地 → 网络:确认对话框("您所在网络上的其他设备将能够使用访问 PIN 连接 VoiceStudio"),然后调用
POST /system/network/enable;"切换中…"状态覆盖该次调用。 - 网络 → 本地:立即切换(朝着更安全的方向),调用
POST /system/network/disable。 - 启用时可展开面板:每个 LAN 地址都带有逐行的复制 + 在浏览器中打开按钮、一个编码了
http://<ip>:<share_port>/?pin=<pin>的 QR,以及用于手动输入的 PIN。 - 可见性: 当后端绑定到回环地址(桌面应用和本地运行——
/system/info.bind_host == 127.0.0.1)时显示该开关。在运维人员已将后端绑定到0.0.0.0(OMNIVOICE_BIND_HOST=0.0.0.0)的服务器部署中,该开关会被隐藏,因为暴露范围已是运维人员需要考虑的事项。默认的本地行为在每个平台上保持一致。 - 设置 → "共享与远程访问"面板(
frontend/src/components/settings/SharingPanel.jsx):完整的界面 — LAN 开关(与页脚开关对应),再加上 Tailscale 部分(状态、启用/禁用、带有复制/打开/QR 的*.ts.netURL,以及 CLI 缺失时的安装链接)。 - 远程 PIN 门(
frontend/src/components/RemoteAuthGate.jsx):当 SPA 从非回环来源加载且 API 调用返回401时,显示 PIN 输入界面。挂载时,从 URL 中读取?pin=(由 QR 填充)以自动认证。PIN 保存在sessionStorage中,并由 API 客户端附带到请求中。 - API 客户端请求头注入: 共享的 fetch 包装器(
frontend/src/api/client.*)在sessionStorage中存在 PIN 时附加X-VoiceStudio-Pin。回环场景(桌面应用)从不设置也从不使用它。
5. 数据流¶
启用 LAN 共享(桌面): 页脚开关 → 确认 → POST /system/network/enable(回环)→ 后端启动 0.0.0.0:P+1 监听器(同一应用),生成 PIN → 返回 {share_port, pin, lan_addresses} → 面板渲染地址 + QR(http://<ip>:<P+1>/?pin=<pin>)+ PIN。
远程设备: 扫描 QR → 从 http://<ip>:<P+1>/?pin=<pin> 加载 SPA(SPA 外壳无需 PIN 即可获取)→ SPA 读取 ?pin=,存储它,发送 X-VoiceStudio-Pin(并获得 ov_pin cookie)→ 中间件对同一正在运行的后端进行校验 → 用户看到相同的项目、声音、任务、已加载模型。
Tailscale: 设置 → 启用 → POST /system/tailscale/enable → tailscale serve 代理 127.0.0.1:P → 返回 https://<machine>.<tailnet>.ts.net → 用户从任意 tailnet 设备打开该地址;请求经由 Tailscale 的代理从回环到达后端 → 绕过 PIN → 身份由 Tailscale 强制验证。
禁用: 页脚/设置 → POST .../disable → 监听器停止 / tailscale serve reset → 0.0.0.0 关闭;整个过程中正在运行的模型和任务不受影响。
6. 安全模型¶
- 每次启动默认仅绑定回环。 在用户明确启用之前,不会绑定任何
0.0.0.0;不存在持久化的自动暴露。 - 控制端点仅限回环,通过
system路由器上现有的require_loopback依赖实现(不可伪造的request.client.host,已在 #157 安全审查中确认)——LAN 客户端无法启用共享、读取 PIN 或访问/system/set-env(该端点可设置可执行路径 → 若暴露将构成 RCE)。 access_pin仅由 API 返回给回环调用者。- PIN 门控基于客户端 IP: 回环(包括经 Tailscale 代理的请求)受信任;直接 LAN 需要 PIN。除非设置了 PIN,否则该强制机制不起作用 → docker
0.0.0.0部署不受影响(向后兼容)。 - Tailscale 路径无需开放端口,也无需 PIN —— 它代理回环并依赖 tailnet 身份 + TLS。
- PIN 是一个会话密钥,每次启用时重新生成;它从不持久化到磁盘。
7. 错误处理¶
- 共享启用失败(无空闲端口 / 绑定错误):返回错误,保持本地状态,在 UI 中显示 toast 提示;绝不留下半开状态。
- 未找到 LAN 地址(离线 / 无网卡):启用仍然成功,但面板显示"没有可达的网络接口 — 请连接 Wi-Fi/以太网";QR 被省略。
- Tailscale CLI 缺失 / 未登录: 设置中显示"未检测到 Tailscale",并附安装/登录链接;启用按钮被禁用。
tailscale serve失败: 将 stderr 展示给用户;保持 LAN 共享独立(两条路径互不依赖)。- 禁用是幂等的,并且始终安全(如果已关闭则为空操作)。
8. 测试¶
- 后端(
tests/): network_share:启用时选择一个空闲端口并启动监听器;get_state反映该状态;禁用时将其关闭;PIN 为 6 位数字;lan_ipv4_addresses过滤回环/链路本地地址(模拟psutil.net_if_addrs)。- 中间件:未设置 PIN 时直通;回环绕过;非回环 + 无 PIN → 401;非回环 + 有效 PIN(请求头/查询参数/cookie)→ 200 并设置 cookie;SPA 外壳 +
/health对外开放;access_pin仅对回环调用者存在。 - 控制端点拒绝非回环调用者,返回 403。
- Tailscale 模块:解析
tailscale status --jsonfixture 数据;CLI 缺失时优雅降级为installed:false(模拟 subprocess)。 - 前端(Vitest):
NetworkToggle默认处于本地(Local)状态;在桌面环境之外被禁用;启用时面板渲染地址 + QR + PIN;RemoteAuthGate在 401 时显示并从?pin=自动填充;API 客户端仅在已设置时才附加X-VoiceStudio-Pin。
9. 依赖¶
- 后端: 无新增(
psutil、uvicorn、secrets已存在;tailscale是通过 subprocess 调用的外部 CLI,可选)。 - 前端: 一个小型二维码库(
qrcode),用于页脚面板和 Tailscale URL 展示。
10. 跨平台一致性¶
- 默认(Local) 行为在 macOS / Windows / Linux 上逐字节完全一致——严格的默认一致性规则得到满足,因为对外暴露是显式选择加入(页脚开关 / Settings)。
- LAN 共享是纯 Python/asyncio + psutil → 各平台行为一致。
- Tailscale 在三种操作系统上都使用同一个
tailscaleCLI,并在未安装时优雅降级(这是与平台无关的缺失处理,而非平台差异化行为)。
11. 实施顺序(针对计划)¶
- 后端
network_share模块 + 控制端点 +/system/info字段(+ 测试)。 NetworkAccessMiddleware+ PIN cookie + SPA 外壳白名单(+ 测试)。- 前端 api-client PIN 头 +
RemoteAuthGate(+ 测试)。 NetworkToggle页脚组件 + 面板 + QR(+ 测试)。- Tailscale 模块 + 端点(+ 测试)。
- Settings 中的
SharingPanel(LAN 镜像 + Tailscale)(+ 测试)。 - 文档:新增“Sharing & remote access”页面(LAN PIN/QR + Tailscale),并在 PR #125 上添加评论,说明此实现取代了默认翻转方案。
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 om/VoiceStudio