From 76cf3d0555ae20bbdc0aed86898d4fe06117ca8f Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 09:41:18 +0200 Subject: [PATCH] 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 --- .../2026-08-04-server-terminal-design.md | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-server-terminal-design.md diff --git a/docs/superpowers/specs/2026-08-04-server-terminal-design.md b/docs/superpowers/specs/2026-08-04-server-terminal-design.md new file mode 100644 index 0000000..32d1212 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-server-terminal-design.md @@ -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 ` 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="",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 `" "`. **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 `` 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.