#!/bin/bash
# Richtet einen Ubuntu- oder Debian-Server als HAProxy-Knoten für ZPO ein.
#
# Installiert HAProxy und die HAProxy Data Plane API, legt die
# Grundkonfiguration aus doc/haproxy-grundkonfiguration.md an und gibt am
# Ende die Zugangsdaten aus, die in ZPO einzutragen sind.
#
# Der schnelle Weg — direkt aus dem Netz, ohne vorher etwas abzulegen:
#
#   curl -fsSL https://<server>/zpo/setup-haproxy.sh | sudo bash
#
# Mit Optionen dahinter:
#
#   curl -fsSL https://<server>/zpo/setup-haproxy.sh | sudo bash -s -- --name proxy-nord
#
# Oder klassisch, wenn die Datei schon da liegt:
#
#   sudo ./setup-haproxy.sh --name <kennung>
#
#   --name <kennung>     Kennung des Knotens, wird auch der Hostname
#                        (Vorgabe: der Hostname, den die Maschine schon hat)
#   --api-bind <ip>      Adresse, auf der die Data Plane API lauscht
#                        (Vorgabe: die erste eigene IP — nicht 0.0.0.0,
#                        die Verwaltungs-Schnittstelle gehört nicht ins
#                        offene Netz)
#   --api-port <port>    Vorgabe 5555
#   --api-user <name>    Vorgabe zpo
#   --help               diese Hilfe
#
# Das Skript ist wiederholbar: Jeder Schritt prüft erst den Ist-Zustand.
# Ein zweiter Lauf erzeugt kein neues Passwort, sondern lässt das
# vorhandene stehen.
set -euo pipefail
# Keine Rückfragen von apt: Das Skript läuft oft aus einer Pipe, da ist
# niemand da, der antworten könnte.
export DEBIAN_FRONTEND=noninteractive

# usage steht als eigene Funktion da und liest nicht die eigene Datei:
# Aus einer Pipe gestartet gibt es keine Datei, die sich lesen ließe.
usage() {
    cat <<'USAGE'
setup-haproxy.sh — richtet einen Server als HAProxy-Knoten für ZPO ein.

  curl -fsSL https://<server>/zpo/setup-haproxy.sh | sudo bash
  curl -fsSL https://<server>/zpo/setup-haproxy.sh | sudo bash -s -- --name proxy-nord
  sudo ./setup-haproxy.sh --name proxy-nord

Optionen:
  --name <kennung>   Kennung des Knotens, wird auch der Hostname
                     (Vorgabe: der Hostname, den die Maschine schon hat)
  --api-bind <ip>    Adresse, auf der die Verwaltungs-Schnittstelle lauscht
                     (Vorgabe: die erste eigene IP, nicht 0.0.0.0)
  --api-port <port>  Vorgabe 5555
  --api-user <name>  Vorgabe zpo
  --help             diese Hilfe

Der Lauf ist wiederholbar: Ein zweiter Durchgang erzeugt kein neues
Passwort, sondern lässt das vorhandene stehen.
USAGE
}

NAME=""
API_BIND=""
API_PORT=5555
API_USER=zpo
DPAPI_VERSION=3.4.3

while [ $# -gt 0 ]; do
    case "$1" in
        --name)     NAME="${2:-}"; shift 2 ;;
        --api-bind) API_BIND="${2:-}"; shift 2 ;;
        --api-port) API_PORT="${2:-}"; shift 2 ;;
        --api-user) API_USER="${2:-}"; shift 2 ;;
        --help|-h)  usage; exit 0 ;;
        *) echo "Unbekannte Option: $1" >&2; exit 1 ;;
    esac
done
# Ohne --name bleibt der Rechner, wie er heißt. Das macht den Aufruf aus
# der Pipe argumentlos möglich, und wer den Namen setzen will, kann.
[ -n "$NAME" ] || NAME="$(hostname -s)"
if [ "$(id -u)" != 0 ]; then
    echo "Bitte als root ausführen — etwa so:" >&2
    echo "  curl -fsSL <adresse> | sudo bash" >&2
    exit 1
