diff --git a/backend/app.py b/backend/app.py index d653b8f..f60ff2f 100644 --- a/backend/app.py +++ b/backend/app.py @@ -18,7 +18,7 @@ from fastapi.staticfiles import StaticFiles from starlette.requests import Request from config import FRONTEND_DIST, VERSION -from routers import agent, connect, gateway_proxy, health, maintenance, memory, models, reminders as reminders_router, routing, system, voice +from routers import agent, connect, console, gateway_proxy, health, maintenance, memory, models, reminders as reminders_router, routing, system, voice from services import memory as memory_svc, reminders, sentry, warmer # Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration @@ -81,6 +81,7 @@ app.include_router(voice.router) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat ( app.include_router(reminders_router.router) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto) app.include_router(maintenance.router) +app.include_router(console.router) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all # Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html. diff --git a/backend/config.py b/backend/config.py index b644751..37272b1 100644 --- a/backend/config.py +++ b/backend/config.py @@ -97,10 +97,14 @@ VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:865 # Hermes-Terminal: ttyd-Web-Terminal der interaktiven Agent-CLI (Ersatz für AnythingLLM-Chat). # Wird in MC2 per iframe eingebettet (Terminal-Seite). Siehe deploy/hermes-terminal.service. HERMES_TERMINAL_URL = os.environ.get("MC_HERMES_TERMINAL_URL", "http://192.168.178.151:7681").rstrip("/") -# Box-Konsole: zweites ttyd-Web-Terminal, das eine echte Login-Shell auf der Box öffnet -# (direkter SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Hermes- -# Terminal). Wird in MC2 per iframe eingebettet (Konsole-Seite). Siehe deploy/box-console.service. -BOX_CONSOLE_URL = os.environ.get("MC_BOX_CONSOLE_URL", "http://192.168.178.151:7682").rstrip("/") +# Box-Konsole: zweites ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback +# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001 +# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole +# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff. +# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard. +BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/") +# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel). +BOX_CONSOLE_PATH = "/console/" # GitHub-Repo für Update-Checks. HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent") HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) diff --git a/backend/routers/console.py b/backend/routers/console.py new file mode 100644 index 0000000..dabde1f --- /dev/null +++ b/backend/routers/console.py @@ -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") diff --git a/backend/services/agent.py b/backend/services/agent.py index 13311cc..0281d90 100644 --- a/backend/services/agent.py +++ b/backend/services/agent.py @@ -11,7 +11,7 @@ import re import httpx import psutil -from config import HERMES_TERMINAL_URL, BOX_CONSOLE_URL, HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL +from config import HERMES_TERMINAL_URL, BOX_CONSOLE_UPSTREAM, BOX_CONSOLE_PATH, HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL log = logging.getLogger(__name__) @@ -141,11 +141,12 @@ def agent_status() -> dict: "gateway_url": HERMES_API_URL, # Interaktives Web-Terminal (ttyd → `hermes chat`), eingebettet in MC2. "terminal_url": HERMES_TERMINAL_URL, - # Box-Konsole (ttyd → Login-Shell): direkter SSH-artiger Box-Zugriff, eingebettet in MC2. - "box_console_url": BOX_CONSOLE_URL, + # Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/). + "box_console_url": BOX_CONSOLE_PATH, "gateway_reachable": _reach(HERMES_API_URL, "/health"), "terminal_reachable": _reach(HERMES_TERMINAL_URL, "/"), - "box_console_reachable": _reach(BOX_CONSOLE_URL, "/"), + # Erreichbarkeit des lokalen ttyd-Upstreams (Loopback, base-path /console). + "box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"), "home_exists": home.exists(), "brain_model": brain_model, # Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig). diff --git a/deploy/box-console.service b/deploy/box-console.service index dd12144..fef9c28 100644 --- a/deploy/box-console.service +++ b/deploy/box-console.service @@ -8,13 +8,14 @@ After=network.target # direkte, SSH-artige Zugriff, den das UI als „Konsole" einbettet (iframe). Schwester-Dienst # zum hermes-terminal (das die Agent-CLI zeigt); dieser hier zeigt die nackte Shell. # -# SICHERHEIT: --writable + LAN-Bind ohne Auth = dasselbe Trust-Modell wie das Hermes-Terminal -# und das MC2-Dashboard (vertrautes Heim-LAN, kein Internet-Exposure). Kein Passwort — bewusst, -# analog zum Hermes-Terminal. Für Basic-Auth am ExecStart `--credential :` ergänzen. +# SICHERHEIT: Bindet NUR an Loopback (lo/127.0.0.1) und läuft hinter dem MC2-Reverse-Proxy +# (routers/console.py → same-origin /console/ auf dem offenen Port 9001). Port 7682 ist von +# außen dicht → keine eigene Firewall-Regel nötig. Kein Passwort — bewusst, gleiches LAN-Trust- +# Modell wie das Dashboard/Hermes-Terminal (kein Internet-Exposure). Type=simple -# --interface eno1 = LAN-Bind (box-spezifisch; eno1 trägt 192.168.178.151). +# --interface lo = nur Loopback. --base-path /console = ttyd bedient /console/* (Reverse-Proxy). # -t = Terminal-Optionen (dunkles Theme passend zum UI). -ExecStart=/usr/bin/ttyd --writable --interface eno1 --port 7682 --max-clients 2 --cwd %h -t 'theme={"background":"#0b0f1a"}' /bin/bash -l +ExecStart=/usr/bin/ttyd --writable --interface lo --port 7682 --base-path /console --max-clients 2 --cwd %h -t 'theme={"background":"#0b0f1a"}' /bin/bash -l Restart=always RestartSec=3 diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d6fea04..788c9f4 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -12,6 +12,8 @@ export default defineConfig({ proxy: { // Dev: /api → FastAPI-Backend (Override via MC_API_TARGET, z.B. die Box) "/api": { target: process.env.MC_API_TARGET || "http://127.0.0.1:9000", changeOrigin: true }, + // Box-Konsole (ttyd) läuft same-origin über den MC2-Reverse-Proxy (/console/ + /console/ws). + "/console": { target: process.env.MC_API_TARGET || "http://127.0.0.1:9000", changeOrigin: true, ws: true }, }, }, build: { outDir: "dist" },