跳转至

认证本地 API

对于需要消费一个已固定的、私有的 VoiceStudio 部署的应用后端,请从生产环境私有 API 模式开始。

VoiceStudio 的后端默认仅限回环(loopback)且无需认证 —— 在与 http://localhost:3900 位于同一台机器上运行的脚本不需要密钥、不需要 PIN、也不需要任何请求头。只有当从另一台设备(局域网中的手机、通过 Tailscale 连接的笔记本电脑、反向代理后面的客户端)访问后端时,本页的内容才真正开始起作用。

存在两道相互独立的门禁,二者都是开启前保持无效,另外还有一个环境变量可豁免受信任的调用方:

门禁 启用方式 防护对象 适用范围
共享 PIN 应用内的 Network 共享开关 临时 LAN 共享访客,单次会话 非回环 HTTP
API 密钥 后端上的 OMNIVOICE_API_KEY 环境变量 直接客户端和第一方会话引导 非回环 HTTP + WebSocket
受信任网络 OMNIVOICE_TRUSTED_NETWORKS 环境变量 豁免上面的两道门禁 仅限非回环消费路由

回环流量(127.0.0.1、::1、localhost)永远不会被门禁拦截 —— 无论设置了哪道门禁,本地工具都能在无需任何改动的情况下继续正常工作。

