#!/bin/bash
# ============================================================
# alpha-openvpn — root half of the OpenVPN add-on.
#
#   alpha-openvpn status                machine-readable state (see below)
#   alpha-openvpn install               apt-get openvpn + easy-rsa, build a CA,
#                                       server cert/key, DH + a server.conf
#   alpha-openvpn service up|down|restart
#   alpha-openvpn client-add <name>     issue a client cert, print the .ovpn
#   alpha-openvpn client-del <name>     revoke it
#   alpha-openvpn client-conf <name>    print an existing client's .ovpn
#   alpha-openvpn clients               one line per client
#
# WHY ROOT: /etc/openvpn holds the CA private key (0600 root) and every verb is
# a systemctl call or a PKI operation. www-data may do neither.
#
# ⚠️ This coexists with WireGuard rather than replacing it — different port
# (1194/udp), different subnet (10.20.0.0/24), its own systemd unit. Nothing
# here touches wg0; the panel's WireGuard screen must keep working with the
# add-on disabled OR removed.
# ============================================================
set -euo pipefail

ETC=/etc/openvpn
SRV=$ETC/server
PKI=$ETC/easy-rsa/pki
EASY=/usr/share/easy-rsa
UNIT=openvpn-server@server
PORT=1194
SUBNET=10.20.0.0
MASK=255.255.255.0

# ---- install progress ----------------------------------------------------
# The install takes a minute or two (apt, a CA, DH parameters), far too long to
# hold a web request open. It therefore runs DETACHED and reports through this
# state file, which the panel polls. Same `START`/`PCT` convention OmniForge
# uses for its downloads, plus MSG/DONE/ERR.
# ⚠️ Written by root, read by www-data → the directory and file must be
# world-readable or the progress bar sits at 0 forever.
STATE_DIR=/var/www/alpha-private/openvpn
STATE=$STATE_DIR/install.state

prog() {  # prog <pct> <message>
  mkdir -p "$STATE_DIR" 2>/dev/null || true
  chmod 0755 "$STATE_DIR" 2>/dev/null || true
  { echo "START $START_TS"; echo "PCT $1"; echo "MSG $2"; } > "$STATE" 2>/dev/null || true
  chmod 0644 "$STATE" 2>/dev/null || true
}
prog_end() {  # prog_end <DONE|ERR> <message>
  mkdir -p "$STATE_DIR" 2>/dev/null || true
  { echo "START $START_TS"; echo "PCT $([ "$1" = DONE ] && echo 100 || echo 0)"; echo "MSG $2"; echo "$1"; } > "$STATE" 2>/dev/null || true
  chmod 0644 "$STATE" 2>/dev/null || true
}
START_TS=$(date +%s)

# ⚠️ die() must also break the progress bar out of its poll, or a failed install
# leaves the UI spinning forever with no idea anything went wrong.
die() { [ "${1:-}" != "" ] && [ "${INSTALLING:-0}" = 1 ] && prog_end ERR "$*"; echo "ERR $*" >&2; exit 1; }
ck_name() { [[ "$1" =~ ^[A-Za-z0-9._-]{1,40}$ ]] || die "name: letters, digits, dot, dash, underscore (max 40)"; }
have() { command -v "$1" >/dev/null 2>&1; }

wan_ip() {
  ip route get 1.1.1.1 2>/dev/null | awk '/src/{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1);exit}}'
}


