# vmbr0 automatisch bauen

**Datum:** 1. August 2026
**Zustand:** Entwurf abgenommen, Umsetzung folgt
**Betrifft:** Subsystem A (Host-Übernahme), letzter Punkt vor dem Abnahmeziel

---

## Das Problem

Proxmox auf Debian installiert legt **keine `vmbr0` an** — nur der ISO-Installer
schreibt sie in `/etc/network/interfaces`. `BuildVmTemplate` legt die Vorlage mit
`--net0 virtio,bridge=vmbr0` an, und jeder Klon erbt das. Ein Host ohne Brücke
bedient also keinen einzigen Kunden.

Heute lehnt `ConfigureProxmox::refuseWithoutBridge()` genau hier ab und schickt
den Betreiber an die Tastatur. Das war richtig, solange niemand den Handgriff
sicher automatisieren konnte. Er ist inzwischen einmal von Hand gemacht worden
(`pve-fns-1`, 1. August 2026) — mit derselben Rückfahrkarte, die hier
beschrieben wird.

Danach ist das Abnahmeziel erreichbar: frische Debian-13-Maschine, Host anlegen,
zusehen. Ohne einen einzigen Handgriff.

---

## Die drei festen Entscheidungen

### 1. Derselbe Mechanismus wie `BuildVmTemplate` — Datei fahren, nicht nachbauen

Die Shell-Bibliothek wird auf den Host geladen und dort gefahren. Kein PHP-Nachbau
der Netzlogik. Zwei Fassungen hießen zwei Installationen, die bei jeder
Proxmox-Version nachgezogen werden müssten — und die zweite fiele erst auf, wenn
jemand sie benutzt.

Der Weg ist gebahnt: `template.sh` + `template-run.sh` + `env`, hochgeladen,
abgekoppelt gestartet, per Statusdatei und PID abgefragt.

### 2. Aus dem laufenden Zustand ableiten, nicht aus der Datei des Anbieters

`ip -4 addr`, `ip -4 route`, `/sys/class/net/…` — nicht das, was Hetzner oder
netcup in `/etc/network/interfaces` geschrieben haben. Was läuft, ist überall
gleich strukturiert; wie es aufgeschrieben wurde, nicht. Das ist der Teil, der
mehrere Anbieter zugleich trägt.

### 3. Die Rückfahrkarte ist Bedingung, nicht Beiwerk

Der Schritt sägt an dem Ast, auf dem er sitzt: SSH läuft zu diesem Zeitpunkt über
den Tunnel, und WireGuard hängt an derselben Netzwerkkarte, die in die Brücke
wandert. Eine falsche Brücke nimmt beides mit.

Also: **erst den Zeitgeber für die Rücknahme scharf stellen, dann umstellen, dann
neu verbinden, nachsehen, abbestellen.** Kommt die Verbindung nicht zurück, holt
der Zeitgeber den alten Zustand, und der Schritt landet in einer Wiederholung
statt auf einem toten Server.

---

## Was gebaut wird

### `deploy/bootstrap/lib/bridge.sh` (neu)

Die Brückenhälfte aus `network.sh` (heute Zeilen 47–254), herausgelöst und
**allein lauffähig**. Sie borgt sich heute vier Dinge von woanders, und die
wandern mit hinein:

| geborgt | steht heute in |
|---|---|
| `detect_primary_interface` | `lib/proxmox.sh:132` |
| `log` | `clupilot-bootstrap.sh` |
| `http_get` | `clupilot-bootstrap.sh` |
| `CLUPILOT_PROBE_URL` | `clupilot-bootstrap.sh:273` |

`network.sh` sourced `bridge.sh` künftig und behält seine WireGuard-/nftables-
Hälfte. **Eine Fassung, zwei Benutzer** — der stillgelegte Rettungssystem-Weg
und der Debian-Weg.

Warum nicht `network.sh` im Ganzen fahren: die zweite Hälfte derselben Datei
enthält WireGuard-, nftables- und Datacenter-Firewall-Funktionen, die als
PHP-Schritte längst existieren (`ConfigureWireguard`, `SecureHostFirewall`,
`ConfigureProxmox`). Sie auf den Host zu legen heißt, einem künftigen Leser zwei
Fassungen derselben Sache anzubieten — genau der Fehler, den die
Wege-Entscheidung vermeidet.

### `deploy/bootstrap/lib/bridge-run.sh` (neu)

Der Treiber, gebaut nach dem Muster von `template-run.sh`:

```
/var/lib/clupilot/bridge/
  state       running | ok | failed
  pid         PID = Prozessgruppe (setsid)
  phase       laufende Phase, für die Konsolenzeile
  note        Grund im Fehlerfall
  bridge.log  alles
  env         von CluPilot geschrieben
```

PID als **allererstes**, `trap on_exit`, und die Regel: „läuft noch" heißt, der
Prozess lebt (`kill -0`), nie: in der Statusdatei steht `running`.

### `App\Provisioning\Steps\Host\EnsureNetworkBridge` (neu)

Eingehängt zwischen `RebootIntoPveKernel` und `ConfigureProxmox`.

**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`. Bauen und prüfen, und beides, nicht eines von beiden.

Ein eigener Schritt statt einer Erweiterung von `ConfigureProxmox`, aus drei
Gründen: er braucht `poll()` und eine eigene Frist, er läuft abgekoppelt, und
`ConfigureProxmox` würde seine Firewall-Arbeit sonst bei jedem Poll wiederholen.
Außerdem bekommt der Betreiber eine eigene Zeile in der Konsole, an der er
zusehen kann — und das ist das Abnahmeziel.

### Abgekoppelt ist hier Bedingung, nicht Optimierung

`BuildVmTemplate` läuft abgekoppelt, weil zwanzig Minuten länger sind als der
Befehlszeitablauf. Dieser Schritt läuft abgekoppelt, 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.

---

## Der Ablauf

### Auf dem Host — `bridge-run.sh`

1. **sichern** — `tar czf` über `/etc/network/interfaces` und
   `/etc/network/interfaces.d/`, dazu der Zustand der Fremdverwalter, die gleich
   entmachtet werden.
2. **Zeitgeber stellen** — systemd-Timer, 5 Minuten, `OnActiveSec`.
   **Vor jeder Änderung, ausnahmslos.** Ein `sleep &` täte es nicht: ein
   Hintergrundlauf stirbt mit seiner Sitzung, und die Sitzung ist genau das, was
   abreißt.
   Das Rücknahme-Skript schreibt zusätzlich `failed` **und den Grund** in die
   Statusdatei. Sonst 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.
3. **übernehmen** — cloud-inits Netzteil abschalten
   (`/etc/cloud/cloud.cfg.d/99-disable-network-config.cfg`),
   `systemd-networkd`/NetworkManager maskieren, kollidierende
   `interfaces.d`-Strophen wegräumen, die dieselbe Karte beanspruchen.
4. **umstellen** — Strophe nach `/etc/network/interfaces`, `ifreload -a`
   (Rückfall: `systemctl restart networking`). **Hier stirbt SSH.**
5. **nachsehen — beide Richtungen.** Siehe eigenen Abschnitt unten.
6. `state=ok`, Ende. **Der Treiber bestellt nichts ab.**

### In CluPilot — `EnsureNetworkBridge`

**Erster Blick, vor allem anderen:** trägt `vmbr0` bereits die Standardroute und
eine Adresse, dann `advance()`, ohne irgendetwas anzufassen. Dieselbe Abkürzung
wie „Vorlage existiert und meldet `template: 1`". Sonst baut ein Wiederanlauf ein
funktionierendes Netz um — genau `pve-fns-1`.

**Guard: keine Brücke auf einer Brücke.** Ist die primäre Schnittstelle keine
physische Karte (`/sys/class/net/$if/device` fehlt, oder `…/bridge`, `…/bonding`
ist vorhanden), dann `fail()` mit Klartext statt bauen. `bridge_ports` auf einem
Bond oder einer bestehenden Bridge ist falsch.

**Erster Besuch** (kein `bridge_deadline` im Run-Kontext):

- Zustand lesen, Plan ableiten, Fremdverwalter erkennen.
- Unbekannter Verwalter → `fail()` mit der heutigen Handarbeit-Meldung. Kein
  Versuch ins Blaue.
- `bridge.sh`, `bridge-run.sh` und `env` hochladen.
- `bridge_deadline` (15 min) und `bridge_attempts` in den Run-Kontext.
- `nohup setsid sh bridge-run.sh`, auf die PID-Datei warten.
- `poll(20)`.

**Jeder weitere Besuch:**

- `keyLogin()` **in einem try/catch**. Fliegt es und die Frist läuft noch →
  `poll(20, 'warte darauf, dass der Host über die neue Brücke wieder antwortet')`.
  Das ist der erwartete Fall, kein Fehler.
  **Warum das die tragende Zeile ist:** `RunRunner.php:102` 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.
- Status lesen (`state`/`alive`/`phase`/`note`), gleiche Form wie
  `BuildVmTemplate::readStatus()`.

| gelesen | Reaktion |
|---|---|
| `ok`, Brücke steht | abbestellen, dann `advance()` — **in dieser Reihenfolge** |
| `ok`, Brücke fehlt | der Zeitgeber war schneller → `giveUp()` |
| `running`, Prozess lebt | `poll(20)` |
| `running`, Prozess tot | Lauf ist tot, nicht langsam → auf die Rücknahme warten |
| `failed` | auf die Rücknahme warten, dann `giveUp()` mit `note` |
| leer | nie angelaufen → `giveUp()` |
| Frist überschritten | `giveUp()` |

**„Auf die Rücknahme warten" heißt konkret:** `poll(20)` weiter, bis entweder das
Rücknahme-Skript `failed` samt Grund geschrieben hat **oder** der Zeitgeber nicht
mehr existiert und der alte Zustand wieder auf der Karte liegt. Erst dann
`giveUp()`. Nicht sofort scheitern: solange die Rücknahme aussteht, ist die
Maschine mitten in einer Umstellung, und ein `fail()` hinterließe sie dort.
Gedeckelt ist das Warten durch die Schritt-Frist — und die ist das Dreifache des
Zeitgebers, das Warten kann also nicht unbegrenzt laufen.

`giveUp()` löscht `bridge_deadline` (damit ein Wiederanlauf wieder als erster
Besuch gilt), räumt die Statusdateien weg und beendet einen etwa noch laufenden
Treiber über seine Prozessgruppe — dieselbe Reihenfolge und dieselbe
`cmdline`-Absicherung gegen eine wiederverwendete PID wie in
`BuildVmTemplate::giveUp()`. `bridge_attempts` bleibt stehen.

**Fristen:** Zeitgeber 5 Minuten, Schritt-Frist 15 Minuten, `maxDuration()` 3600
(damit die eigene Frist die Fehlermeldung schreibt und nicht der generische
Zeitablauf, der zusätzlich einen Versuch kostet — dieselbe Form wie
`RebootIntoPveKernel` und `BuildVmTemplate`).

Die Frist ist **absichtlich das Dreifache** des Zeitgebers: wenn der Schritt
aufgibt, ist der alte Zustand garantiert längst zurückgespielt. Das ist die
Zeile, die „Wiederholung statt toter Server" wahr macht.

**Höchstens zwei automatische Versuche je Lauf** (`bridge_attempts`, von
`giveUp()` **nicht** gelöscht). Der zweite ist für den Fall, dass der Betreiber
zwischen den Versuchen etwas repariert hat; danach die Handarbeit-Meldung samt
der Strophe, die der Schritt geschrieben hätte.

---

## Die Erfolgsbedingung — beide Richtungen

Der Zeitgeber wird **erst** abbestellt, wenn beides stimmt:

```
# 1. komme ich raus?    http_get "$CLUPILOT_PROBE_URL"
# 2. steht der Tunnel?  date +%s  gegen  wg show wg0 latest-handshakes
```

Nur `1.` zu prüfen ist notwendig und **nicht hinreichend**. Der Fehlerfall:
`ifreload -a` bringt `vmbr0` sauber hoch, die Maschine erreicht das Internet, der
Treiber ist zufrieden und bestellt ab — aber `wg0` kommt nicht zurück. Ergebnis:
Der Host ist öffentlich erreichbar, CluPilot ist ausgesperrt, und die
Rückfahrkarte wurde gerade weggeworfen.

`wg0.conf` (`ConfigureWireguard::renderConfig()`) enthält **keine
Gerätebindung** — kein `PostUp` nennt die Karte. Der Tunnel hängt also nicht
wörtlich an `enp0s31f6`, sondern an der Quelladresse, die die Routing-Tabelle
hergibt — und genau das ist die Größe, die der Umbau anfasst. Das macht den
Fehlerfall nicht unwahrscheinlicher, nur unauffälliger: kein Fehler im
Protokoll, nur ein Handshake, der ausbleibt.

Prüfung `2.` ist **wortgleich** die aus v1.3.85 (`ConfigureWireguard`): frischer
Handshake (180 s) vom konfigurierten Hub, gestellt mit `date +%s` und
`wg show wg0 latest-handshakes`. Dieselbe Frage, dieselbe Antwort, lokal
stellbar — niemand muss von außen hereinkommen.

**Ist der Handshake schal: einmal `systemctl restart wg-quick@wg0`, dann noch
einmal nachsehen.** Das ist zu diesem Zeitpunkt gefahrlos, weil die SSH-Sitzung
ohnehin schon weg ist, und verwandelt einen hängenden Tunnel in einen laufenden
statt in eine Rücknahme. Bleibt er schal, `failed` — der Zeitgeber holt den alten
Zustand.

### Kein `ping`

`network.sh:190` und `:365` prüfen mit `ping -c2`. Das ist wortgleich die Falle,
die schon `ConfigureWireguard` erwischt hat: Hetzners `Debian-trixie-…-base` hat
kein `iputils-ping`, und `PrepareBaseSystem` installiert es nicht (es holt
`curl gnupg ifupdown2 chrony`). Die Nachsehen-Stufe sagte damit **immer** „nicht
erreichbar", der Zeitgeber spielte **immer** zurück — der Schritt käme nie durch.

`bridge.sh` prüft deshalb ohne `ping`:

1. Standardroute liegt auf `vmbr0`, mit dem erwarteten Gateway.
2. Die erwartete Adresse liegt auf `vmbr0`.
3. `http_get "$CLUPILOT_PROBE_URL"` (`curl`, Rückfall `wget` — beide vorhanden).
4. Frischer WireGuard-Handshake.

`iputils-ping` wird **nicht** nachinstalliert. Ein Paket nachzuziehen, um eine
schlechtere Prüfung benutzen zu können, ist rückwärts.

---

## Was aus dem laufenden Zustand abgeleitet wird

Alles auf dem Host, alles aus `ip`/`sys`:

| Größe | Quelle |
|---|---|
| primäre Karte | `ip -4 route show default` → `dev` |
| Adresse/Maske | `ip -4 -o addr show dev $if scope global`, erste |
| Gateway | `ip -4 route show default` → `via` |
| MAC | `/sys/class/net/$if/address` → `hwaddress ether`, festgenagelt |
| physisch? | `/sys/class/net/$if/device` da, `…/bridge`+`…/bonding` weg |
| DHCP? | Flag `dynamic` in `ip -4 -o addr show` (Datei-Grep nur als Zweitsignal) |
| Zusatzrouten | `ip -4 route show dev $if` minus Standard-, Subnetz- und Gateway-Link-Route |

**Anbieterform** (`detect_network_style`, bleibt wie sie ist):

- `dhcp` — Adresse kommt per DHCP.
- `routed` — Gateway außerhalb des eigenen Subnetzes (Hetzner dediziert, /32).
  Braucht `pointopoint`, sonst findet der Kernel das Gateway nicht und die
  Maschine ist still weg.
- `subnet` — gewöhnliches Subnetz, Gateway darin.

Ob das Gateway im eigenen Subnetz liegt, beantwortet `ip -4 route get` — mit
derselben Logik, die der Kernel später anwendet, statt Netzmasken nachzurechnen.

### IPv6

IPv6 trägt in dieser Installation keinen Verkehr; IPv4 genügt. Mitgenommen wird
es trotzdem — aber **nur in der einen Form, die nichts kostet**: liegt auf der
Karte eine **statische, globale** v6-Adresse und gibt es ein v6-Standardgateway,
wandern beide als `iface vmbr0 inet6 static` mit. Das deckt Hetzners
`2a01:…::2/64` samt `fe80::1` ab und ist derselbe Dreisatz wie bei v4.

**Nicht nachgebaut werden SLAAC und DHCPv6.** 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 innerhalb des Fensters, das die
ganze Konstruktion gerade zu überleben versucht, und er kaufte nichts. In dem
Fall wird v6 fallen gelassen **und eine Zeile ins Protokoll geschrieben**. Still
verlieren gilt nicht.

### Fremdverwalter

Erkannt und entmachtet werden drei: cloud-init (Netzteil), `systemd-networkd`,
NetworkManager. Alles, was der Schritt **nicht** erkennt, führt zur heutigen
Handarbeit-Meldung statt zu einem Versuch ins Blaue.

Der Grund, warum das überhaupt hineingehört: aus dem laufenden Zustand
abzuleiten löst das *Lesen*, 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.

Dazu der kleinere Verwandte: `build_bridge` behält heute die Zeile
`source /etc/network/interfaces.d/*`. Bleibt dort eine Strophe liegen, die
dieselbe Karte beansprucht, hat der Host zwei Stellen, die seine Adresse
vergeben.

---

## Was ausdrücklich nicht gebaut wird

- **Kein PHP-Nachbau** der Netzlogik. Der Schritt lädt und fährt.
- **Kein zweiter Rücknahmeweg.** Nur der Zeitgeber stellt zurück, nie der
  Treiber selbst und nie CluPilot. Ein Treiber, der zurückspielt und das
  Ergebnis über eine Verbindung meldet, die es vielleicht nicht gibt, rät über
  seine eigene Erreichbarkeit. Ein Besitzer, an einer Stelle.
- **Kein `iputils-ping`.**
- **Keine Änderung an `ConfigureProxmox`.** `refuseWithoutBridge()` bleibt
  wortgleich stehen und wird zur Nachprüfung.
- **Kein SLAAC/DHCPv6-Nachbau.**
- **Keine Reparatur des stillgelegten Rettungssystem-Wegs.** `network.sh:365`
  benutzt weiterhin `ping` in `wireguard_handshake_proven`. Bekannt, außerhalb
  dieses Schnitts, hier notiert.

---

## Tests

Nach der Testtechnik, die sich bei `BuildVmTemplate` gelohnt hat:

1. **Byte für Byte.** Ein Test vergleicht das hochgeladene `bridge.sh` gegen die
   Repo-Datei. Dieselbe Zusicherung wie bei `template.sh`.
2. **`sh -n` über jeden Befehl, den der Schritt absetzt** (`FakeRemoteShell`
   führt nichts aus), plus `sh -n` über `bridge.sh` und `bridge-run.sh` selbst.
   Gegengeprobt mit absichtlich kaputtem Quoting.
3. **Der Strophengenerator, gegen echte `sh` gefahren.** `build_bridge` schreibt
   nach `${CLUPILOT_INTERFACES_FILE:-/etc/network/interfaces}` und ruft
   `${CLUPILOT_IFRELOAD:-ifreload}` — dieselbe Überschreibbarkeit wie
   `CLUPILOT_STORAGE_CFG`. Damit prüft ein Pest-Test die erzeugte Strophe gegen
   Aufzeichnungen von `ip addr`/`ip route` aus vier Fällen: Hetzner dediziert
   (`routed`, /32, `pointopoint`), Hetzner Cloud (`dhcp`), netcup (`subnet`),
   und statisches IPv6 mit `fe80::1`.
   **Das ist der wertvollste Test des Vorhabens** — die Strophe ist das, was die
   Maschine umbringt.
4. **Die Reihenfolge-Zusicherung.** Ein Test über die abgesetzte Befehlsfolge:
   der Zeitgeber steht **vor** dem ersten verändernden Befehl.
5. **Beide Richtungen.** Ein Test, dass ein schaler Handshake trotz erreichbarem
   Internet **nicht** abbestellt.
6. **Das Verhalten des Schritts:** Verbindungsabriss → `poll`, nicht `retry`
   (die Zeile aus `RunRunner.php:102`); `ok` ohne Brücke → `giveUp`; `running`
   ohne lebenden Prozess → `giveUp`; Frist > Zeitgeber; Abbestellen erst nach
   der Nachprüfung; Brücke schon da → `advance()` ohne einen einzigen
   verändernden Befehl.

---

## Abnahme

Frische Debian-13-Maschine, Host in `/admin/hosts` anlegen, zusehen, `active` —
ohne einen Handgriff. Und auf `pve-fns-1`, das die Brücke schon hat: der Schritt
meldet `advance()` und fasst nichts an.

---

Siehe `docs/superpowers/specs/2026-07-25-host-onboarding-design.md`,
`deploy/bootstrap/lib/template.sh`, `app/Provisioning/Steps/Host/BuildVmTemplate.php`.
