# 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/RebootIntoPveKernel.php` — **der Neustart ist das
    einzige Unumkehrbare am ganzen Vorgang.** Die Datei beweist den Bootpfad,
    *bevor* sie neu startet, statt ihn aus „apt hat nicht gemeckert" zu
    schließen: `/boot/vmlinuz-*-pve` vorhanden, `/boot/initrd.img-*-pve`
    vorhanden (ein volles `/boot` hinterlässt genau die halbe Initramfs), und
    `update-grub` **an seinem Exit-Status** geprüft — den der Vorgänger verwarf.
    Alle drei scheitern hart statt zu wiederholen, weil keine sich von selbst
    behebt. Dazu die 15-Minuten-Frist, die ihre eigenen Marker löscht, damit ein
    Wiederholungslauf frisch neu startet statt sofort wieder in die abgelaufene
    Frist zu laufen. **Diese Datei stand nicht in der ersten Fassung dieser
    Liste, steht aber auf der Löschliste des Plattform-Plans.**
  - `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 -- <pfade>`, `git commit -F - -- <pfade>`. 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` | Die Installation (`debian_installed`, `rebooted`) und die Paketquellen (`proxmox_installed`) |
| `deploy/bootstrap/lib/network.sh` | Brücke mit Selbstrücknahme, nftables |
| `deploy/bootstrap/lib/traefik.sh` | Traefik: Binary, statische Konfiguration, Unit |
| `deploy/bootstrap/lib/template.sh` | Die goldene Vorlage |
| `deploy/bootstrap/lib/register.sh` | Rolle, Token, Registrierung, Schlüsseltausch |
| `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.

**Wie es auf die Maschine kommt** (stand in keiner der beiden Fassungen, ist
aber entschieden): als **Archiv**. Die kopierte Zeile aus dem Adminbereich holt
`bootstrap.tar.gz`, packt es nach `/opt/clupilot/bootstrap` aus und startet von
dort — ungefähr so:

```sh
curl -fsSL https://app.clupilot.com/bootstrap.tar.gz | tar xz -C /opt/clupilot \
  && /opt/clupilot/bootstrap/clupilot-bootstrap.sh --code … --wg-private … …
```

Ein `curl … | sh` schied aus: es kann keine `lib/` haben, und die getrennten
Dateien oben sind kein Schmuck. Das Archiv ist eine **statische Datei**, kein
Endpunkt, der Auskunft gibt — Spec §5 bleibt damit unangetastet. Das Ausliefern
selbst gehört zum Plattform-Plan (Task 7 zeigt die Zeile); dieser Plan setzt nur
voraus, dass das Skript samt `lib/` unter `/opt/clupilot/bootstrap` liegt.

---

### Task 1: Gerüst, Melden und Nachreichen

**Files:**
- Create: `deploy/bootstrap/clupilot-bootstrap.sh`, `deploy/bootstrap/lib/report.sh`

**Interfaces:**
- Produces: `report <section> <state> [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`.

- [x] **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.

- [x] **Step 3: Committen.** Nachricht: `Give the bootstrap a spine and a way to report`

---

### Task 2: `rescue_checked` — vor allem anderen nachsehen

- [x] **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.
- [x] **Step 3: Committen.** `Refuse a machine that cannot do the job`

---

### Task 3: `debian_installed` und `rebooted`

- [x] **Step 1: Schreiben**

> **Korrigiert gegenüber der ersten Fassung.** Dort stand „Debian 13 ins
> Zielsystem", und danach hätte Task 4 daraus einen Proxmox-Host gemacht. Der
> Betreiber hat das bestritten, und er hat recht: aus dem Rettungssystem wird
> **Proxmox direkt** installiert. Es gibt kein Debian-Zwischensystem, das
> nachher jemand umbaut.
>
> **Die Abschnittsschlüssel bleiben trotzdem unverändert** — sie sind die
> einzige Absprache mit dem Plattform-Plan und werden nicht wegen einer
> Umbenennung angefasst. Sie passen auch weiterhin, denn Proxmox VE *ist*
> Debian mit PVE-Kernel und -Paketen:
>
> - `debian_installed` = **Grundsystem geschrieben** (das Abbild liegt auf der
>   Platte). Die Beschriftung in der Konsole heißt entsprechend
>   „Grundsystem geschrieben", nicht „Debian installiert" — das ist Sache von
>   Task 7 des Plattform-Plans (`hosts.section.*`).
> - `proxmox_installed` = **PVE läuft nachweislich** (Task 4).

