Ctrl K
ComunidadGuías…

install.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
#
# W-UI installer.
#
# Brings a bare server to the point where the panel can actually serve tunnels:
# WireGuard, AmneziaWG, OpenVPN, L2TP/IPsec, IKEv2, PPTP, OpenConnect, xray and
# sing-box for the proxy outbounds, nftables, kernel forwarding, a systemd unit
# and the panel itself. Safe to re-run — it upgrades in place rather than starting
# over, and every step checks whether it is already done.
#
#   curl -fsSL https://raw.githubusercontent.com/AbolfazlTafakori/w-ui/main/install.sh | sudo bash
#
# It asks first how to install:
#
#   1) Manual     every choice is asked -- the port, the URL path, who signs
#                 in, the database, the certificate, and which protocols
#   2) Automatic  three questions -- the database, a domain, a certificate for
#                 the address -- and everything else is chosen and set up, every
#                 protocol installed, and shown at the end: the address, the
#                 username and the password
#
# Answer nothing and it uses the defaults below. Every answer can also be given
# as a flag, which is what --yes needs to run unattended.
#
# Flags:
#   --local <path>     install a binary you already built instead of downloading
#   --from-source      build from the repository in the current directory
#   --no-amnezia       skip AmneziaWG (it builds a kernel module and needs headers)
#   --no-openvpn       skip OpenVPN if you only sell WireGuard
#   --no-l2tp          skip L2TP/IPsec (strongSwan, xl2tpd)
#   --no-ikev2         skip IKEv2 (strongSwan and its EAP plugins)
#   --no-pptp          skip PPTP (pppd)
#   --no-openconnect   skip OpenConnect (ocserv)
#   --no-proxy-cores   skip xray and sing-box (the proxy outbounds)
#                      Every protocol installed stays off until a tunnel uses
#                      it, and one skipped is installed by the panel when the
#                      first tunnel of its kind is made.
#   --port <n>         panel port (default: random)
#   --sub-port <n>     subscription service port (default: random)
#   --db <engine>      sqlite (default) or postgres, for a large customer count
#   --username <name>  administrator name (default admin)
#   --password <pass>  administrator password (default: generated)
#   --domain <name>    get a Let's Encrypt certificate for this domain
#   --ip-cert [addr]   get a 6-day Let's Encrypt certificate for this server's
#                      address (auto-detected unless given); renews itself
#   --email <addr>     where the certificate authority sends expiry notices
#   --tls-cert <path>  use a certificate you already have
#   --tls-key <path>   its private key
#   --no-tls           serve plain HTTP (only sane behind a proxy or tunnel)
#   --local-only       bind to 127.0.0.1, for use behind a proxy or a tunnel
#   --auto             the automatic install: three questions, the rest chosen
#   --manual           the manual install: every question, as before
#   -y, --yes          ask nothing; use flags, environment and defaults
#   --uninstall        remove the service, binary and unit; keeps the database
#   --purge            remove everything including the database
#
# errexit and nounset, deliberately without pipefail.
#
# Nearly every pipeline here ends in `head`, `grep -q` or `tail`, which is
# how those tools are meant to be used: they close the pipe as soon as they
# have what they need, and the command upstream dies of SIGPIPE. Under
# pipefail that is reported as a failure of the whole pipeline -- exit 141 --
# and errexit then ends the install without printing anything at all.
#
# That is not a hypothetical. It killed a real install at the first random
# password: `tr -dc … </dev/urandom | head -c 16` exits 141 every single
# time, and the whole run stopped there with an empty log.
#
# Worse, it can give the wrong answer rather than no answer:
# `acme.sh --list | grep` returns 141 when grep matches and finds
# what it wanted, so the check reports the plugin missing when it is there.
#
# Nothing here depends on catching a failure in the middle of a pipeline;
# every pipeline's meaning is its last command.
set -eu

# Whether this host runs systemd. Checked once: a container and a few
# distributions do not, and the installer has to finish on them rather than
# aborting at the first systemctl call with a message about a bus.
have_systemd() {
  [[ -d /run/systemd/system ]] && command -v systemctl >/dev/null 2>&1
}

BIN_PATH=/usr/local/bin/wui
MENU_PATH=/usr/local/bin/w-ui
MENU_URL="${WUI_MENU_URL:-https://raw.githubusercontent.com/AbolfazlTafakori/w-ui/main/w-ui.sh}"
DATA_DIR=/var/lib/wui
CONF_DIR=/etc/wui
UNIT=/etc/systemd/system/wui.service
# Scheduled backups live outside the data directory on purpose: removing
# the panel, or its data, must never remove the copies of it.
BACKUP_DIR=/var/backups/wui
SERVICE_USER=wui
RELEASE_URL="${WUI_RELEASE_URL:-}"
ASSET_URL=""
SUMS_URL=""
# Where to fetch from when nothing else was asked for. Overridable, because a
# fork or a mirror is a normal thing to install from.
REPO="${WUI_REPO:-AbolfazlTafakori/w-ui}"
BRANCH="${WUI_BRANCH:-main}"

# The panel binary's own default, kept only for an older install whose unit
# names no port. A new install never gets it: unless the operator types a
# port, it gets a random free one.
PANEL_PORT=2096
# The subscription service's own port. Random, like the panel's: the panel
# picks it and enables the service, so a customer's link works from the first
# minute without the operator having to find the setting.
SUB_PORT="${WUI_SUB_PORT:-}"
SUB_KNOWN=0
# Set only when the port was read from an install already here: a port
# given on the command line is still a fresh install to switch on.
SUB_EXISTING=0
# Where the data lives. SQLite for most installs; PostgreSQL for one with a
# lot of customers, the way the classic panel offers both.
DB_DRIVER="${WUI_DB_DRIVER:-}"
DB_KNOWN=0
DB_SOURCE=""
DB_PASS=""
WANT_AMNEZIA=1
WANT_OPENVPN=1
WANT_L2TP=1
WANT_IKEV2=1
WANT_PPTP=1
WANT_OPENCONNECT=1
WANT_PROXY=1
LOCAL_BIN=""
FROM_SOURCE=0
ACTION=install

# What the questions fill in. Pre-seeded from the environment so a cloud
# image can set them once and never see a prompt.
ADMIN_USER="${WUI_ADMIN_USER:-}"
ADMIN_PASS="${WUI_ADMIN_PASSWORD:-}"
ADMIN_GENERATED=0
API_TOKEN=""
# Set by configure when a panel is already installed here: this run is an
# update, and leaves what the first install handed out as it was.
IS_UPDATE=0
TLS_MODE=""
TLS_CERT="${WUI_TLS_CERT:-}"
TLS_KEY="${WUI_TLS_KEY:-}"
ACME_DOMAIN="${WUI_DOMAIN:-}"
ACME_IP="${WUI_SERVER_IP:-}"
ACME_IPV6="${WUI_SSL_IPV6:-}"
TLS_DOMAIN=""
TLS_KEPT=0
ACME_EMAIL="${WUI_ACME_EMAIL:-}"
ACME_METHOD=""
# Which address the panel binds to. 127.0.0.1 when something else is
# terminating TLS in front of it, in which case the panel must not be
# reachable from outside at all.
LISTEN_ADDR="${WUI_LISTEN_ADDR:-0.0.0.0}"
# The URL path the panel answers on, one segment, empty for the root.
BASE_PATH="${WUI_BASE_PATH:-}"
# Set when an existing install already decided these, so re-running does not
# move a panel that people have bookmarked.
PORT_KNOWN=0
BASE_KNOWN=0
ASSUME_YES=0
INTERACTIVE=0
# manual or auto; asked first when empty. From the environment so a script
# can pick it the way it picks anything else.
INSTALL_MODE="${WUI_INSTALL_MODE:-}"

# ── output ───────────────────────────────────────────────────────────────────
# Colour only on a terminal, and never under NO_COLOR: a log, a pipe or a
# test reads exactly the same words with none of it.
if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
  R=$'\e[31m'; G=$'\e[32m'; Y=$'\e[33m'; C=$'\e[36m'; M=$'\e[35m'; B=$'\e[1m'; D=$'\e[2m'; U=$'\e[4m'; N=$'\e[0m'
  # The panel's own colour, for its name.
  P=$'\e[38;5;203m'
else
  R=""; G=""; Y=""; C=""; M=""; B=""; D=""; U=""; N=""; P=""
fi

# What each kind of line looks like: a step is a cyan heading, done is
# green, a warning yellow and a failure red -- so the one line that needs
# reading stands out of a long install.
step() { printf '\n%s%s==>%s %s%s%s%s\n' "$B" "$C" "$N" "$B" "$C" "$*" "$N"; }
info() { printf '    %s\n' "$*"; }
ok()   { printf '    %s%s✓%s %s%s%s\n' "$B" "$G" "$N" "$G" "$*" "$N"; }
warn() { printf '    %s%s!%s %s%s%s\n' "$B" "$Y" "$N" "$Y" "$*" "$N"; }
die()  { printf '\n%s%serror:%s %s%s%s\n\n' "$B" "$R" "$N" "$R" "$*" "$N" >&2; exit 1; }

# How a question is laid out: its heading magenta, each choice's number
# green, a note yellow. The same words info would print, only coloured.
qhead() { printf '    %s%s%s%s\n' "$B" "$M" "$*" "$N"; }
opt()   { printf '      %s%s%s)%s %s\n' "$B" "$G" "$1" "$N" "$2"; }
note()  { printf '    %s%s%s\n' "$Y" "$*" "$N"; }

# kv LABEL VALUE [COLOUR] is one line of the summary: the label in cyan, the
# value in green unless another colour is given.
kv() { printf '  %s%-10s%s %s%s%s\n' "$C" "$1" "$N" "${3:-$G}" "$2" "$N"; }
# state VALUE colours what the summary says about a part: missing or off is
# red, not installed yellow, anything else -- a version, on -- green.
state() {
  case "$1" in
    missing|off) printf '%s%s%s' "$R" "$1" "$N" ;;
    "not installed") printf '%s%s%s' "$Y" "$1" "$N" ;;
    *) printf '%s%s%s' "$G" "$1" "$N" ;;
  esac
}

# ── arguments ────────────────────────────────────────────────────────────────
# WUI_LIB_ONLY lets the test suites source this file for its functions without
# installing anything. It is what makes them able to test what actually ships:
# they used to cut the definitions out with sed, and sed on one machine split a
# long comment line in half, so every test after it ran against a file that was
# not this one.
#
# Checked here rather than further down because a sourced file inherits the
# caller's positional parameters -- inside a function, that is the function's
# own arguments, which this loop would then try to parse as install flags.
LIB_ONLY="${WUI_LIB_ONLY:-0}"

while [[ "$LIB_ONLY" != 1 && $# -gt 0 ]]; do
  case "$1" in
    --local)       LOCAL_BIN="${2:?--local needs a path}"; shift 2 ;;
    --from-source) FROM_SOURCE=1; shift ;;
    --no-amnezia)  WANT_AMNEZIA=0; shift ;;
    --no-openvpn)  WANT_OPENVPN=0; shift ;;
    --no-l2tp)     WANT_L2TP=0; shift ;;
    --no-ikev2)    WANT_IKEV2=0; shift ;;
    --no-pptp)     WANT_PPTP=0; shift ;;
    --no-openconnect) WANT_OPENCONNECT=0; shift ;;
    --no-proxy-cores) WANT_PROXY=0; shift ;;
    --port)        PANEL_PORT="${2:?--port needs a number}"; PORT_KNOWN=1; shift 2 ;;
    --sub-port)    SUB_PORT="${2:?--sub-port needs a number}"; SUB_KNOWN=1; shift 2 ;;
    --db)          DB_DRIVER="${2:?--db needs sqlite or postgres}"; DB_KNOWN=1; shift 2 ;;
    --username)    ADMIN_USER="${2:?--username needs a name}"; shift 2 ;;
    --password)    ADMIN_PASS="${2:?--password needs a value}"; shift 2 ;;
    --domain)      ACME_DOMAIN="${2:?--domain needs a name}"; shift 2 ;;
    --ip-cert)     ACME_IP="${2:-}"; [[ -n "$ACME_IP" && "$ACME_IP" != -* ]] && shift; TLS_MODE=ip; shift ;;
    --email)       ACME_EMAIL="${2:?--email needs an address}"; shift 2 ;;
    --tls-cert)    TLS_CERT="${2:?--tls-cert needs a path}"; shift 2 ;;
    --tls-key)     TLS_KEY="${2:?--tls-key needs a path}"; shift 2 ;;
    --path)        BASE_PATH="${2:?--path needs a value}"; BASE_KNOWN=1; shift 2 ;;
    --no-path)     BASE_PATH=""; BASE_KNOWN=1; shift ;;
    --local-only)  LISTEN_ADDR=127.0.0.1; shift ;;
    --no-tls)      TLS_MODE=none; shift ;;
    -y|--yes)      ASSUME_YES=1; shift ;;
    --auto)        INSTALL_MODE=auto; shift ;;
    --manual)      INSTALL_MODE=manual; shift ;;
    --uninstall)   ACTION=uninstall; shift ;;
    --purge)       ACTION=purge; shift ;;
    -h|--help)     awk '/^# Flags:/,/^set -/' "$0" | sed 's/^# \{0,1\}//; /^set -/d'; exit 0 ;;
    *)             die "unknown option: $1" ;;
  esac
done

[[ "$LIB_ONLY" == 1 || $EUID -eq 0 ]] || die "run as root (sudo bash $0)"

# ── platform ─────────────────────────────────────────────────────────────────
detect_os() {
  [[ -r /etc/os-release ]] || die "cannot read /etc/os-release; unsupported system"
  # shellcheck disable=SC1091
  . /etc/os-release
  OS_ID="${ID:-unknown}"
  OS_LIKE="${ID_LIKE:-}"
  OS_NAME="${PRETTY_NAME:-$OS_ID}"
  # shellcheck disable=SC2034 # for the scripts that source this one
  OS_VERSION="${VERSION_ID:-}"
  # The archive codename, which is what a third-party repository publishes
  # under. A release too new for a PPA is the usual reason apt breaks after
  # adding one.
  OS_CODENAME="${VERSION_CODENAME:-${UBUNTU_CODENAME:-}}"

  case "$OS_ID" in
    ubuntu|debian) FAMILY=debian ;;
    fedora|rhel|centos|almalinux|rocky|ol) FAMILY=rhel ;;
    *)
      case "$OS_LIKE" in
        *debian*)          FAMILY=debian ;;
        *rhel*|*fedora*)   FAMILY=rhel ;;
        *)                 die "unsupported distribution: $OS_NAME" ;;
      esac ;;
  esac

  ARCH_RAW="$(uname -m)"
  case "$ARCH_RAW" in
    x86_64|amd64)  ARCH=amd64 ;;
    aarch64|arm64) ARCH=arm64 ;;
    *) die "unsupported architecture: $ARCH_RAW" ;;
  esac
  KERNEL="$(uname -r)"
}

# ── uninstall ────────────────────────────────────────────────────────────────
# One archive of everything the panel holds, in root's home, before anything
# is deleted. Cheap -- a few hundred kilobytes -- and the difference between
# a mistake and a disaster.
last_copy_of_data() {
  [[ -d "$DATA_DIR" ]] || return 0
  local out
  out="/root/wui-last-copy-$(date +%Y%m%d-%H%M%S).tar.gz"
  # The panel's own archive when it can make one -- the database from either
  # engine, restorable into either -- and a plain copy when it cannot.
  local made=""
  if [[ -x "$BIN_PATH" && -r "$CONF_DIR/db.env" ]]; then
    # shellcheck disable=SC1091
    made=$( (set -a; . "$CONF_DIR/db.env"; set +a; WUI_DATA_DIR="$DATA_DIR" "$BIN_PATH" backup create --dir /root 2>/dev/null | tail -1) )
  fi
  if [[ -n "$made" && -f "$made" ]] && mv -f "$made" "$out"; then
    chmod 0600 "$out"
    ok "a copy of the database and keys is at $out"
  elif tar czf "$out" --exclude='*/backups' -C / "${DATA_DIR#/}" "${CONF_DIR#/}" 2>/dev/null; then
    chmod 0600 "$out"
    ok "a copy of the database and keys is at $out"
  else
    warn "could not copy $DATA_DIR before removing it"
    if [[ "$INTERACTIVE" == 1 ]]; then
      ask_yn "Delete it anyway?" n || die "nothing was deleted"
    fi
  fi
}

do_uninstall() {
  step "Removing W-UI"
  if have_systemd && systemctl list-unit-files 2>/dev/null | grep -q '^wui\.service'; then
    systemctl disable --now wui.service >/dev/null 2>&1 || true
    ok "service stopped and disabled"
  fi
  # With the panel stopped, and before the binary goes: the agent is part of
  # it, and is what takes an L2TP tunnel down.
  remove_l2tp_agent
  # What the firewall helper opened is closed, while the binary that knows
  # which rules are its own is still here; the operator's rules stay.
  if have_systemd && [[ -f /etc/systemd/system/wui-firewall.service ]]; then
    systemctl disable --now wui-firewall.path >/dev/null 2>&1 || true
    rm -f "$DATA_DIR/firewall/ports.json"
    [[ -x "$BIN_PATH" ]] && "$BIN_PATH" firewall-sync "$DATA_DIR" >/dev/null 2>&1 || true
    systemctl disable wui-firewall.service >/dev/null 2>&1 || true
    rm -f /etc/systemd/system/wui-firewall.service /etc/systemd/system/wui-firewall.path
    rm -rf /var/lib/wui-firewall
  fi
  have_systemd && systemctl disable --now wui-update.path >/dev/null 2>&1 || true
  have_systemd && systemctl disable wui-amnezia-kmod.service >/dev/null 2>&1 || true
  rm -f "$UNIT" /etc/systemd/system/wui-update.path /etc/systemd/system/wui-update.service \
    /etc/systemd/system/wui-amnezia-kmod.service /usr/local/libexec/wui-amnezia-kmod
  have_systemd && systemctl daemon-reload || true
  rm -f "$BIN_PATH" "$MENU_PATH"
  ok "binary and unit removed"

  if [[ "$ACTION" == purge ]]; then
    # Never the only copy. A purge typed on the wrong server, or a script
    # that reached this with nobody watching, must leave the customers
    # recoverable: the database and keys go to root's home first.
    last_copy_of_data
    rm -rf "$DATA_DIR" "$CONF_DIR"
    id -u "$SERVICE_USER" >/dev/null 2>&1 && userdel "$SERVICE_USER" 2>/dev/null || true
    warn "database and configuration deleted"
  else
    info "database kept at $DATA_DIR — use --purge to delete it too"
  fi
  [[ -d "$BACKUP_DIR" ]] && info "scheduled backups kept at $BACKUP_DIR"

  # The VPN packages are deliberately left alone: they may predate this panel,
  # and removing WireGuard would take down tunnels the operator still needs.
  info "wireguard / openvpn / strongswan / xl2tpd / nftables were left installed"
  printf '\n%sW-UI removed.%s\n\n' "$G" "$N"
  exit 0
}

