diff --git a/config.py b/config.py index 431ee52..37d1c57 100644 --- a/config.py +++ b/config.py @@ -33,6 +33,8 @@ USERPREFS = DATA / "userprefs.json" IP_LABELS_LOCAL = DATA / "ip_labels.json" IP_LABELS_REMOTE = os.environ.get("IP_LABELS_REMOTE", "") SETTINGS_PATH = DATA / "settings.json" +MCP_REGISTRY = DATA / "mcp_registry.json" +MCP_SELF_URL = os.environ.get("MCP_SELF_URL", "https://weircon.dk/mcp") # ---- Medie-oprydning (Emby + Sonarr/Radarr) ---- EMBY_URL = os.environ.get("EMBY_URL", "").rstrip("/") @@ -56,7 +58,6 @@ MEDIA_DEFAULTS = { "do_series": True, } - # ---- YouTube offline (yt-dlp + Emby) ---- YT_DOWNLOAD_DIR = os.environ.get("YT_DOWNLOAD_DIR", "/media/yt-offline") YT_DLP_BIN = os.environ.get("YT_DLP_BIN", "/usr/local/bin/yt-dlp") @@ -101,4 +102,6 @@ ADMIN_MODULES = [ "desc": "Slet aldrig-set film & serier automatisk — med karens, behold-liste og ntfy."}, {"key": "yt", "title": "YouTube offline", "icon": "📺", "desc": "Hent YouTube-videoer til Emby - og slet dem igen naar de er set."}, + {"key": "mcp-keys", "title": "MCP-nøgler", "icon": "🔑", + "desc": "Opret og se API-nøgler til alle MCP-servere (crawlkit, Gitea, HA, Paperless, portal)."}, ] diff --git a/main.py b/main.py index 8de3f37..5238855 100644 --- a/main.py +++ b/main.py @@ -7,7 +7,6 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware -import asyncio import time import authentik as ak @@ -18,8 +17,20 @@ import services as svc import store import versions import ytdl as yt +import mcpkeys +import mcp_server +import mcp_gateway -app = FastAPI(title="Weircon Portal") +# Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True +# => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated. +mcp_app = mcp_gateway.gateway.http_app(path="/mcp", json_response=True) +app = FastAPI(title="Weircon Portal", lifespan=mcp_app.lifespan) +for _mcp_route in mcp_app.routes: + app.router.routes.append(_mcp_route) +# route-extend copies routes but NOT middleware — carry FastMCP's auth/context +# middleware over so the /mcp auth (OIDC + static dual-auth) actually runs. +for _mcp_mw in reversed(mcp_app.user_middleware): + app.add_middleware(_mcp_mw.cls, *_mcp_mw.args, **_mcp_mw.kwargs) app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, https_only=True, same_site="lax", max_age=60 * 60 * 12) app.mount("/static", StaticFiles(directory=config.BASE / "static"), name="static") @@ -511,7 +522,8 @@ async def search_index(request: Request): {"type": "page", "title": "IP → label & land", "url": "/admin/ip-labels", "extra": "ip geo"}, {"type": "page", "title": "Statistik", "url": "/admin/stats", "extra": "matomo"}, {"type": "page", "title": "Medie-oprydning", "url": "/admin/media", "extra": "film serier emby sonarr radarr slet disk plads"}, - {"type": "page", "title": "YouTube offline", "url": "/admin/yt", "extra": "youtube yt-dlp download offline fly emby"}] + {"type": "page", "title": "YouTube offline", "url": "/admin/yt", "extra": "youtube yt-dlp download offline fly emby"}, + {"type": "page", "title": "MCP-nøgler", "url": "/admin/mcp-keys", "extra": "mcp api nøgler token claude"}] try: for a in await ak.fetch_user_apps(user["username"]): items.append({"type": "app", "title": a["name"], "url": a["url"], @@ -526,6 +538,103 @@ async def healthz(): return {"ok": True, "configured": bool(config.CLIENT_ID and config.API_TOKEN)} +# ---------------- MCP-nøgler (central registry) ---------------- +@app.get("/admin/mcp-keys", response_class=HTMLResponse) +async def admin_mcp_keys(request: Request, saved: str = ""): + user = require_admin(request) + external = [{**e, "command": mcpkeys.build_command( + mcp_id=e["id"], transport=e.get("transport", "http"), url=e.get("url"), + token=e.get("token"), header=e.get("auth_header", "Authorization"), + command=e.get("command"))} for e in mcpkeys.list_external()] + portal_tokens = mcpkeys.list_portal_tokens() + new_token = request.session.pop("mcp_new", None) + oauth_access = mcpkeys.get_oauth_access() + configured = oauth_access.get("users") or {} + # Service accounts (outposts etc.) are not people and would only clutter the + # picker. If Authentik is unreachable the page still renders — the rows fall + # back to the bare username. + try: + people = [u for u in await ak.list_users() + if not u["username"].startswith("ak-outpost-") + and (u.get("type") or "internal") != "service_account"] + except Exception: + people = [] + by_name = {u["username"]: u for u in people} + + def _label(username): + u = by_name.get(username) + full = (u or {}).get("name") or "" + return f"{full} ({username})" if full else username + + oauth_rows = [{"username": u, "allowed": v, "label": _label(u)} + for u, v in sorted(configured.items())] + ak_users_available = sorted( + ({"username": u["username"], "label": _label(u["username"])} + for u in people if u["username"] not in configured), + key=lambda x: x["label"].lower()) + return templates.TemplateResponse(request, "admin_mcp_keys.html", { + "request": request, "user": user, "is_admin": True, "external": external, + "portal_tokens": portal_tokens, "self_url": mcpkeys.SELF_URL, "saved": saved, + "new_token": new_token, "namespaces": mcpkeys.namespaces(), + "oauth_access": oauth_access, "oauth_rows": oauth_rows, + "ak_users_available": ak_users_available}) + + +@app.post("/admin/mcp-keys") +async def admin_mcp_keys_post(request: Request): + require_admin(request) + form = await request.form() + action = form.get("action", "") + if action == "save_external": + eid = (form.get("id") or "").strip() + if eid: + mcpkeys.upsert_external({ + "id": eid, "label": (form.get("label") or eid).strip(), + "transport": (form.get("transport") or "http").strip(), + "url": (form.get("url") or "").strip(), + "auth_header": (form.get("auth_header") or "Authorization").strip(), + "token": (form.get("token") or "").strip(), + "command": (form.get("command") or "").strip(), + "notes": (form.get("notes") or "").strip()}) + return RedirectResponse("/admin/mcp-keys?saved=Gemt", status_code=303) + if action == "delete_external": + mcpkeys.delete_external((form.get("id") or "").strip()) + return RedirectResponse("/admin/mcp-keys?saved=Slettet", status_code=303) + if action == "set_allowed": + mcpkeys.set_token_allowed((form.get("name") or "").strip(), + form.getlist("allowed")) + return RedirectResponse("/admin/mcp-keys?saved=Adgang gemt", status_code=303) + if action == "set_oauth_default": + mcpkeys.set_oauth_default(form.getlist("default")) + return RedirectResponse("/admin/mcp-keys?saved=Standard gemt", status_code=303) + if action == "set_oauth_user": + who = (form.get("name") or "").strip() + if who: + mcpkeys.set_oauth_user(who, form.getlist("allowed")) + return RedirectResponse("/admin/mcp-keys?saved=Adgang gemt", status_code=303) + if action == "del_oauth_user": + mcpkeys.del_oauth_user((form.get("name") or "").strip()) + return RedirectResponse("/admin/mcp-keys?saved=Fjernet", status_code=303) + if action == "mint_portal": + name = (form.get("name") or "").strip() or "ny-nøgle" + tok = mcpkeys.mint_portal_token(name, form.getlist("allowed")) + cmd = mcpkeys.build_command(mcp_id="weircon", transport="http", + url=mcpkeys.SELF_URL, token=tok) + request.session["mcp_new"] = {"name": name, "token": tok, "command": cmd} + return RedirectResponse("/admin/mcp-keys?saved=Token oprettet — kopiér det nu, det vises kun én gang", status_code=303) + if action == "revoke_portal": + mcpkeys.revoke_portal_token((form.get("name") or "").strip()) + return RedirectResponse("/admin/mcp-keys?saved=Token spærret", status_code=303) + if action == "healthcheck": + eid = (form.get("id") or "").strip() + for e in mcpkeys.list_external(): + if e["id"] == eid: + e["last_check"] = await mcpkeys.health_check(e) + mcpkeys.upsert_external(e) + return RedirectResponse("/admin/mcp-keys?saved=Tjekket", status_code=303) + return RedirectResponse("/admin/mcp-keys", status_code=303) + + # ---------------- YouTube offline ---------------- @app.get("/admin/yt", response_class=HTMLResponse) async def admin_yt(request: Request, saved: str = ""): diff --git a/mcp_gateway.py b/mcp_gateway.py new file mode 100644 index 0000000..ea6c9b8 --- /dev/null +++ b/mcp_gateway.py @@ -0,0 +1,166 @@ +"""MCP-gateway/router: ÉT FastMCP-endpoint der føderer portalens egne tools +(in-process) + alle registrerede eksterne MCP'er (crawlkit, Home Assistant, …). + +**Dual-auth** (FastMCP MultiAuth): +- Authentik-OAuth (OIDCProxy) — så gatewayen kan tilføjes som Custom Connector på + claude.ai og bruges i mobil/web-appen (forbruger-appen kan kun OAuth). +- Statisk portal-token (PortalTokenVerifier mod registry-hash) — så den headless + Claude Code CLI virker uændret uden OAuth-flow. + +Bygges fra registry'et ved opstart; genstart portalen efter ændringer i registry'et. +""" +import logging +import os + +from fastmcp import FastMCP +from fastmcp.exceptions import ToolError +from fastmcp.server import create_proxy +from fastmcp.server.auth import AccessToken, MultiAuth, TokenVerifier +from fastmcp.server.auth.oidc_proxy import OIDCProxy +from fastmcp.server.dependencies import get_access_token +from fastmcp.server.middleware import Middleware +from key_value.aio.stores.disk import DiskStore + +import mcpkeys +import mcp_server + +log = logging.getLogger("portal.mcpscope") + +CLAUDE_REDIRECTS = ["https://claude.ai/api/mcp/auth_callback", + "https://claude.com/api/mcp/auth_callback"] + + +class PortalTokenVerifier(TokenVerifier): + """Accept the portal's static bearer tokens (headless CLI), validated against the + registry hash (mcpkeys.verify_portal_token).""" + + async def verify_token(self, token: str): + rec = mcpkeys.resolve_portal_token(token) + if rec is None: + return None + # Carry the key's allow-list into the request so ScopeMiddleware can + # enforce it without looking the token up a second time. + # Must carry every required scope: required_scopes is enforced for all + # verifiers, so a static key missing one would start getting 403. + return AccessToken(token=token, client_id="portal-static", + scopes=["openid", "offline_access"], + claims={"portal_allowed": list(rec.get("allowed") or []), + "portal_key": rec.get("name", "")}) + + +def _build_auth(): + static = PortalTokenVerifier() + cfg = os.environ.get("MCP_OIDC_CONFIG_URL") + cid = os.environ.get("MCP_OIDC_CLIENT_ID") + csec = os.environ.get("MCP_OIDC_CLIENT_SECRET") + if not (cfg and cid and csec): + return static # OIDC not configured → static-token only + oidc = OIDCProxy( + config_url=cfg, client_id=cid, client_secret=csec, + base_url="https://weircon.dk", redirect_path="/mcp/oauth/callback", + allowed_client_redirect_uris=CLAUDE_REDIRECTS, + client_storage=DiskStore(directory="/opt/portal/data/mcp_oauth"), + forward_resource=False, # Authentik rejects the RFC-8707 resource param ("malformed") + # offline_access is what makes Authentik issue a refresh token. claude.ai + # sends no scope of its own, so whatever is listed here IS the upstream + # scope. With only "openid" the connector got a 1-hour access token and + # nothing to renew it with, and went 401 an hour into every session. + required_scopes=["openid", "offline_access"], + require_authorization_consent=False, # claude.ai is the only allowed client + # (redirect allowlist) → skip extra consent + ) + return MultiAuth(server=oidc, verifiers=[static]) + + + + +def _namespace_of(tool_name: str, known: list) -> str | None: + """Map a namespaced tool name back to its backend. + + Longest prefix wins, so a backend id that is a prefix of another (``mail`` + vs ``mail_asger``) cannot swallow the wrong tools. + """ + for ns in sorted(known, key=len, reverse=True): + if tool_name == ns or tool_name.startswith(f"{ns}_"): + return ns + return None + + +def _allowed_for_request() -> list: + """Which backends the current caller may reach. + + Static portal keys carry their list in the token claims; Authentik callers + are looked up by subject in the registry. Anything we cannot identify gets + nothing — this is an access-control decision, so it fails closed. + """ + try: + tok = get_access_token() + except Exception as exc: # pragma: no cover - diagnostic path + log.warning("mcp-scope: get_access_token failed (%r) -> oauth default", exc) + return mcpkeys.allowed_for_oauth_any([]) + if tok is None: + # Authenticated but unidentifiable: FastMCP already 401s anything that + # is not authenticated, so this is a known-good caller we simply cannot + # name. Denying everything would strand it with an empty tool list; + # fall back to the admin-configured OAuth default instead, which is an + # explicit allow-list and does not include the mailboxes. + log.warning("mcp-scope: authenticated caller with no access token in " + "context -> falling back to the oauth default") + return mcpkeys.allowed_for_oauth_any([]) + claims = getattr(tok, "claims", None) or {} + if "portal_allowed" in claims: + return list(claims["portal_allowed"]) + candidates = [getattr(tok, "subject", None), claims.get("preferred_username"), + claims.get("nickname"), claims.get("email"), claims.get("upn"), + claims.get("username"), claims.get("uid"), claims.get("sub")] + allowed = mcpkeys.allowed_for_oauth_any(candidates) + log.warning("mcp-scope: oauth caller candidates=%r claim_keys=%s -> %s", + [c for c in candidates if c], sorted(claims), allowed) + return allowed + + +class ScopeMiddleware(Middleware): + """Per-key access control over the federated backends. + + Filtering list_tools is not enough on its own: a caller that already knows a + tool name could still invoke it. So call_tool is checked against the same + allow-list, and that check — not the listing — is the actual gate. + """ + + def __init__(self, namespaces_fn): + self._namespaces = namespaces_fn + + async def on_list_tools(self, context, call_next): + tools = await call_next(context) + allowed = set(_allowed_for_request()) + known = self._namespaces() + return [t for t in tools + if _namespace_of(getattr(t, "name", ""), known) in allowed] + + async def on_call_tool(self, context, call_next): + name = getattr(getattr(context, "message", None), "name", "") or "" + known = self._namespaces() + ns = _namespace_of(name, known) + if ns not in set(_allowed_for_request()): + raise ToolError( + f"denne nøgle har ikke adgang til '{ns or name}'" + ) + return await call_next(context) + +def build_gateway() -> FastMCP: + gw = FastMCP("weircon-gateway", auth=_build_auth()) + # Namespaces are read per request, not captured, so adding a backend to the + # registry does not need a code change here. + gw.add_middleware(ScopeMiddleware(lambda: ["portal"] + [ + e["id"] for e in mcpkeys.list_external() if e.get("id")])) + gw.mount(mcp_server.mcp, namespace="portal") + for e in mcpkeys.list_external(): + if e.get("url") and e.get("token") and e.get("transport") in ("http", "sse"): + conf = {"mcpServers": {e["id"]: { + "transport": e["transport"], "url": e["url"], + "headers": {e.get("auth_header", "Authorization"): f"Bearer {e['token']}"}}}} + gw.mount(create_proxy(conf), namespace=e["id"]) + return gw + + +gateway = build_gateway() diff --git a/mcp_server.py b/mcp_server.py new file mode 100644 index 0000000..638e998 --- /dev/null +++ b/mcp_server.py @@ -0,0 +1,200 @@ +"""Portalens egen MCP-server (FastMCP) — read-only indblik i portal/homelab-data +plus en ntfy-notify-handling. Monteres in-process i main.py (uvicorn-lifespan). + +Tools genbruger de eksisterende portal-moduler. Alt pakkes i try/except, så en +downstream-fejl giver en pæn fejl frem for at vælte MCP-kaldet. +""" +import asyncio + +from fastmcp import FastMCP + +import authentik as ak +import config +import mediaclean as mc +import services as svc +import store +import versions +import ytdl as yt + +mcp = FastMCP("weircon-portal") + + +def _safe(v): + return v + + +@mcp.tool +async def service_versions() -> dict: + """Versions/opdaterings-overblik for de overvågede tjenester (current vs latest).""" + try: + return {"services": await versions.all_versions()} + except Exception as e: + return {"error": str(e)[:200]} + + +@mcp.tool +async def matomo_summary() -> dict: + """Kort Matomo-trafikoverblik for weircon.dk.""" + try: + return await svc.matomo_summary() + except Exception as e: + return {"error": str(e)[:200]} + + +@mcp.tool +async def egress_status() -> dict: + """Random-proxy (CT140) egress-status: tunneler, rollover, inflight.""" + try: + return await svc.rp_status() + except Exception as e: + return {"error": str(e)[:200]} + + +@mcp.tool +async def portal_apps() -> dict: + """Katalog over Authentik-apps (navn, gruppe, host) som portalen kender.""" + try: + return {"apps": await ak.fetch_all_apps_admin()} + except Exception as e: + return {"error": str(e)[:200]} + + +@mcp.tool +def media_cleanup_status() -> dict: + """Status for medie-oprydningen: indstillinger, state (på-vej-ud/behold) og om den er konfigureret.""" + try: + return {"configured": mc.configured(), "settings": mc.get_settings(), + "state": mc.get_state()} + except Exception as e: + return {"error": str(e)[:200]} + + +@mcp.tool +def ip_labels() -> dict: + """IP/præfiks → label & land som vist i statistikken.""" + try: + return {"labels": store.load_json(config.IP_LABELS_LOCAL, {})} + except Exception as e: + return {"error": str(e)[:200]} + + +@mcp.tool +async def notify(message: str, title: str = "Claude") -> dict: + """Send en push-notifikation til dig via ntfy.""" + try: + await mc.ntfy(title, message) + return {"sent": True} + except Exception as e: + return {"sent": False, "error": str(e)[:200]} + + +# ---------------- YouTube offline ---------------- +@mcp.tool +def yt_status() -> dict: + """Status for YouTube-offline: hentede videoer (set/favorit/hvornår de slettes), + download-kø, overvågede playlister/kanaler og de aktuelle indstillinger. + + Videoer slettes 7 dage efter de er markeret som set i Emby. Favoritter slettes aldrig. + """ + try: + st = yt.get_state() + owned = yt.owned_paths() + leaving = st["leaving"] + videos = [{"titel": t, "sti": p, "stoerrelse": yt._human(yt.file_size(p)), + "slettes_efter": (leaving.get(p) or {}).get("delete_after")} + for p, t in owned.items()] + jobs = sorted(st["jobs"].values(), key=lambda j: j["added"], reverse=True) + return { + "konfigureret": yt.configured(), + "indstillinger": yt.get_settings(), + "videoer": videos, + "paa_vej_ud": len(leaving), + "koe": [{"url": j["url"], "status": j["status"], "titel": j.get("title"), + "fejl": j.get("error")} for j in jobs[:15] + if j["status"] in ("queued", "running", "error")], + "abonnementer": [ + {"id": sb["id"], "navn": sb["title"], "type": sb["kind"], + "cycle": sb.get("cycle") or None, "aktiv": sb.get("enabled", True), + "paa_disken": len(yt.sub_files(st, sb["id"]))} + for sb in st["subs"].values()], + } + except Exception as e: + return {"error": str(e)[:200]} + + +@mcp.tool +async def yt_download(url: str, playlist: bool = False) -> dict: + """Hent en YouTube-video ned til Emby-biblioteket, klar til offline-brug. + + Returnerer med det samme; selve hentningen kører i baggrunden (kan tage minutter + for en lang video). Brug yt_status til at følge den. Sæt playlist=True for at + hente hele playlisten bag URL'en i stedet for kun den enkelte video. + """ + try: + url = (url or "").strip() + if not url.startswith(("http://", "https://")): + return {"error": "Ikke en URL"} + job = yt.add_job(url, playlist=bool(playlist)) + asyncio.create_task(yt.process_queue()) + return {"lagt_i_koe": True, "job": job["id"], "url": job["url"], + "bemaerk": "hentes i baggrunden — se yt_status"} + except Exception as e: + return {"error": str(e)[:200]} + + +@mcp.tool +async def yt_watch(url: str, kind: str = "", cycle: int = 0) -> dict: + """Overvåg en playliste eller kanal og hent nye videoer automatisk. + + kind = "playlist" eller "channel" (tom = gæt ud fra URL'en). + For en kanal SKAL cycle sættes: cycle=X holder de X nyeste videoer liggende, + og en ny video skubber den ældste ud. Favoritter tæller ikke med i X og slettes + aldrig. En playliste henter kun videoer der lægges i den fremover — ikke dem + der allerede ligger der. + """ + try: + url = (url or "").strip() + if not url.startswith(("http://", "https://")): + return {"error": "Ikke en URL"} + kind = kind if kind in ("playlist", "channel") else yt.sub_kind_from_url(url) + if kind == "channel" and int(cycle or 0) < 1: + return {"error": "En kanal kraever cycle >= 1 (antal nyeste videoer der holdes)"} + sub = await yt.add_sub(url, kind=kind, cycle=int(cycle or 0) if kind == "channel" else 0) + asyncio.create_task(_yt_sync_all()) + return {"overvaages": True, "id": sub["id"], "navn": sub["title"], + "type": sub["kind"], "cycle": sub["cycle"] or None, + "bemaerk": "tjekker og henter i baggrunden"} + except Exception as e: + return {"error": str(e)[:200]} + + +@mcp.tool +def yt_unwatch(sub_id: str) -> dict: + """Stop med at overvåge en playliste/kanal. Allerede hentede videoer bliver + liggende og følger herefter kun den almindelige set-regel. Id'et fås fra yt_status.""" + try: + return {"fjernet": yt.del_sub(sub_id)} + except Exception as e: + return {"error": str(e)[:200]} + + +@mcp.tool +async def yt_check_subs() -> dict: + """Tjek alle overvågede playlister/kanaler nu i stedet for at vente på natkørslen.""" + try: + res = await yt.sync_subs() + asyncio.create_task(_yt_sync_all()) + return {"tjekket": res["checked"], "nye_i_koe": res["queued"], + "fejl": res["errors"]} + except Exception as e: + return {"error": str(e)[:200]} + + +async def _yt_sync_all(): + """Baggrundsarbejde: hent kø og haandhaev kanal-cyklus. Fejl slugges bevidst — + den natlige timer koerer det samme igen.""" + try: + await yt.process_queue() + await yt.enforce_cycles() + except Exception: + pass diff --git a/mcpkeys.py b/mcpkeys.py new file mode 100644 index 0000000..d8fdc78 --- /dev/null +++ b/mcpkeys.py @@ -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 diff --git a/templates/admin_mcp_keys.html b/templates/admin_mcp_keys.html new file mode 100644 index 0000000..abd841b --- /dev/null +++ b/templates/admin_mcp_keys.html @@ -0,0 +1,185 @@ +{% extends "base.html" %} +{% block title %}Admin — MCP-nøgler{% endblock %} +{% block body %} +

