- news-melden.sh verschiebt Text- und Sprechfassung nach dem Versand nach *.gesendet.*. Hermes' write_file ueberschreibt keine vorhandene Datei; der liegengebliebene Bericht vom Vortag liess jeden Morgenlauf 4 von 5 Schreibversuchen scheitern. Ein zweiter Aufruf desselben Laufs erkennt den schon verschickten Bericht weiterhin. - hermes_ui-Proxy: Seit Hermes v0.21 leitet das Dashboard auf /login um und die Anmeldeseite schickt an /auth/password-login — beides ohne /hermes-ui-Praefix. Der Knopf „Hermes-GUI oeffnen“ landete deshalb auf MC2s „Diese Seite gibt es nicht“. Umleitungen und Anmelde-Pfade werden jetzt umgeschrieben, mehrere Set-Cookie bleiben. - Tests fuer die Pfad-Umschreibung (12 Backend-Tests gesamt). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
164 lines
7.1 KiB
Python
164 lines
7.1 KiB
Python
"""
|
|
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 (`hermes dashboard`, :9119) wird über den 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.
|
|
|
|
Seit Hermes v0.21 hat das Dashboard eine eigene Anmeldung: `/` leitet auf `/login?next=…` um,
|
|
und die Anmeldeseite schickt an `/auth/password-login`. Beide Pfade sind absolut und kennen
|
|
das Präfix nicht — MC2 reichte die Umleitung unverändert weiter, und der Knopf „Hermes-GUI
|
|
öffnen“ landete auf MC2s eigener „Diese Seite gibt es nicht“ (gefunden 23.09.2026). Deshalb
|
|
schreibt der Proxy Umleitungen und diese Pfade im HTML auf `/hermes-ui/…` um.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
|
|
import httpx
|
|
import websockets
|
|
from config import HERMES_BUILTIN_UI_UPSTREAM
|
|
from fastapi import APIRouter, Request, WebSocket
|
|
from starlette.responses import RedirectResponse, Response
|
|
|
|
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 <head> (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"<head><script>" + _BASE_PATH_SET + b";</script>"
|
|
# Absolute Anmelde-Pfade in der (server-gerenderten) Login-Seite: "/auth/…", "/login", "/logout".
|
|
_ANMELDE_PFADE = re.compile(rb'(["\'(=])/(auth|login|logout)(?=[/?"\')\s])')
|
|
|
|
|
|
def praefixiere_ort(ort: str) -> str:
|
|
"""Umleitungsziel unter /hermes-ui/ holen. Fremde Ziele (http…, //host) bleiben."""
|
|
if ort.startswith("/") and not ort.startswith(("//", f"/{_BASE}/")):
|
|
return f"/{_BASE}{ort}"
|
|
return ort
|
|
|
|
|
|
def praefixiere_html(body: bytes) -> bytes:
|
|
"""Basis-Pfad der SPA setzen und die absoluten Anmelde-Pfade unter /hermes-ui/ legen."""
|
|
if _BASE_PATH_RE.search(body):
|
|
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
|
|
elif b"<head>" in body:
|
|
body = body.replace(b"<head>", _HEAD_INJECT, 1)
|
|
return _ANMELDE_PFADE.sub(rb"\1/" + _BASE.encode() + rb"/\2", body)
|
|
|
|
|
|
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():
|
|
body = praefixiere_html(body)
|
|
resp = Response(content=body, status_code=r.status_code)
|
|
# multi_items statt dict: Die Anmeldung setzt Cookies, und mehrere Set-Cookie-Zeilen
|
|
# dürfen nicht zu einer zusammenfallen.
|
|
for k, v in r.headers.multi_items():
|
|
kl = k.lower()
|
|
if kl in _DROP:
|
|
continue
|
|
if kl == "location":
|
|
v = praefixiere_ort(v)
|
|
resp.raw_headers.append((kl.encode("latin-1"), v.encode("latin-1")))
|
|
return resp
|
|
|
|
|
|
@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",
|
|
)
|