# ── packages ─────────────────────────────────────────────────────────────────
# Third-party repositories break. When one does, everything this installer
# actually needs is still in the distribution's own archive, so a failed
# refresh is reported and stepped over rather than being fatal.
#
# It is fatal only in the sense that `set -e` would make it so: `apt-get update`
# returns non-zero if any single source failed, including one this installer
# added itself on an earlier run. That is how an install could end at the first
# step with nothing done.
# keep_dkms_from_breaking_apt: on Ubuntu 24.10 and later, a DKMS module that
# does not build for a newly installed kernel fails that kernel's package, and
# dpkg is left half-configured: every apt install after it fails, the panel's
# and the server's own updates alike. The AmneziaWG module does not build for
# every kernel the day it comes out, so it would do that on a customer's
# server at the next unattended upgrade. This file is DKMS's own switch for
# it: a module that fails is reported and the kernel installs. For that
# kernel, wui-amnezia-kmod tries the module again at boot, and the tunnels run
# on amneziawg-go until one builds.
keep_dkms_from_breaking_apt() {
  [[ "$FAMILY" == debian ]] || return 0
  install -d -m 0755 /etc/dkms 2>/dev/null || return 0
  [[ -f /etc/dkms/no-autoinstall-errors ]] && return 0
  printf '%s\n' "# W-UI: a DKMS module that does not build for a new kernel must not leave" \
    "# dpkg broken; the AmneziaWG module is rebuilt at boot by wui-amnezia-kmod." \
    > /etc/dkms/no-autoinstall-errors
}

# drop_failed_amnezia_package takes out the AmneziaWG module package when
# its own build failed. amneziawg-dkms builds the module in its postinst;
# when the build fails the package stays half-configured, and apt then
# refuses every install after it -- OpenVPN, L2TP, IKEv2, PPTP, OpenConnect,
# and the server's own updates. Seen on Ubuntu 22.04 with kernel 5.15.0-46.
# The userspace amneziawg-go carries the tunnels instead and needs none of it;
# amneziawg-tools, configured on its own, stays.
drop_failed_amnezia_package() {
  [[ "$FAMILY" == debian ]] || return 0
  dpkg --audit 2>/dev/null | grep -q amneziawg || return 0
  DEBIAN_FRONTEND=noninteractive dpkg --remove --force-remove-reinstreq \
    amneziawg amneziawg-dkms >/dev/null 2>&1 || true
  DEBIAN_FRONTEND=noninteractive dpkg --configure -a >/dev/null 2>&1 || true
  if dpkg --audit 2>/dev/null | grep -q amneziawg; then
    warn "the AmneziaWG module package is still half-installed; apt may refuse other packages"
    warn "  remove it with: dpkg --remove --force-remove-reinstreq amneziawg amneziawg-dkms"
  else
    ok "removed the AmneziaWG module package that did not build; apt works again"
  fi
}

# repair_dpkg finishes what a failed run left half-configured -- a kernel
# whose DKMS module did not build, most often -- so the installs below can
# work. Nothing is removed but an AmneziaWG module package that cannot build,
# which would otherwise keep apt broken for good.
repair_dpkg() {
  [[ "$FAMILY" == debian ]] || return 0
  [[ -z "$(dpkg --audit 2>/dev/null)" ]] && return 0
  warn "dpkg has packages left half-installed; finishing them"
  if [[ -d /var/lib/dkms/amneziawg ]]; then
    keep_dkms_from_breaking_apt
  fi
  if DEBIAN_FRONTEND=noninteractive dpkg --configure -a >/tmp/wui-dpkg.log 2>&1; then
    ok "dpkg is consistent again"
    return 0
  fi
  drop_failed_amnezia_package
  if [[ -z "$(dpkg --audit 2>/dev/null)" ]]; then
    ok "dpkg is consistent again"
  else
    warn "dpkg could not finish them; see /tmp/wui-dpkg.log"
  fi
}

pkg_refresh() {
  case "$FAMILY" in
    debian)
      if ! DEBIAN_FRONTEND=noninteractive apt-get update -qq 2>/tmp/wui-apt.err; then
        # Name the repositories that failed, so the operator can see it is not
        # the distribution archive that is unreachable.
        local broken
        broken="$(grep -oE "https?://[^ ]+" /tmp/wui-apt.err 2>/dev/null | sort -u | head -3)"
        warn "some package sources could not be refreshed"
        [[ -n "$broken" ]] && printf '%s\n' "$broken" | sed 's/^/      /'
        warn "continuing with what the distribution archive has"
      fi
      rm -f /tmp/wui-apt.err
      ;;
    rhel)   : ;;  # dnf refreshes per transaction
  esac
}

# Remove an AmneziaWG PPA that this installer added and that does not publish
# for this release.
#
# Left in place it breaks every `apt-get update` on the machine from then on,
# including this installer's own -- which is exactly how a second run ends at
# the first step. Only the file this installer writes is touched.
repair_amnezia_ppa() {
  [[ "$FAMILY" == debian ]] || return 0
  local f found=0
  for f in /etc/apt/sources.list.d/amnezia-ubuntu-ppa-*.list \
           /etc/apt/sources.list.d/amnezia-ubuntu-ppa-*.sources; do
    [[ -e "$f" ]] || continue
    if ! ppa_publishes_for_this_release; then
      rm -f "$f"
      found=1
    fi
  done
  if [[ "$found" == 1 ]]; then
    warn "removed the AmneziaWG repository: it publishes nothing for ${OS_CODENAME:-this release}"
    warn "that repository was breaking apt; standard WireGuard is unaffected"
    DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true
  fi
}

# Does the AmneziaWG PPA actually have packages for this release?
#
# `add-apt-repository` succeeds whether or not it does -- it only writes a file
# -- so asking first is the difference between skipping a feature and leaving
# the machine with an apt that no longer works.
ppa_publishes_for_this_release() {
  [[ -n "${OS_CODENAME:-}" ]] || return 1
  # Without curl there is no way to ask, and "cannot ask" must not be read as
  # "no" -- that would have the repair below remove a repository that is
  # working perfectly well. This runs before curl is installed.
  command -v curl >/dev/null 2>&1 || return 0
  curl -fsSI --max-time 10 \
    "https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu/dists/${OS_CODENAME}/Release" \
    >/dev/null 2>&1
}

pkg_install() {
  case "$FAMILY" in
    debian) DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@" >/dev/null ;;
    # --allowerasing: RHEL 9 images ship curl-minimal, which conflicts with
    # the curl package and otherwise stops the whole install at step one.
    rhel)   dnf install -y -q --allowerasing "$@" >/dev/null ;;
  esac
}

have() { command -v "$1" >/dev/null 2>&1; }

install_base() {
  step "Installing base packages"
  # Before anything else: if an earlier run left a repository behind that this
  # release has no packages in, apt is broken and nothing below would work.
  repair_amnezia_ppa
  repair_dpkg
  pkg_refresh

  local base
  case "$FAMILY" in
    debian) base=(curl ca-certificates gnupg tar iproute2 nftables iptables procps) ;;
    rhel)   base=(curl ca-certificates gnupg2 tar iproute nftables iptables procps-ng) ;;
  esac

  pkg_install "${base[@]}" || die "could not install base packages"
  ok "base tools installed"

  # qrencode draws QR codes in the terminal for the menu; the panel draws
  # its own. On RHEL it lives in EPEL, which a fresh machine may not have,
  # so its absence is a note rather than a failed install.
  if ! pkg_install qrencode; then
    if [[ "$FAMILY" == rhel ]] && pkg_install epel-release && pkg_install qrencode; then
      ok "qrencode installed from EPEL"
    else
      warn "qrencode is not available here; QR codes are still made by the panel itself"
    fi
  fi
}

install_wireguard() {
  step "Installing WireGuard"
  case "$FAMILY" in
    debian) pkg_install wireguard wireguard-tools ;;
    rhel)   pkg_install wireguard-tools ;;
  esac

  have wg || die "wireguard-tools did not install; the panel cannot serve WireGuard without it"
  ok "wg $(wg --version 2>/dev/null | awk '{print $2}')"

  # The module is built into the kernel from 5.6 onward. Loading it now surfaces
  # a container or a stripped kernel here, rather than at the first customer.
  if modprobe wireguard 2>/dev/null; then
    ok "wireguard kernel module loaded"
  else
    warn "could not load the wireguard module (kernel $KERNEL)"
    warn "on a VPS this usually means the provider ships a kernel without it"
  fi
}

# AmneziaWG without a kernel module.
#
# The obfuscation lives in a fork of the WireGuard kernel module, and that fork
# is packaged only for the distributions its project has caught up with. On a
# release that is newer than those packages there is no module and no way to
# build one, and the panel would be left offering plain WireGuard on exactly the
# networks where plain WireGuard is what gets blocked.
#
# amneziawg-go speaks the same protocol over a TUN device in userspace. It costs
# some throughput and nothing else: no headers, no DKMS, and no tunnel that dies
# the next time the machine takes a kernel upgrade and reboots.
AMNEZIA_TOOLS_TAG="v3.1.20260812"
# The daemon from the same release as the tools: a version both ends of a
# profile were made for, rather than whatever the branch holds that day.
AMNEZIA_GO_TAG="v3.1.20260812"
GO_BOOTSTRAP_VERSION="1.25.1"

install_amnezia_userspace() {
  warn "falling back to userspace AmneziaWG (amneziawg-go)"

  local tmp
  tmp="$(mktemp -d)" || { AMNEZIA_OK=0; return 0; }

  if ! amnezia_build_tools "$tmp" || ! amnezia_build_go "$tmp"; then
    rm -rf "$tmp"
    AMNEZIA_OK=0
    warn "AmneziaWG is unavailable; the panel will still serve standard WireGuard"
    warn "  obfuscated tunnels will refuse to start and say why"
    return 0
  fi
  rm -rf "$tmp"

  AMNEZIA_OK=1
  ok "awg $(awg --version 2>/dev/null | awk '{print $2}') (userspace)"
  ok "amneziawg-go installed; obfuscated tunnels run without a kernel module"
}

# amnezia_build_tools builds awg and awg-quick, the userspace half that is
# needed whichever way the device itself is provided.
amnezia_build_tools() {
  local tmp="$1"
  have awg && return 0

  case "$FAMILY" in
    debian) pkg_install git make gcc libc6-dev || return 1 ;;
    rhel)   pkg_install git make gcc glibc-devel || return 1 ;;
  esac

  git clone --depth 1 -b "$AMNEZIA_TOOLS_TAG" \
    https://github.com/amnezia-vpn/amneziawg-tools.git "$tmp/tools" >/dev/null 2>&1 || {
    warn "could not fetch amneziawg-tools"
    return 1
  }
  make -C "$tmp/tools/src" -j"$(nproc 2>/dev/null || echo 2)" >/dev/null 2>&1 || {
    warn "amneziawg-tools did not build here"
    return 1
  }
  make -C "$tmp/tools/src" install >/dev/null 2>&1 || return 1
  have awg
}

# amnezia_build_go builds the daemon that provides the device.
#
# The project publishes no binaries, so this needs a Go toolchain. The one in
# the distribution is used when it is new enough, and otherwise an official
# toolchain is unpacked into a temporary directory and thrown away afterwards
# rather than installed onto the machine.
amnezia_build_go() {
  local tmp="$1"
  have amneziawg-go && return 0

  local go=""
  if have go && go_is_recent_enough; then
    go="$(command -v go)"
  else
    go="$(amnezia_fetch_go "$tmp")" || {
      warn "no Go toolchain to build amneziawg-go with"
      return 1
    }
  fi

  # Three tries: GitHub drops a connection now and then from some
  # countries, and one dropped clone is no reason to go without it.
  local try
  for try in 1 2 3; do
    rm -rf "$tmp/go-src"
    git clone --depth 1 -b "$AMNEZIA_GO_TAG" https://github.com/amnezia-vpn/amneziawg-go.git \
      "$tmp/go-src" >"$tmp/clone.log" 2>&1 && break
    if [[ "$try" == 3 ]]; then
      warn "could not fetch amneziawg-go: $(tail -1 "$tmp/clone.log" 2>/dev/null)"
      return 1
    fi
    sleep 5
  done

  ( cd "$tmp/go-src" \
    && GOFLAGS=-mod=mod GOPATH="$tmp/gopath" GOCACHE="$tmp/gocache" CGO_ENABLED=0 \
       "$go" build -trimpath -ldflags "-s -w" -o "$tmp/amneziawg-go" . ) >/dev/null 2>&1 || {
    warn "amneziawg-go did not build here"
    return 1
  }

  install -m755 "$tmp/amneziawg-go" /usr/local/bin/amneziawg-go || return 1
  have amneziawg-go
}

# go_is_recent_enough compares the installed toolchain against what the source
# asks for. A Go too old fails deep inside the build with a message about a
# language feature, which is a bad way to find out.
go_is_recent_enough() {
  local v
  v="$(go env GOVERSION 2>/dev/null | sed 's/^go//')" || return 1
  [[ -n "$v" ]] || return 1
  local major minor
  major="${v%%.*}"
  minor="${v#*.}"
  minor="${minor%%.*}"
  (( major > 1 )) || (( minor >= 25 ))
}

# amnezia_fetch_go unpacks an official toolchain into tmp and prints its path.
#
# From go.dev first, then from mirrors of it: Google's servers answer 404 to
# some countries -- Iran's among them -- where a mirror still serves the same
# file. Whichever it came from, the archive is checked against the checksum
# go.dev publishes for it, so a mirror can only hand over the real toolchain
# or nothing.
amnezia_fetch_go() {
  local tmp="$1" arch sum
  case "$(uname -m)" in
    x86_64)  arch=amd64 ;;
    aarch64) arch=arm64 ;;
    armv7l)  arch=armv6l ;;
    *) return 1 ;;
  esac
  case "$GO_BOOTSTRAP_VERSION/$arch" in
    1.25.1/amd64)  sum=7716a0d940a0f6ae8e1f3b3f4f36299dc53e31b16840dbd171254312c41ca12e ;;
    1.25.1/arm64)  sum=65a3e34fb2126f55b34e1edfc709121660e1be2dee6bdf405fc399a63a95a87d ;;
    1.25.1/armv6l) sum=eb949be683e82a99e9861dafd7057e31ea40b161eae6c4cd18fdc0e8c4ae6225 ;;
    *) return 1 ;;
  esac

  local file="go${GO_BOOTSTRAP_VERSION}.linux-${arch}.tar.gz" url got=0
  for url in "https://go.dev/dl/$file" \
             "https://mirrors.aliyun.com/golang/$file" \
             "https://mirror.nju.edu.cn/golang/$file"; do
    rm -f "$tmp/go.tgz"
    curl -fsSL --max-time 300 "$url" -o "$tmp/go.tgz" 2>/dev/null || continue
    if [[ "$(sha256sum "$tmp/go.tgz" | cut -d' ' -f1)" == "$sum" ]]; then
      got=1
      break
    fi
  done
  [[ "$got" == 1 ]] || return 1
  tar -xzf "$tmp/go.tgz" -C "$tmp" 2>/dev/null || return 1
  [[ -x "$tmp/go/bin/go" ]] || return 1
  printf '%s' "$tmp/go/bin/go"
}

install_amnezia() {
  step "Installing AmneziaWG (anti-DPI)"

  # Building a kernel module needs headers matching the running kernel. Missing
  # them is the usual reason DKMS fails, so it is checked before the attempt.
  case "$FAMILY" in
    debian)
      if ! pkg_install "linux-headers-$KERNEL"; then
        warn "no headers for kernel $KERNEL; the AmneziaWG module cannot be built"
        install_amnezia_userspace
        return 0
      fi
      # Asked before it is added. A PPA that publishes nothing for this
      # release still installs cleanly as a sources file and then breaks every
      # apt operation afterwards, including this installer's own on the next
      # run. Ubuntu releases routinely ship before their PPAs catch up.
      if ! ppa_publishes_for_this_release; then
        warn "AmneziaWG has no packages for ${OS_CODENAME:-this release} yet"
        install_amnezia_userspace
        return 0
      fi

      pkg_install software-properties-common
      if ! add-apt-repository -y ppa:amnezia/ppa >/dev/null 2>&1; then
        warn "could not add ppa:amnezia/ppa"
        install_amnezia_userspace
        return 0
      fi
      pkg_refresh
      keep_dkms_from_breaking_apt
      if ! pkg_install amneziawg; then
        warn "amneziawg failed to build against kernel $KERNEL"
        drop_failed_amnezia_package
        install_amnezia_userspace
        return 0
      fi
      ;;
    rhel)
      pkg_install "kernel-devel-$KERNEL" dnf-plugins-core || true
      dnf copr enable -y amneziavpn/amneziawg >/dev/null 2>&1 || {
        warn "could not enable the AmneziaWG COPR"
        install_amnezia_userspace
        return 0
      }
      pkg_install amneziawg-dkms amneziawg-tools || {
        warn "amneziawg failed to build against kernel $KERNEL"
        install_amnezia_userspace
        return 0
      }
      ;;
  esac

  if have awg; then
    AMNEZIA_OK=1
    ok "awg $(awg --version 2>/dev/null | awk '{print $2}')"
    if [[ -r /sys/module/amneziawg/version ]] ||
       { modprobe amneziawg 2>/dev/null && [[ -r /sys/module/amneziawg/version ]]; }; then
      ok "kernel module $(cat /sys/module/amneziawg/version)"
      amnezia_future_kernels
    else
      # The tools are here but nothing can make the device. Rather than leave
      # that to be discovered when the first obfuscated tunnel refuses to start,
      # the userspace daemon is installed now.
      warn "the amneziawg module is not available on this kernel"
      install_amnezia_userspace
      return 0
    fi
  else
    warn "awg not found after install"
    install_amnezia_userspace
  fi
}

# amnezia_future_kernels keeps the AmneziaWG module working across kernel
# upgrades. The module is built by DKMS for each kernel, and DKMS can only
# build it where that kernel's headers are installed: the headers of the
# running kernel alone, which is all a VPS has by default, leave the next
# kernel without a module and every obfuscated tunnel down after the reboot
# that installs it. So:
#
#   - the headers' meta-package follows the kernel's, so each new kernel
#     arrives with its headers and DKMS builds the module as it installs;
#   - wui-amnezia-kmod builds it at boot when that did not happen (a kernel
#     installed before this, or a headers package that came late);
#   - and amneziawg-go is there besides, so a kernel no module builds for
#     still carries the tunnels, in userspace, rather than none.
amnezia_future_kernels() {
  local meta=""
  case "$FAMILY" in
    debian)
      # 6.8.0-45-generic -> generic, 6.1.0-25-cloud-amd64 -> cloud-amd64:
      # the flavour the kernel's own meta-package is named after.
      meta="linux-headers-${KERNEL#*-*-}"
      [[ "$meta" == "linux-headers-$KERNEL" ]] && meta=""
      ;;
    rhel) meta="kernel-devel" ;;
  esac
  if [[ -n "$meta" ]]; then
    if pkg_install "$meta"; then
      ok "$meta: each new kernel gets its headers, and the module is rebuilt for it"
    else
      warn "no $meta package; the module is rebuilt at boot for a new kernel instead"
    fi
  fi
  write_amnezia_kmod
  if ! have amneziawg-go; then
    local tmp
    tmp="$(mktemp -d)" || return 0
    if amnezia_build_go "$tmp"; then
      ok "amneziawg-go installed besides, for a kernel the module does not build on"
    else
      warn "amneziawg-go did not build; a kernel the module cannot build on would leave obfuscated tunnels down"
    fi
    rm -rf "$tmp"
  fi
}

