bridge.sh: nachsehen in beide Richtungen, und kein ping

Nur 'komme ich raus' zu pruefen ist notwendig und NICHT hinreichend.
Der Fehlerfall: ifreload bringt vmbr0 sauber hoch, die Maschine erreicht
das Internet, der Treiber waere zufrieden — aber wg0 kommt nicht zurueck.
Dann lebt der Host oeffentlich, CluPilot ist ausgesperrt, und wer hier
abbestellt, hat die Rueckfahrkarte weggeworfen.

wg0.conf enthaelt keine Geraetebindung; der Tunnel haengt an der
Quelladresse, die die Routing-Tabelle hergibt — und genau das ist die
Groesse, die der Umbau anfasst. Das macht den Fall nicht
unwahrscheinlicher, nur unauffaelliger: kein Fehler im Protokoll, nur
ein Handshake, der ausbleibt.

Ist der Handshake schal, wird wg-quick@wg0 EINMAL neu gestartet und
nochmal nachgesehen. Gefahrlos, weil ifreload die SSH-Sitzung ohnehin
schon mitgenommen hat — und es verwandelt einen haengenden Tunnel in
einen laufenden statt in eine Ruecknahme.

Kein ping: Hetzners Debian-Basis hat keins, PrepareBaseSystem
installiert es nicht, und network.sh:190 haette damit immer 'nicht
erreichbar' gesagt. Ein Test haelt bridge.sh ping-frei.

Nebenbei ein Fehler in den Tests selbst behoben: 'if gibtsnicht; then
… else echo NEIN; fi' ist in sh unwahr, also war jeder Test, der NEIN
erwartete, gruen SOLANGE die Funktion fehlte. verdictBody() meldet jetzt
FEHLT und trennt 'falsch' von 'gibt es nicht'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
nexxo 2026-08-01 12:47:02 +02:00
parent 0005d321a3
commit e963821eb4
2 changed files with 248 additions and 3 deletions

View File

@ -43,6 +43,9 @@ CLUPILOT_ROLLBACK_UNIT="${CLUPILOT_ROLLBACK_UNIT:-clupilot-network-rollback}"
CLUPILOT_NET_BACKUP="${CLUPILOT_NET_BACKUP:-/var/lib/clupilot/interfaces.vor-der-bruecke}"
CLUPILOT_WG_HUB_PUBKEY="${CLUPILOT_WG_HUB_PUBKEY:-}"
CLUPILOT_WG_HANDSHAKE_MAX_AGE="${CLUPILOT_WG_HANDSHAKE_MAX_AGE:-180}"
CLUPILOT_WG="${CLUPILOT_WG:-wg}"
CLUPILOT_HANDSHAKE_TRIES="${CLUPILOT_HANDSHAKE_TRIES:-15}"
CLUPILOT_HANDSHAKE_WAIT="${CLUPILOT_HANDSHAKE_WAIT:-2}"
CLUPILOT_SYSTEMCTL="${CLUPILOT_SYSTEMCTL:-systemctl}"
CLUPILOT_UNIT_DIR="${CLUPILOT_UNIT_DIR:-/etc/systemd/system}"
CLUPILOT_SBIN_DIR="${CLUPILOT_SBIN_DIR:-/usr/local/sbin}"
@ -310,6 +313,92 @@ $(extra_routes "$_iface" "$_gw")${_inet6}
EOF
}
# ---------------------------------------------------------------------------
# Nachsehen — beide Richtungen
# ---------------------------------------------------------------------------
#
# Kein `ping`. Hetzners `Debian-trixie-…-base` bringt kein `iputils-ping` mit,
# und `PrepareBaseSystem` installiert es nicht (es holt curl, gnupg, ifupdown2,
# chrony). Eine Prüfung damit sagte IMMER „nicht erreichbar", der Zeitgeber
# spielte IMMER zurück, und der Schritt käme nie durch. Dieselbe Falle hatte
# `ConfigureWireguard` schon einmal, und sie kostete eine Übernahme.
internet_reachable() {
http_get "$CLUPILOT_PROBE_URL" >/dev/null 2>&1
}
# Steht der Tunnel?
#
# Wortgleich die Prüfung aus v1.3.85: frischer Handshake vom KONFIGURIERTEN Hub.
# Nicht „irgendein Peer hat gehandshaked" — auf demselben Hub liegen auch
# Mitarbeiter-Zugänge, und deren Handshake beweist nichts über CluPilots Weg
# hierher.
#
# `0` ist der Wert, den WireGuard für einen Peer meldet, mit dem noch nie
# gesprochen wurde. Vorhanden ist er trotzdem — deshalb die eigene Prüfung
# darauf, statt ihn als Sekundenzahl von 1970 durchgehen zu lassen.
tunnel_handshake_fresh() {
[ -n "$CLUPILOT_WG_HUB_PUBKEY" ] || return 1
_now="$(date +%s)"
_hs="$("$CLUPILOT_WG" show wg0 latest-handshakes 2>/dev/null \
| awk -v k="$CLUPILOT_WG_HUB_PUBKEY" '$1 == k { print $2; exit }')"
[ -n "$_hs" ] || return 1
[ "$_hs" -gt 0 ] 2>/dev/null || return 1
[ "$(( _now - _hs ))" -lt "$CLUPILOT_WG_HANDSHAKE_MAX_AGE" ]
}
# Die Bedingung zum Abbestellen.
#
# Nur „komme ich raus" zu prüfen ist notwendig und NICHT hinreichend. Der
# Fehlerfall: `ifreload -a` bringt die Brücke sauber hoch, die Maschine erreicht
# das Internet, der Treiber wäre zufrieden — aber wg0 kommt nicht zurück.
# Ergebnis: der Host lebt öffentlich, CluPilot ist ausgesperrt, und die
# Rückfahrkarte wurde gerade weggeworfen. Von Hand reparierbar, und genau das
# soll dieser Schritt ja verhindern.
#
# `wg0.conf` enthält KEINE Gerätebindung (siehe
# ConfigureWireguard::renderConfig) — der Tunnel hängt also nicht wörtlich an
# der alten Karte, sondern an der Quelladresse, die die Routing-Tabelle hergibt.
# Und genau das ist die Größe, die der Umbau anfasst. Das macht den Fall nicht
# unwahrscheinlicher, nur unauffälliger: kein Fehler im Protokoll, nur ein
# Handshake, der ausbleibt.
bridge_proven() {
if ! bridge_is_up; then
log 'Die Brücke trägt die Standardroute oder die Adresse nicht'
return 1
fi
if ! internet_reachable; then
log "Kein Weg nach draußen (${CLUPILOT_PROBE_URL})"
return 1
fi
if tunnel_handshake_fresh; then
return 0
fi
# Einmal nachhelfen. Gefahrlos: die SSH-Sitzung ist zu diesem Zeitpunkt
# ohnehin weg, `ifreload` hat sie mitgenommen. Ein hängender Tunnel wird so
# zu einem laufenden statt zu einer Rücknahme.
log 'Handshake schal — wg-quick@wg0 einmal neu starten'
"$CLUPILOT_SYSTEMCTL" restart wg-quick@wg0 >/dev/null 2>&1 || true
_i=0
while [ "$_i" -lt "$CLUPILOT_HANDSHAKE_TRIES" ]; do
if tunnel_handshake_fresh; then
log 'Tunnel steht nach dem Neustart von wg0'
return 0
fi
[ "$CLUPILOT_HANDSHAKE_WAIT" -gt 0 ] && sleep "$CLUPILOT_HANDSHAKE_WAIT"
_i=$(( _i + 1 ))
done
log 'Tunnel steht auch nach dem Neustart von wg0 nicht — CluPilot käme nicht zurück'
return 1
}
# ---------------------------------------------------------------------------
# Die Selbstrücknahme
# ---------------------------------------------------------------------------

