CluPilotCloud/docs/superpowers/plans/2026-08-01-network-bridge.md

117 KiB
Raw Blame History

vmbr0 automatisch bauen — Umsetzungsplan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ein neuer Pipeline-Schritt EnsureNetworkBridge baut vmbr0 auf einem Debian-Proxmox-Host selbsttätig, unter einer Rückfahrkarte, die den Host auch dann zurückholt, wenn die Brücke ihn vom Netz nimmt.

Architecture: Derselbe Mechanismus wie BuildVmTemplate: eine Shell-Bibliothek (lib/bridge.sh) und ein Treiber (lib/bridge-run.sh) werden wortgleich auf den Host geladen und abgekoppelt gefahren; der PHP-Schritt fragt eine Statusdatei ab und verbraucht dafür poll(), nicht retry(). Der Treiber stellt vor jeder Änderung einen systemd-Zeitgeber, der die alte Netzkonfiguration zurückspielt; abbestellt wird er von CluPilot, erst nachdem es sich über den Tunnel neu verbunden und die Brücke nachgeprüft hat.

Tech Stack: PHP 8 / Laravel 13.8, Pest, POSIX sh (dash auf Debian), ifupdown2, systemd, WireGuard.

Spec: docs/superpowers/specs/2026-08-01-network-bridge-design.md

Global Constraints

  • Der Zeitgeber steht vor jeder Änderung. Keine Zeile, die Netzkonfiguration anfasst, darf vor schedule_network_rollback laufen. Das ist die Zusicherung, die Task 5 prüft.
  • Nur der Zeitgeber stellt zurück. Kein zweiter Rücknahmeweg — weder im Treiber noch in PHP. giveUp() bestellt den Zeitgeber nicht ab.
  • Abbestellt wird erst, wenn beide Richtungen stimmen: http_get "$CLUPILOT_PROBE_URL" und frischer WireGuard-Handshake (180 s) vom konfigurierten Hub.
  • Kein ping. Hetzners Debian-trixie-…-base hat kein iputils-ping, PrepareBaseSystem installiert es nicht (curl gnupg ifupdown2 chrony). Es wird auch nicht nachinstalliert.
  • Alles aus dem laufenden Zustand: ip -4 addr, ip -4 route, /sys/class/net/…. Die Datei des Anbieters ist höchstens Zweitsignal.
  • Kein PHP-Nachbau der Netzlogik. Der Schritt lädt und fährt; ein Test vergleicht Byte für Byte gegen die Repo-Datei.
  • Eine Fassung. Nach Task 1 gibt es detect_primary_interface genau einmal im Repo.
  • POSIX sh, kein Bash-ismus. Jeder Befehl, den der Schritt absetzt, muss sh -n bestehen.
  • Arbeitsverzeichnis auf dem Host: /var/lib/clupilot/bridge.
  • Fristen: Zeitgeber 5 min, Schritt-Frist 15 min, maxDuration() 3600.
  • Kommentarsprache: Shell-Bibliotheken auf Deutsch (wie template.sh, network.sh), PHP-Docblocks auf Englisch (wie BuildVmTemplate.php). Betreibertexte Deutsch.
  • R22 gilt: eine Prüfrunde, eine Fix-Runde, ein Re-Review über den Fix-Diff. Danach werden Restbefunde geparkt.
  • Tests laufen im Container (R8, Docker-first). Nackt gibt es auf dieser Maschine kein php:
    docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest
    
    Das gilt auch für pint. Der clupilot-Helfer hat dafür kein Unterkommando.
  • CommandResult::failure(int $exitCode = 1, string $stderr = '') — zwei Parameter. CommandResult::success(string $stdout = '').
  • Zwischenstände dürfen unfertig sein, aber nicht scharf. Task 6 lässt bewusst ein fail('not implemented yet') stehen, das Task 7 ersetzt. Das ist gefahrlos, weil der Schritt erst in Task 9 in die Pipeline kommt — bis dahin kann ihn kein Lauf erreichen. Wer die Reihenfolge umstellt, nimmt sich diese Sicherheit.

Task 1: bridge.sh — Erkennung, allein lauffähig

Die Brückenhälfte aus network.sh wird zu einer eigenen, selbstgenügsamen Bibliothek. Diese Task bringt nur die Erkennung hinüber (lesen, nichts verändern) und macht sie ohne die vier geborgten Helfer lauffähig.

Files:

  • Create: deploy/bootstrap/lib/bridge.sh
  • Modify: deploy/bootstrap/lib/proxmox.sh:132-135 (Kopie von detect_primary_interface entfernen)
  • Modify: deploy/bootstrap/clupilot-bootstrap.sh:968 (lib/bridge.sh vor lib/proxmox.sh sourcen)
  • Modify: deploy/bootstrap/lib/network.sh:44-103 (Erkennungsteil entfernen, Verweis-Kommentar setzen)
  • Test: tests/Feature/Provisioning/BridgeScriptTest.php

Interfaces:

  • Consumes: nichts.
  • Produces (alle in bridge.sh, POSIX sh):
    • detect_primary_interface() -> stdout: iface-Name (leer, wenn keine Standardroute)
    • interface_is_physical(iface) -> exit 0/1
    • address_is_dynamic(iface) -> exit 0/1
    • detect_network_style(iface) -> stdout: dhcp|routed|subnet
    • bridge_exists(name) -> exit 0/1
    • bridge_carries_default_route(name) -> exit 0/1
    • bridge_has_address(name) -> exit 0/1
    • bridge_is_up() -> exit 0/1 (die drei zusammen, gegen $CLUPILOT_BRIDGE)
    • Variablen mit Vorgaben: CLUPILOT_BRIDGE, CLUPILOT_INTERFACES_FILE, CLUPILOT_INTERFACES_D, CLUPILOT_SYS_NET, CLUPILOT_IP, CLUPILOT_PROBE_URL, CLUPILOT_IFRELOAD, CLUPILOT_ROLLBACK_UNIT, CLUPILOT_NET_BACKUP, CLUPILOT_WORK_DIR, CLUPILOT_WG_HUB_PUBKEY, CLUPILOT_WG_HANDSHAKE_MAX_AGE
    • log(msg) und http_get(url), beide unter einem Definitionsschutz (command -v), damit clupilot-bootstrap.sh seine eigenen behält.

CLUPILOT_IP ist der Kniff, der die Erkennung ohne Host prüfbar macht: statt ip direkt aufzurufen, ruft jede Funktion "$CLUPILOT_IP". Ein Test setzt das auf ein Attrappen-Skript, das aufgezeichnete Ausgaben zurückgibt — dieselbe Technik wie CLUPILOT_STORAGE_CFG beim Vorlagenbau.

  • Step 1: Den Testfall schreiben

tests/Feature/Provisioning/BridgeScriptTest.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());
}

/** 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 ist überall
    // gleich; wie der Anbieter es aufgeschrieben hat, nicht.
    $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(
        'if interface_is_physical bond0; then echo JA; else echo NEIN; fi',
        ['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(
        'if interface_is_physical enp0s31f6; then echo JA; else echo NEIN; fi',
        ['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.
    $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(
        'if bridge_is_up; then echo JA; else echo NEIN; fi',
        ['CLUPILOT_IP' => $ip]
    );

    expect($out)->toBe('NEIN');
});
  • Step 2: Testlauf, der scheitern muss
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php

Erwartet: FAIL — deploy/bootstrap/lib/bridge.sh existiert nicht, sh bricht mit „No such file or directory" ab.

  • Step 3: bridge.sh anlegen (Erkennungsteil)

deploy/bootstrap/lib/bridge.sh:

# shellcheck shell=sh
#
# Die Brücke über die primäre Netzkarte — Erkennen, Sichern, Zeitgeber, Bauen,
# Nachsehen.
#
# ---------------------------------------------------------------------------
# Warum das eine eigene Datei ist
# ---------------------------------------------------------------------------
#
# Diese Zeilen standen in `network.sh`, geschrieben für den stillgelegten
# Rettungssystem-Weg, und borgten sich vier Dinge von woanders:
# `detect_primary_interface` aus `proxmox.sh`, `log`, `http_get` und
# `CLUPILOT_PROBE_URL` aus `clupilot-bootstrap.sh`. Der Debian-Weg lädt sie
# einzeln auf den Host und fährt sie dort — geborgte Helfer wären dann nicht da.
#
# Also allein lauffähig. `network.sh` behält seine WireGuard- und
# nftables-Hälfte; diese Datei wird von beiden Wegen benutzt und existiert genau
# einmal. Eine zweite Fassung wären zwei Installationen, die bei jeder
# Proxmox-Version nachgezogen werden müssten — und die zweite fiele erst auf,
# wenn jemand sie benutzt.
#
# ---------------------------------------------------------------------------
# Warum aus dem LAUFENDEN Zustand abgeleitet wird
# ---------------------------------------------------------------------------
#
# Nicht aus `/etc/network/interfaces`. Was läuft, ist bei jedem Anbieter gleich
# strukturiert; wie es aufgeschrieben wurde, nicht. Das ist der Teil, der
# Hetzner und netcup zugleich trägt.

# ---------------------------------------------------------------------------
# Stellschrauben — überschreibbar, damit die Bibliothek ohne Host prüfbar ist
# ---------------------------------------------------------------------------

CLUPILOT_BRIDGE="${CLUPILOT_BRIDGE:-vmbr0}"
CLUPILOT_WORK_DIR="${CLUPILOT_WORK_DIR:-/var/lib/clupilot/bridge}"
CLUPILOT_INTERFACES_FILE="${CLUPILOT_INTERFACES_FILE:-/etc/network/interfaces}"
CLUPILOT_INTERFACES_D="${CLUPILOT_INTERFACES_D:-/etc/network/interfaces.d}"
CLUPILOT_SYS_NET="${CLUPILOT_SYS_NET:-/sys/class/net}"
CLUPILOT_IP="${CLUPILOT_IP:-ip}"
CLUPILOT_IFRELOAD="${CLUPILOT_IFRELOAD:-ifreload}"
CLUPILOT_PROBE_URL="${CLUPILOT_PROBE_URL:-http://deb.debian.org/}"
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}"

# ---------------------------------------------------------------------------
# Die zwei Helfer, die diese Datei früher geborgt hat
# ---------------------------------------------------------------------------
#
# Unter Definitionsschutz: `clupilot-bootstrap.sh` bringt eigene mit, und wer
# zuerst da ist, behält recht. Sonst überschriebe das Laden dieser Bibliothek
# die Protokollierung des Rettungssystem-Wegs.

if ! command -v log >/dev/null 2>&1; then
    log() {
        printf '%s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$1"
    }
fi

if ! command -v http_get >/dev/null 2>&1; then
    http_get() {
        if command -v curl >/dev/null 2>&1; then
            curl -fsSL --retry 3 --retry-delay 2 --max-time 20 "$1"
        else
            wget -qO- --timeout=20 "$1"
        fi
    }
fi

# ---------------------------------------------------------------------------
# Feststellen, was da ist
# ---------------------------------------------------------------------------

# Die Schnittstelle, über die die Vorgabe-Route geht. Nicht „die erste, die
# nicht lo heißt": eine Maschine mit zwei Karten hat oft eine angeschlossene und
# eine nicht.
detect_primary_interface() {
    "$CLUPILOT_IP" -4 route show default 2>/dev/null \
        | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }'
}

# Ist das eine physische Karte?
#
# `bridge_ports` auf einem Bond oder einer bestehenden Bridge ist falsch — die
# Brücke nähme sich ihren eigenen Unterbau als Port, und was dabei herauskommt,
# ist von hier aus nicht mehr zu reparieren. VLAN-Geräte und Bonds haben keinen
# `device`-Verweis; die beiden weiteren Prüfungen sind der Gürtel dazu.
interface_is_physical() {
    _if="${1:-}"

    [ -n "$_if" ] || return 1
    [ -e "${CLUPILOT_SYS_NET}/${_if}/device" ] || return 1
    [ ! -d "${CLUPILOT_SYS_NET}/${_if}/bridge" ] || return 1
    [ ! -d "${CLUPILOT_SYS_NET}/${_if}/bonding" ] || return 1

    return 0
}

# Kam die Adresse per DHCP?
#
# Aus dem laufenden Zustand: der Kernel markiert eine geleaste Adresse mit
# `dynamic`. Das steht bei jedem Anbieter gleich da — anders als die Datei, in
# die er es geschrieben hat.
address_is_dynamic() {
    "$CLUPILOT_IP" -4 -o addr show dev "$1" scope global 2>/dev/null \
        | grep -q '[[:space:]]dynamic[[:space:]]'
}

# Wie der Anbieter das Netz aufzieht. Eine Brücke, die für den einen Fall
# richtig ist, nimmt den anderen vom Netz.
#
# - `dhcp`    — die Adresse kommt per DHCP (Cloud-Produkte).
# - `routed`  — geroutete Einzeladresse, Gateway AUSSERHALB des eigenen Subnetzes
#               (Hetzner-dediziert mit /32). Braucht eine pointopoint-Route,
#               sonst findet der Kernel das Gateway nicht.
# - `subnet`  — gewöhnliches Subnetz, Gateway darin.
detect_network_style() {
    _iface="${1:-}"
    [ -n "$_iface" ] || _iface="$(detect_primary_interface)"

    # Laufender Zustand zuerst. Die Datei des Anbieters ist nur das Zweitsignal
    # — sie sagt, was jemand aufgeschrieben hat, nicht was gilt.
    if address_is_dynamic "$_iface"; then
        printf 'dhcp'
        return 0
    fi

    if grep -qsE "iface[[:space:]]+${_iface}[[:space:]]+inet[[:space:]]+dhcp" \
        "$CLUPILOT_INTERFACES_FILE" "${CLUPILOT_INTERFACES_D}"/* 2>/dev/null; then
        printf 'dhcp'
        return 0
    fi

    _cidr="$("$CLUPILOT_IP" -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')"
    _gw="$("$CLUPILOT_IP" -4 route show default 2>/dev/null | awk '{ print $3; exit }')"
    _prefix="${_cidr##*/}"

    # /32 heißt: das eigene Subnetz besteht aus der eigenen Adresse. Ein Gateway
    # darin kann es nicht geben.
    if [ "$_prefix" = '32' ]; then
        printf 'routed'
        return 0
    fi

    # Liegt das Gateway im eigenen Subnetz? `ip route get` beantwortet das, ohne
    # dass dieses Skript Netzmasken rechnen muss — und rechnet dabei mit
    # derselben Logik, die der Kernel später anwendet.
    if [ -n "$_gw" ] && "$CLUPILOT_IP" -4 route get "$_gw" 2>/dev/null | grep -q "dev ${_iface}.*src"; then
        printf 'subnet'
        return 0
    fi

    printf 'routed'
}

