Terminal-Bruecke: ein Container, eine Aufgabe, ein Ticket

Der Beiwagen nach dem Vorbild von kuma-bridge: Python, ein Zweck, kein
Laravel. Er steht im Netz-Namensraum von queue-provisioning, weil dort wg0
lebt und nur von dort ein Host ueberhaupt erreichbar ist.

Drei Punkte, die das Review von Aufgabe 2 offen gelassen hat, sind hier
entschieden:

- Das Ticket reist als Sec-WebSocket-Protocol, nicht in der Adresszeile.
  Die Adresse eines Upgrade-Antrags schreibt jeder Reverse Proxy mit, und
  auf der Strecke stehen zwei, von denen nur einer aus diesem Repo
  konfiguriert wird.
- /terminal/ws bleibt an der Wurzel, unabhaengig von AdminArea::prefix():
  nginx kann die Konfiguration der Anwendung nicht lesen, der Ort muss ein
  fester Text sein. In beiden Dateien steht die Begruendung.
- Die Bruecke sendet ausschliesslich Binaerrahmen.

Abweichungen vom Plan, jeweils gemessen statt vermutet:

- Der Schluessel in Redis traegt REDIS_PREFIX (clupilot-database-), nicht
  CACHE_PREFIX: TerminalTicket schreibt ueber Redis::connection('cache'),
  und phpredis stellt die Praefix-Option dieser Verbindung voran.
- Port 8082 statt 8081: im selben Namensraum lauscht der VPN-Gateway schon
  auf VPN_HEALTH_PORT, und der zweite Zuhoerer auf einem Port stirbt.
- SSH ueber paramiko.Transport statt SSHClient. SSHClient prueft gegen
  known_hosts, die dieser Container nicht hat und aus einem Fingerabdruck
  auch nicht bilden kann; RejectPolicy verbaende nie, AutoAddPolicy
  meldete sich mit einem Root-Schluessel an, bevor irgendetwas geprueft
  ist. Der Transport erlaubt die richtige Reihenfolge: Handschlag,
  Fingerabdruck, dann erst Anmeldung.
- Der Fingerabdruck wird gebildet wie in PhpseclibRemoteShell, nicht wie
  bei OpenSSH: gehasht wird "<algorithmus> <base64-blob>", nicht der Blob.
- proxy_pass ueber eine Variable mit resolver, damit nginx nicht beim
  Start scheitert, wenn die Bruecke gerade nicht laeuft.
- /terminal/ws antwortet auf einem oeffentlichen Hostnamen mit 404,
  dieselbe Regel wie /admin eine Zeile darueber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
nexxo 2026-08-02 19:09:07 +02:00
parent 4e52c35f24
commit c6403bf829
8 changed files with 560 additions and 2 deletions

View File

@ -100,6 +100,21 @@ services:
- net.ipv4.ip_forward=1
ports:
- "${WG_HUB_PORT:-51820}:51820/udp"
networks:
default:
aliases:
# Der Name, unter dem nginx die Terminal-Bruecke erreicht. Sie teilt
# sich den Netz-Namensraum DIESES Containers (`network_mode` unten bei
# `terminal`) und hat deshalb keine eigene Adresse im Compose-Netz und
# keinen eigenen DNS-Eintrag — ein Container ohne eigenes Netz bekommt
# keinen. Der Alias gibt der Adresse den Namen des Dienstes, der dort
# antwortet, statt in docker/nginx/default.conf den Namen des
# Provisionierungs-Workers stehen zu haben.
#
# Faellt `network_mode` bei `terminal` je weg, muss dieser Alias mit
# weg: sonst zeigen zwei DNS-Eintraege auf denselben Namen und nur
# einer davon hat einen Zuhoerer.
- terminal
volumes:
- .:/var/www/html
- wireguard:/etc/wireguard
@ -175,6 +190,47 @@ services:
- queue-provisioning
- app
# ── Terminal-Bruecke: SSH auf einen Host, als WebSocket ─────────────────────
#
# Steht im Tunnel wie vpn-dns und vpn-gateway, aus demselben Grund und mit
# demselben Mittel: wg0 lebt im Netz-Namensraum von queue-provisioning, und
# nur wer darin steht, erreicht einen Host. Gemessen, nicht vermutet — aus dem
# app-Container laeuft jeder Verbindungsversuch in eine Zeitueberschreitung.
#
# Kein `ports:` und keins moeglich: ein Dienst, der sich einen Namensraum
# teilt, veroeffentlicht keine eigenen Ports. Erreichbar ist er allein ueber
# nginx im app-Container, an genau einem Ort (`location = /terminal/ws`).
#
# Kein Profil: anders als kuma-bridge ist das hier keine Zusatzausstattung.
# Wer den Terminal-Knopf in der Konsole sieht, soll ihn auch druecken koennen.
terminal:
build:
context: ./docker/terminal
image: clupilot-terminal:dev
restart: unless-stopped
network_mode: "service:queue-provisioning"
environment:
# Datenbank 1 — dieselbe, auf der `Redis::connection('cache')` das Ticket
# ablegt (REDIS_CACHE_DB, Vorgabe 1).
REDIS_URL: "redis://redis:6379/1"
# phpredis stellt jedem Schluessel dieser Verbindung
# `database.redis.options.prefix` voran. Der ist aus APP_NAME abgeleitet
# (Str::slug(APP_NAME).'-database-') und fuer PHP unsichtbar, fuer jeden
# anderen Client aber Teil des Schluessels. Wer APP_NAME aendert, setzt
# REDIS_PREFIX in .env — sonst sucht die Bruecke unter dem alten Namen.
REDIS_PREFIX: "${REDIS_PREFIX:-clupilot-database-}"
# Fuenfzehn Minuten ohne Ein- UND Ausgabe beenden die Sitzung.
IDLE_SECONDS: "900"
# Nicht 8081: der VPN-Gateway lauscht in genau diesem Namensraum schon auf
# VPN_HEALTH_PORT (Vorgabe 8081), und der zweite Zuhoerer auf einem Port
# stirbt beim Start.
PORT: "8082"
depends_on:
# Nicht bloss Reihenfolge: ohne den Namensraum von queue-provisioning
# startet dieser Container gar nicht.
- queue-provisioning
- redis
scheduler:
image: clupilot-app:dev
restart: unless-stopped

View File

@ -43,6 +43,51 @@ server {
try_files $uri $uri/ /index.php?$query_string;
}
# Der einzige Pfad zur Terminal-Bruecke. Bewusst eng: kein Praefix-Match
# auf /terminal, sondern genau dieser eine Ort.
#
# AN DER WURZEL, nicht unter AdminArea::prefix(): nginx kann die Konfiguration
# der Anwendung nicht lesen, dieser Ort muss also ein fester Text sein — und
# er ist ohnehin keine Konsolenseite, sondern eine Steckdose, die PHP nie
# sieht. resources/js/terminal.js verbindet aus demselben Grund fest hierher.
#
# ACHTUNG: dieser Pfad laeuft NICHT durch PHP, also greift
# RestrictConsoleNetwork hier nicht. Der Riegel ist allein das Ticket —
# einmalig, dreissig Sekunden, an einen Host und einen Betreiber gebunden.
# So steht es auch in der Spec. Das Ticket steht deshalb NICHT in der
# Adresszeile, sondern in `Sec-WebSocket-Protocol`: die Adresse eines
# Upgrade-Antrags landet im Zugriffsprotokoll, der Kopf nicht.
#
# `terminal` ist ein Netz-Alias von queue-provisioning (siehe
# docker-compose.yml): die Bruecke steht im Tunnel und teilt sich dessen
# Netz-Namensraum.
location = /terminal/ws {
# Dieselbe Regel wie bei /admin darueber: die Bruecke ist Werkzeug der
# Konsole und hat auf einem oeffentlichen Namen nichts zu suchen.
if ($admin_host_is_public) {
return 404;
}
# Aufgeloest zur Laufzeit, nicht beim Start. nginx loest einen festen
# Namen in `proxy_pass` beim Laden der Konfiguration auf und WEIGERT
# SICH ZU STARTEN, wenn er ihn nicht findet ("host not found in
# upstream"). Damit haengte die ganze Konsole daran, dass die
# Terminal-Bruecke schon laeuft — wegen eines Fensters, das gerade
# niemand offen hat. Ueber eine Variable mit `resolver` (Dockers
# eingebauter DNS) wird daraus ein 502 an genau dieser Stelle, waehrend
# alles andere weiterlaeuft.
resolver 127.0.0.11 valid=10s ipv6=off;
set $terminal_upstream terminal:8082;
proxy_pass http://$terminal_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 900s;
proxy_send_timeout 900s;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}