View File

@ -58,6 +58,27 @@ function runBridgeSh(string $body, array $env = [], string $pre = ''): string
return trim($result->output());
}
/**
* Baut einen Testrumpf, der JA oder NEIN meldet und FEHLT, wenn es die
* Funktion gar nicht gibt.
*
* Ohne diese Unterscheidung ist jeder Test, der NEIN erwartet, grün, SOLANGE die
* Funktion fehlt: `if gibtsnicht; then … else echo NEIN; fi` ist in sh schlicht
* unwahr. Die rote Phase wäre dann aus dem falschen Grund rot und die grüne
* bewiese nichts genau die Sorte Attrappe, die hier schon einmal vier echte
* Fehler durchgelassen hat.
*
* Die Ausgabe der Funktion selbst wird verworfen, damit `log`-Zeilen das Urteil
* nicht verwässern und `toBe` statt `toContain` geprüft werden kann.
*/
function verdictBody(string $call): string
{
$function = strtok($call, ' ');
return "command -v {$function} >/dev/null 2>&1 || { echo FEHLT; exit 1; }\n"
."if {$call} >/dev/null 2>&1; then echo JA; else echo NEIN; fi";
}
/** Ein /sys/class/net-Baum aus Textdateien. */
function fakeSysNet(string $dir, string $iface, string $mac, bool $physical = true): string
{
@ -122,7 +143,7 @@ it('hält eine Bridge und einen Bond für keine physische Karte', function () {
mkdir("{$sys}/bond0/bonding", 0o755, true);
$out = runBridgeSh(
'if interface_is_physical bond0; then echo JA; else echo NEIN; fi',
verdictBody('interface_is_physical bond0'),
['CLUPILOT_SYS_NET' => $sys]
);
@ -133,7 +154,7 @@ it('hält eine gewöhnliche Karte für eine physische', function () {
$sys = fakeSysNet($this->dir, 'enp0s31f6', 'a8:a1:59:00:11:22');
$out = runBridgeSh(
'if interface_is_physical enp0s31f6; then echo JA; else echo NEIN; fi',
verdictBody('interface_is_physical enp0s31f6'),
['CLUPILOT_SYS_NET' => $sys]
);
@ -152,7 +173,7 @@ it('sagt nur dann „Brücke steht", wenn sie Route UND Adresse trägt', functio
]);
$out = runBridgeSh(
'if bridge_is_up; then echo JA; else echo NEIN; fi',
verdictBody('bridge_is_up'),
['CLUPILOT_IP' => $ip]
);
@ -490,6 +511,141 @@ it('bestellt beim Abbestellen alles ab, was gestellt wurde', function () {
->and(file_exists($this->dir.'/sbin/clupilot-network-rollback.sh'))->toBeFalse();
});
/*
|--------------------------------------------------------------------------
| Nachsehen beide Richtungen
|--------------------------------------------------------------------------
*/
/** Eine `wg`-Attrappe, die einen Handshake von genau einem Peer meldet. */
function fakeWg(string $dir, string $peer, int $secondsAgo): string
{
$when = time() - $secondsAgo;
file_put_contents("{$dir}/wg", "#!/bin/sh\nprintf '%s\\t%s\\n' '{$peer}' '{$when}'\n");
chmod("{$dir}/wg", 0o755);
return "{$dir}/wg";
}
/** Eine Brücke, die steht und trägt. */
function fakeUpBridge(string $dir): string
{
return fakeIp($dir, [
'link show vmbr0' => '5: vmbr0: <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