#!/usr/bin/env bash
# Clusev WireGuard gate + peer CLI (HOST-side, root). Invoked via `clusev wg <cmd>` (the host
# wrapper execs this from the deployed repo tree). Puts the panel behind a WG tunnel: the VM is the
# WG server, operator devices peer in, and TCP 80/443 is filtered to the WG subnet by a dedicated
# iptables chain hung off DOCKER-USER. The WG UDP port is NEVER matched. SSH (22) is only filtered if
# the operator opts into the SEPARATE ssh-lock (default off); the server console + `clusev wg down`
# are always a way back in. The panel gate is OFF until `clusev wg up`.
set -euo pipefail

WG_IF="wg0"
WG_DIR="/etc/wireguard"
WG_CONF="${WG_DIR}/${WG_IF}.conf"
GATE_CHAIN="CLUSEV-WG-GATE"
STATE_DIR="/etc/clusev"
WG_ENV="${STATE_DIR}/wg.env"
GATE_MARKER="${STATE_DIR}/wg-gate.enabled"
PANEL_PORTS="80,443"   # see spec §3 post-DNAT note: matches the published container ports (Caddy 80/443)
# OPTIONAL SSH gate (separate, dangerous): SSH (22) is a HOST service — not Docker-published — so it
# can't be filtered via DOCKER-USER; it needs a dedicated chain on the host INPUT chain. ESTABLISHED
# sessions + the wg0 interface + the WG subnet are always allowed, so enabling it never cuts the
# current SSH session, only NEW non-tunnel SSH. Off unless the operator explicitly enables it.
SSH_MARKER="${STATE_DIR}/wg-ssh-gate.enabled"
SSH_CHAIN="CLUSEV-WG-SSH"
# Shape-check for a /N CIDR (single source for the subnet validators below). Single-quoted = literal.
readonly CIDR_RE='^[0-9]{1,3}(\.[0-9]{1,3}){3}/[0-9]{1,2}$'

# Project root (for the run/ bridge dir). The collect timer sets CLUSEV_DIR; otherwise derive it
# from this script's location (docker/wg/clusev-wg.sh -> ../..).
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
CLUSEV_DIR="${CLUSEV_DIR:-$(cd "${SELF_DIR}/../.." && pwd)}"
RUN_DIR="${CLUSEV_DIR}/run"

bold() { printf '\033[1m%s\033[0m\n' "$*"; }
info() { printf '  %s\n' "$*"; }
warn() { printf '\033[33m  ! %s\033[0m\n' "$*" >&2; }
die()  { printf '\033[31m  x %s\033[0m\n' "$*" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
need_root() { [ "$(id -u)" = 0 ] || die "Bitte mit sudo ausfuehren: sudo clusev wg ${1}"; }
# Peer names land in a wg0.conf comment + are matched by awk/grep; restrict to a safe charset so a
# name can never inject extra config lines (defence-in-depth — the caller is already root).
valid_name() { case "$1" in ''|*[!A-Za-z0-9._-]*) return 1 ;; *) return 0 ;; esac; }

# ── subnet helpers (SP1 assumes the default /24; documented in the runbook) ───────────────────
subnet_first_ip() { local b="${1%%/*}"; printf '%s.%s\n' "${b%.*}" "$(( ${b##*.} + 1 ))"; }

# Heuristic collision check: warn+re-prompt if the chosen subnet's /24 prefix already appears in the
# host's routes or addresses. Not a formal CIDR-overlap proof — it is the practical guard the spec
# asks for (catch a subnet that clashes with an existing interface/route), so a default cannot slip
# past a LAN collision.
subnet_collides() {
  local net="${1%%/*}" prefix
  prefix="${net%.*}"   # first three octets, e.g. 10.99.0
  ip -o addr 2>/dev/null | awk '{print $4}' | grep -q "^${prefix}\." && return 0
  ip -o route 2>/dev/null | awk '{print $1}' | grep -q "^${prefix}\." && return 0
  return 1
}

detect_endpoint_ip() {
  # Same lookup as install.sh: public IP via ipify, fall back to the first local address.
  curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null \
    || hostname -I 2>/dev/null | awk '{print $1}'
}

require_setup() { [ -f "$WG_CONF" ] && [ -f "$WG_ENV" ] || die "Keine WG-Konfiguration — erst 'clusev wg setup' ausfuehren."; }

# ── the gate ──────────────────────────────────────────────────────────────────────────────────
gate_apply() {
  # Called by `up` and by the systemd unit on boot/Docker-restart. No-op (success) unless the
  # marker says the gate is enabled, so the boot unit never gates an operator who ran `down`.
  [ -f "$GATE_MARKER" ] || { info "Gate-Marker fehlt — Gate nicht angewendet."; return 0; }
  have iptables || die "iptables nicht gefunden."
  # shellcheck disable=SC1090
  [ -f "$WG_ENV" ] && . "$WG_ENV"
  [ -n "${WG_SUBNET:-}" ] || die "WG_SUBNET unbekannt (${WG_ENV} fehlt) — Gate nicht angewendet."
  iptables -nL DOCKER-USER >/dev/null 2>&1 || die "DOCKER-USER-Chain fehlt (laeuft Docker?) — Gate nicht angewendet."

  iptables -N "$GATE_CHAIN" 2>/dev/null || iptables -F "$GATE_CHAIN"
  iptables -A "$GATE_CHAIN" -i lo -j RETURN
  iptables -A "$GATE_CHAIN" -s "$WG_SUBNET" -p tcp -m multiport --dport "$PANEL_PORTS" -j RETURN
  iptables -A "$GATE_CHAIN" -p tcp -m multiport --dport "$PANEL_PORTS" -j DROP
  # exactly one jump at the top of DOCKER-USER (idempotent)
  iptables -D DOCKER-USER -j "$GATE_CHAIN" 2>/dev/null || true
  iptables -I DOCKER-USER 1 -j "$GATE_CHAIN"
}

