* fix(remote-auth): route API-key 401 to an API-key gate, not the PIN form
When OMNIVOICE_API_KEY is set (remote-backend mode), a non-loopback browser
gets 401 "API key required" from BearerKeyMiddleware. But client.ts fired
`ov:pin-required` on every 401, surfacing the PIN gate — whose payload
(sessionStorage ov_pin / X-OmniVoice-Pin) can never satisfy the API-key
middleware. A remote user was stuck on a PIN form they could not pass.
Read the 401 `detail` and dispatch a single `ov:auth-required` CustomEvent
carrying the mode; RemoteAuthGate renders the matching PIN or API-key form.
Adds a `?api_key=` deep-link bootstrap (one-shot — scrubbed from the URL so a
reload can't re-clobber a corrected key) and a guarded saveApiKey helper.
Backend is unchanged — the two 401s are distinguishable by their `detail`
body ("API key required" vs "PIN required"). Docs: remote-gpu.md gains a
"From a browser" subsection for the new ?api_key= deep link.
* fix(remote-auth): preserve URL hash when scrubbing credentials
The replaceState that scrubs ?api_key=/?pin= rebuilt the URL from pathname
(+ optional query) and dropped url.hash, nuking any deep-link fragment
(e.g. #settings). Rebuild with pathname + (?query) + hash.
Addresses greptile + coderabbit review feedback on #1154.
* fix(remote-auth): guard 401 routing against a non-string/malformed detail
String(detail) can itself throw on a 401 detail whose toString is broken
(e.g. { toString: null }), aborting the auth-event dispatch. Match only real
strings with typeof; anything else falls back to PIN mode.
Addresses coderabbit's 17:03 re-review finding on #1154.
* fix(remote-auth): read the deep-link API key from the URL fragment (#api_key=)
Move the remote-backend deep link from ?api_key= (query) to #api_key=
(fragment): fragments are never sent to the server, so the durable key stays
out of the GPU box's and any reverse proxy's request logs on the page load
(greptile P1). ?pin= stays on the query (QR flow, session PIN).
The bootstrap is extracted into a pure, unit-tested _parseDeepLinkCredentials
helper (pin from the query, api_key from the fragment, one-shot scrub of both,
plus a legacy ?api_key= scrubbed-without-reading so a stray query key never
lingers). Docs document #api_key= with encoding guidance for keys containing
+ / & / # / =.
5.2 KiB
Remote GPU backend
Run the OmniVoice 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.
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 │
│ OmniVoice UI │ ──────────────────────────▶ │ OmniVoice backend │
│ (thin client) │ Authorization: Bearer … │ OMNIVOICE_API_KEY set │
└──────────────┘ └─────────────────────┘
The desktop app is the thin client — there is no separate binary. You set a Backend URL and an API key in Settings, and every request (including the dictation and TTS WebSockets) is sent to the remote with the key attached.
1. On the GPU box: run the backend with a key
Generate a key and start the backend with it set:
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=….
When OMNIVOICE_API_KEY is set, every non-loopback HTTP and WebSocket
request must present it, as Authorization: Bearer <key>, ?api_key=<key>
(browser WebSockets can't set headers), or the ov_key cookie the backend
sets after the first authenticated request. Loopback traffic on the box
itself is never gated, so local tools keep working.
2. Reach it over Tailscale
Install Tailscale on both machines (its client is BSD-3 open source; self-host the control plane with 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:
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).
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
:3900if you didn't use Serve, or no port if you did). - API key: the value of
OMNIVOICE_API_KEYfrom step 1. - Test connection hits
{url}/healthand shows the remote's version and device. - Save & reload stores both in this browser/app and restarts the UI
against the remote. The URL must be a full
http://orhttps://URL (gpu-box:3900alone 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 key is stored for that browser and the fragment is scrubbed from the
address bar (so it doesn't linger in history or get re-applied on a reload). 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 key attached to every request. If a
request ever 401s again (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 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 unlessOMNIVOICE_SERVER_MODE=1is set on the box; in server mode the key is the access control for those too. - The key is compared in constant time and never logged.