#!/usr/bin/env bash
# =============================================================================
# AetherWS 2.3.7
# VLESS / VMess / Trojan / Shadowsocks / SSH
# WebSocket + TLS on :443 (Nginx front, Xray backend, SSH raw-upgrade bridge)
#
# Supported: Ubuntu 22.04 / 24.04, Debian 12
#
# 2.3.7 - Shadowsocks 2022 done correctly (on top of 2.3.6)
#   CRITICAL (2.3.6 could not install / produced unusable links)
#     1. SS-2022 needs a BASE64 pre-shared key of the exact cipher length
#        (`openssl rand -base64 32`); 2.3.6 used 32 hex bytes (64 chars), which
#        Xray rejects, so write_xray_config_initial failed.
#     2. SIP022: for 2022-blake3-* ciphers the ss:// userinfo MUST NOT be
#        base64-encoded (method and key are percent-encoded). 2.3.6 base64'd it
#        and hard-coded the cipher in the link, so old installs got wrong links.
#   Shadowsocks
#     - cipher selectable: 2022-blake3-aes-256-gcm (default), -chacha20-poly1305
#       (faster on CPUs without AES-NI), -aes-128-gcm. Env SS_METHOD / menu 05.
#     - two links: Xray/sing-box style (type=ws&security=tls&sni&host&path,
#       independent SNI/Host) and SIP003 v2ray-plugin style.
#     - legacy aes-128-gcm installs are upgraded by menu 13 / `aether repair`.
#     - self-test checks cipher + key length.
#   WebSocket paths: validated charset (they end up in nginx, JSON, systemd and
#   the env file), unique, never /.well-known; AETHER_PATH_* env for automation.
# 2.3.6 (kept): IPv6 validation, no `exit` in do_uninstall, hidden SSH password
#   prompt, nginx temp file in WORK_DIR (+chmod 644), guarded E2E_DIR removal.
#
# 2.3.5 - fixes over 2.3.4
#   1. A failed REinstall no longer leaves paths.env / config.json / domain in
#      a half-new state: they are backed up and restored (until the new Nginx
#      vhost is live).
#   2. SSH_TCP_FORWARDING / SSH_MAXSTARTUPS / NGX_LIMIT_* are saved in
#      paths.env, so menu 9 / menu 13 / repair no longer revert them to
#      defaults (an explicit environment value still wins).
#   3. Uninstall: tunnel users that are kept get /usr/sbin/nologin (their
#      ForceCommand restriction disappears with the drop-in).
#   4. Protocol E2E tests fall back to 127.0.0.1 (same SNI/Host) for the
#      default profile, like the profile probe: no false failures on VPSes
#      that cannot reach their own public IP (hairpin NAT / DNS cache).
#   5. A failed final profile probe is a warning (links may not work), not a
#      fatal error that skipped `self_install`; AETHER_SKIP_PROBE=1 skips it.
#   Also: port-availability check for 10000-10003/9022, stale aether-rate.conf
#   removal, PerSourcePenaltyExemptList (OpenSSH >= 9.8) for the loopback
#   bridge, AllowUsers/AllowGroups warning, ::ffff: AAAA false positive,
#   minimum SSH password length (8), Xray update tests the current config with
#   the NEW binary before replacing, `allowInsecure` removed from test configs.
#
# Earlier (2.3.4): ssh_users stdin hang, E2E "000" acceptance, profile probe
# reloading old values, default-site re-enable on failed install, sshd drop-in
# rollback, cfg_lock, write_* return codes, header(), traps, menu `&& ||`
# patterns, conflict checks, printf for secrets, apt error output.
# =============================================================================
export LC_ALL=C.UTF-8 2>/dev/null || true
set -uo pipefail
umask 022
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

VERSION="2.3.7"
APP="AetherWS"
CONF_DIR=/etc/aether
DOMAIN_FILE=$CONF_DIR/domain
IP_FILE=$CONF_DIR/ip
ENV_FILE=$CONF_DIR/paths.env
XRAY_BIN=/usr/local/bin/xray
XRAY_DIR=/usr/local/etc/xray
XRAY_CFG=$XRAY_DIR/config.json
XRAY_ASSET=/usr/local/share/xray
XRAY_LOG=/var/log/xray
BRIDGE_DIR=/usr/local/lib/aether
BRIDGE=$BRIDGE_DIR/ssh-ws-bridge.py
HOLD=$BRIDGE_DIR/ssh-hold.sh
NGX_CONF=/etc/nginx/conf.d/aether.conf
NGX_RATE=/etc/nginx/conf.d/00-aether-rate.conf
NGX_ACME=/etc/nginx/conf.d/99-aether-acme.conf
WEBROOT_ACME=/var/www/aether-acme
SITE_ROOT=/var/www/aether-site
SSH_GROUP=tunnel-only
SSH_DROPIN=/etc/ssh/sshd_config.d/00-aether.conf
SSH_LEGACY_DROPIN=/etc/ssh/sshd_config.d/aether.conf
BIN_LINK=/usr/local/bin/aether
HOOK=/etc/letsencrypt/renewal-hooks/deploy/aether-nginx.sh
LOCK_DIR=/run/aether
E2E_DIR=$LOCK_DIR/e2e
E2E_CURRENT_PID=""
E2E_CODE=""
WORK_DIR=""
IP_CACHE=""
AETHER_INSTALL_ACTIVE=0
AETHER_LOCK_HELD=0
AETHER_WAS_INSTALLED=0
AETHER_STATE_BACKUP=0
SSH_INCLUDE_MARKER=$CONF_DIR/ssh-include-added
NGINX_DEFAULT_BACKUP=$CONF_DIR/backups/nginx-default
NGINX_AETHER_BACKUP=$CONF_DIR/backups/aether.conf
STATE_BACKUP_DIR=$CONF_DIR/backups/state

REMOTE_PROXY=""
CLIENT_SNI=""
CLIENT_HOST=""
CLIENT_PORT=443

P_VLESS=10000
P_VMESS=10001
P_TROJAN=10002
P_SS=10003
P_SSHWS=9022

# Remember which tunables were set explicitly in the environment: those win
# over the values saved in paths.env (see load_conf).
ENV_OVERRIDE=""
for _k in SSH_MAXSTARTUPS SSH_TCP_FORWARDING NGX_LIMIT_CONN NGX_LIMIT_RATE NGX_LIMIT_BURST; do
  if [ -n "${!_k:-}" ]; then ENV_OVERRIDE="$ENV_OVERRIDE $_k "; fi
done
unset _k

SSH_MAXSTARTUPS=${SSH_MAXSTARTUPS:-100:30:200}
# "local" = only -L / dynamic (SOCKS) forwarding, no -R. Use "yes" to allow both.
SSH_TCP_FORWARDING=${SSH_TCP_FORWARDING:-local}
# Per-client-IP Nginx limits (all WebSocket paths share the same counters).
NGX_LIMIT_CONN=${NGX_LIMIT_CONN:-200}
NGX_LIMIT_RATE=${NGX_LIMIT_RATE:-100}
NGX_LIMIT_BURST=${NGX_LIMIT_BURST:-200}
# Shadowsocks-2022 cipher used for NEW keys (install / regenerate).
SS_METHOD=${SS_METHOD:-2022-blake3-aes-256-gcm}

export XRAY_LOCATION_ASSET=$XRAY_ASSET

R=""; G=""; Y=""; C=""; W=""; N=""
BW=66

setup_colors() {
  if [ -t 1 ] && [ "${NO_COLOR:-}" != "1" ] && [ "${TERM:-dumb}" != "dumb" ]; then
    R=$'\e[1;31m'; G=$'\e[1;32m'; Y=$'\e[1;33m'; C=$'\e[1;36m'; W=$'\e[1;37m'; N=$'\e[0m'
  else
    R=""; G=""; Y=""; C=""; W=""; N=""
  fi
}
setup_colors

# One private work directory for every temp file; removed by cleanup().
init_work_dir() {
  if [ -n "$WORK_DIR" ] && [ -d "$WORK_DIR" ]; then return 0; fi
  install -d -m 755 "$LOCK_DIR" || return 1
  WORK_DIR=$(mktemp -d "$LOCK_DIR/work.XXXXXX") || return 1
  chmod 700 "$WORK_DIR" 2>/dev/null || true
  return 0
}

cleanup() {
  if [ -n "${E2E_CURRENT_PID:-}" ]; then
    kill "$E2E_CURRENT_PID" 2>/dev/null || true
    wait "$E2E_CURRENT_PID" 2>/dev/null || true
    E2E_CURRENT_PID=""
  fi
  # A REinstall that died before the new Nginx vhost landed: put the previous
  # credentials/paths/domain back so Xray, the bridge and Nginx agree again.
  if [ "${AETHER_STATE_BACKUP:-0}" -eq 1 ] && [ -d "$STATE_BACKUP_DIR" ]; then
    AETHER_STATE_BACKUP=0
    [ -f "$STATE_BACKUP_DIR/paths.env" ] && cp -a "$STATE_BACKUP_DIR/paths.env" "$ENV_FILE" 2>/dev/null
    [ -f "$STATE_BACKUP_DIR/domain" ] && cp -a "$STATE_BACKUP_DIR/domain" "$DOMAIN_FILE" 2>/dev/null
    [ -f "$STATE_BACKUP_DIR/config.json" ] && cp -a "$STATE_BACKUP_DIR/config.json" "$XRAY_CFG" 2>/dev/null
    if [ -x "$XRAY_BIN" ] && [ -f "$XRAY_CFG" ]; then
      load_conf
      setup_bridge >/dev/null 2>&1 || true
      systemctl restart xray >/dev/null 2>&1 || true
      warn "Reinstall did not complete; the previous AetherWS configuration was restored."
    fi
  fi
  if [ "${AETHER_INSTALL_ACTIVE:-0}" -eq 1 ]; then
    # Put the previous Aether vhost back only if the new one never landed.
    if [ -f "$NGINX_AETHER_BACKUP" ] && [ ! -f "$NGX_CONF" ]; then
      install -d -m 755 "$(dirname "$NGX_CONF")" 2>/dev/null || true
      cp -a "$NGINX_AETHER_BACKUP" "$NGX_CONF" 2>/dev/null || true
      nginx -t >/dev/null 2>&1 && (systemctl reload nginx >/dev/null 2>&1 || true)
    fi
    # Bring the stock default site back only while Aether's vhost is absent:
    # both claim default_server, so having both makes `nginx -t` fail.
    if [ -f "$NGINX_DEFAULT_BACKUP" ] && [ ! -e /etc/nginx/sites-enabled/default ] && [ ! -f "$NGX_CONF" ]; then
      install -d -m 755 /etc/nginx/sites-enabled 2>/dev/null || true
      cp -a "$NGINX_DEFAULT_BACKUP" /etc/nginx/sites-enabled/default 2>/dev/null || true
      nginx -t >/dev/null 2>&1 && (systemctl reload nginx >/dev/null 2>&1 || true)
    fi
  fi
  # Leftovers written by versions <= 2.3.3
  rm -f /tmp/aether-profile-probe.log /tmp/aether-profile-install.log 2>/dev/null || true
  if [ -n "${WORK_DIR:-}" ]; then rm -rf "$WORK_DIR" 2>/dev/null || true; fi
  if [ -n "${E2E_DIR:-}" ] && [ "$E2E_DIR" != "/" ]; then rm -rf "$E2E_DIR" 2>/dev/null || true; fi
}
# INT/TERM must terminate the script (a handler that merely returns lets the
# script continue); `exit` then fires the EXIT trap exactly once.
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

info() { printf '%s[*]%s %s\n' "$C" "$N" "$*"; }
ok()   { printf '%s[+]%s %s\n' "$G" "$N" "$*"; }
warn() { printf '%s[!]%s %s\n' "$Y" "$N" "$*"; }
err()  { printf '%s[x]%s %s\n' "$R" "$N" "$*" >&2; }
die()  { err "$*"; exit 1; }

pause() {
  [ -t 0 ] || return 0
  read -r -p "Press Enter to continue..." _ || true
}

confirm() {
  if [ "${AETHER_YES:-0}" = 1 ]; then return 0; fi
  [ -t 0 ] || return 1
  local a
  read -r -p "$1 [y/N]: " a || return 1
  [[ $a =~ ^[Yy]$ ]]
}

need_root() {
  [ "$(id -u)" -eq 0 ] || die "Run as root (sudo -i)."
  init_work_dir || die "Cannot create the work directory under $LOCK_DIR."
}

validate_ssh_maxstartups() {
  [[ "$SSH_MAXSTARTUPS" =~ ^[0-9]+:[0-9]+:[0-9]+$ ]] || return 1
  local a b c
  IFS=: read -r a b c <<< "$SSH_MAXSTARTUPS"
  [ "$a" -ge 1 ] || return 1
  [ "$b" -ge 0 ] && [ "$b" -le 100 ] || return 1
  [ "$c" -ge "$a" ] || return 1
  [ "$c" -le 65535 ] || return 1
}

validate_ssh_forwarding() {
  case "$SSH_TCP_FORWARDING" in
    local|yes) return 0 ;;
    *) return 1 ;;
  esac
}

validate_ngx_limits() {
  local v
  for v in "$NGX_LIMIT_CONN" "$NGX_LIMIT_RATE" "$NGX_LIMIT_BURST"; do
    [[ "$v" =~ ^[0-9]{1,6}$ ]] || return 1
    [ "$v" -ge 1 ] || return 1
  done
  return 0
}

check_os() {
  [ -r /etc/os-release ] || die "Cannot detect OS."
  local id ver
  id=$(. /etc/os-release; echo "${ID:-}")
  ver=$(. /etc/os-release; echo "${VERSION_ID:-}")
  case "$id:$ver" in
    ubuntu:22.04|ubuntu:24.04|debian:12) ;;
    *) [ "${AETHER_FORCE:-0}" = 1 ] || die "Unsupported OS ($id $ver). Use Ubuntu 22.04/24.04 or Debian 12 (or AETHER_FORCE=1)." ;;
  esac
  command -v systemctl >/dev/null 2>&1 || die "systemd is required."
}

is_installed() {
  [ -x "$XRAY_BIN" ] &&
  [ -f "$XRAY_CFG" ] &&
  [ -f "$DOMAIN_FILE" ] &&
  [ -f "$ENV_FILE" ] &&
  [ -f /etc/systemd/system/xray.service ] &&
  [ -f /etc/systemd/system/aether-ssh-ws.service ] &&
  [ -x "$BRIDGE" ] &&
  [ -x "$HOLD" ] &&
  ( [ -f "$SSH_DROPIN" ] || [ -f "$SSH_LEGACY_DROPIN" ] ) &&
  [ -f "$NGX_CONF" ] &&
  [ -f "/etc/letsencrypt/live/$(cat "$DOMAIN_FILE" 2>/dev/null)/fullchain.pem" ]
}

load_conf() {
  DOMAIN=""
  WSP_VLESS=""; WSP_VMESS=""; WSP_TROJAN=""; WSP_SS=""; WSP_SSH=""
  REMOTE_PROXY=""; CLIENT_SNI=""; CLIENT_HOST=""; CLIENT_PORT=443
  if [ -r "$ENV_FILE" ]; then
    local key value
    while IFS='=' read -r key value; do
      [ -n "$key" ] || continue
      case "$key" in
        \#*) continue ;;
        WSP_VLESS|WSP_VMESS|WSP_TROJAN|WSP_SS|WSP_SSH|REMOTE_PROXY|CLIENT_SNI|CLIENT_HOST|CLIENT_PORT)
          value=${value#\"}; value=${value%\"}
          printf -v "$key" '%s' "$value"
          ;;
        SSH_MAXSTARTUPS|SSH_TCP_FORWARDING|NGX_LIMIT_CONN|NGX_LIMIT_RATE|NGX_LIMIT_BURST)
          value=${value#\"}; value=${value%\"}
          # An explicit environment value overrides the saved one.
          case "$ENV_OVERRIDE" in
            *" $key "*) ;;
            *) printf -v "$key" '%s' "$value" ;;
          esac
          ;;
      esac
    done < "$ENV_FILE"
  fi
  DOMAIN=$(cat "$DOMAIN_FILE" 2>/dev/null || echo "")
  REMOTE_PROXY=${REMOTE_PROXY:-$DOMAIN}
  CLIENT_SNI=${CLIENT_SNI:-$DOMAIN}
  CLIENT_HOST=${CLIENT_HOST:-$DOMAIN}
  CLIENT_PORT=${CLIENT_PORT:-443}
  if declare -F parse_remote_proxy >/dev/null 2>&1; then
    parse_remote_proxy "$REMOTE_PROXY" >/dev/null 2>&1 && CLIENT_PORT="$REMOTE_PROXY_PORT" || true
  fi
}

