diff --git a/docs/superpowers/specs/2026-08-01-network-bridge-design.md b/docs/superpowers/specs/2026-08-01-network-bridge-design.md new file mode 100644 index 0000000..19ae029 --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-network-bridge-design.md @@ -0,0 +1,397 @@ +# 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`.