""" Box-Konsole-Reverse-Proxy. Der ttyd-Login-Shell-Dienst (deploy/box-console.service) bindet NUR an Loopback (127.0.0.1:7682, --base-path /console). Dieser Router reicht ihn über den ohnehin offenen MC2-Port (9001) durch — HTTP (index.html/token) + WebSocket (/console/ws). Vorteil: die Konsole braucht KEINE eigene Firewall-Freigabe (Port 7682 bleibt von außen dicht) und läuft same-origin zum Dashboard. Reiner Byte-Durchreicher; ttyds `tty`-Subprotokoll wird auf beiden Seiten ausgehandelt. """ import asyncio import logging import httpx import websockets from fastapi import APIRouter, Request, WebSocket from starlette.responses import Response from config import BOX_CONSOLE_UPSTREAM log = logging.getLogger(__name__) router = APIRouter() _WS_UPSTREAM = BOX_CONSOLE_UPSTREAM.replace("http://", "ws://").replace("https://", "wss://") @router.websocket("/console/ws") async def console_ws(ws: WebSocket) -> None: """Browser-WS ⇄ lokaler ttyd-WS. ttyd verlangt das `tty`-Subprotokoll auf beiden Seiten.""" await ws.accept(subprotocol="tty") try: async with websockets.connect( f"{_WS_UPSTREAM}/console/ws", subprotocols=["tty"], open_timeout=10, max_size=None, ping_interval=None, ) as up: async def client_to_upstream() -> 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 upstream_to_client() -> 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(client_to_upstream()), asyncio.create_task(upstream_to_client())], return_when=asyncio.FIRST_COMPLETED, ) for task in pending: task.cancel() except Exception: log.debug("console_ws: Verbindung beendet/fehlgeschlagen", exc_info=True) finally: try: await ws.close() except Exception: pass @router.api_route("/console", methods=["GET"]) @router.api_route("/console/{path:path}", methods=["GET", "POST"]) async def console_http(request: Request, path: str = "") -> Response: """Statische ttyd-Assets (index.html, token) durchreichen. /console/ws läuft über die WS-Route.""" upstream = f"{BOX_CONSOLE_UPSTREAM}/console/{path}" try: async with httpx.AsyncClient(timeout=10.0) as c: r = await c.request(request.method, upstream, content=await request.body()) # Content-Type erhalten; Hop-by-Hop-Header weglassen. return Response(content=r.content, status_code=r.status_code, media_type=r.headers.get("content-type")) except httpx.HTTPError as exc: log.debug("console_http: Upstream nicht erreichbar (%s)", exc) return Response(content=b"Box-Konsole (ttyd) nicht erreichbar.", status_code=502, media_type="text/plain")