# 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
}
