6.4 KiB
Ein Terminal auf dem Host, aus der Konsole heraus
Stand: Entwurf, 2. August 2026 Entschieden vom Besitzer, nachdem der Weg über Proxmox' termproxy gemessen und verworfen wurde
Was gebaut wird
Ein Knopf neben jedem Host — in der Liste und auf der Detailseite — öffnet ein eigenes Fenster mit einem Terminal, das per SSH auf diesem Host angemeldet ist. xterm.js im Browser, ein eigener Container dahinter, der nichts anderes tut.
Warum nicht über Proxmox
Der erste Entwurf ging über Proxmox' termproxy: die Konsole holt ein Ticket
über den vorhandenen API-Token, der Browser verbindet direkt zu Proxmox. Kein
eigener Dienst, kein SSH bei uns.
Gemessen auf fsn-01, und daran gescheitert:
access/permissions → HTTP 200 (Token trägt, aber ohne Sys.Console)
termproxy → HTTP 500 Not a HASH reference
at /usr/share/perl5/PVE/APIServer/AnyEvent.pm line 978
Ein fehlendes Recht gäbe ein sauberes 403. Dieser Perl-Fehler kommt aus
Proxmox' Konsolen-Code, der eine Sitzung erwartet und einen API-Token vorfindet.
Der Besitzer hat den Weg daraufhin verworfen, statt ihn weiter zu verfolgen.
Dabei fiel noch etwas auf, das über diese Aufgabe hinaus gilt: der
app-Container erreicht keinen Host. Derselbe Aufruf lief dort in einen
Timeout und erst aus queue-provisioning durch. Nur Container im Tunnel sehen
die Hosts — der neue Container muss also dort stehen.
Der Aufbau
Ein Beiwagen terminal, nach dem Vorbild von kuma-bridge: eigenes Image, ein
Zweck, keine Datenbank, kein Laravel. Python mit websockets und paramiko —
dieselbe Sprache wie der bestehende Beiwagen, statt für einen Dienst eine dritte
Laufzeit einzuführen.
Er steht im WireGuard-Tunnel wie queue-provisioning und ist aus dem Web über
genau einen Pfad erreichbar, den nginx im app-Container durchreicht. Kein
eigener öffentlicher Name, kein eigener Port nach außen.
Der Ablauf
-
Klick auf „Terminal". Eine Livewire-Aktion prüft
hosts.manage— die Aktion, nicht die Seite. Wer einen Host anlegen und entfernen darf, darf auch auf ihm arbeiten; ein eigenes Recht wäre eine Unterscheidung ohne Unterschied (Entscheidung des Besitzers). -
Die App legt ein Ticket in Redis. 32 Byte Zufall als Schlüssel, Lebensdauer 30 Sekunden, Inhalt:
Feld Woher operator_idangemeldeter Betreiber host_uuidder Host, für den es gilt iphosts.wg_ip— die Tunneladresse, nie die öffentlicheuserrootprivate_keySecretVault::get('ssh.private_key')fingerprinthosts.ssh_host_key, gepinnt seitEstablishSshTrust -
Ein eigenes Fenster öffnet
/hosts/{uuid}/terminal?t=<ticket>. Diese Seite gehört uns und zeigt den ASCII-Vorspann von CluPilot. -
Die Seite öffnet den WebSocket auf
/terminal/ws. Das Ticket reist dabei alsSec-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 imapp-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. -
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
root@<ip>mit dem Schlüssel, Fingerabdruck geprüft wie in der Provisionierung, PTY angefordert, und ab da fließen Bytes in beide Richtungen. -
Der Vorspann weicht dem Terminal, sobald das erste Byte da ist — nicht, sobald der WebSocket offen ist. Ein Vorspann, der einer Verbindung weicht, die noch nicht trägt, ist eine Attrappe.
Was der Browser nie sieht
Den Schlüssel, die Tunneladresse, den Benutzernamen. Nur ein undurchsichtiges Ticket, das dreißig Sekunden lebt und beim ersten Gebrauch verfällt. Alles Übrige steht in Redis und wird vom Container gelesen, nicht vom Browser mitgegeben.
Was das kostet — offen benannt
Ein Container, der gleichzeitig im Tunnel steht und aus dem Web erreichbar
ist, mit einem Schlüssel, der auf jedem Host root ist. Das Ticket ist die
einzige Tür. Diese Angriffsfläche existierte vorher nicht; der Besitzer nimmt
sie nach ausdrücklichem Hinweis in Kauf.
Die IP-Freigabeliste greift hier nicht. RestrictConsoleNetwork ist
Laravel-Middleware; ein Pfad, den nginx direkt durchreicht, läuft nicht durch
PHP. Für den WebSocket ist das Ticket der alleinige Riegel — einmalig, dreißig
Sekunden, an einen Host und einen Betreiber gebunden. Das ist für diesen Zweck
enger als eine IP-Liste, aber es ist eine Schicht weniger als beim Rest der
Konsole.
Eine Sitzung ist Root. Es gibt keine Aufzeichnung dessen, was getippt wird, und keine Einschränkung der Befehle. Wer den Knopf drücken darf, hat die Maschine.
Grenzen, die der Container selbst zieht
- Leerlauf: eine Sitzung ohne Ein- und Ausgabe endet nach 15 Minuten.
- Ende ist Ende: schließt der WebSocket, wird der SSH-Kanal geschlossen und der Prozess beendet. Keine Sitzung überlebt ihr Fenster.
- Ein Ticket, eine Sitzung: das Löschen beim Einlösen ist die ganze Regel.
- Fingerabdruck: stimmt er nicht, bricht der Aufbau ab — dieselbe Prüfung, die die Provisionierung auch macht.
Was geprüft wird
Automatisiert, in der bestehenden Suite:
- Ohne
hosts.managegibt es kein Ticket. - Ein Ticket trägt genau eine Einlösung; die zweite läuft ins Leere.
- Nach dreißig Sekunden trägt es nicht mehr.
- Ein Ticket für Host A enthält niemals die Adresse von Host B.
- Der Schlüssel steht im Ticket-Eintrag in Redis und niemals in der Antwort an den Browser.
- Der Knopf erscheint nicht, wenn die Berechtigung fehlt.
Von Hand, weil ein PTY über WebSocket sich in dieser Suite nicht sinnvoll nachstellen lässt: dass das Terminal wirklich aufgeht, Eingaben ankommen und das Schließen des Fensters die Sitzung beendet.
Nicht in diesem Schritt
Ein Terminal auf einer Kunden-Instanz (SSH in die VM). Der Besitzer will es, aber danach — derselbe Container, dieselbe Ticket-Mechanik, andere Zieladresse und eine andere Berechtigung, weil dort Kundendaten liegen.