View File

@ -0,0 +1,36 @@
# CluPilot Terminal-Bruecke: loest ein Ticket ein, baut SSH auf und verbindet
# PTY und WebSocket. Sonst nichts — kein Laravel, keine Datenbank.
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY bridge.py .
# 8082, nicht 8081: dieser Container teilt sich den Netz-Namensraum mit
# queue-provisioning (siehe docker-compose.yml), und dort lauscht der
# VPN-Gateway bereits auf 8081 (VPN_HEALTH_PORT). Zwei Zuhoerer auf demselben
# Port im selben Namensraum bedeuten, dass der zweite beim Start stirbt —
# unter `restart: unless-stopped` in einer Schleife, die niemand sofort liest.
ENV PORT=8082
# Unprivilegiert. Die Bruecke oeffnet nur eine TCP-Verbindung nach aussen und
# einen Zuhoerer ueber 1024; root braucht sie fuer nichts davon.
RUN useradd --create-home --uid 10001 bridge
USER bridge
EXPOSE 8082
# Ueber /healthz, nicht ueber einen blossen Verbindungsversuch: ein TCP-Socket,
# der sofort wieder zugeht, ist fuer die websockets-Bibliothek ein
# abgebrochener Handschlag — alle 30 Sekunden ein Fehler mit Stapelabzug im Log
# dieses Dienstes. Siehe process_request() in bridge.py.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import os,urllib.request; urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('PORT','8082')+'/healthz', timeout=4).read()"
CMD ["python", "bridge.py"]

352
docker/terminal/bridge.py Normal file
View File

