mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
feat(dashboard): support serving under URL prefix via X-Forwarded-Prefix
The Hermes dashboard previously assumed it was served at the root of its host (e.g. https://kanban.tilos.com/). When mounted behind a path-prefix reverse proxy (e.g. https://mission-control.tilos.com/hermes/), the SPA 404'd because: - index.html shipped absolute /assets/index-*.js URLs - React Router had no basename - The plugin loader hit /dashboard-plugins/<name>/... at the root host - CSS in the bundle had absolute url(/fonts/...) references This patch makes the dashboard prefix-aware at runtime, no rebuild required. The proxy injects 'X-Forwarded-Prefix: /hermes' on every request and the Python server: - Rewrites href/src in served index.html to '${prefix}/assets/...' - Injects 'window.__HERMES_BASE_PATH__="${prefix}"' for the SPA to read - Rewrites url() refs in CSS at serve time The SPA reads window.__HERMES_BASE_PATH__ once at boot and: - Prefixes all /api/... fetches via api.ts - Prefixes all /dashboard-plugins/... script/css URLs in usePlugins - Sets <BrowserRouter basename={...}> so client-side routing works When no X-Forwarded-Prefix header is present, behavior is unchanged (empty prefix => serves at root, kanban.tilos.com keeps working). Refs: MC-AUTO-13
This commit is contained in:
parent
6769060ae2
commit
52e2777821
4 changed files with 98 additions and 11 deletions
|
|
@ -52,7 +52,7 @@ from gateway.status import get_running_pid, read_runtime_status
|
|||
try:
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
except ImportError:
|
||||
|
|
@ -3308,12 +3308,42 @@ async def events_ws(ws: WebSocket) -> None:
|
|||
_event_channels.pop(channel, None)
|
||||
|
||||
|
||||
def _normalise_prefix(raw: Optional[str]) -> str:
|
||||
"""Normalise an X-Forwarded-Prefix header value.
|
||||
|
||||
Returns a string like ``"/hermes"`` (no trailing slash) or ``""`` when
|
||||
no prefix is set / the header is malformed. We deliberately reject
|
||||
anything containing ``..`` or non-printable bytes so a hostile proxy
|
||||
can't inject HTML via the prefix.
|
||||
"""
|
||||
if not raw:
|
||||
return ""
|
||||
p = raw.strip()
|
||||
if not p:
|
||||
return ""
|
||||
if not p.startswith("/"):
|
||||
p = "/" + p
|
||||
p = p.rstrip("/")
|
||||
if "//" in p or ".." in p or any(c in p for c in ('"', "'", "<", ">", " ", "\n", "\r", "\t")):
|
||||
return ""
|
||||
if len(p) > 64:
|
||||
return ""
|
||||
return p
|
||||
|
||||
|
||||
def mount_spa(application: FastAPI):
|
||||
"""Mount the built SPA. Falls back to index.html for client-side routing.
|
||||
|
||||
The session token is injected into index.html via a ``<script>`` tag so
|
||||
the SPA can authenticate against protected API endpoints without a
|
||||
separate (unauthenticated) token-dispensing endpoint.
|
||||
|
||||
When served behind a path-prefix reverse proxy (e.g.
|
||||
``mission-control.tilos.com/hermes/*`` -> local Caddy -> :9119), the
|
||||
proxy injects ``X-Forwarded-Prefix: /hermes`` on every request. We
|
||||
rewrite the served ``index.html`` so absolute asset URLs (``/assets/...``)
|
||||
and the SPA's runtime ``__HERMES_BASE_PATH__`` honour that prefix
|
||||
without rebuilding the bundle.
|
||||
"""
|
||||
if not WEB_DIST.exists():
|
||||
@application.get("/{full_path:path}")
|
||||
|
|
@ -3326,24 +3356,62 @@ def mount_spa(application: FastAPI):
|
|||
|
||||
_index_path = WEB_DIST / "index.html"
|
||||
|
||||
def _serve_index():
|
||||
"""Return index.html with the session token injected."""
|
||||
def _serve_index(prefix: str = ""):
|
||||
"""Return index.html with the session token + base-path injected.
|
||||
|
||||
``prefix`` is the normalised ``X-Forwarded-Prefix`` (e.g. ``/hermes``)
|
||||
or empty string when served at root.
|
||||
"""
|
||||
html = _index_path.read_text()
|
||||
chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
|
||||
token_script = (
|
||||
f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";'
|
||||
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};</script>"
|
||||
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
|
||||
f'window.__HERMES_BASE_PATH__="{prefix}";</script>'
|
||||
)
|
||||
if prefix:
|
||||
# Rewrite absolute asset URLs baked into the Vite build so the
|
||||
# browser fetches them through the same proxy prefix.
|
||||
html = html.replace('href="/assets/', f'href="{prefix}/assets/')
|
||||
html = html.replace('src="/assets/', f'src="{prefix}/assets/')
|
||||
html = html.replace('href="/favicon.ico"', f'href="{prefix}/favicon.ico"')
|
||||
html = html.replace('href="/fonts/', f'href="{prefix}/fonts/')
|
||||
html = html.replace('href="/ds-assets/', f'href="{prefix}/ds-assets/')
|
||||
html = html.replace('src="/ds-assets/', f'src="{prefix}/ds-assets/')
|
||||
html = html.replace("</head>", f"{token_script}</head>", 1)
|
||||
return HTMLResponse(
|
||||
html,
|
||||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||||
)
|
||||
|
||||
# When served behind a path-prefix proxy, the built CSS contains
|
||||
# absolute ``url(/fonts/...)`` and ``url(/ds-assets/...)`` references.
|
||||
# Browsers resolve those against the document origin, which means
|
||||
# under ``/hermes`` they'd hit ``mission-control.tilos.com/fonts/...``
|
||||
# (the MC Pages app), not the Hermes backend. Intercept CSS asset
|
||||
# requests BEFORE the StaticFiles mount and rewrite the absolute paths
|
||||
# when a prefix is in play.
|
||||
@application.get("/assets/{filename}.css")
|
||||
async def serve_css(filename: str, request: Request):
|
||||
css_path = WEB_DIST / "assets" / f"{filename}.css"
|
||||
if not css_path.is_file() or not css_path.resolve().is_relative_to(
|
||||
WEB_DIST.resolve()
|
||||
):
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
|
||||
css = css_path.read_text()
|
||||
if prefix:
|
||||
for asset_dir in ("/fonts/", "/fonts-terminal/", "/ds-assets/", "/assets/"):
|
||||
css = css.replace(f"url({asset_dir}", f"url({prefix}{asset_dir}")
|
||||
css = css.replace(f"url(\"{asset_dir}", f"url(\"{prefix}{asset_dir}")
|
||||
css = css.replace(f"url('{asset_dir}", f"url('{prefix}{asset_dir}")
|
||||
return Response(content=css, media_type="text/css")
|
||||
|
||||
application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
|
||||
|
||||
@application.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
async def serve_spa(full_path: str, request: Request):
|
||||
prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
|
||||
file_path = WEB_DIST / full_path
|
||||
# Prevent path traversal via url-encoded sequences (%2e%2e/)
|
||||
if (
|
||||
|
|
@ -3353,7 +3421,7 @@ def mount_spa(application: FastAPI):
|
|||
and file_path.is_file()
|
||||
):
|
||||
return FileResponse(file_path)
|
||||
return _serve_index()
|
||||
return _serve_index(prefix)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue