From a1a69567d4c851ea4fb5c78556b91084b02ccdae Mon Sep 17 00:00:00 2001 From: sascha Date: Sun, 16 Aug 2026 20:56:23 +0200 Subject: [PATCH 1/4] feat(ui): update app.py --- app.py | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index 91be060..2b6da2e 100644 --- a/app.py +++ b/app.py @@ -7,7 +7,7 @@ import httpx, yaml from typing import Literal from pydantic import BaseModel, Field from fastapi import FastAPI, Request, HTTPException, Depends, Query -from fastapi.responses import JSONResponse, RedirectResponse, Response +from fastapi.responses import JSONResponse, RedirectResponse, Response, HTMLResponse from contextlib import asynccontextmanager log = logging.getLogger("butler") @@ -17,6 +17,7 @@ API_DIR = os.environ.get("API_KEY_DIR", "/data/api") VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache") BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "") CONFIG_PATH = os.environ.get("BUTLER_CONFIG", "/data/butler.yaml") +UI_PATH = os.environ.get("BUTLER_UI_PATH", os.path.join(os.path.dirname(__file__), "ui.html")) # --- Config loading --- @@ -227,12 +228,37 @@ async def _dockhand_login(client): # --- Auth --- +_ui_sessions: dict[str, dict] = {} +UI_SESSION_TTL = 8 * 60 * 60 + + +class UiLoginRequest(BaseModel): + token: str + + +def _ui_session(request: Request) -> dict | None: + session_id = request.cookies.get("butler_session", "") + session = _ui_sessions.get(session_id) + if not session: + return None + if session["expires"] <= time.time(): + _ui_sessions.pop(session_id, None) + return None + return session + def _verify(request: Request): if not BUTLER_TOKEN: return auth = request.headers.get("authorization", "") - if auth != f"Bearer {BUTLER_TOKEN}": + if secrets.compare_digest(auth, f"Bearer {BUTLER_TOKEN}"): + return + session = _ui_session(request) + if not session: raise HTTPException(401, "Invalid token") + if request.method not in {"GET", "HEAD", "OPTIONS"}: + csrf = request.headers.get("x-csrf-token", "") + if not csrf or not secrets.compare_digest(csrf, session["csrf"]): + raise HTTPException(403, "Invalid CSRF token") def _get_key(cfg): vault_key = cfg.get("vault_key") @@ -290,6 +316,45 @@ def _inventory_hosts(text: str) -> list[dict]: # --- Routes --- +@app.get("/ui", response_class=HTMLResponse) +async def ui(): + try: + return HTMLResponse(open(UI_PATH, encoding="utf-8").read()) + except FileNotFoundError: + raise HTTPException(503, "Butler UI asset is missing") + + +@app.post("/ui/login") +async def ui_login(payload: UiLoginRequest): + if not BUTLER_TOKEN or not secrets.compare_digest(payload.token, BUTLER_TOKEN): + raise HTTPException(401, "Invalid token") + session_id = secrets.token_urlsafe(32) + csrf = secrets.token_urlsafe(24) + _ui_sessions[session_id] = {"csrf": csrf, "expires": time.time() + UI_SESSION_TTL} + response = JSONResponse({"authenticated": True, "expires_in": UI_SESSION_TTL}) + response.set_cookie("butler_session", session_id, max_age=UI_SESSION_TTL, httponly=True, samesite="strict", path="/") + response.set_cookie("butler_csrf", csrf, max_age=UI_SESSION_TTL, httponly=False, samesite="strict", path="/") + return response + + +@app.get("/ui/session") +async def ui_session(request: Request): + return {"authenticated": _ui_session(request) is not None} + + +@app.post("/ui/logout") +async def ui_logout(request: Request): + session = _ui_session(request) + if session: + csrf = request.headers.get("x-csrf-token", "") + if not csrf or not secrets.compare_digest(csrf, session["csrf"]): + raise HTTPException(403, "Invalid CSRF token") + _ui_sessions.pop(request.cookies.get("butler_session", ""), None) + response = JSONResponse({"authenticated": False}) + response.delete_cookie("butler_session", path="/") + response.delete_cookie("butler_csrf", path="/") + return response + @app.get("/") async def root(): """AI self-onboarding: returns all available endpoints and services.""" @@ -536,6 +601,7 @@ async def info(_=Depends(_verify)): }, "endpoints": { "capabilities": "/capabilities", + "ui": "/ui", "doctor": "/doctor/{target}", "drift": "/drift", "maintenance_preflight": "/maintenance/preflight", From 5723eebfb47d657265a553ac908c80065db87a30 Mon Sep 17 00:00:00 2001 From: sascha Date: Sun, 16 Aug 2026 20:56:24 +0200 Subject: [PATCH 2/4] feat(ui): update tests/test_app.py --- tests/test_app.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_app.py b/tests/test_app.py index 645220a..82356fd 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -46,6 +46,46 @@ def test_health_exposes_current_version(): assert response.json()["version"] == app.VERSION == "2.3.5" +def test_ui_serves_self_contained_operator_console(): + with TestClient(app.app) as client: + response = client.get("/ui") + assert response.status_code == 200 + assert "Pfannkuchen Butler" in response.text + assert 'id="operations-grid"' in response.text + assert 'id="doctor-form"' in response.text + assert 'id="preflight-form"' in response.text + assert "localStorage" not in response.text + + +def test_ui_asset_is_mounted_read_only_in_compose(): + from pathlib import Path + import yaml + compose = yaml.safe_load(Path(app.__file__).with_name("compose.yaml").read_text(encoding="utf-8")) + mounts = compose["services"]["homelab-butler"]["volumes"] + assert "./ui.html:/app/ui.html:ro" in mounts + + +def test_ui_session_login_uses_httponly_cookie_and_csrf(): + app._ui_sessions.clear() + with TestClient(app.app) as client: + denied = client.post("/ui/login", json={"token": "wrong"}) + assert denied.status_code == 401 + + login = client.post("/ui/login", json={"token": "test-token"}) + assert login.status_code == 200 + assert "HttpOnly" in login.headers.get("set-cookie", "") + csrf = client.cookies.get("butler_csrf") + assert csrf + + capabilities = client.get("/capabilities") + assert capabilities.status_code == 200 + + blocked = client.post("/config/reload") + assert blocked.status_code == 403 + allowed = client.post("/config/reload", headers={"X-CSRF-Token": csrf}) + assert allowed.status_code == 200 + + def test_capabilities_is_live_machine_readable_safety_map(): with TestClient(app.app) as client: response = client.get( @@ -74,6 +114,7 @@ def test_info_advertises_capabilities_endpoint(): assert response.json()["endpoints"]["doctor"] == "/doctor/{target}" assert response.json()["endpoints"]["drift"] == "/drift" assert response.json()["endpoints"]["maintenance_preflight"] == "/maintenance/preflight" + assert response.json()["endpoints"]["ui"] == "/ui" def test_audit_persists_and_redacts_secrets(tmp_path, monkeypatch): From 6d05c16f589250059cfe3bf9043c44f655daf69d Mon Sep 17 00:00:00 2001 From: sascha Date: Sun, 16 Aug 2026 20:56:24 +0200 Subject: [PATCH 3/4] feat(ui): update compose.yaml --- compose.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/compose.yaml b/compose.yaml index e0d1f56..a07a30f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -11,6 +11,7 @@ services: - /home/sascha/.ssh:/root/.ssh:ro - ./butler.yaml:/data/butler.yaml:ro - ./app.py:/app/app.py:ro + - ./ui.html:/app/ui.html:ro - ./state:/data/state environment: - API_KEY_DIR=/data/api From 94f6c6b241178c248eb323430c0a835e4b282f1d Mon Sep 17 00:00:00 2001 From: sascha Date: Sun, 16 Aug 2026 20:56:25 +0200 Subject: [PATCH 4/4] feat(ui): add operator console asset --- ui.html | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 ui.html diff --git a/ui.html b/ui.html new file mode 100644 index 0000000..9ad3d27 --- /dev/null +++ b/ui.html @@ -0,0 +1,124 @@ + + + + + + + Pfannkuchen Butler + + + + + + +
+ + + +