#!/bin/bash
# shellcheck disable=SC2088,SC1111
# ==========================================================================
#  saarmetall-lab.sh · Praxislabor zum Buch „Das NetBird-Handbuch“
#  Zero-Trust-Netzwerke mit WireGuard aufbauen, betreiben und prüfsicher
#  dokumentieren
#
#  Holger Reibold · Brain-Media.de · Begleitmaterial zu AdminMission
#  Version 1.0 · 10 Missionen · NetBird 0.81.3
#
#  Das Skript baut auf einer Debian-13-VM das Netz der Saarmetall GmbH aus
#  dem Buch nach: eine selbst betriebene NetBird-Instanz, sieben Peers als
#  Docker-Container und zwei CNC-Steuerungen im OT-Netz des Werks
#  Völklingen. Die VM selbst wird zum Admin-Rechner adm01 mit eigenem
#  NetBird-Client. Danach baut das Skript auf Wunsch den Fehler einer
#  Mission ein und prüft hinterher, ob Sie ihn behoben haben.
#
#    sudo ./saarmetall-lab.sh install     Labor einrichten (einmalig)
#    sudo ./saarmetall-lab.sh mission 5   Fehler aus Mission 5 einbauen
#    sudo ./saarmetall-lab.sh check 5     Ziele von Mission 5 prüfen
#    sudo ./saarmetall-lab.sh reset       Ausgangszustand herstellen
#    sudo ./saarmetall-lab.sh list        Alle Missionen anzeigen
#    sudo ./saarmetall-lab.sh status      Zustand des Labors anzeigen
#
#  Missionen mit einer Antwort:  sudo ./saarmetall-lab.sh check 3 zbx01
#
#  Wichtig: Verwenden Sie das Labor nur in einer eigenen Übungs-VM. Es
#  installiert Docker und einen NetBird-Client auf dem Rechner, ändert
#  /etc/hosts und vertraut einer eigenen Labor-CA. Legen Sie vorher einen
#  Schnappschuss der VM an. Lesen Sie das Skript, bevor Sie es mit
#  root-Rechten ausführen.
#
#  So ist das Labor aufgebaut
#  --------------------------
#    netbird-server   netbird.saarmetall.example (172.20.0.2), Management,
#                     Signal, Relay und STUN in einem Container
#    erp01            ERP-Server (Gruppe srv-erp), TCP 22, 443, 3389
#    dc-sb-01         Domänencontroller mit DNS für intern.saarmetall.example
#    zbx01            Monitoring (srv-monitoring)
#    rp-vk-01         Routing Peer im Werk Völklingen, OT-Netz 10.20.100.0/24
#    nb-buchhaltung   Notebook der Buchhaltung (nb-verwaltung)
#    dl-erp, dl-cnc   Zugänge der Dienstleister für ERP und CNC
#    cnc-01, cnc-02   Steuerungen ohne Client (10.20.100.21 und .22)
#    adm01            diese VM, Ihr Arbeitsplatz in der IT (nb-it)
#
#  Die Verwaltung läuft wie im Buch über die REST-API. Ihr Token liegt in
#  ~/.netbird-token, die Befehle stehen in ~/LIESMICH.txt.
# ==========================================================================

set -o pipefail
umask 022
export LC_ALL=C.UTF-8 LANG=C.UTF-8

VERSION='1.0'
NB_VERSION='0.81.3'

LAB_USER=${SM_LAB_USER:-trainee}
LAB=/opt/saarmetall-lab
STATE=/var/lib/saarmetall-lab
SELF_INSTALLED=/usr/local/sbin/saarmetall-lab
MGMT_HOST=netbird.saarmetall.example
MGMT=https://$MGMT_HOST
SERVER_IP=172.20.0.2
DNSDOM=netbird.selfhosted
INTERN=intern.saarmetall.example
OWNER_MAIL=admin@saarmetall.example
MISSION_MAX=10

PEERS=(erp01 dc-sb-01 zbx01 rp-vk-01 nb-buchhaltung dl-erp dl-cnc)
GRUPPEN=(nb-it nb-verwaltung nb-dl-erp nb-dl-cnc srv-erp srv-dc srv-monitoring rp-vk loc-saarbruecken loc-voelklingen dev-linux res-cnc-hersteller)
declare -A PEER_GRUPPEN=(
  [adm01]='nb-it dev-linux'
  [erp01]='srv-erp loc-saarbruecken'
  [dc-sb-01]='srv-dc loc-saarbruecken'
  [zbx01]='srv-monitoring'
  [rp-vk-01]='rp-vk loc-voelklingen'
  [nb-buchhaltung]='nb-verwaltung dev-linux'
  [dl-erp]='nb-dl-erp'
  [dl-cnc]='nb-dl-cnc'
)

# ---------------------------------------------------------------- Ausgabe

if [ -t 1 ]; then C_OK=$'\e[32m'; C_ERR=$'\e[31m'; C_HI=$'\e[1m'; C_0=$'\e[0m'; else C_OK=''; C_ERR=''; C_HI=''; C_0=''; fi
info()    { printf '%s\n' "$*"; }
schritt() { printf '%s==>%s %s\n' "$C_HI" "$C_0" "$*"; }
warnung() { printf '%sWarnung:%s %s\n' "$C_ERR" "$C_0" "$*" >&2; }
abbruch() { printf '%sFehler:%s %s\n' "$C_ERR" "$C_0" "$*" >&2; exit 1; }
leise()   { "$@" >/dev/null 2>&1; }
sha()     { printf '%s' "$1" | sha256sum | cut -c1-16; }
TH()      { getent passwd "$LAB_USER" | cut -d: -f6; }

dc() { # docker compose oder docker-compose
  if docker compose version >/dev/null 2>&1; then docker compose -p saarmetall -f "$LAB/docker-compose.yml" "$@"
  else docker-compose -p saarmetall -f "$LAB/docker-compose.yml" "$@"; fi
}

# Der Routing Peer hängt in zwei Netzen. Manche Compose-Versionen verbinden
# beim Neuanlegen eines Containers nur eines davon, deshalb hier nachprüfen.
netze_pruefen() {
  local netze
  netze=$(docker inspect rp-vk-01 --format '{{range $n, $_ := .NetworkSettings.Networks}}{{$n}} {{end}}' 2>/dev/null) || return 0
  [[ " $netze " == *" sm-internet "* ]] || leise docker network connect sm-internet rp-vk-01
  [[ " $netze " == *" sm-ot "* ]] || leise docker network connect --ip 10.20.100.2 sm-ot rp-vk-01
  # Standardroute ins Internet-Netz (sm-ot ist intern und hat kein Gateway)
  docker exec rp-vk-01 ip route show default | grep -q 172.20.0.1 || leise docker exec rp-vk-01 ip route replace default via 172.20.0.1
  return 0
}

# ---------------------------------------------------------------- REST-API

token() { cat "$STATE/token" 2>/dev/null; }

