# vmbr0 automatisch bauen **Datum:** 1. August 2026 **Zustand:** Entwurf abgenommen, Umsetzung folgt **Betrifft:** Subsystem A (Host-Übernahme), letzter Punkt vor dem Abnahmeziel --- ## Das Problem Proxmox auf Debian installiert legt **keine `vmbr0` an** — nur der ISO-Installer schreibt sie in `/etc/network/interfaces`. `BuildVmTemplate` legt die Vorlage mit `--net0 virtio,bridge=vmbr0` an, und jeder Klon erbt das. Ein Host ohne Brücke bedient also keinen einzigen Kunden. Heute lehnt `ConfigureProxmox::refuseWithoutBridge()` genau hier ab und schickt den Betreiber an die Tastatur. Das war richtig, solange niemand den Handgriff sicher automatisieren konnte. Er ist inzwischen einmal von Hand gemacht worden (`pve-fns-1`, 1. August 2026) — mit derselben Rückfahrkarte, die hier beschrieben wird. Danach ist das Abnahmeziel erreichbar: frische Debian-13-Maschine, Host anlegen, zusehen. Ohne einen einzigen Handgriff. --- ## Die drei festen Entscheidungen ### 1. Derselbe Mechanismus wie `BuildVmTemplate` — Datei fahren, nicht nachbauen Die Shell-Bibliothek wird auf den Host geladen und dort gefahren. Kein PHP-Nachbau der Netzlogik. Zwei Fassungen hießen zwei Installationen, die bei jeder Proxmox-Version nachgezogen werden müssten — und die zweite fiele erst auf, wenn jemand sie benutzt. Der Weg ist gebahnt: `template.sh` + `template-run.sh` + `env`, hochgeladen, abgekoppelt gestartet, per Statusdatei und PID abgefragt. ### 2. Aus dem laufenden Zustand ableiten, nicht aus der Datei des Anbieters `ip -4 addr`, `ip -4 route`, `/sys/class/net/…` — nicht das, was Hetzner oder netcup in `/etc/network/interfaces` geschrieben haben. Was läuft, ist überall gleich strukturiert; wie es aufgeschrieben wurde, nicht. Das ist der Teil, der mehrere Anbieter zugleich trägt. ### 3. Die Rückfahrkarte ist Bedingung, nicht Beiwerk Der Schritt sägt an dem Ast, auf dem er sitzt: SSH läuft zu diesem Zeitpunkt über den Tunnel, und WireGuard hängt an derselben Netzwerkkarte, die in die Brücke wandert. Eine falsche Brücke nimmt beides mit. Also: **erst den Zeitgeber für die Rücknahme scharf stellen, dann umstellen, dann neu verbinden, nachsehen, abbestellen.** Kommt die Verbindung nicht zurück, holt der Zeitgeber den alten Zustand, und der Schritt landet in einer Wiederholung statt auf einem toten Server. --- ## Was gebaut wird ### `deploy/bootstrap/lib/bridge.sh` (neu) Die Brückenhälfte aus `network.sh` (heute Zeilen 47–254), herausgelöst und **allein lauffähig**. Sie borgt sich heute vier Dinge von woanders, und die wandern mit hinein: | geborgt | steht heute in | |---|---| | `detect_primary_interface` | `lib/proxmox.sh:132` | | `log` | `clupilot-bootstrap.sh` | | `http_get` | `clupilot-bootstrap.sh` | | `CLUPILOT_PROBE_URL` | `clupilot-bootstrap.sh:273` | `network.sh` sourced `bridge.sh` künftig und behält seine WireGuard-/nftables- Hälfte. **Eine Fassung, zwei Benutzer** — der stillgelegte Rettungssystem-Weg und der Debian-Weg. Warum nicht `network.sh` im Ganzen fahren: die zweite Hälfte derselben Datei enthält WireGuard-, nftables- und Datacenter-Firewall-Funktionen, die als PHP-Schritte längst existieren (`ConfigureWireguard`, `SecureHostFirewall`, `ConfigureProxmox`). Sie auf den Host zu legen heißt, einem künftigen Leser zwei Fassungen derselben Sache anzubieten — genau der Fehler, den die Wege-Entscheidung vermeidet. ### `deploy/bootstrap/lib/bridge-run.sh` (neu) Der Treiber, gebaut nach dem Muster von `template-run.sh`: ``` /var/lib/clupilot/bridge/ state running | ok | failed pid PID = Prozessgruppe (setsid) phase laufende Phase, für die Konsolenzeile note Grund im Fehlerfall bridge.log alles env von CluPilot geschrieben ``` PID als **allererstes**, `trap on_exit`, und die Regel: „läuft noch" heißt, der Prozess lebt (`kill -0`), nie: in der Statusdatei steht `running`. ### `App\Provisioning\Steps\Host\EnsureNetworkBridge` (neu) Eingehängt zwischen `RebootIntoPveKernel` und `ConfigureProxmox`. **Nach dem Neustart**, weil dort `ifupdown2` steht (`PrepareBaseSystem` installiert es) und der Tunnel gerade bewiesen hat, dass er einen Neustart überlebt. **Vor `ConfigureProxmox`**, dessen `refuseWithoutBridge()` **unverändert stehen bleibt** und damit zur Nachprüfung wird — dasselbe Paar wie `BuildVmTemplate` → `VerifyVmTemplate`. Bauen und prüfen, und beides, nicht eines von beiden. Ein eigener Schritt statt einer Erweiterung von `ConfigureProxmox`, aus drei Gründen: er braucht `poll()` und eine eigene Frist, er läuft abgekoppelt, und `ConfigureProxmox` würde seine Firewall-Arbeit sonst bei jedem Poll wiederholen. Außerdem bekommt der Betreiber eine eigene Zeile in der Konsole, an der er zusehen kann — und das ist das Abnahmeziel. ### Abgekoppelt ist hier Bedingung, nicht Optimierung `BuildVmTemplate` läuft abgekoppelt, weil zwanzig Minuten länger sind als der Befehlszeitablauf. Dieser Schritt läuft abgekoppelt, weil die SSH-Verbindung **mitten im Befehl stirbt**: `ifreload -a` nimmt die Leitung, über die der Befehl läuft. Ein synchroner Aufruf hätte keinen Rückgabewert, sondern eine Leiche. --- ## Der Ablauf ### Auf dem Host — `bridge-run.sh` 1. **sichern** — `tar czf` über `/etc/network/interfaces` und `/etc/network/interfaces.d/`, dazu der Zustand der Fremdverwalter, die gleich entmachtet werden. 2. **Zeitgeber stellen** — systemd-Timer, 5 Minuten, `OnActiveSec`. **Vor jeder Änderung, ausnahmslos.** Ein `sleep &` täte es nicht: ein Hintergrundlauf stirbt mit seiner Sitzung, und die Sitzung ist genau das, was abreißt. Das Rücknahme-Skript schreibt zusätzlich `failed` **und den Grund** in die Statusdatei. Sonst liest der Schritt nach dem Wiederverbinden `running` und pollt gegen einen Lauf, den es nicht mehr gibt — der Zeitgeber beendet den Treiber ja gerade nicht. 3. **übernehmen** — cloud-inits Netzteil abschalten (`/etc/cloud/cloud.cfg.d/99-disable-network-config.cfg`), `systemd-networkd`/NetworkManager maskieren, kollidierende `interfaces.d`-Strophen wegräumen, die dieselbe Karte beanspruchen. 4. **umstellen** — Strophe nach `/etc/network/interfaces`, `ifreload -a` (Rückfall: `systemctl restart networking`). **Hier stirbt SSH.** 5. **nachsehen — beide Richtungen.** Siehe eigenen Abschnitt unten. 6. `state=ok`, Ende. **Der Treiber bestellt nichts ab.** ### In CluPilot — `EnsureNetworkBridge` **Erster Blick, vor allem anderen:** trägt `vmbr0` bereits die Standardroute und eine Adresse, dann `advance()`, ohne irgendetwas anzufassen. Dieselbe Abkürzung wie „Vorlage existiert und meldet `template: 1`". Sonst baut ein Wiederanlauf ein funktionierendes Netz um — genau `pve-fns-1`. **Guard: keine Brücke auf einer Brücke.** Ist die primäre Schnittstelle keine physische Karte (`/sys/class/net/$if/device` fehlt, oder `…/bridge`, `…/bonding` ist vorhanden), dann `fail()` mit Klartext statt bauen. `bridge_ports` auf einem Bond oder einer bestehenden Bridge ist falsch. **Erster Besuch** (kein `bridge_deadline` im Run-Kontext): - Zustand lesen, Plan ableiten, Fremdverwalter erkennen. - Unbekannter Verwalter → `fail()` mit der heutigen Handarbeit-Meldung. Kein Versuch ins Blaue. - `bridge.sh`, `bridge-run.sh` und `env` hochladen. - `bridge_deadline` (15 min) und `bridge_attempts` in den Run-Kontext. - `nohup setsid sh bridge-run.sh`, auf die PID-Datei warten. - `poll(20)`. **Jeder weitere Besuch:** - `keyLogin()` **in einem try/catch**. Fliegt es und die Frist läuft noch → `poll(20, 'warte darauf, dass der Host über die neue Brücke wieder antwortet')`. Das ist der erwartete Fall, kein Fehler. **Warum das die tragende Zeile ist:** `RunRunner.php:102` verwandelt jede geworfene Ausnahme in ein `retry()`, und `retry` verbraucht das Versuchskonto. Ungefangen brennt der Verbindungsabriss die fünf Versuche in wenigen Minuten durch und lässt den Lauf scheitern, **bevor** der Host wieder da ist. - Status lesen (`state`/`alive`/`phase`/`note`), gleiche Form wie `BuildVmTemplate::readStatus()`. | gelesen | Reaktion | |---|---| | `ok`, Brücke steht | abbestellen, dann `advance()` — **in dieser Reihenfolge** | | `ok`, Brücke fehlt | der Zeitgeber war schneller → `giveUp()` | | `running`, Prozess lebt | `poll(20)` | | `running`, Prozess tot | Lauf ist tot, nicht langsam → auf die Rücknahme warten | | `failed` | auf die Rücknahme warten, dann `giveUp()` mit `note` | | leer | nie angelaufen → `giveUp()` | | Frist überschritten | `giveUp()` | **„Auf die Rücknahme warten" heißt konkret:** `poll(20)` weiter, bis entweder das Rücknahme-Skript `failed` samt Grund geschrieben hat **oder** der Zeitgeber nicht mehr existiert und der alte Zustand wieder auf der Karte liegt. Erst dann `giveUp()`. Nicht sofort scheitern: solange die Rücknahme aussteht, ist die Maschine mitten in einer Umstellung, und ein `fail()` hinterließe sie dort. Gedeckelt ist das Warten durch die Schritt-Frist — und die ist das Dreifache des Zeitgebers, das Warten kann also nicht unbegrenzt laufen. `giveUp()` löscht `bridge_deadline` (damit ein Wiederanlauf wieder als erster Besuch gilt), räumt die Statusdateien weg und beendet einen etwa noch laufenden Treiber über seine Prozessgruppe — dieselbe Reihenfolge und dieselbe `cmdline`-Absicherung gegen eine wiederverwendete PID wie in `BuildVmTemplate::giveUp()`. `bridge_attempts` bleibt stehen. **Fristen:** Zeitgeber 5 Minuten, Schritt-Frist 15 Minuten, `maxDuration()` 3600 (damit die eigene Frist die Fehlermeldung schreibt und nicht der generische Zeitablauf, der zusätzlich einen Versuch kostet — dieselbe Form wie `RebootIntoPveKernel` und `BuildVmTemplate`). Die Frist ist **absichtlich das Dreifache** des Zeitgebers: wenn der Schritt aufgibt, ist der alte Zustand garantiert längst zurückgespielt. Das ist die Zeile, die „Wiederholung statt toter Server" wahr macht. **Höchstens zwei automatische Versuche je Lauf** (`bridge_attempts`, von `giveUp()` **nicht** gelöscht). Der zweite ist für den Fall, dass der Betreiber zwischen den Versuchen etwas repariert hat; danach die Handarbeit-Meldung samt der Strophe, die der Schritt geschrieben hätte. --- ## Die Erfolgsbedingung — beide Richtungen Der Zeitgeber wird **erst** abbestellt, wenn beides stimmt: ``` # 1. komme ich raus? http_get "$CLUPILOT_PROBE_URL" # 2. steht der Tunnel? date +%s gegen wg show wg0 latest-handshakes ``` Nur `1.` zu prüfen ist notwendig und **nicht hinreichend**. Der Fehlerfall: `ifreload -a` bringt `vmbr0` sauber hoch, die Maschine erreicht das Internet, der Treiber ist zufrieden und bestellt ab — aber `wg0` kommt nicht zurück. Ergebnis: Der Host ist öffentlich erreichbar, CluPilot ist ausgesperrt, und die Rückfahrkarte wurde gerade weggeworfen. `wg0.conf` (`ConfigureWireguard::renderConfig()`) enthält **keine Gerätebindung** — kein `PostUp` nennt die Karte. Der Tunnel hängt also nicht wörtlich an `enp0s31f6`, sondern an der Quelladresse, die die Routing-Tabelle hergibt — und genau das ist die Größe, die der Umbau anfasst. Das macht den Fehlerfall nicht unwahrscheinlicher, nur unauffälliger: kein Fehler im Protokoll, nur ein Handshake, der ausbleibt. Prüfung `2.` ist **wortgleich** die aus v1.3.85 (`ConfigureWireguard`): frischer Handshake (180 s) vom konfigurierten Hub, gestellt mit `date +%s` und `wg show wg0 latest-handshakes`. Dieselbe Frage, dieselbe Antwort, lokal stellbar — niemand muss von außen hereinkommen. **Ist der Handshake schal: einmal `systemctl restart wg-quick@wg0`, dann noch einmal nachsehen.** Das ist zu diesem Zeitpunkt gefahrlos, weil die SSH-Sitzung ohnehin schon weg ist, und verwandelt einen hängenden Tunnel in einen laufenden statt in eine Rücknahme. Bleibt er schal, `failed` — der Zeitgeber holt den alten Zustand. ### Kein `ping` `network.sh:190` und `:365` prüfen mit `ping -c2`. Das ist wortgleich die Falle, die schon `ConfigureWireguard` erwischt hat: Hetzners `Debian-trixie-…-base` hat kein `iputils-ping`, und `PrepareBaseSystem` installiert es nicht (es holt `curl gnupg ifupdown2 chrony`). Die Nachsehen-Stufe sagte damit **immer** „nicht erreichbar", der Zeitgeber spielte **immer** zurück — der Schritt käme nie durch. `bridge.sh` prüft deshalb ohne `ping`: 1. Standardroute liegt auf `vmbr0`, mit dem erwarteten Gateway. 2. Die erwartete Adresse liegt auf `vmbr0`. 3. `http_get "$CLUPILOT_PROBE_URL"` (`curl`, Rückfall `wget` — beide vorhanden). 4. Frischer WireGuard-Handshake. `iputils-ping` wird **nicht** nachinstalliert. Ein Paket nachzuziehen, um eine schlechtere Prüfung benutzen zu können, ist rückwärts. --- ## Was aus dem laufenden Zustand abgeleitet wird Alles auf dem Host, alles aus `ip`/`sys`: | Größe | Quelle | |---|---| | primäre Karte | `ip -4 route show default` → `dev` | | Adresse/Maske | `ip -4 -o addr show dev $if scope global`, erste | | Gateway | `ip -4 route show default` → `via` | | MAC | `/sys/class/net/$if/address` → `hwaddress ether`, festgenagelt | | physisch? | `/sys/class/net/$if/device` da, `…/bridge`+`…/bonding` weg | | DHCP? | Flag `dynamic` in `ip -4 -o addr show` (Datei-Grep nur als Zweitsignal) | | Zusatzrouten | `ip -4 route show dev $if` minus Standard-, Subnetz- und Gateway-Link-Route | **Anbieterform** (`detect_network_style`, bleibt wie sie ist): - `dhcp` — Adresse kommt per DHCP. - `routed` — Gateway außerhalb des eigenen Subnetzes (Hetzner dediziert, /32). Braucht `pointopoint`, sonst findet der Kernel das Gateway nicht und die Maschine ist still weg. - `subnet` — gewöhnliches Subnetz, Gateway darin. Ob das Gateway im eigenen Subnetz liegt, beantwortet `ip -4 route get` — mit derselben Logik, die der Kernel später anwendet, statt Netzmasken nachzurechnen. ### IPv6 IPv6 trägt in dieser Installation keinen Verkehr; IPv4 genügt. Mitgenommen wird es trotzdem — aber **nur in der einen Form, die nichts kostet**: liegt auf der Karte eine **statische, globale** v6-Adresse und gibt es ein v6-Standardgateway, wandern beide als `iface vmbr0 inet6 static` mit. Das deckt Hetzners `2a01:…::2/64` samt `fe80::1` ab und ist derselbe Dreisatz wie bei v4. **Nicht nachgebaut werden SLAAC und DHCPv6.** Proxmox setzt `forwarding=1`, und der Kernel verwirft Router-Advertisements dann ohne `accept_ra=2` — das sauber hinzubekommen wäre ein zweiter Satz Fehlerfälle innerhalb des Fensters, das die ganze Konstruktion gerade zu überleben versucht, und er kaufte nichts. In dem Fall wird v6 fallen gelassen **und eine Zeile ins Protokoll geschrieben**. Still verlieren gilt nicht. ### Fremdverwalter Erkannt und entmachtet werden drei: cloud-init (Netzteil), `systemd-networkd`, NetworkManager. Alles, was der Schritt **nicht** erkennt, führt zur heutigen Handarbeit-Meldung statt zu einem Versuch ins Blaue. Der Grund, warum das überhaupt hineingehört: aus dem laufenden Zustand abzuleiten löst das *Lesen*, nicht das *Schreiben*. Führt cloud-init das Netz, ist `/etc/network/interfaces` nicht die Stelle, an der die Wahrheit steht — die Brücke griffe entweder sofort nicht oder, schlimmer, sie griffe jetzt und würde beim nächsten Neustart wieder eingesammelt. Dann läuft die Übernahme grün durch und der Host verliert seine Brücke Monate später, mit Kunden darauf. Der Zeitgeber fängt diesen Fall **nicht** ab. Dazu der kleinere Verwandte: `build_bridge` behält heute die Zeile `source /etc/network/interfaces.d/*`. Bleibt dort eine Strophe liegen, die dieselbe Karte beansprucht, hat der Host zwei Stellen, die seine Adresse vergeben. --- ## Was ausdrücklich nicht gebaut wird - **Kein PHP-Nachbau** der Netzlogik. Der Schritt lädt und fährt. - **Kein zweiter Rücknahmeweg.** Nur der Zeitgeber stellt zurück, nie der Treiber selbst und nie CluPilot. Ein Treiber, der zurückspielt und das Ergebnis über eine Verbindung meldet, die es vielleicht nicht gibt, rät über seine eigene Erreichbarkeit. Ein Besitzer, an einer Stelle. - **Kein `iputils-ping`.** - **Keine Änderung an `ConfigureProxmox`.** `refuseWithoutBridge()` bleibt wortgleich stehen und wird zur Nachprüfung. - **Kein SLAAC/DHCPv6-Nachbau.** - **Keine Reparatur des stillgelegten Rettungssystem-Wegs.** `network.sh:365` benutzt weiterhin `ping` in `wireguard_handshake_proven`. Bekannt, außerhalb dieses Schnitts, hier notiert. --- ## Tests Nach der Testtechnik, die sich bei `BuildVmTemplate` gelohnt hat: 1. **Byte für Byte.** Ein Test vergleicht das hochgeladene `bridge.sh` gegen die Repo-Datei. Dieselbe Zusicherung wie bei `template.sh`. 2. **`sh -n` über jeden Befehl, den der Schritt absetzt** (`FakeRemoteShell` führt nichts aus), plus `sh -n` über `bridge.sh` und `bridge-run.sh` selbst. Gegengeprobt mit absichtlich kaputtem Quoting. 3. **Der Strophengenerator, gegen echte `sh` gefahren.** `build_bridge` schreibt nach `${CLUPILOT_INTERFACES_FILE:-/etc/network/interfaces}` und ruft `${CLUPILOT_IFRELOAD:-ifreload}` — dieselbe Überschreibbarkeit wie `CLUPILOT_STORAGE_CFG`. Damit prüft ein Pest-Test die erzeugte Strophe gegen Aufzeichnungen von `ip addr`/`ip route` aus vier Fällen: Hetzner dediziert (`routed`, /32, `pointopoint`), Hetzner Cloud (`dhcp`), netcup (`subnet`), und statisches IPv6 mit `fe80::1`. **Das ist der wertvollste Test des Vorhabens** — die Strophe ist das, was die Maschine umbringt. 4. **Die Reihenfolge-Zusicherung.** Ein Test über die abgesetzte Befehlsfolge: der Zeitgeber steht **vor** dem ersten verändernden Befehl. 5. **Beide Richtungen.** Ein Test, dass ein schaler Handshake trotz erreichbarem Internet **nicht** abbestellt. 6. **Das Verhalten des Schritts:** Verbindungsabriss → `poll`, nicht `retry` (die Zeile aus `RunRunner.php:102`); `ok` ohne Brücke → `giveUp`; `running` ohne lebenden Prozess → `giveUp`; Frist > Zeitgeber; Abbestellen erst nach der Nachprüfung; Brücke schon da → `advance()` ohne einen einzigen verändernden Befehl. --- ## Abnahme Frische Debian-13-Maschine, Host in `/admin/hosts` anlegen, zusehen, `active` — ohne einen Handgriff. Und auf `pve-fns-1`, das die Brücke schon hat: der Schritt meldet `advance()` und fasst nichts an. --- Siehe `docs/superpowers/specs/2026-07-25-host-onboarding-design.md`, `deploy/bootstrap/lib/template.sh`, `app/Provisioning/Steps/Host/BuildVmTemplate.php`.