#!/usr/bin/env bash
# MonsterOps — guarded VPN privileged-operations wrapper.
#
# The VPN backends need a handful of root actions (bring a WireGuard/L2TP tunnel
# up/down, read link state, add a route). Granting the service user passwordless
# sudo for the raw tools (`ip`, `wg-quick`, `ipsec`, `xl2tpd`, `xl2tpd-control`,
# `kill`) with UNCONSTRAINED arguments is a root-escalation single point of
# failure: `sudo ip netns exec … <cmd>` is a root shell, `sudo wg-quick up <conf>`
# runs the conf's `PostUp=` as root, `sudo kill` can signal any process. The
# app's own argument validation runs INSIDE the process an attacker would control,
# so it is not the boundary.
#
# This wrapper is that boundary. It is the ONLY VPN command the sudoers allow-list
# grants. It exposes a small fixed verb set, re-derives every path from a validated
# tunnel name against fixed directories (no caller-supplied paths), and — for the
# config-file-driven tools — refuses a config that carries a command hook
# (wg-quick Post/Pre Up/Down, strongSwan left/rightupdown, pppd pty/plugin/connect
# …). Root-owned, not writable by the service user.

set -euo pipefail

# Fixed locations (mirror the backends; @VPN_DIR@ is substituted at install time).
# The MO_VPN_* overrides let the fixed dirs be relocated; sudo's env_reset strips
# them, so in production the fixed defaults below always apply.
VPN_CONF_DIR="${MO_VPN_CONF_DIR:-@VPN_DIR@}"   # WireGuard .conf dir (app-owned)
RUN_DIR="${MO_VPN_RUN_DIR:-/run/monsterops-vpn}"
IPSEC_D="${MO_VPN_IPSEC_DIR:-/etc/ipsec.d}"
XL2TPD_D="${MO_VPN_XL2TPD_DIR:-/etc/xl2tpd}"
PPP_D="${MO_VPN_PPP_DIR:-/etc/ppp}"

NAME_RE='^[A-Za-z0-9][A-Za-z0-9_-]{0,14}$'
IFACE_RE='^[A-Za-z0-9][A-Za-z0-9_-]{0,14}$'
CIDR_RE='^[0-9A-Fa-f:.]+(/[0-9]{1,3})?$'

# Config directives that let a config file run an arbitrary command as root.
WG_HOOKS="preup postup predown postdown saveconfig"
IPSEC_HOOKS="leftupdown rightupdown"
PPP_HOOKS="pty notty plugin connect disconnect init welcome \
           ip-up-script ip-down-script ipv6-up-script ipv6-down-script"

die() { echo "mo-vpn: $*" >&2; exit 2; }

_bin() { command -v "$1" 2>/dev/null || die "$1 not found"; }

_name() { [[ "$1" =~ $NAME_RE ]] || die "invalid tunnel name: $1"; printf '%s' "$1"; }

# Reject a config that carries a command-hook directive. Matches the FIRST token of
# each line (split on space/tab/=), so `connect-delay 5000` is NOT `connect`.
_no_hooks() {
  local file="$1"; shift
  [ -r "$file" ] || return 0   # absent → nothing to smuggle; the tool will error if truly needed
  local key f
  while IFS=$' \t=' read -r key _; do
    case "$key" in '' | '#'*) continue ;; esac
    key="${key,,}"
    for f in $*; do
      [ "$key" = "$f" ] && die "refused: config directive '$key' in ${file##*/} may run a command as root"
    done
  done < "$file"
  return 0   # the while-read loop's final read fails at EOF; don't leak that under `set -e`
}

usage() { die "usage: mo-vpn <verb> [args] — see deploy/vpn/mo-vpn"; }

verb="${1-}"; [ -n "$verb" ] || usage; shift || true

case "$verb" in
  wg-quick-up)
    [ "$#" -eq 1 ] || usage; name="$(_name "$1")"
    conf="${VPN_CONF_DIR%/}/${name}.conf"
    [ -r "$conf" ] || die "missing WireGuard config: $conf"
    _no_hooks "$conf" "$WG_HOOKS"
    exec "$(_bin wg-quick)" up "$conf"
    ;;
  wg-quick-down)
    [ "$#" -eq 1 ] || usage; name="$(_name "$1")"
    conf="${VPN_CONF_DIR%/}/${name}.conf"
    if [ -e "$conf" ]; then
      _no_hooks "$conf" "$WG_HOOKS"
      exec "$(_bin wg-quick)" down "$conf"
    fi
    exec "$(_bin wg-quick)" down "$name"   # no conf → interface name, no hooks to run
    ;;
  wg-show)
    [ "$#" -eq 1 ] || usage; name="$(_name "$1")"
    exec "$(_bin wg)" show "$name" dump
    ;;
  ip-link-list)
    [ "$#" -eq 0 ] || usage
    exec "$(_bin ip)" -o link show
    ;;
  ip-route-replace)
    [ "$#" -eq 2 ] || usage
    [[ "$1" =~ $CIDR_RE ]] || die "invalid CIDR: $1"
    [[ "$2" =~ $IFACE_RE ]] || die "invalid interface: $2"
    exec "$(_bin ip)" route replace "$1" dev "$2"
    ;;
  ipsec-reload)
    [ "$#" -eq 0 ] || usage
    exec "$(_bin ipsec)" reload
    ;;
  ipsec-up)
    [ "$#" -eq 1 ] || usage; name="$(_name "$1")"
    _no_hooks "${IPSEC_D}/mr-${name}.conf" "$IPSEC_HOOKS"
    exec "$(_bin ipsec)" up "mr-${name}"
    ;;
  ipsec-down)
    [ "$#" -eq 1 ] || usage; name="$(_name "$1")"
    exec "$(_bin ipsec)" down "mr-${name}"
    ;;
  xl2tpd-start)
    [ "$#" -eq 1 ] || usage; name="$(_name "$1")"
    _no_hooks "${PPP_D}/options.l2tpd.mr-${name}" "$PPP_HOOKS"
    exec "$(_bin xl2tpd)" -c "${XL2TPD_D}/mr-${name}.conf" \
      -C "${RUN_DIR}/${name}.control" -p "${RUN_DIR}/${name}.pid"
    ;;
  xl2tpd-connect | xl2tpd-disconnect)
    [ "$#" -eq 1 ] || usage; name="$(_name "$1")"
    action="${verb#xl2tpd-}"
    exec "$(_bin xl2tpd-control)" -c "${RUN_DIR}/${name}.control" "$action" "mr-${name}"
    ;;
  xl2tpd-stop)
    [ "$#" -eq 1 ] || usage; name="$(_name "$1")"
    pidfile="${RUN_DIR}/${name}.pid"
    [ -r "$pidfile" ] || exit 0
    pid="$(cat "$pidfile")"
    case "$pid" in '' | *[!0-9]*) die "bad pid in $pidfile" ;; esac
    comm="$(cat "/proc/${pid}/comm" 2>/dev/null || true)"
    [ "$comm" = "xl2tpd" ] || die "pid $pid is not xl2tpd (comm='${comm}') — refusing to kill"
    exec "$(_bin kill)" "$pid"
    ;;
  *)
    die "unknown verb: $verb"
    ;;
esac