bridge_exists() {
    "$CLUPILOT_IP" link show "${1:-$CLUPILOT_BRIDGE}" >/dev/null 2>&1
}

# Trägt die Brücke wirklich den Verkehr, oder existiert sie nur?
#
# Eine `vmbr0` ohne Adresse und ohne Vorgaberoute 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.
bridge_carries_default_route() {
    _want="${1:-$CLUPILOT_BRIDGE}"
    _dev="$("$CLUPILOT_IP" -4 route show default 2>/dev/null \
        | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }')"

    [ "$_dev" = "$_want" ]
}

bridge_has_address() {
    [ -n "$("$CLUPILOT_IP" -4 -o addr show dev "${1:-$CLUPILOT_BRIDGE}" scope global 2>/dev/null | awk '{ print $4; exit }')" ]
}

# Die drei zusammen. „Da" reicht nicht — sie muss tragen.
bridge_is_up() {
    bridge_exists "$CLUPILOT_BRIDGE" \
        && bridge_carries_default_route "$CLUPILOT_BRIDGE" \
        && bridge_has_address "$CLUPILOT_BRIDGE"
}
  • Step 4: Testlauf, der bestehen muss
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php

Erwartet: PASS, sechs Tests.

  • Step 5: Die alte Fassung entfernen

In deploy/bootstrap/lib/proxmox.sh den Block detect_primary_interface() { … } (samt Kommentar, Zeilen 130135) löschen und dort einen Verweis hinterlassen:

# `detect_primary_interface` steht in lib/bridge.sh — eine Fassung, zwei
# Benutzer. clupilot-bootstrap.sh lädt bridge.sh vor dieser Datei; die Funktion
# wird erst zur Aufrufzeit aufgelöst, also ist die Reihenfolge unkritisch.

In deploy/bootstrap/lib/network.sh die Zeilen 40103 (Variablen CLUPILOT_ROLLBACK_UNIT/CLUPILOT_NET_BACKUP, bridge_exists, bridge_carries_default_route, bridge_has_address, detect_network_style) löschen und ersetzen durch:

# Die Brücke steht jetzt in lib/bridge.sh — Erkennen, Sichern, Zeitgeber, Bauen,
# Nachsehen, alles beisammen und allein lauffähig, weil der Debian-Weg sie
# einzeln auf den Host lädt. clupilot-bootstrap.sh sourced sie vor dieser Datei.
# Hier bleibt, was Kunden-VMs, WireGuard und nftables angeht.

In deploy/bootstrap/clupilot-bootstrap.sh vor Zeile 968 einfügen:

    # shellcheck source=lib/bridge.sh
    . "${CLUPILOT_BOOTSTRAP_DIR}/lib/bridge.sh"
  • Step 6: Test „eine Fassung" schreiben und laufen lassen

Ans Ende von tests/Feature/Provisioning/BridgeScriptTest.php:

it('hält detect_primary_interface 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.
    // 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')) ?: [],
    );

    $hits = [];
    foreach ($files as $file) {
        if (preg_match('/^detect_primary_interface\(\)/m', (string) file_get_contents($file))) {
            $hits[] = str_replace(base_path().'/', '', $file);
        }
    }

    expect($hits)->toBe(['deploy/bootstrap/lib/bridge.sh']);
});

it('lässt jede Bibliothek des Bootstraps von einer Shell parsen', function () {
    // network.sh und proxmox.sh werden in dieser Task 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);
    }
});
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php

Erwartet: PASS, acht Tests.

  • Step 7: Commit
git add deploy/bootstrap/lib/bridge.sh deploy/bootstrap/lib/network.sh deploy/bootstrap/lib/proxmox.sh deploy/bootstrap/clupilot-bootstrap.sh tests/Feature/Provisioning/BridgeScriptTest.php
git commit -m "bridge.sh: die Erkennung herausgeloest und allein lauffaehig gemacht"

Task 2: bridge.sh — die Strophe

Der wertvollste Teil des Vorhabens: was hier falsch herauskommt, nimmt die Maschine vom Netz. Deshalb wird der Generator gegen eine echte Shell gefahren und gegen vier Anbieterfälle geprüft.

Files:

  • Modify: deploy/bootstrap/lib/bridge.sh (anhängen)
  • Test: tests/Feature/Provisioning/BridgeScriptTest.php (anhängen)

Interfaces:

  • Consumes: detect_network_style, CLUPILOT_IP, CLUPILOT_SYS_NET, CLUPILOT_INTERFACES_FILE, CLUPILOT_BRIDGE, log aus Task 1.
  • Produces:
    • extra_routes(iface, gw) -> stdout: 0..n Zeilen " up ip route add … || true"
    • static_ipv6_on(iface) -> stdout: CIDR oder leer
    • ipv6_gateway() -> stdout: Adresse oder leer
    • write_bridge_stanza(iface, style, cidr, gw) — schreibt nach $CLUPILOT_INTERFACES_FILE, verändert sonst nichts, kein ifreload
    • build_bridge(iface, style, cidr, gw) — ruft write_bridge_stanza und danach "$CLUPILOT_IFRELOAD" -a

Getrennt, weil der Test die Strophe prüfen will, ohne ein Netz neu zu laden.

  • Step 1: Die vier Anbieterfälle als Test schreiben

Anhängen an tests/Feature/Provisioning/BridgeScriptTest.php:

/*
|--------------------------------------------------------------------------
| Die Strophe
|--------------------------------------------------------------------------
|
| Vier Fä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,
    ]);

    $stanza = file_get_contents($out);

    expect($stanza)
        ->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,
    ]);

    $stanza = file_get_contents($out);

    expect($stanza)
        ->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 nimmt bei `ifreload` die 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,
    ]);

    $stanza = file_get_contents($out);

    expect($stanza)
        ->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,
    ]);

    $stanza = file_get_contents($out);

    expect($stanza)
        ->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,
    ]);

    $stanza = file_get_contents($out);

    expect($stanza)
        ->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');
});
  • Step 2: Testlauf, der scheitern muss
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php

Erwartet: FAIL — write_bridge_stanza: not found.

  • Step 3: Den Generator anhängen

An deploy/bootstrap/lib/bridge.sh:

# ---------------------------------------------------------------------------
# Die Strophe
# ---------------------------------------------------------------------------

# Zusatzrouten des Anbieters — alles auf der Karte, was der Kernel nicht von
# selbst wieder anlegt.
#
# Ausgelassen werden zwei Sorten, und beide mit Grund: die Kernel-Route zum
# eigenen Subnetz (`proto kernel`) entsteht, sobald die Adresse steht, und die
# Link-Route zum Gateway legt `pointopoint` an. Sie trotzdem einzutragen ließe
# `ip route add` scheitern — und ein gescheitertes `up` nimmt bei `ifreload`
# die ganze Strophe mit.
extra_routes() {
    _iface="$1"
    _gw="$2"

    "$CLUPILOT_IP" -4 route show dev "$_iface" 2>/dev/null | while IFS= read -r _route; do
        [ -n "$_route" ] || continue

        case "$_route" in
            default*) continue ;;
            "${_gw} "*|"${_gw}") continue ;;
            *'proto kernel'*) continue ;;
        esac

        printf '    up ip route add %s dev %s || true\n' "$_route" "$CLUPILOT_BRIDGE"
    done
}

# Eine statische, globale IPv6-Adresse — oder nichts.
#
# `dynamic` (SLAAC/DHCPv6) und `temporary` (Privacy Extensions) fliegen raus:
# was der Kernel selbst vergibt, gehört nicht in eine Datei geschrieben.
static_ipv6_on() {
    "$CLUPILOT_IP" -6 -o addr show dev "$1" scope global 2>/dev/null \
        | grep -v '[[:space:]]dynamic[[:space:]]' \
        | grep -v '[[:space:]]temporary[[:space:]]' \
        | awk '{ print $4; exit }'
}

ipv6_gateway() {
    "$CLUPILOT_IP" -6 route show default 2>/dev/null \
        | awk '{ for (i = 1; i < NF; i++) if ($i == "via") { print $(i+1); exit } }'
}

# Schreibt die Strophe — und sonst nichts. Kein `ifreload`, kein Sichern, kein
# Zeitgeber. Getrennt von build_bridge, damit genau das prüfbar ist, was eine
# Maschine umbringt, ohne dafür ein Netz neu laden zu müssen.
write_bridge_stanza() {
    _iface="$1"
    _style="$2"
    _cidr="$3"
    _gw="$4"

    _ip4="${_cidr%%/*}"
    _mac="$(cat "${CLUPILOT_SYS_NET}/${_iface}/address" 2>/dev/null)"

    case "$_style" in
        dhcp)
            _inet="iface ${CLUPILOT_BRIDGE} inet dhcp"
            _addr=''
            ;;
        routed)
            # Gateway außerhalb des eigenen Subnetzes. Ohne `pointopoint` findet
            # der Kernel keinen Weg dorthin: die Route zeigte auf ein Netz, in
            # dem das Gateway nicht liegt, und die Maschine wäre still weg.
            _inet="iface ${CLUPILOT_BRIDGE} inet static"
            _addr="    address ${_ip4}/32
    pointopoint ${_gw}
    gateway ${_gw}"
            ;;
        *)
            _inet="iface ${CLUPILOT_BRIDGE} inet static"
            _addr="    address ${_cidr}
    gateway ${_gw}"
            ;;
    esac

    # Festgenagelt: eine Brücke wählt sonst die kleinste MAC ihrer Ports, und
    # manche Anbieter binden den Switchport an die der Karte. Bei einem Port ist
    # das dieselbe — aber „ist dieselbe" und „bleibt dieselbe" sind zweierlei.
    _hw=''
    [ -n "$_mac" ] && _hw="    hwaddress ether ${_mac}"

    _inet6=''
    _v6="$(static_ipv6_on "$_iface")"
    _v6gw="$(ipv6_gateway)"

    if [ -n "$_v6" ] && [ -n "$_v6gw" ]; then
        _inet6="
iface ${CLUPILOT_BRIDGE} inet6 static
    address ${_v6}
    gateway ${_v6gw}"
    elif [ -n "$_v6" ] || [ -n "$_v6gw" ]; then
        log 'IPv6 wird NICHT mit auf die Brücke genommen: keine statische globale Adresse mit Standardgateway (SLAAC/DHCPv6 werden bewusst nicht nachgebaut — Proxmox setzt forwarding=1, und der Kernel verwirft RAs dann ohne accept_ra=2).'
    fi

    cat > "$CLUPILOT_INTERFACES_FILE" <<EOF
# Von CluPilot geschrieben (Schritt EnsureNetworkBridge), weil auf dieser
# Maschine keine ${CLUPILOT_BRIDGE} lag: Proxmox auf Debian legt keine an, nur
# der ISO-Installer tut das.
#
# Abgeleitet aus dem LAUFENDEN Zustand, nicht aus der Datei des Anbieters.
# Anbieterform: ${_style}. Karte: ${_iface}.
#
# Die vorherige Fassung liegt unter ${CLUPILOT_NET_BACKUP}.tar.gz.
source ${CLUPILOT_INTERFACES_D}/*

auto lo
iface lo inet loopback

iface ${_iface} inet manual

auto ${CLUPILOT_BRIDGE}
${_inet}
${_addr}
${_hw}
    bridge-ports ${_iface}
    bridge-stp off
    bridge-fd 0
$(extra_routes "$_iface" "$_gw")${_inet6}
EOF
}

# Strophe schreiben und anwenden. Ab hier ist die Leitung in Gefahr.
build_bridge() {
    write_bridge_stanza "$@"

    log 'Strophe geschrieben:'
    cat "$CLUPILOT_INTERFACES_FILE"

    if command -v "$CLUPILOT_IFRELOAD" >/dev/null 2>&1; then
        "$CLUPILOT_IFRELOAD" -a
    else
        systemctl restart networking
    fi
}
  • Step 4: Testlauf, der bestehen muss
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php

Erwartet: PASS, fünfzehn Tests.

  • Step 5: Commit
git add deploy/bootstrap/lib/bridge.sh tests/Feature/Provisioning/BridgeScriptTest.php
git commit -m "bridge.sh: die Strophe, gegen vier Anbieterfaelle geprueft"

Task 3: bridge.sh — die Rückfahrkarte

Sichern, Zeitgeber, Abbestellen. Der Zeitgeber ist der einzige Grund, warum dieser Schritt die Netzkonfiguration überhaupt anfassen darf.

Files:

  • Modify: deploy/bootstrap/lib/bridge.sh (anhängen)
  • Test: tests/Feature/Provisioning/BridgeScriptTest.php (anhängen)

Interfaces:

  • Consumes: log, CLUPILOT_NET_BACKUP, CLUPILOT_ROLLBACK_UNIT, CLUPILOT_WORK_DIR, CLUPILOT_INTERFACES_FILE, CLUPILOT_INTERFACES_D aus Task 1.

  • Produces:

    • backup_network_config()tar czf "${CLUPILOT_NET_BACKUP}.tar.gz"
    • render_rollback_script() -> stdout (der Inhalt, nicht die Datei — damit prüfbar)
    • schedule_network_rollback(minuten) — schreibt Skript + .service + .timer, daemon-reload, start
    • cancel_network_rollback()
    • CLUPILOT_SYSTEMCTL (Vorgabe systemctl), CLUPILOT_UNIT_DIR (Vorgabe /etc/systemd/system), CLUPILOT_SBIN_DIR (Vorgabe /usr/local/sbin) — überschreibbar für den Test
  • Step 1: Test schreiben

Anhängen an tests/Feature/Provisioning/BridgeScriptTest.php:

/*
|--------------------------------------------------------------------------
| 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');

    expect($stateAt)->not->toBeFalse()
        ->and($restoreAt)->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($script)->toContain('rolled-back')
        ->and(strpos($script, 'rolled-back'))->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, die schedule_network_rollback und build_bridge absetzen.
    $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);

    $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("{$bin}/ifreload", "#!/bin/sh\necho 'ifreload' >> '{$this->dir}/calls'\n");
    chmod("{$bin}/ifreload", 0o755);
    mkdir($this->dir.'/units', 0o755, true);
    mkdir($this->dir.'/sbin', 0o755, true);
    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()
        // Umgekehrt wäre die Reihenfolge, die einen Host dauerhaft unerreichbar
        // macht.
        ->and(strpos($calls, 'start clupilot-network-rollback.timer'))
        ->toBeLessThan(strpos($calls, 'ifreload'))
        // Und die Sicherung muss existieren, bevor der Zeitgeber sie
        // zurückspielen könnte.
        ->and(file_exists($this->dir.'/sicherung.tar.gz'))->toBeTrue();
});
  • Step 2: Testlauf, der scheitern muss
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php

Erwartet: FAIL — render_rollback_script: not found.

  • Step 3: Die Rückfahrkarte anhängen

Zuerst die drei neuen Stellschrauben oben in bridge.sh zu den anderen:

CLUPILOT_SYSTEMCTL="${CLUPILOT_SYSTEMCTL:-systemctl}"
CLUPILOT_UNIT_DIR="${CLUPILOT_UNIT_DIR:-/etc/systemd/system}"
CLUPILOT_SBIN_DIR="${CLUPILOT_SBIN_DIR:-/usr/local/sbin}"

Dann anhängen:

# ---------------------------------------------------------------------------
# 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 hier. Das tut CluPilot, nachdem es sich neu verbunden
# und nachgesehen hat — der Treiber kann über seine eigene Erreichbarkeit 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 Brücke zurück, weil sie binnen ${_minutes}
# Minuten nicht abbestellt wurde — und abbestellen kann CluPilot nur, wenn es
# den Host über 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'
}
  • Step 4: Testlauf, der bestehen muss
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php

Erwartet: PASS, achtzehn Tests.

  • Step 5: Commit
git add deploy/bootstrap/lib/bridge.sh tests/Feature/Provisioning/BridgeScriptTest.php
git commit -m "bridge.sh: die Rueckfahrkarte, und der Grund steht vor dem Zurueckspielen"

Task 4: bridge.sh — nachsehen, beide Richtungen

Die Bedingung zum Abbestellen. Nur „komme ich raus" zu prüfen ist notwendig und nicht hinreichend — und kein ping.

Files:

  • Modify: deploy/bootstrap/lib/bridge.sh (anhängen)
  • Test: tests/Feature/Provisioning/BridgeScriptTest.php (anhängen)

Interfaces:

  • Consumes: bridge_is_up, http_get, log, CLUPILOT_PROBE_URL, CLUPILOT_WG_HUB_PUBKEY, CLUPILOT_WG_HANDSHAKE_MAX_AGE aus Task 1/3.

  • Produces:

    • internet_reachable() -> exit 0/1
    • tunnel_handshake_fresh() -> exit 0/1
    • bridge_proven() -> exit 0/1 — die drei zusammen, mit dem einen wg-Neustart
    • CLUPILOT_WG (Vorgabe wg), CLUPILOT_HANDSHAKE_TRIES (Vorgabe 15), CLUPILOT_HANDSHAKE_WAIT (Vorgabe 2)
  • Step 1: Test schreiben

Anhängen an tests/Feature/Provisioning/BridgeScriptTest.php:

/*
|--------------------------------------------------------------------------
| 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;
    $script = "#!/bin/sh\nprintf '%s\\t%s\\n' '{$peer}' '{$when}'\n";
    file_put_contents("{$dir}/wg", $script);
    chmod("{$dir}/wg", 0o755);

    return "{$dir}/wg";
}

it('bestellt nicht ab, wenn der Tunnel steht, aber das Internet nicht erreichbar ist', function () {
    $ip = fakeIp($this->dir, [
        'link show vmbr0' => '5: vmbr0: <BROADCAST,MULTICAST,UP>',
        '-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',
    ]);
    $wg = fakeWg($this->dir, 'HUBKEY=', 5);

    $out = runBridgeSh(
        'if bridge_proven; then echo JA; else echo NEIN; fi',
        [
            '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)->toContain('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 = fakeIp($this->dir, [
        'link show vmbr0' => '5: vmbr0: <BROADCAST,MULTICAST,UP>',
        '-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',
    ]);
    // Handshake ist eine Stunde alt.
    $wg = fakeWg($this->dir, 'HUBKEY=', 3600);
    $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(
        'if bridge_proven; then echo JA; else echo NEIN; fi',
        [
            'CLUPILOT_IP' => $ip,
            'CLUPILOT_WG' => $wg,
            'CLUPILOT_WG_HUB_PUBKEY' => 'HUBKEY=',
            'CLUPILOT_SYSTEMCTL' => "{$bin}/systemctl",
            'CLUPILOT_HANDSHAKE_TRIES' => '1',
            'CLUPILOT_HANDSHAKE_WAIT' => '0',
        ],
        // Der Weg nach draußen steht — genau das ist der Punkt des Tests.
        pre: 'http_get() { return 0; }'
    );

    expect($out)->toContain('NEIN')
        // Einmal nachhelfen gehört dazu: das ist zu diesem Zeitpunkt gefahrlos,
        // weil ifreload die SSH-Sitzung ohnehin schon mitgenommen hat.
        ->and(file_get_contents($this->dir.'/calls'))
        ->toContain('restart wg-quick@wg0');
});

it('erkennt einen Handshake von einem FREMDEN Peer nicht als den des Hubs', function () {
    // Ein Mitarbeiter-Zugang auf demselben Hub hätte den Beweis sonst
    // erbracht, ohne dass CluPilot selbst durchkommt.
    $wg = fakeWg($this->dir, 'IRGENDWER=', 5);

    $out = runBridgeSh('if tunnel_handshake_fresh; then echo JA; else echo NEIN; fi', [
        'CLUPILOT_WG' => $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. 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');
});
  • Step 2: Testlauf, der scheitern muss
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php

Erwartet: FAIL — bridge_proven: not found.

  • Step 3: Nachsehen anhängen

Neue Stellschrauben oben in bridge.sh:

CLUPILOT_WG="${CLUPILOT_WG:-wg}"
CLUPILOT_HANDSHAKE_TRIES="${CLUPILOT_HANDSHAKE_TRIES:-15}"
CLUPILOT_HANDSHAKE_WAIT="${CLUPILOT_HANDSHAKE_WAIT:-2}"

Dann anhängen:

# ---------------------------------------------------------------------------
# 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.
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
}
  • Step 4: Testlauf, der bestehen muss
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php

Erwartet: PASS, zweiundzwanzig Tests.

  • Step 5: Commit
git add deploy/bootstrap/lib/bridge.sh tests/Feature/Provisioning/BridgeScriptTest.php
git commit -m "bridge.sh: nachsehen in beide Richtungen, und kein ping"

Task 5: bridge-run.sh — der Treiber

Verdrahtet Task 14 in der einen Reihenfolge, die stimmen muss, und übernimmt das Netz von einem Fremdverwalter, falls einer da ist.

Files:

  • Create: deploy/bootstrap/lib/bridge-run.sh
  • Test: tests/Feature/Provisioning/BridgeScriptTest.php (anhängen)

Interfaces:

  • Consumes: alles aus bridge.sh.

  • Produces: ein Skript, das per sh bridge-run.sh läuft und state/pid/phase/note/bridge.log in $CLUPILOT_WORK_DIR schreibt. Liest $CLUPILOT_WORK_DIR/env.

  • Produces (in bridge.sh, weil es dorthin gehört): foreign_network_manager() -> stdout: cloud-init|networkd|network-manager|unbekannt|"" und disown_network_manager().

  • Step 1: Test schreiben

Anhängen an tests/Feature/Provisioning/BridgeScriptTest.php:

/*
|--------------------------------------------------------------------------
| 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.
    $lines = file(base_path('deploy/bootstrap/lib/bridge-run.sh'));
    $pidAt = null;
    foreach ($lines as $i => $line) {
        if (str_contains($line, '/pid"') && str_contains($line, '$$')) {
            $pidAt = $i;
            break;
        }
    }

    expect($pidAt)->not->toBeNull()
        ->and($pidAt)->toBeLessThan(60);
});

it('stellt den Zeitgeber vor dem ersten veraendernden Aufruf', function () {
    // Dieselbe Zusicherung wie in Task 3, hier auf der Ebene des Treibers: die
    // Reihenfolge der AUFRUFE im Skripttext.
    $driver = file_get_contents(base_path('deploy/bootstrap/lib/bridge-run.sh'));

    $schedule = strpos($driver, 'schedule_network_rollback');
    $disown = strpos($driver, 'disown_network_manager');
    $build = strpos($driver, 'build_bridge');
    $backup = strpos($driver, 'backup_network_config');

    expect($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.
    expect(file_get_contents(base_path('deploy/bootstrap/lib/bridge-run.sh')))
        ->not->toContain('cancel_network_rollback');
});

it('laesst den Treiber von einer Shell parsen', function () {
    expect(Process::run(['sh', '-n', base_path('deploy/bootstrap/lib/bridge-run.sh')])->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.
    $etc = $this->dir.'/etc';
    mkdir("{$etc}/cloud", 0o755, true);
    file_put_contents("{$etc}/cloud/cloud.cfg", "datasource_list: [ Hetzner ]\n");

    expect(runBridgeSh('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(runBridgeSh('foreign_network_manager', ['CLUPILOT_ETC' => $etc]))
        ->toBe('');
});
  • Step 2: Testlauf, der scheitern muss
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php

Erwartet: FAIL — bridge-run.sh existiert nicht, foreign_network_manager: not found.

  • Step 3: Fremdverwalter-Erkennung an bridge.sh anhängen

Neue Stellschraube oben:

CLUPILOT_ETC="${CLUPILOT_ETC:-/etc}"

Anhängen:

# ---------------------------------------------------------------------------
# Fremdverwalter
# ---------------------------------------------------------------------------
#
# Aus dem laufenden Zustand abzuleiten löst das LESEN. Es löst nicht das
# SCHREIBEN: führt cloud-init das Netz, ist `/etc/network/interfaces` nicht die
# Stelle, an der die Wahrheit steht. Die Brücke griffe entweder sofort nicht
# oder — schlimmer — sie griffe jetzt und würde beim nächsten Neustart wieder
# eingesammelt. Dann läuft die Übernahme grün durch und der Host verliert seine
# Brücke Monate später, mit Kunden darauf.
#
# Der Zeitgeber fängt diesen Fall NICHT ab. Deshalb steht das hier.

# Wer führt das Netz? Leer heißt: reines ifupdown, und der Weg ist frei.
foreign_network_manager() {
    if [ -f "${CLUPILOT_ETC}/cloud/cloud.cfg" ] || [ -d "${CLUPILOT_ETC}/cloud/cloud.cfg.d" ]; then
        printf 'cloud-init'
        return 0
    fi

    if [ -d "${CLUPILOT_ETC}/systemd/network" ] \
        && [ -n "$(ls -A "${CLUPILOT_ETC}/systemd/network" 2>/dev/null)" ]; then
        printf 'networkd'
        return 0
    fi

    if [ -d "${CLUPILOT_ETC}/NetworkManager/system-connections" ] \
        && [ -n "$(ls -A "${CLUPILOT_ETC}/NetworkManager/system-connections" 2>/dev/null)" ]; then
        printf 'network-manager'
        return 0
    fi

    printf ''
}

# Entmachtet den erkannten Verwalter — im gesicherten Stand, also unter dem
# Zeitgeber. Was hier schiefgeht, holt er zurück.
disown_network_manager() {
    case "$1" in
        cloud-init)
            mkdir -p "${CLUPILOT_ETC}/cloud/cloud.cfg.d"
            printf 'network: {config: disabled}\n' \
                > "${CLUPILOT_ETC}/cloud/cloud.cfg.d/99-clupilot-disable-network.cfg"
            log 'cloud-init: Netzteil abgeschaltet'
            ;;
        networkd)
            "$CLUPILOT_SYSTEMCTL" disable --now systemd-networkd.socket >/dev/null 2>&1 || true
            "$CLUPILOT_SYSTEMCTL" mask systemd-networkd >/dev/null 2>&1 || true
            log 'systemd-networkd maskiert'
            ;;
        network-manager)
            "$CLUPILOT_SYSTEMCTL" disable --now NetworkManager >/dev/null 2>&1 || true
            "$CLUPILOT_SYSTEMCTL" mask NetworkManager >/dev/null 2>&1 || true
            log 'NetworkManager maskiert'
            ;;
        '')
            ;;
    esac

    # Der kleinere Verwandte: bleibt in interfaces.d eine Strophe liegen, die
    # dieselbe Karte beansprucht, hat der Host zwei Stellen, die seine Adresse
    # vergeben — und die Strophe hier behält `source interfaces.d/*`.
    _iface="${2:-}"
    if [ -n "$_iface" ] && [ -d "$CLUPILOT_INTERFACES_D" ]; then
        for _f in "$CLUPILOT_INTERFACES_D"/*; do
            [ -f "$_f" ] || continue
            if grep -qE "iface[[:space:]]+${_iface}[[:space:]]" "$_f" 2>/dev/null; then
                mv "$_f" "${_f}.von-clupilot-beiseitegelegt"
                log "Kollidierende Strophe beiseitegelegt: ${_f}"
            fi
        done
    fi
}
  • Step 4: Den Treiber anlegen

deploy/bootstrap/lib/bridge-run.sh:

#!/bin/sh
# shellcheck shell=sh
#
# Treiber für den Brückenbau, wenn er aus der CluPilot-Pipeline kommt.
#
# `App\Provisioning\Steps\Host\EnsureNetworkBridge` lädt diese Datei zusammen mit
# `bridge.sh` auf den Host und startet sie abgekoppelt. Die eigentliche Arbeit
# macht `bridge.sh` — hier steht nur, in welcher Reihenfolge, und wie der
# Fortschritt zurückgemeldet wird.
#
# ---------------------------------------------------------------------------
# Warum abgekoppelt
# ---------------------------------------------------------------------------
#
# Nicht wegen der Dauer — der Bau ist in Sekunden durch. Sondern weil die
# SSH-Verbindung MITTEN im Befehl stirbt: `ifreload -a` nimmt die Leitung, über
# die der Befehl läuft. Ein synchroner Aufruf hätte keinen Rückgabewert, sondern
# eine Leiche.
#
# ---------------------------------------------------------------------------
# Die Reihenfolge, und warum sie nicht verhandelbar ist
# ---------------------------------------------------------------------------
#
# sichern → Zeitgeber → übernehmen → umstellen → nachsehen.
#
# Der Zeitgeber steht VOR jeder Änderung. Er ist der einzige Grund, warum dieses
# Skript die Netzkonfiguration überhaupt anfassen darf: kommt der Host nicht
# zurück, spielt er den alten Zustand ein, und der Schritt landet in einer
# Wiederholung statt auf einem toten Server.
#
# Abbestellt wird er hier NICHT. Das tut CluPilot, nachdem es sich über den
# Tunnel neu verbunden und nachgesehen hat. Dieses Skript kann über seine eigene
# Erreichbarkeit von außen nur raten.
#
# ---------------------------------------------------------------------------
# Rückmeldung
# ---------------------------------------------------------------------------
#
#   state        running | ok | failed
#   pid          die PID dieses Skripts — und, weil es per `setsid` gestartet
#                wird, zugleich die seiner PROZESSGRUPPE
#   phase        die laufende Phase, für die Fortschrittszeile in der Konsole
#   note         der Grund im Fehlerfall
#   rolled-back  vom Zeitgeber angelegt, wenn er zurückgespielt hat
#   bridge.log   alles
#
# `state` allein ist keine Aussage über den Lauf: stirbt das Skript, bleibt dort
# für immer `running` stehen, weil niemand mehr da ist, der es ändert. Deshalb
# fragt der Schritt zusätzlich `kill -0` gegen `pid`.

set -u

CLUPILOT_WORK_DIR="${CLUPILOT_WORK_DIR:-/var/lib/clupilot/bridge}"

# Als ALLERERSTES, vor jedem Einlesen und jeder Prüfung: der Startbefehl wartet
# darauf, dass hier etwas steht, und bis dahin gilt der Lauf als noch nicht
# angelaufen. `$$` und nicht `$!` auf der anderen Seite, weil `setsid` dazwischen
# liegt — und weil `setsid` daraus eine eigene Sitzung macht, ist diese Zahl
# zugleich die Prozessgruppe, an die ein Abbruch geschickt wird.
echo $$ > "${CLUPILOT_WORK_DIR}/pid"

# shellcheck source=/dev/null
. "${CLUPILOT_WORK_DIR}/env"
# shellcheck source=lib/bridge.sh
. "${CLUPILOT_WORK_DIR}/bridge.sh"

phase() {
    printf '%s' "$1" > "${CLUPILOT_WORK_DIR}/phase"
    log "--- $1"
}

fail() {
    printf '%s' "$1" > "${CLUPILOT_WORK_DIR}/note"
    printf 'failed' > "${CLUPILOT_WORK_DIR}/state"
    log "ABBRUCH: $1"
    exit 1
}

# Ein Abbruch, den keine Zeile hier abgefangen hat (kein Speicher, ein Signal),
# darf nicht als `running` liegen bleiben und den Schritt gegen eine Leiche
# pollen lassen. Der Schritt erkennt das zwar auch an `kill -0`, aber ein Grund
# ist besser als eine Vermutung.
on_exit() {
    _code=$?
    if [ "$_code" -ne 0 ] && [ "$(cat "${CLUPILOT_WORK_DIR}/state" 2>/dev/null)" = 'running' ]; then
        printf 'Der Brueckenbau brach unerwartet ab (Rueckgabewert %s); siehe bridge.log' "$_code" \
            > "${CLUPILOT_WORK_DIR}/note"
        printf 'failed' > "${CLUPILOT_WORK_DIR}/state"
    fi
}
trap on_exit EXIT

# ---------------------------------------------------------------------------
# Ablauf
# ---------------------------------------------------------------------------

cd "$CLUPILOT_WORK_DIR" || exit 1

phase 'Zustand feststellen'

_iface="$(detect_primary_interface)"
[ -n "$_iface" ] || fail 'Keine Karte traegt die Standardroute — auf dieser Maschine ist nicht abzuleiten, worueber eine Bruecke gehen soll.'

interface_is_physical "$_iface" \
    || fail "Die Karte mit der Standardroute (${_iface}) ist keine physische Karte, sondern eine Bruecke, ein Bond oder ein VLAN. bridge_ports darauf waere falsch."

_style="$(detect_network_style "$_iface")"
_cidr="$("$CLUPILOT_IP" -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')"
_gw="$("$CLUPILOT_IP" -4 route show default 2>/dev/null | awk '{ print $3; exit }')"

[ "$_style" = 'dhcp' ] || [ -n "$_cidr" ] \
    || fail "Keine globale IPv4-Adresse auf ${_iface} — nichts, was auf eine Bruecke ziehen koennte."

log "Karte ${_iface}, Form ${_style}, Adresse ${_cidr:-per DHCP}, Gateway ${_gw:-keins}"

_foreign="$(foreign_network_manager)"
case "$_foreign" in
    ''|cloud-init|networkd|network-manager) ;;
    *) fail "Unbekannter Netzverwalter (${_foreign}) — hier wird nicht ins Blaue gebaut." ;;
esac
[ -n "$_foreign" ] && log "Fremdverwalter erkannt: ${_foreign}"

# --- ab hier wird verändert ---

phase 'sichern'
backup_network_config || fail 'Die bestehende Netzkonfiguration liess sich nicht sichern — ohne Rueckfahrkarte wird hier nichts umgestellt.'

phase 'Zeitgeber stellen'
schedule_network_rollback "${CLUPILOT_ROLLBACK_MINUTES:-5}"

phase 'uebernehmen'
disown_network_manager "$_foreign" "$_iface"

phase 'umstellen'
build_bridge "$_iface" "$_style" "$_cidr" "$_gw"

phase 'nachsehen'
bridge_proven || fail 'Die Bruecke steht, aber der Weg nach draussen oder der Tunnel fehlt. Der Zeitgeber spielt den alten Zustand zurueck.'

phase 'wartet auf CluPilot'
printf 'ok' > "${CLUPILOT_WORK_DIR}/state"
log 'Bruecke steht und traegt; der Zeitgeber laeuft weiter, bis CluPilot ihn abbestellt.'
exit 0
  • Step 5: Testlauf, der bestehen muss
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/BridgeScriptTest.php

Erwartet: PASS, achtundzwanzig Tests.

  • Step 6: Commit
git add deploy/bootstrap/lib/bridge.sh deploy/bootstrap/lib/bridge-run.sh tests/Feature/Provisioning/BridgeScriptTest.php
git commit -m "bridge-run.sh: der Treiber, und der Zeitgeber steht vor jeder Aenderung"

Task 6: EnsureNetworkBridge — die Abkürzung und der Guard

Der Schritt, aber nur sein billigster Teil: schon da heißt nichts anfassen, und was keine physische Karte ist, wird nicht gebaut. Noch kein Start, noch kein Poll.

Files:

  • Create: app/Provisioning/Steps/Host/EnsureNetworkBridge.php
  • Test: tests/Feature/Provisioning/HostStepsTest.php (anhängen)

Interfaces:

  • Consumes: HostStep::keyLogin(), HostStep::host(), RemoteShell, StepResult.

  • Produces:

    • EnsureNetworkBridge::key(): 'ensure_network_bridge'
    • EnsureNetworkBridge::WORK_DIR = '/var/lib/clupilot/bridge'
    • EnsureNetworkBridge::maxDuration(): 3600
  • Step 1: Test schreiben

Anhängen an tests/Feature/Provisioning/HostStepsTest.php (und use App\Provisioning\Steps\Host\EnsureNetworkBridge; oben ergänzen):

// --- EnsureNetworkBridge ---
//
// Der Schritt, der an dem Ast sägt, auf dem er sitzt: SSH läuft hier über den
// Tunnel, und WireGuard hängt an derselben Karte, die in die Brücke wandert.
// Deshalb: erst der Zeitgeber, dann umstellen, dann neu verbinden, nachsehen,
// abbestellen.

/** Was der Host auf die Zustandsfrage antwortet. */
function scriptBridgeState(FakeRemoteShell $shell, bool $up, string $iface = 'enp0s31f6', bool $physical = true): void
{
    $shell->script('clupilot-bridge-state', CommandResult::success(implode("\n", [
        'up='.($up ? 'yes' : 'no'),
        'iface='.$iface,
        'physical='.($physical ? 'yes' : 'no'),
        'route=default via 49.12.121.65 dev '.($up ? 'vmbr0' : $iface),
        '',
    ])));
}