# ---- OpenVPN Access Server (the commercial edition) -----------------------
# ⚠️⚠️ THE DANGEROUS PART IS PORTS, NOT PACKAGES. Out of the box Access Server
# wants TCP 443 for its client web server and UDP 1194 for the tunnel. On this
# box 443 is NGINX (the panel and every hosted site) and 1194 is the community
# OpenVPN. Installing without moving those first is how you take the whole
# server off the air. So every port is pinned IMMEDIATELY after install, before
# the service is allowed to run:
#     UDP  1195   tunnel        (community keeps 1194)
#     TCP  9443   tunnel/TCP    (nginx keeps 443)
#     TCP   943   admin + client web UI
install_as() {
  if [ -x /usr/local/openvpn_as/scripts/sacli ]; then
    prog 90 "Access Server already installed — re-checking its ports…"
  else
    prog 20 "Adding the OpenVPN Access Server repository…"
    export DEBIAN_FRONTEND=noninteractive
    apt-get install -y -qq ca-certificates wget gnupg >/dev/null 2>&1 || true
    mkdir -p /etc/apt/keyrings
    wget -qO- https://as-repository.openvpn.net/as-repo-public.asc 2>/dev/null       | gpg --dearmor -o /etc/apt/keyrings/openvpn-as-repo.gpg 2>/dev/null       || die "could not fetch the Access Server repository key"
    CODE=$(. /etc/os-release 2>/dev/null; echo "${VERSION_CODENAME:-jammy}")
    echo "deb [signed-by=/etc/apt/keyrings/openvpn-as-repo.gpg] http://as-repository.openvpn.net/as/debian $CODE main"       > /etc/apt/sources.list.d/openvpn-as-repo.list
    prog 40 "Installing Access Server (this is a large package)…"
    apt-get update -qq >/dev/null 2>&1 || true
    apt-get install -y -qq openvpn-as >/dev/null 2>&1 || die "apt could not install openvpn-as"
  fi

  SACLI=""
  for c in /usr/local/openvpn_as/scripts/sacli /usr/local/openvpn_as/bin/sacli; do
    if [ -x "$c" ]; then SACLI="$c"; break; fi
  done
  [ -n "$SACLI" ] || die "Access Server installed but sacli was not found"

  prog 75 "Moving Access Server off nginx's 443 and off UDP 1194…"
  "$SACLI" --key "vpn.server.daemon.udp.port"  --value "1195" ConfigPut >/dev/null 2>&1 || true
  # ⚠️ vpn.daemon.0.listen.port is a PER-DAEMON override that ships as 1194 — the
  # community server's port. It did not collide only because AS fronts its
  # daemons on internal ports, but leave it and one restart could take 1194.
  "$SACLI" --key "vpn.daemon.0.listen.port"    --value "1195" ConfigPut >/dev/null 2>&1 || true
  "$SACLI" --key "vpn.server.daemon.tcp.port"  --value "9443" ConfigPut >/dev/null 2>&1 || true
  "$SACLI" --key "cs.https.port"               --value "943"  ConfigPut >/dev/null 2>&1 || true
  # its own client subnet, clear of both wg0 (10.10.0.0/24) and community (10.20.0.0/24)
  "$SACLI" --key "vpn.daemon.0.client.network" --value "10.30.0.0" ConfigPut >/dev/null 2>&1 || true
  "$SACLI" --key "vpn.daemon.0.client.netmask_bits" --value "24"   ConfigPut >/dev/null 2>&1 || true
  "$SACLI" start >/dev/null 2>&1 || true

  if command -v ufw >/dev/null 2>&1; then
    ufw allow 1195/udp >/dev/null 2>&1 || true
    ufw allow 9443/tcp >/dev/null 2>&1 || true
    ufw allow 943/tcp  >/dev/null 2>&1 || true
  fi

  # ⚠️ prove we did not steal 443 — if nginx lost it, say so loudly
  if ss -tln 2>/dev/null | grep -q ':443 '; then
    if ! systemctl is-active --quiet nginx; then
      prog_end ERR "Access Server may have taken port 443 — check nginx immediately."
      exit 1
    fi
  fi
}

