# 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. # --------------------------------------------------------------------------- # Feststellen, was da ist — steht jetzt in lib/bridge.sh # --------------------------------------------------------------------------- # # `bridge_exists`, `bridge_carries_default_route`, `bridge_has_address`, # `detect_network_style`, `detect_primary_interface` und die beiden Variablen # `CLUPILOT_ROLLBACK_UNIT`/`CLUPILOT_NET_BACKUP` sind nach `lib/bridge.sh` # gezogen, samt der Helfer, die sie sich früher aus `clupilot-bootstrap.sh` # borgten. # # Der Grund ist der Debian-Weg: dort lädt `EnsureNetworkBridge` die Bibliothek # EINZELN auf den Host und fährt sie, und geborgte Helfer wären dann nicht da. # `clupilot-bootstrap.sh` sourced `bridge.sh` vor dieser Datei, hier ändert sich # also nichts — außer, dass es die Brücke nur noch einmal im Repo gibt. # --------------------------------------------------------------------------- # 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 } # `build_bridge` und `write_bridge_stanza` stehen in lib/bridge.sh. # # Die dortige Fassung kann drei Dinge mehr, und jedes davon hat einen Grund: # sie nagelt die MAC fest, nimmt die Zusatzrouten des Anbieters mit, und trennt # das SCHREIBEN der Strophe vom Anwenden — damit prüfbar ist, was eine Maschine # umbringt, ohne dafür ein Netz neu laden zu müssen. # --------------------------------------------------------------------------- # 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 Host-Firewall (nftables) # --------------------------------------------------------------------------- # # Läuft ZULETZT, in Abschnitt `registered`. Der Grund steht in # `SecureHostFirewall.php` und ist eine Reihenfolgefrage: erst wenn der Tunnel # jeden vorigen Schritt getragen hat, darf SSH nach außen zugehen. Früher # geschlossen, und ein Host hinge mitten in der Übernahme fest. # # nftables statt pve-firewall, und nur die INPUT-Kette: FORWARD und OUTPUT # bleiben auf der Kernel-Vorgabe, damit der Verkehr der Kunden-VMs über die # Brücken nie von einer Host-Regel berührt wird. Zwei Filter, die beide den # Eingang besitzen wollen, sind der Weg zu einem unerreichbaren Host — deshalb # schaltet `enable_datacenter_firewall` die Node-Firewall ausdrücklich ab. # # **Keine automatische Wiederöffnung.** Kein Zeitgeber, kein „nach N Minuten ohne # Handshake wieder auf". Eine Firewall, die sich unter Störung selbst öffnet, ist # keine Firewall. Für den Notfall liegt ein Skript bereit, das von der # Anbieterkonsole aus gestartet wird. CLUPILOT_EMERGENCY_SCRIPT='/usr/local/sbin/clupilot-emergency-open-firewall.sh' render_nftables_config() { _wg_subnet="$1" cat </dev/null || true nft flush ruleset 2>/dev/null || true echo "Firewallregeln verworfen: dieser Host nimmt jetzt auf jedem Port an." SH } # Sperrt den Host ab. Verlangt VORHER einen bewiesenen Tunnel und fasst sonst # nichts an. apply_host_firewall() { if ! wireguard_handshake_proven 3; then log 'Tunnel nicht bewiesen — die Host-Firewall wird nicht angewendet' return 1 fi export DEBIAN_FRONTEND=noninteractive apt-get install -y nftables >/dev/null 2>&1 || { log 'nftables ließ sich nicht installieren'; return 1; } render_nftables_config "$(ipv4_network "$CLUPILOT_WG_IP")" > /etc/nftables.conf render_emergency_script > "$CLUPILOT_EMERGENCY_SCRIPT" chmod 700 "$CLUPILOT_EMERGENCY_SCRIPT" # Erst prüfen, dann anwenden. Ein Regelwerk mit einem Tippfehler wird von # `nft -f` teilweise geladen — und „teilweise" heißt hier: policy drop steht, # die Ausnahmen nicht. if ! nft -c -f /etc/nftables.conf >/dev/null 2>&1; then log 'nftables-Regelwerk ist nicht gültig — es wird nichts angewendet' return 1 fi systemctl enable --now nftables >/dev/null 2>&1 || { log 'nftables ließ sich nicht aktivieren'; return 1; } systemctl reload-or-restart nftables >/dev/null 2>&1 || nft -f /etc/nftables.conf return 0 } # --------------------------------------------------------------------------- # 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 }