# write_amnezia_kmod installs the boot-time check: before the panel starts,
# the module is loaded, or built for the running kernel and then loaded. It
# never fails the boot -- the panel falls back to amneziawg-go on its own --
# and it leaves the reason in /var/log/wui-amnezia-kmod.log.
write_amnezia_kmod() {
  have_systemd || return 0
  install -d -m 0755 /usr/local/libexec
  cat >/usr/local/libexec/wui-amnezia-kmod <<'KMOD'
#!/usr/bin/env bash
# W-UI: the AmneziaWG module for the running kernel, built if it has to be.
k="$(uname -r)"
log=/var/log/wui-amnezia-kmod.log
modprobe amneziawg 2>/dev/null && exit 0
command -v dkms >/dev/null 2>&1 || exit 0
{
  echo "== $(date -Is) kernel $k: no amneziawg module; building it"
  if [[ ! -d "/lib/modules/$k/build" ]]; then
    if command -v apt-get >/dev/null 2>&1; then
      DEBIAN_FRONTEND=noninteractive timeout 300 apt-get install -y -qq "linux-headers-$k"
    elif command -v dnf >/dev/null 2>&1; then
      timeout 300 dnf install -y -q "kernel-devel-$k"
    fi
  fi
  timeout 600 dkms autoinstall -k "$k"
  depmod -a "$k"
  if modprobe amneziawg; then
    echo "module loaded"
  else
    echo "still no module; obfuscated tunnels run on amneziawg-go"
  fi
} >>"$log" 2>&1
exit 0
KMOD
  chmod 0755 /usr/local/libexec/wui-amnezia-kmod
  cat >/etc/systemd/system/wui-amnezia-kmod.service <<UNIT
[Unit]
Description=W-UI — the AmneziaWG kernel module for the running kernel
After=network-online.target
Wants=network-online.target
Before=wui.service

[Service]
Type=oneshot
ExecStart=/usr/local/libexec/wui-amnezia-kmod
TimeoutStartSec=900

[Install]
WantedBy=multi-user.target
UNIT
  systemctl daemon-reload
  systemctl enable wui-amnezia-kmod.service >/dev/null 2>&1 || true
  ok "wui-amnezia-kmod (rebuilds the module at boot after a kernel upgrade)"
}

install_openvpn() {
  step "Installing OpenVPN"
  pkg_install openvpn easy-rsa || {
    warn "openvpn failed to install; the panel will only offer WireGuard"
    OPENVPN_OK=0
    return 0
  }

  # OpenVPN needs a tun device to exist before it will start. It is present on
  # almost every host and absent on almost every minimal container, and the
  # failure without it is a server that exits at boot with a message about a
  # file rather than about networking.
  modprobe tun 2>/dev/null || true
  if [[ -c /dev/net/tun ]]; then
    ok "/dev/net/tun present"
  else
    warn "/dev/net/tun is missing; OpenVPN interfaces will not start"
    warn "  load it with: modprobe tun   (and add 'tun' to /etc/modules-load.d/)"
  fi
  # A minimal image may not have the directory at all.
  install -d /etc/modules-load.d 2>/dev/null || true
  echo tun > /etc/modules-load.d/wui-tun.conf 2>/dev/null || true

  if have openvpn; then
    OPENVPN_OK=1
    ok "$(openvpn --version 2>/dev/null | head -1 | cut -d' ' -f1-2)"
  else
    OPENVPN_OK=0
    warn "openvpn not on PATH after install"
  fi
}

# ── L2TP/IPsec ───────────────────────────────────────────────────────────────
# strongSwan, xl2tpd and pppd, installed and left off.
#
# Nothing of it runs until an L2TP tunnel is made in the panel: the agent
# starts it then and stops it when the tunnel goes. What is already on the
# machine is not touched. A package that would have to be removed for these
# -- libreswan, for another VPN -- stops this step instead, and a service
# that was enabled or running before is the operator's and stays as it was.
L2TP_UNITS=(xl2tpd.service strongswan.service strongswan-starter.service strongswan-swanctl.service ipsec.service)

l2tp_packages() {
  case "$FAMILY" in
    debian)
      DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-remove \
        strongswan-swanctl charon-systemd xl2tpd ppp >/dev/null 2>&1 ;;
    rhel)
      # From EPEL on the RHEL family; Fedora carries them itself.
      [[ "$OS_ID" == fedora ]] || dnf install -y -q epel-release >/dev/null 2>&1 || true
      # Without --allowerasing: nothing already here is removed for these.
      dnf install -y -q strongswan xl2tpd ppp >/dev/null 2>&1 ;;
  esac
}

# The proxy outbounds -- VLESS, VMess, Trojan, Shadowsocks, Hysteria, SOCKS,
# HTTP -- are run by xray, joined to the routing by sing-box. Both are
# installed here, at versions this release was tested with, from their own
# GitHub releases (reachable where Google's servers are not), and each
# download is checked against its published SHA-256 before it is installed.
# A server that has them at these versions already is left as it is.
XRAY_VERSION="v26.3.27"
SINGBOX_VERSION="1.14.1"

install_proxy_cores() {
  step "Installing xray and sing-box (for proxy outbounds)"
  local xa sa xsum ssum tmp
  case "$ARCH" in
    amd64)
      xa=Xray-linux-64.zip
      xsum=23cd9af937744d97776ee35ecad4972cf4b2109d1e0fe6be9930467608f7c8ae
      sa="sing-box-${SINGBOX_VERSION}-linux-amd64"
      ssum=12cb2816b52febb356f6a885b740cc8758c3f30b8ae0ca8edba80f0d2d35343f ;;
    arm64)
      xa=Xray-linux-arm64-v8a.zip
      xsum=4d30283ae614e3057f730f67cd088a42be6fdf91f8639d82cb69e48cde80413c
      sa="sing-box-${SINGBOX_VERSION}-linux-arm64"
      ssum=6060b42fa84c5dcaeae1799af7f61b0f1ae4855d9d5ddc9e02baba17154b3ae2 ;;
    *) warn "no xray or sing-box for $ARCH; proxy outbounds will not come up"; return 0 ;;
  esac
  tmp="$(mktemp -d)" || return 0

  if have xray && xray version 2>/dev/null | head -1 | grep -q "Xray ${XRAY_VERSION#v} "; then
    ok "xray ${XRAY_VERSION} already here"
  elif fetch_checked "https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}/${xa}" "$xsum" "$tmp/xray.zip" &&
       unpack_zip_file "$tmp/xray.zip" xray "$tmp/xray" &&
       install -m 0755 "$tmp/xray" /usr/local/bin/xray; then
    ok "xray ${XRAY_VERSION}"
  else
    warn "xray could not be installed; proxy outbounds will not come up until it is"
  fi

  if have sing-box && sing-box version 2>/dev/null | head -1 | grep -q "version ${SINGBOX_VERSION}$"; then
    ok "sing-box ${SINGBOX_VERSION} already here"
  elif fetch_checked "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/${sa}.tar.gz" "$ssum" "$tmp/sb.tgz" &&
       tar -xzf "$tmp/sb.tgz" -C "$tmp" "${sa}/sing-box" &&
       install -m 0755 "$tmp/${sa}/sing-box" /usr/local/bin/sing-box; then
    ok "sing-box ${SINGBOX_VERSION}"
  else
    warn "sing-box could not be installed; proxy outbounds will not come up until it is"
  fi
  rm -rf "$tmp"
}

# fetch_checked downloads a file and keeps it only if its SHA-256 is the one
# given. Three tries: a dropped connection is no reason to go without it.
fetch_checked() {
  local url="$1" sum="$2" out="$3" try
  for try in 1 2 3; do
    if curl -fsSL --max-time 300 "$url" -o "$out" 2>/dev/null &&
       [[ "$(sha256sum "$out" | cut -d' ' -f1)" == "$sum" ]]; then
      return 0
    fi
    rm -f "$out"
    [[ "$try" == 3 ]] || sleep 5
  done
  return 1
}

# unpack_zip_file takes one file out of a zip, with unzip where there is one
# and python3's zipfile where there is not.
unpack_zip_file() {
  local zip="$1" name="$2" out="$3"
  if have unzip; then
    unzip -p "$zip" "$name" > "$out" 2>/dev/null && [[ -s "$out" ]] && return 0
  fi
  have python3 && python3 - "$zip" "$name" "$out" <<'PYZ'
import sys, zipfile
with zipfile.ZipFile(sys.argv[1]) as z, open(sys.argv[3], "wb") as f:
    f.write(z.read(sys.argv[2]))
PYZ
}

# ── IKEv2, PPTP and OpenConnect ──────────────────────────────────────────────
# Installed the way L2TP is, and left off the same way: the panel's agent
# runs each from its own files while a tunnel of that kind exists. The same
# packages the agent would install the first time one is made, so a tunnel
# made later starts at once rather than after an install.
#
# install_parked LABEL UNITS DEBIAN-PACKAGES RHEL-PACKAGES BINARIES
#
# Each list is one space-separated word. What is already enabled or running
# here is the operator's and is left as it was; what the packages bring
# with them is stopped and disabled, so nothing holds a port the agent's own
# will need. A package that cannot be installed is said, not fatal: the
# panel tries again when the first tunnel of its kind is made.
install_parked() {
  local label="$1" units="$2" deb="$3" rpm="$4" bins="$5" u b theirs=" " installed=1
  step "Installing $label (off until a tunnel uses it)"
  if have_systemd; then
    for u in $units; do
      if systemctl is-enabled --quiet "$u" 2>/dev/null || systemctl is-active --quiet "$u" 2>/dev/null; then
        theirs="$theirs$u "
      fi
    done
  fi
  # shellcheck disable=SC2086
  parked_packages() {
    case "$FAMILY" in
      debian) DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-remove $deb >/dev/null 2>&1 ;;
      rhel)
        [[ "$OS_ID" == fedora ]] || dnf install -y -q epel-release >/dev/null 2>&1 || true
        dnf install -y -q $rpm >/dev/null 2>&1 ;;
      *) return 1 ;;
    esac
  }
  if ! parked_packages; then
    if [[ "$FAMILY" == debian ]]; then
      pkg_refresh
      parked_packages || installed=0
    else
      installed=0
    fi
  fi
  if have_systemd; then
    for u in $units; do
      [[ "$theirs" == *" $u "* ]] && continue
      systemctl disable --now "$u" >/dev/null 2>&1 || true
      systemctl stop "$u" >/dev/null 2>&1 || true
    done
  fi
  for b in $bins; do have "$b" || installed=0; done
  if [[ "$installed" == 1 ]]; then
    ok "$label: installed, off until a tunnel uses it"
  else
    warn "$label could not be installed now; the panel installs it when the first such tunnel is made"
  fi
  [[ "$theirs" == " " ]] || info "already in use here, left as it was:$theirs"
}

install_ikev2() {
  install_parked "IKEv2" \
    "strongswan.service strongswan-starter.service strongswan-swanctl.service ipsec.service" \
    "strongswan-swanctl charon-systemd libcharon-extauth-plugins libcharon-extra-plugins libstrongswan-standard-plugins" \
    "strongswan" \
    "swanctl"
}

# The PPTP server is the panel's own; pppd is all it needs of the system.
install_pptp() {
  install_parked "PPTP" "pptpd.service" "ppp" "ppp" "pppd"
}

# ocserv's package starts its own on 443 (Debian by socket activation); the
# agent runs ocserv from its own files instead. netbase carries the "radius"
# service name ocserv's sign-in looks up, which a minimal image lacks.
install_openconnect() {
  install_parked "OpenConnect" "ocserv.socket ocserv.service" "ocserv netbase" "ocserv" "ocserv occtl"
}

install_l2tp() {
  step "Installing L2TP/IPsec (off until a tunnel uses it)"
  local u theirs=" " installed=1
  if have_systemd; then
    for u in "${L2TP_UNITS[@]}"; do
      if systemctl is-enabled --quiet "$u" 2>/dev/null || systemctl is-active --quiet "$u" 2>/dev/null; then
        theirs="$theirs$u "
      fi
    done
  fi
  if ! l2tp_packages; then
    # A stale package list is the usual reason: one refresh, one more try.
    if [[ "$FAMILY" == debian ]]; then
      pkg_refresh
      l2tp_packages || installed=0
    else
      installed=0
    fi
  fi
  if have_systemd; then
    for u in "${L2TP_UNITS[@]}"; do
      [[ "$theirs" == *" $u "* ]] && continue
      # And stopped on its own: a service from an init script -- Ubuntu's
      # xl2tpd -- is disabled by --now but left running, on L2TP's port.
      systemctl disable --now "$u" >/dev/null 2>&1 || true
      systemctl stop "$u" >/dev/null 2>&1 || true
    done
  fi
  if [[ "$installed" == 1 ]] && have xl2tpd && have pppd && have swanctl; then
    ok "strongSwan, xl2tpd and pppd: installed, off until an L2TP tunnel is made"
  else
    warn "L2TP/IPsec could not be installed now; the panel installs it when the first L2TP tunnel is made"
  fi
  [[ "$theirs" == " " ]] || info "already in use here, left as it was:$theirs"
  l2tp_ports_note
}

# udp_owner prints what holds a UDP port, or nothing when it is free.
udp_owner() {
  have ss || return 0
  local line who
  line=$(ss -lunpH "sport = :$1" 2>/dev/null | head -1)
  [[ -n "$line" ]] || return 0
  who=$(sed -n 's/.*users:(("\([^"]*\)".*/\1/p' <<<"$line")
  printf '%s' "${who:-another service}"
}

# Who already holds L2TP's ports, said now rather than found when the first
# tunnel will not start. strongSwan is not in the way -- the tunnel is added
# to the one already running -- and on an update the agent's own are not.
l2tp_ports_note() {
  if have_systemd && systemctl is-active --quiet wui-l2tp.service; then return 0; fi
  local p who
  for p in 500 4500 1701; do
    who=$(udp_owner "$p")
    [[ -z "$who" || "$who" == charon* ]] && continue
    warn "UDP $p is held by $who; an L2TP tunnel will not start here until it is free"
  done
}

# The L2TP agent, the root half of an L2TP tunnel.
#
# The panel cannot run pppd -- it is unprivileged and sandboxed -- so it
# describes the tunnel in $DATA_DIR/l2tp/state.json, and this path unit runs
# the agent, as root, for as long as that description exists. With no L2TP
# tunnel there is none, and nothing runs. A panel updated from its own button
# writes the same units through the update helper; a test holds the two to
# the same text.
# The firewall helper: with ufw or firewalld on, a tunnel's port is dropped
# there whatever the panel's own tables say, and the tunnel reaches nobody.
# The panel writes the ports its tunnels need to $DATA_DIR/firewall/ports.json;
# this runs, as root, on every change of it and lets them through -- and
# closes only what it opened itself. A test holds these units to the panel's.
write_firewall_helper() {
  have_systemd || return 0
  cat >/etc/systemd/system/wui-firewall.service <<HELPER
[Unit]
Description=W-UI — let the tunnels' ports through ufw or firewalld
After=network-online.target ufw.service firewalld.service

[Service]
Type=oneshot
ExecStart=$BIN_PATH firewall-sync $DATA_DIR

[Install]
WantedBy=multi-user.target
HELPER
  cat >/etc/systemd/system/wui-firewall.path <<HELPER
[Unit]
Description=W-UI — let the tunnels' ports through the firewall whenever they change

[Path]
PathChanged=$DATA_DIR/firewall/ports.json
Unit=wui-firewall.service

[Install]
WantedBy=multi-user.target
HELPER
  systemctl daemon-reload
  systemctl enable --now wui-firewall.path >/dev/null 2>&1 || { warn "could not enable wui-firewall.path; open the tunnels' ports in the firewall yourself"; return 0; }
  systemctl enable wui-firewall.service >/dev/null 2>&1 || true
  systemctl start --no-block wui-firewall.service >/dev/null 2>&1 || true
}

write_l2tp_agent() {
  have_systemd || return 0
  write_firewall_helper
  cat >/etc/systemd/system/wui-l2tp.service <<HELPER
[Unit]
Description=W-UI — PPP agent: runs the L2TP and PPTP tunnels the panel describes
After=network-online.target
Wants=network-online.target
# Started by wui-l2tp.path while the panel describes a tunnel. An agent that
# fails is tried again for as long as it fails, not given up on after five.
StartLimitIntervalSec=0

[Service]
Type=simple
ExecStart=$BIN_PATH l2tp-agent $DATA_DIR
# Only the agent is stopped, never xl2tpd, pptpd and the sessions they carry: a
# restart, an update's included, must not disconnect anyone. The next run
# adopts them.
KillMode=process
Restart=on-failure
RestartSec=5
HELPER
  cat >/etc/systemd/system/wui-l2tp.path <<HELPER
[Unit]
Description=W-UI — start the PPP agent while the panel describes an L2TP, PPTP, IKEv2 or OpenConnect tunnel

[Path]
PathExists=$DATA_DIR/l2tp/state.json
PathExists=$DATA_DIR/pptp/state.json
PathExists=$DATA_DIR/ikev2/state.json
PathExists=$DATA_DIR/openconnect/state.json
Unit=wui-l2tp.service

[Install]
WantedBy=multi-user.target
HELPER
  systemctl daemon-reload
  systemctl enable --now wui-l2tp.path >/dev/null 2>&1 || { warn "could not enable wui-l2tp.path; L2TP tunnels will not start"; return 0; }
  # An agent already running is on the binary just replaced: restarted onto
  # the new one, its sessions staying up.
  systemctl try-restart wui-l2tp.service >/dev/null 2>&1 || true
  ok "wui-l2tp.path (L2TP/IPsec; idle until a tunnel uses it)"
}

# Takes an L2TP tunnel down and removes its agent: for an uninstall, and for
# a move back to a release from before L2TP, which has no agent to carry it.
#
# The agent does the taking down itself, while the binary it is part of is
# still here: with the tunnel's description gone it ends every session, stops
# xl2tpd, unloads IPsec, takes its firewall table away and exits. The panel is
# stopped before this, or it would describe the tunnel again.
remove_l2tp_agent() {
  [[ -f /etc/systemd/system/wui-l2tp.service || -f "$DATA_DIR/l2tp/state.json" ]] || return 0
  if have_systemd; then
    systemctl disable --now wui-l2tp.path >/dev/null 2>&1 || true
    if [[ -f "$DATA_DIR/l2tp/state.json" ]]; then
      rm -f "$DATA_DIR/l2tp/state.json"
      systemctl start wui-l2tp.service >/dev/null 2>&1 || true
      for _ in $(seq 1 30); do
        systemctl is-active --quiet wui-l2tp.service || break
        sleep 1
      done
    fi
    systemctl stop wui-l2tp.service >/dev/null 2>&1 || true
  else
    rm -f "$DATA_DIR/l2tp/state.json"
  fi
  rm -f /etc/systemd/system/wui-l2tp.path /etc/systemd/system/wui-l2tp.service
  if have_systemd; then systemctl daemon-reload || true; fi
  ok "L2TP/IPsec taken down; its agent removed"
}

