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:
+2
-1
@@ -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
@@ -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")))
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user