#!/usr/bin/env bash
# =============================================================================
#  wahPanel — Ubuntu-first unattended installer (Debian/RHEL/Alpine friendly)
#
#  HOSTED AT: https://wahpanel.com/download/install.sh
#  USAGE:
#      curl -sSL https://wahpanel.com/download/install.sh | sudo bash
#      curl -sSL https://wahpanel.com/download/install.sh | WAHPANEL_PORT=2053 sudo -E bash
#
#  This script downloads a pre-compiled Linux binary from:
#      https://wahpanel.com/download/wahpanel.zip
#  (zip contains a single `wahpanel` ELF binary at the archive root)
# =============================================================================
#
# PORTABILITY SELF-GUARD (CRITICAL):
#   Users regularly invoke this as:  sh ./install.sh   or   dash ./install.sh
#   on Ubuntu cloud images where /bin/sh = dash (POSIX, no arrays / no pipefail
#   / no [[ ]] / no $'...' / no ${var/pat/sub}).  The logger + die() helpers +
#   sudoers heredoc block + service heredoc block + self-heal block all use
#   bash features intentionally (for safety, not style).  Instead of failing
#   380 lines deep with a cryptic "syntax error near unexpected token"
#   (which is exactly what you just hit if you reached this comment on a
#   second attempt!) we detect the interpreter mismatch at line ~22 and
#   TRANSPARENTLY RE-EXEC the script under /usr/bin/env bash with the exact
#   same arguments, same positional params, same stdin/stdout/stderr, same
#   exported env vars.  User action: zero.
if [ -z "${BASH_VERSION:-}" ]; then
  # Not running under bash.  Find bash and re-exec.
  _BASH=""
  for _c in /usr/bin/env /bin/bash /usr/bin/bash /usr/local/bin/bash; do
    if [ -x "$_c" ]; then
      # /usr/bin/env needs "bash" as argv[1]; the others are direct paths.
      if [ "$_c" = "/usr/bin/env" ]; then
        if command -v bash >/dev/null 2>&1; then _BASH="$_c bash"; break; fi
      else
        _BASH="$_c"
        break
      fi
    fi
  done
  if [ -z "${_BASH:-}" ]; then
    printf 'FATAL: wahPanel installer requires bash (Bourne-Again Shell) to run.\n' >&2
    printf '       Bash was NOT found anywhere on this system at /bin/bash, /usr/bin/bash.\n' >&2
    printf '       Install it with:  sudo apt install bash -y    (Debian/Ubuntu)\n' >&2
    printf '                             sudo dnf install bash -y    (RHEL/Fedora)\n' >&2
    printf '                             sudo apk add bash           (Alpine)\n' >&2
    printf '       Then re-run: sudo bash ./install.sh\n' >&2
    exit 127
  fi
  # Re-exec self under bash with exact same args + env.  "exec" replaces this
  # process image so PID, open fds, $PPID, $UID/EUID all stay unchanged — the
  # user sees zero indication anything magic happened.
  # shellcheck disable=SC2086
  exec $_BASH "$0" "$@"
  # exec NEVER returns unless _BASH path was wrong / permissions denied.
  printf 'FATAL: re-exec under %q failed (no permission or corrupt binary).\n' "$_BASH" >&2
  exit 126
fi
# ---- END OF BASH SELF-GUARD — from here onwards we are DEFINITELY in bash 4+
set -euo pipefail

# ------------------------------ structured logging ---------------------------
#
# install.sh unified logger:
#   • human-readable coloured output to stderr (interactive TTY friendly)
#   • machine-parseable NDJSON (one object per line) appended to the
#     ${WAHPANEL_INSTALL_LOG:-/var/log/wahpanel/install.log} log file
#   • severity levels: INFO | OK | WARN | ERROR | FATAL | DEBUG
#   • NEVER logs secrets; the redact() helper strips passwords and tokens
#     before any line is emitted.
#
# Environment overrides:
#   WAHPANEL_INSTALL_LOG    Override the NDJSON log path (disable with /dev/null)
#   WAHPANEL_LOG_LEVEL      Minimum severity written to the log file
#                           (DEBUG < INFO < OK < WARN < ERROR < FATAL)
#   WAHPANEL_LOG_NOCOLOR    Set =1 to disable ANSI colour on stderr
#   WAHPANEL_LOG_JSON_STDERR Set =1 to also print JSON on stderr (for automation
#                           that parses installer output).

WAHPANEL_LOG_LEVEL="${WAHPANEL_LOG_LEVEL:-DEBUG}"
WAHPANEL_LOG_NOCOLOR="${WAHPANEL_LOG_NOCOLOR:-0}"
WAHPANEL_LOG_JSON_STDERR="${WAHPANEL_LOG_JSON_STDERR:-0}"
# PANEL_NAME / LOG_DIR are defined below in the config section; resolve default
# against literal "wahpanel" fallback so the ordering is safe.
_default_log_dir="/var/log/wahpanel"
WAHPANEL_INSTALL_LOG="${WAHPANEL_INSTALL_LOG:-${_default_log_dir}/install.log}"

# severity order for level filtering
_sev_debug=0; _sev_info=10; _sev_ok=15; _sev_warn=20; _sev_error=30; _sev_fatal=40
_INSTALL_START_TS="$(date -u +%s)"
_INSTALL_RUN_ID="$(head -c 6 /dev/urandom 2>/dev/null | od -An -tx1 | tr -d ' \n' || echo "local")"

# Ensure the log directory + file exist early so every step is captured.
if [ "${WAHPANEL_INSTALL_LOG}" != "/dev/null" ]; then
  _log_dir="$(dirname "${WAHPANEL_INSTALL_LOG}")"
  mkdir -p "${_log_dir}" 2>/dev/null || true
  touch "${WAHPANEL_INSTALL_LOG}" 2>/dev/null || true
  chmod 0640 "${WAHPANEL_INSTALL_LOG}" 2>/dev/null || true
fi

# redact strips known-sensitive substrings from log messages so nothing like
# an admin password or a JWT secret ever lands on disk.
redact() {
  local s="$1"
  # $TMP_PW (bootstrap admin password)
  if [ -n "${TMP_PW:-}" ]; then
    s="${s//${TMP_PW}/<redacted:admin_password>}"
  fi
  # $BOOTSTRAP_CREDS (one-time banner containing password)
  if [ -n "${BOOTSTRAP_CREDS:-}" ]; then
    s="${s//${BOOTSTRAP_CREDS}/<redacted:bootstrap_creds>}"
  fi
  # Pattern: `--jwt-secret S3cret` / `WAHPANEL_JWT_SECRET=S3cret`
  #          `password=` / `Authorization: Bearer/Basic` / JSON `"password":"..."`
  s=$(echo "$s" | sed -E \
    -e 's/(WAHPANEL_JWT_SECRET=)[^ &$'"'"'"]+/\1<redacted:jwt>/g' \
    -e 's/(--jwt-secret[= ]+)[^ &]+/\1<redacted:jwt>/g' \
    -e 's/(password[=: ][^ &$'"'"'"]{3,})/password=<redacted>/gI' \
    -e 's/(ownerPassword[=: ][^ &$'"'"'"]{3,})/ownerPassword=<redacted>/gI' \
    -e 's/(adminPass[a-zA-Z]*[=: ][^ &$'"'"'"]{3,})/adminPassword=<redacted>/gI' \
    -e 's/"(password|ownerPassword|adminPassword|adminPass)":"[^"]{3,}"/"\1":"<redacted:password>"/gI' \
    -e 's/(Authorization:[[:space:]]*Bearer )[^ ]+/\1<redacted:bearer>/gI' \
    -e 's/(Authorization:[[:space:]]*Basic )[^ ]+/\1<redacted:basic>/gI' \
  )
  printf '%s' "$s"
}

# _levelnum converts a severity keyword into its numeric rank for level filter.
_levelnum() {
  case "$1" in
    DEBUG)  echo $_sev_debug ;;
    INFO)   echo $_sev_info  ;;
    OK)     echo $_sev_ok    ;;
    WARN)   echo $_sev_warn  ;;
    ERROR)  echo $_sev_error ;;
    FATAL)  echo $_sev_fatal ;;
    *)      echo 99 ;;
  esac
}

# _emit_json writes a structured NDJSON line to the install log.
_emit_json() {
  local sev="$1"; shift
  local msg="$1"; shift
  local ctx_json="${1:-{\}}"
  local ts_ns ts_iso pid_elapsed
  ts_ns="$(date -u +%s%N 2>/dev/null || date -u +%s)000000000"
  ts_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
  pid_elapsed="$(( $(date -u +%s) - _INSTALL_START_TS ))"
  # Strip literal newlines from msg so we emit exactly one JSON object per line.
  msg="${msg//$'\n'/ }"
  msg_esc=$(printf '%s' "$msg" | sed 's/\\/\\\\/g; s/"/\\"/g')
  printf '{"ts":"%s","ts_epoch_ns":%s,"run_id":"%s","elapsed_sec":%s,"severity":"%s","source":"installer","component":"install.sh","message":"%s","context":%s}\n' \
    "${ts_iso}" "${ts_ns}" "${_INSTALL_RUN_ID}" "${pid_elapsed}" "${sev}" "${msg_esc}" "${ctx_json}" \
    >> "${WAHPANEL_INSTALL_LOG}" 2>/dev/null || true
}

# _emit_console prints a human-readable line to stderr (colours only on TTY
# unless NOCOLOR is forced).
_emit_console() {
  local sev="$1"; shift
  local prefix="$1"; shift
  local msg="$*"
  if [ "${WAHPANEL_LOG_JSON_STDERR}" = "1" ]; then
    # Forward the same JSON to stderr for automation pipelines.
    _emit_json "${sev}" "${msg}" "{}"
    tail -n 1 "${WAHPANEL_INSTALL_LOG}" 2>/dev/null >&2 || true
    return 0
  fi
  local color="" reset=""
  if [ -t 2 ] && [ "${WAHPANEL_LOG_NOCOLOR}" != "1" ]; then
    case "${sev}" in
      DEBUG) color="\033[1;30m"  ;; # grey
      INFO)  color="\033[1;35m"  ;; # magenta == section
      OK)    color="\033[1;32m"  ;; # green
      WARN)  color="\033[1;33m"  ;; # yellow
      ERROR) color="\033[1;31m"  ;; # red
      FATAL) color="\033[1;41m"  ;; # red bg
    esac
    reset="\033[0m"
  fi
  printf "${color}%s [%s]${reset} %s\n" "${prefix}" "${sev}" "${msg}" >&2
}

# _log is the private dispatcher used by log/ok/warn/error/die/debug.
_log() {
  local sev="$1"; shift
  local glyph="$1"; shift
  local raw_msg="$*"
  local filtered redacted
  filtered=$(_levelnum "${sev}")
  [ "${filtered}" -lt "$(_levelnum "${WAHPANEL_LOG_LEVEL}")" ] && return 0
  redacted="$(redact "${raw_msg}")"
  # Always emit console + JSON if a log file is writable.
  _emit_console "${sev}" "${glyph}" "${redacted}"
  if [ "${WAHPANEL_INSTALL_LOG}" != "/dev/null" ]; then
    _emit_json "${sev}" "${redacted}" "{}"
  fi
}

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

# Standard severities (mirror Go slog naming so logs are grep-identical across
# the installer, the Go daemon and the web request traces).
debug() { _log "DEBUG" "·  " "$*"; }
log()   { _log "INFO"  "==>" "\n$*"; }
ok()    { _log "OK"    " ✔ " "$*"; }
warn()  { _log "WARN"  " ! " "$*"; }
error() { _log "ERROR" " ✗ " "$*"; }
die()   {
  local msg="$*"
  _log "FATAL" "✖  " "${msg}"
  # Append a tail snapshot so ops can see the last N structured lines without
  # having to open the log file.
  if [ "${WAHPANEL_INSTALL_LOG}" != "/dev/null" ] && [ -f "${WAHPANEL_INSTALL_LOG}" ]; then
    printf "\n\033[1;31mInstaller log tail (last 8 lines):\033[0m\n" >&2
    tail -n 8 "${WAHPANEL_INSTALL_LOG}" >&2 || true
    printf "\033[1;31mFull log: %s\033[0m\n\n" "${WAHPANEL_INSTALL_LOG}" >&2
  fi
  exit 1
}

# ------------------------------ config ---------------------------------------
# Default CDN.  These MUST point to the official wahpanel.com domain unless
# you are intentionally running from a self-hosted / mirror / air-gapped
# build; see the WAHPANEL_ALLOW_UNSAFE_DOWNLOAD guard below.
WAHPANEL_DOWNLOAD_BASE="${WAHPANEL_DOWNLOAD_BASE:-https://dev.wahpanel.com}"
case "$WAHPANEL_DOWNLOAD_BASE" in
  https://wahpanel.com|https://dev.wahpanel.com) ;;
  *) die "WAHPANEL_DOWNLOAD_BASE must be https://wahpanel.com or https://dev.wahpanel.com" ;;
esac
_DEFAULT_DOWNLOAD="${WAHPANEL_DOWNLOAD_BASE}/download/wahpanel.zip"
_DEFAULT_CHECKSUM="${_DEFAULT_DOWNLOAD}.sha256"
DOWNLOAD_URL="${DOWNLOAD_URL:-${_DEFAULT_DOWNLOAD}}"
CHECKSUM_URL="${CHECKSUM_URL:-${DOWNLOAD_URL}.sha256}"

# ---------------------------------------------------------------------------
# DOWNLOAD_URL / CHECKSUM_URL override guard.
#   The installer downloads code over HTTPS and runs it as root seconds
#   later.  If the download URL points anywhere other than the official
#   wahpanel.com CDN we MUST:
#     1. Refuse by default — prevents env-var injection attacks via
#        sudoers env_keep, /etc/environment, malicious one-liners, etc.
#     2. Require the operator to explicitly set an opt-in flag:
#            WAHPANEL_ALLOW_UNSAFE_DOWNLOAD=1
#        This makes it IMPOSSIBLE to silently redirect the installer without
#        the operator being aware.
#     3. If the opt-in IS present, print a prominent warning banner so
#        everyone watching the install (and the NDJSON log) can see it.
# ---------------------------------------------------------------------------
_URL_HOST_ONLY() {
  # Strip https?:// prefix then return the hostname:port portion up to the
  # next '/'.  Works for standard URLs.  Uses no bash-specific features
  # beyond what we already use elsewhere (parameter expansions).
  local u="$1" tmp
  tmp="${u#http://}"
  tmp="${tmp#https://}"
  tmp="${tmp%%/*}"
  printf '%s' "${tmp%%\?*}"
}

_DL_HOST="$(_URL_HOST_ONLY "${DOWNLOAD_URL}")"
_DEF_HOST="$(_URL_HOST_ONLY "${_DEFAULT_DOWNLOAD}")"
_CS_HOST="$(_URL_HOST_ONLY "${CHECKSUM_URL}")"
_UNSAFE=0
if [ "${_DL_HOST}" != "${_DEF_HOST}" ]; then _UNSAFE=1; fi
if [ "${_CS_HOST}" != "${_DEF_HOST}" ] && [ "${CHECKSUM_URL}" != "${DOWNLOAD_URL}.sha256" ]; then _UNSAFE=1; fi

if [ "${_UNSAFE}" -eq 1 ]; then
  if [ "${WAHPANEL_ALLOW_UNSAFE_DOWNLOAD:-0}" != "1" ]; then
    cat >&2 <<'EOF'

========================================================================
  wahPanel Installer — SECURITY BLOCKED
========================================================================
  DOWNLOAD_URL and/or CHECKSUM_URL point to a host that is NOT the
  official wahpanel.com CDN. This is almost always the result of an
  environment variable being set by an attacker (via sudoers env_keep,
  /etc/environment, /etc/profile.d/, malicious wrapper script, etc).

  If this is a deliberate, trusted custom CDN / mirror you control,
  re-run the installer with the EXPLICIT opt-in flag:

      WAHPANEL_ALLOW_UNSAFE_DOWNLOAD=1  sudo -E bash $0

  Do NOT set WAHPANEL_ALLOW_UNSAFE_DOWNLOAD unless you fully trust the
  host serving the download. The binary will be extracted, installed to
  /usr/local/bin, registered as a systemd service, and executed as root.
  Current active URLs (shown below) were captured from your environment:
