Files
VoiceStudio/backend/api/dependencies.py
T
Palash DebnathandClaude Fable 5 22ba348f17 feat(remote): backend URL + bearer key + Tailscale docs (Wave 2.3) (#364)
Run inference on a remote GPU box, drive it from the desktop app — opt-in,
off by default (loopback-only is unchanged when no key is set).

Backend:
- BearerKeyMiddleware (main.py): when OMNIVOICE_API_KEY is set, every
  non-loopback HTTP + WebSocket request must present it (Authorization:
  Bearer, ?api_key=, or the ov_key cookie set on first auth). Pure ASGI
  (no response buffering), loopback always bypasses, SPA shell stays
  reachable. Constant-time compare, never logged.
- ws_remote_authorized() in dependencies; capture_ws lets a keyed
  non-loopback client through its inline loopback guard (the thin-client
  dictation case: mic local, GPU remote).

Frontend:
- api/client.ts: ov_backend_url (localStorage) is the top-precedence base
  override; new wsUrl() derives ws scheme + host from the API base (not
  window.location, which lies in the Tauri webview) and appends ?api_key.
  apiFetch attaches the bearer header. Both WS call sites (dictation,
  events) routed through wsUrl; the HTTP transcribe fallback through
  apiFetch.
- Settings > Sharing > Remote backend panel: URL + key fields, a
  test-connection probe against {url}/health, save-and-reload.

Docs: docs/remote-gpu.md — the Tailscale recipe (MagicDNS + Serve, never
Funnel, headscale note, plain-HTTP-is-sniffable warning, PIN-vs-key split).

Tests: 10 bearer-middleware cases (inert without env, loopback bypass,
401 without/pass with key via header+query, wrong key, shell exemption,
plain-ASGI guard, WS handshake reject/accept). Validated in CI.

Spec: parity program Wave 2.3 / competitive-analysis §R2 rungs 1-3.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 03:57:20 +05:30

106 lines
4.3 KiB
Python

"""
Shared FastAPI dependencies.
These are intentionally tiny — one concern per dependency — so they can be
composed at the route or router level without surprises.
Currently exposed:
- `require_loopback`: 403 unless the request came from a loopback origin
(bypassed in explicit server mode — see `_server_mode`).
- `ws_remote_authorized`: whether a WebSocket handshake from a non-loopback
client carries the remote API key (Wave 2.3) — used by WS endpoints that
keep their own inline loopback guards.
"""
import os
import secrets
from fastapi import HTTPException, Request
# IPv4 + IPv6 loopback literals + the conventional `localhost` hostname.
# `request.client.host` carries an address, not a hostname, so the literal
# "localhost" entry is defensive — some upstream wrappers (TestClient with
# a custom client tuple, certain reverse-proxy headers) may pass strings
# rather than parsed addresses. We accept the broader set without weakening
# the guard: nothing here matches a non-loopback origin.
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
_TRUTHY = frozenset({"1", "true", "yes", "on"})
def _server_mode() -> bool:
"""Whether this process is a headless server deployment (Docker image).
In Docker the loopback gate is *unenforceable*: Docker's network NAT
rewrites ``request.client.host`` to the bridge gateway (e.g. 172.17.0.1)
even for a localhost-only ``-p 127.0.0.1:3900:3900`` mapping, so every
request looks non-loopback and the gate 403s the operator out of the
system/settings routes they need (issue #261 — incl. ``/system/info``,
which blanks the version display).
The Docker image sets ``OMNIVOICE_SERVER_MODE=1`` to opt out of the gate.
Network exposure then rests on the operator's port mapping plus the
optional share PIN (``NetworkAccessMiddleware`` still 401s unauthenticated
non-loopback clients whenever a PIN is set). The desktop build never sets
this, so its loopback boundary — including denying LAN share guests access
to admin routes — is unchanged. Read at call time so it stays testable.
"""
return os.environ.get("OMNIVOICE_SERVER_MODE", "").strip().lower() in _TRUTHY
def require_loopback(request: Request) -> None:
"""Reject any request whose `client.host` is not a loopback address.
Use as a router-level dependency to protect every route on the router
in one place:
router = APIRouter(dependencies=[Depends(require_loopback)])
Or as a per-route dependency for narrower scope:
@router.post("/foo", dependencies=[Depends(require_loopback)])
Returns None on success (FastAPI dependency convention). Raises 403
on rejection — the response body is `{"detail": "loopback origin required"}`
so existing tests for `/system/set-env` keep passing without modification.
In server mode (Docker, see `_server_mode`) the gate is a no-op: the
loopback origin is unenforceable there and exposure is governed by the
deployment's port mapping + the optional share PIN instead.
"""
host = request.client.host if request.client else None
if host in _LOOPBACK_HOSTS:
return
if _server_mode():
return
raise HTTPException(status_code=403, detail="loopback origin required")
def remote_api_key() -> str | None:
"""The remote-backend bearer key (Wave 2.3), or None when remote mode is
off. Read at call time so tests can monkeypatch the env."""
return os.environ.get("OMNIVOICE_API_KEY") or None
def ws_remote_authorized(websocket) -> bool:
"""Whether a WebSocket handshake presents the remote API key.
Browser WebSockets cannot set an Authorization header, so the key may
arrive as ``?api_key=`` or via the ``ov_key`` cookie that the bearer
middleware sets on the first authenticated HTTP request. Returns False
when remote mode is off — callers keep their loopback-only behavior.
"""
key = remote_api_key()
if not key:
return False
auth = websocket.headers.get("authorization", "")
supplied = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
if not supplied:
supplied = (
websocket.query_params.get("api_key")
or websocket.cookies.get("ov_key")
or ""
)
return secrets.compare_digest(supplied, key)