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
parent
0005d321a3
commit
e963821eb4
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue