12 KiB
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.TerminalTicketdelegiert daran. Eine zweite Abschrift dieser Weisheit wäre die Stelle, an der beide Seiten später auseinanderlaufen.App\Services\Terminal\ServerTicket—blocker()undissue()in derselben Form wie beim Host, aber mit anderer Herkunft der vier Felder: Adresse und Fingerabdruck aus der Einrichtungsdatei, Benutzerroot, und der Schlüssel aus dem Tresor unterserver.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:
- Ö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 vorhandenenKeypair::generate()(Ed25519, Kommentarclupilot) und legt den privaten Teil im Tresor ab. - Strikt prüfen: genau eine Zeile,
ssh-ed25519 <base64>mit optionalem Kommentar. Alles andere → Abbruch. Dieselbe Härte wie bei der Hostnamen-Prüfung inapply-proxy-hosts. - 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). - Fingerabdruck des Wirt-Hostschlüssels in CluPilots Form bilden:
SHA256:base64 über"<algo> <base64-blob>". Nicht der OpenSSH-Fingerabdruck — das ist die Falle, die inbridge.pybereits 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. - Adresse (Gateway des compose-Netzes) und Fingerabdruck nach
storage/app/deploy/server-terminal.jsonschreiben, 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 inbridge.pywä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.
- Rechte. Ohne
server.terminal→ 403. Mit Fähigkeit, aber Adresse außerhalb der Liste → Bühne mitoff_networkund kein Ticket in Redis. Das ist die Grenze, an der ein Fehler Rechte ausweitet. - Zwei Türen bleiben zwei. Das ausgestellte Ticket trägt
server.private_key, nichtssh.private_key, dazu Adresse und Fingerabdruck aus der Einrichtungsdatei. - 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.
- Das Einrichtungsskript nimmt nur genau eine
ssh-ed25519-Zeile an und schreibt die Optionsliste unveränderlich mit;CONTRACTist erhöht. - 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.