fi
[ -n "$API_BIND" ] || API_BIND="$(hostname -I | awk '{print $1}')"

CREDENTIALS_FILE=/root/haproxy-credentials
step() { printf '\n== %s\n' "$1"; }

step "Hostname $NAME"
if [ "$(hostname)" != "$NAME" ]; then
    hostnamectl set-hostname "$NAME"
    # Ohne Eintrag in /etc/hosts braucht jedes sudo eine DNS-Zeitüberschreitung.
    grep -q "127.0.1.1[[:space:]]\+$NAME" /etc/hosts || \
        sed -i "s/^127\.0\.1\.1.*/127.0.1.1\t$NAME/" /etc/hosts
    grep -q "^127.0.1.1" /etc/hosts || echo -e "127.0.1.1\t$NAME" >> /etc/hosts
    echo "gesetzt"
else
    echo "steht schon"
fi

step "Pakete"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq haproxy openssl curl >/dev/null
echo "$(haproxy -v | head -1)"

step "Data Plane API $DPAPI_VERSION"
if ! command -v dataplaneapi >/dev/null || \
   ! dataplaneapi --version 2>&1 | grep -q "$DPAPI_VERSION"; then
    paket="/tmp/dataplaneapi_${DPAPI_VERSION}_linux_amd64.deb"
    curl -fsSL -o "$paket" \
        "https://github.com/haproxytech/dataplaneapi/releases/download/v${DPAPI_VERSION}/dataplaneapi_${DPAPI_VERSION}_linux_amd64.deb"
    dpkg -i "$paket" >/dev/null
    rm -f "$paket"
    echo "installiert"
else
    echo "steht schon"
fi

step "Verzeichnisse"
install -d -m 0755 -o haproxy -g haproxy /etc/haproxy/maps /etc/haproxy/certs
install -d -m 0750 /var/lib/dataplaneapi /var/lib/dataplaneapi/transactions
# Die Maps brauchen mindestens eine Zeile: HAProxy laedt eine leere
# Map-Datei zwar, registriert sie aber NICHT in der Runtime — und was
# dort nicht steht, kann ZPO auch nicht beschreiben. Der Platzhalter
# zeigt auf das Auffang-Backend und benutzt eine .invalid-Domain, die es
# per RFC 2606 nie geben kann.
mkdir -p /etc/haproxy/general
chown haproxy:haproxy /etc/haproxy/general 2>/dev/null || true
# Platzhalter aelterer Einrichtungen tragen noch deutsche Namen; sie
# zeigen sonst auf ein Backend, das es nicht mehr gibt.
sed -i 's/^zpo-platzhalter\.invalid zpo_unbekannt$/zpo-placeholder.invalid zpo_unknown/' /etc/haproxy/maps/zpo_hosts.map 2>/dev/null || true
if ! grep -q . /etc/haproxy/maps/zpo_hosts.map 2>/dev/null; then
    echo "zpo-placeholder.invalid zpo_unknown" > /etc/haproxy/maps/zpo_hosts.map
fi
# Zugangslisten, Zugriffsregeln und Basic-Auth-Zugaenge laufen ueber
# drei weitere Maps, die dieselbe Henne-und-Ei-Falle haben.
# 203.0.113.0/24 ist TEST-NET-3 nach RFC 5737 — nie vergeben; ein Limit
# von 0 heisst: aus.
if ! grep -q . /etc/haproxy/maps/zpo_ipgroups.map 2>/dev/null; then
    echo "203.0.113.0/24 zpo_placeholder" > /etc/haproxy/maps/zpo_ipgroups.map
fi
if ! grep -q . /etc/haproxy/maps/zpo_ratelimits.map 2>/dev/null; then
    echo "zpo-placeholder.invalid 0/zpo_placeholder/" > /etc/haproxy/maps/zpo_ratelimits.map