========================================================================
EOF
    {
      printf '    DOWNLOAD_URL :  %s\n' "${DOWNLOAD_URL}" >&2
      printf '    CHECKSUM_URL :  %s\n' "${CHECKSUM_URL}" >&2
      printf '    Expected host:  %s\n' "${_DEF_HOST}" >&2
      printf '\n========================================================================\n' >&2
    }
    exit 69  # EX_UNAVAILABLE / sysexits.h convention for rejected request
  fi
  # Opt-in explicitly set.  Banner loudly so everyone sees it.
  warn "===================================================================="
  warn "  UNSAFE DOWNLOAD — NON-DEFAULT CDN IN USE"
  warn "===================================================================="
  warn "  DOWNLOAD_URL : %s" "${DOWNLOAD_URL}"
  warn "  CHECKSUM_URL : %s" "${CHECKSUM_URL}"
  warn "  WAHPANEL_ALLOW_UNSAFE_DOWNLOAD=1 was set by the caller."
  warn "  The binary at these URLs will be installed and run as ROOT."
  warn "  Ensure you trust the host serving them."
  warn "===================================================================="
fi
unset _DL_HOST _DEF_HOST _CS_HOST _UNSAFE _DEFAULT_DOWNLOAD _DEFAULT_CHECKSUM

PANEL_NAME="wahpanel"
BINARY_NAME="${PANEL_NAME}"
INSTALL_BIN_DIR="/usr/local/bin"
BINARY_DEST="${INSTALL_BIN_DIR}/${BINARY_NAME}"

INSTALL_DIR="/var/lib/${PANEL_NAME}"
DB_DIR="${INSTALL_DIR}/db"
LOG_DIR="/var/log/${PANEL_NAME}"
CONF_DIR="/etc/${PANEL_NAME}"
ENV_FILE="${CONF_DIR}/${PANEL_NAME}.env"
SERVICE_FILE="/etc/systemd/system/${PANEL_NAME}.service"
TMP_ZIP="$(mktemp /tmp/wahpanel.XXXXXX.zip)"
TMP_EXTRACT="$(mktemp -d /tmp/wahpanel.XXXXXX)"

DEFAULT_PORT="${WAHPANEL_PORT:-8090}"
if [ -z "${WAHPANEL_PORT:-}" ] && [ -f "$ENV_FILE" ]; then
    _SAVED_PORT="$(sed -n 's/^WAHPANEL_PORT=//p' "$ENV_FILE" | tail -1)"
    DEFAULT_PORT="${_SAVED_PORT:-8090}"
fi
ADMIN_USER="${WAHPANEL_ADMIN_USER:-wah_spark}"

# ------------------------------ neighbour panel detect ----------------------
#
# wahPanel supports co-existing with cPanel / Plesk / CyberPanel.  Running
# two panels on one host is safe ONLY IF ports do not collide and the new
# panel (us) does not mutate the neighbour's configuration files.
#
# Detection runs before any port binding / firewall / binary download so we
# can print a clear banner and auto-adjust the default port.  Detection is
# purely filesystem stats + ss socket inspection; no writes, no shelling
# out to the neighbour panel's binaries.
#
# Values written to WAHPANEL_PANEL_NEIGHBOUR in /etc/wahpanel/wahpanel.env:
#   CYBERPANEL | CPANEL | PLESK | NATIVE
PANEL_NEIGHBOUR=""
_CYBERPANEL_OLS_OWNER=""
_CP_DETECT_DEBUG=""
detect_neighbour_panel() {
    local ss_bin=""
    have ss && ss_bin="ss" || have netstat && ss_bin="netstat" || ss_bin=""

    # --- CyberPanel -------------------------------------------------------
    # Signature: /usr/local/CyberCP exists (Django app dir) AND either
    # lscpd.service active OR port 8090 bound by python (lscpd Django worker).
    local cp_match=0
    if [ -d "/usr/local/CyberCP" ]; then
        cp_match=$((cp_match + 1))
    fi
    if have systemctl; then
        if systemctl is-active --quiet lscpd 2>/dev/null; then
            cp_match=$((cp_match + 1))
        fi
    fi
    if [ -n "$ss_bin" ] && [ "$ss_bin" = "ss" ]; then
        if ss -ltnp 2>/dev/null | grep -E 'LISTEN.*:8090[[:space:]]' | grep -qE 'python|lscpd' 2>/dev/null; then
            cp_match=$((cp_match + 1))
        fi
    fi
    if [ "$cp_match" -ge 1 ]; then
        PANEL_NEIGHBOUR="CYBERPANEL"
        # If /usr/local/lsws exists AND CyberPanel's plogical/vhost creator dir
        # is present, the OpenLiteSpeed on this host is owned by CyberPanel.
        # Installer will skip starting its own LSWS and skip the 7080 firewall
        # rule (CyberPanel owns that port).
        if [ -f "/usr/local/lsws/conf/httpd_config.conf" ] && [ -d "/usr/local/CyberCP/plogical" ]; then
            _CYBERPANEL_OLS_OWNER="1"
        fi
        _CP_DETECT_DEBUG="cp_match=${cp_match}"
        return 0
    fi

    # --- cPanel / WHM ------------------------------------------------------
    if [ -d "/usr/local/cpanel" ] && [ -x "/usr/local/cpanel/cpanel" ]; then
        PANEL_NEIGHBOUR="CPANEL"
        _CP_DETECT_DEBUG="cpanel_dir=1"
        return 0
    fi

    # --- Plesk / Onyx ------------------------------------------------------
    if [ -d "/usr/local/psa" ] || [ -f "/etc/sw-cp-server/conf.d/plesk.conf" ]; then
        PANEL_NEIGHBOUR="PLESK"
        _CP_DETECT_DEBUG="plesk_dir=1"
        return 0
    fi

    PANEL_NEIGHBOUR="NATIVE"
    _CP_DETECT_DEBUG="none"
    return 0
}
detect_neighbour_panel

# --- Critical port collision auto-rebase ------------------------------------
# CyberPanel's admin UI and lscpd daemon HARD-BIND port 8090 on 0.0.0.0.
# wahPanel's default is also 8090.  Without intervention we would either:
#   (a) fail to bind after lscpd, or worse
#   (b) bind 8090 BEFORE lscpd on boot (systemd ordering race), lscpd then
#       fails silently, CyberPanel web UI + file manager + terminal + mail
#       queue + API all die and the user has to manually systemctl restart
#       lscpd after hunting.
#
# Rule: if (CyberPanel detected) AND (user did NOT pass WAHPANEL_PORT=
# explicitly via env), silently rebase DEFAULT_PORT to 2053.
# If the user DID pass WAHPANEL_PORT=XYZ explicitly, respect that as an
# override, rejecting CyberPanel's reserved port 8090.
_EXPLICIT_WAHPANEL_PORT=0
if [ -n "${WAHPANEL_PORT:-}" ]; then
    _EXPLICIT_WAHPANEL_PORT=1
fi
if [ "$PANEL_NEIGHBOUR" = "CYBERPANEL" ]; then
    if [ "$_EXPLICIT_WAHPANEL_PORT" = "0" ] && [ "$DEFAULT_PORT" = "8090" ]; then
        log "CyberPanel detected (${_CP_DETECT_DEBUG}). Default wahPanel port 8090 collides with CyberPanel UI/lscpd port 8090 — auto-rebasing to 2053."
        DEFAULT_PORT="2053"
    elif [ "${WAHPANEL_PORT:-}" = "8090" ]; then
        die "CyberPanel reserves port 8090. Use WAHPANEL_PORT=2053 or another free port."
    fi
fi
# cPanel 2083 / 2087 / 2082 / 2086 collision: warn, don't auto-change (we
# rarely set those).  Plesk 8443 / 8880 same pattern.
if [ "$PANEL_NEIGHBOUR" = "CPANEL" ] && [ "$DEFAULT_PORT" = "2087" -o "$DEFAULT_PORT" = "2083" -o "$DEFAULT_PORT" = "2082" -o "$DEFAULT_PORT" = "2086" ]; then
    warn "wahPanel DEFAULT_PORT=${DEFAULT_PORT} is inside cPanel/WHM reserved range.  Consider WAHPANEL_PORT=2053 to avoid cPanel UI redirect loops."
fi
if [ "$PANEL_NEIGHBOUR" = "PLESK" ] && [ "$DEFAULT_PORT" = "8443" -o "$DEFAULT_PORT" = "8880" ]; then
    warn "wahPanel DEFAULT_PORT=${DEFAULT_PORT} is inside Plesk admin reserved range.  Consider WAHPANEL_PORT=2053."
fi

case "$PANEL_NEIGHBOUR" in
    CYBERPANEL)
        ok "Detected neighbour panel: CYBERPANEL (lscpd + OpenLiteSpeed owner=${_CYBERPANEL_OLS_OWNER:-0}) — wahPanel will run in Read-Only Co-existence Mode."
        _emit_json OK "detected neighbour panel" '{"panel_neighbour":"CYBERPANEL","lscpd_active":true,"ols_owner_cyberpanel":'"${_CYBERPANEL_OLS_OWNER:-0}"',"default_port_override":'"${DEFAULT_PORT}"',"explicit_port":'"${_EXPLICIT_WAHPANEL_PORT}"'}'
        ;;
    CPANEL)
        ok "Detected neighbour panel: CPANEL — wahPanel will run in Read-Only Co-existence Mode."
        _emit_json OK "detected neighbour panel" '{"panel_neighbour":"CPANEL","default_port_override":'"${DEFAULT_PORT}"',"explicit_port":'"${_EXPLICIT_WAHPANEL_PORT}"'}'
        ;;
    PLESK)
        ok "Detected neighbour panel: PLESK — wahPanel will run in Read-Only Co-existence Mode."
        _emit_json OK "detected neighbour panel" '{"panel_neighbour":"PLESK","default_port_override":'"${DEFAULT_PORT}"',"explicit_port":'"${_EXPLICIT_WAHPANEL_PORT}"'}'
        ;;
    *)
        debug "neighbour panel detect: NATIVE (standalone install)."
        ;;
esac
unset _EXPLICIT_WAHPANEL_PORT _CP_DETECT_DEBUG

[[ "$DEFAULT_PORT" =~ ^[0-9]{1,5}$ ]] || die "Invalid WAHPANEL_PORT: expected 1-65535"
DEFAULT_PORT="$((10#$DEFAULT_PORT))"
[ "$DEFAULT_PORT" -ge 1 ] && [ "$DEFAULT_PORT" -le 65535 ] || die "Invalid WAHPANEL_PORT: expected 1-65535"
# Reject occupied ports before packages or installed artifacts are changed.
# A listener owned by wahpanel is allowed for an in-place upgrade.
if have ss; then
    _PORT_LISTENERS="$(ss -H -ltnp | awk -v p=":${DEFAULT_PORT}$" '$4 ~ p')"
    if [ -n "$_PORT_LISTENERS" ] && printf '%s\n' "$_PORT_LISTENERS" | grep -v '"wahpanel"' >/dev/null; then
        die "Port ${DEFAULT_PORT} is occupied by another service. Choose a free WAHPANEL_PORT."
    fi
else
    die "Port inspection requires ss (iproute2). Install it before running the installer."
fi

trap 'rm -rf "$TMP_ZIP" "$TMP_EXTRACT"' EXIT

# First log line (after config section so we have accurate metadata).
debug "installer started: pid=$$  download_url=${DOWNLOAD_URL}  log=${WAHPANEL_INSTALL_LOG}"

# ------------------------------ preflight ------------------------------------
[ "$(id -u)" -eq 0 ] || die "install.sh must be run as root. Try: sudo bash $0 (or pipe via sudo)"
debug "preflight: user=root OK"

# --- Diagnostic snapshot: OS, kernel, host, CyberPanel version, virt, disk, ports
_OS_ID=""; _OS_V=""; _OS_NAME=""
if [ -f /etc/os-release ]; then
  _OS_ID="$( (. /etc/os-release; printf '%s' "${ID:-}") )"
  _OS_V="$(  (. /etc/os-release; printf '%s' "${VERSION_ID:-}") )"
  _OS_NAME="$( (. /etc/os-release; printf '%s' "${PRETTY_NAME:-}") )"
fi
_KERNEL="$(uname -r 2>/dev/null || echo unknown)"; _ARCH="$(uname -m 2>/dev/null || echo unknown)"
_VIRT="$(systemd-detect-virt 2>/dev/null || echo none)"
_HOSTNAME_SH="$(hostname 2>/dev/null || echo unknown)"
_CP_VER="unknown"
if [ -r /usr/local/CyberCP/version.txt ]; then _CP_VER="$(head -1 /usr/local/CyberCP/version.txt 2>/dev/null)"; fi
_LSCPD_ACTIVE="0"; if have systemctl; then systemctl is-active --quiet lscpd 2>/dev/null && _LSCPD_ACTIVE="1"; fi
_OLS_MAJOR="missing"
if [ -x /usr/local/lsws/bin/lshttpd ]; then
  _OLS_VER="$(/usr/local/lsws/bin/lshttpd -v 2>/dev/null | head -1 || echo unknown)"; _OLS_MAJOR="installed"
fi
# Disk free in MiB on 5 critical paths
_disk_mib() {
  local p="$1"; [ -d "$p" ] || { echo 0; return; }
  local m=""; m="$(df -k -P "$p" 2>/dev/null | tail -1 | awk '{print $4}')"
  [ -n "$m" ] && echo $(( m / 1024 )) || echo 0
}
_df_bin="$(_disk_mib /usr/local/bin)"; _df_etc="$(_disk_mib /etc/wahpanel)"; _df_log="$(_disk_mib /var/log/wahpanel)"; _df_lib="$(_disk_mib /var/lib/wahpanel)"; _df_tmp="$(_disk_mib /tmp)"
_env_old_sha256="none"; if [ -f "$ENV_FILE" ]; then _env_old_sha256="$( (sha256sum "$ENV_FILE" 2>/dev/null || shasum -a 256 "$ENV_FILE" 2>/dev/null) | awk '{print $1}' || echo unknown )"; fi
_bin_old_sha256="none"; if [ -x "$BINARY_DEST" ]; then _bin_old_sha256="$( (sha256sum "$BINARY_DEST" 2>/dev/null || shasum -a 256 "$BINARY_DEST" 2>/dev/null) | awk '{print $1}' || echo unknown )"; fi
# TCP port occupancy on the 5 panel-relevant ports: 80 443 7080 8090 2053
_port_owner() {
  local p="$1" proc=""
  if have ss; then
    proc="$(ss -ltnp 2>/dev/null | awk -v P=":$p$" '$4 ~ P {if ($0 ~ /users:\(\("/) {sub(/^.*users:\(\("/, ""); sub(/".*$/, ""); print} else {print "LISTEN"}; exit}')"
  fi
  [ -n "$proc" ] && echo "$proc" || echo "free"
}
_p80="$(_port_owner 80)"; _p443="$(_port_owner 443)"; _p7080="$(_port_owner 7080)"; _p8090="$(_port_owner 8090)"; _p2053="$(_port_owner 2053)"

_emit_json INFO "preflight os" '{"os_id":"'"${_OS_ID}"'","os_version":"'"${_OS_V}"'","os_pretty_name":"'"${_OS_NAME}"'","kernel":"'"${_KERNEL}"'","arch":"'"${_ARCH}"'","virt":"'"${_VIRT}"'","hostname":"'"${_HOSTNAME_SH}"'"}'
_emit_json INFO "preflight cyberpanel" '{"detected_neighbour":"'"${PANEL_NEIGHBOUR:-NATIVE}"'","cyberpanel_version":"'"${_CP_VER}"'","lscpd_active":'"${_LSCPD_ACTIVE}"',"openlitespeed_state":"'"${_OLS_MAJOR}"'","ols_owner_cyberpanel":'"${_CYBERPANEL_OLS_OWNER:-0}"'}'
_emit_json INFO "preflight disk_mib_free" '{"usr_local_bin":'"${_df_bin}"',"etc_wahpanel":'"${_df_etc}"',"var_log_wahpanel":'"${_df_log}"',"var_lib_wahpanel":'"${_df_lib}"',"tmp":'"${_df_tmp}"'}'
_emit_json INFO "preflight port_occupancy" '{"80":"'"${_p80}"'","443":"'"${_p443}"'","7080":"'"${_p7080}"'","8090":"'"${_p8090}"'","2053":"'"${_p2053}"'"}'
_emit_json INFO "preflight existing_artifacts" '{"env_file_sha256":"'"${_env_old_sha256}"'","wahpanel_bin_sha256":"'"${_bin_old_sha256}"'","service_file_exists":'"$([ -f "$SERVICE_FILE" ] && echo 1 || echo 0)"',"db_dir_exists":'"$([ -d "$DB_DIR" ] && echo 1 || echo 0)"'}'
unset _p80 _p443 _p7080 _p8090 _p2053 _df_bin _df_etc _df_log _df_lib _df_tmp _env_old_sha256 _bin_old_sha256 _OS_ID _OS_V _OS_NAME _KERNEL _ARCH _VIRT _HOSTNAME_SH _CP_VER _LSCPD_ACTIVE _OLS_MAJOR _OLS_VER

# Detect distro (Ubuntu/debian primary)
PKG=""
if [ -f /etc/os-release ]; then
  # shellcheck disable=SC1091
  . /etc/os-release
  case "${ID:-}" in
    ubuntu|debian)                      PKG="apt" ;;
    almalinux|centos|rhel|rocky|fedora) PKG="dnf" ;;
    alpine)                             PKG="apk" ;;
    arch)                               PKG="pacman" ;;
  esac
fi
if [ -z "$PKG" ]; then
  # Guess from available package managers
  have apt-get && PKG="apt" || have dnf && PKG="dnf" || have apk && PKG="apk" || true
fi
[ -n "$PKG" ] || warn "Could not detect package manager; skipping dependency install — make sure curl, unzip, openssl, sqlite3 are available."

log "wahPanel installer (${PRETTY_NAME:-linux} · pkg=${PKG:-none})"

# ------------------------------ base deps ------------------------------------
install_deps() {
  case "$1" in
    apt)
      export DEBIAN_FRONTEND=noninteractive
      apt-get update -y
      apt-get install -y --no-install-recommends \
        ca-certificates curl wget openssh-server unzip tar gzip sqlite3 openssl \
        ufw certbot jq acl sudo
      # Ubuntu ships ssh.socket (socket-activated) and ssh.service — ensure one is enabled
      systemctl enable --now ssh.service >/dev/null 2>&1 || \
      systemctl enable --now sshd.service >/dev/null 2>&1 || \
        warn "openssh-server installed but could not enable ssh.service — enable manually with systemctl enable ssh"
      ;;
    dnf)
      dnf install -y ca-certificates curl wget openssh-server unzip tar gzip sqlite \
        openssl ufw certbot jq acl firewalld || true
      systemctl enable --now sshd.service >/dev/null 2>&1 || \
        warn "openssh-server installed but could not enable sshd.service — enable manually with systemctl enable sshd"
      ;;
    apk)
      apk add --no-cache ca-certificates curl wget openssh unzip tar gzip sqlite \
        openssl ufw certbot jq acl || true
      rc-update add sshd default >/dev/null 2>&1 || true
      rc-service sshd start >/dev/null 2>&1 || \
        warn "openssh installed but could not start sshd — run: rc-update add sshd default && rc-service sshd start"
      ;;
    pacman)
      pacman -Syu --noconfirm --needed ca-certificates curl wget openssh unzip tar \
        gzip sqlite openssl ufw certbot jq acl || true
      systemctl enable --now sshd.service >/dev/null 2>&1 || \
        warn "openssh installed but could not enable sshd.service — enable manually with systemctl enable sshd"
      ;;
  esac
}
if [ "$PANEL_NEIGHBOUR" != "NATIVE" ]; then
  log "Co-existence: preserving existing packages and SSH services"
  for b in curl unzip openssl sqlite3; do
    have "$b" || die "missing required binary: $b; install it manually then rerun"
  done
elif [ -n "$PKG" ]; then
  log "Installing base utilities via $PKG"
  install_deps "$PKG" || warn "some packages could not be installed; continuing"
  ok "Base utilities done"
else
  for b in curl unzip openssl sqlite3; do
    have "$b" || die "missing required binary: $b (install via your package manager then rerun)"
  done
fi

# ------------------------------ download zip ---------------------------------
log "Downloading ${DOWNLOAD_URL}"
curl -fsSL --retry 3 --retry-delay 2 --proto '=https' --tlsv1.2 \
  "$DOWNLOAD_URL" -o "$TMP_ZIP" \
  || die "Failed to download wahpanel archive from ${DOWNLOAD_URL}"
[ -s "$TMP_ZIP" ] || die "Downloaded zip is empty: ${TMP_ZIP}"
ok "Downloaded ($(du -h "$TMP_ZIP" | cut -f1))"

# ---------------------------------------------------------------------------
# SHA-256 checksum verification — REQUIRED, not optional.
#   The CDN MUST publish a matching wahpanel.zip.sha256 sidecar at
#   ${DOWNLOAD_URL}.sha256.  If this file is missing / empty / mismatched we
#   REFUSE to install because we cannot prove the bytes we are about to run
#   as root correspond to the release the maintainer signed off on.
#
#   Escape hatch (not recommended, for air-gapped networks only):
#       WAHPANEL_SKIP_CHECKSUM=1  sudo bash ./install.sh
#   This will print a prominent WARN banner and continue.  Anyone enabling
#   this on a public server should have a very good reason.
# ---------------------------------------------------------------------------
_SIDE_EXPECTED=""
if [ "${WAHPANEL_SKIP_CHECKSUM:-0}" != "1" ]; then
  log "Verifying SHA-256 checksum (source: ${CHECKSUM_URL})"
  if ! curl -fsSL --retry 2 --retry-delay 2 --max-time 15 --proto '=https' --tlsv1.2 \
         "$CHECKSUM_URL" -o "${TMP_ZIP}.sha256"; then
    die "Failed to download SHA-256 sidecar from ${CHECKSUM_URL}.
       The sidecar is REQUIRED for integrity verification of the binary that
       will be installed and executed as root. Possible causes:
         - CDN cache not yet updated after a new release upload
         - Network egress blocks the .sha256 extension
         - You are on an air-gapped / offline network
       To verify manually, compare:  sha256sum ${TMP_ZIP}
       against the hash published at https://wahpanel.com/download/
       To skip verification entirely (NOT recommended for public servers):
         WAHPANEL_SKIP_CHECKSUM=1  sudo bash $0"
  fi
  if [ ! -s "${TMP_ZIP}.sha256" ]; then
    die "Downloaded SHA-256 sidecar is empty (${TMP_ZIP}.sha256). Refusing to install unverified binary."
  fi
  # Strip CR just in case the sidecar was produced on Windows with CRLF endings
  _SIDE_EXPECTED=$(tr -d '\r' < "${TMP_ZIP}.sha256" | awk '{print $1}' | head -1)
  _SIDE_ACTUAL=$(sha256sum "$TMP_ZIP" | awk '{print $1}')
  if [ -z "${_SIDE_EXPECTED}" ]; then
    die "SHA-256 sidecar downloaded but contained no parseable hash. Refusing to install unverified binary."
  fi
  if [ "${_SIDE_EXPECTED}" != "${_SIDE_ACTUAL}" ]; then
    die "SHA-256 CHECKSUM MISMATCH.
       Expected (from ${CHECKSUM_URL}):  ${_SIDE_EXPECTED}
       Actual   (sha256sum ${TMP_ZIP}):   ${_SIDE_ACTUAL}
       This almost always means a corrupted or tampered download. DO NOT SKIP
       without manually verifying the hash against the release page at
       https://wahpanel.com/download/ — if the server-side has been replaced
       by an attacker your only protection is this mismatch check.
       Emergency escape hatch (CONFIRM MANUALLY FIRST):
         WAHPANEL_SKIP_CHECKSUM=1  sudo bash $0"
  fi
  ok "SHA-256 checksum verified (${_SIDE_EXPECTED:0:12}… matches)"
else
  warn "===================================================================="
  warn "  WAHPANEL_SKIP_CHECKSUM=1  —  integrity check SKIPPED"
  warn "===================================================================="
  warn "  The SHA-256 sidecar integrity check was disabled by environment"
  warn "  variable. The binary you are about to install as root has NOT"
  warn "  been verified against the official release hash."
  warn "  Only skip checksum verification on trusted, air-gapped networks."
  warn "===================================================================="
fi
unset _SIDE_EXPECTED _SIDE_ACTUAL

# ------------------------------ extract + install binary --------------------
log "Extracting archive"
if ! unzip -qq -o "$TMP_ZIP" -d "$TMP_EXTRACT"; then
  die "Failed to unzip ${TMP_ZIP}. Corrupted or unsupported archive."
fi

# Find binary (support nested directories, e.g. wahpanel-linux-amd64/wahpanel)
BINARY_SRC="$(find "$TMP_EXTRACT" -type f -name "${BINARY_NAME}" -perm -0111 | head -1)"
if [ -z "$BINARY_SRC" ]; then
  # Maybe not marked executable yet — try again by filename only
  BINARY_SRC="$(find "$TMP_EXTRACT" -type f -name "${BINARY_NAME}" | head -1)"
fi
[ -n "$BINARY_SRC" ] || die "No ${BINARY_NAME} binary found inside zip archive (expected at root, or in a subfolder)."

install -d -m 0755 "$INSTALL_BIN_DIR"
install -m 0755 -o root -g root "$BINARY_SRC" "$BINARY_DEST"
BVER="$($BINARY_DEST --version 2>/dev/null || echo '(build-embedded)')"
ok "Installed ${BINARY_DEST} (version: ${BVER})"

# ------------------------------ CLI symlinks ---------------------------------
# Strategy: ONE binary, MANY invocations. We dispatch by os.Args[0] basename
# inside the Go daemon so that CyberPanel-style short names work:
#
#   adminPass <pw>        → same as  wahpanel adminpass <pw>
#   wahpanel-adminpass    → same as  wahpanel adminpass
#   wahpanel-port 8443    → same as  wahpanel port 8443
#
# Symlinks into /usr/local/bin (early in root's secure PATH) AND /usr/bin
# (default PATH location for most shells — guarantees tab-completion).
log "Registering wahPanel CLI aliases (single-binary subcommand dispatch)"
ALIASES="wahpanel-adminpass wahpanel-port wahpanel-creds wahpanel-show-creds"
if [ "$PANEL_NEIGHBOUR" = "NATIVE" ]; then
  ALIASES="$ALIASES adminPass"
fi
for alias_name in $ALIASES; do
  if [ "$PANEL_NEIGHBOUR" != "NATIVE" ] && { [ -e "${INSTALL_BIN_DIR}/${alias_name}" ] || [ -L "${INSTALL_BIN_DIR}/${alias_name}" ]; }; then
    [ "$(readlink "${INSTALL_BIN_DIR}/${alias_name}" 2>/dev/null || true)" = "$BINARY_DEST" ] \
      || die "Existing CLI alias ${alias_name} belongs to another installation"
  fi
  ln -sf "${BINARY_DEST}" "${INSTALL_BIN_DIR}/${alias_name}"
  chown -h root:root "${INSTALL_BIN_DIR}/${alias_name}"
  # /usr/bin copy keeps the `$ which adminPass` UX identical to CyberPanel.
  ln -sf "${BINARY_DEST}" "/usr/bin/${alias_name}" 2>/dev/null || true
done
ok "Symlinks installed: wahpanel-adminpass · wahpanel-port · adminPass · wahpanel-creds  →  ${BINARY_DEST}"
ok "(Verify with: \$ which adminPass  →  ${INSTALL_BIN_DIR}/adminPass  (→ wahpanel binary dispatch))"

# ------------------------------ libexec helper scripts -----------------------
# Helper #1: wahpanel-preflight.sh — runs as REAL ROOT (ExecStartPre=+ in the
# systemd unit file) BEFORE mount namespace, BEFORE User=wahpanel setuid().
# GUARANTEES every path listed in ReadWritePaths= exists with correct owner+mode
# on EVERY service start. This is the ONLY mechanism robust enough to survive
# a user doing "rm -rf /home/vhosts" and rebooting. We use a standalone script
# (not an inlined "sh -c '...'" in the service file) because systemd unit-file
# parsing rules for multi-line values / inline "# comments" are NOT shell
# compatible — "Unbalanced quoting, ignoring" fatal errors are unavoidable with
# an inlined multi-line body. See install.sh notes near unit-file section.
LIBEXEC_DIR="/usr/local/lib/wahpanel"
install -d -m 0755 "${LIBEXEC_DIR}"
PREFLIGHT_HELPER="${LIBEXEC_DIR}/wahpanel-preflight.sh"
cat > "${PREFLIGHT_HELPER}" <<'PREFLIGHT'
#!/bin/sh
# wahpanel-preflight.sh — systemd ExecStartPre=+ root helper
# Runs AFTER namespace setup (note: since namespace = ProtectSystem=strict,
# /home/vhosts no longer appears in ReadWritePaths= — it is a HOST symlink →
# /var/lib/wahpanel/vhosts which is inside StateDirectory so exists already).
# Purpose here: enforce modes on known dirs + create /tmp scratch that
# PrivateTmp= doesn't cover / survive restart.
set -eu

# (A) Runtime scratch dirs
mkdir -p /tmp/wahpanel-runtime
chown wahpanel:wahpanel /tmp/wahpanel-runtime
chmod 0700 /tmp/wahpanel-runtime

# (B) Re-enforce modes on the 5 systemd-managed dirs (and vhosts inside state)
for d in /var/lib/wahpanel /var/lib/wahpanel/vhosts /var/lib/wahpanel/db \
         /var/log/wahpanel /var/cache/wahpanel /run/wahpanel; do
  if [ -d "$d" ]; then
    case "$d" in
      */db)                 chmod 0750 "$d" ;;
      */vhosts)             chmod 0751 "$d" ;;
      */cache/*|*/cache)    chmod 0700 "$d" ;;
      */run/*|*/run)        chmod 0700 "$d" ;;
      *)                    chmod 0750 "$d" ;;
    esac
    chown wahpanel:wahpanel "$d"
  fi
done
if [ -d /etc/wahpanel ]; then
  chmod 0750 /etc/wahpanel
  chown root:wahpanel /etc/wahpanel
fi

exit 0
PREFLIGHT
chmod 0700 "${PREFLIGHT_HELPER}"
chown root:root "${PREFLIGHT_HELPER}"
ok "Installed preflight helper: ${PREFLIGHT_HELPER}  (systemd ExecStartPre=+ root self-heal)"

# ------------------------------ directories + perms --------------------------
#
# NAMESPACE 226 FIX (FINAL — replaces /home/vhosts in ReadWritePaths=):
#   Systemd ProtectSystem=strict runs mount namespace setup BEFORE ANY
#   Exec*= directives (including ExecStartPre=+). ANY path listed in
#   ReadWritePaths= that does NOT exist on disk at that exact moment causes
#   status=226/NAMESPACE fatal error, and no script we write via ExecStartPre
#   ever gets a chance to mkdir. The 5 *Directory= systemd directives ARE
#   created BEFORE namespace, and StateDirectory= → /var/lib/wahpanel is
#   therefore a path that LITERALLY cannot be missing.
#
#   Strategy: store the actual vhost document trees UNDER StateDirectory:
#     /var/lib/wahpanel/vhosts     (always exists, in StateDirectory= tree)
#   and install a HOST-LEVEL symlink at install time:
#     /home/vhosts  →  /var/lib/wahpanel/vhosts
#   This symlink is a real filesystem object (installed ONCE by root outside
#   any namespace). In the daemon's mount namespace, /home/vhosts follows the
#   symlink into /var/lib/wahpanel/vhosts, which is ALREADY writable via the
#   implicit StateDirectory= allowlist. We therefore REMOVE /home/vhosts from
#   ReadWritePaths= entirely, so the ReadWritePaths= list only contains
#   systemd-created paths that are immune to accidental "rm -rf /home/vhosts".
#
#   Result: it is now IMPOSSIBLE for the namespace setup to fail because a
#   user deleted a directory. StateDirectory= recreates /var/lib/wahpanel
#   every start, and symlink target resolution always succeeds.
log "Creating runtime directories"
install -d -m 0750 "${INSTALL_DIR}" "${DB_DIR}"
install -d -m 0750 "${LOG_DIR}"
install -d -m 0750 "${CONF_DIR}"
# Actual vhost data lives inside StateDirectory tree (exists forever):
install -d -m 0751 "${INSTALL_DIR}/vhosts"
# Create symlink /home/vhosts → /var/lib/wahpanel/vhosts (host level, install once):
if [ "$PANEL_NEIGHBOUR" = "NATIVE" ]; then
if [ -L /home/vhosts ] || [ -e /home/vhosts ]; then
  # Old real dir exists: migrate its contents to StateDirectory tree if empty,
  # then replace with symlink.
  if [ -d /home/vhosts ] && [ ! -L /home/vhosts ]; then
    # If user already placed files in old /home/vhosts, move them across
    # before replacing with symlink so no data loss.
    find /home/vhosts -mindepth 1 -maxdepth 1 -exec mv -f {} "${INSTALL_DIR}/vhosts/" \; 2>/dev/null || true
    rmdir /home/vhosts 2>/dev/null || true
  else
    rm -f /home/vhosts
  fi
fi
ln -sfn "${INSTALL_DIR}/vhosts" /home/vhosts
else
  log "Co-existence: preserving /home/vhosts and existing CLI aliases"
fi
install -d -m 0700 /run/wahpanel /var/cache/wahpanel /tmp/wahpanel-runtime 2>/dev/null || true

# ------------------------------ dedicated system user -----------------------
# wahpanel daemon runs as an unprivileged service account (no shell, no
# interactive login) and gains admin rights ONLY via the narrow passwordless
# sudoers rules written below. This is the privilege boundary for the Go
# exec.RunPrivileged() / RunSudo() helpers.
log "Creating wahpanel system user"
if ! id -u wahpanel >/dev/null 2>&1; then
  useradd --system --shell /bin/false --home-dir /var/lib/wahpanel --no-create-home wahpanel \
    || adduser --system --disabled-password --disabled-login --shell /bin/false \
              --home /var/lib/wahpanel --no-create-home wahpanel
  ok "Created user wahpanel (uid=$(id -u wahpanel) gid=$(id -g wahpanel))"
else
  ok "User wahpanel already exists (uid=$(id -u wahpanel))"
fi

# ----------------------------------------------------------------------------
#  "wah" INSTALLER WORKER user — separate privilege boundary for the
#  install-worker child processes.  The master HTTP daemon never invokes
#  apt/dpkg/certbot directly — it forks wahpanel install-worker children
#  that sudo(8) to THIS user, which has a narrow installer-only sudoers
#  whitelist.  If an installer child is compromised, the blast radius is
#  limited to exactly the installer commands enumerated below — not the
#  full run-time daemon rule set.
# ----------------------------------------------------------------------------
log "Creating wah installer-worker system user"
if ! id -u wah >/dev/null 2>&1; then
  useradd --system --no-create-home --shell /usr/sbin/nologin wah \
    || adduser --system --disabled-password --disabled-login --no-create-home \
              --shell /usr/sbin/nologin wah
  ok "Created user wah (uid=$(id -u wah) gid=$(id -g wah))"
else
  ok "User wah already exists (uid=$(id -u wah))"
fi

# Join groups so wah can: read TLS certs (ssl-cert), read/write vhost
# document roots side-by-side with OLS PHP workers (www-data), and read
# wahpanel-owned DB/state files needed for the worker child to open
# system.db (group = wahpanel).
usermod -a -G wahpanel,www-data,ssl-cert wah 2>/dev/null || true
ok "User wah group memberships: wahpanel www-data ssl-cert"

chown -R wahpanel:wahpanel "${INSTALL_DIR}" "${LOG_DIR}" "${INSTALL_DIR}/vhosts"
chown -R wahpanel:wahpanel /run/wahpanel /var/cache/wahpanel /tmp/wahpanel-runtime 2>/dev/null || true
chown root:wahpanel "${CONF_DIR}"
chmod 0750 "${CONF_DIR}"
# The symlink itself should be owned root so wahpanel user cannot unlink/modify it:
if [ "$PANEL_NEIGHBOUR" = "NATIVE" ]; then
  chown -h root:root /home/vhosts 2>/dev/null || true
fi

# ---- PRE-FLIGHT SANDBOX SELF-HEAL (auto-creates + chowns + chmods EVERY dir) -
# NOTE: /home/vhosts is NO LONGER listed here or in ReadWritePaths= (it is
# a host-level symlink → /var/lib/wahpanel/vhosts). Only REAL paths that
# appear in the ACTUAL ReadWritePaths= / *Directory= lines are verified.
PORTABILITY_NOTE_DONE=1
while IFS=' ' read -r _d _owner _mode _comment; do
  # Skip blank lines and bash-style comments (start with #)
  [ -z "${_d}" ] && continue
  case "${_d}" in \#*) continue ;; esac

  # Sanity: owner must contain ':' (user:group), mode must be 4 octal digits
  case "${_owner}" in *:*) ;; *)
    die "Pre-flight: bad owner-field for dir ${_d}: '${_owner}'.  Expected 'user:group'."
  ;; esac
  case "${_mode}" in 0[0-7][0-7][0-7]) ;; *)
    die "Pre-flight: bad mode-field for dir ${_d}: '${_mode}'.  Expected 4-digit octal (0750, 0700, etc)."
  ;; esac

  # (1) Create if missing — install -d = mkdir -p + chmod mode, atomic
  if [ ! -d "${_d}" ]; then
    install -d -m "${_mode}" "${_d}" || \
      die "Pre-flight: cannot create required sandbox directory: ${_d} (mode ${_mode}).  Manual fix:  install -d -m ${_mode} ${_d}"
  fi

  # (2) Force ownership — idempotent (safe to run every install / re-install)
  chown "${_owner}" "${_d}" 2>/dev/null || \
    die "Pre-flight: cannot chown ${_d} -> ${_owner}.  Manual fix:  chown ${_owner} ${_d}"

  # (3) Force mode — idempotent, always run to align with *DirectoryMode=
  #     lines in the generated systemd service file so systemd never emits
  #     "ConfigurationDirectory X already exists but the mode is different"
  chmod "${_mode}" "${_d}" 2>/dev/null || \
    die "Pre-flight: cannot chmod ${_d} -> ${_mode}.  Manual fix:  chmod ${_mode} ${_d}"
done <<'FLIGHT_LIST'
# path                            owner:group        mode   description (ignored)
/var/lib/wahpanel                 wahpanel:wahpanel  0750   state dir (DB shards, runtime state)
/var/lib/wahpanel/db              wahpanel:wahpanel  0750   5 SQLite shard DBs
/var/lib/wahpanel/vhosts          wahpanel:wahpanel  0751   vhost document root tree (symlink target of /home/vhosts)
/var/log/wahpanel                 wahpanel:wahpanel  0750   daemon NDJSON + stderr logs
/etc/wahpanel                     root:wahpanel      0750   config + JWT secret env (root-writable, wahpanel-readable)
/run/wahpanel                     wahpanel:wahpanel  0700   runtime sockets + pidfiles (tmpfs)
/var/cache/wahpanel               wahpanel:wahpanel  0700   staged downloads + build cache
/tmp/wahpanel-runtime             wahpanel:wahpanel  0700   scratch tmpdir (private, wiped on reboot)
FLIGHT_LIST
# End of pre-flight self-heal.
ok "Sandbox pre-flight: all 8 dirs created + owned + moded OK (auto-heal: any missing dirs were recreated)"
ok "Dirs ready: /var/lib/wahpanel /var/lib/wahpanel/db /var/lib/wahpanel/vhosts /var/log/wahpanel /etc/wahpanel  (symlink /home/vhosts -> /var/lib/wahpanel/vhosts)"

# Launch package workers through the system manager, outside the daemon's
# read-only mount namespace. No caller-selected executable, path or unit.
WORKER_HELPER="${LIBEXEC_DIR}/installer-worker.sh"
cat > "${WORKER_HELPER}" <<'WORKER'
#!/bin/sh
set -eu
[ "$#" -eq 3 ] || { echo "usage: installer-worker.sh start|stop|verify job_id component_id" >&2; exit 2; }
action="$1"
job="$2"
component="$3"
case "$job" in ''|*[!0-9]*|0) echo "invalid installer job ID" >&2; exit 2;; esac
[ "${#job}" -le 18 ] || { echo "installer job ID too long" >&2; exit 2; }
case "$component" in
  openlitespeed|mariadb|postgresql|php82|php83|php84|redis|firewall|powerdns|pureftpd|letsencrypt|website_mgmt) ;;
  remove_postgresql|remove_php82|remove_php83|remove_php84|remove_redis|remove_powerdns|remove_pureftpd|remove_letsencrypt) ;;
  *) echo "unsupported installer component" >&2; exit 2;;
esac
unit="wahpanel-install-${job}-${component}.service"
cancel_marker="/run/wahpanel/install-cancel-${job}-${component}"
case "$action" in
  start|verify)
    if [ "$action" = start ]; then
      [ ! -e "$cancel_marker" ] || { echo "installer worker was cancelled before startup" >&2; exit 130; }
      set -- /usr/local/bin/wahpanel install-worker /var/lib/wahpanel "$job" "$component" /var/lib/wahpanel/db
      condition="ExecCondition=/usr/bin/test ! -e $cancel_marker"
      runtime_limit=7200s
    else
      unit="wahpanel-verify-${job}-${component}.service"
      set -- /usr/local/bin/wahpanel verify-component "$component"
      condition="ExecCondition=/usr/bin/true"
      runtime_limit=120s
    fi
    exec /usr/bin/env -i PATH=/usr/sbin:/usr/bin:/sbin:/bin LANG=C \
      /usr/bin/systemd-run --system --quiet --wait --pipe --collect \
      --unit="$unit" --service-type=exec \
      --property=User=root --property=Group=root \
      --property=WorkingDirectory=/var/lib/wahpanel \
      --property=BindsTo=wahpanel.service --property=After=wahpanel.service \
      --property="$condition" \
      --property=KillMode=control-group --property=TimeoutStopSec=8s \
      --property="RuntimeMaxSec=$runtime_limit" --property=MemoryMax=1G --property=CPUQuota=100% \
      --setenv=GOMAXPROCS=2 --setenv=GOMEMLIMIT=384MiB \
      "$@"
    ;;
  stop)
    # Prevent a late service registration from escaping a concurrent stop.
    /usr/bin/touch "$cancel_marker"
    exec /usr/bin/env -i PATH=/usr/sbin:/usr/bin:/sbin:/bin LANG=C \
      /usr/bin/systemctl stop "$unit"
    ;;
  *) echo "invalid installer worker action" >&2; exit 2;;
esac
WORKER
chmod 0755 "${WORKER_HELPER}"
chown root:root "${WORKER_HELPER}"

# ------------------------------ passwordless sudoers rules ------------------
log "Writing /etc/sudoers.d/wahpanel (passwordless, rule-scoped escalation)"
SUDOERS_TMP="$(mktemp /tmp/wahpanel-sudoers.XXXXXX)"
cat > "${SUDOERS_TMP}" <<'SUDOERS'
# ============================================================================
#  wahPanel — targeted passwordless sudo escalation
#  Rules cover ONLY the absolute binary paths actually invoked from the
#  Go daemon (RunSudo → /usr/bin/sudo -- <abs> <args>).
#
#  SUDOERS COMPATIBILITY NOTES (hardened against Ubuntu 22.04/24.04 + RHEL 8/9):
#    (1) Old sudo setting was RENAMED in sudo 1.9.14+ / modern distros AND it's
#        disabled by default everywhere now — so we simply do not set it.
#        Using the old spelling on new Ubuntu (24.04) → visudo rejects it.
#    (2) Colon ':' inside the rule source text is parsed as Runas-spec delimiter —
#        NEVER write literal OWNER-GROUP separator in sudoers rule bodies (e.g.
#        chown OWNER-GROUP PATH written verbatim confuses the parser). Use simple
#        trailing * to match any runtime arg combo (colons in matched ARGV are fine).
#    (3) Character-class globs like [N] are BANNED in sudoers args. Only simple
#        trailing shell globs (prefix*) work across all sudo versions.
# ============================================================================
Defaults:wahpanel secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

# ---------- systemd / service lifecycle ------------------------------------
wahpanel ALL=(root) NOPASSWD: /bin/systemctl start   *
wahpanel ALL=(root) NOPASSWD: /bin/systemctl stop    *
wahpanel ALL=(root) NOPASSWD: /bin/systemctl restart *
wahpanel ALL=(root) NOPASSWD: /bin/systemctl reload  *
wahpanel ALL=(root) NOPASSWD: /bin/systemctl enable  *
wahpanel ALL=(root) NOPASSWD: /bin/systemctl disable *
wahpanel ALL=(root) NOPASSWD: /bin/systemctl status  *
wahpanel ALL=(root) NOPASSWD: /bin/systemctl daemon-reload
wahpanel ALL=(root) NOPASSWD: /usr/bin/systemctl start   *
wahpanel ALL=(root) NOPASSWD: /usr/bin/systemctl stop    *
wahpanel ALL=(root) NOPASSWD: /usr/bin/systemctl restart *
wahpanel ALL=(root) NOPASSWD: /usr/bin/systemctl reload  *
wahpanel ALL=(root) NOPASSWD: /usr/bin/systemctl enable  *
wahpanel ALL=(root) NOPASSWD: /usr/bin/systemctl disable *
wahpanel ALL=(root) NOPASSWD: /usr/bin/systemctl status  *
wahpanel ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload

# ---------- OpenLiteSpeed control binary (lswsctrl) ------------------------
wahpanel ALL=(root) NOPASSWD: /usr/local/lsws/bin/lswsctrl *

# ---------- package managers (OLS, PHP LSPHP, MariaDB, Certbot, etc.) ------
wahpanel ALL=(root) NOPASSWD: /usr/bin/apt-get update
wahpanel ALL=(root) NOPASSWD: /usr/bin/apt-get install -y *
wahpanel ALL=(root) NOPASSWD: /usr/bin/apt-get remove  -y *
wahpanel ALL=(root) NOPASSWD: /usr/bin/apt-get autoremove -y
wahpanel ALL=(root) NOPASSWD: /usr/bin/apt-get upgrade -y
wahpanel ALL=(root) NOPASSWD: /usr/bin/apt update
wahpanel ALL=(root) NOPASSWD: /usr/bin/apt install -y *
wahpanel ALL=(root) NOPASSWD: /usr/bin/apt remove  -y *
wahpanel ALL=(root) NOPASSWD: /usr/bin/dnf install -y *
wahpanel ALL=(root) NOPASSWD: /usr/bin/dnf remove  -y *
wahpanel ALL=(root) NOPASSWD: /usr/bin/dnf upgrade -y
wahpanel ALL=(root) NOPASSWD: /usr/bin/yum install -y *
wahpanel ALL=(root) NOPASSWD: /usr/bin/yum remove  -y *
wahpanel ALL=(root) NOPASSWD: /usr/bin/yum upgrade -y
wahpanel ALL=(root) NOPASSWD: /sbin/apk add --no-cache *
wahpanel ALL=(root) NOPASSWD: /sbin/apk del *

# ---------- firewall wrappers ----------------------------------------------
wahpanel ALL=(root) NOPASSWD: /usr/sbin/ufw allow *
wahpanel ALL=(root) NOPASSWD: /usr/sbin/ufw deny *
wahpanel ALL=(root) NOPASSWD: /usr/sbin/ufw delete *
wahpanel ALL=(root) NOPASSWD: /usr/sbin/ufw limit *
wahpanel ALL=(root) NOPASSWD: /usr/sbin/ufw --force enable
wahpanel ALL=(root) NOPASSWD: /usr/sbin/ufw --force disable
wahpanel ALL=(root) NOPASSWD: /usr/sbin/ufw status numbered
wahpanel ALL=(root) NOPASSWD: /usr/sbin/ufw reload
wahpanel ALL=(root) NOPASSWD: /usr/sbin/iptables -A *
wahpanel ALL=(root) NOPASSWD: /usr/sbin/iptables -I *
wahpanel ALL=(root) NOPASSWD: /usr/sbin/iptables -D *
wahpanel ALL=(root) NOPASSWD: /usr/sbin/iptables -L *
wahpanel ALL=(root) NOPASSWD: /usr/sbin/iptables -F
wahpanel ALL=(root) NOPASSWD: /usr/sbin/iptables-save
wahpanel ALL=(root) NOPASSWD: /usr/bin/firewall-cmd *

# ---------- file ownership / perms (File Manager, vhost chown, etc.) -------
#   IMPORTANT: no literal "user:group" text in these rules (see compat note #2).
#   The Go daemon passes runtime user:group via argv (e.g. chown "vhost1:www-data"),
#   and the simple trailing wildcard matches EVERY valid combination safely.
wahpanel ALL=(root) NOPASSWD: /usr/bin/chown *
wahpanel ALL=(root) NOPASSWD: /usr/bin/chown -R *
wahpanel ALL=(root) NOPASSWD: /usr/bin/chmod *
wahpanel ALL=(root) NOPASSWD: /usr/bin/chmod -R *
wahpanel ALL=(root) NOPASSWD: /bin/chown *
wahpanel ALL=(root) NOPASSWD: /bin/chown -R *
wahpanel ALL=(root) NOPASSWD: /bin/chmod *
wahpanel ALL=(root) NOPASSWD: /bin/chmod -R *

# ---------- Linux user & group management (mailbox users, vhost users) ---
wahpanel ALL=(root) NOPASSWD: /usr/sbin/useradd *
wahpanel ALL=(root) NOPASSWD: /usr/sbin/usermod *
wahpanel ALL=(root) NOPASSWD: /usr/sbin/userdel *
wahpanel ALL=(root) NOPASSWD: /usr/sbin/groupadd *
wahpanel ALL=(root) NOPASSWD: /usr/sbin/groupdel *
wahpanel ALL=(root) NOPASSWD: /usr/sbin/groupmod *

# ---------- ACME / certbot -------------------------------------------------
wahpanel ALL=(root) NOPASSWD: /usr/bin/certbot *
wahpanel ALL=(root) NOPASSWD: /usr/bin/certbot-auto *

# ---------- MariaDB / MySQL watchdog deadlock killer -----------------------
wahpanel ALL=(root) NOPASSWD: /usr/bin/mysql --batch -e *
wahpanel ALL=(root) NOPASSWD: /usr/bin/mysqladmin ping
wahpanel ALL=(root) NOPASSWD: /usr/bin/mysqladmin processlist
wahpanel ALL=(root) NOPASSWD: /usr/bin/mysqladmin kill *

# ---------- OpenLiteSpeed standalone config syntax test -------------------
wahpanel ALL=(root) NOPASSWD: /usr/local/lsws/bin/openlitespeed -t
wahpanel ALL=(root) NOPASSWD: /usr/local/lsws/bin/openlitespeed -v

# ---------- Watchdog: orphan port clearance + drop_caches + file ops -------
wahpanel ALL=(root) NOPASSWD: /usr/bin/fuser -k *
wahpanel ALL=(root) NOPASSWD: /bin/fuser -k *
wahpanel ALL=(root) NOPASSWD: /usr/bin/pkill -9 *
wahpanel ALL=(root) NOPASSWD: /usr/bin/killall -9 *
wahpanel ALL=(root) NOPASSWD: /usr/bin/tee /proc/sys/vm/drop_caches
wahpanel ALL=(root) NOPASSWD: /bin/tee /proc/sys/vm/drop_caches
wahpanel ALL=(root) NOPASSWD: /bin/mv *
wahpanel ALL=(root) NOPASSWD: /usr/bin/mv *
wahpanel ALL=(root) NOPASSWD: /bin/cp *
wahpanel ALL=(root) NOPASSWD: /usr/bin/cp *
wahpanel ALL=(root) NOPASSWD: /usr/bin/truncate -s 0 *
wahpanel ALL=(root) NOPASSWD: /bin/sync

# ---------- wah INSTALLER-WORKER CHILD: narrow installer-only rule set ------
#  RunAs: root
#  Scope: ONLY the exact binaries the install-worker child needs to call
#         to install packages, enable services, create vhost dirs, issue
#         certificates, and (meta) invoke itself via sudo.  If the
#         install-worker binary is ever compromised via a poisoned package
#         mirror, the attacker CANNOT pivot to the full wahpanel daemon
#         rule set above — this is a dedicated privilege boundary.
Defaults:wah secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

# Package managers — installer calls these for OLS, PHP, MariaDB, Redis, etc.
wah ALL=(root) NOPASSWD: /usr/bin/apt-get update
wah ALL=(root) NOPASSWD: /usr/bin/apt-get install -y *
wah ALL=(root) NOPASSWD: /usr/bin/apt-get remove  -y *
wah ALL=(root) NOPASSWD: /usr/bin/apt-get autoremove -y
wah ALL=(root) NOPASSWD: /usr/bin/apt-get upgrade -y
wah ALL=(root) NOPASSWD: /usr/bin/apt update
wah ALL=(root) NOPASSWD: /usr/bin/apt install -y *
wah ALL=(root) NOPASSWD: /usr/bin/apt remove  -y *

# Service lifecycle — installer needs to enable/start OLS, MariaDB, Redis,
# PowerDNS, Pure-FTPd, firewalld/ufw, etc. after installing.
wah ALL=(root) NOPASSWD: /bin/systemctl start   *
wah ALL=(root) NOPASSWD: /bin/systemctl stop    *
wah ALL=(root) NOPASSWD: /bin/systemctl restart *
wah ALL=(root) NOPASSWD: /bin/systemctl enable  *
wah ALL=(root) NOPASSWD: /bin/systemctl disable *
wah ALL=(root) NOPASSWD: /bin/systemctl daemon-reload
wah ALL=(root) NOPASSWD: /usr/bin/systemctl start   *
wah ALL=(root) NOPASSWD: /usr/bin/systemctl stop    *
wah ALL=(root) NOPASSWD: /usr/bin/systemctl restart *
wah ALL=(root) NOPASSWD: /usr/bin/systemctl enable  *
wah ALL=(root) NOPASSWD: /usr/bin/systemctl disable *
wah ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload

# OLS native control binary (post-install restart + syntax test).
wah ALL=(root) NOPASSWD: /usr/local/lsws/bin/lswsctrl *
wah ALL=(root) NOPASSWD: /usr/local/lsws/bin/openlitespeed -t
wah ALL=(root) NOPASSWD: /usr/local/lsws/bin/openlitespeed -v

# Vhost document root + LetsEncrypt state dirs.  Installer creates these
# before packages/users exist, so we need root mkdir.  Simple trailing *
# glob avoids any literal "user:group" Runas-confusing text in the rule
# source (see compat note #2 above).
wah ALL=(root) NOPASSWD: /bin/mkdir -p *
wah ALL=(root) NOPASSWD: /usr/bin/mkdir -p *
wah ALL=(root) NOPASSWD: /bin/chown *
wah ALL=(root) NOPASSWD: /bin/chown -R *
wah ALL=(root) NOPASSWD: /bin/chmod *
wah ALL=(root) NOPASSWD: /bin/chmod -R *
wah ALL=(root) NOPASSWD: /usr/bin/chown *
wah ALL=(root) NOPASSWD: /usr/bin/chown -R *
wah ALL=(root) NOPASSWD: /usr/bin/chmod *
wah ALL=(root) NOPASSWD: /usr/bin/chmod -R *

# ACME / Certbot — installer issues the first LE cert via --standalone or
# --webroot after setting up vhosts.
wah ALL=(root) NOPASSWD: /usr/bin/certbot *
wah ALL=(root) NOPASSWD: /usr/bin/certbot-auto *

# META — master daemon forks install-worker children; when master drops
# privileges to run as wahpanel user it still needs to be able to elevate
# the worker to root (or to wah with another sudo hop).  Explicit absolute
# paths with a trailing wildcard cover every argument combo the master
# passes (stateDir, numeric jobID, alphanumeric componentID, --test-mode=1).
wahpanel ALL=(root) NOPASSWD: /usr/local/bin/wahpanel install-worker *
wahpanel ALL=(root) NOPASSWD: /usr/bin/wahpanel install-worker *
wahpanel ALL=(root) NOPASSWD: /usr/local/sbin/wahpanel install-worker *
wah      ALL=(root) NOPASSWD: /usr/local/bin/wahpanel install-worker *
wah      ALL=(root) NOPASSWD: /usr/bin/wahpanel install-worker *
wah      ALL=(root) NOPASSWD: /usr/local/sbin/wahpanel install-worker *
# Fixed, validated transient worker launcher; not arbitrary systemd-run.
wahpanel ALL=(root) NOPASSWD: /usr/local/lib/wahpanel/installer-worker.sh *
SUDOERS

# Validate syntax before atomically installing — sudo WILL refuse to load
# a broken sudoers file, so `visudo -cf` gate is a hard safety rule.
if have visudo; then
  SUDOERS_ERR="$(visudo -cf "${SUDOERS_TMP}" 2>&1)" || SUDOERS_FAIL=1
  if [ "${SUDOERS_FAIL:-0}" = "1" ] || ! printf '%s\n' "${SUDOERS_ERR}" | grep -qE 'parsed OK|OK$'; then
    error "================================================================"
    error "  SUDOERS SYNTAX CHECK FAILED"
    error "  Rules file: ${SUDOERS_TMP}"
    error "  Each failing line (visudo output):"
    while IFS= read -r line; do
      [ -n "$line" ] && error "    → $line"
    done <<EOF
${SUDOERS_ERR}
EOF
    error "================================================================"
    error "Dumping generated rules (for report):"
    error ""
    cat -n "${SUDOERS_TMP}" >/tmp/wahpanel-sudoers.dump 2>/dev/null || true
    while IFS= read -r line; do error "  $line"; done < /tmp/wahpanel-sudoers.dump
    error "================================================================"
    die "/etc/sudoers.d/wahpanel syntax check FAILED (visudo -cf). Failing rules printed above."
  fi
fi
install -m 0440 -o root -g root "${SUDOERS_TMP}" /etc/sudoers.d/wahpanel
rm -f "${SUDOERS_TMP}" /tmp/wahpanel-sudoers.dump
ok "sudoers installed: /etc/sudoers.d/wahpanel (0440 root:root, visudo syntax verified)"

# ------------------------------ admin env + JWT secret ----------------------
log "Writing ${ENV_FILE}"
if have openssl; then
  JWT_SECRET="$(openssl rand -hex 32)"
else
  JWT_SECRET="$(head -c 32 /dev/urandom 2>/dev/null | od -An -tx1 | tr -d ' \n' || head -c 32 /dev/urandom | base64 | tr -d '\n/+' | head -c 64)"
fi
WAHPANEL_ENVIRONMENT="${WAHPANEL_ENVIRONMENT:-production}"
WAHPANEL_NO_TECH_BANNER="${WAHPANEL_NO_TECH_BANNER:-1}"

# CyberPanel integration defaults.  Written to the env file for every host
# (not only when detected) so upgrades on CyberPanel hosts that were installed
# before the detect block existed still get the right defaults on next run.
# Enabled stays at 0 by default — admin opts in via Settings after generating
# a dedicated API scoped user (docs §6.1).
_WAH_CP_DEFAULT_BASE_URL="https://127.0.0.1:8090"
_WAH_VHOST_DIR="/home/vhosts"
if [ "$PANEL_NEIGHBOUR" != "NATIVE" ]; then
    _WAH_VHOST_DIR="${INSTALL_DIR}/vhosts"
fi
_WAH_CP_DEFAULT_SKIP_VERIFY="0"
if [ "$PANEL_NEIGHBOUR" = "CYBERPANEL" ]; then
    # On known CyberPanel hosts, default InsecureSkipVerify to 1 because
    # loopback :8090 serves the self-signed CyberPanel panel cert by default.
    # Admin can flip to 0 later if they install a real cert on lscpd.
    _WAH_CP_DEFAULT_SKIP_VERIFY="1"
fi

[ -f "$ENV_FILE" ] && warn "${ENV_FILE} already exists — preserving and only adding missing values"
if [ ! -f "$ENV_FILE" ]; then
  cat > "$ENV_FILE" <<EOF
# ================================================================
#  wahPanel runtime environment
#  Managed by install.sh — edits survive future upgrades
# ================================================================
WAHPANEL_ENVIRONMENT=${WAHPANEL_ENVIRONMENT}
WAHPANEL_NO_TECH_BANNER=${WAHPANEL_NO_TECH_BANNER}
WAHPANEL_PORT=${DEFAULT_PORT}
WAHPANEL_DB_DIR=${DB_DIR}
WAHPANEL_JWT_SECRET=${JWT_SECRET}
WAHPANEL_LOG_DIR=${LOG_DIR}
WAHPANEL_VHOST_DIR=${_WAH_VHOST_DIR}
WAHPANEL_ADMIN_BOOTSTRAP_USER=${ADMIN_USER}
# When non-root User=wahpanel in systemd, the daemon auto-detects
# privilege via EUID and falls back to sudo(8) for whitelisted binaries.
WAHPANEL_SUDO_ENABLED=1

# ================================================================
#  TLS / HTTPS listener configuration
#  Default: TLS enabled with auto self-signed cert under state dir
#  (/var/lib/wahpanel/tls/server.{crt,key}).  For a trusted cert,
#  install via certbot, then uncomment TLS_CERT/TLS_KEY below and
#  restart:  sudo systemctl restart wahpanel
# ================================================================
WAHPANEL_TLS=1
#WAHPANEL_TLS_CERT=/etc/letsencrypt/live/panel.example.com/fullchain.pem
#WAHPANEL_TLS_KEY=/etc/letsencrypt/live/panel.example.com/privkey.pem

# ================================================================
#  Co-existence / Neighbour Panel Detection
#  Set by installer auto-detect.  Do not hand-edit; re-run installer
#  to refresh.  Legal values: CYBERPANEL | CPANEL | PLESK | NATIVE
# ================================================================
WAHPANEL_PANEL_NEIGHBOUR=${PANEL_NEIGHBOUR:-NATIVE}

# ================================================================
#  CyberPanel REST API Integration (optional; opt-in)
#  Turn ENABLED to 1 via Settings → Panel Integrations AFTER
#  generating a dedicated "wahpanel_api" RESLLER user in CyberPanel
#  Users → API Access → Enable.  Credentials are stored in SQLite
#  settings (NOT here) — these env keys only carry base URL + TLS
#  knobs so the daemon can dial the correct endpoint.
#  See docs/co-existence/cyberpanel-secondary-install.md §6.
# ================================================================
WAHPANEL_CYBERPANEL_ENABLED=0
WAHPANEL_CYBERPANEL_BASE_URL=${_WAH_CP_DEFAULT_BASE_URL}
WAHPANEL_CYBERPANEL_INSECURE_SKIP_VERIFY=${_WAH_CP_DEFAULT_SKIP_VERIFY}
EOF
else
  # Persist the resolved port, including an explicit upgrade override or
  # automatic CyberPanel rebase, so service and postflight use the same port.
  cp -p "$ENV_FILE" "${ENV_FILE}.bak.wahpanel.install"
  sed -i '/^WAHPANEL_PORT=/d' "$ENV_FILE"
  echo "WAHPANEL_PORT=${DEFAULT_PORT}" >> "$ENV_FILE"
  grep -q '^WAHPANEL_JWT_SECRET=' "$ENV_FILE" 2>/dev/null \
    || echo "WAHPANEL_JWT_SECRET=${JWT_SECRET}" >> "$ENV_FILE"
  grep -q '^WAHPANEL_PORT=' "$ENV_FILE" 2>/dev/null \
    || echo "WAHPANEL_PORT=${DEFAULT_PORT}" >> "$ENV_FILE"
  grep -q '^WAHPANEL_SUDO_ENABLED=' "$ENV_FILE" 2>/dev/null \
    || echo "WAHPANEL_SUDO_ENABLED=1" >> "$ENV_FILE"
  grep -q '^WAHPANEL_ENVIRONMENT=' "$ENV_FILE" 2>/dev/null \
    || echo "WAHPANEL_ENVIRONMENT=${WAHPANEL_ENVIRONMENT}" >> "$ENV_FILE"
  grep -q '^WAHPANEL_NO_TECH_BANNER=' "$ENV_FILE" 2>/dev/null \
    || echo "WAHPANEL_NO_TECH_BANNER=${WAHPANEL_NO_TECH_BANNER}" >> "$ENV_FILE"
  # ---------- TLS / HTTPS keys: append if missing, never overwrite user value ----------
  if ! grep -Eq '^WAHPANEL_TLS=' "$ENV_FILE" 2>/dev/null; then
    echo ""  >> "$ENV_FILE"
    echo "# TLS / HTTPS listener: added by upgrade.  WAHPANEL_TLS=1 enables HTTPS (self-signed auto-gen or WAHPANEL_TLS_CERT/KEY)." >> "$ENV_FILE"
    echo "WAHPANEL_TLS=1" >> "$ENV_FILE"
  fi
  # PANEL_NEIGHBOUR reflects CURRENT host detection state; always refresh on
  # upgrade (not append-missing) because user may have installed CyberPanel
  # after the first wahPanel install.
  if grep -q '^WAHPANEL_PANEL_NEIGHBOUR=' "$ENV_FILE" 2>/dev/null; then
    sed -i '/^WAHPANEL_PANEL_NEIGHBOUR=/d' "$ENV_FILE" 2>/dev/null || true
  fi
  echo "WAHPANEL_PANEL_NEIGHBOUR=${PANEL_NEIGHBOUR:-NATIVE}" >> "$ENV_FILE"
  grep -q '^WAHPANEL_CYBERPANEL_ENABLED=' "$ENV_FILE" 2>/dev/null \
    || echo "WAHPANEL_CYBERPANEL_ENABLED=0" >> "$ENV_FILE"
  grep -q '^WAHPANEL_CYBERPANEL_BASE_URL=' "$ENV_FILE" 2>/dev/null \
    || echo "WAHPANEL_CYBERPANEL_BASE_URL=${_WAH_CP_DEFAULT_BASE_URL}" >> "$ENV_FILE"
  grep -q '^WAHPANEL_CYBERPANEL_INSECURE_SKIP_VERIFY=' "$ENV_FILE" 2>/dev/null \
    || echo "WAHPANEL_CYBERPANEL_INSECURE_SKIP_VERIFY=${_WAH_CP_DEFAULT_SKIP_VERIFY}" >> "$ENV_FILE"
