From e963821eb4e801c38c40712cbae26ec47965cf6e Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 12:47:02 +0200 Subject: [PATCH] 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