🔑 MCP-nøgler

+

Ét sted at oprette og se API-nøgler til alle MCP-servere. Kopiér en +claude mcp add-kommando og kør den på den maskine hvor Claude kører.

+{% if saved %}
{{ saved }}
{% endif %} + +

Eksterne MCP-servere

+ + + + {% for e in external %} + + + + + + + + + {% else %} + + {% endfor %} + +
NavnTransportURL / kommandoStatusToken
{{ e.label }}
{{ e.id }}
{{ e.transport }} + {% if e.url %}
{{ e.url }}
{% endif %} + {{ e.command }} + +
+ {% if e.last_check %} + {% if e.last_check.ok %}●{% elif e.last_check.ok == false %}●{% else %}○{% endif %} + {{ e.last_check.detail }} + {% else %}—{% endif %} +
+
…{{ e.token[-4:] if e.token else "" }} +
+
Ingen endnu.
+ +

Tilføj / rediger ekstern MCP

+
+ +

+

+

+

+

+

+

+

+

+
+ +

Gateway — alle MCP'er bag ét token

+

{{ self_url }} føderer portalens egne værktøjer + alle eksterne MCP'er ovenfor. Opret ét token (fx "remote-desktop") og giv en remote Claude den ene kommando — så har den adgang til alt bagved. Genstart portalen efter ændringer i listen ovenfor for at de slår igennem i gatewayen.

