mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 18:25:26 -04:00
Add admin-only companion pairing
Split 3/4 of the companion bridge (#863, #871 landed 1/4 and 2/4). Adds admin-only device pairing to the companion router. - GET /api/companion/pair -- renders a form; never mints (a GET must not mint a credential: SameSite=Lax session cookies ride top-level GET navigations, so GET-minting would be CSRF-triggerable via a link/<img>) - POST /api/companion/pair -- mints a one-time chat-scoped token. Admin-cookie only; CSRF-safe because a SameSite=Lax cookie is not sent on a cross-site POST, the same protection POST /api/tokens relies on. ?format=json returns the pairing payload for an in-app screen. Minting invalidates the auth middleware's token cache so the code works on the next request with no restart. companion/pairing.py holds the mint/LAN/QR helpers; the token is shown once and stored only as a bcrypt hash + prefix (mirrors routes/api_token_routes.py). Tests (tests/test_companion_pairing.py): - a bearer/'api' caller and a non-admin user are rejected by require_admin (403); an admin passes - the token is returned once and persisted only as a hash - minting invalidates the cache (works without restart) - minting is exposed on POST, never GET (CSRF)
This commit is contained in:
+118
-7
@@ -1,19 +1,30 @@
|
||||
"""Companion bridge — read-only endpoints (/api/companion/*).
|
||||
"""Companion bridge — /api/companion/*.
|
||||
|
||||
A thin, additive layer so a LAN client (e.g. a phone) can discover what a server
|
||||
offers without duplicating any LLM logic. This module is intentionally
|
||||
read-only: it exposes a cheap health check, server identity, and the caller's
|
||||
own model list. Pairing/token-minting and any mutation live in separate changes.
|
||||
offers and pair to it, without duplicating any LLM logic.
|
||||
|
||||
Auth is enforced globally by AuthMiddleware (app.py), so reaching a handler here
|
||||
means the caller is authenticated by either a cookie session or a Bearer `ody_`
|
||||
API token.
|
||||
API token. The read endpoints (ping/info/models) accept either; the pairing
|
||||
endpoints are admin-cookie only.
|
||||
|
||||
Pairing CSRF posture: minting happens ONLY on POST. The session cookie is
|
||||
SameSite=Lax (routes/auth_routes.py), which a browser does not send on a
|
||||
cross-site POST, so an admin's cookie can't be used by a malicious page to mint
|
||||
a token -- the same protection the existing POST /api/tokens relies on. Minting
|
||||
on a GET would be unsafe (Lax cookies ride top-level GET navigations), so GET
|
||||
/pair only renders a form.
|
||||
"""
|
||||
|
||||
import html
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from src.auth_helpers import get_current_user
|
||||
|
||||
from companion import pairing as _pairing
|
||||
|
||||
|
||||
def token_owner(request: Request) -> str | None:
|
||||
"""The real owner to attribute a request to, for read-scoping.
|
||||
@@ -40,6 +51,20 @@ def owner_can_see(row_owner, owner) -> bool:
|
||||
return row_owner is None or row_owner == owner
|
||||
|
||||
|
||||
def mint_pairing_token(owner: str, invalidate=None) -> tuple[str, str]:
|
||||
"""Mint a pairing token AND invalidate the auth middleware's in-memory token
|
||||
cache, so the new token is accepted on the very next request without a server
|
||||
restart. Returns (token_id, raw_token); the raw token is shown once.
|
||||
|
||||
`invalidate` is the app's request.app.state.invalidate_token_cache callable
|
||||
(passed in so this stays a pure, testable unit).
|
||||
"""
|
||||
token_id, raw_token = _pairing.mint_token(owner)
|
||||
if callable(invalidate):
|
||||
invalidate()
|
||||
return token_id, raw_token
|
||||
|
||||
|
||||
def setup_companion_routes() -> APIRouter:
|
||||
router = APIRouter(prefix="/api/companion", tags=["companion"])
|
||||
|
||||
@@ -93,8 +118,6 @@ def setup_companion_routes() -> APIRouter:
|
||||
if owner:
|
||||
q = q.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711
|
||||
for ep in q.all():
|
||||
# Defence in depth: never emit a row the owner rule rejects, even
|
||||
# if the SQL filter above were ever loosened.
|
||||
if not owner_can_see(ep.owner, owner):
|
||||
continue
|
||||
try:
|
||||
@@ -121,4 +144,92 @@ def setup_companion_routes() -> APIRouter:
|
||||
db.close()
|
||||
return {"endpoints": out}
|
||||
|
||||
@router.get("/pair")
|
||||
def pair_page(request: Request):
|
||||
"""Admin-only pairing page. Renders a form that POSTs to mint a code.
|
||||
|
||||
A GET never mints a credential: SameSite=Lax session cookies ride
|
||||
top-level GET navigations, so minting on GET would be triggerable by a
|
||||
link or <img> (CSRF). The actual mint is the POST handler below.
|
||||
"""
|
||||
require_admin(request)
|
||||
page = """<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Pair a device</title>
|
||||
<style>
|
||||
body{font-family:-apple-system,system-ui,sans-serif;max-width:520px;margin:48px auto;padding:0 20px;color:#e8e8e8;background:#16161a}
|
||||
.card{background:#1f1f25;border:1px solid #2c2c35;border-radius:14px;padding:28px;text-align:center}
|
||||
button{background:#7c9cff;color:#0e0e12;border:none;border-radius:10px;padding:12px 20px;font-size:15px;font-weight:600;cursor:pointer}
|
||||
</style></head>
|
||||
<body><div class="card">
|
||||
<h2>Pair a device</h2>
|
||||
<p>Generate a one-time pairing code (a chat-scoped API token) for a LAN client.</p>
|
||||
<form method="POST" action="/api/companion/pair">
|
||||
<button type="submit">Generate pairing code</button>
|
||||
</form>
|
||||
<p style="color:#8a8a96;font-size:12px;margin-top:18px">Admin only. Each code mints a new token, shown once. Manage or revoke under Settings → API tokens.</p>
|
||||
</div></body></html>"""
|
||||
return HTMLResponse(page)
|
||||
|
||||
@router.post("/pair")
|
||||
def pair_create(request: Request):
|
||||
"""Mint a pairing code. Admin-cookie only; CSRF-safe because the
|
||||
SameSite=Lax session cookie is not sent on a cross-site POST (same
|
||||
protection as POST /api/tokens). Minting invalidates the token cache so
|
||||
the code works immediately, no restart. `?format=json` returns the
|
||||
payload for an in-app pairing screen."""
|
||||
require_admin(request)
|
||||
owner = get_current_user(request)
|
||||
invalidate = getattr(request.app.state, "invalidate_token_cache", None)
|
||||
token_id, raw_token = mint_pairing_token(owner, invalidate)
|
||||
|
||||
hosts = _pairing.lan_ip_candidates()
|
||||
host = hosts[0] if hosts else "127.0.0.1"
|
||||
port = request.url.port or _pairing.default_port()
|
||||
payload = _pairing.pairing_payload(host, port, raw_token)
|
||||
qr = _pairing.pairing_qr_png_data_uri(payload)
|
||||
qr_ok = bool(qr and qr.startswith("data:image/png;base64,"))
|
||||
|
||||
if (request.query_params.get("format") or "").lower() == "json":
|
||||
return {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"token": raw_token,
|
||||
"token_id": token_id,
|
||||
"hosts": hosts,
|
||||
"payload": payload,
|
||||
"qr": qr if qr_ok else None,
|
||||
}
|
||||
|
||||
import json as _json
|
||||
payload_json = _json.dumps(payload, separators=(",", ":"))
|
||||
# Only ever emit a known PNG data-URI into the src; every other value is
|
||||
# html.escaped.
|
||||
qr_block = (
|
||||
f'<img src="{html.escape(qr)}" alt="Pairing QR" width="260" height="260">'
|
||||
if qr_ok else "<p><em>QR rendering unavailable -- enter the details manually.</em></p>"
|
||||
)
|
||||
page = f"""<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Pairing code</title>
|
||||
<style>
|
||||
body{{font-family:-apple-system,system-ui,sans-serif;max-width:520px;margin:40px auto;padding:0 20px;color:#e8e8e8;background:#16161a}}
|
||||
.card{{background:#1f1f25;border:1px solid #2c2c35;border-radius:14px;padding:24px;text-align:center}}
|
||||
code{{background:#0e0e12;padding:2px 6px;border-radius:6px;word-break:break-all}}
|
||||
.row{{text-align:left;margin:10px 0;font-size:14px;color:#bdbdc7}}
|
||||
.warn{{color:#e0a85e;font-size:13px;margin-top:18px}}
|
||||
</style></head>
|
||||
<body><div class="card">
|
||||
<h2>Pairing code</h2>
|
||||
{qr_block}
|
||||
<div class="row"><strong>Host:</strong> <code>{html.escape(host)}</code></div>
|
||||
<div class="row"><strong>Port:</strong> <code>{html.escape(str(port))}</code></div>
|
||||
<div class="row"><strong>Token:</strong> <code>{html.escape(raw_token)}</code></div>
|
||||
<div class="row"><strong>Payload:</strong> <code>{html.escape(payload_json)}</code></div>
|
||||
<p class="warn">Shown once. This grants chat access to your Odysseus; revoke it
|
||||
in Settings → API tokens (id <code>{html.escape(token_id)}</code>). The
|
||||
device must be on the same network, and the server must bind to your LAN.</p>
|
||||
</div></body></html>"""
|
||||
return HTMLResponse(page)
|
||||
|
||||
return router
|
||||
|
||||
Reference in New Issue
Block a user