18 KiB
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
- sichern —
tar czfüber/etc/network/interfacesund/etc/network/interfaces.d/, dazu der Zustand der Fremdverwalter, die gleich entmachtet werden. - Zeitgeber stellen — systemd-Timer, 5 Minuten,
OnActiveSec. Vor jeder Änderung, ausnahmslos. Einsleep &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ätzlichfailedund den Grund in die Statusdatei. Sonst liest der Schritt nach dem Wiederverbindenrunningund pollt gegen einen Lauf, den es nicht mehr gibt — der Zeitgeber beendet den Treiber ja gerade nicht. - übernehmen — cloud-inits Netzteil abschalten
(
/etc/cloud/cloud.cfg.d/99-disable-network-config.cfg),systemd-networkd/NetworkManager maskieren, kollidierendeinterfaces.d-Strophen wegräumen, die dieselbe Karte beanspruchen. - umstellen — Strophe nach
/etc/network/interfaces,ifreload -a(Rückfall:systemctl restart networking). Hier stirbt SSH. - nachsehen — beide Richtungen. Siehe eigenen Abschnitt unten.
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.shundenvhochladen.bridge_deadline(15 min) undbridge_attemptsin 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:102verwandelt jede geworfene Ausnahme in einretry(), undretryverbraucht 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 wieBuildVmTemplate::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:
- Standardroute liegt auf
vmbr0, mit dem erwarteten Gateway. - Die erwartete Adresse liegt auf
vmbr0. http_get "$CLUPILOT_PROBE_URL"(curl, Rückfallwget— beide vorhanden).- 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). Brauchtpointopoint, 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:365benutzt weiterhinpinginwireguard_handshake_proven. Bekannt, außerhalb dieses Schnitts, hier notiert.
Tests
Nach der Testtechnik, die sich bei BuildVmTemplate gelohnt hat:
- Byte für Byte. Ein Test vergleicht das hochgeladene
bridge.shgegen die Repo-Datei. Dieselbe Zusicherung wie beitemplate.sh. sh -nüber jeden Befehl, den der Schritt absetzt (FakeRemoteShellführt nichts aus), plussh -nüberbridge.shundbridge-run.shselbst. Gegengeprobt mit absichtlich kaputtem Quoting.- Der Strophengenerator, gegen echte
shgefahren.build_bridgeschreibt nach${CLUPILOT_INTERFACES_FILE:-/etc/network/interfaces}und ruft${CLUPILOT_IFRELOAD:-ifreload}— dieselbe Überschreibbarkeit wieCLUPILOT_STORAGE_CFG. Damit prüft ein Pest-Test die erzeugte Strophe gegen Aufzeichnungen vonip addr/ip routeaus vier Fällen: Hetzner dediziert (routed, /32,pointopoint), Hetzner Cloud (dhcp), netcup (subnet), und statisches IPv6 mitfe80::1. Das ist der wertvollste Test des Vorhabens — die Strophe ist das, was die Maschine umbringt. - Die Reihenfolge-Zusicherung. Ein Test über die abgesetzte Befehlsfolge: der Zeitgeber steht vor dem ersten verändernden Befehl.
- Beide Richtungen. Ein Test, dass ein schaler Handshake trotz erreichbarem Internet nicht abbestellt.
- Das Verhalten des Schritts: Verbindungsabriss →
poll, nichtretry(die Zeile ausRunRunner.php:102);okohne Brücke →giveUp;runningohne 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.