#!/bin/bash
# ============================================================
# alpha-vpn — the root half of VPN Management (WireGuard).
#
#   alpha-vpn status                     interface + peers, machine-readable
#   alpha-vpn conf                       the server config (no private key)
#   alpha-vpn peer-add <name> [ip]       generate a keypair, add the peer
#   alpha-vpn peer-del <pubkey>          remove a peer entirely
#   alpha-vpn peer-off <pubkey>          disable (keep the record)
#   alpha-vpn peer-on  <pubkey>          re-enable
#   alpha-vpn peer-conf <pubkey>         the client config to hand over
#   alpha-vpn service <up|down|restart>
#   alpha-vpn nat <status|on|off>        masquerade the tunnel out of the WAN
#   alpha-vpn setting port <n>           move the listening port (+ firewall)
#   alpha-vpn node-key                   the identity used to reach other locations
#   alpha-vpn node-test <id>             can we reach that location?
#   alpha-vpn node <id> <verb> [args]    run an allow-listed verb over there
#   alpha-vpn proxy-peers                free-proxy upstreams → squid (from vpn-fp-peers.txt)
#   alpha-vpn proxy-route <user> <id|none>  route one browser customer via a free proxy
#
# WHY ROOT: /etc/wireguard is 0700 root — it holds the server private key, and
# every peer change is a `wg` call plus a config rewrite. www-data may do
# neither, so it asks for exactly these verbs.
#
# ⚠️ The panel keeps its own sidecar of peer LABELS (name, group, who) at
# /var/www/alpha-private/vpn-peers.json — never secrets. Only the client's
# PRIVATE key must reach the browser once, at creation; it is written into the
# returned config and never stored on the server.
#
# Every mutating verb rewrites wg0.conf from the live device state and reloads
# without dropping established tunnels (`wg syncconf`), so an admin editing a
# peer does not disconnect everyone else.
# ============================================================
set -euo pipefail

CONF_D=/etc/wireguard
IFACE="${VPN_IFACE:-wg0}"
CONF="$CONF_D/$IFACE.conf"

die() { echo "ERR $*" >&2; exit 1; }
[ -d "$CONF_D" ] || die "wireguard is not installed (no $CONF_D)"
command -v wg >/dev/null 2>&1 || die "the wg command is missing"