> **Und ein zweites Mal korrigiert, an der Mechanik.** Hier stand kurzzeitig
> `installimage` mit einem Proxmox-Abbild. **Das gibt es nicht:** Hetzners
> `installimage` führt kein Proxmox, es gilt dort als nicht unterstützt. Wer
> danach sucht, findet nur Debian — und landet wieder beim Zwischensystem, das
> gerade abgeschafft wurde.

Der Weg ist die **offizielle Proxmox-ISO mit eingebetteter Antwortdatei**, seit
PVE 8.2 der reguläre Weg für unbeaufsichtigte Installationen und von Hetzner
selbst als Anleitung geführt:

1. **Bootmodus feststellen** (Legacy oder UEFI) — davon hängt der QEMU-Aufruf ab.
2. `answer.toml` schreiben: `[global]` mit `fqdn`, `country`, `timezone`,
   `root-password-hashed` und `root-ssh-keys`; `[network]` mit `source`;
   `[disk-setup]` mit dem Dateisystem und der Plattenwahl.
3. **`proxmox-auto-install-assistant prepare-iso`** backt die Antwortdatei in
   die ISO. Vorher `validate-answer` — eine ISO, die wegen eines Tippfehlers in
   den interaktiven Modus fällt, hängt danach unsichtbar in QEMU.
4. Die ISO **unter QEMU** laufen lassen, die gegen die **echten Platten**
   schreibt. Kein VNC, kein Klick: der Eintrag „Automated Installation" wird
   nach zehn Sekunden von selbst gewählt.
5. Neustart aus dem Rettungssystem heraus.

**Dateisystem: ZFS** (`filesystem = "zfs"`, `zfs.raid` passend zur Plattenzahl).
Damit ist auch kein LVM im Spiel. `arc-max` gehört ausdrücklich in die
Antwortdatei — die Vorgabe nimmt sich die Hälfte des Arbeitsspeichers, und den
braucht ein Host, der Kunden trägt, für die Gäste.

