Entwurf: Terminal-Container mit eigenem Ticket, nachdem Proxmox' termproxy gemessen und verworfen wurde
parent
325e69f8f0
commit
632d3213a4
|
|
@ -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.
|
||||
Loading…
Reference in New Issue