Box-Konsole hinter MC2-Reverse-Proxy (kein Firewall-Port noetig)
Problem: ttyd auf :7682 war von aussen per ufw geblockt (nur 7681/9001 offen), also im Browser Timeout — und ufw oeffnen braucht sudo, das MC2 hier nicht passwortlos hat. Fix: ttyd bindet jetzt NUR an Loopback (--interface lo, --base-path /console) und wird von MC2 ueber den ohnehin offenen Port 9001 same-origin durchgereicht: - routers/console.py: HTTP-Passthrough (index/token) + WebSocket-Bridge (tty-Subprotokoll auf beiden Seiten) → /console/ + /console/ws. - app.py: console.router VOR dem SPA-Catch-all eingehaengt. - config: BOX_CONSOLE_UPSTREAM (127.0.0.1:7682) + BOX_CONSOLE_PATH (/console/); agent_status liefert box_console_url=/console/ + reachable=Upstream-Check. - deploy/box-console.service: --interface lo --base-path /console. - vite: /console (ws:true) fuer die Dev-Vorschau geproxyt. Kein Firewall-/sudo-Eingriff noetig; Konsole laeuft same-origin zum Dashboard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
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")
|
||||
Reference in New Issue
Block a user