#!/bin/bash
# ============================================================
# alpha-ovpnas — OpenVPN Access Server's own web UI, for the panel's
# "OpenVPN Web UI" tab (VPN Management).
#
#   alpha-ovpnas status            JSON: installed, running, version, clients, ports, published name
#   alpha-ovpnas restart           restart the openvpnas service
#   alpha-ovpnas publish <sub>     serve the web UI at https://<sub>.<domain> (own vhost + certificate)
#   alpha-ovpnas unpublish         remove that vhost (+ its certificate)
#   alpha-ovpnas install <user> [sub]   official installer in the background; admin password on STDIN
#   alpha-ovpnas uninstall confirm      purge (settings backed up to /root/ovpnas-backup-*.tgz first)
#   alpha-ovpnas set-admin <user>       create/update a superuser; password on STDIN
#   alpha-ovpnas set-net <host> <udp> <tcp>  hostname clients dial + VPN ports
#   alpha-ovpnas optimize               DCO kernel module + cipher order for this CPU
#   alpha-ovpnas seats | sessions       licence seats {used,max} (live) · connected clients
#   alpha-ovpnas user-add|profile|user-del <av-…>   one auto-login user per app device
#
# WHY A SUBDOMAIN: Access Server sends X-Frame-Options: SAMEORIGIN on :943, so
# the panel cannot frame it directly — and a browser-served tool must never sit
# on a path of the admin host. Its own name is reverse-proxied to
# https://127.0.0.1:943 with the frame header replaced by
# `frame-ancestors https://*.<domain>` (a wildcard of the public domain, never
# the admin hostname, because this header is public). Access Server's own admin
# login stays the gate; :943 itself was already public before this.
#
# Only nginx/certbot/systemctl run as root; every argument is validated here.
# nginx is reloaded only after `nginx -t` passes; a failed vhost is removed.
# ============================================================
set -uo pipefail

AS=/usr/local/openvpn_as
SACLI=$AS/scripts/sacli
VHOST=/etc/nginx/sites-available/sub-ovpnas
VHOST_ON=/etc/nginx/sites-enabled/sub-ovpnas
ACME=/var/www/alpha
JOB_STATE=/var/lib/alpha-ovpnas/job.json
JOB_LOG=/var/lib/alpha-ovpnas/install.log
PW_FILE=/var/lib/alpha-ovpnas/.pw
mkdir -p /var/lib/alpha-ovpnas && chmod 700 /var/lib/alpha-ovpnas

die() { printf '{"ok":false,"msg":%s}\n' "$(printf '%s' "$*" | jq -Rs .)"; exit 1; }
conf() { ( . /etc/alpha-panel.conf 2>/dev/null; eval "printf '%s' \"\${$1:-}\"" ); }
DOMAIN="$(conf PRIMARY_DOMAIN | tr 'A-Z' 'a-z')"
published_fqdn() { [ -f "$VHOST" ] && sed -nE 's/^\s*server_name\s+([^; ]+).*/\1/p' "$VHOST" | head -1; }

# ⚠️ Access Server DNATs its public ports (UDP 1195 / TCP 9443) to internal
# daemon ports (914-921…, one per CPU). ufw's INPUT chain then sees the
# INTERNAL port, which it does not allow, and drops it — the public 1195/9443
# rules never match (their counters stayed at 0 while ~52k packets hit the
# DNAT). Accepting already-DNATed traffic is the robust fix: it holds whatever
# the daemon count, and the only DNAT rules on the box are AS's own.
ufw_allow_dnat() {
  command -v ufw >/dev/null 2>&1 || return 0
  local f=/etc/ufw/before.rules
  [ -f "$f" ] || return 0
  if ! grep -q 'alpha-ovpnas: accept DNAT' "$f"; then
    cp -p "$f" "$f.alpha-bak"
    awk '/^\*filter/{inf=1} inf && /^COMMIT/ && !done {print "# alpha-ovpnas: accept DNAT (OpenVPN Access Server forwards its public ports to internal daemons)"; print "-A ufw-before-input -m conntrack --ctstate DNAT -j ACCEPT"; done=1} {print}' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
    ufw reload >/dev/null 2>&1 || { mv "$f.alpha-bak" "$f"; ufw reload >/dev/null 2>&1; return 1; }
  fi
  return 0
}

