feat: allow custom base url
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user