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