From 0f1f3978232f5803de467655f872a1b2776f86b4 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 18:16:14 +0200 Subject: [PATCH] Design a host that installs itself and a platform that only takes it over Co-Authored-By: Claude Opus 5 --- ...st-uebernahme-statt-installation-design.md | 326 ++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-host-uebernahme-statt-installation-design.md diff --git a/docs/superpowers/specs/2026-07-30-host-uebernahme-statt-installation-design.md b/docs/superpowers/specs/2026-07-30-host-uebernahme-statt-installation-design.md new file mode 100644 index 0000000..9760ac6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-host-uebernahme-statt-installation-design.md @@ -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`.