CluPilotCloud/deploy/bootstrap/lib/network.sh

396 lines
17 KiB
Bash

# 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 — steht jetzt in lib/bridge.sh
# ---------------------------------------------------------------------------
#
# `backup_network_config`, `schedule_network_rollback` und
# `cancel_network_rollback` sind mit der Brücke nach `lib/bridge.sh` gezogen.
# Gleiche Namen, gleicher Zweck, also ruft dieser Weg sie unverändert auf.
#
# Zwei Dinge kann die dortige Fassung mehr:
#
# - Das Rücknahme-Skript schreibt `failed` samt Grund in die Statusdatei, BEVOR
# es zurückspielt, und legt zuletzt `rolled-back` an. Der Debian-Weg pollt von
# außen und muss unterscheiden können, ob eine Rücknahme aussteht oder durch
# ist; ohne diese Zeilen liest er `running` und pollt gegen einen Lauf, den es
# nicht mehr gibt — der Zeitgeber beendet den Treiber ja gerade nicht.
# - Der Zeitgeber räumt seine Unit-Dateien nach dem Feuern selbst weg.
# 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 <<EOF
$_ip
EOF
_num=$(( (_a << 24) + (_b << 16) + (_c << 8) + _d ))
_mask=$(( (0xFFFFFFFF << (32 - _prefix)) & 0xFFFFFFFF ))
_net=$(( _num & _mask ))
printf '%d.%d.%d.%d/%d' \
$(( (_net >> 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 <<EOF
[Interface]
Address = ${CLUPILOT_WG_IP}
PrivateKey = ${CLUPILOT_WG_PRIVATE}
[Peer]
PublicKey = ${CLUPILOT_HUB_PUBKEY}
Endpoint = ${CLUPILOT_HUB_ENDPOINT}
AllowedIPs = ${_subnet}
PersistentKeepalive = 25
EOF
}
# Bringt wg0 in den Zustand, den die Übernahme braucht: jetzt oben UND nach
# einem Neustart oben, mit der Konfiguration, die gerade geschrieben wurde.
#
# Drei Zustände, getrennt behandelt, weil die eine Zeile, die das hier ersetzt —
# `systemctl enable --now wg-quick@wg0 || wg-quick up wg0` — zwei davon falsch
# machte: der Rückfall brachte die Schnittstelle hoch OHNE die systemd-Freigabe,
# also kam der Tunnel nach dem Neustart nicht wieder; und beim nächsten Versuch
# scheiterten beide Hälften an „wg0 already exists", also lief der Schritt ewig
# gegen einen Tunnel, der in Wahrheit funktionierte.
#
# **enable vor start**: `enable` ist das, was den Neustart überlebt, und es wird
# auch dann geprüft und nachgeholt, wenn die Schnittstelle schon anderweitig
# oben ist.
bring_up_wireguard() {
_config_changed="$1"
_unit='wg-quick@wg0'
_present=0
ip link show wg0 >/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 <<NFT
#!/usr/sbin/nft -f
# Von CluPilots Bootstrap geschrieben.
# Nicht von Hand ändern — der nächste Lauf überschreibt die Datei.
# Um den Host im Notfall vollständig zu öffnen, von der Anbieterkonsole aus
# ${CLUPILOT_EMERGENCY_SCRIPT} starten.
flush ruleset
table inet clupilot_filter {
chain input {
type filter hook input priority 0; policy drop;
iif "lo" accept
ct state invalid drop
ct state established,related accept
# ICMPv6 ist nicht optional. Neighbour Discovery (135/136) und Router
# Advertisements kommen in der INPUT-Kette an, ein blankes policy drop
# nimmt IPv6 also mit, sobald der Neighbour-Cache abläuft — Minuten, nicht
# Tage. packet-too-big ist die andere Hälfte: daran lernt die Gegenstelle
# einer TLS-Verbindung die Pfad-MTU, und es zu verwerfen lässt große
# Übertragungen ins Leere laufen, statt sie scheitern zu lassen. Eine
# Regel pro Zeile, absichtlich: ein Regelwerk, das man zeilenweise lesen
# kann, kann man auch zeilenweise prüfen.
meta nfproto ipv6 icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, echo-request, echo-reply, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, mld-listener-query, mld-listener-report, mld-listener-done } accept
# IPv4-ICMP, aus demselben Grund: destination-unreachable trägt
# fragmentation-needed (Code 4), worauf die IPv4-Pfad-MTU-Erkennung
# beruht. echo ist dabei, damit der Host auf ein ping antwortet — auch auf
# das des Hubs.
meta nfproto ipv4 icmp type { destination-unreachable, time-exceeded, parameter-problem, echo-request, echo-reply } accept
# Öffentliches Web — Traefik bedient hier den Kundenverkehr.
tcp dport { 80, 443 } accept
# DHCP-Antworten. Manche Anbieter vergeben die IPv4-Adresse per DHCP, und
# ein REBIND kommt als frisches eingehendes Paket an, für das conntrack
# keinen Eintrag hat — verworfen, die Lease läuft ab, und der Host
# verliert die Adresse, über die er erreicht wird. Bewusst eng: nur eine
# Antwort vom Server-Port an den Client-Port.
udp sport 67 udp dport 68 accept
# Verwaltungszugänge: nur über den Tunnel.
ip saddr ${_wg_subnet} tcp dport { 22, 8006 } accept
}
}
NFT
}
render_emergency_script() {
cat <<'SH'
#!/bin/sh
# CluPilots Notöffnung der Firewall.
#
# NUR von der Anbieterkonsole (KVM/seriell) starten, wenn dieser Host über das
# Netz nicht mehr erreichbar ist. Es tut genau eine Sache: nftables abschalten
# und alle Regeln verwerfen, sodass der Host auf jedem Port wieder annimmt.
#
# Bewusst von Hand. Nichts hier öffnet die Firewall selbsttätig, auf einen
# Zeitgeber hin oder weil ein Handshake eine Weile alt aussah — eine Firewall,
# die sich unter Störung selbst öffnet, ist keine Firewall. Die Regeln kommen
# zurück mit:
# nft -f /etc/nftables.conf && systemctl enable --now nftables
set -e
systemctl disable --now nftables 2>/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
}