Entwurf: Terminal-Container mit eigenem Ticket, nachdem Proxmox' termproxy gemessen und verworfen wurde

main
nexxo 2026-08-02 17:19:02 +02:00
parent 325e69f8f0
commit 632d3213a4
1 changed files with 136 additions and 0 deletions

View File

@ -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=<ticket>`. Diese
Seite gehört uns und zeigt den **ASCII-Vorspann** von CluPilot.
4. **Die Seite öffnet den WebSocket** auf `/terminal/ws?t=<ticket>`.
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.