# release_before_l2tp reports whether a release is from before L2TP, which
# came in 3.0. dev-latest, or anything not a version, is taken as the newest.
release_before_l2tp() {
  local t="${1#v}" major
  major="${t%%.*}"
  [[ "$major" =~ ^[0-9]+$ ]] || return 1
  (( major < 3 ))
}

# binary_before_3 reports whether the panel binary just installed is from
# before 3.0, by asking it. Every release answers `version`; a build from
# source answers "dev", which is the newest.
#
# What 3.0 adds beside the panel -- the firewall helper, the L2TP agent --
# runs that binary with a subcommand an older one does not have, and an
# older one given a subcommand it does not know starts the whole panel
# instead: a second panel, as root, on the same tables. So none of it is
# written for an older binary: one installed with --local, or the release
# this script fetched while it was newer than that release.
binary_before_3() {
  local v
  v="$("$BIN_PATH" version 2>/dev/null | head -1)"
  [[ -n "$v" ]] || return 1
  release_before_l2tp "$v"
}

# remove_firewall_helper takes 3.0's firewall helper away, for a release from
# before it: its unit runs `wui firewall-sync`, which an older binary does
# not know, and starts the whole panel instead. The ports it opened stay
# open; the tunnels on them are still there.
remove_firewall_helper() {
  [[ -f /etc/systemd/system/wui-firewall.service || -f /etc/systemd/system/wui-firewall.path ]] || return 0
  if have_systemd; then
    systemctl disable --now wui-firewall.path >/dev/null 2>&1 || true
    systemctl disable --now wui-firewall.service >/dev/null 2>&1 || true
  fi
  rm -f /etc/systemd/system/wui-firewall.path /etc/systemd/system/wui-firewall.service
  if have_systemd; then systemctl daemon-reload || true; fi
  ok "the firewall helper removed; the release being installed has none"
}

enable_forwarding() {
  step "Enabling kernel forwarding"

  # Written to a file rather than only applied, or the setting is lost on the
  # first reboot and every tunnel stops routing with no obvious cause.
  install -d /etc/sysctl.d
  cat >/etc/sysctl.d/99-wui.conf <<'SYSCTL'
# Installed by W-UI. Without forwarding, packets arriving on a tunnel are
# never routed out to the internet and customers connect but reach nothing.
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
SYSCTL

  # sysctl comes from procps, which a minimal image may lack even after
  # the base packages; /proc/sys is the same switch without it.
  if have sysctl; then
    sysctl --system >/dev/null 2>&1 || sysctl -p /etc/sysctl.d/99-wui.conf >/dev/null 2>&1 || true
  else
    echo 1 > /proc/sys/net/ipv4/ip_forward 2>/dev/null || true
    echo 1 > /proc/sys/net/ipv6/conf/all/forwarding 2>/dev/null || true
  fi
  local v4 v6
  v4=$(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null || echo 0)
  v6=$(cat /proc/sys/net/ipv6/conf/all/forwarding 2>/dev/null || echo 0)
  [[ "$v4" == 1 ]] && ok "IPv4 forwarding on" || warn "IPv4 forwarding is still off"
  [[ "$v6" == 1 ]] && ok "IPv6 forwarding on" || warn "IPv6 forwarding is still off"
}

check_nftables() {
  step "Checking the enforcement engine"
  have nft || die "nft missing; quota enforcement cannot run without nftables"
  ok "nft $(nft --version 2>/dev/null | awk '{print $2}')"

  # Proving the ruleset is writable now beats discovering it when the first
  # customer's limit silently fails to apply.
  if nft list tables >/dev/null 2>&1; then
    ok "kernel ruleset is readable"
  else
    warn "cannot read the nftables ruleset; the panel needs CAP_NET_ADMIN"
  fi

  # Everything the panel promises about data limits rests on this one module.
  # Without it the kernel cannot stop a transfer at a byte boundary, so limits
  # fall back to polling and a customer on a fast link overshoots their quota by
  # whatever they can pull between two collections. That is a silent failure —
  # the panel still shows a limit, it just does not hold — so it is worth being
  # noisy about at install time rather than at the end of the month.
  modprobe nft_quota 2>/dev/null || true
  if nft add table inet wui_probe 2>/dev/null &&
     nft add quota inet wui_probe q '{ over 1000 bytes }' 2>/dev/null; then
    ok "kernel supports quota objects; data limits are enforced exactly"
  else
    warn "this kernel has no nft_quota support"
    warn "  data limits will be enforced by polling instead, which means a"
    warn "  customer can overshoot their quota before the panel notices."
    warn "  Hetzner's stock Ubuntu and Debian kernels do support it; custom,"
    warn "  minimal or container kernels often do not."
  fi
  nft delete table inet wui_probe 2>/dev/null || true

  # Speed limits need a classful scheduler. Without one tc accepts the command
  # and the kernel quietly ignores it, so the panel would show a limit that no
  # packet ever meets.
  modprobe sch_htb 2>/dev/null || true
  if ip link add wui-htbprobe type dummy 2>/dev/null; then
    if tc qdisc replace dev wui-htbprobe root handle 1: htb default ffff 2>/dev/null; then
      ok "kernel supports HTB; speed limits are enforced"
    else
      warn "this kernel has no HTB scheduler"
      warn "  per-customer speed limits will be recorded but never applied."
    fi
    ip link del wui-htbprobe 2>/dev/null || true
  fi
}

# ── the panel ────────────────────────────────────────────────────────────────
# The download for this machine's architecture in the newest release, if the
# project has published one.
#
# Asked over the API rather than guessed at a URL, so a release whose assets
# are named differently still resolves, and a repository with no releases at
# all answers cleanly instead of 404-ing on a made-up path.
release_asset_url() {
  local api json
  api="https://api.github.com/repos/${REPO}/releases/latest"
  json="$(curl -fsSL --max-time 20 "$api" 2>/dev/null)" || return 1
  [[ -n "$json" ]] || return 1

  local urls
  urls="$(printf '%s' "$json"     | grep -o '"browser_download_url": *"[^"]*"'     | sed 's/.*"browser_download_url": *"//; s/"$//')"
  ASSET_URL="$(grep -E "linux[-_]${ARCH}$" <<<"$urls" | head -1)"
  SUMS_URL="$(grep -E '/SHA256SUMS$' <<<"$urls" | head -1)"
  [[ -n "$ASSET_URL" ]]
}

# The release carries a checksum list; a download that does not match it is
# not installed. A truncated transfer or a tampered mirror would otherwise
# become the panel.
verify_download() {
  local file="$1" name="$2"
  [[ -n "${SUMS_URL:-}" ]] || { warn "the release publishes no SHA256SUMS; installing unverified"; return 0; }
  local sums want got
  sums="$(curl -fsSL --max-time 20 "$SUMS_URL" 2>/dev/null)" || { warn "could not fetch SHA256SUMS; installing unverified"; return 0; }
  want="$(awk -v n="$name" '$2 == n || $2 == "*" n {print $1}' <<<"$sums" | head -1)"
  [[ -n "$want" ]] || { warn "SHA256SUMS has no entry for $name; installing unverified"; return 0; }
  got="$(sha256sum "$file" | awk '{print $1}')"
  [[ "$got" == "$want" ]] || die "checksum mismatch for $name: the download is not the file that was released"
  ok "checksum verified"
}

# Build the panel from the project's own source, without needing the repository
# to have been cloned first.
#
# `--from-source` used to require a checkout -- it looked for go.mod in the
# working directory -- which the one-line install can never have. It fetches a
# tarball now, and installs Go if the machine has none, because "install Go and
# come back" is not an answer an installer should be giving.
build_from_tarball() {
  local work
  work="$(mktemp -d)"
  # shellcheck disable=SC2064
  trap "rm -rf '$work'" RETURN

  if ! have go; then
    info "installing Go to build with"
    pkg_install golang-go || pkg_install golang || die "could not install Go; use --local <path> with a prebuilt binary"
  fi
  have go || die "Go is still not on PATH after installing it"

  info "fetching source"
  curl -fsSL --max-time 120 \
    "https://codeload.github.com/${REPO}/tar.gz/refs/heads/${BRANCH}" \
    -o "$work/src.tgz" || die "could not fetch the source"
  tar -xzf "$work/src.tgz" -C "$work" || die "could not unpack the source"

  local root
  root="$(find "$work" -maxdepth 1 -type d -name 'w-ui-*' | head -1)"
  [[ -n "$root" && -f "$root/go.mod" ]] || die "the source archive does not look like this project"

  info "building (this takes a minute)…"
  ( cd "$root" && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags "-s -w" -o "$BIN_PATH" ./cmd/wui ) \
    || die "build failed"
  chmod 0755 "$BIN_PATH"
  ok "built from source"
}

# ── asking ───────────────────────────────────────────────────────────────────
# An installer that asks nothing has already decided everything, and some of
# those decisions are wrong for every operator: the port collides with what is
# already on the box, the credentials are a random string nobody chose, and the
# sign-in form that carries an administrator's password runs over plain HTTP.
#
# Everything is asked once, up front, before a single package is fetched — so
# the operator answers for a minute and can then walk away, rather than being
# called back to a prompt after the long part has finished.

# A terminal to talk to, whatever stdin happens to be.
#
# `curl … | bash` gives the shell the script itself on stdin, so a plain `read`
# there swallows the next line of the script instead of the operator's answer.
# /dev/tty is the terminal this process is attached to no matter what stdin was
# redirected to, which is what lets the documented one-liner ask anything at
# all. When there is no terminal — cloud-init, CI, a pipe from a file — every
# question falls back to its flag, its environment variable, or its default.
open_tty() {
  INTERACTIVE=0
  [[ "$ASSUME_YES" == 1 ]] && return 0
  if [[ -e /dev/tty ]] && exec 3<>/dev/tty 2>/dev/null; then
    INTERACTIVE=1
  fi
  return 0
}

# The terminal went away mid-question — Ctrl-D, a closed session, a pipe that
# ran out. Every question that validates its answer sits in a loop, and a loop
# that keeps re-asking a terminal which will never answer again spins forever
# on a machine nobody is watching. It stops here instead.
no_more_input() {
  printf %s "$N" >&3 2>/dev/null || true
  die "no answer — the terminal closed before the questions were finished"
}

# Prompt text, written to the terminal rather than to stdout.
#
# Two reasons it is not a plain printf. An install whose output is being
# captured to a file should still ask its questions on the screen, which is
# what fd 3 is for; and a write that fails must not end the install. Under
# errexit an unchecked `printf … >&3` onto a descriptor that is not writable
# aborts the whole run with nothing but "Bad file descriptor" to show for it.
tty_out() { printf "$@" >&3 2>/dev/null || true; }

# ask VAR "question" "default"
ask() {
  local __var="$1" __q="$2" __def="$3" __ans=""
  if [[ "$INTERACTIVE" == 1 ]]; then
    if [[ -n "$__def" ]]; then
      tty_out '  %s%s▸%s %s%s%s%s %s[%s]%s: ' "$B" "$C" "$N" "$B" "$Y" "$__q" "$N" "$G" "$__def" "$N"
    else
      tty_out '  %s%s▸%s %s%s%s%s: ' "$B" "$C" "$N" "$B" "$Y" "$__q" "$N"
    fi
    IFS= read -r __ans <&3 || no_more_input
  fi
  # Trimmed, because a pasted answer usually arrives with a space attached.
  __ans="${__ans#"${__ans%%[![:space:]]*}"}"
  __ans="${__ans%"${__ans##*[![:space:]]}"}"
  [[ -n "$__ans" ]] || __ans="$__def"
  printf -v "$__var" '%s' "$__ans"
}

# ask_secret VAR "question" — typed blind, confirmed, never echoed.
ask_secret() {
  local __var="$1" __q="$2" a="" b=""
  if [[ "$INTERACTIVE" != 1 ]]; then printf -v "$__var" '%s' ""; return 0; fi
  while true; do
    tty_out '  %s%s▸%s %s%s%s%s: ' "$B" "$C" "$N" "$B" "$Y" "$__q" "$N"
    IFS= read -rs a <&3 || no_more_input
    tty_out '\n'
    if [[ -z "$a" ]]; then printf -v "$__var" '%s' ""; return 0; fi
    if (( ${#a} < 8 )); then
      tty_out '    %s%s!%s %sat least 8 characters%s\n' "$B" "$R" "$N" "$R" "$N"
      continue
    fi
    tty_out '    %sconfirm it%s: ' "$Y" "$N"
    IFS= read -rs b <&3 || no_more_input
    tty_out '\n'
    if [[ "$a" == "$b" ]]; then printf -v "$__var" '%s' "$a"; return 0; fi
    tty_out '    %s%s!%s %sthey do not match — try again%s\n' "$B" "$R" "$N" "$R" "$N"
  done
}

# ask_yn "question" default(y|n) — true when the answer is yes.
ask_yn() {
  local q="$1" def="${2:-n}" ans="" hint="y/N"
  if [[ "$INTERACTIVE" != 1 ]]; then [[ "$def" == y ]]; return; fi
  [[ "$def" == y ]] && hint="Y/n"
  while true; do
    tty_out '  %s%s▸%s %s%s%s%s %s[%s]%s: ' "$B" "$C" "$N" "$B" "$Y" "$q" "$N" "$G" "$hint" "$N"
    IFS= read -r ans <&3 || no_more_input
    ans="${ans:-$def}"
    case "${ans,,}" in
      y|yes) return 0 ;;
      n|no)  return 1 ;;
      *) printf '    %s%s!%s %sanswer y or n%s\n' "$B" "$R" "$N" "$R" "$N" >&3 ;;
    esac
  done
}

# Is something already listening on this TCP port?
#
# The panel must not land on a port another project is serving from. This can
# run before iproute2 is guaranteed to be present, so: ss, then netstat, then
# bash's own TCP connect — whichever the machine has.
port_taken() {
  local p="$1"
  if have ss; then
    ss -ltnH "sport = :$p" 2>/dev/null | grep -q . && return 0
    return 1
  fi
  if have netstat; then
    netstat -ltn 2>/dev/null | grep -qE "[:.]${p}[[:space:]]" && return 0
    return 1
  fi
  # No tool for it: ask the port itself. An answer means somebody is home.
  if (exec 4<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then
    exec 4>&-
    return 0
  fi
  return 1
}

# Who is on that port, in words, so the operator recognises their own service
# rather than being told only that the port is unavailable.
port_owner() {
  local p="$1" who=""
  if have ss; then
    who=$(ss -ltnpH "sport = :$p" 2>/dev/null | sed -n 's/.*users:((\"\([^\"]*\)\".*/\1/p' | head -1)
  fi
  if [[ -n "$who" ]]; then printf '%s' "$who"; else printf 'another service'; fi
}

# What this machine is already running, so an upgrade does not move the panel.
#
# A re-run that handed out a fresh random port and a fresh random path would
# leave every bookmark, every firewall rule and every note the operator wrote
# down pointing at nothing.
read_existing() {
  [[ -f "$UNIT" ]] || return 0
  local v
  v=$(sed -n 's/^Environment=WUI_LISTEN=.*:\([0-9]\{1,5\}\)$/\1/p' "$UNIT" | head -1)
  if [[ -n "$v" && "$PORT_KNOWN" == 0 ]]; then PANEL_PORT="$v"; PORT_KNOWN=1; fi
  v=$(sed -n 's|^Environment=WUI_BASE_PATH=/\{0,1\}\(.*\)|\1|p' "$UNIT" | head -1)
  v="${v%/}"
  if [[ -n "$v" && "$BASE_KNOWN" == 0 ]]; then BASE_PATH="$v"; BASE_KNOWN=1; fi

  # The certificate it already serves, and the address it binds. An upgrade
  # that forgot either would put a panel back on plain HTTP, or on every
  # interface, without anyone having asked.
  local c k
  c=$(sed -n 's/^Environment=WUI_TLS_CERT=//p' "$UNIT" | head -1)
  k=$(sed -n 's/^Environment=WUI_TLS_KEY=//p' "$UNIT" | head -1)
  # Or in wui.env, which the unit reads too: a certificate kept there went on
  # being served after an update, while the update said there was none.
  if [[ -z "$c" && -r "$CONF_DIR/wui.env" ]]; then
    c=$(sed -n 's/^WUI_TLS_CERT=//p' "$CONF_DIR/wui.env" | head -1)
    k=$(sed -n 's/^WUI_TLS_KEY=//p' "$CONF_DIR/wui.env" | head -1)
    c="${c//\"/}"; k="${k//\"/}"
  fi
  if [[ -z "$TLS_MODE" && -z "$TLS_CERT" && -z "$ACME_DOMAIN" && -z "$ACME_IP" && -s "$c" && -s "$k" ]]; then
    TLS_MODE=files; TLS_CERT="$c"; TLS_KEY="$k"; TLS_KEPT=1
  fi
  v=$(sed -n 's/^Environment=WUI_LISTEN=//p' "$UNIT" | head -1)
  v="${v%:*}"
  [[ -n "$v" && "$LISTEN_ADDR" == 0.0.0.0 ]] && LISTEN_ADDR="$v"

  # The engine it runs on. An upgrade that switched a PostgreSQL panel back
  # to SQLite would start it on an empty database.
  if [[ "$DB_KNOWN" == 0 && -r "$CONF_DIR/db.env" ]]; then
    v=$(sed -n 's/^WUI_DB_DRIVER=//p' "$CONF_DIR/db.env" | head -1)
    [[ -n "$v" ]] && { DB_DRIVER="$v"; DB_KNOWN=1; }
    v=$(sed -n 's/^WUI_DB_SOURCE=//p' "$CONF_DIR/db.env" | head -1)
    [[ -n "$v" ]] && DB_SOURCE="$v"
  fi
  if [[ "$DB_KNOWN" == 0 && -f "$DATA_DIR/wui.db" ]]; then
    DB_DRIVER=sqlite; DB_KNOWN=1
  fi
  # And the subscription port the links already carry.
  if [[ "$SUB_KNOWN" == 0 && -r "$CONF_DIR/install-result.env" ]]; then
    v=$(sed -n 's/^WUI_SUB_PORT=//p' "$CONF_DIR/install-result.env" | head -1 | tr -d "'\"")
    [[ "$v" =~ ^[0-9]+$ ]] && { SUB_PORT="$v"; SUB_KNOWN=1; SUB_EXISTING=1; }
  fi
}

# ask_port VAR "question" [port it may not be] -- a free port the operator
# types, or a random free one on enter. Whatever is listening on a port typed
# here belongs to somebody, so it is never offered as something to take over.
ask_port() {
  local __var="$1" __q="$2" __not="${3:-}" __p
  while true; do
    ask __p "$__q" ""
    if [[ -z "$__p" ]]; then
      if [[ -n "$__not" ]]; then __p="$(pick_sub_port)"; else __p="$(random_free_port)"; fi
      info "random port: $__p"
      break
    fi
    if [[ ! "$__p" =~ ^[0-9]+$ ]] || (( __p < 1 || __p > 65535 )); then
      warn "a port is a number from 1 to 65535"
      continue
    fi
    if [[ -n "$__not" && "$__p" == "$__not" ]]; then
      warn "port $__p is the panel's own — pick another"
      continue
    fi
    if port_taken "$__p"; then
      warn "port $__p is already served by $(port_owner "$__p") — pick another"
      continue
    fi
    break
  done
  printf -v "$__var" '%s' "$__p"
}

# A port for the subscription service that is free and is not the panel's.
pick_sub_port() {
  local p tries=0
  while (( tries++ < 20 )); do
    p="$(random_free_port)"
    [[ "$p" != "$PANEL_PORT" ]] && { printf '%s' "$p"; return 0; }
  done
  printf '%s' $(( PANEL_PORT % 60000 + 1025 ))
}

# The environment a wui subcommand needs to open the same database the
# service does, as one string of assignments for env(1).
panel_env() {
  printf 'WUI_DATA_DIR=%s WUI_DB_DRIVER=%s WUI_DB_SOURCE=%s' "$DATA_DIR" "$DB_DRIVER" "$DB_SOURCE"
}

# Whether this machine already has a panel's data, whichever engine holds it.
existing_data() {
  [[ -f "$DATA_DIR/wui.db" ]] && return 0
  [[ "$DB_DRIVER" == postgres && "$DB_KNOWN" == 1 ]] && return 0
  return 1
}

# Say plainly what a re-run does, because the wrong idea about it is the one
# that causes real damage.
#
# A panel on this machine is upgraded in place: same port, same path, same data.
# The installer cannot produce a second panel and should not — two on one
# machine overwrite each other's firewall rules with neither noticing, and the
# panel refuses to start rather than let that happen.
#
# The mistake worth heading off is "I will add a node to this server". A node is
# another panel; it belongs on another machine.
announce_existing() {
  [[ -f "$UNIT" ]] || return 0

  local state="installed"
  if have_systemd && systemctl is-active --quiet wui.service 2>/dev/null; then
    state="installed and running"
  fi

  tty_out '\n%s%sA panel is already %s on this machine.%s\n' "$B" "$Y" "$state" "$N"
  tty_out '%sThis will upgrade it, keeping its port, its path and its data.%s\n' "$D" "$N"
  tty_out '%sIt cannot add a second panel: two on one machine overwrite each%s\n' "$D" "$N"
  tty_out "%sother's firewall rules, so the second one refuses to start. A node%s\n" "$D" "$N"
  tty_out '%sis another panel and belongs on another machine.%s\n\n' "$D" "$N"
}

# The certificate question, on its own so the update path can ask it too
# when it finds a panel with none.
ask_tls() {
  # ── how it is reached ─────────────────────────────────────────────────────
  #
  # The same four choices the classic panel's installer offers, in the same order, with
  # the same default: a certificate for the address itself. Whatever is
  # chosen, the panel answers on the one port above -- by name and by
  # address alike -- so a link written down today works tomorrow.
  tty_out '\n'
  qhead "SSL Certificate Setup (RECOMMENDED)"
  info "SSL is strongly recommended. Skip only if a reverse proxy"
  info "or SSH tunnel handles TLS for you."
  info "Let's Encrypt supports both domains and IP addresses."
  tty_out '\n'
  opt 1 "Let's Encrypt for Domain (90-day validity, auto-renews)"
  opt 2 "Let's Encrypt for IP Address (6-day validity, auto-renews)"
  opt 3 "Custom SSL Certificate (path to existing files)"
  opt 4 "Skip SSL (advanced — behind reverse proxy / SSH tunnel only)"
  note "Note: options 1 & 2 need port 80 reachable from the internet."
  note "Note: option 4 serves the panel over plain HTTP."
  tty_out '\n'

  local choice
  while true; do
    ask choice "Choose" "2"
    case "$choice" in
      1|2|3|4) break ;;
      *) warn "answer 1, 2, 3 or 4" ;;
    esac
  done

  case "$choice" in
    1)
      TLS_MODE=acme
      # Blank is a way out, not a mistake to be corrected. An operator who
      # picked this option and then realised the DNS is not ready yet should
      # be able to finish the install, not be held at a question whose only
      # valid answer they do not have.
      while true; do
        ask ACME_DOMAIN "Domain pointing at this server (blank: no certificate)" "${ACME_DOMAIN:-}"
        if [[ -z "$ACME_DOMAIN" ]]; then
          warn "no domain — the panel will serve plain HTTP"
          TLS_MODE=none
          break
        elif [[ ! "$ACME_DOMAIN" =~ ^[A-Za-z0-9._-]+\.[A-Za-z]{2,}$ ]]; then
          warn "that does not look like a domain name"
        else
          break
        fi
      done
      [[ "$TLS_MODE" == acme ]] && ask ACME_EMAIL "Email for expiry notices (optional)" "${ACME_EMAIL:-}"
      ;;
    2)
      TLS_MODE=ip
      local mine; mine="$(public_ip)"
      if [[ -n "$mine" ]]; then
        if ask_yn "Is $mine the correct incoming public IPv4 address for this server?" y; then
          ACME_IP="$mine"
        fi
      else
        warn "could not auto-detect this server's public address"
      fi
      while [[ -z "$ACME_IP" ]]; do
        ask ACME_IP "This server's public IPv4 address" ""
        [[ "$ACME_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || { warn "that is not an IPv4 address"; ACME_IP=""; }
      done
      ask ACME_IPV6 "IPv6 address to include (blank to skip)" "${ACME_IPV6:-}"
      ;;
    3)
      TLS_MODE=files
      ask TLS_DOMAIN "Domain the certificate was issued for (blank: use the address)" "${TLS_DOMAIN:-}"
      while true; do
        ask TLS_CERT "Path to the certificate (fullchain .crt or .pem)" "${TLS_CERT:-}"
        [[ -s "$TLS_CERT" ]] && break
        warn "no readable file at that path"
      done
      while true; do
        ask TLS_KEY "Path to the private key" "${TLS_KEY:-}"
        [[ -s "$TLS_KEY" ]] && break
        warn "no readable file at that path"
      done
      ;;
    4)
      TLS_MODE=none
      tty_out '\n'
      warn "the panel will be installed WITHOUT SSL/TLS"
      warn "login credentials and cookies travel as plain HTTP; only safe when"
      warn "  a reverse proxy (nginx, Caddy) terminates TLS for you, or"
      warn "  you reach the panel exclusively via an SSH tunnel"
      ;;
  esac

  # A panel on a public address over plain HTTP puts an administrator's
  # password on the wire. Bound to the loopback it is reachable only through
  # an SSH tunnel or a proxy on this machine, which is the one way serving
  # plain HTTP is defensible at all.
  if [[ "$TLS_MODE" == none && "$LISTEN_ADDR" != 127.0.0.1 ]]; then
    tty_out '\n'
    if ask_yn "Bind the panel to 127.0.0.1 only? (recommended — forces SSH tunnel / reverse-proxy access)" n; then
      LISTEN_ADDR=127.0.0.1
    fi
  fi
}

