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
parent
880b5f1998
commit
0f1f397823
|
|
@ -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 A–C
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
Loading…
Reference in New Issue