# A WireGuard key is 32 bytes → 44 base64 chars: 43 + one '=' pad. (It was
# written {42} once; every single key op then died with "bad public key".)
ck_key()  { [[ "$1" =~ ^[A-Za-z0-9+/]{43}=$ ]] || die "bad public key"; }
ck_name() { [[ "$1" =~ ^[A-Za-z0-9._-]{1,40}$ ]] || die "name: letters, digits, dot, dash, underscore (max 40)"; }
ck_ip()   { [[ "$1" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || die "bad IP"; }

# The interface address, e.g. 10.10.0.1/24 → base 10.10.0 and cidr 24
srv_addr()   { sed -n 's/^ *Address *= *\([^,]*\).*/\1/p' "$CONF" 2>/dev/null | head -1; }
srv_port()   { sed -n 's/^ *ListenPort *= *\([0-9]*\).*/\1/p' "$CONF" 2>/dev/null | head -1; }
srv_base()   { srv_addr | cut -d/ -f1 | awk -F. '{print $1"."$2"."$3}'; }

# Next free host address in the pool: highest allocated + 1, never .1 (the server)
#
# ⚠️ Scans EVERY address in the pool anywhere in the file, not just active
# `AllowedIPs` lines. A DISABLED peer keeps its address reserved in a comment,
# and matching only the live lines handed that same address to a new client —
# so re-enabling the disabled peer would have collided.
next_ip() {
  local base esc last
  base="$(srv_base)"
  esc="${base//./\\.}"
  last="$(grep -oE "${esc}\.[0-9]+" "$CONF" 2>/dev/null | awk -F. '{print $NF}' | sort -n | tail -1)"
  [ -n "${last:-}" ] || last=1
  [ "$last" -ge 254 ] && die "the address pool $base.0/24 is full"
  echo "$base.$((last + 1))"
}

# Is this address spoken for — by a live peer OR a disabled reservation?
ip_taken() { grep -qF "$1/32" "$CONF" 2>/dev/null; }

# ---- other locations (multi-node) ----------------------------------------
# The registry is written by the panel as www-data; the KEY lives here, root-only.
# Every field is re-validated below before it reaches ssh, because a file the web
# user can write must never be trusted to contain a hostname.
NODES=/var/www/alpha-private/vpn-nodes.json
NODE_KEYDIR=/var/lib/alpha-vpn
NODE_KEY=$NODE_KEYDIR/node_key

ck_node() { [[ "${1:-}" =~ ^[a-z0-9_-]{1,24}$ ]] || die "bad location id"; }

ensure_node_key() {
  mkdir -p "$NODE_KEYDIR"; chmod 700 "$NODE_KEYDIR"
  if [ ! -f "$NODE_KEY" ]; then
    ssh-keygen -t ed25519 -N "" -C "alpha-vpn-node" -f "$NODE_KEY" >/dev/null 2>&1
  fi
  chmod 600 "$NODE_KEY" 2>/dev/null || true
}

# host, port and user for one location — printed space-separated, empty if unknown.
# Validation happens HERE: whatever the panel wrote, only these shapes get through.
node_fields() {
  local id="$1"
  [ -r "$NODES" ] || return 0
  python3 - "$NODES" "$id" <<'PY' 2>/dev/null || true
import json,re,sys
try: rows = json.load(open(sys.argv[1]))
except Exception: sys.exit(0)
if isinstance(rows, dict): rows = rows.get("nodes", [])
for n in rows if isinstance(rows, list) else []:
    if str(n.get("id","")) != sys.argv[2]: continue
    host = str(n.get("host",""))
    port = str(n.get("port","22") or "22")
    user = str(n.get("user","root") or "root")
    if not re.fullmatch(r"[A-Za-z0-9.\-]{1,253}", host): sys.exit(0)
    if not re.fullmatch(r"[0-9]{1,5}", port): sys.exit(0)
    if not re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}", user): sys.exit(0)
    print(host, port, user)
    break
PY
}

node_ssh() {  # <host> <port> <user> <command> [timeout]
  # ⚠️ -n: no node verb ever needs stdin, and WITHOUT it ssh drains whatever
  # stdin this helper inherited — a caller feeding a script over `bash -s` had
  # the rest of that script silently eaten by the first node call.
  timeout "${5:-25}" ssh -n -i "$NODE_KEY" -p "$2" \
    -o BatchMode=yes -o ConnectTimeout=6 -o StrictHostKeyChecking=accept-new \
    -o UserKnownHostsFile="$NODE_KEYDIR/known_hosts" "$3@$1" "$4" 2>/dev/null
}

# Rewrite wg0.conf from the CURRENT device, preserving the [Interface] block and
# each peer's comment label. Called after every change so a reboot restores
# exactly what is running.
# ⚠️ Every conditional here is a full if/fi, never `[ x ] && echo`. Under
# `set -e` an AND-list whose test fails returns non-zero, and as the last
# statement of the loop body that killed the script *after* `wg set` had already
# changed the device — leaving a peer live but unwritten to the config. Cost an
# afternoon; do not "simplify" these back.
persist() {
  # ⚠️ EVERY loop variable is local. `read -r pub …` without this overwrote the
  # CALLER's $pub — peer-add then echoed an empty key, so the panel stored no
  # key and every later peer-del/toggle answered "bad public key".
  local tmp label pub psk endpoint allowed handshake rx tx keep
  tmp="$(mktemp)"
  # ⚠️ The header is everything before the first [Peer] — but we WRITE each
  # peer's label as a comment line just above its [Peer], so a naive copy pulls
  # that label into the header and the next run copies it again. One
  # "# admin device" line was multiplying per save until this trimmed the
  # trailing blank/comment lines back off.
  awk '/^\[Peer\]/{exit} {print}' "$CONF" \
    | tac | awk 'BEGIN{trim=1} trim && (/^[[:space:]]*$/ || /^#/) {next} {trim=0} {print}' | tac > "$tmp"
  # disabled peers are config-only (not on the device) — carry them across
  awk '/^# DISABLED /{print ""; print; getline; print}' "$CONF" >> "$tmp" 2>/dev/null || true
  # active peers straight from the device — the source of truth once wg is told
  while IFS=$'\t' read -r pub psk endpoint allowed handshake rx tx keep; do
    [ -n "$pub" ] || continue
    label="$(awk -v k="$pub" '/^# /{c=substr($0,3)} /^PublicKey/ && index($0,k){print c; exit}' "$CONF" 2>/dev/null || true)"
    {
      echo ""
      if [ -n "${label:-}" ]; then echo "# $label"; fi
      echo "[Peer]"
      echo "PublicKey = $pub"
      echo "AllowedIPs = $allowed"
      if [ -n "$keep" ] && [ "$keep" != "off" ]; then echo "PersistentKeepalive = $keep"; fi
    } >> "$tmp"
  done < <(wg show "$IFACE" dump | tail -n +2)
  install -m 0600 -o root -g root "$tmp" "$CONF"
  rm -f "$tmp"
}

case "${1:-}" in

  status)
    # interface line then one line per peer, tab-separated — parsed by lib/system/vpn.php
    # ⚠️ The server's PUBLIC key rides on this line on purpose: `wg show` needs
    # root, so the panel (www-data) cannot read it, and every client config —
    # including the mobile app's — is useless without it. Public by definition.
    echo "iface	$IFACE	$(systemctl is-active "wg-quick@$IFACE" 2>/dev/null || echo inactive)	$(srv_addr)	$(srv_port)	$(wg --version 2>/dev/null | awk '{print $2}')	$(wg show "$IFACE" public-key 2>/dev/null)"
    wg show "$IFACE" dump 2>/dev/null | tail -n +2 | while IFS=$'\t' read -r pub psk endpoint allowed handshake rx tx keep; do
      label="$(awk -v k="$pub" '/^# /{c=substr($0,3)} /^PublicKey/ && index($0,k){print c; exit}' "$CONF" 2>/dev/null || true)"
      echo "peer	$pub	$allowed	$endpoint	$handshake	$rx	$tx	$keep	${label:-}"
    done
    # disabled peers live only in the config (removed from the device), so they
    # would otherwise vanish from the UI entirely
    awk '/^# *DISABLED /{d=substr($0,12)} /^#PublicKey/{if(d!=""){print "off\t"$3"\t"d; d=""}}' "$CONF" 2>/dev/null || true
    ;;

  conf)
    # never print PrivateKey — the UI shows the public shape of the server only
    grep -v -i 'PrivateKey' "$CONF"
    ;;

  next-ip)
    # What the NEXT enrolment will be handed. Deliberately calls the very
    # function that assigns it, so the panel can never display an address that
    # disagrees with the one actually issued (a disabled peer's reservation is
    # counted here exactly as it is there).
    echo "OK $(next_ip)"
    ;;

  peer-add)
    name="${2:-}"; [ -n "$name" ] || die "peer name?"; ck_name "$name"
    ip="${3:-}"
    if [ -n "$ip" ]; then ck_ip "$ip"; else ip="$(next_ip)"; fi
    if ip_taken "$ip"; then die "$ip is already allocated"; fi
    priv="$(wg genkey)"
    pub="$(printf '%s' "$priv" | wg pubkey)"
    wg set "$IFACE" peer "$pub" allowed-ips "$ip/32"
    # label the peer in the config, then persist from the device
    printf '\n# %s\n[Peer]\nPublicKey = %s\nAllowedIPs = %s/32\n' "$name" "$pub" "$ip" >> "$CONF"
    persist
    # the private key is returned ONCE and never written to disk here
    echo "OK $pub $ip $priv"
    ;;

  peer-addkey)
    # Add a peer whose keypair was generated BY THE CLIENT. This is how the
    # mobile app enrols: the phone keeps its private key forever and sends only
    # the public half, so the server never holds a credential that could
    # impersonate a user. peer-add (server-generated) stays for admin-issued
    # configs, where somebody has to hand a .conf over.
    name="${2:-}"; [ -n "$name" ] || die "peer name?"; ck_name "$name"
    key="${3:-}"; ck_key "$key"
    ip="${4:-}"
    if [ -n "$ip" ]; then ck_ip "$ip"; else ip="$(next_ip)"; fi
    if wg show "$IFACE" dump | tail -n +2 | cut -f1 | grep -qxF "$key"; then die "that public key is already enrolled"; fi
    if ip_taken "$ip"; then die "$ip is already allocated"; fi
    wg set "$IFACE" peer "$key" allowed-ips "$ip/32"
    printf '\n# %s\n[Peer]\nPublicKey = %s\nAllowedIPs = %s/32\n' "$name" "$key" "$ip" >> "$CONF"
    persist
    echo "OK $key $ip"
    ;;

  peer-del)
    pub="${2:-}"; ck_key "$pub"
    wg set "$IFACE" peer "$pub" remove 2>/dev/null || true
    # drop its stanza from the config too (persist only writes live peers, but a
    # DISABLED peer is not on the device — remove it explicitly)
    # ⚠️ LITERAL match, never a regex: a base64 key contains + and /, so
    # `$0 ~ k` silently matches the wrong paragraph (or none) and the peer
    # stays in the file. index() is the only safe test here.
    tmp="$(mktemp)"
    awk -v k="$pub" 'BEGIN{RS="";ORS="\n\n"} index($0,k)==0' "$CONF" > "$tmp"
    install -m 0600 -o root -g root "$tmp" "$CONF"; rm -f "$tmp"
    echo "OK removed"
    ;;

  peer-off)
    pub="${2:-}"; ck_key "$pub"
    allowed="$(wg show "$IFACE" dump | awk -v k="$pub" -F'\t' '$1==k{print $4}')"
    label="$(awk -v k="$pub" '/^# /{c=substr($0,3)} /^PublicKey/ && index($0,k){print c; exit}' "$CONF" 2>/dev/null || true)"
    [ -n "$allowed" ] || die "peer is not active"
    wg set "$IFACE" peer "$pub" remove
    persist
    # keep the record so the client can be switched back on with the same IP
    printf '\n# DISABLED %s|%s|%s\n#PublicKey = %s\n' "${label:-peer}" "$allowed" "$(date +%s)" "$pub" >> "$CONF"
    echo "OK disabled"
    ;;

  peer-on)
    pub="${2:-}"; ck_key "$pub"
    line="$(grep -n "^# DISABLED " "$CONF" | while IFS=: read -r n rest; do
      nxt="$(sed -n "$((n + 1))p" "$CONF")"
      case "$nxt" in *"$pub"*) echo "$n|$rest"; break ;; esac
    done)"
    [ -n "$line" ] || die "no disabled record for that peer"
    n="${line%%|*}"; rest="${line#*|}"
    allowed="$(echo "$rest" | awk -F'|' '{print $2}')"
    label="$(echo "$rest" | sed 's/^# DISABLED //' | awk -F'|' '{print $1}')"
    [ -n "$allowed" ] || die "that record has no address"
    sed -i "$((n + 1))d;${n}d" "$CONF"
    wg set "$IFACE" peer "$pub" allowed-ips "$allowed"
    printf '\n# %s\n[Peer]\nPublicKey = %s\nAllowedIPs = %s\n' "${label:-peer}" "$pub" "$allowed" >> "$CONF"
    persist
    echo "OK enabled"
    ;;

  peer-conf)
    # The client config WITHOUT its private key — the panel injects the key it
    # was handed at creation. Printing a stored private key is impossible by
    # design: this script never stores one.
    pub="${2:-}"; ck_key "$pub"
    allowed="$(wg show "$IFACE" dump | awk -v k="$pub" -F'\t' '$1==k{print $4}')"
    [ -n "$allowed" ] || die "peer is not active"
    srvpub="$(wg show "$IFACE" public-key)"
    host="${VPN_ENDPOINT:-$(hostname -I | awk '{print $1}')}"
    cat <<EOF