fi
if ! grep -q . /etc/haproxy/maps/zpo_auth.map 2>/dev/null; then
    echo "zpo_placeholder:none 0" > /etc/haproxy/maps/zpo_auth.map
fi
# HTTP-01-Herausforderungen (Let's Encrypt): Token -> Antwort, nur
# solange eine Bestellung laeuft.
if ! grep -q . /etc/haproxy/maps/zpo_acme.map 2>/dev/null; then
    echo "zpo-placeholder-token none" > /etc/haproxy/maps/zpo_acme.map
fi
# Fehlerseiten: "<fehlerprofil>:<fall>" -> Fehler-Backend. Bis ZPO die
# Eintraege setzt, antwortet das Auffang-Backend zpo_err_fallback.
if ! grep -q . /etc/haproxy/maps/zpo_errors.map 2>/dev/null; then
    echo "zpo-placeholder:none zpo_err_fallback" > /etc/haproxy/maps/zpo_errors.map
fi
# Die 401-Seite laedt das Frontend als Datei; ohne sie startet HAProxy
# nicht. ZPO ersetzt sie durch die Seite des Standardprofils.
if [ ! -s /etc/haproxy/general/zpo_err_unauthorized.http ]; then
    printf 'HTTP/1.1 401 Unauthorized\r\nContent-Type: text/plain; charset=utf-8\r\nCache-Control: no-store\r\nConnection: close\r\nContent-Length: 23\r\n\r\nAnmeldung erforderlich.' \
        > /etc/haproxy/general/zpo_err_unauthorized.http
fi
chown haproxy:haproxy /etc/haproxy/maps/zpo_*.map
echo "maps/, certs/ und die Map-Dateien liegen bereit"

step "Platzhalter-Zertifikat"
# Ohne mindestens ein Zertifikat startet HAProxy mit "bind ... ssl crt"
# nicht. ZPO ersetzt es ab M5 durch die echten; bis dahin braucht der
# Knoten etwas, womit er hochkommt.
# Aeltere Einrichtungen nannten die Datei noch deutsch.
if [ -f /etc/haproxy/certs/000-platzhalter.pem ]; then
    mv /etc/haproxy/certs/000-platzhalter.pem /etc/haproxy/certs/000-placeholder.pem
fi
if [ ! -s /etc/haproxy/certs/000-placeholder.pem ]; then
    tmp=$(mktemp -d)
    openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
        -subj "/CN=zpo-placeholder" \
        -keyout "$tmp/k.pem" -out "$tmp/c.pem" >/dev/null 2>&1
    cat "$tmp/c.pem" "$tmp/k.pem" > /etc/haproxy/certs/000-placeholder.pem
    chown haproxy:haproxy /etc/haproxy/certs/000-placeholder.pem
    chmod 0640 /etc/haproxy/certs/000-placeholder.pem
    rm -rf "$tmp"
    echo "erzeugt"
else
    echo "liegt schon da"
fi

step "haproxy.cfg"
if [ -f /etc/haproxy/haproxy.cfg ]; then
    backup="/etc/haproxy/haproxy.cfg.bak-$(date +%Y%m%d-%H%M%S)"
    cp -a /etc/haproxy/haproxy.cfg "$backup"
    echo "vorhandene Konfiguration gesichert: $backup"
fi
# Erst in eine Nebendatei schreiben und pruefen, dann erst
# uebernehmen: Eine ungueltige haproxy.cfg an Ort und Stelle waere eine
# Zeitbombe — der laufende Prozess merkt davon nichts, aber der naechste
# Neustart des Knotens findet keine gueltige Konfiguration mehr vor.
cat > /etc/haproxy/haproxy.cfg.neu <<'CONFIG'
# Grundkonfiguration für ZPO — erzeugt von packaging/setup-haproxy.sh.
#
# Ab hier gehört diese Datei ZPO (Entscheidung E4). Von Hand geänderte
# Zeilen überschreibt die Data Plane API beim nächsten Schreibzugriff.
#
# Geroutet wird über die Map-Datei zpo_hosts.map statt über ACLs: Damit
# braucht eine neue Domain keinen Reload, sondern nur einen Eintrag über
# die Runtime-API.