get_ipv4() {
  local u ip
  for u in https://ipv4.icanhazip.com https://api.ipify.org https://ifconfig.me/ip; do
    ip=$(curl -4 -fsS --max-time 4 "$u" 2>/dev/null | tr -d '[:space:]')
    if [[ $ip =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
      echo "$ip"; return 0
    fi
  done
  return 1
}

get_ipv6() {
  local u ip
  for u in https://api64.ipify.org https://ifconfig.co/ip; do
    ip=$(curl -6 -fsS --max-time 5 "$u" 2>/dev/null | tr -d '[:space:]')
    [[ "$ip" == ::ffff:* ]] && continue
    if [[ $ip =~ ^[0-9a-fA-F:]+$ ]] && [[ $ip == *:* ]]; then
      echo "$ip"; return 0
    fi
  done
  return 1
}

rand_hex() { openssl rand -hex "$1"; }

# --- Shadowsocks 2022 (SIP022): the password is a base64 key whose decoded
# length is fixed by the cipher (16 bytes for aes-128, 32 for the others).
ss_method_valid() {
  case "$1" in
    2022-blake3-aes-256-gcm|2022-blake3-chacha20-poly1305|2022-blake3-aes-128-gcm) return 0 ;;
    *) return 1 ;;
  esac
}
ss_key_len() {
  case "$1" in
    2022-blake3-aes-128-gcm) echo 16 ;;
    2022-blake3-aes-256-gcm|2022-blake3-chacha20-poly1305) echo 32 ;;
    *) return 1 ;;
  esac
}
ss_new_key() { local n; n=$(ss_key_len "$1") || return 1; openssl rand -base64 "$n"; }
ss_gen_password() { if ss_method_valid "$1"; then ss_new_key "$1"; else rand_hex 16; fi; }
# SIP002/SIP022 userinfo: 2022 ciphers -> percent-encoded "method:key" (NOT
# base64); legacy ciphers -> base64url(method:password).
ss_userinfo() {
  local m=$1 p=$2
  if [[ "$m" == 2022-* ]]; then
    printf '%s:%s' "$(url_encode "$m")" "$(url_encode "$p")"
  else
    b64url_oneline "$m:$p"
  fi
}

# --- WebSocket paths. They are written into nginx, JSON, systemd and the env
# file, so only a conservative charset is accepted.
validate_ws_path() {
  local p=$1
  [[ "$p" =~ ^/[A-Za-z0-9._~/-]{1,120}$ ]] || return 1
  [[ "$p" != *//* ]] || return 1
  [[ "$p" != */ ]] || return 1
  [[ "$p" != /.well-known* ]] || return 1
  return 0
}
gen_ws_path() {
  local h1 h2 h3
  h1=$(rand_hex 4); h2=$(rand_hex 6); h3=$(rand_hex 3)
  case $((RANDOM % 6)) in
    0) echo "/api/v2/$1/$h1" ;;
    1) echo "/assets/js/chunk.$h2.js" ;;
    2) echo "/_next/static/chunks/$h1$h3" ;;
    3) echo "/static/media/$h2" ;;
    4) echo "/cdn/assets/$h1-$h3" ;;
    *) echo "/socket/v1/$h2" ;;
  esac
}
# Interactive: Enter keeps the random default. Automation: AETHER_PATH_VLESS /
# _VMESS / _TROJAN / _SS / _SSH.
choose_ws_paths() {
  local -a keys=(vless vmess trojan ss ssh)
  local -a labels=("VLESS " "VMess " "Trojan" "SS    " "SSH-WS")
  local -a envn=(AETHER_PATH_VLESS AETHER_PATH_VMESS AETHER_PATH_TROJAN AETHER_PATH_SS AETHER_PATH_SSH)
  local -a out=()
  local i j nm def v
  if [ -t 0 ]; then info "WebSocket paths (letters, digits, . _ ~ - /). Press Enter for a random default."; fi
  for i in 0 1 2 3 4; do
    nm=${envn[$i]}; def=${!nm:-}
    [ -n "$def" ] || def=$(gen_ws_path "${keys[$i]}")
    v=$def
    if [ -t 0 ]; then
      read -r -p "${labels[$i]} path [$def]: " v; v=${v:-$def}
    fi
    validate_ws_path "$v" || { err "Invalid path '$v' (must start with /, only A-Z a-z 0-9 . _ ~ - /, no //, no trailing /, not /.well-known)."; return 1; }
    for ((j=0; j<i; j++)); do
      [ "${out[$j]}" != "$v" ] || { err "Path '$v' is used twice; every protocol needs its own path."; return 1; }
    done
    out+=("$v")
  done
  WSP_VLESS=${out[0]}; WSP_VMESS=${out[1]}; WSP_TROJAN=${out[2]}; WSP_SS=${out[3]}; WSP_SSH=${out[4]}
}
new_uuid() { cat /proc/sys/kernel/random/uuid; }

url_encode() {
  python3 - "$1" <<'PY'
import sys
from urllib.parse import quote
print(quote(sys.argv[1], safe="-._~"))
PY
}

urlenc_path() { url_encode "$1"; }

b64_oneline() {
  if base64 -w0 </dev/null >/dev/null 2>&1; then
    printf '%s' "$1" | base64 -w0
  else
    printf '%s' "$1" | base64 | tr -d '\n'
  fi
}

b64url_oneline() {
  b64_oneline "$1" | tr '+/' '-_' | tr -d '='
}

# Ports sshd is configured for: sshd_config + systemd socket activation
# (Ubuntu's ssh.socket may listen on a port that sshd -T does not report).
ssh_listen_ports() {
  {
    sshd -T 2>/dev/null | awk '/^port /{print $2}'
    systemctl show ssh.socket -p Listen 2>/dev/null | sed -nE 's/^Listen=.*:([0-9]+) \(Stream\)$/\1/p'
    systemctl show sshd.socket -p Listen 2>/dev/null | sed -nE 's/^Listen=.*:([0-9]+) \(Stream\)$/\1/p'
  } | awk '/^[0-9]+$/ && !seen[$0]++'
}

# Everything above plus ports where a process named sshd is really listening.
ssh_ports_all() {
  {
    ssh_listen_ports
    ss -ltnpH 2>/dev/null | awk '/users:\(\("sshd"/ {k=split($4,a,":"); print a[k]}'
  } | awk '/^[0-9]+$/ && !seen[$0]++'
}

# The port the local bridge should dial: the first configured port that is
# actually listening on loopback/wildcard; otherwise the first configured one.
ssh_port() {
  local p listening
  listening=$(ss -ltnH 2>/dev/null | awk '{print $4}')
  while IFS= read -r p; do
    [ -n "$p" ] || continue
    if grep -Eq "^(0\.0\.0\.0|\*|\[::\]|127\.0\.0\.1):${p}\$" <<< "$listening"; then
      echo "$p"; return 0
    fi
  done < <(ssh_listen_ports)
  p=$(ssh_listen_ports | head -n1)
  echo "${p:-22}"
}

reload_ssh() {
  systemctl reload ssh >/dev/null 2>&1 || \
  systemctl reload sshd >/dev/null 2>&1 || \
  systemctl reload ssh.socket >/dev/null 2>&1 || \
  systemctl restart ssh >/dev/null 2>&1 || \
  systemctl restart sshd >/dev/null 2>&1
}

wait_active() {
  local s=$1 n=${2:-15} i
  for ((i=0; i<n; i++)); do
    systemctl is-active --quiet "$s" && return 0
    sleep 0.5
  done
  return 1
}

# awk reads the whole listing (no early exit), so `pipefail` cannot turn a
# successful match into a SIGPIPE failure.
wait_port() {
  local port=$1 n=${2:-20} i
  for ((i=0; i<n; i++)); do
    if ss -ltnH 2>/dev/null | awk -v p="$port" '{k=split($4,a,":"); if (a[k]==p) f=1} END{exit(f?0:1)}'; then
      return 0
    fi
    sleep 0.25
  done
  return 1
}

journal_hint() {
  command -v journalctl >/dev/null 2>&1 || return 0
  printf '%s--- %s (last 30) ---%s\n' "$Y" "$1" "$N"
  journalctl -u "$1" -n 30 --no-pager -l 2>/dev/null || true
}

# Return codes: 0 = written (changed), 1 = identical (unchanged), 2 = error.
write_atomic_from_stdin() {
  local dest=$1 mode=${2:-644} dir tmp rc=0
  dir=$(dirname "$dest")
  install -d -m 755 "$dir" || return 2
  tmp=$(mktemp "$dir/.aether-write.XXXXXX") || return 2
  if ! cat > "$tmp"; then rm -f "$tmp"; return 2; fi
  if [ -f "$dest" ] && cmp -s "$tmp" "$dest"; then
    rm -f "$tmp"; return 1
  fi
  chmod "$mode" "$tmp" || rc=2
  if [ "$rc" -ne 0 ]; then
    rm -f "$tmp"
    return 2
  fi
  if mv -f "$tmp" "$dest"; then
    return 0
  fi
  rm -f "$tmp"
  return 2
}

backup_file() {
  local src=$1 dst=$2
  [ -f "$src" ] || return 1
  cp -a "$src" "$dst"
}

restore_file() {
  local bak=$1 dst=$2
  [ -f "$bak" ] || return 1
  [ -n "$dst" ] || return 1
  cp -a "$bak" "$dst"
}

# Lock is taken once per holder; after cfg_unlock the next cfg_lock locks again.
cfg_lock() {
  install -d -m 755 "$LOCK_DIR" || return 1
  if [ "${AETHER_LOCK_HELD:-0}" != 1 ]; then
    exec 9>"$LOCK_DIR/config.lock" || return 1
    if ! flock -w 30 9; then
      err "Could not lock $LOCK_DIR/config.lock"
      exec 9>&- 2>/dev/null || true
      return 1
    fi
    AETHER_LOCK_HELD=1
  fi
  return 0
}

cfg_unlock() {
  if [ "${AETHER_LOCK_HELD:-0}" = 1 ]; then
    flock -u 9 2>/dev/null || true
    exec 9>&- 2>/dev/null || true
    AETHER_LOCK_HELD=0
  fi
}

# ----------------------------------------------------------------------------
# Package/Xray installation
# ----------------------------------------------------------------------------
# apt with a real error message (last lines of the log) and a dpkg-lock wait.
apt_run() {
  local log rc
  init_work_dir || return 1
  log=$(mktemp "$WORK_DIR/apt.XXXXXX") || return 1
  DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \
    apt-get -o DPkg::Lock::Timeout=120 "$@" >"$log" 2>&1
  rc=$?
  if [ "$rc" -ne 0 ]; then
    err "apt-get $* failed (exit $rc). Last output:"
    tail -n 20 "$log" >&2 || true
  fi
  rm -f "$log"
  return "$rc"
}

install_packages() {
  info "Installing required packages..."
  apt_run update -y || die "apt update failed."
  apt_run install -y \
    nginx curl unzip jq openssl ca-certificates ufw certbot python3 chrony \
    iproute2 openssh-server logrotate coreutils util-linux procps \
    || die "apt install failed."
  command -v python3 >/dev/null 2>&1 || die "python3 missing after apt install."
  command -v jq >/dev/null 2>&1 || die "jq missing after apt install."
  command -v nginx >/dev/null 2>&1 || die "nginx missing after apt install."
  command -v chronyc >/dev/null 2>&1 || warn "chronyc not available; VMess clock checks will be limited."
}

ensure_time_sync() {
  if command -v timedatectl >/dev/null 2>&1; then
    timedatectl set-ntp true >/dev/null 2>&1 || true
  fi
  if systemctl cat chrony.service >/dev/null 2>&1; then
    systemctl enable --now chrony.service >/dev/null 2>&1 || true
  elif systemctl cat chronyd.service >/dev/null 2>&1; then
    systemctl enable --now chronyd.service >/dev/null 2>&1 || true
  fi
  local i synced="" tracking
  for ((i=0; i<20; i++)); do
    if command -v timedatectl >/dev/null 2>&1; then
      synced=$(timedatectl show -p NTPSynchronized --value 2>/dev/null || true)
      [ "$synced" = yes ] && { ok "System clock synchronized (required by VMess)."; return 0; }
    fi
    if command -v chronyc >/dev/null 2>&1; then
      tracking=$(chronyc tracking 2>/dev/null || true)
      if grep -qiE 'Leap status[[:space:]]*:[[:space:]]*Normal' <<< "$tracking"; then
        ok "Chrony reports a synchronized clock (required by VMess)."
        return 0
      fi
    fi
    sleep 1
  done
  warn "Clock synchronization was not confirmed. VMess may fail until UTC time is within about 120 seconds of real time."
  return 1
}

# Containers (LXC/OpenVZ/Docker...) inherit the host clock and cannot run NTP.
is_container() { systemd-detect-virt -cq 2>/dev/null; }

xray_arch_zip() {
  case "$(uname -m)" in
    x86_64|amd64) echo "Xray-linux-64.zip" ;;
    aarch64|arm64) echo "Xray-linux-arm64-v8a.zip" ;;
    *) return 1 ;;
  esac
}

# Prints the staging directory on stdout; needs init_work_dir() done by need_root.
xray_download_staging() {
  local zip ver base tmp want have
  zip=$(xray_arch_zip) || { err "Unsupported CPU architecture: $(uname -m)"; return 1; }
  ver=${XRAY_VERSION:-latest}
  if [ "$ver" = latest ]; then
    base="https://github.com/XTLS/Xray-core/releases/latest/download"
  else
    base="https://github.com/XTLS/Xray-core/releases/download/${ver}"
  fi
  init_work_dir || return 1
  tmp=$(mktemp -d "$WORK_DIR/xray.XXXXXX") || return 1
  info "Downloading Xray-core (${ver})..." >&2
  if ! curl -fL --proto '=https' --tlsv1.2 --retry 3 --max-time 180 \
      -o "$tmp/$zip" "$base/$zip"; then
    rm -rf "$tmp"; err "Xray download failed."; return 1
  fi
  if ! curl -fL --proto '=https' --tlsv1.2 --retry 3 --max-time 60 \
      -o "$tmp/$zip.dgst" "$base/$zip.dgst"; then
    rm -rf "$tmp"; err "Xray digest download failed."; return 1
  fi
  # Accept both "SHA256=" and current Xray format "SHA2-256="
  want=$(grep -iE 'SHA2?-?256|sha256' "$tmp/$zip.dgst" | grep -oiE '[0-9a-fA-F]{64}' | head -n1 | tr 'A-F' 'a-f')
  have=$(sha256sum "$tmp/$zip" | awk '{print $1}')
  [[ $want =~ ^[0-9a-f]{64}$ ]] || { rm -rf "$tmp"; err "Could not parse SHA256 digest."; return 1; }
  [ "$want" = "$have" ] || { rm -rf "$tmp"; err "SHA256 mismatch: expected $want, got $have"; return 1; }
  ok "Xray archive SHA256 verified: $have" >&2

  mkdir -p "$tmp/x" || { rm -rf "$tmp"; return 1; }
  unzip -tq "$tmp/$zip" >/dev/null 2>&1 || { rm -rf "$tmp"; err "Xray ZIP integrity test failed."; return 1; }
  unzip -oq "$tmp/$zip" xray geoip.dat geosite.dat -d "$tmp/x" || { rm -rf "$tmp"; err "Xray extraction failed."; return 1; }
  [ -x "$tmp/x/xray" ] || { rm -rf "$tmp"; err "Xray binary missing from archive."; return 1; }
  echo "$tmp"
}

install_xray() {
  local tmp=$1
  install -d -m 755 "$XRAY_ASSET" || return 1
  install -m 755 "$tmp/x/xray" "$XRAY_BIN" || return 1
  install -m 644 "$tmp/x/geoip.dat" "$XRAY_ASSET/geoip.dat" || return 1
  install -m 644 "$tmp/x/geosite.dat" "$XRAY_ASSET/geosite.dat" || return 1
  "$XRAY_BIN" version >/dev/null 2>&1 || return 1
  "$XRAY_BIN" version | head -n1
  return 0
}

