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:
Hitonabi
2026-07-04 20:58:40 +02:00
parent 18afa37412
commit 00fc7d6d81
6 changed files with 110 additions and 14 deletions
+2 -1
View File
@@ -18,7 +18,7 @@ from fastapi.staticfiles import StaticFiles
from starlette.requests import Request from starlette.requests import Request
from config import FRONTEND_DIST, VERSION 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 from services import memory as memory_svc, reminders, sentry, warmer
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration # 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(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(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
app.include_router(maintenance.router) 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. # Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
+8 -4
View File
@@ -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). # 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. # 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("/") 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 # Box-Konsole: zweites ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
# (direkter SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Hermes- # (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
# Terminal). Wird in MC2 per iframe eingebettet (Konsole-Seite). Siehe deploy/box-console.service. # rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
BOX_CONSOLE_URL = os.environ.get("MC_BOX_CONSOLE_URL", "http://192.168.178.151:7682").rstrip("/") # 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. # GitHub-Repo für Update-Checks.
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent") 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"))) HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
+87
View File
@@ -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")
+5 -4
View File
@@ -11,7 +11,7 @@ import re
import httpx import httpx
import psutil 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__) log = logging.getLogger(__name__)
@@ -141,11 +141,12 @@ def agent_status() -> dict:
"gateway_url": HERMES_API_URL, "gateway_url": HERMES_API_URL,
# Interaktives Web-Terminal (ttyd → `hermes chat`), eingebettet in MC2. # Interaktives Web-Terminal (ttyd → `hermes chat`), eingebettet in MC2.
"terminal_url": HERMES_TERMINAL_URL, "terminal_url": HERMES_TERMINAL_URL,
# Box-Konsole (ttyd → Login-Shell): direkter SSH-artiger Box-Zugriff, eingebettet in MC2. # Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/).
"box_console_url": BOX_CONSOLE_URL, "box_console_url": BOX_CONSOLE_PATH,
"gateway_reachable": _reach(HERMES_API_URL, "/health"), "gateway_reachable": _reach(HERMES_API_URL, "/health"),
"terminal_reachable": _reach(HERMES_TERMINAL_URL, "/"), "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(), "home_exists": home.exists(),
"brain_model": brain_model, "brain_model": brain_model,
# Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig). # Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig).
+6 -5
View File
@@ -8,13 +8,14 @@ After=network.target
# direkte, SSH-artige Zugriff, den das UI als „Konsole" einbettet (iframe). Schwester-Dienst # 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. # 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 # SICHERHEIT: Bindet NUR an Loopback (lo/127.0.0.1) und läuft hinter dem MC2-Reverse-Proxy
# und das MC2-Dashboard (vertrautes Heim-LAN, kein Internet-Exposure). Kein Passwort — bewusst, # (routers/console.py → same-origin /console/ auf dem offenen Port 9001). Port 7682 ist von
# analog zum Hermes-Terminal. Für Basic-Auth am ExecStart `--credential <user>:<pass>` ergänzen. # 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 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). # -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 Restart=always
RestartSec=3 RestartSec=3
+2
View File
@@ -12,6 +12,8 @@ export default defineConfig({
proxy: { proxy: {
// Dev: /api → FastAPI-Backend (Override via MC_API_TARGET, z.B. die Box) // 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 }, "/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" }, build: { outDir: "dist" },