> **Falle 2 gilt dem Gast, nicht dem Host — hier stand es falsch.** Die frühere
> Begründung („gilt für den Host genauso, weil die Vorlage später von hier aus
> gebaut wird") trägt nicht: `GrowGuestFilesystem` wächst ein *Gast*-Dateisystem,
> und die Vorlage entsteht in **Task 8** aus einem Debian-Cloud-Image, nicht aus
> dem Plattenlayout des Hosts. **Für die Vorlage bleibt Falle 2 unverändert
> verbindlich.** Für den Host ist sie gegenstandslos; dass ZFS ohnehin ohne LVM
> auskommt, ist ein Nebeneffekt und nicht der Grund.

**Anbieter:** heute nur Hetzner-dediziert. Ein zweiter Weg (netcup) wird erst
geschrieben, wenn eine Maschine dasteht, auf der er abgenommen werden kann — ein
ungeprüfter Rückfallpfad ist genau die Behauptung, die dieser Plan sonst überall
vermeidet (R22). Der ISO-Weg ist dabei die bessere Ausgangslage als
`installimage`: er hängt an nichts, was nur einem Anbieter gehört.

**Was der Installer verlangt und die Befehlszeile nicht mitbringt.** Die
Antwortdatei braucht `fqdn` und ein Root-Kennwort. Keins von beidem steht in den
sechs Angaben aus Task 1. Entschieden, bis jemand widerspricht:

- **`fqdn`** wird aus dem Hostnamen gebildet, den CluPilot ohnehin kennt. Bis
  der Adminbereich ihn mitgibt, wird er aus der Rückwärtsauflösung der
  öffentlichen IP genommen und, wenn auch die nichts hergibt, aus `--wg-ip`
  abgeleitet. Der Name wird in Task 9 ohnehin gegen das ersetzt, was CluPilot
  führt.
- **Das Root-Kennwort wird zufällig erzeugt und weggeworfen.** Niemand braucht
  es: die Plattform meldet sich nicht mehr per SSH an (Spec §6), und der Plan
  sagt für den Fehlerfall ausdrücklich, dass eine halb installierte Maschine
  **neu aufgesetzt und nicht nachgebessert** wird (Task 10). Ein Kennwort
  aufzubewahren, das nur im Notfall gebraucht würde, den es laut Plan nicht
  gibt, schafft nur eine weitere Stelle, an der ein Geheimnis liegt.
- **Optional `--root-ssh-key`.** Gibt der Adminbereich einen öffentlichen
  Schlüssel mit, landet er als `root-ssh-keys` in der Antwortdatei. Ohne die
  Angabe gibt es keinen Weg per SSH auf die Maschine, und das ist die Vorgabe.

Der Neustart ist die erste Stelle, an der das Skript die Kontrolle verliert.
**Der Wiederanlauf ist kein handgeschriebener systemd-Dienst mehr** — die
Antwortdatei hat dafür einen `[first-boot]`-Abschnitt. Mit
`source = "from-iso"` liegt das Skript schon in der ISO und startet nach dem
Hochfahren von selbst; das ist auch der einzige Weg, der vor dem Tunnel
funktioniert, weil ein `from-url`-Hook CluPilot noch nicht erreichen könnte.

**Und genau das ist der Punkt, an dem „dieselbe Fortschrittsdatei" Arbeit ist und
keine Feststellung.** Das Rettungssystem läuft im Arbeitsspeicher; sein
Wurzelverzeichnis ist nach dem Neustart weg. Bevor neu gestartet wird, gehen
deshalb **in das frisch installierte System hinüber**:

- `/var/lib/clupilot/progress.jsonl` — sonst gehen `rescue_checked` und
  `debian_installed` verloren.
- das Skript selbst samt `lib/`,
- die Aufrufargumente aus Task 1, in einer Datei, die nur `root` lesen kann —
  der WireGuard-Schlüssel steht darin.

Ohne diesen Handgriff meldet das Skript nach dem Neustart bei Null. Und weil
`flush_reports` erst in Task 6 läuft, **also nach dem Neustart**, fiele der
Verlust nirgends auf — die zwei Abschnitte kämen in der Konsole schlicht nie an.
Genau das prüft Task 6 Step 2, wenn er **alle fünf** vorherigen Abschnitte
verlangt.

Zum Neustart selbst: **lies `RebootIntoPveKernel.php`.** Der Bootpfad wird
bewiesen, bevor neu gestartet wird, nicht danach gehofft.

- [ ] **Step 2: Auf echter Hardware prüfen** — und ausdrücklich, dass es nach dem
  Neustart von allein weiterläuft. **Dazu: nach dem Hochfahren in
  `/var/lib/clupilot/progress.jsonl` nachsehen, dass die Zeilen von *vor* dem
  Neustart noch dort stehen, mit ihren ursprünglichen Zeitstempeln.**
- [x] **Step 3: Committen.** `Write the image and survive the first reboot`
  (war `Write Debian and survive the first reboot`, bevor klar war, dass kein
  Debian-Zwischensystem entsteht)

---

### Task 4: `proxmox_installed`

- [x] **Step 1: Schreiben**

> **Korrigiert mit Task 3.** PVE kommt jetzt schon mit dem Abbild. Dieser
> Abschnitt **installiert** es also nicht mehr, er **nimmt es ab und bringt
> seine Paketquellen in Ordnung** — und genau dafür wird dasselbe Wissen
> gebraucht, nur an anderer Stelle.

**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. Vier Dinge stehen dort, die hier alle gebraucht
werden:

1. **Die Zuordnung Codename → Suite und Release-Schlüssel.** Ein Abbild, dessen
   PVE-Suite nicht zu seinem Debian passt, ist ein Fund und kein Schönheitsfehler
   — PVE gegen eine andere libc gebaut. Passt es nicht, **abbrechen**.
2. **Ein unbekannter Unterbau bricht ab.** Nicht „trixie ist das neueste, also
   trixie". Das nächste Debian ist auch unbekannt, und dann sieht niemand hin.
3. **Die Enterprise-Quelle muss weg.** Ein Proxmox-Abbild bringt
   `/etc/apt/sources.list.d/pve-enterprise.list` mit, und die scheitert ohne
   Abonnement bei **jedem** `apt-get update`. Das ist keine Schönheitskorrektur:
   danach schlägt jede Paketinstallation der folgenden Abschnitte fehl.
4. **`Signed-By` auf die Proxmox-Quelle begrenzen.** Sonst bürgt der
   Proxmox-Schlüssel für alles, was später in `sources.list` auftaucht. Deshalb
   die `.sources`-Datei (deb822) und nicht die alte `.list`-Zeile — und die alte
   dabei entfernen, weil eine stehengebliebene `bookworm`-Zeile auf einer
   trixie-Maschine genau die Vermischung ist, die Punkt 1 verbietet.

- [ ] **Step 2: Auf echter Hardware prüfen.** `pveversion` antwortet, der PVE-Kernel
  läuft (`uname -r` enthält `-pve`), und **`apt-get update` läuft ohne Fehler
  durch** — das ist der Beweis, dass die Enterprise-Quelle wirklich weg ist und
  nicht nur überschrieben wurde.
- [x] **Step 3: Committen.** `Point Proxmox at the sources this Debian actually has`
  (war `Install Proxmox …`, bevor das Abbild PVE schon mitbrachte)

---

### Task 5: `network_bridged` — der gefährlichste Abschnitt

- [x] **Step 1: Schreiben**

**Lies zuerst `ConfigureProxmox.php`.** Dort steht, warum Proxmox auf Debian keine
`vmbr0` anlegt und was eine falsche Brücke anrichtet.

> **Zwei Korrekturen aus der Umsetzung.**
>
> **Die Brücke ist im Regelfall schon da.** Genau die Stelle, die man hier liest,
> sagt es: Proxmox *auf Debian* legt keine `vmbr0` an — „only the ISO installer
> writes that bridge into `/etc/network/interfaces`". Dieser Entwurf installiert
> über die ISO. Der Abschnitt **prüft** deshalb (existiert sie, hat sie eine
> Adresse, trägt sie die Vorgaberoute) und baut nur im Ausnahmefall — dann unter
> der Selbstrücknahme. Dieselbe Datei begründet ausführlich, warum sie eine
> Brücke über die primäre Netzkarte NICHT aus der Ferne baut.
>
> **Die nftables-Sperre gehört nicht hierher.** `SecureHostFirewall.php` lief im
> alten Ablauf ZULETZT, ausdrücklich damit der Tunnel schon trägt, „before
> SSH-to-the-world is ever closed". An dieser Stelle stünde sie **vor**
> `wireguard_joined` und schlösse Port 22, bevor ein Handshake bewiesen ist —
> die Reihenfolge, die einen Host dauerhaft unerreichbar macht. Sie steht
> deshalb in **Task 9**. Die Selbstrücknahme bleibt hier und gilt der
> Netzumstellung; die Firewall behält ihre manuelle Notöffnung, denn „a firewall
> that reopens itself under failure is not a firewall".
>
> **Zu Punkt 4 unten, „von außen prüfen":** das geht vor dem Tunnelbeitritt
> nicht, es gibt keinen Gegenüber. Geprüft wird von innen nach außen (Gateway
> und ein Ziel dahinter), und das ist im Skript auch so benannt. Was es nicht
> abdeckt, deckt der Zeitgeber ab — deshalb steht er da.

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.**
- [x] **Step 3: Committen.** `Bridge the network, and take it back if the host goes quiet`

---

### Task 6: `wireguard_joined` — ab hier ist CluPilot erreichbar

- [x] **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.
- [x] **Step 3: Committen.** `Join the tunnel, then tell the console everything so far`

---

### Task 7: `traefik_running`

- [x] **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.

> **Eine Abhängigkeit, die dieser Plan nicht aufgelöst hatte.** Der
> `http`-Provider braucht den **dauerhaften Host-Token**, und den gibt es erst
> mit `POST /host/register` — also in Abschnitt `registered`, zwei Abschnitte
> später. Der Beweis „die erste Tabelle wurde geholt" lässt sich hier deshalb
> nicht führen.
>
> Aufgelöst, ohne die Abschnittsschlüssel anzufassen: die statische
> Konfiguration wird **zweimal** geschrieben. Hier mit leerem Token — der Dienst
> steht, hält 80 und 443 und antwortet am Ping —, in Task 9 noch einmal mit dem
> echten, gefolgt von einem Neustart und dem Abruf. **Der Beweis entfällt nicht,
> er wandert an die Stelle, an der er zu führen ist.**
>
> Der Abruf wird dort direkt gemacht, mit demselben Token und derselben Adresse
> aus der Konfiguration — nicht über Traefiks eigene Router. Ein frischer Host
> hat noch keine Kunden, seine Tabelle ist also mit Recht leer, und „null
> Router" hieße sowohl „alles in Ordnung" als auch „nichts abgerufen".

- [ ] **Step 2: Auf echter Hardware prüfen**, einschließlich eines abgerufenen
  Routensatzes.
- [x] **Step 3: Committen.** `Run Traefik as a service that fetches its own routes`

---

### Task 8: `template_built` — die goldene Vorlage

- [x] **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.
- [x] **Step 3: Committen.** `Build the golden template so nobody has to remember how`

---

### Task 9: `registered` — Token übergeben

- [x] **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.

**Und der Tausch braucht denselben Beweis wie der Beitritt.** Task 6 lässt den
Tunnel erst mit bewiesenem Handshake gelten; hier wird genau dieser Tunnel unter
laufendem Betrieb auf einen neuen Schlüssel umgestellt. Die Reihenfolge allein
schützt davor nicht — sie sagt nur, wann verworfen wird, nicht ob das Neue
trägt. Deshalb, in dieser Folge:

1. neues Schlüsselpaar erzeugen, registrieren, Antwort haben,
2. `wg0` auf den neuen privaten Schlüssel umstellen,
3. **erneuten Handshake beweisen** — dieselbe Prüfung wie in Task 6, nicht die
   geschriebene Datei,
4. **erst dann** den alten Schlüssel verwerfen.

Kommt der Handshake nicht, wird auf den alten Schlüssel zurückgestellt und der
Abschnitt scheitert laut. Ein Host, der sich im letzten Schritt selbst
aussperrt, ist der eine Fall, den niemand aus der Ferne repariert — und er
passiert am Ende eines Laufs, der bis dahin alles richtig gemacht hat.

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

Das ist aber **keine Regel gegen `|| true` an sich**, und wer sie so liest,
bricht Task 3. `RebootIntoPveKernel.php` toleriert
`apt-get -y remove linux-image-amd64 || true` **mit Begründung**: ein Image, das
das Metapaket nie hatte, lässt apt ohne Fehler in der Sache von Null verschieden
enden — und ob der Debian-Kernel weg ist, ist ohnehin nicht die Frage, sondern
ob ein Proxmox-Kernel bootet, was drei Zeilen später eigenständig geprüft wird.
Die Regel lautet: **kein `|| true` über einem Befehl, dessen Fehlschlag etwas
bedeutet** — und wo eines steht, steht der Grund daneben.

- [ ] **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. **Und der Tunnel steht danach
  noch** — nach dem Schlüsseltausch einmal `wg show` ansehen und einen frischen
  Handshake mit dem *neuen* Schlüssel sehen, nicht den alten Zählerstand.
- [x] **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.

- [x] 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.
