# 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 } # --------------------------------------------------------------------------- # WireGuard # --------------------------------------------------------------------------- # Netzadresse zu einer Adresse mit Präfix: 10.66.0.11/24 -> 10.66.0.0/24 # # Gerechnet statt geraten. `AllowedIPs` mit der Wirtsadresse statt der # Netzadresse ließe den Tunnel handshaken und danach jeden anderen Teilnehmer # unerreichbar — ein Fehler, der aussieht wie ein Routingproblem irgendwo sonst. ipv4_network() { _ip="${1%%/*}" _prefix="${1##*/}" IFS=. read -r _a _b _c _d <> 24) & 255 )) $(( (_net >> 16) & 255 )) \ $(( (_net >> 8) & 255 )) $(( _net & 255 )) "$_prefix" } # Die Tunneladresse von CluPilot — dieselbe, die `--api` trägt. # # Sie ist auch das Ziel für den Handshake-Beweis: wenn CluPilot über den Tunnel # antwortet, steht der Tunnel. Ein eigener Parameter dafür wäre ein zweiter Wert, # der dasselbe meint und irgendwann auseinanderläuft. api_host() { printf '%s' "$CLUPILOT_API" | sed -e 's#^[a-z]*://##' -e 's#[:/].*##' } render_wireguard_config() { _subnet="$(ipv4_network "$CLUPILOT_WG_IP")" cat </dev/null 2>&1 && _present=1 # `is-enabled` wird nach seinem Rückgabewert gefragt, nicht nach seinem # Wortlaut: eine nie freigegebene Template-Unit („disabled", 1) und eine, die # es noch nicht gibt, werden damit gleich behandelt. if ! systemctl is-enabled --quiet "$_unit" 2>/dev/null; then if ! systemctl enable "$_unit" >/dev/null 2>&1; then log "konnte ${_unit} nicht freigeben — wg0 überlebte keinen Neustart" return 1 fi fi if [ "$_present" -eq 0 ]; then systemctl start "$_unit" >/dev/null 2>&1 || { log 'wg0 ließ sich nicht starten'; return 1; } return 0 fi # Vorhanden und unverändert: die laufende Schnittstelle passt schon zur # Datei. Hier neu zu starten ist das, was einen funktionierenden Tunnel # kaputt aussehen ließ. [ "$_config_changed" -eq 0 ] && return 0 # Vorhanden mit geänderter Konfiguration: Neustart, NICHT `wg syncconf`. # syncconf wendet nur Peers an; eine korrigierte `Address` oder `AllowedIPs` # bliebe still wirkungslos — genau die Sorte „die Datei hat sich geändert und # nichts ist passiert". systemctl restart "$_unit" >/dev/null 2>&1 || { log 'Neustart von wg0 mit der korrigierten Konfiguration fehlgeschlagen'; return 1; } return 0 } # Der Tunnel gilt erst mit BEWIESENEM Handshake, nicht mit geschriebener Datei. # # Eine gespeicherte Tunneladresse ohne Beweis machte jeden weiteren # Verbindungsversuch unbrauchbar, und die Rettung war Datenbank-Handarbeit. wireguard_handshake_proven() { _target="$(api_host)" [ -n "$_target" ] || return 1 _tries="${1:-10}" while [ "$_tries" -gt 0 ]; do if ping -c1 -W2 "$_target" >/dev/null 2>&1; then return 0 fi _tries=$((_tries - 1)) sleep 3 done return 1 } # --------------------------------------------------------------------------- # 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 }