# 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.
