""" ttyd-Reverse-Proxy für die eingebetteten Web-Terminals (Box-Konsole + Hermes-Terminal). Beide ttyd-Dienste binden NUR an Loopback (--base-path /) und werden hier über den ohnehin offenen MC2-Port (9001) same-origin durchgereicht — HTTP (index.html/token) + WebSocket (//ws). Vorteil: keine eigene Firewall-Freigabe je Terminal nötig und alles läuft same-origin zum Dashboard. Reiner Byte-Durchreicher; ttyds `tty`-Subprotokoll wird auf beiden Seiten ausgehandelt. Der Login-Schutz sitzt IM Terminal (ttyd startet die Shell über `su` → fragt das Box-Passwort ab), nicht im Proxy. """ import asyncio import logging import httpx import websockets from config import BOX_CONSOLE_UPSTREAM from fastapi import APIRouter, Request, WebSocket from starlette.responses import Response log = logging.getLogger(__name__) router = APIRouter() def _register(base: str, upstream: str) -> None: """Registriert HTTP- + WS-Proxy-Routen für eine ttyd-Instanz (base-path `/`).""" ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://") @router.websocket(f"/{base}/ws") async def _proxy_ws(ws: WebSocket) -> None: await ws.accept(subprotocol="tty") try: async with websockets.connect( f"{ws_upstream}/{base}/ws", subprotocols=["tty"], 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("ttyd-proxy ws (%s) beendet/fehlgeschlagen", base, exc_info=True) finally: try: await ws.close() except Exception: pass async def _proxy_http(request: Request, path: str = "") -> Response: url = f"{upstream}/{base}/{path}" try: async with httpx.AsyncClient(timeout=10.0) as c: r = await c.request(request.method, url, content=await request.body()) return Response(content=r.content, status_code=r.status_code, media_type=r.headers.get("content-type")) except httpx.HTTPError as exc: log.debug("ttyd-proxy http (%s) nicht erreichbar: %s", base, exc) return Response(content=b"Terminal (ttyd) nicht erreichbar.", status_code=502, media_type="text/plain") router.add_api_route(f"/{base}", _proxy_http, methods=["GET"], name=f"{base}_root") router.add_api_route(f"/{base}/{{path:path}}", _proxy_http, methods=["GET", "POST"], name=f"{base}_path") # Box-Konsole (Login-Shell) — Loopback-ttyd. (Das Hermes-Terminal-ttyd ist mit dem # Umzug auf Hermes Desktop entfallen; die Agent-Oberfläche ist die Desktop-App bzw. /hermes-ui/.) _register("console", BOX_CONSOLE_UPSTREAM)