feat: add admin panel
This commit is contained in:
@@ -0,0 +1,707 @@
|
||||
"""Single-operator admin console.
|
||||
|
||||
Jinja2 + HTMX, no SPA framework. Session cookies signed with
|
||||
``SESSION_SECRET`` via ``itsdangerous`` (8h TTL). CSRF is double-submit:
|
||||
the signed session payload carries a random token that must echo back
|
||||
on every state-changing request as a ``csrf_token`` form field.
|
||||
|
||||
What lives in here:
|
||||
|
||||
* ``GET /login`` / ``POST /login`` — env-cred check (``ADMIN_USER`` /
|
||||
``ADMIN_PASS`` compared with :func:`hmac.compare_digest`).
|
||||
* ``POST /logout`` — clears the cookie.
|
||||
* ``GET /`` — dashboard: declared agents + MCP namespaces + the last
|
||||
audit slice.
|
||||
* ``GET /tokens`` + ``POST /tokens`` + ``POST /tokens/{id}/revoke`` —
|
||||
bearer-token CRUD. Plaintext is rendered exactly once at creation; the
|
||||
DB only ever holds the Argon2 hash. HTMX fragments swap rows in place.
|
||||
* ``GET /audit`` — paginated audit list (id-cursor).
|
||||
|
||||
Things the admin frontend is *not* responsible for: HTTP-token verify
|
||||
on ``/v1/messages`` and ``/mcp`` — that lives on those frontends and
|
||||
uses :class:`TokenStore`. Admin's only authentication path is the
|
||||
cookie session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import TYPE_CHECKING, Annotated, Any
|
||||
|
||||
import itsdangerous
|
||||
from fastapi import FastAPI, Form, HTTPException, Request, status
|
||||
from fastapi.responses import (
|
||||
HTMLResponse,
|
||||
RedirectResponse,
|
||||
Response,
|
||||
StreamingResponse,
|
||||
)
|
||||
from jinja2 import Environment, PackageLoader, select_autoescape
|
||||
|
||||
from beaver_gateway.core import audit
|
||||
from beaver_gateway.core.auth import VALID_SCOPES, hash_token
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
from beaver_gateway.storage import (
|
||||
create_token,
|
||||
list_audit_records,
|
||||
list_tokens,
|
||||
revoke_token,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
|
||||
from beaver_gateway.core.events import MessageStreamEvent
|
||||
from beaver_gateway.frontends.base import GatewayRuntime
|
||||
|
||||
|
||||
_log = logging.getLogger("beaver_gateway.frontends.admin")
|
||||
|
||||
# Cookie name is namespaced so a user can run multiple gateway instances
|
||||
# behind one host header without sessions colliding. ``_v1`` lets us
|
||||
# bump the payload schema later by invalidating old cookies via salt.
|
||||
SESSION_COOKIE = "beaver_admin_session"
|
||||
SESSION_MAX_AGE = 8 * 3600
|
||||
SESSION_SALT = "beaver-gateway.admin.session.v1"
|
||||
|
||||
# Scopes the admin is allowed to mint. ``admin`` is reserved (no bearer
|
||||
# route consumes it yet, but listing it keeps future programmatic
|
||||
# admin access first-class) — see ``core.auth.VALID_SCOPES``.
|
||||
CREATABLE_SCOPES = ("*", "messages", "mcp", "admin")
|
||||
|
||||
# Audit-log page size — also used by the dashboard's "recent activity"
|
||||
# panel. Small enough to keep the dashboard cheap, big enough to be
|
||||
# useful at a glance.
|
||||
AUDIT_PAGE_SIZE = 50
|
||||
|
||||
# /login brute-force ceiling: 5 failed attempts per source IP within
|
||||
# LOGIN_WINDOW_SECONDS shuts the door with a 429 until the oldest
|
||||
# failure ages out. Single-operator admin, so a legit user only ever
|
||||
# burns one IP — a forgiving window beats a tight one with a lockout.
|
||||
LOGIN_MAX_ATTEMPTS = 5
|
||||
LOGIN_WINDOW_SECONDS = 300.0
|
||||
|
||||
|
||||
__all__ = ["AdminFrontend"]
|
||||
|
||||
|
||||
class AdminFrontend(Frontend):
|
||||
"""FastAPI app behind ``/login`` / ``/tokens`` / ``/audit`` / ``/``."""
|
||||
|
||||
def __init__(self, *, host: str = "0.0.0.0", port: int = 8002) -> None: # noqa: S104
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._runtime: GatewayRuntime | None = None
|
||||
self._app: FastAPI | None = None
|
||||
|
||||
def configure(self, runtime: GatewayRuntime) -> None:
|
||||
# Refuse to wire up if the env didn't carry the bits we need —
|
||||
# an admin UI with empty creds is a footgun, and an unsigned
|
||||
# session cookie is no session at all.
|
||||
if not runtime.session_secret:
|
||||
msg = "AdminFrontend requires SESSION_SECRET in env"
|
||||
raise RuntimeError(msg)
|
||||
if not runtime.admin_user or not runtime.admin_pass:
|
||||
msg = "AdminFrontend requires ADMIN_USER and ADMIN_PASS in env"
|
||||
raise RuntimeError(msg)
|
||||
self._runtime = runtime
|
||||
self._app = self._build_app(runtime)
|
||||
|
||||
async def serve(self) -> None:
|
||||
import uvicorn
|
||||
|
||||
if self._app is None:
|
||||
msg = "configure() must be called before serve()"
|
||||
raise RuntimeError(msg)
|
||||
config = uvicorn.Config(
|
||||
self._app, host=self.host, port=self.port, log_level="info"
|
||||
)
|
||||
await uvicorn.Server(config).serve()
|
||||
|
||||
# ---- app construction ----------------------------------------------
|
||||
|
||||
def _build_app(self, runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
||||
# Routes are declared inline so they close over ``runtime`` /
|
||||
# ``signer`` / ``templates`` instead of threading them through
|
||||
# self-state. Splitting the function would just trade size for
|
||||
# bookkeeping — same total surface, harder to follow.
|
||||
templates = _build_template_env()
|
||||
signer = itsdangerous.URLSafeTimedSerializer(
|
||||
runtime.session_secret, salt=SESSION_SALT
|
||||
)
|
||||
login_limit = _LoginRateLimit(
|
||||
max_attempts=LOGIN_MAX_ATTEMPTS, window=LOGIN_WINDOW_SECONDS
|
||||
)
|
||||
app = FastAPI(title="beaver-gateway / admin", docs_url=None, redoc_url=None)
|
||||
|
||||
def render(name: str, **ctx: Any) -> str:
|
||||
return templates.get_template(name).render(**ctx)
|
||||
|
||||
# ---- login ----
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request) -> Response:
|
||||
# Already-signed-in users skip the form. Login flash is
|
||||
# carried in a one-shot query param so a failed POST can
|
||||
# redirect back here without leaking creds in the URL.
|
||||
if _current_user(request, signer):
|
||||
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
error = request.query_params.get("error")
|
||||
return HTMLResponse(render("login.html", error=error))
|
||||
|
||||
@app.post("/login", response_class=HTMLResponse)
|
||||
async def login_submit(
|
||||
request: Request,
|
||||
username: Annotated[str, Form(...)],
|
||||
password: Annotated[str, Form(...)],
|
||||
) -> Response:
|
||||
ip = _client_ip(request)
|
||||
if not login_limit.check(ip):
|
||||
_log.warning("admin login rate-limited: ip=%s user=%r", ip, username)
|
||||
await audit.log(
|
||||
runtime,
|
||||
actor=f"admin:{username}",
|
||||
kind="login_failed",
|
||||
reason="rate_limited",
|
||||
ip=ip,
|
||||
)
|
||||
# 429 carries the rendered form back inline so the user
|
||||
# sees the same page with an error banner — no redirect
|
||||
# roundtrip, and an explicit Retry-After for any well-
|
||||
# behaved client (or Caddy doing its own bookkeeping).
|
||||
return HTMLResponse(
|
||||
render(
|
||||
"login.html",
|
||||
error="too many attempts; try again in a few minutes",
|
||||
),
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
headers={"Retry-After": str(int(LOGIN_WINDOW_SECONDS))},
|
||||
)
|
||||
ok = hmac.compare_digest(
|
||||
username, runtime.admin_user
|
||||
) and hmac.compare_digest(password, runtime.admin_pass)
|
||||
if not ok:
|
||||
login_limit.record_failure(ip)
|
||||
_log.info("admin login failed: user=%r ip=%s", username, ip)
|
||||
await audit.log(
|
||||
runtime,
|
||||
actor=f"admin:{username}",
|
||||
kind="login_failed",
|
||||
reason="bad_credentials",
|
||||
ip=ip,
|
||||
)
|
||||
# 303-redirect after POST so the browser doesn't replay
|
||||
# the form on refresh. Status-303 forces GET on the
|
||||
# follow-up regardless of the original method.
|
||||
return RedirectResponse(
|
||||
"/login?error=invalid+credentials",
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
login_limit.clear(ip)
|
||||
csrf = secrets.token_urlsafe(24)
|
||||
cookie = signer.dumps({"user": username, "csrf": csrf})
|
||||
response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
_set_session_cookie(response, cookie)
|
||||
_log.info("admin login ok: user=%s ip=%s", username, ip)
|
||||
await audit.log(runtime, actor=f"admin:{username}", kind="login_ok", ip=ip)
|
||||
return response
|
||||
|
||||
@app.post("/logout")
|
||||
async def logout(request: Request) -> Response:
|
||||
session = _require_session(request, signer)
|
||||
await _require_csrf(request, session)
|
||||
response = RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
response.delete_cookie(SESSION_COOKIE)
|
||||
await audit.log(runtime, actor=f"admin:{session['user']}", kind="logout")
|
||||
return response
|
||||
|
||||
# ---- dashboard ----
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request) -> Response:
|
||||
session = _require_session(request, signer)
|
||||
async with runtime.db.session() as db_session:
|
||||
audit = await list_audit_records(db_session, limit=AUDIT_PAGE_SIZE)
|
||||
tokens = await list_tokens(db_session, include_revoked=False)
|
||||
endpoints = _build_endpoint_catalog(request, runtime)
|
||||
return HTMLResponse(
|
||||
render(
|
||||
"dashboard.html",
|
||||
user=session["user"],
|
||||
csrf=session["csrf"],
|
||||
agents=list(runtime.agents),
|
||||
mcps=list(runtime.mcps),
|
||||
audit=audit,
|
||||
tokens=tokens,
|
||||
endpoints=endpoints,
|
||||
)
|
||||
)
|
||||
|
||||
# ---- tokens ----
|
||||
|
||||
@app.get("/tokens", response_class=HTMLResponse)
|
||||
async def tokens_page(request: Request) -> Response:
|
||||
session = _require_session(request, signer)
|
||||
include_revoked = request.query_params.get("include_revoked") == "1"
|
||||
async with runtime.db.session() as db_session:
|
||||
tokens = await list_tokens(db_session, include_revoked=include_revoked)
|
||||
return HTMLResponse(
|
||||
render(
|
||||
"tokens.html",
|
||||
user=session["user"],
|
||||
csrf=session["csrf"],
|
||||
tokens=tokens,
|
||||
scopes=CREATABLE_SCOPES,
|
||||
include_revoked=include_revoked,
|
||||
)
|
||||
)
|
||||
|
||||
@app.post("/tokens", response_class=HTMLResponse)
|
||||
async def tokens_create(
|
||||
request: Request,
|
||||
name: Annotated[str, Form(...)],
|
||||
scope: Annotated[str, Form(...)],
|
||||
) -> Response:
|
||||
session = _require_session(request, signer)
|
||||
await _require_csrf(request, session)
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "token name is required"
|
||||
)
|
||||
if scope not in VALID_SCOPES:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, f"invalid scope: {scope!r}"
|
||||
)
|
||||
# Generate the plaintext server-side — clients never pick
|
||||
# their own. URL-safe so it copies cleanly into ``.env``.
|
||||
plaintext = secrets.token_urlsafe(32)
|
||||
hashed = hash_token(plaintext)
|
||||
async with runtime.db.session() as db_session:
|
||||
try:
|
||||
row = await create_token(
|
||||
db_session, name=name, scope=scope, hashed_value=hashed
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Likely UNIQUE(name) violation; the column is
|
||||
# indexed so collisions are common. We surface a
|
||||
# readable HTMX-friendly response rather than 500.
|
||||
_log.warning("token create failed: %s", exc)
|
||||
return HTMLResponse(
|
||||
render(
|
||||
"_token_error.html",
|
||||
message=f"could not create token {name!r}: {exc}",
|
||||
),
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
await runtime.token_store.invalidate()
|
||||
await audit.log(
|
||||
runtime,
|
||||
actor=f"admin:{session['user']}",
|
||||
kind="token_create",
|
||||
name=name,
|
||||
scope=scope,
|
||||
token_id=row.id,
|
||||
)
|
||||
return HTMLResponse(
|
||||
render(
|
||||
"_token_created.html",
|
||||
token=row,
|
||||
plaintext=plaintext,
|
||||
csrf=session["csrf"],
|
||||
)
|
||||
)
|
||||
|
||||
@app.post("/tokens/{token_id}/revoke", response_class=HTMLResponse)
|
||||
async def tokens_revoke(request: Request, token_id: int) -> Response:
|
||||
session = _require_session(request, signer)
|
||||
await _require_csrf(request, session)
|
||||
async with runtime.db.session() as db_session:
|
||||
ok = await revoke_token(db_session, token_id=token_id)
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, f"no active token with id {token_id}"
|
||||
)
|
||||
await runtime.token_store.invalidate()
|
||||
await audit.log(
|
||||
runtime,
|
||||
actor=f"admin:{session['user']}",
|
||||
kind="token_revoke",
|
||||
token_id=token_id,
|
||||
)
|
||||
# Re-fetch so the row reflects the just-stamped revoked_at.
|
||||
async with runtime.db.session() as db_session:
|
||||
rows = await list_tokens(db_session, include_revoked=True)
|
||||
row = next((r for r in rows if r.id == token_id), None)
|
||||
if row is None:
|
||||
# Shouldn't happen — revoke_token said ok — but stay
|
||||
# honest: respond empty so the row disappears from the
|
||||
# table.
|
||||
return HTMLResponse("")
|
||||
return HTMLResponse(
|
||||
render("_token_row.html", token=row, csrf=session["csrf"])
|
||||
)
|
||||
|
||||
# ---- audit ----
|
||||
|
||||
@app.get("/audit", response_class=HTMLResponse)
|
||||
async def audit_page(request: Request) -> Response:
|
||||
session = _require_session(request, signer)
|
||||
before_raw = request.query_params.get("before")
|
||||
before_id: int | None = None
|
||||
if before_raw and before_raw.isdigit():
|
||||
before_id = int(before_raw)
|
||||
async with runtime.db.session() as db_session:
|
||||
rows = await list_audit_records(
|
||||
db_session, limit=AUDIT_PAGE_SIZE, before_id=before_id
|
||||
)
|
||||
# Cursor for the "next page" link — the smallest id on this
|
||||
# page; ``None`` if we've run out of rows.
|
||||
next_before = rows[-1].id if rows and len(rows) == AUDIT_PAGE_SIZE else None
|
||||
return HTMLResponse(
|
||||
render(
|
||||
"audit.html",
|
||||
user=session["user"],
|
||||
csrf=session["csrf"],
|
||||
audit=rows,
|
||||
next_before=next_before,
|
||||
)
|
||||
)
|
||||
|
||||
# ---- chat ----
|
||||
|
||||
# In-process playground: lets the operator drive any configured
|
||||
# agent without minting a bearer token. Auth comes from the
|
||||
# admin cookie; CSRF rides an ``X-CSRF-Token`` header because
|
||||
# the body is JSON (no form to read). We call the backend
|
||||
# directly — no HTTP hop to ``/v1/messages`` — so a chat turn
|
||||
# bypasses the token store but is still audited as
|
||||
# ``kind="messages"`` with ``actor="admin:<user>"`` and
|
||||
# ``source="admin_chat"`` in the detail.
|
||||
@app.get("/chat", response_class=HTMLResponse)
|
||||
async def chat_page(request: Request) -> Response:
|
||||
session = _require_session(request, signer)
|
||||
available = [
|
||||
a for a in runtime.agents if runtime.backends.get(a.name) is not None
|
||||
]
|
||||
return HTMLResponse(
|
||||
render(
|
||||
"chat.html",
|
||||
user=session["user"],
|
||||
csrf=session["csrf"],
|
||||
agents=available,
|
||||
)
|
||||
)
|
||||
|
||||
@app.post("/chat/send")
|
||||
async def chat_send(request: Request) -> Response:
|
||||
session = _require_session(request, signer)
|
||||
submitted = request.headers.get("x-csrf-token")
|
||||
if not isinstance(submitted, str) or not hmac.compare_digest(
|
||||
submitted, session["csrf"]
|
||||
):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "csrf check failed")
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, f"invalid JSON: {exc}"
|
||||
) from exc
|
||||
model = body.get("model")
|
||||
if not isinstance(model, str):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "missing or non-string `model`"
|
||||
)
|
||||
agent = runtime.agents.get(model)
|
||||
if agent is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, f"unknown agent: {model!r}"
|
||||
)
|
||||
backend = runtime.backends.get(agent.name)
|
||||
if backend is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
f"no backend configured for agent {agent.name!r}",
|
||||
)
|
||||
messages = body.get("messages") or []
|
||||
if not isinstance(messages, list):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "`messages` must be a list"
|
||||
)
|
||||
system = body.get("system")
|
||||
await audit.log(
|
||||
runtime,
|
||||
actor=f"admin:{session['user']}",
|
||||
kind="messages",
|
||||
agent_name=agent.name,
|
||||
source="admin_chat",
|
||||
msgs=len(messages),
|
||||
)
|
||||
events = backend.complete(
|
||||
agent=agent,
|
||||
messages=messages,
|
||||
system=system if isinstance(system, str) else None,
|
||||
)
|
||||
return StreamingResponse(
|
||||
_sse_events(events), media_type="text/event-stream"
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# ---- helpers ------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_template_env() -> Environment:
|
||||
env = Environment(
|
||||
loader=PackageLoader("beaver_gateway.frontends.admin", "templates"),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
env.filters["fmt_dt"] = _fmt_dt
|
||||
env.filters["fmt_detail"] = _fmt_detail
|
||||
return env
|
||||
|
||||
|
||||
def _fmt_dt(value: datetime | None) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
# ISO without microseconds, with explicit ``Z`` when UTC — easier to
|
||||
# scan than the default ``+00:00``.
|
||||
s = value.replace(microsecond=0).isoformat()
|
||||
return s.replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _fmt_detail(raw: str) -> str:
|
||||
"""Pretty-print the audit detail blob; leave invalid JSON alone."""
|
||||
if not raw or raw == "{}":
|
||||
return ""
|
||||
try:
|
||||
return json.dumps(json.loads(raw), separators=(", ", ": "))
|
||||
except json.JSONDecodeError:
|
||||
return raw
|
||||
|
||||
|
||||
def _build_endpoint_catalog(
|
||||
request: Request, runtime: GatewayRuntime
|
||||
) -> dict[str, Any]:
|
||||
"""Collect copy-pastable URLs for sibling bearer frontends.
|
||||
|
||||
Precedence per frontend:
|
||||
|
||||
1. ``frontend.public_base_url`` if set — the operator's explicit
|
||||
statement of "this is the URL my reverse proxy (Caddy / nginx /
|
||||
Cloudflare / …) puts in front of me". Used verbatim with the
|
||||
internal path (``/v1/messages``, ``/<ns>/`` for MCP) appended.
|
||||
2. ``{scheme}://{request_hostname}:{frontend_port}`` — derived from
|
||||
the browser's own request so dev / no-proxy setups Just Work.
|
||||
Scheme honours ``X-Forwarded-Proto`` so a TLS terminator in
|
||||
front of the admin gets the right protocol.
|
||||
|
||||
Imports happen inside the function to avoid a hard dep from
|
||||
``admin.frontend`` on the other frontend modules — they're optional
|
||||
and may have non-trivial transitive deps (aiohttp etc.).
|
||||
"""
|
||||
from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend
|
||||
from beaver_gateway.frontends.mcp_server import McpServerFrontend
|
||||
|
||||
scheme = request.headers.get("x-forwarded-proto") or request.url.scheme
|
||||
hostname = request.url.hostname or "localhost"
|
||||
|
||||
def _base_for(fe: AnthropicMessagesFrontend | McpServerFrontend) -> str:
|
||||
if fe.public_base_url:
|
||||
return fe.public_base_url
|
||||
return f"{scheme}://{hostname}:{fe.port}"
|
||||
|
||||
anthropic_base: str | None = None
|
||||
mcp_base: str | None = None
|
||||
for fe in runtime.frontends:
|
||||
if isinstance(fe, AnthropicMessagesFrontend) and anthropic_base is None:
|
||||
anthropic_base = _base_for(fe)
|
||||
elif isinstance(fe, McpServerFrontend) and mcp_base is None:
|
||||
mcp_base = _base_for(fe)
|
||||
|
||||
agent_rows: list[dict[str, Any]] = []
|
||||
if anthropic_base is not None:
|
||||
messages_url = f"{anthropic_base}/v1/messages"
|
||||
agent_rows.extend(
|
||||
{
|
||||
"agent": a.name,
|
||||
"model": a.model,
|
||||
"agent_type": a.__class__.__name__,
|
||||
"url": messages_url,
|
||||
}
|
||||
for a in runtime.agents
|
||||
)
|
||||
|
||||
mcp_rows: list[dict[str, Any]] = []
|
||||
if mcp_base is not None:
|
||||
mcp_rows.extend(
|
||||
{"namespace": m.name, "kind": m.kind, "url": f"{mcp_base}/{m.name}/"}
|
||||
for m in runtime.mcps
|
||||
)
|
||||
# ``all`` is synthesised by the aggregator whenever at least
|
||||
# one MCP is configured — see McpServerFrontend._upstream_url.
|
||||
if runtime.mcps:
|
||||
mcp_rows.append(
|
||||
{"namespace": "all", "kind": "bundle", "url": f"{mcp_base}/all/"}
|
||||
)
|
||||
|
||||
return {
|
||||
"anthropic_base": anthropic_base,
|
||||
"mcp_base": mcp_base,
|
||||
"agents": agent_rows,
|
||||
"mcps": mcp_rows,
|
||||
}
|
||||
|
||||
|
||||
async def _sse_events(
|
||||
events: AsyncIterator[MessageStreamEvent],
|
||||
) -> AsyncIterator[bytes]:
|
||||
r"""Serialize a backend stream into Anthropic's ``text/event-stream`` form.
|
||||
|
||||
Same wire shape as :mod:`beaver_gateway.frontends.anthropic` —
|
||||
duplicated rather than imported so the admin frontend stays
|
||||
independent of that module's private helpers, and so a mid-stream
|
||||
failure surfaces as an in-band ``error`` event the chat UI can
|
||||
render rather than a dangling connection.
|
||||
"""
|
||||
try:
|
||||
async for ev in events:
|
||||
payload = ev.model_dump_json()
|
||||
yield f"event: {ev.type}\ndata: {payload}\n\n".encode()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_log.exception("admin chat backend stream failed")
|
||||
err = json.dumps(
|
||||
{"type": "error", "error": {"type": "api_error", "message": str(exc)}}
|
||||
)
|
||||
yield f"event: error\ndata: {err}\n\n".encode()
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, value: str) -> None:
|
||||
# ``samesite=lax`` keeps the cookie out of cross-site POSTs but
|
||||
# follows top-level navigation; ``httponly`` keeps it out of JS;
|
||||
# ``secure`` is gated on the deployment scheme — toggled by reverse
|
||||
# proxies in front. Skip the secure flag here so localhost dev
|
||||
# works over plain HTTP; production deployments behind a TLS
|
||||
# terminator should set ``Secure`` via the proxy.
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
value,
|
||||
max_age=SESSION_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def _current_user(
|
||||
request: Request, signer: itsdangerous.URLSafeTimedSerializer
|
||||
) -> dict[str, Any] | None:
|
||||
raw = request.cookies.get(SESSION_COOKIE)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
payload = signer.loads(raw, max_age=SESSION_MAX_AGE)
|
||||
except itsdangerous.BadSignature:
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
user = payload.get("user")
|
||||
csrf = payload.get("csrf")
|
||||
if not isinstance(user, str) or not isinstance(csrf, str):
|
||||
return None
|
||||
return {"user": user, "csrf": csrf}
|
||||
|
||||
|
||||
def _require_session(
|
||||
request: Request, signer: itsdangerous.URLSafeTimedSerializer
|
||||
) -> dict[str, Any]:
|
||||
session = _current_user(request, signer)
|
||||
if session is None:
|
||||
# GET endpoints want a redirect (so the browser walks the user
|
||||
# to the login form), not a JSON 401. Mutating endpoints will
|
||||
# still trip CSRF below, so the redirect is harmless for those.
|
||||
raise HTTPException(status.HTTP_303_SEE_OTHER, headers={"Location": "/login"})
|
||||
return session
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""Best-effort source IP, in precedence order.
|
||||
|
||||
1. ``Cf-Connecting-IP`` — Cloudflare's edge writes this and strips
|
||||
anything inbound, so when it's present it's authoritative.
|
||||
2. ``X-Forwarded-For`` leftmost entry — what Caddy / nginx set when
|
||||
they're the only proxy. We trust this because the deploy plan
|
||||
puts Caddy directly in front; if the chain ever grows untrusted
|
||||
hops, this header becomes spoofable from the public side.
|
||||
3. Socket peer — direct Tailscale / localhost hits.
|
||||
"""
|
||||
cf = request.headers.get("cf-connecting-ip")
|
||||
if cf:
|
||||
return cf.strip()
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
first = xff.split(",", 1)[0].strip()
|
||||
if first:
|
||||
return first
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
class _LoginRateLimit:
|
||||
"""In-memory sliding-window failure counter for ``POST /login``.
|
||||
|
||||
Keyed by source IP (see :func:`_client_ip`). Each failure appends a
|
||||
monotonic timestamp; :meth:`check` drops timestamps older than
|
||||
``window`` and refuses once ``max_attempts`` remain in the bucket.
|
||||
A successful login calls :meth:`clear` to wipe the IP's bucket.
|
||||
|
||||
No external store: a single-operator admin only needs to survive
|
||||
process lifetime. Lives entirely on the event loop thread, so no
|
||||
lock is needed — every method is synchronous and doesn't ``await``.
|
||||
"""
|
||||
|
||||
__slots__ = ("_failures", "_max_attempts", "_window")
|
||||
|
||||
def __init__(self, *, max_attempts: int, window: float) -> None:
|
||||
self._failures: dict[str, deque[float]] = {}
|
||||
self._max_attempts = max_attempts
|
||||
self._window = window
|
||||
|
||||
def check(self, ip: str) -> bool:
|
||||
bucket = self._failures.get(ip)
|
||||
if bucket is None:
|
||||
return True
|
||||
cutoff = time.monotonic() - self._window
|
||||
while bucket and bucket[0] < cutoff:
|
||||
bucket.popleft()
|
||||
if not bucket:
|
||||
self._failures.pop(ip, None)
|
||||
return True
|
||||
return len(bucket) < self._max_attempts
|
||||
|
||||
def record_failure(self, ip: str) -> None:
|
||||
bucket = self._failures.setdefault(ip, deque())
|
||||
cutoff = time.monotonic() - self._window
|
||||
while bucket and bucket[0] < cutoff:
|
||||
bucket.popleft()
|
||||
bucket.append(time.monotonic())
|
||||
|
||||
def clear(self, ip: str) -> None:
|
||||
self._failures.pop(ip, None)
|
||||
|
||||
|
||||
async def _require_csrf(request: Request, session: dict[str, Any]) -> None:
|
||||
form = await request.form()
|
||||
submitted = form.get("csrf_token")
|
||||
if not isinstance(submitted, str) or not hmac.compare_digest(
|
||||
submitted, session["csrf"]
|
||||
):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "csrf check failed")
|
||||
Reference in New Issue
Block a user