# ── where the data lives ─────────────────────────────────────────────────────
#
# SQLite is one file, needs nothing installed, and carries a few thousand
# customers without noticing. PostgreSQL is for the install that outgrows that.
# Both are backed up in the same archive, and a backup from either restores
# into the other. Asked the same way by both kinds of install.
ask_database() {
  if [[ "$DB_KNOWN" == 1 ]]; then
    info "keeping the database this install already uses: $DB_DRIVER"
    return 0
  fi
  tty_out '\n'
  qhead "Database"
  opt 1 "SQLite      — one file, nothing to install; fine up to a few thousand customers (default)"
  opt 2 "PostgreSQL  — for a large number of customers; installed and configured here"
  local dbc
  while true; do
    ask dbc "Choose" "1"
    case "$dbc" in
      1) DB_DRIVER=sqlite; break ;;
      2) DB_DRIVER=postgres; break ;;
      *) warn "answer 1 or 2" ;;
    esac
  done
  info "database: $DB_DRIVER"
}

# ── manual or automatic ──────────────────────────────────────────────────────
#
# The first question of a fresh install. Manual is every question the
# installer has always asked, in the same order. Automatic is for someone who
# does not want to decide what a port or a URL path should be: it asks only
# what cannot be guessed -- the database, a domain, a certificate -- and
# chooses the rest the way pressing enter through the manual questions would.
choose_install_mode() {
  case "$INSTALL_MODE" in
    manual|auto) info "install mode: $INSTALL_MODE"; return 0 ;;
    "") ;;
    *) die "the install mode is manual or auto, not $INSTALL_MODE" ;;
  esac
  tty_out '\n'
  qhead "How do you want to install?"
  opt 1 "Manual     — you choose the port, the URL path, the administrator, the database,"
  info "                  the certificate and what is installed"
  opt 2 "Automatic  — three questions (database, domain, certificate); everything else is"
  info "                  chosen and set up for you, and shown at the end (default)"
  local m
  while true; do
    ask m "Choose" "2"
    case "$m" in
      1) INSTALL_MODE=manual; break ;;
      2) INSTALL_MODE=auto; break ;;
      *) warn "answer 1 or 2" ;;
    esac
  done
}

# ── the automatic install ────────────────────────────────────────────────────
#
# Three questions, and the rest decided as the manual install's defaults would
# decide it: a random free port, a random URL path, a generated administrator
# and password, a free subscription port, and every protocol. Nothing after
# this asks anything: a certificate that cannot be issued -- port 80 served by
# another project, a domain that does not point here yet -- is skipped rather
# than asked about, and the closing summary says so beside everything needed to
# sign in.
configure_auto() {
  step "Automatic install"
  info "three questions; the port, the URL path, the administrator and the rest are chosen for you"
  info "and everything needed to sign in is shown at the end"

  ask_database

  # ── a domain, if there is one ────────────────────────────────────────────
  if [[ -z "$TLS_MODE" ]]; then
    tty_out '\n'
    qhead "Domain"
    info "  a domain whose DNS record points at this server gets a certificate for it"
    info "  (Let's Encrypt, 90 days, renews itself). Leave it blank if you have none."
    local d here
    here="$(public_ip)"
    while true; do
      ask d "Domain (blank if you have none)" "${ACME_DOMAIN:-}"
      if [[ -z "$d" ]]; then
        ACME_DOMAIN=""
        break
      fi
      if [[ ! "$d" =~ ^[A-Za-z0-9._-]+\.[A-Za-z]{2,}$ ]]; then
        warn "that does not look like a domain name"
        continue
      fi
      if domain_points_here "$d"; then
        ACME_DOMAIN="$d"; TLS_MODE=acme
        break
      fi
      # Said now, while it can still be put right, rather than as a failed
      # certificate at the end of the install.
      warn "$d does not point at this server${here:+ ($here)} yet"
      warn "  the certificate authority has to reach it here; add an A record, or leave it blank"
      if ask_yn "Use it anyway? (the certificate is tried, and skipped if it fails)" n; then
        ACME_DOMAIN="$d"; TLS_MODE=acme
        break
      fi
    done
  fi

  # ── otherwise, a certificate for the address itself ──────────────────────
  if [[ -z "$TLS_MODE" ]]; then
    tty_out '\n'
    if ask_yn "Get a free SSL certificate for this server's IP address? (recommended; 6 days, renews itself)" y; then
      ACME_IP="${ACME_IP:-$(public_ip)}"
      if [[ "$ACME_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
        TLS_MODE=ip
      else
        warn "could not find this server's public address; the panel will serve plain HTTP"
        warn "  get a certificate later from the menu: w-ui → 20"
        ACME_IP=""; TLS_MODE=none
      fi
    else
      TLS_MODE=none
      warn "no certificate — the panel serves plain HTTP until you get one: w-ui → 20"
    fi
  fi

  # Everything else, as the manual defaults would have it.
  configure_defaults

  tty_out '\n'
  read_back
}

# What is about to be installed, read back before it is. Both kinds of install
# show it; only the manual one asks to go ahead.
read_back() {
  info "Panel port     $PANEL_PORT"
  info "Sub port       $SUB_PORT"
  info "Database       $DB_DRIVER"
  info "Administrator  $ADMIN_USER"
  local shown_path="/"
  [[ -n "$BASE_PATH" ]] && shown_path="/$BASE_PATH/"
  case "$TLS_MODE" in
    acme)  info "Address        https://$ACME_DOMAIN:$PANEL_PORT$shown_path  (certificate from Let's Encrypt)" ;;
    ip)    info "Address        https://$ACME_IP:$PANEL_PORT$shown_path  (6-day certificate from Let's Encrypt, renews itself)" ;;
    files) info "Address        https://${TLS_DOMAIN:-<your host>}:$PANEL_PORT$shown_path  (your own certificate)" ;;
    none)  info "Address        http://$([[ "$LISTEN_ADDR" == 127.0.0.1 ]] && echo 127.0.0.1 || echo '<this server>'):$PANEL_PORT$shown_path  (no certificate)" ;;
  esac
  local extras="WireGuard"
  [[ "$WANT_OPENVPN" == 1 ]] && extras="$extras, OpenVPN"
  [[ "$WANT_AMNEZIA" == 1 ]] && extras="$extras, AmneziaWG"
  [[ "$WANT_L2TP" == 1 ]] && extras="$extras, L2TP/IPsec"
  [[ "$WANT_IKEV2" == 1 ]] && extras="$extras, IKEv2"
  [[ "$WANT_PPTP" == 1 ]] && extras="$extras, PPTP"
  [[ "$WANT_OPENCONNECT" == 1 ]] && extras="$extras, OpenConnect"
  [[ "$WANT_PROXY" == 1 ]] && extras="$extras, xray and sing-box"
  info "Installing     $extras (each off until a tunnel uses it)"
  tty_out '\n'
}

# existing_install reports whether a panel is already installed here, which
# makes this run an update. The unit is what every release's install wrote,
# so an install from v1.0.0 on is found by it, whatever its database.
existing_install() {
  [[ -f "$UNIT" ]]
}

configure() {
  open_tty
  read_existing
  announce_existing

  # A panel is already here: this is an update, and an update asks nothing.
  # The port, the path, the certificate, the database, the administrator and
  # the packages are whatever they are; only the binary and the scripts move.
  if existing_install; then
    IS_UPDATE=1
    step "Update"
    info "keeping the port, path, certificate, database and administrator this install has"
    configure_upgrade
    return 0
  fi

  if [[ "$INTERACTIVE" != 1 ]]; then
    # Said, not silently assumed. An operator who piped this from a file and
    # expected questions should learn that here, not at the summary.
    step "Configuration"
    info "no terminal to ask on — using flags, environment and defaults"
    configure_defaults
    return 0
  fi

  choose_install_mode
  if [[ "$INSTALL_MODE" == auto ]]; then
    configure_auto
    return 0
  fi

  step "Setup"
  info "a few questions, then the install runs on its own"
  tty_out '\n'

  # ── the port ──────────────────────────────────────────────────────────────
  #
  # Random unless the operator wants otherwise. A panel on a port that is
  # written down in a README is a panel that gets found by anyone sweeping the
  # address space for one, and what they find is a form to guess passwords at.
  # The question offers no port of its own for the same reason: enter gives a
  # random one, never a number from the documentation.
  if [[ "$PORT_KNOWN" == 1 ]]; then
    info "keeping the port this install already uses: $PANEL_PORT"
  elif ask_yn "Choose the panel port yourself? (otherwise a random one is used)" n; then
    ask_port PANEL_PORT "Panel port (enter for a random one)" ""
  else
    PANEL_PORT="$(random_free_port)"
    info "panel port: $PANEL_PORT"
  fi

  # ── the subscription port ─────────────────────────────────────────────────
  #
  # Asked as the panel's is. The customers' links carry it, so an operator
  # with a port open for it at their provider, or a CDN in front, sets it
  # here rather than finding it in the settings later.
  if [[ "$SUB_KNOWN" == 1 ]]; then
    info "keeping the subscription port this install already uses: $SUB_PORT"
  elif ask_yn "Choose the subscription port yourself? (otherwise a random one is used)" n; then
    ask_port SUB_PORT "Subscription port (enter for a random one)" "$PANEL_PORT"
  else
    SUB_PORT="$(pick_sub_port)"
    info "subscription port: $SUB_PORT"
  fi

  # ── the path it answers on ────────────────────────────────────────────────
  #
  # The other half of not being found. With a random prefix there is nothing at
  # the root of this address at all: every path outside it answers 404,
  # including the sign-in page. It is not a replacement for the password — it
  # is what stops the password being the only thing in the way.
  if [[ "$BASE_KNOWN" == 1 ]]; then
    info "keeping the path this install already uses: /$BASE_PATH/"
  elif ask_yn "Choose the panel's URL path yourself? (otherwise a random one is used)" n; then
    while true; do
      ask BASE_PATH "URL path (one segment, blank for none)" ""
      # Surrounding slashes are how people write a path and are simply
      # dropped. A slash in the middle is a different path from the one
      # typed, so it is refused rather than quietly flattened -- an
      # operator who is handed a path they did not type will write down
      # the one they did.
      BASE_PATH="${BASE_PATH#/}"
      BASE_PATH="${BASE_PATH%/}"
      if [[ -z "$BASE_PATH" ]]; then
        warn "no path — the panel answers at the root, where anyone can find it"
        break
      fi
      if [[ "$BASE_PATH" == api ]]; then
        warn "\"api\" is where the API already answers; pick another"
        continue
      fi
      if [[ ! "$BASE_PATH" =~ ^[A-Za-z0-9._~-]+$ ]]; then
        warn "one segment of letters, digits and - _ . ~ — no slashes or spaces"
        continue
      fi
      break
    done
  else
    BASE_PATH="$(gen_string 18)"
    info "panel path: /$BASE_PATH/"
  fi

  # ── who signs in ──────────────────────────────────────────────────────────
  #
  # Generated unless asked otherwise, name included. "admin" is half of every
  # credential-stuffing attempt ever made against a panel, and a name nobody
  # can guess costs nothing to keep in the same place as the password.
  if ask_yn "Choose the administrator name and password yourself? (otherwise both are generated)" n; then
    ask ADMIN_USER "Administrator username" "${ADMIN_USER:-admin}"
    ask_secret ADMIN_PASS "Administrator password (blank to generate one)"
    [[ -n "$ADMIN_PASS" ]] || info "a password will be generated and shown at the end"
  else
    [[ -n "$ADMIN_USER" ]] || ADMIN_USER="$(gen_string 10)"
    info "administrator: $ADMIN_USER"
    info "the password is generated and shown once, at the end"
  fi

  ask_database

  ask_tls

  ask_protocols

  # ── read it back ──────────────────────────────────────────────────────────
  tty_out '\n'
  read_back

  ask_yn "Start the install with these?" y || die "cancelled — nothing was changed"
}

