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
parent
4e52c35f24
commit
c6403bf829
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue