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>
88 lines
2.5 KiB
Python
88 lines
2.5 KiB
Python
"""BearerKeyMiddleware — remote-backend API key gate (Wave 2.3).
|
|
|
|
Mirrors tests/test_network_middleware.py: a TestClient with a chosen client
|
|
address exercises the loopback bypass, the SPA-shell exemption, and the
|
|
401-without / pass-with-key paths. The env var is the switch.
|
|
"""
|
|
import os
|
|
|
|
os.environ.setdefault("OMNIVOICE_MODEL", "test")
|
|
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def key_env(monkeypatch):
|
|
monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret-key")
|
|
yield "s3cret-key"
|
|
|
|
|
|
def _client(addr=("10.0.0.5", 1)):
|
|
from fastapi.testclient import TestClient
|
|
from main import app
|
|
return TestClient(app, client=addr)
|
|
|
|
|
|
def test_inert_without_env(monkeypatch):
|
|
monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False)
|
|
c = _client() # non-loopback
|
|
assert c.get("/health").status_code == 200
|
|
|
|
|
|
def test_loopback_bypasses_key(key_env):
|
|
c = _client(("127.0.0.1", 1))
|
|
assert c.get("/system/info").status_code == 200
|
|
|
|
|
|
def test_non_loopback_without_key_401(key_env):
|
|
c = _client()
|
|
r = c.get("/v1/audio/voices")
|
|
assert r.status_code == 401
|
|
assert r.json()["detail"] == "API key required"
|
|
|
|
|
|
def test_non_loopback_with_bearer_passes(key_env):
|
|
c = _client()
|
|
r = c.get("/v1/audio/voices", headers={"Authorization": "Bearer s3cret-key"})
|
|
assert r.status_code != 401
|
|
|
|
|
|
def test_query_param_key_passes(key_env):
|
|
c = _client()
|
|
r = c.get("/v1/audio/voices?api_key=s3cret-key")
|
|
assert r.status_code != 401
|
|
|
|
|
|
def test_wrong_key_401(key_env):
|
|
c = _client()
|
|
r = c.get("/v1/audio/voices", headers={"Authorization": "Bearer nope"})
|
|
assert r.status_code == 401
|
|
|
|
|
|
def test_shell_paths_served_without_key(key_env):
|
|
c = _client()
|
|
assert c.get("/health").status_code == 200
|
|
|
|
|
|
def test_middleware_is_plain_asgi():
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from main import BearerKeyMiddleware
|
|
assert not issubclass(BearerKeyMiddleware, BaseHTTPMiddleware)
|
|
assert callable(getattr(BearerKeyMiddleware, "__call__", None))
|
|
|
|
|
|
def test_ws_handshake_rejected_without_key(key_env):
|
|
"""A non-loopback WS handshake without the key is closed, not accepted."""
|
|
c = _client()
|
|
with pytest.raises(Exception):
|
|
with c.websocket_connect("/ws/transcribe"):
|
|
pass
|
|
|
|
|
|
def test_ws_handshake_accepted_with_query_key(key_env):
|
|
c = _client()
|
|
# ws_remote_authorized reads ?api_key; the capture handler then accepts.
|
|
with c.websocket_connect("/ws/transcribe?api_key=s3cret-key") as ws:
|
|
ws.close()
|