# 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
<?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**

```bash
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`:

```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**

```bash
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 130–135) **löschen** und dort einen Verweis hinterlassen:

```sh
# `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 40–103 (Variablen `CLUPILOT_ROLLBACK_UNIT`/`CLUPILOT_NET_BACKUP`, `bridge_exists`, `bridge_carries_default_route`, `bridge_has_address`, `detect_network_style`) **löschen** und ersetzen durch:

```sh
# 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:

```sh
    # 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`:

```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);
    }
});
```

```bash
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**

```bash
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`:

```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**

```bash
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`:

```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**

```bash
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**

```bash
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`:

```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**

```bash
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:

```sh
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:

```sh
# ---------------------------------------------------------------------------
# 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**

```bash
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**

```bash
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`:

```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**

```bash
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`:

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

Dann anhängen:

```sh
# ---------------------------------------------------------------------------
# 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**

```bash
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**

```bash
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 1–4 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`:

```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**

```bash
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:

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

Anhängen:

```sh
# ---------------------------------------------------------------------------
# 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`:

```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**

```bash
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**

```bash
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):

```php
// --- 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**

```bash
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
<?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**

```bash
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**

```bash
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`:

```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**

```bash
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:

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

Konstruktor:

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

`execute()` ersetzen:

```php
    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:

```php
    /** @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**

```bash
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**

```bash
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`:

```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**

```bash
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:

```php
    /**
     * 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**

```bash
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**

```bash
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 6–8.
- Produces: nichts Neues.

- [ ] **Step 1: Test schreiben**

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

```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**

```bash
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`:

```php
            // 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'`:

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

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

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

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

```php
        ->toContain('bootstrap/lib/bridge.sh')
        ->toContain('bootstrap/lib/bridge-run.sh')
```

- [ ] **Step 4: Testlauf, der bestehen muss**

```bash
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**

```bash
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**

```bash
cd /home/nexxo/clupilot && docker compose exec -T -u "$(id -u):$(id -g)" app ./vendor/bin/pint --dirty
```

- [ ] **Step 7: Commit**

```bash
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.
