diff --git a/docs/superpowers/specs/2026-08-02-terminal-container-design.md b/docs/superpowers/specs/2026-08-02-terminal-container-design.md new file mode 100644 index 0000000..fe81964 --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-terminal-container-design.md @@ -0,0 +1,136 @@ +# 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=`. Diese + Seite gehört uns und zeigt den **ASCII-Vorspann** von CluPilot. + +4. **Die Seite öffnet den WebSocket** auf `/terminal/ws?t=`. + +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@` 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.