# api METHODE PFAD [JSON]: gibt den Antworttext aus, Fehlercode bei HTTP >= 400
api() {
  local m=$1 p=$2 d=${3:-} out code
  out=$(curl -sS --max-time 20 -X "$m" -H "Authorization: Token $(token)" -H 'Content-Type: application/json' \
    ${d:+--data "$d"} -w '\n%{http_code}' "$MGMT$p" 2>&1) || { printf '%s\n' "$out" >&2; return 1; }
  code=${out##*$'\n'}; out=${out%$'\n'*}
  [[ $out =~ ^[[:space:]]*null[[:space:]]*$ ]] && out='[]'   # leere Listen liefert die API als null
  printf '%s' "$out"
  [ "$code" -lt 400 ] || { printf 'API %s %s: HTTP %s %s\n' "$m" "$p" "$code" "$out" >&2; return 1; }
}
# ID eines Objekts anhand seines Namens: id_von groups nb-it
id_von() { api GET "/api/$1" | jq -r --arg n "$2" '.[] | select(.name == $n) | .id' | head -n1; }
gid() { id_von groups "$1"; }
gids_json() { local g out='[]'; for g in "$@"; do out=$(jq -c --arg i "$(gid "$g")" '. + [$i]' <<<"$out"); done; printf '%s' "$out"; }
peer_id() { id_von peers "$1"; }
peer_ip() { api GET /api/peers | jq -r --arg n "$1" '.[] | select(.name == $n) | .ip' | head -n1; }
peer_online() { [ "$(api GET /api/peers | jq -r --arg n "$1" '.[] | select(.name == $n) | .connected' | head -n1)" = true ]; }

# Verbindungstest aus einem Container: verbindet VON ZIEL PORT [udp]
verbindet() { docker exec "$1" nc -z ${4:+-u} -w 3 "$2" "$3" >/dev/null 2>&1; }

# ==========================================================================
#  Dateien des Labors
# ==========================================================================

peer_dockerfile() {
cat <<EOF
# Saarmetall-Labor: NetBird-Client mit kleinen Testdiensten
FROM netbirdio/netbird:$NB_VERSION
RUN apk add --no-cache python3 netcat-openbsd bind-tools curl dnsmasq ca-certificates
COPY sm-entrypoint.sh sm-dienste.py /usr/local/bin/
RUN chmod 755 /usr/local/bin/sm-entrypoint.sh /usr/local/bin/sm-dienste.py
ENTRYPOINT ["/usr/local/bin/sm-entrypoint.sh"]
EOF
}

peer_entrypoint() {
cat <<'EOF'
#!/bin/bash
# Saarmetall-Labor: Startskript der Peer-Container
#  SM_SETUP_KEY Setup-Key für die erste Anmeldung. Bewusst nicht NB_SETUP_KEY:
#               Der NetBird-Client liest NB_*-Variablen als Vorgabe für seine
#               Optionen, eine leere NB_SETUP_KEY würde --setup-key überdecken.
#  SM_PORTS     TCP-Ports der Testdienste, zum Beispiel "22 443 3389"
#  SM_DNS=1     dnsmasq für intern.saarmetall.example starten
#  SM_CLIENT=0  ohne NetBird (Steuerungen im OT-Netz)
update-ca-certificates >/dev/null 2>&1
[ -n "$SM_PORTS" ] && python3 /usr/local/bin/sm-dienste.py $SM_PORTS &
# dnsmasq läuft in einer Schleife, damit ein Neustart (pkill dnsmasq) die Zone neu einliest
if [ "$SM_DNS" = 1 ]; then
  ( while :; do dnsmasq --keep-in-foreground --conf-file=/etc/sm-dns/dnsmasq.conf; sleep 1; done ) &
fi
[ "$SM_CLIENT" = 0 ] && exec sleep infinity
netbird service run --log-file console &
daemon=$!
for _ in $(seq 1 30); do netbird status >/dev/null 2>&1 && break; sleep 1; done
# Ohne Setup-Key nur wieder verbinden, wenn der Peer schon registriert ist.
# Sonst würde netbird up eine Browser-Anmeldung starten und den Dienst blockieren.
if netbird status 2>/dev/null | grep -q 'Management: Connected'; then
  :
elif [ -n "$SM_SETUP_KEY" ]; then
  netbird up --management-url "$NB_MANAGEMENT_URL" --hostname "$NB_HOSTNAME" --setup-key "$SM_SETUP_KEY" ||
    echo "WARN sm-entrypoint: Anmeldung mit Setup-Key fehlgeschlagen, warte auf netbird up --setup-key ..." >&2
elif ! netbird status 2>/dev/null | grep -q 'NeedsLogin'; then
  netbird up --management-url "$NB_MANAGEMENT_URL" --hostname "$NB_HOSTNAME" ||
    echo "WARN sm-entrypoint: Verbindung fehlgeschlagen" >&2
else
  echo "INFO sm-entrypoint: Peer nicht registriert, warte auf netbird up --setup-key ..." >&2
fi
wait "$daemon"
EOF
}

peer_dienste() {
cat <<'EOF'
#!/usr/bin/python3
# Saarmetall-Labor: einfache Testdienste je Port (443 mit TLS)
import os, socket, ssl, sys, threading
NAME = os.environ.get('NB_HOSTNAME') or socket.gethostname()
def antwort(port):
    if port in (80, 443):
        text = 'ERP-Anmeldung' if NAME == 'erp01' else 'Steuerung bereit'
        body = ('<!doctype html><title>%s</title><h1>%s</h1>\n' % (NAME, text)).encode()
        return b'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: ' + str(len(body)).encode() + b'\r\nConnection: close\r\n\r\n' + body
    if port == 22:
        return b'SSH-2.0-OpenSSH_10.0p2 Debian-7\r\n'
    return ('%s %d bereit\r\n' % (NAME, port)).encode()
def dienst(port):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(('0.0.0.0', port)); s.listen(16)
    ctx = None
    if port == 443 and os.path.exists('/etc/sm/tls.crt'):
        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); ctx.load_cert_chain('/etc/sm/tls.crt', '/etc/sm/tls.key')
    while True:
        c, _ = s.accept()
        try:
            if ctx: c = ctx.wrap_socket(c, server_side=True)
            if port in (80, 443): c.settimeout(3); c.recv(4096)
            c.sendall(antwort(port))
        except Exception:
            pass
        finally:
            try: c.close()
            except Exception: pass
for p in sys.argv[1:]:
    threading.Thread(target=dienst, args=(int(p),), daemon=True).start()
threading.Event().wait()
EOF
}

server_config() {
cat <<EOF
# NetBird Combined Server · Saarmetall-Labor
server:
  listenAddress: ":443"
  exposedAddress: "$MGMT:443"
  stunPorts:
    - 3478
  metricsPort: 9090
  healthcheckAddress: ":9000"
  logLevel: "info"
  logFile: "console"
  tls:
    certFile: "/etc/netbird/tls/server.crt"
    keyFile: "/etc/netbird/tls/server.key"
  authSecret: "$(cat "$STATE/relay-secret")"
  dataDir: "/var/lib/netbird/"
  disableAnonymousMetrics: true
  disableGeoliteUpdate: true
  auth:
    issuer: "$MGMT/oauth2"
    localAuthDisabled: false
    signKeyRefreshEnabled: false
    dashboardRedirectURIs:
      - "$MGMT/nb-auth"
      - "$MGMT/nb-silent-auth"
    cliRedirectURIs:
      - "http://localhost:53000/"
  store:
    engine: "sqlite"
    dsn: ""
    encryptionKey: "$(cat "$STATE/store-key")"
EOF
}

peer_service() { # peer_service NAME PORTS [ENV-ZEILEN] [WEITERE-ZEILEN]
  local n=$1 ports=$2 env=${3:-} extra=${4:-'    networks: [sm-internet]'} var
  var=KEY_$(printf '%s' "$n" | tr 'a-z-' 'A-Z_')
  printf '  %s:\n' "$n"
  printf '    image: saarmetall/peer:%s\n' "$NB_VERSION"
  printf '    container_name: %s\n    hostname: %s\n    restart: unless-stopped\n' "$n" "$n"
  printf '    cap_add: [NET_ADMIN, NET_RAW, SYS_ADMIN]\n    devices: ["/dev/net/tun:/dev/net/tun"]\n'
  printf '    environment:\n      NB_MANAGEMENT_URL: "%s"\n      NB_HOSTNAME: "%s"\n' "$MGMT" "$n"
  printf '      SM_SETUP_KEY: "${%s:-}"\n      SM_PORTS: "%s"\n' "$var" "$ports"
  [ -n "$env" ] && printf '%s\n' "$env"
  printf '    volumes:\n'
  printf '      - ./ca.crt:/usr/local/share/ca-certificates/saarmetall-labor.crt:ro\n'
  printf '      - ./tls:/etc/sm:ro\n      - ./dns:/etc/sm-dns:ro\n      - state-%s:/var/lib/netbird\n' "$n"
  printf '%s\n' "$extra"
}

