diff --git a/app.py b/app.py
index 2b6da2e..91be060 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, HTMLResponse
+from fastapi.responses import JSONResponse, RedirectResponse, Response
from contextlib import asynccontextmanager
log = logging.getLogger("butler")
@@ -17,7 +17,6 @@ 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 ---
@@ -228,37 +227,12 @@ 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 secrets.compare_digest(auth, f"Bearer {BUTLER_TOKEN}"):
- return
- session = _ui_session(request)
- if not session:
+ if auth != f"Bearer {BUTLER_TOKEN}":
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")
@@ -316,45 +290,6 @@ 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."""
@@ -601,7 +536,6 @@ 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 a07a30f..e0d1f56 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -11,7 +11,6 @@ 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 82356fd..645220a 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -46,46 +46,6 @@ 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(
@@ -114,7 +74,6 @@ 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
deleted file mode 100644
index 9ad3d27..0000000
--- a/ui.html
+++ /dev/null
@@ -1,124 +0,0 @@
-
-
-
-
-
-
- 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.
—
-
-
-
-
-
-
-
-
-
-
-