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