compose_datei() {
  printf '# Saarmetall-Labor · erzeugt von saarmetall-lab.sh\nservices:\n'
  printf '  netbird-server:\n    image: netbirdio/netbird-server:%s\n    container_name: netbird-server\n' "$NB_VERSION"
  printf '    restart: unless-stopped\n    command: ["--config", "/etc/netbird/config.yaml"]\n'
  printf '    environment:\n      NB_SETUP_PAT_ENABLED: "${SETUP_PAT:-false}"\n      NB_DISABLE_GEOLOCATION: "true"\n'
  printf '    volumes:\n      - netbird_data:/var/lib/netbird\n      - ./config.yaml:/etc/netbird/config.yaml:ro\n      - ./tls:/etc/netbird/tls:ro\n'
  printf '    networks:\n      sm-internet:\n        ipv4_address: %s\n        aliases: [%s]\n' "$SERVER_IP" "$MGMT_HOST"
  peer_service erp01 '22 443 3389'
  peer_service dc-sb-01 '' '      SM_DNS: "1"'
  peer_service zbx01 '10051'
  peer_service nb-buchhaltung ''
  peer_service dl-erp ''
  peer_service dl-cnc ''
  peer_service build-7f3a '' '' $'    networks: [sm-internet]\n    profiles: [vorfall]'
  peer_service rp-vk-01 '' '' $'    sysctls:\n      net.ipv4.ip_forward: "1"\n    networks:\n      sm-internet:\n      sm-ot:\n        ipv4_address: 10.20.100.2'
  local c ip
  for c in cnc-01:21 cnc-02:22; do
    ip=${c#*:}; c=${c%:*}
    printf '  %s:\n    image: saarmetall/peer:%s\n    container_name: %s\n    hostname: %s\n    restart: unless-stopped\n' "$c" "$NB_VERSION" "$c" "$c"
    printf '    environment:\n      SM_CLIENT: "0"\n      SM_PORTS: "80 5900"\n      NB_HOSTNAME: "%s"\n' "$c"
    printf '    networks:\n      sm-ot:\n        ipv4_address: 10.20.100.%s\n' "$ip"
  done
  printf 'networks:\n  sm-internet:\n    name: sm-internet\n    ipam:\n      config: [{subnet: 172.20.0.0/24}]\n'
  printf '  sm-ot:\n    name: sm-ot\n    internal: true\n    ipam:\n      config: [{subnet: 10.20.100.0/24}]\n'
  printf 'volumes:\n  netbird_data:\n'
  local n
  for n in "${PEERS[@]}" build-7f3a; do printf '  state-%s:\n' "$n"; done
}

dnsmasq_conf() {
  printf '# dc-sb-01 · interne Zone %s\nno-resolv\nno-hosts\nlocal=/%s/\n' "$INTERN" "$INTERN"
  local n ip
  for n in erp01 dc-sb-01 zbx01; do
    ip=$(peer_ip "$n")
    [ -n "$ip" ] && printf 'address=/%s.%s/%s\n' "$n" "$INTERN" "$ip"
  done
}

zertifikate() {
  [ -s "$LAB/ca.crt" ] && [ -s "$LAB/tls/server.crt" ] && return 0
  local t; t=$(mktemp -d)
  mkdir -p "$LAB/tls"
  openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -subj '/O=Saarmetall Labor/CN=Saarmetall Labor CA' \
    -keyout "$STATE/ca.key" -out "$LAB/ca.crt" >/dev/null 2>&1 || abbruch "openssl konnte die Labor-CA nicht erzeugen."
  zert() { # zert CN DATEI SAN
    openssl req -newkey rsa:2048 -nodes -subj "/O=Saarmetall GmbH/CN=$1" -keyout "$LAB/tls/$2.key" -out "$t/r.csr" >/dev/null 2>&1
    printf 'subjectAltName=%s\nextendedKeyUsage=serverAuth\n' "$3" > "$t/ext"
    openssl x509 -req -in "$t/r.csr" -CA "$LAB/ca.crt" -CAkey "$STATE/ca.key" -CAcreateserial -days 3650 -extfile "$t/ext" -out "$LAB/tls/$2.crt" >/dev/null 2>&1
  }
  zert "$MGMT_HOST" server "DNS:$MGMT_HOST,IP:$SERVER_IP"
  zert "erp01.$DNSDOM" tls "DNS:erp01.$DNSDOM,DNS:erp01.$INTERN,DNS:erp01"
  chmod 640 "$LAB/tls/"*.key
  cp "$LAB/ca.crt" /usr/local/share/ca-certificates/saarmetall-labor.crt
  leise update-ca-certificates
  rm -rf "$t"
}

# ==========================================================================
#  Konto der Saarmetall GmbH über die REST-API einrichten
# ==========================================================================

# Richtlinie als JSON: richtlinie NAME BESCHREIBUNG "QUELLGRUPPEN" "ZIELGRUPPEN" PROTOKOLL "PORTS" [bidi] [POSTURE-ID]
richtlinie() {
  local name=$1 desc=$2 src=$3 dst=$4 proto=$5 ports=$6 bidi=${7:-false} pc=${8:-}
  local s d p
  # shellcheck disable=SC2086
  s=$(gids_json $src); d=$(gids_json $dst)
  p=$(printf '%s\n' $ports | jq -R . | jq -sc 'map(select(length > 0))')
  jq -nc --arg n "$name" --arg ds "$desc" --argjson s "$s" --argjson d "$d" --arg pr "$proto" --argjson p "$p" \
    --argjson b "$bidi" --arg pc "$pc" '{name: $n, description: $ds, enabled: true,
      source_posture_checks: (if $pc == "" then [] else [$pc] end),
      rules: [{name: $n, description: $ds, enabled: true, action: "accept", bidirectional: $b, protocol: $pr,
               ports: $p, sources: $s, destinations: $d}]}'
}

soll_richtlinie() { # soll_richtlinie NAME: JSON des Ausgangszustands
  local pc; pc=$(id_von posture-checks 'Client aktuell')
  case $1 in
    'IT auf Server') richtlinie "$1" 'Administration der Server (Verantwortlich: M. Scholl)' 'nb-it' 'srv-erp srv-dc srv-monitoring' tcp '22 443 3389' ;;
    'Verwaltung auf ERP') richtlinie "$1" 'ERP-Weboberfläche für die Verwaltung (Verantwortlich: Leitung Verwaltung)' 'nb-verwaltung' 'srv-erp' tcp '443' false "$pc" ;;
    'ERP-Dienstleister') richtlinie "$1" 'Fernwartung ERP per Remotedesktop (Vertrag ERP-Partner)' 'nb-dl-erp' 'srv-erp' tcp '3389' ;;
    'CNC-Fernwartung') richtlinie "$1" 'Hersteller der CNC-Steuerungen, nur VNC auf die eigenen Anlagen' 'nb-dl-cnc' 'res-cnc-hersteller' tcp '5900' ;;
    'DNS intern') richtlinie "$1" 'Namensauflösung über die Domänencontroller' 'dev-linux nb-it' 'srv-dc' udp '53' ;;
    'Monitoring') richtlinie "$1" 'Zabbix fragt die Agenten ab' 'srv-monitoring' 'srv-erp srv-dc' tcp '10050' ;;
  esac
}
SOLL_RICHTLINIEN=('IT auf Server' 'Verwaltung auf ERP' 'ERP-Dienstleister' 'CNC-Fernwartung' 'DNS intern' 'Monitoring')
SOLL_KEYS=(server-saarbruecken routing-voelklingen notebooks-verwaltung dienstleister)

schluessel_neu() { # schluessel_neu NAME TYP SEKUNDEN "GRUPPEN": gibt den Klartext-Schlüssel aus
  local g limit=0
  # shellcheck disable=SC2086
  g=$(gids_json $4)
  [ "$2" = one-off ] && limit=1
  api POST /api/setup-keys "$(jq -nc --arg n "$1" --arg t "$2" --argjson e "$3" --argjson g "$g" --argjson l "$limit" \
    '{name: $n, type: $t, expires_in: $e, auto_groups: $g, usage_limit: $l, ephemeral: false}')" | jq -r .key
}

soll_schluessel() {
  case $1 in
    server-saarbruecken) schluessel_neu "$1" reusable 2592000 'loc-saarbruecken' ;;
    routing-voelklingen) schluessel_neu "$1" one-off 604800 'rp-vk loc-voelklingen' ;;
    notebooks-verwaltung) schluessel_neu "$1" reusable 2592000 'nb-verwaltung dev-linux' ;;
    dienstleister) schluessel_neu "$1" reusable 2592000 '' ;;
  esac
}

posture_soll() {
  jq -nc '{name: "Client aktuell", description: "Mindestversion des NetBird-Clients", checks: {nb_version_check: {min_version: "0.70.0"}}}'
}

ns_soll() {
  local dc g; dc=$(peer_ip dc-sb-01); g=$(gids_json dev-linux nb-it)
  jq -nc --arg ip "$dc" --arg d "$INTERN" --argjson g "$g" '{name: "ns-intern", description: "Interne Domain über die Domänencontroller",
    nameservers: [{ip: $ip, ns_type: "udp", port: 53}], enabled: true, groups: $g, primary: false, domains: [$d], search_domains_enabled: false}'
}

warte_auf() { # warte_auf SEKUNDEN BESCHREIBUNG BEFEHL...
  local t=$1 was=$2 i; shift 2
  for (( i = 0; i < t; i++ )); do "$@" >/dev/null 2>&1 && return 0; sleep 1; done
  warnung "Zeitüberschreitung: $was"; return 1
}

peers_bereit() { # alle Peers registriert und verbunden
  local n liste; liste=$(api GET /api/peers) || return 1
  for n in "$@"; do
    [ "$(jq -r --arg n "$n" '.[] | select(.name == $n) | .connected' <<<"$liste" | head -n1)" = true ] || return 1
  done
}

# Peer neu anmelden (nach Löschen oder Neuaufbau): peer_anmelden NAME
peer_anmelden() {
  local n=$1 key
  # shellcheck disable=SC2086
  key=$(schluessel_neu "install-$n" one-off 86400 "${PEER_GRUPPEN[$n]}") || return 1
  if [ "$n" = adm01 ]; then
    leise netbird down
    netbird up --management-url "$MGMT" --setup-key "$key" --hostname adm01 >/dev/null 2>&1
  else
    leise docker exec "$n" netbird down
    docker exec "$n" netbird up --management-url "$MGMT" --setup-key "$key" --hostname "$n" >/dev/null 2>&1
  fi
  warte_auf 60 "Anmeldung von $n" peers_bereit "$n"
  api DELETE "/api/setup-keys/$(id_von setup-keys "install-$n")" >/dev/null
}