global
    log stdout format raw local0 info
    stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
    stats timeout 30s
    user haproxy
    group haproxy
    daemon
    ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
    ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256

defaults
    mode http
    log global
    option httplog
    option forwardfor
    option http-server-close
    timeout connect 5s
    timeout client  60s
    timeout server  60s
    timeout http-request 10s

# Das generische Frontend. Es entscheidet nichts selbst, sondern schlägt
# den Hostnamen in zpo_hosts.map nach — diese Datei pflegt ZPO.
frontend zpo_in
    bind :80
    bind :443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1

    # Die Anmeldeseite (401) für Zugangslisten mit Basic Auth. Im
    # Frontend gibt es nur eine — ZPO legt die des Standardprofils ab.
    errorfile 401 /etc/haproxy/general/zpo_err_unauthorized.http

    # Zählt die Anfragen je Client-IP und Dienst der letzten zehn
    # Sekunden — die Grundlage der Rate-Limits (M7). Der Schlüssel
    # enthält den Hostnamen: Wer einen Dienst stark nutzt, darf dadurch
    # nicht an einem anderen mit knappem Limit scheitern.
    stick-table type string len 192 size 200k expire 60s store http_req_rate(10s)

    # Hostnamen ohne Port und in Kleinbuchstaben nachschlagen. Auf einem
    # eigenen Eingangsport gehört der Port zum Schlüssel ("name:8443"):
    # So kann derselbe Name auf jedem Port zu einem anderen Dienst führen,
    # und ein Dienst auf 8443 ist nicht nebenbei über 443 erreichbar.
    http-request set-var(txn.zpo_host) req.hdr(host),field(1,:),lower
    http-request set-var-fmt(txn.zpo_host) "%[var(txn.zpo_host)]:%[dst_port]" unless { dst_port 80 443 }

    # HTTP-01 (Let's Encrypt): Die CA fragt /.well-known/acme-challenge/
    # <token> auf Port 80 ab. ZPO legt Token und Antwort in zpo_acme.map,
    # solange eine Bestellung läuft. Die Antwort kommt vor Umleitung,
    # Zugangslisten und Rate-Limit — die CA fragt von überall und per http.
    http-request set-var(txn.zpo_acme) path,field(4,/),map(/etc/haproxy/maps/zpo_acme.map) if { path_beg /.well-known/acme-challenge/ }
    http-request return status 200 content-type text/plain lf-string "%[var(txn.zpo_acme)]" if { var(txn.zpo_acme) -m found }

    # Zugriffsregeln des Dienstes aus zpo_ratelimits.map. Der Wert ist
    # "<limit>/<zugangsliste>/<schalter>/<modus>"; kein Eintrag heißt:
    # kein Limit, keine Zugangsliste, keine Schalter.
    # add(0) macht aus dem Textfeld eine Zahl. set-var-int waere
    # naheliegender, gibt es aber erst ab HAProxy 2.9.
    http-request set-var(txn.zpo_rule) var(txn.zpo_host),map(/etc/haproxy/maps/zpo_ratelimits.map,"")
    http-request set-var(txn.zpo_limit) var(txn.zpo_rule),field(1,/),add(0)
    http-request set-var(txn.zpo_group) var(txn.zpo_rule),field(2,/)
    http-request set-var(txn.zpo_flags) var(txn.zpo_rule),field(3,/)
    http-request set-var(txn.zpo_mode) var(txn.zpo_rule),field(4,/)
    http-request set-var(txn.zpo_mode) str(ip_only) if !{ var(txn.zpo_mode) -m len gt 0 }
    # Fünftes Feld: das Fehlerprofil des Dienstes, leer heißt Standard.
    http-request set-var(txn.zpo_errprof) var(txn.zpo_rule),field(5,/)
    http-request set-var(txn.zpo_errprof) str(default) if !{ var(txn.zpo_errprof) -m len gt 0 }

    # Unbekannter Hostname: die 404-Seite des Standardprofils.
    http-request set-var(txn.zpo_error) str(not_found) if !{ var(txn.zpo_host),map(/etc/haproxy/maps/zpo_hosts.map) -m found }

    # Schalter "f" (force https): die Umleitung gilt je Dienst, nicht
    # global. Die Schalter sind eine Buchstabenfolge, der Abgleich als
    # Teilzeichenkette reicht — ZPO vergibt die Buchstaben eindeutig.
    http-request redirect scheme https code 301 if !{ ssl_fc } { var(txn.zpo_flags) -m sub f }

    # Schalter "h" (hsts) und "s" (Sicherheitsköpfe): Antwortköpfe je
    # Dienst. HSTS gehört nur auf verschlüsselte Antworten.
    http-after-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains" if { ssl_fc } { var(txn.zpo_flags) -m sub h }
    http-after-response set-header X-Content-Type-Options nosniff if { var(txn.zpo_flags) -m sub s }
    http-after-response set-header X-Frame-Options SAMEORIGIN if { var(txn.zpo_flags) -m sub s }
    http-after-response set-header Referrer-Policy strict-origin-when-cross-origin if { var(txn.zpo_flags) -m sub s }

    # Zugangslisten: Netze aus zpo_ipgroups.map, Basic-Auth-Zugänge aus
    # zpo_auth.map ("<liste>:<base64(benutzer:passwort)> 1"). Beides wird
    # zur Laufzeit nachgeschlagen, eine neue Liste braucht keinen Reload.
    # Die Pfadregeln der Dienste (in deren Backends) lesen dieselben
    # Variablen. Eine Liste gilt nur, wenn ihr Name nicht leer ist —
    # "-m found" trüge nicht, auch eine leere Zeichenkette ist gesetzt.
    http-request set-var(txn.zpo_src_group) src,map_ip(/etc/haproxy/maps/zpo_ipgroups.map)
    http-request set-var(txn.zpo_token) req.hdr(authorization),word(2," ")
    http-request set-var(txn.zpo_ip_ok) bool(1) if { var(txn.zpo_src_group),strcmp(txn.zpo_group) eq 0 }
    http-request set-var(txn.zpo_auth_key) var(txn.zpo_group),concat(":",txn.zpo_token)
    http-request set-var(txn.zpo_auth_ok) bool(1) if { var(txn.zpo_auth_key),map(/etc/haproxy/maps/zpo_auth.map) -m found }
    acl zpo_listed var(txn.zpo_group) -m len gt 0
    acl zpo_ip_ok var(txn.zpo_ip_ok) -m bool
    acl zpo_auth_ok var(txn.zpo_auth_ok) -m bool
    acl zpo_refused var(txn.zpo_error) -m found
    # Intern heißt: aus einem privaten oder lokalen Netz. Davon hängt ab,
    # welche 403-Seite der Client sieht.
    acl zpo_internal src 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 127.0.0.0/8 fc00::/7 ::1

    # ip_only: das Netz muss passen. and: Netz UND Anmeldung.
    # basic_only: nur die Anmeldung. or: Netz ODER Anmeldung.
    # Abgewiesen wird nicht hier, sondern über das Fehler-Backend des
    # Fehlerprofils (unten) — dort steht die passende Seite.
    http-request set-var(txn.zpo_denied) bool(1) if zpo_listed { var(txn.zpo_mode) -m str ip_only } !zpo_ip_ok
    http-request set-var(txn.zpo_denied) bool(1) if zpo_listed { var(txn.zpo_mode) -m str and } !zpo_ip_ok
    http-request set-var(txn.zpo_error) str(forbidden_internal) if !zpo_refused { var(txn.zpo_denied) -m bool } zpo_internal
    http-request set-var(txn.zpo_error) str(forbidden_external) if !zpo_refused { var(txn.zpo_denied) -m bool }
    http-request auth realm ZPO if !zpo_refused zpo_listed { var(txn.zpo_mode) -m str and } !zpo_auth_ok
    http-request auth realm ZPO if !zpo_refused zpo_listed { var(txn.zpo_mode) -m str basic_only } !zpo_auth_ok
    http-request auth realm ZPO if !zpo_refused zpo_listed { var(txn.zpo_mode) -m str or } !zpo_ip_ok !zpo_auth_ok

    # Rate-Limit (M7): Über dem Limit des Dienstes wird mit 429
    # abgelehnt. Limit 0 schaltet die Begrenzung aus.
    #
    # Die Differenz steht im Umweg über eine Variable, weil eine ACL in
    # 2.8 nur gegen eine Konstante vergleichen kann — die arithmetischen
    # Konverter nehmen dagegen einen Variablennamen entgegen.
    http-request track-sc0 src,concat("@",txn.zpo_host)
    http-request set-var(txn.zpo_over) sc0_http_req_rate,sub(txn.zpo_limit)
    http-request set-var(txn.zpo_error) str(too_many_requests) if !zpo_refused { var(txn.zpo_limit) -m int gt 0 } { var(txn.zpo_over) -m int gt 0 }

    # Abgewiesene Anfragen gehen zum Fehler-Backend ihres Fehlerprofils
    # ("<profil>:<fall>" in zpo_errors.map); fehlt der Eintrag, antwortet
    # zpo_err_fallback schlicht mit dem richtigen Status.
    use_backend %[var(txn.zpo_errprof),concat(":",txn.zpo_error),map(/etc/haproxy/maps/zpo_errors.map,zpo_err_fallback)] if zpo_refused
    use_backend %[var(txn.zpo_host),map(/etc/haproxy/maps/zpo_hosts.map)]

    default_backend zpo_unknown

