diff --git a/deploy/bootstrap/clupilot-bootstrap.sh b/deploy/bootstrap/clupilot-bootstrap.sh index f54ee65..a27caa9 100755 --- a/deploy/bootstrap/clupilot-bootstrap.sh +++ b/deploy/bootstrap/clupilot-bootstrap.sh @@ -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 diff --git a/deploy/bootstrap/lib/network.sh b/deploy/bootstrap/lib/network.sh new file mode 100644 index 0000000..1dde840 --- /dev/null +++ b/deploy/bootstrap/lib/network.sh @@ -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" </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" < "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.timer" </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 </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 +}