306 lines
12 KiB
Bash
306 lines
12 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
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
}
|