fi
chmod 0640 "$ENV_FILE"
chown root:wahpanel "$ENV_FILE"
_ENV_NEW_SHA256="none"
_ENV_NEW_SHA256="$( (sha256sum "$ENV_FILE" 2>/dev/null || shasum -a 256 "$ENV_FILE" 2>/dev/null) | awk '{print $1}' || echo unknown )"
# Sanitise any keys that contain actual secrets — context JSON only records their EXISTENCE, never values.
_NEIGHBOUR_WROTE="$(grep -E '^WAHPANEL_PANEL_NEIGHBOUR=' "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || echo NONE)"
_CP_ENABLED_WROTE="$(grep -E '^WAHPANEL_CYBERPANEL_ENABLED=' "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || echo 0)"
_CP_BASE_WROTE="$(grep -E '^WAHPANEL_CYBERPANEL_BASE_URL=' "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || echo NONE)"
_CP_SKIP_WROTE="$(grep -E '^WAHPANEL_CYBERPANEL_INSECURE_SKIP_VERIFY=' "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || echo 0)"
_PORT_WROTE="$(grep -E '^WAHPANEL_PORT=' "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || echo 0)"
_ENV_WROTE="$(grep -E '^WAHPANEL_ENVIRONMENT=' "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || echo unknown)"
_BANNER_WROTE="$(grep -E '^WAHPANEL_NO_TECH_BANNER=' "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || echo unknown)"
_SUDO_WROTE="$(grep -E '^WAHPANEL_SUDO_ENABLED=' "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || echo unknown)"
_TLS_WROTE="$(grep -E '^WAHPANEL_TLS=' "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || echo unset)"
_HAS_JWT_SEC=0; grep -q '^WAHPANEL_JWT_SECRET=' "$ENV_FILE" 2>/dev/null && _HAS_JWT_SEC=1
_emit_json OK "env file written" '{"path":"'"${ENV_FILE}"'","mode":"0640","owner":"root:wahpanel","env_sha256":"'"${_ENV_NEW_SHA256}"'","keys":{"WAHPANEL_PORT":"'"${_PORT_WROTE}"'","WAHPANEL_ENVIRONMENT":"'"${_ENV_WROTE}"'","WAHPANEL_NO_TECH_BANNER":"'"${_BANNER_WROTE}"'","WAHPANEL_SUDO_ENABLED":"'"${_SUDO_WROTE}"'","WAHPANEL_TLS":"'"${_TLS_WROTE}"'","WAHPANEL_PANEL_NEIGHBOUR":"'"${_NEIGHBOUR_WROTE}"'","WAHPANEL_CYBERPANEL_ENABLED":"'"${_CP_ENABLED_WROTE}"'","WAHPANEL_CYBERPANEL_BASE_URL":"'"${_CP_BASE_WROTE}"'","WAHPANEL_CYBERPANEL_INSECURE_SKIP_VERIFY":"'"${_CP_SKIP_WROTE}"'","WAHPANEL_JWT_SECRET_present":'"${_HAS_JWT_SEC}"'}}'
ok "Environment file: ${ENV_FILE} (0640 root:wahpanel, ENVIRONMENT=${WAHPANEL_ENVIRONMENT}, NO_TECH_BANNER=${WAHPANEL_NO_TECH_BANNER}, TLS=${_TLS_WROTE}, NEIGHBOUR=${PANEL_NEIGHBOUR:-NATIVE}, CYBERPANEL_ENABLED=0)"
unset _ENV_NEW_SHA256 _NEIGHBOUR_WROTE _CP_ENABLED_WROTE _CP_BASE_WROTE _CP_SKIP_WROTE _PORT_WROTE _ENV_WROTE _BANNER_WROTE _SUDO_WROTE _HAS_JWT_SEC

unset _WAH_CP_DEFAULT_BASE_URL _WAH_CP_DEFAULT_SKIP_VERIFY

# ------------------------------ firewall (UFW + firewalld + iptables) ------
configure_firewall() {
    local PANEL_PORT="$1"
    if [ "$PANEL_NEIGHBOUR" != "NATIVE" ]; then
        # Use only an already active backend. Never activate a new firewall,
        # reload unrelated rules, or change shared web/mail/DNS/SSH ports.
        if have firewall-cmd && firewall-cmd --state >/dev/null 2>&1; then
            firewall-cmd --add-port="${PANEL_PORT}/tcp" \
                || die "Could not open WahPanel port in active firewalld"
            firewall-cmd --permanent --add-port="${PANEL_PORT}/tcp" \
                || die "Could not persist WahPanel port in active firewalld"
        elif have ufw && ufw status | grep -q '^Status: active'; then
            ufw allow "${PANEL_PORT}/tcp" comment 'WahPanel Daemon' \
                || die "Could not open WahPanel port in active UFW"
        else
            warn "Existing firewall preserved. Allow TCP ${PANEL_PORT} in your current host/provider firewall."
        fi
        return 0
    fi
    local HTTP_PORT=80
    local HTTPS_PORT=443
    local OLS_ADMIN_PORT=7080

    # --- Co-existence override: skip 7080 if CyberPanel is the LSWS owner.
    #     CyberPanel already binds that port for the OLS WebAdmin console and
    #     has its own firewall rules.  Opening it twice is redundant and
    #     misleads the operator into thinking wahPanel controls it.
    local OLS_ADMIN_SKIP=0
    if [ "$PANEL_NEIGHBOUR" = "CYBERPANEL" ] && [ "${_CYBERPANEL_OLS_OWNER:-0}" = "1" ]; then
        OLS_ADMIN_SKIP=1
        log "OpenLiteSpeed WebAdmin (port ${OLS_ADMIN_PORT}) is owned by CyberPanel — skipping redundant firewall rule."
    fi

    if [ "$OLS_ADMIN_SKIP" = "1" ]; then
        log "Opening firewall: panel=${PANEL_PORT}, HTTP=${HTTP_PORT}, HTTPS=${HTTPS_PORT} (OLS-admin=${OLS_ADMIN_PORT} SKIP, owned by CyberPanel)"
    else
        log "Opening firewall: panel=${PANEL_PORT}, HTTP=${HTTP_PORT}, HTTPS=${HTTPS_PORT}, OLS-admin=${OLS_ADMIN_PORT}"
    fi

    # ---- UFW (Ubuntu / Debian) ----------------------------------------------
    if have ufw; then
        _UFW_PANEL=0; _UFW_HTTP=0; _UFW_HTTPS=0; _UFW_OLS=0
        # Use the wahpanel sudoers-whitelisted binary explicitly; when run as
        # root the `sudo -u root` prefix is a no-op, but unifies invocations.
        run_as_root ufw allow "${PANEL_PORT}/tcp" comment 'WahPanel Daemon' >/dev/null 2>&1 && _UFW_PANEL=1 || true
        run_as_root ufw allow "${HTTP_PORT}/tcp"    comment 'HTTP Web' >/dev/null 2>&1 && _UFW_HTTP=1 || true
        run_as_root ufw allow "${HTTPS_PORT}/tcp"   comment 'HTTPS Web' >/dev/null 2>&1 && _UFW_HTTPS=1 || true
        if [ "$OLS_ADMIN_SKIP" = "0" ]; then
            run_as_root ufw allow "${OLS_ADMIN_PORT}/tcp" comment 'OpenLiteSpeed Admin' >/dev/null 2>&1 && _UFW_OLS=1 || true
        fi
        # Best-effort enable; non-interactive `--force` so it never prompts.
        run_as_root ufw --force enable >/dev/null 2>&1 || true
        _UFW_ENABLED=0; (ufw status verbose 2>/dev/null | grep -qE '^Status:[[:space:]]+active') && _UFW_ENABLED=1
        _emit_json OK "firewall applied" '{"backend":"ufw","ols_admin_skip":'"${OLS_ADMIN_SKIP}"',"panel_port":'"${PANEL_PORT}"',"rules":{"panel":'"${_UFW_PANEL}"',"http":'"${_UFW_HTTP}"',"https":'"${_UFW_HTTPS}"',"ols_admin":'"${_UFW_OLS}"'},"ufw_active":'"${_UFW_ENABLED}"'}'
        ok "UFW rules applied"
        return 0
    fi

    # ---- Firewalld (RHEL / AlmaLinux / Fedora) ------------------------------
    if have firewall-cmd; then
        _FW_PANEL=0; _FW_HTTP=0; _FW_HTTPS=0; _FW_OLS=0; _FW_RELOAD=0
        run_as_root firewall-cmd --permanent --add-port="${PANEL_PORT}/tcp" >/dev/null 2>&1 && _FW_PANEL=1 || true
        run_as_root firewall-cmd --permanent --add-port="${HTTP_PORT}/tcp"  >/dev/null 2>&1 && _FW_HTTP=1 || true
        run_as_root firewall-cmd --permanent --add-port="${HTTPS_PORT}/tcp" >/dev/null 2>&1 && _FW_HTTPS=1 || true
        if [ "$OLS_ADMIN_SKIP" = "0" ]; then
            run_as_root firewall-cmd --permanent --add-port="${OLS_ADMIN_PORT}/tcp" >/dev/null 2>&1 && _FW_OLS=1 || true
        fi
        run_as_root firewall-cmd --reload >/dev/null 2>&1 && _FW_RELOAD=1 || true
        _FW_STATE="unknown"; _FW_STATE="$(firewall-cmd --state 2>/dev/null || echo not-installed)"
        _emit_json OK "firewall applied" '{"backend":"firewalld","ols_admin_skip":'"${OLS_ADMIN_SKIP}"',"panel_port":'"${PANEL_PORT}"',"rules":{"panel":'"${_FW_PANEL}"',"http":'"${_FW_HTTP}"',"https":'"${_FW_HTTPS}"',"ols_admin":'"${_FW_OLS}"'},"reload_ok":'"${_FW_RELOAD}"',"firewalld_state":"'"${_FW_STATE}"'"}'
        ok "Firewalld rules applied + reloaded"
        return 0
    fi

    # ---- Raw IPTables fallback ----------------------------------------------
    if have iptables; then
        _IPT_PANEL=0; _IPT_HTTP=0; _IPT_HTTPS=0; _IPT_OLS=0
        run_as_root iptables -A INPUT -p tcp --dport "${PANEL_PORT}"   -j ACCEPT -m comment --comment "WahPanel Daemon" >/dev/null 2>&1 && _IPT_PANEL=1 || true
        run_as_root iptables -A INPUT -p tcp --dport "${HTTP_PORT}"    -j ACCEPT >/dev/null 2>&1 && _IPT_HTTP=1 || true
        run_as_root iptables -A INPUT -p tcp --dport "${HTTPS_PORT}"   -j ACCEPT >/dev/null 2>&1 && _IPT_HTTPS=1 || true
        if [ "$OLS_ADMIN_SKIP" = "0" ]; then
            run_as_root iptables -A INPUT -p tcp --dport "${OLS_ADMIN_PORT}" -j ACCEPT >/dev/null 2>&1 && _IPT_OLS=1 || true
        fi
        _emit_json WARN "firewall applied" '{"backend":"iptables","ols_admin_skip":'"${OLS_ADMIN_SKIP}"',"panel_port":'"${PANEL_PORT}"',"rules":{"panel":'"${_IPT_PANEL}"',"http":'"${_IPT_HTTP}"',"https":'"${_IPT_HTTPS}"',"ols_admin":'"${_IPT_OLS}"'},"note":"iptables rules are transient - save to /etc/iptables/rules.v4 manually for reboots on Debian, or service iptables save on RHEL"}'
        # Best-effort persistence (save rules for next reboot)
        ( have iptables-save && mkdir -p /etc/iptables 2>/dev/null && run_as_root iptables-save > /etc/iptables/rules.v4 ) 2>/dev/null || true
        ok "IPTables rules applied"
        return 0
    fi

    _emit_json WARN "firewall applied" '{"backend":"none","ols_admin_skip":'"${OLS_ADMIN_SKIP}"',"panel_port":'"${PANEL_PORT}"',"note":"No supported firewall backend detected - open ports manually in your firewall."}'
    warn "No supported firewall binary found (tried: ufw, firewall-cmd, iptables). Open ports manually."
    return 0
}

# Helper that runs a command as root regardless of whether install.sh is being
# executed through sudo or directly as root (avoids nested `sudo sudo` errors).
run_as_root() {
    if [ "$(id -u)" -eq 0 ]; then
        "$@"
    else
        sudo -n -- "$@"
    fi
}

configure_firewall "${DEFAULT_PORT}"
ok "Firewall done (best-effort)"

# ------------------------------ systemd unit ---------------------------------
log "Installing systemd service → ${SERVICE_FILE} (User=wahpanel)"
cat > "${SERVICE_FILE}" <<'UNIT'
[Unit]
Description=WahPanel Core Control Daemon
Documentation=https://docs.wahpanel.com
After=network-online.target local-fs.target nss-user-lookup.target
Wants=network-online.target

[Service]
Type=simple
User=wahpanel
Group=wahpanel

# ================= SYSTEMD BUILT-IN DIR MANAGEMENT =================
# IMPORTANT systemd UNIT-FILE SYNTAX NOTE (critical, learned the hard way):
#   In systemd .service files, the "#" character starts a UNIT-FILE-LEVEL
#   comment if and ONLY IF it appears as the FIRST NON-BLANK glyph on the
#   line. You CANNOT put "# trailing inline comments" after Key=value pairs
#   (unlike bash / Go / Python). Anything after the "=" — including spaces
#   and anything that looks like a comment — becomes the literal VALUE.
#   This was the root cause of:
#     (a) "StateDirectory= path is absolute, ignoring: /var/lib/wahpanel"
#         (b) "RuntimeDirectoryPreserve=restart ... ignoring: Invalid argument"
#         (c) "Unbalanced quoting, ignoring: \"+/bin/sh -c '\""
#   All three were caused by innocent-looking inline "# ..." comments after
#   values that systemd concatenated into the value / broke the multi-line
#   single-quote boundary. Rule: NO inline comments. Comments ONLY on their
#   own lines starting with column-0 "#".
#
# Directories below are specified as SIMPLE RELATIVE NAMES ("wahpanel").
# systemd then creates them AUTOMATICALLY under the canonical parent paths:
#   StateDirectory=wahpanel        ->  /var/lib/wahpanel
#   LogsDirectory=wahpanel         ->  /var/log/wahpanel
#   CacheDirectory=wahpanel        ->  /var/cache/wahpanel
#   RuntimeDirectory=wahpanel      ->  /run/wahpanel          (tmpfs)
#   ConfigurationDirectory=wahpanel->  /etc/wahpanel
# Directories are created with User=wahpanel:wahpanel ownership and the
# EXPLICIT *Mode= values listed below before mount namespace / ExecStart.
StateDirectory=wahpanel
StateDirectoryMode=0750
LogsDirectory=wahpanel
LogsDirectoryMode=0750
CacheDirectory=wahpanel
CacheDirectoryMode=0700
RuntimeDirectory=wahpanel
RuntimeDirectoryMode=0700
ConfigurationDirectory=wahpanel
ConfigurationDirectoryMode=0750
# RuntimeDirectoryPreserve: "yes" = keep /run/wahpanel across daemon restart
# (systemd ≥ 236). Do NOT use "restart" (added 2025/systemd 256); Ubuntu 24.04
# ships systemd 252 which only accepts "yes" / "no" enum values.
RuntimeDirectoryPreserve=yes

# ================= SELF-HEALING PRE-FLIGHT (REAL ROOT) =================
# The "+" prefix on ExecStartPre tells systemd to run this command as REAL
# ROOT (bypassing User=wahpanel) BEFORE setting up the mount namespace /
# calling setuid(). This is the ONLY way to guarantee /home/vhosts (and any
# paths listed in ReadWritePaths= below) exist on EVERY service start —
# even if a user or cleanup script deleted them. We write a tiny helper
# script to disk (see helper install above at /usr/local/lib/wahpanel/)
# instead of inlining a 15-line shell script here, because systemd unit-file
# parsing does NOT understand shell quoting rules: inside a passed string
# the characters '#' and line continuations still get interpreted as
# UNIT-FILE comment syntax / line concatenation, which mangles the shell
# script body and produces "Unbalanced quoting, ignoring" fatal errors.
ExecStartPre=+@LIBEXECDIR@/wahpanel-preflight.sh

WorkingDirectory=/var/lib/wahpanel