VoiceStudio 将消费(TTS、听写、声音)与管理(/system/*、/api/settings/* —— RCE 级别)区分开来。PIN 和受信任网络属于消费凭据;管理面只能通过回环或使用 API 密钥访问。即使拥有密钥,宿主路径能力也仍仅限桌面端(参见 管理路由)。

两道门禁可以同时生效。PIN 与 API 密钥相互独立;当二者都设置时,各自只在其覆盖的路径上被检查。会话交换会在 PIN 门禁之前验证主密钥,以便 UI 能安全地完成引导;之后的普通 HTTP 请求仍然需要 PIN,UI 会随即提示输入。


共享 PIN

PIN 是轻量级的应用内路径:打开 Network 共享(页脚 Local 胶囊 → Network,或 设置 → 共享与远程访问),应用会为该会话生成一个新的 6 位 PIN。每次启用共享时它都会重新生成,并且永远不会写入磁盘。UI 操作指南请参见 docs/sharing.md。

位置 方式
请求头 X-OmniVoice-Pin: <pin>
查询参数 ?pin=<pin>
Cookie ov_pin=<pin> —— 后端在首次通过有效的 PIN 校验后会自动设置它,因此浏览器会话只需验证一次
# From another device on the LAN — with the PIN
curl http://<host>:3900/v1/audio/voices \
  -H "X-OmniVoice-Pin: 123456"

缺少或错误的 PIN 会返回:

HTTP/1.1 401 Unauthorized
{"detail": "PIN required"}

关于 PIN 门禁(NetworkAccessMiddleware、backend/main.py)的说明:

  • 它仅覆盖 HTTP —— 不会拦截 WebSocket。听写 WebSocket 有自己的防护(见下文),并且 PIN 不能授权该连接;远程听写请使用 API 密钥或受信任网络。
  • SPA 外壳(/、/index.html、/favicon*、/assets/*、/health)始终在无需 PIN 的情况下提供,以便加载 PIN 提示 UI。
  • 它属于消费凭据:有效的 PIN 永远无法解锁管理面(因为它只有 6 位数字,可被暴力破解)。管理需要回环访问或 API 密钥。
  • 当未设置 PIN 时,它完全不起作用(包括 Docker,除非在应用内启用了网络共享)。Docker 的桥接网络可能使宿主机自己的浏览器成为非回环客户端;如果 PIN 丢失,请按照 Docker 恢复步骤 操作。

API 密钥

导出历史(GET /export/history、POST /export/record)接受本地客户端和经过认证的远程消费者,包括 API 密钥会话和共享 PIN 访客。匿名远程调用者会被拒绝。记录一次导出仅会存储元数据;/export 和 /export/reveal 仍然需要本机原生访问,删除历史记录则保留其管理员边界。在没有配置密钥或 PIN 的显式服务器模式下,导出元数据保持其现有的开放消费策略,但匿名响应中的目标路径会被移除;该模式下的访问控制由操作员的端口映射决定。

API 密钥是后端针对 GPU 主机、Docker 容器或反向代理主机的持久根凭据。直接的 API 客户端可以在每个请求中携带它。第一方的浏览器/Electron UI 则会将其一次性兑换为短期管理员会话,并且从不存储主密钥。在后端进程中设置它:

# Generate a strong key and start the backend with it
export OMNIVOICE_API_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(24))')"
uv run uvicorn backend.main:app --host 0.0.0.0 --port 3900
# Docker: pass -e OMNIVOICE_API_KEY=…

在设置了 OMNIVOICE_API_KEY 后,每个非回环 HTTP 和 WebSocket 请求都必须提供可接受的凭据。SPA 外壳路径保持公开;POST /api/auth/session 只是穿过中间件,以便其路由能够验证主密钥并执行一次性交换。直接客户端的兼容性接受以下方式:

位置 方式
请求头 Authorization: Bearer <key> —— 脚本和 SDK 的首选
旧版 Cookie ov_key=<key> —— 仅为兼容性而接受,并由第一方 UI 迁移;后端不再创建它
旧版查询参数 ?api_key=<key> —— 仅用于兼容性。URL 中的密钥会泄露到代理/访问日志和浏览器历史记录中
# Prefer an encrypted transport (Tailscale Serve / TLS) for a real key; plain
# http:// on an untrusted network exposes the Bearer token on the wire.
curl https://gpu-box:3900/v1/audio/speech \
  -H "Authorization: Bearer $OMNIVOICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"tts-1","voice":"alloy","input":"Hello from a keyed backend.","response_format":"wav"}' \
  --output speech.wav
# The OpenAI SDK sends the key as a Bearer token automatically
from openai import OpenAI

client = OpenAI(
    base_url="https://gpu-box:3900/v1",
    api_key="<your OMNIVOICE_API_KEY>",   # must match OMNIVOICE_API_KEY on the backend
)                                          # (any string works ONLY when no key is set — the loopback default)
audio = client.audio.speech.create(
    model="tts-1", voice="alloy", input="Hello from a keyed backend.",
    response_format="wav",
)
audio.write_to_file("speech.wav")

缺少或错误的密钥会返回:

HTTP/1.1 401 Unauthorized
{"detail": "API key required"}

在 WebSocket 上,缺少或错误的密钥会以关闭代码 1008(策略违规)拒绝握手,而不是返回 JSON 主体。

关于 API 密钥门槛(BearerKeyMiddleware、backend/main.py)的说明:

  • 密钥以恒定时间进行比较,并且绝不会被记录到日志中。
  • 后端绝不会将主密钥复制到响应 cookie 中。浏览器客户端只会收到 ov_session,这是一个不透明的、HttpOnly、SameSite=Strict 凭据。
  • SPA 外壳路径在 HTTP 上绕过该门槛,以便远程 UI 能够加载并显示问题所在;WebSocket 没有此类豁免。
  • 纯 HTTP 可被嗅探——在恶意网络上的 http:// 中,Bearer 密钥可以从线路上被读取。对于完全受信任的 LAN 之外的任何场景,请使用 Tailscale (WireGuard) 或 TLS。完整远程后端设置请参阅 docs/remote-gpu.md。

第一方管理员会话

内置 UI 使用一种更严格的协议:

  1. POST /api/auth/session 仅通过 Authorization 头接收主密钥一次,并为精确同源浏览器选择 {"transport":"cookie"},为跨源客户端(包括 Electron 应用的远程后端连接)选择 {"transport":"bearer"}。
  2. Cookie 传输返回 204,并将 ov_session 设置为 HttpOnly、SameSite=Strict、路径 /,最大生存时间为八小时。Bearer 传输返回一个不透明的 ovs_admin_session_… 值,UI 会将其保存在 localStorage 中,并与确切的后端基础 URL 绑定,因此经过身份验证的重新加载和新标签页会复用它,直到其有限过期时间。Bearer JSON 响应同时包含 expires_at 和有限的 expires_in;UI 在可用时使用相对生存时间,这样远程 GPU 主机与浏览器之间的时钟偏差不会导致有效会话被拒绝。expires_at 保留用于与较旧的客户端和服务器进行向后兼容。
  3. DELETE /api/auth/session 会使会话失效。移除或轮换 OMNIVOICE_API_KEY、后端重启、显式退出登录以及八小时截止时间也会使其失效。

选择 使用本地 会丢弃当前存储的 bearer 会话,包括通过测试未保存的后端 URL 所创建的会话。存储的会话将针对其自身的后端被撤销,并且所配置后端的 cookie 会话也会尽最大努力被撤销。存储的凭据会立即清除;在两次有限的撤销尝试都完成后,应用会重新加载到本地模式。

主密钥绝不会写入 localStorage/sessionStorage,绝不会由后端返回,也绝不会放入 WebSocket URL 中。旧版的 ov_api_key 浏览器存储会在迁移等待网络之前被删除。所有认证响应(包括错误)都带有 Cache-Control: no-store。

失败的会话交换按客户端限制为滚动 60 秒窗口内十次尝试,之后返回 429 并带有 Retry-After。正确的主密钥始终会被验证并清除失败窗口,因此攻击者无法通过故意耗尽该限制来锁定操作员。

通过 Cookie 认证的修改操作需要精确允许的 Origin 以及 X-VoiceStudio-CSRF: 1。带有副作用的 GET 操作还要求浏览器提供 Sec-Fetch-Site: same-origin。Bearer/header 客户端不受环境 Cookie CSRF 检查的约束。


听写 WebSocket

位于 ws://<host>:3900/ws/transcribe 的实时听写流带有其自身的内联保护(backend/api/routers/capture_ws.py),此外 还有 API 密钥中间件。非回环客户端只有在满足以下任一条件时才能访问它:

  • 位于受信任网络上(通过 is_local_host),或
  • 在 Authorization 中提供直接客户端的 API 密钥,或通过旧的 ov_key/?api_key= 传输方式。
ws://gpu-box:3900/ws/transcribe?api_key=<key>

该 URL 形式仅为非浏览器兼容而保留。第一方 UI 绝不会构造它。Bearer 管理员会话首先调用 POST /api/auth/ws-ticket,并且只在 URL 中放入返回的 ws_ticket。门票的作用域限定为 /ws/transcribe、/ws/events 或 /ws/tts(实时配音预览流)之一,30 秒后过期,返回相同的有限 expires_in/expires_at 对,并且以原子方式最多消费一次。同源 UI WebSocket 使用 HttpOnly 会话 cookie,并且必须通过精确的 Origin 验证;null、缺失和仿冒来源会被拒绝。

共享 PIN 不授权听写——PIN 门槛仅限 HTTP,听写保护只检查 API 密钥(或受信任网络成员资格)。只输入了 PIN 的 LAN 访客可以使用 HTTP API,但不能使用实时听写。当 API 密钥和受信任网络都不适用时,握手会以代码 1008 和原因 loopback origin required 关闭。


受信任网络

OMNIVOICE_TRUSTED_NETWORKS 是一个以逗号分隔的 CIDR 范围列表,其客户端被消费门槛视为回环受信任——因此反向代理或受信任的 LAN/Tailnet 可以在没有 PIN 或密钥的情况下访问 API(当代理剥离 Authorization 头时很有用)。此类请求仍会进行主机检查:转发原始 Host 的代理(Caddy 和 Nginx Proxy Manager 会这样做)需要将其主机名放入 OMNIVOICE_ALLOWED_HOSTS,客户端使用的任何其他名称(除 IP 地址、localhost、本机名称或 *.ts.net 名称之外)也需要如此——请参阅主机名。

export OMNIVOICE_TRUSTED_NETWORKS="192.168.1.0/24,10.0.0.0/8"

它所豁免的内容与它不豁免的内容:

  • 豁免(通过 is_local_host、backend/api/dependencies.py):共享 PIN 门禁、API 密钥门禁以及听写 WebSocket 守卫。位于已列出范围内的客户端访问这些消费路由时无需 PIN 或密钥。
  • 绝不豁免管理员。 /system/* 和 /api/settings/* 属于 RCE 级管理员面;可信网络成员资格是一种消费豁免,不会延伸到它们——即使在 OMNIVOICE_SERVER_MODE=1 下也是如此。参见管理员路由。

细节:格式错误的 CIDR 条目会被静默忽略(坏条目绝不会卡死门禁);来自双栈代理的 IPv4 映射 IPv6 地址(::ffff:192.168.1.5)会被解包以匹配 IPv4 CIDR;该值在请求时读取,因此在生产中重启后端以应用更改。默认为空——严格回环默认行为不变。

Docker 主机回环

OMNIVOICE_TRUST_HOST_LOOPBACK=1(仅限服务器模式)允许 Docker 主机自身的浏览器跳过共享 PIN。在容器内,该浏览器从默认网关到达,因此只有恰好那个套接字地址符合条件,且仅限主端口(不是 LAN 分享监听器),并且永远不会为其读取转发头。它仅授予 PIN 的消费权限:API 密钥门禁和管理员路由不受影响。默认关闭。风险与设置: Docker 安装。


管理员路由与服务器模式

管理员路由——/system/*(包括 set-env,RCE 级)、/api/settings/*、引擎选择/安装/卸载、媒体工具、MCP 绑定以及发音设置——位于比消费路由更严格的门禁(require_admin、backend/api/dependencies.py)之后。在桌面构建中,它们仅限真实回环:来自其他机器的 PIN、密钥或可信网络都无法到达它们。

在服务器模式(OMNIVOICE_SERVER_MODE=1,即 Docker 镜像)下,回环来源无法强制执行——NAT 会重写来源,甚至 -p 127.0.0.1:3900:3900 映射也看起来是非回环的——因此真实回环要求被放弃(issue #261,否则操作员会被 403 挡在自己的 /system/* 之外)。取而代之的是凭据规则,而不是移除:

  • 未配置任何凭据(既没有 API 密钥也没有共享 PIN)→ 只读的管理员发现功能仍可用于裸 Docker 引导流程,但 POST/PUT/PATCH/DELETE 请求会被拒绝。有副作用的 GET 操作也会被拒绝:引擎健康检查可能启动一个 sidecar,深度诊断可能加载模型,LLM 提供商发现会使用已保存的提供商凭据发起请求。在远程更改设置或触发这些操作之前,请先设置 OMNIVOICE_API_KEY。
  • 已配置 API 密钥 → 管理员操作要求该API 密钥(直接客户端的 Authorization / 旧式查询参数或 cookie)、有效的短期管理员会话,或真正的回环。6 位共享 PIN 不用于管理员门禁(它可被暴力破解),可信网络成员资格也永远不会。因此,仅 PIN 的服务器模式部署会让管理员路由保持仅回环;远程管理员操作以完整的 API 密钥为基础。

受管 sidecar 安装即使有 API 密钥也仍然仅限真实回环。其安装程序会获取可变源码并创建可编辑环境,因此在源码供应链被固定之前,必须在该机器上直接运行。

主机路径绝不会通过 HTTP 选择。Electron 主进程会验证模型缓存和导出目标(在原生保存/打开对话框中选择)以及自定义 FFmpeg/FFprobe 二进制文件,写入一个私有的一次性能力令牌,并且只有这种不透明授权才能到达后端。因此 /export 接受 authorization 令牌,从不接受 destination_path;任意导出路径的暴露操作在原生进程中完成,而 HTTP 回退仅限于服务器拥有的数据根目录。/system/set-env 根本不接受可执行路径键,并且 sidecar 引擎文件夹(OMNIVOICE_*_DIR,后端会运行其中的解释器)在 env 文件中设置(或保持未设置);HTTP API 只能清除它们。服务器模式和 API 密钥不会削弱该原生边界。

这是对一次真实提权漏洞(#1213)的修复:在此之前,服务器模式使管理员门禁形同虚设,因此如果设置了 API 密钥并且配置了可信 CIDR,该 CIDR 内的 LAN 客户端可以完全不带任何凭据 POST /system/set-env——RCE 级——因为 API 密钥中间件将其视为 is_local_host 放行。现在,管理员门禁独立于消费豁免。


来自其他来源的浏览器(CORS)

以上所有内容都只对身份验证设置门槛。浏览器前端在与后端不同的来源上提供时,会先遇到另一堵墙:CORS。后端的允许列表默认仅包含回环 + 桌面来源(http://localhost:<ui-port>、http://127.0.0.1:<ui-port>、app://voicestudio),因此通过 LAN IP 打开开发/源码 UI(例如 http://192.168.1.159:3901 访问 …:3900)会以 "Missing Header: Access-Control-Allow-Origin" 阻止每个请求——无论 OMNIVOICE_SERVER_MODE 或 OMNIVOICE_TRUSTED_NETWORKS 如何,这两者都不影响 CORS(#1348)。

请添加浏览器地址栏中显示的精确来源:

export OMNIVOICE_ALLOWED_ORIGINS="http://192.168.1.159:3901,http://localhost:3901,http://127.0.0.1:3901,app://voicestudio"

每个条目必须是裸来源——scheme://host:port,即浏览器在其 Origin 头中发送的确切内容——不能有路径,也不能有尾部斜杠(http://192.168.1.159:3901/ 永远不会匹配)。该变量替换默认列表,因此请将回环来源和 app://voicestudio 来源与你的自定义来源一同重新列出。(应用内 LAN 分享和 Tailscale 流程见 docs/sharing.md,不需要此设置——它们从同一来源提供 UI 和 API。)如果你只移动了 Vite 开发服务器的端口,请改而设置 OMNIVOICE_UI_PORT(旧的 VOICESTUDIO_UI_PORT 名称仍被接受);Vite 和默认列表都会跟随它。

CORS 同时包裹两道认证关口:无凭据的浏览器预检会在 PIN/API 密钥校验之前得到应答,关口生成的 401 响应也会保留 CORS 头,以便 UI 读取实际失败原因并提示输入正确的凭据。

终止 TLS 的代理必须在 ASGI 服务器边界建立有效的 scheme。Uvicorn 的代理头处理默认信任 loopback,这覆盖了 Tailscale Serve;位于其他地址的自定义代理必须用 --forwarded-allow-ips=<proxy-ip> 列出(且代理头必须保持启用)。VoiceStudio 刻意不在应用内部信任原始的 X-Forwarded-Proto 头:一旦 Uvicorn 接受了受信任的代理,解析出的 ASGI scheme 将驱动精确的 Origin 检查和会话 cookie 的 Secure 属性。对于 /studio 这类公开路径前缀,要么在转发前剥离该前缀,要么将 ASGI root_path 配置为相同的值。WebSocket 票据验证只移除该受信任且已配置的前缀;它绝不会仅因为路径以 /ws/events、/ws/transcribe 或 /ws/tts 结尾就接受任意路径。

来自其他网站和主机名的请求

与上述所有关口无关,后端在每一种配置下都会拒绝两类浏览器流量,包括 loopback:

  • 其他网站发送的请求。 如果状态变更请求(POST、PUT、PATCH、DELETE)或 WebSocket 握手的 Origin 不被允许,将返回 403(WebSocket 关闭码 1008);如果其 Sec-Fetch-Site 为 cross-site 或 same-site 且没有允许的 Origin,同样会被拒绝。浏览器在媒体和下载 GET 请求(<video src>、下载链接)上不发送 Origin;这类请求在其 Referer 是允许的来源时通过,缺少或外部 Referer 则被拒绝。会启动工作或访问机器外部资源的 GET 路由也会拒绝跨站 Sec-Fetch-Site:配音下载和导出(视频、音频、MP3、音轨、片段剪辑)、配音片段和视频预览、转写流、语音和原型预览、生成音频的 Ogg/Opus 编码、历史记录、头像搜索、社区目录读取、安装预检、模型访问检查、存储重新扫描、Hugging Face 令牌检查、LLM 提供商模型列表、Tailscale 状态、引擎健康与磁盘用量检查,以及诊断。普通导航到 UI 不受影响。允许的来源包括此后端自身的来源、app://voicestudio 以及 OMNIVOICE_ALLOWED_ORIGINS 列表(参见 CORS)。不发送 Origin 和 Sec-Fetch-Site 的客户端——脚本、curl、SDK、MCP 客户端、桌面应用自身进程——不受影响。
  • 无法识别的主机名。 仅凭网络位置获得授权(loopback、受信任网络,或未设置 API 密钥时的任意客户端)且未携带有效凭据的请求,必须通过 IP 地址、localhost / *.localhost、本机主机名、Tailscale MagicDNS 名称(*.ts.net)、容器主机别名(host.docker.internal、gateway.docker.internal、host.containers.internal)或列在 OMNIVOICE_ALLOWED_HOSTS、OMNIVOICE_ALLOWED_ORIGINS、OMNIVOICE_MCP_ALLOWED_HOSTS、OMNIVOICE_API_URL、OMNIVOICE_PUBLIC_API_BASE 或 OMNIVOICE_BIND_HOST 中的主机名来访问后端。这可以阻止网页将自身域名重新指向 127.0.0.1(DNS 重绑定)。携带有效 API 密钥或管理员会话(包括 WebSocket 票据)的请求不会进行主机名检查,无论其对端地址是什么——因此同一台机器上的 Caddy 或 cloudflared 可以连同客户端的密钥一起转发原始 Host。错误或缺失的凭据,或仅有分享 PIN,则不会获得这种豁免。UI 外壳、其静态资源以及会话交换也不进行主机名检查,因此位于任何主机名上的远程 UI 仍可加载并登录。要在没有凭据的情况下以其他名称访问后端——例如保留原始 Host 的反向代理,或以 http://nas.lan:3900 打开的 Docker 主机——请将其列出:
export OMNIVOICE_ALLOWED_HOSTS="nas.lan,.home.example"   # ".suffix" allows subdomains

打包的桌面应用没有针对此设置的 Settings 字段。将同一行去掉 export(OMNIVOICE_ALLOWED_HOSTS=nas.lan)添加到用户环境文件——macOS 和 Linux 为 ~/.config/omnivoice/env,Windows 为 %USERPROFILE%\.config\omnivoice\env,或由 OMNIVOICE_ENV_FILE 指定的文件——然后重启 VoiceStudio。Docker 则通过 -e OMNIVOICE_ALLOWED_HOSTS=... 接受该设置。

OMNIVOICE_ALLOWED_HOSTS=* 会关闭主机名检查;仅在代理自身验证 Host 的情况下使用。直接调用 API 的浏览器扩展或其他 Web 应用需要将其来源加入 OMNIVOICE_ALLOWED_ORIGINS。

状态码

状态码 含义 处理方法
401 消费认证失败——{"detail": "PIN required"} 或 {"detail": "API key required"}。 提供 PIN / 密钥(上述的 header、cookie 或查询参数)。WebSocket 会以关闭码 1008 呈现此状态。
403 授权失败:请求来自其他网站或使用了无法识别的主机名(详情)、需要 loopback/本机访问、cookie Origin/CSRF 校验失败、服务器模式下的变更操作缺少管理员凭据,或本机路径能力无效/已过期。 PIN 无法授予管理员或文件系统访问权限。请重新认证 UI;脚本应使用 API 密钥 header;本机操作请从桌面应用运行。管理关口只在有凭据可满足时才会指明所需密钥:配置了 OMNIVOICE_API_KEY 的服务器模式返回 {"detail": "loopback origin or admin API key required"}(内置 UI 会将其导向 API 密钥登录表单);仅 PIN/无密钥的服务器模式和桌面构建返回 {"detail": "loopback origin required"}(只有 loopback 能满足该关口)。
429 管理员会话交换失败超过了每客户端限制、GPU 池已饱和,或模型下载被限流。响应会带有 Retry-After;工作负载限流还会携带 X-VoiceStudio-Retryable: true。 等待 Retry-After 秒后再试。对于认证,重试前请验证主凭据;正确的主凭据永远不会被锁定。

另请参阅

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 om/VoiceStudio