/** Was der Host auf die Statusfrage antwortet. */
function scriptBridgeStatus(FakeRemoteShell $shell, string $state, bool $alive = false, string $phase = '', string $note = '', bool $rolledBack = false): void
{
    $shell->script('clupilot-bridge-status', CommandResult::success(implode("\n", [
        'state='.$state,
        'alive='.($alive ? 'yes' : 'no'),
        'phase='.$phase,
        'note='.$note,
        'rolledback='.($rolledBack ? 'yes' : 'no'),
        '',
    ])));
}

it('fasst einen Host, der die Brücke schon hat, nicht an', function () {
    // pve-fns-1 hat sie von Hand. Ein Wiederanlauf, der sie umbaut, baut ein
    // funktionierendes Netz um — und riskiert dafür genau das, wofür es die
    // Rückfahrkarte gibt.
    $s = fakeServices();
    $host = Host::factory()->active()->create();
    $run = hostRun($host);
    proveTunnel($run, $host);
    scriptBridgeState($s['shell'], up: true);

    expect(app(EnsureNetworkBridge::class)->execute($run)->type)->toBe('advance')
        ->and($s['shell']->files())->toBe([])
        ->and($s['shell']->ran('clupilot-bridge-start'))->toBeFalse();
});

it('baut keine Brücke auf einem Bond oder einer bestehenden Brücke', function () {
    // bridge_ports darauf ist falsch: die Brücke nähme sich ihren eigenen
    // Unterbau als Port, und was dabei herauskommt, ist aus der Ferne nicht mehr
    // zu reparieren.
    $s = fakeServices();
    $host = Host::factory()->active()->create();
    $run = hostRun($host);
    proveTunnel($run, $host);
    scriptBridgeState($s['shell'], up: false, iface: 'bond0', physical: false);

    $result = app(EnsureNetworkBridge::class)->execute($run);

    expect($result->type)->toBe('fail')
        ->and($result->reason)->toContain('bond0')
        ->and($s['shell']->ran('clupilot-bridge-start'))->toBeFalse();
});

