CluPilotCloud/deploy/bootstrap/lib/network.sh

425 lines
16 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.
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
}
# ---------------------------------------------------------------------------
# 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 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
}