bridge.sh: die Rueckfahrkarte, und der Grund steht vor dem Zurueckspielen

Der Zeitgeber ist eine systemd-Einheit und kein 'sleep &': ein
Hintergrundlauf stirbt mit seiner Sitzung, und die Sitzung ist genau
das, was abreisst, wenn die Umstellung schiefgeht.

Zwei Dinge gegenueber der Fassung, die in network.sh stand:

- render_rollback_script ist vom Stellen getrennt, damit die Reihenfolge
  darin ohne systemd pruefbar ist. Und die Reihenfolge ist der Punkt:
  'failed' samt Grund wird geschrieben, BEVOR zurueckgespielt wird —
  ein halb gegluecktes Zurueckspielen soll das Urteil trotzdem
  hinterlassen. 'rolled-back' kommt zuletzt und ist das Signal, auf das
  CluPilot wartet.
- Der Zeitgeber raeumt seine Unit-Dateien nach dem Feuern selbst weg.
  Sonst sieht eine abgeschlossene Ruecknahme beim naechsten Hinsehen aus
  wie eine ausstehende.

Der Treiber bestellt NICHT ab. Das tut CluPilot, nachdem es sich ueber
den Tunnel neu verbunden hat — ein Skript auf dem Host kann ueber seine
eigene Erreichbarkeit von aussen nur raten.