# Antwort für Hostnamen, die ZPO (noch) nicht kennt.
backend zpo_unknown
    http-request return status 404 content-type "text/plain" string "Kein Dienst unter diesem Namen."

# Schlichte Antworten, solange ZPO die Fehlerseiten noch nicht verteilt hat.
backend zpo_err_fallback
    http-request deny deny_status 403 if { var(txn.zpo_error) -m beg forbidden }
    http-request deny deny_status 429 if { var(txn.zpo_error) -m str too_many_requests }
    http-request return status 404 content-type "text/plain" string "Kein Dienst unter diesem Namen."
CONFIG
# Was ZPO auf dem Knoten angelegt hat, übersteht einen erneuten Lauf:
# seine Backends (zpo_…) und die zusätzlichen Eingangsports im Frontend
# (bind … name zpo_bind_…). Ohne das wären alle Dienste weg, bis ZPO sie
# neu ausspielt. Die Prüfung mit haproxy -c folgt erst danach.
# Schreibt die Data Plane API die Konfiguration um, benennt sie den
# defaults-Abschnitt ("unnamed_defaults_1") und hängt "from …" an jedes
# Backend. Die neue Konfiguration hat einen unbenannten Abschnitt —
# ohne den Zusatz erben die Backends ihn wie gehabt.
if [ -f /etc/haproxy/haproxy.cfg ]; then
    kept=$(mktemp -d)
    grep -E '^[[:space:]]+bind .* name zpo_bind_' /etc/haproxy/haproxy.cfg > "$kept/binds" || true
    awk '/^[^[:space:]#]/ { keep = ($1 == "backend" && $2 ~ /^zpo_/ && $2 != "zpo_unknown" && $2 != "zpo_unbekannt" && $2 != "zpo_err_fallback") } keep' \
        /etc/haproxy/haproxy.cfg | sed -E 's/^(backend [^[:space:]]+) from [^[:space:]]+$/\1/' > "$kept/backends"
    awk -v binds="$kept/binds" '{ print } /^    bind :443 / { while ((getline line < binds) > 0) print line }' \
        /etc/haproxy/haproxy.cfg.neu > "$kept/haproxy.cfg"
    { cat "$kept/haproxy.cfg"; echo; cat "$kept/backends"; } > /etc/haproxy/haproxy.cfg.neu
    echo "übernommen: $(grep -c . "$kept/binds") Eingangsports, $(grep -c '^backend ' "$kept/backends") Backends von ZPO"
    rm -rf "$kept"
