# 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.
# Die EINE Standardroute, aus der alles andere abgeleitet wird.
#
# `head -1`, und alle drei Leser hängen daran. Ein Host kann mehrere haben, und
# wer Karte und Gateway aus verschiedenen Zeilen zieht, baut eine Brücke über die
# Karte der einen mit dem Gateway der anderen — ein Netz, das nirgends hinführt.
primary_default_route() {
    "$CLUPILOT_IP" -4 route show default 2>/dev/null | head -1
}

detect_primary_interface() {
    primary_default_route \
        | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }'
}

# Das Gateway dieser Route — hinter dem `via`, nie an fester Feldnummer.
#
# `default dev ens3 scope link` hat keins. Ein `awk '{ print $3 }'` lieferte dort
# den KARTENNAMEN, und daraus würde `gateway ens3` in der Strophe: `ifreload`
# scheitert, der Zeitgeber räumt ab — und abgeräumt wird ein Host, der zu bauen
# gewesen wäre. Leer heißt hier ehrlich leer.
default_gateway() {
    primary_default_route \
        | awk '{ for (i = 1; i < NF; i++) if ($i == "via") { print $(i+1); exit } }'
}

# Gibt es überhaupt eine Standardroute? Ohne `via` ist sie eine Link-Route, und
# die muss die Brücke als eigene `up`-Zeile mitnehmen, sonst verliert der Host
# seinen Weg nach draußen.
has_default_route() {
    primary_default_route | grep -q .
}

# 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="$(default_gateway)"
    _prefix="${_cidr##*/}"

    # Kein Gateway: dann gibt es weder eins im eigenen Subnetz noch eines
    # außerhalb, und `routed` zu melden hieße `pointopoint` auf nichts.
    if [ -z "$_gw" ]; then
        printf 'subnet'
        return 0
    fi

    # /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)"

    # Ohne Gateway gibt es kein pointopoint-Ziel. Nach detect_network_style kann
    # das nicht mehr vorkommen — write_bridge_stanza wird aber auch direkt
    # aufgerufen, und `pointopoint` auf einen leeren Wert ist eine Strophe, die
    # `ifreload` ablehnt.
    if [ -z "$_gw" ] && [ "$_style" = 'routed' ]; then
        _style='subnet'
    fi

    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}"
            if [ -n "$_gw" ]; then
                _addr="${_addr}
    gateway ${_gw}"
            elif has_default_route; then
                # Standardroute ohne `via` — eine Link-Route. Sie hat kein
                # Gateway, das in eine `gateway`-Zeile passte, und ginge sonst
                # beim Umbau verloren.
                _addr="${_addr}
    up ip route add default dev ${CLUPILOT_BRIDGE} || true"
            fi
            ;;
    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" <<EOF
