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:
agw
2026-09-20 09:20:35 +02:00
parent 2c146b0711
commit 9675a5b76b
6 changed files with 943 additions and 4 deletions
+4 -1
View File
@@ -33,6 +33,8 @@ USERPREFS = DATA / "userprefs.json"
IP_LABELS_LOCAL = DATA / "ip_labels.json" IP_LABELS_LOCAL = DATA / "ip_labels.json"
IP_LABELS_REMOTE = os.environ.get("IP_LABELS_REMOTE", "") IP_LABELS_REMOTE = os.environ.get("IP_LABELS_REMOTE", "")
SETTINGS_PATH = DATA / "settings.json" 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) ---- # ---- Medie-oprydning (Emby + Sonarr/Radarr) ----
EMBY_URL = os.environ.get("EMBY_URL", "").rstrip("/") EMBY_URL = os.environ.get("EMBY_URL", "").rstrip("/")
@@ -56,7 +58,6 @@ MEDIA_DEFAULTS = {
"do_series": True, "do_series": True,
} }
# ---- YouTube offline (yt-dlp + Emby) ---- # ---- YouTube offline (yt-dlp + Emby) ----
YT_DOWNLOAD_DIR = os.environ.get("YT_DOWNLOAD_DIR", "/media/yt-offline") 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") 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."}, "desc": "Slet aldrig-set film & serier automatisk — med karens, behold-liste og ntfy."},
{"key": "yt", "title": "YouTube offline", "icon": "📺", {"key": "yt", "title": "YouTube offline", "icon": "📺",
"desc": "Hent YouTube-videoer til Emby - og slet dem igen naar de er set."}, "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)."},
] ]
+112 -3
View File
@@ -7,7 +7,6 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
import asyncio
import time import time
import authentik as ak import authentik as ak
@@ -18,8 +17,20 @@ import services as svc
import store import store
import versions import versions
import ytdl as yt 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, app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, https_only=True,
same_site="lax", max_age=60 * 60 * 12) same_site="lax", max_age=60 * 60 * 12)
app.mount("/static", StaticFiles(directory=config.BASE / "static"), name="static") 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": "IP → label & land", "url": "/admin/ip-labels", "extra": "ip geo"},
{"type": "page", "title": "Statistik", "url": "/admin/stats", "extra": "matomo"}, {"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": "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: try:
for a in await ak.fetch_user_apps(user["username"]): for a in await ak.fetch_user_apps(user["username"]):
items.append({"type": "app", "title": a["name"], "url": a["url"], 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)} 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 ---------------- # ---------------- YouTube offline ----------------
@app.get("/admin/yt", response_class=HTMLResponse) @app.get("/admin/yt", response_class=HTMLResponse)
async def admin_yt(request: Request, saved: str = ""): async def admin_yt(request: Request, saved: str = ""):
+166
View File
@@ -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()
+200
View File
@@ -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
+276
View File
@@ -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
+185
View File
@@ -0,0 +1,185 @@
{% extends "base.html" %}
{% block title %}Admin — MCP-nøgler{% endblock %}
{% block body %}
<h1>🔑 MCP-nøgler</h1>
<p class="wt-lead">Ét sted at oprette og se API-nøgler til alle MCP-servere. Kopiér en
<code>claude mcp add</code>-kommando og kør den på den maskine hvor Claude kører.</p>
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
<h2 style="margin-top:1.5rem">Eksterne MCP-servere</h2>
<table class="wt-table">
<thead><tr><th>Navn</th><th>Transport</th><th>URL / kommando</th><th>Status</th><th>Token</th><th></th></tr></thead>
<tbody>
{% for e in external %}
<tr>
<td><strong>{{ e.label }}</strong><br><small>{{ e.id }}</small></td>
<td>{{ e.transport }}</td>
<td>
{% if e.url %}<div><small>{{ e.url }}</small></div>{% endif %}
<code class="cmd" style="display:block;word-break:break-all;margin-top:.3rem">{{ e.command }}</code>
<button type="button" class="wt-btn" onclick="cp(this)">Kopiér kommando</button>
</td>
<td>
{% if e.last_check %}
{% if e.last_check.ok %}<span style="color:var(--ok,green)">●</span>{% elif e.last_check.ok == false %}<span style="color:var(--err,crimson)">●</span>{% else %}<span>○</span>{% endif %}
<small>{{ e.last_check.detail }}</small>
{% else %}<small>—</small>{% endif %}
<form method="post" style="margin-top:.3rem"><input type="hidden" name="action" value="healthcheck"><input type="hidden" name="id" value="{{ e.id }}"><button class="wt-btn" type="submit">Tjek</button></form>
</td>
<td><small>…{{ e.token[-4:] if e.token else "" }}</small></td>
<td>
<form method="post" onsubmit="return confirm('Slet {{ e.label }}?')"><input type="hidden" name="action" value="delete_external"><input type="hidden" name="id" value="{{ e.id }}"><button class="wt-btn" type="submit">✕</button></form>
</td>
</tr>
{% else %}
<tr><td colspan="6"><small>Ingen endnu.</small></td></tr>
{% endfor %}
</tbody>
</table>
<h3 style="margin-top:1.2rem">Tilføj / rediger ekstern MCP</h3>
<form method="post" class="wt-card" style="max-width:680px">
<input type="hidden" name="action" value="save_external">
<p><label>ID (kort, fx <code>gitea</code>)<br><input name="id" required></label></p>
<p><label>Navn<br><input name="label"></label></p>
<p><label>Transport<br>
<select name="transport"><option value="http">http</option><option value="sse">sse</option><option value="stdio">stdio</option></select></label></p>
<p><label>URL (http/sse)<br><input name="url" placeholder="https://…/mcp"></label></p>
<p><label>Token<br><input name="token"></label></p>
<p><label>Auth-header (default Authorization)<br><input name="auth_header" placeholder="Authorization"></label></p>
<p><label>Kommando (kun stdio, fx <code>npx -y paperless-mcp …</code>)<br><input name="command"></label></p>
<p><label>Note<br><input name="notes"></label></p>
<p><button class="wt-btn wt-btn--primary" type="submit">Gem</button></p>
</form>
<h2 style="margin-top:2rem">Gateway — alle MCP'er bag ét token</h2>
<p class="wt-lead"><code>{{ self_url }}</code> 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. <strong>Genstart portalen efter ændringer i listen ovenfor</strong> for at de slår igennem i gatewayen.</p>
{% if new_token %}
<div class="wt-flash" style="border-left:4px solid var(--ok,green)">
<strong>Nyt token "{{ new_token.name }}" — kopiér det nu, det vises kun én gang.</strong>
<code class="cmd" style="display:block;word-break:break-all;margin:.4rem 0">{{ new_token.token }}</code>
<div><small>Færdig kommando:</small></div>
<code class="cmd" style="display:block;word-break:break-all;margin-top:.2rem">{{ new_token.command }}</code>
<button type="button" class="wt-btn" onclick="cp(this)">Kopiér kommando</button>
</div>
{% endif %}
<form method="post" class="wt-card" style="margin-bottom:1rem;max-width:760px">
<input type="hidden" name="action" value="mint_portal">
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<input name="name" placeholder="fx remote-desktop" required>
<button class="wt-btn wt-btn--primary" type="submit">Opret token</button>
</div>
<div style="margin-top:.7rem">
<small><strong>Adgang til:</strong></small><br>
{% for ns in namespaces %}
<label style="margin-right:.9rem;white-space:nowrap"><input type="checkbox" name="allowed" value="{{ ns }}"> {{ ns }}</label>
{% endfor %}
<div style="margin-top:.3rem"><small>Uden markering får nøglen ingen adgang til noget.</small></div>
</div>
</form>
<table class="wt-table">
<thead><tr><th>Navn</th><th>Adgang</th><th>Oprettet</th><th>Sidst brugt</th><th>Status</th><th></th></tr></thead>
<tbody>
{% for t in portal_tokens %}
<tr>
<td>{{ t.name }}</td>
<td>
{% if t.revoked %}<small>—</small>{% else %}
<form method="post" style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<input type="hidden" name="action" value="set_allowed">
<input type="hidden" name="name" value="{{ t.name }}">
{% for ns in namespaces %}
<label style="white-space:nowrap"><input type="checkbox" name="allowed" value="{{ ns }}"{% if ns in t.allowed %} checked{% endif %}> {{ ns }}</label>
{% endfor %}
<button class="wt-btn" type="submit">Gem</button>
</form>
{% endif %}
</td>
<td><small>{{ t.created_at }}</small></td>
<td><small>{{ t.last_used_at or "—" }}</small></td>
<td>{% if t.revoked %}<span style="color:var(--err,crimson)">spærret</span>{% else %}<span style="color:var(--ok,green)">aktiv</span>{% endif %}</td>
<td>{% if not t.revoked %}<form method="post" onsubmit="return confirm('Spær {{ t.name }}?')"><input type="hidden" name="action" value="revoke_portal"><input type="hidden" name="name" value="{{ t.name }}"><button class="wt-btn" type="submit">Spær</button></form>{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="6"><small>Ingen tokens endnu.</small></td></tr>
{% endfor %}
</tbody>
</table>
<p><small>Tokens vises kun ved oprettelse og gemmes kun som hash — mistet token spærres og et nyt oprettes.</small></p>
<h3 style="margin-top:2rem">Authentik-brugere</h3>
<p><small>Connectors i Claude-appen logger ind som en <strong>Authentik-bruger</strong> og har ikke et token her,
så deres adgang sættes på identitet i stedet. Brugere uden egen linje får standarden.</small></p>
<form method="post" class="wt-card" style="max-width:760px;margin-bottom:1rem">
<input type="hidden" name="action" value="set_oauth_default">
<div><small><strong>Standard</strong> — gælder Authentik-brugere uden egen linje</small></div>
<div style="margin:.35rem 0 .7rem">
{% for ns in namespaces %}
<label style="margin-right:.9rem;white-space:nowrap"><input type="checkbox" name="default" value="{{ ns }}"{% if ns in oauth_access.default %} checked{% endif %}> {{ ns }}</label>
{% endfor %}
</div>
<button class="wt-btn" type="submit">Gem standard</button>
</form>
<table class="wt-table">
<thead><tr><th>Bruger</th><th>Adgang</th><th></th></tr></thead>
<tbody>
{% for u in oauth_rows %}
<tr>
<td>{{ u.label }}</td>
<td>
<form method="post" style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<input type="hidden" name="action" value="set_oauth_user">
<input type="hidden" name="name" value="{{ u.username }}">
{% for ns in namespaces %}
<label style="white-space:nowrap"><input type="checkbox" name="allowed" value="{{ ns }}"{% if ns in u.allowed %} checked{% endif %}> {{ ns }}</label>
{% endfor %}
<button class="wt-btn" type="submit">Gem</button>
</form>
</td>
<td>
<form method="post" onsubmit="return confirm('Fjern egen adgang for {{ u.username }}? Brugeren falder tilbage til standarden.')">
<input type="hidden" name="action" value="del_oauth_user">
<input type="hidden" name="name" value="{{ u.username }}">
<button class="wt-btn" type="submit">✕</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="3"><small>Ingen brugere med egen adgang — alle bruger standarden.</small></td></tr>
{% endfor %}
</tbody>
</table>
<form method="post" class="wt-card" style="max-width:760px;margin-top:1rem">
<input type="hidden" name="action" value="set_oauth_user">
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<select name="name" required>
<option value="">— vælg Authentik-bruger —</option>
{% for u in ak_users_available %}<option value="{{ u.username }}">{{ u.label }}</option>{% endfor %}
</select>
<button class="wt-btn wt-btn--primary" type="submit">Tilføj</button>
</div>
<div style="margin-top:.6rem">
<small><strong>Adgang til:</strong></small><br>
{% for ns in namespaces %}
<label style="margin-right:.9rem;white-space:nowrap"><input type="checkbox" name="allowed" value="{{ ns }}"> {{ ns }}</label>
{% endfor %}
</div>
{% if not ak_users_available %}<div style="margin-top:.4rem"><small>Alle Authentik-brugere har allerede en egen linje (eller Authentik svarer ikke).</small></div>{% endif %}
</form>
<script>
function cp(btn){
var code = btn.previousElementSibling;
while (code && !code.classList.contains("cmd")) { code = code.previousElementSibling; }
if (!code) return;
navigator.clipboard.writeText(code.textContent.trim()).then(function(){
var o = btn.textContent; btn.textContent = "Kopieret ✓"; setTimeout(function(){btn.textContent=o;}, 1200);
});
}
</script>
{% endblock %}