From 81df08aaa665baa00f65f28764664d8ac5aaf1ad Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 12:34:26 +0200 Subject: [PATCH 01/11] bridge.sh: die Erkennung herausgeloest und allein lauffaehig gemacht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Bruecken-Erkennung stand in network.sh, geschrieben fuer den stillgelegten Rettungssystem-Weg, und borgte 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 laedt die Bibliothek EINZELN auf den Host und faehrt sie dort — geborgte Helfer waeren dann nicht da. Also allein lauffaehig, mit log und http_get unter einem command-v-Schutz, damit der Bootstrap seine eigenen behaelt. Neu und aus dem laufenden Zustand abgeleitet: - interface_is_physical — bridge_ports auf einem Bond oder einer bestehenden Bridge ist falsch, und aus der Ferne nicht reparierbar. - address_is_dynamic — der Kernel markiert eine geleaste Adresse, das steht bei jedem Anbieter gleich da. Die Datei des Anbieters ist nur noch das Zweitsignal. Geprueft gegen eine echte sh mit aufgezeichneten ip-Ausgaben, plus die Zusicherung, dass es detect_primary_interface im Repo genau einmal gibt. Co-Authored-By: Claude Opus 5 --- deploy/bootstrap/clupilot-bootstrap.sh | 4 + deploy/bootstrap/lib/bridge.sh | 184 +++++++++++++++++ deploy/bootstrap/lib/network.sh | 70 +------ deploy/bootstrap/lib/proxmox.sh | 10 +- .../Feature/Provisioning/BridgeScriptTest.php | 194 ++++++++++++++++++ 5 files changed, 396 insertions(+), 66 deletions(-) create mode 100644 deploy/bootstrap/lib/bridge.sh create mode 100644 tests/Feature/Provisioning/BridgeScriptTest.php diff --git a/deploy/bootstrap/clupilot-bootstrap.sh b/deploy/bootstrap/clupilot-bootstrap.sh index 0881dbc..dfc3240 100755 --- a/deploy/bootstrap/clupilot-bootstrap.sh +++ b/deploy/bootstrap/clupilot-bootstrap.sh @@ -965,6 +965,10 @@ main() { CLUPILOT_BOOTSTRAP_DIR="$(resolve_bootstrap_dir)" # shellcheck source=lib/report.sh . "${CLUPILOT_BOOTSTRAP_DIR}/lib/report.sh" + # VOR proxmox.sh und network.sh: bridge.sh hält seit dem Debian-Weg die + # Brücke samt `detect_primary_interface`, und beide greifen darauf zu. + # shellcheck source=lib/bridge.sh + . "${CLUPILOT_BOOTSTRAP_DIR}/lib/bridge.sh" # shellcheck source=lib/proxmox.sh . "${CLUPILOT_BOOTSTRAP_DIR}/lib/proxmox.sh" # shellcheck source=lib/network.sh diff --git a/deploy/bootstrap/lib/bridge.sh b/deploy/bootstrap/lib/bridge.sh new file mode 100644 index 0000000..4b10723 --- /dev/null +++ b/deploy/bootstrap/lib/bridge.sh @@ -0,0 +1,184 @@ +# 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" +} diff --git a/deploy/bootstrap/lib/network.sh b/deploy/bootstrap/lib/network.sh index aeead2b..18004c1 100644 --- a/deploy/bootstrap/lib/network.sh +++ b/deploy/bootstrap/lib/network.sh @@ -37,70 +37,20 @@ # Die Sperre steht deshalb in Abschnitt `registered` (Task 9), an derselben # Stelle wie früher: nach dem bewiesenen Handshake, nach Traefik. -CLUPILOT_ROLLBACK_UNIT='clupilot-network-rollback' -CLUPILOT_NET_BACKUP='/var/lib/clupilot/interfaces.vor-der-bruecke' - # --------------------------------------------------------------------------- -# Feststellen, was da ist +# Feststellen, was da ist — steht jetzt in lib/bridge.sh # --------------------------------------------------------------------------- - -bridge_exists() { - ip link show "${1:-vmbr0}" >/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() { - _dev="$(ip -4 route show default 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }')" - [ "$_dev" = "${1:-vmbr0}" ] -} - -bridge_has_address() { - [ -n "$(ip -4 -o addr show dev "${1:-vmbr0}" scope global 2>/dev/null | awk '{ print $4; exit }')" ] -} - -# Wie der Anbieter das Netz aufzieht. Eine Brücke, die für den einen Fall -# richtig ist, nimmt den anderen vom Netz. +# `bridge_exists`, `bridge_carries_default_route`, `bridge_has_address`, +# `detect_network_style`, `detect_primary_interface` und die beiden Variablen +# `CLUPILOT_ROLLBACK_UNIT`/`CLUPILOT_NET_BACKUP` sind nach `lib/bridge.sh` +# gezogen, samt der Helfer, die sie sich früher aus `clupilot-bootstrap.sh` +# borgten. # -# - `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)" - - if grep -qsE "iface[[:space:]]+${_iface}[[:space:]]+inet[[:space:]]+dhcp" /etc/network/interfaces /etc/network/interfaces.d/* 2>/dev/null; then - printf 'dhcp' - return 0 - fi - - _cidr="$(ip -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')" - _gw="$(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" ] && ip -4 route get "$_gw" 2>/dev/null | grep -q "dev ${_iface}.*src"; then - printf 'subnet' - return 0 - fi - - printf 'routed' -} +# Der Grund ist der Debian-Weg: dort lädt `EnsureNetworkBridge` die Bibliothek +# EINZELN auf den Host und fährt sie, und geborgte Helfer wären dann nicht da. +# `clupilot-bootstrap.sh` sourced `bridge.sh` vor dieser Datei, hier ändert sich +# also nichts — außer, dass es die Brücke nur noch einmal im Repo gibt. # --------------------------------------------------------------------------- # Die Selbstrücknahme diff --git a/deploy/bootstrap/lib/proxmox.sh b/deploy/bootstrap/lib/proxmox.sh index d9680bb..3e597da 100644 --- a/deploy/bootstrap/lib/proxmox.sh +++ b/deploy/bootstrap/lib/proxmox.sh @@ -126,12 +126,10 @@ detect_boot_mode() { fi } -# 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() { - ip -4 route show default 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }' -} +# `detect_primary_interface` steht in lib/bridge.sh — eine Fassung, zwei +# Benutzer. clupilot-bootstrap.sh lädt bridge.sh vor dieser Datei, und die +# Funktion wird ohnehin erst zur Aufrufzeit aufgelöst, also ist die Reihenfolge +# unkritisch. # Setzt CLUPILOT_NET_CIDR, _GATEWAY, _DNS, _MAC aus dem, was das Rettungssystem # gerade benutzt. diff --git a/tests/Feature/Provisioning/BridgeScriptTest.php b/tests/Feature/Provisioning/BridgeScriptTest.php new file mode 100644 index 0000000..72d1144 --- /dev/null +++ b/tests/Feature/Provisioning/BridgeScriptTest.php @@ -0,0 +1,194 @@ + $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 steht bei + // jedem Anbieter gleich da — anders als die Datei, in die er es geschrieben + // hat. + $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; sein Kommentar behauptete, er halte die + // Abwesenheit fest, und er hielt nichts fest. + $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'); +}); + +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 beim Herauslösen 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); + } +}); From b630c079ca6fa62aa752a258601b088b9cf5170e Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 12:37:17 +0200 Subject: [PATCH 02/11] bridge.sh: die Strophe, gegen vier Anbieterfaelle geprueft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write_bridge_stanza ist von build_bridge getrennt: das Schreiben ist das, was eine Maschine umbringt, und so ist es pruefbar, ohne dafuer ein Netz neu laden zu muessen. Beide Pfade sind ueberschreibbar (CLUPILOT_INTERFACES_FILE, CLUPILOT_IFRELOAD), dieselbe Technik wie CLUPILOT_STORAGE_CFG beim Vorlagenbau. Drei Dinge kann die Fassung mehr als die alte in network.sh: - hwaddress festgenagelt. Eine Bruecke waehlt sonst die kleinste MAC ihrer Ports; bei einem Port ist das dieselbe, aber 'ist dieselbe' und 'bleibt dieselbe' sind zweierlei. - Zusatzrouten des Anbieters wandern mit. Ausgelassen bleiben die Kernel-Route zum eigenen Subnetz und die Link-Route zum Gateway — beide entstehen von selbst, und ein gescheitertes 'up' nimmt bei ifreload die ganze Strophe mit. - IPv6, aber nur statisch und global. SLAAC/DHCPv6 werden bewusst nicht nachgebaut (forwarding=1 laesst den Kernel RAs ohne accept_ra=2 verwerfen) — dafuer gibt es eine Zeile ins Protokoll statt eines stillen Verlusts. Geprueft gegen echte sh: Hetzner /32 routed mit pointopoint, netcup Subnetz ohne, DHCP ohne Adresse, statisches IPv6 mit fe80::1, SLAAC faellt weg, Zusatzrouten kommen mit. network.sh hat sein eigenes build_bridge abgegeben; ein Test haelt jetzt jede der sieben Brueckenfunktionen auf genau einer Stelle im Repo fest. Co-Authored-By: Claude Opus 5 --- deploy/bootstrap/lib/bridge.sh | 138 ++++++++++++ deploy/bootstrap/lib/network.sh | 62 +---- .../Feature/Provisioning/BridgeScriptTest.php | 213 +++++++++++++++++- 3 files changed, 348 insertions(+), 65 deletions(-) diff --git a/deploy/bootstrap/lib/bridge.sh b/deploy/bootstrap/lib/bridge.sh index 4b10723..1df0ea3 100644 --- a/deploy/bootstrap/lib/bridge.sh +++ b/deploy/bootstrap/lib/bridge.sh @@ -182,3 +182,141 @@ bridge_is_up() { && bridge_carries_default_route "$CLUPILOT_BRIDGE" \ && bridge_has_address "$CLUPILOT_BRIDGE" } + +# --------------------------------------------------------------------------- +# 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 +} diff --git a/deploy/bootstrap/lib/network.sh b/deploy/bootstrap/lib/network.sh index 18004c1..a5a258e 100644 --- a/deploy/bootstrap/lib/network.sh +++ b/deploy/bootstrap/lib/network.sh @@ -144,64 +144,12 @@ host_still_reachable() { http_get "$CLUPILOT_PROBE_URL" >/dev/null 2>&1 } -# Schreibt eine Brücke über die primäre Netzkarte. +# `build_bridge` und `write_bridge_stanza` stehen in lib/bridge.sh. # -# Läuft NUR, wenn der ISO-Installer keine hinterlassen hat — und dann unter dem -# Zeitgeber. Die drei Formen unterscheiden sich in genau der Zeile, die -# entscheidet, ob die Maschine danach noch da ist. -build_bridge() { - _iface="$1" - _style="$2" - _cidr="$3" - _gw="$4" - - _ip="${_cidr%%/*}" - - case "$_style" in - dhcp) - _inet='iface vmbr0 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 vmbr0 inet static' - _addr=" address ${_ip}/32 - pointopoint ${_gw} - gateway ${_gw}" - ;; - *) - _inet='iface vmbr0 inet static' - _addr=" address ${_cidr} - gateway ${_gw}" - ;; - esac - - cat > /etc/network/interfaces </dev/null 2>&1; then - ifreload -a - else - systemctl restart networking - fi -} +# Die dortige Fassung kann drei Dinge mehr, und jedes davon hat einen Grund: +# sie nagelt die MAC fest, nimmt die Zusatzrouten des Anbieters mit, und trennt +# das SCHREIBEN der Strophe vom Anwenden — damit prüfbar ist, was eine Maschine +# umbringt, ohne dafür ein Netz neu laden zu müssen. # --------------------------------------------------------------------------- # WireGuard diff --git a/tests/Feature/Provisioning/BridgeScriptTest.php b/tests/Feature/Provisioning/BridgeScriptTest.php index 72d1144..b615fb6 100644 --- a/tests/Feature/Provisioning/BridgeScriptTest.php +++ b/tests/Feature/Provisioning/BridgeScriptTest.php @@ -159,12 +159,15 @@ it('sagt nur dann „Brücke steht", wenn sie Route UND Adresse trägt', functio expect($out)->toBe('NEIN'); }); -it('hält detect_primary_interface an genau einer Stelle im Repo', function () { +it('hält jede Brückenfunktion 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. // + // Die Liste wächst mit bridge.sh mit: wer eine Funktion hierher zieht und + // die alte Fassung stehen lässt, fällt hier auf und nicht auf einem Host. + // // 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. @@ -173,14 +176,208 @@ it('hält detect_primary_interface an genau einer Stelle im Repo', function () { 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); - } - } + $functions = [ + 'detect_primary_interface', + 'detect_network_style', + 'bridge_exists', + 'bridge_carries_default_route', + 'bridge_has_address', + 'build_bridge', + 'write_bridge_stanza', + ]; - expect($hits)->toBe(['deploy/bootstrap/lib/bridge.sh']); + foreach ($functions as $function) { + $hits = []; + foreach ($files as $file) { + if (preg_match('/^'.$function.'\(\)/m', (string) file_get_contents($file))) { + $hits[] = str_replace(base_path().'/', '', $file); + } + } + + expect($hits)->toBe(['deploy/bootstrap/lib/bridge.sh'], $function.' steht nicht genau einmal im Repo'); + } +}); + +/* +|-------------------------------------------------------------------------- +| Die Strophe +|-------------------------------------------------------------------------- +| +| Vier Anbieterfä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, + ]); + + expect(file_get_contents($out)) + ->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, + ]); + + expect(file_get_contents($out)) + ->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 ein gescheitertes `up` nimmt bei + // `ifreload` die ganze 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, + ]); + + expect(file_get_contents($out)) + ->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, + ]); + + expect(file_get_contents($out)) + ->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, + ]); + + expect(file_get_contents($out)) + ->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'); }); it('lässt jede Bibliothek des Bootstraps von einer Shell parsen', function () { From 0005d321a33d8d9df5b1fb82e92e29ecee50c7dd Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 12:39:44 +0200 Subject: [PATCH 03/11] bridge.sh: die Rueckfahrkarte, und der Grund steht vor dem Zurueckspielen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Zeitgeber ist eine systemd-Einheit und kein 'sleep &': ein Hintergrundlauf stirbt mit seiner Sitzung, und die Sitzung ist genau das, was abreisst, wenn die Umstellung schiefgeht. Zwei Dinge gegenueber der Fassung, die in network.sh stand: - render_rollback_script ist vom Stellen getrennt, damit die Reihenfolge darin ohne systemd pruefbar ist. Und die Reihenfolge ist der Punkt: 'failed' samt Grund wird geschrieben, BEVOR zurueckgespielt wird — ein halb gegluecktes Zurueckspielen soll das Urteil trotzdem hinterlassen. 'rolled-back' kommt zuletzt und ist das Signal, auf das CluPilot wartet. - Der Zeitgeber raeumt seine Unit-Dateien nach dem Feuern selbst weg. Sonst sieht eine abgeschlossene Ruecknahme beim naechsten Hinsehen aus wie eine ausstehende. Der Treiber bestellt NICHT ab. Das tut CluPilot, nachdem es sich ueber den Tunnel neu verbunden hat — ein Skript auf dem Host kann ueber seine eigene Erreichbarkeit von aussen nur raten. Geprueft: der Zeitgeber steht vor dem ersten veraendernden Aufruf. Co-Authored-By: Claude Opus 5 --- deploy/bootstrap/lib/bridge.sh | 107 +++++++++++++++++ deploy/bootstrap/lib/network.sh | 79 ++----------- .../Feature/Provisioning/BridgeScriptTest.php | 110 ++++++++++++++++++ 3 files changed, 229 insertions(+), 67 deletions(-) diff --git a/deploy/bootstrap/lib/bridge.sh b/deploy/bootstrap/lib/bridge.sh index 1df0ea3..d31dc7a 100644 --- a/deploy/bootstrap/lib/bridge.sh +++ b/deploy/bootstrap/lib/bridge.sh @@ -43,6 +43,9 @@ 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}" +CLUPILOT_SYSTEMCTL="${CLUPILOT_SYSTEMCTL:-systemctl}" +CLUPILOT_UNIT_DIR="${CLUPILOT_UNIT_DIR:-/etc/systemd/system}" +CLUPILOT_SBIN_DIR="${CLUPILOT_SBIN_DIR:-/usr/local/sbin}" # --------------------------------------------------------------------------- # Die zwei Helfer, die diese Datei früher geborgt hat @@ -307,6 +310,110 @@ $(extra_routes "$_iface" "$_gw")${_inet6} EOF } +# --------------------------------------------------------------------------- +# 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 vom Treiber. Das tut CluPilot, nachdem es sich über +# den Tunnel neu verbunden und nachgesehen hat — ein Skript auf dem Host kann +# über seine eigene Erreichbarkeit von außen 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' +} + # Strophe schreiben und anwenden. Ab hier ist die Leitung in Gefahr. build_bridge() { write_bridge_stanza "$@" diff --git a/deploy/bootstrap/lib/network.sh b/deploy/bootstrap/lib/network.sh index a5a258e..730a624 100644 --- a/deploy/bootstrap/lib/network.sh +++ b/deploy/bootstrap/lib/network.sh @@ -53,76 +53,21 @@ # also nichts — außer, dass es die Brücke nur noch einmal im Repo gibt. # --------------------------------------------------------------------------- -# Die Selbstrücknahme +# Die Selbstrücknahme — steht jetzt in lib/bridge.sh # --------------------------------------------------------------------------- # -# Das Muster aus Block B, unverändert, nur im Skript statt in der Pipeline: -# sichern, Zeitgeber auf fünf Minuten, umstellen, nachsehen, abbestellen. +# `backup_network_config`, `schedule_network_rollback` und +# `cancel_network_rollback` sind mit der Brücke nach `lib/bridge.sh` gezogen. +# Gleiche Namen, gleicher Zweck, also ruft dieser Weg sie unverändert auf. # -# 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. - -backup_network_config() { - mkdir -p "$(dirname -- "$CLUPILOT_NET_BACKUP")" - tar czf "${CLUPILOT_NET_BACKUP}.tar.gz" \ - -C / etc/network/interfaces $( [ -d /etc/network/interfaces.d ] && printf 'etc/network/interfaces.d' ) \ - 2>/dev/null -} - -# Stellt einen Zeitgeber, der die gesicherte Konfiguration zurückspielt und das -# Netz neu startet, falls ihn niemand abbestellt. -schedule_network_rollback() { - _minutes="${1:-5}" - - cat > "/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh" </dev/null 2>&1; then - ifreload -a -else - systemctl restart networking -fi -logger -t clupilot 'Netzkonfiguration zurückgespielt: die Brücke hat den Host vom Netz genommen.' -EOF - chmod 700 "/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh" - - cat > "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.service" < "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.timer" </dev/null || true - rm -f "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.timer" \ - "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.service" \ - "/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh" - systemctl daemon-reload 2>/dev/null || true - log 'Selbstrücknahme abbestellt' -} +# Zwei Dinge kann die dortige Fassung mehr: +# +# - Das Rücknahme-Skript schreibt `failed` samt Grund in die Statusdatei, BEVOR +# es zurückspielt, und legt zuletzt `rolled-back` an. Der Debian-Weg pollt von +# außen und muss unterscheiden können, ob eine Rücknahme aussteht oder durch +# ist; ohne diese Zeilen liest er `running` und pollt gegen einen Lauf, den es +# nicht mehr gibt — der Zeitgeber beendet den Treiber ja gerade nicht. +# - Der Zeitgeber räumt seine Unit-Dateien nach dem Feuern selbst weg. # Antwortet die Maschine noch? # diff --git a/tests/Feature/Provisioning/BridgeScriptTest.php b/tests/Feature/Provisioning/BridgeScriptTest.php index b615fb6..022179c 100644 --- a/tests/Feature/Provisioning/BridgeScriptTest.php +++ b/tests/Feature/Provisioning/BridgeScriptTest.php @@ -184,6 +184,9 @@ it('hält jede Brückenfunktion an genau einer Stelle im Repo', function () { 'bridge_has_address', 'build_bridge', 'write_bridge_stanza', + 'backup_network_config', + 'schedule_network_rollback', + 'cancel_network_rollback', ]; foreach ($functions as $function) { @@ -380,6 +383,113 @@ it('schreibt eine Strophe, die ein Mensch als von CluPilot erkennt', function () ->toContain('EnsureNetworkBridge'); }); +/* +|-------------------------------------------------------------------------- +| 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'); + $doneAt = strpos($script, 'rolled-back'); + + expect($stateAt)->not->toBeFalse() + ->and($restoreAt)->not->toBeFalse() + ->and($doneAt)->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($doneAt)->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. Umgekehrt wäre es die Reihenfolge, die einen Host dauerhaft + // unerreichbar macht. + $bin = $this->dir.'/bin'; + mkdir($bin, 0o755, true); + file_put_contents("{$bin}/systemctl", "#!/bin/sh\necho \"systemctl \$*\" >> '{$this->dir}/calls'\n"); + file_put_contents("{$bin}/ifreload", "#!/bin/sh\necho 'ifreload' >> '{$this->dir}/calls'\n"); + chmod("{$bin}/systemctl", 0o755); + chmod("{$bin}/ifreload", 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($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() + ->and(strpos($calls, 'start clupilot-network-rollback.timer')) + ->toBeLessThan(strpos($calls, 'ifreload')) + // Und die Sicherung muss existieren, bevor der Zeitgeber sie + // überhaupt zurückspielen könnte. + ->and(file_exists($this->dir.'/sicherung.tar.gz'))->toBeTrue(); +}); + +it('bestellt beim Abbestellen alles ab, was gestellt wurde', function () { + // Ein Zeitgeber, dessen Unit-Dateien liegen bleiben, sieht beim nächsten + // Hinsehen aus wie eine ausstehende Rücknahme. + $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); + + runBridgeSh( + "schedule_network_rollback 5\ncancel_network_rollback", + [ + 'CLUPILOT_SYSTEMCTL' => "{$bin}/systemctl", + 'CLUPILOT_UNIT_DIR' => $this->dir.'/units', + 'CLUPILOT_SBIN_DIR' => $this->dir.'/sbin', + 'CLUPILOT_WORK_DIR' => $this->dir, + ] + ); + + expect(file_get_contents($this->dir.'/calls'))->toContain('stop clupilot-network-rollback.timer') + ->and(file_exists($this->dir.'/units/clupilot-network-rollback.timer'))->toBeFalse() + ->and(file_exists($this->dir.'/units/clupilot-network-rollback.service'))->toBeFalse() + ->and(file_exists($this->dir.'/sbin/clupilot-network-rollback.sh'))->toBeFalse(); +}); + it('lässt jede Bibliothek des Bootstraps von einer Shell parsen', function () { // network.sh und proxmox.sh werden beim Herauslösen beschnitten. Ein // verrutschter Schnitt fiele sonst erst auf dem Rettungssystem-Weg auf, und From e963821eb4e801c38c40712cbae26ec47965cf6e Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 12:47:02 +0200 Subject: [PATCH 04/11] bridge.sh: nachsehen in beide Richtungen, und kein ping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nur 'komme ich raus' zu pruefen ist notwendig und NICHT hinreichend. Der Fehlerfall: ifreload bringt vmbr0 sauber hoch, die Maschine erreicht das Internet, der Treiber waere zufrieden — aber wg0 kommt nicht zurueck. Dann lebt der Host oeffentlich, CluPilot ist ausgesperrt, und wer hier abbestellt, hat die Rueckfahrkarte weggeworfen. wg0.conf enthaelt keine Geraetebindung; der Tunnel haengt an der Quelladresse, die die Routing-Tabelle hergibt — und genau das ist die Groesse, die der Umbau anfasst. Das macht den Fall nicht unwahrscheinlicher, nur unauffaelliger: kein Fehler im Protokoll, nur ein Handshake, der ausbleibt. Ist der Handshake schal, wird wg-quick@wg0 EINMAL neu gestartet und nochmal nachgesehen. Gefahrlos, weil ifreload die SSH-Sitzung ohnehin schon mitgenommen hat — und es verwandelt einen haengenden Tunnel in einen laufenden statt in eine Ruecknahme. Kein ping: Hetzners Debian-Basis hat keins, PrepareBaseSystem installiert es nicht, und network.sh:190 haette damit immer 'nicht erreichbar' gesagt. Ein Test haelt bridge.sh ping-frei. Nebenbei ein Fehler in den Tests selbst behoben: 'if gibtsnicht; then … else echo NEIN; fi' ist in sh unwahr, also war jeder Test, der NEIN erwartete, gruen SOLANGE die Funktion fehlte. verdictBody() meldet jetzt FEHLT und trennt 'falsch' von 'gibt es nicht'. Co-Authored-By: Claude Opus 5 --- deploy/bootstrap/lib/bridge.sh | 89 ++++++++++ .../Feature/Provisioning/BridgeScriptTest.php | 162 +++++++++++++++++- 2 files changed, 248 insertions(+), 3 deletions(-) diff --git a/deploy/bootstrap/lib/bridge.sh b/deploy/bootstrap/lib/bridge.sh index d31dc7a..94453b4 100644 --- a/deploy/bootstrap/lib/bridge.sh +++ b/deploy/bootstrap/lib/bridge.sh @@ -43,6 +43,9 @@ 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}" +CLUPILOT_WG="${CLUPILOT_WG:-wg}" +CLUPILOT_HANDSHAKE_TRIES="${CLUPILOT_HANDSHAKE_TRIES:-15}" +CLUPILOT_HANDSHAKE_WAIT="${CLUPILOT_HANDSHAKE_WAIT:-2}" CLUPILOT_SYSTEMCTL="${CLUPILOT_SYSTEMCTL:-systemctl}" CLUPILOT_UNIT_DIR="${CLUPILOT_UNIT_DIR:-/etc/systemd/system}" CLUPILOT_SBIN_DIR="${CLUPILOT_SBIN_DIR:-/usr/local/sbin}" @@ -310,6 +313,92 @@ $(extra_routes "$_iface" "$_gw")${_inet6} EOF } +# --------------------------------------------------------------------------- +# 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. +# +# `0` ist der Wert, den WireGuard für einen Peer meldet, mit dem noch nie +# gesprochen wurde. Vorhanden ist er trotzdem — deshalb die eigene Prüfung +# darauf, statt ihn als Sekundenzahl von 1970 durchgehen zu lassen. +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 +} + # --------------------------------------------------------------------------- # Die Selbstrücknahme # --------------------------------------------------------------------------- diff --git a/tests/Feature/Provisioning/BridgeScriptTest.php b/tests/Feature/Provisioning/BridgeScriptTest.php index 022179c..d53ad37 100644 --- a/tests/Feature/Provisioning/BridgeScriptTest.php +++ b/tests/Feature/Provisioning/BridgeScriptTest.php @@ -58,6 +58,27 @@ function runBridgeSh(string $body, array $env = [], string $pre = ''): string return trim($result->output()); } +/** + * Baut einen Testrumpf, der JA oder NEIN meldet — und FEHLT, wenn es die + * Funktion gar nicht gibt. + * + * Ohne diese Unterscheidung ist jeder Test, der NEIN erwartet, grün, SOLANGE die + * Funktion fehlt: `if gibtsnicht; then … else echo NEIN; fi` ist in sh schlicht + * unwahr. Die rote Phase wäre dann aus dem falschen Grund rot und die grüne + * bewiese nichts — genau die Sorte Attrappe, die hier schon einmal vier echte + * Fehler durchgelassen hat. + * + * Die Ausgabe der Funktion selbst wird verworfen, damit `log`-Zeilen das Urteil + * nicht verwässern und `toBe` statt `toContain` geprüft werden kann. + */ +function verdictBody(string $call): string +{ + $function = strtok($call, ' '); + + return "command -v {$function} >/dev/null 2>&1 || { echo FEHLT; exit 1; }\n" + ."if {$call} >/dev/null 2>&1; then echo JA; else echo NEIN; fi"; +} + /** Ein /sys/class/net-Baum aus Textdateien. */ function fakeSysNet(string $dir, string $iface, string $mac, bool $physical = true): string { @@ -122,7 +143,7 @@ it('hält eine Bridge und einen Bond für keine physische Karte', function () { mkdir("{$sys}/bond0/bonding", 0o755, true); $out = runBridgeSh( - 'if interface_is_physical bond0; then echo JA; else echo NEIN; fi', + verdictBody('interface_is_physical bond0'), ['CLUPILOT_SYS_NET' => $sys] ); @@ -133,7 +154,7 @@ 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', + verdictBody('interface_is_physical enp0s31f6'), ['CLUPILOT_SYS_NET' => $sys] ); @@ -152,7 +173,7 @@ it('sagt nur dann „Brücke steht", wenn sie Route UND Adresse trägt', functio ]); $out = runBridgeSh( - 'if bridge_is_up; then echo JA; else echo NEIN; fi', + verdictBody('bridge_is_up'), ['CLUPILOT_IP' => $ip] ); @@ -490,6 +511,141 @@ it('bestellt beim Abbestellen alles ab, was gestellt wurde', function () { ->and(file_exists($this->dir.'/sbin/clupilot-network-rollback.sh'))->toBeFalse(); }); +/* +|-------------------------------------------------------------------------- +| 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; + file_put_contents("{$dir}/wg", "#!/bin/sh\nprintf '%s\\t%s\\n' '{$peer}' '{$when}'\n"); + chmod("{$dir}/wg", 0o755); + + return "{$dir}/wg"; +} + +/** Eine Brücke, die steht und trägt. */ +function fakeUpBridge(string $dir): string +{ + return fakeIp($dir, [ + 'link show vmbr0' => '5: vmbr0: mtu 1500', + '-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', + ]); +} + +it('bestellt nicht ab, wenn der Tunnel steht, aber das Internet nicht erreichbar ist', function () { + $ip = fakeUpBridge($this->dir); + $wg = fakeWg($this->dir, 'HUBKEY=', 5); + + $out = runBridgeSh( + verdictBody('bridge_proven'), + [ + '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)->toBe('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 = fakeUpBridge($this->dir); + $wg = fakeWg($this->dir, 'HUBKEY=', 3600); // eine Stunde alt + $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( + verdictBody('bridge_proven'), + [ + 'CLUPILOT_IP' => $ip, + 'CLUPILOT_WG' => $wg, + 'CLUPILOT_WG_HUB_PUBKEY' => 'HUBKEY=', + 'CLUPILOT_SYSTEMCTL' => "{$bin}/systemctl", + 'CLUPILOT_HANDSHAKE_TRIES' => '1', + 'CLUPILOT_HANDSHAKE_WAIT' => '0', + ], + pre: 'http_get() { return 0; }' + ); + + expect($out)->toBe('NEIN') + // Einmal nachhelfen gehört dazu: das ist zu diesem Zeitpunkt gefahrlos, + // weil ifreload die SSH-Sitzung ohnehin schon mitgenommen hat, und + // verwandelt einen hängenden Tunnel in einen laufenden statt in eine + // Rücknahme. + ->and(file_get_contents($this->dir.'/calls')) + ->toContain('restart wg-quick@wg0'); +}); + +it('bestellt ab, wenn beide Richtungen stimmen', function () { + $ip = fakeUpBridge($this->dir); + $wg = fakeWg($this->dir, 'HUBKEY=', 5); + + $out = runBridgeSh( + verdictBody('bridge_proven'), + [ + 'CLUPILOT_IP' => $ip, + 'CLUPILOT_WG' => $wg, + 'CLUPILOT_WG_HUB_PUBKEY' => 'HUBKEY=', + ], + pre: 'http_get() { return 0; }' + ); + + expect($out)->toBe('JA'); +}); + +it('erkennt einen Handshake von einem FREMDEN Peer nicht als den des Hubs', function () { + // Auf demselben Hub liegen auch Mitarbeiter-Zugänge. Deren Handshake sagt + // nichts über CluPilots eigenen Weg hierher. + $wg = fakeWg($this->dir, 'IRGENDWER=', 5); + + $out = runBridgeSh( + verdictBody('tunnel_handshake_fresh'), + ['CLUPILOT_WG' => $wg, 'CLUPILOT_WG_HUB_PUBKEY' => 'HUBKEY='] + ); + + expect($out)->toBe('NEIN'); +}); + +it('hält einen Handshake, der nie stattgefunden hat, nicht für frisch', function () { + // WireGuard meldet 0 für einen Peer, mit dem noch nie gesprochen wurde. + // Als Sekunden gelesen ist das 1970 — uralt, aber `0` ist auch der Wert, + // den eine naive Prüfung für „vorhanden" hält. + $wg = fakeWg($this->dir, 'HUBKEY=', 0); + file_put_contents($this->dir.'/wg', "#!/bin/sh\nprintf '%s\\t%s\\n' 'HUBKEY=' '0'\n"); + + $out = runBridgeSh( + verdictBody('tunnel_handshake_fresh'), + ['CLUPILOT_WG' => $this->dir.'/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 (curl, gnupg, ifupdown2, chrony). 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'); +}); + it('lässt jede Bibliothek des Bootstraps von einer Shell parsen', function () { // network.sh und proxmox.sh werden beim Herauslösen beschnitten. Ein // verrutschter Schnitt fiele sonst erst auf dem Rettungssystem-Weg auf, und From b0761de1ebab220846a776827651b9cd5ec43f5d Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 12:56:16 +0200 Subject: [PATCH 05/11] bridge-run.sh: der Treiber, und der Zeitgeber steht vor jeder Aenderung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verdrahtet bridge.sh in der einen Reihenfolge, die stimmen muss: sichern -> Zeitgeber -> uebernehmen -> umstellen -> nachsehen. Alles davor stellt nur fest und veraendert nichts; ab 'sichern' gibt es einen Weg zurueck, und erst ab dann darf ueberhaupt etwas angefasst werden. Abgekoppelt ist hier Bedingung, nicht Optimierung: ifreload -a nimmt die Leitung, ueber die der Befehl laeuft. PID als allererstes, damit ein frueher Poll nicht 'running aber nicht lebendig' liest und einen gesunden Lauf fuer tot erklaert. Der Treiber bestellt den Zeitgeber NIE ab — das tut CluPilot nach dem Wiederverbinden. Ein Test haelt das fest. Dazu die Fremdverwalter-Erkennung in bridge.sh: cloud-init, networkd, NetworkManager werden benannt und entmachtet, Unbekanntes fuehrt zum Abbruch statt zu einem Versuch ins Blaue. Der Zeitgeber faengt diesen Fall NICHT ab — zu seiner Zeit war alles in Ordnung, und die Bruecke verschwaende erst beim naechsten Neustart, mit Kunden darauf. Drei Tests fahren den Treiber in einer Sandkiste WIRKLICH durch, statt nur seinen Text zu lesen: die ip-Attrappe antwortet vor dem ifreload anders als danach. Sie belegen ok, failed-ohne-Bruecke und failed-mit-schalem-Tunnel — und in allen drei Faellen, dass die Rueckfahrkarte stehen bleibt. Co-Authored-By: Claude Opus 5 --- deploy/bootstrap/lib/bridge-run.sh | 152 ++++++++++ deploy/bootstrap/lib/bridge.sh | 79 ++++++ .../Feature/Provisioning/BridgeScriptTest.php | 266 ++++++++++++++++++ 3 files changed, 497 insertions(+) create mode 100644 deploy/bootstrap/lib/bridge-run.sh diff --git a/deploy/bootstrap/lib/bridge-run.sh b/deploy/bootstrap/lib/bridge-run.sh new file mode 100644 index 0000000..d99227a --- /dev/null +++ b/deploy/bootstrap/lib/bridge-run.sh @@ -0,0 +1,152 @@ +#!/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, samt der geschriebenen Strophe +# +# `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" + +# CLUPILOT_WG_HUB_PUBKEY und alles andere Veränderliche schreibt der Schritt. +# 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 + +# --------------------------------------------------------------------------- +# Feststellen — noch wird nichts verändert +# --------------------------------------------------------------------------- + +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}" + +# Unbekanntes wird nicht ins Blaue gebaut. Ein Verwalter, den dieses Skript nicht +# entmachten kann, nimmt die Bruecke beim naechsten Neustart wieder weg — und das +# faellt erst auf, wenn Kunden darauf liegen. +_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 diff --git a/deploy/bootstrap/lib/bridge.sh b/deploy/bootstrap/lib/bridge.sh index 94453b4..d519bb5 100644 --- a/deploy/bootstrap/lib/bridge.sh +++ b/deploy/bootstrap/lib/bridge.sh @@ -36,6 +36,7 @@ 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_ETC="${CLUPILOT_ETC:-/etc}" CLUPILOT_IP="${CLUPILOT_IP:-ip}" CLUPILOT_IFRELOAD="${CLUPILOT_IFRELOAD:-ifreload}" CLUPILOT_PROBE_URL="${CLUPILOT_PROBE_URL:-http://deb.debian.org/}" @@ -313,6 +314,84 @@ $(extra_routes "$_iface" "$_gw")${_inet6} EOF } +# --------------------------------------------------------------------------- +# 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: zu seiner Zeit war alles in Ordnung. +# 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: die Strophe behält `source interfaces.d/*`. Bleibt + # dort eine Datei liegen, die dieselbe Karte beansprucht, hat der Host zwei + # Stellen, die seine Adresse vergeben. + _iface="${2:-}" + if [ -n "$_iface" ] && [ -d "$CLUPILOT_INTERFACES_D" ]; then + for _f in "$CLUPILOT_INTERFACES_D"/*; do + [ -f "$_f" ] || continue + case "$_f" in + *.von-clupilot-beiseitegelegt) continue ;; + esac + 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 +} + # --------------------------------------------------------------------------- # Nachsehen — beide Richtungen # --------------------------------------------------------------------------- diff --git a/tests/Feature/Provisioning/BridgeScriptTest.php b/tests/Feature/Provisioning/BridgeScriptTest.php index d53ad37..f9fa15c 100644 --- a/tests/Feature/Provisioning/BridgeScriptTest.php +++ b/tests/Feature/Provisioning/BridgeScriptTest.php @@ -79,6 +79,22 @@ function verdictBody(string $call): string ."if {$call} >/dev/null 2>&1; then echo JA; else echo NEIN; fi"; } +/** + * Ruft eine Funktion, die etwas ausgibt — und meldet FEHLT, wenn es sie nicht + * gibt. Dieselbe Begründung wie bei verdictBody(): eine leere Ausgabe und eine + * fehlende Funktion sind von außen nicht zu unterscheiden. + */ +function callBridgeFn(string $call, array $env = [], string $pre = ''): string +{ + $function = strtok($call, ' '); + + return runBridgeSh( + "command -v {$function} >/dev/null 2>&1 || { echo FEHLT; exit 1; }\n{$call}", + $env, + $pre + ); +} + /** Ein /sys/class/net-Baum aus Textdateien. */ function fakeSysNet(string $dir, string $iface, string $mac, bool $physical = true): string { @@ -646,6 +662,256 @@ it('benutzt an keiner Stelle ping', function () { ->not->toMatch('/(^|[^-\w])ping\s/m'); }); +/* +|-------------------------------------------------------------------------- +| 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. + $driver = base_path('deploy/bootstrap/lib/bridge-run.sh'); + expect(file_exists($driver))->toBeTrue('bridge-run.sh fehlt'); + + $pidAt = null; + foreach (file($driver) ?: [] as $i => $line) { + if (str_contains($line, '/pid"') && str_contains($line, '$$')) { + $pidAt = $i; + break; + } + } + + expect($pidAt)->not->toBeNull('keine Zeile schreibt $$ in die pid-Datei') + ->and($pidAt)->toBeLessThan(60); +}); + +it('stellt den Zeitgeber vor dem ersten verändernden Aufruf', function () { + // Dieselbe Zusicherung wie bei bridge.sh, hier auf der Ebene des Treibers: + // die Reihenfolge der AUFRUFE im Skripttext. Umgekehrt wäre es die + // Reihenfolge, die einen Host dauerhaft unerreichbar macht. + $driver = base_path('deploy/bootstrap/lib/bridge-run.sh'); + expect(file_exists($driver))->toBeTrue('bridge-run.sh fehlt'); + + $text = file_get_contents($driver); + $backup = strpos($text, 'backup_network_config'); + $schedule = strpos($text, 'schedule_network_rollback'); + $disown = strpos($text, 'disown_network_manager'); + $build = strpos($text, 'build_bridge'); + + expect($backup)->not->toBeFalse() + ->and($schedule)->not->toBeFalse() + ->and($disown)->not->toBeFalse() + ->and($build)->not->toBeFalse() + ->and($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 von außen. + $driver = base_path('deploy/bootstrap/lib/bridge-run.sh'); + expect(file_exists($driver))->toBeTrue('bridge-run.sh fehlt'); + + expect(file_get_contents($driver))->not->toContain('cancel_network_rollback'); +}); + +it('lässt den Treiber von einer Shell parsen', function () { + $driver = base_path('deploy/bootstrap/lib/bridge-run.sh'); + expect(file_exists($driver))->toBeTrue('bridge-run.sh fehlt'); + + expect(Process::run(['sh', '-n', $driver])->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, denn zu seiner Zeit war alles in Ordnung. + $etc = $this->dir.'/etc'; + mkdir("{$etc}/cloud", 0o755, true); + file_put_contents("{$etc}/cloud/cloud.cfg", "datasource_list: [ Hetzner ]\n"); + + expect(callBridgeFn('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(callBridgeFn('foreign_network_manager', ['CLUPILOT_ETC' => $etc])) + ->toBe(''); +}); + +it('entmachtet cloud-init im gesicherten Stand', function () { + $etc = $this->dir.'/etc'; + mkdir("{$etc}/cloud", 0o755, true); + file_put_contents("{$etc}/cloud/cloud.cfg", "datasource_list: [ Hetzner ]\n"); + + runBridgeSh('disown_network_manager cloud-init ens3', ['CLUPILOT_ETC' => $etc]); + + expect(file_get_contents("{$etc}/cloud/cloud.cfg.d/99-clupilot-disable-network.cfg")) + ->toContain('network: {config: disabled}'); +}); + +it('legt eine kollidierende interfaces.d-Strophe beiseite', function () { + // Die Strophe behält `source interfaces.d/*`. Bleibt dort eine Datei + // liegen, die dieselbe Karte beansprucht, hat der Host zwei Stellen, die + // seine Adresse vergeben. + $etc = $this->dir.'/etc'; + $d = $this->dir.'/interfaces.d'; + mkdir($etc, 0o755, true); + mkdir($d, 0o755, true); + file_put_contents("{$d}/50-cloud-init", "auto ens3\niface ens3 inet static\n address 10.0.0.7/24\n"); + file_put_contents("{$d}/99-egal", "iface eth9 inet manual\n"); + + runBridgeSh('disown_network_manager "" ens3', [ + 'CLUPILOT_ETC' => $etc, + 'CLUPILOT_INTERFACES_D' => $d, + ]); + + expect(file_exists("{$d}/50-cloud-init"))->toBeFalse() + ->and(file_exists("{$d}/50-cloud-init.von-clupilot-beiseitegelegt"))->toBeTrue() + // Eine Strophe für eine ANDERE Karte bleibt, wo sie ist. + ->and(file_exists("{$d}/99-egal"))->toBeTrue(); +}); + +/** + * Baut eine Sandkiste, in der bridge-run.sh wirklich durchläuft. + * + * Der Kniff ist die `ip`-Attrappe: sie antwortet VOR dem `ifreload` anders als + * danach, weil die `ifreload`-Attrappe eine Marke setzt. Ohne das könnte + * `bridge_proven` nie wahr werden — die Brücke trägt die Standardroute ja erst, + * nachdem umgestellt wurde. + * + * @return array{work:string, calls:string} + */ +function bridgeSandbox(string $dir, bool $bridgeComesUp = true, int $handshakeAge = 5): array +{ + $work = "{$dir}/work"; + $bin = "{$dir}/bin"; + $state = "{$dir}/state"; + foreach ([$work, $bin, $state, "{$dir}/units", "{$dir}/sbin"] as $d) { + mkdir($d, 0o755, true); + } + + copy(base_path('deploy/bootstrap/lib/bridge.sh'), "{$work}/bridge.sh"); + file_put_contents("{$dir}/interfaces", "# die Fassung von vorher\n"); + + $up = $bridgeComesUp ? "[ -f '{$state}/bridged' ]" : 'false'; + + file_put_contents("{$bin}/ip", <<> '{$dir}/calls'\n"); + file_put_contents("{$bin}/systemctl", "#!/bin/sh\necho \"systemctl \$*\" >> '{$dir}/calls'\n"); + file_put_contents("{$bin}/wg", "#!/bin/sh\nprintf '%s\\t%s\\n' 'HUBKEY=' '".(time() - $handshakeAge)."'\n"); + file_put_contents("{$bin}/logger", "#!/bin/sh\nexit 0\n"); + foreach (['ip', 'ifreload', 'systemctl', 'wg', 'logger'] as $f) { + chmod("{$bin}/{$f}", 0o755); + } + + // `http_get` als Funktion: bridge.sh legt seine eigene nur unter einem + // `command -v`-Schutz an, wer zuerst da ist behält recht — und env wird vor + // bridge.sh gesourced. + file_put_contents("{$work}/env", implode("\n", [ + "CLUPILOT_WORK_DIR={$work}", + "CLUPILOT_IP={$bin}/ip", + "CLUPILOT_IFRELOAD={$bin}/ifreload", + "CLUPILOT_SYSTEMCTL={$bin}/systemctl", + "CLUPILOT_WG={$bin}/wg", + "CLUPILOT_INTERFACES_FILE={$dir}/interfaces", + "CLUPILOT_INTERFACES_D={$dir}/interfaces.d", + "CLUPILOT_NET_BACKUP={$dir}/sicherung", + "CLUPILOT_UNIT_DIR={$dir}/units", + "CLUPILOT_SBIN_DIR={$dir}/sbin", + "CLUPILOT_SYS_NET={$dir}/sys", + "CLUPILOT_ETC={$dir}/etc", + 'CLUPILOT_WG_HUB_PUBKEY=HUBKEY=', + 'CLUPILOT_HANDSHAKE_TRIES=1', + 'CLUPILOT_HANDSHAKE_WAIT=0', + 'http_get() { return 0; }', + '', + ])); + + fakeSysNet($dir, 'enp0s31f6', 'a8:a1:59:00:11:22'); + mkdir("{$dir}/etc", 0o755, true); + + return ['work' => $work, 'calls' => "{$dir}/calls"]; +} + +it('fährt den ganzen Ablauf durch und meldet ok', function () { + // Bis hierher prüfen die Tests, dass der Treiber DASTEHT — `sh -n` und die + // Reihenfolge im Text. Keiner prüft, dass er LÄUFT. Genau das ist aber die + // Datei, die auf einem echten Host das Netz umstellt. + $box = bridgeSandbox($this->dir); + + $result = Process::path($box['work']) + ->env(['CLUPILOT_WORK_DIR' => $box['work']]) + ->run('sh '.base_path('deploy/bootstrap/lib/bridge-run.sh')); + + expect(trim((string) file_get_contents($box['work'].'/state')))->toBe('ok') + ->and(trim((string) file_get_contents($box['work'].'/phase')))->toBe('wartet auf CluPilot') + ->and($result->exitCode())->toBe(0) + // Die Strophe ist geschrieben und trägt die Karte als Port. + ->and(file_get_contents($this->dir.'/interfaces'))->toContain('bridge-ports enp0s31f6') + // Und die Rückfahrkarte steht noch: abbestellen ist CluPilots Sache. + ->and(file_exists($this->dir.'/units/clupilot-network-rollback.timer'))->toBeTrue() + ->and(file_get_contents($box['calls']))->not->toContain('stop clupilot-network-rollback.timer'); +}); + +it('meldet failed und lässt den Zeitgeber stehen, wenn die Brücke nicht trägt', function () { + // Der Fall, für den die ganze Konstruktion gebaut ist. Der Treiber räumt + // NICHT selbst auf — er überlässt es dem Zeitgeber und sagt nur, warum. + $box = bridgeSandbox($this->dir, bridgeComesUp: false); + + $result = Process::path($box['work']) + ->env(['CLUPILOT_WORK_DIR' => $box['work']]) + ->run('sh '.base_path('deploy/bootstrap/lib/bridge-run.sh')); + + expect(trim((string) file_get_contents($box['work'].'/state')))->toBe('failed') + ->and($result->exitCode())->toBe(1) + ->and(file_get_contents($box['work'].'/note'))->toContain('Zeitgeber') + ->and(file_exists($this->dir.'/units/clupilot-network-rollback.timer'))->toBeTrue() + ->and(file_get_contents($box['calls']))->not->toContain('stop clupilot-network-rollback.timer'); +}); + +it('meldet failed, wenn die Brücke steht, der Tunnel aber schal bleibt', function () { + // Öffentlich erreichbar und trotzdem ausgesperrt: die Richtung, die der + // Treiber ohne die Handshake-Prüfung für Erfolg gehalten hätte. + $box = bridgeSandbox($this->dir, handshakeAge: 3600); + + Process::path($box['work']) + ->env(['CLUPILOT_WORK_DIR' => $box['work']]) + ->run('sh '.base_path('deploy/bootstrap/lib/bridge-run.sh')); + + expect(trim((string) file_get_contents($box['work'].'/state')))->toBe('failed') + ->and(file_get_contents($box['calls']))->toContain('restart wg-quick@wg0') + ->and(file_exists($this->dir.'/units/clupilot-network-rollback.timer'))->toBeTrue(); +}); + it('lässt jede Bibliothek des Bootstraps von einer Shell parsen', function () { // network.sh und proxmox.sh werden beim Herauslösen beschnitten. Ein // verrutschter Schnitt fiele sonst erst auf dem Rettungssystem-Weg auf, und From 786dd54257f78f95d3496a9eaefdf0faaa57b8f7 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 13:02:23 +0200 Subject: [PATCH 06/11] EnsureNetworkBridge: schon da heisst nichts anfassen, und keine Bruecke auf einer Bruecke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Schritt, aber erst sein billigster Teil. Zwei Zusicherungen: - Traegt vmbr0 schon Standardroute UND Adresse, dann advance() ohne einen einzigen veraendernden Befehl. pve-fns-1 hat die Bruecke von Hand; ein Wiederanlauf, der sie umbaut, baut ein funktionierendes Netz um. - Ist die Karte mit der Standardroute keine physische (Bond, Bridge, VLAN), dann fail() mit Klartext statt bauen. bridge_ports darauf waere falsch, und was dabei herauskommt, ist aus der Ferne nicht mehr zu reparieren. readBridgeState fragt beides in einem Rundlauf und leitet aus dem LAUFENDEN Zustand ab (ip, /sys/class/net), nie aus der Datei des Anbieters. 'up' sind absichtlich alle drei Fakten: eine vmbr0 ohne Adresse und ohne Standardroute ist eine Bruecke im Sinne von 'ip link' und sonst nichts. Start, Poll und Abbestellen folgen. Bis dahin steht dort ein fail() — gefahrlos, weil der Schritt noch nicht in der Pipeline haengt. Co-Authored-By: Claude Opus 5 --- .../Steps/Host/EnsureNetworkBridge.php | 172 ++++++++++++++++++ tests/Feature/Provisioning/HostStepsTest.php | 76 ++++++++ 2 files changed, 248 insertions(+) create mode 100644 app/Provisioning/Steps/Host/EnsureNetworkBridge.php diff --git a/app/Provisioning/Steps/Host/EnsureNetworkBridge.php b/app/Provisioning/Steps/Host/EnsureNetworkBridge.php new file mode 100644 index 0000000..49085ce --- /dev/null +++ b/app/Provisioning/Steps/Host/EnsureNetworkBridge.php @@ -0,0 +1,172 @@ +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 (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. '. + 'Current default route: '.($state['route'] ?: '(none reported)').'.' + ); + } + + // Start, poll and cancel arrive in the next task. Harmless until then: + // the step is not in the pipeline yet, so no run can reach this line. + 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. + * + * "up" is deliberately all three facts and not `ip link show`: a vmbr0 with + * no address and no default route is a bridge in the sense of `ip link` and + * nothing else. The step this replaces checked exactly that and threw the + * answer away. + * + * @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'], + ]; + } +} diff --git a/tests/Feature/Provisioning/HostStepsTest.php b/tests/Feature/Provisioning/HostStepsTest.php index dad8f2f..b5e74ef 100644 --- a/tests/Feature/Provisioning/HostStepsTest.php +++ b/tests/Feature/Provisioning/HostStepsTest.php @@ -13,6 +13,7 @@ use App\Provisioning\Steps\Host\CompleteHostOnboarding; use App\Provisioning\Steps\Host\ConfigureProxmox; use App\Provisioning\Steps\Host\ConfigureWireguard; use App\Provisioning\Steps\Host\CreateAutomationToken; +use App\Provisioning\Steps\Host\EnsureNetworkBridge; use App\Provisioning\Steps\Host\EstablishSshTrust; use App\Provisioning\Steps\Host\InstallProxmoxVe; use App\Provisioning\Steps\Host\PrepareBaseSystem; @@ -1646,3 +1647,78 @@ it('asks only for privileges Proxmox 9 still knows', function () { expect(array_diff($angefordert, $gueltig))->toBe([]); }); + +// --- 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 den Zeitgeber stellen, 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') + ->and($s['shell']->ran('clupilot-bridge-start'))->toBeFalse(); +}); From 712422c161aff44948042f32e0c9982cecc3d344 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 13:04:03 +0200 Subject: [PATCH 07/11] EnsureNetworkBridge: Start abgekoppelt, und der Verbindungsabriss wird gepollt statt gezaehlt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die tragende Zeile ist das try/catch um keyLogin. RunRunner:102 verwandelt jede geworfene Ausnahme in ein retry(), und retry verbraucht das Versuchskonto — ungefangen brennt der erwartete Verbindungsabriss die fuenf Versuche in wenigen Minuten durch und laesst den Lauf scheitern, BEVOR der Host wieder da ist. Genau der Unterschied zwischen 'Wiederholung' und 'toter Server'. Unterschieden wird am Termin im Run-Kontext: ohne ihn hat dieser Lauf nichts angefasst, dann ist ein Verbindungsfehler ein gewoehnlicher und darf einen Versuch kosten. Mit ihm laeuft gerade eine Umstellung, dann wird gepollt. bridge.sh und bridge-run.sh gehen wortgleich hoch (Test vergleicht Byte fuer Byte gegen die Repo-Datei), env traegt den Hub-Schluessel mit, damit der Treiber den Handshake gegen den RICHTIGEN Peer prueft. Start per nohup setsid, PID-Datei abgewartet. Co-Authored-By: Claude Opus 5 --- .../Steps/Host/EnsureNetworkBridge.php | 215 +++++++++++++++++- tests/Feature/Provisioning/HostStepsTest.php | 91 ++++++++ 2 files changed, 301 insertions(+), 5 deletions(-) diff --git a/app/Provisioning/Steps/Host/EnsureNetworkBridge.php b/app/Provisioning/Steps/Host/EnsureNetworkBridge.php index 49085ce..7a2d022 100644 --- a/app/Provisioning/Steps/Host/EnsureNetworkBridge.php +++ b/app/Provisioning/Steps/Host/EnsureNetworkBridge.php @@ -5,6 +5,9 @@ namespace App\Provisioning\Steps\Host; use App\Models\ProvisioningRun; use App\Provisioning\StepResult; use App\Services\Ssh\RemoteShell; +use App\Services\Wireguard\WireguardHub; +use Illuminate\Support\Carbon; +use Throwable; /** * Builds vmbr0 on a Debian-installed Proxmox host, under a timer that puts the @@ -67,7 +70,7 @@ class EnsureNetworkBridge extends HostStep /** One automatic attempt, plus one for the case an operator fixed something. */ private const MAX_ATTEMPTS = 2; - public function __construct(private RemoteShell $shell) {} + public function __construct(private RemoteShell $shell, private WireguardHub $hub) {} public function key(): string { @@ -86,7 +89,17 @@ class EnsureNetworkBridge extends HostStep public function execute(ProvisioningRun $run): StepResult { $host = $this->host($run); - $this->keyLogin($this->shell, $host); + + // 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(); @@ -117,9 +130,201 @@ class EnsureNetworkBridge extends HostStep ); } - // Start, poll and cancel arrive in the next task. Harmless until then: - // the step is not in the pipeline yet, so no run can reach this line. - return StepResult::fail('not implemented yet'); + // 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, 'The bridge build failed: '.($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 each time the host did not '. + 'come back both ways; 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. + // + // `rolled-back` is removed first: it is the signal that a PREVIOUS + // attempt's timer fired, and a leftover would make this attempt read its + // own change as already rolled back. + $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 five facts about a build in flight, in one round trip. + * + * `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)); + } + + /** @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 giveUp(ProvisioningRun $run, string $reason): StepResult + { + $run->forgetContext('bridge_deadline'); + + return StepResult::fail($reason); } /** diff --git a/tests/Feature/Provisioning/HostStepsTest.php b/tests/Feature/Provisioning/HostStepsTest.php index b5e74ef..cee5a8b 100644 --- a/tests/Feature/Provisioning/HostStepsTest.php +++ b/tests/Feature/Provisioning/HostStepsTest.php @@ -1722,3 +1722,94 @@ it('gibt auf, wenn gar keine Karte die Standardroute trägt', function () { expect(app(EnsureNetworkBridge::class)->execute($run)->type)->toBe('fail') ->and($s['shell']->ran('clupilot-bridge-start'))->toBeFalse(); }); + +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 prüft der Treiber den Handshake + // gegen irgendeinen Peer statt gegen den, über den CluPilot kommt. + ->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 Viertelstunde 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('scheitert nach der Frist, wenn der Host gar nicht mehr antwortet', function () { + // Der Zeitgeber hat dann laengst zurückgespielt — seine Frist ist ein + // Drittel dieser. Genau deshalb heißt Aufgeben hier „der Host ist wieder da, + // nur ohne Brücke" und nicht „der Host ist weg". + $s = fakeServices(); + $host = Host::factory()->active()->create(); + $run = hostRun($host, ['bridge_deadline' => now()->subMinute()->toIso8601String()]); + proveTunnel($run, $host); + $s['shell']->failConnect = true; + + $result = app(EnsureNetworkBridge::class)->execute($run); + + expect($result->type)->toBe('fail') + ->and($result->reason)->toContain('rollback timer') + ->and($run->fresh()->context('bridge_deadline'))->toBeNull(); +}); + +it('startet keinen zweiten Treiber, solange der erste lebt', function () { + // Zwei Treiber auf einer /etc/network/interfaces, jeder 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(); +}); From ba7e25da546f79c94ed3d34ac1e753b4f56c79ae Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 13:05:58 +0200 Subject: [PATCH 08/11] EnsureNetworkBridge: abbestellen nach der Nachpruefung, aufgeben ohne die Rueckfahrkarte wegzuwerfen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei Enden, und jedes hat eine Reihenfolge: - Abbestellt wird erst, NACHDEM die Bruecke nachgeprueft ist. Dass der Zweig erreicht wird, ist der Beweis: SSH kam ueber den Tunnel an und vmbr0 traegt Standardroute und Adresse — bridge_proven, von aussen gefragt. Und nur, wenn DIESER Lauf den Zeitgeber auch gestellt hat; ohne Termin im Kontext gehoeren die Unit-Dateien jemand anderem. - awaitRollback pollt, bis 'rolled-back' liegt, statt sofort zu scheitern. Solange der Zeitgeber aussteht, steckt die Maschine mitten in einer Umstellung, und ein fail() liesse sie dort liegen. Gedeckelt durch die Schritt-Frist, die das Dreifache der Zeitgeber-Frist ist. - giveUp loescht den Termin (sonst wird jedes Retry nach einem Fehlschlag zum sofortigen zweiten Fehlschlag), behaelt bridge_attempts (sonst ist der Deckel von zwei Versuchen keiner) und bestellt den Zeitgeber NICHT ab — er ist die Rueckfahrkarte, und steht er noch, hat er seinen Grund. Der sh-n-Test ist gegengeprobt: mit absichtlich kaputtem Quoting faellt er. Co-Authored-By: Claude Opus 5 --- .../Steps/Host/EnsureNetworkBridge.php | 104 ++++++++++- tests/Feature/Provisioning/HostStepsTest.php | 167 ++++++++++++++++++ 2 files changed, 268 insertions(+), 3 deletions(-) diff --git a/app/Provisioning/Steps/Host/EnsureNetworkBridge.php b/app/Provisioning/Steps/Host/EnsureNetworkBridge.php index 7a2d022..f066b7f 100644 --- a/app/Provisioning/Steps/Host/EnsureNetworkBridge.php +++ b/app/Provisioning/Steps/Host/EnsureNetworkBridge.php @@ -106,7 +106,19 @@ class EnsureNetworkBridge extends HostStep // 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 thrown away, and only + // if this run is the one that armed it: without a deadline in the + // context, those unit files belong to somebody else. if ($state['up']) { + if ($run->context('bridge_deadline') !== null) { + $this->cancelRollback(); + $run->forgetContext('bridge_deadline'); + } + return StepResult::advance(); } @@ -314,17 +326,103 @@ class EnsureNetworkBridge extends HostStep return (string) file_get_contents(base_path('deploy/bootstrap/'.$relative)); } - /** @param array{state:string, alive:string, phase:string, note:string, rolledback:string} $status */ + /** + * 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. A fail() would leave it there, and 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 { - return $this->giveUp($run, $reason); + $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 bridge_proven asked + * from the outside, the strongest form of the question there is. + */ + 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'); - return StepResult::fail($reason); + $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); } /** diff --git a/tests/Feature/Provisioning/HostStepsTest.php b/tests/Feature/Provisioning/HostStepsTest.php index cee5a8b..14e7979 100644 --- a/tests/Feature/Provisioning/HostStepsTest.php +++ b/tests/Feature/Provisioning/HostStepsTest.php @@ -1813,3 +1813,170 @@ it('startet keinen zweiten Treiber, solange der erste lebt', function () { ->and($result->reason)->toContain('umstellen') ->and($s['shell']->ran('clupilot-bridge-start'))->toBeFalse(); }); + +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. + // + // Dass dieser Zweig überhaupt erreicht wird, IST der Beweis: SSH kam über + // den Tunnel an, und vmbr0 trägt Standardroute und Adresse. Das ist + // bridge_proven, von außen gefragt. + $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); + + $stateAt = $cancelAt = null; + foreach ($s['shell']->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 Besuch ihn als + // „mitten in einer Umstellung". + ->and($run->fresh()->context('bridge_deadline'))->toBeNull(); +}); + +it('bestellt nichts ab, wenn die Brücke schon vor diesem Lauf da war', function () { + // Ohne Termin hat dieser Lauf nie einen Zeitgeber gestellt. Ein `stop` und + // ein `rm` auf fremde Unit-Dateien wären ein Eingriff ohne Anlass. + $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']->ran('clupilot-bridge-cancel'))->toBeFalse(); +}); + +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') + // 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() + ->and($s['shell']->ran('clupilot-bridge-reset'))->toBeTrue(); +}); + +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. Der ZÄHLER bleibt dagegen stehen, + // sonst ist der Deckel von zwei Versuchen keiner. + $s = fakeServices(); + $host = Host::factory()->active()->create(); + $run = hostRun($host, [ + 'bridge_deadline' => now()->addMinutes(10)->toIso8601String(), + 'bridge_attempts' => 1, + ]); + 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() + ->and($run->fresh()->context('bridge_attempts'))->toBe(1); +}); + +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(); + + $run = hostRun($host); + proveTunnel($run, $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 + + scriptBridgeState($s['shell'], up: true); + app(EnsureNetworkBridge::class)->execute( + hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]) + ); // state + cancel + + $checked = 0; + foreach ($s['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); +}); From 669045021a4a465e75a789316b5d9b966d61eca1 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 13:14:40 +0200 Subject: [PATCH 09/11] EnsureNetworkBridge in die Pipeline: nach dem Neustart, vor der Nachpruefung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Schritt haengt jetzt zwischen RebootIntoPveKernel und ConfigureProxmox. Nach dem Neustart, weil dort ifupdown2 steht und der Tunnel gerade bewiesen hat, dass er einen Neustart ueberlebt. Vor ConfigureProxmox, dessen refuseWithoutBridge() unveraendert stehen bleibt und damit zur Nachpruefung wird — bauen UND pruefen, dasselbe Paar wie BuildVmTemplate -> VerifyVmTemplate. Beschriftung in beiden Sprachen, und ein Test verlangt das kuenftig von JEDEM Pipeline-Schritt statt nur vom neuen: wer einen einhaengt und die Sprachdateien vergisst, faellt im Test auf statt in der Konsole. bridge.sh und bridge-run.sh gehoeren ins Bootstrap-Archiv — es ist der einzige Weg, auf dem das Skript auf eine nackte Maschine kommt. Der End-to-End-Test lief rot, und zu Recht: sein Attrappen-Host sagte nichts ueber sein Netz, also lehnte der neue Schritt ab. Das war der Beweis, dass er wirklich haengt. Er bekommt jetzt einen Host, der seine Bruecke schon hat — den Bau-Pfad kann eine Attrappe nicht nachstellen, er lebt davon, dass die Verbindung abreisst und wiederkommt. Dafuer gibt es die Schritt-Tests und die drei Sandkasten-Laeufe. scriptBridgeState/scriptBridgeStatus liegen in tests/Pest.php, nicht in einer einzelnen Testdatei — zwei Dateien brauchen sie. Co-Authored-By: Claude Opus 5 --- config/provisioning.php | 9 +++ lang/de/hosts.php | 1 + lang/en/hosts.php | 1 + tests/Feature/Host/BootstrapArchiveTest.php | 10 ++- .../HostOnboardingEndToEndTest.php | 14 ++++ tests/Feature/Provisioning/HostStepsTest.php | 78 +++++++++++++------ tests/Pest.php | 37 +++++++++ 7 files changed, 123 insertions(+), 27 deletions(-) diff --git a/config/provisioning.php b/config/provisioning.php index ea83881..11b4c19 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -36,6 +36,15 @@ return [ Host\ConfigureWireguard::class, Host\InstallProxmoxVe::class, Host\RebootIntoPveKernel::class, + // 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, Host\ConfigureProxmox::class, Host\CreateAutomationToken::class, Host\VerifyProxmoxApi::class, diff --git a/lang/de/hosts.php b/lang/de/hosts.php index 3435c7c..2ffedd8 100644 --- a/lang/de/hosts.php +++ b/lang/de/hosts.php @@ -140,6 +140,7 @@ return [ 'configure_wireguard' => 'WireGuard einrichten', 'install_proxmox_ve' => 'Proxmox VE installieren', 'reboot_into_pve_kernel' => 'In Proxmox-Kernel neu starten', + 'ensure_network_bridge' => 'Netzbrücke vmbr0 bauen', 'configure_proxmox' => 'Proxmox konfigurieren', 'create_automation_token' => 'Automation-Token erstellen', 'verify_proxmox_api' => 'Proxmox-API prüfen', diff --git a/lang/en/hosts.php b/lang/en/hosts.php index b143500..97f1452 100644 --- a/lang/en/hosts.php +++ b/lang/en/hosts.php @@ -140,6 +140,7 @@ return [ 'configure_wireguard' => 'Configure WireGuard', 'install_proxmox_ve' => 'Install Proxmox VE', 'reboot_into_pve_kernel' => 'Reboot into Proxmox kernel', + 'ensure_network_bridge' => 'Build the vmbr0 network bridge', 'configure_proxmox' => 'Configure Proxmox', 'create_automation_token' => 'Create automation token', 'verify_proxmox_api' => 'Verify Proxmox API', diff --git a/tests/Feature/Host/BootstrapArchiveTest.php b/tests/Feature/Host/BootstrapArchiveTest.php index 35e9e91..d6e9459 100644 --- a/tests/Feature/Host/BootstrapArchiveTest.php +++ b/tests/Feature/Host/BootstrapArchiveTest.php @@ -1,5 +1,9 @@ code = App\Support\HostEnrolment::issue(App\Models\Host::factory()->create()); + $this->code = HostEnrolment::issue(Host::factory()->create()); }); -function fetchArchive(): \Illuminate\Testing\TestResponse +function fetchArchive(): TestResponse { return test()->get('http://files.clupilot.test/bootstrap.tar.gz?code='.test()->code); } @@ -39,6 +43,8 @@ it('unpacks to bootstrap/ with the script and its library', function () { ->toContain('bootstrap/lib/report.sh') ->toContain('bootstrap/lib/proxmox.sh') ->toContain('bootstrap/lib/network.sh') + ->toContain('bootstrap/lib/bridge.sh') + ->toContain('bootstrap/lib/bridge-run.sh') ->toContain('bootstrap/lib/traefik.sh') ->toContain('bootstrap/lib/template.sh') ->toContain('bootstrap/lib/register.sh') diff --git a/tests/Feature/Provisioning/HostOnboardingEndToEndTest.php b/tests/Feature/Provisioning/HostOnboardingEndToEndTest.php index d5680b5..b5ec325 100644 --- a/tests/Feature/Provisioning/HostOnboardingEndToEndTest.php +++ b/tests/Feature/Provisioning/HostOnboardingEndToEndTest.php @@ -24,6 +24,13 @@ it('drives a fresh host all the way to active (mocked)', function () { $s = fakeServices(); $s['shell']->script('wg pubkey', CommandResult::success('HOSTPUBKEY0000=')); scriptLiveTunnel($s['shell']); + // Ein Host, der seine Brücke schon hat — der ISO-installierte oder der von + // Hand gebaute. EnsureNetworkBridge fasst den nicht an und meldet advance. + // Der BAU-Pfad steckt nicht hier: er ist abgekoppelt und lebt davon, dass + // die Verbindung abreißt und wiederkommt, was eine Attrappe nicht + // nachstellt. Dafür gibt es die Schritt-Tests und die drei Sandkasten-Läufe + // in BridgeScriptTest, die bridge-run.sh wirklich ausführen. + scriptBridgeState($s['shell'], up: true); $s['shell']->script('uname -r', CommandResult::success('6.8.12-4-pve')); $s['shell']->script('pveum user token add', CommandResult::success( json_encode(['full-tokenid' => 'automation@pve!clupilot', 'value' => 'tok-secret-123']) @@ -89,6 +96,13 @@ it('does not duplicate external resources when a step re-runs after a crash', fu $s = fakeServices(); $s['shell']->script('wg pubkey', CommandResult::success('HOSTPUBKEY0000=')); scriptLiveTunnel($s['shell']); + // Ein Host, der seine Brücke schon hat — der ISO-installierte oder der von + // Hand gebaute. EnsureNetworkBridge fasst den nicht an und meldet advance. + // Der BAU-Pfad steckt nicht hier: er ist abgekoppelt und lebt davon, dass + // die Verbindung abreißt und wiederkommt, was eine Attrappe nicht + // nachstellt. Dafür gibt es die Schritt-Tests und die drei Sandkasten-Läufe + // in BridgeScriptTest, die bridge-run.sh wirklich ausführen. + scriptBridgeState($s['shell'], up: true); $s['shell']->script('uname -r', CommandResult::success('6.8.12-4-pve')); $s['shell']->script('pveum user token add', CommandResult::success( json_encode(['full-tokenid' => 'automation@pve!clupilot', 'value' => 'tok-secret-123']) diff --git a/tests/Feature/Provisioning/HostStepsTest.php b/tests/Feature/Provisioning/HostStepsTest.php index 14e7979..c9228e4 100644 --- a/tests/Feature/Provisioning/HostStepsTest.php +++ b/tests/Feature/Provisioning/HostStepsTest.php @@ -1655,31 +1655,6 @@ it('asks only for privileges Proxmox 9 still knows', function () { // Deshalb: erst den Zeitgeber stellen, 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 @@ -1980,3 +1955,56 @@ it('schickt nur Shell, die eine Shell auch parsen kann', function () { expect($checked)->toBeGreaterThanOrEqual(6); }); + +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. + // + // CommandResult::failure(int $exitCode = 1, string $stderr = '') — zwei + // Parameter, nicht drei. + $s = fakeServices(); + $host = Host::factory()->active()->create(); + $run = hostRun($host); + proveTunnel($run, $host); + $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 je Schritt. Eine ohne Beschriftung zeigt den + // Schlüssel — der Betreiber säße beim Zusehen vor 'hosts.step.…'. + foreach (['de', 'en'] as $locale) { + expect(trans('hosts.step.ensure_network_bridge', [], $locale)) + ->not->toBe('hosts.step.ensure_network_bridge', "Beschriftung fehlt in {$locale}"); + } +}); + +it('legt jeden Pipeline-Schritt mit einer Beschriftung ab', function () { + // Nicht nur den neuen. Wer künftig einen Schritt einhängt und die + // Sprachdateien vergisst, fällt hier auf statt in der Konsole. + foreach (config('provisioning.pipelines.host') as $class) { + $key = app($class)->key(); + + foreach (['de', 'en'] as $locale) { + expect(trans("hosts.step.{$key}", [], $locale)) + ->not->toBe("hosts.step.{$key}", "Beschriftung fehlt: {$key} ({$locale})"); + } + } +}); diff --git a/tests/Pest.php b/tests/Pest.php index f901758..83919ce 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -224,6 +224,43 @@ function withRealEnv(array $vars, Closure $callback): mixed } } +/** + * Was der Host auf die Zustandsfrage von EnsureNetworkBridge antwortet. + * + * `up` ist absichtlich EIN Wert aus drei Fakten — Brücke da, trägt die + * Standardroute, hat eine Adresse. Der Schritt fragt sie in einem Rundlauf ab, + * weil eine vmbr0 ohne Adresse und ohne Route eine Brücke im Sinne von + * `ip link show` ist und sonst nichts. + */ +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 über einen laufenden Brückenbau meldet. + * + * `alive` wird auf dem Host gerechnet (`kill -0`), weil die PID hier nichts + * bedeutet; `rolledback` ist die Marke, die der Zeitgeber ZULETZT setzt. + */ +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'), + '', + ]))); +} + /** * Was ein stehender Tunnel auf dem Host meldet. * From 0bb61d39bd39ea27b1a87489e37943b0ccd864d5 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 13:27:49 +0200 Subject: [PATCH 10/11] Codex-Runde 1: drei P1 an der Rueckfahrkarte, plus ein Gateway ohne via MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Ruecknahme konnte Erfolg melden, ohne einen zu haben. Drei Wege dorthin, alle behoben: - Sie schaltete den entmachteten Netzverwalter nicht wieder ein. Die Sicherung umfasst nur /etc/network/interfaces*; kam die Verbindung von cloud-init, networkd oder NetworkManager, spielte die Ruecknahme eine Datei zurueck, die die Maschine nie getragen hat, und liess den Verwalter abgeschaltet. Genau der tote Host, den der Zeitgeber verhindern soll. disown_network_manager hinterlaesst jetzt eine Notiz (WAS entmachtet, WELCHE Strophen verdraengt), die das Ruecknahme-Skript beim Feuern liest — aufgeschrieben statt eingebacken, weil der Zeitgeber vor dem Entmachten gestellt wird. - Sie setzte 'rolled-back' auch, wenn tar oder ifreload scheiterten. Das urspruengliche network.sh hatte dafuer set -e; beim Umbau ist es verlorengegangen. Jetzt bricht jeder Fehlschlag ab, bevor die Marke entsteht — CluPilot pollt dann bis zur Frist statt 'ist zurueck' zu glauben. - Eine verdraengte interfaces.d-Strophe wurde nur umbenannt. Der Stern in 'source interfaces.d/*' fasst sie weiter; das versteckte die Kollision vor dem Leser, statt sie zu loesen. Jetzt wandert sie aus dem Verzeichnis heraus, und die Ruecknahme holt sie zurueck. Dazu ein eigener Fund: das Gateway wurde mit awk '{print }' gelesen. Bei 'default dev ens3 scope link' ist das der KARTENNAME, woraus 'gateway ens3' in der Strophe wuerde. default_gateway() liest jetzt hinter dem via, und eine Standardroute ohne via wandert als eigene up-Zeile mit, statt verlorenzugehen. Zurueckgewiesen: der P2 zu extra_routes. 'ip route show dev X' laesst das dev-Feld WEG (im Container nachgemessen), das angehaengte 'dev vmbr0' ist also richtig. Co-Authored-By: Claude Opus 5 --- deploy/bootstrap/lib/bridge.sh | 123 +++++++++-- .../Feature/Provisioning/BridgeScriptTest.php | 196 +++++++++++++++--- 2 files changed, 282 insertions(+), 37 deletions(-) diff --git a/deploy/bootstrap/lib/bridge.sh b/deploy/bootstrap/lib/bridge.sh index d519bb5..f35db54 100644 --- a/deploy/bootstrap/lib/bridge.sh +++ b/deploy/bootstrap/lib/bridge.sh @@ -87,6 +87,24 @@ detect_primary_interface() { | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }' } +# Das Gateway der Standardroute — hinter dem `via`, nie an fester Feldnummer. +# +# `default dev ens3 scope link` hat keins. Ein `awk '{ print $3 }'` lieferte dort +# den KARTENNAMEN, und daraus würde `gateway ens3` in der Strophe: `ifreload` +# scheitert, der Zeitgeber räumt ab — und abgeräumt wird ein Host, der zu bauen +# gewesen wäre. Leer heißt hier ehrlich leer. +default_gateway() { + "$CLUPILOT_IP" -4 route show default 2>/dev/null \ + | awk '{ for (i = 1; i < NF; i++) if ($i == "via") { print $(i+1); exit } }' +} + +# Gibt es überhaupt eine Standardroute? Ohne `via` ist sie eine Link-Route, und +# die muss die Brücke als eigene `up`-Zeile mitnehmen, sonst verliert der Host +# seinen Weg nach draußen. +has_default_route() { + "$CLUPILOT_IP" -4 route show default 2>/dev/null | grep -q . +} + # Ist das eine physische Karte? # # `bridge_ports` auf einem Bond oder einer bestehenden Bridge ist falsch — die @@ -140,9 +158,16 @@ detect_network_style() { 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 }')" + _gw="$(default_gateway)" _prefix="${_cidr##*/}" + # Kein Gateway: dann gibt es weder eins im eigenen Subnetz noch eines + # außerhalb, und `routed` zu melden hieße `pointopoint` auf nichts. + if [ -z "$_gw" ]; then + printf 'subnet' + return 0 + fi + # /32 heißt: das eigene Subnetz besteht aus der eigenen Adresse. Ein Gateway # darin kann es nicht geben. if [ "$_prefix" = '32' ]; then @@ -247,6 +272,14 @@ write_bridge_stanza() { _ip4="${_cidr%%/*}" _mac="$(cat "${CLUPILOT_SYS_NET}/${_iface}/address" 2>/dev/null)" + # Ohne Gateway gibt es kein pointopoint-Ziel. Nach detect_network_style kann + # das nicht mehr vorkommen — write_bridge_stanza wird aber auch direkt + # aufgerufen, und `pointopoint` auf einen leeren Wert ist eine Strophe, die + # `ifreload` ablehnt. + if [ -z "$_gw" ] && [ "$_style" = 'routed' ]; then + _style='subnet' + fi + case "$_style" in dhcp) _inet="iface ${CLUPILOT_BRIDGE} inet dhcp" @@ -263,8 +296,17 @@ write_bridge_stanza() { ;; *) _inet="iface ${CLUPILOT_BRIDGE} inet static" - _addr=" address ${_cidr} + _addr=" address ${_cidr}" + if [ -n "$_gw" ]; then + _addr="${_addr} gateway ${_gw}" + elif has_default_route; then + # Standardroute ohne `via` — eine Link-Route. Sie hat kein + # Gateway, das in eine `gateway`-Zeile passte, und ginge sonst + # beim Umbau verloren. + _addr="${_addr} + up ip route add default dev ${CLUPILOT_BRIDGE} || true" + fi ;; esac @@ -351,8 +393,22 @@ foreign_network_manager() { } # Entmachtet den erkannten Verwalter — im gesicherten Stand, also unter dem -# Zeitgeber. Was hier schiefgeht, holt er zurück. +# Zeitgeber. +# +# Und hinterlässt, WAS entmachtet wurde. Das ist die Hälfte, die zuerst fehlte: +# die Sicherung umfasst nur `/etc/network/interfaces*`, also könnte die Rücknahme +# einen abgeschalteten cloud-init nicht wieder einschalten. Auf einer Maschine, +# deren Verbindung von cloud-init kam, spielte sie damit eine Datei zurück, die +# die Maschine nie getragen hat — und ließe den Verwalter, der es tat, +# abgeschaltet. Genau der tote Host, den der Zeitgeber verhindern soll. +# +# Aufgeschrieben statt ins Rücknahme-Skript gebacken, weil der Zeitgeber VOR +# dieser Funktion gestellt wird. Das Skript liest die Notiz erst, wenn es feuert. disown_network_manager() { + mkdir -p "$CLUPILOT_WORK_DIR" + printf '%s' "${1:-}" > "${CLUPILOT_WORK_DIR}/disowned" + : > "${CLUPILOT_WORK_DIR}/moved-aside" + case "${1:-}" in cloud-init) mkdir -p "${CLUPILOT_ETC}/cloud/cloud.cfg.d" @@ -377,16 +433,21 @@ disown_network_manager() { # Der kleinere Verwandte: die Strophe behält `source interfaces.d/*`. Bleibt # dort eine Datei liegen, die dieselbe Karte beansprucht, hat der Host zwei # Stellen, die seine Adresse vergeben. + # + # HERAUS aus dem Verzeichnis, nicht bloß umbenannt. Der Stern in + # `source .../interfaces.d/*` fasst auch eine umbenannte Datei — Umbenennen + # allein löst die Kollision also nicht, es versteckt sie nur vor dem Leser. _iface="${2:-}" + _aside="${CLUPILOT_WORK_DIR}/verdraengt" + if [ -n "$_iface" ] && [ -d "$CLUPILOT_INTERFACES_D" ]; then for _f in "$CLUPILOT_INTERFACES_D"/*; do [ -f "$_f" ] || continue - case "$_f" in - *.von-clupilot-beiseitegelegt) continue ;; - esac if grep -qE "iface[[:space:]]+${_iface}[[:space:]]" "$_f" 2>/dev/null; then - mv "$_f" "${_f}.von-clupilot-beiseitegelegt" - log "Kollidierende Strophe beiseitegelegt: ${_f}" + mkdir -p "$_aside" + mv "$_f" "${_aside}/$(basename "$_f")" || continue + printf '%s\n' "$_f" >> "${CLUPILOT_WORK_DIR}/moved-aside" + log "Kollidierende Strophe aus dem Quellverzeichnis genommen: ${_f}" fi done fi @@ -517,19 +578,57 @@ render_rollback_script() { # Netzkonfiguration von VOR der Bruecke zurueck, weil sie binnen ${_minutes} # Minuten nicht abbestellt wurde — und abbestellen kann CluPilot nur, wenn es # den Host ueber den Tunnel wieder erreicht. +# Das Urteil zuerst, damit auch ein halb gegluecktes Zurueckspielen einen Grund +# hinterlaesst. printf 'failed' > '${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 / +# Zuerst den entmachteten Netzverwalter wieder einschalten. Die Sicherung +# umfasst nur /etc/network/interfaces* — kam die Verbindung dieser Maschine von +# cloud-init, networkd oder NetworkManager, hilft das Zurueckspielen allein +# nichts, solange der Verwalter abgeschaltet bleibt. +_m="\$(cat '${CLUPILOT_WORK_DIR}/disowned' 2>/dev/null)" +case "\$_m" in + cloud-init) + rm -f '${CLUPILOT_ETC}/cloud/cloud.cfg.d/99-clupilot-disable-network.cfg' + ;; + networkd) + systemctl unmask systemd-networkd 2>/dev/null || true + systemctl enable --now systemd-networkd.socket systemd-networkd 2>/dev/null || true + ;; + network-manager) + systemctl unmask NetworkManager 2>/dev/null || true + systemctl enable --now NetworkManager 2>/dev/null || true + ;; +esac + +# Verdraengte Strophen zurueck an ihren Platz. +if [ -f '${CLUPILOT_WORK_DIR}/moved-aside' ]; then + while IFS= read -r _orig; do + [ -n "\$_orig" ] || continue + _base="\$(basename "\$_orig")" + if [ -f '${CLUPILOT_WORK_DIR}/verdraengt/'"\$_base" ]; then + mv '${CLUPILOT_WORK_DIR}/verdraengt/'"\$_base" "\$_orig" + fi + done < '${CLUPILOT_WORK_DIR}/moved-aside' +fi + +# Ab hier zaehlt jeder Fehlschlag. Ohne diese Pruefungen liefe das Skript weiter +# und setzte die Marke, obwohl der Host auf einer kaputten Konfiguration steht — +# und CluPilot meldete 'der alte Zustand ist zurueck' und gaebe auf. +tar xzf '${CLUPILOT_NET_BACKUP}.tar.gz' -C / || exit 1 + if command -v ifreload >/dev/null 2>&1; then - ifreload -a + ifreload -a || exit 1 else - systemctl restart networking + systemctl restart networking || exit 1 fi logger -t clupilot 'Netzkonfiguration zurueckgespielt: die Bruecke hat den Host vom Netz genommen.' -# Erst jetzt. Vorher hiesse es: zurueckgespielt, obwohl es noch laeuft. +# Erst jetzt, und nur nach einem geglueckten Zurueckspielen. Die Marke ist das +# Signal, auf das CluPilot wartet, um aufzugeben — sie darf nie 'zurueckgespielt' +# sagen, wenn es nicht stimmt. : > '${CLUPILOT_WORK_DIR}/rolled-back' # Selbst wegraeumen, sonst liegen Unit-Dateien herum, die aussehen, als stuende diff --git a/tests/Feature/Provisioning/BridgeScriptTest.php b/tests/Feature/Provisioning/BridgeScriptTest.php index f9fa15c..81b749b 100644 --- a/tests/Feature/Provisioning/BridgeScriptTest.php +++ b/tests/Feature/Provisioning/BridgeScriptTest.php @@ -196,6 +196,58 @@ it('sagt nur dann „Brücke steht", wenn sie Route UND Adresse trägt', functio expect($out)->toBe('NEIN'); }); +it('liest kein Gateway aus einer Standardroute, die keins hat', function () { + // `default dev ens3 scope link` — kein `via`. Ein `awk '{print $3}'` liefert + // dort den KARTENNAMEN, und daraus würde `gateway ens3` in der Strophe. + // Der Zeitgeber finge das ab, aber der Host wäre umsonst gescheitert. + $ip = fakeIp($this->dir, [ + '-4 route show default' => 'default dev ens3 scope link', + ]); + + expect(runBridgeSh('default_gateway', ['CLUPILOT_IP' => $ip]))->toBe(''); +}); + +it('liest das Gateway hinter dem via, nicht an fester Feldnummer', function () { + $ip = fakeIp($this->dir, [ + '-4 route show default' => 'default via 10.0.0.1 dev ens3 proto dhcp src 10.0.0.7 metric 100', + ]); + + expect(runBridgeSh('default_gateway', ['CLUPILOT_IP' => $ip]))->toBe('10.0.0.1'); +}); + +it('hält eine gatewaylose Karte nicht für eine geroutete Einzeladresse', function () { + // Ohne Gateway gibt es weder eins im eigenen Subnetz noch eines ausserhalb. + // `routed` zu melden hiesse `pointopoint` auf nichts. + $ip = fakeIp($this->dir, [ + '-4 route show default' => 'default dev ens3 scope link', + '-4 -o addr show dev ens3 scope global' => '2: ens3 inet 10.0.0.7/24 scope global ens3', + ]); + + expect(runBridgeSh('detect_network_style ens3', ['CLUPILOT_IP' => $ip])) + ->toBe('subnet'); +}); + +it('schreibt ohne Gateway auch keine gateway-Zeile', 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:07'); + $out = $this->dir.'/interfaces'; + + runBridgeSh('write_bridge_stanza ens3 subnet 10.0.0.7/24 ""', [ + 'CLUPILOT_IP' => $ip, + 'CLUPILOT_SYS_NET' => $sys, + 'CLUPILOT_INTERFACES_FILE' => $out, + ]); + + expect(file_get_contents($out)) + ->toContain('address 10.0.0.7/24') + ->not->toContain('gateway') + ->not->toContain('pointopoint'); +}); + it('hält jede Brückenfunktion 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 @@ -450,6 +502,119 @@ it('schreibt ein Rücknahme-Skript, das den Grund festhält BEVOR es zurückspie ->and($doneAt)->toBeGreaterThan($restoreAt); }); +it('legt die Marke NICHT an, wenn das Zurückspielen scheitert', function () { + // Ohne diese Prüfung laufen tar und ifreload ins Leere, das Skript macht + // weiter und setzt `rolled-back` — CluPilot meldet dann „der alte Zustand + // ist zurück" und gibt auf, während der Host auf einer kaputten + // Konfiguration steht. Das ursprüngliche network.sh hatte dafür `set -e`; + // beim Umbau ist es verlorengegangen. + $work = $this->dir.'/work'; + mkdir($work, 0o755, true); + + $script = runBridgeSh('render_rollback_script 5', [ + 'CLUPILOT_WORK_DIR' => $work, + // Ein Archiv, das es nicht gibt: tar scheitert. + 'CLUPILOT_NET_BACKUP' => $this->dir.'/gibtesnicht', + 'CLUPILOT_UNIT_DIR' => $this->dir.'/units', + ]); + file_put_contents($this->dir.'/rollback.sh', $script); + + $result = Process::run('sh '.$this->dir.'/rollback.sh'); + + expect($result->exitCode())->not->toBe(0) + ->and(file_exists($work.'/rolled-back'))->toBeFalse() + // Das Urteil steht trotzdem — es wird VOR dem Zurückspielen geschrieben. + ->and(file_get_contents($work.'/state'))->toBe('failed'); +}); + +it('schaltet die Rücknahme den entmachteten Netzverwalter wieder ein', function () { + // Sonst spielt sie eine /etc/network/interfaces zurück, die diese Maschine + // nie getragen hat, während der Verwalter, der es tat, abgeschaltet bleibt. + // Genau der tote Host, den der Zeitgeber verhindern soll. + $work = $this->dir.'/work'; + $etc = $this->dir.'/etc'; + mkdir($work, 0o755, true); + mkdir("{$etc}/cloud/cloud.cfg.d", 0o755, true); + file_put_contents("{$etc}/cloud/cloud.cfg", "datasource_list: [ Hetzner ]\n"); + + $env = [ + 'CLUPILOT_WORK_DIR' => $work, + 'CLUPILOT_ETC' => $etc, + 'CLUPILOT_NET_BACKUP' => $this->dir.'/sicherung', + 'CLUPILOT_UNIT_DIR' => $this->dir.'/units', + 'CLUPILOT_INTERFACES_D' => $this->dir.'/interfaces.d', + ]; + + // Erst entmachten, dann das Skript fahren, das der Zeitgeber gestellt hat. + runBridgeSh('disown_network_manager cloud-init ens3', $env); + expect(file_exists("{$etc}/cloud/cloud.cfg.d/99-clupilot-disable-network.cfg"))->toBeTrue(); + + // Eine Sicherung, damit tar nicht vorher scheitert. + file_put_contents($this->dir.'/leer', "x\n"); + Process::run('tar czf '.$this->dir.'/sicherung.tar.gz -C '.$this->dir.' leer'); + + $script = runBridgeSh('render_rollback_script 5', $env); + file_put_contents($this->dir.'/rollback.sh', $script); + Process::run('sh '.$this->dir.'/rollback.sh'); + + expect(file_exists("{$etc}/cloud/cloud.cfg.d/99-clupilot-disable-network.cfg"))->toBeFalse(); +}); + +it('nimmt eine verdrängte Strophe aus dem Quellverzeichnis heraus', function () { + // `source /etc/network/interfaces.d/*` fasst auch eine umbenannte Datei — + // der Stern nimmt sie mit. Umbenennen allein löst die Kollision also nicht. + $etc = $this->dir.'/etc'; + $d = $this->dir.'/interfaces.d'; + $work = $this->dir.'/work'; + mkdir($etc, 0o755, true); + mkdir($d, 0o755, true); + mkdir($work, 0o755, true); + file_put_contents("{$d}/50-cloud-init", "auto ens3\niface ens3 inet static\n address 10.0.0.7/24\n"); + file_put_contents("{$d}/99-egal", "iface eth9 inet manual\n"); + + runBridgeSh('disown_network_manager "" ens3', [ + 'CLUPILOT_ETC' => $etc, + 'CLUPILOT_INTERFACES_D' => $d, + 'CLUPILOT_WORK_DIR' => $work, + ]); + + // Nichts, was der Stern noch fassen könnte. + expect(glob("{$d}/*"))->toBe(["{$d}/99-egal"]) + ->and(file_exists("{$work}/verdraengt/50-cloud-init"))->toBeTrue() + // Und die Rücknahme muss wissen, wohin damit. + ->and(file_get_contents("{$work}/moved-aside"))->toContain("{$d}/50-cloud-init"); +}); + +it('stellt die Rücknahme eine verdrängte Strophe zurück', function () { + $etc = $this->dir.'/etc'; + $d = $this->dir.'/interfaces.d'; + $work = $this->dir.'/work'; + mkdir($etc, 0o755, true); + mkdir($d, 0o755, true); + mkdir($work, 0o755, true); + file_put_contents("{$d}/50-cloud-init", "iface ens3 inet static\n"); + + $env = [ + 'CLUPILOT_ETC' => $etc, + 'CLUPILOT_INTERFACES_D' => $d, + 'CLUPILOT_WORK_DIR' => $work, + 'CLUPILOT_NET_BACKUP' => $this->dir.'/sicherung', + 'CLUPILOT_UNIT_DIR' => $this->dir.'/units', + ]; + + runBridgeSh('disown_network_manager "" ens3', $env); + expect(file_exists("{$d}/50-cloud-init"))->toBeFalse(); + + file_put_contents($this->dir.'/leer', "x\n"); + Process::run('tar czf '.$this->dir.'/sicherung.tar.gz -C '.$this->dir.' leer'); + + $script = runBridgeSh('render_rollback_script 5', $env); + file_put_contents($this->dir.'/rollback.sh', $script); + Process::run('sh '.$this->dir.'/rollback.sh'); + + expect(file_exists("{$d}/50-cloud-init"))->toBeTrue(); +}); + 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. @@ -754,34 +919,16 @@ it('entmachtet cloud-init im gesicherten Stand', function () { mkdir("{$etc}/cloud", 0o755, true); file_put_contents("{$etc}/cloud/cloud.cfg", "datasource_list: [ Hetzner ]\n"); - runBridgeSh('disown_network_manager cloud-init ens3', ['CLUPILOT_ETC' => $etc]); + runBridgeSh('disown_network_manager cloud-init ens3', [ + 'CLUPILOT_ETC' => $etc, + // Die Notiz, aus der die Rücknahme später liest, WAS entmachtet wurde. + 'CLUPILOT_WORK_DIR' => $this->dir.'/work', + ]); expect(file_get_contents("{$etc}/cloud/cloud.cfg.d/99-clupilot-disable-network.cfg")) ->toContain('network: {config: disabled}'); }); -it('legt eine kollidierende interfaces.d-Strophe beiseite', function () { - // Die Strophe behält `source interfaces.d/*`. Bleibt dort eine Datei - // liegen, die dieselbe Karte beansprucht, hat der Host zwei Stellen, die - // seine Adresse vergeben. - $etc = $this->dir.'/etc'; - $d = $this->dir.'/interfaces.d'; - mkdir($etc, 0o755, true); - mkdir($d, 0o755, true); - file_put_contents("{$d}/50-cloud-init", "auto ens3\niface ens3 inet static\n address 10.0.0.7/24\n"); - file_put_contents("{$d}/99-egal", "iface eth9 inet manual\n"); - - runBridgeSh('disown_network_manager "" ens3', [ - 'CLUPILOT_ETC' => $etc, - 'CLUPILOT_INTERFACES_D' => $d, - ]); - - expect(file_exists("{$d}/50-cloud-init"))->toBeFalse() - ->and(file_exists("{$d}/50-cloud-init.von-clupilot-beiseitegelegt"))->toBeTrue() - // Eine Strophe für eine ANDERE Karte bleibt, wo sie ist. - ->and(file_exists("{$d}/99-egal"))->toBeTrue(); -}); - /** * Baut eine Sandkiste, in der bridge-run.sh wirklich durchläuft. * @@ -797,7 +944,7 @@ function bridgeSandbox(string $dir, bool $bridgeComesUp = true, int $handshakeAg $work = "{$dir}/work"; $bin = "{$dir}/bin"; $state = "{$dir}/state"; - foreach ([$work, $bin, $state, "{$dir}/units", "{$dir}/sbin"] as $d) { + foreach ([$work, $bin, $state, "{$dir}/units", "{$dir}/sbin", "{$dir}/etc"] as $d) { mkdir($d, 0o755, true); } @@ -857,7 +1004,6 @@ function bridgeSandbox(string $dir, bool $bridgeComesUp = true, int $handshakeAg ])); fakeSysNet($dir, 'enp0s31f6', 'a8:a1:59:00:11:22'); - mkdir("{$dir}/etc", 0o755, true); return ['work' => $work, 'calls' => "{$dir}/calls"]; } From 133b2cf5521c9404268f61844d4ced451ab86fca Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 13:34:12 +0200 Subject: [PATCH 11/11] Codex-Runde 2: der Gateway-Fix erreichte den Produktivpfad nicht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Befunde, beide echt, und der erste ist ein Patzer: detect_network_style wurde auf default_gateway() umgestellt, bridge-run.sh blieb auf awk '{ print }'. Der Treiber reichte also weiter den KARTENNAMEN als Gateway an build_bridge, und in der Strophe stand 'gateway ens3' — der Fix half genau der Stelle nicht, fuer die er gedacht war. Meine Sandkiste konnte das nicht sehen: sie hatte immer ein via. Jetzt ist sie parametrisiert, und ein Test faehrt bridge-run.sh mit einer via-losen Standardroute durch und liest die geschriebene Strophe. Der Beweis laeuft ueber den Produktivpfad, nicht ueber eine Einzelfunktion. Zweitens: default_gateway suchte das erste via IRGENDWO in der Ausgabe, detect_primary_interface das dev der ERSTEN Zeile. Bei zwei Standardrouten baute das eine Bruecke ueber die Karte der einen mit dem Gateway der anderen. Beide haengen jetzt an primary_default_route mit head -1 — eine Route, eine Quelle. Co-Authored-By: Claude Opus 5 --- deploy/bootstrap/lib/bridge-run.sh | 5 ++- deploy/bootstrap/lib/bridge.sh | 17 ++++++-- .../Feature/Provisioning/BridgeScriptTest.php | 41 ++++++++++++++++++- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/deploy/bootstrap/lib/bridge-run.sh b/deploy/bootstrap/lib/bridge-run.sh index d99227a..a99a46e 100644 --- a/deploy/bootstrap/lib/bridge-run.sh +++ b/deploy/bootstrap/lib/bridge-run.sh @@ -108,7 +108,10 @@ interface_is_physical "$_iface" \ _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 }')" +# Über den Helfer, nicht mit `awk '{ print $3 }'`: bei `default dev ens3 scope +# link` steht dort der KARTENNAME, und der landete als `gateway ens3` in der +# Strophe. Beides — Karte und Gateway — kommt aus derselben Route. +_gw="$(default_gateway)" [ "$_style" = 'dhcp' ] || [ -n "$_cidr" ] \ || fail "Keine globale IPv4-Adresse auf ${_iface} — nichts, was auf eine Bruecke ziehen koennte." diff --git a/deploy/bootstrap/lib/bridge.sh b/deploy/bootstrap/lib/bridge.sh index f35db54..8bc006b 100644 --- a/deploy/bootstrap/lib/bridge.sh +++ b/deploy/bootstrap/lib/bridge.sh @@ -82,19 +82,28 @@ fi # 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. +# Die EINE Standardroute, aus der alles andere abgeleitet wird. +# +# `head -1`, und alle drei Leser hängen daran. Ein Host kann mehrere haben, und +# wer Karte und Gateway aus verschiedenen Zeilen zieht, baut eine Brücke über die +# Karte der einen mit dem Gateway der anderen — ein Netz, das nirgends hinführt. +primary_default_route() { + "$CLUPILOT_IP" -4 route show default 2>/dev/null | head -1 +} + detect_primary_interface() { - "$CLUPILOT_IP" -4 route show default 2>/dev/null \ + primary_default_route \ | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }' } -# Das Gateway der Standardroute — hinter dem `via`, nie an fester Feldnummer. +# Das Gateway dieser Route — hinter dem `via`, nie an fester Feldnummer. # # `default dev ens3 scope link` hat keins. Ein `awk '{ print $3 }'` lieferte dort # den KARTENNAMEN, und daraus würde `gateway ens3` in der Strophe: `ifreload` # scheitert, der Zeitgeber räumt ab — und abgeräumt wird ein Host, der zu bauen # gewesen wäre. Leer heißt hier ehrlich leer. default_gateway() { - "$CLUPILOT_IP" -4 route show default 2>/dev/null \ + primary_default_route \ | awk '{ for (i = 1; i < NF; i++) if ($i == "via") { print $(i+1); exit } }' } @@ -102,7 +111,7 @@ default_gateway() { # die muss die Brücke als eigene `up`-Zeile mitnehmen, sonst verliert der Host # seinen Weg nach draußen. has_default_route() { - "$CLUPILOT_IP" -4 route show default 2>/dev/null | grep -q . + primary_default_route | grep -q . } # Ist das eine physische Karte? diff --git a/tests/Feature/Provisioning/BridgeScriptTest.php b/tests/Feature/Provisioning/BridgeScriptTest.php index 81b749b..8b7326d 100644 --- a/tests/Feature/Provisioning/BridgeScriptTest.php +++ b/tests/Feature/Provisioning/BridgeScriptTest.php @@ -939,7 +939,7 @@ it('entmachtet cloud-init im gesicherten Stand', function () { * * @return array{work:string, calls:string} */ -function bridgeSandbox(string $dir, bool $bridgeComesUp = true, int $handshakeAge = 5): array +function bridgeSandbox(string $dir, bool $bridgeComesUp = true, int $handshakeAge = 5, bool $withGateway = true): array { $work = "{$dir}/work"; $bin = "{$dir}/bin"; @@ -952,12 +952,15 @@ function bridgeSandbox(string $dir, bool $bridgeComesUp = true, int $handshakeAg file_put_contents("{$dir}/interfaces", "# die Fassung von vorher\n"); $up = $bridgeComesUp ? "[ -f '{$state}/bridged' ]" : 'false'; + // Eine Standardroute ohne `via` ist eine Link-Route. Sie hat kein Gateway, + // und ein `awk '{ print $3 }'` läse dort den KARTENNAMEN. + $route = $withGateway ? 'default via 10.0.0.1 dev $DEV' : 'default dev $DEV scope link'; file_put_contents("{$bin}/ip", <<and(file_get_contents($box['calls']))->not->toContain('stop clupilot-network-rollback.timer'); }); +it('schreibt kein Gateway in die Strophe, wenn die Standardroute keins hat', function () { + // Der Beweis über den PRODUKTIVPFAD, nicht über eine Einzelfunktion. + // `detect_network_style` auf `default_gateway()` umzustellen half nichts, + // solange bridge-run.sh sein Gateway weiter mit `awk '{ print $3 }'` las: + // der Treiber reichte `ens3` an build_bridge weiter, und in der Strophe + // stand `gateway ens3`. Ein Test an der Bibliothek allein hätte das nie + // gesehen. + $box = bridgeSandbox($this->dir, withGateway: false); + + Process::path($box['work']) + ->env(['CLUPILOT_WORK_DIR' => $box['work']]) + ->run('sh '.base_path('deploy/bootstrap/lib/bridge-run.sh')); + + $stanza = (string) file_get_contents($this->dir.'/interfaces'); + + expect($stanza)->toContain('address 10.0.0.7/24') + ->not->toContain('gateway enp0s31f6') + ->not->toContain('pointopoint') + // Die Link-Route geht sonst beim Umbau verloren. + ->toContain('up ip route add default dev vmbr0'); +}); + +it('nimmt Karte und Gateway aus DERSELBEN Standardroute', function () { + // Bei zwei Standardrouten nahm `detect_primary_interface` das `dev` der + // ersten und `default_gateway` das `via` der zweiten — die Brücke bekäme + // die Karte der einen mit dem Gateway der anderen. + $ip = fakeIp($this->dir, [ + '-4 route show default' => "default dev ens3 scope link\ndefault via 10.0.0.1 dev ens4 metric 200", + ]); + + expect(runBridgeSh('detect_primary_interface', ['CLUPILOT_IP' => $ip]))->toBe('ens3') + ->and(runBridgeSh('default_gateway', ['CLUPILOT_IP' => $ip]))->toBe(''); +}); + it('meldet failed und lässt den Zeitgeber stehen, wenn die Brücke nicht trägt', function () { // Der Fall, für den die ganze Konstruktion gebaut ist. Der Treiber räumt // NICHT selbst auf — er überlässt es dem Zeitgeber und sagt nur, warum.