gate_remove() {
  have iptables || return 0
  iptables -D DOCKER-USER -j "$GATE_CHAIN" 2>/dev/null || true
  iptables -F "$GATE_CHAIN" 2>/dev/null || true
  iptables -X "$GATE_CHAIN" 2>/dev/null || true
}

# ── the SSH gate (optional, host INPUT chain) ───────────────────────────────────────────────────
ssh_gate_apply() {
  # No-op unless the operator enabled it. Applied alongside the panel gate by the boot unit + `up`.
  # Every guard below is FAIL-OPEN (return 0 = no DROP installed = SSH stays reachable): a missing
  # marker, no iptables, a tunnel that is down, or a corrupt subnet must NEVER wall off SSH.
  [ -f "$SSH_MARKER" ] || return 0
  have iptables || return 0
  # Self-guard (defence-in-depth; the boot unit also has ConditionPathExists=wg0): never install the
  # DROP while the tunnel interface is down — that is exactly when it would lock the operator out.
  [ -e "/sys/class/net/${WG_IF}" ] || return 0
  # shellcheck disable=SC1090
  [ -f "$WG_ENV" ] && . "$WG_ENV"
  [ -n "${WG_SUBNET:-}" ] || return 0
  # Re-validate the subnet shape before it reaches `iptables -s`; a malformed value fails open
  # (no DROP) rather than aborting mid-chain and leaving SSH half-filtered.
  printf '%s' "$WG_SUBNET" | grep -qE "$CIDR_RE" || return 0
  iptables -N "$SSH_CHAIN" 2>/dev/null || iptables -F "$SSH_CHAIN"
  iptables -A "$SSH_CHAIN" -i lo -j RETURN
  iptables -A "$SSH_CHAIN" -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN  # never cut a live session
  iptables -A "$SSH_CHAIN" -i "$WG_IF" -j RETURN                                 # SSH over the tunnel
  iptables -A "$SSH_CHAIN" -s "$WG_SUBNET" -j RETURN                             # belt + suspenders
  iptables -A "$SSH_CHAIN" -p tcp --dport 22 -j DROP                             # everyone else
  iptables -D INPUT -p tcp --dport 22 -j "$SSH_CHAIN" 2>/dev/null || true
  iptables -I INPUT 1 -p tcp --dport 22 -j "$SSH_CHAIN"
}

ssh_gate_remove() {
  have iptables || return 0
  iptables -D INPUT -p tcp --dport 22 -j "$SSH_CHAIN" 2>/dev/null || true
  iptables -F "$SSH_CHAIN" 2>/dev/null || true
  iptables -X "$SSH_CHAIN" 2>/dev/null || true
}

# Cores: enable/disable the SSH gate (marker + apply/remove). Refuse to enable if wg0 is down (so a
# broken tunnel can't be the moment you also wall off SSH). Errors to stderr.
_ssh_gate_on() {
  require_setup
  [ -e "/sys/class/net/${WG_IF}" ] || { echo "wg0 nicht aktiv" >&2; return 1; }
  mkdir -p "$STATE_DIR"
  printf 'enabled\n' > "$SSH_MARKER"
  ssh_gate_apply
}
_ssh_gate_off() {
  rm -f "$SSH_MARKER"
  ssh_gate_remove
}

# ── commands ──────────────────────────────────────────────────────────────────────────────────

# Core: create a peer + print ONLY the client config to stdout (errors to stderr, non-zero on fail).
# Shared by the interactive cmd_add_peer and the write-bridge serve-request.
_peer_add() {
  local name="$1"
  require_setup
  # shellcheck disable=SC1090
  . "$WG_ENV"
  [ -n "${WG_SUBNET:-}" ] && [ -n "${WG_SERVER_PUBKEY:-}" ] && [ -n "${WG_ENDPOINT:-}" ] || { echo "wg.env unvollstaendig" >&2; return 1; }
  grep -qF "# clusev-peer: ${name}" "$WG_CONF" 2>/dev/null && { echo "Peer existiert bereits" >&2; return 1; }
  local ip cpriv cpub
  ip="$(next_free_ip)" || { echo "kein freier Adressraum" >&2; return 1; }
  cpriv="$(wg genkey)"; cpub="$(printf '%s' "$cpriv" | wg pubkey)"
  cat >> "$WG_CONF" <<EOF

# clusev-peer: ${name}
[Peer]
PublicKey = ${cpub}
AllowedIPs = ${ip}/32
EOF
  wg set "$WG_IF" peer "$cpub" allowed-ips "${ip}/32"
  cat <<EOF
[Interface]
PrivateKey = ${cpriv}
Address = ${ip}/32
DNS = ${WG_DNS:-1.1.1.1}

[Peer]
PublicKey = ${WG_SERVER_PUBKEY}
Endpoint = ${WG_ENDPOINT}
AllowedIPs = ${WG_SUBNET}
PersistentKeepalive = 25
EOF
}

