Fem nye værktøjer i portal-navnerummet: yt_status, yt_download, yt_watch, yt_unwatch, yt_check_subs. Gatewayen monterer mcp_server under namespace "portal", så de arver portal_-præfikset og de scopes nøglerne allerede har — ingen ekstra registrering. Download og abonnement-tjek returnerer med det samme og arbejder videre i baggrunden, så et MCP-kald ikke blokerer i minutter på en lang video. Importerer samtidig mcp_server.py, mcp_gateway.py, mcpkeys.py og templates/admin_mcp_keys.html, som kun har eksisteret i prod (CT151) og aldrig i repoet. ÅRSAG TIL IMPORTEN: den manglende viden i repoet kostede nedetid. Da jeg udrullede repoets main.py og config.py til prod i går, overskrev jeg MCP-monteringen (mcp_app.lifespan + route/middleware-overførsel), hele /admin/mcp-keys-modulet og konstanterne MCP_REGISTRY/MCP_SELF_URL. weircon.dk/mcp svarede 404 i ~13 timer. Rettet ved at genopbygge begge filer fra prod-backuppen plus YouTube-ændringerne, ikke omvendt. Repo og prod er nu identiske bortset fra static/portal.css og templates/admin.html, hvor repoet er FORAN (wt-*-temaklasser, aldrig udrullet). Claude-Session: https://claude.ai/code/session_011WxG8pbLj61jYU1MMaD7VC
277 lines
8.8 KiB
Python
277 lines
8.8 KiB
Python
"""Central MCP-nøgleregister/vault for portalen.
|
|
|
|
Ét sted for homelab'ens MCP-servere:
|
|
- **external**-entries (crawlkit, Gitea, HA, Paperless): vi gemmer deres token i
|
|
klartekst, så `claude mcp add`-kommandoen altid kan vises. Portalen autentificerer
|
|
dem IKKE — det gør den enkelte downstream-server.
|
|
- **portal_tokens**: bearer-tokens til portalens EGEN MCP (hash til auth + klartekst
|
|
til visning).
|
|
|
|
Filen skrives mode 600 (samme niveau som /etc/portal.env).
|
|
"""
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import secrets
|
|
from datetime import datetime, timezone
|
|
|
|
import httpx
|
|
|
|
import config
|
|
|
|
REG = config.MCP_REGISTRY
|
|
SELF_URL = config.MCP_SELF_URL
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
|
|
|
|
def _hash(t: str) -> str:
|
|
return hashlib.sha256(t.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _load() -> dict:
|
|
try:
|
|
d = json.loads(REG.read_text("utf-8"))
|
|
except Exception:
|
|
d = {}
|
|
d.setdefault("external", [])
|
|
d.setdefault("portal_tokens", [])
|
|
d.setdefault("oauth_access", {"default": None, "users": {}})
|
|
_backfill_allowed(d)
|
|
return d
|
|
|
|
|
|
def _namespaces(d: dict) -> list:
|
|
"""Every backend the gateway can expose: the portal itself + each external."""
|
|
return ["portal"] + [e["id"] for e in d.get("external", []) if e.get("id")]
|
|
|
|
|
|
def _backfill_allowed(d: dict) -> None:
|
|
"""Give pre-scoping tokens an explicit allow-list of what exists TODAY.
|
|
|
|
Deliberately not a wildcard: a token minted before scoping keeps exactly the
|
|
access it already had, and any backend added later (mail, say) is denied
|
|
until someone ticks it. Default-deny for new backends is the whole point.
|
|
"""
|
|
current = _namespaces(d)
|
|
for t in d.get("portal_tokens", []):
|
|
if t.get("allowed") is None:
|
|
t["allowed"] = list(current)
|
|
oa = d.get("oauth_access") or {}
|
|
if oa.get("default") is None:
|
|
oa["default"] = list(current)
|
|
d["oauth_access"] = oa
|
|
|
|
|
|
def _save(d: dict) -> None:
|
|
REG.write_text(json.dumps(d, ensure_ascii=False, indent=2), "utf-8")
|
|
try:
|
|
os.chmod(REG, 0o600)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
# ---------------- external MCP-entries ----------------
|
|
def list_external() -> list:
|
|
return _load()["external"]
|
|
|
|
|
|
def upsert_external(entry: dict) -> None:
|
|
d = _load()
|
|
d["external"] = [e for e in d["external"] if e.get("id") != entry["id"]] + [entry]
|
|
_save(d)
|
|
|
|
|
|
def delete_external(eid: str) -> None:
|
|
d = _load()
|
|
d["external"] = [e for e in d["external"] if e.get("id") != eid]
|
|
_save(d)
|
|
|
|
|
|
# ---------------- portalens egne MCP-tokens ----------------
|
|
def list_portal_tokens() -> list:
|
|
return _load()["portal_tokens"]
|
|
|
|
|
|
def mint_portal_token(name: str, allowed: list | None = None) -> str:
|
|
token = secrets.token_urlsafe(32)
|
|
d = _load()
|
|
d["portal_tokens"].append({
|
|
"name": name, "token_hash": _hash(token),
|
|
"allowed": list(allowed) if allowed is not None else [],
|
|
"created_at": _now(), "last_used_at": None, "revoked": False})
|
|
_save(d)
|
|
return token
|
|
|
|
|
|
def namespaces() -> list:
|
|
"""Backends the gateway can expose (for the admin UI's checkboxes)."""
|
|
return _namespaces(_load())
|
|
|
|
|
|
def set_token_allowed(name: str, allowed: list) -> None:
|
|
d = _load()
|
|
for t in d["portal_tokens"]:
|
|
if t.get("name") == name:
|
|
t["allowed"] = list(allowed)
|
|
_save(d)
|
|
|
|
|
|
def get_oauth_access() -> dict:
|
|
return _load()["oauth_access"]
|
|
|
|
|
|
def set_oauth_access(default: list, users: dict) -> None:
|
|
d = _load()
|
|
d["oauth_access"] = {"default": list(default),
|
|
"users": {k: list(v) for k, v in users.items()}}
|
|
_save(d)
|
|
|
|
|
|
def set_oauth_default(allowed: list) -> None:
|
|
d = _load()
|
|
oa = d.get("oauth_access") or {}
|
|
oa["default"] = list(allowed)
|
|
d["oauth_access"] = oa
|
|
_save(d)
|
|
|
|
|
|
def set_oauth_user(username: str, allowed: list) -> None:
|
|
d = _load()
|
|
oa = d.get("oauth_access") or {}
|
|
users = oa.get("users") or {}
|
|
users[username] = list(allowed)
|
|
oa["users"] = users
|
|
d["oauth_access"] = oa
|
|
_save(d)
|
|
|
|
|
|
def del_oauth_user(username: str) -> None:
|
|
d = _load()
|
|
oa = d.get("oauth_access") or {}
|
|
(oa.get("users") or {}).pop(username, None)
|
|
d["oauth_access"] = oa
|
|
_save(d)
|
|
|
|
|
|
def allowed_for_oauth(subject: str | None) -> list:
|
|
"""What an Authentik-authenticated caller may reach.
|
|
|
|
Falls back to the configured default when the user has no explicit entry —
|
|
the default is itself a list, so it can be narrowed to nothing.
|
|
"""
|
|
oa = _load()["oauth_access"]
|
|
users = oa.get("users") or {}
|
|
for key in filter(None, [subject, (subject or "").lower()]):
|
|
if key in users:
|
|
return list(users[key])
|
|
return list(oa.get("default") or [])
|
|
|
|
|
|
def allowed_for_oauth_any(candidates) -> list:
|
|
"""What an Authentik caller may reach, matched on ANY of its identifiers.
|
|
|
|
Authentik's ``sub`` is a UUID, but the admin UI stores entries by *username*
|
|
— so matching on the subject alone never hits and everyone silently gets the
|
|
default. Try every identity claim the token carries, case-insensitively.
|
|
"""
|
|
oa = _load()["oauth_access"]
|
|
users = oa.get("users") or {}
|
|
lowered = {str(k).lower(): v for k, v in users.items()}
|
|
for c in candidates:
|
|
if not c:
|
|
continue
|
|
if c in users:
|
|
return list(users[c])
|
|
hit = lowered.get(str(c).lower())
|
|
if hit is not None:
|
|
return list(hit)
|
|
return list(oa.get("default") or [])
|
|
|
|
|
|
def resolve_portal_token(token: str) -> dict | None:
|
|
"""Return the token record (incl. its allow-list), or None. Bumps
|
|
last_used_at at most ~1/min, like verify_portal_token."""
|
|
if not token:
|
|
return None
|
|
h = _hash(token)
|
|
d = _load()
|
|
now = _now()
|
|
for t in d["portal_tokens"]:
|
|
if not t.get("revoked") and hmac.compare_digest(t.get("token_hash", ""), h):
|
|
last = t.get("last_used_at") or ""
|
|
if last < now[:16]:
|
|
t["last_used_at"] = now
|
|
_save(d)
|
|
return dict(t)
|
|
return None
|
|
|
|
|
|
def revoke_portal_token(name: str) -> None:
|
|
d = _load()
|
|
for t in d["portal_tokens"]:
|
|
if t.get("name") == name:
|
|
t["revoked"] = True
|
|
_save(d)
|
|
|
|
|
|
def verify_portal_token(token: str) -> bool:
|
|
"""True for a valid, non-revoked portal token."""
|
|
return resolve_portal_token(token) is not None
|
|
|
|
|
|
# ---------------- helpers ----------------
|
|
def build_command(*, mcp_id, transport="http", url=None, token=None,
|
|
header="Authorization", command=None) -> str:
|
|
if transport == "stdio":
|
|
return f"claude mcp add --scope user {mcp_id} -- {(command or '').strip()}".strip()
|
|
hdr = f' --header "{header}: Bearer {token}"' if token else ""
|
|
return f"claude mcp add --scope user --transport {transport} {mcp_id} {url}{hdr}"
|
|
|
|
|
|
class PortalMCPAuthMiddleware:
|
|
"""Pure-ASGI gate: require a valid portal bearer token on the /mcp endpoint;
|
|
everything else (incl. lifespan + the web UI) passes through untouched."""
|
|
|
|
def __init__(self, app):
|
|
self.app = app
|
|
|
|
async def __call__(self, scope, receive, send):
|
|
if scope.get("type") == "http" and scope.get("path", "").rstrip("/") == "/mcp":
|
|
headers = dict(scope.get("headers") or [])
|
|
raw = headers.get(b"authorization", b"").decode("latin-1")
|
|
token = raw[7:].strip() if raw[:7].lower() == "bearer " else ""
|
|
if not verify_portal_token(token):
|
|
body = b'{"error":"unauthorized"}'
|
|
await send({"type": "http.response.start", "status": 401,
|
|
"headers": [(b"content-type", b"application/json"),
|
|
(b"content-length", str(len(body)).encode())]})
|
|
await send({"type": "http.response.body", "body": body})
|
|
return
|
|
await self.app(scope, receive, send)
|
|
|
|
|
|
async def health_check(entry: dict) -> dict:
|
|
"""Ping an external MCP endpoint. Any 2xx/4xx = reachable (401 = auth required)."""
|
|
res = {"at": _now(), "ok": None, "detail": ""}
|
|
url = entry.get("url")
|
|
if entry.get("transport") == "stdio" or not url:
|
|
res["detail"] = "stdio (lokal) — ikke pinget"
|
|
return res
|
|
try:
|
|
async with httpx.AsyncClient(timeout=8) as c:
|
|
r = await c.post(url, json={"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
"params": {}},
|
|
headers={"Content-Type": "application/json",
|
|
"Accept": "application/json, text/event-stream"})
|
|
res["ok"] = 200 <= r.status_code < 500
|
|
res["detail"] = f"HTTP {r.status_code}" + (" — nåbar (auth kræves)"
|
|
if r.status_code == 401 else "")
|
|
except Exception as e:
|
|
res["ok"] = False
|
|
res["detail"] = str(e)[:160]
|
|
return res
|