it('gibt auf, wenn gar keine Karte die Standardroute trägt', function () {
    $s = fakeServices();
    $host = Host::factory()->active()->create();
    $run = hostRun($host);
    proveTunnel($run, $host);
    scriptBridgeState($s['shell'], up: false, iface: '', physical: false);

    expect(app(EnsureNetworkBridge::class)->execute($run)->type)->toBe('fail');
});
  • Step 2: Testlauf, der scheitern muss
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="Brücke schon|Bond oder|keine Karte"

Erwartet: FAIL — Class "App\Provisioning\Steps\Host\EnsureNetworkBridge" not found.

  • Step 3: Den Schritt anlegen (nur Abkürzung und Guard)

app/Provisioning/Steps/Host/EnsureNetworkBridge.php:

<?php

namespace App\Provisioning\Steps\Host;

use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Ssh\RemoteShell;

/**
 * Builds vmbr0 on a Debian-installed Proxmox host, under a timer that puts the
 * old network configuration back if the bridge takes the machine off the net.
 *
 * Proxmox installed on top of Debian does not create vmbr0 — only the ISO
 * installer writes that bridge into /etc/network/interfaces. BuildVmTemplate
 * creates the golden image with `--net0 virtio,bridge=vmbr0` and every clone
 * inherits it, so a host without the bridge serves no customer at all.
 *
 * ConfigureProxmox used to refuse here and send an operator to the keyboard.
 * That was right while nobody could do this safely from a remote shell. What
 * makes it safe is not cleverness about network layouts — it is the way back.
 *
 * ---------------------------------------------------------------------------
 * The step saws through the branch it is sitting on
 * ---------------------------------------------------------------------------
 *
 * By this point SSH runs over the WireGuard tunnel (keyLogin prefers wg_ip once
 * the handshake is proven, and RebootIntoPveKernel has proven it), and wg0 hangs
 * off the same NIC that is about to become a bridge port. A wrong bridge takes
 * the tunnel AND the public address with it.
 *
 * So the order is: arm the rollback timer, change, reconnect, check, cancel. If
 * the connection does not come back, the timer restores the old configuration
 * and this step lands in a retry rather than on a dead server.
 *
 * ---------------------------------------------------------------------------
 * Why it runs detached
 * ---------------------------------------------------------------------------
 *
 * Not because it is slow — the build takes seconds. Because `ifreload -a` takes
 * the line the command itself is running over. A synchronous call would not
 * return a value; it would return a corpse. So the driver is started with
 * `nohup setsid` and this step comes back every 20 seconds, each poll its own
 * short job.
 *
 * The shell library is SHIPPED, never re-implemented here — same as
 * BuildVmTemplate. A PHP copy of the provider-shape rules would be two
 * installations to keep level, and the second one only reveals itself to
 * whoever runs it.
 */
class EnsureNetworkBridge extends HostStep
{
    /** Where the script, its env and its status files live on the host. */
    public const WORK_DIR = '/var/lib/clupilot/bridge';

    /** The rollback timer's fuse, in minutes. */
    private const ROLLBACK_MINUTES = 5;

    /**
     * The step's own deadline — deliberately three times the timer's fuse.
     *
     * By the time this step gives up, the rollback has necessarily already run,
     * so "gave up" always means "the host is back on its old configuration".
     * That is the line that makes "a retry rather than a dead server" true.
     */
    private const DEADLINE_MINUTES = 15;

    /** One automatic attempt, plus one for the case an operator fixed something. */
    private const MAX_ATTEMPTS = 2;

    public function __construct(private RemoteShell $shell) {}

    public function key(): string
    {
        return 'ensure_network_bridge';
    }

    public function maxDuration(): int
    {
        // Above DEADLINE_MINUTES so the step's own deadline decides the failure
        // and can say what went wrong, rather than the generic step timeout —
        // which would additionally spend a retry. Same shape as
        // RebootIntoPveKernel and BuildVmTemplate.
        return 3600;
    }

    public function execute(ProvisioningRun $run): StepResult
    {
        $host = $this->host($run);
        $this->keyLogin($this->shell, $host);

        $state = $this->readBridgeState();

        // Already there means touch nothing. The same shortcut as "the template
        // exists and reports template: 1" — and here it matters more, because
        // rebuilding a working network is the one thing that can end this badly.
        if ($state['up']) {
            return StepResult::advance();
        }

        if ($state['iface'] === '') {
            return StepResult::fail(
                'No interface on this host carries the default route, so there is nothing to derive a bridge '.
                'from. Check the machine on the provider console.'
            );
        }

        // bridge_ports on a bond or an existing bridge is wrong: the bridge
        // would take its own substrate as a port, and what comes out of that
        // cannot be repaired from a remote shell.
        if (! $state['physical']) {
            return StepResult::fail(
                'The interface carrying the default route ('.$state['iface'].') is not a physical NIC — it is a '.
                'bridge, a bond or a VLAN. CluPilot will not put a bridge on top of it. Build vmbr0 by hand in '.
                '/etc/network/interfaces, apply it with `ifreload -a`, confirm you still have SSH, then retry. '.
                'Current default route: '.($state['route'] ?: '(none reported)').'.'
            );
        }

        // Start, poll and cancel arrive in the next task.
        return StepResult::fail('not implemented yet');
    }

    /**
     * What the host says about its bridge and its primary NIC, in one round trip.
     *
     * Derived from the RUNNING state — `ip`, `/sys/class/net` — never from the
     * provider's file. What runs is structured the same everywhere; how it was
     * written down is not, and that is the part that carries Hetzner and netcup
     * at once.
     *
     * @return array{up:bool, iface:string, physical:bool, route:string}
     */
    private function readBridgeState(): array
    {
        $out = $this->shell->run(implode("\n", [
            ': clupilot-bridge-state',
            'B=vmbr0',
            'IF=$(ip -4 route show default 2>/dev/null | awk \'{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }\')',
            'UP=no',
            'if ip link show "$B" >/dev/null 2>&1 && [ "$IF" = "$B" ] && [ -n "$(ip -4 -o addr show dev "$B" scope global 2>/dev/null)" ]; then UP=yes; fi',
            'PHY=no',
            'if [ -n "$IF" ] && [ -e "/sys/class/net/$IF/device" ] && [ ! -d "/sys/class/net/$IF/bridge" ] && [ ! -d "/sys/class/net/$IF/bonding" ]; then PHY=yes; fi',
            'printf \'up=%s\n\' "$UP"',
            'printf \'iface=%s\n\' "$IF"',
            'printf \'physical=%s\n\' "$PHY"',
            'printf \'route=%s\n\' "$(ip -4 route show default 2>/dev/null | head -1)"',
        ]))->stdout;

        $parsed = ['up' => 'no', 'iface' => '', 'physical' => 'no', 'route' => ''];

        foreach (preg_split('/\R/', $out) ?: [] as $line) {
            [$key, $value] = array_pad(explode('=', $line, 2), 2, '');
            if (array_key_exists($key, $parsed)) {
                $parsed[$key] = trim($value);
            }
        }

        return [
            'up' => $parsed['up'] === 'yes',
            'iface' => $parsed['iface'],
            'physical' => $parsed['physical'] === 'yes',
            'route' => $parsed['route'],
        ];
    }
}
  • Step 4: Testlauf, der bestehen muss
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="Brücke schon|Bond oder|keine Karte"

