CluPilotCloud/docs/superpowers/plans/2026-07-30-host-uebernahme-...

29 KiB

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.phpder 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.phprender() 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:

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.

  • Step 1: Das Gerüst schreiben

#!/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

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

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

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

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

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