CluPilotCloud/docs/superpowers/specs/2026-08-02-terminal-contain...

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

  1. 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).

  2. Die App legt ein Ticket in Redis. 32 Byte Zufall als Schlüssel, Lebensdauer 30 Sekunden, Inhalt:

    Feld Woher
    operator_id angemeldeter Betreiber
    host_uuid der Host, für den es gilt
    ip hosts.wg_ip — die Tunneladresse, nie die öffentliche
    user root
    private_key SecretVault::get('ssh.private_key')
    fingerprint hosts.ssh_host_key, gepinnt seit EstablishSshTrust
  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. 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 root@<ip> mit dem Schlüssel, Fingerabdruck geprüft wie in der Provisionierung, PTY angefordert, und ab da fließen Bytes in beide Richtungen.

  6. 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:

  1. Ohne hosts.manage gibt es kein Ticket.
  2. Ein Ticket trägt genau eine Einlösung; die zweite läuft ins Leere.
  3. Nach dreißig Sekunden trägt es nicht mehr.
  4. Ein Ticket für Host A enthält niemals die Adresse von Host B.
  5. Der Schlüssel steht im Ticket-Eintrag in Redis und niemals in der Antwort an den Browser.
  6. 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.