Erwartet: PASS, drei Tests.

  • Step 5: Commit
git add app/Provisioning/Steps/Host/EnsureNetworkBridge.php tests/Feature/Provisioning/HostStepsTest.php
git commit -m "EnsureNetworkBridge: schon da heisst nichts anfassen, und keine Bruecke auf einer Bruecke"

Task 7: EnsureNetworkBridge — Start und Verbindungsabriss

Der Teil, der über Leben und Tod des Laufs entscheidet: die Dateien wortgleich hochladen, abgekoppelt starten, und den erwarteten Verbindungsabriss als poll behandeln statt als retry.

Files:

  • Modify: app/Provisioning/Steps/Host/EnsureNetworkBridge.php
  • Test: tests/Feature/Provisioning/HostStepsTest.php (anhängen)

Interfaces:

  • Consumes: readBridgeState() aus Task 6, FakeRemoteShell::$failConnect.

  • Produces: Run-Kontext-Schlüssel bridge_deadline (ISO-8601) und bridge_attempts (int).

  • Step 1: Test schreiben

Anhängen an tests/Feature/Provisioning/HostStepsTest.php:

it('lädt bridge.sh und bridge-run.sh wortgleich hoch und startet abgekoppelt', function () {
    // Byte für Byte. In dem Moment, in dem diese Datei aus einer PHP-Vorlage
    // gerendert statt gelesen wird, gibt es wieder zwei Fassungen der
    // Anbieterformen — das eine, was der Entwurf verbietet.
    $s = fakeServices();
    $host = Host::factory()->active()->create();
    $run = hostRun($host);
    proveTunnel($run, $host);
    scriptBridgeState($s['shell'], up: false);

    $result = app(EnsureNetworkBridge::class)->execute($run);
    $files = $s['shell']->files();

    expect($result->type)->toBe('poll')
        ->and($files['/var/lib/clupilot/bridge/bridge.sh'] ?? null)
        ->toBe(file_get_contents(base_path('deploy/bootstrap/lib/bridge.sh')))
        ->and($files['/var/lib/clupilot/bridge/bridge-run.sh'] ?? null)
        ->toBe(file_get_contents(base_path('deploy/bootstrap/lib/bridge-run.sh')))
        // Der Hub-Schlüssel muss mit, sonst kann der Treiber den Handshake
        // nicht gegen den RICHTIGEN Peer prüfen.
        ->and($files['/var/lib/clupilot/bridge/env'] ?? '')->toContain('CLUPILOT_WG_HUB_PUBKEY=')
        ->and($files['/var/lib/clupilot/bridge/env'] ?? '')->toContain('CLUPILOT_ROLLBACK_MINUTES=5')
        ->and($s['shell']->ran('nohup'))->toBeTrue()
        ->and($s['shell']->ran('setsid'))->toBeTrue()
        ->and($run->fresh()->context('bridge_deadline'))->not->toBeNull();
});

it('pollt weiter, wenn der Host während der Umstellung nicht antwortet', function () {
    // DIE tragende Zeile. RunRunner verwandelt jede geworfene Ausnahme in ein
    // retry(), und retry verbraucht das Versuchskonto — ungefangen brennt der
    // Verbindungsabriss die fünf Versuche in wenigen Minuten durch und lässt den
    // Lauf scheitern, BEVOR der Host wieder da ist.
    $s = fakeServices();
    $host = Host::factory()->active()->create();
    $run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]);
    proveTunnel($run, $host);
    $s['shell']->failConnect = true;

    $result = app(EnsureNetworkBridge::class)->execute($run);

    expect($result->type)->toBe('poll')
        ->and($result->reason)->toContain('Brücke');
});

it('lässt einen Verbindungsfehler VOR der Umstellung ein gewöhnlicher bleiben', function () {
    // Ohne Termin hat dieser Lauf nichts angefasst. Dann ist ein
    // Verbindungsfehler kein erwarteter Abriss, sondern ein Fehler — und darf
    // das Versuchskonto kosten, statt eine Stunde lang gepollt zu werden.
    $s = fakeServices();
    $host = Host::factory()->active()->create();
    $run = hostRun($host);
    proveTunnel($run, $host);
    $s['shell']->failConnect = true;

    expect(app(EnsureNetworkBridge::class)->execute($run)->type)->toBe('retry');
});

it('startet keinen zweiten Treiber, solange der erste lebt', function () {
    // Zwei Treiber auf einer /etc/network/interfaces, beide mit eigenem
    // Zeitgeber: der eine bestellt ab, was der andere gestellt hat.
    $s = fakeServices();
    $host = Host::factory()->active()->create();
    $run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]);
    proveTunnel($run, $host);
    scriptBridgeState($s['shell'], up: false);
    scriptBridgeStatus($s['shell'], 'running', alive: true, phase: 'umstellen');

    $result = app(EnsureNetworkBridge::class)->execute($run);

    expect($result->type)->toBe('poll')
        ->and($result->reason)->toContain('umstellen')
        ->and($s['shell']->ran('clupilot-bridge-start'))->toBeFalse();
});

it('gibt auf, wenn der Treiber tot ist, obwohl die Statusdatei running sagt', function () {
    // Die Datei sagt running und wird es immer — niemand ist mehr da, der es
    // ändert. Darauf zu pollen wartet eine Viertelstunde gegen eine Leiche.
    $s = fakeServices();
    $host = Host::factory()->active()->create();
    $run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]);
    proveTunnel($run, $host);
    scriptBridgeState($s['shell'], up: false);
    scriptBridgeStatus($s['shell'], 'running', alive: false, rolledBack: true);

    expect(app(EnsureNetworkBridge::class)->execute($run)->type)->toBe('fail');
});
  • Step 2: Testlauf, der scheitern muss
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="wortgleich hoch|während der Umstellung|VOR der Umstellung|zweiten Treiber|Treiber tot"

Erwartet: FAIL — not implemented yet.

  • Step 3: execute() umbauen und Start/Status ergänzen

In EnsureNetworkBridge.php use ergänzen:

use App\Services\Wireguard\WireguardHub;
use Illuminate\Support\Carbon;
use Throwable;

Konstruktor:

    public function __construct(private RemoteShell $shell, private WireguardHub $hub) {}

execute() ersetzen:

    public function execute(ProvisioningRun $run): StepResult
    {
        $host = $this->host($run);

        // The connection dropping is the EXPECTED case here, not an error:
        // `ifreload -a` takes the line this step is speaking over. Left
        // uncaught, RunRunner turns the exception into retry() — and retry
        // spends the attempt budget, five of which are gone in minutes, long
        // before the host is back.
        try {
            $this->keyLogin($this->shell, $host);
        } catch (Throwable $e) {
            return $this->whileDisconnected($run, $e);
        }

        $state = $this->readBridgeState();

        // Already there means touch nothing. The same shortcut as "the template
        // exists and reports template: 1" — and here it matters more, because
        // rebuilding a working network is the one thing that can end this badly.
        //
        // It is also the success path: reaching this line means SSH arrived over
        // the tunnel AND vmbr0 carries the default route with an address. That
        // is bridge_proven, asked from the outside, which is the strongest form
        // of the question there is. Only now is the timer cancelled.
        if ($state['up']) {
            if ($run->context('bridge_deadline') !== null) {
                $this->cancelRollback();
                $run->forgetContext('bridge_deadline');
            }

            return StepResult::advance();
        }

        if ($state['iface'] === '') {
            return StepResult::fail(
                'No interface on this host carries the default route, so there is nothing to derive a bridge '.
                'from. Check the machine on the provider console.'
            );
        }

        // bridge_ports on a bond or an existing bridge is wrong: the bridge
        // would take its own substrate as a port, and what comes out of that
        // cannot be repaired from a remote shell.
        if (! $state['physical']) {
            return StepResult::fail(
                'The interface carrying the default route ('.$state['iface'].') is not a physical NIC — it is a '.
                'bridge, a bond or a VLAN. CluPilot will not put a bridge on top of it. Build vmbr0 by hand in '.
                '/etc/network/interfaces, apply it with `ifreload -a`, confirm you still have SSH, then retry. '.
                'Current default route: '.($state['route'] ?: '(none reported)').'.'
            );
        }

        // Nothing on the host is cleaned up when a run ends, so a status file
        // can be older than this run. The deadline in the run context is the
        // only thing that tells a first visit from a lost one: it is written
        // when THIS run starts a build and cleared again by giveUp().
        if ($run->context('bridge_deadline') === null) {
            return $this->start($run);
        }

        $status = $this->readStatus();

        return match ($status['state']) {
            '' => $this->giveUp(
                $run,
                'The bridge build was started but left no status behind, so it never really began. Check '.
                self::WORK_DIR.' and the free space on the host, then retry.'
            ),
            'running' => $this->whileRunning($run, $status),
            // Reaching here means the driver said ok while vmbr0 does NOT carry
            // the default route — so the timer got there first, or is about to.
            'ok' => $this->awaitRollback($run, $status, 'The bridge build reported success, but vmbr0 does not carry the default route.'),
            'failed' => $this->awaitRollback($run, $status, $status['note'] ?: 'no reason recorded'),
            default => $this->giveUp($run, 'The bridge build left an unreadable state ("'.$status['state'].'").'),
        };
    }

    /**
     * The host did not answer.
     *
     * With a deadline in the context, this run has changed the network and the
     * silence is the change taking effect — poll, do not retry. Without one,
     * nothing has been touched and an unreachable host is an ordinary
     * connection error that may cost an attempt.
     */
    private function whileDisconnected(ProvisioningRun $run, Throwable $e): StepResult
    {
        $deadline = $run->context('bridge_deadline');

        if ($deadline === null) {
            return StepResult::retry(20, 'host not reachable: '.$e->getMessage());
        }

        if (now()->greaterThan(Carbon::parse($deadline))) {
            $run->forgetContext('bridge_deadline');

            return StepResult::fail(
                'The host did not come back over the tunnel after the bridge was applied, and the deadline of '.
                self::DEADLINE_MINUTES.' minutes has passed. The rollback timer will have restored the previous '.
                'network configuration '.self::ROLLBACK_MINUTES.' minutes after the change, so the machine should '.
                'be reachable on its old settings — check it, then retry. Last error: '.$e->getMessage()
            );
        }

        return StepResult::poll(20, 'warte darauf, dass der Host über die neue Brücke wieder antwortet');
    }

    /** Ship the library, write the env, launch detached. */
    private function start(ProvisioningRun $run): StepResult
    {
        $attempts = (int) $run->context('bridge_attempts', 0);

        if ($attempts >= self::MAX_ATTEMPTS) {
            return StepResult::fail(
                'CluPilot tried to build vmbr0 automatically '.$attempts.' times and the host did not come back '.
                'both ways each time; the previous network configuration was restored. Build the bridge by hand '.
                'in /etc/network/interfaces (bridge_ports = the NIC carrying the default route, host address and '.
                'gateway moved onto vmbr0), apply it with `ifreload -a`, confirm you still have SSH, then retry '.
                'this run. The stanza CluPilot wrote is in '.self::WORK_DIR.'/bridge.log.'
            );
        }

        $this->shell->putFile(self::WORK_DIR.'/bridge.sh', $this->asset('lib/bridge.sh'));
        $this->shell->putFile(self::WORK_DIR.'/bridge-run.sh', $this->asset('lib/bridge-run.sh'));

        // The hub key so the driver can check the handshake against the RIGHT
        // peer: staff peers live on the same hub, and their handshake says
        // nothing about CluPilot's own way in.
        $this->shell->putFile(self::WORK_DIR.'/env', implode("\n", [
            '# Written by CluPilot at the start of every bridge build. Do not edit.',
            'CLUPILOT_WORK_DIR='.self::WORK_DIR,
            'CLUPILOT_ROLLBACK_MINUTES='.self::ROLLBACK_MINUTES,
            'CLUPILOT_WG_HUB_PUBKEY='.escapeshellarg(trim($this->hub->publicKey())),
            '',
        ]));

        $run->mergeContext([
            'bridge_deadline' => now()->addMinutes(self::DEADLINE_MINUTES)->toIso8601String(),
            'bridge_attempts' => $attempts + 1,
        ]);

        // `setsid` puts the driver in a session of its own, so the pid it
        // records is also its PROCESS GROUP id — which is what makes giving up
        // possible later. The pid therefore comes from the script (`$$`), not
        // from `$!` here: with setsid in between, `$!` can be a process that is
        // already gone. Waiting for the file closes the gap that creates.
        $this->shell->run(implode("\n", [
            ': clupilot-bridge-start',
            'W='.self::WORK_DIR,
            'mkdir -p "$W"',
            'rm -f "$W/rolled-back"',
            "printf 'running' > \"\$W/state\"",
            "printf 'starting' > \"\$W/phase\"",
            ': > "$W/note"',
            ': > "$W/pid"',
            'nohup setsid sh "$W/bridge-run.sh" </dev/null >> "$W/bridge.log" 2>&1 &',
            'i=0; while [ "$i" -lt 15 ] && [ ! -s "$W/pid" ]; do sleep 1; i=$((i + 1)); done',
        ]));

        return StepResult::poll(20, 'Netzbrücke wird gebaut');
    }

    /** @param  array{state:string, alive:string, phase:string, note:string, rolledback:string}  $status */
    private function whileRunning(ProvisioningRun $run, array $status): StepResult
    {
        if ($status['alive'] !== 'yes') {
            // A kill, an OOM, a reboot. The file says running and always will —
            // there is nobody left to change it. The timer is still armed, so
            // the machine gets its old configuration back either way.
            return $this->awaitRollback(
                $run,
                $status,
                'The bridge build stopped running without reporting a result — it was killed or the host restarted.'
            );
        }

        $deadline = $run->context('bridge_deadline');
        if ($deadline !== null && now()->greaterThan(Carbon::parse($deadline))) {
            return $this->giveUp(
                $run,
                'The bridge build passed its deadline of '.self::DEADLINE_MINUTES.' minutes while still in phase "'.
                ($status['phase'] ?: 'unknown').'". Check '.self::WORK_DIR.'/bridge.log on the host.'
            );
        }

        return StepResult::poll(20, 'Netzbrücke wird gebaut: '.($status['phase'] ?: 'läuft'));
    }

    /**
     * The four facts about a build in flight, plus whether the timer has fired.
     *
     * `alive` is computed on the host because that is the only place the answer
     * exists: the pid means nothing here.
     *
     * @return array{state:string, alive:string, phase:string, note:string, rolledback:string}
     */
    private function readStatus(): array
    {
        $out = $this->shell->run(implode("\n", [
            ': clupilot-bridge-status',
            'W='.self::WORK_DIR,
            'S=$(cat "$W/state" 2>/dev/null)',
            'P=$(cat "$W/pid" 2>/dev/null)',
            'A=no',
            'if [ -n "$P" ] && kill -0 "$P" 2>/dev/null; then A=yes; fi',
            'R=no',
            'if [ -f "$W/rolled-back" ]; then R=yes; fi',
            "printf 'state=%s\\n' \"\$S\"",
            "printf 'alive=%s\\n' \"\$A\"",
            "printf 'phase=%s\\n' \"\$(head -1 \"\$W/phase\" 2>/dev/null)\"",
            "printf 'note=%s\\n' \"\$(head -1 \"\$W/note\" 2>/dev/null)\"",
            "printf 'rolledback=%s\\n' \"\$R\"",
        ]))->stdout;

        $status = ['state' => '', 'alive' => 'no', 'phase' => '', 'note' => '', 'rolledback' => 'no'];

        foreach (preg_split('/\R/', $out) ?: [] as $line) {
            [$key, $value] = array_pad(explode('=', $line, 2), 2, '');
            if (array_key_exists($key, $status)) {
                $status[$key] = trim($value);
            }
        }

        return $status;
    }

    /** The shipped file, read from the repo — never rendered from a string in here. */
    private function asset(string $relative): string
    {
        return (string) file_get_contents(base_path('deploy/bootstrap/'.$relative));
    }

