feat(ui): update app.py

This commit is contained in:
sascha 2026-08-16 20:56:23 +02:00
parent 5058ddf0a1
commit a1a69567d4

70
app.py
View file

@ -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",