# Gruppenmitgliedschaften wie im Buch herstellen
gruppen_soll() {
  local liste g n ids name
  liste=$(api GET /api/peers) || return 1
  for g in "${GRUPPEN[@]}"; do
    [ "$g" = res-cnc-hersteller ] && continue
    ids='[]'
    for n in adm01 "${PEERS[@]}"; do
      [[ " ${PEER_GRUPPEN[$n]} " == *" $g "* ]] || continue
      ids=$(jq -c --arg i "$(jq -r --arg n "$n" '.[] | select(.name == $n) | .id' <<<"$liste" | head -n1)" '. + [$i] | map(select(length > 0))' <<<"$ids")
    done
    name=$g
    api PUT "/api/groups/$(gid "$g")" "$(jq -nc --arg n "$name" --argjson p "$ids" '{name: $n, peers: $p}')" >/dev/null
  done
}

konto_soll() {
  local g n id pc net rp rt res soll ist
  # Gruppen
  for g in "${GRUPPEN[@]}"; do
    [ -n "$(gid "$g")" ] || api POST /api/groups "$(jq -nc --arg n "$g" '{name: $n}')" >/dev/null
  done
  gruppen_soll
  # Schlüssel: nur die Schlüssel aus dem Buch, gültig
  while IFS=$'\t' read -r id n ist; do
    [ -n "$id" ] || continue
    if [[ " ${SOLL_KEYS[*]} " != *" $n "* ]] || [ "$ist" != valid ]; then api DELETE "/api/setup-keys/$id" >/dev/null; fi
  done < <(api GET /api/setup-keys | jq -r '.[] | [.id, .name, .state] | @tsv')
  for n in "${SOLL_KEYS[@]}"; do
    if [ -z "$(id_von setup-keys "$n")" ]; then
      soll=$(soll_schluessel "$n")
      [ "$n" = notebooks-verwaltung ] && { printf '%s\n' "$soll" > "$STATE/key-notebooks-verwaltung"; chmod 600 "$STATE/key-notebooks-verwaltung"; }
    fi
  done
  # Posture Check
  pc=$(id_von posture-checks 'Client aktuell')
  if [ -z "$pc" ]; then api POST /api/posture-checks "$(posture_soll)" >/dev/null; else api PUT "/api/posture-checks/$pc" "$(posture_soll)" >/dev/null; fi
  # Network Werk Völklingen mit Routing Peer und Ressource
  net=$(id_von networks 'Werk Völklingen')
  [ -n "$net" ] || net=$(api POST /api/networks '{"name":"Werk Völklingen","description":"OT-Netz und Server im Werk"}' | jq -r .id)
  rp=$(peer_id rp-vk-01)
  rt=$(api GET "/api/networks/$net/routers" | jq -r '.[0].id // empty')
  soll=$(jq -nc --arg p "$rp" '{peer: $p, metric: 100, masquerade: true, enabled: true}')
  if [ -z "$rt" ]; then api POST "/api/networks/$net/routers" "$soll" >/dev/null; else api PUT "/api/networks/$net/routers/$rt" "$soll" >/dev/null; fi
  res=$(api GET "/api/networks/$net/resources" | jq -r '.[] | select(.name == "cnc-01") | .id' | head -n1)
  soll=$(jq -nc --argjson g "$(gids_json res-cnc-hersteller)" '{name: "cnc-01", description: "CNC-Bearbeitungszentrum 1", address: "10.20.100.21/32", enabled: true, groups: $g}')
  if [ -z "$res" ]; then api POST "/api/networks/$net/resources" "$soll" >/dev/null; else api PUT "/api/networks/$net/resources/$res" "$soll" >/dev/null; fi
  # Richtlinien: Default aus, die übrigen wie im Buch
  while IFS=$'\t' read -r id n; do
    [ -n "$id" ] || continue
    if [ "$n" = Default ]; then
      api PUT "/api/policies/$id" "$(api GET "/api/policies/$id" | jq -c '{name, description, enabled: false, source_posture_checks,
        rules: [.rules[] | {name, description, enabled, action, bidirectional, protocol, ports: (.ports // []),
        sources: [.sources[]?.id], destinations: [.destinations[]?.id]}]}')" >/dev/null
    elif [[ " ${SOLL_RICHTLINIEN[*]} " != *" $n "* ]]; then
      api DELETE "/api/policies/$id" >/dev/null
    fi
  done < <(api GET /api/policies | jq -r '.[] | [.id, .name] | @tsv')
  for n in "${SOLL_RICHTLINIEN[@]}"; do
    id=$(id_von policies "$n"); soll=$(soll_richtlinie "$n")
    if [ -z "$id" ]; then api POST /api/policies "$soll" >/dev/null; else api PUT "/api/policies/$id" "$soll" >/dev/null; fi
  done
  # Nameserver-Gruppe
  while IFS=$'\t' read -r id n; do
    [ -n "$id" ] && [ "$n" != ns-intern ] && api DELETE "/api/dns/nameservers/$id" >/dev/null
  done < <(api GET /api/dns/nameservers | jq -r '.[] | [.id, .name] | @tsv')
  id=$(id_von dns/nameservers ns-intern)
  if [ -z "$id" ]; then api POST /api/dns/nameservers "$(ns_soll)" >/dev/null; else api PUT "/api/dns/nameservers/$id" "$(ns_soll)" >/dev/null; fi
  # Zone auf dc-sb-01
  mkdir -p "$LAB/dns"
  soll=$(dnsmasq_conf)
  if [ "$soll" != "$(cat "$LAB/dns/dnsmasq.conf" 2>/dev/null)" ]; then
    printf '%s\n' "$soll" > "$LAB/dns/dnsmasq.conf"
    leise docker exec dc-sb-01 pkill dnsmasq
  fi
}

# ==========================================================================
#  install
# ==========================================================================

pruefe_root() { [ "$(id -u)" = 0 ] || abbruch "Bitte mit sudo aufrufen: sudo $0 $*"; }

host_client() {
  if [ ! -x /usr/bin/netbird ] || [ "$(/usr/bin/netbird version 2>/dev/null)" != "$NB_VERSION" ]; then
    docker run --rm --entrypoint cat "netbirdio/netbird:$NB_VERSION" /usr/local/bin/netbird > /usr/bin/netbird.neu &&
      chmod 755 /usr/bin/netbird.neu && mv /usr/bin/netbird.neu /usr/bin/netbird || abbruch "NetBird-Client konnte nicht aus dem Image kopiert werden."
  fi
  if [ ! -e /etc/systemd/system/netbird.service ] && [ ! -e /lib/systemd/system/netbird.service ]; then
    netbird service install >/dev/null 2>&1 || abbruch "netbird service install ist gescheitert."
  fi
  leise systemctl daemon-reload
  leise systemctl enable --now netbird
  warte_auf 30 'NetBird-Dienst auf adm01' netbird status
}