cmd_add_peer() {
  need_root add-peer
  local name="${1:-}"; [ -n "$name" ] || die "Name fehlt: clusev wg add-peer <name>"
  valid_name "$name" || die "Ungueltiger Peer-Name — erlaubt: A-Z a-z 0-9 . _ -"
  local conf; conf="$(_peer_add "$name")" || die "Anlegen fehlgeschlagen: ${conf}"
  # shellcheck disable=SC1090
  [ -f "$WG_ENV" ] && . "$WG_ENV"
  local ip; ip="$(printf '%s\n' "$conf" | awk '/^Address/{print $3; exit}')"
  bold "Peer '${name}' angelegt (${ip%%/*})."
  printf '%s\n' "$conf"
  if have qrencode; then
    printf '%s\n' "$conf" | qrencode -t ansiutf8 || warn "QR-Erzeugung fehlgeschlagen — nutze die Textkonfiguration oben."
  else
    warn "qrencode nicht installiert — nutze die Textkonfiguration oben."
  fi
  info "AllowedIPs=${WG_SUBNET:-} = Split-Tunnel (nur Panel ueber WG). Voll-Tunnel (0.0.0.0/0) moeglich, aber NICHT empfohlen — leitet allen Client-Verkehr ueber Clusev."
}

# Core: remove the named peer from the running interface + wg0.conf. Errors to stderr.
_peer_remove() {
  local name="$1"
  require_setup
  local pub
  pub="$(WANT="# clusev-peer: ${name}" awk '$0==ENVIRON["WANT"]{f=1;next} f&&/PublicKey/{gsub(/[ \t]/,"");sub(/PublicKey=/,"");print;exit}' "$WG_CONF")"
  [ -n "$pub" ] || { echo "Peer '${name}' nicht gefunden" >&2; return 1; }
  wg set "$WG_IF" peer "$pub" remove 2>/dev/null || true
  local tmp; tmp="$(mktemp)"
  WANT="# clusev-peer: ${name}" awk '
    $0==ENVIRON["WANT"] {drop=1; next}
    drop && /^[[:space:]]*$/ {drop=0; next}
    drop {next}
    {print}
  ' "$WG_CONF" > "$tmp"
  install -m 0600 "$tmp" "$WG_CONF"; rm -f "$tmp"
}

cmd_remove_peer() {
  need_root remove-peer
  local name="${1:-}"; [ -n "$name" ] || die "Name fehlt: clusev wg remove-peer <name>"
  valid_name "$name" || die "Ungueltiger Peer-Name — erlaubt: A-Z a-z 0-9 . _ -"
  _peer_remove "$name" || die "Entfernen fehlgeschlagen."
  bold "Peer '${name}' entfernt."
}

# Core: enable the gate (write marker + apply). Refuses if wg0 is down. Errors to stderr.
_gate_up_now() {
  require_setup
  [ -e "/sys/class/net/${WG_IF}" ] || { echo "wg0 nicht aktiv" >&2; return 1; }
  mkdir -p "$STATE_DIR"
  printf 'enabled\n' > "$GATE_MARKER"
  gate_apply
}

cmd_up() {
  need_root up
  require_setup
  [ -e "/sys/class/net/${WG_IF}" ] || die "${WG_IF} ist nicht aktiv. Erst Tunnel testen (siehe 'clusev wg status'), dann 'clusev wg up'."
  warn "Vor dem Gate sicherstellen, dass der Tunnel das Panel erreicht. Notausgang ueber SSH: 'clusev wg down'."
  _gate_up_now
  bold "WireGuard-Gate aktiv — Panel (80/443) nur noch ueber den Tunnel erreichbar."
  info "Notausgang (per SSH, falls der Tunnel nicht erreicht): clusev wg down"
}

cmd_down() {
  need_root down
  # Full escape: removes BOTH gates (panel + the optional SSH gate) + their markers.
  rm -f "$GATE_MARKER" "$SSH_MARKER"
  gate_remove
  ssh_gate_remove
  bold "WireGuard-Gate entfernt — Panel (80/443) wieder oeffentlich erreichbar (vorbehaltlich Cloud-/Host-Firewall)."
  info "SSH-Sperre (falls aktiv) ebenfalls entfernt."
}

cmd_ssh_lock() {
  need_root ssh-lock
  warn "SSH (22) wird gesperrt — danach erreichst du den Host nur noch UEBER DEN TUNNEL oder die Server-Konsole."
  warn "Verlierst du deinen WG-Zugang, kommst du per SSH NICHT mehr rein. Lege dir vorher einen zweiten WG-Peer als Backup an."
  if _ssh_gate_on; then
    bold "SSH-Sperre aktiv — Port 22 nur noch ueber wg0/${WG_IF} bzw. das WG-Subnetz erreichbar."
    info "Aufheben: 'clusev wg ssh-unlock' (oder 'clusev wg down' fuer alle Gates) — von der Server-Konsole aus immer moeglich."
  else
    die "SSH-Sperre fehlgeschlagen (wg0 aktiv? Setup vorhanden?)."
  fi
}

cmd_ssh_unlock() {
  need_root ssh-unlock
  _ssh_gate_off
  bold "SSH-Sperre entfernt — Port 22 wieder normal erreichbar (vorbehaltlich Cloud-/Host-Firewall)."
}

