#!/bin/bash
# ============================================================
# alpha-vpnstore — the public address of the VPN storefront
# (panel: VPN ▸ Storefront ▸ Settings ▸ Address).
#
#   alpha-vpnstore status              JSON {ok, host, tls, redirects[]}
#   alpha-vpnstore check <fqdn>        JSON {ok, fqdn, ips[], here, server_ip, taken} — does it point HERE?
#   alpha-vpnstore move <fqdn> [keep]  serve the store on <fqdn>: certificate first, then the vhost.
#                                      keep=1 (default) turns every earlier name into a 301 to the new one;
#                                      keep=0 drops the earlier names and their certificates.
#
# The vhost is the same shape provisioning/steps/02-portals.sh renders from nginx/portal.tpl
# (one docroot, one front controller) plus optional redirect blocks for earlier names, and its
# first server_name IS the store's address — the panel reads it from there, so the page, the
# checkout's Origin check and the builder can never disagree with what nginx actually serves.
# The admin host is refused outright; a name another site already answers is never taken over;
# nginx is reloaded only after `nginx -t` passes, and a refused vhost is put back as it was.
# ============================================================
set -uo pipefail

ROOT=/var/www/vpn-portal
VHOST=/etc/nginx/sites-available/vpn-portal
VHOST_ON=/etc/nginx/sites-enabled/vpn-portal
ACMEV=/etc/nginx/sites-available/vpn-portal-acme
ACMEV_ON=/etc/nginx/sites-enabled/vpn-portal-acme
LE=/etc/letsencrypt/live

die() { jq -n --arg m "$*" '{ok:false,msg:$m}'; exit 1; }
conf() { ( . /etc/alpha-panel.conf 2>/dev/null; eval "printf '%s' \"\${$1:-}\"" ); }
PANEL="$(conf PANEL_SUBDOMAIN | tr 'A-Z' 'a-z')"
EMAIL="$(conf ADMIN_EMAIL)"

