243 lines
11 KiB
Markdown
243 lines
11 KiB
Markdown
# Authenticating the local API
|
|
|
|
OmniVoice's backend is **loopback-only and unauthenticated by default** — a
|
|
script running on the same machine as `http://localhost:3900` needs no key, no
|
|
PIN, no header. Everything on this page only matters once you reach the backend
|
|
from **another device** (a phone on your LAN, a laptop over Tailscale, a client
|
|
behind a reverse proxy).
|
|
|
|
There are two independent gates, both **inert until you turn them on**, plus one
|
|
env var that exempts trusted callers:
|
|
|
|
| Gate | Turn on with | Guards | Applies to |
|
|
|---|---|---|---|
|
|
| **Share PIN** | the in-app Network share toggle | casual LAN-share guests, one session | non-loopback **HTTP** |
|
|
| **API key** | `OMNIVOICE_API_KEY` env var on the backend | a durable remote credential | non-loopback **HTTP + WebSocket** |
|
|
| **Trusted networks** | `OMNIVOICE_TRUSTED_NETWORKS` env var | *exempts* the two gates above | non-loopback **consumption** routes only |
|
|
|
|
Loopback traffic (`127.0.0.1`, `::1`, `localhost`) is **never** gated — local
|
|
tools keep working unchanged whichever gate is set.
|
|
|
|
> OmniVoice separates **consumption** (TTS, dictation, voices) from
|
|
> **administration** (`/system/*`, `/api/settings/*` — RCE-class). The PIN and
|
|
> trusted networks are *consumption* credentials; the **admin surface is only
|
|
> ever reached from loopback or with the API key** (see [Admin routes](#admin-routes-and-server-mode)).
|
|
|
|
> Both gates can be active at once. The PIN and the API key are independent; when
|
|
> both are set, each is checked on the paths it covers.
|
|
|
|
---
|
|
|
|
## Share PIN
|
|
|
|
The PIN is the lightweight, in-app path: flip on **Network** sharing (footer
|
|
**Local** pill → **Network**, or **Settings → Sharing & Remote Access**) and the
|
|
app generates a fresh **6-digit PIN** for that session. It is regenerated every
|
|
time you enable sharing and is **never written to disk**. See
|
|
[docs/sharing.md](sharing.md) for the UI walkthrough.
|
|
|
|
While a PIN is set, every **non-loopback HTTP request** to an API route must
|
|
present it. Supply it any one of three ways:
|
|
|
|
| Where | How |
|
|
|---|---|
|
|
| Header | `X-OmniVoice-Pin: <pin>` |
|
|
| Query param | `?pin=<pin>` |
|
|
| Cookie | `ov_pin=<pin>` — the backend sets this automatically after the first valid PIN, so browser sessions only prove it once |
|
|
|
|
```bash
|
|
# From another device on the LAN — with the PIN
|
|
curl http://<host>:3900/v1/audio/voices \
|
|
-H "X-OmniVoice-Pin: 123456"
|
|
```
|
|
|
|
A missing or wrong PIN returns:
|
|
|
|
```
|
|
HTTP/1.1 401 Unauthorized
|
|
{"detail": "PIN required"}
|
|
```
|
|
|
|
Notes on the PIN gate (`NetworkAccessMiddleware`, `backend/main.py`):
|
|
|
|
- It covers **HTTP only** — it does **not** gate WebSockets. The dictation
|
|
WebSocket has its own guard (see below), and the PIN does **not** authorize
|
|
it; use the API key or a trusted network for remote dictation.
|
|
- The SPA shell (`/`, `/index.html`, `/favicon*`, `/assets/*`, `/health`) is
|
|
always served un-PIN'd so the PIN-prompt UI can load.
|
|
- It is a **consumption** credential: a valid PIN never unlocks the admin surface
|
|
(it is 6 digits, brute-forceable). Admin needs loopback or the API key.
|
|
- It is completely inert when no PIN is set (the default, and every Docker
|
|
deploy).
|
|
|
|
---
|
|
|
|
## API key
|
|
|
|
The API key is the durable credential for running the backend somewhere and
|
|
driving it remotely — a GPU box on your tailnet, a Docker container, a
|
|
reverse-proxied host. Set it on the **backend** process:
|
|
|
|
```bash
|
|
# 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=…
|
|
```
|
|
|
|
While `OMNIVOICE_API_KEY` is set, every **non-loopback HTTP and WebSocket**
|
|
request must present it (the SPA shell paths below are the only HTTP exception).
|
|
Supply it any one of three ways:
|
|
|
|
| Where | How |
|
|
|---|---|
|
|
| Header | `Authorization: Bearer <key>` — **preferred**; the one place a key isn't at risk of landing in a log |
|
|
| Cookie | `ov_key=<key>` — set automatically after the first authenticated HTTP request; the safer fallback for browser WebSockets |
|
|
| Query param | `?api_key=<key>` — last resort (browser WebSockets can't set headers). **A key in a URL leaks into proxy/access logs and browser history** — prefer the header or cookie |
|
|
|
|
```bash
|
|
# 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
|
|
```
|
|
|
|
```python
|
|
# 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.",
|
|
)
|
|
audio.stream_to_file("speech.wav")
|
|
```
|
|
|
|
A missing or wrong key returns:
|
|
|
|
```
|
|
HTTP/1.1 401 Unauthorized
|
|
{"detail": "API key required"}
|
|
```
|
|
|
|
On a **WebSocket**, a missing or wrong key rejects the handshake with close
|
|
code **1008** (policy violation) instead of a JSON body.
|
|
|
|
Notes on the API-key gate (`BearerKeyMiddleware`, `backend/main.py`):
|
|
|
|
- The key is compared in **constant time** and is **never logged**.
|
|
- The SPA shell paths bypass the gate on **HTTP** so a remote UI can load and
|
|
show what's wrong; WebSockets have no such exemption.
|
|
- **Plain HTTP is sniffable** — a Bearer key over `http://` on a hostile
|
|
network can be read off the wire. Use Tailscale (WireGuard) or TLS for
|
|
anything beyond a fully trusted LAN. See
|
|
[docs/remote-gpu.md](remote-gpu.md) for the full remote-backend setup.
|
|
|
|
---
|
|
|
|
## Dictation WebSocket
|
|
|
|
The live-dictation stream at **`ws://<host>:3900/ws/transcribe`** carries its
|
|
own inline guard (`backend/api/routers/capture_ws.py`) *in addition to* the
|
|
API-key middleware. A non-loopback client reaches it only if it is **either**:
|
|
|
|
- on a [trusted network](#trusted-networks) (`is_local_host` passes), **or**
|
|
- presenting the **API key** — as `Authorization: Bearer <key>`, the `ov_key`
|
|
cookie, or `?api_key=<key>` (URL keys leak into logs — prefer the cookie).
|
|
|
|
```
|
|
ws://gpu-box:3900/ws/transcribe?api_key=<key>
|
|
```
|
|
|
|
The **share PIN does not authorize dictation** — the PIN gate is HTTP-only, and
|
|
the dictation guard checks only the API key (or trusted-network membership). A
|
|
LAN guest who has only entered a PIN can use the HTTP API but **not** live
|
|
dictation. When neither the API key nor a trusted network applies, the handshake
|
|
is closed with code **1008** and reason `loopback origin required`.
|
|
|
|
---
|
|
|
|
## Trusted networks
|
|
|
|
`OMNIVOICE_TRUSTED_NETWORKS` is a comma-separated list of **CIDR ranges** whose
|
|
clients are treated as loopback-trusted by the **consumption** gates — so a
|
|
reverse proxy or a trusted LAN/Tailnet can reach the API **without a PIN or key**
|
|
(useful when a proxy strips the `Authorization` header).
|
|
|
|
```bash
|
|
export OMNIVOICE_TRUSTED_NETWORKS="192.168.1.0/24,10.0.0.0/8"
|
|
```
|
|
|
|
What it exempts vs. what it does not:
|
|
|
|
- **Exempts** (via `is_local_host`, `backend/api/dependencies.py`): the share
|
|
PIN gate, the API-key gate, and the dictation WebSocket guard. Clients in a
|
|
listed range need no PIN or key for these **consumption** routes.
|
|
- **Never exempts admin.** `/system/*` and `/api/settings/*` are the RCE-class
|
|
admin surface; trusted-network membership is a *consumption* exemption and
|
|
does not reach them — even under `OMNIVOICE_SERVER_MODE=1`. See
|
|
[Admin routes](#admin-routes-and-server-mode).
|
|
|
|
Details: malformed CIDR entries are silently ignored (a bad entry never wedges
|
|
the gate); IPv4-mapped IPv6 addresses (`::ffff:192.168.1.5`) from dual-stack
|
|
proxies are unwrapped so they match IPv4 CIDRs; the value is read at request
|
|
time, so in production **restart the backend** to apply a change. Default empty
|
|
— no change to the strict loopback default.
|
|
|
|
---
|
|
|
|
## Admin routes and server mode
|
|
|
|
Admin routes — `/system/*` (including `set-env`, **RCE-class**),
|
|
`/api/settings/*`, engine install/uninstall, media tools, MCP bindings — sit on
|
|
a stricter gate (`require_loopback`, `backend/api/dependencies.py`) than
|
|
consumption. On the desktop build they are **true-loopback-only**: no PIN, key,
|
|
or trusted network reaches them from another machine.
|
|
|
|
In **server mode** (`OMNIVOICE_SERVER_MODE=1`, the Docker image) the loopback
|
|
origin is unenforceable — NAT rewrites the source and even a
|
|
`-p 127.0.0.1:3900:3900` mapping looks non-loopback — so the true-loopback
|
|
requirement is dropped (issue #261, else the operator is 403'd out of their own
|
|
`/system/*`). It is replaced by a **credential rule**, not removed:
|
|
|
|
- **No credential configured** (no API key, no PIN) → admin is open. The bare
|
|
Docker flow; exposure rests entirely on your port mapping / firewall.
|
|
- **A credential is configured** → admin requires the **API key** (`Authorization:
|
|
Bearer` / `?api_key` / `ov_key` cookie), or genuine loopback. The **6-digit
|
|
share PIN does not gate admin** (it is brute-forceable), and trusted-network
|
|
membership never does either. So a **PIN-only** server-mode deployment keeps
|
|
admin loopback-only; remote admin requires the long API key.
|
|
|
|
This is the fix for a real escalation (#1213): before it, server mode made the
|
|
admin gate a no-op, so with an API key set *and* a trusted CIDR configured, a LAN
|
|
client in that CIDR could `POST /system/set-env` — RCE-class — with **no
|
|
credential at all**, because the API-key middleware waved it through as
|
|
`is_local_host`. Now the admin gate is independent of the consumption exemptions.
|
|
|
|
---
|
|
|
|
## Status codes
|
|
|
|
| Code | Meaning | What to do |
|
|
|---|---|---|
|
|
| **401** | Consumption auth failed — `{"detail": "PIN required"}` or `{"detail": "API key required"}`. | Supply the PIN / key (header, cookie, or query param above). A WebSocket surfaces this as close code **1008**. |
|
|
| **403** | `{"detail": "loopback origin required"}` — you reached a **loopback-gated** route (admin: `/system/*`, `/api/settings/*`; or a `require_local` route from outside a trusted network) from a non-loopback origin. | A PIN won't help. Run the request from the box itself; for `require_local` routes add the caller to `OMNIVOICE_TRUSTED_NETWORKS`; for **admin** routes use `OMNIVOICE_SERVER_MODE=1` **and** present the **API key** (the PIN/trusted-network don't reach admin). |
|
|
| **429** | **Not an auth failure.** The GPU pool is saturated (admission control) or a model download is rate-limited. Ships with `Retry-After` and `X-OmniVoice-Retryable: true`. | Back off for `Retry-After` seconds and retry the identical request. |
|
|
|
|
---
|
|
|
|
## See also
|
|
|
|
- [docs/remote-gpu.md](remote-gpu.md) — end-to-end remote-backend setup over
|
|
Tailscale, with the API key.
|
|
- [docs/sharing.md](sharing.md) — the in-app LAN share + PIN flow.
|
|
- [docs/agentic-voice.md](agentic-voice.md) — pointing OpenAI-compatible agent
|
|
frameworks at OmniVoice.
|
|
- [docs/mcp.md](mcp.md) — the MCP server for AI agents.
|