next_free_ip() {
  # shellcheck disable=SC1090
  . "$WG_ENV"
  [ -n "${WG_SUBNET:-}" ] || die "WG_SUBNET fehlt in ${WG_ENV} — Setup erneut ausfuehren."
  local prefix="${WG_SUBNET%%/*}"; prefix="${prefix%.*}"   # first three octets (SP1: /24)
  local used; used="$(awk -F= '/AllowedIPs/{gsub(/[ \t]/,"",$2);split($2,a,"/");print a[1]}' "$WG_CONF" 2>/dev/null || true)"
  used="$(printf '%s\n%s\n' "$used" "${WG_SERVER_IP:-}")"
  local i cand
  for i in $(seq 2 254); do
    cand="${prefix}.${i}"
    printf '%s\n' "$used" | grep -qx "$cand" || { printf '%s\n' "$cand"; return 0; }
  done
  return 1
}

cmd_status() {
  need_root status
  bold "WireGuard (${WG_IF})"
  if [ -e "/sys/class/net/${WG_IF}" ]; then wg show "$WG_IF" 2>/dev/null || true; else info "wg0 nicht aktiv (Setup/Tunnel noch nicht gestartet)."; fi
  echo
  bold "Gate"
  if [ -f "$GATE_MARKER" ]; then info "Panel-Marker: aktiv (${GATE_MARKER})"; else info "Panel-Marker: aus"; fi
  if iptables -nL "$GATE_CHAIN" >/dev/null 2>&1; then info "Chain ${GATE_CHAIN}: vorhanden"; else info "Chain ${GATE_CHAIN}: nicht vorhanden"; fi
  if [ -f "$SSH_MARKER" ]; then info "SSH-Sperre: AKTIV (${SSH_MARKER}) — Notausgang nur ueber den Tunnel/Konsole"; else info "SSH-Sperre: aus"; fi
  echo
  bold "Dienst wg-quick@${WG_IF}"
  systemctl is-active "wg-quick@${WG_IF}" 2>/dev/null || true
}

# Non-interactive setup for the dashboard write-bridge. Args are re-validated here (defence in
# depth). Refuses to clobber an existing wg0.conf. Does NOT enable the gate. On success prints the
# first peer's client config to stdout (for the show-once modal). Errors to stderr, non-zero on fail.
_setup_noninteractive() {
  local subnet="$1" server_ip="$2" port="$3" endpoint="$4" peer1="$5"
  have wg || { echo "wireguard-tools fehlt" >&2; return 1; }
  [ -f "$WG_CONF" ] && { echo "WireGuard ist bereits eingerichtet" >&2; return 1; }
  printf '%s' "$subnet" | grep -qE "$CIDR_RE" || { echo "Ungueltiges Subnetz" >&2; return 1; }
  case "$server_ip" in ''|*[!0-9.]*) echo "Ungueltige Server-IP" >&2; return 1 ;; esac
  case "$port" in ''|*[!0-9]*) echo "Ungueltiger Port" >&2; return 1 ;; esac
  [ "$port" -ge 1 ] && [ "$port" -le 65535 ] || { echo "Port ausserhalb 1-65535" >&2; return 1; }
  case "$endpoint" in ''|*[!A-Za-z0-9.:_-]*) echo "Ungueltiger Endpoint" >&2; return 1 ;; esac
  valid_name "$peer1" || { echo "Ungueltiger Peer-Name" >&2; return 1; }
  subnet_collides "$subnet" && { echo "Subnetz kollidiert mit einer bestehenden Route/Adresse" >&2; return 1; }

  _write_server_conf "$subnet" "$server_ip" "$port" "$endpoint"
  systemctl enable --now "wg-quick@${WG_IF}" || { echo "wg-quick@${WG_IF} konnte nicht gestartet werden" >&2; return 1; }
  _peer_add "$peer1"
}

# Generate the server keypair and write wg0.conf + wg.env (both 0600). Shared by both setup paths
# (dashboard _setup_noninteractive + interactive cmd_setup); the caller does `systemctl enable --now`
# and the first-peer add, since those differ (error style return-vs-die, _peer_add vs cmd_add_peer).
_write_server_conf() {
  local subnet="$1" server_ip="$2" port="$3" endpoint="$4"
  # WireGuard requires the endpoint as host:port. If the operator gave a bare host (no colon),
  # append the listen port — otherwise every client config has an invalid `Endpoint =` line.
  case "$endpoint" in *:*) ;; *) endpoint="${endpoint}:${port}" ;; esac
  umask 077; mkdir -p "$WG_DIR" "$STATE_DIR"
  local srv_priv srv_pub prefix
  srv_priv="$(wg genkey)"; srv_pub="$(printf '%s' "$srv_priv" | wg pubkey)"
  prefix="${subnet##*/}"
  cat > "$WG_CONF" <<EOF
[Interface]
Address = ${server_ip}/${prefix}
ListenPort = ${port}
PrivateKey = ${srv_priv}
EOF
  chmod 600 "$WG_CONF"
  cat > "$WG_ENV" <<EOF
WG_SUBNET=${subnet}
WG_SERVER_IP=${server_ip}
WG_PORT=${port}
WG_ENDPOINT=${endpoint}
WG_DNS=1.1.1.1
WG_SERVER_PUBKEY=${srv_pub}
EOF
  chmod 600 "$WG_ENV"
}