awaitRollback(), cancelRollback() und giveUp() folgen in Task 8; für diesen Testlauf genügen Rümpfe:

    /** @param  array{state:string, alive:string, phase:string, note:string, rolledback:string}  $status */
    private function awaitRollback(ProvisioningRun $run, array $status, string $reason): StepResult
    {
        return $this->giveUp($run, $reason);
    }

    private function cancelRollback(): void {}

    private function giveUp(ProvisioningRun $run, string $reason): StepResult
    {
        $run->forgetContext('bridge_deadline');

        return StepResult::fail($reason);
    }
  • Step 4: Testlauf, der bestehen muss
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="wortgleich hoch|während der Umstellung|VOR der Umstellung|zweiten Treiber|Treiber tot"

Erwartet: PASS, fünf Tests.

  • Step 5: Commit
git add app/Provisioning/Steps/Host/EnsureNetworkBridge.php tests/Feature/Provisioning/HostStepsTest.php
git commit -m "EnsureNetworkBridge: Start abgekoppelt, und der Verbindungsabriss wird gepollt statt gezaehlt"

Task 8: EnsureNetworkBridge — abbestellen, warten, aufgeben

Die drei Enden: abbestellen erst nach der Nachprüfung, auf die Rücknahme warten statt sofort zu scheitern, und aufgeben, ohne die Rückfahrkarte wegzuwerfen.

Files:

  • Modify: app/Provisioning/Steps/Host/EnsureNetworkBridge.php
  • Test: tests/Feature/Provisioning/HostStepsTest.php (anhängen)

Interfaces:

  • Consumes: readStatus(), readBridgeState(), Kontext-Schlüssel aus Task 7.

  • Produces: keine neuen öffentlichen Namen.

  • Step 1: Test schreiben

Anhängen an tests/Feature/Provisioning/HostStepsTest.php:

it('bestellt den Zeitgeber erst ab, nachdem die Brücke nachgeprüft ist', function () {
    // Reihenfolge, nicht Geschmack. Abbestellen heißt: die Rückfahrkarte
    // wegwerfen. Wer das vor der Nachprüfung tut, tut es auf Verdacht.
    $s = fakeServices();
    $host = Host::factory()->active()->create();
    $run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]);
    proveTunnel($run, $host);
    scriptBridgeState($s['shell'], up: true);

    $result = app(EnsureNetworkBridge::class)->execute($run);

    $recorded = $s['shell']->recorded();
    $stateAt = null;
    $cancelAt = null;
    foreach ($recorded as $i => $command) {
        if ($stateAt === null && str_contains($command, 'clupilot-bridge-state')) {
            $stateAt = $i;
        }
        if ($cancelAt === null && str_contains($command, 'clupilot-bridge-cancel')) {
            $cancelAt = $i;
        }
    }

    expect($result->type)->toBe('advance')
        ->and($stateAt)->not->toBeNull()
        ->and($cancelAt)->not->toBeNull()
        ->and($stateAt)->toBeLessThan($cancelAt)
        // Und der Termin ist weg, sonst liest ein späterer Lauf ihn als „mitten
        // in einer Umstellung".
        ->and($run->fresh()->context('bridge_deadline'))->toBeNull();
});

it('wartet auf die Rücknahme, statt einen halb umgestellten Host liegen zu lassen', function () {
    // Solange der Zeitgeber aussteht, steckt die Maschine mitten in einer
    // Umstellung. Ein fail() ließe sie dort liegen, und der Nächste, der
    // hinsieht, fände einen halben Host ohne Erklärung.
    $s = fakeServices();
    $host = Host::factory()->active()->create();
    $run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]);
    proveTunnel($run, $host);
    scriptBridgeState($s['shell'], up: false);
    scriptBridgeStatus($s['shell'], 'failed', note: 'Tunnel steht nicht', rolledBack: false);

    $result = app(EnsureNetworkBridge::class)->execute($run);

    expect($result->type)->toBe('poll')
        // Und der Termin bleibt stehen, sonst gilt der nächste Besuch als
        // erster und startet einen zweiten Treiber.
        ->and($run->fresh()->context('bridge_deadline'))->not->toBeNull();
});

it('scheitert mit dem Grund des Treibers, sobald die Rücknahme durch ist', function () {
    $s = fakeServices();
    $host = Host::factory()->active()->create();
    $run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]);
    proveTunnel($run, $host);
    scriptBridgeState($s['shell'], up: false);
    scriptBridgeStatus($s['shell'], 'failed', note: 'Tunnel steht nicht', rolledBack: true);

    $result = app(EnsureNetworkBridge::class)->execute($run);

    expect($result->type)->toBe('fail')
        ->and($result->reason)->toContain('Tunnel steht nicht')
        ->and($run->fresh()->context('bridge_deadline'))->toBeNull();
});

it('bestellt den Zeitgeber beim Aufgeben NICHT ab', function () {
    // Er ist die Rückfahrkarte. Steht er noch, hat er seinen Grund — und wer ihn
    // beim Aufgeben abbestellt, lässt den Host genau in dem Zustand stehen, für
    // den er gestellt wurde.
    $s = fakeServices();
    $host = Host::factory()->active()->create();
    $run = hostRun($host, ['bridge_deadline' => now()->subMinute()->toIso8601String()]);
    proveTunnel($run, $host);
    scriptBridgeState($s['shell'], up: false);
    scriptBridgeStatus($s['shell'], 'running', alive: true, phase: 'nachsehen');

    expect(app(EnsureNetworkBridge::class)->execute($run)->type)->toBe('fail')
        ->and($s['shell']->ran('clupilot-bridge-cancel'))->toBeFalse();
});

it('versucht es höchstens zweimal je Lauf', function () {
    // Der zweite ist für den Fall, dass der Betreiber zwischen den Versuchen
    // etwas repariert hat. Danach die Handarbeit-Meldung — ein dritter Versuch
    // gegen dieselbe Maschine wäre nur dieselbe Viertelstunde noch einmal.
    $s = fakeServices();
    $host = Host::factory()->active()->create();
    $run = hostRun($host, ['bridge_attempts' => 2]);
    proveTunnel($run, $host);
    scriptBridgeState($s['shell'], up: false);

    $result = app(EnsureNetworkBridge::class)->execute($run);

    expect($result->type)->toBe('fail')
        ->and($result->reason)->toContain('by hand')
        ->and($s['shell']->ran('clupilot-bridge-start'))->toBeFalse();
});

it('erlaubt einem Betreiber, nach einem Fehlschlag von vorn anzufangen', function () {
    // giveUp() löscht den Termin — sonst macht die Erst-Besuch-Prüfung aus
    // jedem Retry nach einem Fehlschlag einen sofortigen zweiten Fehlschlag,
    // ohne dass der Host überhaupt angefasst wurde.
    $s = fakeServices();
    $host = Host::factory()->active()->create();
    $run = hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()]);
    proveTunnel($run, $host);
    scriptBridgeState($s['shell'], up: false);
    scriptBridgeStatus($s['shell'], 'failed', note: 'irgendwas', rolledBack: true);

    app(EnsureNetworkBridge::class)->execute($run);

    expect($run->fresh()->context('bridge_deadline'))->toBeNull()
        // Der Zähler NICHT — sonst ist der Deckel von zwei Versuchen keiner.
        ->and($run->fresh()->context('bridge_attempts'))->not->toBeNull();
});

it('schickt nur Shell, die eine Shell auch parsen kann', function () {
    // FakeRemoteShell führt keinen dieser Befehle aus. Ein verrutschtes
    // Anführungszeichen käme sonst zuerst auf einem echten Host zur Ausführung
    // — und der Schritt, den es kaputtmacht, ist der, der das Netz umstellt.
    $s = fakeServices();
    $host = Host::factory()->active()->create();
    proveTunnel(hostRun($host), $host);

    $run = hostRun($host);
    scriptBridgeState($s['shell'], up: false);
    app(EnsureNetworkBridge::class)->execute($run);              // state + start

    scriptBridgeStatus($s['shell'], 'failed', note: 'nope', rolledBack: true);
    app(EnsureNetworkBridge::class)->execute(
        hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()])
    );                                                            // state + status + log + reset

    $s2 = fakeServices();
    proveTunnel(hostRun($host), $host);
    scriptBridgeState($s2['shell'], up: true);
    app(EnsureNetworkBridge::class)->execute(
        hostRun($host, ['bridge_deadline' => now()->addMinutes(10)->toIso8601String()])
    );                                                            // state + cancel

    $checked = 0;
    foreach (array_merge($s['shell']->recorded(), $s2['shell']->recorded()) as $command) {
        expect(Process::input($command)->run('sh -n')->successful())
            ->toBeTrue("kein gültiges POSIX-sh:\n".$command);
        $checked++;
    }

    expect($checked)->toBeGreaterThanOrEqual(6);
});
  • Step 2: Testlauf, der scheitern muss
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="Zeitgeber erst ab|wartet auf die Rücknahme|Grund des Treibers|NICHT ab|höchstens zweimal|von vorn anzufangen|parsen kann"

