""" Same-origin Reverse-Proxy für die EINGEBAUTE Hermes-Web-GUI (`hermes serve`/`dashboard`). Anders als das ttyd-Terminal (reiner Byte-Durchreicher, siehe routers/console.py) ist die Hermes-GUI eine volle Single-Page-App: HTML + gehashte Assets + JSON-API + MEHRERE WebSockets (/api/ws, /api/console, /api/pty, /api/events). Deshalb hier ein GENERISCHER Reverse-Proxy für alles unter `/hermes-ui/*` (alle HTTP-Methoden + beliebige WebSockets). Der Backend-Server läuft NUR auf Loopback (127.0.0.1:9119, `hermes serve --skip-build`, kein Login — gleiches LAN-Trust-Modell wie Konsole/Terminal) und wird über den ohnehin offenen MC2-Port (9001) same-origin durchgereicht → keine eigene Firewall-Freigabe, kein CORS. Damit die absoluten Pfade der SPA (`/assets`, `/api`, `/api/ws`) nicht mit MC2s eigenen (`/assets`, `/api`) kollidieren, wird das Hermes-Bundle mit Vite-`base=/hermes-ui/` gebaut (deploy.sh) — dann liegen ALLE seine Pfade unter `/hermes-ui/`. Dieser Proxy streift das Präfix ab und leitet an `:9119/...` weiter. """ import asyncio import logging import re import httpx import websockets from fastapi import APIRouter, Request, WebSocket from starlette.responses import RedirectResponse, Response from config import HERMES_BUILTIN_UI_UPSTREAM log = logging.getLogger(__name__) router = APIRouter() _BASE = "hermes-ui" # Hop-by-hop- + Kodierungs-Header, die ein Proxy nicht 1:1 weiterreichen darf (Länge/Encoding # rechnet Starlette neu; httpx liefert bereits dekodierten Body). _DROP = { "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", "content-encoding", "content-length", "host", } @router.websocket(f"/{_BASE}/{{path:path}}") async def _proxy_ws(ws: WebSocket, path: str) -> None: """Beliebige WebSocket unter /hermes-ui/* (z. B. /hermes-ui/api/ws) an :9119 durchreichen.""" ws_upstream = HERMES_BUILTIN_UI_UPSTREAM.replace("http://", "ws://").replace("https://", "wss://") query = ws.url.query target = f"{ws_upstream}/{path}" + (f"?{query}" if query else "") await ws.accept() try: async with websockets.connect( target, open_timeout=10, max_size=None, ping_interval=None, ) as up: async def c2u() -> None: while True: msg = await ws.receive() if msg.get("type") == "websocket.disconnect": return if (t := msg.get("text")) is not None: await up.send(t) elif (b := msg.get("bytes")) is not None: await up.send(b) async def u2c() -> None: async for m in up: if isinstance(m, (bytes, bytearray)): await ws.send_bytes(bytes(m)) else: await ws.send_text(m) _done, pending = await asyncio.wait( [asyncio.create_task(c2u()), asyncio.create_task(u2c())], return_when=asyncio.FIRST_COMPLETED, ) for task in pending: task.cancel() except Exception: log.debug("hermes-ui ws-proxy (%s) beendet/fehlgeschlagen", path, exc_info=True) finally: try: await ws.close() except Exception: pass # Die SPA liest ihren API/WS-Basis-Pfad zur Laufzeit aus `window.__HERMES_BASE_PATH__` (nf() im # Bundle). `hermes serve` injiziert diese Zuweisung selbst ans Ende von (Wert leer, weil es # auf Root läuft) — dessen Wert überschreiben wir auf /hermes-ui, sonst setzt die GUI ihre /api-Calls # auf MC2s eigenen /api-Namensraum (SPA-Fallback = HTML) ab. Die Assets liegen bereits per # Vite-base=/hermes-ui/ richtig. MC2 macht die Ersetzung → überlebt Hermes-Updates, ohne die gebaute # index.html patchen zu müssen. Fällt die Server-Injektion je weg, setzen wir sie ersatzweise selbst. _BASE_PATH_RE = re.compile(rb'window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"') _BASE_PATH_SET = b'window.__HERMES_BASE_PATH__="/' + _BASE.encode() + b'"' _HEAD_INJECT = b"" async def _proxy_http(request: Request, path: str = "") -> Response: upstream = f"{HERMES_BUILTIN_UI_UPSTREAM}/{path}" fwd_headers = {k: v for k, v in request.headers.items() if k.lower() not in _DROP} # Upstream unkomprimiert anfordern: sonst greift die HTML-Injektion nicht und die # content-encoding-Header würden nicht zum (bereits dekodierten) Body passen. fwd_headers["accept-encoding"] = "identity" try: async with httpx.AsyncClient(timeout=30.0, follow_redirects=False) as c: r = await c.request( request.method, upstream, headers=fwd_headers, params=request.query_params, content=await request.body(), ) except httpx.HTTPError as exc: log.debug("hermes-ui http-proxy (%s) nicht erreichbar: %s", path, exc) return Response( content=b"Hermes-WebUI (hermes dashboard) nicht erreichbar.", status_code=502, media_type="text/plain", ) body = r.content if "text/html" in r.headers.get("content-type", "").lower(): if _BASE_PATH_RE.search(body): body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body) elif b"" in body: body = body.replace(b"", _HEAD_INJECT, 1) resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in _DROP} return Response(content=body, status_code=r.status_code, headers=resp_headers) @router.get(f"/{_BASE}") async def _root_redirect() -> RedirectResponse: # Ohne Schrägstrich → auf /hermes-ui/ umleiten (die SPA erwartet den Präfix mit Slash). return RedirectResponse(url=f"/{_BASE}/") router.add_api_route( f"/{_BASE}/{{path:path}}", _proxy_http, methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], name="hermes_ui_proxy", )