fi
if ! haproxy -c -f /etc/haproxy/haproxy.cfg.neu >/dev/null; then
    echo "FEHLER: die erzeugte Konfiguration ist ungültig — nichts geändert" >&2
    haproxy -c -f /etc/haproxy/haproxy.cfg.neu 2>&1 | grep ALERT >&2 || true
    rm -f /etc/haproxy/haproxy.cfg.neu
    exit 1
fi
mv /etc/haproxy/haproxy.cfg.neu /etc/haproxy/haproxy.cfg
echo "Konfiguration ist gültig und übernommen"

step "Zugangsdaten der Verwaltungs-Schnittstelle"
if [ -s "$CREDENTIALS_FILE" ] && grep -q "^password=" "$CREDENTIALS_FILE"; then
    PASSWORD=$(sed -n 's/^password=//p' "$CREDENTIALS_FILE")
    echo "vorhandenes Passwort aus $CREDENTIALS_FILE übernommen"
else
    PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' | cut -c1-28)
    echo "neues Passwort erzeugt"
fi
# Die API speichert den Hash, nicht das Passwort (insecure: false).
HASH=$(openssl passwd -5 "$PASSWORD")

step "dataplaneapi.yml"
cat > /etc/haproxy/dataplaneapi.yml <<CONFIG
# Verwaltungs-Schnittstelle für ZPO — erzeugt von setup-haproxy.sh.
#
# Die Schnittstelle gehört NICHT ins offene Netz: Sie lauscht nur auf der
# internen Adresse dieses Servers. Wer sie von außen erreichbar machen
# will, stellt einen Reverse Proxy mit TLS davor.
dataplaneapi:
  host: $API_BIND
  port: $API_PORT
  user:
    - name: $API_USER
      insecure: false
      password: "$HASH"
  transaction:
    transaction_dir: /var/lib/dataplaneapi/transactions
  resources:
    maps_dir: /etc/haproxy/maps
    ssl_certs_dir: /etc/haproxy/certs
    # Ablage für Dateien ohne Schlüsselmaterial. Dorthin legt ZPO die
    # Zertifikate, gegen die ein Dienst sein https-Ziel prüft — der
    # Zertifikatsspeicher weist sie ab, weil ihnen der private
    # Schlüssel fehlt.
    general_storage_dir: /etc/haproxy/general