cmd_install() {
  pruefe_root install
  getent passwd "$LAB_USER" >/dev/null || abbruch "Der Benutzer $LAB_USER fehlt (adduser $LAB_USER)."
  [ -e /dev/net/tun ] || abbruch "/dev/net/tun fehlt. Ist das Kernelmodul tun verfügbar (modprobe tun)?"
  info "${C_HI}Saarmetall-Labor $VERSION: Installation (NetBird $NB_VERSION)${C_0}"
  echo
  mkdir -p "$LAB" "$STATE" "$LAB/dns" "$LAB/peer"
  chmod 700 "$STATE"; chmod 750 "$LAB"

  schritt "Pakete installieren (Docker, jq, curl)"
  export DEBIAN_FRONTEND=noninteractive
  leise apt-get update
  apt-get install -y -q docker.io jq curl openssl ca-certificates >/dev/null 2>"$STATE/apt.log" ||
    abbruch "Paketinstallation gescheitert, Details in $STATE/apt.log"
  docker compose version >/dev/null 2>&1 || apt-get install -y -q docker-compose >/dev/null 2>>"$STATE/apt.log" ||
    abbruch "docker compose ist nicht verfügbar, Details in $STATE/apt.log"
  leise systemctl enable --now docker
  usermod -aG docker "$LAB_USER"

  schritt "Zertifikate und Konfiguration erzeugen"
  [ -s "$STATE/relay-secret" ] || openssl rand -base64 32 | tr -d '=\n' > "$STATE/relay-secret"
  [ -s "$STATE/store-key" ] || openssl rand -base64 32 | tr -d '\n' > "$STATE/store-key"
  [ -s "$STATE/owner-pass" ] || openssl rand -base64 24 | tr -d '/+=\n' > "$STATE/owner-pass"
  zertifikate
  server_config > "$LAB/config.yaml"; chmod 640 "$LAB/config.yaml"
  compose_datei > "$LAB/docker-compose.yml"
  peer_dockerfile > "$LAB/peer/Dockerfile"; peer_entrypoint > "$LAB/peer/sm-entrypoint.sh"; peer_dienste > "$LAB/peer/sm-dienste.py"
  : > "$LAB/dns/dnsmasq.conf"
  grep -q "$MGMT_HOST" /etc/hosts || printf '\n# Saarmetall-Labor: Steuerungsebene (Container netbird-server)\n%s\t%s\n' "$SERVER_IP" "$MGMT_HOST" >> /etc/hosts

  schritt "Images laden und bauen (das dauert beim ersten Mal einige Minuten)"
  local img
  for img in "netbirdio/netbird-server:$NB_VERSION" "netbirdio/netbird:$NB_VERSION"; do
    leise docker image inspect "$img" || docker pull -q "$img" >/dev/null ||
      abbruch "Das Image $img konnte nicht geladen werden. Hat die VM Internetzugang?"
  done
  docker build -q -t "saarmetall/peer:$NB_VERSION" "$LAB/peer" >/dev/null 2>>"$STATE/build.log" || abbruch "Das Peer-Image konnte nicht gebaut werden."

  schritt "NetBird-Server starten und Eigentümer anlegen"
  if [ ! -s "$STATE/token" ]; then
    printf 'SETUP_PAT=true\n' > "$LAB/.env"
    dc up -d netbird-server >/dev/null 2>&1 || abbruch "Der NetBird-Server startet nicht: docker logs netbird-server"
    warte_auf 90 'NetBird-Server' curl -sf --max-time 3 "$MGMT/api/instance" || abbruch "Der NetBird-Server antwortet nicht: docker logs netbird-server"
    local antwort
    antwort=$(curl -sS --max-time 20 -X POST -H 'Content-Type: application/json' "$MGMT/api/setup" -d "$(jq -nc --arg e "$OWNER_MAIL" \
      --arg p "$(cat "$STATE/owner-pass")" '{email: $e, name: "Saarmetall Labor", password: $p, create_pat: true, pat_expire_in: 365}')")
    jq -r '.personal_access_token // empty' <<<"$antwort" > "$STATE/token"
    [ -s "$STATE/token" ] || abbruch "Die Ersteinrichtung ist gescheitert: $antwort"
    chmod 600 "$STATE/token"
    printf 'SETUP_PAT=false\n' > "$LAB/.env"
    dc up -d netbird-server >/dev/null 2>&1
    warte_auf 90 'NetBird-Server nach dem Neustart' curl -sf --max-time 3 "$MGMT/api/instance"
  fi
  install -m 600 -o "$LAB_USER" -g "$LAB_USER" "$STATE/token" "$(TH)/.netbird-token"

  schritt "Gruppen anlegen und Peers starten"
  local g n key
  for g in "${GRUPPEN[@]}"; do
    [ -n "$(gid "$g")" ] || api POST /api/groups "$(jq -nc --arg n "$g" '{name: $n}')" >/dev/null || abbruch "API nicht erreichbar"
  done
  { printf 'SETUP_PAT=false\n'
    for n in "${PEERS[@]}"; do
      if [ -z "$(peer_id "$n")" ]; then
        # shellcheck disable=SC2086
        key=$(schluessel_neu "install-$n" one-off 86400 "${PEER_GRUPPEN[$n]}")
        printf 'KEY_%s=%s\n' "$(printf '%s' "$n" | tr 'a-z-' 'A-Z_')" "$key"
      fi
    done; } > "$LAB/.env.neu"
  mv "$LAB/.env.neu" "$LAB/.env"; chmod 600 "$LAB/.env"
  dc up -d >/dev/null 2>&1 || abbruch "Die Container starten nicht: docker compose -p saarmetall ps"
  netze_pruefen
  warte_auf 180 'Anmeldung der Peers' peers_bereit "${PEERS[@]}" || abbruch "Nicht alle Peers haben sich angemeldet. docker logs <name> zeigt den Grund."
  printf 'SETUP_PAT=false\n' > "$LAB/.env"
  while IFS=$'\t' read -r g n; do [[ $n == install-* ]] && api DELETE "/api/setup-keys/$g" >/dev/null; done < <(api GET /api/setup-keys | jq -r '.[] | [.id, .name] | @tsv')

  schritt "adm01 als Admin-Rechner anmelden"
  host_client
  [ -n "$(peer_id adm01)" ] || peer_anmelden adm01

  schritt "Richtlinien, Network, DNS und Posture Check wie im Buch einrichten"
  konto_soll

  schritt "Programm nach $SELF_INSTALLED kopieren"
  [ "$(readlink -f "$0")" = "$SELF_INSTALLED" ] || install -m 755 "$0" "$SELF_INSTALLED"
  liesmich | install -m 644 -o "$LAB_USER" -g "$LAB_USER" /dev/stdin "$(TH)/LIESMICH.txt"
  date '+%F %T' > "$STATE/installiert"
  echo
  info "${C_OK}Fertig.${C_0} Das Netz der Saarmetall GmbH läuft."
  info "Melden Sie sich einmal ab und wieder an, damit $LAB_USER die Gruppe docker erhält."
  info "Erste Schritte stehen in ~/LIESMICH.txt. Danach zum Beispiel: sudo $0 mission 1"
}

liesmich() {
  cat <<EOF
Saarmetall GmbH · NetBird-Labor

Steuerungsebene:  $MGMT (Container netbird-server)
API-Token:        ~/.netbird-token (Eigentümer des Kontos)
Peers:            docker ps
Ihr Rechner:      adm01 (netbird status)

Beispiele:
  TOKEN=\$(cat ~/.netbird-token)
  curl -s -H "Authorization: Token \$TOKEN" $MGMT/api/peers | jq '.[] | {name, ip, connected}'
  docker exec nb-buchhaltung netbird status
  docker exec nb-buchhaltung nc -zv -w 3 erp01.$DNSDOM 443

Missionen:
  sudo saarmetall-lab mission 1     Fehler einbauen
  sudo saarmetall-lab check 1       prüfen
  sudo saarmetall-lab reset         Ausgangszustand
EOF
}

# ==========================================================================
#  grundzustand: Labor in den Ausgangszustand bringen (reset und mission)
# ==========================================================================

papierkorb() {
  local p ziel; ziel="$(TH)/.saarmetall-lab-papierkorb/$(date +%Y%m%d-%H%M%S)"
  for p in "$@"; do [ -e "$p" ] || continue; mkdir -p "$ziel"; mv "$p" "$ziel/"; done
  [ -d "$(TH)/.saarmetall-lab-papierkorb" ] && chown -R "$LAB_USER:" "$(TH)/.saarmetall-lab-papierkorb"
  return 0
}

grundzustand() {
  local n id
  # Container: Vorfall-Peer weg, alle anderen laufen mit leerem Schlüssel in der Umgebung
  if docker ps -a --format '{{.Names}}' | grep -qx build-7f3a; then leise dc --profile vorfall rm -sf build-7f3a; leise docker volume rm saarmetall_state-build-7f3a; fi
  id=$(peer_id build-7f3a); [ -n "$id" ] && api DELETE "/api/peers/$id" >/dev/null
  printf 'SETUP_PAT=false\n' > "$LAB/.env"
  leise dc up -d
  netze_pruefen
  # adm01
  rm -f /root/netbird-setup-key.txt
  systemctl is-enabled -q netbird || leise systemctl enable netbird
  systemctl is-active -q netbird || { leise systemctl start netbird; warte_auf 20 'NetBird-Dienst' netbird status; }
  # Peers registriert und verbunden?
  for n in adm01 "${PEERS[@]}"; do
    if [ -z "$(peer_id "$n")" ] || ! warte_auf 20 "Verbindung von $n" peers_bereit "$n"; then
      peer_anmelden "$n"
    fi
  done
  konto_soll
  papierkorb "$(TH)/berichte/vorfall-netbird.txt"
  rm -f "$STATE/aktiv" "$STATE/merker"
}

# ==========================================================================
#  Missionen: Fehler einbauen (m_N) und Ziele prüfen (c_N)
# ==========================================================================

ZIELE_ALLE=0; ZIELE_OK=0; ANTWORT=''; MISSION=0

ziel() {
  local text=$1; shift
  ZIELE_ALLE=$(( ZIELE_ALLE + 1 ))
  if "$@" >/dev/null 2>&1; then ZIELE_OK=$(( ZIELE_OK + 1 )); printf '  %s[ok]%s    %s\n' "$C_OK" "$C_0" "$text"
  else printf '  %s[fehlt]%s %s\n' "$C_ERR" "$C_0" "$text"; fi
}
ziel_antwort() {
  if [ -z "$ANTWORT" ]; then
    ZIELE_ALLE=$(( ZIELE_ALLE + 1 ))
    printf '  %s[fehlt]%s %s\n          Antwort mitgeben: sudo %s check %d "IHRE ANTWORT"\n' "$C_ERR" "$C_0" "$1" "$0" "$MISSION"
  else
    ziel "$1" antwort_richtig
  fi
}
antwort_richtig() {
  local v; v=$(printf '%s' "$ANTWORT" | tr 'A-Z' 'a-z' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//; s/\.netbird\.selfhosted$//')
  case $MISSION in
    3) [ "$(sha "$v")" = '4055af4fd0531002' ] ;;
    *) return 1 ;;
  esac
}
merke() { echo "$1=$2" >> "$STATE/merker"; }
gemerkt() { awk -F= -v k="$1" '$1 == k {v = $2} END {print v}' "$STATE/merker" 2>/dev/null; }