Erwartet: FAIL — clupilot-bridge-cancel wird nie abgesetzt, awaitRollback gibt sofort auf.

  • Step 3: Die drei Enden richtig bauen

Die Rümpfe aus Task 7 ersetzen:

    /**
     * Wait for the rollback to complete, and only then fail.
     *
     * Not immediately: while the timer is still pending the machine is in the
     * middle of a change, and a fail() would leave it there — the next person to
     * look would find half a host with no explanation of why.
     *
     * The rollback script writes `failed` plus a reason BEFORE it restores, so
     * even a partial restore leaves a verdict, and touches `rolled-back` as its
     * LAST action. That file is the signal: it is only there once the old
     * configuration is genuinely back.
     *
     * The wait is capped by the step's deadline, which is three times the
     * timer's fuse — so this cannot poll forever.
     *
     * @param  array{state:string, alive:string, phase:string, note:string, rolledback:string}  $status
     */
    private function awaitRollback(ProvisioningRun $run, array $status, string $reason): StepResult
    {
        $deadline = $run->context('bridge_deadline');
        $expired = $deadline !== null && now()->greaterThan(Carbon::parse($deadline));

        if ($status['rolledback'] !== 'yes' && ! $expired) {
            return StepResult::poll(
                20,
                'die Brücke trägt nicht — warte auf die Rücknahme des Zeitgebers'
            );
        }

        return $this->giveUp(
            $run,
            $reason.' The rollback timer has put the previous network configuration back'.
            ($status['rolledback'] === 'yes' ? '' : ' (or is about to — its fuse is '.self::ROLLBACK_MINUTES.' minutes)').
            ', so the machine is reachable on its old settings.'
        );
    }

    /**
     * Throw the return ticket away — and never before the bridge has been
     * checked. Reaching the caller means SSH arrived over the tunnel and vmbr0
     * carries the default route with an address, which is the proof from the
     * outside.
     */
    private function cancelRollback(): void
    {
        $this->shell->run(implode("\n", [
            ': clupilot-bridge-cancel',
            'U=clupilot-network-rollback',
            'systemctl stop "$U.timer" 2>/dev/null || true',
            'rm -f "/etc/systemd/system/$U.timer" "/etc/systemd/system/$U.service" "/usr/local/sbin/$U.sh"',
            'systemctl daemon-reload 2>/dev/null || true',
        ]));
    }

    /**
     * Fail, and leave nothing behind that would make a retry read this verdict
     * again — the status on the host and the deadline in the run, which is what
     * execute() reads to tell a first visit from a lost one.
     *
     * `bridge_attempts` deliberately SURVIVES: it is the cap on how often
     * CluPilot will drive a live host's network into a rollback, and clearing it
     * here would make that cap meaningless.
     *
     * The rollback timer is deliberately NOT cancelled. It is the return ticket;
     * if it is still armed, that is because nobody has proven the host came back
     * — and cancelling it while giving up would leave the machine in exactly the
     * state the timer exists for.
     */
    private function giveUp(ProvisioningRun $run, string $reason): StepResult
    {
        $run->forgetContext('bridge_deadline');

        $tail = trim($this->shell->run(
            ': clupilot-bridge-log'."\n".'tail -n 25 '.self::WORK_DIR.'/bridge.log 2>/dev/null'
        )->stdout);

        // Stop the driver BEFORE the status files go, never after. Clearing them
        // makes the step retryable, and a retry meeting a driver still running
        // gets two of them on one /etc/network/interfaces, each with a timer of
        // its own — one cancelling what the other armed.
        //
        // The whole process group (`-$P`), because the driver spends its time
        // waiting on an ifreload. The cmdline guard is against a recycled pid:
        // this file can be minutes old, and killing a stranger's process group
        // would be a far worse bug than the one being handled.
        $this->shell->run(implode("\n", [
            ': clupilot-bridge-reset',
            'W='.self::WORK_DIR,
            'P=$(cat "$W/pid" 2>/dev/null)',
            'if [ -n "$P" ] && kill -0 "$P" 2>/dev/null && tr "\\0" " " < /proc/"$P"/cmdline 2>/dev/null | grep -q bridge-run; then',
            '  kill -TERM -"$P" 2>/dev/null || kill -TERM "$P" 2>/dev/null',
            '  i=0; while [ "$i" -lt 10 ] && kill -0 "$P" 2>/dev/null; do sleep 1; i=$((i + 1)); done',
            '  kill -KILL -"$P" 2>/dev/null || kill -KILL "$P" 2>/dev/null',
            'fi',
            'rm -f "$W/state" "$W/pid" "$W/note" "$W/rolled-back"',
        ]));

        return StepResult::fail($tail === '' ? $reason : $reason.' Last lines: '.$tail);
    }
  • Step 4: Testlauf, der bestehen muss
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="Zeitgeber erst ab|wartet auf die Rücknahme|Grund des Treibers|NICHT ab|höchstens zweimal|von vorn anzufangen|parsen kann"

Erwartet: PASS, sieben Tests.

  • Step 5: Commit
git add app/Provisioning/Steps/Host/EnsureNetworkBridge.php tests/Feature/Provisioning/HostStepsTest.php
git commit -m "EnsureNetworkBridge: abbestellen nach der Nachpruefung, aufgeben ohne die Rueckfahrkarte wegzuwerfen"

Task 9: Verdrahten

Der Schritt in die Pipeline, die Beschriftung in beide Sprachen, bridge.sh ins Bootstrap-Archiv — und die ganze Suite grün.

Files:

  • Modify: config/provisioning.php (Pipeline host, zwischen RebootIntoPveKernel und ConfigureProxmox)
  • Modify: lang/de/hosts.php:136-152, lang/en/hosts.php (Block step)
  • Modify: tests/Feature/Host/BootstrapArchiveTest.php:43 (bridge.sh und bridge-run.sh erwarten)
  • Test: tests/Feature/Provisioning/HostStepsTest.php (anhängen)

Interfaces:

  • Consumes: EnsureNetworkBridge aus Task 68.

  • Produces: nichts Neues.

  • Step 1: Test schreiben

Anhängen an tests/Feature/Provisioning/HostStepsTest.php:

it('baut die Brücke nach dem Neustart und vor ConfigureProxmox', function () {
    // Nach dem Neustart, weil dort ifupdown2 steht und der Tunnel gerade
    // bewiesen hat, dass er einen Neustart überlebt. Vor ConfigureProxmox, weil
    // dessen refuseWithoutBridge() die Nachprüfung ist — bauen UND prüfen,
    // dasselbe Paar wie BuildVmTemplate → VerifyVmTemplate.
    $pipeline = config('provisioning.pipelines.host');

    $bridge = array_search(EnsureNetworkBridge::class, $pipeline, true);
    $reboot = array_search(RebootIntoPveKernel::class, $pipeline, true);
    $proxmox = array_search(ConfigureProxmox::class, $pipeline, true);

    expect($bridge)->not->toBeFalse()
        ->and($reboot)->toBeLessThan($bridge)
        ->and($bridge)->toBeLessThan($proxmox);
});

it('lässt ConfigureProxmox die Nachprüfung bleiben', function () {
    // Der Schritt davor baut. Dieser prüft. Beides, nicht eines von beiden —
    // sonst meldet ein Bau, der still danebenging, trotzdem Erfolg.
    $s = fakeServices();
    $host = Host::factory()->active()->create();
    $run = hostRun($host);
    proveTunnel($run, $host);
    // CommandResult::failure(int $exitCode = 1, string $stderr = '') — zwei
    // Parameter, nicht drei.
    $s['shell']->script('ip link show vmbr0', CommandResult::failure(1, 'Device does not exist'));

    expect(app(ConfigureProxmox::class)->execute($run)->type)->toBe('fail');
});

it('beschriftet den Schritt in beiden Sprachen', function () {
    // Die Konsole zeigt eine Zeile pro Schritt. Eine ohne Beschriftung zeigt den
    // Schlüssel — und der Betreiber sieht beim Zusehen 'hosts.step.…'.
    expect(trans('hosts.step.ensure_network_bridge', [], 'de'))->not->toBe('hosts.step.ensure_network_bridge')
        ->and(trans('hosts.step.ensure_network_bridge', [], 'en'))->not->toBe('hosts.step.ensure_network_bridge');
});
  • Step 2: Testlauf, der scheitern muss
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="nach dem Neustart und vor|Nachprüfung bleiben|beiden Sprachen"

Erwartet: FAIL — EnsureNetworkBridge steht nicht in der Pipeline, die Beschriftung fehlt.

  • Step 3: Verdrahten

In config/provisioning.php, in pipelines.host, zwischen Host\RebootIntoPveKernel::class und Host\ConfigureProxmox::class:

            // Nach dem Neustart, weil dort ifupdown2 steht (PrepareBaseSystem
            // installiert es) und der Tunnel gerade bewiesen hat, dass er einen
            // Neustart überlebt.
            //
            // Vor ConfigureProxmox, dessen refuseWithoutBridge() unverändert
            // stehen bleibt und damit zur Nachprüfung wird — dasselbe Paar wie
            // BuildVmTemplate → VerifyVmTemplate. Ein Bau, der still danebenging,
            // fällt dann hier auf und nicht erst beim ersten bezahlten Klon.
            Host\EnsureNetworkBridge::class,

In lang/de/hosts.php, im Block step, nach 'reboot_into_pve_kernel':

        'ensure_network_bridge' => 'Netzbrücke vmbr0 bauen',

In lang/en/hosts.php an derselben Stelle:

        'ensure_network_bridge' => 'Build the vmbr0 network bridge',

In tests/Feature/Host/BootstrapArchiveTest.php bei Zeile 43 ergänzen:

        ->toContain('bootstrap/lib/bridge.sh')
        ->toContain('bootstrap/lib/bridge-run.sh')
  • Step 4: Testlauf, der bestehen muss
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest tests/Feature/Provisioning/HostStepsTest.php --filter="nach dem Neustart und vor|Nachprüfung bleiben|beiden Sprachen"

Erwartet: PASS, drei Tests.

  • Step 5: Die ganze Suite
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pest

Erwartet: alles grün. Ein Fehlschlag in BootstrapArchiveTest oder HostStepsTest heißt, dass eine Nachbarschaft übersehen wurde — nicht, dass der Test falsch ist.

  • Step 6: Lint
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pint --dirty
  • Step 7: Commit
git add config/provisioning.php lang/de/hosts.php lang/en/hosts.php tests/Feature/Host/BootstrapArchiveTest.php tests/Feature/Provisioning/HostStepsTest.php
git commit -m "EnsureNetworkBridge in die Pipeline: nach dem Neustart, vor der Nachpruefung"

Nach dem Plan

  1. R15 / Codex-Review über den Diff, nach docs/…/clupilot-r15-codex-review. Höchstens zwei Runden ohne P1 (R22.2); Restbefunde als Folgepunkte.
  2. Version ziehen und Marke setzen nach dem Release-Verfahren — Aktualisierungen hängen an v*-Marken.
  3. Abnahme auf echter Hardware. Zwei Läufe, und der zweite ist der wichtigere:
    • Frische Debian-13-Maschine ohne Brücke: Host anlegen, zusehen, active — ohne einen Handgriff.
    • pve-fns-1, das die Brücke schon von Hand hat: der Schritt muss advance() melden und nichts anfassen.
  4. Nicht in diesem Schnitt (bekannt, notiert): network.sh:365 benutzt weiterhin ping in wireguard_handshake_proven — der stillgelegte Rettungssystem-Weg.