fpm_sock() { for v in 8.3 8.4 8.2 8.1; do [ -S "/run/php/php$v-fpm.sock" ] && { echo "/run/php/php$v-fpm.sock"; return; }; done; echo /run/php/php8.3-fpm.sock; }
cur_host()  { [ -f "$VHOST" ] && sed -nE 's/^[[:space:]]*server_name[[:space:]]+([^; ]+).*/\1/p' "$VHOST" | head -1; }
redirects() { [ -f "$VHOST" ] && sed -nE 's/^# alpha-vpnstore redirect: ([^ ]+).*/\1/p' "$VHOST"; }
valid()     { [ ${#1} -le 253 ] && [[ "$1" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$ ]]; }
norm()      { printf '%s' "${1:-}" | tr 'A-Z' 'a-z' | sed 's/\.$//'; }
here_ips()  { hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+(\.[0-9]+){3}$'; }
dns_ips()   { getent ahostsv4 "$1" 2>/dev/null | awk '{print $1}' | sort -u; }
# 0 = resolves to this server · 1 = resolves elsewhere · 2 = does not resolve
resolves_here() {
  local ips; ips="$(dns_ips "$1")"
  [ -n "$ips" ] || return 2
  for i in $ips; do here_ips | grep -qx "$i" && return 0; done
  return 1
}
# `systemctl reload` returns before nginx's workers have the new config — wait until the name really answers.
# Certificate checked (no -k) and only the store's own answers count: until the new block is live, nginx's
# default site answers the name with ITS certificate and a 301, which must not pass for "done".
serving() {
  local i c
  for i in $(seq 1 40); do
    c="$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 --resolve "$1:443:127.0.0.1" "https://$1/" 2>/dev/null)"
    case "$c" in 200|503) return 0 ;; esac
    sleep 0.5
  done
  return 1
}
# which enabled site (other than the store's own vhost) already answers this name?
taken_by() {
  local f="$1" w
  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")" ] || { basename "$w"; return; }
  done
}

vhost() {   # <host> — reads $REDIR (space-separated earlier names)
  local h="$1" sock o; sock="$(fpm_sock)"
  cat <<EOF
# VPN storefront — written by /usr/local/sbin/alpha-vpnstore (same shape as provisioning/nginx/portal.tpl).
# Its own docroot on its own hostname — deliberately NOT the admin host, which must never appear
# in anything a visitor can fetch. The FIRST server_name below is the store's address.
server {
    listen 80;
    listen [::]:80;
    server_name $h;
    root $ROOT;

    location ^~ /.well-known/acme-challenge/ { allow all; root $ROOT; default_type "text/plain"; }
    location / { return 301 https://\$host\$request_uri; }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name $h;
    root $ROOT;

    ssl_certificate     $LE/$h/fullchain.pem;
    ssl_certificate_key $LE/$h/privkey.pem;

    add_header X-Content-Type-Options nosniff always;
    add_header Referrer-Policy strict-origin-when-cross-origin always;
    add_header X-Frame-Options SAMEORIGIN always;

    location ^~ /.well-known/acme-challenge/ { allow all; root $ROOT; default_type "text/plain"; }

    # single front controller — every path is served by index.php
    location / { try_files \$uri /index.php\$is_args\$args; }

    location = /index.php {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:$sock;
    }

    # nothing else in the docroot may execute, and dotfiles stay private
    location ~ \.php\$ { return 404; }
    location ~ /\.(?!well-known) { deny all; }
}
EOF
  for o in $REDIR; do
    [ -f "$LE/$o/fullchain.pem" ] || continue      # a name without its certificate cannot answer https
    cat <<EOF

# alpha-vpnstore redirect: $o -> $h
server {
    listen 80;
    listen [::]:80;
    server_name $o;
    location ^~ /.well-known/acme-challenge/ { allow all; root $ROOT; default_type "text/plain"; }
    location / { return 301 https://$h\$request_uri; }
}
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name $o;
    ssl_certificate     $LE/$o/fullchain.pem;
    ssl_certificate_key $LE/$o/privkey.pem;
    location ^~ /.well-known/acme-challenge/ { allow all; root $ROOT; default_type "text/plain"; }
    location / { return 301 https://$h\$request_uri; }
}
EOF
  done
}

case "${1:-}" in
  status)
    h="$(cur_host)"; tls=false
    [ -n "$h" ] && [ -f "$LE/$h/fullchain.pem" ] && tls=true
    jq -n --arg h "$h" --argjson tls "$tls" --arg r "$(redirects | tr '\n' ' ')" \
      '{ok:true, host:$h, tls:$tls, redirects:($r | split(" ") | map(select(length > 0)))}'
    ;;

  check)
    f="$(norm "${2:-}")"
    valid "$f" || die "That is not a valid host name (e.g. vpn.example.com)."
    resolves_here "$f"; rc=$?
    jq -n --arg f "$f" --arg ips "$(dns_ips "$f" | tr '\n' ' ')" --argjson here "$([ $rc -eq 0 ] && echo true || echo false)" \
      --arg ip "$(here_ips | head -1)" --arg taken "$(taken_by "$f")" --arg cur "$(cur_host)" --argjson admin "$([ "$f" = "$PANEL" ] && echo true || echo false)" \
      '{ok:true, fqdn:$f, ips:($ips | split(" ") | map(select(length > 0))), here:$here, server_ip:$ip, taken:$taken, current:($f == $cur), admin:$admin}'
    ;;

  move)
    f="$(norm "${2:-}")"; keep="${3:-1}"
    valid "$f" || die "That is not a valid host name (e.g. vpn.example.com)."
    [ -n "$PANEL" ] && [ "$f" = "$PANEL" ] && die "That is the admin panel's own address — the store can never live there."
    [ -d "$ROOT" ] && [ -f "$ROOT/index.php" ] || die "The storefront docroot $ROOT is missing — run provisioning step 02-portals first."
    old="$(cur_host)"
    [ "$f" = "$old" ] && die "The store already lives at $f."
    t="$(taken_by "$f")"; [ -z "$t" ] || die "$f is already served by the site \"$t\" — remove it there first."
    resolves_here "$f"; rc=$?
    [ $rc -eq 2 ] && die "$f does not resolve yet — add an A record pointing to $(here_ips | head -1), wait for it to spread, then try again."
    [ $rc -eq 1 ] && die "$f points to $(dns_ips "$f" | tr '\n' ' ')— not to this server ($(here_ips | head -1)). Change its A record first."

    # 1. a certificate for the new name. A temporary HTTP-only block answers the challenge,
    #    so the store keeps serving on its current name the whole time.
    if [ ! -f "$LE/$f/fullchain.pem" ]; then
      if ! grep -qE "server_name[^;]*[[:space:]]${f//./\\.}[[:space:];]" "$VHOST" 2>/dev/null; then
        printf 'server {\n    listen 80;\n    listen [::]:80;\n    server_name %s;\n    location ^~ /.well-known/acme-challenge/ { allow all; root %s; default_type "text/plain"; }\n    location / { return 404; }\n}\n' "$f" "$ROOT" > "$ACMEV"
        ln -sf "$ACMEV" "$ACMEV_ON"
        if ! nginx -t >/dev/null 2>&1; then rm -f "$ACMEV_ON" "$ACMEV"; die "nginx refused the temporary challenge block — nothing changed."; fi
        systemctl reload nginx
      fi
      if [ -n "$EMAIL" ]; then reg=(-m "$EMAIL"); else reg=(--register-unsafely-without-email); fi
      out="$(certbot certonly --webroot -w "$ROOT" -d "$f" --non-interactive --agree-tos "${reg[@]}" 2>&1)"; crc=$?
      if [ -f "$ACMEV" ]; then rm -f "$ACMEV_ON" "$ACMEV"; nginx -t >/dev/null 2>&1 && systemctl reload nginx; fi
      [ $crc -eq 0 ] && [ -f "$LE/$f/fullchain.pem" ] || die "Could not get a certificate for $f: $(printf '%s' "$out" | tail -c 300)"
    fi

    # 2. the store on the new name; earlier names become redirects (newest five) — or go away
    prev="$(printf '%s\n' $(redirects) $old | awk 'NF && !s[$0]++')"
    REDIR=""
    [ "$keep" = 1 ] && REDIR="$(printf '%s\n' $prev | grep -vx "$f" | tail -5 | tr '\n' ' ')"
    bak="$(cat "$VHOST" 2>/dev/null)"
    vhost "$f" > "$VHOST"; chmod 644 "$VHOST"
    [ -L "$VHOST_ON" ] || ln -s "$VHOST" "$VHOST_ON"
    if ! nginx -t >/dev/null 2>&1; then
      printf '%s\n' "$bak" > "$VHOST"; nginx -t >/dev/null 2>&1 && systemctl reload nginx
      die "nginx refused the new server block — the store stays at ${old:-its old address}."
    fi
    systemctl reload nginx
    live=true; serving "$f" || live=false

    # 3. names no longer answered lose their certificates — never the new one, never one another site uses
    now="$(redirects)"; gone=""
    for o in $prev; do
      [ "$o" = "$f" ] && continue
      printf '%s\n' $now | grep -qx "$o" && continue
      grep -Rqs "$LE/$o/" /etc/nginx/sites-enabled/ && continue
      certbot delete --cert-name "$o" --non-interactive >/dev/null 2>&1 && gone="$gone $o"
    done
    jq -n --arg f "$f" --arg old "$old" --arg r "$(printf '%s ' $now)" --arg g "$gone" --argjson live "$live" \
      '{ok:true, host:$f, old:$old, live:$live, redirects:($r | split(" ") | map(select(length > 0))), dropped:($g | split(" ") | map(select(length > 0))),
        msg:("The store now answers at https://" + $f + "/" + (if ($r | length) > 1 then " — the old address forwards there." else "." end)
             + (if $live then "" else " (nginx is still picking it up — give it a few seconds)" end))}'
    ;;

  *)
    echo "usage: alpha-vpnstore status | check <fqdn> | move <fqdn> [keep=1|0]" >&2
    exit 2
    ;;
esac