cmd_setup() {
  need_root setup
  have wg || die "wireguard-tools nicht installiert (erwartet via install.sh)."
  if [ -f "$WG_CONF" ]; then
    warn "${WG_CONF} existiert bereits — bestehende Peers gingen bei Neukonfiguration verloren."
    read -rp "  Trotzdem neu konfigurieren? [reconfigure/abort] (abort): " ans || ans=abort
    [ "${ans:-abort}" = "reconfigure" ] || die "Abgebrochen — bestehende Konfiguration unberuehrt."
  fi

  local subnet default_subnet="10.99.0.0/24"
  while :; do
    if ! read -rp "  WG-Subnetz (privates /24, darf NICHT mit LAN/VPN kollidieren) [${default_subnet}]: " subnet; then
      subnet="$default_subnet"   # closed stdin → take the default once
    fi
    subnet="${subnet:-$default_subnet}"
    if subnet_collides "$subnet"; then
      warn "Subnetz ${subnet} ueberschneidet eine bestehende Route/Adresse — bitte ein anderes waehlen."
      [ -t 0 ] || die "Subnetz-Kollision und keine interaktive Eingabe moeglich (stdin geschlossen)."
      continue
    fi
    break
  done

  local server_ip default_ip port endpoint peer1
  default_ip="$(subnet_first_ip "$subnet")"
  read -rp "  Server-Tunnel-IP [${default_ip}]: " server_ip || true; server_ip="${server_ip:-$default_ip}"
  read -rp "  Listen-Port (UDP) [51820]: " port || true; port="${port:-51820}"
  local ep; ep="$(detect_endpoint_ip)"
  if [ -n "$ep" ]; then
    local default_ep="${ep}:${port}"
    info "Hinweis: hinter NAT/Cloud-LB ist die erkannte IP evtl. NICHT die Waehl-Adresse der Clients — vor 'clusev wg up' pruefen."
    read -rp "  Oeffentlicher Endpoint (IP oder DNS:Port) [${default_ep}]: " endpoint || true
    endpoint="${endpoint:-$default_ep}"
  else
    warn "Oeffentliche IP konnte nicht erkannt werden — bitte Endpoint manuell angeben."
    read -rp "  Oeffentlicher Endpoint (IP oder DNS:Port, erforderlich): " endpoint || true
  fi
  [ -n "$endpoint" ] || die "Endpoint erforderlich."
  read -rp "  Name des ersten Peers [client-1]: " peer1 || true; peer1="${peer1:-client-1}"

  _write_server_conf "$subnet" "$server_ip" "$port" "$endpoint"
  systemctl enable --now "wg-quick@${WG_IF}" || die "wg-quick@${WG_IF} konnte nicht gestartet werden — 'journalctl -u wg-quick@${WG_IF}' pruefen."
  cmd_add_peer "$peer1"
  echo
  bold "Setup fertig. Tunnel ist OBEN, aber das Gate ist AUS (Panel weiter oeffentlich)."
  info "1) Client importieren (QR oben), 2) http://${server_ip} ueber den Tunnel oeffnen, 3) dann: clusev wg up"
}

# Minimal-but-correct JSON string escaper (backslash, quote, CR, tab, newline).
_json_str() {
  local s="$1"
  s="${s//\\/\\\\}"
  s="${s//\"/\\\"}"
  s="${s//$'\r'/}"
  s="${s//$'\t'/\\t}"
  s="${s//$'\n'/\\n}"
  printf '"%s"' "$s"
}

# Write run/wg-result-<id>.json (0600, owned by the run/ owner = the app uid).
_write_result() {
  local id="$1" ok="$2" msg="$3" config="$4"
  local f="${RUN_DIR}/wg-result-${id}.json" cfg='null'
  [ -n "$config" ] && cfg="$(_json_str "$config")"
  mkdir -p "$RUN_DIR" 2>/dev/null || true
  printf '{"id":"%s","ok":%s,"message":%s,"config":%s,"at":%s}\n' \
    "$id" "$ok" "$(_json_str "$msg")" "$cfg" "$(date +%s 2>/dev/null || echo 0)" > "${f}.tmp"
  mv -f "${f}.tmp" "$f" 2>/dev/null || { rm -f "${f}.tmp"; return 0; }
  chown "$(stat -c %u:%g "$RUN_DIR" 2>/dev/null || echo 0:0)" "$f" 2>/dev/null || true
  chmod 600 "$f" 2>/dev/null || true
}