haproxy:
  config_file: /etc/haproxy/haproxy.cfg
  haproxy_bin: /usr/sbin/haproxy
  reload:
    reload_cmd: systemctl reload haproxy
    restart_cmd: systemctl restart haproxy
    reload_delay: 5
    reload_strategy: custom
CONFIG
chmod 0600 /etc/haproxy/dataplaneapi.yml
echo "geschrieben, lauscht auf $API_BIND:$API_PORT"

step "systemd-Dienst"
# Die mitgelieferte Unit bleibt unangetastet — sie liest ihre Optionen aus
# /etc/default/dataplaneapi, und genau dort wird unsere Konfiguration
# eingehängt. Eine ersetzte Unit wäre beim nächsten Paketupdate wieder weg.
cat > /etc/default/dataplaneapi <<'DEFAULTS'
# Von ZPO gesetzt: Die Konfiguration liegt bei der haproxy.cfg, die sie
# verwaltet, nicht im Paketverzeichnis.
SYSD_OPTIONS='-f /etc/haproxy/dataplaneapi.yml'
DEFAULTS

install -d -m 0755 /etc/systemd/system/dataplaneapi.service.d
cat > /etc/systemd/system/dataplaneapi.service.d/zpo.conf <<'UNIT'
# Ergänzungen von ZPO zur Unit aus dem Paket.
[Unit]
After=haproxy.service
Wants=haproxy.service

