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