# Process one UI write-request. Whitelists the action + re-validates every arg HOST-SIDE.
cmd_serve_request() {
  need_root serve-request
  umask 077   # result + QR sidecar carry a private key — born 0600, never world-readable in run/ (0775)
  local req="${RUN_DIR}/wg-request.json" work="${RUN_DIR}/wg-request.processing"
  [ -f "$req" ] || return 0
  mv -f "$req" "$work" 2>/dev/null || return 0
  local json id action name
  json="$(cat "$work" 2>/dev/null || true)"; rm -f "$work"

  # Each field is OPTIONAL per action (e.g. gate-up carries no name/endpoint/port/subnet, setup may
  # carry an empty endpoint). The trailing `|| true` is LOAD-BEARING: under `set -euo pipefail` a
  # non-matching grep makes the pipeline fail, which would abort the whole handler BEFORE it writes a
  # result — the app then never gets a reply and times out ("Keine Antwort vom Host"). Never remove it.
  id="$(printf '%s' "$json"      | grep -oE '"id":"[A-Za-z0-9]{16,64}"'       | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
  [ -n "$id" ] || return 0
  action="$(printf '%s' "$json"  | grep -oE '"action":"[a-z-]{1,24}"'         | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
  name="$(printf '%s' "$json"    | grep -oE '"name":"[A-Za-z0-9._-]{1,64}"'   | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
  local endpoint port subnet dns
  endpoint="$(printf '%s' "$json" | grep -oE '"endpoint":"[A-Za-z0-9.:_-]{1,128}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
  port="$(printf '%s' "$json"     | grep -oE '"port":"[0-9]{1,5}"'                 | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
  subnet="$(printf '%s' "$json"   | grep -oE '"subnet":"[0-9./]{1,18}"'            | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
  dns="$(printf '%s' "$json"      | grep -oE '"dns":"[0-9., ]{1,128}"'             | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"

  local ok=false msg='ok' config=''
  case "$action" in
    add-peer)
      if valid_name "$name"; then
        if config="$(_peer_add "$name" 2>/dev/null)"; then ok=true; else ok=false; msg='add-failed'; config=''; fi
      else msg='invalid-name'; fi ;;
    # Every handler runs in a SUBSHELL ( ... ): these helpers call require_setup, which `die`s (exit)
    # on a not-yet-configured tunnel. Without the subshell that exit would abort serve-request BEFORE
    # _write_result, so the app would time out instead of getting a clean failure reply. add-peer/setup
    # are already isolated because they run inside a $(...) command substitution (its own subshell).
    remove-peer)
      if valid_name "$name"; then
        if ( _peer_remove "$name" ) >/dev/null 2>&1; then ok=true; else msg='remove-failed'; fi
      else msg='invalid-name'; fi ;;
    gate-up)       if ( _gate_up_now ) >/dev/null 2>&1; then ok=true; else msg='gate-up-failed'; fi ;;
    gate-down)     if ( rm -f "$GATE_MARKER"; gate_remove ) >/dev/null 2>&1; then ok=true; else msg='gate-down-failed'; fi ;;  # panel only; SSH gate is its own toggle
    gate-ssh-on)   if ( _ssh_gate_on )  >/dev/null 2>&1; then ok=true; else msg='ssh-gate-on-failed'; fi ;;
    gate-ssh-off)  if ( _ssh_gate_off ) >/dev/null 2>&1; then ok=true; else msg='ssh-gate-off-failed'; fi ;;
    set-endpoint) if [ -n "$endpoint" ] && ( _set_endpoint "$endpoint" ) >/dev/null 2>&1; then ok=true; else msg='set-endpoint-failed'; fi ;;
    set-port)     if [ -n "$port" ]     && ( _set_port "$port" )         >/dev/null 2>&1; then ok=true; else msg='set-port-failed'; fi ;;
    set-subnet)   if [ -n "$subnet" ]   && ( _set_subnet "$subnet" )     >/dev/null 2>&1; then ok=true; else msg='set-subnet-failed'; fi ;;
    set-dns)      if [ -n "$dns" ]      && ( _set_dns "$dns" )           >/dev/null 2>&1; then ok=true; else msg='set-dns-failed'; fi ;;
    setup)
      local sip ep pn
      sip="$(subnet_first_ip "$subnet" 2>/dev/null || true)"
      ep="$endpoint"; [ -n "$ep" ] || ep="$(detect_endpoint_ip):${port}"
      pn="$name"; [ -n "$pn" ] || pn='client-1'
      if [ -n "$subnet" ] && [ -n "$port" ]; then
        local tmperr; tmperr="$(mktemp 2>/dev/null || echo /tmp/wgsetup.err)"
        if config="$(_setup_noninteractive "$subnet" "$sip" "$port" "$ep" "$pn" 2>"$tmperr")"; then
          ok=true
        else
          ok=false; config=''; msg="$(tr '\n' ' ' < "$tmperr" 2>/dev/null | head -c 120)"; [ -n "$msg" ] || msg='setup-failed'
        fi
        rm -f "$tmperr"
      else
        msg='setup-bad-args'
      fi ;;
    *)         msg='unknown-action' ;;
  esac

  _write_result "$id" "$ok" "$msg" "$config"
  if [ "$ok" = true ] && { [ "$action" = add-peer ] || [ "$action" = setup ]; } && [ -n "$config" ] && have qrencode; then
    local qr="${RUN_DIR}/wg-qr-${id}.svg"
    if printf '%s\n' "$config" | qrencode -t SVG -o "$qr" 2>/dev/null; then
      chown "$(stat -c %u:%g "$RUN_DIR" 2>/dev/null || echo 0:0)" "$qr" 2>/dev/null || true
      chmod 600 "$qr" 2>/dev/null || true
    fi
  fi
}

# Emit run/wg-status.json (atomic temp+mv). Public, NO secrets. If WG isn't configured, emits
# {"configured":false}. Run every few seconds by clusev-wg-collect.timer; the dashboard reads it.
# ── settings mutators (cores: no prints; errors to stderr; non-zero on fail) ──────────────────
_set_endpoint() {
  local ep="$1"; require_setup
  # Re-validated here (defence-in-depth): the bare CLI path cmd_set_endpoint passes $1 unchecked.
  case "$ep" in *[!A-Za-z0-9.:_-]*|'') echo "bad endpoint" >&2; return 1 ;; esac
  # WireGuard requires host:port — append the listen port if the operator gave a bare host (no colon).
  if [ "${ep#*:}" = "$ep" ]; then
    local _p
    # shellcheck disable=SC1090
    _p="$( . "$WG_ENV" 2>/dev/null; printf '%s' "${WG_PORT:-51820}" )"
    ep="${ep}:${_p}"
  fi
  sed -i "s#^WG_ENDPOINT=.*#WG_ENDPOINT=${ep}#" "$WG_ENV"
}