@ -0,0 +1,352 @@
"""CluPilot Terminal-Bruecke.
Loest ein einmaliges Ticket ein, baut damit eine SSH-Sitzung auf und verbindet
deren PTY mit einem WebSocket. Mehr tut dieser Dienst nicht: keine Datenbank,
kein Laravel, keine eigene Anmeldung.
Das Ticket ist die einzige Tuer. Es lebt dreissig Sekunden, traegt genau eine
Sitzung, und `GETDEL` loescht es im selben Zug, in dem es gelesen wird.
DAS TICKET STEHT NICHT IN DER ADRESSZEILE. Es kommt als `Sec-WebSocket-Protocol`
derselbe Weg, den Kubernetes fuer `exec` benutzt, und aus demselben Grund: die
Adresszeile eines Upgrade-Antrags schreibt jeder Reverse Proxy auf der Strecke
mit, und auf dieser Strecke stehen zwei (nginx im app-Container, Caddy davor),
von denen nur einer aus diesem Repo konfiguriert wird. Dreissig Sekunden und
eine einzige Einloesung begrenzen den Schaden, aber ein Geheimnis, das gar nicht
erst in ein Protokoll geraet, braucht diese Begrenzung nicht.
DIE AUSGABE GEHT IMMER ALS BINAERRAHMEN raus, nie als Text siehe to_browser().
Was hereinkommt, sind Tastenanschlaege des Browsers als Textrahmen; diese
Asymmetrie ist Absicht: xterm.js liefert Strings, ein PTY liefert Bytes, und
was ein PTY schickt, ist nicht zwingend gueltiges UTF-8.
"""
import asyncio
import base64
import hashlib
import http
import io
import json
import logging
import os
import re
import socket
import paramiko
import redis.asyncio as redis
from websockets.asyncio.server import serve as websocket_server
from websockets.exceptions import InvalidStatus
REDIS_URL = os.environ.get("REDIS_URL", "redis://redis:6379/1")
# phpredis stellt jedem Schluessel dieser Verbindung `database.redis.options.
# prefix` voran — auf dieser Installation `clupilot-database-`, abgeleitet aus
# APP_NAME. Fuer PHP ist das unsichtbar, fuer jeden anderen Client ist es Teil
# des Schluessels. Wer APP_NAME aendert, ohne REDIS_PREFIX zu setzen, findet
# hier kein Ticket mehr — deshalb steht der benutzte Wert beim Start im Log.
REDIS_PREFIX = os.environ.get("REDIS_PREFIX", "clupilot-database-")
IDLE_SECONDS = int(os.environ.get("IDLE_SECONDS", "900"))
PORT = int(os.environ.get("PORT", "8082"))
# 32 Byte Zufall als Hex, so wie TerminalTicket::issue() sie bildet. Was diese
# Form nicht hat, ist kein Ticket dieser Anwendung und braucht Redis gar nicht
# erst zu behelligen.
TICKET_PATTERN = re.compile(r"\A[0-9a-f]{64}\Z")
log = logging.getLogger("terminal")
async def redeem_payload(ticket: str) -> dict | None:
"""Liest das Ticket und loescht es im selben Zug.
Der Inhalt ist reines JSON, weil TerminalTicket ihn ueber `Redis::
connection('cache')` schreibt und nicht ueber die Cache-Fassade die
verpackte den JSON-Text noch einmal PHP-serialisiert (`s:412:"{...}"`).
Nachgemessen, nicht angenommen. Faellt das je zurueck, wirft json.loads
hier, die Sitzung kommt nicht zustande und es steht im Log: besser als ein
Parser, der beide Formen frisst und den Rueckfall verdeckt.
"""
client = redis.from_url(REDIS_URL)
try:
raw = await client.getdel(f"{REDIS_PREFIX}terminal:ticket:{ticket}")
finally:
await client.aclose()
if not raw:
return None
return json.loads(raw.decode() if isinstance(raw, bytes) else raw)
def load_key(text: str) -> paramiko.PKey:
"""Der Schluessel aus dem Tresor, in der Form, in der er dort liegt.
App\\Services\\Ssh\\Keypair legt Ed25519 im OpenSSH-Format an; ein Betreiber
kann aber auch einen eigenen Schluessel eingesetzt haben. paramiko hat kein
"lade, was auch immer das ist" jede Klasse liest genau ihr Format.
"""
for cls in (paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.RSAKey):
try:
return cls.from_private_key(io.StringIO(text))
except (paramiko.SSHException, ValueError):
continue
raise RuntimeError("Der hinterlegte SSH-Schluessel ist in keinem unterstuetzten Format lesbar")
def fingerprints(key: paramiko.PKey) -> set[str]:
"""Die Fingerabdruecke SO, WIE CLUPILOT SIE BILDET — nicht wie OpenSSH.
PhpseclibRemoteShell::hostKeyFingerprint() hasht nicht den rohen
Schluessel-Blob (das waere der bekannte OpenSSH-Fingerabdruck), sondern die
Zeichenkette `<algorithmus> <base64-blob>`, die phpseclib'
getServerPublicHostKey() zurueckgibt:
'SHA256:' . base64_encode(hash('sha256', $key, true))
Wer das hier mit dem OpenSSH-Fingerabdruck vergleicht, bekommt bei JEDEM
Host eine Abweichung und faellt in die Versuchung, die Pruefung fuer kaputt
zu halten und wegzulassen. Deshalb steht es hier so ausfuehrlich.
Mehrere Kandidaten, weil `<algorithmus>` bei phpseclib aus der SIGNATUR
stammt: bei einem RSA-Hostschluessel steht dort je nach Aushandlung
`rsa-sha2-256`/`rsa-sha2-512`, waehrend der Blob selbst `ssh-rsa` heisst
(Net/SSH2.php, key_exchange). Bei Ed25519 und ECDSA sind beide gleich.
Mehrere Kandidaten schwaechen nichts ab: der base64-Blob des Hostschluessels
steckt in jedem einzelnen von ihnen, ein anderer Host hat also auch unter
jedem Namen einen anderen Fingerabdruck.
"""
blob = base64.b64encode(key.asbytes()).decode()
names = [key.get_name()]
if key.get_name() == "ssh-rsa":
names += ["rsa-sha2-256", "rsa-sha2-512"]
return {
"SHA256:" + base64.b64encode(hashlib.sha256(f"{name} {blob}".encode()).digest()).decode()
for name in names
}
def connect(payload: dict) -> tuple[paramiko.Transport, paramiko.Channel]:
"""SSH aufbauen — Fingerabdruck geprueft, BEVOR der Schluessel benutzt wird.
Bewusst ueber Transport statt ueber SSHClient: SSHClient prueft den
Hostschluessel gegen seine known_hosts-Sammlung, und dieser Container hat
keine. Er kann auch keine haben CluPilot kennt vom Host nur den
Fingerabdruck, also einen Hash, aus dem sich kein Schluessel zurueckrechnen
laesst. Uebrig blieben zwei schlechte Wege: RejectPolicy (verbindet nie) oder
AutoAddPolicy (vertraut blind und meldet sich mit einem Root-Schluessel an,
bevor irgendetwas geprueft ist). Der Transport erlaubt die dritte, richtige
Reihenfolge: Handschlag, Fingerabdruck vergleichen, erst dann anmelden.
"""
key = load_key(payload["private_key"])
expected = (payload.get("fingerprint") or "").strip()
if not expected:
# TerminalTicket::issue() laesst das gar nicht erst entstehen. Wenn hier
# doch nichts steht, ist die Antwort trotzdem nicht "dann eben ohne".
raise RuntimeError("Ticket ohne Fingerabdruck")
# Eigener Socket mit Zeitgrenze: paramiko.Transport(("ip", 22)) verbindet im
# Konstruktor ohne eine, und ein Host im Tunnel, der gerade nicht da ist,
# haengt dann am TCP-Standard von zwei Minuten — laenger, als das Ticket
# ueberhaupt gilt.
sock = socket.create_connection((payload["ip"], 22), timeout=10)
sock.settimeout(None)
transport = paramiko.Transport(sock)
transport.banner_timeout = 15
try:
transport.start_client(timeout=10)
remote = transport.get_remote_server_key()
if expected not in fingerprints(remote):
raise RuntimeError("Fingerabdruck weicht ab")
transport.auth_publickey(payload["user"], key)
# Damit eine tote Gegenstelle auffaellt, bevor der Leerlauf zuschlaegt:
# ein Fenster, das nach einem Netzausfall stumm offen bleibt, sieht aus
# wie eine haengende Sitzung.
transport.set_keepalive(30)
channel = transport.open_session(timeout=10)
channel.get_pty(term="xterm-256color")
channel.invoke_shell()
channel.settimeout(0.0)
except Exception:
transport.close()
raise
return transport, channel
async def bridge(websocket, payload: dict) -> None:
"""Bytes in beide Richtungen, bis eine der drei Grenzen greift."""
loop = asyncio.get_running_loop()
try:
transport, channel = await loop.run_in_executor(None, connect, payload)
except Exception as exc:
# Die Meldung ins Log, nicht an den Browser: was genau schiefging, ist
# eine Auskunft ueber den Host, und die hat auf der anderen Seite eines
# WebSockets nichts verloren.
log.warning("Kein SSH zu %s: %s", payload.get("ip"), exc)
await websocket.close(code=4502)
return
log.info(
"Sitzung offen — Host %s (%s), Betreiber %s",
payload.get("host_uuid"), payload.get("ip"), payload.get("operator_id"),
)
# Ein gemeinsamer Zeitstempel fuer beide Richtungen: Leerlauf heisst, dass
# WEDER getippt noch ausgegeben wurde.
last = loop.time()
async def to_ssh():
nonlocal last
async for message in websocket:
last = loop.time()
data = message.encode() if isinstance(message, str) else message
await loop.run_in_executor(None, channel.sendall, data)
async def to_browser():
nonlocal last
while True:
if channel.recv_ready():
data = channel.recv(32768)
if not data:
return
last = loop.time()
# bytes, also ein BINAERRAHMEN — immer. Ein gemischter Betrieb
# aus Text- und Binaerrahmen haette hier keinen Nutzen und im
# Browser zwei Fehlerbilder.
await websocket.send(data)
continue
if channel.closed or channel.exit_status_ready() or channel.eof_received:
return
await asyncio.sleep(0.02)
async def idle_watch():
while True:
await asyncio.sleep(5)
if loop.time() - last > IDLE_SECONDS:
log.info("Sitzung nach %s s Leerlauf beendet", IDLE_SECONDS)
return
tasks = [asyncio.create_task(task()) for task in (to_ssh, to_browser, idle_watch)]
try:
# Was zuerst endet, beendet alles: das Fenster zu, die Sitzung tot oder
# der Leerlauf abgelaufen.
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
finally:
for task in tasks:
task.cancel()
channel.close()
transport.close()
await websocket.close()
log.info("Sitzung geschlossen — Host %s", payload.get("host_uuid"))
async def serve(websocket) -> None:
ticket = websocket.subprotocol or ""
payload = await redeem_payload(ticket) if TICKET_PATTERN.match(ticket) else None
if payload is None:
# Kein Grund, keine Meldung mit Inhalt: ein unbekanntes Ticket darf
# nicht verraten, ob es je eines gab. Und nie das Ticket selbst ins Log
# — es ist genau so lange gueltig wie das, was jemand mitliest.
log.info("Ticket abgewiesen (unbekannt, abgelaufen oder schon eingeloest)")
await websocket.close(code=4401)
return
await bridge(websocket, payload)
class HealthNoiseFilter(logging.Filter):
"""Die Lebendpruefung ist kein Fehler, auch wenn die Bibliothek das so sieht.
websockets protokolliert JEDEN Handschlag, der nicht mit 101 endet, als
"opening handshake failed" samt Stapelabzug auch den, den wir selbst mit
200 beantwortet haben (siehe process_request). Alle 30 Sekunden ein Fehler
plus eine Abweisung im Log macht das Log wertlos, und "einfach ignorieren"
ist keine Anleitung.
Deshalb genau dieser eine Fall: abgewiesen mit 200, also unsere eigene
Antwort auf /healthz kein anderer Weg durch diesen Dienst endet mit 200.
Ein echter Fehlschlag (abgebrochene Verbindung, kaputter Antrag, 4xx) steht
weiterhin da.
"""
def filter(self, record: logging.LogRecord) -> bool:
exception = record.exc_info[1] if record.exc_info else None
if isinstance(exception, InvalidStatus) and exception.response.status_code == 200:
return False
# Vorsichtig ausgepackt: ein Filter, der selbst wirft, reisst die
# Protokollierung mit — und damit die Meldung, die gerade gebraucht wird.
arguments = record.args if isinstance(record.args, tuple) else ()
return not (str(record.msg).startswith("connection rejected") and arguments[:1] == (200,))
def process_request(connection, request):
"""Ein Ort fuer die Lebendpruefung — mit Antwort, ohne Handschlag.
Die naheliegende Pruefung (TCP aufmachen, wieder zumachen) laesst diesen
Dienst alle 30 Sekunden einen abgebrochenen WebSocket-Handschlag als
Fehlermeldung mit vollem Stapelabzug protokollieren. Ein Log, das im
Ruhezustand Fehler schreibt, ist ein Log, das im Ernstfall niemand liest.
"""
if request.path == "/healthz":
return connection.respond(http.HTTPStatus.OK, "ok\n")
return None
def select_subprotocol(connection, subprotocols):
"""Das angebotene Ticket wird zurueckgespiegelt, sonst bricht der Browser ab.
Hier wird NICHT geprueft, ob es ein gutes Ticket ist: ein abgelehntes
Unterprotokoll laesst den Handschlag scheitern, und der Browser bekaeme
einen nackten Verbindungsfehler statt der Schliessung mit 4401, an der die
Seite "abgewiesen" von "kein Weg dorthin" unterscheidet. Die Pruefung steht
in serve(), eine Zeile spaeter.
Die websockets-Bibliothek hat den Wert vorher als HTTP-Token geparst; CR/LF
kaeme hier also gar nicht erst an.
"""
return subprotocols[0] if subprotocols else None
async def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log.info("Terminal-Bruecke lauscht auf %s, Redis-Praefix %r, Leerlauf %s s", PORT, REDIS_PREFIX, IDLE_SECONDS)
# Ein eigener Logger fuer die Bibliothek, damit der Filter oben nur ihre
# Meldungen sieht und nicht die dieses Moduls.
server_log = logging.getLogger("terminal.server")
server_log.addFilter(HealthNoiseFilter())
async with websocket_server(
serve,
"0.0.0.0",
PORT,
logger=server_log,
process_request=process_request,
select_subprotocol=select_subprotocol,
ping_interval=20,
):
await asyncio.Future()
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,7 @@
# Feste Versionen, nicht Spannen: dieser Dienst meldet sich mit einem Schluessel
# an, der auf jedem Host root ist. Was sich hier von selbst aktualisiert, aendert
# im Zweifel die Handschlag- oder die Schluesselbehandlung — und das faellt erst
# am naechsten Host auf.
websockets==13.1
paramiko==3.5.0
redis==5.2.1