# What is installed alongside WireGuard. One question for all of it -- the
# answer most operators want -- and only on a no, one for each, so a panel
# that sells two protocols is not made to carry the rest. A protocol left out
# here is not lost: the panel installs it when the first tunnel of its kind
# is made. A --no-... flag already given is kept either way.
ask_protocols() {
  tty_out '\n'
  qhead "Protocols"
  info "  WireGuard always; besides it OpenVPN, AmneziaWG, L2TP/IPsec, IKEv2, PPTP,"
  info "  OpenConnect, and xray with sing-box for the proxy outbounds. Each stays off"
  info "  until a tunnel uses it."
  ask_yn "Install every protocol?" y && return 0
  if ask_yn "OpenVPN?" y; then WANT_OPENVPN=1; else WANT_OPENVPN=0; fi
  if ask_yn "AmneziaWG (obfuscated WireGuard; builds a kernel module)?" y; then WANT_AMNEZIA=1; else WANT_AMNEZIA=0; fi
  if ask_yn "L2TP/IPsec?" y; then WANT_L2TP=1; else WANT_L2TP=0; fi
  if ask_yn "IKEv2?" y; then WANT_IKEV2=1; else WANT_IKEV2=0; fi
  if ask_yn "PPTP?" y; then WANT_PPTP=1; else WANT_PPTP=0; fi
  if ask_yn "OpenConnect (Cisco AnyConnect compatible)?" y; then WANT_OPENCONNECT=1; else WANT_OPENCONNECT=0; fi
  if ask_yn "xray and sing-box (proxy outbounds: VLESS, VMess, Trojan, Shadowsocks...)?" y; then WANT_PROXY=1; else WANT_PROXY=0; fi
}

# Everything as it is. What read_existing could not find is left at what it
# was on this machine: the tunnels that are installed stay installed and
# nothing new is added, and a panel with no certificate keeps serving plain
# HTTP -- the update script is what offers one.
configure_upgrade() {
  [[ -n "$TLS_MODE" ]] || TLS_MODE=none
  [[ -n "$DB_DRIVER" ]] || DB_DRIVER=sqlite
  [[ -n "$SUB_PORT" ]] || SUB_PORT="$(pick_sub_port)"
  if [[ "$WANT_OPENVPN" == 1 && ! -x "$(command -v openvpn 2>/dev/null)" ]]; then WANT_OPENVPN=0; fi
  if [[ "$WANT_AMNEZIA" == 1 && ! -x "$(command -v awg 2>/dev/null)" ]]; then WANT_AMNEZIA=0; fi
  if [[ "$WANT_IKEV2" == 1 ]] && ! have swanctl; then WANT_IKEV2=0; fi
  if [[ "$WANT_PPTP" == 1 ]] && ! have pppd; then WANT_PPTP=0; fi
  if [[ "$WANT_OPENCONNECT" == 1 ]] && ! have ocserv; then WANT_OPENCONNECT=0; fi
}

# The same decisions, made without a terminal.
configure_defaults() {
  # The same decisions the questions would have reached, made without a
  # terminal: unguessable unless something was passed in.
  ADMIN_USER="${ADMIN_USER:-$(gen_string 10)}"
  [[ "$BASE_KNOWN" == 1 ]] || BASE_PATH="$(gen_string 18)"
  if [[ "$PORT_KNOWN" == 0 ]]; then
    PANEL_PORT="$(random_free_port)"
  elif port_taken "$PANEL_PORT" && [[ "$(port_owner "$PANEL_PORT")" != wui ]]; then
    # The panel itself holding the port is an upgrade, not a conflict.
    die "port $PANEL_PORT is already served by $(port_owner "$PANEL_PORT"); pass --port with a free one"
  fi
  [[ "$SUB_KNOWN" == 1 ]] || SUB_PORT="$(pick_sub_port)"
  [[ -n "$DB_DRIVER" ]] || DB_DRIVER=sqlite
  case "$DB_DRIVER" in
    sqlite|postgres) ;;
    *) die "--db must be sqlite or postgres, not $DB_DRIVER" ;;
  esac
  if [[ -n "$TLS_MODE" ]]; then
    : # --no-tls, or a mode already chosen
  elif [[ -n "$ACME_DOMAIN" ]]; then
    TLS_MODE=acme
  elif [[ -n "$ACME_IP" || "${WUI_SSL_MODE:-}" == ip ]]; then
    TLS_MODE=ip
    [[ -n "$ACME_IP" ]] || ACME_IP="$(public_ip)"
    [[ -n "$ACME_IP" ]] || { warn "no public address found; the panel will serve plain HTTP"; TLS_MODE=none; }
  elif [[ -n "$TLS_CERT" && -n "$TLS_KEY" ]]; then
    TLS_MODE=files
  else
    TLS_MODE=none
  fi
}

# ── certificates ─────────────────────────────────────────────────────────────
CERT_DIR="$CONF_DIR/certs"

# Run a command as the panel's own account.
#
# The panel reads its database and its private key as this user, so anything
# this script creates on its behalf is created by it too — otherwise the first
# thing the service does on boot is fail to open a root-owned file.
as_service_user() {
  if have runuser; then
    runuser -u "$SERVICE_USER" -- "$@"
  elif have sudo; then
    sudo -u "$SERVICE_USER" -- "$@"
  else
    # su takes a command string, so the arguments have to be quoted back up.
    local q="" a
    for a in "$@"; do q+="$(printf '%q ' "$a")"; done
    su -s /bin/sh -c "$q" "$SERVICE_USER"
  fi
}

# n random alphanumeric characters. Alphanumeric so the result survives being
# copied out of a terminal, typed into a phone, or pasted through a chat app
# that helpfully reformats punctuation.
gen_string() {
  local n="${1:-16}" out=""
  # Read a fixed number of bytes rather than letting head close the pipe.
  # /dev/urandom is a file, so head reading from it kills nothing, and the
  # length is checked instead of assumed: a password that came out short
  # because a tool was interrupted is a security bug, not a cosmetic one.
  while (( ${#out} < n )); do
    out+="$(head -c 256 /dev/urandom 2>/dev/null | base64 2>/dev/null | LC_ALL=C tr -dc 'A-Za-z0-9')"
    [[ -n "$out" ]] || die "cannot read randomness from /dev/urandom"
  done
  printf '%s' "${out:0:n}"
}

# A high port nothing else is listening on.
#
# Random rather than fixed: a well-known port is the first thing a scanner
# tries. Checked for a listener before it is offered, because a collision here
# would take down whatever was already there.
random_free_port() {
  local p tries=0
  while (( tries++ < 60 )); do
    if have shuf; then p=$(shuf -i 1024-62000 -n 1); else p=$(( (RANDOM << 15 | RANDOM) % 60977 + 1024 )); fi
    port_taken "$p" || { printf '%s' "$p"; return 0; }
  done
  # Sixty collisions in a row is not a busy machine, it is a broken check.
  # Falling back to the prompt's own default beats returning nothing.
  printf '%s' "$PANEL_PORT"
}

gen_password() {
  # 16 characters from a real random source, alphanumeric so it survives being
  # copied out of a terminal, typed into a phone, or pasted through a chat app
  # that helpfully reformats punctuation.
  gen_string 16
}

# This server's address as the internet sees it, for checking that a domain
# actually points here before asking a certificate authority to confirm it.
public_ip() {
  local u ip
  for u in https://api4.ipify.org https://ipv4.icanhazip.com https://v4.api.ipinfo.io/ip \
           https://ipv4.myexternalip.com/raw https://4.ident.me https://check-host.net/ip; do
    ip="$(curl -fsS -4 --max-time 3 "$u" 2>/dev/null | tr -d '[:space:]"')"
    if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then printf '%s' "$ip"; return 0; fi
  done
  hostname -I 2>/dev/null | awk '{print $1}'
}

# Does the domain resolve to this machine?
#
# Not fatal when it does not: a domain behind a proxy, or one whose DNS has not
# propagated yet, both look like this. But it is by far the most common reason
# an ACME challenge fails, and finding out before the certificate authority
# says no saves an operator a confusing five minutes.
domain_points_here() {
  local d="$1" mine resolved=""
  mine="$(public_ip)"
  [[ -n "$mine" ]] || return 0
  if have getent; then
    resolved="$(getent ahostsv4 "$d" 2>/dev/null | awk '{print $1}' | sort -u)"
  elif have dig; then
    resolved="$(dig +short A "$d" 2>/dev/null)"
  else
    return 0
  fi
  [[ -n "$resolved" ]] || return 1
  grep -qx "$mine" <<<"$resolved"
}

setup_tls() {
  case "$TLS_MODE" in
    none)
      step "Certificate"
      warn "none — the panel serves plain HTTP"
      warn "put it behind a reverse proxy, or reach it over an SSH tunnel"
      return 0
      ;;
    files)
      step "Certificate"
      if [[ "${TLS_KEPT:-0}" == 1 ]]; then
        ok "keeping the certificate this install already serves: $TLS_CERT"
        return 0
      fi
      use_existing_cert
      return 0
      ;;
    ip)
      step "Certificate for $ACME_IP"
      issue_ip_certificate || {
        warn "continuing without a certificate — the panel will serve plain HTTP"
        warn "check that port 80 is open, then run: w-ui  → 20 → 6"
        TLS_MODE=none
        TLS_CERT=""; TLS_KEY=""
      }
      return 0
      ;;
    acme)
      step "Certificate for $ACME_DOMAIN"
      issue_certificate || {
        # A failed certificate must not be a failed install. Everything else
        # works; the panel comes up on HTTP and the operator can re-run the
        # certificate step from the menu once the cause is fixed.
        warn "continuing without a certificate — the panel will serve plain HTTP"
        warn "fix the cause, then re-run this installer and choose a domain again"
        TLS_MODE=none
        TLS_CERT=""; TLS_KEY=""
      }
      return 0
      ;;
  esac
}

# A certificate the operator already has.
#
# Left where it is rather than copied, so whatever renews it keeps working. The
# only thing checked is that the panel's own account can read it, because a key
# under /etc/letsencrypt/live is root-only by default and the service does not
# run as root.
use_existing_cert() {
  local ok_cert=1 ok_key=1
  as_service_user test -r "$TLS_CERT" 2>/dev/null || ok_cert=0
  as_service_user test -r "$TLS_KEY" 2>/dev/null || ok_key=0

  if [[ "$ok_cert" == 1 && "$ok_key" == 1 ]]; then
    ok "using $TLS_CERT"
    ok "the service account can read both files"
    return 0
  fi

  warn "$SERVICE_USER cannot read the certificate, so the panel could not start with it"
  install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$CERT_DIR"
  install -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0644 "$TLS_CERT" "$CERT_DIR/panel.crt"
  install -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0600 "$TLS_KEY" "$CERT_DIR/panel.key"
  TLS_CERT="$CERT_DIR/panel.crt"
  TLS_KEY="$CERT_DIR/panel.key"
  ok "copied into $CERT_DIR"
  warn "this is a copy: when the certificate is renewed, copy it again and"
  warn "  restart the panel, or grant $SERVICE_USER read access to the original"
}

# ── acme.sh ──────────────────────────────────────────────────────────────────
# A fixed home rather than $HOME. Under `sudo bash` the environment often
# still carries the calling user's home, so acme.sh would install itself
# into their directory while its renewal runs as root — and a later run of
# this installer would not find it there.
ACME_HOME="$( { getent passwd root 2>/dev/null || true; } | cut -d: -f6)"
ACME_HOME="${ACME_HOME:-/root}/.acme.sh"
acme() { "$ACME_HOME/acme.sh" --home "$ACME_HOME" "$@"; }

install_acme() {
  [[ -x "$ACME_HOME/acme.sh" ]] && return 0
  info "installing acme.sh"
  curl -fsSL --max-time 60 https://get.acme.sh -o /tmp/get-acme.sh || {
    warn "could not download acme.sh"; return 1; }
  ( HOME="${ACME_HOME%/.acme.sh}" sh /tmp/get-acme.sh "home=$ACME_HOME" ${ACME_EMAIL:+--accountemail "$ACME_EMAIL"} >/dev/null 2>&1 )
  rm -f /tmp/get-acme.sh
  [[ -x "$ACME_HOME/acme.sh" ]] || { warn "acme.sh is not installed"; return 1; }
  # Let's Encrypt is named on each request below, never set as acme.sh's
  # default: another project on this server may keep certificates in the same
  # acme.sh with a different authority, and changing the default would move
  # their renewals.
  return 0
}

# Renewal must happen with nobody watching, or the certificate expires and
# the panel goes dark. acme.sh renews from a cron entry -- which is nothing
# on the many small images that ship without a cron daemon. So a systemd
# timer runs its renewal check every six hours as well; the two are harmless
# together, since acme.sh only renews what is due. Six hours, not a day: an
# address certificate lives six days and is renewed at six, and a daily
# check could miss the window.
ensure_renewal() {
  acme --install-cronjob >/dev/null 2>&1 || true

  cat > /etc/systemd/system/wui-cert-renew.service <<UNIT
[Unit]
Description=W-UI certificate renewal (acme.sh)
After=network-online.target

[Service]
Type=oneshot
Environment=HOME=${ACME_HOME%/.acme.sh}
ExecStart=$ACME_HOME/acme.sh --cron --home $ACME_HOME
UNIT
  cat > /etc/systemd/system/wui-cert-renew.timer <<UNIT
[Unit]
Description=W-UI certificate renewal check, every six hours

[Timer]
OnCalendar=*-*-* 00/6:00:00
RandomizedDelaySec=30m
Persistent=true

[Install]
WantedBy=timers.target
UNIT
  systemctl daemon-reload
  if systemctl enable --now wui-cert-renew.timer >/dev/null 2>&1; then
    ok "renewal timer installed (wui-cert-renew.timer, every 6 hours)"
  else
    warn "could not enable the renewal timer; acme.sh's own cron entry is the fallback"
  fi
}

# The port the HTTP-01 challenge answers on. Let's Encrypt always knocks on
# 80; when something else already holds it, the operator can forward 80 to
# another port and name it here, as the classic panel's installer allows.
acme_http_port() {
  ACME_HTTP_PORT=80
  if ! port_taken 80; then return 0; fi
  local who; who="$(port_owner 80)"
  warn "port 80 is already served by $who"
  if [[ "$INSTALL_MODE" == auto ]]; then
    # Another project's port is left alone. The certificate can be had later,
    # once port 80 is free or forwarded, from the menu.
    warn "it belongs to something else on this server and is left alone — skipping the certificate"
    return 1
  fi
  if [[ "$INTERACTIVE" != 1 ]]; then
    warn "no terminal to ask for another port — skipping the certificate"
    return 1
  fi
  local alt
  ask alt "Another port for the ACME listener (forward external 80 to it; blank to give up)" ""
  [[ -n "$alt" ]] || return 1
  [[ "$alt" =~ ^[0-9]+$ ]] && (( alt >= 1 && alt <= 65535 )) || { warn "not a port"; return 1; }
  port_taken "$alt" && { warn "port $alt is taken too"; return 1; }
  ACME_HTTP_PORT="$alt"
  info "reminder: Let's Encrypt still reaches port 80; forward external port 80 to $alt for validation"
  return 0
}

# Put an issued certificate where the panel's own account can read it, and
# keep it there across renewals. No restart in the reload command: the panel
# re-reads the files itself when they change.
install_cert_files() {
  local name="$1" dir="$CERT_DIR/$1"
  shift
  install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$CERT_DIR"
  install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$dir"
  acme --install-cert "$@" \
    --fullchain-file "$dir/fullchain.pem" \
    --key-file "$dir/privkey.pem" \
    --reloadcmd "chown $SERVICE_USER:$SERVICE_USER $dir/fullchain.pem $dir/privkey.pem; chmod 600 $dir/privkey.pem" \
    >/dev/null 2>&1 || true
  [[ -s "$dir/fullchain.pem" && -s "$dir/privkey.pem" ]] || {
    warn "the certificate was issued but could not be installed"; return 1; }
  chown "$SERVICE_USER:$SERVICE_USER" "$dir/fullchain.pem" "$dir/privkey.pem"
  chmod 644 "$dir/fullchain.pem"
  chmod 600 "$dir/privkey.pem"
  TLS_CERT="$dir/fullchain.pem"
  TLS_KEY="$dir/privkey.pem"
  ACME_METHOD=standalone
  ensure_renewal
  ok "issued and installed in $dir"
  ok "renewal is automatic; the panel picks the new certificate up by itself"
  warn "renewal needs port 80 reachable again; keep it open"
  return 0
}

# A free certificate from Let's Encrypt for a domain, over the HTTP-01
# challenge on the standalone listener, as the classic panel's installer gets one.
issue_certificate() {
  if ! domain_points_here "$ACME_DOMAIN"; then
    warn "$ACME_DOMAIN does not resolve to this server's address"
    warn "  the certificate authority has to reach it here to issue anything"
    # The automatic install asked about this with the domain, and was told
    # to go ahead.
    if [[ "$INTERACTIVE" == 1 && "$INSTALL_MODE" != auto ]]; then
      ask_yn "Try anyway?" n || return 1
    else
      warn "trying anyway"
    fi
  fi
  acme_http_port || return 1
  pkg_install socat || warn "socat is missing; the standalone challenge may not work"
  install_acme || return 1

  local listen=""
  ip -4 addr show scope global 2>/dev/null | grep -q "inet " || listen="--listen-v6"

  info "asking Let's Encrypt for a certificate (this takes a moment)"
  local out
  out=$(acme --issue -d "$ACME_DOMAIN" $listen --standalone --server letsencrypt --httpport "$ACME_HTTP_PORT" --keylength ec-256 --force 2>&1) || true
  if ! acme --list 2>/dev/null | awk '{print $1}' | grep -Fxq "$ACME_DOMAIN"; then
    warn "the certificate was not issued"
    # The operator needs the authority's own words, not a summary of them.
    printf '%s\n' "$out" | tail -12 | sed 's/^/      /'
    rm -rf "$ACME_HOME/${ACME_DOMAIN:?}" "$ACME_HOME/${ACME_DOMAIN:?}_ecc"
    return 1
  fi
  install_cert_files "$ACME_DOMAIN" -d "$ACME_DOMAIN" --ecc
}