_set_dns() {
  local dns="$1"; require_setup
  # One or more IPv4 addresses, comma/space separated on INPUT (e.g. "10.0.0.1" or "1.1.1.1, 1.0.0.1").
  # Only affects NEW peer configs (the DNS line is baked into each client config at creation).
  case "$dns" in *[!0-9.,\ ]*|'') echo "bad dns" >&2; return 1 ;; esac
  printf '%s' "$dns" | grep -qE '^[0-9]{1,3}(\.[0-9]{1,3}){3}([,[:space:]]+[0-9]{1,3}(\.[0-9]{1,3}){3})*$' || { echo "bad dns" >&2; return 1; }
  # Normalise every comma/space run to a single comma before STORING: WireGuard's peer `DNS =`
  # line wants a comma list, and — critically — an unquoted space in wg.env would split the
  # WG_DNS assignment so the next `. "$WG_ENV"` runs the second address as a command. Store as a
  # single comma-separated token and QUOTE the assignment (defence-in-depth) so sourcing is safe.
  dns="$(printf '%s' "$dns" | tr -s ',[:space:]' ',' | sed -e 's/^,//' -e 's/,$//')"
  if grep -q '^WG_DNS=' "$WG_ENV" 2>/dev/null; then
    sed -i "s#^WG_DNS=.*#WG_DNS=\"${dns}\"#" "$WG_ENV"
  else
    printf 'WG_DNS="%s"\n' "$dns" >> "$WG_ENV"   # older wg.env (pre-DNS) has no line yet
  fi
}

_set_port() {
  local port="$1"; require_setup
  # Re-validated here: cmd_set_port passes $1 unchecked; also adds the 1-65535 range the bridge regex omits.
  case "$port" in ''|*[!0-9]*) echo "bad port" >&2; return 1 ;; esac
  [ "$port" -ge 1 ] && [ "$port" -le 65535 ] || { echo "port out of range" >&2; return 1; }
  sed -i "s#^ListenPort = .*#ListenPort = ${port}#" "$WG_CONF"
  sed -i "s#^WG_PORT=.*#WG_PORT=${port}#" "$WG_ENV"
  systemctl restart "wg-quick@${WG_IF}" || { echo "wg-quick restart failed" >&2; return 1; }
}

_set_subnet() {
  local subnet="$1"; require_setup
  # Re-validated here (defence-in-depth): the bare CLI path cmd_set_subnet passes $1 unchecked.
  case "$subnet" in *[!0-9./]*|'') echo "bad subnet" >&2; return 1 ;; esac
  printf '%s' "$subnet" | grep -qE "$CIDR_RE" || { echo "bad subnet" >&2; return 1; }
  subnet_collides "$subnet" && { echo "subnet collides with an existing route/address" >&2; return 1; }
  local prefix server_ip
  prefix="${subnet##*/}"
  server_ip="$(subnet_first_ip "$subnet")"
  sed -i "s#^Address = .*#Address = ${server_ip}/${prefix}#" "$WG_CONF"
  sed -i "s#^WG_SUBNET=.*#WG_SUBNET=${subnet}#" "$WG_ENV"
  sed -i "s#^WG_SERVER_IP=.*#WG_SERVER_IP=${server_ip}#" "$WG_ENV"
  systemctl restart "wg-quick@${WG_IF}" || { echo "wg-quick restart failed" >&2; return 1; }
}

cmd_set_endpoint() { need_root set-endpoint; local v="${1:-}"; [ -n "$v" ] || die "Endpoint fehlt."; _set_endpoint "$v" || die "Endpoint setzen fehlgeschlagen."; bold "Endpoint gesetzt: ${v}"; }
cmd_set_port()     { need_root set-port;     local v="${1:-}"; [ -n "$v" ] || die "Port fehlt.";     _set_port "$v"     || die "Port setzen fehlgeschlagen.";     bold "Port gesetzt: ${v} (Clients muessen den Port aktualisieren)."; }
cmd_set_subnet()   { need_root set-subnet;   local v="${1:-}"; [ -n "$v" ] || die "Subnetz fehlt.";  _set_subnet "$v"   || die "Subnetz setzen fehlgeschlagen.";  bold "Subnetz gesetzt: ${v} (bestehende Peers muessen neu angelegt werden)."; }
cmd_set_dns()      { need_root set-dns;      local v="${1:-}"; [ -n "$v" ] || die "DNS fehlt.";      _set_dns "$v"      || die "DNS ungueltig — nur IPv4, kommagetrennt.";        bold "DNS gesetzt: ${v} (gilt fuer kuenftige Peer-Configs)."; }

