# shellcheck shell=sh # # Die Brücke über die primäre Netzkarte — Erkennen, Sichern, Zeitgeber, Bauen, # Nachsehen. # # --------------------------------------------------------------------------- # Warum das eine eigene Datei ist # --------------------------------------------------------------------------- # # Diese Zeilen standen in `network.sh`, geschrieben für den stillgelegten # Rettungssystem-Weg, und borgten sich vier Dinge von woanders: # `detect_primary_interface` aus `proxmox.sh`, `log`, `http_get` und # `CLUPILOT_PROBE_URL` aus `clupilot-bootstrap.sh`. Der Debian-Weg lädt sie # einzeln auf den Host und fährt sie dort — geborgte Helfer wären dann nicht da. # # Also allein lauffähig. `network.sh` behält seine WireGuard- und # nftables-Hälfte; diese Datei wird von beiden Wegen benutzt und existiert genau # einmal. Eine zweite Fassung wären zwei Installationen, die bei jeder # Proxmox-Version nachgezogen werden müssten — und die zweite fiele erst auf, # wenn jemand sie benutzt. # # --------------------------------------------------------------------------- # Warum aus dem LAUFENDEN Zustand abgeleitet wird # --------------------------------------------------------------------------- # # Nicht aus `/etc/network/interfaces`. Was läuft, ist bei jedem Anbieter gleich # strukturiert; wie es aufgeschrieben wurde, nicht. Das ist der Teil, der # Hetzner und netcup zugleich trägt. # --------------------------------------------------------------------------- # Stellschrauben — überschreibbar, damit die Bibliothek ohne Host prüfbar ist # --------------------------------------------------------------------------- CLUPILOT_BRIDGE="${CLUPILOT_BRIDGE:-vmbr0}" CLUPILOT_WORK_DIR="${CLUPILOT_WORK_DIR:-/var/lib/clupilot/bridge}" CLUPILOT_INTERFACES_FILE="${CLUPILOT_INTERFACES_FILE:-/etc/network/interfaces}" CLUPILOT_INTERFACES_D="${CLUPILOT_INTERFACES_D:-/etc/network/interfaces.d}" CLUPILOT_SYS_NET="${CLUPILOT_SYS_NET:-/sys/class/net}" CLUPILOT_ETC="${CLUPILOT_ETC:-/etc}" CLUPILOT_IP="${CLUPILOT_IP:-ip}" CLUPILOT_IFRELOAD="${CLUPILOT_IFRELOAD:-ifreload}" CLUPILOT_PROBE_URL="${CLUPILOT_PROBE_URL:-http://deb.debian.org/}" CLUPILOT_ROLLBACK_UNIT="${CLUPILOT_ROLLBACK_UNIT:-clupilot-network-rollback}" CLUPILOT_NET_BACKUP="${CLUPILOT_NET_BACKUP:-/var/lib/clupilot/interfaces.vor-der-bruecke}" CLUPILOT_WG_HUB_PUBKEY="${CLUPILOT_WG_HUB_PUBKEY:-}" CLUPILOT_WG_HANDSHAKE_MAX_AGE="${CLUPILOT_WG_HANDSHAKE_MAX_AGE:-180}" CLUPILOT_WG="${CLUPILOT_WG:-wg}" CLUPILOT_HANDSHAKE_TRIES="${CLUPILOT_HANDSHAKE_TRIES:-15}" CLUPILOT_HANDSHAKE_WAIT="${CLUPILOT_HANDSHAKE_WAIT:-2}" CLUPILOT_SYSTEMCTL="${CLUPILOT_SYSTEMCTL:-systemctl}" CLUPILOT_UNIT_DIR="${CLUPILOT_UNIT_DIR:-/etc/systemd/system}" CLUPILOT_SBIN_DIR="${CLUPILOT_SBIN_DIR:-/usr/local/sbin}" # --------------------------------------------------------------------------- # Die zwei Helfer, die diese Datei früher geborgt hat # --------------------------------------------------------------------------- # # Unter Definitionsschutz: `clupilot-bootstrap.sh` bringt eigene mit, und wer # zuerst da ist, behält recht. Sonst überschriebe das Laden dieser Bibliothek # die Protokollierung des Rettungssystem-Wegs. if ! command -v log >/dev/null 2>&1; then log() { printf '%s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$1" } fi if ! command -v http_get >/dev/null 2>&1; then http_get() { if command -v curl >/dev/null 2>&1; then curl -fsSL --retry 3 --retry-delay 2 --max-time 20 "$1" else wget -qO- --timeout=20 "$1" fi } fi # --------------------------------------------------------------------------- # Feststellen, was da ist # --------------------------------------------------------------------------- # Die Schnittstelle, über die die Vorgabe-Route geht. Nicht „die erste, die # nicht lo heißt": eine Maschine mit zwei Karten hat oft eine angeschlossene und # eine nicht. detect_primary_interface() { "$CLUPILOT_IP" -4 route show default 2>/dev/null \ | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }' } # Ist das eine physische Karte? # # `bridge_ports` auf einem Bond oder einer bestehenden Bridge ist falsch — die # Brücke nähme sich ihren eigenen Unterbau als Port, und was dabei herauskommt, # ist von hier aus nicht mehr zu reparieren. VLAN-Geräte und Bonds haben keinen # `device`-Verweis; die beiden weiteren Prüfungen sind der Gürtel dazu. interface_is_physical() { _if="${1:-}" [ -n "$_if" ] || return 1 [ -e "${CLUPILOT_SYS_NET}/${_if}/device" ] || return 1 [ ! -d "${CLUPILOT_SYS_NET}/${_if}/bridge" ] || return 1 [ ! -d "${CLUPILOT_SYS_NET}/${_if}/bonding" ] || return 1 return 0 } # Kam die Adresse per DHCP? # # Aus dem laufenden Zustand: der Kernel markiert eine geleaste Adresse mit # `dynamic`. Das steht bei jedem Anbieter gleich da — anders als die Datei, in # die er es geschrieben hat. address_is_dynamic() { "$CLUPILOT_IP" -4 -o addr show dev "$1" scope global 2>/dev/null \ | grep -q '[[:space:]]dynamic[[:space:]]' } # 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)" # Laufender Zustand zuerst. Die Datei des Anbieters ist nur das Zweitsignal # — sie sagt, was jemand aufgeschrieben hat, nicht was gilt. if address_is_dynamic "$_iface"; then printf 'dhcp' return 0 fi if grep -qsE "iface[[:space:]]+${_iface}[[:space:]]+inet[[:space:]]+dhcp" \ "$CLUPILOT_INTERFACES_FILE" "${CLUPILOT_INTERFACES_D}"/* 2>/dev/null; then printf 'dhcp' return 0 fi _cidr="$("$CLUPILOT_IP" -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')" _gw="$("$CLUPILOT_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" ] && "$CLUPILOT_IP" -4 route get "$_gw" 2>/dev/null | grep -q "dev ${_iface}.*src"; then printf 'subnet' return 0 fi printf 'routed' } bridge_exists() { "$CLUPILOT_IP" link show "${1:-$CLUPILOT_BRIDGE}" >/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() { _want="${1:-$CLUPILOT_BRIDGE}" _dev="$("$CLUPILOT_IP" -4 route show default 2>/dev/null \ | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }')" [ "$_dev" = "$_want" ] } bridge_has_address() { [ -n "$("$CLUPILOT_IP" -4 -o addr show dev "${1:-$CLUPILOT_BRIDGE}" scope global 2>/dev/null | awk '{ print $4; exit }')" ] } # Die drei zusammen. „Da" reicht nicht — sie muss tragen. bridge_is_up() { bridge_exists "$CLUPILOT_BRIDGE" \ && bridge_carries_default_route "$CLUPILOT_BRIDGE" \ && bridge_has_address "$CLUPILOT_BRIDGE" } # --------------------------------------------------------------------------- # Die Strophe # --------------------------------------------------------------------------- # Zusatzrouten des Anbieters — alles auf der Karte, was der Kernel nicht von # selbst wieder anlegt. # # Ausgelassen werden zwei Sorten, und beide mit Grund: die Kernel-Route zum # eigenen Subnetz (`proto kernel`) entsteht, sobald die Adresse steht, und die # Link-Route zum Gateway legt `pointopoint` an. Sie trotzdem einzutragen ließe # `ip route add` scheitern — und ein gescheitertes `up` nimmt bei `ifreload` # die ganze Strophe mit. extra_routes() { _iface="$1" _gw="$2" "$CLUPILOT_IP" -4 route show dev "$_iface" 2>/dev/null | while IFS= read -r _route; do [ -n "$_route" ] || continue case "$_route" in default*) continue ;; "${_gw} "*|"${_gw}") continue ;; *'proto kernel'*) continue ;; esac printf ' up ip route add %s dev %s || true\n' "$_route" "$CLUPILOT_BRIDGE" done } # Eine statische, globale IPv6-Adresse — oder nichts. # # `dynamic` (SLAAC/DHCPv6) und `temporary` (Privacy Extensions) fliegen raus: # was der Kernel selbst vergibt, gehört nicht in eine Datei geschrieben. static_ipv6_on() { "$CLUPILOT_IP" -6 -o addr show dev "$1" scope global 2>/dev/null \ | grep -v '[[:space:]]dynamic[[:space:]]' \ | grep -v '[[:space:]]temporary[[:space:]]' \ | awk '{ print $4; exit }' } ipv6_gateway() { "$CLUPILOT_IP" -6 route show default 2>/dev/null \ | awk '{ for (i = 1; i < NF; i++) if ($i == "via") { print $(i+1); exit } }' } # Schreibt die Strophe — und sonst nichts. Kein `ifreload`, kein Sichern, kein # Zeitgeber. Getrennt von build_bridge, damit genau das prüfbar ist, was eine # Maschine umbringt, ohne dafür ein Netz neu laden zu müssen. write_bridge_stanza() { _iface="$1" _style="$2" _cidr="$3" _gw="$4" _ip4="${_cidr%%/*}" _mac="$(cat "${CLUPILOT_SYS_NET}/${_iface}/address" 2>/dev/null)" case "$_style" in dhcp) _inet="iface ${CLUPILOT_BRIDGE} 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 ${CLUPILOT_BRIDGE} inet static" _addr=" address ${_ip4}/32 pointopoint ${_gw} gateway ${_gw}" ;; *) _inet="iface ${CLUPILOT_BRIDGE} inet static" _addr=" address ${_cidr} gateway ${_gw}" ;; esac # Festgenagelt: eine Brücke wählt sonst die kleinste MAC ihrer Ports, und # manche Anbieter binden den Switchport an die der Karte. Bei einem Port ist # das dieselbe — aber „ist dieselbe" und „bleibt dieselbe" sind zweierlei. _hw='' [ -n "$_mac" ] && _hw=" hwaddress ether ${_mac}" _inet6='' _v6="$(static_ipv6_on "$_iface")" _v6gw="$(ipv6_gateway)" if [ -n "$_v6" ] && [ -n "$_v6gw" ]; then _inet6=" iface ${CLUPILOT_BRIDGE} inet6 static address ${_v6} gateway ${_v6gw}" elif [ -n "$_v6" ] || [ -n "$_v6gw" ]; then log 'IPv6 wird NICHT mit auf die Brücke genommen: keine statische globale Adresse mit Standardgateway (SLAAC/DHCPv6 werden bewusst nicht nachgebaut — Proxmox setzt forwarding=1, und der Kernel verwirft RAs dann ohne accept_ra=2).' fi cat > "$CLUPILOT_INTERFACES_FILE" </dev/null)" ]; then printf 'networkd' return 0 fi if [ -d "${CLUPILOT_ETC}/NetworkManager/system-connections" ] \ && [ -n "$(ls -A "${CLUPILOT_ETC}/NetworkManager/system-connections" 2>/dev/null)" ]; then printf 'network-manager' return 0 fi printf '' } # Entmachtet den erkannten Verwalter — im gesicherten Stand, also unter dem # Zeitgeber. Was hier schiefgeht, holt er zurück. disown_network_manager() { case "${1:-}" in cloud-init) mkdir -p "${CLUPILOT_ETC}/cloud/cloud.cfg.d" printf 'network: {config: disabled}\n' \ > "${CLUPILOT_ETC}/cloud/cloud.cfg.d/99-clupilot-disable-network.cfg" log 'cloud-init: Netzteil abgeschaltet' ;; networkd) "$CLUPILOT_SYSTEMCTL" disable --now systemd-networkd.socket >/dev/null 2>&1 || true "$CLUPILOT_SYSTEMCTL" mask systemd-networkd >/dev/null 2>&1 || true log 'systemd-networkd maskiert' ;; network-manager) "$CLUPILOT_SYSTEMCTL" disable --now NetworkManager >/dev/null 2>&1 || true "$CLUPILOT_SYSTEMCTL" mask NetworkManager >/dev/null 2>&1 || true log 'NetworkManager maskiert' ;; '') ;; esac # Der kleinere Verwandte: die Strophe behält `source interfaces.d/*`. Bleibt # dort eine Datei liegen, die dieselbe Karte beansprucht, hat der Host zwei # Stellen, die seine Adresse vergeben. _iface="${2:-}" if [ -n "$_iface" ] && [ -d "$CLUPILOT_INTERFACES_D" ]; then for _f in "$CLUPILOT_INTERFACES_D"/*; do [ -f "$_f" ] || continue case "$_f" in *.von-clupilot-beiseitegelegt) continue ;; esac if grep -qE "iface[[:space:]]+${_iface}[[:space:]]" "$_f" 2>/dev/null; then mv "$_f" "${_f}.von-clupilot-beiseitegelegt" log "Kollidierende Strophe beiseitegelegt: ${_f}" fi done fi } # --------------------------------------------------------------------------- # Nachsehen — beide Richtungen # --------------------------------------------------------------------------- # # Kein `ping`. Hetzners `Debian-trixie-…-base` bringt kein `iputils-ping` mit, # und `PrepareBaseSystem` installiert es nicht (es holt curl, gnupg, ifupdown2, # chrony). Eine Prüfung damit sagte IMMER „nicht erreichbar", der Zeitgeber # spielte IMMER zurück, und der Schritt käme nie durch. Dieselbe Falle hatte # `ConfigureWireguard` schon einmal, und sie kostete eine Übernahme. internet_reachable() { http_get "$CLUPILOT_PROBE_URL" >/dev/null 2>&1 } # Steht der Tunnel? # # Wortgleich die Prüfung aus v1.3.85: frischer Handshake vom KONFIGURIERTEN Hub. # Nicht „irgendein Peer hat gehandshaked" — auf demselben Hub liegen auch # Mitarbeiter-Zugänge, und deren Handshake beweist nichts über CluPilots Weg # hierher. # # `0` ist der Wert, den WireGuard für einen Peer meldet, mit dem noch nie # gesprochen wurde. Vorhanden ist er trotzdem — deshalb die eigene Prüfung # darauf, statt ihn als Sekundenzahl von 1970 durchgehen zu lassen. tunnel_handshake_fresh() { [ -n "$CLUPILOT_WG_HUB_PUBKEY" ] || return 1 _now="$(date +%s)" _hs="$("$CLUPILOT_WG" show wg0 latest-handshakes 2>/dev/null \ | awk -v k="$CLUPILOT_WG_HUB_PUBKEY" '$1 == k { print $2; exit }')" [ -n "$_hs" ] || return 1 [ "$_hs" -gt 0 ] 2>/dev/null || return 1 [ "$(( _now - _hs ))" -lt "$CLUPILOT_WG_HANDSHAKE_MAX_AGE" ] } # Die Bedingung zum Abbestellen. # # Nur „komme ich raus" zu prüfen ist notwendig und NICHT hinreichend. Der # Fehlerfall: `ifreload -a` bringt die Brücke sauber hoch, die Maschine erreicht # das Internet, der Treiber wäre zufrieden — aber wg0 kommt nicht zurück. # Ergebnis: der Host lebt öffentlich, CluPilot ist ausgesperrt, und die # Rückfahrkarte wurde gerade weggeworfen. Von Hand reparierbar, und genau das # soll dieser Schritt ja verhindern. # # `wg0.conf` enthält KEINE Gerätebindung (siehe # ConfigureWireguard::renderConfig) — der Tunnel hängt also nicht wörtlich an # der alten Karte, sondern an der Quelladresse, die die Routing-Tabelle hergibt. # Und genau das ist die Größe, die der Umbau anfasst. Das macht den Fall nicht # unwahrscheinlicher, nur unauffälliger: kein Fehler im Protokoll, nur ein # Handshake, der ausbleibt. bridge_proven() { if ! bridge_is_up; then log 'Die Brücke trägt die Standardroute oder die Adresse nicht' return 1 fi if ! internet_reachable; then log "Kein Weg nach draußen (${CLUPILOT_PROBE_URL})" return 1 fi if tunnel_handshake_fresh; then return 0 fi # Einmal nachhelfen. Gefahrlos: die SSH-Sitzung ist zu diesem Zeitpunkt # ohnehin weg, `ifreload` hat sie mitgenommen. Ein hängender Tunnel wird so # zu einem laufenden statt zu einer Rücknahme. log 'Handshake schal — wg-quick@wg0 einmal neu starten' "$CLUPILOT_SYSTEMCTL" restart wg-quick@wg0 >/dev/null 2>&1 || true _i=0 while [ "$_i" -lt "$CLUPILOT_HANDSHAKE_TRIES" ]; do if tunnel_handshake_fresh; then log 'Tunnel steht nach dem Neustart von wg0' return 0 fi [ "$CLUPILOT_HANDSHAKE_WAIT" -gt 0 ] && sleep "$CLUPILOT_HANDSHAKE_WAIT" _i=$(( _i + 1 )) done log 'Tunnel steht auch nach dem Neustart von wg0 nicht — CluPilot käme nicht zurück' return 1 } # --------------------------------------------------------------------------- # Die Selbstrücknahme # --------------------------------------------------------------------------- # # 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. # # Abbestellt wird er NICHT vom Treiber. Das tut CluPilot, nachdem es sich über # den Tunnel neu verbunden und nachgesehen hat — ein Skript auf dem Host kann # über seine eigene Erreichbarkeit von außen nur raten. backup_network_config() { mkdir -p "$(dirname -- "$CLUPILOT_NET_BACKUP")" tar czf "${CLUPILOT_NET_BACKUP}.tar.gz" \ -C / "${CLUPILOT_INTERFACES_FILE#/}" \ $( [ -d "$CLUPILOT_INTERFACES_D" ] && printf '%s' "${CLUPILOT_INTERFACES_D#/}" ) \ 2>/dev/null } # Der Inhalt des Rücknahme-Skripts — als Text, nicht als Datei. # # Getrennt, damit die Reihenfolge darin prüfbar ist, ohne systemd zu brauchen. # Und die Reihenfolge ist der Punkt: das Urteil wird geschrieben, BEVOR # zurückgespielt wird, damit auch ein halb geglücktes Zurückspielen einen Grund # hinterlässt. `rolled-back` kommt zuletzt — das ist das Signal, auf das CluPilot # wartet, und es darf erst stehen, wenn der alte Zustand wirklich zurück ist. render_rollback_script() { _minutes="${1:-5}" cat < '${CLUPILOT_WORK_DIR}/state' printf 'Die Bruecke nahm den Host vom Netz; die Netzkonfiguration von vorher wurde zurueckgespielt.' > '${CLUPILOT_WORK_DIR}/note' 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 zurueckgespielt: die Bruecke hat den Host vom Netz genommen.' # Erst jetzt. Vorher hiesse es: zurueckgespielt, obwohl es noch laeuft. : > '${CLUPILOT_WORK_DIR}/rolled-back' # Selbst wegraeumen, sonst liegen Unit-Dateien herum, die aussehen, als stuende # noch eine Ruecknahme aus. rm -f '${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.timer' \\ '${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.service' systemctl daemon-reload 2>/dev/null || true EOF } # Stellt den Zeitgeber. Ab hier gibt es einen Weg zurück, und erst ab hier darf # irgendetwas am Netz verändert werden. schedule_network_rollback() { _minutes="${1:-5}" mkdir -p "$CLUPILOT_SBIN_DIR" "$CLUPILOT_UNIT_DIR" "$CLUPILOT_WORK_DIR" render_rollback_script "$_minutes" > "${CLUPILOT_SBIN_DIR}/${CLUPILOT_ROLLBACK_UNIT}.sh" chmod 700 "${CLUPILOT_SBIN_DIR}/${CLUPILOT_ROLLBACK_UNIT}.sh" cat > "${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.service" < "${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.timer" </dev/null || true rm -f "${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.timer" \ "${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.service" \ "${CLUPILOT_SBIN_DIR}/${CLUPILOT_ROLLBACK_UNIT}.sh" "$CLUPILOT_SYSTEMCTL" daemon-reload 2>/dev/null || true log 'Selbstrücknahme abbestellt' } # Strophe schreiben und anwenden. Ab hier ist die Leitung in Gefahr. build_bridge() { write_bridge_stanza "$@" log 'Strophe geschrieben:' cat "$CLUPILOT_INTERFACES_FILE" if command -v "$CLUPILOT_IFRELOAD" >/dev/null 2>&1; then "$CLUPILOT_IFRELOAD" -a else systemctl restart networking fi }