feat(ui): update app.py
This commit is contained in:
parent
5058ddf0a1
commit
a1a69567d4
1 changed files with 68 additions and 2 deletions
70
app.py
70
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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue