Bridge the network, and take it back if the host goes quiet

Reading ConfigureProxmox.php changed what this section does. The missing vmbr0
that killed the first paid order is a consequence of installing Proxmox ON TOP
OF DEBIAN — "only the ISO installer writes that bridge into
/etc/network/interfaces". This design installs from the ISO, so the bridge is
normally already there. The section verifies it instead of building it: exists,
has an address, and actually carries the default route. The old step checked
only the first of those, threw the result away, and its comment claimed it
recorded the absence.

When there is no bridge, it builds one, and only then is this the dangerous
section the plan calls it. Backup, five-minute rollback timer, switch, look,
cancel — and the timer is a systemd unit rather than a backgrounded sleep,
because a background job dies with its session and the session is exactly what
breaks when the switch goes wrong. It is set BEFORE the change; setting it after
would mean setting it at the moment the connection is already gone.

The three provider shapes differ in one line, and that line decides whether the
machine is still there afterwards. A routed single address needs pointopoint,
because its gateway is outside its own subnet and without it the kernel has no
route to reach it. Which shape applies is decided by asking `ip route get` about
the gateway rather than by doing subnet arithmetic here — same logic the kernel
will apply later.

The reachability check is named for what it is: inside-out. A true outside-in
proof would need a counterpart, and before the tunnel there is none. It is
enough because a bridge that takes the host off the network takes both
directions with it, and what it does not cover is precisely what the timer
covers.

One deviation from the plan, written into it: the nftables lockdown does NOT
belong here. SecureHostFirewall ran LAST in the old pipeline, deliberately, so
the tunnel had already carried every earlier step before SSH-to-the-world was
closed. Here it would sit BEFORE wireguard_joined and shut port 22 with no
handshake yet proven — the ordering that makes a host permanently unreachable.
It moves to Task 9, where it used to be. The self-rollback stays here and covers
the network change; the firewall keeps its manual emergency release, because a
firewall that reopens itself under failure is not a firewall.

The datacenter firewall comes over verbatim, including why `enable 1` alone is a
trap: the management ipset is seeded from the public subnet, so enabling it
blindly drops the tunnel address that every later step arrives on. Three
settings in order, then read back — a pvesh set returning 0 is not the same as
the value being in cluster.fw, and a write that lost quorum is exactly the case
where every customer VM's rules stay inert while onboarding reports success.

Verified without hardware: all four files dash-clean, and the generated
interfaces file is correct for subnet, routed (with pointopoint) and dhcp.
Step 2 unticked — the self-rollback has to be triggered on purpose on real
hardware, and the plan is right that without that it is an assertion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/host-bootstrap
nexxo 2026-07-30 20:06:18 +02:00
parent f214713113
commit e9b2862331
2 changed files with 376 additions and 1 deletions

View File

@ -624,6 +624,71 @@ section_proxmox_installed() {
return 0
}
# ---------------------------------------------------------------------------
# Abschnitt 5: network_bridged — der gefährlichste Abschnitt
# ---------------------------------------------------------------------------
#
# Gefährlich ist er nur noch in seinem Ausnahmefall. Über die ISO legt der
# Installer `vmbr0` selbst an; dieser Abschnitt nimmt sie dann ab, statt sie zu
# bauen. Muss doch gebaut werden, greift die Selbstrücknahme.
section_network_bridged() {
_iface="$(detect_primary_interface)"
_style="$(detect_network_style "$_iface")"
if bridge_exists vmbr0 && bridge_has_address vmbr0 && bridge_carries_default_route vmbr0; then
log "vmbr0 steht und trägt die Vorgaberoute (Anbieterform: ${_style})"
_built='vom Installer'
else
# Ab hier wird an dem Netz gearbeitet, über das dieser Lauf gerade läuft.
if [ -z "$_iface" ]; then
CLUPILOT_SECTION_NOTE='keine primäre Netzkarte gefunden — ohne die lässt sich keine Brücke bauen'
return 1
fi
_cidr="$(ip -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')"
_gw="$(ip -4 route show default 2>/dev/null | awk '{ print $3; exit }')"
if [ -z "$_cidr" ] || [ -z "$_gw" ]; then
CLUPILOT_SECTION_NOTE="keine Adresse oder kein Gateway auf ${_iface} — zu wenig, um eine Brücke zu bauen, die danach noch trägt"
return 1
fi
log "keine vmbr0 vorhanden; baue sie über ${_iface} (${_style}, ${_cidr} via ${_gw})"
if ! backup_network_config; then
CLUPILOT_SECTION_NOTE='Netzkonfiguration ließ sich nicht sichern — ohne Sicherung wird hier nichts umgestellt'
return 1
fi
# Der Zeitgeber steht VOR der Umstellung. Danach ihn zu stellen hieße,
# ihn genau dann stellen zu wollen, wenn die Verbindung schon weg ist.
schedule_network_rollback 5
build_bridge "$_iface" "$_style" "$_cidr" "$_gw"
# Dem Netz einen Moment geben, bevor gefragt wird.
sleep 10
if ! host_still_reachable; then
# Nichts zurückspielen und nicht abbestellen: der Zeitgeber macht
# das gleich selbst, und er macht es auch dann, wenn dieser Lauf
# hier stirbt.
CLUPILOT_SECTION_NOTE='die Brücke hat den Host vom Netz genommen; die Selbstrücknahme läuft in wenigen Minuten und stellt den vorigen Stand wieder her'
return 1
fi
cancel_network_rollback
_built="von diesem Skript (${_style})"
fi
if ! enable_datacenter_firewall; then
CLUPILOT_SECTION_NOTE='die Datacenter-Firewall ließ sich nicht einschalten — ohne sie wären die 80/443-Regeln jeder Kunden-VM wirkungslos'
return 1
fi
CLUPILOT_SECTION_NOTE="vmbr0 ${_built}, Datacenter-Firewall an"
return 0
}
main() {
parse_arguments "$@"
@ -632,6 +697,8 @@ main() {
. "${CLUPILOT_BOOTSTRAP_DIR}/lib/report.sh"
# shellcheck source=lib/proxmox.sh
. "${CLUPILOT_BOOTSTRAP_DIR}/lib/proxmox.sh"
# shellcheck source=lib/network.sh
. "${CLUPILOT_BOOTSTRAP_DIR}/lib/network.sh"
init_reporting
if [ "$CLUPILOT_RESUMING" = '1' ]; then
@ -663,7 +730,10 @@ main() {
die 'Abbruch: Proxmox ist nicht in dem Zustand, den die folgenden Abschnitte voraussetzen.'
fi
# run_section network_bridged section_network_bridged # Task 5
if ! run_section network_bridged section_network_bridged; then
die 'Abbruch bei der Netzumstellung.'
fi
# run_section wireguard_joined section_wireguard_joined # Task 6
# run_section traefik_running section_traefik_running # Task 7
# run_section template_built section_template_built # Task 8

View File

@ -0,0 +1,305 @@
# shellcheck shell=sh
#
# Die Brücke, die Selbstrücknahme und die Datacenter-Firewall.
#
# ---------------------------------------------------------------------------
# Was sich durch den ISO-Weg geändert hat
# ---------------------------------------------------------------------------
#
# `ConfigureProxmox.php` hält als eigenen Fund fest: **Proxmox auf Debian legt
# keine `vmbr0` an — nur der ISO-Installer schreibt sie in
# `/etc/network/interfaces`.** Genau daran starb der erste bezahlte Auftrag:
# die Übernahme meldete `active` auf einem Host ohne Brücke, an die man eine
# Kunden-VM hätte hängen können.
#
# Dieser Entwurf installiert über die ISO. Die Brücke ist also im Regelfall
# schon da, und dieser Abschnitt **prüft sie, statt sie zu bauen**. Das ist kein
# Wegkürzen: dieselbe Datei begründet ausführlich, warum sie eine Brücke über die
# primäre Netzkarte NICHT aus der Ferne baut — ein Fehler dabei nimmt die
# Maschine dauerhaft vom Netz, und zurück kommt sie nur über die Anbieterkonsole.
#
# Bleibt der Fall, dass doch keine da ist. Dann — und nur dann — wird gebaut, und
# zwar mit der Selbstrücknahme aus Block B des Handoffs: sichern, Zeitgeber
# stellen, umstellen, nachsehen, abbestellen.
#
# ---------------------------------------------------------------------------
# Was hier NICHT passiert
# ---------------------------------------------------------------------------
#
# Die nftables-Sperre des Hosts gehört **nicht** in diesen Abschnitt, obwohl der
# Plan sie in seiner Leseliste führt. `SecureHostFirewall.php` sagt, warum, und
# es ist eine Reihenfolgefrage: dieser Schritt lief im alten Ablauf ZULETZT,
# „so the tunnel has already carried every prior step through a reboot before
# SSH-to-the-world is ever closed". Hier stünde er VOR `wireguard_joined` — und
# schlösse Port 22 zu einem Zeitpunkt, zu dem noch kein Tunnel bewiesen ist. Das
# ist die Reihenfolge, die einen Host dauerhaft unerreichbar macht.
#
# Die Sperre steht deshalb in Abschnitt `registered` (Task 9), an derselben
# Stelle wie früher: nach dem bewiesenen Handshake, nach Traefik.
CLUPILOT_ROLLBACK_UNIT='clupilot-network-rollback'
CLUPILOT_NET_BACKUP='/var/lib/clupilot/interfaces.vor-der-bruecke'
# ---------------------------------------------------------------------------
# Feststellen, was da ist
# ---------------------------------------------------------------------------
bridge_exists() {
ip link show "${1:-vmbr0}" >/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() {
_dev="$(ip -4 route show default 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }')"
[ "$_dev" = "${1:-vmbr0}" ]
}
bridge_has_address() {
[ -n "$(ip -4 -o addr show dev "${1:-vmbr0}" scope global 2>/dev/null | awk '{ print $4; exit }')" ]
}
# 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)"
if grep -qsE "iface[[:space:]]+${_iface}[[:space:]]+inet[[:space:]]+dhcp" /etc/network/interfaces /etc/network/interfaces.d/* 2>/dev/null; then
printf 'dhcp'
return 0
fi
_cidr="$(ip -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')"
_gw="$(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" ] && ip -4 route get "$_gw" 2>/dev/null | grep -q "dev ${_iface}.*src"; then
printf 'subnet'
return 0
fi
printf 'routed'
}
# ---------------------------------------------------------------------------
# Die Selbstrücknahme
# ---------------------------------------------------------------------------
#
# Das Muster aus Block B, unverändert, nur im Skript statt in der Pipeline:
# 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.
backup_network_config() {
mkdir -p "$(dirname -- "$CLUPILOT_NET_BACKUP")"
tar czf "${CLUPILOT_NET_BACKUP}.tar.gz" \
-C / etc/network/interfaces $( [ -d /etc/network/interfaces.d ] && printf 'etc/network/interfaces.d' ) \
2>/dev/null
}
# Stellt einen Zeitgeber, der die gesicherte Konfiguration zurückspielt und das
# Netz neu startet, falls ihn niemand abbestellt.
schedule_network_rollback() {
_minutes="${1:-5}"
cat > "/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh" <<EOF
#!/bin/sh
# Von CluPilots Bootstrap gestellt. Spielt die Netzkonfiguration von VOR der
# Brücke zurück, falls der Bootstrap sie nicht binnen ${_minutes} Minuten
# abbestellt hat — weil er das nur kann, wenn die Maschine noch erreichbar ist.
set -e
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 zurückgespielt: die Brücke hat den Host vom Netz genommen.'
EOF
chmod 700 "/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh"
cat > "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.service" <<EOF
[Unit]
Description=CluPilot: Netzkonfiguration zurückspielen, wenn die Brücke den Host vom Netz nimmt
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh
EOF
cat > "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.timer" <<EOF
[Unit]
Description=CluPilot: Frist für die Selbstrücknahme der Netzumstellung
[Timer]
OnActiveSec=${_minutes}min
AccuracySec=1s
EOF
systemctl daemon-reload
systemctl start "${CLUPILOT_ROLLBACK_UNIT}.timer"
log "Selbstrücknahme steht: ohne Abbestellung läuft sie in ${_minutes} Minuten"
}
cancel_network_rollback() {
systemctl stop "${CLUPILOT_ROLLBACK_UNIT}.timer" 2>/dev/null || true
rm -f "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.timer" \
"/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.service" \
"/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh"
systemctl daemon-reload 2>/dev/null || true
log 'Selbstrücknahme abbestellt'
}
# Antwortet die Maschine noch?
#
# Ehrlich benannt: das hier prüft von INNEN nach außen. Ein Beweis, dass die
# Maschine von außen erreichbar ist, ginge nur über einen Gegenüber — und den
# gibt es vor dem Tunnelbeitritt nicht. Es reicht trotzdem, weil eine Brücke, die
# den Host vom Netz nimmt, in aller Regel beide Richtungen nimmt: Gateway
# unerreichbar, Namensauflösung tot, kein Paket kommt durch.
#
# Was es NICHT abdeckt, deckt der Zeitgeber ab. Deshalb steht er da.
host_still_reachable() {
_gw="$(ip -4 route show default 2>/dev/null | awk '{ print $3; exit }')"
[ -n "$_gw" ] || return 1
ping -c2 -W2 "$_gw" >/dev/null 2>&1 || return 1
# Zweite Stufe: irgendetwas jenseits des Gateways. Ein erreichbares Gateway
# bei totem Weiterweg ist genau der Zustand, den eine falsche Route erzeugt.
http_get "$CLUPILOT_PROBE_URL" >/dev/null 2>&1
}
# Schreibt eine Brücke über die primäre Netzkarte.
#
# Läuft NUR, wenn der ISO-Installer keine hinterlassen hat — und dann unter dem
# Zeitgeber. Die drei Formen unterscheiden sich in genau der Zeile, die
# entscheidet, ob die Maschine danach noch da ist.
build_bridge() {
_iface="$1"
_style="$2"
_cidr="$3"
_gw="$4"
_ip="${_cidr%%/*}"
case "$_style" in
dhcp)
_inet='iface vmbr0 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 vmbr0 inet static'
_addr=" address ${_ip}/32
pointopoint ${_gw}
gateway ${_gw}"
;;
*)
_inet='iface vmbr0 inet static'
_addr=" address ${_cidr}
gateway ${_gw}"
;;
esac
cat > /etc/network/interfaces <<EOF
# Von CluPilots Bootstrap geschrieben, weil der Installer keine vmbr0
# hinterlassen hat. Anbieterform: ${_style}.
source /etc/network/interfaces.d/*
auto lo
iface lo inet loopback
iface ${_iface} inet manual
auto vmbr0
${_inet}
${_addr}
bridge-ports ${_iface}
bridge-stp off
bridge-fd 0
EOF
if command -v ifreload >/dev/null 2>&1; then
ifreload -a
else
systemctl restart networking
fi
}
# ---------------------------------------------------------------------------
# Die Datacenter-Firewall
# ---------------------------------------------------------------------------
#
# Wörtlich aus `ConfigureProxmox.php`. Ohne sie sind die „nur 80/443"-Regeln,
# die für jede Kunden-VM geschrieben werden, **wirkungslos** — auf jedem Host,
# den diese Pipeline je übernommen hat.
#
# `enable 1` allein ist eine Falle. Die Eingangsrichtlinie steht auf DROP, die
# Node-Firewall auf ENABLED, und die einzigen Quellen, die dann noch an 8006 und
# 22 kommen, sind die Mitglieder des automatisch erzeugten `management`-ipsets —
# gespeist aus dem ÖFFENTLICHEN Subnetz. Die Tunneladresse steht nicht darin.
#
# Also drei Einstellungen, in dieser Reihenfolge, jede mit einer Aufgabe:
#
# 1. `policy_in`/`policy_out` auf ACCEPT — das ist die HOST-Richtlinie. Ein Gast
# liest sein eigenes `vm.fw`, das für jede Kunden-VM ausdrücklich auf DROP
# gesetzt wird; ACCEPT hier lockert also keine einzige Kundenregel.
# 2. Node-Firewall AUS. Die Host-Richtlinie liegt in nftables. Zwei Filter, die
# beide den Eingang besitzen wollen, sind der Weg zu einem Host, der aus
# einem Grund unerreichbar ist, den niemand findet: ein Paket müsste von
# BEIDEN angenommen werden.
# 3. `enable 1` zuletzt, wenn keines der beiden mehr beißen kann.
#
# `policy_forward` bleibt absichtlich unangetastet — es greift nur auf dem
# nftables-Hintergrund von pve-firewall, der hier aus ist.
enable_datacenter_firewall() {
for _cmd in \
'pvesh set /cluster/firewall/options --policy_in ACCEPT --policy_out ACCEPT' \
'pvesh set /nodes/"$(hostname)"/firewall/options --enable 0' \
'pvesh set /cluster/firewall/options --enable 1'
do
if ! eval "$_cmd" >/dev/null 2>&1; then
log "Datacenter-Firewall: ${_cmd} fehlgeschlagen"
return 1
fi
done
# Zurücklesen. `pvesh set` mit Rückgabewert 0 heißt nicht, dass es in
# `cluster.fw` steht: /etc/pve ist ein repliziertes Dateisystem, und ein
# Schreibvorgang ohne Quorum ist genau der Fall, in dem die Regeln jeder
# Kunden-VM wirkungslos blieben, während die Übernahme Erfolg meldet.
if ! pvesh get /cluster/firewall/options --output-format json 2>/dev/null \
| tr -d ' \n' | grep -q '"enable":1'; then
log 'Datacenter-Firewall liest sich nicht als eingeschaltet zurück'
return 1
fi
return 0
}