[Service]
# Der Dienst muss wiederkommen: Ohne ihn ist der Knoten für ZPO stumm,
# und die Unit aus dem Paket bringt kein Restart= mit.
Restart=always
RestartSec=5
# Härtung, soweit die Aufgabe es zulässt: Die API schreibt haproxy.cfg
# und lädt HAProxy neu, mehr braucht sie nicht.
NoNewPrivileges=true
ProtectHome=true
ProtectSystem=full
ReadWritePaths=/etc/haproxy /var/lib/dataplaneapi /run/haproxy
UNIT
systemctl daemon-reload
systemctl enable haproxy dataplaneapi >/dev/null 2>&1 || true

# Wichtig: Das Paket startet HAProxy schon beim Installieren — mit der
# Ubuntu-Standardkonfiguration. Ein "enable --now" fände den Dienst dann
# aktiv und liesse ihn in Ruhe, unsere Konfiguration käme nie zum Zug.
# Deshalb ausdrücklich nachladen; reload statt restart, damit ein zweiter
# Lauf auf einem Knoten im Betrieb keine Verbindungen abreisst.
if systemctl is-active --quiet haproxy; then
    systemctl reload haproxy
else
    systemctl start haproxy
fi
systemctl restart dataplaneapi
sleep 3
for dienst in haproxy dataplaneapi; do
    printf '%-14s %s\n' "$dienst" "$(systemctl is-active $dienst)"
done

step "Firewall"
# Nur wenn ufw ueberhaupt laeuft: Ein Knoten mit aktiver Firewall und
# ohne diese Regel ist fuer ZPO stumm — die Verwaltungs-Schnittstelle
# ist dann von aussen dicht, und der Ausfallschutz haelt den Knoten
# fuer tot, obwohl HAProxy laeuft.
if command -v ufw >/dev/null && ufw status 2>/dev/null | head -1 | grep -q active; then
    ufw allow "$API_PORT/tcp" comment "zpo: Data Plane API" >/dev/null
    echo "$API_PORT/tcp freigegeben"
else
    echo "ufw nicht aktiv — $API_PORT/tcp muss anderweitig erreichbar sein"
fi

step "Zugangsdaten ablegen"
cat > "$CREDENTIALS_FILE" <<CREDENTIALS
# Zugangsdaten der HAProxy Data Plane API auf $NAME.
# Erzeugt von ZPO packaging/setup-haproxy.sh am $(date -Is).
# In ZPO als Proxy-Knoten eintragen: Verwaltungs-Adresse, Benutzer, Passwort.
node=$NAME
address=http://$API_BIND:$API_PORT
user=$API_USER
password=$PASSWORD
CREDENTIALS
chmod 0600 "$CREDENTIALS_FILE"
echo "$CREDENTIALS_FILE (nur für root lesbar)"

step "Probe"
if curl -fsS -u "$API_USER:$PASSWORD" "http://$API_BIND:$API_PORT/v3/info" >/dev/null; then
    echo "die Schnittstelle antwortet"
else
    echo "FEHLER: die Schnittstelle antwortet nicht" >&2
    exit 1
fi
if curl -fsS -u "$API_USER:$PASSWORD" \
     "http://$API_BIND:$API_PORT/v3/services/haproxy/runtime/maps" | grep -q zpo_hosts; then
    echo "die Map-Datei zpo_hosts.map ist da"
else
    echo "WARNUNG: zpo_hosts.map taucht in der Runtime nicht auf" >&2
fi

cat <<FERTIG

== Fertig

In ZPO als Proxy-Knoten eintragen:
  Verwaltungs-Adresse   http://$API_BIND:$API_PORT
  Benutzer              $API_USER
  Passwort              $PASSWORD

Die interne Adresse (die in die lokalen DNS-Einträge wandert) ist die
Adresse, unter der Clients im lokalen Netz diesen Proxy erreichen — hier
vermutlich $API_BIND, aber das entscheidet die Netzführung, nicht dieses
Skript. Ist der Proxy auch aus dem Internet erreichbar, gehört die
öffentliche Adresse (etwa die des Routers) in das Feld darunter.
FERTIG
