Butler Operator-Weboberfläche #42

Merged
sascha merged 4 commits from feature/butler-operator-ui into main 2026-08-16 20:56:41 +02:00
Showing only changes of commit a1a69567d4 - Show all commits

70
app.py
View file

@ -7,7 +7,7 @@ import httpx, yaml
from typing import Literal from typing import Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from fastapi import FastAPI, Request, HTTPException, Depends, Query 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 from contextlib import asynccontextmanager
log = logging.getLogger("butler") 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") VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache")
BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "") BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "")
CONFIG_PATH = os.environ.get("BUTLER_CONFIG", "/data/butler.yaml") 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 --- # --- Config loading ---
@ -227,12 +228,37 @@ async def _dockhand_login(client):
# --- Auth --- # --- 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): def _verify(request: Request):
if not BUTLER_TOKEN: if not BUTLER_TOKEN:
return return
auth = request.headers.get("authorization", "") 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") 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): def _get_key(cfg):
vault_key = cfg.get("vault_key") vault_key = cfg.get("vault_key")
@ -290,6 +316,45 @@ def _inventory_hosts(text: str) -> list[dict]:
# --- Routes --- # --- 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("/") @app.get("/")
async def root(): async def root():
"""AI self-onboarding: returns all available endpoints and services.""" """AI self-onboarding: returns all available endpoints and services."""
@ -536,6 +601,7 @@ async def info(_=Depends(_verify)):
}, },
"endpoints": { "endpoints": {
"capabilities": "/capabilities", "capabilities": "/capabilities",
"ui": "/ui",
"doctor": "/doctor/{target}", "doctor": "/doctor/{target}",
"drift": "/drift", "drift": "/drift",
"maintenance_preflight": "/maintenance/preflight", "maintenance_preflight": "/maintenance/preflight",