# A certificate for the address itself: Let's Encrypt's short-lived profile,
# valid six days and renewed by the timer, so the panel is on HTTPS with no
# domain at all -- what the classic panel's installer does by default.
issue_ip_certificate() {
  acme_http_port || return 1
  pkg_install socat || warn "socat is missing; the standalone challenge may not work"
  install_acme || return 1

  local args=(-d "$ACME_IP")
  [[ -n "$ACME_IPV6" && "$ACME_IPV6" == *:* ]] && args+=(-d "$ACME_IPV6")

  info "asking Let's Encrypt for a 6-day certificate for $ACME_IP (this takes a moment)"
  local out
  out=$(acme --issue "${args[@]}" --standalone --server letsencrypt \
    --certificate-profile shortlived --days 6 --httpport "$ACME_HTTP_PORT" --force 2>&1) || true
  if ! acme --list 2>/dev/null | awk '{print $1}' | grep -Fxq "$ACME_IP"; then
    warn "the certificate was not issued"
    printf '%s\n' "$out" | tail -12 | sed 's/^/      /'
    rm -rf "$ACME_HOME/${ACME_IP:?}" "$ACME_HOME/${ACME_IP:?}_ecc"
    [[ -n "$ACME_IPV6" ]] && rm -rf "$ACME_HOME/${ACME_IPV6:?}" "$ACME_HOME/${ACME_IPV6:?}_ecc"
    return 1
  fi
  install_cert_files ip -d "$ACME_IP" --ecc
}

# ── the administrator ────────────────────────────────────────────────────────
# Created before the panel first starts, so the account is the one the operator
# chose. The panel generates a random administrator only when it finds none,
# which after this it never does.
# The panel's own way into PostgreSQL, and nothing else.
#
# A RHEL-family cluster authenticates loopback connections by ident, which
# refuses the panel's password login. This used to be fixed by rewriting the
# loopback lines for every database to scram-sha-256 -- and on a server where
# another project already uses PostgreSQL, that change is theirs too: a role
# whose password is stored as md5 can no longer sign in, and the other project
# stops working the moment this installer runs. Instead two lines for this
# panel's database and role alone go first in pg_hba.conf, where the first
# matching line wins, and every other line is left as it was.
allow_panel_login() {
  local hba
  hba="$(runuser -u postgres -- psql -qAt -c 'SHOW hba_file' 2>/dev/null || true)"
  if [[ -z "$hba" || ! -f "$hba" ]]; then
    warn "could not find pg_hba.conf; if the panel cannot sign in to its database, allow the role wui over 127.0.0.1 with scram-sha-256"
    return 0
  fi
  grep -Eq '^host[[:space:]]+wui[[:space:]]+wui[[:space:]]+127[.]0[.]0[.]1/32' "$hba" && return 0
  local tmp
  tmp="$(mktemp)"
  {
    printf '# W-UI: its own database and role, over the loopback. Nothing else here was changed.\n'
    printf 'host    wui    wui    127.0.0.1/32    scram-sha-256\n'
    printf 'host    wui    wui    ::1/128         scram-sha-256\n'
    cat "$hba"
  } > "$tmp"
  # Written through, not moved: the file keeps its owner and permissions.
  cat "$tmp" > "$hba"
  rm -f "$tmp"
  runuser -u postgres -- psql -qAt -c 'SELECT pg_reload_conf()' >/dev/null 2>&1 || true
  ok "PostgreSQL allows the panel's role into its database; other databases untouched"
}

# The database the panel opens. SQLite is one file under the data directory
# and needs nothing. PostgreSQL is installed from the distribution, given a
# role and a database of its own with a generated password, and the
# connection string is kept root-only in $CONF_DIR/db.env, which the unit
# reads. Re-runs keep what is there.
setup_database() {
  step "Database"
  install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$CONF_DIR"

  if [[ "$DB_DRIVER" == sqlite ]]; then
    DB_SOURCE="$DATA_DIR/wui.db"
    printf 'WUI_DB_DRIVER=sqlite\nWUI_DB_SOURCE=%s\n' "$DB_SOURCE" > "$CONF_DIR/db.env"
    chmod 600 "$CONF_DIR/db.env"
    ok "SQLite at $DB_SOURCE"
    return 0
  fi

  if [[ -n "$DB_SOURCE" ]] && [[ "$DB_SOURCE" == postgres://* ]]; then
    ok "PostgreSQL, as already configured"
    ensure_postgres_running
    return 0
  fi

  info "installing PostgreSQL"
  case "$FAMILY" in
    debian) pkg_install postgresql postgresql-contrib || die "could not install PostgreSQL" ;;
    rhel)
      pkg_install postgresql-server postgresql-contrib || die "could not install PostgreSQL"
      if [[ ! -s /var/lib/pgsql/data/PG_VERSION ]]; then
        # The distribution's wrapper wants systemd; where there is none,
        # initdb itself. Password logins over the loopback either way --
        # the RHEL default is ident, which would refuse the panel's.
        install -d -o postgres -g postgres -m 700 /var/lib/pgsql/data
        if ! postgresql-setup --initdb >/dev/null 2>&1; then
          runuser -u postgres -- initdb -D /var/lib/pgsql/data --auth-local=peer --auth-host=scram-sha-256 -E UTF8 >/dev/null 2>&1             || die "could not initialise PostgreSQL"
        fi
      fi
      ;;
  esac
  ensure_postgres_running
  allow_panel_login

  DB_PASS="$(gen_string 32)"
  # One role and one database, owned by it. Created idempotently: a role
  # that exists gets the new password rather than an error.
  as_postgres() { runuser -u postgres -- psql -v ON_ERROR_STOP=1 -qAt -c "SET password_encryption = 'scram-sha-256'" "$@"; }
  if as_postgres -c "SELECT 1 FROM pg_roles WHERE rolname='wui'" | grep -q 1; then
    as_postgres -c "ALTER ROLE wui WITH LOGIN PASSWORD '$DB_PASS'" >/dev/null || die "could not set the database password"
  else
    as_postgres -c "CREATE ROLE wui WITH LOGIN PASSWORD '$DB_PASS'" >/dev/null || die "could not create the database role"
  fi
  if ! as_postgres -c "SELECT 1 FROM pg_database WHERE datname='wui'" | grep -q 1; then
    as_postgres -c "CREATE DATABASE wui OWNER wui" >/dev/null || die "could not create the database"
  fi

  DB_SOURCE="postgres://wui:$DB_PASS@127.0.0.1:5432/wui?sslmode=disable"
  local prev; prev=$(umask); umask 077
  printf 'WUI_DB_DRIVER=postgres\nWUI_DB_SOURCE=%s\n' "$DB_SOURCE" > "$CONF_DIR/db.env"
  umask "$prev"
  chmod 600 "$CONF_DIR/db.env"
  ok "PostgreSQL database wui, credentials in $CONF_DIR/db.env"
}

ensure_postgres_running() {
  if have_systemd; then
    systemctl enable --now postgresql >/dev/null 2>&1 || true
  elif runuser -u postgres -- pg_isready -q 2>/dev/null; then
    :
  elif have service; then
    # A container or a host without systemd: the init script, or the
    # cluster tool Debian ships, or pg_ctl itself on RHEL.
    service postgresql start >/dev/null 2>&1 || true
  fi
  if ! runuser -u postgres -- pg_isready -q 2>/dev/null; then
    if have pg_ctlcluster; then
      local ver; ver=$(ls /etc/postgresql 2>/dev/null | sort -V | tail -1)
      [[ -n "$ver" ]] && pg_ctlcluster "$ver" main start >/dev/null 2>&1 || true
    elif [[ -d /var/lib/pgsql/data ]]; then
      # The socket directory systemd's tmpfiles would have made.
      install -d -o postgres -g postgres -m 755 /run/postgresql 2>/dev/null || true
      runuser -u postgres -- pg_ctl -D /var/lib/pgsql/data -l /var/lib/pgsql/startup.log -w start >/dev/null 2>&1 || true
    fi
  fi
  local _
  for _ in $(seq 1 30); do
    runuser -u postgres -- pg_isready -q 2>/dev/null && return 0
    sleep 1
  done
  [[ -r /var/lib/pgsql/startup.log ]] && tail -5 /var/lib/pgsql/startup.log >&2
  die "PostgreSQL did not come up; see: journalctl -u postgresql"
}

# The subscription service: on, on its own port, with the panel's
# certificate. Only on a fresh install; an upgrade keeps whatever the
# operator has set since.
apply_sub_settings() {
  step "Subscription service"
  if [[ "$SUB_EXISTING" == 1 ]]; then
    ok "kept on port $SUB_PORT"
    return 0
  fi
  local args=(--sub-enable --sub-port "$SUB_PORT")
  if [[ -n "$TLS_CERT" && -n "$TLS_KEY" ]]; then
    args+=(--sub-cert "$TLS_CERT" --sub-key "$TLS_KEY")
  fi
  # shellcheck disable=SC2046
  if as_service_user env $(panel_env) "$BIN_PATH" setting set "${args[@]}" >/dev/null 2>&1; then
    ok "enabled on port $SUB_PORT"
  else
    warn "could not enable the subscription service; turn it on under Settings → Subscription"
  fi
}

apply_admin() {
  step "Administrator account"

  # This installer is safe to re-run, and that has to include the account
  # somebody is already signing in with. Nothing was asked for here, so
  # nothing is changed: generating a fresh password over a working one
  # would lock an operator out of their own panel on an upgrade.
  if existing_data && [[ -z "$ADMIN_PASS" ]]; then
    ok "existing account left as it is"
    info "to change it:  wui admin reset --username NAME"
    return 0
  fi

  ADMIN_GENERATED=0
  if [[ -z "$ADMIN_PASS" ]]; then
    ADMIN_PASS="$(gen_password)"
    ADMIN_GENERATED=1
  fi

  # Piped, never passed as an argument: an argument is visible in ps to every
  # user on the machine for as long as the command runs.
  # shellcheck disable=SC2046
  if printf '%s' "$ADMIN_PASS" | as_service_user env $(panel_env) \
      "$BIN_PATH" admin reset --username "$ADMIN_USER" --password-stdin --quiet; then
    ok "$ADMIN_USER"
  else
    warn "could not create the administrator here"
    warn "  the panel will generate one on first start; find it with:"
    warn "    journalctl -u wui | grep -A6 'First run'"
    ADMIN_PASS=""
  fi
}

# local_bin_fits says whether a binary is an ELF program for this machine:
# its e_machine, bytes 18-19, little-endian. One for another machine would
# be installed, fail to run, and be read by the shell as a script, with an
# error that says nothing about why.
local_bin_fits() {
  local magic machine
  magic="$(od -An -tx1 -N4 "$1" 2>/dev/null | tr -d ' \n')"
  [[ "$magic" == "7f454c46" ]] || return 1
  machine="$(od -An -tx1 -j18 -N2 "$1" 2>/dev/null | tr -d ' \n')"
  case "$ARCH" in
    amd64) [[ "$machine" == "3e00" ]] ;;
    arm64) [[ "$machine" == "b700" ]] ;;
    *) return 0 ;;
  esac
}

install_binary() {
  step "Installing the panel"

  if [[ -n "$LOCAL_BIN" ]]; then
    [[ -f "$LOCAL_BIN" ]] || die "no such file: $LOCAL_BIN"
    local_bin_fits "$LOCAL_BIN" || die "$LOCAL_BIN is not a Linux $ARCH program; build it for this server (GOARCH=$ARCH)"
    install -m 0755 "$LOCAL_BIN" "$BIN_PATH"
    ok "installed from $LOCAL_BIN"

  elif [[ "$FROM_SOURCE" == 1 ]]; then
    if [[ -f go.mod ]]; then
      # Run from a checkout: build what is in front of us, which is what
      # somebody testing a change expects.
      have go || die "--from-source needs Go on PATH"
      info "building (this takes a minute)…"
      CGO_ENABLED=0 go build -ldflags "-s -w" -o "$BIN_PATH" ./cmd/wui \
        || die "build failed"
      chmod 0755 "$BIN_PATH"
      ok "built from source"
    else
      build_from_tarball
    fi

  elif [[ -n "$RELEASE_URL" ]]; then
    info "downloading $RELEASE_URL"
    curl -fsSL "$RELEASE_URL" -o /tmp/wui.new || die "download failed"
    install -m 0755 /tmp/wui.new "$BIN_PATH"
    rm -f /tmp/wui.new
    ok "installed from release"

  else
    # Nothing was asked for, which is what `curl … | bash` looks like. Work it
    # out rather than refusing: a published build if there is one, and the
    # source if there is not.
    #
    # This used to be a `die`, which meant the one-line install printed in the
    # README could not succeed on any machine -- it reached the last step and
    # stopped there having installed WireGuard, OpenVPN and nothing else.
    if release_asset_url; then
      info "downloading $ASSET_URL"
      curl -fsSL "$ASSET_URL" -o /tmp/wui.new || die "download failed"
      verify_download /tmp/wui.new "${ASSET_URL##*/}"
      install -m 0755 /tmp/wui.new "$BIN_PATH"
      rm -f /tmp/wui.new
      ok "installed from the latest release"
    else
      info "no published build for $ARCH; building from source instead"
      build_from_tarball
    fi
  fi

  [[ -x "$BIN_PATH" ]] || die "installed binary is not executable"
  ok "$BIN_PATH"
}

install_menu() {
  step "Installing the w-ui command"

  # Prefer the copy beside this installer. Someone running --from-source or
  # --local wants the script from their checkout, not whatever is on the branch.
  local src=""
  if [[ -f "$(dirname "$0")/w-ui.sh" ]]; then
    src="$(dirname "$0")/w-ui.sh"
  elif [[ -f ./w-ui.sh ]]; then
    src=./w-ui.sh
  fi

  if [[ -n "$src" ]]; then
    install -m 0755 "$src" "$MENU_PATH"
    ok "installed from $src"
  elif curl -fsSL "$MENU_URL" -o /tmp/w-ui.sh; then
    install -m 0755 /tmp/w-ui.sh "$MENU_PATH"
    rm -f /tmp/w-ui.sh
    ok "downloaded"
  else
    warn "could not install the w-ui command; the panel still works"
    return 0
  fi

  # A CRLF here makes the shebang line unparseable, and the error then names
  # the interpreter rather than the file - a confusing first impression on a
  # repository that has been edited on Windows.
  local clean
  clean=$(mktemp)
  if tr -d '\015' < "$MENU_PATH" > "$clean"; then
    install -m 0755 "$clean" "$MENU_PATH"
  fi
  rm -f "$clean"

  ok "$MENU_PATH - run 'w-ui' to manage the panel"
}

create_user() {
  step "Creating the service account"
  if id -u "$SERVICE_USER" >/dev/null 2>&1; then
    ok "user $SERVICE_USER already exists"
  else
    useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER"
    ok "user $SERVICE_USER created"
  fi

  install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$DATA_DIR" "$CONF_DIR" "$BACKUP_DIR"
  ok "$DATA_DIR (0750, owned by $SERVICE_USER)"
}

write_unit() {
  step "Installing the systemd unit"

  # Two lines or none. An empty WUI_TLS_CERT is not the same as an unset one:
  # the panel refuses to start when only half a pair is present, which is the
  # right answer for a typo and the wrong one for a plain-HTTP install.
  local TLS_ENV="" BASE_ENV=""
  [[ -n "$BASE_PATH" ]] && BASE_ENV="Environment=WUI_BASE_PATH=$BASE_PATH"$'\n'
  if [[ -n "$TLS_CERT" && -n "$TLS_KEY" ]]; then
    TLS_ENV="Environment=WUI_TLS_CERT=$TLS_CERT"$'\n'"Environment=WUI_TLS_KEY=$TLS_KEY"
  fi

  cat >"$UNIT" <<UNITFILE
[Unit]
Description=W-UI — WireGuard and OpenVPN panel
Documentation=https://github.com/abolfazl/w-ui
After=network-online.target nftables.service postgresql.service
Wants=network-online.target

[Service]
Type=simple
User=$SERVICE_USER
Group=$SERVICE_USER

# The panel programs nftables and WireGuard through netlink, which needs
# CAP_NET_ADMIN. CAP_NET_BIND_SERVICE is for the tunnels themselves: a tunnel on
# UDP 443 is often the only one that survives a network which blocks by port,
# and a userspace tunnel binds that socket in this process. Granting exactly
# these two instead of running as root keeps a compromised panel from owning the
# whole machine.
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
NoNewPrivileges=true

Environment=WUI_LISTEN=$LISTEN_ADDR:$PANEL_PORT
Environment=WUI_DATA_DIR=$DATA_DIR
Environment=WUI_BACKUP_DIR=$BACKUP_DIR
$BASE_ENV$TLS_ENV
# The database: driver and connection string, root-only because the
# PostgreSQL one carries a password.
EnvironmentFile=-$CONF_DIR/db.env
EnvironmentFile=-$CONF_DIR/wui.env

ExecStart=$BIN_PATH
ExecReload=/bin/kill -HUP \$MAINPID
WorkingDirectory=$DATA_DIR

Restart=always
RestartSec=3

# Only the panel is stopped, not everything it started.
#
# Where there is no amneziawg kernel module the tunnel is carried by an
# amneziawg-go process the panel launches, and that process has to outlive it.
# The default here kills the whole control group, which would turn every panel
# restart -- including an automatic one after an update -- into a disconnection
# for every customer on an obfuscated tunnel.
KillMode=process

# The socket amneziawg-go answers on. Preserved across restarts for the same
# reason: removing it would orphan a tunnel that is still carrying traffic.
RuntimeDirectory=amneziawg
RuntimeDirectoryMode=0750
RuntimeDirectoryPreserve=yes

# The panel needs the network and its own data directory, nothing else.
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=$DATA_DIR $BACKUP_DIR
ProtectKernelTunables=false
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK

[Install]
WantedBy=multi-user.target
UNITFILE

  if have_systemd; then
    systemctl daemon-reload
    ok "$UNIT"
  else
    ok "$UNIT (written, not loaded)"
    warn "this host is not running systemd, so the service was not registered"
    warn "  start the panel yourself with:"
    warn "    WUI_LISTEN=$LISTEN_ADDR:$PANEL_PORT WUI_DATA_DIR=$DATA_DIR $BIN_PATH"
  fi
}

# Updating from the panel.
#
# The panel runs as $SERVICE_USER, in a sandbox where its own binary is
# read-only, so it cannot replace itself -- which is right for a process
# facing the internet. It downloads the release and checks the signature into
# $DATA_DIR/update and leaves a request there; this path unit sees the request
# and runs the installed binary, as root, to check it all again with its own
# key, refuse anything that is not a newer signed release, put it in place and
# restart the panel. Root only ever runs a build this project signed.
write_update_helper() {
  have_systemd || return 0
  install -d -m 0700 -o "$SERVICE_USER" -g "$SERVICE_USER" "$DATA_DIR/update"
  cat >/etc/systemd/system/wui-update.service <<HELPER
[Unit]
Description=W-UI — install an update the panel downloaded and checked

[Service]
Type=oneshot
ExecStart=$BIN_PATH apply-update $DATA_DIR
# A release is checked and moved in seconds. The bound is for a staged file
# that is not one -- a pipe would otherwise hold this open for good.
TimeoutStartSec=120
HELPER
  cat >/etc/systemd/system/wui-update.path <<HELPER
[Unit]
Description=W-UI — watch for an update the panel asks to install

[Path]
PathExists=$DATA_DIR/update/request
Unit=wui-update.service

[Install]
WantedBy=multi-user.target
HELPER
  systemctl daemon-reload
  systemctl enable --now wui-update.path >/dev/null 2>&1 || { warn "could not enable wui-update.path; updating from the panel will not work"; return 0; }
  ok "wui-update.path (updates from the panel)"
}