case "${1:-}" in

  status)
    # tab-separated key/value; the lib parses it. Always exits 0 so "not
    # installed" is a normal answer, not an error.
    if have openvpn; then
      echo -e "installed\t1"
      echo -e "version\t$(openvpn --version 2>/dev/null | head -1 | awk '{print $2}')"
    else
      echo -e "installed\t0"
      echo -e "version\t"
    fi
    if [ -f "$SRV/server.conf" ]; then echo -e "configured\t1"; else echo -e "configured\t0"; fi
    # ⚠️ `systemctl is-active` PRINTS its answer and exits non-zero, so a naive
    # `$(... || echo inactive)` captures BOTH and emits a stray second line.
    # ⚠️ `x=$(cmd)` ADOPTS cmd's exit status, and these commands exit non-zero
    # when the unit is missing — under `set -e` that killed the script halfway
    # through its own output. Every capture here ends in `|| true`.
    st=$(systemctl is-active "$UNIT" 2>/dev/null | head -1) || true; [ -n "$st" ] || st=inactive
    en=$(systemctl is-enabled "$UNIT" 2>/dev/null | head -1) || true; [ -n "$en" ] || en=disabled
    echo -e "state\t$st"
    echo -e "enabled\t$en"
    echo -e "port\t$(sed -n 's/^port *\([0-9]*\).*/\1/p' "$SRV/server.conf" 2>/dev/null | head -1)"
    echo -e "proto\t$(sed -n 's/^proto *\([a-z0-9]*\).*/\1/p' "$SRV/server.conf" 2>/dev/null | head -1)"
    echo -e "subnet\t$(sed -n 's/^server *\([0-9. ]*\).*/\1/p' "$SRV/server.conf" 2>/dev/null | head -1)"
    echo -e "wan\t$(wan_ip)"
    # connected clients, if the management status file is being written
    on=0
    if [ -f /run/openvpn-server/status-server.log ]; then
      on=$(awk -F, '/^CLIENT_LIST/{n++} END{print n+0}' /run/openvpn-server/status-server.log 2>/dev/null | head -1) || true
    fi
    echo -e "online\t${on:-0}"
    cl=$(ls "$PKI/issued" 2>/dev/null | grep -v '^server\.crt$' | grep -c '\.crt$' | head -1) || true
    echo -e "clients\t${cl:-0}"
    ;;

  clients)
    [ -d "$PKI/issued" ] || exit 0
    for c in "$PKI/issued"/*.crt; do
      [ -e "$c" ] || continue
      n=$(basename "$c" .crt)
      [ "$n" = "server" ] && continue
      # revoked certs stay in issued/ — index.txt is the source of truth
      st=valid
      if grep -q "^R.*/CN=$n\$" "$PKI/index.txt" 2>/dev/null; then st=revoked; fi
      exp=$(openssl x509 -enddate -noout -in "$c" 2>/dev/null | cut -d= -f2)
      printf '%s\t%s\t%s\t%s\n' "$n" "$st" "$(stat -c %Y "$c" 2>/dev/null)" "$exp"
    done
    ;;

  install)
    INSTALLING=1
    # edition: community (default) | as (Access Server) | both
    ED="${2:-community}"
    case "$ED" in community|as|both) : ;; *) die "edition must be community, as or both" ;; esac

    if [ "$ED" = "as" ]; then
      install_as
      prog_end DONE "OpenVPN Access Server is installed."
      echo "OK installed access-server"
      exit 0
    fi

    if have openvpn && [ -f "$SRV/server.conf" ]; then
      if [ "$ED" = "both" ]; then
        install_as
        prog_end DONE "Access Server added alongside the community server."
        echo "OK installed access-server"
        exit 0
      fi
      prog_end DONE "Already installed."; echo "OK already installed"; exit 0
    fi
    prog 5 "Installing the OpenVPN packages…"
    export DEBIAN_FRONTEND=noninteractive
    apt-get install -y -qq openvpn easy-rsa >/dev/null 2>&1 || die "apt could not install openvpn/easy-rsa"
    [ -d "$EASY" ] || die "easy-rsa is missing after install"
    mkdir -p "$ETC/easy-rsa" "$SRV"
    # a fresh PKI only when there is not one already — never clobber a live CA
    if [ ! -f "$PKI/ca.crt" ]; then
      cp -r "$EASY"/* "$ETC/easy-rsa/" 2>/dev/null || true
      cd "$ETC/easy-rsa"
      prog 30 "Building the private certificate authority…"
      ./easyrsa --batch init-pki >/dev/null 2>&1
      EASYRSA_REQ_CN="AlphaPanel CA" ./easyrsa --batch build-ca nopass >/dev/null 2>&1 || die "could not build the CA"
      # by far the slowest step — say so, so a long pause does not read as a hang
      prog 45 "Generating Diffie-Hellman parameters (this is the slow part)…"
      ./easyrsa --batch gen-dh >/dev/null 2>&1 || die "could not generate DH parameters"
      prog 78 "Issuing the server certificate…"
      ./easyrsa --batch build-server-full server nopass >/dev/null 2>&1 || die "could not issue the server certificate"
      ./easyrsa --batch gen-crl >/dev/null 2>&1 || true
      openvpn --genkey secret "$SRV/ta.key" >/dev/null 2>&1 || openvpn --genkey --secret "$SRV/ta.key" >/dev/null 2>&1 || true
    fi
    prog 88 "Writing the server configuration…"
    install -m 0644 "$PKI/ca.crt"             "$SRV/ca.crt"
    install -m 0644 "$PKI/issued/server.crt"  "$SRV/server.crt"
    install -m 0600 "$PKI/private/server.key" "$SRV/server.key"
    install -m 0644 "$PKI/dh.pem"             "$SRV/dh.pem"
    [ -f "$PKI/crl.pem" ] && install -m 0644 "$PKI/crl.pem" "$SRV/crl.pem"
    cat > "$SRV/server.conf" <<EOF
# Managed by the Alpha Panel OpenVPN add-on.
port $PORT
proto udp
dev tun
ca ca.crt
cert server.crt
key server.key
dh dh.pem
tls-auth ta.key 0
server $SUBNET $MASK
topology subnet
push "redirect-gateway def1 bypass-dhcp"
push "dhcp-option DNS 1.1.1.1"
keepalive 10 120
cipher AES-256-GCM
auth SHA256
user nobody
group nogroup
persist-key
persist-tun
status /run/openvpn-server/status-server.log
verb 3
explicit-exit-notify 1
EOF
    mkdir -p /run/openvpn-server
    prog 93 "Setting up NAT and the firewall…"
    # NAT so clients actually reach the internet. ⚠️ Same lesson as WireGuard:
    # masquerade ALONE does nothing while ufw's FORWARD policy is DROP.
    WAN=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}')
    if [ -n "$WAN" ]; then
      iptables -t nat -C POSTROUTING -s "$SUBNET/24" -o "$WAN" -j MASQUERADE 2>/dev/null \
        || iptables -t nat -A POSTROUTING -s "$SUBNET/24" -o "$WAN" -j MASQUERADE
      iptables -C FORWARD -i tun0 -o "$WAN" -j ACCEPT 2>/dev/null \
        || iptables -I FORWARD 1 -i tun0 -o "$WAN" -j ACCEPT
      iptables -C FORWARD -i "$WAN" -o tun0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null \
        || iptables -I FORWARD 2 -i "$WAN" -o tun0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
      have ufw && ufw route allow in on tun0 out on "$WAN" >/dev/null 2>&1 || true
    fi
    have ufw && ufw allow "$PORT"/udp >/dev/null 2>&1 || true
    prog 97 "Starting the OpenVPN service…"
    systemctl enable "$UNIT" >/dev/null 2>&1 || true
    systemctl restart "$UNIT" >/dev/null 2>&1 || true
    sleep 1
    act=$(systemctl is-active "$UNIT" 2>/dev/null | head -1) || true
    [ -n "$act" ] || act=inactive
    if [ "${ED:-community}" = "both" ]; then
      install_as
      prog_end DONE "Community server + Access Server are installed."
      echo "OK installed both $act"
      exit 0
    fi
    if [ "$act" = active ]; then prog_end DONE "OpenVPN is installed and running."
    else prog_end ERR "Installed, but the service did not start — check systemctl status $UNIT."; fi
    echo "OK installed $act"
    ;;

  service)
    [ -f "$SRV/server.conf" ] || die "OpenVPN is not set up yet"
    case "${2:-}" in
      up)      systemctl start "$UNIT" ;;
      down)    systemctl stop "$UNIT" ;;
      restart) systemctl restart "$UNIT" ;;

  *) die "service up|down|restart" ;;
    esac
    echo "OK $(systemctl is-active "$UNIT" 2>/dev/null || echo inactive)"
    ;;

  client-add)
    n="${2:-}"; ck_name "$n"
    [ -d "$ETC/easy-rsa" ] || die "OpenVPN is not set up yet"
    [ -f "$PKI/issued/$n.crt" ] && die "a client called $n already exists"
    cd "$ETC/easy-rsa"
    ./easyrsa --batch build-client-full "$n" nopass >/dev/null 2>&1 || die "could not issue a certificate for $n"
    echo "OK $n"
    ;;

  client-del)
    n="${2:-}"; ck_name "$n"
    [ -f "$PKI/issued/$n.crt" ] || die "no such client"
    cd "$ETC/easy-rsa"
    ./easyrsa --batch revoke "$n" >/dev/null 2>&1 || die "could not revoke $n"
    ./easyrsa --batch gen-crl >/dev/null 2>&1 || true
    [ -f "$PKI/crl.pem" ] && install -m 0644 "$PKI/crl.pem" "$SRV/crl.pem"
    systemctl restart "$UNIT" >/dev/null 2>&1 || true
    echo "OK revoked"
    ;;

  client-conf)
    # A complete, importable .ovpn with the keys inlined. This is a working
    # credential — the panel serves it once, over an auth-gated endpoint.
    n="${2:-}"; ck_name "$n"
    [ -f "$PKI/issued/$n.crt" ] || die "no such client"
    [ -f "$PKI/private/$n.key" ] || die "that client has no key on this server"
    host="${OVPN_ENDPOINT:-$(wan_ip)}"
    p=$(sed -n 's/^port *\([0-9]*\).*/\1/p' "$SRV/server.conf" 2>/dev/null | head -1); p=${p:-$PORT}
    cat <<EOF
