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