From 587d1351d109f8e63ec90d399e5dbf7f65fbc798 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 12:37:17 +0200 Subject: [PATCH] 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 () {