client
dev tun
proto udp
remote $host $p
resolv-retry infinite
nobind
persist-key
persist-tun
remote-cert-tls server
cipher AES-256-GCM
auth SHA256
verb 3
<ca>
$(cat "$SRV/ca.crt")
</ca>
<cert>
$(openssl x509 -in "$PKI/issued/$n.crt" 2>/dev/null)
</cert>
<key>
$(cat "$PKI/private/$n.key")
</key>
$( [ -f "$SRV/ta.key" ] && printf '<tls-auth>\n%s\n</tls-auth>\nkey-direction 1\n' "$(cat "$SRV/ta.key")" )
EOF
    ;;

  as-status)
    # OpenVPN ACCESS SERVER (the commercial edition). Licence keys belong to IT,
    # not to the community `openvpn` package this add-on installs — community
    # OpenVPN is GPL and has no key to enter. Reports honestly when absent.
    SACLI=""
    for c in /usr/local/openvpn_as/scripts/sacli /usr/local/openvpn_as/bin/sacli; do
      if [ -x "$c" ]; then SACLI="$c"; break; fi
    done
    if [ -z "$SACLI" ]; then echo -e "installed	0"; exit 0; fi
    echo -e "installed	1"
    v=$("$SACLI" Version 2>/dev/null | head -1) || true
    echo -e "version	${v:-}"
    # ⚠️ NOT "LicenseStatus" — that command does not exist in Access Server 3.x
    # (it answers "Unknown command" and the card then always read "no
    # subscription"). SubscriptionStatus is the real one.
    lic=$("$SACLI" SubscriptionStatus 2>/dev/null | tr -d '
') || true
    echo -e "license	${lic:-}"
    ;;

  as-activate)
    # WARNING: the key is a PAID credential — it goes straight to sacli and is
    # never written to disk by this script.
    k="${2:-}"
    [[ "$k" =~ ^[A-Za-z0-9._:-]{8,120}$ ]] || die "that does not look like an activation key"
    SACLI=""
    for c in /usr/local/openvpn_as/scripts/sacli /usr/local/openvpn_as/bin/sacli; do
      if [ -x "$c" ]; then SACLI="$c"; break; fi
    done
    [ -n "$SACLI" ] || die "OpenVPN Access Server is not installed on this server"
    out=$("$SACLI" --key "$k" LoadSubscription 2>&1) || out=""
    if [ -z "$out" ]; then
      out=$("$SACLI" ActivateSubscription --key "$k" 2>&1) || die "activation failed: $(echo "$out" | tail -1)"
    fi
    "$SACLI" start >/dev/null 2>&1 || true
    echo "OK activated"
    ;;

  *)
    die "usage: alpha-openvpn status|clients|install|service <up|down|restart>|client-add <name>|client-del <name>|client-conf <name>|as-status|as-activate <key>"
    ;;
esac
