Hermes-Terminal same-origin + Passwort-Login; Engine/OS-Update-Fixes

Terminals (wie die Box-Konsole):
- hermes-terminal bindet jetzt NUR Loopback (--interface lo --base-path /hermes-terminal)
  und wird von MC2 same-origin durchgereicht (routers/console.py generalisiert auf beide
  ttyd-Instanzen). Kein eigener Firewall-Port mehr noetig.
- Beide Terminals starten die Shell/CLI ueber `su - hitonabi` → fragen beim Oeffnen das
  Box-Passwort ab (PAM gegen das echte Konto, nichts gespeichert). „Login mit sudo-PW".
- agent_status.terminal_url = /hermes-terminal/ (+ reachable via Loopback-Check).

Engine-Update (llama.cpp) — Fix „nicht moeglich":
- update-engine.sh/update-swap.sh sind per sudoers NOPASSWD freigegeben → das fruehere
  `sudo true`-Passwort-Gate hat sie faelschlich blockiert (wenn kein/falsches Box-PW). Gate
  entfernt → Engine-/Router-Update laufen jetzt passwortlos.

OS-Update (apt) — Fix „nicht moeglich":
- DEBIAN_FRONTEND wird jetzt INNERHALB `sudo bash -c '…'` gesetzt statt `sudo VAR=… cmd`
  (sonst lehnt sudos env-Policy die Variable ab und das Upgrade bricht ab).
- Frontend verschluckt password_required/incorrect_password nicht mehr still, sondern zeigt
  einen klaren Hinweis (Box-Passwort in „Box-Zugang" setzen/pruefen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-04 21:18:55 +02:00
parent 00fc7d6d81
commit eff23643e6
11 changed files with 215 additions and 185 deletions
+66 -65
View File
@@ -1,13 +1,12 @@
"""
Box-Konsole-Reverse-Proxy.
ttyd-Reverse-Proxy für die eingebetteten Web-Terminals (Box-Konsole + Hermes-Terminal).
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.
Beide ttyd-Dienste binden NUR an Loopback (--base-path /<name>) und werden hier über
den ohnehin offenen MC2-Port (9001) same-origin durchgereicht — HTTP (index.html/token)
+ WebSocket (/<name>/ws). Vorteil: keine eigene Firewall-Freigabe je Terminal nötig und
alles läuft same-origin zum Dashboard. Reiner Byte-Durchreicher; ttyds `tty`-Subprotokoll
wird auf beiden Seiten ausgehandelt. Der Login-Schutz sitzt IM Terminal (ttyd startet die
Shell über `su` → fragt das Box-Passwort ab), nicht im Proxy.
"""
import asyncio
@@ -18,70 +17,72 @@ import websockets
from fastapi import APIRouter, Request, WebSocket
from starlette.responses import Response
from config import BOX_CONSOLE_UPSTREAM
from config import BOX_CONSOLE_UPSTREAM, HERMES_TERMINAL_UPSTREAM
log = logging.getLogger(__name__)
router = APIRouter()
_WS_UPSTREAM = BOX_CONSOLE_UPSTREAM.replace("http://", "ws://").replace("https://", "wss://")
def _register(base: str, upstream: str) -> None:
"""Registriert HTTP- + WS-Proxy-Routen für eine ttyd-Instanz (base-path `/<base>`)."""
ws_upstream = 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:
@router.websocket(f"/{base}/ws")
async def _proxy_ws(ws: WebSocket) -> None: # noqa: ANN001 — Closure je base
await ws.accept(subprotocol="tty")
try:
await ws.close()
async with websockets.connect(
f"{ws_upstream}/{base}/ws", subprotocols=["tty"],
open_timeout=10, max_size=None, ping_interval=None,
) as up:
async def c2u() -> 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 u2c() -> 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(c2u()), asyncio.create_task(u2c())],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
except Exception:
pass
log.debug("ttyd-proxy ws (%s) beendet/fehlgeschlagen", base, exc_info=True)
finally:
try:
await ws.close()
except Exception:
pass
async def _proxy_http(request: Request, path: str = "") -> Response:
url = f"{upstream}/{base}/{path}"
try:
async with httpx.AsyncClient(timeout=10.0) as c:
r = await c.request(request.method, url, content=await request.body())
return Response(content=r.content, status_code=r.status_code,
media_type=r.headers.get("content-type"))
except httpx.HTTPError as exc:
log.debug("ttyd-proxy http (%s) nicht erreichbar: %s", base, exc)
return Response(content=b"Terminal (ttyd) nicht erreichbar.", status_code=502,
media_type="text/plain")
router.add_api_route(f"/{base}", _proxy_http, methods=["GET"], name=f"{base}_root")
router.add_api_route(f"/{base}/{{path:path}}", _proxy_http, methods=["GET", "POST"],
name=f"{base}_path")
@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")
# Box-Konsole (Login-Shell) und Hermes-Terminal (Agent-CLI) — beide Loopback-ttyd.
_register("console", BOX_CONSOLE_UPSTREAM)
_register("hermes-terminal", HERMES_TERMINAL_UPSTREAM)