Entwurf: ein Terminal fuer den CluPilot-Server selbst
Ein Host-Terminal reicht auf eine Maschine, dieses auf alles. Deshalb eigene Faehigkeit, eigener Schluessel, und die Netzliste ohne ihren Schalter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/versandtakt
parent
cfd1481797
commit
76cf3d0555
|
|
@ -0,0 +1,236 @@
|
|||
# Terminal für den CluPilot-Server — Entwurf
|
||||
|
||||
**Stand:** 2026-08-04
|
||||
**Zustand:** Vom Betreiber abschnittsweise freigegeben. Ansatz, Berechtigung,
|
||||
Erreichbarkeit, Protokolltiefe und Bühne sind entschieden.
|
||||
|
||||
## Worum es geht
|
||||
|
||||
Seit v1.4.2 kann ein Betreiber aus der Konsole eine Root-Schale auf einem
|
||||
Proxmox-Host öffnen. Gewünscht ist dasselbe für den **CluPilot-Server selbst**:
|
||||
die Debian-VM, auf der die Steuerungsebene läuft.
|
||||
|
||||
Das ist nicht dieselbe Funktion mit einem anderen Ziel. Ein Host-Terminal reicht
|
||||
auf **eine** Maschine. Dieses hier reicht auf **alles** — Datenbank, Tresor,
|
||||
DNS-Token, jede Kundeninstanz. Wer es hat, hat die Plattform. Daraus folgt jede
|
||||
Entscheidung unten.
|
||||
|
||||
## Was entschieden ist
|
||||
|
||||
| Frage | Entscheidung |
|
||||
|---|---|
|
||||
| Reichweite | Die Debian-VM selbst, als root — nicht der `app`-Container |
|
||||
| Berechtigung | Neue Fähigkeit `server.terminal`, per Migration nur an Owner |
|
||||
| Erreichbarkeit | Die bestehende `trusted_ranges`-Liste, aber ohne ihren Schalter |
|
||||
| Protokoll | Wer, wann, von wo — **kein** Mitschnitt des Inhalts |
|
||||
| Weg zum Wirt | Eigener SSH-Schlüssel auf die sshd des Wirts |
|
||||
|
||||
### Warum kein Mitschnitt
|
||||
|
||||
Der Inhalt einer Root-Sitzung enthält zwangsläufig Passwörter, Tokens und
|
||||
Schlüssel im Klartext. Wer ihn aufbewahrt, legt ein zweites Geheimnislager an,
|
||||
das niemand bewacht — und das genau die Werte enthält, die der Tresor mit einem
|
||||
eigenen Schlüssel schützt.
|
||||
|
||||
### Warum die Liste, aber nicht ihr Schalter
|
||||
|
||||
`RestrictConsoleNetwork` kennt die Liste bereits, ist aber **abschaltbar**
|
||||
(`console.network_restricted`, Vorgabe *aus*). Für das Server-Terminal gilt die
|
||||
Liste **immer**. Sonst hinge die Root-Konsole an einem Schalter, den es für
|
||||
einen anderen Zweck gibt: wer ihn aus gutem Grund umlegt — ausgesperrt, muss von
|
||||
unterwegs ran —, öffnete damit still auch diese Tür.
|
||||
|
||||
Der Preis dieser Entscheidung ist, dass eine in der Konsole pflegbare Liste über
|
||||
eine Root-Konsole mitentscheidet. Bezahlt wird er mit einem **Hinweis an der
|
||||
Pflegestelle**: auf `/admin/settings`, dort wo die Liste steht, wird gesagt,
|
||||
dass diese Adressen auch bestimmen, von wo aus eine Root-Schale zum Server
|
||||
aufgeht. Wer sie ändert, sieht, was daran hängt.
|
||||
|
||||
## Die Strecke
|
||||
|
||||
**Gemessen, nicht vermutet:** aus dem `terminal`-Container ist die sshd des
|
||||
Wirts unter **172.18.0.1** erreichbar — dem Gateway des `clupilot_default`-
|
||||
Netzes. Die Antwort war `SSH-2.0-OpenSSH_10.0p2`. Die Adresse ist
|
||||
installationsabhängig und steht deshalb **nicht im Code**, sondern wird beim
|
||||
Einrichten festgehalten.
|
||||
|
||||
**Die Brücke bleibt unangetastet.** `docker/terminal/bridge.py` kennt keine
|
||||
Hosts, nur Tickets: Adresse, Benutzer, privater Schlüssel, Fingerabdruck. Ein
|
||||
Server-Ticket sieht für sie aus wie jedes andere. Kein neues Format, keine neuen
|
||||
Schließcodes, kein zweiter Weg — und damit auch keine zweite Stelle, an der die
|
||||
Fingerabdruck-Prüfung schiefgehen kann. Auch der compose-Dienst und die
|
||||
nginx-Stelle `location = /terminal/ws` bleiben, wie sie sind.
|
||||
|
||||
**Auf der PHP-Seite** kommt ein zweiter Ausstellungsweg dazu:
|
||||
|
||||
- `App\Services\Terminal\TicketStore` — die Feinheiten des Ticket-Speichers
|
||||
wandern hierher: rohes Redis statt Cache-Fassade, `GETDEL`, `REDIS_PREFIX`,
|
||||
dreißig Sekunden, genau eine Einlösung. `TerminalTicket` delegiert daran. Eine
|
||||
zweite Abschrift dieser Weisheit wäre die Stelle, an der beide Seiten später
|
||||
auseinanderlaufen.
|
||||
- `App\Services\Terminal\ServerTicket` — `blocker()` und `issue()` in derselben
|
||||
Form wie beim Host, aber mit anderer Herkunft der vier Felder:
|
||||
Adresse und Fingerabdruck aus der Einrichtungsdatei, Benutzer `root`, und der
|
||||
Schlüssel aus dem Tresor unter **`server.private_key`**.
|
||||
|
||||
`server.private_key` ist ausdrücklich **nicht** `ssh.private_key`. Ein Schlüssel,
|
||||
der beides öffnet, macht aus zwei Türen eine — und der Flottenschlüssel liegt
|
||||
auf jedem übernommenen Host in `authorized_keys`.
|
||||
|
||||
Als `host_uuid` trägt ein Server-Ticket den Wert `server`. Damit bleibt die
|
||||
vorhandene Log-Zeile der Brücke lesbar („Sitzung offen — Host server
|
||||
(172.18.0.1), Betreiber 3"), ohne dass an ihr etwas geändert werden muss.
|
||||
|
||||
## Die Einrichtung
|
||||
|
||||
Ein Update läuft unter dem Dienstkonto; `deploy/update.sh` weigert sich, als
|
||||
root zu laufen. Für alles, was auf dem Wirt root braucht, gibt es bereits den
|
||||
gebauten Mechanismus: `deploy/install-agent.sh` schreibt die privilegierte
|
||||
Hälfte per Here-Dokument nach `/usr/local/sbin/clupilot-host-step` und trägt in
|
||||
sudoers *eine* feste Kommandozeile ein. Das Dienstkonto kann kein Byte davon
|
||||
ändern.
|
||||
|
||||
Dort — und nirgends sonst — gehört der Weg zum Wirt hin.
|
||||
|
||||
**`CONTRACT` steigt von 2 auf 3**, neuer Schritt `install-server-terminal`. Nach
|
||||
dem Muster von `apply-proxy-hosts`: der Schritt **holt sich** den Wert selbst und
|
||||
prüft ihn hart; das Dienstkonto liefert keine Konfiguration, nur die Auswahl.
|
||||
|
||||
Fünf Schritte:
|
||||
|
||||
1. Öffentlichen Schlüssel aus der Anwendung holen —
|
||||
`docker compose exec -T -u www-data app php artisan clupilot:server-terminal-key`.
|
||||
Die Anwendung erzeugt das Paar bei Bedarf mit dem vorhandenen
|
||||
`Keypair::generate()` (Ed25519, Kommentar `clupilot`) und legt den privaten
|
||||
Teil im Tresor ab.
|
||||
2. Strikt prüfen: genau **eine** Zeile, `ssh-ed25519 <base64>` mit optionalem
|
||||
Kommentar. Alles andere → Abbruch. Dieselbe Härte wie bei der
|
||||
Hostnamen-Prüfung in `apply-proxy-hosts`.
|
||||
3. Eintragen in `/root/.ssh/authorized_keys`, hinter einem Markierungskommentar,
|
||||
damit ein zweiter Lauf **ersetzt** statt anzuhängen. Feste Optionsliste,
|
||||
nicht aus der Anwendung: `from="<compose-Netz>",restrict,pty` — nur aus dem
|
||||
Container-Netz, keine Weiterleitungen, aber ein PTY (ohne das kein Terminal).
|
||||
4. Fingerabdruck des Wirt-Hostschlüssels **in CluPilots Form** bilden:
|
||||
`SHA256:` base64 über `"<algo> <base64-blob>"`. **Nicht** der
|
||||
OpenSSH-Fingerabdruck — das ist die Falle, die in `bridge.py` bereits zwölf
|
||||
Zeilen Kommentar wert war, und wer sie hier erneut tritt, bekommt bei jedem
|
||||
Verbindungsversuch eine Abweichung und die Versuchung, die Prüfung für kaputt
|
||||
zu halten.
|
||||
5. Adresse (Gateway des compose-Netzes) und Fingerabdruck nach
|
||||
`storage/app/deploy/server-terminal.json` schreiben, dem Dienstkonto lesbar.
|
||||
|
||||
Fehlt diese Datei, sagt die Seite es in ihrer eigenen Sprache — „einmal
|
||||
`sudo bash deploy/install-agent.sh` ausführen" —, statt ein Fenster zu öffnen,
|
||||
das nie verbindet.
|
||||
|
||||
### Was ausdrücklich dazugesagt gehört
|
||||
|
||||
Damit wird das Dienstkonto auf dem Wirt root-fähig: es hat `.env` und die
|
||||
Datenbank, kann also den Schlüssel aus dem Tresor lesen. Das lässt sich nicht
|
||||
wegbauen — es **ist** die Funktion: ein Weg aus der Anwendung heraus zu root.
|
||||
Auch die beiden verworfenen Ansätze (host-seitiger PTY-Dienst, privilegierter
|
||||
Container) hätten daran nichts geändert.
|
||||
|
||||
Verworfen wurde:
|
||||
|
||||
- **Ein host-seitiger PTY-Dienst über einen Unix-Socket.** Kein Schlüssel, keine
|
||||
zusätzliche Netz-Tür — aber ein neuer root-Dienst, ein eigenes Protokoll und
|
||||
ein zweiter Weg in `bridge.py`. Ein selbstgeschriebenes PTY-Protokoll ist
|
||||
genau die Sorte Code, in der ein Fehler still zu Root wird. Nach R22 der
|
||||
falsche Aufwand für diese Aufgabe.
|
||||
- **Ein privilegierter Container mit `nsenter`.** Am wenigsten Code — und der
|
||||
von nginx erreichbare Container wäre dauerhaft root auf dem Wirt. Ein Fehler
|
||||
in `bridge.py` wäre dann die Übernahme der Plattform, nicht ein kaputtes
|
||||
Fenster.
|
||||
|
||||
## Die Tür und die Oberfläche
|
||||
|
||||
**Die Fähigkeit.** `server.terminal` kommt per Migration in den Katalog, nach
|
||||
dem Muster der bestehenden `add_*_capability`-Migrationen, und wird nur der
|
||||
Owner-Rolle zugeteilt. Sie steht damit im Rollen-Editor — der selbst hinter
|
||||
`staff.manage` liegt, also heute nur beim Owner. Weitergeben kann sie nur er.
|
||||
|
||||
**Das Netz.** Der Prädikat-Teil von `RestrictConsoleNetwork::allows()` wird
|
||||
wiederverwendet; `isRestricted()` wird **nicht** gefragt (Begründung oben).
|
||||
|
||||
**Die Seite.** `App\Livewire\Admin\ServerTerminal`, `layouts.bare`, eigener Tab,
|
||||
Route in `routes/admin.php`. Reihenfolge im `mount()`: Fähigkeit, dann Netz,
|
||||
dann Einrichtungsdatei, dann Ticket. Und wie beim Host-Terminal: **sie wirft
|
||||
nie.** Ein Stacktrace in einem Terminalfenster ist ein Bruch mitten im Produkt.
|
||||
|
||||
**Der Knopf** sitzt auf `/admin/settings` in einem eigenen Abschnitt. Ohne die
|
||||
Fähigkeit ist er nicht da. Mit Fähigkeit, aber falschem Netz oder fehlender
|
||||
Einrichtung ist er sichtbar und gesperrt, mit Begründung — wer die Fähigkeit
|
||||
hat, darf erfahren, warum es gerade nicht geht, sonst sucht er eine halbe Stunde
|
||||
nach einem Knopf, der nie erscheint.
|
||||
|
||||
**Keine Bestätigung im Modal.** R23 zielt auf Aktionen mit Folgen; das Öffnen
|
||||
eines Fensters ändert für sich nichts, und das Host-Terminal öffnet ebenso
|
||||
direkt. Eine Rückfrage, die man täglich wegklickt, erzieht zum Wegklicken.
|
||||
|
||||
**Das Protokoll**, in zwei Hälften, weil keine allein reicht: PHP schreibt beim
|
||||
Ausstellen, *wer* (Name, nicht nur id) von *welcher Adresse* eine Root-Konsole
|
||||
angefordert hat — das weiß nur die Anwendung. Die Brücke schreibt wie bisher
|
||||
Öffnen und Schließen.
|
||||
|
||||
## Die Bühne
|
||||
|
||||
Der Vorspann des Host-Terminals — Wortmarke als Blockgrafik, Titel, Notiz,
|
||||
Neu-laden-Knopf — wird zum gemeinsamen Bauteil `<x-admin.terminal-screen>` mit
|
||||
den Attributen, die es schon gibt: Ticket, Bühnentitel, Bühnennotiz,
|
||||
Meldungssatz, Sichtbarkeit des Knopfes.
|
||||
|
||||
`resources/js/terminal.js` bleibt **unverändert**: es hängt an `data-terminal`,
|
||||
`data-ticket` und `data-stage-messages`, nicht an der Seite, und weiß von Hosts
|
||||
ohnehin nichts. Die Server-Seite füllt dieselbe Bühne aus `server.terminal.*` —
|
||||
„Verbinde mit dem CluPilot-Server" statt eines Hostnamens.
|
||||
|
||||
Beim Herausziehen gilt: die Host-Ansicht muss danach zeichengleich dieselbe
|
||||
sein. Eine Umstellung ohne Entwurfsentscheidung (R22 §5) — kein eigener Review,
|
||||
aber der bestehende Testlauf.
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
**Was der Server schon weiß**, steht sofort auf der Bühne, ohne dass ein
|
||||
WebSocket aufgeht:
|
||||
|
||||
| Merkwort | Bedeutung |
|
||||
|---|---|
|
||||
| `no_key` | kein `server.private_key` im Tresor |
|
||||
| `not_installed` | `server-terminal.json` fehlt oder ist unlesbar |
|
||||
| `off_network` | die Adresse steht nicht in der Liste |
|
||||
| `unknown` | der Fangzaun, mit `report()` und Neu-laden-Knopf |
|
||||
|
||||
**Was erst der Browser weiß**, kommt unverändert aus den Schließcodes der
|
||||
Brücke: **4401** abgewiesen, **4502** kein SSH.
|
||||
|
||||
Eine Sache verdient eigenen Text: **erzeugt der Wirt seine Hostschlüssel neu**
|
||||
— Neuaufsetzen, `dpkg-reconfigure openssh-server` —, passt der gepinnte
|
||||
Fingerabdruck nicht mehr, und das Terminal ist zu. Genau so soll es sein. Die
|
||||
Rückfahrkarte ist dieselbe Zeile wie bei `not_installed`, und sie gehört in den
|
||||
4502-Text, ohne zu behaupten, welcher der beiden Gründe es war.
|
||||
|
||||
## Tests
|
||||
|
||||
An den Grenzen statt flächendeckend (R22 §4). `DeploymentRunsAsTheAppUserTest`
|
||||
zeigt, dass Skripte und compose hier ohnehin aus PHP heraus als Text gelesen
|
||||
werden.
|
||||
|
||||
1. **Rechte.** Ohne `server.terminal` → 403. Mit Fähigkeit, aber Adresse
|
||||
außerhalb der Liste → Bühne mit `off_network` und **kein Ticket in Redis**.
|
||||
Das ist die Grenze, an der ein Fehler Rechte ausweitet.
|
||||
2. **Zwei Türen bleiben zwei.** Das ausgestellte Ticket trägt
|
||||
`server.private_key`, nicht `ssh.private_key`, dazu Adresse und Fingerabdruck
|
||||
aus der Einrichtungsdatei.
|
||||
3. **Der Ticket-Speicher bleibt, was er war:** rohes JSON, ein Präfix, genau
|
||||
eine Einlösung — nach dem Herausziehen derselbe Test für beide Wege.
|
||||
4. **Das Einrichtungsskript** nimmt nur genau eine `ssh-ed25519`-Zeile an und
|
||||
schreibt die Optionsliste unveränderlich mit; `CONTRACT` ist erhöht.
|
||||
5. **Die Seite wirft nie** — kaputter Tresor oder Redis weg ergibt `unknown`,
|
||||
keine Laravel-Fehlerseite.
|
||||
|
||||
## Offen gelassen
|
||||
|
||||
- Die PTY-Größe bleibt bei 80×24, wie beim Host-Terminal. Das ist ein
|
||||
bestehender offener Punkt der Brücke und wird hier nicht mitgelöst.
|
||||
- Ein Terminal für Kundeninstanzen bleibt zurückgestellt.
|
||||
Loading…
Reference in New Issue