diff --git a/docs/superpowers/plans/2026-08-01-network-bridge.md b/docs/superpowers/plans/2026-08-01-network-bridge.md new file mode 100644 index 0000000..79ec683 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-network-bridge.md @@ -0,0 +1,2878 @@ +# vmbr0 automatisch bauen — 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 neuer Pipeline-Schritt `EnsureNetworkBridge` baut `vmbr0` auf einem Debian-Proxmox-Host selbsttätig, unter einer Rückfahrkarte, die den Host auch dann zurückholt, wenn die Brücke ihn vom Netz nimmt. + +**Architecture:** Derselbe Mechanismus wie `BuildVmTemplate`: eine Shell-Bibliothek (`lib/bridge.sh`) und ein Treiber (`lib/bridge-run.sh`) werden wortgleich auf den Host geladen und abgekoppelt gefahren; der PHP-Schritt fragt eine Statusdatei ab und verbraucht dafür `poll()`, nicht `retry()`. Der Treiber stellt vor jeder Änderung einen systemd-Zeitgeber, der die alte Netzkonfiguration zurückspielt; abbestellt wird er von CluPilot, erst nachdem es sich über den Tunnel neu verbunden und die Brücke nachgeprüft hat. + +**Tech Stack:** PHP 8 / Laravel 13.8, Pest, POSIX `sh` (dash auf Debian), `ifupdown2`, systemd, WireGuard. + +**Spec:** `docs/superpowers/specs/2026-08-01-network-bridge-design.md` + +## Global Constraints + +- **Der Zeitgeber steht vor jeder Änderung.** Keine Zeile, die Netzkonfiguration anfasst, darf vor `schedule_network_rollback` laufen. Das ist die Zusicherung, die Task 5 prüft. +- **Nur der Zeitgeber stellt zurück.** Kein zweiter Rücknahmeweg — weder im Treiber noch in PHP. `giveUp()` bestellt den Zeitgeber **nicht** ab. +- **Abbestellt wird erst, wenn beide Richtungen stimmen:** `http_get "$CLUPILOT_PROBE_URL"` **und** frischer WireGuard-Handshake (180 s) vom konfigurierten Hub. +- **Kein `ping`.** Hetzners `Debian-trixie-…-base` hat kein `iputils-ping`, `PrepareBaseSystem` installiert es nicht (`curl gnupg ifupdown2 chrony`). Es wird auch nicht nachinstalliert. +- **Alles aus dem laufenden Zustand:** `ip -4 addr`, `ip -4 route`, `/sys/class/net/…`. Die Datei des Anbieters ist höchstens Zweitsignal. +- **Kein PHP-Nachbau der Netzlogik.** Der Schritt lädt und fährt; ein Test vergleicht Byte für Byte gegen die Repo-Datei. +- **Eine Fassung.** Nach Task 1 gibt es `detect_primary_interface` genau einmal im Repo. +- **POSIX `sh`**, kein Bash-ismus. Jeder Befehl, den der Schritt absetzt, muss `sh -n` bestehen. +- **Arbeitsverzeichnis auf dem Host:** `/var/lib/clupilot/bridge`. +- **Fristen:** Zeitgeber 5 min, Schritt-Frist 15 min, `maxDuration()` 3600. +- **Kommentarsprache:** Shell-Bibliotheken auf Deutsch (wie `template.sh`, `network.sh`), PHP-Docblocks auf Englisch (wie `BuildVmTemplate.php`). Betreibertexte Deutsch. +- **R22 gilt:** eine Prüfrunde, eine Fix-Runde, ein Re-Review über den Fix-Diff. Danach werden Restbefunde geparkt. +- **Tests laufen im Container** (R8, Docker-first). Nackt gibt es auf dieser Maschine kein `php`: + ``` + docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest + ``` + Das gilt auch für `pint`. Der `clupilot`-Helfer hat dafür **kein** Unterkommando. +- **`CommandResult::failure(int $exitCode = 1, string $stderr = '')`** — zwei Parameter. `CommandResult::success(string $stdout = '')`. +- **Zwischenstände dürfen unfertig sein, aber nicht scharf.** Task 6 lässt bewusst ein `fail('not implemented yet')` stehen, das Task 7 ersetzt. Das ist gefahrlos, weil der Schritt erst in **Task 9** in die Pipeline kommt — bis dahin kann ihn kein Lauf erreichen. Wer die Reihenfolge umstellt, nimmt sich diese Sicherheit. + +--- + +### Task 1: `bridge.sh` — Erkennung, allein lauffähig + +Die Brückenhälfte aus `network.sh` wird zu einer eigenen, selbstgenügsamen Bibliothek. Diese Task bringt nur die **Erkennung** hinüber (lesen, nichts verändern) und macht sie ohne die vier geborgten Helfer lauffähig. + +**Files:** +- Create: `deploy/bootstrap/lib/bridge.sh` +- Modify: `deploy/bootstrap/lib/proxmox.sh:132-135` (Kopie von `detect_primary_interface` entfernen) +- Modify: `deploy/bootstrap/clupilot-bootstrap.sh:968` (`lib/bridge.sh` **vor** `lib/proxmox.sh` sourcen) +- Modify: `deploy/bootstrap/lib/network.sh:44-103` (Erkennungsteil entfernen, Verweis-Kommentar setzen) +- Test: `tests/Feature/Provisioning/BridgeScriptTest.php` + +**Interfaces:** +- Consumes: nichts. +- Produces (alle in `bridge.sh`, POSIX `sh`): + - `detect_primary_interface() -> stdout: iface-Name` (leer, wenn keine Standardroute) + - `interface_is_physical(iface) -> exit 0/1` + - `address_is_dynamic(iface) -> exit 0/1` + - `detect_network_style(iface) -> stdout: dhcp|routed|subnet` + - `bridge_exists(name) -> exit 0/1` + - `bridge_carries_default_route(name) -> exit 0/1` + - `bridge_has_address(name) -> exit 0/1` + - `bridge_is_up() -> exit 0/1` (die drei zusammen, gegen `$CLUPILOT_BRIDGE`) + - Variablen mit Vorgaben: `CLUPILOT_BRIDGE`, `CLUPILOT_INTERFACES_FILE`, `CLUPILOT_INTERFACES_D`, `CLUPILOT_SYS_NET`, `CLUPILOT_IP`, `CLUPILOT_PROBE_URL`, `CLUPILOT_IFRELOAD`, `CLUPILOT_ROLLBACK_UNIT`, `CLUPILOT_NET_BACKUP`, `CLUPILOT_WORK_DIR`, `CLUPILOT_WG_HUB_PUBKEY`, `CLUPILOT_WG_HANDSHAKE_MAX_AGE` + - `log(msg)` und `http_get(url)`, **beide unter einem Definitionsschutz** (`command -v`), damit `clupilot-bootstrap.sh` seine eigenen behält. + +`CLUPILOT_IP` ist der Kniff, der die Erkennung ohne Host prüfbar macht: statt `ip` direkt aufzurufen, ruft jede Funktion `"$CLUPILOT_IP"`. Ein Test setzt das auf ein Attrappen-Skript, das aufgezeichnete Ausgaben zurückgibt — dieselbe Technik wie `CLUPILOT_STORAGE_CFG` beim Vorlagenbau. + +- [ ] **Step 1: Den Testfall schreiben** + +`tests/Feature/Provisioning/BridgeScriptTest.php`: + +```php + $answers Argumentmuster => Ausgabe + */ +function fakeIp(string $dir, array $answers): string +{ + $cases = ''; + foreach ($answers as $pattern => $output) { + $cases .= " '{$pattern}') cat <<'OUT'\n{$output}\nOUT\n ;;\n"; + } + + $script = "#!/bin/sh\ncase \"\$*\" in\n{$cases} *) exit 1 ;;\nesac\n"; + file_put_contents("{$dir}/ip", $script); + chmod("{$dir}/ip", 0o755); + + return "{$dir}/ip"; +} + +/** + * Fährt eine Zeile Shell mit bridge.sh im Rücken und gibt stdout zurück. + * + * `$pre` läuft VOR dem Sourcen. Damit kann ein Test `log` oder `http_get` selbst + * definieren — bridge.sh legt beide unter einem `command -v`-Schutz an, wer + * zuerst da ist, behält recht. Das ist zugleich die Probe auf diesen Schutz und + * spart dem Test eine Abhängigkeit von curl im Container. + */ +function runBridgeSh(string $body, array $env = [], string $pre = ''): string +{ + $lib = base_path('deploy/bootstrap/lib/bridge.sh'); + $exports = ''; + foreach ($env as $key => $value) { + $exports .= "export {$key}=".escapeshellarg($value)."\n"; + } + + $result = Process::input("{$exports}{$pre}\n. '{$lib}'\n{$body}\n")->run('sh'); + + return trim($result->output()); +} + +/** Ein /sys/class/net-Baum aus Textdateien. */ +function fakeSysNet(string $dir, string $iface, string $mac, bool $physical = true): string +{ + $root = "{$dir}/sys"; + mkdir("{$root}/{$iface}", 0o755, true); + file_put_contents("{$root}/{$iface}/address", $mac."\n"); + if ($physical) { + mkdir("{$root}/{$iface}/device", 0o755, true); + } + + return $root; +} + +beforeEach(function () { + $this->dir = sys_get_temp_dir().'/bridge-'.bin2hex(random_bytes(6)); + mkdir($this->dir, 0o755, true); +}); + +it('nimmt die Karte, über die die Standardroute geht — nicht die erste beste', function () { + // Eine Maschine mit zwei Karten hat oft eine angeschlossene und eine nicht. + $ip = fakeIp($this->dir, [ + '-4 route show default' => 'default via 49.12.121.65 dev enp0s31f6 proto static', + ]); + + expect(runBridgeSh('detect_primary_interface', ['CLUPILOT_IP' => $ip])) + ->toBe('enp0s31f6'); +}); + +it('erkennt eine geroutete Einzeladresse an der /32', function () { + // Hetzner dediziert. Das eigene Subnetz besteht aus der eigenen Adresse — + // ein Gateway darin kann es nicht geben, es braucht pointopoint. + $ip = fakeIp($this->dir, [ + '-4 route show default' => 'default via 49.12.121.65 dev enp0s31f6', + '-4 -o addr show dev enp0s31f6 scope global' => '2: enp0s31f6 inet 49.12.121.79/32 scope global enp0s31f6\ valid_lft forever preferred_lft forever', + ]); + + expect(runBridgeSh('detect_network_style enp0s31f6', ['CLUPILOT_IP' => $ip])) + ->toBe('routed'); +}); + +it('erkennt DHCP am laufenden Zustand, nicht an der Datei des Anbieters', function () { + // Der Kernel markiert eine geleaste Adresse als `dynamic`. Das ist überall + // gleich; wie der Anbieter es aufgeschrieben hat, nicht. + $ip = fakeIp($this->dir, [ + '-4 route show default' => 'default via 10.0.0.1 dev ens3', + '-4 -o addr show dev ens3 scope global' => '2: ens3 inet 10.0.0.7/24 brd 10.0.0.255 scope global dynamic ens3\ valid_lft 3521sec preferred_lft 3521sec', + ]); + + expect(runBridgeSh('detect_network_style ens3', [ + 'CLUPILOT_IP' => $ip, + // Absichtlich auf eine Datei zeigen, die es nicht gibt: die Antwort darf + // nicht davon abhängen. + 'CLUPILOT_INTERFACES_FILE' => $this->dir.'/gibtesnicht', + ]))->toBe('dhcp'); +}); + +it('hält eine Bridge und einen Bond für keine physische Karte', function () { + // bridge_ports auf einem Bond oder einer bestehenden Bridge ist falsch: die + // Brücke nähme sich ihren eigenen Unterbau als Port. + $sys = fakeSysNet($this->dir, 'bond0', 'aa:bb:cc:dd:ee:ff', physical: false); + mkdir("{$sys}/bond0/bonding", 0o755, true); + + $out = runBridgeSh( + 'if interface_is_physical bond0; then echo JA; else echo NEIN; fi', + ['CLUPILOT_SYS_NET' => $sys] + ); + + expect($out)->toBe('NEIN'); +}); + +it('hält eine gewöhnliche Karte für eine physische', function () { + $sys = fakeSysNet($this->dir, 'enp0s31f6', 'a8:a1:59:00:11:22'); + + $out = runBridgeSh( + 'if interface_is_physical enp0s31f6; then echo JA; else echo NEIN; fi', + ['CLUPILOT_SYS_NET' => $sys] + ); + + expect($out)->toBe('JA'); +}); + +it('sagt nur dann „Brücke steht", wenn sie Route UND Adresse trägt', function () { + // Eine vmbr0 ohne Adresse und ohne Standardroute ist eine Brücke im Sinne + // von `ip link show` und sonst nichts. Der alte Schritt prüfte genau das + // und warf das Ergebnis weg. + $ip = fakeIp($this->dir, [ + 'link show vmbr0' => '5: vmbr0: mtu 1500', + '-4 route show default' => 'default via 49.12.121.65 dev enp0s31f6', + '-4 -o addr show dev vmbr0 scope global' => '', + ]); + + $out = runBridgeSh( + 'if bridge_is_up; then echo JA; else echo NEIN; fi', + ['CLUPILOT_IP' => $ip] + ); + + expect($out)->toBe('NEIN'); +}); +``` + +- [ ] **Step 2: Testlauf, der scheitern muss** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php +``` + +Erwartet: FAIL — `deploy/bootstrap/lib/bridge.sh` existiert nicht, `sh` bricht mit „No such file or directory" ab. + +- [ ] **Step 3: `bridge.sh` anlegen (Erkennungsteil)** + +`deploy/bootstrap/lib/bridge.sh`: + +```sh +# shellcheck shell=sh +# +# Die Brücke über die primäre Netzkarte — Erkennen, Sichern, Zeitgeber, Bauen, +# Nachsehen. +# +# --------------------------------------------------------------------------- +# Warum das eine eigene Datei ist +# --------------------------------------------------------------------------- +# +# Diese Zeilen standen in `network.sh`, geschrieben für den stillgelegten +# Rettungssystem-Weg, und borgten sich vier Dinge von woanders: +# `detect_primary_interface` aus `proxmox.sh`, `log`, `http_get` und +# `CLUPILOT_PROBE_URL` aus `clupilot-bootstrap.sh`. Der Debian-Weg lädt sie +# einzeln auf den Host und fährt sie dort — geborgte Helfer wären dann nicht da. +# +# Also allein lauffähig. `network.sh` behält seine WireGuard- und +# nftables-Hälfte; diese Datei wird von beiden Wegen benutzt und existiert genau +# einmal. Eine zweite Fassung wären zwei Installationen, die bei jeder +# Proxmox-Version nachgezogen werden müssten — und die zweite fiele erst auf, +# wenn jemand sie benutzt. +# +# --------------------------------------------------------------------------- +# Warum aus dem LAUFENDEN Zustand abgeleitet wird +# --------------------------------------------------------------------------- +# +# Nicht aus `/etc/network/interfaces`. Was läuft, ist bei jedem Anbieter gleich +# strukturiert; wie es aufgeschrieben wurde, nicht. Das ist der Teil, der +# Hetzner und netcup zugleich trägt. + +# --------------------------------------------------------------------------- +# Stellschrauben — überschreibbar, damit die Bibliothek ohne Host prüfbar ist +# --------------------------------------------------------------------------- + +CLUPILOT_BRIDGE="${CLUPILOT_BRIDGE:-vmbr0}" +CLUPILOT_WORK_DIR="${CLUPILOT_WORK_DIR:-/var/lib/clupilot/bridge}" +CLUPILOT_INTERFACES_FILE="${CLUPILOT_INTERFACES_FILE:-/etc/network/interfaces}" +CLUPILOT_INTERFACES_D="${CLUPILOT_INTERFACES_D:-/etc/network/interfaces.d}" +CLUPILOT_SYS_NET="${CLUPILOT_SYS_NET:-/sys/class/net}" +CLUPILOT_IP="${CLUPILOT_IP:-ip}" +CLUPILOT_IFRELOAD="${CLUPILOT_IFRELOAD:-ifreload}" +CLUPILOT_PROBE_URL="${CLUPILOT_PROBE_URL:-http://deb.debian.org/}" +CLUPILOT_ROLLBACK_UNIT="${CLUPILOT_ROLLBACK_UNIT:-clupilot-network-rollback}" +CLUPILOT_NET_BACKUP="${CLUPILOT_NET_BACKUP:-/var/lib/clupilot/interfaces.vor-der-bruecke}" +CLUPILOT_WG_HUB_PUBKEY="${CLUPILOT_WG_HUB_PUBKEY:-}" +CLUPILOT_WG_HANDSHAKE_MAX_AGE="${CLUPILOT_WG_HANDSHAKE_MAX_AGE:-180}" + +# --------------------------------------------------------------------------- +# Die zwei Helfer, die diese Datei früher geborgt hat +# --------------------------------------------------------------------------- +# +# Unter Definitionsschutz: `clupilot-bootstrap.sh` bringt eigene mit, und wer +# zuerst da ist, behält recht. Sonst überschriebe das Laden dieser Bibliothek +# die Protokollierung des Rettungssystem-Wegs. + +if ! command -v log >/dev/null 2>&1; then + log() { + printf '%s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$1" + } +fi + +if ! command -v http_get >/dev/null 2>&1; then + http_get() { + if command -v curl >/dev/null 2>&1; then + curl -fsSL --retry 3 --retry-delay 2 --max-time 20 "$1" + else + wget -qO- --timeout=20 "$1" + fi + } +fi + +# --------------------------------------------------------------------------- +# Feststellen, was da ist +# --------------------------------------------------------------------------- + +# Die Schnittstelle, über die die Vorgabe-Route geht. Nicht „die erste, die +# nicht lo heißt": eine Maschine mit zwei Karten hat oft eine angeschlossene und +# eine nicht. +detect_primary_interface() { + "$CLUPILOT_IP" -4 route show default 2>/dev/null \ + | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }' +} + +# Ist das eine physische Karte? +# +# `bridge_ports` auf einem Bond oder einer bestehenden Bridge ist falsch — die +# Brücke nähme sich ihren eigenen Unterbau als Port, und was dabei herauskommt, +# ist von hier aus nicht mehr zu reparieren. VLAN-Geräte und Bonds haben keinen +# `device`-Verweis; die beiden weiteren Prüfungen sind der Gürtel dazu. +interface_is_physical() { + _if="${1:-}" + + [ -n "$_if" ] || return 1 + [ -e "${CLUPILOT_SYS_NET}/${_if}/device" ] || return 1 + [ ! -d "${CLUPILOT_SYS_NET}/${_if}/bridge" ] || return 1 + [ ! -d "${CLUPILOT_SYS_NET}/${_if}/bonding" ] || return 1 + + return 0 +} + +# Kam die Adresse per DHCP? +# +# Aus dem laufenden Zustand: der Kernel markiert eine geleaste Adresse mit +# `dynamic`. Das steht bei jedem Anbieter gleich da — anders als die Datei, in +# die er es geschrieben hat. +address_is_dynamic() { + "$CLUPILOT_IP" -4 -o addr show dev "$1" scope global 2>/dev/null \ + | grep -q '[[:space:]]dynamic[[:space:]]' +} + +# Wie der Anbieter das Netz aufzieht. Eine Brücke, die für den einen Fall +# richtig ist, nimmt den anderen vom Netz. +# +# - `dhcp` — die Adresse kommt per DHCP (Cloud-Produkte). +# - `routed` — geroutete Einzeladresse, Gateway AUSSERHALB des eigenen Subnetzes +# (Hetzner-dediziert mit /32). Braucht eine pointopoint-Route, +# sonst findet der Kernel das Gateway nicht. +# - `subnet` — gewöhnliches Subnetz, Gateway darin. +detect_network_style() { + _iface="${1:-}" + [ -n "$_iface" ] || _iface="$(detect_primary_interface)" + + # Laufender Zustand zuerst. Die Datei des Anbieters ist nur das Zweitsignal + # — sie sagt, was jemand aufgeschrieben hat, nicht was gilt. + if address_is_dynamic "$_iface"; then + printf 'dhcp' + return 0 + fi + + if grep -qsE "iface[[:space:]]+${_iface}[[:space:]]+inet[[:space:]]+dhcp" \ + "$CLUPILOT_INTERFACES_FILE" "${CLUPILOT_INTERFACES_D}"/* 2>/dev/null; then + printf 'dhcp' + return 0 + fi + + _cidr="$("$CLUPILOT_IP" -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')" + _gw="$("$CLUPILOT_IP" -4 route show default 2>/dev/null | awk '{ print $3; exit }')" + _prefix="${_cidr##*/}" + + # /32 heißt: das eigene Subnetz besteht aus der eigenen Adresse. Ein Gateway + # darin kann es nicht geben. + if [ "$_prefix" = '32' ]; then + printf 'routed' + return 0 + fi + + # Liegt das Gateway im eigenen Subnetz? `ip route get` beantwortet das, ohne + # dass dieses Skript Netzmasken rechnen muss — und rechnet dabei mit + # derselben Logik, die der Kernel später anwendet. + if [ -n "$_gw" ] && "$CLUPILOT_IP" -4 route get "$_gw" 2>/dev/null | grep -q "dev ${_iface}.*src"; then + printf 'subnet' + return 0 + fi + + printf 'routed' +} + +bridge_exists() { + "$CLUPILOT_IP" link show "${1:-$CLUPILOT_BRIDGE}" >/dev/null 2>&1 +} + +# Trägt die Brücke wirklich den Verkehr, oder existiert sie nur? +# +# Eine `vmbr0` ohne Adresse und ohne Vorgaberoute ist eine Brücke im Sinne von +# `ip link show` und sonst nichts. Der alte Schritt prüfte genau das und warf das +# Ergebnis weg; sein Kommentar behauptete, er halte die Abwesenheit fest, und er +# hielt nichts fest. +bridge_carries_default_route() { + _want="${1:-$CLUPILOT_BRIDGE}" + _dev="$("$CLUPILOT_IP" -4 route show default 2>/dev/null \ + | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }')" + + [ "$_dev" = "$_want" ] +} + +bridge_has_address() { + [ -n "$("$CLUPILOT_IP" -4 -o addr show dev "${1:-$CLUPILOT_BRIDGE}" scope global 2>/dev/null | awk '{ print $4; exit }')" ] +} + +# Die drei zusammen. „Da" reicht nicht — sie muss tragen. +bridge_is_up() { + bridge_exists "$CLUPILOT_BRIDGE" \ + && bridge_carries_default_route "$CLUPILOT_BRIDGE" \ + && bridge_has_address "$CLUPILOT_BRIDGE" +} +``` + +- [ ] **Step 4: Testlauf, der bestehen muss** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php +``` + +Erwartet: PASS, sechs Tests. + +- [ ] **Step 5: Die alte Fassung entfernen** + +In `deploy/bootstrap/lib/proxmox.sh` den Block `detect_primary_interface() { … }` (samt Kommentar, Zeilen 130–135) **löschen** und dort einen Verweis hinterlassen: + +```sh +# `detect_primary_interface` steht in lib/bridge.sh — eine Fassung, zwei +# Benutzer. clupilot-bootstrap.sh lädt bridge.sh vor dieser Datei; die Funktion +# wird erst zur Aufrufzeit aufgelöst, also ist die Reihenfolge unkritisch. +``` + +In `deploy/bootstrap/lib/network.sh` die Zeilen 40–103 (Variablen `CLUPILOT_ROLLBACK_UNIT`/`CLUPILOT_NET_BACKUP`, `bridge_exists`, `bridge_carries_default_route`, `bridge_has_address`, `detect_network_style`) **löschen** und ersetzen durch: + +```sh +# Die Brücke steht jetzt in lib/bridge.sh — Erkennen, Sichern, Zeitgeber, Bauen, +# Nachsehen, alles beisammen und allein lauffähig, weil der Debian-Weg sie +# einzeln auf den Host lädt. clupilot-bootstrap.sh sourced sie vor dieser Datei. +# Hier bleibt, was Kunden-VMs, WireGuard und nftables angeht. +``` + +In `deploy/bootstrap/clupilot-bootstrap.sh` vor Zeile 968 einfügen: + +```sh + # shellcheck source=lib/bridge.sh + . "${CLUPILOT_BOOTSTRAP_DIR}/lib/bridge.sh" +``` + +- [ ] **Step 6: Test „eine Fassung" schreiben und laufen lassen** + +Ans Ende von `tests/Feature/Provisioning/BridgeScriptTest.php`: + +```php +it('hält detect_primary_interface an genau einer Stelle im Repo', function () { + // Zwei Fassungen wären zwei Installationen, die bei jeder Proxmox-Version + // nachgezogen werden müssten — und die zweite fiele erst auf, wenn jemand + // sie benutzt. Genau die Entscheidung, die den Rettungssystem-Weg + // stillgelegt hat. + // array_merge, NICHT `+`: bei numerischen Schlüsseln behält der + // Vereinigungsoperator die linke Seite und wirft die rechte still weg. Und + // PHPs glob() kennt kein rekursives `**`, also zwei Muster. + $files = array_merge( + glob(base_path('deploy/bootstrap/lib/*.sh')) ?: [], + glob(base_path('deploy/bootstrap/*.sh')) ?: [], + ); + + $hits = []; + foreach ($files as $file) { + if (preg_match('/^detect_primary_interface\(\)/m', (string) file_get_contents($file))) { + $hits[] = str_replace(base_path().'/', '', $file); + } + } + + expect($hits)->toBe(['deploy/bootstrap/lib/bridge.sh']); +}); + +it('lässt jede Bibliothek des Bootstraps von einer Shell parsen', function () { + // network.sh und proxmox.sh werden in dieser Task beschnitten. Ein + // verrutschter Schnitt fiele sonst erst auf dem Rettungssystem-Weg auf, und + // der wird selten gefahren. + foreach (glob(base_path('deploy/bootstrap/lib/*.sh')) as $file) { + expect(Process::run(['sh', '-n', $file])->successful()) + ->toBeTrue('kein gültiges POSIX-sh: '.$file); + } +}); +``` + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php +``` + +Erwartet: PASS, acht Tests. + +- [ ] **Step 7: Commit** + +```bash +git add deploy/bootstrap/lib/bridge.sh deploy/bootstrap/lib/network.sh deploy/bootstrap/lib/proxmox.sh deploy/bootstrap/clupilot-bootstrap.sh tests/Feature/Provisioning/BridgeScriptTest.php +git commit -m "bridge.sh: die Erkennung herausgeloest und allein lauffaehig gemacht" +``` + +--- + +### Task 2: `bridge.sh` — die Strophe + +Der wertvollste Teil des Vorhabens: was hier falsch herauskommt, nimmt die Maschine vom Netz. Deshalb wird der Generator gegen eine echte Shell gefahren und gegen vier Anbieterfälle geprüft. + +**Files:** +- Modify: `deploy/bootstrap/lib/bridge.sh` (anhängen) +- Test: `tests/Feature/Provisioning/BridgeScriptTest.php` (anhängen) + +**Interfaces:** +- Consumes: `detect_network_style`, `CLUPILOT_IP`, `CLUPILOT_SYS_NET`, `CLUPILOT_INTERFACES_FILE`, `CLUPILOT_BRIDGE`, `log` aus Task 1. +- Produces: + - `extra_routes(iface, gw) -> stdout: 0..n Zeilen " up ip route add … || true"` + - `static_ipv6_on(iface) -> stdout: CIDR oder leer` + - `ipv6_gateway() -> stdout: Adresse oder leer` + - `write_bridge_stanza(iface, style, cidr, gw)` — schreibt nach `$CLUPILOT_INTERFACES_FILE`, verändert sonst nichts, kein `ifreload` + - `build_bridge(iface, style, cidr, gw)` — ruft `write_bridge_stanza` und danach `"$CLUPILOT_IFRELOAD" -a` + +Getrennt, weil der Test die Strophe prüfen will, ohne ein Netz neu zu laden. + +- [ ] **Step 1: Die vier Anbieterfälle als Test schreiben** + +Anhängen an `tests/Feature/Provisioning/BridgeScriptTest.php`: + +```php +/* +|-------------------------------------------------------------------------- +| Die Strophe +|-------------------------------------------------------------------------- +| +| Vier Fälle, und sie unterscheiden sich in genau der Zeile, die entscheidet, +| ob die Maschine danach noch da ist. +*/ + +it('schreibt für eine geroutete /32 eine pointopoint-Strophe', function () { + // Hetzner dediziert. Ohne `pointopoint` findet der Kernel keinen Weg zum + // Gateway: die Route zeigte auf ein Netz, in dem das Gateway nicht liegt, + // und die Maschine wäre still weg. + $ip = fakeIp($this->dir, [ + '-4 route show dev enp0s31f6' => "49.12.121.65 scope link\ndefault via 49.12.121.65", + '-6 -o addr show dev enp0s31f6 scope global' => '', + '-6 route show default' => '', + ]); + $sys = fakeSysNet($this->dir, 'enp0s31f6', 'a8:a1:59:00:11:22'); + $out = $this->dir.'/interfaces'; + + runBridgeSh('write_bridge_stanza enp0s31f6 routed 49.12.121.79/32 49.12.121.65', [ + 'CLUPILOT_IP' => $ip, + 'CLUPILOT_SYS_NET' => $sys, + 'CLUPILOT_INTERFACES_FILE' => $out, + ]); + + $stanza = file_get_contents($out); + + expect($stanza) + ->toContain('address 49.12.121.79/32') + ->toContain('pointopoint 49.12.121.65') + ->toContain('gateway 49.12.121.65') + ->toContain('bridge-ports enp0s31f6') + ->toContain('iface enp0s31f6 inet manual') + // Festgenagelt, damit die Brücke nicht irgendeine MAC wählt: manche + // Anbieter binden den Switchport an die der Karte. + ->toContain('hwaddress ether a8:a1:59:00:11:22') + ->toContain('auto vmbr0'); +}); + +it('schreibt für ein gewöhnliches Subnetz Adresse und Gateway ohne pointopoint', function () { + // netcup und die meisten anderen. `pointopoint` wäre hier falsch. + $ip = fakeIp($this->dir, [ + '-4 route show dev ens3' => '192.168.10.0/24 proto kernel scope link src 192.168.10.7', + '-6 -o addr show dev ens3 scope global' => '', + '-6 route show default' => '', + ]); + $sys = fakeSysNet($this->dir, 'ens3', 'de:ad:be:ef:00:01'); + $out = $this->dir.'/interfaces'; + + runBridgeSh('write_bridge_stanza ens3 subnet 192.168.10.7/24 192.168.10.1', [ + 'CLUPILOT_IP' => $ip, + 'CLUPILOT_SYS_NET' => $sys, + 'CLUPILOT_INTERFACES_FILE' => $out, + ]); + + $stanza = file_get_contents($out); + + expect($stanza) + ->toContain('address 192.168.10.7/24') + ->toContain('gateway 192.168.10.1') + ->not->toContain('pointopoint') + // Die Route zum eigenen Subnetz gehört NICHT in die Zusatzrouten: die + // legt der Kernel selbst an, sobald die Adresse steht. Ein zweites Mal + // hinzufügen scheitert und nimmt bei `ifreload` die Strophe mit. + ->not->toContain('up ip route add 192.168.10.0/24'); +}); + +it('schreibt für DHCP keine Adresse in die Strophe', function () { + $ip = fakeIp($this->dir, [ + '-4 route show dev ens3' => '', + '-6 -o addr show dev ens3 scope global' => '', + '-6 route show default' => '', + ]); + $sys = fakeSysNet($this->dir, 'ens3', 'de:ad:be:ef:00:02'); + $out = $this->dir.'/interfaces'; + + runBridgeSh('write_bridge_stanza ens3 dhcp 10.0.0.7/24 10.0.0.1', [ + 'CLUPILOT_IP' => $ip, + 'CLUPILOT_SYS_NET' => $sys, + 'CLUPILOT_INTERFACES_FILE' => $out, + ]); + + $stanza = file_get_contents($out); + + expect($stanza) + ->toContain('iface vmbr0 inet dhcp') + ->not->toContain('address 10.0.0.7') + ->toContain('bridge-ports ens3'); +}); + +it('nimmt eine statische globale IPv6 samt link-local Gateway mit', function () { + // Hetzners Form: 2a01:…::2/64 mit fe80::1. Statisch, also derselbe Dreisatz + // wie bei v4 — und damit der Fall, der laut Entwurf nichts kostet. + $ip = fakeIp($this->dir, [ + '-4 route show dev enp0s31f6' => '', + '-6 -o addr show dev enp0s31f6 scope global' => '2: enp0s31f6 inet6 2a01:4f8:1c1c::2/64 scope global \ valid_lft forever preferred_lft forever', + '-6 route show default' => 'default via fe80::1 dev enp0s31f6 metric 1024', + ]); + $sys = fakeSysNet($this->dir, 'enp0s31f6', 'a8:a1:59:00:11:22'); + $out = $this->dir.'/interfaces'; + + runBridgeSh('write_bridge_stanza enp0s31f6 routed 49.12.121.79/32 49.12.121.65', [ + 'CLUPILOT_IP' => $ip, + 'CLUPILOT_SYS_NET' => $sys, + 'CLUPILOT_INTERFACES_FILE' => $out, + ]); + + $stanza = file_get_contents($out); + + expect($stanza) + ->toContain('iface vmbr0 inet6 static') + ->toContain('address 2a01:4f8:1c1c::2/64') + ->toContain('gateway fe80::1'); +}); + +it('lässt SLAAC-IPv6 liegen und sagt es ins Protokoll', function () { + // 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 in genau dem Fenster, das diese + // Konstruktion zu überleben versucht — und v6 trägt hier keinen Verkehr. + // Still verlieren gilt trotzdem nicht. + $ip = fakeIp($this->dir, [ + '-4 route show dev ens3' => '', + '-6 -o addr show dev ens3 scope global' => '2: ens3 inet6 2a01:4f8:aaaa::17/64 scope global dynamic \ valid_lft 86000sec preferred_lft 14000sec', + '-6 route show default' => 'default via fe80::1 dev ens3', + ]); + $sys = fakeSysNet($this->dir, 'ens3', 'de:ad:be:ef:00:03'); + $out = $this->dir.'/interfaces'; + + $log = runBridgeSh('write_bridge_stanza ens3 subnet 10.0.0.7/24 10.0.0.1', [ + 'CLUPILOT_IP' => $ip, + 'CLUPILOT_SYS_NET' => $sys, + 'CLUPILOT_INTERFACES_FILE' => $out, + ]); + + expect(file_get_contents($out))->not->toContain('inet6 static') + ->and($log)->toContain('IPv6'); +}); + +it('nimmt die Zusatzrouten des Anbieters mit auf die Brücke', function () { + // Was der Anbieter extra eingetragen hat, verliert der Host sonst — und es + // fällt erst auf, wenn jemand das Ziel dahinter braucht. + $ip = fakeIp($this->dir, [ + '-4 route show dev enp0s31f6' => "49.12.121.65 scope link\n10.128.0.0/16 via 49.12.121.65\n49.12.121.64/26 proto kernel scope link src 49.12.121.79", + '-6 -o addr show dev enp0s31f6 scope global' => '', + '-6 route show default' => '', + ]); + $sys = fakeSysNet($this->dir, 'enp0s31f6', 'a8:a1:59:00:11:22'); + $out = $this->dir.'/interfaces'; + + runBridgeSh('write_bridge_stanza enp0s31f6 subnet 49.12.121.79/26 49.12.121.65', [ + 'CLUPILOT_IP' => $ip, + 'CLUPILOT_SYS_NET' => $sys, + 'CLUPILOT_INTERFACES_FILE' => $out, + ]); + + $stanza = file_get_contents($out); + + expect($stanza) + ->toContain('up ip route add 10.128.0.0/16 via 49.12.121.65 dev vmbr0') + // Weder die Link-Route zum Gateway noch die Kernel-Route zum eigenen + // Subnetz: beide entstehen von selbst, und ein zweiter Versuch + // scheitert und nimmt die Strophe mit. + ->not->toContain('up ip route add 49.12.121.65 ') + ->not->toContain('up ip route add 49.12.121.64/26'); +}); + +it('schreibt eine Strophe, die ein Mensch als von CluPilot erkennt', function () { + // Wer in sechs Monaten in diese Datei sieht, muss wissen, wer sie + // geschrieben hat und woraus. + $ip = fakeIp($this->dir, [ + '-4 route show dev ens3' => '', + '-6 -o addr show dev ens3 scope global' => '', + '-6 route show default' => '', + ]); + $sys = fakeSysNet($this->dir, 'ens3', 'de:ad:be:ef:00:04'); + $out = $this->dir.'/interfaces'; + + runBridgeSh('write_bridge_stanza ens3 subnet 10.0.0.7/24 10.0.0.1', [ + 'CLUPILOT_IP' => $ip, + 'CLUPILOT_SYS_NET' => $sys, + 'CLUPILOT_INTERFACES_FILE' => $out, + ]); + + expect(file_get_contents($out)) + ->toContain('Von CluPilot geschrieben') + ->toContain('EnsureNetworkBridge'); +}); +``` + +- [ ] **Step 2: Testlauf, der scheitern muss** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php +``` + +Erwartet: FAIL — `write_bridge_stanza: not found`. + +- [ ] **Step 3: Den Generator anhängen** + +An `deploy/bootstrap/lib/bridge.sh`: + +```sh +# --------------------------------------------------------------------------- +# Die Strophe +# --------------------------------------------------------------------------- + +# Zusatzrouten des Anbieters — alles auf der Karte, was der Kernel nicht von +# selbst wieder anlegt. +# +# Ausgelassen werden zwei Sorten, und beide mit Grund: die Kernel-Route zum +# eigenen Subnetz (`proto kernel`) entsteht, sobald die Adresse steht, und die +# Link-Route zum Gateway legt `pointopoint` an. Sie trotzdem einzutragen ließe +# `ip route add` scheitern — und ein gescheitertes `up` nimmt bei `ifreload` +# die ganze Strophe mit. +extra_routes() { + _iface="$1" + _gw="$2" + + "$CLUPILOT_IP" -4 route show dev "$_iface" 2>/dev/null | while IFS= read -r _route; do + [ -n "$_route" ] || continue + + case "$_route" in + default*) continue ;; + "${_gw} "*|"${_gw}") continue ;; + *'proto kernel'*) continue ;; + esac + + printf ' up ip route add %s dev %s || true\n' "$_route" "$CLUPILOT_BRIDGE" + done +} + +# Eine statische, globale IPv6-Adresse — oder nichts. +# +# `dynamic` (SLAAC/DHCPv6) und `temporary` (Privacy Extensions) fliegen raus: +# was der Kernel selbst vergibt, gehört nicht in eine Datei geschrieben. +static_ipv6_on() { + "$CLUPILOT_IP" -6 -o addr show dev "$1" scope global 2>/dev/null \ + | grep -v '[[:space:]]dynamic[[:space:]]' \ + | grep -v '[[:space:]]temporary[[:space:]]' \ + | awk '{ print $4; exit }' +} + +ipv6_gateway() { + "$CLUPILOT_IP" -6 route show default 2>/dev/null \ + | awk '{ for (i = 1; i < NF; i++) if ($i == "via") { print $(i+1); exit } }' +} + +# Schreibt die Strophe — und sonst nichts. Kein `ifreload`, kein Sichern, kein +# Zeitgeber. Getrennt von build_bridge, damit genau das prüfbar ist, was eine +# Maschine umbringt, ohne dafür ein Netz neu laden zu müssen. +write_bridge_stanza() { + _iface="$1" + _style="$2" + _cidr="$3" + _gw="$4" + + _ip4="${_cidr%%/*}" + _mac="$(cat "${CLUPILOT_SYS_NET}/${_iface}/address" 2>/dev/null)" + + case "$_style" in + dhcp) + _inet="iface ${CLUPILOT_BRIDGE} inet dhcp" + _addr='' + ;; + routed) + # Gateway außerhalb des eigenen Subnetzes. Ohne `pointopoint` findet + # der Kernel keinen Weg dorthin: die Route zeigte auf ein Netz, in + # dem das Gateway nicht liegt, und die Maschine wäre still weg. + _inet="iface ${CLUPILOT_BRIDGE} inet static" + _addr=" address ${_ip4}/32 + pointopoint ${_gw} + gateway ${_gw}" + ;; + *) + _inet="iface ${CLUPILOT_BRIDGE} inet static" + _addr=" address ${_cidr} + gateway ${_gw}" + ;; + esac + + # Festgenagelt: eine Brücke wählt sonst die kleinste MAC ihrer Ports, und + # manche Anbieter binden den Switchport an die der Karte. Bei einem Port ist + # das dieselbe — aber „ist dieselbe" und „bleibt dieselbe" sind zweierlei. + _hw='' + [ -n "$_mac" ] && _hw=" hwaddress ether ${_mac}" + + _inet6='' + _v6="$(static_ipv6_on "$_iface")" + _v6gw="$(ipv6_gateway)" + + if [ -n "$_v6" ] && [ -n "$_v6gw" ]; then + _inet6=" +iface ${CLUPILOT_BRIDGE} inet6 static + address ${_v6} + gateway ${_v6gw}" + elif [ -n "$_v6" ] || [ -n "$_v6gw" ]; then + log 'IPv6 wird NICHT mit auf die Brücke genommen: keine statische globale Adresse mit Standardgateway (SLAAC/DHCPv6 werden bewusst nicht nachgebaut — Proxmox setzt forwarding=1, und der Kernel verwirft RAs dann ohne accept_ra=2).' + fi + + cat > "$CLUPILOT_INTERFACES_FILE" </dev/null 2>&1; then + "$CLUPILOT_IFRELOAD" -a + else + systemctl restart networking + fi +} +``` + +- [ ] **Step 4: Testlauf, der bestehen muss** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php +``` + +Erwartet: PASS, fünfzehn Tests. + +- [ ] **Step 5: Commit** + +```bash +git add deploy/bootstrap/lib/bridge.sh tests/Feature/Provisioning/BridgeScriptTest.php +git commit -m "bridge.sh: die Strophe, gegen vier Anbieterfaelle geprueft" +``` + +--- + +### Task 3: `bridge.sh` — die Rückfahrkarte + +Sichern, Zeitgeber, Abbestellen. Der Zeitgeber ist der einzige Grund, warum dieser Schritt die Netzkonfiguration überhaupt anfassen darf. + +**Files:** +- Modify: `deploy/bootstrap/lib/bridge.sh` (anhängen) +- Test: `tests/Feature/Provisioning/BridgeScriptTest.php` (anhängen) + +**Interfaces:** +- Consumes: `log`, `CLUPILOT_NET_BACKUP`, `CLUPILOT_ROLLBACK_UNIT`, `CLUPILOT_WORK_DIR`, `CLUPILOT_INTERFACES_FILE`, `CLUPILOT_INTERFACES_D` aus Task 1. +- Produces: + - `backup_network_config()` — `tar czf "${CLUPILOT_NET_BACKUP}.tar.gz"` + - `render_rollback_script() -> stdout` (der Inhalt, nicht die Datei — damit prüfbar) + - `schedule_network_rollback(minuten)` — schreibt Skript + `.service` + `.timer`, `daemon-reload`, `start` + - `cancel_network_rollback()` + - `CLUPILOT_SYSTEMCTL` (Vorgabe `systemctl`), `CLUPILOT_UNIT_DIR` (Vorgabe `/etc/systemd/system`), `CLUPILOT_SBIN_DIR` (Vorgabe `/usr/local/sbin`) — überschreibbar für den Test + +- [ ] **Step 1: Test schreiben** + +Anhängen an `tests/Feature/Provisioning/BridgeScriptTest.php`: + +```php +/* +|-------------------------------------------------------------------------- +| Die Rückfahrkarte +|-------------------------------------------------------------------------- +*/ + +it('schreibt ein Rücknahme-Skript, das den Grund festhält BEVOR es zurückspielt', function () { + // Ohne diese zwei Zeilen 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. Und zuerst schreiben, nicht + // zuletzt: ein halb geglücktes Zurückspielen soll das Urteil trotzdem + // hinterlassen. + $script = runBridgeSh('render_rollback_script 5', [ + 'CLUPILOT_WORK_DIR' => '/var/lib/clupilot/bridge', + ]); + + $stateAt = strpos($script, "printf 'failed'"); + $restoreAt = strpos($script, 'tar xzf'); + + expect($stateAt)->not->toBeFalse() + ->and($restoreAt)->not->toBeFalse() + ->and($stateAt)->toBeLessThan($restoreAt) + ->and($script)->toContain('/var/lib/clupilot/bridge/note') + // Das Signal, auf das CluPilot wartet: erst wenn das da ist, ist der + // alte Zustand wirklich zurück. + ->and($script)->toContain('rolled-back') + ->and(strpos($script, 'rolled-back'))->toBeGreaterThan($restoreAt); +}); + +it('räumt der Zeitgeber sich nach dem Feuern selbst weg', function () { + // Sonst liegen Unit-Dateien herum, die aussehen, als stünde noch eine + // Rücknahme aus. + expect(runBridgeSh('render_rollback_script 5')) + ->toContain('clupilot-network-rollback.timer'); +}); + +it('stellt den Zeitgeber, bevor irgendetwas verändert wird', function () { + // Die tragende Zusicherung des ganzen Entwurfs. Geprüft an der Reihenfolge + // der Aufrufe, die schedule_network_rollback und build_bridge absetzen. + $bin = $this->dir.'/bin'; + mkdir($bin, 0o755, true); + file_put_contents("{$bin}/systemctl", "#!/bin/sh\necho \"systemctl \$*\" >> '{$this->dir}/calls'\n"); + chmod("{$bin}/systemctl", 0o755); + + $ip = fakeIp($this->dir, [ + '-4 route show dev ens3' => '', + '-6 -o addr show dev ens3 scope global' => '', + '-6 route show default' => '', + ]); + $sys = fakeSysNet($this->dir, 'ens3', 'de:ad:be:ef:00:05'); + file_put_contents("{$bin}/ifreload", "#!/bin/sh\necho 'ifreload' >> '{$this->dir}/calls'\n"); + chmod("{$bin}/ifreload", 0o755); + mkdir($this->dir.'/units', 0o755, true); + mkdir($this->dir.'/sbin', 0o755, true); + file_put_contents($this->dir.'/interfaces', "# alt\n"); + + runBridgeSh( + "backup_network_config\nschedule_network_rollback 5\nbuild_bridge ens3 subnet 10.0.0.7/24 10.0.0.1", + [ + 'CLUPILOT_IP' => $ip, + 'CLUPILOT_SYS_NET' => $sys, + 'CLUPILOT_INTERFACES_FILE' => $this->dir.'/interfaces', + 'CLUPILOT_INTERFACES_D' => $this->dir.'/interfaces.d', + 'CLUPILOT_NET_BACKUP' => $this->dir.'/sicherung', + 'CLUPILOT_SYSTEMCTL' => "{$bin}/systemctl", + 'CLUPILOT_IFRELOAD' => "{$bin}/ifreload", + 'CLUPILOT_UNIT_DIR' => $this->dir.'/units', + 'CLUPILOT_SBIN_DIR' => $this->dir.'/sbin', + 'CLUPILOT_WORK_DIR' => $this->dir, + ] + ); + + $calls = file_get_contents($this->dir.'/calls'); + + expect(strpos($calls, 'start clupilot-network-rollback.timer'))->not->toBeFalse() + ->and(strpos($calls, 'ifreload'))->not->toBeFalse() + // Umgekehrt wäre die Reihenfolge, die einen Host dauerhaft unerreichbar + // macht. + ->and(strpos($calls, 'start clupilot-network-rollback.timer')) + ->toBeLessThan(strpos($calls, 'ifreload')) + // Und die Sicherung muss existieren, bevor der Zeitgeber sie + // zurückspielen könnte. + ->and(file_exists($this->dir.'/sicherung.tar.gz'))->toBeTrue(); +}); +``` + +- [ ] **Step 2: Testlauf, der scheitern muss** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php +``` + +Erwartet: FAIL — `render_rollback_script: not found`. + +- [ ] **Step 3: Die Rückfahrkarte anhängen** + +Zuerst die drei neuen Stellschrauben oben in `bridge.sh` zu den anderen: + +```sh +CLUPILOT_SYSTEMCTL="${CLUPILOT_SYSTEMCTL:-systemctl}" +CLUPILOT_UNIT_DIR="${CLUPILOT_UNIT_DIR:-/etc/systemd/system}" +CLUPILOT_SBIN_DIR="${CLUPILOT_SBIN_DIR:-/usr/local/sbin}" +``` + +Dann anhängen: + +```sh +# --------------------------------------------------------------------------- +# Die Selbstrücknahme +# --------------------------------------------------------------------------- +# +# Sichern, Zeitgeber auf fünf Minuten, umstellen, nachsehen, abbestellen. +# +# Der Zeitgeber ist eine systemd-Einheit und kein `sleep &`: ein Hintergrundlauf +# stirbt mit seiner Sitzung, und die Sitzung ist genau das, was abreißt, wenn die +# Umstellung schiefgeht. Er ist der einzige Grund, warum dieser Abschnitt die +# Netzkonfiguration überhaupt anfassen darf. +# +# Abbestellt wird er NICHT hier. Das tut CluPilot, nachdem es sich neu verbunden +# und nachgesehen hat — der Treiber kann über seine eigene Erreichbarkeit nur +# raten. + +backup_network_config() { + mkdir -p "$(dirname -- "$CLUPILOT_NET_BACKUP")" + tar czf "${CLUPILOT_NET_BACKUP}.tar.gz" \ + -C / "${CLUPILOT_INTERFACES_FILE#/}" \ + $( [ -d "$CLUPILOT_INTERFACES_D" ] && printf '%s' "${CLUPILOT_INTERFACES_D#/}" ) \ + 2>/dev/null +} + +# Der Inhalt des Rücknahme-Skripts — als Text, nicht als Datei. +# +# Getrennt, damit die Reihenfolge darin prüfbar ist, ohne systemd zu brauchen. +# Und die Reihenfolge ist der Punkt: das Urteil wird geschrieben, BEVOR +# zurückgespielt wird, damit auch ein halb geglücktes Zurückspielen einen Grund +# hinterlässt. `rolled-back` kommt zuletzt — das ist das Signal, auf das CluPilot +# wartet, und es darf erst stehen, wenn der alte Zustand wirklich zurück ist. +render_rollback_script() { + _minutes="${1:-5}" + + cat < '${CLUPILOT_WORK_DIR}/state' +printf 'Die Bruecke nahm den Host vom Netz; die Netzkonfiguration von vorher wurde zurueckgespielt.' > '${CLUPILOT_WORK_DIR}/note' + +tar xzf '${CLUPILOT_NET_BACKUP}.tar.gz' -C / +if command -v ifreload >/dev/null 2>&1; then + ifreload -a +else + systemctl restart networking +fi + +logger -t clupilot 'Netzkonfiguration zurueckgespielt: die Bruecke hat den Host vom Netz genommen.' + +# Erst jetzt. Vorher hiesse es: zurueckgespielt, obwohl es noch laeuft. +: > '${CLUPILOT_WORK_DIR}/rolled-back' + +# Selbst wegraeumen, sonst liegen Unit-Dateien herum, die aussehen, als stuende +# noch eine Ruecknahme aus. +rm -f '${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.timer' \\ + '${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.service' +systemctl daemon-reload 2>/dev/null || true +EOF +} + +# Stellt den Zeitgeber. Ab hier gibt es einen Weg zurück, und erst ab hier darf +# irgendetwas am Netz verändert werden. +schedule_network_rollback() { + _minutes="${1:-5}" + + mkdir -p "$CLUPILOT_SBIN_DIR" "$CLUPILOT_UNIT_DIR" "$CLUPILOT_WORK_DIR" + + render_rollback_script "$_minutes" > "${CLUPILOT_SBIN_DIR}/${CLUPILOT_ROLLBACK_UNIT}.sh" + chmod 700 "${CLUPILOT_SBIN_DIR}/${CLUPILOT_ROLLBACK_UNIT}.sh" + + cat > "${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.service" < "${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.timer" </dev/null || true + rm -f "${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.timer" \ + "${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.service" \ + "${CLUPILOT_SBIN_DIR}/${CLUPILOT_ROLLBACK_UNIT}.sh" + "$CLUPILOT_SYSTEMCTL" daemon-reload 2>/dev/null || true + log 'Selbstrücknahme abbestellt' +} +``` + +- [ ] **Step 4: Testlauf, der bestehen muss** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php +``` + +Erwartet: PASS, achtzehn Tests. + +- [ ] **Step 5: Commit** + +```bash +git add deploy/bootstrap/lib/bridge.sh tests/Feature/Provisioning/BridgeScriptTest.php +git commit -m "bridge.sh: die Rueckfahrkarte, und der Grund steht vor dem Zurueckspielen" +``` + +--- + +### Task 4: `bridge.sh` — nachsehen, beide Richtungen + +Die Bedingung zum Abbestellen. Nur „komme ich raus" zu prüfen ist notwendig und nicht hinreichend — und kein `ping`. + +**Files:** +- Modify: `deploy/bootstrap/lib/bridge.sh` (anhängen) +- Test: `tests/Feature/Provisioning/BridgeScriptTest.php` (anhängen) + +**Interfaces:** +- Consumes: `bridge_is_up`, `http_get`, `log`, `CLUPILOT_PROBE_URL`, `CLUPILOT_WG_HUB_PUBKEY`, `CLUPILOT_WG_HANDSHAKE_MAX_AGE` aus Task 1/3. +- Produces: + - `internet_reachable() -> exit 0/1` + - `tunnel_handshake_fresh() -> exit 0/1` + - `bridge_proven() -> exit 0/1` — die drei zusammen, mit dem einen `wg`-Neustart + - `CLUPILOT_WG` (Vorgabe `wg`), `CLUPILOT_HANDSHAKE_TRIES` (Vorgabe `15`), `CLUPILOT_HANDSHAKE_WAIT` (Vorgabe `2`) + +- [ ] **Step 1: Test schreiben** + +Anhängen an `tests/Feature/Provisioning/BridgeScriptTest.php`: + +```php +/* +|-------------------------------------------------------------------------- +| Nachsehen — beide Richtungen +|-------------------------------------------------------------------------- +*/ + +/** Eine `wg`-Attrappe, die einen Handshake von genau einem Peer meldet. */ +function fakeWg(string $dir, string $peer, int $secondsAgo): string +{ + $when = time() - $secondsAgo; + $script = "#!/bin/sh\nprintf '%s\\t%s\\n' '{$peer}' '{$when}'\n"; + file_put_contents("{$dir}/wg", $script); + chmod("{$dir}/wg", 0o755); + + return "{$dir}/wg"; +} + +it('bestellt nicht ab, wenn der Tunnel steht, aber das Internet nicht erreichbar ist', function () { + $ip = fakeIp($this->dir, [ + 'link show vmbr0' => '5: vmbr0: ', + '-4 route show default' => 'default via 10.0.0.1 dev vmbr0', + '-4 -o addr show dev vmbr0 scope global' => '5: vmbr0 inet 10.0.0.7/24 scope global vmbr0', + ]); + $wg = fakeWg($this->dir, 'HUBKEY=', 5); + + $out = runBridgeSh( + 'if bridge_proven; then echo JA; else echo NEIN; fi', + [ + 'CLUPILOT_IP' => $ip, + 'CLUPILOT_WG' => $wg, + 'CLUPILOT_WG_HUB_PUBKEY' => 'HUBKEY=', + ], + // Kein Weg nach draußen — als Funktion gesetzt statt über eine Adresse, + // damit der Test nicht davon abhängt, ob im Container curl liegt. + pre: 'http_get() { return 1; }' + ); + + expect($out)->toContain('NEIN'); +}); + +it('bestellt NICHT ab, wenn das Internet erreichbar ist, der Tunnel aber schal', function () { + // Der Fehlerfall, für den diese Prüfung existiert: ifreload bringt vmbr0 + // sauber hoch, die Maschine erreicht das Internet, der Treiber wäre + // zufrieden — aber wg0 kommt nicht zurück. Dann lebt der Host öffentlich, + // CluPilot ist ausgesperrt, und wer hier abbestellt, hat die Rückfahrkarte + // weggeworfen. + $ip = fakeIp($this->dir, [ + 'link show vmbr0' => '5: vmbr0: ', + '-4 route show default' => 'default via 10.0.0.1 dev vmbr0', + '-4 -o addr show dev vmbr0 scope global' => '5: vmbr0 inet 10.0.0.7/24 scope global vmbr0', + ]); + // Handshake ist eine Stunde alt. + $wg = fakeWg($this->dir, 'HUBKEY=', 3600); + $bin = $this->dir.'/bin'; + mkdir($bin, 0o755, true); + file_put_contents("{$bin}/systemctl", "#!/bin/sh\necho \"systemctl \$*\" >> '{$this->dir}/calls'\n"); + chmod("{$bin}/systemctl", 0o755); + + $out = runBridgeSh( + 'if bridge_proven; then echo JA; else echo NEIN; fi', + [ + 'CLUPILOT_IP' => $ip, + 'CLUPILOT_WG' => $wg, + 'CLUPILOT_WG_HUB_PUBKEY' => 'HUBKEY=', + 'CLUPILOT_SYSTEMCTL' => "{$bin}/systemctl", + 'CLUPILOT_HANDSHAKE_TRIES' => '1', + 'CLUPILOT_HANDSHAKE_WAIT' => '0', + ], + // Der Weg nach draußen steht — genau das ist der Punkt des Tests. + pre: 'http_get() { return 0; }' + ); + + expect($out)->toContain('NEIN') + // Einmal nachhelfen gehört dazu: das ist zu diesem Zeitpunkt gefahrlos, + // weil ifreload die SSH-Sitzung ohnehin schon mitgenommen hat. + ->and(file_get_contents($this->dir.'/calls')) + ->toContain('restart wg-quick@wg0'); +}); + +it('erkennt einen Handshake von einem FREMDEN Peer nicht als den des Hubs', function () { + // Ein Mitarbeiter-Zugang auf demselben Hub hätte den Beweis sonst + // erbracht, ohne dass CluPilot selbst durchkommt. + $wg = fakeWg($this->dir, 'IRGENDWER=', 5); + + $out = runBridgeSh('if tunnel_handshake_fresh; then echo JA; else echo NEIN; fi', [ + 'CLUPILOT_WG' => $wg, + 'CLUPILOT_WG_HUB_PUBKEY' => 'HUBKEY=', + ]); + + expect($out)->toBe('NEIN'); +}); + +it('benutzt an keiner Stelle ping', function () { + // Hetzners Debian-Basis hat kein iputils-ping, und PrepareBaseSystem + // installiert es nicht. Eine Prüfung damit sagte immer „nicht erreichbar", + // der Zeitgeber spielte immer zurück, der Schritt käme nie durch — dieselbe + // Falle, die ConfigureWireguard schon einmal erwischt hat. + expect(file_get_contents(base_path('deploy/bootstrap/lib/bridge.sh'))) + ->not->toMatch('/(^|[^-\w])ping\s/m'); +}); +``` + +- [ ] **Step 2: Testlauf, der scheitern muss** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php +``` + +Erwartet: FAIL — `bridge_proven: not found`. + +- [ ] **Step 3: Nachsehen anhängen** + +Neue Stellschrauben oben in `bridge.sh`: + +```sh +CLUPILOT_WG="${CLUPILOT_WG:-wg}" +CLUPILOT_HANDSHAKE_TRIES="${CLUPILOT_HANDSHAKE_TRIES:-15}" +CLUPILOT_HANDSHAKE_WAIT="${CLUPILOT_HANDSHAKE_WAIT:-2}" +``` + +Dann anhängen: + +```sh +# --------------------------------------------------------------------------- +# Nachsehen — beide Richtungen +# --------------------------------------------------------------------------- +# +# Kein `ping`. Hetzners `Debian-trixie-…-base` bringt kein `iputils-ping` mit, +# und `PrepareBaseSystem` installiert es nicht (es holt curl, gnupg, ifupdown2, +# chrony). Eine Prüfung damit sagte IMMER „nicht erreichbar", der Zeitgeber +# spielte IMMER zurück, und der Schritt käme nie durch. Dieselbe Falle hatte +# `ConfigureWireguard` schon einmal, und sie kostete eine Übernahme. + +internet_reachable() { + http_get "$CLUPILOT_PROBE_URL" >/dev/null 2>&1 +} + +# Steht der Tunnel? +# +# Wortgleich die Prüfung aus v1.3.85: frischer Handshake vom KONFIGURIERTEN Hub. +# Nicht „irgendein Peer hat gehandshaked" — auf demselben Hub liegen auch +# Mitarbeiter-Zugänge, und deren Handshake beweist nichts über CluPilots Weg +# hierher. +tunnel_handshake_fresh() { + [ -n "$CLUPILOT_WG_HUB_PUBKEY" ] || return 1 + + _now="$(date +%s)" + _hs="$("$CLUPILOT_WG" show wg0 latest-handshakes 2>/dev/null \ + | awk -v k="$CLUPILOT_WG_HUB_PUBKEY" '$1 == k { print $2; exit }')" + + [ -n "$_hs" ] || return 1 + [ "$_hs" -gt 0 ] 2>/dev/null || return 1 + [ "$(( _now - _hs ))" -lt "$CLUPILOT_WG_HANDSHAKE_MAX_AGE" ] +} + +# Die Bedingung zum Abbestellen. +# +# Nur „komme ich raus" zu prüfen ist notwendig und NICHT hinreichend. Der +# Fehlerfall: `ifreload -a` bringt die Brücke sauber hoch, die Maschine erreicht +# das Internet, der Treiber wäre zufrieden — aber wg0 kommt nicht zurück. +# Ergebnis: der Host lebt öffentlich, CluPilot ist ausgesperrt, und die +# Rückfahrkarte wurde gerade weggeworfen. Von Hand reparierbar, und genau das +# soll dieser Schritt ja verhindern. +# +# wg0.conf enthält keine Gerätebindung (siehe ConfigureWireguard::renderConfig), +# der Tunnel hängt also nicht wörtlich an der alten Karte, sondern an der +# Quelladresse, die die Routing-Tabelle hergibt — und genau das ist die Größe, +# die der Umbau anfasst. Das macht den Fall nicht unwahrscheinlicher, nur +# unauffälliger: kein Fehler im Protokoll, nur ein Handshake, der ausbleibt. +bridge_proven() { + if ! bridge_is_up; then + log "Die Brücke trägt die Standardroute oder die Adresse nicht" + return 1 + fi + + if ! internet_reachable; then + log "Kein Weg nach draußen (${CLUPILOT_PROBE_URL})" + return 1 + fi + + if tunnel_handshake_fresh; then + return 0 + fi + + # Einmal nachhelfen. Gefahrlos: die SSH-Sitzung ist zu diesem Zeitpunkt + # ohnehin weg, `ifreload` hat sie mitgenommen. Ein hängender Tunnel wird so + # zu einem laufenden statt zu einer Rücknahme. + log 'Handshake schal — wg-quick@wg0 einmal neu starten' + "$CLUPILOT_SYSTEMCTL" restart wg-quick@wg0 >/dev/null 2>&1 || true + + _i=0 + while [ "$_i" -lt "$CLUPILOT_HANDSHAKE_TRIES" ]; do + if tunnel_handshake_fresh; then + log 'Tunnel steht nach dem Neustart von wg0' + return 0 + fi + [ "$CLUPILOT_HANDSHAKE_WAIT" -gt 0 ] && sleep "$CLUPILOT_HANDSHAKE_WAIT" + _i=$(( _i + 1 )) + done + + log 'Tunnel steht auch nach dem Neustart von wg0 nicht — CluPilot käme nicht zurück' + return 1 +} +``` + +- [ ] **Step 4: Testlauf, der bestehen muss** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php +``` + +Erwartet: PASS, zweiundzwanzig Tests. + +- [ ] **Step 5: Commit** + +```bash +git add deploy/bootstrap/lib/bridge.sh tests/Feature/Provisioning/BridgeScriptTest.php +git commit -m "bridge.sh: nachsehen in beide Richtungen, und kein ping" +``` + +--- + +### Task 5: `bridge-run.sh` — der Treiber + +Verdrahtet Task 1–4 in der einen Reihenfolge, die stimmen muss, und übernimmt das Netz von einem Fremdverwalter, falls einer da ist. + +**Files:** +- Create: `deploy/bootstrap/lib/bridge-run.sh` +- Test: `tests/Feature/Provisioning/BridgeScriptTest.php` (anhängen) + +**Interfaces:** +- Consumes: alles aus `bridge.sh`. +- Produces: ein Skript, das per `sh bridge-run.sh` läuft und `state`/`pid`/`phase`/`note`/`bridge.log` in `$CLUPILOT_WORK_DIR` schreibt. Liest `$CLUPILOT_WORK_DIR/env`. +- Produces (in `bridge.sh`, weil es dorthin gehört): `foreign_network_manager() -> stdout: cloud-init|networkd|network-manager|unbekannt|""` und `disown_network_manager()`. + +- [ ] **Step 1: Test schreiben** + +Anhängen an `tests/Feature/Provisioning/BridgeScriptTest.php`: + +```php +/* +|-------------------------------------------------------------------------- +| Der Treiber +|-------------------------------------------------------------------------- +*/ + +it('schreibt die PID als allererstes', function () { + // Der Startbefehl wartet darauf, dass hier etwas steht, und bis dahin gilt + // der Lauf als nicht angelaufen. Steht es weiter unten, liest ein früher + // Poll `running`-aber-nicht-lebendig und erklärt einen gesunden Lauf für tot. + $lines = file(base_path('deploy/bootstrap/lib/bridge-run.sh')); + $pidAt = null; + foreach ($lines as $i => $line) { + if (str_contains($line, '/pid"') && str_contains($line, '$$')) { + $pidAt = $i; + break; + } + } + + expect($pidAt)->not->toBeNull() + ->and($pidAt)->toBeLessThan(60); +}); + +it('stellt den Zeitgeber vor dem ersten veraendernden Aufruf', function () { + // Dieselbe Zusicherung wie in Task 3, hier auf der Ebene des Treibers: die + // Reihenfolge der AUFRUFE im Skripttext. + $driver = file_get_contents(base_path('deploy/bootstrap/lib/bridge-run.sh')); + + $schedule = strpos($driver, 'schedule_network_rollback'); + $disown = strpos($driver, 'disown_network_manager'); + $build = strpos($driver, 'build_bridge'); + $backup = strpos($driver, 'backup_network_config'); + + expect($backup)->toBeLessThan($schedule) + ->and($schedule)->toBeLessThan($disown) + ->and($disown)->toBeLessThan($build); +}); + +it('bestellt den Zeitgeber nirgends selbst ab', function () { + // Nur der Zeitgeber stellt zurück, und nur CluPilot bestellt ihn ab — nach + // dem Wiederverbinden. Ein Treiber, der das selbst täte, rät über seine + // eigene Erreichbarkeit. + expect(file_get_contents(base_path('deploy/bootstrap/lib/bridge-run.sh'))) + ->not->toContain('cancel_network_rollback'); +}); + +it('laesst den Treiber von einer Shell parsen', function () { + expect(Process::run(['sh', '-n', base_path('deploy/bootstrap/lib/bridge-run.sh')])->successful()) + ->toBeTrue(); +}); + +it('erkennt cloud-init als Fremdverwalter des Netzes', function () { + // Aus dem laufenden Zustand abzuleiten löst das LESEN, nicht das SCHREIBEN. + // Führt cloud-init das Netz, griffe die Brücke entweder sofort nicht oder, + // schlimmer, sie griffe jetzt und würde beim nächsten Neustart wieder + // eingesammelt — mit Kunden darauf. Der Zeitgeber fängt diesen Fall NICHT ab. + $etc = $this->dir.'/etc'; + mkdir("{$etc}/cloud", 0o755, true); + file_put_contents("{$etc}/cloud/cloud.cfg", "datasource_list: [ Hetzner ]\n"); + + expect(runBridgeSh('foreign_network_manager', ['CLUPILOT_ETC' => $etc])) + ->toBe('cloud-init'); +}); + +it('meldet keinen Fremdverwalter auf einer Maschine mit reinem ifupdown', function () { + $etc = $this->dir.'/etc'; + mkdir($etc, 0o755, true); + + expect(runBridgeSh('foreign_network_manager', ['CLUPILOT_ETC' => $etc])) + ->toBe(''); +}); +``` + +- [ ] **Step 2: Testlauf, der scheitern muss** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php +``` + +Erwartet: FAIL — `bridge-run.sh` existiert nicht, `foreign_network_manager: not found`. + +- [ ] **Step 3: Fremdverwalter-Erkennung an `bridge.sh` anhängen** + +Neue Stellschraube oben: + +```sh +CLUPILOT_ETC="${CLUPILOT_ETC:-/etc}" +``` + +Anhängen: + +```sh +# --------------------------------------------------------------------------- +# Fremdverwalter +# --------------------------------------------------------------------------- +# +# Aus dem laufenden Zustand abzuleiten löst das LESEN. Es löst 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. Deshalb steht das hier. + +# Wer führt das Netz? Leer heißt: reines ifupdown, und der Weg ist frei. +foreign_network_manager() { + if [ -f "${CLUPILOT_ETC}/cloud/cloud.cfg" ] || [ -d "${CLUPILOT_ETC}/cloud/cloud.cfg.d" ]; then + printf 'cloud-init' + return 0 + fi + + if [ -d "${CLUPILOT_ETC}/systemd/network" ] \ + && [ -n "$(ls -A "${CLUPILOT_ETC}/systemd/network" 2>/dev/null)" ]; then + printf 'networkd' + return 0 + fi + + if [ -d "${CLUPILOT_ETC}/NetworkManager/system-connections" ] \ + && [ -n "$(ls -A "${CLUPILOT_ETC}/NetworkManager/system-connections" 2>/dev/null)" ]; then + printf 'network-manager' + return 0 + fi + + printf '' +} + +# Entmachtet den erkannten Verwalter — im gesicherten Stand, also unter dem +# Zeitgeber. Was hier schiefgeht, holt er zurück. +disown_network_manager() { + case "$1" in + cloud-init) + mkdir -p "${CLUPILOT_ETC}/cloud/cloud.cfg.d" + printf 'network: {config: disabled}\n' \ + > "${CLUPILOT_ETC}/cloud/cloud.cfg.d/99-clupilot-disable-network.cfg" + log 'cloud-init: Netzteil abgeschaltet' + ;; + networkd) + "$CLUPILOT_SYSTEMCTL" disable --now systemd-networkd.socket >/dev/null 2>&1 || true + "$CLUPILOT_SYSTEMCTL" mask systemd-networkd >/dev/null 2>&1 || true + log 'systemd-networkd maskiert' + ;; + network-manager) + "$CLUPILOT_SYSTEMCTL" disable --now NetworkManager >/dev/null 2>&1 || true + "$CLUPILOT_SYSTEMCTL" mask NetworkManager >/dev/null 2>&1 || true + log 'NetworkManager maskiert' + ;; + '') + ;; + esac + + # Der kleinere Verwandte: bleibt in interfaces.d eine Strophe liegen, die + # dieselbe Karte beansprucht, hat der Host zwei Stellen, die seine Adresse + # vergeben — und die Strophe hier behält `source interfaces.d/*`. + _iface="${2:-}" + if [ -n "$_iface" ] && [ -d "$CLUPILOT_INTERFACES_D" ]; then + for _f in "$CLUPILOT_INTERFACES_D"/*; do + [ -f "$_f" ] || continue + if grep -qE "iface[[:space:]]+${_iface}[[:space:]]" "$_f" 2>/dev/null; then + mv "$_f" "${_f}.von-clupilot-beiseitegelegt" + log "Kollidierende Strophe beiseitegelegt: ${_f}" + fi + done + fi +} +``` + +- [ ] **Step 4: Den Treiber anlegen** + +`deploy/bootstrap/lib/bridge-run.sh`: + +```sh +#!/bin/sh +# shellcheck shell=sh +# +# Treiber für den Brückenbau, wenn er aus der CluPilot-Pipeline kommt. +# +# `App\Provisioning\Steps\Host\EnsureNetworkBridge` lädt diese Datei zusammen mit +# `bridge.sh` auf den Host und startet sie abgekoppelt. Die eigentliche Arbeit +# macht `bridge.sh` — hier steht nur, in welcher Reihenfolge, und wie der +# Fortschritt zurückgemeldet wird. +# +# --------------------------------------------------------------------------- +# Warum abgekoppelt +# --------------------------------------------------------------------------- +# +# Nicht wegen der Dauer — der Bau ist in Sekunden durch. Sondern 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. +# +# --------------------------------------------------------------------------- +# Die Reihenfolge, und warum sie nicht verhandelbar ist +# --------------------------------------------------------------------------- +# +# sichern → Zeitgeber → übernehmen → umstellen → nachsehen. +# +# Der Zeitgeber steht VOR jeder Änderung. Er ist der einzige Grund, warum dieses +# Skript die Netzkonfiguration überhaupt anfassen darf: kommt der Host nicht +# zurück, spielt er den alten Zustand ein, und der Schritt landet in einer +# Wiederholung statt auf einem toten Server. +# +# Abbestellt wird er hier NICHT. Das tut CluPilot, nachdem es sich über den +# Tunnel neu verbunden und nachgesehen hat. Dieses Skript kann über seine eigene +# Erreichbarkeit von außen nur raten. +# +# --------------------------------------------------------------------------- +# Rückmeldung +# --------------------------------------------------------------------------- +# +# state running | ok | failed +# pid die PID dieses Skripts — und, weil es per `setsid` gestartet +# wird, zugleich die seiner PROZESSGRUPPE +# phase die laufende Phase, für die Fortschrittszeile in der Konsole +# note der Grund im Fehlerfall +# rolled-back vom Zeitgeber angelegt, wenn er zurückgespielt hat +# bridge.log alles +# +# `state` allein ist keine Aussage über den Lauf: stirbt das Skript, bleibt dort +# für immer `running` stehen, weil niemand mehr da ist, der es ändert. Deshalb +# fragt der Schritt zusätzlich `kill -0` gegen `pid`. + +set -u + +CLUPILOT_WORK_DIR="${CLUPILOT_WORK_DIR:-/var/lib/clupilot/bridge}" + +# Als ALLERERSTES, vor jedem Einlesen und jeder Prüfung: der Startbefehl wartet +# darauf, dass hier etwas steht, und bis dahin gilt der Lauf als noch nicht +# angelaufen. `$$` und nicht `$!` auf der anderen Seite, weil `setsid` dazwischen +# liegt — und weil `setsid` daraus eine eigene Sitzung macht, ist diese Zahl +# zugleich die Prozessgruppe, an die ein Abbruch geschickt wird. +echo $$ > "${CLUPILOT_WORK_DIR}/pid" + +# shellcheck source=/dev/null +. "${CLUPILOT_WORK_DIR}/env" +# shellcheck source=lib/bridge.sh +. "${CLUPILOT_WORK_DIR}/bridge.sh" + +phase() { + printf '%s' "$1" > "${CLUPILOT_WORK_DIR}/phase" + log "--- $1" +} + +fail() { + printf '%s' "$1" > "${CLUPILOT_WORK_DIR}/note" + printf 'failed' > "${CLUPILOT_WORK_DIR}/state" + log "ABBRUCH: $1" + exit 1 +} + +# Ein Abbruch, den keine Zeile hier abgefangen hat (kein Speicher, ein Signal), +# darf nicht als `running` liegen bleiben und den Schritt gegen eine Leiche +# pollen lassen. Der Schritt erkennt das zwar auch an `kill -0`, aber ein Grund +# ist besser als eine Vermutung. +on_exit() { + _code=$? + if [ "$_code" -ne 0 ] && [ "$(cat "${CLUPILOT_WORK_DIR}/state" 2>/dev/null)" = 'running' ]; then + printf 'Der Brueckenbau brach unerwartet ab (Rueckgabewert %s); siehe bridge.log' "$_code" \ + > "${CLUPILOT_WORK_DIR}/note" + printf 'failed' > "${CLUPILOT_WORK_DIR}/state" + fi +} +trap on_exit EXIT + +# --------------------------------------------------------------------------- +# Ablauf +# --------------------------------------------------------------------------- + +cd "$CLUPILOT_WORK_DIR" || exit 1 + +phase 'Zustand feststellen' + +_iface="$(detect_primary_interface)" +[ -n "$_iface" ] || fail 'Keine Karte traegt die Standardroute — auf dieser Maschine ist nicht abzuleiten, worueber eine Bruecke gehen soll.' + +interface_is_physical "$_iface" \ + || fail "Die Karte mit der Standardroute (${_iface}) ist keine physische Karte, sondern eine Bruecke, ein Bond oder ein VLAN. bridge_ports darauf waere falsch." + +_style="$(detect_network_style "$_iface")" +_cidr="$("$CLUPILOT_IP" -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')" +_gw="$("$CLUPILOT_IP" -4 route show default 2>/dev/null | awk '{ print $3; exit }')" + +[ "$_style" = 'dhcp' ] || [ -n "$_cidr" ] \ + || fail "Keine globale IPv4-Adresse auf ${_iface} — nichts, was auf eine Bruecke ziehen koennte." + +log "Karte ${_iface}, Form ${_style}, Adresse ${_cidr:-per DHCP}, Gateway ${_gw:-keins}" + +_foreign="$(foreign_network_manager)" +case "$_foreign" in + ''|cloud-init|networkd|network-manager) ;; + *) fail "Unbekannter Netzverwalter (${_foreign}) — hier wird nicht ins Blaue gebaut." ;; +esac +[ -n "$_foreign" ] && log "Fremdverwalter erkannt: ${_foreign}" + +# --- ab hier wird verändert --- + +phase 'sichern' +backup_network_config || fail 'Die bestehende Netzkonfiguration liess sich nicht sichern — ohne Rueckfahrkarte wird hier nichts umgestellt.' + +phase 'Zeitgeber stellen' +schedule_network_rollback "${CLUPILOT_ROLLBACK_MINUTES:-5}" + +phase 'uebernehmen' +disown_network_manager "$_foreign" "$_iface" + +phase 'umstellen' +build_bridge "$_iface" "$_style" "$_cidr" "$_gw" + +phase 'nachsehen' +bridge_proven || fail 'Die Bruecke steht, aber der Weg nach draussen oder der Tunnel fehlt. Der Zeitgeber spielt den alten Zustand zurueck.' + +phase 'wartet auf CluPilot' +printf 'ok' > "${CLUPILOT_WORK_DIR}/state" +log 'Bruecke steht und traegt; der Zeitgeber laeuft weiter, bis CluPilot ihn abbestellt.' +exit 0 +``` + +- [ ] **Step 5: Testlauf, der bestehen muss** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php +``` + +Erwartet: PASS, achtundzwanzig Tests. + +- [ ] **Step 6: Commit** + +```bash +git add deploy/bootstrap/lib/bridge.sh deploy/bootstrap/lib/bridge-run.sh tests/Feature/Provisioning/BridgeScriptTest.php +git commit -m "bridge-run.sh: der Treiber, und der Zeitgeber steht vor jeder Aenderung" +``` + +--- + +### Task 6: `EnsureNetworkBridge` — die Abkürzung und der Guard + +Der Schritt, aber nur sein billigster Teil: schon da heißt nichts anfassen, und was keine physische Karte ist, wird nicht gebaut. Noch kein Start, noch kein Poll. + +**Files:** +- Create: `app/Provisioning/Steps/Host/EnsureNetworkBridge.php` +- Test: `tests/Feature/Provisioning/HostStepsTest.php` (anhängen) + +**Interfaces:** +- Consumes: `HostStep::keyLogin()`, `HostStep::host()`, `RemoteShell`, `StepResult`. +- Produces: + - `EnsureNetworkBridge::key(): 'ensure_network_bridge'` + - `EnsureNetworkBridge::WORK_DIR = '/var/lib/clupilot/bridge'` + - `EnsureNetworkBridge::maxDuration(): 3600` + +- [ ] **Step 1: Test schreiben** + +Anhängen an `tests/Feature/Provisioning/HostStepsTest.php` (und `use App\Provisioning\Steps\Host\EnsureNetworkBridge;` oben ergänzen): + +```php +// --- EnsureNetworkBridge --- +// +// Der Schritt, der an dem Ast sägt, auf dem er sitzt: SSH läuft hier über den +// Tunnel, und WireGuard hängt an derselben Karte, die in die Brücke wandert. +// Deshalb: erst der Zeitgeber, dann umstellen, dann neu verbinden, nachsehen, +// abbestellen. + +/** Was der Host auf die Zustandsfrage antwortet. */ +function scriptBridgeState(FakeRemoteShell $shell, bool $up, string $iface = 'enp0s31f6', bool $physical = true): void +{ + $shell->script('clupilot-bridge-state', CommandResult::success(implode("\n", [ + 'up='.($up ? 'yes' : 'no'), + 'iface='.$iface, + 'physical='.($physical ? 'yes' : 'no'), + 'route=default via 49.12.121.65 dev '.($up ? 'vmbr0' : $iface), + '', + ]))); +} + +/** Was der Host auf die Statusfrage antwortet. */ +function scriptBridgeStatus(FakeRemoteShell $shell, string $state, bool $alive = false, string $phase = '', string $note = '', bool $rolledBack = false): void +{ + $shell->script('clupilot-bridge-status', CommandResult::success(implode("\n", [ + 'state='.$state, + 'alive='.($alive ? 'yes' : 'no'), + 'phase='.$phase, + 'note='.$note, + 'rolledback='.($rolledBack ? 'yes' : 'no'), + '', + ]))); +} + +it('fasst einen Host, der die Brücke schon hat, nicht an', function () { + // pve-fns-1 hat sie von Hand. Ein Wiederanlauf, der sie umbaut, baut ein + // funktionierendes Netz um — und riskiert dafür genau das, wofür es die + // Rückfahrkarte gibt. + $s = fakeServices(); + $host = Host::factory()->active()->create(); + $run = hostRun($host); + proveTunnel($run, $host); + scriptBridgeState($s['shell'], up: true); + + expect(app(EnsureNetworkBridge::class)->execute($run)->type)->toBe('advance') + ->and($s['shell']->files())->toBe([]) + ->and($s['shell']->ran('clupilot-bridge-start'))->toBeFalse(); +}); + +it('baut keine Brücke auf einem Bond oder einer bestehenden Brücke', function () { + // bridge_ports darauf ist falsch: die Brücke nähme sich ihren eigenen + // Unterbau als Port, und was dabei herauskommt, ist aus der Ferne nicht mehr + // zu reparieren. + $s = fakeServices(); + $host = Host::factory()->active()->create(); + $run = hostRun($host); + proveTunnel($run, $host); + scriptBridgeState($s['shell'], up: false, iface: 'bond0', physical: false); + + $result = app(EnsureNetworkBridge::class)->execute($run); + + expect($result->type)->toBe('fail') + ->and($result->reason)->toContain('bond0') + ->and($s['shell']->ran('clupilot-bridge-start'))->toBeFalse(); +}); + +it('gibt auf, wenn gar keine Karte die Standardroute trägt', function () { + $s = fakeServices(); + $host = Host::factory()->active()->create(); + $run = hostRun($host); + proveTunnel($run, $host); + scriptBridgeState($s['shell'], up: false, iface: '', physical: false); + + expect(app(EnsureNetworkBridge::class)->execute($run)->type)->toBe('fail'); +}); +``` + +- [ ] **Step 2: Testlauf, der scheitern muss** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="Brücke schon|Bond oder|keine Karte" +``` + +Erwartet: FAIL — `Class "App\Provisioning\Steps\Host\EnsureNetworkBridge" not found`. + +- [ ] **Step 3: Den Schritt anlegen (nur Abkürzung und Guard)** + +`app/Provisioning/Steps/Host/EnsureNetworkBridge.php`: + +```php +host($run); + $this->keyLogin($this->shell, $host); + + $state = $this->readBridgeState(); + + // Already there means touch nothing. The same shortcut as "the template + // exists and reports template: 1" — and here it matters more, because + // rebuilding a working network is the one thing that can end this badly. + if ($state['up']) { + return StepResult::advance(); + } + + if ($state['iface'] === '') { + return StepResult::fail( + 'No interface on this host carries the default route, so there is nothing to derive a bridge '. + 'from. Check the machine on the provider console.' + ); + } + + // bridge_ports on a bond or an existing bridge is wrong: the bridge + // would take its own substrate as a port, and what comes out of that + // cannot be repaired from a remote shell. + if (! $state['physical']) { + return StepResult::fail( + 'The interface carrying the default route ('.$state['iface'].') is not a physical NIC — it is a '. + 'bridge, a bond or a VLAN. CluPilot will not put a bridge on top of it. Build vmbr0 by hand in '. + '/etc/network/interfaces, apply it with `ifreload -a`, confirm you still have SSH, then retry. '. + 'Current default route: '.($state['route'] ?: '(none reported)').'.' + ); + } + + // Start, poll and cancel arrive in the next task. + return StepResult::fail('not implemented yet'); + } + + /** + * What the host says about its bridge and its primary NIC, in one round trip. + * + * Derived from the RUNNING state — `ip`, `/sys/class/net` — never from the + * provider's file. What runs is structured the same everywhere; how it was + * written down is not, and that is the part that carries Hetzner and netcup + * at once. + * + * @return array{up:bool, iface:string, physical:bool, route:string} + */ + private function readBridgeState(): array + { + $out = $this->shell->run(implode("\n", [ + ': clupilot-bridge-state', + 'B=vmbr0', + 'IF=$(ip -4 route show default 2>/dev/null | awk \'{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }\')', + 'UP=no', + 'if ip link show "$B" >/dev/null 2>&1 && [ "$IF" = "$B" ] && [ -n "$(ip -4 -o addr show dev "$B" scope global 2>/dev/null)" ]; then UP=yes; fi', + 'PHY=no', + 'if [ -n "$IF" ] && [ -e "/sys/class/net/$IF/device" ] && [ ! -d "/sys/class/net/$IF/bridge" ] && [ ! -d "/sys/class/net/$IF/bonding" ]; then PHY=yes; fi', + 'printf \'up=%s\n\' "$UP"', + 'printf \'iface=%s\n\' "$IF"', + 'printf \'physical=%s\n\' "$PHY"', + 'printf \'route=%s\n\' "$(ip -4 route show default 2>/dev/null | head -1)"', + ]))->stdout; + + $parsed = ['up' => 'no', 'iface' => '', 'physical' => 'no', 'route' => '']; + + foreach (preg_split('/\R/', $out) ?: [] as $line) { + [$key, $value] = array_pad(explode('=', $line, 2), 2, ''); + if (array_key_exists($key, $parsed)) { + $parsed[$key] = trim($value); + } + } + + return [ + 'up' => $parsed['up'] === 'yes', + 'iface' => $parsed['iface'], + 'physical' => $parsed['physical'] === 'yes', + 'route' => $parsed['route'], + ]; + } +} +``` + +- [ ] **Step 4: Testlauf, der bestehen muss** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="Brücke schon|Bond oder|keine Karte" +``` + +Erwartet: PASS, drei Tests. + +- [ ] **Step 5: Commit** + +```bash +git add app/Provisioning/Steps/Host/EnsureNetworkBridge.php tests/Feature/Provisioning/HostStepsTest.php +git commit -m "EnsureNetworkBridge: schon da heisst nichts anfassen, und keine Bruecke auf einer Bruecke" +``` + +--- + +### Task 7: `EnsureNetworkBridge` — Start und Verbindungsabriss + +Der Teil, der über Leben und Tod des Laufs entscheidet: die Dateien wortgleich hochladen, abgekoppelt starten, und den erwarteten Verbindungsabriss als `poll` behandeln statt als `retry`. + +**Files:** +- Modify: `app/Provisioning/Steps/Host/EnsureNetworkBridge.php` +- Test: `tests/Feature/Provisioning/HostStepsTest.php` (anhängen) + +**Interfaces:** +- Consumes: `readBridgeState()` aus Task 6, `FakeRemoteShell::$failConnect`. +- Produces: Run-Kontext-Schlüssel `bridge_deadline` (ISO-8601) und `bridge_attempts` (int). + +- [ ] **Step 1: Test schreiben** + +Anhängen an `tests/Feature/Provisioning/HostStepsTest.php`: + +```php +it('lädt bridge.sh und bridge-run.sh wortgleich hoch und startet abgekoppelt', function () { + // Byte für Byte. In dem Moment, in dem diese Datei aus einer PHP-Vorlage + // gerendert statt gelesen wird, gibt es wieder zwei Fassungen der + // Anbieterformen — das eine, was der Entwurf verbietet. + $s = fakeServices(); + $host = Host::factory()->active()->create(); + $run = hostRun($host); + proveTunnel($run, $host); + scriptBridgeState($s['shell'], up: false); + + $result = app(EnsureNetworkBridge::class)->execute($run); + $files = $s['shell']->files(); + + expect($result->type)->toBe('poll') + ->and($files['/var/lib/clupilot/bridge/bridge.sh'] ?? null) + ->toBe(file_get_contents(base_path('deploy/bootstrap/lib/bridge.sh'))) + ->and($files['/var/lib/clupilot/bridge/bridge-run.sh'] ?? null) + ->toBe(file_get_contents(base_path('deploy/bootstrap/lib/bridge-run.sh'))) + // Der Hub-Schlüssel muss mit, sonst kann der Treiber den Handshake + // nicht gegen den RICHTIGEN Peer prüfen. + ->and($files['/var/lib/clupilot/bridge/env'] ?? '')->toContain('CLUPILOT_WG_HUB_PUBKEY=') + ->and($files['/var/lib/clupilot/bridge/env'] ?? '')->toContain('CLUPILOT_ROLLBACK_MINUTES=5') + ->and($s['shell']->ran('nohup'))->toBeTrue() + ->and($s['shell']->ran('setsid'))->toBeTrue() + ->and($run->fresh()->context('bridge_deadline'))->not->toBeNull(); +}); + +it('pollt weiter, wenn der Host während der Umstellung nicht antwortet', function () { + // DIE tragende Zeile. RunRunner 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. + $s = fakeServices(); + $host = Host::factory()->active()->create(); + $run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]); + proveTunnel($run, $host); + $s['shell']->failConnect = true; + + $result = app(EnsureNetworkBridge::class)->execute($run); + + expect($result->type)->toBe('poll') + ->and($result->reason)->toContain('Brücke'); +}); + +it('lässt einen Verbindungsfehler VOR der Umstellung ein gewöhnlicher bleiben', function () { + // Ohne Termin hat dieser Lauf nichts angefasst. Dann ist ein + // Verbindungsfehler kein erwarteter Abriss, sondern ein Fehler — und darf + // das Versuchskonto kosten, statt eine Stunde lang gepollt zu werden. + $s = fakeServices(); + $host = Host::factory()->active()->create(); + $run = hostRun($host); + proveTunnel($run, $host); + $s['shell']->failConnect = true; + + expect(app(EnsureNetworkBridge::class)->execute($run)->type)->toBe('retry'); +}); + +it('startet keinen zweiten Treiber, solange der erste lebt', function () { + // Zwei Treiber auf einer /etc/network/interfaces, beide mit eigenem + // Zeitgeber: der eine bestellt ab, was der andere gestellt hat. + $s = fakeServices(); + $host = Host::factory()->active()->create(); + $run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]); + proveTunnel($run, $host); + scriptBridgeState($s['shell'], up: false); + scriptBridgeStatus($s['shell'], 'running', alive: true, phase: 'umstellen'); + + $result = app(EnsureNetworkBridge::class)->execute($run); + + expect($result->type)->toBe('poll') + ->and($result->reason)->toContain('umstellen') + ->and($s['shell']->ran('clupilot-bridge-start'))->toBeFalse(); +}); + +it('gibt auf, wenn der Treiber tot ist, obwohl die Statusdatei running sagt', function () { + // Die Datei sagt running und wird es immer — niemand ist mehr da, der es + // ändert. Darauf zu pollen wartet eine Viertelstunde gegen eine Leiche. + $s = fakeServices(); + $host = Host::factory()->active()->create(); + $run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]); + proveTunnel($run, $host); + scriptBridgeState($s['shell'], up: false); + scriptBridgeStatus($s['shell'], 'running', alive: false, rolledBack: true); + + expect(app(EnsureNetworkBridge::class)->execute($run)->type)->toBe('fail'); +}); +``` + +- [ ] **Step 2: Testlauf, der scheitern muss** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="wortgleich hoch|während der Umstellung|VOR der Umstellung|zweiten Treiber|Treiber tot" +``` + +Erwartet: FAIL — `not implemented yet`. + +- [ ] **Step 3: `execute()` umbauen und Start/Status ergänzen** + +In `EnsureNetworkBridge.php` `use` ergänzen: + +```php +use App\Services\Wireguard\WireguardHub; +use Illuminate\Support\Carbon; +use Throwable; +``` + +Konstruktor: + +```php + public function __construct(private RemoteShell $shell, private WireguardHub $hub) {} +``` + +`execute()` ersetzen: + +```php + public function execute(ProvisioningRun $run): StepResult + { + $host = $this->host($run); + + // The connection dropping is the EXPECTED case here, not an error: + // `ifreload -a` takes the line this step is speaking over. Left + // uncaught, RunRunner turns the exception into retry() — and retry + // spends the attempt budget, five of which are gone in minutes, long + // before the host is back. + try { + $this->keyLogin($this->shell, $host); + } catch (Throwable $e) { + return $this->whileDisconnected($run, $e); + } + + $state = $this->readBridgeState(); + + // Already there means touch nothing. The same shortcut as "the template + // exists and reports template: 1" — and here it matters more, because + // rebuilding a working network is the one thing that can end this badly. + // + // It is also the success path: reaching this line means SSH arrived over + // the tunnel AND vmbr0 carries the default route with an address. That + // is bridge_proven, asked from the outside, which is the strongest form + // of the question there is. Only now is the timer cancelled. + if ($state['up']) { + if ($run->context('bridge_deadline') !== null) { + $this->cancelRollback(); + $run->forgetContext('bridge_deadline'); + } + + return StepResult::advance(); + } + + if ($state['iface'] === '') { + return StepResult::fail( + 'No interface on this host carries the default route, so there is nothing to derive a bridge '. + 'from. Check the machine on the provider console.' + ); + } + + // bridge_ports on a bond or an existing bridge is wrong: the bridge + // would take its own substrate as a port, and what comes out of that + // cannot be repaired from a remote shell. + if (! $state['physical']) { + return StepResult::fail( + 'The interface carrying the default route ('.$state['iface'].') is not a physical NIC — it is a '. + 'bridge, a bond or a VLAN. CluPilot will not put a bridge on top of it. Build vmbr0 by hand in '. + '/etc/network/interfaces, apply it with `ifreload -a`, confirm you still have SSH, then retry. '. + 'Current default route: '.($state['route'] ?: '(none reported)').'.' + ); + } + + // Nothing on the host is cleaned up when a run ends, so a status file + // can be older than this run. The deadline in the run context is the + // only thing that tells a first visit from a lost one: it is written + // when THIS run starts a build and cleared again by giveUp(). + if ($run->context('bridge_deadline') === null) { + return $this->start($run); + } + + $status = $this->readStatus(); + + return match ($status['state']) { + '' => $this->giveUp( + $run, + 'The bridge build was started but left no status behind, so it never really began. Check '. + self::WORK_DIR.' and the free space on the host, then retry.' + ), + 'running' => $this->whileRunning($run, $status), + // Reaching here means the driver said ok while vmbr0 does NOT carry + // the default route — so the timer got there first, or is about to. + 'ok' => $this->awaitRollback($run, $status, 'The bridge build reported success, but vmbr0 does not carry the default route.'), + 'failed' => $this->awaitRollback($run, $status, $status['note'] ?: 'no reason recorded'), + default => $this->giveUp($run, 'The bridge build left an unreadable state ("'.$status['state'].'").'), + }; + } + + /** + * The host did not answer. + * + * With a deadline in the context, this run has changed the network and the + * silence is the change taking effect — poll, do not retry. Without one, + * nothing has been touched and an unreachable host is an ordinary + * connection error that may cost an attempt. + */ + private function whileDisconnected(ProvisioningRun $run, Throwable $e): StepResult + { + $deadline = $run->context('bridge_deadline'); + + if ($deadline === null) { + return StepResult::retry(20, 'host not reachable: '.$e->getMessage()); + } + + if (now()->greaterThan(Carbon::parse($deadline))) { + $run->forgetContext('bridge_deadline'); + + return StepResult::fail( + 'The host did not come back over the tunnel after the bridge was applied, and the deadline of '. + self::DEADLINE_MINUTES.' minutes has passed. The rollback timer will have restored the previous '. + 'network configuration '.self::ROLLBACK_MINUTES.' minutes after the change, so the machine should '. + 'be reachable on its old settings — check it, then retry. Last error: '.$e->getMessage() + ); + } + + return StepResult::poll(20, 'warte darauf, dass der Host über die neue Brücke wieder antwortet'); + } + + /** Ship the library, write the env, launch detached. */ + private function start(ProvisioningRun $run): StepResult + { + $attempts = (int) $run->context('bridge_attempts', 0); + + if ($attempts >= self::MAX_ATTEMPTS) { + return StepResult::fail( + 'CluPilot tried to build vmbr0 automatically '.$attempts.' times and the host did not come back '. + 'both ways each time; the previous network configuration was restored. Build the bridge by hand '. + 'in /etc/network/interfaces (bridge_ports = the NIC carrying the default route, host address and '. + 'gateway moved onto vmbr0), apply it with `ifreload -a`, confirm you still have SSH, then retry '. + 'this run. The stanza CluPilot wrote is in '.self::WORK_DIR.'/bridge.log.' + ); + } + + $this->shell->putFile(self::WORK_DIR.'/bridge.sh', $this->asset('lib/bridge.sh')); + $this->shell->putFile(self::WORK_DIR.'/bridge-run.sh', $this->asset('lib/bridge-run.sh')); + + // The hub key so the driver can check the handshake against the RIGHT + // peer: staff peers live on the same hub, and their handshake says + // nothing about CluPilot's own way in. + $this->shell->putFile(self::WORK_DIR.'/env', implode("\n", [ + '# Written by CluPilot at the start of every bridge build. Do not edit.', + 'CLUPILOT_WORK_DIR='.self::WORK_DIR, + 'CLUPILOT_ROLLBACK_MINUTES='.self::ROLLBACK_MINUTES, + 'CLUPILOT_WG_HUB_PUBKEY='.escapeshellarg(trim($this->hub->publicKey())), + '', + ])); + + $run->mergeContext([ + 'bridge_deadline' => now()->addMinutes(self::DEADLINE_MINUTES)->toIso8601String(), + 'bridge_attempts' => $attempts + 1, + ]); + + // `setsid` puts the driver in a session of its own, so the pid it + // records is also its PROCESS GROUP id — which is what makes giving up + // possible later. The pid therefore comes from the script (`$$`), not + // from `$!` here: with setsid in between, `$!` can be a process that is + // already gone. Waiting for the file closes the gap that creates. + $this->shell->run(implode("\n", [ + ': clupilot-bridge-start', + 'W='.self::WORK_DIR, + 'mkdir -p "$W"', + 'rm -f "$W/rolled-back"', + "printf 'running' > \"\$W/state\"", + "printf 'starting' > \"\$W/phase\"", + ': > "$W/note"', + ': > "$W/pid"', + 'nohup setsid sh "$W/bridge-run.sh" > "$W/bridge.log" 2>&1 &', + 'i=0; while [ "$i" -lt 15 ] && [ ! -s "$W/pid" ]; do sleep 1; i=$((i + 1)); done', + ])); + + return StepResult::poll(20, 'Netzbrücke wird gebaut'); + } + + /** @param array{state:string, alive:string, phase:string, note:string, rolledback:string} $status */ + private function whileRunning(ProvisioningRun $run, array $status): StepResult + { + if ($status['alive'] !== 'yes') { + // A kill, an OOM, a reboot. The file says running and always will — + // there is nobody left to change it. The timer is still armed, so + // the machine gets its old configuration back either way. + return $this->awaitRollback( + $run, + $status, + 'The bridge build stopped running without reporting a result — it was killed or the host restarted.' + ); + } + + $deadline = $run->context('bridge_deadline'); + if ($deadline !== null && now()->greaterThan(Carbon::parse($deadline))) { + return $this->giveUp( + $run, + 'The bridge build passed its deadline of '.self::DEADLINE_MINUTES.' minutes while still in phase "'. + ($status['phase'] ?: 'unknown').'". Check '.self::WORK_DIR.'/bridge.log on the host.' + ); + } + + return StepResult::poll(20, 'Netzbrücke wird gebaut: '.($status['phase'] ?: 'läuft')); + } + + /** + * The four facts about a build in flight, plus whether the timer has fired. + * + * `alive` is computed on the host because that is the only place the answer + * exists: the pid means nothing here. + * + * @return array{state:string, alive:string, phase:string, note:string, rolledback:string} + */ + private function readStatus(): array + { + $out = $this->shell->run(implode("\n", [ + ': clupilot-bridge-status', + 'W='.self::WORK_DIR, + 'S=$(cat "$W/state" 2>/dev/null)', + 'P=$(cat "$W/pid" 2>/dev/null)', + 'A=no', + 'if [ -n "$P" ] && kill -0 "$P" 2>/dev/null; then A=yes; fi', + 'R=no', + 'if [ -f "$W/rolled-back" ]; then R=yes; fi', + "printf 'state=%s\\n' \"\$S\"", + "printf 'alive=%s\\n' \"\$A\"", + "printf 'phase=%s\\n' \"\$(head -1 \"\$W/phase\" 2>/dev/null)\"", + "printf 'note=%s\\n' \"\$(head -1 \"\$W/note\" 2>/dev/null)\"", + "printf 'rolledback=%s\\n' \"\$R\"", + ]))->stdout; + + $status = ['state' => '', 'alive' => 'no', 'phase' => '', 'note' => '', 'rolledback' => 'no']; + + foreach (preg_split('/\R/', $out) ?: [] as $line) { + [$key, $value] = array_pad(explode('=', $line, 2), 2, ''); + if (array_key_exists($key, $status)) { + $status[$key] = trim($value); + } + } + + return $status; + } + + /** The shipped file, read from the repo — never rendered from a string in here. */ + private function asset(string $relative): string + { + return (string) file_get_contents(base_path('deploy/bootstrap/'.$relative)); + } +``` + +`awaitRollback()`, `cancelRollback()` und `giveUp()` folgen in Task 8; für diesen Testlauf genügen Rümpfe: + +```php + /** @param array{state:string, alive:string, phase:string, note:string, rolledback:string} $status */ + private function awaitRollback(ProvisioningRun $run, array $status, string $reason): StepResult + { + return $this->giveUp($run, $reason); + } + + private function cancelRollback(): void {} + + private function giveUp(ProvisioningRun $run, string $reason): StepResult + { + $run->forgetContext('bridge_deadline'); + + return StepResult::fail($reason); + } +``` + +- [ ] **Step 4: Testlauf, der bestehen muss** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="wortgleich hoch|während der Umstellung|VOR der Umstellung|zweiten Treiber|Treiber tot" +``` + +Erwartet: PASS, fünf Tests. + +- [ ] **Step 5: Commit** + +```bash +git add app/Provisioning/Steps/Host/EnsureNetworkBridge.php tests/Feature/Provisioning/HostStepsTest.php +git commit -m "EnsureNetworkBridge: Start abgekoppelt, und der Verbindungsabriss wird gepollt statt gezaehlt" +``` + +--- + +### Task 8: `EnsureNetworkBridge` — abbestellen, warten, aufgeben + +Die drei Enden: abbestellen erst nach der Nachprüfung, auf die Rücknahme warten statt sofort zu scheitern, und aufgeben, ohne die Rückfahrkarte wegzuwerfen. + +**Files:** +- Modify: `app/Provisioning/Steps/Host/EnsureNetworkBridge.php` +- Test: `tests/Feature/Provisioning/HostStepsTest.php` (anhängen) + +**Interfaces:** +- Consumes: `readStatus()`, `readBridgeState()`, Kontext-Schlüssel aus Task 7. +- Produces: keine neuen öffentlichen Namen. + +- [ ] **Step 1: Test schreiben** + +Anhängen an `tests/Feature/Provisioning/HostStepsTest.php`: + +```php +it('bestellt den Zeitgeber erst ab, nachdem die Brücke nachgeprüft ist', function () { + // Reihenfolge, nicht Geschmack. Abbestellen heißt: die Rückfahrkarte + // wegwerfen. Wer das vor der Nachprüfung tut, tut es auf Verdacht. + $s = fakeServices(); + $host = Host::factory()->active()->create(); + $run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]); + proveTunnel($run, $host); + scriptBridgeState($s['shell'], up: true); + + $result = app(EnsureNetworkBridge::class)->execute($run); + + $recorded = $s['shell']->recorded(); + $stateAt = null; + $cancelAt = null; + foreach ($recorded as $i => $command) { + if ($stateAt === null && str_contains($command, 'clupilot-bridge-state')) { + $stateAt = $i; + } + if ($cancelAt === null && str_contains($command, 'clupilot-bridge-cancel')) { + $cancelAt = $i; + } + } + + expect($result->type)->toBe('advance') + ->and($stateAt)->not->toBeNull() + ->and($cancelAt)->not->toBeNull() + ->and($stateAt)->toBeLessThan($cancelAt) + // Und der Termin ist weg, sonst liest ein späterer Lauf ihn als „mitten + // in einer Umstellung". + ->and($run->fresh()->context('bridge_deadline'))->toBeNull(); +}); + +it('wartet auf die Rücknahme, statt einen halb umgestellten Host liegen zu lassen', function () { + // Solange der Zeitgeber aussteht, steckt die Maschine mitten in einer + // Umstellung. Ein fail() ließe sie dort liegen, und der Nächste, der + // hinsieht, fände einen halben Host ohne Erklärung. + $s = fakeServices(); + $host = Host::factory()->active()->create(); + $run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]); + proveTunnel($run, $host); + scriptBridgeState($s['shell'], up: false); + scriptBridgeStatus($s['shell'], 'failed', note: 'Tunnel steht nicht', rolledBack: false); + + $result = app(EnsureNetworkBridge::class)->execute($run); + + expect($result->type)->toBe('poll') + // Und der Termin bleibt stehen, sonst gilt der nächste Besuch als + // erster und startet einen zweiten Treiber. + ->and($run->fresh()->context('bridge_deadline'))->not->toBeNull(); +}); + +it('scheitert mit dem Grund des Treibers, sobald die Rücknahme durch ist', function () { + $s = fakeServices(); + $host = Host::factory()->active()->create(); + $run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]); + proveTunnel($run, $host); + scriptBridgeState($s['shell'], up: false); + scriptBridgeStatus($s['shell'], 'failed', note: 'Tunnel steht nicht', rolledBack: true); + + $result = app(EnsureNetworkBridge::class)->execute($run); + + expect($result->type)->toBe('fail') + ->and($result->reason)->toContain('Tunnel steht nicht') + ->and($run->fresh()->context('bridge_deadline'))->toBeNull(); +}); + +it('bestellt den Zeitgeber beim Aufgeben NICHT ab', function () { + // Er ist die Rückfahrkarte. Steht er noch, hat er seinen Grund — und wer ihn + // beim Aufgeben abbestellt, lässt den Host genau in dem Zustand stehen, für + // den er gestellt wurde. + $s = fakeServices(); + $host = Host::factory()->active()->create(); + $run = hostRun($host, ['bridge_deadline' => now()->subMinute()->toIso8601String()]); + proveTunnel($run, $host); + scriptBridgeState($s['shell'], up: false); + scriptBridgeStatus($s['shell'], 'running', alive: true, phase: 'nachsehen'); + + expect(app(EnsureNetworkBridge::class)->execute($run)->type)->toBe('fail') + ->and($s['shell']->ran('clupilot-bridge-cancel'))->toBeFalse(); +}); + +it('versucht es höchstens zweimal je Lauf', function () { + // Der zweite ist für den Fall, dass der Betreiber zwischen den Versuchen + // etwas repariert hat. Danach die Handarbeit-Meldung — ein dritter Versuch + // gegen dieselbe Maschine wäre nur dieselbe Viertelstunde noch einmal. + $s = fakeServices(); + $host = Host::factory()->active()->create(); + $run = hostRun($host, ['bridge_attempts' => 2]); + proveTunnel($run, $host); + scriptBridgeState($s['shell'], up: false); + + $result = app(EnsureNetworkBridge::class)->execute($run); + + expect($result->type)->toBe('fail') + ->and($result->reason)->toContain('by hand') + ->and($s['shell']->ran('clupilot-bridge-start'))->toBeFalse(); +}); + +it('erlaubt einem Betreiber, nach einem Fehlschlag von vorn anzufangen', function () { + // giveUp() löscht den Termin — sonst macht die Erst-Besuch-Prüfung aus + // jedem Retry nach einem Fehlschlag einen sofortigen zweiten Fehlschlag, + // ohne dass der Host überhaupt angefasst wurde. + $s = fakeServices(); + $host = Host::factory()->active()->create(); + $run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]); + proveTunnel($run, $host); + scriptBridgeState($s['shell'], up: false); + scriptBridgeStatus($s['shell'], 'failed', note: 'irgendwas', rolledBack: true); + + app(EnsureNetworkBridge::class)->execute($run); + + expect($run->fresh()->context('bridge_deadline'))->toBeNull() + // Der Zähler NICHT — sonst ist der Deckel von zwei Versuchen keiner. + ->and($run->fresh()->context('bridge_attempts'))->not->toBeNull(); +}); + +it('schickt nur Shell, die eine Shell auch parsen kann', function () { + // FakeRemoteShell führt keinen dieser Befehle aus. Ein verrutschtes + // Anführungszeichen käme sonst zuerst auf einem echten Host zur Ausführung + // — und der Schritt, den es kaputtmacht, ist der, der das Netz umstellt. + $s = fakeServices(); + $host = Host::factory()->active()->create(); + proveTunnel(hostRun($host), $host); + + $run = hostRun($host); + scriptBridgeState($s['shell'], up: false); + app(EnsureNetworkBridge::class)->execute($run); // state + start + + scriptBridgeStatus($s['shell'], 'failed', note: 'nope', rolledBack: true); + app(EnsureNetworkBridge::class)->execute( + hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]) + ); // state + status + log + reset + + $s2 = fakeServices(); + proveTunnel(hostRun($host), $host); + scriptBridgeState($s2['shell'], up: true); + app(EnsureNetworkBridge::class)->execute( + hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]) + ); // state + cancel + + $checked = 0; + foreach (array_merge($s['shell']->recorded(), $s2['shell']->recorded()) as $command) { + expect(Process::input($command)->run('sh -n')->successful()) + ->toBeTrue("kein gültiges POSIX-sh:\n".$command); + $checked++; + } + + expect($checked)->toBeGreaterThanOrEqual(6); +}); +``` + +- [ ] **Step 2: Testlauf, der scheitern muss** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="Zeitgeber erst ab|wartet auf die Rücknahme|Grund des Treibers|NICHT ab|höchstens zweimal|von vorn anzufangen|parsen kann" +``` + +Erwartet: FAIL — `clupilot-bridge-cancel` wird nie abgesetzt, `awaitRollback` gibt sofort auf. + +- [ ] **Step 3: Die drei Enden richtig bauen** + +Die Rümpfe aus Task 7 ersetzen: + +```php + /** + * Wait for the rollback to complete, and only then fail. + * + * Not immediately: while the timer is still pending the machine is in the + * middle of a change, and a fail() would leave it there — the next person to + * look would find half a host with no explanation of why. + * + * The rollback script writes `failed` plus a reason BEFORE it restores, so + * even a partial restore leaves a verdict, and touches `rolled-back` as its + * LAST action. That file is the signal: it is only there once the old + * configuration is genuinely back. + * + * The wait is capped by the step's deadline, which is three times the + * timer's fuse — so this cannot poll forever. + * + * @param array{state:string, alive:string, phase:string, note:string, rolledback:string} $status + */ + private function awaitRollback(ProvisioningRun $run, array $status, string $reason): StepResult + { + $deadline = $run->context('bridge_deadline'); + $expired = $deadline !== null && now()->greaterThan(Carbon::parse($deadline)); + + if ($status['rolledback'] !== 'yes' && ! $expired) { + return StepResult::poll( + 20, + 'die Brücke trägt nicht — warte auf die Rücknahme des Zeitgebers' + ); + } + + return $this->giveUp( + $run, + $reason.' The rollback timer has put the previous network configuration back'. + ($status['rolledback'] === 'yes' ? '' : ' (or is about to — its fuse is '.self::ROLLBACK_MINUTES.' minutes)'). + ', so the machine is reachable on its old settings.' + ); + } + + /** + * Throw the return ticket away — and never before the bridge has been + * checked. Reaching the caller means SSH arrived over the tunnel and vmbr0 + * carries the default route with an address, which is the proof from the + * outside. + */ + private function cancelRollback(): void + { + $this->shell->run(implode("\n", [ + ': clupilot-bridge-cancel', + 'U=clupilot-network-rollback', + 'systemctl stop "$U.timer" 2>/dev/null || true', + 'rm -f "/etc/systemd/system/$U.timer" "/etc/systemd/system/$U.service" "/usr/local/sbin/$U.sh"', + 'systemctl daemon-reload 2>/dev/null || true', + ])); + } + + /** + * Fail, and leave nothing behind that would make a retry read this verdict + * again — the status on the host and the deadline in the run, which is what + * execute() reads to tell a first visit from a lost one. + * + * `bridge_attempts` deliberately SURVIVES: it is the cap on how often + * CluPilot will drive a live host's network into a rollback, and clearing it + * here would make that cap meaningless. + * + * The rollback timer is deliberately NOT cancelled. It is the return ticket; + * if it is still armed, that is because nobody has proven the host came back + * — and cancelling it while giving up would leave the machine in exactly the + * state the timer exists for. + */ + private function giveUp(ProvisioningRun $run, string $reason): StepResult + { + $run->forgetContext('bridge_deadline'); + + $tail = trim($this->shell->run( + ': clupilot-bridge-log'."\n".'tail -n 25 '.self::WORK_DIR.'/bridge.log 2>/dev/null' + )->stdout); + + // Stop the driver BEFORE the status files go, never after. Clearing them + // makes the step retryable, and a retry meeting a driver still running + // gets two of them on one /etc/network/interfaces, each with a timer of + // its own — one cancelling what the other armed. + // + // The whole process group (`-$P`), because the driver spends its time + // waiting on an ifreload. The cmdline guard is against a recycled pid: + // this file can be minutes old, and killing a stranger's process group + // would be a far worse bug than the one being handled. + $this->shell->run(implode("\n", [ + ': clupilot-bridge-reset', + 'W='.self::WORK_DIR, + 'P=$(cat "$W/pid" 2>/dev/null)', + 'if [ -n "$P" ] && kill -0 "$P" 2>/dev/null && tr "\\0" " " < /proc/"$P"/cmdline 2>/dev/null | grep -q bridge-run; then', + ' kill -TERM -"$P" 2>/dev/null || kill -TERM "$P" 2>/dev/null', + ' i=0; while [ "$i" -lt 10 ] && kill -0 "$P" 2>/dev/null; do sleep 1; i=$((i + 1)); done', + ' kill -KILL -"$P" 2>/dev/null || kill -KILL "$P" 2>/dev/null', + 'fi', + 'rm -f "$W/state" "$W/pid" "$W/note" "$W/rolled-back"', + ])); + + return StepResult::fail($tail === '' ? $reason : $reason.' Last lines: '.$tail); + } +``` + +- [ ] **Step 4: Testlauf, der bestehen muss** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="Zeitgeber erst ab|wartet auf die Rücknahme|Grund des Treibers|NICHT ab|höchstens zweimal|von vorn anzufangen|parsen kann" +``` + +Erwartet: PASS, sieben Tests. + +- [ ] **Step 5: Commit** + +```bash +git add app/Provisioning/Steps/Host/EnsureNetworkBridge.php tests/Feature/Provisioning/HostStepsTest.php +git commit -m "EnsureNetworkBridge: abbestellen nach der Nachpruefung, aufgeben ohne die Rueckfahrkarte wegzuwerfen" +``` + +--- + +### Task 9: Verdrahten + +Der Schritt in die Pipeline, die Beschriftung in beide Sprachen, `bridge.sh` ins Bootstrap-Archiv — und die ganze Suite grün. + +**Files:** +- Modify: `config/provisioning.php` (Pipeline `host`, zwischen `RebootIntoPveKernel` und `ConfigureProxmox`) +- Modify: `lang/de/hosts.php:136-152`, `lang/en/hosts.php` (Block `step`) +- Modify: `tests/Feature/Host/BootstrapArchiveTest.php:43` (bridge.sh und bridge-run.sh erwarten) +- Test: `tests/Feature/Provisioning/HostStepsTest.php` (anhängen) + +**Interfaces:** +- Consumes: `EnsureNetworkBridge` aus Task 6–8. +- Produces: nichts Neues. + +- [ ] **Step 1: Test schreiben** + +Anhängen an `tests/Feature/Provisioning/HostStepsTest.php`: + +```php +it('baut die Brücke nach dem Neustart und vor ConfigureProxmox', function () { + // Nach dem Neustart, weil dort ifupdown2 steht und der Tunnel gerade + // bewiesen hat, dass er einen Neustart überlebt. Vor ConfigureProxmox, weil + // dessen refuseWithoutBridge() die Nachprüfung ist — bauen UND prüfen, + // dasselbe Paar wie BuildVmTemplate → VerifyVmTemplate. + $pipeline = config('provisioning.pipelines.host'); + + $bridge = array_search(EnsureNetworkBridge::class, $pipeline, true); + $reboot = array_search(RebootIntoPveKernel::class, $pipeline, true); + $proxmox = array_search(ConfigureProxmox::class, $pipeline, true); + + expect($bridge)->not->toBeFalse() + ->and($reboot)->toBeLessThan($bridge) + ->and($bridge)->toBeLessThan($proxmox); +}); + +it('lässt ConfigureProxmox die Nachprüfung bleiben', function () { + // Der Schritt davor baut. Dieser prüft. Beides, nicht eines von beiden — + // sonst meldet ein Bau, der still danebenging, trotzdem Erfolg. + $s = fakeServices(); + $host = Host::factory()->active()->create(); + $run = hostRun($host); + proveTunnel($run, $host); + // CommandResult::failure(int $exitCode = 1, string $stderr = '') — zwei + // Parameter, nicht drei. + $s['shell']->script('ip link show vmbr0', CommandResult::failure(1, 'Device does not exist')); + + expect(app(ConfigureProxmox::class)->execute($run)->type)->toBe('fail'); +}); + +it('beschriftet den Schritt in beiden Sprachen', function () { + // Die Konsole zeigt eine Zeile pro Schritt. Eine ohne Beschriftung zeigt den + // Schlüssel — und der Betreiber sieht beim Zusehen 'hosts.step.…'. + expect(trans('hosts.step.ensure_network_bridge', [], 'de'))->not->toBe('hosts.step.ensure_network_bridge') + ->and(trans('hosts.step.ensure_network_bridge', [], 'en'))->not->toBe('hosts.step.ensure_network_bridge'); +}); +``` + +- [ ] **Step 2: Testlauf, der scheitern muss** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="nach dem Neustart und vor|Nachprüfung bleiben|beiden Sprachen" +``` + +Erwartet: FAIL — `EnsureNetworkBridge` steht nicht in der Pipeline, die Beschriftung fehlt. + +- [ ] **Step 3: Verdrahten** + +In `config/provisioning.php`, in `pipelines.host`, zwischen `Host\RebootIntoPveKernel::class` und `Host\ConfigureProxmox::class`: + +```php + // 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. Ein Bau, der still danebenging, + // fällt dann hier auf und nicht erst beim ersten bezahlten Klon. + Host\EnsureNetworkBridge::class, +``` + +In `lang/de/hosts.php`, im Block `step`, nach `'reboot_into_pve_kernel'`: + +```php + 'ensure_network_bridge' => 'Netzbrücke vmbr0 bauen', +``` + +In `lang/en/hosts.php` an derselben Stelle: + +```php + 'ensure_network_bridge' => 'Build the vmbr0 network bridge', +``` + +In `tests/Feature/Host/BootstrapArchiveTest.php` bei Zeile 43 ergänzen: + +```php + ->toContain('bootstrap/lib/bridge.sh') + ->toContain('bootstrap/lib/bridge-run.sh') +``` + +- [ ] **Step 4: Testlauf, der bestehen muss** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="nach dem Neustart und vor|Nachprüfung bleiben|beiden Sprachen" +``` + +Erwartet: PASS, drei Tests. + +- [ ] **Step 5: Die ganze Suite** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest +``` + +Erwartet: alles grün. Ein Fehlschlag in `BootstrapArchiveTest` oder `HostStepsTest` heißt, dass eine Nachbarschaft übersehen wurde — nicht, dass der Test falsch ist. + +- [ ] **Step 6: Lint** + +```bash +cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pint --dirty +``` + +- [ ] **Step 7: Commit** + +```bash +git add config/provisioning.php lang/de/hosts.php lang/en/hosts.php tests/Feature/Host/BootstrapArchiveTest.php tests/Feature/Provisioning/HostStepsTest.php +git commit -m "EnsureNetworkBridge in die Pipeline: nach dem Neustart, vor der Nachpruefung" +``` + +--- + +## Nach dem Plan + +1. **R15 / Codex-Review** über den Diff, nach `docs/…/clupilot-r15-codex-review`. Höchstens zwei Runden ohne P1 (R22.2); Restbefunde als Folgepunkte. +2. **Version ziehen und Marke setzen** nach dem Release-Verfahren — Aktualisierungen hängen an `v*`-Marken. +3. **Abnahme auf echter Hardware.** Zwei Läufe, und der zweite ist der wichtigere: + - Frische Debian-13-Maschine ohne Brücke: Host anlegen, zusehen, `active` — ohne einen Handgriff. + - `pve-fns-1`, das die Brücke schon von Hand hat: der Schritt muss `advance()` melden und **nichts** anfassen. +4. **Nicht in diesem Schnitt** (bekannt, notiert): `network.sh:365` benutzt weiterhin `ping` in `wireguard_handshake_proven` — der stillgelegte Rettungssystem-Weg.