richtlinie_json() { api GET /api/policies | jq -c --arg n "$1" '.[] | select(.name == $n)' | head -n1; }
# PUT-Körper aus einer gelesenen Richtlinie (Gruppenobjekte werden zu IDs)
als_put() { jq -c '{name, description, enabled, source_posture_checks: (.source_posture_checks // []),
  rules: [.rules[] | {name, description, enabled, action, bidirectional, protocol, ports: (.ports // []),
  sources: [.sources[]?.id], destinations: [.destinations[]?.id]}]}'; }
richtlinie_aendern() { # richtlinie_aendern NAME JQ-FILTER
  local j id; j=$(richtlinie_json "$1"); id=$(jq -r .id <<<"$j")
  api PUT "/api/policies/$id" "$(als_put <<<"$j" | jq -c "$2")" >/dev/null
}
peer_in_gruppe() { api GET /api/peers | jq -e --arg n "$1" --arg g "$2" '.[] | select(.name == $n) | .groups[] | select(.name == $g)' >/dev/null; }
erp() { echo "erp01.$DNSDOM"; }
erp_ip() { peer_ip erp01; }

# ------------------------------------------------------------- Mission 1
m_1() { leise systemctl disable --now netbird; }
c_1() {
  ziel 'Dienst netbird läuft' systemctl is-active -q netbird
  ziel 'Dienst netbird startet beim Booten' systemctl is-enabled -q netbird
  ziel 'adm01 ist mit der Steuerungsebene verbunden' bash -c 'netbird status | grep -q "Management: Connected"'
}

# ------------------------------------------------------------- Mission 2
m_2() {
  local id key
  leise netbird down
  id=$(peer_id adm01); [ -n "$id" ] && api DELETE "/api/peers/$id" >/dev/null
  leise systemctl stop netbird
  rm -rf /var/lib/netbird/*.json /etc/netbird/config.json
  leise systemctl start netbird
  warte_auf 20 'NetBird-Dienst' netbird status
  key=$(schluessel_neu adm01-einrichtung one-off 86400 'nb-it dev-linux')
  install -m 600 /dev/null /root/netbird-setup-key.txt
  printf '%s\n' "$key" > /root/netbird-setup-key.txt
}
c_2() {
  ziel 'adm01 ist verbunden' bash -c 'netbird status | grep -q "Management: Connected"'
  ziel "Angemeldet bei $MGMT" bash -c "netbird status --json | jq -e '.management.url | contains(\"$MGMT_HOST\")'"
  ziel 'adm01 ist in der Gruppe nb-it' peer_in_gruppe adm01 nb-it
  ziel 'Der Setup-Key liegt nicht mehr auf dem Rechner' test ! -e /root/netbird-setup-key.txt
}

# ------------------------------------------------------------- Mission 3
m_3() { leise docker stop zbx01; }
c_3() {
  ziel_antwort 'Fehlenden Peer korrekt gemeldet'
  ziel 'zbx01 ist wieder verbunden' peers_bereit zbx01
}

# ------------------------------------------------------------- Mission 4
m_4() {
  local id k
  id=$(id_von setup-keys notebooks-verwaltung)
  api PUT "/api/setup-keys/$id" "$(api GET "/api/setup-keys/$id" | jq -c '{revoked: true, auto_groups}')" >/dev/null
  id=$(peer_id nb-buchhaltung); [ -n "$id" ] && api DELETE "/api/peers/$id" >/dev/null
  leise dc rm -sf nb-buchhaltung
  leise docker volume rm saarmetall_state-nb-buchhaltung
  k=$(cat "$STATE/key-notebooks-verwaltung" 2>/dev/null)
  printf 'SETUP_PAT=false\nKEY_NB_BUCHHALTUNG=%s\n' "$k" > "$LAB/.env"
  leise dc up -d nb-buchhaltung
  printf 'SETUP_PAT=false\n' > "$LAB/.env"
  sleep 8
}
c_4() {
  schluessel_ok() {
    local kid k
    kid=$(api GET /api/events/audit | jq -r --arg n nb-buchhaltung '[.[] | select(.activity_code == "peer.setupkey.add" and .meta.name == $n)] | sort_by(.timestamp) | last | .initiator_id')
    k=$(api GET /api/setup-keys | jq -c --arg i "$kid" '.[] | select(.id == $i)')
    [ -n "$k" ] && [ "$(jq -r .type <<<"$k")" = one-off ] &&
      jq -e '(.expires | sub("\\.[0-9]+"; "") | fromdateiso8601) - now <= 7 * 86400 + 60 and (.expires | startswith("0001") | not)' <<<"$k"
  }
  ziel 'nb-buchhaltung ist verbunden' peers_bereit nb-buchhaltung
  ziel 'nb-buchhaltung ist in der Gruppe nb-verwaltung' peer_in_gruppe nb-buchhaltung nb-verwaltung
  ziel 'Der verwendete Schlüssel ist einmalig und läuft in höchstens 7 Tagen ab' schluessel_ok
  ziel 'Das ERP antwortet dem Notebook auf Port 443' verbindet nb-buchhaltung "$(erp)" 443
}

# ------------------------------------------------------------- Mission 5
m_5() {
  richtlinie_aendern Default '.enabled = true'
  api DELETE "/api/policies/$(id_von policies 'Verwaltung auf ERP')" >/dev/null
  sleep 5
}
c_5() {
  local ip; ip=$(erp_ip)
  default_aus() { [ -z "$(richtlinie_json Default)" ] || [ "$(richtlinie_json Default | jq -r .enabled)" = false ]; }
  ziel 'Keine aktive Default-Richtlinie mehr' default_aus
  ziel 'Die Buchhaltung erreicht das ERP auf TCP 443' verbindet nb-buchhaltung "$(erp)" 443
  ziel 'Die Buchhaltung erreicht das ERP nicht per SSH (TCP 22)' bash -c "! docker exec nb-buchhaltung nc -z -w 3 $ip 22"
  ziel 'dl-cnc erreicht das ERP nicht mehr' bash -c "! docker exec dl-cnc nc -z -w 3 $ip 443 && ! docker exec dl-cnc nc -z -w 3 $ip 3389"
  monitoring_an() { [ "$(richtlinie_json Monitoring | jq -r .enabled)" = true ]; }
  ziel 'Der Monitoring-Server erreicht seine Agenten weiterhin' monitoring_an
}

# ------------------------------------------------------------- Mission 6
m_6() { richtlinie_aendern ERP-Dienstleister '.rules[0].protocol = "all" | .rules[0].ports = [] | .rules[0].bidirectional = true'; sleep 5; }
c_6() {
  local ip; ip=$(erp_ip)
  regel_ok() { richtlinie_json ERP-Dienstleister | jq -e '.enabled and (.rules | length == 1) and .rules[0].protocol == "tcp" and .rules[0].ports == ["3389"] and (.rules[0].bidirectional | not)'; }
  ziel 'dl-erp erreicht erp01 per Remotedesktop (TCP 3389)' verbindet dl-erp "$(erp)" 3389
  ziel 'dl-erp erreicht erp01 nicht auf anderen Ports' bash -c "! docker exec dl-erp nc -z -w 3 $ip 443 && ! docker exec dl-erp nc -z -w 3 $ip 22"
  ziel 'erp01 kann keine Verbindung zu dl-erp aufbauen (Regel nur in eine Richtung)' regel_ok
}

# ------------------------------------------------------------- Mission 7
m_7() {
  local net rt
  net=$(id_von networks 'Werk Völklingen'); rt=$(api GET "/api/networks/$net/routers" | jq -c '.[0]')
  api PUT "/api/networks/$net/routers/$(jq -r .id <<<"$rt")" "$(jq -c '{peer, peer_groups, metric, masquerade, enabled: false}' <<<"$rt")" >/dev/null
  api DELETE "/api/policies/$(id_von policies CNC-Fernwartung)" >/dev/null
  sleep 5
}
c_7() {
  router_an() { local net; net=$(id_von networks 'Werk Völklingen'); api GET "/api/networks/$net/routers" | jq -e 'any(.[]; .enabled)'; }
  ziel 'Der Routing Peer im Network „Werk Völklingen“ ist aktiv' router_an
  ziel 'dl-cnc erreicht cnc-01 auf Port 5900' verbindet dl-cnc 10.20.100.21 5900
  ziel 'dl-cnc erreicht cnc-01 nicht auf Port 80' bash -c '! docker exec dl-cnc nc -z -w 3 10.20.100.21 80'
  ziel 'dl-cnc erreicht cnc-02 nicht' bash -c '! docker exec dl-cnc nc -z -w 3 10.20.100.22 5900'
}

# ------------------------------------------------------------- Mission 8
m_8() {
  local id j alt
  id=$(id_von dns/nameservers ns-intern); j=$(api GET "/api/dns/nameservers/$id")
  alt=$(peer_ip dc-sb-01 | awk -F. '{printf "%s.%s.%d.%s", $1, $2, ($3 + 7) % 256, $4}')
  merke ns_alt "$alt"
  api PUT "/api/dns/nameservers/$id" "$(jq -c --arg ip "$alt" '{name, description, nameservers: [{ip: $ip, ns_type: "udp", port: 53}], enabled, groups, primary, domains, search_domains_enabled}' <<<"$j")" >/dev/null
  sleep 5
}
c_8() {
  local dc; dc=$(peer_ip dc-sb-01)
  aufgeloest() { [ "$(docker exec nb-buchhaltung dig +short +time=3 +tries=1 "erp01.$INTERN" 2>/dev/null | tail -n1)" = "$(erp_ip)" ]; }
  ns() { api GET /api/dns/nameservers | jq -c '.[] | select(.name == "ns-intern")'; }
  ziel "nb-buchhaltung löst erp01.$INTERN auf" aufgeloest
  auf_dc() { ns | jq -e --arg ip "$dc" 'any(.nameservers[]; .ip == $ip)'; }
  split() { ns | jq -e --arg d "$INTERN" '(.primary | not) and (.domains | index($d))'; }
  ziel 'ns-intern zeigt auf den Domänencontroller dc-sb-01' auf_dc
  ziel 'ns-intern gilt nur für die interne Domain (Split DNS)' split
}

# ------------------------------------------------------------- Mission 9
m_9() {
  local id; id=$(id_von posture-checks 'Client aktuell')
  api PUT "/api/posture-checks/$id" "$(posture_soll | jq -c '.checks.nb_version_check.min_version = "0.90.0"')" >/dev/null
  sleep 5
}
c_9() {
  local pc; pc=$(id_von posture-checks 'Client aktuell')
  gebunden() { richtlinie_json 'Verwaltung auf ERP' | jq -e --arg p "$pc" '.enabled and (.source_posture_checks | index($p))'; }
  version_ok() {
    local v; v=$(api GET "/api/posture-checks/$pc" | jq -r '.checks.nb_version_check.min_version // empty')
    [ -n "$v" ] && [ "$(printf '%s\n%s\n' 0.70.0 "$v" | sort -V | head -n1)" = 0.70.0 ] && [ "$(printf '%s\n%s\n' "$v" "$NB_VERSION" | sort -V | head -n1)" = "$v" ]
  }
  ziel 'Die Buchhaltung erreicht das ERP wieder auf TCP 443' verbindet nb-buchhaltung "$(erp)" 443
  ziel '„Verwaltung auf ERP“ ist weiterhin an den Posture Check gebunden' gebunden
  ziel 'Die Mindestversion ist nicht niedriger als 0.70.0' version_ok
}

# ------------------------------------------------------------- Mission 10
m_10() {
  local key
  key=$(schluessel_neu server-saarbruecken-alt reusable 0 'srv-erp loc-saarbruecken')
  printf 'SETUP_PAT=false\nKEY_BUILD_7F3A=%s\n' "$key" > "$LAB/.env"
  leise dc --profile vorfall up -d build-7f3a
  printf 'SETUP_PAT=false\n' > "$LAB/.env"
  warte_auf 90 'Anmeldung von build-7f3a' peers_bereit build-7f3a
}
c_10() {
  local t; t="$(TH)/berichte/vorfall-netbird.txt"
  key_weg() { local s; s=$(api GET /api/setup-keys | jq -r '.[] | select(.name == "server-saarbruecken-alt") | .state'); [ -z "$s" ] || [ "$s" = revoked ]; }
  bericht_ok() {
    local ts utc lokal
    ts=$(api GET /api/events/audit | jq -r '[.[] | select(.activity_code == "peer.setupkey.add" and .meta.name == "build-7f3a")] | first | .timestamp')
    utc=$(date -u -d "$ts" +%H:%M 2>/dev/null); lokal=$(date -d "$ts" +%H:%M 2>/dev/null)
    grep -q build-7f3a "$t" && grep -q server-saarbruecken-alt "$t" && { grep -q "$utc" "$t" || grep -q "$lokal" "$t"; }
  }
  peer_weg() { [ -z "$(peer_id build-7f3a)" ]; }
  ziel 'Der fremde Peer ist aus dem Konto entfernt' peer_weg
  ziel 'Der Setup-Key server-saarbruecken-alt ist widerrufen oder gelöscht' key_weg
  ziel 'erp01 und die übrigen Server laufen weiter' peers_bereit erp01 dc-sb-01
  ziel 'Der Vorfallbericht nennt Peer, Schlüssel und Uhrzeit' bericht_ok
}

# ==========================================================================
#  Befehle
# ==========================================================================

installiert() { [ -e "$STATE/installiert" ] || abbruch "Das Labor ist noch nicht eingerichtet. Zuerst: sudo $0 install"; }
nummer_pruefen() {
  [[ ${1:-} =~ ^[0-9]+$ ]] || abbruch "Bitte eine Missionsnummer angeben, zum Beispiel: sudo $0 $2 5"
  MISSION=$(( 10#$1 ))
  (( MISSION >= 1 && MISSION <= MISSION_MAX )) || abbruch "Missionen gibt es von 1 bis $MISSION_MAX."
}
umbrechen() { fold -s -w 74 | sed 's/[[:space:]]*$//'; }

cmd_mission() {
  nummer_pruefen "${1:-}" mission; installiert
  schritt "Ausgangszustand herstellen"; grundzustand
  schritt "Fehler aus Mission $MISSION einbauen"; "m_$MISSION"
  echo "$MISSION" > "$STATE/aktiv"
  echo; ticket "$MISSION"
  printf '%sMission %d: %s%s  (%s)\nVon: %s\n\n' "$C_HI" "$MISSION" "$T_TITEL" "$C_0" "$T_BUCH" "$T_VON"
  printf '%s\n' "$T_TEXT" | umbrechen; echo
  printf '%sAuftrag:%s ' "$C_HI" "$C_0"; printf '%s\n' "$T_AUFTRAG" | umbrechen | sed '2,$s/^/         /'
  echo
  if [ "$MISSION" = 3 ]; then info "Antwort prüfen:  sudo $0 check $MISSION \"IHRE ANTWORT\""; else info "Ergebnis prüfen: sudo $0 check $MISSION"; fi
}
cmd_check() {
  nummer_pruefen "${1:-}" check; installiert
  ANTWORT=${2:-}
  local a; a=$(cat "$STATE/aktiv" 2>/dev/null)
  [ -n "$a" ] && [ "$a" != "$MISSION" ] && warnung "Eingebaut ist gerade Mission $a. Für Mission $MISSION zuerst: sudo $0 mission $MISSION"
  ticket "$MISSION"; printf 'Mission %d: %s\n' "$MISSION" "$T_TITEL"
  "c_$MISSION"
  if (( ZIELE_OK == ZIELE_ALLE )); then printf '%sAlle Ziele erfüllt. Mission %d ist abgeschlossen.%s\n' "$C_OK" "$MISSION" "$C_0"; return 0; fi
  printf '%d von %d Zielen erfüllt.\n' "$ZIELE_OK" "$ZIELE_ALLE"; return 1
}
cmd_reset() { installiert; schritt "Ausgangszustand herstellen"; grundzustand; info "Erledigt. Das Netz der Saarmetall GmbH ist im Ausgangszustand."; }
cmd_list() {
  local n a; a=$(cat "$STATE/aktiv" 2>/dev/null)
  printf '\n%sDas NetBird-Handbuch · Missionen bei der Saarmetall GmbH%s\n' "$C_HI" "$C_0"
  for (( n = 1; n <= MISSION_MAX; n++ )); do
    ticket "$n"
    printf '  %2d  %-36s %s%s\n' "$n" "$T_TITEL" "$T_BUCH" "$([ "$n" = "$a" ] && printf '  %s(eingebaut)%s' "$C_OK" "$C_0")"
  done; echo
}
cmd_status() {
  installiert
  local a n; a=$(cat "$STATE/aktiv" 2>/dev/null)
  info "Saarmetall-Labor $VERSION (NetBird $NB_VERSION), eingerichtet am $(cat "$STATE/installiert")"
  if [ -n "$a" ]; then ticket "$a"; info "Eingebaut: Mission $a ($T_TITEL)"; else info "Eingebaut: keine Mission (Ausgangszustand)"; fi
  echo
  pz() { if "$@" >/dev/null 2>&1; then printf '  %s[ok]%s    ' "$C_OK" "$C_0"; else printf '  %s[fehlt]%s ' "$C_ERR" "$C_0"; fi; }
  pz curl -sf --max-time 5 "$MGMT/api/instance"; echo "NetBird-Server $MGMT"
  pz api GET /api/users/current; echo 'API-Token gültig'
  for n in adm01 "${PEERS[@]}"; do pz peers_bereit "$n"; echo "Peer $n verbunden"; done
}
cmd_hilfe() {
  cat <<EOF
saarmetall-lab.sh $VERSION · Praxislabor zum Buch „Das NetBird-Handbuch“ (NetBird $NB_VERSION)

  sudo $0 install          Labor einrichten (Docker, NetBird-Server, Peers)
  sudo $0 mission NR       Fehler aus Mission NR einbauen
  sudo $0 check NR         Ziele von Mission NR prüfen
  sudo $0 check NR ANTWORT Antwort einer Mission prüfen
  sudo $0 reset            Ausgangszustand herstellen
  sudo $0 list             Missionen anzeigen
  sudo $0 status           Zustand des Labors
EOF
}

main() {
  local b=${1:-hilfe}; shift || true
  case $b in
    install) cmd_install "$@" ;;
    mission) pruefe_root; cmd_mission "$@" ;;
    check|pruefen) pruefe_root; cmd_check "$@" ;;
    reset) pruefe_root; cmd_reset ;;
    list|liste) cmd_list ;;
    status) pruefe_root; cmd_status ;;
    hilfe|help|-h|--help) cmd_hilfe ;;
    *) cmd_hilfe; exit 2 ;;
  esac
}

# ==========================================================================
#  Tickets der Missionen (Texte aus AdminMission)
# ==========================================================================

ticket() {
  T_TITEL=''; T_VON=''; T_TEXT=''; T_AUFTRAG=''; T_BUCH=''
  case $1 in
    1) T_TITEL='Der Agent schläft'; T_VON='Lena Kraus (Administration)'; T_BUCH='Kapitel 6.4'
       T_TEXT='Dein Arbeitsplatz adm01 taucht im Dashboard seit dem Wochenende als offline auf. Der NetBird-Client ist installiert, aber offenbar läuft er nicht. Ohne ihn erreichst du keinen einzigen Server.'
       T_AUFTRAG='Bring den NetBird-Dienst auf adm01 wieder zum Laufen, sorge dafür, dass er beim Booten startet, und prüfe die Verbindung mit netbird status.' ;;
    2) T_TITEL='Anmelden am eigenen Management'; T_VON='Martin Scholl (IT-Leitung)'; T_BUCH='Kapitel 5 und 8.3'
       T_TEXT='adm01 wurde neu aufgesetzt und hängt noch an keiner Instanz. Wir betreiben NetBird selbst unter https://netbird.saarmetall.example. Lena hat dir einen Einmal-Setup-Key unter /root/netbird-setup-key.txt abgelegt. Er bringt den Rechner automatisch in die Gruppe nb-it.'
       T_AUFTRAG='Melde adm01 mit dem Setup-Key bei https://netbird.saarmetall.example an und entferne den Schlüssel danach vom Rechner.' ;;
    3) T_TITEL='Wer fehlt?'; T_VON='Monitoring (automatischer Alarm)'; T_BUCH='Kapitel 4.4'
       T_TEXT='Peer-Zähler auf adm01 auffällig: Es sind weniger Peers verbunden als erwartet. Eine Gegenstelle antwortet nicht mehr.'
       T_AUFTRAG='Finde mit netbird status heraus, welcher Peer offline ist, und melde seinen Namen. Bring ihn danach wieder online.' ;;
    4) T_TITEL='Setup-Key abgelehnt'; T_VON='Monika Berger (Buchhaltung)'; T_BUCH='Kapitel 8.3'
       T_TEXT='Mein Notebook wurde neu installiert und kommt seitdem nicht mehr ins Netz. Die IT hat mir gesagt, es meldet sich automatisch an. Tut es aber nicht. Ich komme nicht ins ERP.'
       T_AUFTRAG='Finde heraus, warum sich nb-buchhaltung nicht anmelden kann. Lege einen neuen Einmal-Setup-Key mit höchstens 7 Tagen Laufzeit für die Gruppe nb-verwaltung an und melde das Notebook damit an.' ;;
    5) T_TITEL='Default-Allow muss weg'; T_VON='Sabine Weiler (Informationssicherheit)'; T_BUCH='Kapitel 9.1 bis 9.4'
       T_TEXT='Beim Pentest konnte sich der Dienstleister-Zugang dl-cnc mit dem ERP-Server verbinden. Das darf nicht sein. Offenbar hat jemand die Default-Richtlinie wieder eingeschaltet und dafür „Verwaltung auf ERP“ gelöscht, weil „es dann einfach ging“.'
       T_AUFTRAG='Schalte die Default-Richtlinie ab oder lösche sie und lege die Richtlinie „Verwaltung auf ERP“ wieder an: nb-verwaltung auf srv-erp, nur TCP 443, in eine Richtung.' ;;
    6) T_TITEL='Zu viel für den ERP-Partner'; T_VON='Sabine Weiler (Informationssicherheit)'; T_BUCH='Kapitel 9.3'
       T_TEXT='Bei der Prüfung der Richtlinien ist mir „ERP-Dienstleister“ aufgefallen: alle Protokolle, in beide Richtungen. Laut Vertrag braucht der Partner nur Remotedesktop auf den ERP-Server. Und der Server soll niemals von sich aus Verbindungen zum Dienstleister aufbauen.'
       T_AUFTRAG='Beschränke die Richtlinie „ERP-Dienstleister“ auf TCP 3389 und auf eine Richtung, ohne den Zugang des Partners zu unterbrechen.' ;;
    7) T_TITEL='Fernwartung CNC'; T_VON='Martin Scholl (IT-Leitung)'; T_BUCH='Kapitel 11'
       T_TEXT='Der Hersteller der CNC-Steuerungen meldet, dass er nicht mehr auf cnc-01 (10.20.100.21) im Werk Völklingen kommt. Die Steuerung selbst hat keinen NetBird-Client, sie hängt hinter dem Routing Peer rp-vk-01. Nach dem Wartungsfenster am Wochenende ist irgendetwas liegen geblieben.'
       T_AUFTRAG='Sorge dafür, dass dl-cnc die Steuerung cnc-01 über den Routing Peer auf Port 5900 erreicht, aber keine andere Maschine und keinen anderen Port.' ;;
    8) T_TITEL='Interne Namen'; T_VON='Monika Berger (Buchhaltung)'; T_BUCH='Kapitel 12'
       T_TEXT='Mit der Adresse komme ich ins ERP, aber der Link im Handbuch der Buchhaltung, erp01.intern.saarmetall.example, funktioniert nicht. Lena meint, seit der Domänencontroller ausgetauscht wurde.'
       T_AUFTRAG='Repariere die Nameserver-Gruppe ns-intern, sodass nb-buchhaltung erp01.intern.saarmetall.example auflöst. Die Gruppe soll weiterhin nur für die interne Domain gelten.' ;;
    9) T_TITEL='Gesperrt durch den Posture Check'; T_VON='Martin Scholl (IT-Leitung)'; T_BUCH='Kapitel 10.1 und 10.5'
       T_TEXT='Seit heute früh kommt die gesamte Verwaltung nicht mehr ins ERP. Netzwerk und Server sind in Ordnung. Sabine hat gestern die Gerätechecks „verschärft“. Bitte reparieren, aber den Check nicht einfach entfernen, er ist eine Auflage aus dem Audit.'
       T_AUFTRAG='Finde den Posture Check, der die Verwaltung aussperrt, und setze die Mindestversion auf einen Wert, den die ausgerollten Clients erfüllen. Der Check bleibt an der Richtlinie.' ;;
    10) T_TITEL='Der fremde Peer'; T_VON='Sabine Weiler (Informationssicherheit)'; T_BUCH='Kapitel 18.1 und 19.8'
       T_TEXT='Alarm aus der Auswertung des Aktivitätsprotokolls: Heute Nacht hat sich ein Peer angemeldet, den niemand kennt. Er ist in einer Servergruppe gelandet. Vermutlich ist ein Setup-Key aus einem alten Skript im Umlauf. Das ist ein Sicherheitsvorfall nach unserem Notfallplan.'
       T_AUFTRAG='Finde den fremden Peer und den Setup-Key, mit dem er sich angemeldet hat. Entferne den Peer, widerrufe den Schlüssel und halte beides in ~/berichte/vorfall-netbird.txt fest (Peer-Name, Schlüsselname, Uhrzeit der Anmeldung).' ;;
  esac
}

main "$@"