cmd_collect() {
  mkdir -p "$RUN_DIR" 2>/dev/null || true
  # Drop result + QR sidecars older than 60 s (they carry a private key; the app reads them once).
  find "$RUN_DIR" -maxdepth 1 -type f \( -name 'wg-result-*.json' -o -name 'wg-qr-*.svg' \) -mmin +1 -delete 2>/dev/null || true
  local now tmp dest
  now="$(date +%s 2>/dev/null || echo 0)"
  dest="${RUN_DIR}/wg-status.json"
  tmp="$(mktemp "${RUN_DIR}/.wg-status.XXXXXX" 2>/dev/null)" || tmp="${RUN_DIR}/.wg-status.tmp"

  if [ ! -f "$WG_CONF" ]; then
    printf '{"at":%s,"configured":false}\n' "$now" > "$tmp"
    mv -f "$tmp" "$dest" 2>/dev/null || { rm -f "$tmp"; return 0; }
    chmod 644 "$dest" 2>/dev/null || true
    return 0
  fi

  # shellcheck disable=SC1090
  [ -f "$WG_ENV" ] && . "$WG_ENV"
  local gate_marker=false gate_chain=false ssh_marker=false wgq
  [ -f "$GATE_MARKER" ] && gate_marker=true
  iptables -nL "$GATE_CHAIN" >/dev/null 2>&1 && gate_chain=true
  [ -f "$SSH_MARKER" ] && ssh_marker=true
  wgq="$(systemctl is-active "wg-quick@${WG_IF}" 2>/dev/null || echo inactive)"

  # Peers from `wg show wg0 dump`, named via the wg0.conf `# clusev-peer:` comments. Peer names are
  # valid_name-restricted and pubkeys/endpoints are from safe charsets, so the values are JSON-safe.
  local peers_json
  peers_json="$({ wg show "$WG_IF" dump 2>/dev/null || true; } | awk -v conf="$WG_CONF" '
    BEGIN {
      name=""
      while ((getline line < conf) > 0) {
        if (line ~ /^# clusev-peer:/) { sub(/^# clusev-peer:[ \t]*/,"",line); name=line }
        else if (name != "" && line ~ /^PublicKey[ \t]*=/) { p=line; sub(/^PublicKey[ \t]*=[ \t]*/,"",p); names[p]=name; name="" }
      }
      close(conf); out=""; first=1
    }
    NR==1 { next }   # interface line
    {
      pk=$1; endp=$3; hs=$5+0; rx=$6+0; tx=$7+0
      if (endp=="(none)") endp=""
      nm=(pk in names)?names[pk]:""
      rec="{\"name\":\"" nm "\",\"pubkey\":\"" pk "\",\"endpoint\":\"" endp "\",\"latest_handshake\":" hs ",\"rx\":" rx ",\"tx\":" tx "}"
      if (first) { out=rec; first=0 } else { out=out "," rec }
    }
    END { printf "[%s]", out }
  ')"
  [ -n "$peers_json" ] || peers_json="[]"

  printf '{"at":%s,"configured":true,"gate":{"marker":%s,"chain":%s,"ssh":%s},"wg_quick":"%s","server":{"subnet":"%s","server_ip":"%s","port":%s,"endpoint":"%s","dns":"%s","pubkey":"%s"},"peers":%s}\n' \
    "$now" "$gate_marker" "$gate_chain" "$ssh_marker" "$wgq" \
    "${WG_SUBNET:-}" "${WG_SERVER_IP:-}" "${WG_PORT:-0}" "${WG_ENDPOINT:-}" "${WG_DNS:-}" "${WG_SERVER_PUBKEY:-}" \
    "$peers_json" > "$tmp"
  mv -f "$tmp" "$dest" 2>/dev/null || { rm -f "$tmp"; return 0; }
  chmod 644 "$dest" 2>/dev/null || true
}

usage() {
  cat <<'EOF'
clusev wg — WireGuard-Zugang (Host)

  clusev wg setup              WG einrichten (interaktiv): Subnetz, Keys, erster Peer
  clusev wg up                 Gate aktivieren — Panel (80/443) nur ueber den Tunnel
  clusev wg down               Gate entfernen — Panel wieder oeffentlich (Notausgang!)
  clusev wg status             Tunnel, Peers, Gate-Status anzeigen
  clusev wg add-peer <name>    neuen Client anlegen (+ QR-Code)
  clusev wg remove-peer <name> Client entfernen
  clusev wg set-endpoint <ip:port>  oeffentlichen Endpoint aendern
  clusev wg set-port <udp>          Listen-Port aendern (Clients neu)
  clusev wg set-subnet <cidr>       Subnetz aendern (Peers neu anlegen)
  clusev wg set-dns <ip[,ip]>       DNS fuer kuenftige Peer-Configs setzen
  clusev wg ssh-lock                SSH (22) sperren — nur noch ueber den Tunnel (Vorsicht: Aussperr-Gefahr!)
  clusev wg ssh-unlock              SSH-Sperre wieder aufheben

  Der WG-Port bleibt IMMER offen. 'clusev wg down' (Server-Konsole) ist der Notausgang fuer ALLE Gates.
  ssh-lock kann dich aussperren — vorher einen zweiten WG-Peer als Backup-Zugang anlegen.
EOF
}

cmd="${1:-help}"; shift 2>/dev/null || true
case "$cmd" in
  setup)       cmd_setup "$@" ;;
  up)          cmd_up "$@" ;;
  down)        cmd_down "$@" ;;
  status)      cmd_status "$@" ;;
  add-peer)    cmd_add_peer "$@" ;;
  remove-peer) cmd_remove_peer "$@" ;;
  set-endpoint)  cmd_set_endpoint "$@" ;;
  set-port)      cmd_set_port "$@" ;;
  set-subnet)    cmd_set_subnet "$@" ;;
  set-dns)       cmd_set_dns "$@" ;;
  ssh-lock)      cmd_ssh_lock "$@" ;;
  ssh-unlock)    cmd_ssh_unlock "$@" ;;
  gate-apply)  need_root gate-apply; gate_apply; ssh_gate_apply ;;   # called by clusev-wg-gate.service (both gates re-applied on boot)
  collect)     cmd_collect ;;   # called by clusev-wg-collect.timer (read-only; no root needed)
  serve-request) cmd_serve_request ;;   # called by clusev-wg-request.service
  help|-h|--help) usage ;;
  *)           printf 'Unbekannter Befehl: %s\n\n' "$cmd" >&2; usage; exit 64 ;;
esac
