""" Hermes-UI Reverse-Proxy — bettet das Hermes-Web-Dashboard in Mission Control ein. Das Hermes-Agent-Framework bringt eine fertige Web-UI mit (Chat mit Live-Tool- Aktivitaet, Approval-Prompts, Settings, Sessions). Sie laeuft als eigener Dienst lokal auf der Box (`hermes dashboard`, Port 9119, an 127.0.0.1 gebunden). MC proxyt sie unter `/hermes-ui/` und bettet sie per iframe ein: - Das Dashboard ist explizit fuer Prefix-Reverse-Proxy gebaut: wir setzen `X-Forwarded-Prefix: /hermes-ui`, dann rewritet es index.html/CSS/Asset-URLs und seinen SPA-Base-Path selbst. - Auf Loopback injiziert das Dashboard seinen eigenen Session-Token in die SPA -> kein zweiter Login. MC bleibt das eine Gateway (LAN-only). Damit faellt der fruehere Eigenbau-Chat (Proxy auf :8642) weg. """ import asyncio import httpx import websockets from fastapi import APIRouter, Request, WebSocket from fastapi.responses import RedirectResponse, Response from starlette.websockets import WebSocketState from config import HERMES_DASHBOARD_URL router = APIRouter() PREFIX = "/hermes-ui" _WS_BASE = HERMES_DASHBOARD_URL.replace("http://", "ws://").replace("https://", "wss://") # Hop-by-hop-Header, die ein Proxy nicht weiterreichen darf (RFC 7230) plus solche, # die httpx/Starlette selbst neu berechnen (Laenge/Encoding). _HOP = { "host", "content-length", "connection", "keep-alive", "transfer-encoding", "content-encoding", "upgrade", "proxy-authenticate", "proxy-authorization", "te", "trailers", } _METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"] @router.get(PREFIX) def _hermes_ui_root_redirect(): """Bare /hermes-ui -> /hermes-ui/ (sonst greift das Asset-Prefix-Rewriting nicht).""" return RedirectResponse(url=PREFIX + "/") @router.api_route(PREFIX + "/{path:path}", methods=_METHODS) async def hermes_ui_proxy(request: Request, path: str): """HTTP-Reverse-Proxy auf das Hermes-Dashboard mit Prefix-Header.""" url = f"{HERMES_DASHBOARD_URL}/{path}" headers = {k: v for k, v in request.headers.items() if k.lower() not in _HOP} headers["X-Forwarded-Prefix"] = PREFIX body = await request.body() timeout = httpx.Timeout(60.0, connect=10.0) async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client: r = await client.request( request.method, url, params=request.query_params, headers=headers, content=body, ) resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in _HOP} return Response( content=r.content, status_code=r.status_code, headers=resp_headers, media_type=r.headers.get("content-type"), ) @router.websocket(PREFIX + "/api/{name}") async def hermes_ui_ws(ws: WebSocket, name: str): """WebSocket-Bruecke fuer die Chat-/Event-WS des Dashboards (pty/ws/pub/events).""" await ws.accept() qs = ws.url.query target = f"{_WS_BASE}/api/{name}" + (f"?{qs}" if qs else "") try: async with websockets.connect( target, additional_headers={"X-Forwarded-Prefix": PREFIX}, max_size=None, open_timeout=10, ping_interval=None, ) as up: async def client_to_upstream(): try: while True: msg = await ws.receive() if msg.get("type") == "websocket.disconnect": break if msg.get("text") is not None: await up.send(msg["text"]) elif msg.get("bytes") is not None: await up.send(msg["bytes"]) except Exception: pass finally: await up.close() async def upstream_to_client(): try: async for m in up: if isinstance(m, (bytes, bytearray)): await ws.send_bytes(m) else: await ws.send_text(m) except Exception: pass finally: if ws.application_state != WebSocketState.DISCONNECTED: await ws.close() await asyncio.gather(client_to_upstream(), upstream_to_client()) except Exception: if ws.application_state != WebSocketState.DISCONNECTED: try: await ws.close() except Exception: pass