+ +{% if new_token %} +
+ Nyt token "{{ new_token.name }}" — kopiér det nu, det vises kun én gang. + {{ new_token.token }} +
Færdig kommando:
+ {{ new_token.command }} + +
+{% endif %} + +
+ +
+ + +
+
+ Adgang til:
+ {% for ns in namespaces %} + + {% endfor %} +
Uden markering får nøglen ingen adgang til noget.
+
+
+ + + + {% for t in portal_tokens %} + + + + + + + + + {% else %} + + {% endfor %} + +
NavnAdgangOprettetSidst brugtStatus
{{ t.name }} + {% if t.revoked %}—{% else %} +
+ + + {% for ns in namespaces %} + + {% endfor %} + +
+ {% endif %} +
{{ t.created_at }}{{ t.last_used_at or "—" }}{% if t.revoked %}spærret{% else %}aktiv{% endif %}{% if not t.revoked %}
{% endif %}
Ingen tokens endnu.
+

Tokens vises kun ved oprettelse og gemmes kun som hash — mistet token spærres og et nyt oprettes.

+ +

Authentik-brugere

+

Connectors i Claude-appen logger ind som en Authentik-bruger og har ikke et token her, +så deres adgang sættes på identitet i stedet. Brugere uden egen linje får standarden.

+ +
+ +
Standard — gælder Authentik-brugere uden egen linje
+
+ {% for ns in namespaces %} + + {% endfor %} +
+ +
+ + + + + {% for u in oauth_rows %} + + + + + + {% else %} + + {% endfor %} + +
BrugerAdgang
{{ u.label }} +
+ + + {% for ns in namespaces %} + + {% endfor %} + +
+
+
+ + + +
+
Ingen brugere med egen adgang — alle bruger standarden.
+ +
+ +
+ + +
+
+ Adgang til:
+ {% for ns in namespaces %} + + {% endfor %} +
+ {% if not ak_users_available %}
Alle Authentik-brugere har allerede en egen linje (eller Authentik svarer ikke).
{% endif %} +
+ + +{% endblock %}