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 import time
from collections import deque from collections import deque
from typing import TYPE_CHECKING, Annotated, Any from typing import TYPE_CHECKING, Annotated, Any
from urllib.parse import urlsplit
import itsdangerous import itsdangerous
from fastapi import FastAPI, Form, HTTPException, Request, status from fastapi import FastAPI, Form, HTTPException, Request, status
@@ -98,9 +99,34 @@ __all__ = ["AdminFrontend"]
class AdminFrontend(Frontend): class AdminFrontend(Frontend):
"""FastAPI app behind ``/login`` / ``/tokens`` / ``/audit`` / ``/``.""" """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.host = host
self.port = port 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._runtime: GatewayRuntime | None = None
self._app: FastAPI | None = None self._app: FastAPI | None = None
@@ -136,16 +162,27 @@ class AdminFrontend(Frontend):
# self-state. Splitting the function would just trade size for # self-state. Splitting the function would just trade size for
# bookkeeping — same total surface, harder to follow. # bookkeeping — same total surface, harder to follow.
templates = _build_template_env() templates = _build_template_env()
url_prefix = self.url_prefix
signer = itsdangerous.URLSafeTimedSerializer( signer = itsdangerous.URLSafeTimedSerializer(
runtime.session_secret, salt=SESSION_SALT runtime.session_secret, salt=SESSION_SALT
) )
login_limit = _LoginRateLimit( login_limit = _LoginRateLimit(
max_attempts=LOGIN_MAX_ATTEMPTS, window=LOGIN_WINDOW_SECONDS 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) app = FastAPI(title="beaver-gateway / admin", docs_url=None, redoc_url=None)
def render(name: str, **ctx: Any) -> str: 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 ---- # ---- login ----
@@ -155,7 +192,9 @@ class AdminFrontend(Frontend):
# carried in a one-shot query param so a failed POST can # carried in a one-shot query param so a failed POST can
# redirect back here without leaking creds in the URL. # redirect back here without leaking creds in the URL.
if _current_user(request, signer): 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") error = request.query_params.get("error")
return HTMLResponse(render("login.html", error=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 # the form on refresh. Status-303 forces GET on the
# follow-up regardless of the original method. # follow-up regardless of the original method.
return RedirectResponse( return RedirectResponse(
"/login?error=invalid+credentials", f"{url_prefix}/login?error=invalid+credentials",
status_code=status.HTTP_303_SEE_OTHER, status_code=status.HTTP_303_SEE_OTHER,
) )
login_limit.clear(ip) login_limit.clear(ip)
csrf = secrets.token_urlsafe(24) csrf = secrets.token_urlsafe(24)
cookie = signer.dumps({"user": username, "csrf": csrf}) cookie = signer.dumps({"user": username, "csrf": csrf})
response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) response = RedirectResponse(
_set_session_cookie(response, cookie) 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) _log.info("admin login ok: user=%s ip=%s", username, ip)
await audit.log(runtime, actor=f"admin:{username}", kind="login_ok", ip=ip) await audit.log(runtime, actor=f"admin:{username}", kind="login_ok", ip=ip)
return response return response
@app.post("/logout") @app.post("/logout")
async def logout(request: Request) -> Response: 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) await _require_csrf(request, session)
response = RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER) response = RedirectResponse(
response.delete_cookie(SESSION_COOKIE) 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") await audit.log(runtime, actor=f"admin:{session['user']}", kind="logout")
return response return response
@@ -229,7 +275,7 @@ class AdminFrontend(Frontend):
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request) -> Response: 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: async with runtime.db.session() as db_session:
audit = await list_audit_records(db_session, limit=AUDIT_PAGE_SIZE) audit = await list_audit_records(db_session, limit=AUDIT_PAGE_SIZE)
tokens = await list_tokens(db_session, include_revoked=False) tokens = await list_tokens(db_session, include_revoked=False)
@@ -251,7 +297,7 @@ class AdminFrontend(Frontend):
@app.get("/tokens", response_class=HTMLResponse) @app.get("/tokens", response_class=HTMLResponse)
async def tokens_page(request: Request) -> Response: 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" include_revoked = request.query_params.get("include_revoked") == "1"
async with runtime.db.session() as db_session: async with runtime.db.session() as db_session:
tokens = await list_tokens(db_session, include_revoked=include_revoked) tokens = await list_tokens(db_session, include_revoked=include_revoked)
@@ -272,7 +318,7 @@ class AdminFrontend(Frontend):
name: Annotated[str, Form(...)], name: Annotated[str, Form(...)],
scope: Annotated[str, Form(...)], scope: Annotated[str, Form(...)],
) -> Response: ) -> Response:
session = _require_session(request, signer) session = _require_session(request, signer, url_prefix=url_prefix)
await _require_csrf(request, session) await _require_csrf(request, session)
name = name.strip() name = name.strip()
if not name: if not name:
@@ -324,7 +370,7 @@ class AdminFrontend(Frontend):
@app.post("/tokens/{token_id}/revoke", response_class=HTMLResponse) @app.post("/tokens/{token_id}/revoke", response_class=HTMLResponse)
async def tokens_revoke(request: Request, token_id: int) -> Response: 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) await _require_csrf(request, session)
async with runtime.db.session() as db_session: async with runtime.db.session() as db_session:
ok = await revoke_token(db_session, token_id=token_id) ok = await revoke_token(db_session, token_id=token_id)
@@ -356,7 +402,7 @@ class AdminFrontend(Frontend):
@app.get("/audit", response_class=HTMLResponse) @app.get("/audit", response_class=HTMLResponse)
async def audit_page(request: Request) -> Response: 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_raw = request.query_params.get("before")
before_id: int | None = None before_id: int | None = None
if before_raw and before_raw.isdigit(): if before_raw and before_raw.isdigit():
@@ -390,7 +436,7 @@ class AdminFrontend(Frontend):
# ``source="admin_chat"`` in the detail. # ``source="admin_chat"`` in the detail.
@app.get("/chat", response_class=HTMLResponse) @app.get("/chat", response_class=HTMLResponse)
async def chat_page(request: Request) -> Response: async def chat_page(request: Request) -> Response:
session = _require_session(request, signer) session = _require_session(request, signer, url_prefix=url_prefix)
available = [ available = [
a for a in runtime.agents if runtime.backends.get(a.name) is not None 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") @app.post("/chat/send")
async def chat_send(request: Request) -> Response: 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") submitted = request.headers.get("x-csrf-token")
if not isinstance(submitted, str) or not hmac.compare_digest( if not isinstance(submitted, str) or not hmac.compare_digest(
submitted, session["csrf"] submitted, session["csrf"]
@@ -651,7 +697,7 @@ async def _sse_events_and_broadcast(
_log.exception("turn_log_handler raised; continuing") _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 # ``samesite=lax`` keeps the cookie out of cross-site POSTs but
# follows top-level navigation; ``httponly`` keeps it out of JS; # follows top-level navigation; ``httponly`` keeps it out of JS;
# ``secure`` is gated on the deployment scheme — toggled by reverse # ``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, max_age=SESSION_MAX_AGE,
httponly=True, httponly=True,
samesite="lax", samesite="lax",
path="/", path=path,
) )
@@ -688,14 +734,19 @@ def _current_user(
def _require_session( def _require_session(
request: Request, signer: itsdangerous.URLSafeTimedSerializer request: Request,
signer: itsdangerous.URLSafeTimedSerializer,
*,
url_prefix: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
session = _current_user(request, signer) session = _current_user(request, signer)
if session is None: if session is None:
# GET endpoints want a redirect (so the browser walks the user # GET endpoints want a redirect (so the browser walks the user
# to the login form), not a JSON 401. Mutating endpoints will # to the login form), not a JSON 401. Mutating endpoints will
# still trip CSRF below, so the redirect is harmless for those. # 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 return session
@@ -185,13 +185,13 @@
<div class="inner"> <div class="inner">
<h1>beaver-gateway</h1> <h1>beaver-gateway</h1>
<nav class="tabs"> <nav class="tabs">
<a href="/" class="{% if active == 'dashboard' %}active{% endif %}">Dashboard</a> <a href="{{ p }}/" class="{% if active == 'dashboard' %}active{% endif %}">Dashboard</a>
<a href="/chat" class="{% if active == 'chat' %}active{% endif %}">Chat</a> <a href="{{ p }}/chat" class="{% if active == 'chat' %}active{% endif %}">Chat</a>
<a href="/tokens" class="{% if active == 'tokens' %}active{% endif %}">Tokens</a> <a href="{{ p }}/tokens" class="{% if active == 'tokens' %}active{% endif %}">Tokens</a>
<a href="/audit" class="{% if active == 'audit' %}active{% endif %}">Audit</a> <a href="{{ p }}/audit" class="{% if active == 'audit' %}active{% endif %}">Audit</a>
</nav> </nav>
<span class="actor">Signed in as <strong>{{ user }}</strong></span> <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 }}"> <input type="hidden" name="csrf_token" value="{{ csrf }}">
<button type="submit">Log out</button> <button type="submit">Log out</button>
</form> </form>
@@ -10,7 +10,7 @@
{% if not token.revoked_at %} {% if not token.revoked_at %}
<form <form
class="inline" class="inline"
hx-post="/tokens/{{ token.id }}/revoke" hx-post="{{ p }}/tokens/{{ token.id }}/revoke"
hx-target="#token-row-{{ token.id }}" hx-target="#token-row-{{ token.id }}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-confirm="Revoke token {{ token.name }}? This cannot be undone." hx-confirm="Revoke token {{ token.name }}? This cannot be undone."
@@ -25,7 +25,7 @@
</table> </table>
<p class="muted" style="margin-top:1rem;"> <p class="muted" style="margin-top:1rem;">
{% if next_before %} {% if next_before %}
<a href="/audit?before={{ next_before }}">Older entries →</a> <a href="{{ p }}/audit?before={{ next_before }}">Older entries →</a>
{% else %} {% else %}
End of log. End of log.
{% endif %} {% endif %}
@@ -118,6 +118,7 @@
<script> <script>
(() => { (() => {
const CSRF = {{ csrf | tojson }}; const CSRF = {{ csrf | tojson }};
const URL_PREFIX = {{ p | tojson }};
const agentSelect = document.getElementById("agent-select"); const agentSelect = document.getElementById("agent-select");
const messagesEl = document.getElementById("messages"); const messagesEl = document.getElementById("messages");
const form = document.getElementById("chat-form"); const form = document.getElementById("chat-form");
@@ -291,7 +292,7 @@
let resp; let resp;
try { try {
resp = await fetch("/chat/send", { resp = await fetch(URL_PREFIX + "/chat/send", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -68,7 +68,7 @@
</div> </div>
<p class="muted ep-note"> <p class="muted ep-note">
Beaver stores only an Argon2 hash of each token, so the plaintext can't be reconstructed. 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 Below: pick a token to see which endpoints its scope covers, paste the secret to fill it
into URL / curl, then click Copy. into URL / curl, then click Copy.
</p> </p>
@@ -194,7 +194,7 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </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 %} {% else %}
<p class="muted">Nothing logged yet.</p> <p class="muted">Nothing logged yet.</p>
{% endif %} {% endif %}
@@ -71,7 +71,7 @@
{% if error %} {% if error %}
<div class="error">{{ error }}</div> <div class="error">{{ error }}</div>
{% endif %} {% endif %}
<form method="post" action="/login"> <form method="post" action="{{ p }}/login">
<label for="username">Username</label> <label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" required autofocus> <input id="username" name="username" type="text" autocomplete="username" required autofocus>
<label for="password">Password</label> <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> <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> <div id="token-create-result"></div>
<form <form
hx-post="/tokens" hx-post="{{ p }}/tokens"
hx-target="#token-create-result" hx-target="#token-create-result"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful){this.reset();}" hx-on::after-request="if(event.detail.successful){this.reset();}"
@@ -37,9 +37,9 @@
Tokens Tokens
<span class="muted" style="font-size:0.8em; font-weight:400; margin-left:0.5rem;"> <span class="muted" style="font-size:0.8em; font-weight:400; margin-left:0.5rem;">
{% if include_revoked %} {% if include_revoked %}
<a href="/tokens">Hide revoked</a> <a href="{{ p }}/tokens">Hide revoked</a>
{% else %} {% else %}
<a href="/tokens?include_revoked=1">Show revoked</a> <a href="{{ p }}/tokens?include_revoked=1">Show revoked</a>
{% endif %} {% endif %}
</span> </span>
</h2> </h2>