feat(mcp): eksponér YouTube-offline i portalens MCP + importér MCP-laget i repoet
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
This commit is contained in:
+276
@@ -0,0 +1,276 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user