$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()); } /** * 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"; } /** * 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 { $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( verdictBody('interface_is_physical bond0'), ['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( verdictBody('interface_is_physical enp0s31f6'), ['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( verdictBody('bridge_is_up'), ['CLUPILOT_IP' => $ip] ); 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 // 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. $files = array_merge( glob(base_path('deploy/bootstrap/lib/*.sh')) ?: [], glob(base_path('deploy/bootstrap/*.sh')) ?: [], ); $functions = [ 'detect_primary_interface', 'detect_network_style', 'bridge_exists', 'bridge_carries_default_route', 'bridge_has_address', 'build_bridge', 'write_bridge_stanza', 'backup_network_config', 'schedule_network_rollback', 'cancel_network_rollback', ]; 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'); }); /* |-------------------------------------------------------------------------- | 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('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. 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(); }); /* |-------------------------------------------------------------------------- | 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'); }); /* |-------------------------------------------------------------------------- | 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, // 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}'); }); /** * 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, bool $withGateway = true): array { $work = "{$dir}/work"; $bin = "{$dir}/bin"; $state = "{$dir}/state"; foreach ([$work, $bin, $state, "{$dir}/units", "{$dir}/sbin", "{$dir}/etc"] 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'; // 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", <<> '{$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'); 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('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. $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 // 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); } });