diff --git a/docs/superpowers/plans/2026-07-30-host-uebernahme-bootstrap-skript.md b/docs/superpowers/plans/2026-07-30-host-uebernahme-bootstrap-skript.md new file mode 100644 index 0000000..02e9c05 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-host-uebernahme-bootstrap-skript.md @@ -0,0 +1,299 @@ +# Host-Übernahme — Bootstrap-Skript, Umsetzungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ein Skript, das aus einer nackten Maschine im Rettungssystem einen fertigen Proxmox-Host macht — mit Traefik, WireGuard-Beitritt und goldener Nextcloud-Vorlage — und CluPilot nach jedem Abschnitt sagt, wie weit es ist. + +**Architecture:** Ein einziges POSIX-Shell-Skript, das der Betreiber mit einer Zeile aus dem Adminbereich startet. Es läuft in Abschnitten; jeder meldet sich zurück, sobald ein Weg zu CluPilot existiert. Abschnitte vor dem Tunnelbeitritt werden lokal mitgeschrieben und nachgereicht. Das Skript ist so gebaut, dass ein zweiter Lauf auf einer frisch aufgesetzten Maschine immer gleich ausgeht. + +**Tech Stack:** POSIX sh, Debian 13 (trixie), Proxmox VE 9, WireGuard, Traefik, `qemu-img`, cloud-init, Docker + Compose im Gast. + +## Global Constraints + +- **Vollfassung des Entwurfs:** `docs/superpowers/specs/2026-07-30-host-uebernahme-statt-installation-design.md`. Bei Widerspruch gewinnt die Spec. +- **DIE WICHTIGSTE AUFLAGE: das Wissen liegt schon im Repository, erfinde es nicht neu.** Die heutigen Pipeline-Schritte enthalten alles, was hier gebraucht wird — und sie werden vom Plattform-Plan **gelöscht**. Lies sie, **bevor** das passiert: + - `app/Provisioning/Steps/Host/InstallProxmoxVe.php` — die Codename-Tabelle (Debian 13/trixie ↔ PVE 9), die Paketquellen, die Reihenfolge. Der Handoff hält fest, dass ein fest verdrahtetes `bookworm` heute PVE-8-Quellen gegen Debian 13 gesetzt hätte. + - `app/Provisioning/Steps/Host/ConfigureProxmox.php` — die vollständige Diagnose zu `vmbr0`: warum Proxmox auf Debian keine anlegt, was eine falsche Brücke anrichtet, und die Datacenter-Firewall, ohne die die „nur 80/443"-Regeln der Kunden-VMs **wirkungslos** sind. + - `app/Provisioning/Steps/Host/SecureHostFirewall.php` — die nftables-Regeln, samt der Korrektur, dass **nicht jedes ICMP** verworfen werden darf (sonst sind IPv6 und PMTUD gebrochen). + - `app/Provisioning/Steps/Host/ConfigureWireguard.php` — wie `wg0` systemd-aktiviert wird. Der Handoff hält fest, dass ein `||`-Rückfall die fehlende Aktivierung verdeckte und der Neustart eine nicht mehr bootende Maschine hinterlassen konnte. + - `app/Provisioning/Steps/Host/CreateAutomationToken.php` — die Proxmox-Rolle **inklusive `Sys.Modify`**, ohne das jede Kundenbereitstellung am Backup-Schritt stirbt. + - `app/Services/Traefik/SshTraefikWriter.php` — `render()` gibt die Entrypoint- und certResolver-Namen aus, zu denen die statische Konfiguration passen muss. Der Handoff: *„hier zuerst nachlesen, nicht raten."* +- **Die drei Vorlagen-Fallen aus dem Handoff, Block A**, sind nicht verhandelbar: + 1. `user: www-data` am `app`-Dienst der Compose-Datei — sonst schlagen **alle** `occ`-Aufrufe fehl. + 2. Die Root-Partition ist die **letzte** auf der Platte und liegt **nicht** auf LVM — `GrowGuestFilesystem` erkennt LVM absichtlich nicht und schriebe sonst ein Kontingent über ein Dateisystem, das nie gewachsen ist. + 3. `qemu-guest-agent` **muss** im Image sein — `WaitForGuestAgent` hängt daran. +- **Abschnittsschlüssel**, exakt und in dieser Reihenfolge — die einzige Absprache mit dem Plattform-Plan: + `rescue_checked`, `debian_installed`, `rebooted`, `proxmox_installed`, `network_bridged`, `wireguard_joined`, `traefik_running`, `template_built`, `registered` +- **Kein Docker auf dem Hypervisor.** Traefik ist Binary plus systemd-Unit. nftables bleibt einziger Eigentümer der Host-Firewallregeln (Handoff §1). +- **Commit-Disziplin:** `git add -- `, `git commit -F - -- `. Nie `-A`, `.`, `-a` oder nackt. +- **Dieser Plan hat keine Testsuite.** Ein Skript, das ein Betriebssystem installiert, lässt sich nicht in SQLite beweisen. Jede Aufgabe endet stattdessen mit einem Lauf auf **echter Hardware** — und 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."* +- **Nicht geeignet als Prüfstand:** Hetzner Cloud (CPX/CX) — keine verschachtelte Virtualisierung, also kein `/dev/kvm`, also startet kein Gast. Bei netcup produktabhängig. Entscheidend ist `grep -cE 'vmx|svm' /proc/cpuinfo && ls -l /dev/kvm`. + +--- + +## Dateistruktur + +| Datei | Verantwortung | +|---|---| +| `deploy/bootstrap/clupilot-bootstrap.sh` | Das Skript. Ein Einstiegspunkt, Abschnitte als Funktionen. | +| `deploy/bootstrap/lib/report.sh` | Melden und Nachreichen. Kennt als Einziges den Weg zu CluPilot. | +| `deploy/bootstrap/lib/proxmox.sh` | Abschnitte 4 und 5 (Installation, Brücke) | +| `deploy/bootstrap/lib/network.sh` | Brücke mit Selbstrücknahme, nftables | +| `deploy/bootstrap/lib/template.sh` | Die goldene Vorlage | +| `deploy/bootstrap/assets/docker-compose.yml` | Die Compose-Datei, die in die Vorlage kommt | +| `docs/runbooks/host-bootstrap.md` | Was zu tun ist, wenn das Skript stehenbleibt | + +Getrennte Dateien, weil `template.sh` und `network.sh` unabhängig voneinander +geprüft und wiederholt werden — und weil ein Skript, das alles in einer Datei +macht, beim Nachlesen im Fehlerfall unbrauchbar ist. + +--- + +### Task 1: Gerüst, Melden und Nachreichen + +**Files:** +- Create: `deploy/bootstrap/clupilot-bootstrap.sh`, `deploy/bootstrap/lib/report.sh` + +**Interfaces:** +- Produces: `report
[message]` — schreibt lokal nach `/var/lib/clupilot/progress.jsonl` **mit Zeitstempel** und versucht zu senden. `flush_reports` — reicht alles Ungesendete nach. +- Consumes: aus der Befehlszeile `--code`, `--wg-private`, `--wg-ip`, `--hub-pubkey`, `--hub-endpoint`, `--api`. + +- [ ] **Step 1: Das Gerüst schreiben** + +```sh +#!/bin/sh +set -eu + +# Läuft im Rettungssystem des Anbieters, mit genau den Werten, die der +# Adminbereich in die kopierte Zeile geschrieben hat. Es holt nichts ab: alles, +# was vor dem Tunnel gebraucht wird, steht bereits hier (Spec §5). +``` + +`report` schreibt **immer zuerst lokal**, dann erst der Sendeversuch. Vor +`wireguard_joined` gibt es keinen Weg zu CluPilot; die Zeilen sammeln sich und +werden von `flush_reports` nachgereicht, **mit ihren eigenen Zeitstempeln**. +Sonst sieht eine zwanzigminütige Installation in der Konsole aus wie eine +Sekunde. + +- [ ] **Step 2: Auf echter Hardware prüfen** + +Rettungssystem starten, Skript mit erfundenen Werten aufrufen, prüfen dass +`/var/lib/clupilot/progress.jsonl` entsteht und die Sendeversuche sauber +scheitern statt das Skript abzubrechen. + +- [ ] **Step 3: Committen.** Nachricht: `Give the bootstrap a spine and a way to report` + +--- + +### Task 2: `rescue_checked` — vor allem anderen nachsehen + +- [ ] **Step 1: Schreiben** + +Prüft, bevor irgendetwas geschrieben wird: + +- verschachtelte Virtualisierung: `grep -cE 'vmx|svm' /proc/cpuinfo` **und** `ls /dev/kvm` +- Platte groß genug für Vorlage plus Kunden +- Netz erreichbar, Uhr plausibel (ein falsches Datum lässt jede TLS-Prüfung scheitern) +- die Maschine ist wirklich ein Rettungssystem und nicht versehentlich ein + laufender Produktivserver — **der teuerste denkbare Fehlgriff dieses Skripts** + +**Bricht ab, statt zu raten.** Ein Skript, das eine Platte überschreibt, hat +genau hier seine einzige Gelegenheit, es nicht zu tun. + +- [ ] **Step 2: Auf echter Hardware prüfen**, auch der Abbruchfall: einmal auf einer + Maschine ohne `/dev/kvm` starten und sehen, dass es sich weigert. +- [ ] **Step 3: Committen.** `Refuse a machine that cannot do the job` + +--- + +### Task 3: `debian_installed` und `rebooted` + +- [ ] **Step 1: Schreiben** + +Debian 13 ins Zielsystem, **Root-Partition als letzte auf der Platte, kein LVM** +(Falle 2 aus Block A — sie gilt für den Host genauso, weil die Vorlage später von +hier aus gebaut wird). Danach Neustart aus dem Rettungssystem heraus. + +Der Neustart ist die erste Stelle, an der das Skript die Kontrolle verliert. Es +hinterlässt deshalb einen systemd-Dienst, der es nach dem Hochfahren **selbst +wieder aufnimmt** — mit demselben Code und derselben Fortschrittsdatei. + +- [ ] **Step 2: Auf echter Hardware prüfen** — und ausdrücklich, dass es nach dem + Neustart von allein weiterläuft. +- [ ] **Step 3: Committen.** `Write Debian and survive the first reboot` + +--- + +### Task 4: `proxmox_installed` + +- [ ] **Step 1: Schreiben** + +**Lies zuerst `InstallProxmoxVe.php`.** Die Codename-Tabelle dort kennt Debian 13 +und PVE 9 und ist aus einem echten Fehler entstanden. Übernimm sie, statt die +Paketquellen zu tippen — und übernimm auch, dass ein **unbekanntes Release +abbricht** statt zu raten. + +- [ ] **Step 2: Auf echter Hardware prüfen.** `pveversion` antwortet, der PVE-Kernel + läuft. +- [ ] **Step 3: Committen.** `Install Proxmox from the sources this Debian actually has` + +--- + +### Task 5: `network_bridged` — der gefährlichste Abschnitt + +- [ ] **Step 1: Schreiben** + +**Lies zuerst `ConfigureProxmox.php`.** Dort steht, warum Proxmox auf Debian keine +`vmbr0` anlegt und was eine falsche Brücke anrichtet. + +Das Muster aus Block B des Handoffs, unverändert, nur im Skript: + +1. Aktuelle Netzkonfiguration sichern. +2. **Systemd-Timer auf fünf Minuten**, der sie zurückspielt und neu startet. +3. Brücke aktivieren. +4. **Von außen** prüfen, ob der Host antwortet. +5. Erst dann den Timer abbestellen. + +Der Abschnitt muss **erkennen, was er vorfindet**: DHCP auf einer Schnittstelle +(Cloud) gegen geroutete Einzel-IP mit Gateway außerhalb des Subnetzes +(Hetzner-Dedicated) gegen netcup. Eine Brücke, die für den einen Fall richtig ist, +nimmt den anderen vom Netz. + +Dazu die **Datacenter-Firewall einschalten** — ohne sie sind die „nur 80/443"-Regeln +der Kunden-VMs wirkungslos, und `ConfigureProxmox` hielt das als eigenen Fund fest. + +- [ ] **Step 2: Auf echter Hardware prüfen — und die Selbstrücknahme absichtlich + auslösen.** Eine Brücke bauen, die die Maschine vom Netz nimmt, und zusehen, wie + sie von selbst zurückkommt. **Ohne diesen Versuch ist der Zeitgeber eine + Behauptung.** +- [ ] **Step 3: Committen.** `Bridge the network, and take it back if the host goes quiet` + +--- + +### Task 6: `wireguard_joined` — ab hier ist CluPilot erreichbar + +- [ ] **Step 1: Schreiben** + +`wg0` aus den mitgegebenen Werten, **systemd-aktiviert** — lies +`ConfigureWireguard.php` dazu: ein `||`-Rückfall verdeckte dort die fehlende +Aktivierung, und der Handoff nennt als Folge eine Maschine, die den Neustart nicht +überlebt. + +Der Tunnel gilt erst mit **bewiesenem Handshake**, nicht mit geschriebener Datei. +Auch das ist ein Fund aus dem Handoff: eine gespeicherte Tunnel-IP ohne Beweis +machte jeden weiteren Verbindungsversuch unbrauchbar, und die Rettung war +Datenbank-Handarbeit. + +Sobald der Handshake steht: `flush_reports` — alles bis hierher wird nachgereicht. + +- [ ] **Step 2: Auf echter Hardware prüfen.** In der Konsole müssen jetzt **alle + fünf** bisherigen Abschnitte auftauchen, mit ihren echten Zeitstempeln. +- [ ] **Step 3: Committen.** `Join the tunnel, then tell the console everything so far` + +--- + +### Task 7: `traefik_running` + +- [ ] **Step 1: Schreiben** + +Binary plus systemd-Unit, **kein Docker**. Statische Konfiguration mit +`websecure`-Entrypoint und `letsencrypt`-certResolver — **deren Namen zu dem passen +müssen, was der Routen-Endpunkt ausliefert.** Lies dazu `SshTraefikWriter::render()` +im Repository, solange es sie noch gibt. + +Der `http`-Provider zeigt auf `GET /host/routes` über die **Tunnel-Adresse** von +CluPilot, mit dem dauerhaften Host-Token aus der Registrierung. + +**Prüfen, nicht nur installieren:** Dienst läuft, 80 und 443 sind belegt, der +Ping-Endpunkt antwortet, und **die erste Routentabelle wurde tatsächlich geholt**. +Ein Host, der Traefik installiert hat, aber nie eine Tabelle bekam, ist nicht +fertig. + +- [ ] **Step 2: Auf echter Hardware prüfen**, einschließlich eines abgerufenen + Routensatzes. +- [ ] **Step 3: Committen.** `Run Traefik as a service that fetches its own routes` + +--- + +### Task 8: `template_built` — die goldene Vorlage + +- [ ] **Step 1: Schreiben** + +Aus einem Debian-13-Cloud-Image, auf `template_vmid 9000`: + +- `qemu-guest-agent` **im Image** (Falle 3) +- Docker und Compose +- `/opt/nextcloud/docker-compose.yml` aus `assets/`, das + `NEXTCLOUD_ADMIN_USER`, `NEXTCLOUD_ADMIN_PASSWORD` und `MYSQL_PASSWORD` aus der + `.env` liest und sich beim ersten Start selbst installiert +- **`user: www-data` am `app`-Dienst** (Falle 1) +- Root-Partition **letzte auf der Platte, kein LVM** (Falle 2) +- als Proxmox-Vorlage konvertieren + +**Die Vorlage bleibt kundenunabhängig.** Passwörter werden nie in ein Image +gebacken — die Pipeline schreibt sie beim Klonen in die `.env` des Gastes. + +Prüfen: `qm config 9000` meldet `template: 1`. Der Handoff hält als eigenen Fund +fest, dass `VerifyVmTemplate` heute nur die **Existenz** prüft, nicht das Merkmal. + +- [ ] **Step 2: Auf echter Hardware prüfen — und einmal wirklich klonen.** Eine VM + aus 9000 erzeugen, starten, `occ status` als `www-data` aufrufen. **Ohne diesen + Klon ist die Vorlage nicht bewiesen**, und der Handoff nennt genau diesen Fall: + der erste bezahlte Auftrag starb sonst *nach* der Zahlung. +- [ ] **Step 3: Committen.** `Build the golden template so nobody has to remember how` + +--- + +### Task 9: `registered` — Token übergeben + +- [ ] **Step 1: Schreiben** + +Proxmox-Rolle und Token erzeugen — **`Sys.Modify` nicht vergessen**, siehe +`CreateAutomationToken.php`; ohne das stirbt jede Kundenbereitstellung am +Backup-Schritt. + +Dann `POST /host/register` über die Tunnel-Adresse mit Token, **frisch erzeugtem +WireGuard-Pubkey** (der aus der Befehlszeile wird damit wertlos), Fingerabdruck und +Eckdaten. Die Antwort trägt das dauerhafte Host-Token für Traefik. + +**Reihenfolge:** erst registrieren und die Antwort haben, dann den alten Schlüssel +verwerfen. Die Gegenseite nimmt erst auf und entfernt dann — aber das Skript darf +sich auch von seiner Seite nicht vorzeitig abschneiden. + +Zum Schluss: `pveum acl modify … || true` und `pveum user add … || true` +**nicht** übernehmen. Der Handoff nennt sie als offenen Punkt — sie verdecken +Fehler, dieselbe Klasse wie der bereits behobene `role add`. + +- [ ] **Step 2: Auf echter Hardware prüfen.** Der Host steht in der Konsole auf + `active`, die sechs Schritte der Kette laufen durch, und die Bereitschaftsseite + meldet `provisioning.usable_host` als erfüllt. +- [ ] **Step 3: Committen.** `Hand over the token and let the console take it from here` + +--- + +### Task 10: Das Runbook + +**Files:** `docs/runbooks/host-bootstrap.md` + +Was zu tun ist, wenn ein Abschnitt stehenbleibt — je Abschnitt: woran man es +merkt, wo man nachsieht, was der übliche Grund ist. Und ausdrücklich: **eine halb +installierte Maschine wird neu aufgesetzt, nicht nachgebessert.** Ein neuer Code +aus dem Adminbereich, Rettungssystem, noch einmal. + +- [ ] Schreiben, committen. Nachricht: `Write down what to do when the bootstrap stops` + +--- + +## Der ganze Durchlauf + +- [ ] Eine **frisch aufgesetzte** Maschine, Rettungssystem, eine Zeile einfügen, zusehen. +- [ ] Der Host steht am Ende auf `active`, ohne dass jemand etwas abgetippt hat. +- [ ] **Danach ein zweiter Lauf auf einer wieder frisch aufgesetzten Maschine.** Er + muss gleich ausgehen. Ein Skript, das nur einmal funktioniert hat, hat nicht + funktioniert. + +## Was danach offen bleibt + +- Der Plattform-Plan (`2026-07-30-host-uebernahme-plattform.md`), falls er noch läuft. +- Die Schritte 5 bis 8 der Reihenfolge aus dem Handoff: Testpaket kaufen, eigene + Domain, kündigen, widerrufen. Erst danach ist die Kette bewiesen. diff --git a/docs/superpowers/plans/2026-07-30-host-uebernahme-plattform.md b/docs/superpowers/plans/2026-07-30-host-uebernahme-plattform.md new file mode 100644 index 0000000..01ede90 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-host-uebernahme-plattform.md @@ -0,0 +1,707 @@ +# Host-Übernahme — Plattformseite, Umsetzungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** CluPilot nimmt einen fertig installierten Proxmox-Host in Betrieb, ohne ihn je zu installieren und ohne dauerhaften Shell-Zugang — der Host meldet sich selbst, holt seine Routen selbst und wird nur noch geprüft. + +**Architecture:** Beim Anlegen eines Hosts erzeugt die Konsole ein WireGuard-Schlüsselpaar und einen Einmal-Code und zeigt beides in einer kopierbaren Befehlszeile. Das Bootstrap-Skript (eigener Plan) tritt damit dem Tunnel bei und meldet sich über drei Endpunkte zurück, die **ausschließlich** aus dem WireGuard-Subnetz erreichbar sind. Traefik auf dem Host holt seine Routentabelle über denselben Weg ab, statt sie per SSH geschrieben zu bekommen. Die Host-Kette schrumpft von vierzehn auf sechs Schritte, die nur noch nachsehen. + +**Tech Stack:** PHP 8.3, Laravel 13, Livewire 3, Pest 4, MariaDB, Tailwind v3, Docker Compose. + +## Global Constraints + +- **Vollfassung des Entwurfs:** `docs/superpowers/specs/2026-07-30-host-uebernahme-statt-installation-design.md`. Bei Widerspruch gewinnt die Spec. +- **Die härteste Bedingung, aus §8a:** Es entsteht **kein einziger neuer Weg von außen** — weder auf den Host noch zu CluPilot. `register`, `progress` und `routes` liegen alle drei hinter der Tunnel-Beschränkung. Ein Task, der einen davon öffentlich erreichbar macht, ist gescheitert, auch wenn seine Tests grün sind. +- **Commit-Disziplin, nicht verhandelbar.** Weitere Sitzungen arbeiten im selben Repository. **Immer** `git add -- ` und `git commit -F - -- `. **Nie** `git add -A`, `git add .`, `git commit -a` oder ein nacktes `git commit`. +- `pint` nur auf eigene Pfade, **nie** `--dirty`, keine Kosmetik an fremden Dateien. +- **R18–R24 aus `CLAUDE.md`** gelten und werden per Test erzwungen. R22 besonders: eine Prüfrunde, eine Fix-Runde, dann parken. +- **Pest-Fallen, beide heute erlebt:** `toThrow(EinInterface::class)` beweist nichts — immer eine konkrete Klasse. Und `toContain($a, $b)` ist **variadisch**, der zweite Parameter ist kein Meldungstext; dasselbe gilt für `toHaveKey()`. +- **Testisolation:** die Suite läuft mit `CACHE_STORE=array`, der zwischen Testdateien nicht geleert wird. Ein Test, der von `Settings` abhängt, setzt den Wert selbst. +- **Betreiber-Rollen** heißen exakt `Owner`, `Admin`, `Support`, `Billing`, `Read-only`, `Developer`. Fabrik: `Operator::factory()->role('Owner')->create()`. Testpasswort `passwort-fuer-tests`. Guard-Name `'operator'` — bei `Livewire::actingAs($op, 'operator')` **nicht vergessen**. +- **Tests laufen nur im Container**, `docker compose` aus `/home/nexxo/clupilot`: + `docker compose exec -T -w /var/www/html/.worktrees/host-bootstrap app php artisan test` +- **Immer die volle Suite**, nie nur einen Filter. Eine Aufgabe hat heute mit einem Filter gearbeitet und einen Bruch übersehen, der erst zwei Aufgaben später auffiel. +- **Kein `migrate` gegen die Entwicklungsdatenbank.** Die Container bedienen ein anderes Arbeitsverzeichnis; eine Migration von hier aus beschädigt die laufende Installation. Beweise führen die Tests auf SQLite im Speicher. + +--- + +## Was schon da ist + +| | | +|---|---| +| `App\Services\Wireguard\WireguardHub` | `allocateIp()`, `addPeer($pubkey, $ip)`, `removePeer($pubkey)`, `endpoint()`, `publicKey()`, `peers()` | +| `config('admin_access.trusted_ranges')` | aus `TRUSTED_RANGES`, Vorgabe `10.66.0.0/24,127.0.0.1` | +| `App\Http\Middleware\RestrictConsoleNetwork` | **das Vorbild** für die Tunnel-Beschränkung — nicht neu erfinden | +| `App\Services\Secrets\SecretVault` | Tresor mit Modus-Plätzen; `stripe.secret` ist strikt | +| `App\Support\Readiness` | fünf Prüfgruppen, Spread-Array, Wächter-Test über die Registry | +| `hosts` | `uuid, name, dns_name, datacenter, public_ip, wg_ip, wg_pubkey, ssh_host_key, api_token_ref, total_gb, total_ram_mb, cpu_cores, pve_version, node, status, last_seen_at` | +| `SshTraefikWriter::write($trafficHost, $subdomain, array $hostnames, $backend)` | die abzulösende Stelle | +| `ConfigureDnsAndTls` | bildet `$hostnames = array_filter([$fqdn, $customDomain])` — beide Namen | + +--- + +### Task 1: Einmal-Code und Schlüsselpaar beim Anlegen + +**Files:** +- Create: `database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php` +- Create: `app/Support/HostEnrolment.php` +- Test: `tests/Feature/Host/HostEnrolmentTest.php` + +**Interfaces:** +- Consumes: `WireguardHub::allocateIp()`, `publicKey()`, `endpoint()`, `addPeer()`. +- Produces: `HostEnrolment::issue(Host $host): string` — legt Code, Schlüsselpaar und Peer an und gibt den **Klartext-Code** genau einmal zurück. `HostEnrolment::claim(string $code): ?Host` — löst einen gültigen, unverbrauchten Code auf und markiert ihn als verbraucht. Spalten auf `hosts`: `enrolment_code_hash`, `enrolment_expires_at`, `enrolment_used_at`. + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +create(); + + $code = HostEnrolment::issue($host); + + expect($code)->toHaveLength(32); + + $stored = DB::table('hosts')->where('id', $host->id)->value('enrolment_code_hash'); + expect($stored)->not->toBe($code) + ->and($stored)->not->toContain($code); +}); + +it('resolves a valid code to its host and consumes it', function () { + $host = Host::factory()->create(); + $code = HostEnrolment::issue($host); + + expect(HostEnrolment::claim($code)?->id)->toBe($host->id); + + // Verbraucht. Ein zweiter Lauf braucht einen neuen Code aus der Konsole — + // eine halb installierte Maschine wird neu aufgesetzt, nicht nachgebessert. + expect(HostEnrolment::claim($code))->toBeNull(); +}); + +it('refuses a code past its expiry', function () { + $host = Host::factory()->create(); + $code = HostEnrolment::issue($host); + + $this->travel(25)->hours(); + + expect(HostEnrolment::claim($code))->toBeNull(); +}); + +it('refuses a code that was never issued', function () { + expect(HostEnrolment::claim(str_repeat('a', 32)))->toBeNull(); +}); + +/** + * Der Hub muss den Peer kennen, BEVOR der Host im Tunnel ist — sonst käme er + * nie hinein und müsste seinen Schlüssel über einen öffentlichen Weg melden. + * Genau das vermeidet dieser Entwurf. + */ +it('allocates a tunnel address and admits the peer up front', function () { + $host = Host::factory()->create(['wg_ip' => null, 'wg_pubkey' => null]); + + HostEnrolment::issue($host); + + $host->refresh(); + expect($host->wg_ip)->not->toBeNull() + ->and($host->wg_pubkey)->not->toBeNull(); + + expect(app(\App\Services\Wireguard\WireguardHub::class)->peers()) + ->toContain($host->wg_pubkey); +}); +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Run: `cd /home/nexxo/clupilot && docker compose exec -T -w /var/www/html/.worktrees/host-bootstrap app php artisan test --filter=HostEnrolmentTest` +Expected: FAIL mit `Class "App\Support\HostEnrolment" not found` + +- [ ] **Step 3: Migration und Klasse schreiben** + +Die Migration legt drei Spalten an `hosts` an — Hash, Ablauf, Verbrauch. Der +private Schlüssel wird **nicht** gespeichert: er wandert einmal in die +Befehlszeile und ist nach dem Tausch (Task 3) wertlos. + +```php +public function issue(Host $host): string +{ + $hub = app(WireguardHub::class); + + // Das Paar entsteht HIER und nicht auf dem Host. Erzeugte der Host es + // selbst, müsste er seinen öffentlichen Schlüssel melden, bevor der Tunnel + // steht — und dafür bräuchte es einen öffentlichen Endpunkt. Der Preis ist + // ein Schlüssel, der Minuten lebt (Task 3 tauscht ihn), der Gewinn ist eine + // Netzgrenze, die unverändert bleibt. + [$privateKey, $publicKey] = self::keypair(); + $ip = $hub->allocateIp(); + $hub->addPeer($publicKey, $ip); + + $code = Str::random(32); + + $host->update([ + 'wg_ip' => $ip, + 'wg_pubkey' => $publicKey, + 'enrolment_code_hash' => hash('sha256', $code), + 'enrolment_expires_at' => now()->addDay(), + 'enrolment_used_at' => null, + ]); + + // Der private Schlüssel wird bewusst nicht abgelegt. Er geht in die + // Befehlszeile und sonst nirgendwohin. + self::$issuedPrivateKey = $privateKey; + + return $code; +} +``` + +`claim()` sucht über den Hash, prüft `enrolment_expires_at > now()` und +`enrolment_used_at === null`, setzt `enrolment_used_at` und gibt den Host zurück. + +**Hash statt `Hash::make`:** ein `sha256` über einen 32-Zeichen-Zufallswert reicht +hier und ist suchbar. Ein bcrypt-Hash wäre nicht in einer `where`-Bedingung +auflösbar, ohne alle Zeilen durchzuprobieren. + +- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen** + +Run: wie Step 2 +Expected: PASS, 5 Tests + +- [ ] **Step 5: Volle Suite** + +Run: `cd /home/nexxo/clupilot && docker compose exec -T -w /var/www/html/.worktrees/host-bootstrap app php artisan test` +Expected: PASS + +- [ ] **Step 6: Committen** + +```bash +git add -- database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php app/Support/HostEnrolment.php tests/Feature/Host/HostEnrolmentTest.php +git commit -F - -- database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php app/Support/HostEnrolment.php tests/Feature/Host/HostEnrolmentTest.php <<'EOF' +Let the console admit a host to the tunnel before it exists + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +### Task 2: Die Tunnel-Beschränkung — der wichtigste Test dieses Plans + +**Files:** +- Create: `app/Http/Middleware/RestrictTunnelOnly.php` +- Modify: `bootstrap/app.php` (Alias registrieren) +- Test: `tests/Feature/Host/TunnelOnlyTest.php` + +**Interfaces:** +- Consumes: `config('admin_access.trusted_ranges')`, das Muster von `RestrictConsoleNetwork`. +- Produces: Middleware-Alias `tunnel-only`. + +**Lies zuerst `app/Http/Middleware/RestrictConsoleNetwork.php`.** Es löst dieselbe +Frage bereits — CIDR-Vergleich gegen `trusted_ranges` — und dieser Task soll ihm +folgen, nicht eine zweite Formulierung danebenstellen. Wenn sich die Logik +herausziehen lässt, statt sie zu verdoppeln, tu das. + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +set('admin_access.trusted_ranges', ['10.66.0.0/24']); +}); + +dataset('host endpoints', [ + 'register' => ['post', '/host/register'], + 'progress' => ['post', '/host/progress'], + 'routes' => ['get', '/host/routes'], +]); + +it('answers 404 from outside the tunnel', function (string $method, string $path) { + // 404, nicht 403: eine Adresse, die es für Fremde nicht gibt, verrät auch + // nicht, dass es sie gibt. Dasselbe Muster wie RestrictAdminHost. + $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.9']) + ->call($method, $path) + ->assertNotFound(); +})->with('host endpoints'); + +it('is reachable from inside the tunnel', function (string $method, string $path) { + // Erreichbar heißt nicht erlaubt — ohne gültigen Ausweis folgt weiter unten + // eine Abweisung. Hier zählt nur, dass die Beschränkung nicht schon greift. + $this->withServerVariables(['REMOTE_ADDR' => '10.66.0.11']) + ->call($method, $path) + ->assertStatus(fn (int $status) => $status !== 404); +})->with('host endpoints'); +``` + +- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen** + +Expected: FAIL — die Routen gibt es noch nicht + +- [ ] **Step 3: Middleware und Routen-Gerüst schreiben** + +`RestrictTunnelOnly` prüft `$request->ip()` gegen `admin_access.trusted_ranges` +und wirft sonst `abort(404)`. Die drei Routen entstehen als Gerüst, das noch +nichts tut außer zu antworten — gefüllt werden sie in Task 3 bis 5. + +- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen** + +Expected: PASS, 6 Tests + +- [ ] **Step 5: Volle Suite, dann committen** + +Nachricht: `Make the host endpoints invisible from outside the tunnel` + +--- + +### Task 3: `POST /host/register` — Token in den Tresor, Schlüssel tauschen + +**Files:** +- Create: `app/Http/Controllers/HostRegistrationController.php` +- Modify: `app/Services/Secrets/SecretVault.php` (Registry-Eintrag je Host) +- Test: `tests/Feature/Host/HostRegistrationTest.php` + +**Interfaces:** +- Consumes: `HostEnrolment::claim()` (Task 1), Tunnel-Middleware (Task 2), `WireguardHub::addPeer/removePeer`. +- Produces: `hosts.api_token_ref` gefüllt, `hosts.wg_pubkey` auf den frischen Schlüssel, Antwort enthält das **dauerhafte Host-Token** für den Routen-Abruf. + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +set('admin_access.trusted_ranges', ['10.66.0.0/24']); + $this->host = Host::factory()->create(['status' => 'onboarding']); + $this->code = HostEnrolment::issue($this->host); +}); + +function registerAs(string $code, array $payload = []): \Illuminate\Testing\TestResponse +{ + return test()->withServerVariables(['REMOTE_ADDR' => '10.66.0.11']) + ->postJson('/host/register', array_merge([ + 'code' => $code, + 'api_token' => 'root@pam!clupilot=abc-123', + 'wg_pubkey' => 'FRESHKEYFRESHKEYFRESHKEYFRESHKEYFRESHKEY01=', + 'node' => 'pve-fsn-1', + 'pve_version' => '9.0-1', + 'total_gb' => 1000, + 'total_ram_mb' => 65536, + 'cpu_cores' => 16, + 'ssh_host_key' => 'SHA256:abc', + ], $payload)); +} + +it('puts the proxmox token in the vault and never in the response', function () { + $response = registerAs($this->code); + + $response->assertOk(); + expect($response->json())->not->toContain('root@pam!clupilot=abc-123'); + expect($this->host->fresh()->api_token_ref)->not->toBeNull(); +}); + +/** + * Die Reihenfolge ist die ganze Aussage: erst aufnehmen, dann entfernen. + * Andersherum schneidet sich der Host im selben Aufruf den Ast ab, auf dem er + * sitzt, und die Antwort erreicht ihn nie. + */ +it('admits the new key before dropping the old one', function () { + $old = $this->host->fresh()->wg_pubkey; + + registerAs($this->code)->assertOk(); + + $peers = app(WireguardHub::class)->peers(); + expect($peers)->toContain('FRESHKEYFRESHKEYFRESHKEYFRESHKEYFRESHKEY01=') + ->and($peers)->not->toContain($old); + expect($this->host->fresh()->wg_pubkey)->toBe('FRESHKEYFRESHKEYFRESHKEYFRESHKEYFRESHKEY01='); +}); + +it('refuses a code that belongs to another host', function () { + $other = Host::factory()->create(); + $otherCode = HostEnrolment::issue($other); + + registerAs($otherCode)->assertOk(); + + // Der fremde Host wurde eingerichtet, DIESER nicht. + expect($this->host->fresh()->api_token_ref)->toBeNull(); +}); + +it('refuses a spent code', function () { + registerAs($this->code)->assertOk(); + registerAs($this->code)->assertStatus(422); +}); + +it('hands back a durable host token for the route pull', function () { + expect(registerAs($this->code)->json('host_token'))->toBeString()->not->toBeEmpty(); +}); +``` + +- [ ] **Step 2 bis 6:** Fehlschlag bestätigen, schreiben, bestätigen, volle Suite, committen. + +**Beim Schreiben beachten:** Der Proxmox-Token gehört in den Tresor (verschlüsselt +mit `SECRETS_KEY`), nicht in eine Spalte. `hosts.api_token_ref` hält nur den +Verweis — so war es schon vor diesem Plan, und der Handoff nennt die Verknüpfung +mit `SECRETS_KEY` ausdrücklich als das teuerste Einzelstück. + +Nachricht: `Take the host's token through the tunnel and swap its key` + +--- + +### Task 4: `POST /host/progress` — mit Nachreichen + +**Files:** +- Create: `app/Http/Controllers/HostProgressController.php` +- Create: `database/migrations/2026_08_02_100000_record_what_a_host_reported.php` +- Test: `tests/Feature/Host/HostProgressTest.php` + +**Interfaces:** +- Produces: Tabelle `host_progress_events` (`host_id`, `section`, `state`, `message`, `occurred_at`), die Abschnittsliste aus §7 der Spec. + +Die Abschnitte, in dieser Reihenfolge und mit genau diesen Schlüsseln — sie sind +die **einzige Absprache** zwischen diesem Plan und dem Skript-Plan: + +`rescue_checked`, `debian_installed`, `rebooted`, `proxmox_installed`, +`network_bridged`, `wireguard_joined`, `traefik_running`, `template_built`, +`registered` + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +Der wichtigste Fall ist das **Nachreichen**: alles vor `wireguard_joined` kann der +Host nicht melden, weil es bis dahin keinen Weg zu CluPilot gibt. Er schreibt es +lokal mit und reicht es beim ersten erreichbaren Aufruf nach. + +```php +it('keeps the reported timestamps, not the arrival time', function () { + // Sonst sieht eine zwanzigminütige Installation in der Konsole aus wie eine + // Sekunde, und man kann nicht erkennen, welcher Abschnitt lange gedauert hat. + $reported = now()->subMinutes(18); + + postProgress($this->code, [ + ['section' => 'rescue_checked', 'state' => 'done', 'occurred_at' => $reported->toIso8601String()], + ['section' => 'debian_installed', 'state' => 'done', 'occurred_at' => $reported->addMinutes(6)->toIso8601String()], + ])->assertOk(); + + expect($this->host->progressEvents()->first()->occurred_at->toIso8601String()) + ->toBe($reported->copy()->subMinutes(6)->toIso8601String()); +}); + +it('refuses a section it does not know', function () { + // Ein Abschnittsname aus dem Netz. Ohne diese Prüfung füllt jemand die + // Fortschrittsanzeige mit erfundenen Zeilen. + postProgress($this->code, [['section' => 'erfunden', 'state' => 'done']]) + ->assertStatus(422); +}); +``` + +- [ ] **Steps 2 bis 6** wie gehabt. Nachricht: `Let the host tell the console how far it got` + +--- + +### Task 5: `GET /host/routes` — Traefik holt sich seine Tabelle + +**Files:** +- Create: `app/Http/Controllers/HostRoutesController.php` +- Create: `app/Services/Traefik/RouteTable.php` +- Test: `tests/Feature/Host/HostRoutesTest.php` + +**Interfaces:** +- Consumes: das dauerhafte Host-Token aus Task 3, `Instance` mit `subdomain`, `custom_domain`, `routed_hostnames`. +- Produces: `RouteTable::forHost(Host $host): array` — Traefiks dynamische Konfiguration für genau diesen Host. + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +it('serves only the instances of this host', function () { + // Ein entwendetes Token zeigt die Routen genau eines Hosts, nicht die aller. + $mine = Instance::factory()->create(['host_id' => $this->host->id, 'subdomain' => 'meine']); + $theirs = Instance::factory()->create(['subdomain' => 'fremde']); + + $body = json_encode(fetchRoutes($this->hostToken)->json()); + + expect($body)->toContain('meine')->not->toContain('fremde'); +}); + +it('serves both the subdomain and a proven custom domain', function () { + // Dazunehmen, nicht ersetzen — dieselbe Regel wie ConfigureDnsAndTls. + Instance::factory()->create([ + 'host_id' => $this->host->id, + 'subdomain' => 'mueller', + 'custom_domain' => 'cloud.mueller-gmbh.de', + 'domain_verified_at' => now(), + ]); + + $body = json_encode(fetchRoutes($this->hostToken)->json()); + + expect($body)->toContain('mueller.')->and($body)->toContain('cloud.mueller-gmbh.de'); +}); + +it('leaves out a custom domain that was never proven', function () { + Instance::factory()->create([ + 'host_id' => $this->host->id, + 'custom_domain' => 'nicht-bewiesen.de', + 'domain_verified_at' => null, + ]); + + expect(json_encode(fetchRoutes($this->hostToken)->json())) + ->not->toContain('nicht-bewiesen.de'); +}); +``` + +**Beim Schreiben:** Die Entrypoint- und certResolver-Namen müssen zu dem passen, +was `SshTraefikWriter::render()` heute ausgibt. Der Handoff warnt in Block C +ausdrücklich: *„hier zuerst nachlesen, nicht raten."* Das gilt unverändert. + +- [ ] **Steps 2 bis 6.** Nachricht: `Serve a host the routes it is supposed to answer for` + +--- + +### Task 6: `SshTraefikWriter` ablösen + +**Files:** +- Modify: `app/Services/Traefik/` (neuer `TraefikWriter`, der in die Datenbank schreibt) +- Delete: `app/Services/Traefik/SshTraefikWriter.php` +- Test: `tests/Feature/Host/NoShellForRoutesTest.php` + +**Interfaces:** +- Die Aufrufer (`ConfigureDnsAndTls`, `RunAcceptanceChecks`, `EndInstanceService`) ändern sich **nicht**. Sie rufen weiter `TraefikWriter::write(...)`; nur was dahinter passiert, ändert sich. + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +/** + * Der Beweis, dass die Plattform keinen Shell-Zugang mehr auf Kundenhosts + * braucht. Solange irgendwo eine RemoteShell für Routen aufgemacht wird, ist der + * Entwurf nicht eingelöst — egal wie grün alles andere ist. + */ +it('opens no shell when an address is configured', function () { + $s = fakeServices(); + $instance = Instance::factory()->create(['host_id' => $this->host->id]); + + app(ConfigureDnsAndTls::class)->execute(runFor($instance)); + + expect($s['shell']->connectionsWith('key'))->toBeEmpty(); +}); + +it('has no SshTraefikWriter left in the repository', function () { + expect(file_exists(base_path('app/Services/Traefik/SshTraefikWriter.php')))->toBeFalse(); +}); +``` + +- [ ] **Steps 2 bis 6.** Nachricht: `Stop reaching into customer hosts to write a route` + +--- + +### Task 7: Adminbereich — Befehlszeile und Fortschritt + +**Files:** +- Modify: `app/Livewire/Admin/HostCreate.php`, `HostDetail.php` und deren Blades +- Test: `tests/Feature/Admin/HostEnrolmentPageTest.php` + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +it('shows the command exactly once, right after creating the host', function () { + // Der Code steht nur als Hash in der Datenbank. Wer die Seite neu lädt, + // bekommt ihn nicht wieder — er legt einen neuen an. + $page = Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator') + ->test(HostCreate::class) + ->set('name', 'pve-fsn-2') + ->set('datacenter', 'fsn') + ->set('publicIp', '198.51.45.9') + ->call('create'); + + $page->assertSee('curl'); + + Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator') + ->test(HostDetail::class, ['uuid' => Host::latest('id')->first()->uuid]) + ->assertDontSee('curl'); +}); + +it('shows which section the host is stuck in', function () { + // „Irgendwas ging schief" ist keine Auskunft. Welcher Abschnitt offen ist, + // ist eine. + $this->host->progressEvents()->create([ + 'section' => 'proxmox_installed', 'state' => 'done', 'occurred_at' => now()->subMinutes(30), + ]); + + Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator') + ->test(HostDetail::class, ['uuid' => $this->host->uuid]) + ->assertSee(__('hosts.section.network_bridged')); +}); +``` + +**R18, R19, R23, R24 gelten** — Blades werden angefasst. Die vier Wächter-Tests am +Ende ausdrücklich mitlaufen lassen. Zeiten der Fortschrittsmeldungen gehen durch +`->local()`. + +- [ ] **Steps 2 bis 6.** Nachricht: `Give the operator one line to copy and a progress to watch` + +--- + +### Task 8: Die Subdomain gehört dem Kunden + +**Files:** +- Modify: `app/Livewire/Order.php` bzw. die Kasse, `app/Provisioning/Steps/Customer/ReserveResources.php` +- Create: `app/Rules/AvailableSubdomain.php` +- Test: `tests/Feature/SubdomainChoiceTest.php` + +**Interfaces:** +- Heute: `ReserveResources::uniqueSubdomain()` bildet `Str::slug($customer->name).'-'.Str::random(5)`. Künftig kommt die Subdomain **aus dem Auftrag**; die Ableitung bleibt nur als Rückfall für einen leeren Wunsch. + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +it('refuses a name the platform needs itself', function (string $name) { + expect(validator(['subdomain' => $name], ['subdomain' => new AvailableSubdomain])->passes()) + ->toBeFalse(); +})->with(['www', 'mail', 'admin', 'api', 'app', 'ws', 'status', 'vpn']); + +it('refuses a name another customer already has, whatever the case', function () { + Instance::factory()->create(['subdomain' => 'mueller']); + + expect(validator(['subdomain' => 'MUELLER'], ['subdomain' => new AvailableSubdomain])->passes()) + ->toBeFalse(); +}); + +it('falls back to a neutral name when the field is left empty', function () { + // Die Vorgabe darf NICHT der Nachname sein: jede Subdomain landet über das + // Zertifikat dauerhaft in den öffentlichen Certificate-Transparency-Logs. + $order = orderWithoutSubdomainWish(customerNamed('Anna Müller')); + + $subdomain = app(ReserveResources::class)->execute(runFor($order))->context('subdomain'); + + expect($subdomain)->not->toContain('mueller'); +}); + +it('keeps the name the customer chose', function () { + $order = orderWishing('meine-wolke'); + + expect(app(ReserveResources::class)->execute(runFor($order))->context('subdomain')) + ->toBe('meine-wolke'); +}); +``` + +- [ ] **Steps 2 bis 6.** Der Hinweis neben dem Feld muss sagen, dass die Adresse + öffentlich und dauerhaft ist. Nachricht: `Let the customer pick the address they will keep` + +--- + +### Task 9: Die Kette schrumpfen + +**Files:** +- Modify: `config/provisioning.php` +- Delete: `app/Provisioning/Steps/Host/{EstablishSshTrust,PrepareBaseSystem,ConfigureWireguard,InstallProxmoxVe,RebootIntoPveKernel,ConfigureProxmox,CreateAutomationToken}.php` und ihre Tests +- Modify: `app/Provisioning/Steps/Host/SecureHostFirewall.php` — vom Schreiben zum Prüfen +- Test: `tests/Feature/Provisioning/HostPipelineShapeTest.php` + +> **HALT — die einzige echte Kopplung zwischen den zwei Plänen.** +> +> Diese sieben Dateien sind die **Quelle** für den Skript-Plan. Sie enthalten +> Wissen, das aus echten Ausfällen entstanden ist: die Codename-Tabelle für +> Debian 13/PVE 9, die vollständige `vmbr0`-Diagnose, die nftables-Regeln samt +> der ICMP-Korrektur, die systemd-Aktivierung von `wg0`, und die Proxmox-Rolle +> **inklusive `Sys.Modify`**, ohne das jede Kundenbereitstellung am +> Backup-Schritt stirbt. +> +> **Führe diesen Task erst aus, wenn Task 4 bis 9 des Skript-Plans geschrieben +> sind** — oder vergewissere dich, dass der Skript-Strang sie gelesen hat. Aus +> `git log` sind sie zwar wiederherstellbar, aber niemand liest die Historie, +> wenn er nicht weiß, dass dort etwas fehlt. Genau so gehen Erkenntnisse +> verloren, die einmal einen Ausfall gekostet haben. + +- [ ] **Step 1: Den fehlschlagenden Test schreiben** + +```php +it('has six steps that only look, never change', function () { + expect(config('provisioning.pipelines.host'))->toBe([ + Host\ValidateHostInput::class, + Host\VerifyProxmoxApi::class, + Host\VerifyVmTemplate::class, + Host\RegisterHostDns::class, + Host\RegisterCapacity::class, + Host\CompleteHostOnboarding::class, + ]); +}); + +it('opens no shell anywhere in the host pipeline', function () { + // Der Beweis für §1 der Spec: der einzige dauerhafte Zugang ist der + // Proxmox-Token. + $s = fakeServices(); + + foreach (config('provisioning.pipelines.host') as $step) { + app($step)->execute($this->run); + } + + expect($s['shell']->connectionsWith('key'))->toBeEmpty() + ->and($s['shell']->connectionsWith('password'))->toBeEmpty(); +}); +``` + +**Achtung:** `SecureHostFirewall` bleibt, wird aber zur Prüfung. Ob es dafür noch +in der Kette steht oder in die Bereitschaftsprüfungen wandert, entscheidet der +Implementierer — begründet im Bericht. + +- [ ] **Steps 2 bis 6.** Nachricht: `Shrink the host pipeline to six steps that only look` + +--- + +### Task 10: Bereitschaftsprüfungen nachziehen + +**Files:** +- Modify: `app/Support/Readiness/OnboardingChecks.php`, `ProvisioningChecks.php` +- Modify: `lang/de/readiness.php`, `lang/en/readiness.php` +- Test: `tests/Feature/Readiness/OnboardingChecksTest.php`, `ProvisioningChecksTest.php` + +Zu ändern, aus §10 der Spec: + +- `onboarding.ssh_private_key` — **nicht mehr blockierend.** Die Plattform braucht + keinen SSH-Schlüssel mehr. Der Tresor-Eintrag bleibt (Notfallzugang), sein + Fehlen hält nichts auf. +- `provisioning.traefik_path` — **entfällt.** Es gibt kein Verzeichnis mehr, in das + CluPilot schreibt. +- **Neu:** mindestens ein Host holt seine Routen tatsächlich ab (letzter Abruf + jünger als das Intervall). Ein Host, der das nicht mehr tut, serviert + stillschweigend einen veralteten Stand. + +**Der Wächter-Test aus dem Betriebsmodus-Vorhaben erzwingt, dass jeder +Tresor-Eintrag auf der Bereitschaftsseite auftaucht.** Wer hier einen Eintrag +entfernt oder umbenennt, zieht ihn nach. + +- [ ] **Steps 1 bis 6.** Nachricht: `Teach the readiness page that nobody writes to a host any more` + +--- + +## Abschluss + +- [ ] Volle Suite grün. +- [ ] `VERSION` **nicht** anheben, **kein** Tag. Das passiert erst, wenn auch der Skript-Plan durch ist und beide auf echter Hardware zusammen liefen. +- [ ] Branch pushen: + +```bash +git push "https://x-access-token:$TOKEN@git.bave.dev/boban/CluPilotCloud.git" feature/host-bootstrap +``` + +## Was danach offen bleibt + +- **Der Skript-Plan** (`2026-07-30-host-uebernahme-bootstrap-skript.md`) — läuft parallel, trifft sich mit diesem nur an den Abschnittsschlüsseln aus Task 4. +- **Die Abnahme auf echter Hardware.** Sie ist die einzige, die zählt. +- Die zwei Seed-Hosts aus `DatabaseSeeder` entfernen. +- Nextcloud zurücksetzen und die Störungsmeldung — beschlossen, eigene Entwürfe, §14 der Spec.