feat: allow custom base url

This commit is contained in:
h
2026-05-21 23:27:57 +02:00
parent e8a4e3aa82
commit 64ad92b4c1
8 changed files with 86 additions and 34 deletions
+71 -20
View File
@@ -32,6 +32,7 @@ import secrets
import time
from collections import deque
from typing import TYPE_CHECKING, Annotated, Any
from urllib.parse import urlsplit
import itsdangerous
from fastapi import FastAPI, Form, HTTPException, Request, status
@@ -98,9 +99,34 @@ __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
def __init__(
self,
*,
host: str = "0.0.0.0", # noqa: S104
port: int = 8002,
public_base_url: str | None = None,
) -> None:
self.host = host
self.port = port
# The reverse proxy in front (Caddy `handle_path /admin/*`, nginx
# `location /admin/`, …) may strip a path prefix before forwarding.
# If it does, the admin's self-emitted URLs (redirects, form
# actions, HTMX `hx-post`, nav links, JS `fetch`, the session
# cookie's `Path`) need that prefix back on or the browser will
# walk off the mounted subtree. We derive the prefix from
# ``public_base_url``'s path component:
#
# public_base_url=https://api.example.com/admin
# → url_prefix = "/admin"
# → links rendered as `/admin/login`, cookie scoped to /admin
#
# Empty / unset = root-mounted; the historical no-proxy behaviour.
if public_base_url:
self.public_base_url = public_base_url.rstrip("/")
self.url_prefix = urlsplit(self.public_base_url).path.rstrip("/")
else:
self.public_base_url = None
self.url_prefix = ""
self._runtime: GatewayRuntime | None = None
self._app: FastAPI | None = None
@@ -136,16 +162,27 @@ class AdminFrontend(Frontend):
# self-state. Splitting the function would just trade size for
# bookkeeping — same total surface, harder to follow.
templates = _build_template_env()
url_prefix = self.url_prefix
signer = itsdangerous.URLSafeTimedSerializer(
runtime.session_secret, salt=SESSION_SALT
)
login_limit = _LoginRateLimit(
max_attempts=LOGIN_MAX_ATTEMPTS, window=LOGIN_WINDOW_SECONDS
)
# Cookies scoped to ``url_prefix`` (or ``/`` at the root) so a
# single domain can host multiple gateways without cookie cross-
# talk. Browsers ignore a Set-Cookie / delete-cookie whose Path
# doesn't match, so the matching path also has to flow into
# ``_set_session_cookie`` *and* the logout `delete_cookie`.
cookie_path = url_prefix or "/"
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)
# ``p`` is the prefix Jinja templates prepend to every
# internal path (``{{ p }}/login``, ``{{ p }}/tokens`` …).
# Injected here rather than on ``templates.globals`` because
# ty types the globals dict against Jinja's built-ins.
return templates.get_template(name).render(p=url_prefix, **ctx)
# ---- login ----
@@ -155,7 +192,9 @@ class AdminFrontend(Frontend):
# 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)
return RedirectResponse(
f"{url_prefix}/", status_code=status.HTTP_303_SEE_OTHER
)
error = request.query_params.get("error")
return HTMLResponse(render("login.html", error=error))
@@ -204,24 +243,31 @@ class AdminFrontend(Frontend):
# the form on refresh. Status-303 forces GET on the
# follow-up regardless of the original method.
return RedirectResponse(
"/login?error=invalid+credentials",
f"{url_prefix}/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)
response = RedirectResponse(
f"{url_prefix}/", status_code=status.HTTP_303_SEE_OTHER
)
_set_session_cookie(response, cookie, path=cookie_path)
_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)
session = _require_session(request, signer, url_prefix=url_prefix)
await _require_csrf(request, session)
response = RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(SESSION_COOKIE)
response = RedirectResponse(
f"{url_prefix}/login", status_code=status.HTTP_303_SEE_OTHER
)
# `path` must match the Set-Cookie path, otherwise the
# browser silently keeps the cookie and re-login looks fine
# but the next request re-uses the stale session.
response.delete_cookie(SESSION_COOKIE, path=cookie_path)
await audit.log(runtime, actor=f"admin:{session['user']}", kind="logout")
return response
@@ -229,7 +275,7 @@ class AdminFrontend(Frontend):
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request) -> Response:
session = _require_session(request, signer)
session = _require_session(request, signer, url_prefix=url_prefix)
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)
@@ -251,7 +297,7 @@ class AdminFrontend(Frontend):
@app.get("/tokens", response_class=HTMLResponse)
async def tokens_page(request: Request) -> Response:
session = _require_session(request, signer)
session = _require_session(request, signer, url_prefix=url_prefix)
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)
@@ -272,7 +318,7 @@ class AdminFrontend(Frontend):
name: Annotated[str, Form(...)],
scope: Annotated[str, Form(...)],
) -> Response:
session = _require_session(request, signer)
session = _require_session(request, signer, url_prefix=url_prefix)
await _require_csrf(request, session)
name = name.strip()
if not name:
@@ -324,7 +370,7 @@ class AdminFrontend(Frontend):
@app.post("/tokens/{token_id}/revoke", response_class=HTMLResponse)
async def tokens_revoke(request: Request, token_id: int) -> Response:
session = _require_session(request, signer)
session = _require_session(request, signer, url_prefix=url_prefix)
await _require_csrf(request, session)
async with runtime.db.session() as db_session:
ok = await revoke_token(db_session, token_id=token_id)
@@ -356,7 +402,7 @@ class AdminFrontend(Frontend):
@app.get("/audit", response_class=HTMLResponse)
async def audit_page(request: Request) -> Response:
session = _require_session(request, signer)
session = _require_session(request, signer, url_prefix=url_prefix)
before_raw = request.query_params.get("before")
before_id: int | None = None
if before_raw and before_raw.isdigit():
@@ -390,7 +436,7 @@ class AdminFrontend(Frontend):
# ``source="admin_chat"`` in the detail.
@app.get("/chat", response_class=HTMLResponse)
async def chat_page(request: Request) -> Response:
session = _require_session(request, signer)
session = _require_session(request, signer, url_prefix=url_prefix)
available = [
a for a in runtime.agents if runtime.backends.get(a.name) is not None
]
@@ -405,7 +451,7 @@ class AdminFrontend(Frontend):
@app.post("/chat/send")
async def chat_send(request: Request) -> Response:
session = _require_session(request, signer)
session = _require_session(request, signer, url_prefix=url_prefix)
submitted = request.headers.get("x-csrf-token")
if not isinstance(submitted, str) or not hmac.compare_digest(
submitted, session["csrf"]
@@ -651,7 +697,7 @@ async def _sse_events_and_broadcast(
_log.exception("turn_log_handler raised; continuing")
def _set_session_cookie(response: Response, value: str) -> None:
def _set_session_cookie(response: Response, value: str, *, path: 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
@@ -664,7 +710,7 @@ def _set_session_cookie(response: Response, value: str) -> None:
max_age=SESSION_MAX_AGE,
httponly=True,
samesite="lax",
path="/",
path=path,
)
@@ -688,14 +734,19 @@ def _current_user(
def _require_session(
request: Request, signer: itsdangerous.URLSafeTimedSerializer
request: Request,
signer: itsdangerous.URLSafeTimedSerializer,
*,
url_prefix: str = "",
) -> 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"})
raise HTTPException(
status.HTTP_303_SEE_OTHER, headers={"Location": f"{url_prefix}/login"}
)
return session
@@ -185,13 +185,13 @@
<div class="inner">
<h1>beaver-gateway</h1>
<nav class="tabs">
<a href="/" class="{% if active == 'dashboard' %}active{% endif %}">Dashboard</a>
<a href="/chat" class="{% if active == 'chat' %}active{% endif %}">Chat</a>
<a href="/tokens" class="{% if active == 'tokens' %}active{% endif %}">Tokens</a>
<a href="/audit" class="{% if active == 'audit' %}active{% endif %}">Audit</a>
<a href="{{ p }}/" class="{% if active == 'dashboard' %}active{% endif %}">Dashboard</a>
<a href="{{ p }}/chat" class="{% if active == 'chat' %}active{% endif %}">Chat</a>
<a href="{{ p }}/tokens" class="{% if active == 'tokens' %}active{% endif %}">Tokens</a>
<a href="{{ p }}/audit" class="{% if active == 'audit' %}active{% endif %}">Audit</a>
</nav>
<span class="actor">Signed in as <strong>{{ user }}</strong></span>
<form class="inline" method="post" action="/logout">
<form class="inline" method="post" action="{{ p }}/logout">
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<button type="submit">Log out</button>
</form>
@@ -10,7 +10,7 @@
{% if not token.revoked_at %}
<form
class="inline"
hx-post="/tokens/{{ token.id }}/revoke"
hx-post="{{ p }}/tokens/{{ token.id }}/revoke"
hx-target="#token-row-{{ token.id }}"
hx-swap="outerHTML"
hx-confirm="Revoke token {{ token.name }}? This cannot be undone."
@@ -25,7 +25,7 @@
</table>
<p class="muted" style="margin-top:1rem;">
{% if next_before %}
<a href="/audit?before={{ next_before }}">Older entries →</a>
<a href="{{ p }}/audit?before={{ next_before }}">Older entries →</a>
{% else %}
End of log.
{% endif %}
@@ -118,6 +118,7 @@
<script>
(() => {
const CSRF = {{ csrf | tojson }};
const URL_PREFIX = {{ p | tojson }};
const agentSelect = document.getElementById("agent-select");
const messagesEl = document.getElementById("messages");
const form = document.getElementById("chat-form");
@@ -291,7 +292,7 @@
let resp;
try {
resp = await fetch("/chat/send", {
resp = await fetch(URL_PREFIX + "/chat/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -68,7 +68,7 @@
</div>
<p class="muted ep-note">
Beaver stores only an Argon2 hash of each token, so the plaintext can't be reconstructed.
Paste the value you saved at creation; if you've lost it, <a href="/tokens">mint a new one</a>.
Paste the value you saved at creation; if you've lost it, <a href="{{ p }}/tokens">mint a new one</a>.
Below: pick a token to see which endpoints its scope covers, paste the secret to fill it
into URL / curl, then click Copy.
</p>
@@ -194,7 +194,7 @@
{% endfor %}
</tbody>
</table>
<p class="muted" style="margin-top:0.85rem;"><a href="/audit">Full log →</a></p>
<p class="muted" style="margin-top:0.85rem;"><a href="{{ p }}/audit">Full log →</a></p>
{% else %}
<p class="muted">Nothing logged yet.</p>
{% endif %}
@@ -71,7 +71,7 @@
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="post" action="/login">
<form method="post" action="{{ p }}/login">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" required autofocus>
<label for="password">Password</label>
@@ -7,7 +7,7 @@
<p class="muted">Plaintext is shown <strong>once</strong>, immediately after creation. Copy it before you navigate away — the database only ever holds the Argon2 hash.</p>
<div id="token-create-result"></div>
<form
hx-post="/tokens"
hx-post="{{ p }}/tokens"
hx-target="#token-create-result"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful){this.reset();}"
@@ -37,9 +37,9 @@
Tokens
<span class="muted" style="font-size:0.8em; font-weight:400; margin-left:0.5rem;">
{% if include_revoked %}
<a href="/tokens">Hide revoked</a>
<a href="{{ p }}/tokens">Hide revoked</a>
{% else %}
<a href="/tokens?include_revoked=1">Show revoked</a>
<a href="{{ p }}/tokens?include_revoked=1">Show revoked</a>
{% endif %}
</span>
</h2>