setup_xray_env() {
  id xray >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin xray || return 1
  install -d -m 750 -o root -g xray "$XRAY_DIR" || return 1
  install -d -m 750 -o xray -g xray "$XRAY_LOG" || return 1
  write_atomic_from_stdin /etc/logrotate.d/aether-xray 644 <<EOF2
$XRAY_LOG/*.log {
    daily
    rotate 7
    compress
    missingok
    notifempty
    copytruncate
}
EOF2
  local lrc=$?
  [ "$lrc" -le 1 ] || return 1
  write_atomic_from_stdin /etc/systemd/system/xray.service 644 <<EOF2
[Unit]
Description=Xray-core (AetherWS)
After=network-online.target nss-lookup.target
Wants=network-online.target

[Service]
User=xray
Group=xray
Type=simple
Environment=XRAY_LOCATION_ASSET=$XRAY_ASSET
ExecStart=$XRAY_BIN run -config $XRAY_CFG
ExecReload=/bin/kill -HUP \$MAINPID
Restart=on-failure
RestartSec=3
TimeoutStopSec=10
LimitNOFILE=1048576
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectControlGroups=true
LockPersonality=true
RestrictSUIDSGID=true
ReadWritePaths=$XRAY_LOG
ReadOnlyPaths=$XRAY_DIR $XRAY_ASSET

[Install]
WantedBy=multi-user.target
EOF2
  local rc=$?
  [ "$rc" -le 1 ] || return 1
  systemctl daemon-reload || return 1
  return 0
}

fix_log_perms() {
  chown -R xray:xray "$XRAY_LOG" 2>/dev/null || true
  chmod 750 "$XRAY_LOG" 2>/dev/null || true
}

validate_xray_config_file() {
  local file=$1
  jq . "$file" >/dev/null 2>&1 || return 1
  "$XRAY_BIN" run -test -config "$file" >/dev/null 2>&1 || return 1
  return 0
}

write_xray_config_initial() {
  local u1 u2 t s tmp
  u1=$(new_uuid) || return 1
  u2=$(new_uuid) || return 1
  t=$(rand_hex 16) || return 1
  ss_method_valid "$SS_METHOD" || { err "Invalid SS_METHOD='$SS_METHOD'"; return 1; }
  s=$(ss_new_key "$SS_METHOD") || return 1   # base64 key, exact length for the cipher
  tmp=$(mktemp "$XRAY_DIR/.config.json.XXXXXX") || return 1
  cat > "$tmp" <<EOF2
{
  "log": {
    "loglevel": "warning",
    "access": "none",
    "error": "$XRAY_LOG/error.log"
  },
  "inbounds": [
    {
      "tag": "vless-ws",
      "listen": "127.0.0.1",
      "port": $P_VLESS,
      "protocol": "vless",
      "settings": {
        "clients": [
          { "id": "$u1", "email": "default", "level": 0 }
        ],
        "decryption": "none"
      },
      "streamSettings": {
        "network": "ws",
        "security": "none",
        "wsSettings": { "path": "$WSP_VLESS" }
      }
    },
    {
      "tag": "vmess-ws",
      "listen": "127.0.0.1",
      "port": $P_VMESS,
      "protocol": "vmess",
      "settings": {
        "clients": [
          { "id": "$u2", "alterId": 0, "email": "default", "level": 0 }
        ]
      },
      "streamSettings": {
        "network": "ws",
        "security": "none",
        "wsSettings": { "path": "$WSP_VMESS" }
      }
    },
    {
      "tag": "trojan-ws",
      "listen": "127.0.0.1",
      "port": $P_TROJAN,
      "protocol": "trojan",
      "settings": {
        "clients": [
          { "password": "$t", "email": "default", "level": 0 }
        ]
      },
      "streamSettings": {
        "network": "ws",
        "security": "none",
        "wsSettings": { "path": "$WSP_TROJAN" }
      }
    },
    {
      "tag": "ss-ws",
      "listen": "127.0.0.1",
      "port": $P_SS,
      "protocol": "shadowsocks",
      "settings": {
        "method": "$SS_METHOD",
        "password": "$s",
        "network": "tcp",
        "email": "default"
      },
      "streamSettings": {
        "network": "ws",
        "security": "none",
        "wsSettings": { "path": "$WSP_SS" }
      }
    }
  ],
  "outbounds": [
    { "tag": "direct", "protocol": "freedom", "settings": {} },
    { "tag": "block", "protocol": "blackhole", "settings": {} }
  ],
  "routing": {
    "domainStrategy": "IPIfNonMatch",
    "rules": [
      {
        "type": "field",
        "ip": [ "geoip:private" ],
        "outboundTag": "block"
      }
    ]
  }
}
EOF2
  chmod 640 "$tmp" || { rm -f "$tmp"; return 1; }
  chown root:xray "$tmp" || { rm -f "$tmp"; return 1; }
  if ! validate_xray_config_file "$tmp"; then
    "$XRAY_BIN" run -test -config "$tmp" 2>&1 || true
    rm -f "$tmp"
    err "Generated Xray configuration was rejected."
    return 1
  fi
  mv -f "$tmp" "$XRAY_CFG" || { rm -f "$tmp"; return 1; }
  chown root:xray "$XRAY_CFG" || return 1
  chmod 640 "$XRAY_CFG" || return 1
  fix_log_perms
}

cfg_apply() {
  local tmp bak rc=1
  cfg_lock || return 1
  tmp=$(mktemp "$XRAY_DIR/.config.new.XXXXXX") || { cfg_unlock; return 1; }
  bak=$(mktemp "$XRAY_DIR/.config.bak.XXXXXX") || { rm -f "$tmp"; cfg_unlock; return 1; }
  rm -f "$bak"

  if ! jq "$@" "$XRAY_CFG" > "$tmp"; then
    err "jq update failed; configuration unchanged."
    rm -f "$tmp"
    cfg_unlock
    return 1
  fi
  chmod 640 "$tmp" && chown root:xray "$tmp" || { rm -f "$tmp"; cfg_unlock; return 1; }
  if ! validate_xray_config_file "$tmp"; then
    err "Xray rejected the new configuration; configuration unchanged."
    "$XRAY_BIN" run -test -config "$tmp" 2>&1 || true
    rm -f "$tmp"
    cfg_unlock
    return 1
  fi
  if ! cp -a "$XRAY_CFG" "$bak"; then
    err "Could not back up the current Xray configuration."
    rm -f "$tmp" "$bak"
    cfg_unlock
    return 1
  fi
  mv -f "$tmp" "$XRAY_CFG" || { rm -f "$tmp" "$bak"; cfg_unlock; return 1; }
  chown root:xray "$XRAY_CFG" || true
  chmod 640 "$XRAY_CFG" || true
  fix_log_perms

  if systemctl restart xray >/dev/null 2>&1 && wait_active xray 15; then
    rm -f "$bak"
    cfg_unlock
    return 0
  fi

  err "Xray restart failed; rolling back the last configuration."
  journal_hint xray
  cp -a "$bak" "$XRAY_CFG" || true
  chown root:xray "$XRAY_CFG" || true
  chmod 640 "$XRAY_CFG" || true
  fix_log_perms
  if ! systemctl restart xray >/dev/null 2>&1 || ! wait_active xray 15; then
    err "Xray also failed with the previous configuration."
    rc=1
  else
    rc=1
    ok "Previous Xray configuration restored."
  fi
  rm -f "$bak"
  cfg_unlock
  return "$rc"
}

ss_password() {
  jq -r --arg t ss-ws '.inbounds[]|select(.tag==$t).settings.password // empty' "$XRAY_CFG" 2>/dev/null
}

ss_method() {
  jq -r --arg t ss-ws '.inbounds[]|select(.tag==$t).settings.method // empty' "$XRAY_CFG" 2>/dev/null
}

# Cipher is a 2022 one and the stored key decodes to exactly the right length.
ss_key_ok() {
  local m p n len
  m=$(ss_method); p=$(ss_password)
  [ -n "$m" ] && [ -n "$p" ] || return 1
  ss_method_valid "$m" || return 1
  n=$(ss_key_len "$m") || return 1
  len=$(printf '%s' "$p" | base64 -d 2>/dev/null | wc -c)
  [ "$len" -eq "$n" ]
}

# Installs made with <= 2.3.5 used legacy aes-128-gcm: move them to SS-2022.
ss_upgrade_legacy() {
  local m s
  m=$(ss_method); [ -n "$m" ] || return 0
  ss_method_valid "$m" && return 0
  warn "Shadowsocks uses the legacy cipher '$m'; upgrading to $SS_METHOD (old Shadowsocks links stop working; new ones: menu 05 / 07)."
  s=$(ss_new_key "$SS_METHOD") || return 1
  cfg_apply --arg m "$SS_METHOD" --arg p "$s" '(.inbounds[]|select(.tag=="ss-ws").settings) |= (.method=$m | .password=$p)'
}

# ----------------------------------------------------------------------------
# SSH bridge
# ----------------------------------------------------------------------------
# Return codes of write_hold_script / write_bridge_py / write_bridge_unit keep
# the write_atomic_from_stdin contract: 0 = changed, 1 = unchanged, 2 = error.
write_hold_script() {
  write_atomic_from_stdin "$HOLD" 755 <<'EOF2'
#!/bin/sh
# Tunnel-only SSH sessions execute this command through ForceCommand.
# The account must have a real shell (/bin/bash), because nologin exits
# before ForceCommand can keep the forwarding session alive.
trap '' HUP INT TERM
exec /bin/sleep infinity
EOF2
  local rc=$?
  [ "$rc" -le 1 ] || return 2
  [ -x "$HOLD" ] || return 2
  return "$rc"
}

write_bridge_py() {
  write_atomic_from_stdin "$BRIDGE" 755 <<'PYEOF'
#!/usr/bin/env python3
"""AetherWS SSH-over-HTTP-Upgrade raw TCP bridge.

This is intentionally NOT RFC 6455 WebSocket framing. After HTTP 101 the
connection becomes a raw bidirectional TCP stream for SSH-over-Upgrade
clients (HTTP Injector/HTTP Custom style).
"""
import asyncio
import socket
import sys
from urllib.parse import urlsplit

LISTEN_PORT = int(sys.argv[1])
TARGET_PORT = int(sys.argv[2])
EXPECTED_PATH = sys.argv[3]
TARGET = ("127.0.0.1", TARGET_PORT)
MAX_HEADER = 16384
READ_CHUNK = 4096
RESP_101 = (
    b"HTTP/1.1 101 Switching Protocols\r\n"
    b"Upgrade: websocket\r\n"
    b"Connection: Upgrade\r\n\r\n"
)
RESP_400 = b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
RESP_502 = b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"


def close_writer(writer):
    try:
        writer.close()
    except Exception:
        pass


def split_headers(buf: bytes):
    idx = buf.find(b"\r\n\r\n")
    if idx >= 0:
        end = idx + 4
        return bytes(buf[:end]), bytes(buf[end:])
    idx = buf.find(b"\n\n")
    if idx >= 0:
        end = idx + 2
        return bytes(buf[:end]), bytes(buf[end:])
    return None


def parse_request(head: bytes):
    try:
        lines = head.replace(b"\r\n", b"\n").split(b"\n")
        first = lines[0].decode("latin1")
        parts = first.split(" ", 2)
        if len(parts) != 3 or parts[0].upper() != "GET" or parts[2].upper() != "HTTP/1.1":
            return False
        target = parts[1]
        path = urlsplit(target).path or "/"
        headers = {}
        for raw in lines[1:]:
            if not raw or b":" not in raw:
                continue
            k, v = raw.split(b":", 1)
            headers[k.decode("latin1").strip().lower()] = v.decode("latin1").strip().lower()
        if path != EXPECTED_PATH:
            return False
        if "websocket" not in headers.get("upgrade", ""):
            return False
        if "upgrade" not in headers.get("connection", ""):
            return False
        return True
    except Exception:
        return False


async def read_headers(reader):
    buf = bytearray()
    while True:
        if len(buf) >= MAX_HEADER:
            raise ValueError("header too large")
        chunk = await asyncio.wait_for(reader.read(READ_CHUNK), 10)
        if not chunk:
            raise EOFError
        buf.extend(chunk)
        parts = split_headers(bytes(buf))
        if parts is not None:
            return parts


async def pipe(reader, writer):
    try:
        while True:
            data = await reader.read(65536)
            if not data:
                break
            writer.write(data)
            await writer.drain()
    except (ConnectionResetError, BrokenPipeError, asyncio.IncompleteReadError, ConnectionAbortedError):
        pass
    except Exception as exc:
        print(f"pipe error: {exc}", file=sys.stderr, flush=True)
    finally:
        try:
            if writer.can_write_eof():
                writer.write_eof()
                await writer.drain()
            else:
                close_writer(writer)
        except Exception:
            close_writer(writer)


async def handle(cr, cw):
    try:
        sock = cw.get_extra_info("socket")
        if sock is not None:
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
    except Exception:
        pass
    try:
        head, leftover = await read_headers(cr)
    except Exception:
        close_writer(cw)
        return
    if not parse_request(head):
        try:
            cw.write(RESP_400)
            await cw.drain()
        except Exception:
            pass
        close_writer(cw)
        return
    try:
        sr, sw = await asyncio.wait_for(asyncio.open_connection(*TARGET), 5)
    except Exception as exc:
        print(f"sshd connect failed: {exc}", file=sys.stderr, flush=True)
        try:
            cw.write(RESP_502)
            await cw.drain()
        except Exception:
            pass
        close_writer(cw)
        return
    try:
        cw.write(RESP_101)
        await cw.drain()
        if leftover:
            sw.write(leftover)
            await sw.drain()
        await asyncio.gather(pipe(cr, sw), pipe(sr, cw))
    except Exception as exc:
        print(f"tunnel error: {exc}", file=sys.stderr, flush=True)
    finally:
        close_writer(sw)
        close_writer(cw)


async def main():
    print(
        f"AetherWS SSH bridge 127.0.0.1:{LISTEN_PORT} -> 127.0.0.1:{TARGET_PORT} path={EXPECTED_PATH}",
        file=sys.stderr,
        flush=True,
    )
    server = await asyncio.start_server(handle, "127.0.0.1", LISTEN_PORT, limit=65536, backlog=2048)
    async with server:
        await server.serve_forever()


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        pass
PYEOF
  local rc=$?
  [ "$rc" -le 1 ] || return 2
  [ -x "$BRIDGE" ] || return 2
  return "$rc"
}

write_bridge_unit() {
  local sp=$1
  id aether-bridge >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin aether-bridge || return 2
  write_atomic_from_stdin /etc/systemd/system/aether-ssh-ws.service 644 <<EOF2
[Unit]
Description=AetherWS SSH-over-HTTP-Upgrade bridge
After=network-online.target ssh.service
Wants=network-online.target

[Service]
User=aether-bridge
Group=aether-bridge
WorkingDirectory=/
ExecStart=/usr/bin/python3 -u $BRIDGE $P_SSHWS $sp $WSP_SSH
Restart=always
RestartSec=2
TimeoutStopSec=8
LimitNOFILE=65536
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectControlGroups=true
LockPersonality=true
RestrictSUIDSGID=true
RestrictAddressFamilies=AF_INET AF_UNIX
SyslogIdentifier=aether-ssh-ws

[Install]
WantedBy=multi-user.target
EOF2
  local rc=$?
  [ "$rc" -le 1 ] || return 2
  if [ "$rc" -eq 0 ]; then
    systemctl daemon-reload || return 2
  fi
  return "$rc"
}

# Users of the tunnel-only group: primary-group members (passwd) plus the
# explicit member list. The whole listing goes through ONE `sort -u`.
ssh_users() {
  local gid members
  gid=$(getent group "$SSH_GROUP" 2>/dev/null | cut -d: -f3)
  [ -n "$gid" ] || return 0
  members=$(getent group "$SSH_GROUP" 2>/dev/null | cut -d: -f4-)
  {
    awk -F: -v g="$gid" '$4==g{print $1}' /etc/passwd
    if [ -n "$members" ]; then tr ',' '\n' <<< "$members"; fi
  } | sed '/^$/d' | sort -u
}

migrate_tunnel_shells() {
  local u
  while IFS= read -r u; do
    [ -n "$u" ] || continue
    usermod -s /bin/bash "$u" 2>/dev/null || true
  done < <(ssh_users)
}

write_ssh_dropin_content() {
  cat <<EOF2
# AetherWS tunnel-only SSH users.
# All public SSH-WS sessions reach sshd through 127.0.0.1 bridge connections.
MaxStartups $SSH_MAXSTARTUPS
EOF2
  # OpenSSH >= 9.8: never penalise the loopback address every bridge
  # connection comes from (older versions reject the unknown keyword).
  if sshd -T 2>/dev/null | grep -qi '^persourcepenalties'; then
    echo "PerSourcePenaltyExemptList 127.0.0.1,::1"
  fi
  cat <<EOF2

Match Group $SSH_GROUP
    PasswordAuthentication yes
    KbdInteractiveAuthentication yes
    PubkeyAuthentication no
    PermitEmptyPasswords no
    ForceCommand $HOLD
    PermitTTY no
    X11Forwarding no
    AllowAgentForwarding no
    AllowTcpForwarding $SSH_TCP_FORWARDING
    AllowStreamLocalForwarding no
    PermitTunnel no
    GatewayPorts no
    PermitUserRC no
    MaxSessions 20
    ClientAliveInterval 30
    ClientAliveCountMax 6
    MaxAuthTries 4
EOF2
}

# Put sshd_config and the drop-in back exactly as they were before setup_ssh.
ssh_restore_state() {
  local main_backup=$1 dropin_backup=$2 had=$3 prev=$4
  restore_file "$main_backup" /etc/ssh/sshd_config || true
  rm -f "$SSH_DROPIN" "$SSH_LEGACY_DROPIN"
  if [ "$had" -eq 1 ] && [ -n "$prev" ]; then
    restore_file "$dropin_backup" "$prev" || true
  fi
}

setup_ssh() {
  validate_ssh_maxstartups || { err "Invalid SSH_MAXSTARTUPS='$SSH_MAXSTARTUPS' (expected start:rate:full)."; return 1; }
  validate_ssh_forwarding || { err "Invalid SSH_TCP_FORWARDING='$SSH_TCP_FORWARDING' (use local or yes)."; return 1; }
  init_work_dir || return 1
  getent group "$SSH_GROUP" >/dev/null 2>&1 || groupadd "$SSH_GROUP" || return 1
  install -d -m 755 /etc/ssh/sshd_config.d "$LOCK_DIR" || return 1

  local main_backup dropin_backup tmp="" had_dropin=0 include_added=0 previous_dropin_path="" rc=0
  main_backup=$(mktemp "$WORK_DIR/sshd_config.XXXXXX") || return 1
  dropin_backup=$(mktemp "$WORK_DIR/aether.conf.XXXXXX") || { rm -f "$main_backup"; return 1; }
  rm -f "$main_backup" "$dropin_backup"
  backup_file /etc/ssh/sshd_config "$main_backup" || { rm -f "$main_backup" "$dropin_backup"; return 1; }
  if backup_file "$SSH_DROPIN" "$dropin_backup"; then
    had_dropin=1; previous_dropin_path=$SSH_DROPIN
  elif backup_file "$SSH_LEGACY_DROPIN" "$dropin_backup"; then
    had_dropin=1; previous_dropin_path=$SSH_LEGACY_DROPIN
  fi

  if ! grep -qE '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/\*\.conf' /etc/ssh/sshd_config 2>/dev/null; then
    if sed -i '1i# AetherWS managed sshd_config.d include.\nInclude /etc/ssh/sshd_config.d/*.conf' /etc/ssh/sshd_config; then
      include_added=1
    else
      rc=1
    fi
  fi

  if [ "$rc" -eq 0 ]; then
    tmp=$(mktemp "$WORK_DIR/sshdropin.XXXXXX") || rc=1
  fi
  if [ "$rc" -eq 0 ]; then write_ssh_dropin_content > "$tmp" || rc=1; fi
  if [ "$rc" -eq 0 ]; then install -m 644 "$tmp" "$SSH_DROPIN" || rc=1; fi
  [ -z "$tmp" ] || rm -f "$tmp"

  if [ "$rc" -eq 0 ]; then
    # sshd -t needs the privilege-separation directory even when sshd is
    # socket-activated and not running yet.
    install -d -m 755 /run/sshd 2>/dev/null || true
    if ! sshd -t 2>/dev/null; then
      err "sshd rejected the AetherWS configuration; rolling back."
      sshd -t || true
      rc=1
    fi
  fi
  if [ "$rc" -eq 0 ] && ! reload_ssh; then
    err "Could not reload/restart sshd; restoring the previous SSH configuration."
    rc=2
  fi

  if [ "$rc" -ne 0 ]; then
    ssh_restore_state "$main_backup" "$dropin_backup" "$had_dropin" "$previous_dropin_path"
    if [ "$rc" -eq 2 ]; then
      sshd -t >/dev/null 2>&1 && reload_ssh || true
    fi
    rm -f "$main_backup" "$dropin_backup"
    return 1
  fi

  if [ "$include_added" -eq 1 ]; then
    printf '1\n' > "$SSH_INCLUDE_MARKER" || true
  fi
  rm -f "$SSH_LEGACY_DROPIN"
  migrate_tunnel_shells
  if sshd -T 2>/dev/null | grep -Eiq '^(allowusers|allowgroups) '; then
    warn "sshd has AllowUsers/AllowGroups set: make sure group '$SSH_GROUP' is allowed, or tunnel users will be rejected."
  fi
  rm -f "$main_backup" "$dropin_backup"
  return 0
}

setup_bridge() {
  load_conf
  [ -n "${WSP_SSH:-}" ] || { err "SSH WebSocket path is missing."; return 1; }
  local hold_rc bridge_rc unit_rc sp changed=0
  write_hold_script; hold_rc=$?
  [ "$hold_rc" -le 1 ] || { err "Could not write $HOLD"; return 1; }
  write_bridge_py; bridge_rc=$?
  [ "$bridge_rc" -le 1 ] || { err "Could not write $BRIDGE"; return 1; }
  sp=$(ssh_port) || return 1
  write_bridge_unit "$sp"; unit_rc=$?
  [ "$unit_rc" -le 1 ] || { err "Could not write the aether-ssh-ws unit"; return 1; }
  [ "$hold_rc" -eq 0 ] && changed=1
  [ "$bridge_rc" -eq 0 ] && changed=1
  [ "$unit_rc" -eq 0 ] && changed=1
  systemctl enable aether-ssh-ws >/dev/null 2>&1 || return 1
  if [ "$changed" -eq 1 ] || ! systemctl is-active --quiet aether-ssh-ws; then
    systemctl restart aether-ssh-ws >/dev/null 2>&1 || return 1
  fi
  wait_active aether-ssh-ws 15
}

# ----------------------------------------------------------------------------
# Nginx / TLS
# ----------------------------------------------------------------------------
ngx_v6() { [ -s /proc/net/if_inet6 ]; }

write_nginx_rate_zone() {
  validate_ngx_limits || return 1
  write_atomic_from_stdin "$NGX_RATE" 644 <<EOF2
# AetherWS global HTTP maps/limits.
limit_req_zone \$binary_remote_addr zone=aether_ws:10m rate=${NGX_LIMIT_RATE}r/s;
limit_conn_zone \$binary_remote_addr zone=aether_conn:10m;
map \$http_upgrade \$aether_connection {
    default      close;
    ~*websocket  upgrade;
}
EOF2
  local rc=$?
  [ "$rc" -le 1 ]
}

# True when another (non-Aether) server block already claims the domain.
# Exact token comparison (dots are not wildcards); Aether's own files ignored.
nginx_domain_conflict() {
  local dom=$1
  command -v nginx >/dev/null 2>&1 || return 1
  nginx -T 2>/dev/null | awk -v d="$dom" -v own1="$NGX_CONF" -v own2="$NGX_ACME" '
    /^# configuration file / { file=$0; sub(/^# configuration file /,"",file); sub(/:$/,"",file); next }
    /^[[:space:]]*server_name[[:space:]]+/ {
      if (file == own1 || file == own2) next
      line=$0; sub(/^[[:space:]]*server_name[[:space:]]+/,"",line); sub(/;.*$/,"",line)
      n=split(line, a, /[[:space:]]+/)
      for (i=1; i<=n; i++) if (tolower(a[i]) == tolower(d)) found=1
    }
    END { exit(found ? 0 : 1) }
  '
}

# True when a non-Aether server block already owns default_server on 80/443.
# Only the FIRST argument of `listen` is the address/port, so 8080 or 4430 do
# not match; comments are stripped; Aether's own vhost is ignored.
nginx_default_conflict() {
  command -v nginx >/dev/null 2>&1 || return 1
  nginx -T 2>/dev/null | awk -v self="$NGX_CONF" '
    /^# configuration file / { file=$0; sub(/^# configuration file /,"",file); sub(/:$/,"",file); next }
    /^[[:space:]]*listen[[:space:]]+/ {
      sub(/#.*$/, "")
      if ($0 ~ /default_server/) {
        tok=$2; sub(/;$/,"",tok)
        if (tok ~ /(^|:)(80|443)$/ && file != self) found=1
      }
    }
    END { exit(found ? 0 : 1) }
  '
}

write_nginx_acme() {
  local dom=$1 v6=""
  [ -n "$dom" ] || return 1
  ngx_v6 && v6='    listen [::]:80;'
  install -d -m 755 "$WEBROOT_ACME" || return 1
  write_atomic_from_stdin "$NGX_ACME" 644 <<EOF2
server {
    listen 80;
$v6
    server_name $dom;
    location ^~ /.well-known/acme-challenge/ {
        root $WEBROOT_ACME;
        default_type text/plain;
        try_files \$uri =404;
    }
    location / { return 404; }
}
EOF2
  local rc=$?
  [ "$rc" -le 1 ] || return 1
  nginx -t >/dev/null 2>&1 || { nginx -t || true; return 1; }
  systemctl reload nginx >/dev/null 2>&1 || systemctl restart nginx >/dev/null 2>&1 || return 1
}

ws_location() {
  local path=$1 port=$2
  cat <<EOF2

    location = $path {
        if (\$http_upgrade !~* "^websocket\$") { return 404; }
        limit_req zone=aether_ws burst=$NGX_LIMIT_BURST nodelay;
        limit_conn aether_conn $NGX_LIMIT_CONN;
        proxy_pass http://127.0.0.1:$port;
        proxy_http_version 1.1;
        proxy_set_header Upgrade \$http_upgrade;
        proxy_set_header Connection \$aether_connection;
        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_read_timeout 86400s;
        proxy_send_timeout 86400s;
        proxy_connect_timeout 10s;
        proxy_buffering off;
        proxy_request_buffering off;
        tcp_nodelay on;
    }
EOF2
}

write_nginx_full() {
  local dom=$1 v6_443="" tmp prev_conf prev_rate had_conf=0 had_rate=0
  [ -n "$dom" ] || return 1
  init_work_dir || return 1
  validate_ngx_limits || { err "Invalid NGX_LIMIT_* values (positive integers required)."; return 1; }
  load_conf
  ngx_v6 && v6_443='    listen [::]:443 ssl default_server;'

  tmp=$(mktemp "$WORK_DIR/aether-main.XXXXXX") || return 1
  cat > "$tmp" <<EOF2
server {
    listen 80 default_server;
    $(ngx_v6 && echo 'listen [::]:80 default_server;')
    server_name $dom _;
    server_tokens off;
    location ^~ /.well-known/acme-challenge/ {
        root $WEBROOT_ACME;
        default_type text/plain;
        try_files \$uri =404;
    }
    location / { return 301 https://$dom\$request_uri; }
}

server {
    listen 443 ssl default_server;
$v6_443
    server_name $dom _;
    server_tokens off;

    ssl_certificate     /etc/letsencrypt/live/$dom/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/$dom/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:AETHER_SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;
    add_header Strict-Transport-Security "max-age=31536000" always;
    add_header X-Content-Type-Options nosniff always;
    gzip off;

    root $SITE_ROOT;
    index index.html;
    location / { try_files \$uri \$uri/ =404; }
EOF2
  ws_location "$WSP_VLESS"  "$P_VLESS"  >> "$tmp"
  ws_location "$WSP_VMESS"  "$P_VMESS"  >> "$tmp"
  ws_location "$WSP_TROJAN" "$P_TROJAN" >> "$tmp"
  ws_location "$WSP_SS"     "$P_SS"      >> "$tmp"
  ws_location "$WSP_SSH"    "$P_SSHWS"   >> "$tmp"
  echo "}" >> "$tmp"

  had_conf=0; had_rate=0
  prev_conf=$(mktemp "$WORK_DIR/nginx.conf.XXXXXX") || { rm -f "$tmp"; return 1; }
  prev_rate=$(mktemp "$WORK_DIR/nginx.rate.XXXXXX") || { rm -f "$tmp" "$prev_conf"; return 1; }
  rm -f "$prev_conf" "$prev_rate"
  if [ -f "$NGX_CONF" ]; then cp -a "$NGX_CONF" "$prev_conf"; had_conf=1; fi
  if [ -f "$NGX_RATE" ]; then cp -a "$NGX_RATE" "$prev_rate"; had_rate=1; fi

  if ! write_nginx_rate_zone; then
    if [ "$had_rate" -eq 1 ]; then cp -a "$prev_rate" "$NGX_RATE"; else rm -f "$NGX_RATE"; fi
    rm -f "$tmp" "$prev_conf" "$prev_rate"
    return 1
  fi
  # Stale zone file from very old versions would define the zones twice.
  rm -f /etc/nginx/conf.d/aether-rate.conf

  chmod 644 "$tmp" 2>/dev/null || true
  mv -f "$tmp" "$NGX_CONF" || {
    if [ "$had_conf" -eq 1 ]; then cp -a "$prev_conf" "$NGX_CONF"; else rm -f "$NGX_CONF"; fi
    if [ "$had_rate" -eq 1 ]; then cp -a "$prev_rate" "$NGX_RATE"; else rm -f "$NGX_RATE"; fi
    rm -f "$tmp" "$prev_conf" "$prev_rate"
    return 1
  }
  if ! nginx -t >/dev/null 2>&1; then
    nginx -t || true
    if [ "$had_conf" -eq 1 ]; then cp -a "$prev_conf" "$NGX_CONF"; else rm -f "$NGX_CONF"; fi
    if [ "$had_rate" -eq 1 ]; then cp -a "$prev_rate" "$NGX_RATE"; else rm -f "$NGX_RATE"; fi
    rm -f "$prev_conf" "$prev_rate"
    return 1
  fi
  if ! systemctl reload nginx >/dev/null 2>&1 && ! systemctl restart nginx >/dev/null 2>&1; then
    err "Nginx reload/restart failed; restoring the previous configuration."
    if [ "$had_conf" -eq 1 ]; then cp -a "$prev_conf" "$NGX_CONF"; else rm -f "$NGX_CONF"; fi
    if [ "$had_rate" -eq 1 ]; then cp -a "$prev_rate" "$NGX_RATE"; else rm -f "$NGX_RATE"; fi
    nginx -t >/dev/null 2>&1 && (systemctl reload nginx >/dev/null 2>&1 || systemctl restart nginx >/dev/null 2>&1) || true
    rm -f "$prev_conf" "$prev_rate"
    return 1
  fi
  rm -f "$NGX_ACME" "$prev_conf" "$prev_rate"
  return 0
}

install_site() {
  install -d -m 755 "$SITE_ROOT" "$WEBROOT_ACME" || return 1
  cat > "$SITE_ROOT/index.html" <<'EOF2'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Welcome</title>
<style>body{font-family:sans-serif;max-width:42rem;margin:15vh auto;color:#333;padding:0 1rem}</style>
</head>
<body><h1>Welcome</h1><p>This site is under construction.</p></body>
</html>
EOF2
  chmod 644 "$SITE_ROOT/index.html" || return 1
}

issue_cert() {
  local dom=$1 email=${2:-}
  local -a cargs
  cargs=(certonly --webroot -w "$WEBROOT_ACME" -d "$dom" --non-interactive --agree-tos --keep-until-expiring --key-type ecdsa --elliptic-curve secp256r1)
  if [ -n "$email" ]; then cargs+=(--email "$email"); else cargs+=(--register-unsafely-without-email); fi

  install -d -m 755 "$WEBROOT_ACME" || return 1
  if ! systemctl is-active --quiet nginx; then systemctl start nginx >/dev/null 2>&1 || return 1; fi
  write_nginx_acme "$dom" || return 1

  info "Requesting Let's Encrypt certificate for $dom ..."
  if certbot "${cargs[@]}"; then
    rm -f "$NGX_ACME"
    nginx -t >/dev/null 2>&1 || { nginx -t || true; return 1; }
    systemctl reload nginx >/dev/null 2>&1 || systemctl restart nginx >/dev/null 2>&1 || return 1
    return 0
  fi
  err "Certificate issuance failed for $dom."
  rm -f "$NGX_ACME"
  nginx -t >/dev/null 2>&1 && (systemctl reload nginx >/dev/null 2>&1 || true)
  return 1
}

setup_certbot_hook() {
  install -d -m 755 "$(dirname "$HOOK")" || return 1
  cat > "$HOOK" <<'EOF2'
#!/bin/sh
nginx -t && systemctl reload nginx
EOF2
  chmod 755 "$HOOK" || return 1
  systemctl enable --now certbot.timer >/dev/null 2>&1 || true
}

# ----------------------------------------------------------------------------
# Probes
# ----------------------------------------------------------------------------
ws_probe() {
  local path=$1
  curl -sS -D - -o /dev/null --max-time 5 --http1.1 \
    --resolve "$DOMAIN:443:127.0.0.1" \
    -H 'Upgrade: websocket' \
    -H 'Connection: Upgrade' \
    -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    -H 'Sec-WebSocket-Version: 13' \
    "https://$DOMAIN$path" 2>/dev/null | head -n1 | tr -d '\r'
}

bridge_local_probe() {
  python3 - "$P_SSHWS" "$WSP_SSH" <<'PY'
import socket, sys
port=int(sys.argv[1]); path=sys.argv[2]
req=(f"GET {path} HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n".encode()+b"SSH-2.0-AetherProbe_1.0\r\n")
s=socket.create_connection(("127.0.0.1",port),3); s.settimeout(5); s.sendall(req)
buf=b""
while len(buf)<8192:
    c=s.recv(4096)
    if not c: break
    buf+=c
    if b"HTTP/1.1 101 Switching Protocols" in buf and b"SSH-2.0-" in buf.split(b"\r\n\r\n",1)[1]: break
s.close()
if not buf.startswith(b"HTTP/1.1 101 Switching Protocols"): raise SystemExit("missing 101")
if b"\r\n\r\n" not in buf: raise SystemExit("missing header terminator")
if b"SSH-2.0-" not in buf.split(b"\r\n\r\n",1)[1]: raise SystemExit("missing ssh banner")
print("HTTP/1.1 101 Switching Protocols")
print("SSH banner: OK")
PY
}

bridge_tls_e2e_probe() {
  python3 - "$DOMAIN" "$WSP_SSH" <<'PY'
import socket, ssl, sys
host=sys.argv[1]; path=sys.argv[2]
raw=socket.create_connection(("127.0.0.1",443),5)
ctx=ssl.create_default_context()
s=ctx.wrap_socket(raw, server_hostname=host); s.settimeout(6)
req=(f"GET {path} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n".encode()+b"SSH-2.0-AetherProbe_1.0\r\n")
s.sendall(req); buf=b""
while len(buf)<16384:
    try: c=s.recv(4096)
    except socket.timeout: break
    if not c: break
    buf+=c
    if b"\r\n\r\n" in buf and b"SSH-2.0-" in buf.split(b"\r\n\r\n",1)[1]: break
s.close()
first=buf.split(b"\r\n",1)[0]
if first != b"HTTP/1.1 101 Switching Protocols": raise SystemExit(f"missing HTTP 101: {first!r}")
if b"\r\n\r\n" not in buf: raise SystemExit("missing header terminator")
if b"SSH-2.0-" not in buf.split(b"\r\n\r\n",1)[1]: raise SystemExit("missing SSH banner after 101")
print("HTTP/1.1 101 Switching Protocols")
print("TLS+Nginx leftover SSH banner: OK")
PY
}

tls_probe() {
  python3 - "$DOMAIN" <<'PY'
import socket, ssl, sys
host=sys.argv[1]
raw=socket.create_connection(("127.0.0.1",443),5)
ctx=ssl.create_default_context()
s=ctx.wrap_socket(raw, server_hostname=host)
if s.version() not in ("TLSv1.2","TLSv1.3"):
    raise SystemExit("unexpected TLS version")
print(f"TLS: {s.version()}")
print(f"Cipher: {s.cipher()[0]}")
s.close()
PY
}

find_free_port() {
  python3 - <<'PY'
import socket
s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()
PY
}

# $4 (optional): address to dial instead of the Remote Proxy host; SNI/Host
# stay as configured.
build_client_config() {
  local proto=$1 socks_port=$2 out=$3 addr=${4:-} id secret method
  load_conf
  addr=${addr:-$REMOTE_PROXY_HOST}
  case "$proto" in
    vless)
      id=$(jq -r '.inbounds[]|select(.tag=="vless-ws").settings.clients[0].id' "$XRAY_CFG") || return 1
      cat > "$out" <<EOF2
{
  "log": {"loglevel":"warning"},
  "inbounds": [{"listen":"127.0.0.1","port":$socks_port,"protocol":"socks","settings":{"auth":"noauth","udp":false}}],
  "outbounds": [{
    "protocol":"vless",
    "settings":{"vnext":[{"address":"$addr","port":$REMOTE_PROXY_PORT,"users":[{"id":"$id","encryption":"none","level":0}]}]},
    "streamSettings":{"network":"ws","security":"tls","tlsSettings":{"serverName":"$CLIENT_SNI","alpn":["http/1.1"],"fingerprint":"chrome"},"wsSettings":{"path":"$WSP_VLESS","headers":{"Host":"$CLIENT_HOST"}}}
  }]
}
EOF2
      ;;
    vmess)
      id=$(jq -r '.inbounds[]|select(.tag=="vmess-ws").settings.clients[0].id' "$XRAY_CFG") || return 1
      cat > "$out" <<EOF2
{
  "log": {"loglevel":"warning"},
  "inbounds": [{"listen":"127.0.0.1","port":$socks_port,"protocol":"socks","settings":{"auth":"noauth","udp":false}}],
  "outbounds": [{
    "protocol":"vmess",
    "settings":{"vnext":[{"address":"$addr","port":$REMOTE_PROXY_PORT,"users":[{"id":"$id","alterId":0,"security":"auto","level":0}]}]},
    "streamSettings":{"network":"ws","security":"tls","tlsSettings":{"serverName":"$CLIENT_SNI","alpn":["http/1.1"],"fingerprint":"chrome"},"wsSettings":{"path":"$WSP_VMESS","headers":{"Host":"$CLIENT_HOST"}}}
  }]
}
EOF2
      ;;
    trojan)
      secret=$(jq -r '.inbounds[]|select(.tag=="trojan-ws").settings.clients[0].password' "$XRAY_CFG") || return 1
      cat > "$out" <<EOF2
{
  "log": {"loglevel":"warning"},
  "inbounds": [{"listen":"127.0.0.1","port":$socks_port,"protocol":"socks","settings":{"auth":"noauth","udp":false}}],
  "outbounds": [{
    "protocol":"trojan",
    "settings":{"servers":[{"address":"$addr","port":$REMOTE_PROXY_PORT,"password":"$secret","level":0}]},
    "streamSettings":{"network":"ws","security":"tls","tlsSettings":{"serverName":"$CLIENT_SNI","alpn":["http/1.1"],"fingerprint":"chrome"},"wsSettings":{"path":"$WSP_TROJAN","headers":{"Host":"$CLIENT_HOST"}}}
  }]
}
EOF2
      ;;
    ss)
      secret=$(ss_password) || return 1
      method=$(jq -r '.inbounds[]|select(.tag=="ss-ws").settings.method' "$XRAY_CFG") || return 1
      cat > "$out" <<EOF2
{
  "log": {"loglevel":"warning"},
  "inbounds": [{"listen":"127.0.0.1","port":$socks_port,"protocol":"socks","settings":{"auth":"noauth","udp":false}}],
  "outbounds": [{
    "protocol":"shadowsocks",
    "settings":{"servers":[{"address":"$addr","port":$REMOTE_PROXY_PORT,"method":"$method","password":"$secret","level":0}]},
    "streamSettings":{"network":"ws","security":"tls","tlsSettings":{"serverName":"$CLIENT_SNI","alpn":["http/1.1"],"fingerprint":"chrome"},"wsSettings":{"path":"$WSP_SS","headers":{"Host":"$CLIENT_HOST"}}}
  }]
}
EOF2
      ;;
    *) return 1 ;;
  esac
  chmod 600 "$out"
  validate_xray_config_file "$out"
}

# One attempt: local Xray client -> $2 -> fetch a URL through the tunnel.
# Sets E2E_CODE. curl prints 000 when nothing was received, so ONLY a genuine
# HTTP status (1xx-5xx) counts as success. Leaves $E2E_DIR/<proto>.log behind.
_e2e_once() {
  local proto=$1 addr=$2 sp cfg log pid url=${AETHER_TEST_URL:-https://example.com/}
  E2E_CODE=""
  install -d -m 700 "$E2E_DIR" || return 1
  sp=$(find_free_port) || return 1
  cfg=$(mktemp "$E2E_DIR/${proto}.config.XXXXXX") || return 1
  log="$E2E_DIR/${proto}.log"
  if ! build_client_config "$proto" "$sp" "$cfg" "$addr"; then
    err "$proto client test config is invalid"
    rm -f "$cfg"
    return 1
  fi
  "$XRAY_BIN" run -config "$cfg" >"$log" 2>&1 &
  pid=$!
  E2E_CURRENT_PID=$pid
  if ! wait_port "$sp" 20; then
    err "$proto local Xray client did not open its SOCKS port"
    kill "$pid" 2>/dev/null || true
    wait "$pid" 2>/dev/null || true
    E2E_CURRENT_PID=""
    rm -f "$cfg"
    return 1
  fi
  E2E_CODE=$(curl -sS -o /dev/null -w '%{http_code}' --proxy "socks5h://127.0.0.1:$sp" --connect-timeout 4 --max-time 12 "$url" 2>/dev/null || true)
  kill "$pid" 2>/dev/null || true
  wait "$pid" 2>/dev/null || true
  E2E_CURRENT_PID=""
  rm -f "$cfg"
  [[ "$E2E_CODE" =~ ^[1-5][0-9]{2}$ ]]
}

# Real end-to-end check through the configured Remote Proxy. For the default
# profile (Remote Proxy == this domain:443) a failure through the public
# address is retried against 127.0.0.1 (DNS cache / hairpin NAT tolerance).
protocol_e2e_probe() {
  local proto=$1 url=${AETHER_TEST_URL:-https://example.com/}
  load_conf
  validate_client_profile_syntax || return 1
  if _e2e_once "$proto" "$REMOTE_PROXY_HOST"; then
    ok "$proto protocol E2E via Xray client: HTTP $E2E_CODE"
    rm -f "$E2E_DIR/$proto.log"
    return 0
  fi
  if [ "${REMOTE_PROXY_HOST:-}" = "${DOMAIN:-}" ] && [ "${REMOTE_PROXY_PORT:-}" = 443 ] \
     && _e2e_once "$proto" 127.0.0.1; then
    warn "$proto protocol E2E passed via 127.0.0.1 but not via the public address (DNS cache / hairpin NAT?): HTTP $E2E_CODE"
    rm -f "$E2E_DIR/$proto.log"
    return 0
  fi
  err "$proto protocol E2E failed (HTTP ${E2E_CODE:-none}; test URL: $url)"
  tail -n 20 "$E2E_DIR/$proto.log" 2>/dev/null || true
  rm -f "$E2E_DIR/$proto.log"
  return 1
}

validate_generated_links() {
  load_conf
  validate_client_profile_syntax || return 1
  local v_id vm_id tr_pass ss_pass ss_m l1 l2 l3 l4 l5
  v_id=$(jq -r '.inbounds[]|select(.tag=="vless-ws").settings.clients[0].id' "$XRAY_CFG") || return 1
  vm_id=$(jq -r '.inbounds[]|select(.tag=="vmess-ws").settings.clients[0].id' "$XRAY_CFG") || return 1
  tr_pass=$(jq -r '.inbounds[]|select(.tag=="trojan-ws").settings.clients[0].password' "$XRAY_CFG") || return 1
  ss_pass=$(ss_password) || return 1
  ss_m=$(ss_method) || return 1
  l1=$(mklink vless-ws default "$v_id") || return 1
  l2=$(mklink vmess-ws default "$vm_id") || return 1
  l3=$(mklink trojan-ws default "$tr_pass") || return 1
  l4=$(mklink ss-ws default "$ss_pass") || return 1
  l5=$(mklink ss-plugin default "$ss_pass") || return 1
  python3 - "$DOMAIN" "$REMOTE_PROXY" "$CLIENT_SNI" "$CLIENT_HOST" "$WSP_VLESS" "$WSP_VMESS" "$WSP_TROJAN" "$WSP_SS" "$ss_m" "$ss_pass" "$l1" "$l2" "$l3" "$l4" "$l5" <<'PY'
import base64, json, sys
from urllib.parse import urlparse, parse_qs, unquote
server, remote, sni, host = sys.argv[1:5]
paths = sys.argv[5:9]
method, pw = sys.argv[9:11]
links = sys.argv[11:16]
assert all(links)
# VLESS
p=urlparse(links[0]); q=parse_qs(p.query)
assert p.scheme == "vless" and p.netloc.split('@',1)[1] == remote
assert q.get("type")==["ws"] and q.get("security")==["tls"]
assert q.get("sni")==[sni] and q.get("host")==[host] and unquote(q["path"][0])==paths[0]
# VMess
vm=links[1][8:]; vm += "="*((4-len(vm)%4)%4)
obj=json.loads(base64.urlsafe_b64decode(vm.encode()).decode())
if remote.startswith("["):
    remote_host, remote_port = remote.rsplit("]:",1)
    remote_host = remote_host.lstrip("[")
else:
    remote_host, remote_port = remote.rsplit(":",1)
assert obj["add"] == remote_host and str(obj["port"]) == remote_port
assert obj["host"] == host and obj["sni"] == sni and obj["path"] == paths[1] and obj["net"] == "ws" and obj["tls"] == "tls"
# Trojan
p=urlparse(links[2]); q=parse_qs(p.query)
assert p.scheme=="trojan" and p.netloc.split('@',1)[1] == remote
assert q.get("type")==["ws"] and q.get("security")==["tls"] and q.get("sni")==[sni] and q.get("host")==[host] and unquote(q["path"][0])==paths[2]
# Shadowsocks: SIP022 (2022-*) userinfo is percent-encoded, legacy is base64url
def userinfo_ok(p):
    if method.startswith("2022-"):
        return unquote(p.username or "")==method and unquote(p.password or "")==pw
    raw=p.username or ""; raw += "="*((4-len(raw)%4)%4)
    return base64.urlsafe_b64decode(raw.encode()).decode()==f"{method}:{pw}"
p=urlparse(links[3]); q=parse_qs(p.query)
assert p.scheme=="ss" and p.netloc.rsplit('@',1)[1]==remote and userinfo_ok(p)
assert q.get("type")==["ws"] and q.get("security")==["tls"] and q.get("sni")==[sni] and q.get("host")==[host] and unquote(q["path"][0])==paths[3]
p=urlparse(links[4]); q=parse_qs(p.query)
assert p.scheme=="ss" and p.netloc.rsplit('@',1)[1]==remote and userinfo_ok(p)
plugin=q["plugin"][0]
assert plugin.startswith("v2ray-plugin;") and "mode=websocket" in plugin and f"host={host}" in plugin and f"path={paths[3]}" in plugin and ";tls" in plugin and ";mux=0" in plugin
print("Generated links: all protocols carry the configured Remote Proxy/SNI/Host/Path (SS: SIP022-correct)")
PY
}

# ----------------------------------------------------------------------------
# Self-test
# ----------------------------------------------------------------------------
self_test() {
  load_conf
  local fails=0 s code name pth cf exp proto prc timers
  echo -e "\n${W}== AetherWS Self-Test ==${N}"

  [ -n "$DOMAIN" ] || { err "Domain missing."; return 1; }
  for s in nginx xray aether-ssh-ws; do
    if systemctl is-active --quiet "$s"; then ok "service $s: active"; else err "service $s: NOT active"; journal_hint "$s"; fails=$((fails+1)); fi
  done

  if [ -x "$XRAY_BIN" ] && validate_xray_config_file "$XRAY_CFG"; then ok "Xray configuration: valid"; else err "Xray configuration: invalid"; fails=$((fails+1)); fi
  if nginx -t >/dev/null 2>&1; then ok "Nginx configuration: valid"; else err "Nginx configuration: invalid"; nginx -t || true; fails=$((fails+1)); fi
  install -d -m 755 /run/sshd 2>/dev/null || true
  if sshd -t >/dev/null 2>&1; then ok "sshd configuration: valid"; else err "sshd configuration: invalid"; sshd -t || true; fails=$((fails+1)); fi

  code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 --resolve "$DOMAIN:443:127.0.0.1" "https://$DOMAIN/" 2>/dev/null || true)
  if [ "$code" = 200 ]; then ok "HTTPS decoy site: 200"; else err "HTTPS decoy site: ${code:-no response}"; fails=$((fails+1)); fi
  if tls_probe >/dev/null 2>&1; then ok "TLS handshake + hostname verification: OK"; else err "TLS handshake/hostname verification failed"; fails=$((fails+1)); fi

  cf="/etc/letsencrypt/live/$DOMAIN/fullchain.pem"
  if [ -f "$cf" ]; then
    if openssl x509 -in "$cf" -noout -checkend 2592000 >/dev/null 2>&1; then ok "Certificate valid for at least 30 days"; else exp=$(openssl x509 -in "$cf" -noout -enddate 2>/dev/null | cut -d= -f2); warn "Certificate expires soon: ${exp:-unknown}"; fi
  else err "Certificate file missing"; fails=$((fails+1)); fi

  for name in "VLESS:$WSP_VLESS" "VMess:$WSP_VMESS" "Trojan:$WSP_TROJAN" "Shadowsocks:$WSP_SS"; do
    pth=${name#*:}; name=${name%%:*}; s=$(ws_probe "$pth")
    if [[ "$s" == *101* ]]; then ok "$name WebSocket transport through Nginx: 101"; else err "$name WebSocket transport: '${s:-no response}'"; fails=$((fails+1)); fi
  done

  if validate_client_profile_syntax; then ok "Client Remote Proxy/SNI/Host syntax: OK"; else err "Client profile syntax invalid"; fails=$((fails+1)); fi
  client_profile_probe_smart >/dev/null 2>&1; prc=$?
  case "$prc" in
    0) ok "Client Remote Proxy TLS + WS probe: 101" ;;
    2) warn "Client Remote Proxy probe failed through the public address but passed locally (DNS propagation / hairpin NAT?). Re-test from another network." ;;
    *) err "Client Remote Proxy TLS + WS probe failed"; fails=$((fails+1)) ;;
  esac
  if validate_generated_links >/dev/null 2>&1; then ok "Generated VLESS/VMess/Trojan/Shadowsocks links: syntax OK"; else err "Generated client links failed validation"; fails=$((fails+1)); fi

  s=$(ss_method)
  if ss_key_ok; then ok "Shadowsocks cipher: $s (key length OK)"; else err "Shadowsocks cipher/key invalid or legacy (${s:-none}); run repair (menu 13)"; fails=$((fails+1)); fi

  if ensure_time_sync >/dev/null 2>&1; then
    ok "Time synchronization check: OK"
  elif is_container; then
    warn "Container detected: the clock comes from the host and cannot be verified here (VMess needs synchronized UTC time)."
  else
    err "Time synchronization could not be confirmed (VMess requires synchronized UTC time)"; fails=$((fails+1))
  fi

  for proto in vless vmess trojan ss; do
    if protocol_e2e_probe "$proto"; then :; else fails=$((fails+1)); fi
  done

  if bridge_local_probe >/dev/null 2>&1; then ok "SSH bridge local E2E: 101 + sshd banner"; else err "SSH bridge local E2E failed"; s=$(bridge_local_probe 2>&1 | tail -n 5); [ -n "$s" ] && warn "$s"; fails=$((fails+1)); fi
  if bridge_tls_e2e_probe >/dev/null 2>&1; then ok "SSH-WS full E2E: TLS + Nginx + leftover + sshd banner"; else err "SSH-WS full E2E failed"; s=$(bridge_tls_e2e_probe 2>&1 | tail -n 5); [ -n "$s" ] && warn "$s"; fails=$((fails+1)); fi
  if [ -x "$HOLD" ]; then ok "SSH ForceCommand hold script: present"; else err "SSH hold script missing"; fails=$((fails+1)); fi

  local exposed
  exposed=$(ss -ltnH 2>/dev/null | awk '{print $4}' | grep -E ":($P_VLESS|$P_VMESS|$P_TROJAN|$P_SS|$P_SSHWS)$" | grep -vE '(^127\.0\.0\.1:|^\[::1\]:)' || true)
  if [ -z "$exposed" ]; then ok "Internal protocol/bridge ports: localhost only"; else err "Internal ports exposed: $exposed"; fails=$((fails+1)); fi

  if getent group "$SSH_GROUP" >/dev/null 2>&1; then ok "SSH tunnel group '$SSH_GROUP': exists"; else err "SSH tunnel group missing"; fails=$((fails+1)); fi
  if [ -f "$SSH_DROPIN" ] && grep -q "ForceCommand $HOLD" "$SSH_DROPIN" && grep -qE "AllowTcpForwarding (yes|local)" "$SSH_DROPIN"; then ok "SSH tunnel-only policy: present"; else err "SSH tunnel-only policy missing/incomplete"; fails=$((fails+1)); fi
  timers=$(systemctl list-timers --all 2>/dev/null || true)
  if grep -qi certbot <<< "$timers"; then ok "Certbot renewal timer: present"; else warn "Certbot renewal timer not detected"; fi

  echo
  if [ "$fails" -eq 0 ]; then ok "ALL REQUIRED CHECKS PASSED."; else warn "$fails required check(s) failed."; fi
  return "$fails"
}

# ----------------------------------------------------------------------------
# DNS / install / update / uninstall
# ----------------------------------------------------------------------------
validate_domain_input() {
  local dom=$1
  [[ $dom =~ ^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+([a-z]{2,}|xn--[a-z0-9-]+)$ ]]
}

preflight_dns() {
  local dom=$1 ip dnsips dns6 my6
  ip=$(get_ipv4 2>/dev/null || true)
  dnsips=$(getent ahostsv4 "$dom" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ')
  if [ -n "$ip" ]; then
    case " $dnsips" in
      *" $ip "*) ok "Domain A record includes the VPS IPv4 ($ip)." ;;
      *)
        warn "Domain A records: ${dnsips:-none}; detected VPS IPv4: $ip"
        confirm "Continue anyway?" || return 1
        ;;
    esac
  fi
  # ::ffff:a.b.c.d is an IPv4-mapped answer for an A-only domain, not an AAAA.
  dns6=$(getent ahostsv6 "$dom" 2>/dev/null | awk '$1 !~ /^::ffff:/ {print $1}' | sort -u | head -n1)
  if [ -n "$dns6" ]; then
    my6=$(get_ipv6 2>/dev/null || true)
    warn "Domain has AAAA: $dns6"
    if [ -n "$my6" ] && [ "$dns6" = "$my6" ]; then
      ok "AAAA matches detected VPS IPv6."
    else
      warn "Detected VPS IPv6: ${my6:-none}. Let's Encrypt prefers IPv6 when AAAA exists."
      confirm "The AAAA record is correct; continue?" || return 1
    fi
  fi
  return 0
}

read_domain_email() {
  _dom=""; _email=""
  if [ ! -t 0 ]; then
    if [ -n "${AETHER_DOMAIN:-}" ]; then
      _dom=${AETHER_DOMAIN,,}; _email=${AETHER_EMAIL:-}; return 0
    fi
    die "Interactive install needs a terminal. Save the script to a file and run it, or set AETHER_DOMAIN=example.com (optional AETHER_EMAIL, AETHER_YES=1)."
  fi
  read -r -p "Enter your domain (A record must point to this VPS): " _dom
  _dom=${_dom,,}
  read -r -p "Email for Let's Encrypt notices (optional): " _email
}

# Snapshot of what a failed REinstall must be able to put back.
backup_install_state() {
  rm -rf "$STATE_BACKUP_DIR"
  install -d -m 700 "$STATE_BACKUP_DIR" || return 1
  [ -f "$ENV_FILE" ] && { cp -a "$ENV_FILE" "$STATE_BACKUP_DIR/paths.env" || return 1; }
  [ -f "$DOMAIN_FILE" ] && { cp -a "$DOMAIN_FILE" "$STATE_BACKUP_DIR/domain" || return 1; }
  [ -f "$XRAY_CFG" ] && { cp -a "$XRAY_CFG" "$STATE_BACKUP_DIR/config.json" || return 1; }
  AETHER_STATE_BACKUP=1
  return 0
}

do_install() {
  need_root; check_os
  AETHER_WAS_INSTALLED=0
  if is_installed; then
    warn "AetherWS is already installed. Reinstall regenerates all Xray credentials and paths."
    confirm "Continue with reinstall?" || return 0
    AETHER_WAS_INSTALLED=1
  fi
  local dom email ip remote_proxy client_sni client_host plog prc xray_tmp _v p
  read_domain_email; dom=$_dom; email=$_email
  validate_domain_input "$dom" || die "Invalid domain."
  if [ -n "$email" ]; then [[ $email =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]] || die "Invalid email."; fi

  remote_proxy=${AETHER_REMOTE_PROXY:-$dom:443}
  client_sni=${AETHER_SNI:-$dom}
  client_host=${AETHER_HOST:-$dom}
  if [ -t 0 ]; then
    read -r -p "Remote Proxy [${remote_proxy}]: " _v; remote_proxy=${_v:-$remote_proxy}
    read -r -p "TLS SNI [${client_sni}]: " _v; client_sni=${_v:-$client_sni}
    read -r -p "WebSocket Host [${client_host}]: " _v; client_host=${_v:-$client_host}
  fi
  REMOTE_PROXY=$remote_proxy; CLIENT_SNI=$client_sni; CLIENT_HOST=$client_host; CLIENT_PORT=443
  validate_client_profile_syntax || die "Invalid Remote Proxy/SNI/Host profile."
  parse_remote_proxy "$REMOTE_PROXY" || die "Invalid Remote Proxy."

  install_packages
  preflight_dns "$dom" || { warn "Cancelled."; return 0; }

  # The Aether HTTPS server is deliberately the default on 443 so a validated
  # custom WS Host can still reach Aether when a remote proxy changes Host.
  # From here on we touch Nginx: cleanup() rolls back if we die before the
  # vhost lands (and only then - see AETHER_INSTALL_ACTIVE=0 below).
  AETHER_INSTALL_ACTIVE=1
  install -d -m 700 "$(dirname "$NGINX_DEFAULT_BACKUP")" || die "Cannot create Nginx backup directory."
  rm -f "$NGINX_AETHER_BACKUP"
  if [ -f /etc/nginx/sites-enabled/default ]; then
    rm -f "$NGINX_DEFAULT_BACKUP"
    cp -a /etc/nginx/sites-enabled/default "$NGINX_DEFAULT_BACKUP" || die "Could not back up the existing Nginx default site."
  fi
  if [ -f "$NGX_CONF" ]; then
    cp -a "$NGX_CONF" "$NGINX_AETHER_BACKUP" || die "Could not back up the existing Aether Nginx configuration."
  fi
  rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true
  if nginx_default_conflict; then
    # Restoring the stock site next to Aether's own vhost would duplicate default_server.
    if [ -f "$NGINX_DEFAULT_BACKUP" ] && [ ! -f "$NGX_CONF" ]; then
      cp -a "$NGINX_DEFAULT_BACKUP" /etc/nginx/sites-enabled/default 2>/dev/null || true
    fi
    die "Another Nginx default_server is already configured on 80/443. Remove or reconfigure it before installing AetherWS."
  fi

  if ss -ltnpH '( sport = :80 or sport = :443 )' 2>/dev/null | awk '$1 == "LISTEN" && $4 !~ /^127\.0\.0\.1:/ && $4 !~ /^\[::1\]:/ && $NF !~ /nginx/ {f=1} END{exit(f?0:1)}'; then
    die "Port 80/443 is used by another public listener. Free it before installing."
  fi
  # Internal ports (e.g. 10000 is Webmin's default): only Aether's own
  # processes may already hold them (reinstall).
  for p in $P_VLESS $P_VMESS $P_TROJAN $P_SS $P_SSHWS; do
    if ss -ltnpH "( sport = :$p )" 2>/dev/null | awk '$1 == "LISTEN" && $NF !~ /xray|python3/ {f=1} END{exit(f?0:1)}'; then
      die "Internal port $p is already used by another program. Free it before installing."
    fi
  done
  if nginx_domain_conflict "$dom" 2>/dev/null; then
    die "Another Nginx configuration already claims $dom. Remove/rename that server block first."
  fi

  if ! ensure_time_sync; then
    if is_container; then
      warn "Container virtualization detected: the clock is inherited from the host and cannot be verified here. Keep the HOST clock accurate (VMess needs UTC within ~2 minutes)."
    else
      die "System clock is not synchronized. VMess requires accurate UTC time; fix NTP/chrony and retry."
    fi
  fi
  validate_ssh_maxstartups || die "Invalid SSH_MAXSTARTUPS='$SSH_MAXSTARTUPS'."
  validate_ssh_forwarding || die "Invalid SSH_TCP_FORWARDING='$SSH_TCP_FORWARDING' (use local or yes)."
  validate_ngx_limits || die "Invalid NGX_LIMIT_* values (positive integers required)."
  ss_method_valid "$SS_METHOD" || die "Invalid SS_METHOD='$SS_METHOD' (use 2022-blake3-aes-256-gcm, 2022-blake3-chacha20-poly1305 or 2022-blake3-aes-128-gcm)."

  install -d -m 755 "$LOCK_DIR" || die "Cannot create $LOCK_DIR"
  install -d -m 755 "$CONF_DIR" || die "Cannot create $CONF_DIR"
  # Reinstall: snapshot credentials/paths/domain BEFORE they are overwritten.
  if [ "$AETHER_WAS_INSTALLED" -eq 1 ]; then
    backup_install_state || die "Could not back up the current AetherWS state."
  fi
  echo "$dom" > "$DOMAIN_FILE" || die "Cannot write domain file"
  ip=$(get_ipv4 2>/dev/null || true)
  if [ -n "$ip" ]; then echo "$ip" > "$IP_FILE"; fi
  choose_ws_paths || die "Invalid WebSocket paths."
  write_client_profile || die "Cannot write Aether configuration."
  DOMAIN=$dom

  xray_tmp=$(xray_download_staging) || die "Could not download/verify Xray."
  if ! install_xray "$xray_tmp"; then rm -rf "$xray_tmp"; die "Could not install Xray binary/assets."; fi
  rm -rf "$xray_tmp"
  setup_xray_env || die "Could not configure Xray service."
  write_xray_config_initial || die "Could not generate Xray configuration."

  install_site || die "Could not create decoy site."
  systemctl enable xray >/dev/null 2>&1 || die "Could not enable Xray."
  systemctl restart xray >/dev/null 2>&1 || { journal_hint xray; die "Xray failed to start."; }
  wait_active xray 15 || { journal_hint xray; die "Xray did not become active."; }

  setup_ssh || die "SSH configuration failed; previous SSH settings were restored."
  setup_bridge || { journal_hint aether-ssh-ws; die "SSH-WS bridge failed to start."; }

  systemctl enable nginx >/dev/null 2>&1 || die "Could not enable Nginx."
  systemctl start nginx >/dev/null 2>&1 || { journal_hint nginx; die "Nginx failed to start."; }
  setup_certbot_hook || warn "Could not install Certbot deploy hook."
  setup_firewall || die "Firewall setup failed."

  issue_cert "$dom" "$email" || die "Certificate issuance failed. Check DNS, AAAA, and ports 80/443."
  write_nginx_full "$dom" || die "Nginx final configuration failed."
  # Aether's vhost (which owns default_server) is live now: from here on a
  # failure must NOT bring the stock default site (or the old state) back.
  AETHER_INSTALL_ACTIVE=0
  AETHER_STATE_BACKUP=0
  rm -f "$NGINX_AETHER_BACKUP"
  rm -rf "$STATE_BACKUP_DIR"

  # Install the `aether` command before the probe, so a probe problem never
  # leaves the machine without it.
  self_install

  if [ "${AETHER_SKIP_PROBE:-0}" = 1 ]; then
    warn "AETHER_SKIP_PROBE=1: Remote Proxy/SNI/Host probe skipped."
  else
    plog=$(mktemp "$WORK_DIR/profile.XXXXXX") || die "Cannot create a temp file."
    client_profile_probe_smart >"$plog" 2>&1; prc=$?
    case "$prc" in
      0) ;;
      2) warn "Remote Proxy probe failed through the public address but passed locally (DNS propagation / hairpin NAT?). Continuing; re-test from another network." ;;
      *) cat "$plog" >&2 || true
         warn "Remote Proxy/SNI/Host profile failed the TLS+WebSocket probe: generated links may not work. Fix it with 'aether profile' (or set AETHER_SKIP_PROBE=1 to skip this probe)." ;;
    esac
    rm -f "$plog"
  fi

  sleep 1
  if self_test; then
    echo
    ok "AetherWS $VERSION installation complete."
    echo "Type: aether"
  else
    warn "Installation finished, but self-test reported failures. DO NOT assume all protocols are working."
    pause
    return 1
  fi
  pause
}

# Allow every real sshd port BEFORE changing the default policy / enabling UFW.
setup_firewall() {
  info "Configuring UFW: allow SSH + 80 + 443; deny other incoming traffic."
  local p ports
  ports=$(ssh_ports_all)
  [ -n "$ports" ] || ports=22
  for p in $ports; do
    ufw allow "$p/tcp" >/dev/null 2>&1 || return 1
  done
  ufw allow 80/tcp >/dev/null 2>&1 || return 1
  ufw allow 443/tcp >/dev/null 2>&1 || return 1
  ufw default deny incoming >/dev/null 2>&1 || return 1
  ufw default allow outgoing >/dev/null 2>&1 || return 1
  ufw --force enable >/dev/null 2>&1 || return 1
  return 0
}

do_update_xray() {
  is_installed || { err "Not installed."; return 1; }
  need_root
  local tmp bakdir
  bakdir=$(mktemp -d "$WORK_DIR/xray-update.XXXXXX") || return 1
  cp -a "$XRAY_BIN" "$bakdir/xray" || { rm -rf "$bakdir"; err "Could not back up Xray binary."; return 1; }
  [ -f "$XRAY_ASSET/geoip.dat" ] && cp -a "$XRAY_ASSET/geoip.dat" "$bakdir/geoip.dat"
  [ -f "$XRAY_ASSET/geosite.dat" ] && cp -a "$XRAY_ASSET/geosite.dat" "$bakdir/geosite.dat"

  tmp=$(xray_download_staging) || { rm -rf "$bakdir"; return 1; }
  # The NEW binary must accept the CURRENT configuration before it replaces
  # the working one (newer Xray releases drop deprecated options).
  if ! XRAY_LOCATION_ASSET="$tmp/x" "$tmp/x/xray" run -test -config "$XRAY_CFG" >/dev/null 2>&1; then
    err "The new Xray version rejects the current configuration; update cancelled (nothing changed)."
    XRAY_LOCATION_ASSET="$tmp/x" "$tmp/x/xray" run -test -config "$XRAY_CFG" 2>&1 | tail -n 10 >&2 || true
    rm -rf "$tmp" "$bakdir"
    return 1
  fi
  if ! install_xray "$tmp"; then
    rm -rf "$tmp"
    cp -a "$bakdir/xray" "$XRAY_BIN" || true
    [ -f "$bakdir/geoip.dat" ] && cp -a "$bakdir/geoip.dat" "$XRAY_ASSET/geoip.dat"
    [ -f "$bakdir/geosite.dat" ] && cp -a "$bakdir/geosite.dat" "$XRAY_ASSET/geosite.dat"
    rm -rf "$bakdir"
    return 1
  fi
  rm -rf "$tmp"
  if systemctl restart xray >/dev/null 2>&1 && wait_active xray 15; then
    rm -rf "$bakdir"; ok "Xray updated successfully."; return 0
  fi
  err "Xray update failed after restart; rolling back binary/assets."
  journal_hint xray
  cp -a "$bakdir/xray" "$XRAY_BIN" || true
  [ -f "$bakdir/geoip.dat" ] && cp -a "$bakdir/geoip.dat" "$XRAY_ASSET/geoip.dat"
  [ -f "$bakdir/geosite.dat" ] && cp -a "$bakdir/geosite.dat" "$XRAY_ASSET/geosite.dat"
  rm -rf "$bakdir"
  systemctl restart xray >/dev/null 2>&1 || true
  return 1
}

# ----------------------------------------------------------------------------
# Script install / repair / uninstall
# ----------------------------------------------------------------------------
self_install() {
  local src
  src=$(readlink -f "$0" 2>/dev/null || true)
  if [ -n "$src" ] && [ -f "$src" ] && [ -s "$src" ]; then
    case "$src" in /dev/fd/*|/proc/self/fd/*|/dev/stdin) warn "Save the script to a file to install the 'aether' command."; return 0;; esac
    if [ "$src" != "$BIN_LINK" ]; then
      install -m 755 "$src" "$BIN_LINK" && ok "Menu command installed: aether"
    fi
  else
    [ -f "$BIN_LINK" ] || warn "Save the script to a file to install the 'aether' command."
  fi
}

do_repair() {
  is_installed || { err "Not installed."; return 1; }
  need_root; load_conf
  info "Repairing Xray service, SSH bridge, sshd drop-in, Nginx, and TLS hook..."
  setup_xray_env || return 1
  ss_upgrade_legacy || warn "Shadowsocks cipher upgrade failed"
  setup_ssh || return 1
  setup_bridge || return 1
  setup_certbot_hook || warn "Certbot hook refresh failed"
  if [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ]; then
    write_nginx_full "$DOMAIN" || return 1
  else
    err "Certificate missing for $DOMAIN"
    return 1
  fi
  systemctl restart xray >/dev/null 2>&1 || return 1
  wait_active xray 15 || return 1
  systemctl is-active --quiet aether-ssh-ws || return 1
  self_install
  self_test
}

do_uninstall() {
  need_root
  load_conf
  echo -e "${R}This removes AetherWS services/configuration. Existing Nginx/UFW packages and rules are retained.${N}"
  local a
  if [ "${AETHER_YES:-0}" = 1 ]; then a=YES; else read -r -p "Type YES to confirm: " a || return; fi
  [ "$a" = YES ] || { echo "Cancelled."; return; }

  systemctl disable --now xray aether-ssh-ws >/dev/null 2>&1 || true
  rm -f /etc/systemd/system/xray.service /etc/systemd/system/aether-ssh-ws.service
  systemctl daemon-reload >/dev/null 2>&1 || true

  rm -f "$XRAY_BIN" "$NGX_CONF" "$NGX_RATE" /etc/nginx/conf.d/aether-rate.conf "$NGX_ACME" "$HOOK" /etc/logrotate.d/aether-xray
  rm -rf "$XRAY_DIR" "$XRAY_ASSET" "$XRAY_LOG" "$BRIDGE_DIR" "$SITE_ROOT" "$WEBROOT_ACME" "$E2E_DIR"
  systemctl reload nginx >/dev/null 2>&1 || true

  if getent group "$SSH_GROUP" >/dev/null 2>&1; then
    local u
    if confirm "Delete all SSH tunnel-only users?"; then
      while IFS= read -r u; do
        [ -n "$u" ] || continue
        pkill -KILL -u "$u" 2>/dev/null || true
        userdel "$u" 2>/dev/null && echo "removed $u" || true
      done < <(ssh_users)
    else
      # Kept users lose ForceCommand/PermitTTY with the drop-in: take away
      # their shell so they do not become ordinary interactive accounts.
      while IFS= read -r u; do
        [ -n "$u" ] || continue
        usermod -s /usr/sbin/nologin "$u" 2>/dev/null || true
        pkill -KILL -u "$u" 2>/dev/null || true
      done < <(ssh_users)
      warn "Kept tunnel users were switched to /usr/sbin/nologin."
    fi
    groupdel "$SSH_GROUP" >/dev/null 2>&1 || warn "Group $SSH_GROUP remains because it still has members."
  fi

  rm -f "$SSH_DROPIN" "$SSH_LEGACY_DROPIN"
  if [ -f "$SSH_INCLUDE_MARKER" ]; then
    sed -i '/^# AetherWS managed sshd_config\.d include\.$/d;/^Include \/etc\/ssh\/sshd_config\.d\/\*\.conf$/d' /etc/ssh/sshd_config 2>/dev/null || true
    rm -f "$SSH_INCLUDE_MARKER"
  fi
  if sshd -t >/dev/null 2>&1; then reload_ssh || true; fi
  userdel xray >/dev/null 2>&1 || true
  userdel aether-bridge >/dev/null 2>&1 || true

  if [ -n "$DOMAIN" ] && [ -d "/etc/letsencrypt/live/$DOMAIN" ] && confirm "Delete the Let's Encrypt certificate for $DOMAIN?"; then
    certbot delete --cert-name "$DOMAIN" --non-interactive >/dev/null 2>&1 && echo "certificate deleted" || true
  fi
  # The stock nginx default site was backed up at install time; put it back.
  if [ -f "$NGINX_DEFAULT_BACKUP" ] && [ ! -e /etc/nginx/sites-enabled/default ]; then
    install -d -m 755 /etc/nginx/sites-enabled || true
    cp -a "$NGINX_DEFAULT_BACKUP" /etc/nginx/sites-enabled/default 2>/dev/null || true
    nginx -t >/dev/null 2>&1 && (systemctl reload nginx >/dev/null 2>&1 || true)
  fi
  rm -rf "$CONF_DIR" "$LOCK_DIR"
  rm -f "$BIN_LINK"
  ok "AetherWS removed. Nginx/UFW packages and existing firewall rules were intentionally left in place."
  return 0
}

# ----------------------------------------------------------------------------
# Client connection profile: Remote Proxy / TLS SNI / WebSocket Host
# ----------------------------------------------------------------------------
validate_dns_name() {
  local v=$1
  [ -n "$v" ] || return 1
  [ "${#v}" -le 253 ] || return 1
  [[ "$v" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\.([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*$ ]]
}

validate_ipv4() {
  local v=$1 octet
  local -a octets
  [[ "$v" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1
  IFS=. read -r -a octets <<< "$v"
  [ "${#octets[@]}" -eq 4 ] || return 1
  for octet in "${octets[@]}"; do
    [ "$octet" -ge 0 ] && [ "$octet" -le 255 ] || return 1
  done
}

validate_client_host() {
  local v=$1
  [ -n "$v" ] || return 1
  [[ "$v" != *[[:space:]]* ]] || return 1
  [[ "$v" != *$'\r'* && "$v" != *$'\n'* ]] || return 1
  if [[ "$v" =~ ^[0-9]+(\.[0-9]+){3}$ ]]; then
    validate_ipv4 "$v"
  else
    validate_dns_name "$v"
  fi
}

parse_remote_proxy() {
  local raw=$1 host port
  REMOTE_PROXY_HOST=""; REMOTE_PROXY_PORT=""
  if [[ "$raw" =~ ^\[([^]]+)\]:([0-9]{1,5})$ ]]; then
    host="${BASH_REMATCH[1]}"; port="${BASH_REMATCH[2]}"
  elif [[ "$raw" =~ ^([^:]+):([0-9]{1,5})$ ]]; then
    host="${BASH_REMATCH[1]}"; port="${BASH_REMATCH[2]}"
  else
    host="$raw"; port="443"
  fi
  [ -n "$host" ] || return 1
  [[ "$port" =~ ^[0-9]{1,5}$ ]] || return 1
  [ "$port" -ge 1 ] && [ "$port" -le 65535 ] || return 1
  if [[ "$host" == *:* ]]; then
    [[ "$raw" == \[*\]:* ]] || return 1
    python3 - "$host" <<'PY' >/dev/null 2>&1
import ipaddress, sys
ipaddress.IPv6Address(sys.argv[1])
PY
    [ "$?" -eq 0 ] || return 1
  else
    validate_client_host "$host" || return 1
  fi
  REMOTE_PROXY_HOST="$host"
  REMOTE_PROXY_PORT="$port"
  if [[ "$host" == *:* ]]; then
    REMOTE_PROXY="[$host]:$port"
  else
    REMOTE_PROXY="$host:$port"
  fi
}

validate_client_profile_syntax() {
  parse_remote_proxy "$REMOTE_PROXY" || return 1
  validate_dns_name "$CLIENT_SNI" || return 1
  validate_client_host "$CLIENT_HOST" || return 1
}

client_profile_summary() {
  load_conf
  parse_remote_proxy "$REMOTE_PROXY" >/dev/null 2>&1 || true
  echo "Remote Proxy : $REMOTE_PROXY"
  echo "TLS SNI      : $CLIENT_SNI"
  echo "WS Host      : $CLIENT_HOST"
  echo "Port         : $REMOTE_PROXY_PORT"
}

# Probes every WS path through the Remote Proxy / SNI / Host that are CURRENTLY
# IN MEMORY: callers run load_conf first, or set the values (menu 14) first.
# It must NOT call load_conf itself - that reloaded the saved profile and made
# menu 14 test and re-save the OLD values.
# Optional $1: address to dial instead of the Remote Proxy host (same SNI/Host).
client_profile_probe() {
  local connect_host=${1:-}
  validate_client_profile_syntax || return 1
  python3 - "${connect_host:-$REMOTE_PROXY_HOST}" "$REMOTE_PROXY_PORT" "$CLIENT_SNI" "$CLIENT_HOST" \
    "$WSP_VLESS" "$WSP_VMESS" "$WSP_TROJAN" "$WSP_SS" "$WSP_SSH" <<'PY'
import socket, ssl, sys
remote_host, remote_port, sni, host, *paths = sys.argv[1:]
raw_host = remote_host.strip('[]')
ctx = ssl.create_default_context()
ctx.set_alpn_protocols(["http/1.1"])
for path in paths:
    raw = socket.create_connection((raw_host, int(remote_port)), 8)
    s = None
    try:
        s = ctx.wrap_socket(raw, server_hostname=sni)
        s.settimeout(8)
        req = (
            f"GET {path} HTTP/1.1\r\n"
            f"Host: {host}\r\n"
            "User-Agent: AetherWS-Probe/2.3\r\n"
            "Upgrade: websocket\r\n"
            "Connection: Upgrade\r\n"
            "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
            "Sec-WebSocket-Version: 13\r\n\r\n"
        ).encode("ascii")
        s.sendall(req)
        line = b""
        while b"\r\n" not in line and len(line) < 1024:
            chunk = s.recv(1)
            if not chunk:
                break
            line += chunk
        status = line.split(b"\r\n", 1)[0]
        if status != b"HTTP/1.1 101 Switching Protocols":
            raise SystemExit(f"{path}: {status!r}")
    finally:
        try:
            if s is not None: s.close()
            else: raw.close()
        except Exception: pass
print("TLS SNI + certificate: OK")
print(f"HTTP Host + WebSocket Upgrade: 101 for {len(paths)} paths")
PY
}

# 0 = OK through the configured Remote Proxy.
# 2 = failed through the public address, but the DEFAULT profile (Remote Proxy
#     == this domain on 443) answers correctly on 127.0.0.1: DNS cache or
#     hairpin NAT, not a configuration error.
# 1 = failed.
client_profile_probe_smart() {
  client_profile_probe && return 0
  if [ "${REMOTE_PROXY_HOST:-}" = "${DOMAIN:-}" ] && [ "${REMOTE_PROXY_PORT:-}" = 443 ] \
     && client_profile_probe 127.0.0.1 >/dev/null 2>&1; then
    return 2
  fi
  return 1
}

# Saves the client profile AND the tunables (SSH forwarding, MaxStartups,
# Nginx limits) so menu 9 / 13 / repair keep them.
write_client_profile() {
  validate_dns_name "$CLIENT_SNI" || return 1
  validate_client_host "$CLIENT_HOST" || return 1
  parse_remote_proxy "$REMOTE_PROXY" || return 1
  CLIENT_PORT="$REMOTE_PROXY_PORT"
  local tmp
  tmp=$(mktemp "$CONF_DIR/.paths.XXXXXX") || return 1
  cat > "$tmp" <<EOF2
WSP_VLESS="$WSP_VLESS"
WSP_VMESS="$WSP_VMESS"
WSP_TROJAN="$WSP_TROJAN"
WSP_SS="$WSP_SS"
WSP_SSH="$WSP_SSH"
REMOTE_PROXY="$REMOTE_PROXY"
CLIENT_SNI="$CLIENT_SNI"
CLIENT_HOST="$CLIENT_HOST"
CLIENT_PORT="$CLIENT_PORT"
SSH_MAXSTARTUPS="$SSH_MAXSTARTUPS"
SSH_TCP_FORWARDING="$SSH_TCP_FORWARDING"
NGX_LIMIT_CONN="$NGX_LIMIT_CONN"
NGX_LIMIT_RATE="$NGX_LIMIT_RATE"
NGX_LIMIT_BURST="$NGX_LIMIT_BURST"
EOF2
  chmod 600 "$tmp" || { rm -f "$tmp"; return 1; }
  mv -f "$tmp" "$ENV_FILE" || { rm -f "$tmp"; return 1; }
}

client_profile_menu() {
  load_conf
  local old_proxy=$REMOTE_PROXY old_sni=$CLIENT_SNI old_host=$CLIENT_HOST np ns nh plog
  clear 2>/dev/null || true
  echo -e "${C}== Client Remote Proxy / SNI / Host ==${N}\n"
  echo "These values describe the client-facing endpoint; they are independent fields."
  echo
  read -r -p "Remote Proxy [$REMOTE_PROXY]: " np; np=${np:-$REMOTE_PROXY}
  read -r -p "TLS SNI [$CLIENT_SNI]: " ns; ns=${ns:-$CLIENT_SNI}
  read -r -p "WebSocket Host [$CLIENT_HOST]: " nh; nh=${nh:-$CLIENT_HOST}
  REMOTE_PROXY=$np; CLIENT_SNI=$ns; CLIENT_HOST=$nh
  if ! validate_client_profile_syntax; then
    REMOTE_PROXY=$old_proxy; CLIENT_SNI=$old_sni; CLIENT_HOST=$old_host
    err "Invalid profile syntax; nothing changed."
    return 1
  fi
  init_work_dir || return 1
  plog=$(mktemp "$WORK_DIR/profile.XXXXXX") || return 1
  # Probes the values typed above (the probe no longer reloads the saved ones).
  if ! client_profile_probe >"$plog" 2>&1; then
    cat "$plog" >&2 || true
    rm -f "$plog"
    REMOTE_PROXY=$old_proxy; CLIENT_SNI=$old_sni; CLIENT_HOST=$old_host
    err "Profile TLS+WS probe failed; nothing changed."
    return 1
  fi
  rm -f "$plog"
  if ! write_client_profile; then
    REMOTE_PROXY=$old_proxy; CLIENT_SNI=$old_sni; CLIENT_HOST=$old_host
    err "Could not save the profile; nothing changed."
    return 1
  fi
  ok "Client profile saved and verified."
  client_profile_summary
}

mklink() {
  local tag=$1 n=$2 s=$3 enc json plugin_str frag method ui
  load_conf
  validate_client_profile_syntax || return 1
  frag=$(url_encode "$DOMAIN-$n")
  case "$tag" in
    vless-ws)
      enc=$(urlenc_path "$WSP_VLESS")
      echo "vless://$s@$REMOTE_PROXY?encryption=none&security=tls&sni=$(url_encode "$CLIENT_SNI")&fp=chrome&type=ws&host=$(url_encode "$CLIENT_HOST")&path=$enc#$frag-VLESS"
      ;;
    vmess-ws)
      json=$(jq -nc --arg ps "$DOMAIN-$n-VMess" --arg add "$REMOTE_PROXY_HOST" --arg port "$REMOTE_PROXY_PORT" --arg id "$s" --arg path "$WSP_VMESS" --arg host "$CLIENT_HOST" --arg sni "$CLIENT_SNI" \
        '{v:"2",ps:$ps,add:$add,port:$port,id:$id,aid:"0",scy:"auto",net:"ws",type:"none",host:$host,path:$path,tls:"tls",sni:$sni,alpn:"http/1.1",fp:"chrome"}')
      echo "vmess://$(b64_oneline "$json")"
      ;;
    trojan-ws)
      enc=$(urlenc_path "$WSP_TROJAN")
      echo "trojan://$s@$REMOTE_PROXY?security=tls&sni=$(url_encode "$CLIENT_SNI")&fp=chrome&type=ws&host=$(url_encode "$CLIENT_HOST")&path=$enc#$frag-Trojan"
      ;;
    ss-ws|ss-plugin)
      method=$(ss_method) || return 1
      [ -n "$method" ] || return 1
      ui=$(ss_userinfo "$method" "$s") || return 1
      if [ "$tag" = ss-ws ]; then
        # Xray / sing-box share-link style (v2rayN, v2rayNG, NekoBox, Hiddify ...)
        enc=$(urlenc_path "$WSP_SS")
        echo "ss://$ui@$REMOTE_PROXY?type=ws&security=tls&sni=$(url_encode "$CLIENT_SNI")&fp=chrome&alpn=http%2F1.1&host=$(url_encode "$CLIENT_HOST")&path=$enc#$frag-SS"
      else
        # SIP003 v2ray-plugin style (shadowsocks-rust / Shadowsocks Android)
        plugin_str="v2ray-plugin;mode=websocket;host=$CLIENT_HOST;path=$WSP_SS;tls;mux=0"
        echo "ss://$ui@$REMOTE_PROXY/?plugin=$(url_encode "$plugin_str")#$frag-SS-plugin"
      fi
      ;;
  esac
}

# v2ray-plugin uses one value for both TLS SNI and WS Host.
ss_sni_note() {
  load_conf
  if [ "$CLIENT_SNI" != "$CLIENT_HOST" ]; then
    warn "v2ray-plugin uses the same value for TLS SNI and WS Host. Your SNI ($CLIENT_SNI) differs from Host ($CLIENT_HOST), so the SIP003 plugin link cannot express this profile; use the Xray-style Shadowsocks link, or VLESS/Trojan/VMess."
  fi
}

clients_of() {
  jq -r --arg t "$1" '.inbounds[]|select(.tag==$t).settings.clients[]?|"\(.email // "default")\t\(.id // .password)"' "$XRAY_CFG" 2>/dev/null
}

list_clients() {
  local tag=$1 n s i=0
  while IFS=$'\t' read -r n s; do
    [ -n "$n" ] || continue
    i=$((i+1))
    printf '%s%d) %s%s\n' "$W" "$i" "$n" "$N"
    printf '   %s%s%s\n' "$C" "$(mklink "$tag" "$n" "$s")" "$N"
  done < <(clients_of "$tag")
  [ "$i" -gt 0 ] || warn "No users."
}

add_client() {
  local tag=$1 name secret
  read -r -p "User name (letters/digits/_-, max 32): " name
  [[ $name =~ ^[A-Za-z0-9_-]{1,32}$ ]] || { err "Invalid name."; return 1; }
  if jq -e --arg t "$tag" --arg n "$name" '.inbounds[]|select(.tag==$t).settings.clients[]|select(.email==$n)' "$XRAY_CFG" >/dev/null 2>&1; then
    err "User already exists."; return 1
  fi
  if [ "$tag" = trojan-ws ]; then
    secret=$(rand_hex 16)
    cfg_apply --arg t "$tag" --arg n "$name" --arg s "$secret" \
      '(.inbounds[]|select(.tag==$t).settings.clients) += [{password:$s,email:$n,level:0}]' || return 1
  else
    secret=$(new_uuid)
    cfg_apply --arg t "$tag" --arg n "$name" --arg s "$secret" \
      '(.inbounds[]|select(.tag==$t).settings.clients) += [{id:$s,email:$n,level:0}]' || return 1
  fi
  ok "User added:"; printf '%s%s%s\n' "$C" "$(mklink "$tag" "$name" "$secret")" "$N"
}

del_client() {
  local tag=$1 idx cnt name
  cnt=$(jq --arg t "$tag" '[.inbounds[]|select(.tag==$t).settings.clients[]]|length' "$XRAY_CFG" 2>/dev/null) || return 1
  list_clients "$tag"
  [ "$cnt" -gt 1 ] || { warn "At least one user must remain."; return 0; }
  read -r -p "Number to delete: " idx
  [[ $idx =~ ^[0-9]+$ ]] && [ "$idx" -ge 1 ] && [ "$idx" -le "$cnt" ] || { err "Invalid number."; return 1; }
  name=$(clients_of "$tag" | sed -n "${idx}p" | cut -f1)
  if cfg_apply --arg t "$tag" --arg n "$name" '(.inbounds[]|select(.tag==$t).settings.clients) |= map(select(.email!=$n))'; then
    ok "Deleted $name."
  fi
}

proto_menu() {
  local tag=$1 label=$2 c
  while true; do
    clear 2>/dev/null || true
    echo -e "${C}== $label · WebSocket + TLS ==${N}\n"
    echo "  1) Add user"; echo "  2) Delete user"; echo "  3) List users + links"; echo "  0) Back"
    read -r -p "> " c || return 0
    case $c in
      1) add_client "$tag"; pause ;;
      2) del_client "$tag"; pause ;;
      3) list_clients "$tag"; pause ;;
      0) return 0 ;;
    esac
  done
}

show_ss_links() {
  local s
  s=$(ss_password)
  if [ -z "$s" ]; then err "Could not read Shadowsocks password"; return 1; fi
  printf '%sCipher: %s%s\n\n' "$W" "$(ss_method)" "$N"
  printf '%s1) Xray/sing-box style (v2rayN, v2rayNG, NekoBox, Hiddify, sing-box):%s\n' "$W" "$N"
  printf '%s%s%s\n\n' "$C" "$(mklink ss-ws default "$s")" "$N"
  printf '%s2) SIP003 v2ray-plugin style (shadowsocks-rust, Shadowsocks Android + v2ray-plugin):%s\n' "$W" "$N"
  printf '%s%s%s\n' "$C" "$(mklink ss-plugin default "$s")" "$N"
  ss_sni_note
}

ss_menu() {
  local c s m nm
  while true; do
    clear 2>/dev/null || true
    echo -e "${C}== Shadowsocks 2022 · WebSocket + TLS ==${N}\n"
    echo "  Cipher: $(ss_method)"
    echo "  1) Show links"
    echo "  2) Regenerate key"
    echo "  3) Change cipher"
    echo "  0) Back"
    read -r -p "> " c || return 0
    case $c in
      1) show_ss_links; pause ;;
      2) m=$(ss_method)
         s=$(ss_gen_password "$m") || { err "Could not generate a key"; pause; continue; }
         if cfg_apply --arg p "$s" '(.inbounds[]|select(.tag=="ss-ws").settings.password)=$p'; then
           ok "New links:"; show_ss_links
         fi
         pause ;;
      3) echo "  a) 2022-blake3-aes-256-gcm        (AES-NI servers/clients: fastest)"
         echo "  b) 2022-blake3-chacha20-poly1305  (phones/CPUs without AES hardware)"
         echo "  c) 2022-blake3-aes-128-gcm        (lightest)"
         read -r -p "Cipher [a/b/c]: " nm
         case $nm in
           a) nm=2022-blake3-aes-256-gcm ;;
           b) nm=2022-blake3-chacha20-poly1305 ;;
           c) nm=2022-blake3-aes-128-gcm ;;
           *) err "Cancelled."; pause; continue ;;
         esac
         s=$(ss_new_key "$nm") || { err "Could not generate a key"; pause; continue; }
         if cfg_apply --arg m "$nm" --arg p "$s" '(.inbounds[]|select(.tag=="ss-ws").settings) |= (.method=$m | .password=$p)'; then
           ok "Cipher changed to $nm. New links:"; show_ss_links
         fi
         pause ;;
      0) return 0 ;;
    esac
  done
}

kv() { printf '%s%-13s:%s %s\n' "$W" "$1" "$N" "$2"; }

# Values (including the password) go through printf %s: no backslash escapes.
ssh_info() {
  load_conf
  kv "Remote Proxy" "$REMOTE_PROXY"
  kv "TLS SNI" "$CLIENT_SNI"
  kv "WS Host" "$CLIENT_HOST"
  kv "Port" "$CLIENT_PORT"
  kv "Path" "$WSP_SSH"
  if [ -n "${1:-}" ]; then
    kv "User" "$1"
    kv "Pass" "${2:-}"
  fi
  kv "Payload" "GET $WSP_SSH HTTP/1.1[crlf]Host: $CLIENT_HOST[crlf]Connection: Upgrade[crlf]User-Agent: [ua][crlf]Upgrade: websocket[crlf][crlf]"
  echo "After HTTP 101 the stream is raw TCP to OpenSSH; this is HTTP Upgrade, not RFC6455 framing."
}

ssh_add() {
  local u days pw exp
  read -r -p "Username (a-z, 0-9, _-, max 24): " u
  [[ $u =~ ^[a-z_][a-z0-9_-]{0,23}$ ]] || { err "Invalid username."; return 1; }
  id "$u" >/dev/null 2>&1 && { err "User already exists on this system."; return 1; }
  read -r -p "Valid for how many days [30]: " days; days=${days:-30}
  [[ $days =~ ^[0-9]{1,4}$ ]] && [ "$days" -ge 1 ] || { err "Invalid days."; return 1; }
  read -r -s -p "Password (empty = random): " pw; echo; pw=${pw:-$(rand_hex 16)}
  [ "${#pw}" -ge 8 ] || { err "Password must be at least 8 characters (these accounts are reachable on the public SSH port)."; return 1; }
  exp=$(date -d "+$days days" +%F) || return 1
  useradd -M -g "$SSH_GROUP" -s /bin/bash -e "$exp" "$u" || { err "useradd failed"; return 1; }
  if ! printf '%s:%s\n' "$u" "$pw" | chpasswd; then userdel "$u" >/dev/null 2>&1 || true; err "chpasswd failed"; return 1; fi
  ok "SSH-WS user created (expires $exp)"; ssh_info "$u" "$pw"
}

ssh_list() {
  local u exp
  printf '%-24s %s\n' USER EXPIRES
  while IFS= read -r u; do
    [ -n "$u" ] || continue
    exp=$(chage -l "$u" 2>/dev/null | awk -F': ' '/Account expires/{print $2}')
    printf '%-24s %s\n' "$u" "${exp:-never}"
  done < <(ssh_users)
}

ssh_del() {
  local u users
  ssh_list
  read -r -p "Username to delete: " u
  [ -n "$u" ] || { err "No username given."; return 1; }
  users=$(ssh_users)
  grep -Fxq -- "$u" <<< "$users" || { err "Not a tunnel-only user; refusing."; return 1; }
  pkill -KILL -u "$u" 2>/dev/null || true
  if userdel "$u"; then ok "Deleted $u."; fi
}

ssh_menu() {
  local c
  while true; do
    clear 2>/dev/null || true
    echo -e "${C}== SSH · WebSocket + TLS ==${N}\n"
    echo "  1) Add user"; echo "  2) Delete user"; echo "  3) List users"; echo "  4) Connection info"; echo "  0) Back"
    read -r -p "> " c || return 0
    case $c in
      1) ssh_add; pause ;;
      2) ssh_del; pause ;;
      3) ssh_list; pause ;;
      4) ssh_info; pause ;;
      0) return 0 ;;
    esac
  done
}

show_all_links() {
  load_conf
  local t u s
  for t in vless-ws vmess-ws trojan-ws; do
    u=${t%-ws}; echo -e "\n${W}== ${u^^} ==${N}"; list_clients "$t"
  done
  echo -e "\n${W}== SHADOWSOCKS 2022 (WS + TLS) ==${N}"
  show_ss_links
  echo -e "\n${W}== CLIENT PROFILE ==${N}"; client_profile_summary
  echo -e "\n${W}== SSH-WS ==${N}"; ssh_info
}

change_domain() {
  load_conf
  local nd olddom=$DOMAIN oldproxy=$REMOTE_PROXY oldsni=$CLIENT_SNI oldhost=$CLIENT_HOST
  read -r -p "New domain: " nd; nd=${nd,,}
  validate_domain_input "$nd" || { err "Invalid domain."; return 1; }
  if [ "$nd" != "$olddom" ] && nginx_domain_conflict "$nd" 2>/dev/null; then
    err "Another Nginx configuration already claims $nd."; return 1
  fi
  preflight_dns "$nd" || return 1
  issue_cert "$nd" "" || { err "Certificate failed; domain unchanged."; return 1; }
  if ! write_nginx_full "$nd"; then
    err "New certificate exists but Nginx switch failed; old domain remains active."
    return 1
  fi
  echo "$nd" > "$DOMAIN_FILE" || { err "Could not save new domain"; return 1; }
  DOMAIN=$nd
  if [ "$oldproxy" = "$olddom:443" ] && [ "$oldsni" = "$olddom" ] && [ "$oldhost" = "$olddom" ]; then
    REMOTE_PROXY="$nd:443"; CLIENT_SNI="$nd"; CLIENT_HOST="$nd"
    write_client_profile || warn "Could not refresh default client profile"
  fi
  ok "Domain changed from $olddom to $nd"
}

cert_menu() {
  local c cf
  while true; do
    load_conf
    clear 2>/dev/null || true
    echo -e "${C}== Certificate (Let's Encrypt) ==${N}\n"
    echo "  1) Show status / expiry"; echo "  2) Renew now (if due)"; echo "  3) Change domain"; echo "  0) Back"
    read -r -p "> " c || return 0
    case $c in
      1) cf="/etc/letsencrypt/live/$DOMAIN/fullchain.pem"
         if [ -f "$cf" ]; then openssl x509 -in "$cf" -noout -subject -enddate; else err "No certificate found."; fi
         pause ;;
      2) if certbot renew --cert-name "$DOMAIN"; then systemctl reload nginx >/dev/null 2>&1 || true; fi
         pause ;;
      3) change_domain; pause ;;
      0) return 0 ;;
    esac
  done
}

restart_all() {
  ensure_time_sync >/dev/null 2>&1 || true
  setup_ssh || warn "sshd repair failed"
  setup_bridge || warn "bridge repair failed"
  systemctl restart xray >/dev/null 2>&1 || warn "Xray restart failed"
  systemctl restart aether-ssh-ws >/dev/null 2>&1 || warn "SSH bridge restart failed"
  systemctl restart nginx >/dev/null 2>&1 || warn "Nginx restart failed"
  wait_active xray 15 || true
  wait_active aether-ssh-ws 15 || true
  wait_active nginx 15 || true
  self_test
}

# ----------------------------------------------------------------------------
# UI/status
# ----------------------------------------------------------------------------
hr() { printf '%s%s' "$C" "$1"; printf '═%.0s' $(seq 1 "$BW"); printf '%s%s\n' "$2" "$N"; }
row() {
  local plain=$1 col=${2:-$1} pad
  pad=$((BW-2-${#plain})); [ "$pad" -lt 0 ] && pad=0
  printf '%s║%s %s%*s %s║%s\n' "$C" "$N" "$col" "$pad" "" "$C" "$N"
}
item() { row "[$1]  $2" "${Y}[$1]${N}  $2"; }
svc_state() { systemctl is-active --quiet "$1" && echo on || echo off; }

header() {
  local os tot=0 avail=0 used=0 pct=0 bar="" filled=0 ip t name st col i
  local sp="" sc="" ufw_state title lead tplain
  os=$(. /etc/os-release; echo "${PRETTY_NAME:-Linux}")
  read -r tot avail < <(free -m | awk '/^Mem:/{print $2, $7}') || true
  tot=${tot:-0}; avail=${avail:-0}
  used=$((tot-avail))
  if [ "$tot" -gt 0 ]; then pct=$((used*100/tot)); else pct=0; fi
  filled=$((pct*16/100))
  for ((i=0; i<16; i++)); do
    if [ "$i" -lt "$filled" ]; then bar+="█"; else bar+="░"; fi
  done
  ip=$(cat "$IP_FILE" 2>/dev/null || true)
  if [ -z "$ip" ]; then
    [ -n "$IP_CACHE" ] || IP_CACHE=$(get_ipv4 2>/dev/null || echo unknown)
    ip=$IP_CACHE
  fi
  for name in "Nginx:nginx" "Xray:xray" "SSH-WS:aether-ssh-ws"; do
    st=$(svc_state "${name#*:}"); t=${name%%:*}
    if [ "$st" = on ]; then col=$G; sp+="$t ● ON    "; sc+="$t ${col}● ON${N}    "; else col=$R; sp+="$t ● OFF   "; sc+="$t ${col}● OFF${N}   "; fi
  done
  ufw_state=$(ufw status 2>/dev/null || true)
  if [[ "$ufw_state" == *"Status: active"* ]]; then sp+="UFW ● ON"; sc+="UFW ${G}● ON${N}"; else sp+="UFW ● OFF"; sc+="UFW ${R}● OFF${N}"; fi
  # Title row: the plain text passed to row() must include the same left
  # padding as the coloured one, otherwise the right border drifts.
  title="AETHER WS · SECURE · v$VERSION"
  lead=$(( (BW-2-${#title})/2 )); [ "$lead" -ge 0 ] || lead=0
  tplain=$(printf '%*s%s' "$lead" '' "$title")
  hr "╔" "╗"
  row "$tplain" "$(printf '%*s%s%s%s' "$lead" '' "$W" "$title" "$N")"
  hr "╠" "╣"
  row "SYSTEM : $os"
  row "RAM    : [$bar] ${pct}% ($used / $tot MB)"
  row "IP VPS : $ip"
  row "DOMAIN : ${DOMAIN:-not set}"
  hr "╠" "╣"
  row "SERVICES"
  row "$sp" "$sc"
  hr "╠" "╣"
}

menu_require_installed() {
  is_installed && return 0
  err "Install first."
  pause
  return 1
}

main_menu() {
  need_root; local c
  self_install >/dev/null 2>&1 || true
  while true; do
    load_conf
    clear 2>/dev/null || true
    header
    item 01 "Install / Reinstall Full Stack"
    item 02 "VLESS  WebSocket + TLS"
    item 03 "VMess  WebSocket + TLS"
    item 04 "Trojan WebSocket + TLS"
    item 05 "Shadowsocks WebSocket + TLS"
    item 06 "SSH over WebSocket + TLS"
    item 07 "Show Connection Links"
    item 08 "Certificate (Let's Encrypt)"
    item 09 "Restart Services"
    item 10 "Uninstall Completely"
    item 11 "Update Xray-core (SHA256 verified)"
    item 12 "Run Full Self-Test"
    item 13 "Repair / apply script upgrades"
    item 14 "Client Remote Proxy / SNI / Host"
    item 00 "Exit"
    hr "╚" "╝"
    read -r -p "Select: " c || exit 0
    case $c in
      1|01) do_install ;;
      2|02) menu_require_installed && proto_menu vless-ws "VLESS" ;;
      3|03) menu_require_installed && proto_menu vmess-ws "VMess" ;;
      4|04) menu_require_installed && proto_menu trojan-ws "Trojan" ;;
      5|05) menu_require_installed && ss_menu ;;
      6|06) menu_require_installed && ssh_menu ;;
      7|07) if menu_require_installed; then show_all_links; pause; fi ;;
      8|08) menu_require_installed && cert_menu ;;
      9|09) if menu_require_installed; then restart_all; pause; fi ;;
      10) do_uninstall ;;
      11) do_update_xray; pause ;;
      12) if menu_require_installed; then self_test; pause; fi ;;
      13) if menu_require_installed; then do_repair; pause; fi ;;
      14) if menu_require_installed; then client_profile_menu; pause; fi ;;
      0|00) clear 2>/dev/null || true; exit 0 ;;
    esac
  done
}

usage() {
  echo "AetherWS $VERSION"
  echo "Usage: $0 [menu|install|links|test|repair|profile|uninstall|status|version]"
}

do_status() {
  need_root; load_conf
  echo "AetherWS $VERSION"
  echo "domain : ${DOMAIN:-not set}"
  echo "nginx  : $(svc_state nginx)"
  echo "xray   : $(svc_state xray)"
  echo "ssh-ws : $(svc_state aether-ssh-ws)"
  if [ -n "${WSP_VLESS:-}" ]; then
    echo "paths  : vless=$WSP_VLESS vmess=$WSP_VMESS trojan=$WSP_TROJAN ss=$WSP_SS ssh=$WSP_SSH"
  fi
  if [ -x "$XRAY_BIN" ]; then "$XRAY_BIN" version | head -n1 || true; fi
}

# ----------------------------------------------------------------------------
# The exit status of `links`, `test` and `profile` is the status of the
# command itself ("Not installed." is only printed when it really is not).
case "${1:-}" in
  install) do_install ;;
  links) need_root; load_conf; is_installed || die "Not installed."; show_all_links ;;
  test) need_root; is_installed || die "Not installed."; self_test ;;
  repair) do_repair ;;
  profile) need_root; is_installed || die "Not installed."; client_profile_menu ;;
  status) do_status ;;
  version|-V|--version) echo "AetherWS $VERSION" ;;
  uninstall) do_uninstall ;;
  -h|--help|help) usage ;;
  menu|"") need_root; if is_installed; then main_menu; else do_install; is_installed && main_menu; fi ;;
  *) usage; exit 1 ;;
esac

