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.

-
- - - - -
-
-
- - -
- - - -