diff --git a/docker-compose.yml b/docker-compose.yml index 98a517d..0b9deb0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf index 05b3af2..3fbcb1c 100644 --- a/docker/nginx/default.conf +++ b/docker/nginx/default.conf @@ -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; } diff --git a/docker/terminal/Dockerfile b/docker/terminal/Dockerfile new file mode 100644 index 0000000..200feea --- /dev/null +++ b/docker/terminal/Dockerfile @@ -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"] diff --git a/docker/terminal/bridge.py b/docker/terminal/bridge.py new file mode 100644 index 0000000..b6cd199 --- /dev/null +++ b/docker/terminal/bridge.py @@ -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 ` `, 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 `` 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()) diff --git a/docker/terminal/requirements.txt b/docker/terminal/requirements.txt new file mode 100644 index 0000000..44c0afd --- /dev/null +++ b/docker/terminal/requirements.txt @@ -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 diff --git a/docs/superpowers/specs/2026-08-02-terminal-container-design.md b/docs/superpowers/specs/2026-08-02-terminal-container-design.md index fe81964..e282be1 100644 --- a/docs/superpowers/specs/2026-08-02-terminal-container-design.md +++ b/docs/superpowers/specs/2026-08-02-terminal-container-design.md @@ -67,7 +67,14 @@ eigener öffentlicher Name, kein eigener Port nach außen. 3. **Ein eigenes Fenster** öffnet `/hosts/{uuid}/terminal?t=`. Diese Seite gehört uns und zeigt den **ASCII-Vorspann** von CluPilot. -4. **Die Seite öffnet den WebSocket** auf `/terminal/ws?t=`. +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 diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 84a4358..e591547 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -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)) } diff --git a/tests/Feature/DeploymentRunsAsTheAppUserTest.php b/tests/Feature/DeploymentRunsAsTheAppUserTest.php index c75988e..b012e73 100644 --- a/tests/Feature/DeploymentRunsAsTheAppUserTest.php +++ b/tests/Feature/DeploymentRunsAsTheAppUserTest.php @@ -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'); +});