CluPilotCloud/docs/superpowers/specs/2026-08-04-server-terminal-...

12 KiB
Raw Blame History

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\ServerTicketblocker() 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.