# Von CluPilot geschrieben (Schritt EnsureNetworkBridge), weil auf dieser
# Maschine keine ${CLUPILOT_BRIDGE} lag: Proxmox auf Debian legt keine an, nur
# der ISO-Installer tut das.
#
# Abgeleitet aus dem LAUFENDEN Zustand, nicht aus der Datei des Anbieters.
# Anbieterform: ${_style}. Karte: ${_iface}.
#
# Die vorherige Fassung liegt unter ${CLUPILOT_NET_BACKUP}.tar.gz.
source ${CLUPILOT_INTERFACES_D}/*

auto lo
iface lo inet loopback

iface ${_iface} inet manual

auto ${CLUPILOT_BRIDGE}
${_inet}
${_addr}
${_hw}
    bridge-ports ${_iface}
    bridge-stp off
    bridge-fd 0
$(extra_routes "$_iface" "$_gw")${_inet6}
EOF
}

# ---------------------------------------------------------------------------
# Fremdverwalter
# ---------------------------------------------------------------------------
#
# Aus dem laufenden Zustand abzuleiten löst das LESEN. Es löst nicht das
# SCHREIBEN: führt cloud-init das Netz, ist `/etc/network/interfaces` nicht die
# Stelle, an der die Wahrheit steht. Die Brücke griffe entweder sofort nicht
# oder — schlimmer — sie griffe jetzt und würde beim nächsten Neustart wieder
# eingesammelt. Dann läuft die Übernahme grün durch und der Host verliert seine
# Brücke Monate später, mit Kunden darauf.
#
# Der Zeitgeber fängt diesen Fall NICHT ab: zu seiner Zeit war alles in Ordnung.
# Deshalb steht das hier.

# Wer führt das Netz? Leer heißt: reines ifupdown, und der Weg ist frei.
foreign_network_manager() {
    if [ -f "${CLUPILOT_ETC}/cloud/cloud.cfg" ] || [ -d "${CLUPILOT_ETC}/cloud/cloud.cfg.d" ]; then
        printf 'cloud-init'
        return 0
    fi

    if [ -d "${CLUPILOT_ETC}/systemd/network" ] \
        && [ -n "$(ls -A "${CLUPILOT_ETC}/systemd/network" 2>/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.
#
# Und hinterlässt, WAS entmachtet wurde. Das ist die Hälfte, die zuerst fehlte:
# die Sicherung umfasst nur `/etc/network/interfaces*`, also könnte die Rücknahme
# einen abgeschalteten cloud-init nicht wieder einschalten. Auf einer Maschine,
# deren Verbindung von cloud-init kam, spielte sie damit eine Datei zurück, die
# die Maschine nie getragen hat — und ließe den Verwalter, der es tat,
# abgeschaltet. Genau der tote Host, den der Zeitgeber verhindern soll.
#
# Aufgeschrieben statt ins Rücknahme-Skript gebacken, weil der Zeitgeber VOR
# dieser Funktion gestellt wird. Das Skript liest die Notiz erst, wenn es feuert.
disown_network_manager() {
    mkdir -p "$CLUPILOT_WORK_DIR"
    printf '%s' "${1:-}" > "${CLUPILOT_WORK_DIR}/disowned"
    : > "${CLUPILOT_WORK_DIR}/moved-aside"

    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.
    #
    # HERAUS aus dem Verzeichnis, nicht bloß umbenannt. Der Stern in
    # `source .../interfaces.d/*` fasst auch eine umbenannte Datei — Umbenennen
    # allein löst die Kollision also nicht, es versteckt sie nur vor dem Leser.
    _iface="${2:-}"
    _aside="${CLUPILOT_WORK_DIR}/verdraengt"

    if [ -n "$_iface" ] && [ -d "$CLUPILOT_INTERFACES_D" ]; then
        for _f in "$CLUPILOT_INTERFACES_D"/*; do
            [ -f "$_f" ] || continue
            if grep -qE "iface[[:space:]]+${_iface}[[:space:]]" "$_f" 2>/dev/null; then
                mkdir -p "$_aside"
                mv "$_f" "${_aside}/$(basename "$_f")" || continue
                printf '%s\n' "$_f" >> "${CLUPILOT_WORK_DIR}/moved-aside"
                log "Kollidierende Strophe aus dem Quellverzeichnis genommen: ${_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 <<EOF
#!/bin/sh
# Von CluPilot gestellt (Schritt EnsureNetworkBridge). Spielt die
# Netzkonfiguration von VOR der Bruecke zurueck, weil sie binnen ${_minutes}
# Minuten nicht abbestellt wurde — und abbestellen kann CluPilot nur, wenn es
# den Host ueber den Tunnel wieder erreicht.
# Das Urteil zuerst, damit auch ein halb gegluecktes Zurueckspielen einen Grund
# hinterlaesst.
printf 'failed' > '${CLUPILOT_WORK_DIR}/state'
printf 'Die Bruecke nahm den Host vom Netz; die Netzkonfiguration von vorher wurde zurueckgespielt.' > '${CLUPILOT_WORK_DIR}/note'

# Zuerst den entmachteten Netzverwalter wieder einschalten. Die Sicherung
# umfasst nur /etc/network/interfaces* — kam die Verbindung dieser Maschine von
# cloud-init, networkd oder NetworkManager, hilft das Zurueckspielen allein
# nichts, solange der Verwalter abgeschaltet bleibt.
_m="\$(cat '${CLUPILOT_WORK_DIR}/disowned' 2>/dev/null)"
case "\$_m" in
    cloud-init)
        rm -f '${CLUPILOT_ETC}/cloud/cloud.cfg.d/99-clupilot-disable-network.cfg'
        ;;
    networkd)
        systemctl unmask systemd-networkd 2>/dev/null || true
        systemctl enable --now systemd-networkd.socket systemd-networkd 2>/dev/null || true
        ;;
    network-manager)
        systemctl unmask NetworkManager 2>/dev/null || true
        systemctl enable --now NetworkManager 2>/dev/null || true
        ;;
esac

# Verdraengte Strophen zurueck an ihren Platz.
if [ -f '${CLUPILOT_WORK_DIR}/moved-aside' ]; then
    while IFS= read -r _orig; do
        [ -n "\$_orig" ] || continue
        _base="\$(basename "\$_orig")"
        if [ -f '${CLUPILOT_WORK_DIR}/verdraengt/'"\$_base" ]; then
            mv '${CLUPILOT_WORK_DIR}/verdraengt/'"\$_base" "\$_orig"
        fi
    done < '${CLUPILOT_WORK_DIR}/moved-aside'
fi

# Ab hier zaehlt jeder Fehlschlag. Ohne diese Pruefungen liefe das Skript weiter
# und setzte die Marke, obwohl der Host auf einer kaputten Konfiguration steht —
# und CluPilot meldete 'der alte Zustand ist zurueck' und gaebe auf.
tar xzf '${CLUPILOT_NET_BACKUP}.tar.gz' -C / || exit 1

if command -v ifreload >/dev/null 2>&1; then
    ifreload -a || exit 1
else
    systemctl restart networking || exit 1
fi

logger -t clupilot 'Netzkonfiguration zurueckgespielt: die Bruecke hat den Host vom Netz genommen.'

# Erst jetzt, und nur nach einem geglueckten Zurueckspielen. Die Marke ist das
# Signal, auf das CluPilot wartet, um aufzugeben — sie darf nie 'zurueckgespielt'
# sagen, wenn es nicht stimmt.
: > '${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" <<EOF
[Unit]
Description=CluPilot: Netzkonfiguration zurückspielen, wenn die Brücke den Host vom Netz nimmt

[Service]
Type=oneshot
ExecStart=${CLUPILOT_SBIN_DIR}/${CLUPILOT_ROLLBACK_UNIT}.sh
EOF

    cat > "${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.timer" <<EOF
[Unit]
Description=CluPilot: Frist für die Selbstrücknahme der Netzumstellung

[Timer]
OnActiveSec=${_minutes}min
AccuracySec=1s
EOF

    "$CLUPILOT_SYSTEMCTL" daemon-reload
    "$CLUPILOT_SYSTEMCTL" start "${CLUPILOT_ROLLBACK_UNIT}.timer"
    log "Selbstrücknahme steht: ohne Abbestellung läuft sie in ${_minutes} Minuten"
}

cancel_network_rollback() {
    "$CLUPILOT_SYSTEMCTL" stop "${CLUPILOT_ROLLBACK_UNIT}.timer" 2>/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
}