[Interface]
Address = $allowed
PrivateKey = __CLIENT_PRIVATE_KEY__
DNS = ${VPN_DNS:-1.1.1.1}

[Peer]
PublicKey = $srvpub
Endpoint = $host:$(srv_port)
AllowedIPs = ${VPN_ALLOWED:-$(srv_base).0/24}
PersistentKeepalive = 25
EOF
    ;;

  service)
    case "${2:-}" in
      up)      systemctl start "wg-quick@$IFACE" ;;
      down)    systemctl stop "wg-quick@$IFACE" ;;
      restart) systemctl restart "wg-quick@$IFACE" ;;
      *) die "service up|down|restart" ;;
    esac
    systemctl is-enabled "wg-quick@$IFACE" >/dev/null 2>&1 || systemctl enable "wg-quick@$IFACE" >/dev/null 2>&1 || true
    echo "OK $(systemctl is-active "wg-quick@$IFACE" 2>/dev/null || echo inactive)"
    ;;

  node-key)
    # The identity this server uses to reach its other locations. Generated on
    # first use and kept ROOT-ONLY: www-data must never hold a key that can log
    # into another box as root. The panel only ever sees the PUBLIC half, which
    # it shows you to paste into the remote node's authorized_keys.
    ensure_node_key
    echo "OK $(cat "$NODE_KEY.pub")"
    ;;

  node-test)
    id="${2:-}"; ck_node "$id"
    read -r host port user <<<"$(node_fields "$id")"
    [ -n "$host" ] || die "no such location: $id"
    ensure_node_key
    out="$(node_ssh "$host" "$port" "$user" "/usr/local/sbin/alpha-vpn status 2>/dev/null | head -1" || true)"
    case "$out" in
      iface*) echo "OK $out" ;;
      "")     die "cannot reach $user@$host:$port — check the address, the firewall, and that this server's node key is in the remote root's authorized_keys" ;;
      *)      die "reached $host but alpha-vpn did not answer (is the panel installed there?): $out" ;;
    esac
    ;;

  node)
    # Run one allow-listed verb on another location. The remote alpha-vpn
    # validates every argument again on arrival — this list only limits WHICH
    # verbs may cross the wire at all.
    id="${2:-}"; ck_node "$id"
    verb="${3:-}"
    case "$verb" in
      status|peer-addkey|peer-del|peer-off|peer-on|nat|conf) : ;;
      # browser routing per location: the proxy verbs may cross too. proxy-user
      # only ever carries a crypt HASH over the wire, never a password.
      proxy-status|proxy-user|proxy-stat|proxy-usage|proxy-users|proxy-setup|proxy-who) : ;;
      *) die "verb not allowed on a remote location: $verb" ;;
    esac
    shift 3
    read -r host port user <<<"$(node_fields "$id")"
    [ -n "$host" ] || die "no such location: $id"
    ensure_node_key
    # each argument is quoted for the remote shell; the remote script re-checks them
    rcmd="/usr/local/sbin/alpha-vpn $verb"
    for a in "$@"; do rcmd="$rcmd $(printf '%q' "$a")"; done
    # proxy-setup may apt-install squid on the far end — 25s would kill it mid-flight
    t=25
    if [ "$verb" = "proxy-setup" ]; then t=200; fi
    node_ssh "$host" "$port" "$user" "$rcmd" "$t"
    ;;

  nat)
    # Masquerade the tunnel's traffic out of the WAN interface — the difference
    # between "clients can reach the server" and "clients can reach the internet
    # THROUGH the server". The rule is added to the live table AND to the
    # interface's PostUp/PostDown so it survives a reboot; both halves are
    # removed together, so the two can never disagree.
    sub="$(srv_addr)"
    wan="$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}')"
    [ -n "$sub" ] && [ -n "$wan" ] || die "cannot determine the tunnel subnet or WAN interface"
    net="$(srv_base).0/$(echo "$sub" | cut -d/ -f2)"
    case "${2:-status}" in
      # ⚠️ MASQUERADE ALONE DOES NOTHING HERE. This box runs ufw with
      # DEFAULT_FORWARD_POLICY="DROP", so forwarded packets are dropped long
      # before they reach the nat table: the tunnel came up, handshook, carried
      # traffic — and every packet to the internet vanished. "NAT on" therefore
      # means masquerade AND a forward rule, always together.
      status)
        m=0; f=0
        iptables -t nat -C POSTROUTING -s "$net" -o "$wan" -j MASQUERADE 2>/dev/null && m=1
        iptables -C FORWARD -i "$IFACE" -o "$wan" -j ACCEPT 2>/dev/null && f=1
        if [ "$m" = 1 ] && [ "$f" = 1 ]; then echo "on $net $wan"; else echo "off $net $wan"; fi
        ;;
      on)
        iptables -t nat -C POSTROUTING -s "$net" -o "$wan" -j MASQUERADE 2>/dev/null \
          || iptables -t nat -A POSTROUTING -s "$net" -o "$wan" -j MASQUERADE
        iptables -C FORWARD -i "$IFACE" -o "$wan" -j ACCEPT 2>/dev/null \
          || iptables -I FORWARD 1 -i "$IFACE" -o "$wan" -j ACCEPT
        iptables -C FORWARD -i "$wan" -o "$IFACE" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null \
          || iptables -I FORWARD 2 -i "$wan" -o "$IFACE" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
        # ufw owns the FORWARD chain and rebuilds it on reload, so register the
        # same permission with ufw as well — otherwise a `ufw reload` silently
        # cuts every client off the internet again.
        if command -v ufw >/dev/null 2>&1; then
          ufw route allow in on "$IFACE" out on "$wan" >/dev/null 2>&1 || true
        fi
        if ! grep -q '^PostUp *=.*MASQUERADE' "$CONF"; then
          # replayed by wg-quick on boot, so a restart cannot lose any of it
          sed -i "/^ListenPort/a PostUp = iptables -t nat -A POSTROUTING -s $net -o $wan -j MASQUERADE; iptables -I FORWARD 1 -i $IFACE -o $wan -j ACCEPT; iptables -I FORWARD 2 -i $wan -o $IFACE -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT\nPostDown = iptables -t nat -D POSTROUTING -s $net -o $wan -j MASQUERADE; iptables -D FORWARD -i $IFACE -o $wan -j ACCEPT; iptables -D FORWARD -i $wan -o $IFACE -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT" "$CONF"
        fi
        echo "OK on"
        ;;
      off)
        while iptables -t nat -C POSTROUTING -s "$net" -o "$wan" -j MASQUERADE 2>/dev/null; do
          iptables -t nat -D POSTROUTING -s "$net" -o "$wan" -j MASQUERADE
        done
        while iptables -C FORWARD -i "$IFACE" -o "$wan" -j ACCEPT 2>/dev/null; do
          iptables -D FORWARD -i "$IFACE" -o "$wan" -j ACCEPT
        done
        while iptables -C FORWARD -i "$wan" -o "$IFACE" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null; do
          iptables -D FORWARD -i "$wan" -o "$IFACE" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
        done
        if command -v ufw >/dev/null 2>&1; then
          ufw route delete allow in on "$IFACE" out on "$wan" >/dev/null 2>&1 || true
        fi
        sed -i '/^Post\(Up\|Down\) *=.*MASQUERADE/d' "$CONF"
        echo "OK off"
        ;;
      *) die "nat status|on|off" ;;
    esac
    ;;

  isolation)
    # Client-to-client blocking — the difference between a PUBLIC VPN (customers
    # must never see each other's devices) and a private mesh. The FORWARD
    # policy on this box is DROP, so wg0→wg0 is dropped by accident today; this
    # verb makes the state EXPLICIT and inspectable either way.
    #   on  = explicit DROP rule, and no ACCEPT anywhere (the public default)
    #   off = explicit ACCEPT rule, persisted through reboots via PostUp
    case "${2:-status}" in
      status)
        if iptables -C FORWARD -i "$IFACE" -o "$IFACE" -j ACCEPT 2>/dev/null; then echo "off"; else echo "on"; fi
        ;;
      on)
        while iptables -C FORWARD -i "$IFACE" -o "$IFACE" -j ACCEPT 2>/dev/null; do
          iptables -D FORWARD -i "$IFACE" -o "$IFACE" -j ACCEPT
        done
        iptables -C FORWARD -i "$IFACE" -o "$IFACE" -j DROP 2>/dev/null \
          || iptables -I FORWARD 1 -i "$IFACE" -o "$IFACE" -j DROP
        sed -i "/^Post\(Up\|Down\) *=.*-i $IFACE -o $IFACE -j ACCEPT/d" "$CONF"
        echo "OK on"
        ;;
      off)
        while iptables -C FORWARD -i "$IFACE" -o "$IFACE" -j DROP 2>/dev/null; do
          iptables -D FORWARD -i "$IFACE" -o "$IFACE" -j DROP
        done
        iptables -C FORWARD -i "$IFACE" -o "$IFACE" -j ACCEPT 2>/dev/null \
          || iptables -I FORWARD 1 -i "$IFACE" -o "$IFACE" -j ACCEPT
        if ! grep -q "PostUp *=.*-i $IFACE -o $IFACE -j ACCEPT" "$CONF"; then
          sed -i "/^ListenPort/a PostUp = iptables -I FORWARD 1 -i $IFACE -o $IFACE -j ACCEPT\nPostDown = iptables -D FORWARD -i $IFACE -o $IFACE -j ACCEPT" "$CONF"
        fi
        echo "OK off"
        ;;
      *) die "isolation status|on|off" ;;
    esac
    ;;

  # ------------------------------------------------------------------ browser proxy
  # The Chrome/Firefox extensions are NOT WireGuard — an extension can only
  # proxy the browser. This is that proxy: squid with TLS on :4433 (riding the
  # api.<domain> certificate) and per-customer Basic auth from a users file the
  # panel maintains. Usage is read back per user for the quota cron.
  proxy-setup)
    # /etc/alpha-panel.conf uses PRIMARY_DOMAIN (see lib/domain.php)
    D="$(. /etc/alpha-panel.conf 2>/dev/null; echo "${PRIMARY_DOMAIN:-}")"
    [ -n "$D" ] || die "no PRIMARY_DOMAIN in /etc/alpha-panel.conf"
    CRT="/etc/letsencrypt/live/api.$D/fullchain.pem"
    KEY="/etc/letsencrypt/live/api.$D/privkey.pem"
    [ -r "$CRT" ] && [ -r "$KEY" ] || die "no certificate for api.$D — issue it first"
    command -v squid >/dev/null 2>&1 || {
      export DEBIAN_FRONTEND=noninteractive
      apt-get install -y -qq squid-openssl >/dev/null 2>&1 || die "could not install squid-openssl"
    }
    touch /etc/squid/alphavpn-users
    chown root:proxy /etc/squid/alphavpn-users
    chmod 640 /etc/squid/alphavpn-users
    # squid refuses to start on an EMPTY proxy_auth acl file — seed a name that
    # can never be an email address
    [ -s /etc/squid/alphavpn-denied ] || echo '__none__' > /etc/squid/alphavpn-denied
    chmod 644 /etc/squid/alphavpn-denied
    NCSA="$(ls /usr/lib/squid/basic_ncsa_auth 2>/dev/null || ls /usr/lib64/squid/basic_ncsa_auth 2>/dev/null)"
    [ -n "$NCSA" ] || die "basic_ncsa_auth helper not found"
    cat > /etc/squid/conf.d/alphavpn.conf <<EOF
# AlphaVPN browser-extension proxy — managed by alpha-vpn proxy-setup
https_port 4433 tls-cert=$CRT tls-key=$KEY
auth_param basic program $NCSA /etc/squid/alphavpn-users
auth_param basic realm AlphaVPN
# ⚠️ This is the REVOCATION WINDOW, not a performance knob. Squid keeps a
# validated username:password pair cached for this long, so deleting a user
# from the file (disconnect, over-quota, admin kick) only really bites once it
# expires. The helper is a local file read — 10s costs nothing and makes
# revocation effectively immediate.
auth_param basic credentialsttl 10 seconds
auth_param basic children 20 startup=2 idle=2
# ⚠️ credentialsttl alone is NOT enough: squid ALSO keeps the user record in its
# own cache for authenticate_ttl (default 1 HOUR), and a deleted account kept
# browsing for that long. Both must be short for revocation to mean anything.
authenticate_ttl 15 seconds
authenticate_ip_ttl 0 seconds
acl alphavpn_auth proxy_auth REQUIRED
# ⚠️ REVOCATION. Deleting a user from the password file is NOT enough: squid
# caches the validated pair in memory and kept serving a deleted account until
# a full restart (which would disconnect every other customer). This deny list
# is evaluated per request against the authenticated username, so it beats the
# cache and takes effect on `squid -k reconfigure` — graceful, immediate.
acl alphavpn_denied proxy_auth "/etc/squid/alphavpn-denied"
http_access deny alphavpn_denied
# free-proxy locations: a user routed through one gets a tag here (see proxy-peers)
external_acl_type alphavpn_fp ttl=5 negative_ttl=5 children-max=5 children-startup=1 %un /usr/local/lib/alpha-vpn/fpmap
acl alphavpn_fpmapped external alphavpn_fp
http_access allow alphavpn_auth alphavpn_fpmapped
http_access allow alphavpn_auth
# privacy: never tell the destination who is behind the proxy
forwarded_for delete
via off
request_header_access X-Forwarded-For deny all
cache deny all
# ⚠️ The first four fields are FIXED — proxy-stat and proxy-usage parse them
# positionally (\$3 = up, \$4 = down). New fields may only ever be APPENDED, and
# the User-Agent must stay last because it contains spaces.
# %>a and the agent are what tells the panel WHO is connected (real client
# address + which browser's extension), since a proxy session has no peer.
logformat alphavpn %ts %un %>st %<st %>a "%{User-Agent}>h"
access_log daemon:/var/log/squid/alphavpn.log alphavpn
EOF
    # the external ACL named above must exist before squid starts
    mkdir -p /usr/local/lib/alpha-vpn
    if [ ! -x /usr/local/lib/alpha-vpn/fpmap ]; then
      printf '#!/bin/bash
while IFS= read -r l; do echo ERR; done
' > /usr/local/lib/alpha-vpn/fpmap
      chmod 755 /usr/local/lib/alpha-vpn/fpmap
    fi
    touch /etc/squid/alphavpn-fpmap; chown root:proxy /etc/squid/alphavpn-fpmap; chmod 640 /etc/squid/alphavpn-fpmap
    systemctl enable --now squid >/dev/null 2>&1
    systemctl restart squid
    command -v ufw >/dev/null 2>&1 && ufw allow 4433/tcp >/dev/null 2>&1
    sleep 2
    ss -tln | grep -q ':4433' && echo "OK proxy on api.$D:4433" || die "squid did not open :4433 — check systemctl status squid"
    ;;

  proxy-status)
    if ! command -v squid >/dev/null 2>&1; then echo "MISSING"; exit 0; fi
    # ⚠️ The listening socket disappears for a moment during a reconfigure, so a
    # bare port probe reports STOPPED on a perfectly healthy proxy. Trust the
    # unit state as well — either being true means we can serve.
    if ss -tln 2>/dev/null | grep -q ':4433' || systemctl is-active --quiet squid; then
      echo "RUNNING 4433"
    else
      echo "STOPPED"
    fi
    echo "USERS $(grep -c ':' /etc/squid/alphavpn-users 2>/dev/null || echo 0)"
    # The NAME this proxy must be dialled by — it serves THIS location's
    # api.<domain> certificate, so a panel routing an extension here has to ask
    # rather than guess. (Full if/fi: an && tail under set -e kills the script.)
    D="$(. /etc/alpha-panel.conf 2>/dev/null; echo "${PRIMARY_DOMAIN:-}")"
    if [ -n "$D" ]; then echo "HOST api.$D"; fi
    ;;

  proxy-user)
    # set <user> <hash> | del <user> — the PANEL computes the {SHA} hash; only
    # the hash ever reaches this helper, never the password.
    U="${3:-}"; F=/etc/squid/alphavpn-users
    [[ "$U" =~ ^[a-z0-9._+-]+@[a-z0-9.-]+$ ]] || die "bad user"
    case "${2:-}" in
      set)
        H="${4:-}"
        # ⚠️ squid's basic_ncsa_auth REJECTS Apache's {SHA} form — it wants a
        # crypt(3) hash. PHP's crypt($pw, '$1$'.$salt) produces exactly this.
        case "$H" in
          '$1$'*|'$apr1$'*) ;;
          *) die "bad hash (expected a \$1\$ crypt hash)" ;;
        esac
        case "$H" in *[^A-Za-z0-9./\$]*) die "bad hash" ;; esac
        D=/etc/squid/alphavpn-denied
        touch "$F"; grep -vF "$U:" "$F" > "$F.tmp" 2>/dev/null || true
        printf '%s:%s\n' "$U" "$H" >> "$F.tmp"
        mv "$F.tmp" "$F"; chown root:proxy "$F"; chmod 640 "$F"
        # Reconnecting must lift a previous revocation — but ONLY that case needs
        # a reconfigure. ⚠️ Reconfiguring on every connect made squid drop its
        # listener for a moment, so the NEXT connect saw "proxy not running" and
        # 503'd: pressing Connect failed roughly every other time. A brand-new
        # credential needs nothing — the ncsa helper reads the file per auth.
        touch "$D"
        if grep -qxF "$U" "$D" 2>/dev/null; then
          grep -vxF "$U" "$D" > "$D.tmp" 2>/dev/null || true
          grep -qxF '__none__' "$D.tmp" 2>/dev/null || echo '__none__' >> "$D.tmp"
          mv "$D.tmp" "$D"; chmod 644 "$D"
          squid -k reconfigure 2>/dev/null || true
        fi
        echo "OK set"
        ;;
      del)
        D=/etc/squid/alphavpn-denied
        touch "$F"; grep -vF "$U:" "$F" > "$F.tmp" 2>/dev/null || true
        mv "$F.tmp" "$F"; chown root:proxy "$F"; chmod 640 "$F"
        # the deny list is what actually cuts a live session off
        touch "$D"; grep -qxF "$U" "$D" 2>/dev/null || echo "$U" >> "$D"
        chmod 644 "$D"
        squid -k reconfigure 2>/dev/null || true
        echo "OK del"
        ;;
      *) die "proxy-user set <user> <hash> | del <user>" ;;
    esac
    ;;

  proxy-users)
    # Who currently holds a browser credential, and when the proxy last saw
    # them: "email <tab> last_epoch". The panel shows these as live sessions.
    F=/etc/squid/alphavpn-users
    L=/var/log/squid/alphavpn.log
    [ -f "$F" ] || exit 0
    cut -d: -f1 "$F" | while read -r u; do
      [ -n "$u" ] || continue
      last=$(tail -n 4000 "$L" 2>/dev/null | awk -v U="$u" '$2 == U { t = $1 } END { print int(t) }')
      printf '%s\t%s\n' "$u" "${last:-0}"
    done
    ;;

  proxy-stat)
    # Cumulative bytes for ONE user, read-only. ⚠️ Deliberately does NOT touch
    # the billing offset that `proxy-usage` maintains — the live UI must never
    # consume the deltas the quota cron is waiting for.
    U="${2:-}"
    [[ "$U" =~ ^[a-z0-9._+-]+@[a-z0-9.-]+$ ]] || die "bad user"
    L=/var/log/squid/alphavpn.log
    [ -f "$L" ] || { echo "BYTES 0"; exit 0; }
    # logformat is: %ts %un %>st %<st  →  $3 = from client (UP), $4 = to client (DOWN)
    awk -v U="$U" '$2 == U { u += $3; d += $4 } END { printf "BYTES %d\nDOWN %d\nUP %d\n", d + u, d, u }' "$L"
    ;;

  proxy-who)
    # The client behind ONE browser session: real address + user agent, taken
    # from the newest request that user actually made. Read-only, and it touches
    # no offset — the billing deltas are proxy-usage's business alone.
    U="${2:-}"
    [[ "$U" =~ ^[a-z0-9._+-]+@[a-z0-9.-]+$ ]] || die "bad user"
    L=/var/log/squid/alphavpn.log
    [ -f "$L" ] || exit 0
    # only lines carrying the newer format (>= 5 fields) can answer this
    tail -n 4000 "$L" | awk -v U="$U" '$2 == U && NF >= 5 {
        ip = $5
        ua = ""
        p = index($0, "\"")
        if (p > 0) { ua = substr($0, p + 1); sub(/"[[:space:]]*$/, "", ua) }
      }
      END { if (ip != "" && ip != "-") print "IP " ip; if (ua != "" && ua != "-") print "UA " ua }'
    ;;

  proxy-usage)
    # Per-user bytes since the last call — the quota cron consumes this. Reads
    # the squid log as root, remembers its offset, prints "user bytes" lines.
    L=/var/log/squid/alphavpn.log
    O=/var/lib/alpha-vpn/proxy-log.offset
    mkdir -p /var/lib/alpha-vpn
    [ -f "$L" ] || { echo "NOLOG"; exit 0; }
    sz="$(stat -c%s "$L")"
    off="$(cat "$O" 2>/dev/null || echo 0)"
    case "$off" in ''|*[!0-9]*) off=0 ;; esac
    [ "$off" -gt "$sz" ] && off=0   # rotated
    tail -c +"$((off + 1))" "$L" | awk '$2 != "-" && $2 != "" { u[$2] += $3 + $4 } END { for (x in u) print x, u[x] }'
    echo "$sz" > "$O"
    ;;

  setting)
    k="${2:-}"; v="${3:-}"
    case "$k" in
      port)
        [[ "$v" =~ ^[0-9]{2,5}$ ]] || die "bad port"
        old="$(srv_port)"
        sed -i "s/^ *ListenPort *=.*/ListenPort = $v/" "$CONF"
        wg set "$IFACE" listen-port "$v"
        # the firewall must follow, or the new port is simply unreachable
        command -v ufw >/dev/null 2>&1 && { ufw allow "$v"/udp >/dev/null 2>&1 || true; [ -n "$old" ] && ufw delete allow "$old"/udp >/dev/null 2>&1 || true; }
        echo "OK port $v"
        ;;
      *) die "setting port <value>" ;;
    esac
    ;;

  # ------------------------------------------------------------------ free-proxy locations
  # Third-party HTTP proxies offered as extra browser "locations". They are
  # CHAINED behind our own squid, never handed to the client: the customer
  # still authenticates to us over TLS (so quota, revocation and the deny list
  # all keep working), and squid forwards THAT user's requests to the chosen
  # upstream. Which user goes where is a map file read per request by a tiny
  # external-ACL helper, so routing a user needs no reconfigure.
  proxy-peers)
    # Rewrite the upstream set from the panel's spec file: one "id host port"
    # per line. Every field is re-validated here; the panel is not trusted.
    S=/var/www/alpha-private/vpn-fp-peers.txt
    C=/etc/squid/conf.d/alphavpn.conf
    P=/etc/squid/conf.d/alphavpn.peers.conf
    M=/etc/squid/alphavpn-fpmap
    H=/usr/local/lib/alpha-vpn/fpmap
    [ -f "$C" ] || die "the browser proxy is not set up here (run proxy-setup first)"
    mkdir -p /usr/local/lib/alpha-vpn
    cat > "$H.tmp" <<'FPMAP'
#!/bin/bash
# squid external ACL (alpha-vpn proxy-peers): reads the authenticated user,
# answers "OK tag=<free-proxy id>" when that user is routed through one.
# squid URL-escapes the name (@ → %40), so it is decoded before the lookup.
M=/etc/squid/alphavpn-fpmap
while IFS= read -r line; do
  u="${line%% *}"
  u="$(printf '%b' "${u//%/\\x}")"
  id=""
  if [ -r "$M" ]; then id="$(awk -v u="$u" '$1 == u { print $2; exit }' "$M")"; fi
  if [ -n "$id" ]; then echo "OK tag=$id"; else echo "ERR"; fi
done
FPMAP
    mv "$H.tmp" "$H"; chmod 755 "$H"
    touch "$M"; chown root:proxy "$M"; chmod 640 "$M"
    # the hook that sets the tag must sit AFTER the deny list (a revoked user is
    # refused before routing is even considered) and BEFORE the plain allow —
    # inserted once into a box set up before this feature existed
    if ! grep -q 'alphavpn_fpmapped' "$C"; then
      awk -v h="$H" '{ print } /^http_access deny alphavpn_denied/ {
        print "# free-proxy locations (alpha-vpn proxy-peers): tag a user routed through one"
        print "external_acl_type alphavpn_fp ttl=5 negative_ttl=5 children-max=5 children-startup=1 %un " h
        print "acl alphavpn_fpmapped external alphavpn_fp"
        print "http_access allow alphavpn_auth alphavpn_fpmapped" }' "$C" > "$C.tmp" && mv "$C.tmp" "$C"
    fi
    T="$P.tmp"; n=0
    {
      echo "# AlphaVPN free-proxy locations — generated by alpha-vpn proxy-peers. Do not edit."
      if [ -f "$S" ]; then
        while read -r id host port _; do
          [[ "$id" =~ ^fp-[a-f0-9]{8}$ ]] || continue
          [[ "$host" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ || "$host" =~ ^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$ ]] || continue
          [[ "$port" =~ ^[0-9]{1,5}$ ]] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ] || continue
          n=$((n + 1)); [ "$n" -le 200 ] || break
          echo "cache_peer $host parent $port 0 no-query no-digest no-netdb-exchange connect-timeout=10 connect-fail-limit=5 name=$id"
          echo "acl alphavpn_tag_$id tag $id"
          echo "cache_peer_access $id allow alphavpn_tag_$id"
          echo "cache_peer_access $id deny all"
          # a routed user must never silently fall back to THIS server's address
          echo "never_direct allow alphavpn_tag_$id"
        done < "$S"
      fi
    } > "$T"
    [ -f "$P" ] && cp -p "$P" "$P.bak"
    mv "$T" "$P"; chmod 644 "$P"
    if ! squid -k parse >/dev/null 2>&1; then
      if [ -f "$P.bak" ]; then mv "$P.bak" "$P"; else rm -f "$P"; fi
      die "squid rejected the generated peer list — previous one restored"
    fi
    rm -f "$P.bak"
    squid -k reconfigure 2>/dev/null || true
    echo "OK peers $n"
    ;;

  proxy-route)
    # proxy-route <user> <fp-id|none> — send one customer through a free proxy
    # (or back to this server). Read per request by the fpmap helper; no reload.
    U="${2:-}"; ID="${3:-}"; M=/etc/squid/alphavpn-fpmap
    [[ "$U" =~ ^[a-z0-9._+-]+@[a-z0-9.-]+$ ]] || die "bad user"
    [[ "$ID" == "none" || "$ID" =~ ^fp-[a-f0-9]{8}$ ]] || die "bad id"
    touch "$M"
    awk -v u="$U" '$1 != u' "$M" > "$M.tmp"
    if [ "$ID" != "none" ]; then printf '%s %s\n' "$U" "$ID" >> "$M.tmp"; fi
    mv "$M.tmp" "$M"; chown root:proxy "$M"; chmod 640 "$M"
    echo "OK route $ID"
    ;;

  *)
    die "usage: alpha-vpn status|conf|peer-add <name> [ip]|peer-del <key>|peer-off <key>|peer-on <key>|peer-conf <key>|service <up|down|restart>|setting <k> <v>"
    ;;
esac