Geprueft: der Zeitgeber steht vor dem ersten veraendernden Aufruf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
nexxo 2026-08-01 12:39:44 +02:00
parent b630c079ca
commit 0005d321a3
3 changed files with 229 additions and 67 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_SYSTEMCTL="${CLUPILOT_SYSTEMCTL:-systemctl}"
CLUPILOT_UNIT_DIR="${CLUPILOT_UNIT_DIR:-/etc/systemd/system}"
CLUPILOT_SBIN_DIR="${CLUPILOT_SBIN_DIR:-/usr/local/sbin}"
# ---------------------------------------------------------------------------
# Die zwei Helfer, die diese Datei früher geborgt hat
@ -307,6 +310,110 @@ $(extra_routes "$_iface" "$_gw")${_inet6}
EOF
}
# ---------------------------------------------------------------------------
# Die Selbstrücknahme
# ---------------------------------------------------------------------------
#
# Sichern, Zeitgeber auf fünf Minuten, umstellen, nachsehen, abbestellen.
#
# Der Zeitgeber ist eine systemd-Einheit und kein `sleep &`: ein Hintergrundlauf
# stirbt mit seiner Sitzung, und die Sitzung ist genau das, was abreißt, wenn die
# Umstellung schiefgeht. Er ist der einzige Grund, warum dieser Abschnitt die
# Netzkonfiguration überhaupt anfassen darf.
#
# Abbestellt wird er NICHT vom Treiber. Das tut CluPilot, nachdem es sich über
# den Tunnel neu verbunden und nachgesehen hat — ein Skript auf dem Host kann
# über seine eigene Erreichbarkeit von außen nur raten.
backup_network_config() {
mkdir -p "$(dirname -- "$CLUPILOT_NET_BACKUP")"
tar czf "${CLUPILOT_NET_BACKUP}.tar.gz" \
-C / "${CLUPILOT_INTERFACES_FILE#/}" \
$( [ -d "$CLUPILOT_INTERFACES_D" ] && printf '%s' "${CLUPILOT_INTERFACES_D#/}" ) \
2>/dev/null
}
# Der Inhalt des Rücknahme-Skripts — als Text, nicht als Datei.
#
# Getrennt, damit die Reihenfolge darin prüfbar ist, ohne systemd zu brauchen.
# Und die Reihenfolge ist der Punkt: das Urteil wird geschrieben, BEVOR
# zurückgespielt wird, damit auch ein halb geglücktes Zurückspielen einen Grund
# hinterlässt. `rolled-back` kommt zuletzt — das ist das Signal, auf das CluPilot
# wartet, und es darf erst stehen, wenn der alte Zustand wirklich zurück ist.
render_rollback_script() {
_minutes="${1:-5}"
cat <<EOF
#!/bin/sh
# Von CluPilot gestellt (Schritt EnsureNetworkBridge). Spielt die
# Netzkonfiguration von VOR der Bruecke zurueck, weil sie binnen ${_minutes}
# Minuten nicht abbestellt wurde — und abbestellen kann CluPilot nur, wenn es
# den Host ueber den Tunnel wieder erreicht.
printf 'failed' > '${CLUPILOT_WORK_DIR}/state'
printf 'Die Bruecke nahm den Host vom Netz; die Netzkonfiguration von vorher wurde zurueckgespielt.' > '${CLUPILOT_WORK_DIR}/note'
tar xzf '${CLUPILOT_NET_BACKUP}.tar.gz' -C /
if command -v ifreload >/dev/null 2>&1; then
ifreload -a
else
systemctl restart networking
fi
logger -t clupilot 'Netzkonfiguration zurueckgespielt: die Bruecke hat den Host vom Netz genommen.'
# Erst jetzt. Vorher hiesse es: zurueckgespielt, obwohl es noch laeuft.
: > '${CLUPILOT_WORK_DIR}/rolled-back'
# Selbst wegraeumen, sonst liegen Unit-Dateien herum, die aussehen, als stuende
# noch eine Ruecknahme aus.
rm -f '${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.timer' \\
'${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.service'
systemctl daemon-reload 2>/dev/null || true
EOF
}
# Stellt den Zeitgeber. Ab hier gibt es einen Weg zurück, und erst ab hier darf
# irgendetwas am Netz verändert werden.
schedule_network_rollback() {
_minutes="${1:-5}"
mkdir -p "$CLUPILOT_SBIN_DIR" "$CLUPILOT_UNIT_DIR" "$CLUPILOT_WORK_DIR"
render_rollback_script "$_minutes" > "${CLUPILOT_SBIN_DIR}/${CLUPILOT_ROLLBACK_UNIT}.sh"
chmod 700 "${CLUPILOT_SBIN_DIR}/${CLUPILOT_ROLLBACK_UNIT}.sh"
cat > "${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.service" <<EOF
[Unit]
Description=CluPilot: Netzkonfiguration zurückspielen, wenn die Brücke den Host vom Netz nimmt
[Service]
Type=oneshot
ExecStart=${CLUPILOT_SBIN_DIR}/${CLUPILOT_ROLLBACK_UNIT}.sh
EOF
cat > "${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.timer" <<EOF
[Unit]
Description=CluPilot: Frist für die Selbstrücknahme der Netzumstellung
[Timer]
OnActiveSec=${_minutes}min
AccuracySec=1s
EOF
"$CLUPILOT_SYSTEMCTL" daemon-reload
"$CLUPILOT_SYSTEMCTL" start "${CLUPILOT_ROLLBACK_UNIT}.timer"
log "Selbstrücknahme steht: ohne Abbestellung läuft sie in ${_minutes} Minuten"
}
cancel_network_rollback() {
"$CLUPILOT_SYSTEMCTL" stop "${CLUPILOT_ROLLBACK_UNIT}.timer" 2>/dev/null || true
rm -f "${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.timer" \
"${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.service" \
"${CLUPILOT_SBIN_DIR}/${CLUPILOT_ROLLBACK_UNIT}.sh"
"$CLUPILOT_SYSTEMCTL" daemon-reload 2>/dev/null || true
log 'Selbstrücknahme abbestellt'
}
# Strophe schreiben und anwenden. Ab hier ist die Leitung in Gefahr.
build_bridge() {
write_bridge_stanza "$@"

View File

@ -53,76 +53,21 @@
# also nichts — außer, dass es die Brücke nur noch einmal im Repo gibt.
# ---------------------------------------------------------------------------
# Die Selbstrücknahme
# Die Selbstrücknahme — steht jetzt in lib/bridge.sh
# ---------------------------------------------------------------------------
#
# Das Muster aus Block B, unverändert, nur im Skript statt in der Pipeline:
# sichern, Zeitgeber auf fünf Minuten, umstellen, nachsehen, abbestellen.
# `backup_network_config`, `schedule_network_rollback` und
# `cancel_network_rollback` sind mit der Brücke nach `lib/bridge.sh` gezogen.
# Gleiche Namen, gleicher Zweck, also ruft dieser Weg sie unverändert auf.
#
# Der Zeitgeber ist eine systemd-Einheit und kein `sleep &`: ein Hintergrundlauf
# stirbt mit seiner Sitzung, und die Sitzung ist genau das, was abreißt, wenn die
# Umstellung schiefgeht. Er ist der einzige Grund, warum dieser Abschnitt die
# Netzkonfiguration überhaupt anfassen darf.
backup_network_config() {
mkdir -p "$(dirname -- "$CLUPILOT_NET_BACKUP")"
tar czf "${CLUPILOT_NET_BACKUP}.tar.gz" \
-C / etc/network/interfaces $( [ -d /etc/network/interfaces.d ] && printf 'etc/network/interfaces.d' ) \
2>/dev/null
}
# Stellt einen Zeitgeber, der die gesicherte Konfiguration zurückspielt und das
# Netz neu startet, falls ihn niemand abbestellt.
schedule_network_rollback() {
_minutes="${1:-5}"
cat > "/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh" <<EOF
#!/bin/sh
# Von CluPilots Bootstrap gestellt. Spielt die Netzkonfiguration von VOR der
# Brücke zurück, falls der Bootstrap sie nicht binnen ${_minutes} Minuten
# abbestellt hat — weil er das nur kann, wenn die Maschine noch erreichbar ist.
set -e
tar xzf '${CLUPILOT_NET_BACKUP}.tar.gz' -C /
if command -v ifreload >/dev/null 2>&1; then
ifreload -a
else
systemctl restart networking
fi
logger -t clupilot 'Netzkonfiguration zurückgespielt: die Brücke hat den Host vom Netz genommen.'
EOF
chmod 700 "/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh"
cat > "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.service" <<EOF
[Unit]
Description=CluPilot: Netzkonfiguration zurückspielen, wenn die Brücke den Host vom Netz nimmt
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh
EOF
cat > "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.timer" <<EOF
[Unit]
Description=CluPilot: Frist für die Selbstrücknahme der Netzumstellung
[Timer]
OnActiveSec=${_minutes}min
AccuracySec=1s
EOF
systemctl daemon-reload
systemctl start "${CLUPILOT_ROLLBACK_UNIT}.timer"
log "Selbstrücknahme steht: ohne Abbestellung läuft sie in ${_minutes} Minuten"
}
cancel_network_rollback() {
systemctl stop "${CLUPILOT_ROLLBACK_UNIT}.timer" 2>/dev/null || true
rm -f "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.timer" \
"/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.service" \
"/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh"
systemctl daemon-reload 2>/dev/null || true
log 'Selbstrücknahme abbestellt'
}
# Zwei Dinge kann die dortige Fassung mehr:
#
# - Das Rücknahme-Skript schreibt `failed` samt Grund in die Statusdatei, BEVOR
# es zurückspielt, und legt zuletzt `rolled-back` an. Der Debian-Weg pollt von
# außen und muss unterscheiden können, ob eine Rücknahme aussteht oder durch
# ist; ohne diese Zeilen liest er `running` und pollt gegen einen Lauf, den es
# nicht mehr gibt — der Zeitgeber beendet den Treiber ja gerade nicht.
# - Der Zeitgeber räumt seine Unit-Dateien nach dem Feuern selbst weg.
# Antwortet die Maschine noch?
#

View File

@ -184,6 +184,9 @@ it('hält jede Brückenfunktion an genau einer Stelle im Repo', function () {
'bridge_has_address',
'build_bridge',
'write_bridge_stanza',
'backup_network_config',
'schedule_network_rollback',
'cancel_network_rollback',
];
foreach ($functions as $function) {
@ -380,6 +383,113 @@ it('schreibt eine Strophe, die ein Mensch als von CluPilot erkennt', function ()
->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();
});
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