# Port 943 straight to the internet is how the bots reach Access Server. Once
# its web UI has its own name (nginx -> 127.0.0.1:943), close 943 publicly;
# with no published name it stays open, so the admin is never locked out.
ui_port_policy() {
  command -v ufw >/dev/null 2>&1 || return 0
  local f; f="$(published_fqdn)"
  if [ -n "$f" ] && [ -f "/etc/letsencrypt/live/$f/fullchain.pem" ]; then
    ufw delete allow 943/tcp >/dev/null 2>&1 || true
    echo closed
  else
    ufw allow 943/tcp >/dev/null 2>&1 || true
    echo open
  fi
}

nginx_apply() {   # <vhost text>
  local bak=""
  [ -f "$VHOST" ] && bak="$(cat "$VHOST")"
  printf '%s\n' "$1" > "$VHOST"; chmod 644 "$VHOST"
  [ -L "$VHOST_ON" ] || ln -s "$VHOST" "$VHOST_ON"
  if ! nginx -t >/dev/null 2>&1; then
    if [ -n "$bak" ]; then printf '%s\n' "$bak" > "$VHOST"; else rm -f "$VHOST_ON" "$VHOST"; fi
    return 1
  fi
  systemctl reload nginx
}
vhost_http() {   # <fqdn>
  cat <<EOF
# ALPHA-OVPNAS — OpenVPN Access Server web UI (127.0.0.1:943). Written by /usr/local/sbin/alpha-ovpnas.
server {
    listen 80; listen [::]:80;
    server_name $1;
    location ^~ /.well-known/acme-challenge/ { allow all; root $ACME; default_type "text/plain"; }
    location / { return 301 https://\$host\$request_uri; }
}
EOF
}
vhost_tls() {    # <fqdn>
  vhost_http "$1"
  cat <<EOF
server {
    listen 443 ssl http2; listen [::]:443 ssl http2;
    server_name $1;
    ssl_certificate     /etc/letsencrypt/live/$1/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/$1/privkey.pem;
    add_header X-Robots-Tag "noindex, nofollow" always;
    # /rest/ is Access Server's username+password profile API - the endpoint
    # credential-stuffing bots hammer (962 attempts in a day). App users get
    # profiles from the panel's own API, so it is refused here outright.
    location ^~ /rest/ { return 403; }
    location / {
        limit_req zone=alpha_req burst=40 nodelay;
        proxy_pass https://127.0.0.1:943;
        proxy_ssl_verify off;
        proxy_http_version 1.1;
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header Upgrade \$http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 300;
        # framed by the panel's VPN tab: a wildcard of the public domain, never the admin hostname
        proxy_hide_header X-Frame-Options;
        proxy_hide_header Content-Security-Policy;
        add_header Content-Security-Policy "frame-ancestors 'self' https://*.$DOMAIN" always;
    }
}
EOF
}

case "${1:-}" in
  status)
    job='{}'; [ -f "$JOB_STATE" ] && job="$(cat "$JOB_STATE")"
    logtail=""; [ -f "$JOB_LOG" ] && logtail="$(tail -n 14 "$JOB_LOG" | cut -c1-200)"
    if [ ! -x "$SACLI" ]; then
      jq -n --argjson job "$job" --arg log "$logtail" '{ok:true, installed:false, job:$job, log:$log}'; exit 0
    fi
    admins="$(timeout 15 "$SACLI" UserPropGet 2>/dev/null | jq -c '[to_entries[] | select(.value.prop_superuser == "true") | .key]' 2>/dev/null)"
    [ -n "$admins" ] || admins='[]'
    active=false; systemctl is-active --quiet openvpnas && active=true
    ver="$(timeout 15 "$SACLI" Version 2>/dev/null | head -1)"
    sum="$(timeout 15 "$SACLI" VPNSummary 2>/dev/null)"; [ -n "$sum" ] || sum='{}'
    cfg="$(timeout 20 "$SACLI" ConfigQuery 2>/dev/null)"; [ -n "$cfg" ] || cfg='{}'
    f="$(published_fqdn)"; certok=false
    [ -n "$f" ] && [ -f "/etc/letsencrypt/live/$f/fullchain.pem" ] && certok=true
    listening=false; ss -tln 2>/dev/null | grep -q ':943 ' && listening=true
    jq -n --argjson active "$active" --arg ver "$ver" --argjson sum "$sum" --argjson cfg "$cfg" \
          --arg fqdn "$f" --argjson certok "$certok" --argjson listening "$listening"           --argjson admins "$admins" --argjson job "$job" --arg log "$logtail" '{
      admins: $admins, job: $job, log: $log,
      ok: true, installed: true, running: $active, listening: $listening, version: $ver,
      clients: ($sum.n_clients // 0),
      host_name: ($cfg["host.name"] // ""),
      admin_port: ($cfg["admin_ui.https.port"] // "943"),
      client_port: ($cfg["cs.https.port"] // "943"),
      vpn_udp_port: ($cfg["vpn.daemon.0.listen.port"] // ""),
      vpn_tcp_port: ($cfg["vpn.server.daemon.tcp.port"] // ""),
      fqdn: (if $certok then $fqdn else "" end)
    }'
    ;;

  restart)
    [ -x "$SACLI" ] || die "OpenVPN Access Server is not installed"
    systemctl restart openvpnas || die "openvpnas did not restart"
    for i in $(seq 1 30); do ss -tln 2>/dev/null | grep -q ':943 ' && break; sleep 1; done
    jq -n '{ok:true, msg:"OpenVPN Access Server restarted."}'
    ;;

  publish)
    [ -x "$SACLI" ] || die "OpenVPN Access Server is not installed"
    sub="$(printf '%s' "${2:-}" | tr 'A-Z' 'a-z')"
    [[ "$sub" =~ ^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$ ]] || die "use letters, digits and dashes (1-40), e.g. openvpn"
    [ -n "$DOMAIN" ] || die "PRIMARY_DOMAIN is not set in /etc/alpha-panel.conf"
    f="$sub.$DOMAIN"
    # another site already answering on this name is never taken over
    for w in $(grep -Rls -E "server_name[^;]*[[:space:]]${f//./\\.}[[:space:];]" /etc/nginx/sites-enabled/ 2>/dev/null); do
      [ "$(readlink -f "$w")" = "$(readlink -f "$VHOST")" ] || die "$f is already served by $(basename "$w")"
    done
    getent hosts "$f" >/dev/null || die "$f does not resolve — add a DNS record (or a * wildcard) for it first"
    old="$(published_fqdn)"
    if [ ! -f "/etc/letsencrypt/live/$f/fullchain.pem" ]; then
      nginx_apply "$(vhost_http "$f")" || die "nginx refused the server block"
      em="$(conf ADMIN_EMAIL)"
      if [ -n "$em" ]; then reg=(-m "$em"); else reg=(--register-unsafely-without-email); fi
      out="$(certbot certonly --webroot -w "$ACME" -d "$f" --non-interactive --agree-tos "${reg[@]}" 2>&1)" \
        || die "could not get a certificate for $f: $(printf '%s' "$out" | tail -c 300)"
    fi
    nginx_apply "$(vhost_tls "$f")" || die "nginx refused the server block"
    if [ -n "$old" ] && [ "$old" != "$f" ]; then certbot delete --cert-name "$old" --non-interactive >/dev/null 2>&1 || true; fi
    pp="$(ui_port_policy)"
    jq -n --arg f "$f" --arg pp "$pp" '{ok:true, fqdn:$f, url:("https://" + $f + "/admin"), port943:$pp, msg:("Published at https://" + $f + "/ - direct :943 " + $pp)}'
    ;;

  unpublish)
    f="$(published_fqdn)"
    rm -f "$VHOST_ON" "$VHOST"
    nginx -t >/dev/null 2>&1 && systemctl reload nginx
    [ -n "$f" ] && certbot delete --cert-name "$f" --non-interactive >/dev/null 2>&1
    ui_port_policy >/dev/null
    jq -n '{ok:true, msg:"Unpublished - Access Server is reachable on :943 again."}'
    ;;

  # ---------------------------------------------------------------- lifecycle
  # install <admin-user> [sub]   password on STDIN (never argv). Runs in the
  # background (the official installer takes minutes); progress → JOB_LOG,
  # state → JOB_STATE, read back by `status`.
  install)
    [ -x "$SACLI" ] && die "OpenVPN Access Server is already installed"
    U="${2:-openvpn}"; SUB="${3:-}"
    [[ "$U" =~ ^[a-z][a-z0-9._-]{1,31}$ ]] || die "admin username: 2-32 chars, lowercase letters, digits, . _ -"
    [ -z "$SUB" ] || [[ "$SUB" =~ ^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$ ]] || die "bad web UI name"
    IFS= read -r PW || true
    [ "${#PW}" -ge 10 ] || die "admin password: at least 10 characters"
    if [ -f "$JOB_STATE" ] && grep -q '"running"' "$JOB_STATE" 2>/dev/null; then die "an install is already running"; fi
    umask 077; printf '%s' "$PW" > "$PW_FILE"
    printf '{"state":"running","step":"starting","at":%s}\n' "$(date +%s)" > "$JOB_STATE"
    : > "$JOB_LOG"
    setsid "$0" _install-run "$U" "$SUB" >>"$JOB_LOG" 2>&1 < /dev/null &
    jq -n '{ok:true, msg:"Installing OpenVPN Access Server — this takes a few minutes."}'
    ;;

  _install-run)
    U="$2"; SUB="${3:-}"
    step() { printf '{"state":"running","step":%s,"at":%s}\n' "$(printf '%s' "$1" | jq -Rs .)" "$(date +%s)" > "$JOB_STATE"; echo "== $1"; }
    fail() { printf '{"state":"failed","step":%s,"at":%s}\n' "$(printf '%s' "$1" | jq -Rs .)" "$(date +%s)" > "$JOB_STATE"; echo "!! $1"; rm -f "$PW_FILE"; exit 1; }
    step "Downloading the official installer"
    curl -fsS https://packages.openvpn.net/as/install.sh -o /tmp/alpha-as-install.sh || fail "could not download the installer"
    step "Installing packages (apt) — this is the slow part"
    DEBIAN_FRONTEND=noninteractive bash /tmp/alpha-as-install.sh --yes || fail "the official installer failed (see log)"
    rm -f /tmp/alpha-as-install.sh
    for i in $(seq 1 60); do [ -x "$SACLI" ] && break; sleep 2; done
    [ -x "$SACLI" ] || fail "Access Server did not appear after install"
    step "Moving Access Server off ports nginx and the community OpenVPN already use"
    # :443 TCP belongs to nginx; :1194 UDP to the community server (if any)
    TCPP=9443; UDPP=1194
    ss -lun 2>/dev/null | grep -q ':1194 ' && UDPP=1195
    HN="$(curl -fsS -4 --max-time 6 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}')"
    "$SACLI" --key "vpn.server.daemon.tcp.port" --value "$TCPP" ConfigPut >/dev/null
    "$SACLI" --key "vpn.daemon.0.listen.port" --value "$UDPP" ConfigPut >/dev/null
    "$SACLI" --key "vpn.server.port_share.enable" --value "false" ConfigPut >/dev/null
    [ -n "$HN" ] && "$SACLI" --key "host.name" --value "$HN" ConfigPut >/dev/null
    step "Creating the admin account"
    PW="$(cat "$PW_FILE" 2>/dev/null)"; rm -f "$PW_FILE"
    "$SACLI" --user "$U" --key "type" --value "user_compile" UserPropPut >/dev/null
    "$SACLI" --user "$U" --key "prop_superuser" --value "true" UserPropPut >/dev/null
    "$SACLI" --user "$U" --new_pass "$PW" SetLocalPassword >/dev/null || fail "could not set the admin password"
    # the installer's own 'openvpn' account gets a random password nobody knows, unless it IS the chosen admin
    if [ "$U" != "openvpn" ]; then "$SACLI" --user openvpn --new_pass "$(openssl rand -hex 24)" SetLocalPassword >/dev/null 2>&1 || true; fi
    "$SACLI" start >/dev/null 2>&1 || systemctl restart openvpnas
    if command -v ufw >/dev/null 2>&1; then ufw allow "$TCPP"/tcp >/dev/null 2>&1; ufw allow "$UDPP"/udp >/dev/null 2>&1; fi
    ufw_allow_dnat || echo "could not add the ufw DNAT rule"
    step "Enabling kernel offload (DCO) and the right cipher for this CPU"
    "$0" optimize >/dev/null 2>&1 || echo "optimize skipped — run it from the panel"
    if [ -n "$SUB" ]; then step "Publishing the web UI at $SUB.$DOMAIN"; "$0" publish "$SUB" || echo "publish failed — retry from the panel"; fi
    echo "web UI port 943: $(ui_port_policy)"
    printf '{"state":"done","step":"Installed","at":%s}\n' "$(date +%s)" > "$JOB_STATE"
    echo "== done"
    ;;

  uninstall)
    [ -x "$SACLI" ] || die "OpenVPN Access Server is not installed"
    [ "${2:-}" = "confirm" ] || die "pass 'confirm' to uninstall"
    B="/root/ovpnas-backup-$(date +%Y%m%d-%H%M%S).tgz"
    tar czf "$B" -C "$AS" etc 2>/dev/null || true          # users, certificates, settings — kept in case
    systemctl stop openvpnas 2>/dev/null || true
    DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq openvpn-as openvpn-as-bundled-clients >/dev/null 2>&1 || die "apt could not remove openvpn-as"
    rm -f /etc/apt/sources.list.d/openvpn-as-repo.list /etc/apt/keyrings/as-repository.asc
    f="$(published_fqdn)"; rm -f "$VHOST_ON" "$VHOST"; nginx -t >/dev/null 2>&1 && systemctl reload nginx
    [ -n "$f" ] && certbot delete --cert-name "$f" --non-interactive >/dev/null 2>&1
    rm -f "$JOB_STATE" "$JOB_LOG"
    jq -n --arg b "$B" '{ok:true, msg:("Uninstalled. Settings backed up to " + $b), backup:$b}'
    ;;

  # optimize — the two warnings Access Server raises on a typical VPS:
  #  · "data-channel offloading failed": the DCO kernel module was never built →
  #    install openvpn-dco-dkms (official AS repo) and load ovpn_dco_v2.
  #  · "CPU AES acceleration not available": the hypervisor hides AES-NI (only the
  #    hosting provider can pass it through) → prefer CHACHA20-POLY1305, which is
  #    fast in software, ahead of AES-GCM. The warning itself stays until the
  #    provider exposes AES; the slowdown it warns about does not.
  optimize)
    [ -x "$SACLI" ] || die "OpenVPN Access Server is not installed"
    notes=()
    # ⚠️ Access Server 3.x ships OpenVPN 2.7, which needs the NEW in-kernel `ovpn`
    # interface (Linux 6.16+) or its backport `ovpn-dkms`. The older
    # `openvpn-dco-dkms` (ovpn_dco_v2) is what OpenVPN 2.6 uses — AS ignores it
    # ("Kernel support for ovpn interfaces missing"). Same package the official
    # installer picks (DCO_NAME="ovpn-dkms"), same switch (vpn.server.daemon.ovpndco).
    if ! modinfo ovpn >/dev/null 2>&1; then
      DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "linux-headers-$(uname -r)" ovpn-dkms >/dev/null 2>&1         || die "could not install ovpn-dkms (kernel headers for $(uname -r) missing?)"
    fi
    modprobe ovpn 2>/dev/null || die "ovpn-dkms was built but the ovpn module does not load on kernel $(uname -r)"
    echo ovpn > /etc/modules-load.d/alpha-ovpn.conf
    notes+=("kernel offload module loaded")
    ufw_allow_dnat && notes+=("firewall accepts Access Server's forwarded ports") || notes+=("firewall rule NOT applied")
    if grep -qw aes /proc/cpuinfo; then ciphers="AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305"; notes+=("CPU has AES-NI — AES-GCM first")
    else ciphers="CHACHA20-POLY1305:AES-256-GCM:AES-128-GCM"; notes+=("no AES-NI — ChaCha20-Poly1305 preferred"); fi
    "$SACLI" --key "vpn.server.data_ciphers" --value "$ciphers" ConfigPut >/dev/null
    "$SACLI" --key "vpn.server.daemon.ovpndco" --value "true" ConfigPut >/dev/null
    # a full restart: the daemons only probe the kernel for offload when they start
    systemctl restart openvpnas
    for i in $(seq 1 40); do ss -tln 2>/dev/null | grep -q ':943 ' && break; sleep 1; done
    sleep 3
    sum="$(timeout 15 "$SACLI" VPNSummary 2>/dev/null)"; [ -n "$sum" ] || sum='{}'
    jq -n --argjson sum "$sum" --arg n "$(IFS='; '; echo "${notes[*]}")" --arg c "$ciphers"       '{ok:true, msg:("Optimized: " + $n), ciphers:$c, dco:($sum.ovpn_dco_available // false), dco_ver:($sum.ovpn_dco_ver // "")}'
    ;;

  # ---------------------------------------------------------------- app users
  # One Access Server user per enrolled app device ("av-…"), auto-login profile.
  # Only names starting with av- are ever touched here — the admin accounts
  # can never be edited or deleted through these verbs.
  seats)            # licensed concurrent connections, live: {used, max}
    [ -x "$SACLI" ] || die "OpenVPN Access Server is not installed"
    u="$(timeout 15 "$SACLI" LicUsage 2>/dev/null | jq -c '.' 2>/dev/null)"
    [ -n "$u" ] || u='[0,0]'
    n="$(timeout 15 "$SACLI" UserPropGet 2>/dev/null | jq '[keys[] | select(startswith("av-"))] | length' 2>/dev/null)"
    jq -n --argjson u "$u" --argjson n "${n:-0}" '{ok:true, used:($u[0] // 0), max:($u[1] // 0), app_users:$n}'
    ;;

  user-add)         # user-add <av-name> → {ok, user, profile(base64)}
    [ -x "$SACLI" ] || die "OpenVPN Access Server is not installed"
    U="${2:-}"; [[ "$U" =~ ^av-[a-z0-9-]{3,46}$ ]] || die "bad app user name"
    "$SACLI" --user "$U" --key "type" --value "user_connect" UserPropPut >/dev/null || die "Access Server refused the user"
    "$SACLI" --user "$U" --key "prop_autologin" --value "true" UserPropPut >/dev/null
    p="$(timeout 40 "$SACLI" --user "$U" GetAutologin 2>/dev/null)"
    printf '%s' "$p" | grep -q '^client' || { "$SACLI" --user "$U" UserPropDelAll >/dev/null 2>&1; die "Access Server did not produce a profile"; }
    jq -n --arg u "$U" --arg p "$(printf '%s' "$p" | base64 -w0)" '{ok:true, user:$u, profile:$p}'
    ;;

  profile)          # profile <av-name> → {ok, profile(base64)}
    [ -x "$SACLI" ] || die "OpenVPN Access Server is not installed"
    U="${2:-}"; [[ "$U" =~ ^av-[a-z0-9-]{3,46}$ ]] || die "bad app user name"
    p="$(timeout 40 "$SACLI" --user "$U" GetAutologin 2>/dev/null)"
    printf '%s' "$p" | grep -q '^client' || die "no profile for $U"
    jq -n --arg p "$(printf '%s' "$p" | base64 -w0)" '{ok:true, profile:$p}'
    ;;

  user-del)         # user-del <av-name> — cut the session, revoke the cert, drop the user
    [ -x "$SACLI" ] || die "OpenVPN Access Server is not installed"
    U="${2:-}"; [[ "$U" =~ ^av-[a-z0-9-]{3,46}$ ]] || die "bad app user name"
    timeout 20 "$SACLI" --user "$U" DisconnectUser >/dev/null 2>&1 || true
    timeout 20 "$SACLI" --user "$U" RevokeUser >/dev/null 2>&1 || true
    timeout 20 "$SACLI" --user "$U" UserPropDelAll >/dev/null 2>&1 || true
    jq -n --arg u "$U" '{ok:true, msg:("Removed " + $u)}'
    ;;

  sessions)         # live connections, panel-shaped
    [ -x "$SACLI" ] || { echo '[]'; exit 0; }
    timeout 20 "$SACLI" VPNStatus 2>/dev/null | jq -c '[ .[] | select(type == "object" and has("client_list")) as $d
        | ($d.client_list_header // {}) as $h | $d.client_list[]
        | { user: .[$h["Username"] // 8], real: .[$h["Real Address"] // 1], vip: .[$h["Virtual Address"] // 2],
            rx: (.[$h["Bytes Received"] // 4] | tonumber? // 0), tx: (.[$h["Bytes Sent"] // 5] | tonumber? // 0),
            since: (.[$h["Connected Since (time_t)"] // 7] | tonumber? // 0) } ]' 2>/dev/null || echo '[]'
    ;;

  # ---------------------------------------------------------------- settings
  set-admin)        # set-admin <user>   new password on STDIN
    [ -x "$SACLI" ] || die "OpenVPN Access Server is not installed"
    U="${2:-}"; [[ "$U" =~ ^[a-z][a-z0-9._-]{1,31}$ ]] || die "admin username: 2-32 chars, lowercase letters, digits, . _ -"
    IFS= read -r PW || true
    [ "${#PW}" -ge 10 ] || die "password: at least 10 characters"
    "$SACLI" --user "$U" --key "type" --value "user_compile" UserPropPut >/dev/null
    "$SACLI" --user "$U" --key "prop_superuser" --value "true" UserPropPut >/dev/null
    "$SACLI" --user "$U" --new_pass "$PW" SetLocalPassword >/dev/null || die "Access Server refused the password"
    jq -n --arg u "$U" '{ok:true, msg:("Admin " + $u + " saved — sign in with the new password.")}'
    ;;

  set-net)          # set-net <hostname> <udp-port> <tcp-port>
    [ -x "$SACLI" ] || die "OpenVPN Access Server is not installed"
    HN="${2:-}"; UDPP="${3:-}"; TCPP="${4:-}"
    [[ "$HN" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] || die "hostname: a DNS name or IP clients can reach"
    for p in "$UDPP" "$TCPP"; do [[ "$p" =~ ^[0-9]{2,5}$ ]] && [ "$p" -ge 1 ] && [ "$p" -le 65535 ] || die "ports must be 1-65535"; done
    case "$TCPP" in 80|443|943) die "TCP $TCPP is taken (web server / Access Server UI)";; esac
    cur_t="$("$SACLI" ConfigQuery 2>/dev/null | jq -r '."vpn.server.daemon.tcp.port" // ""')"
    cur_u="$("$SACLI" ConfigQuery 2>/dev/null | jq -r '."vpn.daemon.0.listen.port" // ""')"
    # a port someone else already listens on is refused (our own current ports are fine)
    if [ "$TCPP" != "$cur_t" ] && ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$TCPP\$"; then die "TCP $TCPP is already in use"; fi
    if [ "$UDPP" != "$cur_u" ] && ss -lun 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$UDPP\$"; then die "UDP $UDPP is already in use"; fi
    "$SACLI" --key "host.name" --value "$HN" ConfigPut >/dev/null
    "$SACLI" --key "vpn.daemon.0.listen.port" --value "$UDPP" ConfigPut >/dev/null
    "$SACLI" --key "vpn.server.daemon.tcp.port" --value "$TCPP" ConfigPut >/dev/null
    "$SACLI" start >/dev/null 2>&1 || die "Access Server did not restart with the new settings"
    if command -v ufw >/dev/null 2>&1; then ufw allow "$TCPP"/tcp >/dev/null 2>&1; ufw allow "$UDPP"/udp >/dev/null 2>&1; fi
    jq -n '{ok:true, msg:"Saved — Access Server restarted. Existing client profiles must be re-downloaded."}'
    ;;

  *) die "usage: alpha-ovpnas status|restart|publish <sub>|unpublish|install <user> [sub]|uninstall confirm|set-admin <user>|set-net <host> <udp> <tcp>" ;;
esac
