CluPilotCloud/docs/superpowers/specs/2026-08-01-network-bridge-d...

18 KiB
Raw Blame History

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 47254), 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 BuildVmTemplateVerifyVmTemplate. 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. sicherntar 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 defaultdev
Adresse/Maske ip -4 -o addr show dev $if scope global, erste
Gateway ip -4 route show defaultvia
MAC /sys/class/net/$if/addresshwaddress 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.