View File

@ -67,7 +67,14 @@ eigener öffentlicher Name, kein eigener Port nach außen.
3. **Ein eigenes Fenster** öffnet `/hosts/{uuid}/terminal?t=<ticket>`. Diese
Seite gehört uns und zeigt den **ASCII-Vorspann** von CluPilot.
4. **Die Seite öffnet den WebSocket** auf `/terminal/ws?t=<ticket>`.
4. **Die Seite öffnet den WebSocket** auf `/terminal/ws`. Das Ticket reist dabei
als `Sec-WebSocket-Protocol`, **nicht in der Adresszeile** — beim Bau von
Aufgabe 3 so entschieden: die Adresse eines Upgrade-Antrags schreibt jeder
Reverse Proxy auf der Strecke mit, und auf dieser Strecke stehen zwei (nginx
im `app`-Container, Caddy davor), von denen nur einer aus diesem Repo
konfiguriert wird. Dreißig Sekunden und eine einzige Einlösung begrenzen den
Schaden — aber ein Geheimnis, das gar nicht erst in ein Protokoll gerät,
braucht diese Begrenzung nicht.
5. **Der Container löst das Ticket ein.** Er liest es aus Redis und **löscht es
im selben Zug** — ein Ticket trägt genau eine Sitzung. Danach SSH auf

