Design a host that installs itself and a platform that only takes it over

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/host-bootstrap
nexxo 2026-07-30 18:16:14 +02:00
parent 880b5f1998
commit 0f1f397823
1 changed files with 326 additions and 0 deletions

View File

@ -0,0 +1,326 @@
# Spec — Der Host installiert sich selbst, CluPilot übernimmt ihn nur
**Datum:** 2026-07-30
**Status:** entworfen, noch nicht umgesetzt
**Basis:** `feature/betriebsmodus` (Betriebsmodus + Bereitschaftsseite)
**Vorgänger-Kontext:** `docs/handoffs/2026-07-30-real-run-handoff.md` §3, Blöcke AC
---
## 1. Ziel
Die Verantwortungsgrenze verschiebt sich. **CluPilot installiert nichts mehr.** Ein
Bootstrap-Skript macht aus einer nackten Maschine einen fertigen Proxmox-Host mit
Traefik, WireGuard-Beitritt und goldener Nextcloud-Vorlage. CluPilot übernimmt ihn
danach und benutzt ihn.
Der einzige dauerhafte Zugang, den die Plattform zu einem Host behält, ist der
**Proxmox-API-Token**. Kein SSH, keine root-Shell, nichts, was eine Shell braucht.
### Warum
Der Betreiber will „so wenig Fehler wie möglich". Die drei teuersten Fehlerquellen
des heutigen Entwurfs sind alle Installationsarbeit über SSH:
- **`vmbr0` vollautomatisch** — im Handoff selbst als „riskantester Code des
Vorhabens" bezeichnet. Eine Brücke, die für einen Anbieter richtig ist, nimmt bei
einem anderen die Maschine vom Netz. `ConfigureProxmox` bricht heute bewusst mit
einer Anleitung ab, statt es zu versuchen.
- **Traefik als Systemdienst über SSH installieren**, samt Prüfung, dass er lauscht.
- **Die goldene Vorlage als Runbook.** Ein Dokument für einen Menschen, mit genau
den Fallen, die der Handoff aufzählt: `user: www-data` vergessen, LVM statt einer
letzten Partition, `qemu-guest-agent` nicht im Image.
Alle drei verschwinden als Plattformarbeit, wenn die Maschine fertig ankommt.
### Nicht Teil dieses Vorhabens
- **Teil C des Betriebsmodus-Vorhabens** (Testdaten markieren, eigene Belegserie,
Aufräumen inkl. VM und DNS). Weiterhin beschlossen, weiterhin eigener Entwurf.
- Das Bestellen des Servers und das Starten des Rettungssystems. Das passiert im
Kundenbereich von Hetzner oder netcup und ist von hier aus nicht erreichbar.
- Migration bestehender Hosts. Es gibt keine echten; die zwei in der Datenbank sind
Seed-Daten aus `DatabaseSeeder` und werden entfernt, nicht überführt.
---
## 2. Ausgangsbefund — gemessen, nicht gelesen
Am 2026-07-30 auf dem Stand von `feature/betriebsmodus` nachgemessen.
| Befund | Beleg |
|---|---|
| Die Host-Kette hat **14 Schritte** | `config/provisioning.php`, `pipelines.host` |
| Sie bleibt beim siebten stehen | `ConfigureProxmox` liefert `fail`, wenn `ip link show vmbr0` nichts findet |
| **Kein Traefik-Schritt** in der Kette | Block C nie gebaut |
| Routen gehen **per SSH als root** auf den Host | `SshTraefikWriter::write()``connectWithKey($trafficHost, 'root', …)` |
| Und zwar **nicht einmalig** | Aufrufer: `ConfigureDnsAndTls`, `RunAcceptanceChecks`, `EndInstanceService` |
| Die Bereitschaftsseite meldet 6 blockierende Lücken | `Readiness::blocking()`, davon 2 echt |
Der vierte und fünfte Befund sind der Grund, warum „Zugang nur für die
VM-Erstellung" heute **nicht** stimmt: das Routing braucht eine dauerhafte
root-Shell auf jedem Kundenhost.
---
## 3. Entscheidungen aus dem Brainstorming (verbindlich)
| Frage | Entscheidung |
|---|---|
| Wer installiert? | **Ein Bootstrap-Skript auf der Maschine.** CluPilot nie. |
| Wie kommt eine Kundenroute auf den Host? | **Der Host holt sie sich.** Traefik-HTTP-Provider fragt einen CluPilot-Endpunkt im Tunnel ab. |
| Was hinterlässt das Skript? | **Alles, inklusive der goldenen Vorlage** auf `template_vmid 9000`. |
| Wie finden Host und Konsole zueinander? | **Einmal-Code aus dem Adminbereich**, alles Weitere im Tunnel. |
| Was sieht die Konsole währenddessen? | **Schritt für Schritt**, gemeldet vom Host. |
| Dauerhafter Zugang der Plattform | **Nur der Proxmox-Token.** |
---
## 4. Der Ablauf, wie ihn der Betreiber erlebt
1. Server beim Anbieter bestellen, **Rettungssystem starten** (Kundenbereich des
Anbieters).
2. Im Adminbereich **Host anlegen**: Name, Rechenzentrum, öffentliche IP. Die
Konsole zeigt **eine Zeile zum Kopieren** mit dem Einmal-Code darin.
3. Diese Zeile **einmal** im Rettungssystem einfügen.
4. Zusehen. Der Fortschritt läuft in der Konsole mit.
5. Der Host steht auf `active`, sobald er sich gemeldet und die Prüfungen bestanden
hat.
Zwei Stellen, an denen der Betreiber etwas tut. Kein Wert wird abgetippt.
---
## 5. Der Einmal-Code und der eine öffentliche Endpunkt
### Das Henne-Ei-Problem
Alles soll im Tunnel passieren — aber um dem Tunnel beizutreten, braucht das Skript
die WireGuard-Angaben. Etwas muss einmal von außen kommen.
### Die Auflösung
Genau **ein** öffentlicher Endpunkt, `POST /enrol`, der ohne gültigen Code nichts
preisgibt.
- Beim Anlegen des Hosts entsteht ein Code: 32 Zeichen aus `random_bytes`, in der
Datenbank **nur als Hash** (wie ein Passwort), mit `expires_at` **60 Minuten** und
`used_at`.
- Das Skript ruft `POST /enrol` mit dem Code. Antwort bei Erfolg: WireGuard-Subnetz,
Hub-Pubkey, Hub-Endpunkt, die dem Host zugeteilte Tunnel-IP, und ein
**Fortschritts-Token** für die Rückmeldungen aus §7.
- Der Code ist danach verbraucht (`used_at`), auch bei Abbruch. Ein zweiter Versuch
braucht einen neuen Code aus der Konsole. Ein Skriptlauf, der stirbt, wird nicht
„nochmal probiert" — er wird neu angestoßen, weil die halb installierte Maschine
ohnehin neu aufgesetzt gehört.
- **Ratenbegrenzung** und immer dieselbe Antwortzeit bei falschem Code: der
Endpunkt darf nicht verraten, ob ein Code existiert.
- Antwortet **nicht**, wenn der Host in der Konsole schon `active` ist.
### Was der Endpunkt NICHT tut
Er nimmt keinen Proxmox-Token entgegen und schreibt keine Zugangsdaten. Alles, was
ein Geheimnis ist, geht den Rückweg durch den Tunnel (§6). Der öffentliche Endpunkt
gibt nur her, was ein Angreifer mit gültigem Code ohnehin bekäme: wo der Hub steht.
---
## 6. Der Rückweg: Token durch den Tunnel
Nachdem das Skript dem Tunnel beigetreten ist, meldet es sich über die
**Tunnel-Adresse** von CluPilot — nicht über die öffentliche.
`POST /host/register` (nur aus dem WireGuard-Subnetz erreichbar, `TRUSTED_RANGES`)
mit dem Fortschritts-Token und:
- dem Proxmox-API-Token, den das Skript auf der Maschine erzeugt hat
- dem SSH-Host-Schlüssel-Fingerabdruck (nur zur Anzeige; die Plattform verbindet
sich nicht mehr)
- Knotenname, `pve_version`, Plattengröße, RAM, Kerne
CluPilot legt den Token in den Tresor (`SecretVault`, verschlüsselt mit
`SECRETS_KEY`) und schreibt `hosts.api_token_ref`. **Der Betreiber sieht den Token
nie.**
Warum durch den Tunnel: ein Proxmox-Token, der über einen öffentlichen Endpunkt
gereicht wird, liegt für die Dauer der Anfrage in fremden Protokollen und
Zwischenspeichern. Der Tunnel ist ohnehin da, wenn dieser Aufruf passiert.
---
## 7. Fortschritt: der Host erzählt, die Konsole zeigt
`POST /host/progress` (ebenfalls nur aus dem Tunnel, mit dem Fortschritts-Token)
meldet je Abschnitt einen Schlüssel und einen Zustand.
Die Abschnitte des Skripts, in dieser Reihenfolge:
| Schlüssel | Was passiert |
|---|---|
| `rescue_checked` | Virtualisierung vorhanden (`vmx`/`svm`), Platte groß genug, Netz da |
| `debian_installed` | Grundsystem geschrieben, Partition **als letzte, ohne LVM** |
| `rebooted` | Maschine ist aus dem Rettungssystem heraus neu gestartet |
| `proxmox_installed` | PVE-Pakete drauf, Kernel aktiv |
| `network_bridged` | `vmbr0` steht, Maschine antwortet noch |
| `wireguard_joined` | Handshake mit dem Hub bewiesen |
| `traefik_running` | Dienst läuft, lauscht auf 80/443, holt seine Routen ab |
| `template_built` | Vorlage auf 9000, als Template konvertiert |
| `registered` | Token übergeben, Host meldet sich fertig |
Die Konsole zeigt dieselbe Fortschrittsdarstellung wie heute. Bleibt eine Meldung
länger als eine gesetzte Frist aus, steht dort **welcher Abschnitt** offen ist —
nicht „irgendetwas ging schief".
**`network_bridged` ist der gefährliche Abschnitt** und behält deshalb die
Selbstrücknahme aus Block B des Handoffs, nur im Skript statt in der Pipeline:
Netzkonfiguration sichern, Zeitgeber auf fünf Minuten der sie zurückspielt, Brücke
aktivieren, **von außen** prüfen ob die Maschine noch antwortet, erst dann den
Zeitgeber abbestellen. Eine Maschine, die dabei vom Netz geht, kommt von selbst
zurück.
---
## 8. Das Routing dreht die Richtung um
Heute schreibt CluPilot eine YAML-Datei per SSH in Traefiks dynamisches
Verzeichnis. Künftig liefert CluPilot sie aus und Traefik holt sie ab.
- Neuer Endpunkt `GET /host/routes` (nur aus dem Tunnel), der **alle** Routen dieses
Hosts als eine Traefik-Konfiguration ausliefert.
- Er braucht ein **dauerhaftes Host-Token**, nicht den Fortschritts-Token: der
Fortschritts-Token gilt für die Einrichtung und ist danach wertlos, während die
Routen für die Lebensdauer des Hosts abgerufen werden. CluPilot gibt es bei
`POST /host/register` (§6) zurück, das Skript legt es in Traefiks statische
Konfiguration. Es liegt wie jedes Zugangsdatum verschlüsselt im Tresor und ist
je Host verschieden — ein entwendetes Token zeigt die Routen genau eines Hosts,
nicht die aller.
- Traefiks statische Konfiguration bekommt einen `http`-Provider auf diese URL, mit
Abrufintervall.
- `SshTraefikWriter` und die `RemoteShell`-Abhängigkeit der Routen-Schreiber
entfallen. `TraefikWriter` bleibt als Schnittstelle; die Aufrufer
(`ConfigureDnsAndTls`, `RunAcceptanceChecks`, `EndInstanceService`) ändern sich
**nicht** — sie schreiben weiter in die Datenbank, nur liest jetzt der Host.
**Der Nebengewinn ist größer als die Ersparnis an Zugang:** heute ist ein
fehlgeschlagener Schreibvorgang verloren und niemand merkt es. Künftig holt sich
ein Host, der eine Stunde nicht erreichbar war, seinen Stand beim nächsten Abruf
von selbst wieder. Der Zustand konvergiert, statt zu driften.
**Namen müssen passen:** Der Handoff warnt in Block C ausdrücklich, dass
Entrypoint- und certResolver-Namen zu dem passen müssen, was `SshTraefikWriter::render()`
schon ausgibt — „hier zuerst nachlesen, nicht raten". Das gilt unverändert für die
statische Konfiguration, die das Skript schreibt.
---
## 9. Die Host-Kette schrumpft
| Heute | Künftig |
|---|---|
| `ValidateHostInput` | bleibt |
| `EstablishSshTrust` | **entfällt** |
| `PrepareBaseSystem` | **entfällt** |
| `ConfigureWireguard` | **entfällt** (das Skript tritt bei; die Kette prüft nur den Handshake) |
| `InstallProxmoxVe` | **entfällt** |
| `RebootIntoPveKernel` | **entfällt** |
| `ConfigureProxmox` | **entfällt** — Block B erledigt sich |
| `CreateAutomationToken` | **entfällt** (das Skript erzeugt ihn) |
| `VerifyProxmoxApi` | bleibt, wird zentral |
| `VerifyVmTemplate` | bleibt |
| `RegisterHostDns` | bleibt |
| `RegisterCapacity` | bleibt |
| `SecureHostFirewall` | **entfällt** als Schreiben, bleibt als **Prüfung** |
| `CompleteHostOnboarding` | bleibt |
Sechs Schritte statt vierzehn, und die verbleibenden **sehen nach, statt zu
verändern**. Ein Host, der die Kette nicht besteht, wird nicht repariert — er wird
neu aufgesetzt, was jetzt ein Befehl ist.
---
## 10. Was an der Bereitschaftsseite nachzuziehen ist
Das Betriebsmodus-Vorhaben hat Prüfungen gebaut, die von der alten Welt ausgehen:
- `onboarding.ssh_private_key`**entfällt als blockierend.** Die Plattform
braucht keinen SSH-Schlüssel mehr. Der Tresor-Eintrag bleibt (Altbestand,
Notfallzugang), aber sein Fehlen hält nichts mehr auf.
- `provisioning.traefik_path`**entfällt.** Es gibt kein Verzeichnis mehr, in das
CluPilot schreibt.
- Neu: **der Routen-Endpunkt ist erreichbar** und mindestens ein Host holt ihn
tatsächlich ab (letzter Abruf jünger als das Intervall). Ein Host, der seine
Routen nicht mehr holt, serviert stillschweigend einen veralteten Stand — genau
die Sorte stiller Fehler, gegen die die Seite gebaut ist.
Der Wächter-Test aus Aufgabe 12 erzwingt, dass jeder Tresor-Eintrag auf der Seite
auftaucht; Änderungen an der Registry ziehen ihn nach.
---
## 11. Tests
Ein Skript, das ein Betriebssystem installiert, lässt sich nicht in SQLite prüfen.
Die Beweislast teilt sich:
**Was die Suite prüfen kann und muss:**
1. `POST /enrol` gibt ohne gültigen Code nichts preis, und die Antwortzeit
unterscheidet nicht zwischen „Code falsch" und „Code existiert nicht".
2. Ein Code gilt **einmal** und verfällt nach 60 Minuten.
3. Der Code liegt **nur als Hash** in der Datenbank.
4. `POST /host/register` und `/host/progress` sind **von außerhalb des Tunnels
nicht erreichbar** — der wichtigste Test dieser Spec.
5. Der Proxmox-Token landet im Tresor und **nie** in einer Antwort, einem Protokoll
oder einer Ansicht.
6. `GET /host/routes` liefert genau die Routen dieses Hosts und keine fremden.
7. Die geschrumpfte Kette hat die sechs Schritte in der festgelegten Reihenfolge.
8. Ein Fortschrittsbericht für einen fremden Host wird abgewiesen.
**Was nur auf echter Hardware zu beweisen ist:** alles im Skript. Der Handoff sagt
dazu das Richtige — *„Jeder Schritt, der scheitert, ist ein Fund — und der Server
darf dabei kaputtgehen, Debian ist neu eingespielt in Minuten."* Der dedizierte
Testserver ist der Prüfstand, und das Skript ist so gebaut, dass ein zweiter Lauf
auf einer frisch aufgesetzten Maschine immer gleich ausgeht.
---
## 12. Reihenfolge
1. Einmal-Code, `POST /enrol`, Ablauf und Verbrauch — mit den Sicherheitstests.
2. Tunnel-Endpunkte `register` und `progress`, Token in den Tresor.
3. Adminbereich: Host anlegen zeigt die Befehlszeile, Fortschritt läuft mit.
4. Routen-Endpunkt und Umstellung von `SshTraefikWriter` auf den Abruf.
5. Die Kette schrumpfen, Bereitschaftsprüfungen nachziehen.
6. Das Bootstrap-Skript, Abschnitt für Abschnitt, mit Rückmeldung nach jedem.
7. Auf echter Hardware durchlaufen lassen.
Schritt 1 bis 5 ist prüfbare Plattformarbeit. Schritt 6 ist das Skript. Schritt 7
ist die Abnahme, und sie ist die einzige, die zählt.
---
## 13. Risiken
- **Ein öffentlicher Endpunkt mehr.** Heute ist alles außer Portal und Website
zugesperrt. `POST /enrol` ist die erste Ausnahme. Er gibt ohne gültigen Code
nichts preis und ist ratenbegrenzt — aber er ist neu, und neue Türen sind
Türen.
- **Der Fortschritt hängt am Host.** Ein Skript, das stirbt, bevor es meldet,
hinterlässt eine Konsole, die auf etwas wartet, das nie kommt. Deshalb je
Abschnitt eine Frist und eine sichtbare Aussage, welcher offen ist.
- **Das Skript ist nicht wie die übrige Codebasis prüfbar.** Das ist ein echter
Rückschritt in der Beweisbarkeit und der Preis dafür, dass die riskanteste
Arbeit die Plattform verlässt. Er ist es wert, aber er ist nicht null.
- **Traefik holt sich Routen — auch veraltete.** Fällt CluPilot aus, servieren die
Hosts weiter, was sie zuletzt geholt haben. Das ist die richtige Richtung, muss
aber bewusst so sein und gehört auf die Bereitschaftsseite (§10).
---
## 14. Folgepunkte
- Die zwei Seed-Hosts aus `DatabaseSeeder` entfernen. Sie stehen auf `active`,
haben keinen Token und tragen Adressen aus RFC 5737.
- Teil C des Betriebsmodus-Vorhabens (Testdaten und Aufräumen).
- Die geparkten Befunde aus `.superpowers/sdd/…/progress.md`, insbesondere der
zweite `source()`-Leser in `integrations.blade.php`.