CluPilotCloud/tests/Feature/Provisioning/BridgeScriptTest.php

658 lines
26 KiB
PHP

<?php
use Illuminate\Support\Facades\Process;
/*
|--------------------------------------------------------------------------
| bridge.sh, gegen eine echte Shell gefahren
|--------------------------------------------------------------------------
|
| Der Rest der Testsuite liest die Befehle dieses Schritts als Zeichenketten —
| FakeRemoteShell führt keinen davon aus. Die Strophe, die diese Bibliothek
| schreibt, ist aber genau das, was eine Maschine umbringt, wenn sie falsch
| ist. Deshalb wird bridge.sh hier mit `sh` wirklich ausgeführt, gegen
| aufgezeichnete Ausgaben von `ip` statt gegen einen Host.
|
| `CLUPILOT_IP` zeigt dabei auf ein Attrappen-Skript, `CLUPILOT_SYS_NET` auf
| einen Baum aus Textdateien. Beides ist die einzige Berührung mit dem System,
| die die Bibliothek hat.
*/
/**
* Legt eine `ip`-Attrappe an, die auf feste Argumentmuster feste Ausgaben gibt.
*
* @param array<string, string> $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";
}
/** 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: <BROADCAST,MULTICAST,UP> 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('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('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: <BROADCAST,MULTICAST,UP> 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
// 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);
}
});