View File

@ -26,7 +26,16 @@ if (root) {
term.loadAddon(fit)
const scheme = location.protocol === 'https:' ? 'wss' : 'ws'
const socket = new WebSocket(`${scheme}://${location.host}/terminal/ws?t=${encodeURIComponent(root.dataset.ticket)}`)
// Das Ticket reist als Unterprotokoll, NICHT in der Adresszeile: die Adresse
// eines Upgrade-Antrags schreibt jeder Reverse Proxy auf der Strecke mit
// (nginx hier, Caddy davor), ein Kopffeld nicht. Die Brücke spiegelt den
// Wert zurück, sonst bricht der Browser den Handschlag ab.
//
// `/terminal/ws` steht fest an der Wurzel und folgt AdminArea::prefix()
// bewusst nicht: die Stelle wird von nginx durchgereicht und sieht PHP nie,
// also muss sie ein fester Text sein — in docker/nginx/default.conf steht
// derselbe, mit derselben Begründung.
const socket = new WebSocket(`${scheme}://${location.host}/terminal/ws`, [root.dataset.ticket])
socket.binaryType = 'arraybuffer'
// Der Vorspann weicht beim ERSTEN BYTE, nicht bei `onopen`.
@ -53,6 +62,11 @@ if (root) {
// Kommt vom Container ein Textrahmen statt eines Binärrahmens, ist
// `new Uint8Array(event.data)` bei einem String ein leeres Array —
// `binaryType = 'arraybuffer'` oben regelt nur Binärrahmen.
//
// Die Brücke hat sich inzwischen festgelegt: sie sendet ausschließlich
// Binärrahmen (docker/terminal/bridge.py, to_browser). Diese Verzweigung
// bleibt trotzdem stehen — sie kostet nichts und ist die einzige Stelle,
// an der ein Rückfall nicht als stumme leere Ausgabe ankäme.
term.write(typeof event.data === 'string' ? event.data : new Uint8Array(event.data))
}

View File

@ -161,3 +161,44 @@ it('runs from a copy of itself, so a checkout cannot rewrite it mid-flight', fun
// derselbe Tausch.
expect($update)->toContain('mktemp /tmp/clupilot-update-');
});
it('reicht den Terminal-Pfad durch, ohne ihn weiter zu oeffnen als noetig', function () {
$nginx = File::get(base_path('docker/nginx/default.conf'));
// Genau dieser eine Ort, kein Präfix: ein `location /terminal` machte
// jeden Unterpfad zum Weg in den Container.
expect($nginx)->toContain('location = /terminal/ws')
->and($nginx)->not->toContain('location /terminal')
->and($nginx)->toContain('terminal:8082')
->and($nginx)->toContain('proxy_pass http://$terminal_upstream')
->and($nginx)->toContain('Upgrade $http_upgrade');
// Und das Ticket reist im Kopf, nicht in der Adresszeile — sonst stünde es
// im Zugriffsprotokoll jedes Reverse Proxy auf der Strecke. Der Browser ist
// die Seite, die das entscheidet.
expect(File::get(base_path('resources/js/terminal.js')))
->not->toContain('/terminal/ws?');
});
it('laesst die Terminal-Bruecke dort stehen, wo ein Host ueberhaupt erreichbar ist', function () {
// Der app-Container erreicht keinen einzigen Host — gemessen, nicht
// vermutet: wg0 lebt im Netz-Namensraum von queue-provisioning. Wer diese
// Zeile für Aufräumen hält, bekommt einen Container, der startet, gesund
// aussieht und bei jeder Sitzung in eine Zeitüberschreitung läuft.
$compose = File::get(base_path('docker-compose.yml'));
$block = preg_split('/^ terminal:$/m', $compose)[1] ?? '';
$block = preg_split('/^ \S/m', $block)[0] ?? '';
expect($block)->not->toBe('')
->and($block)->toContain('network_mode: "service:queue-provisioning"')
// Kein eigener Port nach außen: erreichbar allein über nginx. Ein
// Dienst in fremdem Namensraum könnte ihn ohnehin nicht veröffentlichen
// — Docker lehnt das Compose-File dann komplett ab.
->and($block)->not->toContain('ports:');
// Und der Name, unter dem nginx ihn anspricht, muss der Alias des
// Namensraum-Eigentümers sein: ein Container ohne eigenes Netz hat keinen
// eigenen DNS-Eintrag.
expect($compose)->toContain(' - terminal');
});