EnvironmentFile=-/etc/wahpanel/wahpanel.env
# Pass --port via env var (falls back to WAHPANEL_PORT inside daemon if set).
# Explicitly pass --tls so scheme is never ambiguous: env default is 1 (HTTPS),
# but an admin can override to 0 in wahpanel.env.  --tls-cert/--tls-key default
# to empty string, and main.go falls back to auto self-signed cert inside
# state dir when they're not provided.
ExecStart=/usr/local/bin/wahpanel serve --port=${WAHPANEL_PORT} --tls=${WAHPANEL_TLS} --tls-cert=${WAHPANEL_TLS_CERT} --tls-key=${WAHPANEL_TLS_KEY}
ExecReload=/bin/kill -HUP $MAINPID

Restart=always
RestartSec=5s
TimeoutStopSec=30
LimitNOFILE=65536
MemoryHigh=3G
MemoryMax=4G
CPUQuota=200%
Environment=GOMAXPROCS=2 GOMEMLIMIT=384MiB

# ---- privilege sandbox ------------------------------------------------------
# Only these specific capabilities; no more. The daemon CANNOT gain any other
# capability at runtime. File capabilities (capsh --print) from the binary
# itself are bounded by this set.
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID CAP_DAC_OVERRIDE
# Ambient caps survive execve() into child processes so lswsctrl / certbot
# helpers spawned by the daemon can bind privileged ports without extra sudo.
AmbientCapabilities=CAP_NET_BIND_SERVICE

# Whitelist the exact filesystem paths the daemon is allowed to mutate
# (SQLite state, logs, vhost files, its own config). Everything else under
# ProtectSystem=strict is READ-ONLY to the daemon's user namespace.
# NOTE: the 5 *Directory= directives above (State/Logs/Cache/Runtime/Config)
# already GUARANTEE /var/lib/wahpanel and similar dirs exist. The vhost
# document root /home/vhosts is NOT listed here (and does NOT need to be):
# it is a HOST-LEVEL filesystem symlink installed by the installer pointing
# to /var/lib/wahpanel/vhosts, which is INSIDE StateDirectory= tree and
# therefore already implicitly writable. Symlink target resolution happens
# AFTER the allowlist check. This is the only way to make 226/NAMESPACE
# impossible even if someone does "rm -rf /home/vhosts" on the host.
ProtectSystem=strict
ReadWritePaths=/var/lib/wahpanel /var/log/wahpanel /tmp /var/cache/wahpanel /run/wahpanel
ReadWritePaths=-/etc/wahpanel -/etc/apache2 -/etc/nginx -/usr/local/lsws -/etc/mysql -/etc/exim4 -/etc/dovecot -/etc/bind -/etc/unbound -/var/spool/mail -/var/lib/mysql
ReadOnlyPaths=/usr /boot /etc/alternatives /etc/profile.d /etc/sudoers.d
# ProtectHome=tmpfs hides /root /home/$USER from the namespace. /home/vhosts
# still works because it resolves via symlink OUTSIDE the namespace to the
# writable /var/lib/wahpanel/vhosts location.
ProtectHome=tmpfs
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectHostname=yes
ProtectClock=yes
NoNewPrivileges=no
SystemCallArchitectures=native
KeyringMode=private

[Install]
WantedBy=multi-user.target
UNIT

# ---- Post-process the unit file: replace placeholders with absolute paths -
# (h) replace @LIBEXECDIR@ token with the actual /usr/local/lib/wahpanel path
# (note: the heredoc above was <<'UNIT' so NO shell $ expansion happened inside;
#  systemd vars like $MAINPID ${WAHPANEL_PORT} are preserved literally as systemd
#  specifiers/env expansions, NOT bash-expanded at install time)
LIBEXEC_DIR="${LIBEXEC_DIR:-/usr/local/lib/wahpanel}"
if command -v sed >/dev/null 2>&1; then
  sed -i "s|@LIBEXECDIR@|${LIBEXEC_DIR}|g" "${SERVICE_FILE}"
  # Also: guard against any stray inline comments (# ... at END of a value line
  # starting with column >0). If a prior install script / manual edit ever
  # writes one, strip it so systemd parser doesn't concatenate comment text
  # to the configuration value (which causes all 4 bad-setting bugs we fixed).
  # This sed only applies to lines that start with a known keyword followed by
  # '=', i.e. "Key=value  # comment" → removes trailing "  # comment".
  sed -i -E '/^[[:space:]]*(StateDirectory|LogsDirectory|CacheDirectory|RuntimeDirectory|ConfigurationDirectory|StateDirectoryMode|LogsDirectoryMode|CacheDirectoryMode|RuntimeDirectoryMode|ConfigurationDirectoryMode|RuntimeDirectoryPreserve|ExecStartPre|WorkingDirectory|EnvironmentFile|ExecStart|ExecReload|Restart|RestartSec|TimeoutStopSec|LimitNOFILE|CapabilityBoundingSet|AmbientCapabilities|ProtectSystem|ReadWritePaths|ReadOnlyPaths|ProtectHome|PrivateTmp|PrivateDevices|ProtectKernelTunables|ProtectKernelModules|ProtectControlGroups|ProtectHostname|ProtectClock|NoNewPrivileges|SystemCallArchitectures|KeyringMode|WantedBy)[[:space:]]*=/{s/[[:space:]]+#.*$//}' "${SERVICE_FILE}"
fi
chmod 0644 "${SERVICE_FILE}"
chown root:root "${SERVICE_FILE}"

systemctl daemon-reload
_SVC_ENABLED_ERR=0; systemctl enable "${PANEL_NAME}.service" --quiet 2>/dev/null || _SVC_ENABLED_ERR=1
_SVC_ENABLED=1; [ "${_SVC_ENABLED_ERR}" = "0" ] || _SVC_ENABLED=0
_SVC_LOADED=0; systemctl is-enabled --quiet "${PANEL_NAME}.service" 2>/dev/null && _SVC_LOADED=1 || true
_SVC_ACTIVE_PRE=0; systemctl is-active --quiet "${PANEL_NAME}.service" 2>/dev/null && _SVC_ACTIVE_PRE=1 || true
_emit_json OK "systemd service registered" '{"service_file":"'"${SERVICE_FILE}"'","daemon_reload_ok":1,"enabled":'"${_SVC_ENABLED}"',"is_enabled_systemd":'"${_SVC_LOADED}"',"active_before_start":'"${_SVC_ACTIVE_PRE}"'}'
ok "Service wahpanel.service enabled (runs as wahpanel:wahpanel)"

# ------------------------------ start daemon + first-run bootstrap ----------
log "Starting wahpanel daemon on :${DEFAULT_PORT}"
systemctl restart "${PANEL_NAME}.service"
sleep 2
DAEMON_OK=0
for _ in 1 2 3 4 5 6 7 8 9 10; do
  if systemctl is-active --quiet "${PANEL_NAME}.service"; then
    DAEMON_OK=1
    break
  fi
  sleep 1
done
_SVC_ACTIVE_POST="${DAEMON_OK}"
_SVC_MAIN_PID=""; _SVC_MAIN_PID="$(systemctl show -p MainPID --value "${PANEL_NAME}.service" 2>/dev/null || echo 0)"
_SVC_MEM_CUR=""; _SVC_MEM_CUR="$(systemctl show -p MemoryCurrent --value "${PANEL_NAME}.service" 2>/dev/null || echo unknown)"
_SVC_EXIT_CODE=""; _SVC_EXIT_CODE="$(systemctl show -p ExecMainStatus --value "${PANEL_NAME}.service" 2>/dev/null || echo unknown)"
_emit_json OK "daemon start" '{"port":'"${DEFAULT_PORT}"',"active":'"${_SVC_ACTIVE_POST}"',"main_pid":"'"${_SVC_MAIN_PID}"'","memory_current":"'"${_SVC_MEM_CUR}"'","exec_main_status":"'"${_SVC_EXIT_CODE}"'","wait_loops":10}'
if [ "${DAEMON_OK}" = "1" ]; then
  ok "wahpanel.service is ACTIVE"
else
  warn "Daemon not active immediately. Check: journalctl -u ${PANEL_NAME}.service -n 200"
fi

# ------------------------------ postflight validation ------------------------
log "Running postflight validation (127.0.0.1:${DEFAULT_PORT})"
_HEALTHZ_CODE=0; _HEALTHZ_BODY=""; _ENV_CODE=0; _ENV_BODY=""
if have curl; then
  _HEALTHZ_BODY="$(curl -skS --max-time 8 -w '%{http_code}' -o /tmp/_wahpanel_hz.$$ "https://127.0.0.1:${DEFAULT_PORT}/healthz" 2>/dev/null || echo 000)"
  _HEALTHZ_CODE="${_HEALTHZ_BODY: -3}"; _HEALTHZ_BODY="$(cat /tmp/_wahpanel_hz.$$ 2>/dev/null || true)"; rm -f /tmp/_wahpanel_hz.$$
  _ENV_BODY_RAW="$(curl -skS --max-time 8 -w '%{http_code}' -o /tmp/_wahpanel_env.$$ "https://127.0.0.1:${DEFAULT_PORT}/api/env" 2>/dev/null || echo 000)"
  _ENV_CODE="${_ENV_BODY_RAW: -3}"; _ENV_BODY="$(cat /tmp/_wahpanel_env.$$ 2>/dev/null || true)"; rm -f /tmp/_wahpanel_env.$$
fi
_BIN_SHA="$( (sha256sum "${BINARY_DEST}" 2>/dev/null || shasum -a 256 "${BINARY_DEST}" 2>/dev/null) | awk '{print $1}' || echo unknown )"
_SVC_FILE_SHA="$( (sha256sum "${SERVICE_FILE}" 2>/dev/null || shasum -a 256 "${SERVICE_FILE}" 2>/dev/null) | awk '{print $1}' || echo unknown )"
# Extract 2 safe booleans from /api/env JSON if we got one: panel_neighbour, cyberpanel_api_enabled (not secrets — those are never present there)
_PF_NEIGHBOUR="unknown"; _PF_CP_ENABLED="unknown"
if [ -n "${_ENV_BODY}" ]; then
  _PF_NEIGHBOUR="$(printf '%s' "${_ENV_BODY}" | sed -nE 's/.*"panel_neighbour":"([A-Z]+)".*/\1/p' | head -1 || echo unknown)"
  case "${_PF_NEIGHBOUR}" in CYBERPANEL|CPANEL|PLESK|NATIVE) ;; *) _PF_NEIGHBOUR="parse_failed" ;; esac
  _PF_CP_ENABLED="$(printf '%s' "${_ENV_BODY}" | sed -nE 's/.*"cyberpanel_api_enabled":(true|false).*/\1/p' | head -1 || echo unknown)"
fi
_emit_json OK "postflight validation" '{"port":'"${DEFAULT_PORT}"',"healthz_http_code":'"${_HEALTHZ_CODE}"',"env_http_code":'"${_ENV_CODE}"',"panel_neighbour_from_api":"'"${_PF_NEIGHBOUR}"'","cyberpanel_api_enabled_from_api":"'"${_PF_CP_ENABLED}"'","bin_sha256":"'"${_BIN_SHA}"'","service_file_sha256":"'"${_SVC_FILE_SHA}"'","env_file_path":"'"${ENV_FILE}"'","db_dir_path":"'"${DB_DIR}"'"}'
if [ "${_HEALTHZ_CODE}" = "200" ] && [ "${_ENV_CODE}" = "200" ]; then
  ok "Postflight OK: /healthz + /api/env both 200 on 127.0.0.1:${DEFAULT_PORT}"
else
  warn "Postflight non-200: healthz=${_HEALTHZ_CODE} env=${_ENV_CODE}. Daemon may still be booting — re-check in 5s with:  curl -sk https://127.0.0.1:${DEFAULT_PORT}/healthz"
fi
unset _SVC_ENABLED_ERR _SVC_ENABLED _SVC_LOADED _SVC_ACTIVE_PRE _SVC_ACTIVE_POST _SVC_MAIN_PID _SVC_MEM_CUR _SVC_EXIT_CODE _HEALTHZ_CODE _HEALTHZ_BODY _ENV_CODE _ENV_BODY _BIN_SHA _SVC_FILE_SHA _PF_NEIGHBOUR _PF_CP_ENABLED

# ------------------------------ credentials banner ---------------------------
#
# IMPORTANT — credentials are stored in MODE 0600 file (/var/lib/wahpanel/admin_initial_creds)
# so only root can read it. Starting with v1.0.0, the banner password is NEVER printed
# to journald (stdout goes to systemd journal socket/pipe when running as service → non-TTY).
# Instead, we have FOUR reliable recoveries for the admin password, in priority order:
#   1) Direct read of the mode-0600 file (fastest, 99% of fresh installs)
#   2) The brand-new `wahpanel show-creds` CLI command (same effect, more user-friendly)
#   3) The admin can reset it anytime with `sudo adminPass <new_password>`
#   4) Login page shows a hint pointing to (2)
INITIAL_CREDS_FILE="${INSTALL_DIR}/admin_initial_creds"
INITIAL_USER=""
INITIAL_PW=""
# Read the fields written by writeInitialAdminCreds(); never guess an account.
initial_credential_field() {
  sed -nE "s/^#[[:space:]]+$1:[[:space:]]+//p" | head -1
}
# Wait up to ~8 seconds for the bootstrap file to appear (daemon creates it
# asynchronously right after the service starts).
for _ in 1 2 3 4; do
  if [ -r "${INITIAL_CREDS_FILE}" ]; then
    INITIAL_USER="$(initial_credential_field Username < "${INITIAL_CREDS_FILE}")"
    # Extract password from the "#   Password: <value>" comment lines the Go daemon
    # writes (format is guaranteed by writeInitialAdminCreds()).
    INITIAL_PW="$(initial_credential_field Password < "${INITIAL_CREDS_FILE}")"
    if [ -n "${INITIAL_PW}" ]; then
      break
    fi
  fi
  sleep 2
done
# If the file does not exist or grep could not find the password, try the new show-creds
# CLI subcommand (reads mode-0600 file for us:
if [ -z "${INITIAL_PW}" ]; then
  CREDS_OUTPUT="$(${BINARY_DEST} show-creds 2>/dev/null || true)"
  if [ -n "${CREDS_OUTPUT}" ]; then
    INITIAL_USER="$(printf '%s\n' "${CREDS_OUTPUT}" | initial_credential_field Username)"
    INITIAL_PW="$(printf '%s\n' "${CREDS_OUTPUT}" | initial_credential_field Password)"
  fi
fi
# On upgrades, an administrator may have renamed the bootstrap account or
# removed the initial credentials file. Prefer the persisted account name.
if have sqlite3 && [ -r "${DB_DIR}/system.db" ]; then
  _PERSISTED_ADMIN_USER="$(sqlite3 -readonly "${DB_DIR}/system.db" \
    "SELECT username FROM users WHERE role='admin' ORDER BY id ASC LIMIT 1;" 2>/dev/null || true)"
  if [ -n "${_PERSISTED_ADMIN_USER}" ]; then
    INITIAL_USER="${_PERSISTED_ADMIN_USER}"
  fi
  unset _PERSISTED_ADMIN_USER
fi

PRIMARY_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
PUBLIC_IP=""
if have curl; then
  PUBLIC_IP="$(curl -fsSL --max-time 5 https://api.ipify.org 2>/dev/null || true)"
fi

_elapsed="$(( $(date -u +%s) - _INSTALL_START_TS ))"
# Final machine-parseable install_complete record — all the data a dev needs
# to re-diagnose a reinstall without re-running install.sh.
_PANEL_NEIGHBOUR_FINAL="$(grep -E '^WAHPANEL_PANEL_NEIGHBOUR=' "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || echo NATIVE)"
_CP_ENABLED_FINAL="$(grep -E '^WAHPANEL_CYBERPANEL_ENABLED=' "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || echo 0)"
_OLS_OWNER_FINAL="0"; [ "$PANEL_NEIGHBOUR" = "CYBERPANEL" ] && [ "${_CYBERPANEL_OLS_OWNER:-0}" = "1" ] && _OLS_OWNER_FINAL="1"
_RUN_MODE="install"; [ -f "$ENV_FILE.bak.wahpanel.install" ] || [ "$(grep -c '^WAHPANEL_JWT_SECRET=' "$ENV_FILE" 2>/dev/null || echo 0)" = "0" ] || _RUN_MODE="upgrade"
_emit_json OK "install complete" '{"run_id":"'"${_INSTALL_RUN_ID}"'","elapsed_sec":'"${_elapsed}"',"port":'"${DEFAULT_PORT}"',"panel_neighbour":"'"${_PANEL_NEIGHBOUR_FINAL}"'","cyberpanel_enabled_env":"'"${_CP_ENABLED_FINAL}"'","openlitespeed_owner_cyberpanel":'"${_OLS_OWNER_FINAL}"',"run_mode":"'"${_RUN_MODE}"'","download_url":"'"${DOWNLOAD_URL}"'","log_path":"'"${WAHPANEL_INSTALL_LOG}"'"}'
ok "install complete: elapsed=${_elapsed}s  port=${DEFAULT_PORT}  log=${WAHPANEL_INSTALL_LOG}"

