* fix(security): replace persistent admin keys with sessions Exchange the remote administrator key once for bounded, revocable credentials. Canonicalize backend principals, enforce cookie CSRF and exact origins, and use path-bound one-use WebSocket tickets. Migrate the bundled UI away from durable master-key storage and credential-bearing URLs. Add unit, integration, static-hygiene, and production-browser regressions plus synchronized operator documentation. * docs: link session hardening to PR 1528 * fix(security): key session indexes with process pepper Use HMAC-SHA-256 instead of an unkeyed digest for in-memory session and WebSocket-ticket indexes. This preserves constant-size lookup identifiers, makes copied records unusable without the process pepper, and resolves CodeQL's weak sensitive-data hash finding. * fix(auth): align empty bearer migration precedence Centralize the Authorization-channel presence decision with canonical principal parsing. Bearer followed only by spaces now remains an empty channel during legacy cookie migration, while unsupported or invalid explicit credentials stay authoritative and fail closed. * fix(security): harden admin session review boundaries * fix(security): derive key generations with HKDF * fix(auth): anchor the admin-session store so module reloads cannot fork it test_master_exchange_does_not_bypass_pin_on_normal_routes failed in full-suite runs: test_mcp_bindings' client fixture purges the services.* tree from sys.modules and reloads main, so api.routers.auth re-imported a fresh services.admin_sessions (new AdminSessionStore) while core.auth kept its import-time reference to the old one — the exchange issued the cookie into one store and the middleware resolved it against another, turning the expected "PIN required" into "API key required". Root cause is the class of bug, not the one test: a process-global auth store defined as a bare module-level singleton forks under importlib.reload or purge-and-reimport. Fix at the source: admin_session_store now resolves through a synthetic sys.modules anchor (_omnivoice_admin_session_store_anchor) that reloads never re-execute and package-prefix purges never match, so every copy of the module shares the one per-process store. No consumer or behavior changes. Regression test reproduces both fork vectors (in-place reload and sys.modules purge + fresh import) and asserts previously issued sessions still resolve and the store identity is preserved; it fails before this fix and passes after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): honor X-Forwarded-Proto for CSRF origin and Secure cookies behind TLS proxies Behind Tailscale Serve (docs/remote-gpu.md) or any TLS-terminating proxy, the browser talks https while the backend hop stays http, so exact-origin CSRF compared an https Origin against an http expectation and rejected every legitimate request, and the session cookie shipped without Secure. uvicorn's ProxyHeadersMiddleware only rewrites the scope for loopback peers, which misses Docker and any non-loopback proxy topology. New core.csrf.effective_scheme derives the client-facing scheme: resolved scope first (uvicorn's trusted-proxy rewrite wins), then an upgrade-only read of X-Forwarded-Proto's first value — https/wss promotes http to https, everything else is ignored, and a genuine TLS hop can never be downgraded. Used by both the destination-origin comparison and auth._secure_cookie so the WS-ticket/logout CSRF paths and the cookie Secure flag agree. Spoofing gains nothing: the host:port half of the origin tuple is untouched, browsers cannot attach the header cross-site without a preflight this API never grants, and forging it on plain http only adds Secure (the browser then drops the cookie — self-harm only). Regression tests: proxied https origin accepted (origin check, Secure flag, logout), comma-separated chains, scope-fallback path, spoofed header still rejects cross-origin, cannot downgrade real https, junk values ignored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): consume the stored admin key only after a successful exchange A remote-backend user upgrading with their backend unreachable lost the only stored copy of OMNIVOICE_API_KEY: every migration path deleted the durable ov_api_key BEFORE the session exchange settled, stranding them until they recovered the key from the server box. Close the whole class: - client.ts bootstrap: read the legacy key, exchange first, and remove the durable copy only after the exchange succeeds; on failure the key stays so the next launch retries the migration (auth gate still rises). - authSession.ts exchangeApiKey: move removeLegacyMaster from before the fetch to the cookie/bearer success paths — the key never coexists with a live session, but a rejected or hung exchange no longer consumes it. - remoteBackendProbe.ts configuredRemoteBackend: stop wiping the key on every app mount. - RemoteBackendPanel: a connection test or an aborted save no longer wipes the pending key; only disabling the remote backend discards it. - prefKeys.js: ov_api_key moves from PREF_KEYS to PRESERVED_KEYS — factory reset preserves the pending connection credential exactly like ov_backend_url; the successful migration is what deletes it. Tighten the credential-hygiene static guard to match: it accepted sessionStorage.setItem('ov_api_key', …) — the exact class it exists to close. The guard now flags .setItem(<master key>) on any storage receiver, quote style, or injected-store alias, with a self-test pinning what it catches and what stays legal. Fail-before/pass-after regression tests: backend unreachable retains the key and the next bootstrap retries it; a successful exchange removes it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(auth): make session validation occupancy-independent * test(auth): catch optional master-key storage calls * feat(docs): add PR control document for bultodepapas in VoiceStudio * docs: keep the PR tracking board in the fork; credit the changelog line The pr-control document is excellent process discipline, but it is the contributor's own operational board (their inventory, their update commands) — it lives naturally in their fork, and docs/agents/ here is context every repo agent loads. Removed with appreciation; the changelog line gains its contributor credit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: debpalash <4178343+debpalash@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
165 lines
8.4 KiB
Markdown
165 lines
8.4 KiB
Markdown
# Remote GPU backend
|
|
|
|
Run the VoiceStudio backend on one machine (a GPU box, a home server) and drive
|
|
it from the desktop app or a browser on another — over your tailnet, with the
|
|
inference staying on the powerful machine.
|
|
|
|
> Calling the API from your own scripts rather than the desktop app? See
|
|
> [docs/api-auth.md](api-auth.md) for a consumer-focused reference of every auth
|
|
> gate (share PIN, API key, dictation WebSocket, trusted networks) with the exact
|
|
> headers, params, and `401`/`403`/`429` meanings.
|
|
|
|
> Want to keep working *here* and only send individual jobs to another GPU? That
|
|
> is a different feature — see [docs/remote-workers.md](remote-workers.md). This
|
|
> page moves the whole backend (and your projects with it) to the other machine;
|
|
> remote workers keep everything local and farm out single tasks. Both are
|
|
> supported, and setting one up does not affect the other.
|
|
|
|
This is opt-in and off by default: with no API key set, the backend stays
|
|
loopback-only exactly as before.
|
|
|
|
## The shape
|
|
|
|
```
|
|
┌──────────────┐ tailnet (WireGuard) ┌─────────────────────┐
|
|
│ laptop │ ws/https to MagicDNS URL │ gpu-box │
|
|
│ VoiceStudio UI │ ──────────────────────────▶ │ VoiceStudio backend │
|
|
│ (thin client) │ short-lived session/ticket │ OMNIVOICE_API_KEY set │
|
|
└──────────────┘ └─────────────────────┘
|
|
```
|
|
|
|
The desktop app *is* the thin client — there is no separate binary. You enter a
|
|
**Backend URL** and an **API key** in Settings. The key is exchanged once for a
|
|
short-lived session; ordinary HTTP requests use that session and WebSockets use
|
|
path-bound, single-use tickets. The master is never stored or put in a URL.
|
|
|
|
## 1. On the GPU box: run the backend with a key
|
|
|
|
Generate a key and start the backend with it set:
|
|
|
|
```bash
|
|
export OMNIVOICE_API_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(24))')"
|
|
export OMNIVOICE_SERVER_MODE=1 # headless: relaxes the loopback admin gate
|
|
uv run uvicorn backend.main:app --host 0.0.0.0 --port 3900
|
|
```
|
|
|
|
The Docker image is the same idea — pass `-e OMNIVOICE_API_KEY=…`.
|
|
|
|
If a **browser** will load the UI from a different origin than the backend
|
|
(e.g. a Vite dev server on `:3901` opened via the box's LAN IP), you also need
|
|
the backend's CORS allow-list to include that origin — see
|
|
[Browsers from another origin (CORS)](api-auth.md#browsers-from-another-origin-cors);
|
|
neither server mode nor trusted networks covers CORS.
|
|
|
|
When `OMNIVOICE_API_KEY` is set, every non-loopback request needs an accepted
|
|
credential. Scripts should use `Authorization: Bearer <key>`. Legacy
|
|
`?api_key=` and `ov_key` transports remain accepted for compatibility, but the
|
|
backend no longer creates a master-key cookie and the bundled UI uses only
|
|
short-lived sessions. Loopback traffic on the box itself remains ungated.
|
|
|
|
## 2. Reach it over Tailscale
|
|
|
|
Install [Tailscale](https://tailscale.com/) on both machines (its client is
|
|
BSD-3 open source; self-host the control plane with
|
|
[headscale](https://github.com/juanfont/headscale) if you want a fully open
|
|
stack). Then the box is reachable at its MagicDNS name:
|
|
|
|
```
|
|
http://gpu-box.your-tailnet.ts.net:3900
|
|
```
|
|
|
|
For TLS (recommended — see the warning below), put the port behind
|
|
**Tailscale Serve** on the box:
|
|
|
|
```bash
|
|
tailscale serve 3900
|
|
# now reachable at https://gpu-box.your-tailnet.ts.net
|
|
```
|
|
|
|
Serve terminates on the node and forwards from `127.0.0.1`, so to the backend
|
|
the request looks like loopback — which is why the **API key is still
|
|
required** in that path (the bearer gate doesn't rely on the source address
|
|
for non-local exposure; set the key and it always applies to keyed clients).
|
|
Uvicorn trusts proxy headers from loopback by default, so Serve's forwarded
|
|
HTTPS scheme becomes the authoritative ASGI scheme and browser session cookies
|
|
receive `Secure`. For a non-loopback reverse proxy, explicitly configure
|
|
Uvicorn's `--forwarded-allow-ips=<proxy-ip>`; the application never trusts an
|
|
arbitrary `X-Forwarded-Proto` header itself.
|
|
|
|
> **Do not use `tailscale funnel`** (public-internet exposure) for this. Even
|
|
> with a key, a voice-cloning backend should not be on the open internet.
|
|
|
|
## 3. In the app: point at the remote
|
|
|
|
Settings → Sharing → **Remote backend**:
|
|
|
|
- **Backend URL**: the MagicDNS URL from step 2 (with `:3900` if you didn't
|
|
use Serve, or no port if you did).
|
|
- **API key**: the value of `OMNIVOICE_API_KEY` from step 1.
|
|
- **Test connection** hits the auth-exempt `{url}/health` with no credential,
|
|
then exchanges the entered key for a session if health succeeds.
|
|
- **Save & reload** stores only the URL and restarts the UI against the remote.
|
|
The key input is cleared after its single exchange. The URL must be a full
|
|
`http://` or `https://` URL
|
|
(`gpu-box:3900` alone is rejected), and saving a URL that hasn't passed
|
|
**Test connection** asks for confirmation first — a wrong base would leave
|
|
the app unable to reach any backend until you change it back here.
|
|
|
|
Leave the URL empty to go back to the local backend.
|
|
|
|
### From a browser (no desktop app)
|
|
|
|
You can also drive the remote from a plain browser — open the URL with the key
|
|
in the **fragment** once:
|
|
|
|
```
|
|
https://gpu-box.your-tailnet.ts.net/#api_key=<key>
|
|
```
|
|
|
|
Use the fragment (`#`, not `?`) deliberately: fragments are never sent to the
|
|
server, so the key stays out of the GPU box's and any reverse proxy's request
|
|
logs. The fragment is scrubbed synchronously, then the key is exchanged once
|
|
for an eight-hour maximum session; the master is not stored. If
|
|
your key contains `+`, `&`, `#`, or `=`, URL-encode it (e.g. `#api_key=a%2Bb`);
|
|
keys from `secrets.token_urlsafe` (above) need no encoding.
|
|
Thereafter the UI loads normally with the short-lived session. Cross-origin
|
|
bearer sessions are tab-scoped; closing the tab requires re-entry. If a request
|
|
401s again (expired/wrong/rotated key), you're prompted to re-enter it. The
|
|
same gate shows a LAN-share **PIN** prompt instead when network sharing — not a
|
|
remote key — is what's gating access.
|
|
|
|
## Security notes
|
|
|
|
- **Plain HTTP is sniffable.** A bearer key over `http://` on a hostile
|
|
network can be read off the wire. Use Tailscale (WireGuard-encrypted) or
|
|
Tailscale Serve (TLS) for anything beyond a fully trusted LAN.
|
|
- The first-party UI never persists `OMNIVOICE_API_KEY`, never creates a URL
|
|
containing it, and never puts its administrator session in a WebSocket URL.
|
|
WebSocket tickets expire after 30 seconds and work once for one path.
|
|
- The API key and the LAN-share **PIN** are independent: the PIN guards a
|
|
casual share session, the key is the durable remote credential. Either can
|
|
be active; both are checked when set.
|
|
- Admin routes (`/system/*`, `/api/settings/*`) stay loopback-gated unless
|
|
`OMNIVOICE_SERVER_MODE=1` is set on the box; in server mode the **API key** is
|
|
the access control for those too (the short share PIN is consumption-only and
|
|
does not gate admin) — see the credential rule below.
|
|
- **Trust a LAN or reverse proxy with `OMNIVOICE_TRUSTED_NETWORKS`.** If you run
|
|
VoiceStudio behind a reverse proxy (nginx, Caddy, NPM) or only expose it on a
|
|
trusted LAN/Tailnet, set `OMNIVOICE_TRUSTED_NETWORKS` to a comma-separated list
|
|
of CIDRs (e.g. `192.168.1.0/24,10.0.0.0/8`); clients from those networks are
|
|
then treated as trusted by the **consumption** gates (share PIN, API key,
|
|
dictation WebSocket) and need no key/PIN. **Admin routes** (`/system/*`,
|
|
`/api/settings/*`) stay true-loopback-only — use `OMNIVOICE_SERVER_MODE=1` for
|
|
headless admin. It's the granular alternative to
|
|
`OMNIVOICE_SERVER_MODE=1` (which trusts *all* non-loopback sources) and
|
|
sidesteps a proxy that strips the `Authorization` header. Default empty — no
|
|
change to the strict loopback default. **Trusted-network membership is a
|
|
*consumption* exemption only — it never unlocks admin by itself, even in
|
|
server mode (#1213).** When combined with `OMNIVOICE_SERVER_MODE=1`, a
|
|
trusted-network client that presents no credential still gets `403` on the
|
|
admin routes (unless no credential is configured at all, the bare-Docker #261
|
|
flow, where admin is open); if a credential is set, only the **API key** — not
|
|
the share PIN — reaches admin. See [`api-auth.md`](api-auth.md) for the full
|
|
two-tier model.
|
|
- The key is compared in constant time and never logged.
|