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",
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
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):
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
+
+
+
+
+
+ 🍳
+ Homelab Control Plane
+ Pfannkuchen Butler
+ Geschützte Operations- und Diagnosekonsole. Der Token bleibt nur für den Login im Arbeitsspeicher und wird danach durch eine HttpOnly-Sitzung ersetzt.
+
+
+
+
+
+
+
+ Übersicht
+
+
+ Live Zustand
Dein Homelab auf einen Blick.
Deterministisch aus Butler-Collectoren – keine geschätzten Zustände.
Noch nicht geladen
+
+
Gesamtzustand
—
Butler Overview
+
Services
—
Funktionsprobes
+
Hosts
—
Inventory erreichbar
+
Backups
—
inkl. Policy
+
+ API-Abdeckung
live aus OpenAPI
+
+
+
+ Korrelierte Diagnose
Doctor
Service, Host, Container, Backup und Disk in einer Prüfung.
+ Gib einen bekannten Host oder Service ein.
+
+
+
+ Read-only Safety Gate
Wartung & Drift
Blocker erkennen, bevor eine Änderung Schaden anrichtet.
+ Noch kein Preflight ausgeführt.
+
+
+
+ Persistente Historie
Audit
Redigierte Butler-Aktionen aus SQLite.
+ Letzte Aktionen
| Zeit | Methode | Endpoint | Status | Dry-run | Detail |
|---|
| Wird geladen … |
+
+
+
+ Vollständiger Katalog
Alle Butler-Funktionen
Jede konkrete Operation aus OpenAPI sowie Proxy- und Metawege. Mutationen werden hier bewusst nicht blind ausgeführt.
—
+
+
+
+
+
+
+
+
+
+
+