PW_STATUS="FOUND"
if [ -z "${INITIAL_PW}" ]; then
  PW_STATUS="HIDDEN (daemon started on fresh install → not yet written to file or already consumed, will appear once shown)"
fi
# Write an /etc/motd-style banner fragment so `ssh root@host` shows the
# install-complete status for the first few boots.
MOTD_FRAGMENT="/etc/motd.d/99-wahpanel"
MOTD_DIR="$(dirname "${MOTD_FRAGMENT}")"
# Belt-and-suspenders: if the caller explicitly disabled color (CI automation
# flag WAHPANEL_LOG_NOCOLOR=1), also scrub the plaintext password before the
# final banner prints. The real suppression gate is still the TTY check below,
# but this covers non-TTY automation pipes that also explicitly turn colors off.
if [ "${WAHPANEL_LOG_NOCOLOR}" = "1" ]; then
  INITIAL_PW=""
fi
# Install the MOTD banner always (even if no pw found yet — motd shows CLI helpers):
if [ -d "${MOTD_DIR}" ]; then
  cat > "${MOTD_FRAGMENT}" <<MOTDEOF
========================================================================
 🎉  wahPanel installed on port ${DEFAULT_PORT}
========================================================================
  Panel URL :  https://${PUBLIC_IP:-${PRIMARY_IP:-<server-ip>}}:${DEFAULT_PORT}
  Username  :  ${INITIAL_USER:-<see /var/lib/wahpanel/admin_initial_creds>}
  Password  :  sudo wahpanel show-creds
           —or—  sudo cat ${INITIAL_CREDS_FILE}
           —or—  sudo adminPass <new_password>  (reset)
  Diag pack :  sudo wahpanel-diag   →  creates /root/wahpanel-diag-*.tar.gz
========================================================================
MOTDEOF
  chmod 0644 "${MOTD_FRAGMENT}" 2>/dev/null || true
  ok "Installed MOTD banner: ${MOTD_FRAGMENT}"
fi

# ------------------------------ diagnostic pack helper -----------------------
# Install `/usr/local/sbin/wahpanel-diag`. This is a standalone shell script,
# so the operator can re-run it MONTHS after install without source-ing
# install.sh.  It redacts secrets inline (JWT, passwords, Basic Bearer) so
# the tarball is safe to hand to a remote engineer.
_DIAG_BIN="/usr/local/sbin/wahpanel-diag"
mkdir -p "$(dirname "${_DIAG_BIN}")" 2>/dev/null || true
cat > "${_DIAG_BIN}" <<'DIAG_SCRIPT_EOF'
#!/usr/bin/env bash
# wahpanel-diag: collect a redacted diagnostic pack for remote debugging.
# Usage:  sudo wahpanel-diag  [OUTPUT_DIR=/root]
set -uo pipefail
PANEL_NAME="${PANEL_NAME:-wahpanel}"
LOG_DIR="${LOG_DIR:-/var/log/${PANEL_NAME}}"
CONF_DIR="${CONF_DIR:-/etc/${PANEL_NAME}}"
ENV_FILE="${CONF_DIR}/${PANEL_NAME}.env"
DB_DIR="${DB_DIR:-/var/lib/${PANEL_NAME}/db}"
SERVICE_NAME="${SERVICE_NAME:-${PANEL_NAME}.service}"
OUT_DIR="${1:-/root}"
mkdir -p "${OUT_DIR}" 2>/dev/null || OUT_DIR="/root"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
TMP_ROOT="$(mktemp -d /tmp/wahpanel-diag.XXXXXX)"
TMP="${TMP_ROOT}/wahpanel-diag-${STAMP}"
mkdir -p "${TMP}"
trap 'rm -rf "${TMP_ROOT}"' EXIT

# redact_one_stream: apply a reasonable secret scrub to stdin -> stdout.
# (Install-time install.sh NDJSON already redacts; this covers /api/env,
#  env files, journald noise, curl -v output from any manual future tests.)
redact_one_stream() {
  sed -E \
    -e 's/(WAHPANEL_JWT_SECRET=)[^ &$'"'"'"]+/\1<redacted:jwt>/g' \
    -e 's/(password[=: ][^ &$'"'"'"]{3,})/password=<redacted>/gI' \
    -e 's/(ownerPassword[=: ][^ &$'"'"'"]{3,})/ownerPassword=<redacted>/gI' \
    -e 's/(adminPass[a-zA-Z]*[=: ][^ &$'"'"'"]{3,})/adminPassword=<redacted>/gI' \
    -e 's/"(password|ownerPassword|adminPassword|adminPass)":"[^"]{3,}"/"\1":"<redacted:password>"/gI' \
    -e 's/(Authorization:[[:space:]]*Bearer )[^ ]+/\1<redacted:bearer>/gI' \
    -e 's/(Authorization:[[:space:]]*Basic )[^ ]+/\1<redacted:basic>/gI'
}

collect() {
  local name="$1" file="$2"
  shift 2
  if [ "$#" -gt 0 ]; then
    ( "$@" >"${TMP}/${name}.log" 2>&1 ) || true
  elif [ -r "${file}" ]; then
    cp -a "${file}" "${TMP}/${name}" 2>/dev/null || true
  fi
  if [ -f "${TMP}/${name}" ]; then
    redact_one_stream < "${TMP}/${name}" > "${TMP}/${name}.redacted.tmp" 2>/dev/null && mv "${TMP}/${name}.redacted.tmp" "${TMP}/${name}"
  elif [ -f "${TMP}/${name}.log" ]; then
    redact_one_stream < "${TMP}/${name}.log" > "${TMP}/${name}.log.redacted.tmp" 2>/dev/null && mv "${TMP}/${name}.log.redacted.tmp" "${TMP}/${name}.log"
  fi
  :
}

# --- Basic host/OS snapshot ---
collect "os-release"     /etc/os-release
collect "uname-a"        "" uname -a
collect "hostnamectl"    "" hostnamectl 2>/dev/null
collect "virt"           "" systemd-detect-virt 2>/dev/null
collect "df-h"           "" df -hT
collect "free-h"         "" free -h
collect "uptime"         "" uptime
collect "ss-ltnp"        "" ss -ltnpH 2>/dev/null
collect "mount"          "" mount
collect "dmesg"          "" dmesg 2>/dev/null || true

# --- wahPanel artifacts ---
[ -d "${LOG_DIR}" ]  && ( mkdir -p "${TMP}/var-log-wahpanel" ; tail -n 1000 "${LOG_DIR}/install.log" 2>/dev/null | redact_one_stream > "${TMP}/var-log-wahpanel/install.log.last1000" 2>/dev/null || true ; for f in "${LOG_DIR}"/*.log* 2>/dev/null; do [ -f "$f" ] && ( tail -n 500 "$f" | redact_one_stream > "${TMP}/var-log-wahpanel/$(basename "$f").last500" 2>/dev/null || true ); done )
[ -f "${ENV_FILE}" ] && ( redact_one_stream < "${ENV_FILE}" > "${TMP}/env-file.redacted.env" 2>/dev/null || true )
[ -d "${DB_DIR}" ]  && ( ls -la "${DB_DIR}" > "${TMP}/db_dir.ls.log" 2>/dev/null; du -sh "${DB_DIR}"/*.db >> "${TMP}/db_dir.ls.log" 2>/dev/null; echo "--- table counts ---" >> "${TMP}/db_dir.ls.log"; for db in "${DB_DIR}"/*.db; do [ -f "$db" ] || continue; if command -v sqlite3 >/dev/null 2>&1; then echo "DB: $(basename "$db")" >> "${TMP}/db_dir.ls.log"; sqlite3 "$db" "SELECT name FROM sqlite_master WHERE type='table' LIMIT 999;" 2>/dev/null | awk '{print " - "$0}' >> "${TMP}/db_dir.ls.log" || true; fi; done )
collect "systemctl-status-wahpanel" "" systemctl status --no-pager --lines=120 "${SERVICE_NAME}"
collect "systemctl-show-wahpanel"   "" systemctl show "${SERVICE_NAME}"
collect "journalctl-24h"            "" journalctl -u "${SERVICE_NAME}" --no-pager --since "24 hours ago"
collect "journalctl-boot"           "" journalctl -u "${SERVICE_NAME}" --no-pager -b
collect "wahpanel-show-version"     "" /usr/local/bin/wahpanel version 2>/dev/null || /usr/local/bin/wahpanel --version 2>/dev/null || true
collect "netstat-r"                 "" netstat -rn 2>/dev/null || ip r 2>/dev/null
collect "iptables-L"                "" iptables -L -n -v 2>/dev/null
collect "ufw-status"                "" ufw status verbose 2>/dev/null
collect "firewalld-state"           "" firewall-cmd --list-all 2>/dev/null
collect "sudoers-wahpanel"          /etc/sudoers.d/wahpanel

# --- CyberPanel neighbour snapshot (if present) ---
if [ -d /usr/local/CyberCP ]; then
  mkdir -p "${TMP}/cyberpanel" 2>/dev/null
  [ -r /usr/local/CyberCP/version.txt ] && ( cp -a /usr/local/CyberCP/version.txt "${TMP}/cyberpanel/version.txt" 2>/dev/null || true )
  collect "cyberpanel-lscpd"     "" systemctl status --no-pager --lines=40 lscpd 2>/dev/null
  collect "cyberpanel-lsws"      "" systemctl status --no-pager --lines=40 lscpd lsws lshttpd 2>/dev/null
  [ -d /usr/local/lsws/conf/vhosts ] && ( ls -1 /usr/local/lsws/conf/vhosts 2>/dev/null > "${TMP}/cyberpanel/lsws_vhosts.list.log" 2>/dev/null || true )
fi

# --- Postflight API probe (best effort; output redacted) ---
DEF_PORT=""
if [ -f "${ENV_FILE}" ]; then
  DEF_PORT="$(grep -E '^WAHPANEL_PORT=' "${ENV_FILE}" 2>/dev/null | tail -1 | cut -d= -f2- || echo 8090)"
fi
if [ -n "${DEF_PORT}" ]; then
  ( curl -skS --max-time 8 "https://127.0.0.1:${DEF_PORT}/healthz" 2>&1 | redact_one_stream > "${TMP}/healthz.txt" 2>/dev/null || true ; echo "" >> "${TMP}/healthz.txt" ; curl -skS --max-time 8 "https://127.0.0.1:${DEF_PORT}/api/env" 2>&1 | redact_one_stream > "${TMP}/api-env.json" 2>/dev/null || true )
fi

# --- Build the manifest ---
{
  echo "wahpanel diagnostic pack — generated ${STAMP}"
  echo ""
  echo "Tree:"
  find "${TMP}" -type f -printf "  %s  %p\n" | sed "s|${TMP}/||g" | sort
} > "${TMP}/MANIFEST.txt"

# --- Tarball ---
OUT="${OUT_DIR}/wahpanel-diag-${STAMP}.tar.gz"
tar -C "${TMP_ROOT}" -czf "${OUT}" "$(basename "${TMP}")" 2>/dev/null
chmod 0600 "${OUT}"
echo "Diagnostic pack: ${OUT}"
echo "Files: $(find "${TMP}" -type f | wc -l)"
echo "Size:  $(du -h "${OUT}" 2>/dev/null | awk '{print $1}')"
DIAG_SCRIPT_EOF
chmod 0755 "${_DIAG_BIN}" 2>/dev/null || true
# Symlink convenience: wahpanel-diag works without full path if /usr/local/sbin is in PATH.
if [ ! -L "/usr/local/bin/wahpanel-diag" ]; then
  ln -sf "${_DIAG_BIN}" /usr/local/bin/wahpanel-diag 2>/dev/null || true
fi
_DIAG_TEST_RUN="$( ( "${_DIAG_BIN}" "/tmp" >/dev/null 2>&1 ) || echo SKIP ; ls -1 /tmp/wahpanel-diag-*.tar.gz 2>/dev/null | tail -1 || true )"
if [ -n "${_DIAG_TEST_RUN}" ] && [ -f "${_DIAG_TEST_RUN}" ]; then
  _DIAG_SIZE="$(du -h "${_DIAG_TEST_RUN}" 2>/dev/null | awk '{print $1}')"
  _DIAG_FILES="$(tar -tzf "${_DIAG_TEST_RUN}" 2>/dev/null | grep -cv '/$' || echo 0)"
  rm -f "${_DIAG_TEST_RUN}"
  _emit_json OK "diagnostic pack installed" '{"bin":"'"${_DIAG_BIN}"'","test_created_tarball":1,"test_files":'"${_DIAG_FILES}"',"test_size":"'"${_DIAG_SIZE}"'","out_dir":"/root","note":"run sudo wahpanel-diag at any time to re-collect"}'
  ok "Diagnostic pack helper installed: ${_DIAG_BIN}  (${_DIAG_FILES} files in dry-run, ${_DIAG_SIZE})"
else
  _emit_json WARN "diagnostic pack installed" '{"bin":"'"${_DIAG_BIN}"'","note":"dry-run skipped or no tarball produced; command still exists"}'
  ok "Diagnostic pack helper installed: ${_DIAG_BIN}"
fi
unset _DIAG_BIN _DIAG_TEST_RUN _DIAG_SIZE _DIAG_FILES

# Final banner — IMPORTANT RULE: only print the plaintext password on stderr ONLY
# when install.sh's stdout/stderr is a TTY (interactive user shell).
# Otherwise (cloud-init / CI captures this output → DO NOT print password.
if [ -t 1 ] || [ -t 2 ]; then
  _tty=1
else
  _tty=0
fi

cat <<EOF

========================================================================
             🎉  wahPanel Installation Complete
========================================================================

  Web Panel:          https://${PUBLIC_IP:-${PRIMARY_IP:-<server-ip>}}:${DEFAULT_PORT}
  Username:           ${INITIAL_USER:-<see sudo wahpanel show-creds>}
  Credentials file:   ${INITIAL_CREDS_FILE}  (mode 0600, root-only)
EOF
if [ -n "${INITIAL_PW}" ] && [ "${_tty}" = "1" ]; then
  # Interactive run — human is watching: print it.
cat <<INNER_EOF
  Password:           ${INITIAL_PW}
INNER_EOF
elif [ -z "${INITIAL_PW}" ]; then
cat <<INNER_EOF
  Password:           (bootstrap user created at daemon first-start — not yet available)
INNER_EOF
else
cat <<INNER_EOF
  Password:           (hidden — stdout not an interactive TTY; see commands below)
INNER_EOF
fi
cat <<EOF

  Retrieve or reset the admin password ANYTIME with these root-only CLI commands:

       sudo wahpanel show-creds
       sudo wahpanel adminpass YourNewStrongPassword!
       sudo cat ${INITIAL_CREDS_FILE}

  (show-creds + adminPass are also available via shorter symlink names:
       \$ sudo wahpanel-show-creds     → same as wahpanel show-creds
       \$ sudo adminPass pw123        → same as wahpanel adminpass pw123
       \$ sudo wahpanel-port 8443     → same as wahpanel port 8443)

  Binary:             ${BINARY_DEST}
  Config:             ${ENV_FILE}
  SQLite DBs:         ${DB_DIR}/{system,web,database,mail,dns}.db
  Logs:               journalctl -u wahpanel -f     -or-     ${LOG_DIR}/
  Systemd:            systemctl {start|stop|restart|status} wahpanel

  Quick tips:
        • Open https://<SERVER-IP>:${DEFAULT_PORT} in your browser
        • Accept the self-signed TLS certificate warning
          → (set WAHPANEL_TLS_CERT / WAHPANEL_TLS_KEY in ${ENV_FILE} for trusted certs)
        • Sign in with the bootstrap admin credentials above
        • Complete the Install Wizard → Install Components (OLS + MariaDB + Certbot + UFW)
        • Add your first domain → Websites → + Add Domain
        • Forgot admin password?   →   sudo adminPass YourNewStrongPassword!
        • Panel unreachable (firewall)? →   sudo wahpanel-port 8090
        • Reinstall / patch debug? →   sudo wahpanel-diag   →   sends /root/wahpanel-diag-*.tar.gz
                                       (redacted logs + env + systemd + journald + CyberPanel snapshot)

  Docs / Support
        https://docs.wahpanel.com        ·    https://github.com/wahpanel/wahpanel
========================================================================
EOF