open_firewall() {
  step "Opening the panel port"

  # A certificate issued through the standalone challenge is renewed the same
  # way, in about sixty days, on a machine nobody is watching. If port 80 is
  # closed by then the renewal fails silently and the panel serves an expired
  # certificate — so the port that made this work stays open.
  # Nothing is opened for a panel that only listens on the loopback: the
  # port is unreachable from outside by design, and a firewall rule for it
  # would be a lie in the operator's own rule list.
  local ports=()
  [[ "$LISTEN_ADDR" == 127.0.0.1 ]] || ports+=("$PANEL_PORT/tcp")
  [[ -n "$SUB_PORT" ]] && ports+=("$SUB_PORT/tcp")
  [[ "$ACME_METHOD" == standalone ]] && ports+=("80/tcp")
  local pr
  if [[ ${#ports[@]} -eq 0 ]]; then
    ok "nothing to open: the panel listens on 127.0.0.1 only"
    return 0
  fi

  if have ufw && ufw status 2>/dev/null | grep -q "Status: active"; then
    for pr in "${ports[@]}"; do
      ufw allow "$pr" >/dev/null 2>&1 && ok "ufw: $pr allowed"
    done
  elif have firewall-cmd && firewall-cmd --state >/dev/null 2>&1; then
    for pr in "${ports[@]}"; do
      firewall-cmd --permanent --add-port="$pr" >/dev/null 2>&1 && ok "firewalld: $pr allowed"
    done
    firewall-cmd --reload >/dev/null 2>&1
  else
    info "no active host firewall detected"
    info "if your provider has one, allow TCP $PANEL_PORT and your tunnel's UDP port"
  fi
}

start_service() {
  if ! have_systemd; then
    # Nothing to start it with. Said plainly rather than failing: everything
    # else installed correctly and the panel runs perfectly well by hand.
    step "Starting W-UI"
    warn "skipped: no systemd on this host"
    return 0
  fi

  step "Starting W-UI"
  local fresh=0
  existing_data || fresh=1

  systemctl enable wui.service >/dev/null 2>&1
  systemctl restart wui.service

  for _ in $(seq 1 20); do
    if systemctl is-active --quiet wui.service; then break; fi
    sleep 0.5
  done

  systemctl is-active --quiet wui.service \
    || die "service failed to start — see: journalctl -u wui -n 50 --no-pager"
  ok "wui.service is running"

  if [[ "$fresh" == 1 && -z "$ADMIN_PASS" ]]; then
    # Only reached when creating the account up front did not work. The panel
    # prints its generated password once, to the journal, and stores it
    # nowhere in the clear; fishing it out here saves the operator the search.
    sleep 1
    FIRST_RUN_PW=$(journalctl -u wui --since "-2 min" --no-pager 2>/dev/null \
      | grep -A1 'password' | grep -oE '[A-Za-z0-9_-]{16}' | head -1 || true)
  fi
}

summary() {
  local ip host scheme
  ip=$(public_ip 2>/dev/null || echo "your-server-ip")
  [[ -n "$ip" ]] || ip="your-server-ip"

  # The address that will actually work. A certificate issued for a domain is
  # not valid for the address, so printing the IP there would hand the operator
  # a URL their browser refuses.
  scheme=http; host="$ip"; local port=":$PANEL_PORT"
  if [[ -n "$TLS_CERT" && -n "$TLS_KEY" ]]; then scheme=https; fi
  [[ "$TLS_MODE" == acme && -n "$ACME_DOMAIN" ]] && host="$ACME_DOMAIN"
  [[ "$TLS_MODE" == ip && -n "$ACME_IP" ]] && host="$ACME_IP"
  [[ "$TLS_MODE" == files && -n "$TLS_DOMAIN" ]] && host="$TLS_DOMAIN"

  # Behind a proxy the panel's own port is not the address anybody uses: nginx
  # answers on 443 for the domain and reaches the panel over the loopback.
  # Printing the panel's port here would hand the operator a URL that is
  # firewalled off from the internet.
  printf '\n%s────────────────────────────────────────────────────────────%s\n' "$M" "$N"
  printf '  %s%sW-UI is installed%s\n\n' "$B" "$G" "$N"
  local shown_path="/"
  [[ -n "$BASE_PATH" ]] && shown_path="/$BASE_PATH/"
  kv Panel "$scheme://$host$port$shown_path" "$B$G$U"
  kv Subs "$scheme://$host:$SUB_PORT/subscribe/<token>"

  if [[ -n "$ADMIN_PASS" ]]; then
    kv Username "$ADMIN_USER" "$B$G"
    kv Password "$ADMIN_PASS" "$B$Y"
    if [[ "$ADMIN_GENERATED" == 1 ]]; then
      printf '\n  %s%sThis password was generated and is shown once. Write it down.%s\n' "$B" "$Y" "$N"
    fi
  elif [[ -n "${FIRST_RUN_PW:-}" ]]; then
    kv Username admin "$B$G"
    kv Password "$FIRST_RUN_PW" "$B$Y"
    printf '\n  %s%sThis password is shown once. Change it after signing in.%s\n' "$B" "$Y" "$N"
  elif ! have_systemd; then
    # Nothing started the panel, so no account exists yet. Claiming an existing
    # one here would send the operator looking for something that is not there.
    printf '  %sThe panel has not been started yet, so no account exists.%s\n' "$Y" "$N"
    printf '  It prints a generated password to its log the first time it runs.\n'
  elif existing_data; then
    printf '  Sign in with your existing admin account.\n'
  else
    printf '  %sThe first-run password could not be read from the log.%s\n' "$Y" "$N"
    printf '  Find it with: %sjournalctl -u wui | grep -A6 "First run"%s\n' "$C" "$N"
  fi

  # part LABEL VALUE: one line of what was installed, its state coloured.
  part() { printf '    %s%-12s%s %s\n' "$C" "$1" "$N" "$2"; }
  printf '\n  %s%sInstalled%s\n' "$B" "$Y" "$N"
  part WireGuard "$(state "$(have wg && wg --version 2>/dev/null | awk '{print $2}' || echo 'missing')")"
  part AmneziaWG "$(state "$([[ "${AMNEZIA_OK:-0}" == 1 ]] && awg --version 2>/dev/null | awk '{print $2}' || echo 'not installed')")"
  part OpenVPN "$(state "$([[ "${OPENVPN_OK:-0}" == 1 ]] && openvpn --version 2>/dev/null | head -1 | awk '{print $2}' || echo 'not installed')")"
  part L2TP/IPsec "$(state "$(have xl2tpd && have swanctl && echo 'installed, off until used' || echo 'not installed')")"
  part IKEv2 "$(state "$(have swanctl && echo 'installed, off until used' || echo 'not installed')")"
  part PPTP "$(state "$(have pppd && echo 'installed, off until used' || echo 'not installed')")"
  part OpenConnect "$(state "$(have ocserv && echo 'installed, off until used' || echo 'not installed')")"
  part xray "$(state "$(have xray && echo "${XRAY_VERSION#v}" || echo 'not installed')")"
  part nftables "$(state "$(nft --version 2>/dev/null | awk '{print $2}')")"
  part forwarding "$(state "$([[ "$(sysctl -n net.ipv4.ip_forward)" == 1 ]] && echo on || echo off)")"
  if [[ "$LISTEN_ADDR" == 127.0.0.1 ]]; then
    part listening "$Y"'127.0.0.1 only — not reachable from outside this server'"$N"
  else
    part listening "$G$LISTEN_ADDR:$PANEL_PORT$N"
  fi
  part "URL path" "$([[ -n "$BASE_PATH" ]] && echo "$G$shown_path$N — nothing else on this address answers" || echo "$Y"'none (the panel is at the root)'"$N")"
  case "$TLS_MODE" in
    acme)  part certificate "${G}Let's Encrypt, renews itself$N" ;;
    ip)    part certificate "${G}Let's Encrypt for $ACME_IP, 6 days, renews itself every 6 hours$N" ;;
    files) part certificate "${G}yours, at $TLS_CERT$N" ;;
    *)     part certificate "${Y}none — this panel serves plain HTTP$N" ;;
  esac

  # cmd COMMAND WHAT: a command in cyan, padded, and what it does.
  cmd() { printf '    %s%-27s%s %s\n' "$C" "$1" "$N" "$2"; }
  printf '\n  %s%sCommands%s\n' "$B" "$Y" "$N"
  if have_systemd; then
    cmd w-ui 'the management menu'
    cmd 'systemctl status wui' 'service state'
    cmd 'journalctl -u wui -f' 'live log'
    cmd 'systemctl restart wui' restart
  else
    # Only what works here. Four systemctl lines on a host with no systemd are
    # four commands that fail.
    printf '    %s%s%s   start it in the foreground\n' "$C" "$BIN_PATH" "$N"
    cmd 'w-ui settings' 'what it is configured with'
    cmd 'w-ui admin' 'reset the administrator'
  fi
  printf '    %sbash %s --uninstall%s   remove (keeps the database)\n' "$C" "$0" "$N"

  printf '\n  %s%sNext%s open the panel, add an interface, then add clients.\n' "$B" "$M" "$N"
  printf '%s────────────────────────────────────────────────────────────%s\n\n' "$M" "$N"

  # Everything the classic panel prints under "Panel Installation Complete", in one
  # place, for the operator who scrolls back for the one line they need.
  printf '  %s═══════════════════════════════════════════%s\n' "$G" "$N"
  printf '  %s%s     Panel Installation Complete!         %s\n' "$B" "$G" "$N"
  printf '  %s═══════════════════════════════════════════%s\n' "$G" "$N"
  # box LABEL VALUE [COLOUR]: a line of it, the label yellow, the value bold.
  box() { printf '  %s%-13s%s%s%s%s%s\n' "$Y" "$1" "$N" "$B" "${3:-$G}" "$2" "$N"; }
  box Username: "$ADMIN_USER"
  [[ -n "$ADMIN_PASS" ]] && box Password: "$ADMIN_PASS" "$R"
  box Port: "$PANEL_PORT"
  box 'Sub Port:' "$SUB_PORT"
  box WebBasePath: "${BASE_PATH:-/}"
  if [[ "$DB_DRIVER" == postgres ]]; then
    box Database: "PostgreSQL (wui on 127.0.0.1, credentials in $CONF_DIR/db.env)"
  else
    box Database: "SQLite ($DATA_DIR/wui.db)"
  fi
  box 'Access URL:' "$scheme://$host$port$shown_path" "$C$U"
  [[ -n "$API_TOKEN" ]] && box 'API Token:' "$API_TOKEN" "$M"
  printf '  %s═══════════════════════════════════════════%s\n' "$G" "$N"
  printf '  %s%s⚠ IMPORTANT: Save these credentials securely!%s\n' "$B" "$R" "$N"
  if [[ "$scheme" == https ]]; then
    printf '  %s⚠ SSL Certificate: Enabled and configured%s\n' "$G" "$N"
  else
    printf '  %s⚠ SSL Certificate: Skipped — panel is HTTP-only. Use a reverse proxy or SSH tunnel.%s\n' "$Y" "$N"
  fi
  write_install_result "$scheme" "$host$port"
}

# Mint the token the classic panel prints as apiToken: automation gets one from the
# first minute without anybody signing in to make it. Never fatal.
#
# Once, at the install. An update mints none: each would be one more token
# with the run of the panel, left behind on every update -- and the one the
# install wrote down is still the one in install-result.env.
issue_api_token() {
  [[ "$IS_UPDATE" == 1 ]] && return 0
  have_systemd && systemctl is-active --quiet wui || return 0
  # shellcheck disable=SC2046
  API_TOKEN=$(env $(panel_env) "$BIN_PATH" token issue --name installer --quiet 2>/dev/null || true)
}

# The same facts as the summary, machine-readable, for cloud-init or a
# login banner to pick up: the classic panel's install-result.env. Mode 600, root only,
# because it holds the password.
#
# Written by the install alone. An update leaves the file as the install wrote
# it -- its values, its mode, its owner: the password is not known to an
# update, and automation reading the file must not find it emptied. An install
# from before the file existed has none, and an update does not make one up.
write_install_result() {
  local scheme="$1" hostport="$2" f="$CONF_DIR/install-result.env"
  if [[ "$IS_UPDATE" == 1 ]]; then
    if [[ -f "$f" ]]; then
      printf '  %sInstall result kept as it was: %s%s\n\n' "$D" "$f" "$N"
    else
      printf '  %sNo %s: this install is older than the file, and an update does not write one.%s\n\n' "$D" "$f" "$N"
    fi
    return 0
  fi
  install -d -m 700 "$CONF_DIR" 2>/dev/null || true
  local prev; prev=$(umask); umask 077
  {
    printf 'WUI_USERNAME=%q\n' "$ADMIN_USER"
    printf 'WUI_PASSWORD=%q\n' "$ADMIN_PASS"
    printf 'WUI_PANEL_PORT=%q\n' "$PANEL_PORT"
    printf 'WUI_WEB_BASE_PATH=%q\n' "${BASE_PATH:-}"
    printf 'WUI_ACCESS_URL=%q\n' "${scheme}://${hostport}/${BASE_PATH:+$BASE_PATH/}"
    printf 'WUI_API_TOKEN=%q\n' "$API_TOKEN"
    printf 'WUI_SUB_PORT=%q\n' "$SUB_PORT"
    printf 'WUI_SUB_URL=%q\n' "${scheme}://${hostport%%:*}:${SUB_PORT}/subscribe/"
    printf 'WUI_DB_TYPE=%q\n' "$DB_DRIVER"
  } > "$f" 2>/dev/null && chmod 600 "$f" && chown root:root "$f" 2>/dev/null \
    && printf '  %sInstall result written to %s (mode 600).%s\n\n' "$G" "$f" "$N" \
    || warn "could not write $f"
  umask "$prev"
}

# IP Limit rides on fail2ban; set it up now so the menu's jail works out
# of the box, as the classic panel does. Opt out with WUI_ENABLE_FAIL2BAN=false. Never
# fatal: a fail2ban that would not install must not fail the panel.
setup_fail2ban() {
  if [[ -n "${WUI_ENABLE_FAIL2BAN+x}" && "$WUI_ENABLE_FAIL2BAN" != true ]]; then
    info "WUI_ENABLE_FAIL2BAN=$WUI_ENABLE_FAIL2BAN, skipping Fail2ban auto-setup"
    return 0
  fi
  have_systemd || return 0
  [[ -x "$MENU_PATH" ]] || return 0
  step "Setting up Fail2ban for the IP Limit feature"
  info "installing fail2ban from the distribution; a minute on a fresh image"
  if "$MENU_PATH" setup-fail2ban </dev/null >/dev/null 2>&1; then
    ok "Fail2ban setup complete"
  else
    warn "Fail2ban setup did not finish; IP Limit stays off until you run 'w-ui' → IP Limit Management"
  fi
}

# the classic panel's closing box: the subcommands, so the operator's next command is
# already on the screen.
usage_box() {
  printf '  %s✓%s %s%s %s finished; the panel is running.%s\n\n' "$G" "$N" "$B" "W-UI $("$BIN_PATH" version 2>/dev/null || echo)" "${1:-installation}" "$N"
  printf '  ╭───────────────────────────────────────────────────────╮\n'
  printf '  │  %sThe w-ui command%s                                     │\n' "$B" "$N"
  printf '  │                                                       │\n'
  for row in "w-ui              - Admin Management Script" \
             "w-ui start        - Start" \
             "w-ui stop         - Stop" \
             "w-ui restart      - Restart" \
             "w-ui status       - Current Status" \
             "w-ui settings     - Current Settings" \
             "w-ui enable       - Enable Autostart on OS Startup" \
             "w-ui disable      - Disable Autostart on OS Startup" \
             "w-ui log          - Check logs" \
             "w-ui banlog       - Check Fail2ban ban logs" \
             "w-ui update       - Update" \
             "w-ui legacy       - Legacy version" \
             "w-ui install      - Install" \
             "w-ui uninstall    - Uninstall"; do
    printf '  │  %s%-17s%s%s%-34s│\n' "$C" "${row%% - *}" "$N" "- " "${row#* - }"
  done
  printf '  ╰───────────────────────────────────────────────────────╯\n'
}

# ── run ──────────────────────────────────────────────────────────────────────
# Everything above is definitions. A test suite that only wants those stops
# here, with the file it sourced being byte for byte the file that installs.
if [[ "$LIB_ONLY" == 1 ]]; then return 0 2>/dev/null || exit 0; fi

detect_os
[[ "$ACTION" == install ]] || do_uninstall

printf '\n  %s%sW-UI%s  %s·%s  %sWireGuard%s · %sOpenVPN%s · %sOpenConnect · IKEv2 · L2TP · PPTP%s   %s%sinstaller%s\n' "$P" "$B" "$N" "$D" "$N" "$G" "$N" "$Y" "$N" "$M" "$N" "$B" "$C" "$N"
printf '  %s%s · kernel %s · %s%s\n' "$D" "$OS_NAME" "$KERNEL" "$ARCH" "$N"

# Asked first, so the rest runs unattended.
configure

install_base
install_wireguard
if [[ "$WANT_AMNEZIA" == 1 ]]; then install_amnezia; else info "AmneziaWG skipped (--no-amnezia)"; fi
if [[ "$WANT_OPENVPN" == 1 ]]; then install_openvpn; else info "OpenVPN skipped (--no-openvpn)"; fi
if [[ "$WANT_L2TP" == 1 ]]; then install_l2tp; else info "L2TP/IPsec skipped; the panel installs it with the first L2TP tunnel"; fi
if [[ "$WANT_IKEV2" == 1 ]]; then install_ikev2; else info "IKEv2 skipped; the panel installs it with the first IKEv2 tunnel"; fi
if [[ "$WANT_PPTP" == 1 ]]; then install_pptp; else info "PPTP skipped; the panel installs it with the first PPTP tunnel"; fi
if [[ "$WANT_OPENCONNECT" == 1 ]]; then install_openconnect; else info "OpenConnect skipped; the panel installs it with the first OpenConnect tunnel"; fi
if [[ "$WANT_PROXY" == 1 ]]; then install_proxy_cores; else info "xray and sing-box skipped (--no-proxy-cores)"; fi
enable_forwarding
check_nftables
install_binary
install_menu
create_user
setup_database
setup_tls
write_unit
write_update_helper
if binary_before_3; then
  remove_l2tp_agent
  remove_firewall_helper
  warn "this binary is from before 3.0: L2TP/IPsec, IKEv2, PPTP, OpenConnect and the firewall helper stay off"
else
  write_l2tp_agent
fi
apply_admin
apply_sub_settings
open_firewall
start_service
issue_api_token
setup_fail2ban
summary
usage_box