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