diff --git a/claudelogin.py b/claudelogin.py new file mode 100644 index 0000000..506e7df --- /dev/null +++ b/claudelogin.py @@ -0,0 +1,63 @@ +"""Claude-login fra telefonen: portalens halvdel af claude-login-agent. + +Claude Code på værten skal logges ind igen hver 4. uge. Værten kan nås fra +portalen, men ikke omvendt, så det foregår gennem tre filer i DIR: + + request portalen: "start et login" (værten sletter den, når den starter) + state.json værten: status, udløb og login-adressen mens et login venter + code portalen: koden fra Claudes side (værten henter og sletter den) + +Selve login-processen kører på værten (claude-login-agent); +koden virker kun sammen med den (PKCE), så portalen gemmer ikke andet end den +korte kode, indtil værten henter den et par sekunder senere. +""" +import json +import os +import re +import time + +import config + +DIR = config.DATA / "claude-login" +KODE = re.compile(r"^[A-Za-z0-9._~#-]{10,600}$") +# Agenten skriver state.json mindst hvert 5. minut; er den ældre end det her, +# kører agenten på værten ikke. +GAMMEL = 12 * 60 + + +def state() -> dict: + try: + st = json.loads((DIR / "state.json").read_text()) + except (OSError, ValueError): + st = {"status": "ukendt"} + if (DIR / "request").exists() and st.get("status") not in ("venter", "tjekker"): + st = {**st, "status": "starter"} + if (DIR / "code").exists(): + st = {**st, "status": "tjekker"} + # Mens et login venter, skriver agenten først igen når det slutter (maks 15 min). + graense = 16 * 60 if st.get("status") == "venter" else GAMMEL + st["stale"] = time.time() - st.get("updated", 0) > graense + return st + + +def request(by: str) -> None: + DIR.mkdir(mode=0o700, exist_ok=True) + tmp = DIR / ".request.tmp" + tmp.write_text(json.dumps({"by": by, "at": int(time.time())})) + os.replace(tmp, DIR / "request") + + +def submit_code(kode: str) -> str | None: + """Gem koden til værten. Returnerer en fejltekst, eller None.""" + kode = "".join((kode or "").split()) + if not KODE.match(kode): + return "Det ligner ikke en login-kode. Kopiér hele koden fra Claudes side." + if state().get("status") != "venter": + return "Der er ikke et login i gang. Start et nyt." + DIR.mkdir(mode=0o700, exist_ok=True) + tmp = DIR / ".code.tmp" + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + f.write(kode) + os.replace(tmp, DIR / "code") + return None diff --git a/config.py b/config.py index a2e3a32..d9620ca 100644 --- a/config.py +++ b/config.py @@ -106,6 +106,8 @@ ADMIN_MODULES = [ "desc": "Hent YouTube-videoer til Emby - og slet dem igen naar de er set."}, {"key": "secrets", "title": "Hemmeligheder til Claude", "icon": "🔏", "desc": "Giv et token eller kodeord til serveren krypteret, uden at skrive det i chatten."}, + {"key": "claude-login", "title": "Claude-login", "icon": "🤖", + "desc": "Log Claude på serveren ind igen fra telefonen, uden terminal."}, {"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 56288e5..045ddc4 100644 --- a/main.py +++ b/main.py @@ -21,6 +21,7 @@ import mcpkeys import mcp_server import mcp_gateway import secretdrop +import claudelogin # Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True # => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated. @@ -353,6 +354,34 @@ async def admin_secrets_delete(request: Request, navn: str): return RedirectResponse(f"/admin/secrets?saved={'Slettet' if ok else 'Fandtes ikke'}", status_code=303) +@app.get("/admin/claude-login", response_class=HTMLResponse) +async def admin_claude_login(request: Request): + user = require_admin(request) + return templates.TemplateResponse(request, "admin_claude_login.html", { + "request": request, "user": user, "is_admin": True, "st": claudelogin.state()}) + + +@app.get("/admin/claude-login/status") +async def admin_claude_login_status(request: Request): + require_admin(request) + return JSONResponse(claudelogin.state(), headers={"Cache-Control": "no-store"}) + + +@app.post("/admin/claude-login/start") +async def admin_claude_login_start(request: Request): + user = require_admin(request) + claudelogin.request((user or {}).get("preferred_username") or "?") + return JSONResponse(claudelogin.state()) + + +@app.post("/admin/claude-login/kode") +async def admin_claude_login_code(request: Request): + require_admin(request) + f = await request.form() + fejl = claudelogin.submit_code(f.get("kode") or "") + return JSONResponse({"error": fejl, **claudelogin.state()}, status_code=400 if fejl else 200) + + @app.get("/admin/groups", response_class=HTMLResponse) async def admin_groups(request: Request, saved: str = ""): user = require_admin(request) diff --git a/templates/admin_claude_login.html b/templates/admin_claude_login.html new file mode 100644 index 0000000..c8febb1 --- /dev/null +++ b/templates/admin_claude_login.html @@ -0,0 +1,114 @@ +{% extends "base.html" %} +{% block title %}Admin — Claude-login{% endblock %} +{% block body %} +
+Claude på serveren skal logges ind igen hver 4. uge. Det kan gøres herfra: start et login, log ind hos Claude i en ny fane, og indsæt koden du får her. Du får en påmindelse på ntfy tre dage før.
+ +…
+ + +Serveren starter login-processen…
+Serveren logger ind…
+