#!/usr/bin/env bash
# =============================================================================
# CorePanel: RHEL 8-10 Base Environment Bootstrap (Consolidated)
# - Keeps firewalld enabled, opening only the ports this stack serves
# - Installs base tools, Postfix, Dovecot (+SASL, Maildir), pure-ftpd
# - Configures MariaDB 10.11 LTS (official repo, AppStream disabled), tuned
# - Installs PHP-FPM 8.4/7.4 (Remi parallel) + corehttpd PHP socket perms
# - Web server (corehttpd) ships in the corepanel meta-package (installed later)
# - Installs PowerDNS Authoritative (LMDB backend) + Recursor behind dnsdist
# - Installs Rspamd + Redis (milter), DKIM/ARC signing, IMAPSieve autolearn
# - User-facing spam policy (per-scope thresholds + allow/deny) via corepanel-sys
# - Per-user Sieve (vacation + filters) at LMTP delivery + ManageSieve (4190)
# - SELinux Enforcing
#
# This file IS the public installer: it is published verbatim as
# https://get.corepanel.net/install.sh (docroot /home/corepanel1/domains/
# get.corepanel.net on server.corepanel.net, uploaded over FTPS), which is the
# URL the documentation tells operators to pipe into bash. Any change here only
# reaches new servers once that copy is re-uploaded.
# Author: CorePanel
# =============================================================================

set -Eeuo pipefail

# ---- Pretty logs -------------------------------------------------------------
LOG_PREFIX="[corepanel-setup]"
# ANSI-C quoting ($'…'): these hold the real escape character, not the two-character
# sequence \033. `echo -e` and `printf '%b'` render either form, but a heredoc does
# not — and the final summary is a heredoc, which used to print the codes verbatim.
RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'; BOLD=$'\033[1m'; CLEAR=$'\033[0m'
log()   { echo -e "${GREEN}${LOG_PREFIX}${CLEAR} $*"; }
warn()  { echo -e "${YELLOW}${LOG_PREFIX}${CLEAR} $*"; }
error() { echo -e "${RED}${LOG_PREFIX}${CLEAR} $*" >&2; }

# ---- Banner ------------------------------------------------------------------
# Quoted heredoc: the art is full of backslashes and backticks, so it must reach
# the terminal verbatim, with no expansion.
print_banner() {
  echo
  printf '%b' "${GREEN}"
  cat <<'ART'
  ____                      ____                         _
 / ___|  ___   _ __   ___  |  _ \   __ _   _ __    ___  | |
| |     / _ \ | '__| / _ \ | |_) | / _` | | '_ \  / _ \ | |
| |___ | (_) || |   |  __/ |  __/ | (_| | | | | ||  __/ | |
 \____| \___/ |_|    \___| |_|     \__,_| |_| |_| \___| |_|
ART
  printf '%b' "${CLEAR}"
  echo
  echo -e " ${BOLD}Web hosting control panel — server bootstrap${CLEAR}"
  echo -e " https://www.corepanel.net"
  echo
}

# ---- Env detection -----------------------------------------------------------
DIST_ID=""; DIST_VER=""; PRETTY=""
require_root() { [[ $EUID -eq 0 ]] || { error "Run as root."; exit 1; }; }

detect_distro() {
  . /etc/os-release
  DIST_ID="${ID:-}"; DIST_VER="${VERSION_ID:-}"; PRETTY="${PRETTY_NAME:-$ID $VERSION_ID}"
  local major="${DIST_VER%%.*}"
  [[ -n "$DIST_ID" && -n "$DIST_VER" ]] || { error "Cannot detect distro."; exit 1; }
  [[ "$major" -ge 8 && "$major" -le 10 ]] || { error "Supported only EL 8–10."; exit 1; }
  log "Detected: ${PRETTY}"
}

# ---- CLI usage ---------------------------------------------------------------
print_usage() {
  cat <<EOF
Usage: $(basename "$0") [--force] [--transform-prepare] [--transform-cutover] [--help]

Bootstraps a RHEL 8-10 server as a CorePanel host (mail, MariaDB, DNS,
PHP-FPM, web server and SELinux policy).

Options:
  -f, --force   Continue even if another control panel is detected (see below).
                This is potentially destructive and is NOT recommended on a
                production server; intended only for deliberate migrations.
      --transform-prepare
                Install the CorePanel stack ALONGSIDE a running cPanel server,
                for an in-place transformation. Nothing that cPanel is serving
                is touched: no daemon of its is stopped, reconfigured or
                replaced, no port it holds is taken, no database is altered and
                the firewall and SELinux mode are left as they are. The panel's
                own services (core, sys, api, auth on :16087 and unix sockets)
                do start; everything that would collide waits for the cutover.
                Refuses to run where there is no cPanel to transform.
      --transform-cutover
                Install and configure the mail, FTP and DNS daemons during the
                cutover of an in-place transformation, once corepanel-transform
                has removed cPanel's own builds of them. It configures only that
                subset: no web server, no PHP, no database, and neither the
                firewall nor the SELinux mode is changed. Refuses to run while
                cPanel's packages still own those paths.
  -y, --yes     Do not ask anything: skips the confirmation AND runs the setup
                wizard non-interactively, so the panel domain and admin email are
                left for the panel's onboarding. Implied when the script runs
                without a terminal (provisioning, CI, cron).
  -h, --help    Show this help and exit.
EOF
}

# ---- Preflight: refuse to run on an incompatible / already-occupied server ----
# The installer takes ownership of the whole stack (mail, MariaDB, DNS, PHP-FPM,
# the web server and SELinux policy). Running it on a box already managed by
# another hosting control panel would overwrite that panel's configuration
# instead of failing cleanly, so we detect the common ones and abort early.
# Pass --force to override (e.g. for a deliberate in-place migration).
FORCE=0

# TRANSFORM_PREPARE selects the PREPARE phase of an in-place transformation
# (CPANEL-TRANSFORM-PLAN.md §5) instead of an installation. It is NOT --force
# with a friendlier name: --force runs the full installer anyway and overwrites
# the panel that is there, while this mode is defined by what it refuses to
# touch. See run_transform_prepare.
TRANSFORM_PREPARE=0

# TRANSFORM_CUTOVER selects the second half of that transformation: the mail,
# FTP and DNS daemons PREPARE could not install, put on and configured while the
# panel's own web server is already serving the sites. See run_transform_cutover.
TRANSFORM_CUTOVER=0

preflight_checks() {
  log "Running preflight checks…"
  local -a conflicts=()

  # Other hosting control panels. Match on canonical install markers — a
  # directory/binary/config that only exists once the panel is installed.
  [[ -d /usr/local/cpanel || -x /usr/local/cpanel/cpanel || -f /etc/wwwacct.conf ]] \
    && conflicts+=("cPanel/WHM (/usr/local/cpanel)")
  [[ -d /usr/local/psa || -d /opt/psa || -x /usr/sbin/plesk ]] \
    && conflicts+=("Plesk (/usr/local/psa)")
  [[ -d /usr/local/directadmin ]] \
    && conflicts+=("DirectAdmin (/usr/local/directadmin)")
  [[ -d /usr/local/CyberCP ]] \
    && conflicts+=("CyberPanel (/usr/local/CyberCP)")
  [[ -d /usr/local/ispconfig || -d /usr/local/ispconfig3 ]] \
    && conflicts+=("ISPConfig (/usr/local/ispconfig)")
  [[ -d /etc/webmin && -d /usr/libexec/webmin ]] \
    && conflicts+=("Webmin (/etc/webmin)")

  if (( ${#conflicts[@]} > 0 )); then
    error "Detected an existing hosting control panel on this server:"
    local c
    for c in "${conflicts[@]}"; do error "  - ${c}"; done
    error "CorePanel takes over the mail, MariaDB, DNS, PHP and web stacks and would"
    error "overwrite the panel above, breaking your current setup."
    if (( FORCE )); then
      warn "--force given: continuing despite the conflict above (this may be destructive)."
    else
      error "Re-run with --force to proceed anyway (NOT recommended on a production box)."
      exit 1
    fi
  fi

  # Existing databases with user data: the installer installs and secures its own
  # MariaDB. A foreign MySQL/MariaDB already holding data is a strong signal this
  # box is in use — warn (soft), but don't abort; the panels above already cover
  # the outright-destructive cases.
  if command -v mysql >/dev/null 2>&1 \
     && { systemctl is-active --quiet mariadb 2>/dev/null || systemctl is-active --quiet mysqld 2>/dev/null; }; then
    local dbs
    dbs="$(mysql -N -B -e 'SHOW DATABASES;' 2>/dev/null \
      | grep -vixE 'information_schema|performance_schema|mysql|sys|test' | wc -l)" || dbs=0
    if [[ "${dbs:-0}" -gt 0 ]]; then
      warn "Found ${dbs} existing user database(s) on the running MySQL/MariaDB server."
      warn "The installer will reconfigure and secure MariaDB in place; review before proceeding."
    fi
  fi

  # Memory. CorePanel's minimum is 2 GB, and this is a warning rather than a
  # refusal: an operator who knows what they are doing on a small box is not
  # someone to lock out, and the machines this was written for are the ones
  # where a service quietly dies later rather than fails now.
  #
  # The threshold is 1800 MB and NOT 2048 on purpose. MemTotal is what is left
  # after the kernel takes its own reservation — measured, 7% to 11% of nominal
  # RAM — so a genuine 2 GB VPS reports somewhere around 1820-1900 MB. Comparing
  # against 2048 would fire on every server that actually meets the minimum,
  # which is the fastest way to teach people to ignore a warning. A 1 GB box
  # reports around 950, so 1800 separates the two cleanly with room to spare.
  local ram_mb swap_mb
  ram_mb="$(awk '/^MemTotal:/{printf "%d", $2/1024}' /proc/meminfo 2>/dev/null || echo 0)"
  swap_mb="$(awk '/^SwapTotal:/{printf "%d", $2/1024}' /proc/meminfo 2>/dev/null || echo 0)"

  if [[ "${ram_mb:-0}" -gt 0 && "${ram_mb:-0}" -lt 1800 ]]; then
    warn ""
    warn "This server has ${ram_mb} MB of RAM. CorePanel needs 2 GB."
    warn "The stack it installs — MariaDB, PowerDNS, Dovecot, Rspamd, four PHP-FPM"
    warn "pools and the panel's own services — will not comfortably fit, and what"
    warn "that looks like is a service the kernel kills hours or days later, not an"
    warn "error now."
    if [[ "${swap_mb:-0}" -eq 0 ]]; then
      warn "There is no swap either, so there is nothing to absorb a spike."
    fi
    warn "Install anyway if you know what you are doing; nothing here is blocked."
    warn ""
  fi

  log "Preflight checks passed."
}

# ---- Confirmation ------------------------------------------------------------
# The installer takes over the whole server stack and there is no uninstall, so
# an operator who runs it by mistake — on the wrong box, or on one that already
# serves something — has no way back. Ask before touching anything.
#
# Only asks when a human can answer: with --yes, or with no terminal at all
# (Vagrant provisioning, CI, cron), it proceeds unattended as before. When the
# script itself arrives on stdin (`curl … | bash`) the operator is still at a
# terminal, so read the answer from /dev/tty instead of the script stream.
ASSUME_YES=0

confirm_install() {
  (( ASSUME_YES )) && return 0

  local tty_in=""
  if [[ -t 0 ]]; then
    tty_in="/dev/stdin"
  elif [[ -t 1 && -r /dev/tty ]]; then
    tty_in="/dev/tty"
  else
    log "No terminal attached: proceeding without confirmation."
    return 0
  fi

  echo
  echo -e "${YELLOW}${BOLD}  About to install CorePanel on this server${CLEAR}"
  echo
  echo -e "  Target : ${BOLD}$(hostname -f 2>/dev/null || hostname)${CLEAR} (${PRETTY})"
  echo
  echo "  CorePanel takes ownership of the whole hosting stack: mail (Postfix,"
  echo "  Dovecot, Rspamd), MariaDB, DNS (PowerDNS), FTP, PHP-FPM, the web server"
  echo "  and the SELinux policy. Existing configuration for any of these is"
  echo "  replaced, and services not managed by CorePanel may stop working."
  echo
  echo -e "  ${BOLD}Install on a fresh server only.${CLEAR} Any data, site or application"
  echo "  already on this machine may be removed. There is no uninstall and the"
  echo "  change cannot be rolled back."
  echo
  printf "  Type 'yes' to continue, anything else to abort: "

  local answer=""
  read -r answer < "${tty_in}" || answer=""
  echo

  if [[ "${answer}" != "yes" ]]; then
    error "Aborted: nothing was installed or modified."
    exit 1
  fi
  log "Confirmed. Starting the installation…"
}

dnf_safe() {
  # A cPanel server carries a global package exclusion in BOTH /etc/dnf/dnf.conf
  # and /etc/yum.conf:
  #
  #   exclude=bind-chroot dovecot* exim* filesystem p0f php* proftpd* pure-ftpd*
  #
  # which cPanel writes to protect its own builds of those. Every install below
  # then fails with "All matches were filtered out by exclude filtering", a
  # message that reads as a broken repository rather than as a line in a config
  # file — and it is what stopped the first real cutover, on cp.pyxsoft.dev.
  #
  # It is lifted for the cutover only, and for one transaction at a time.
  # --transform-prepare must NOT lift it: cPanel is still serving there and its
  # dovecot, exim and pure-ftpd are exactly what the exclusion is keeping this
  # script from replacing underneath it. By the cutover those packages have
  # already been removed, so the exclusion is protecting nothing.
  local ex=()
  [ "${TRANSFORM_CUTOVER:-0}" = "1" ] && ex=(--disableexcludes=main)
  dnf -y ${ex[@]+"${ex[@]}"} "$@" || {
    warn "DNF failed → cleaning metadata & retrying…"
    dnf -y clean all
    dnf -y ${ex[@]+"${ex[@]}"} "$@"
  }
}

# -----------------------------------------------------------------------------
# conf_set <file> <key> <value>
# -----------------------------------------------------------------------------
# Idempotently sets or updates a key=value pair inside a config file.
# - If the file or directory doesn’t exist, it’s created.
# - If the key already exists (even commented), it’s updated in place.
# - If the key doesn’t exist, it’s appended at the end.
# - Handles values with spaces or special chars safely.
# -----------------------------------------------------------------------------
conf_set() {
  local file="$1" key="$2" value="$3"

  # Ensure parent directory exists
  mkdir -p "$(dirname "$file")"

  # Create the file if missing
  [[ -f "$file" ]] || touch "$file"

  # Backup on first modification
  local bak="${file}.corepanel.bak"
  if [[ ! -f "$bak" ]]; then
    cp -a "$file" "$bak"
  fi

  # Escape slashes for sed (so we can safely substitute)
  local escaped_value
  escaped_value="$(printf '%s' "$value" | sed -e 's/[\\/&]/\\&/g')"

  # If key exists, replace it; otherwise append at the end
  if grep -Eq "^[[:space:]]*${key}[[:space:]]*=" "$file"; then
    sed -ri "s|^[[:space:]]*(${key})[[:space:]]*=.*|\\1=${escaped_value}|" "$file"
  else
    printf '%s=%s\n' "$key" "$value" >>"$file"
  fi
}

# ---- Rspamd service account detection ---------------------------------------
# NOTE: Some builds create user/group `_rspamd` instead of `rspamd`.
# We detect the effective account from systemd or passwd/group database.
RSPAMD_USER=""; RSPAMD_GROUP=""

detect_rspamd_user_group() {
  # 1) Try to read from systemd unit (if service installed)
  local u g
  u="$(systemctl show rspamd -p User --value 2>/dev/null | sed 's/^\s*$//')" || true
  g="$(systemctl show rspamd -p Group --value 2>/dev/null | sed 's/^\s*$//')" || true

  # 2) Fallback to known account names
  if [[ -z "$u" ]]; then
    if getent passwd rspamd >/dev/null; then u="rspamd";
    elif getent passwd _rspamd >/dev/null; then u="_rspamd"; fi
  fi

  # 3) Derive group if missing (prefer same name as user)
  if [[ -z "$g" && -n "$u" ]]; then
    if getent group "$u" >/dev/null; then g="$u"; fi
  fi

  # 4) As a last resort (shouldn't happen if package installed), create it
  if [[ -z "$u" ]]; then
    warn "Rspamd account not found yet; creating fallback 'rspamd' system user."
    useradd -r -s /sbin/nologin -d /var/lib/rspamd rspamd || true
    u="rspamd"; g="rspamd"
  fi

  RSPAMD_USER="$u"; RSPAMD_GROUP="${g:-$u}"
  log "Using Rspamd account: ${RSPAMD_USER}:${RSPAMD_GROUP}"
}

# ---- System update & base tools ---------------------------------------------
#
# --nobest on the upgrade, because this step is a courtesy and must not be able
# to abort the installation. The distribution's own repositories go through
# states where one modular stream cannot be resolved — AlmaLinux 8 currently
# offers a perl-Compress-Raw-Zlib whose libperl provider is filtered out by
# modular filtering — and without --nobest dnf treats "the best candidate for
# one unrelated package is uninstallable" as a fatal error for the whole
# transaction. A hosting panel refusing to install because of a perl module it
# never uses is not a failure anybody can act on, and it is what dnf itself
# suggests in the message.
update_system() {
  log "Updating system…"
  dnf_safe makecache
  dnf_safe upgrade --refresh --nobest
}

install_base_tools() {
  log "Installing base tools…"
  local pkgs=(
    curl wget vim git tar unzip rsync jq bind-utils net-tools
    policycoreutils policycoreutils-python-utils checkpolicy ca-certificates
    coreutils hostname chrony acl quota
  )
  dnf_safe install "${pkgs[@]}"
  # ipset: install if present; don't fail if absent
  if dnf list --available ipset &>/dev/null; then dnf_safe install ipset; else warn "ipset not found; skipping."; fi
  systemctl enable --now chronyd >/dev/null 2>&1 || true
  install_app_egress_backend
}

# ---- Mail filtering policy ---------------------------------------------------
# Two settings decide whether spam is caught and where a caught message lands:
# the resolver Rspamd asks (Spamhaus refuses shared provider resolvers and
# Rspamd then switches the list off entirely) and the global Sieve that files a
# flagged message into Junk. Neither is configured here.
#
# They live in corepanel-sys's RPM, which runs on install AND on every upgrade,
# because this script runs once on a brand-new server and never again — so a
# correction written here would never reach any of the servers that already have
# the problem. This function only invokes that helper, at the point in each
# sequence where Rspamd, Dovecot and the recursor all exist.
#
# A full install gets it for free (the RPM's %post runs after every one of those
# is in place), but a transform cutover does not: its RPMs went in during
# PREPARE, long before the mail stack existed. Calling it explicitly is what
# covers that case, and it is idempotent, so doing it twice costs nothing.
apply_mail_filtering_policy() {
  local helper="/opt/corepanel/bin/corepanel-mail-filtering-setup"
  if [[ ! -x "$helper" ]]; then
    warn "${helper} is missing; skipping the mail filtering policy."
    warn "Rspamd may be resolving through a provider resolver that Spamhaus"
    warn "refuses, and flagged spam may be delivered to the INBOX."
    return 0
  fi
  log "Applying the mail filtering policy (Rspamd resolver, spam → Junk)…"
  "$helper" || warn "the mail filtering policy could not be applied in full."
}

# ---- Outbound network backend for published applications ---------------------
# Applications run with PrivateNetwork=true, which leaves them with loopback and
# nothing else. cp-netout attaches one of these to the application's namespace so
# it can reach the internet WITHOUT a single netfilter rule — the same reason
# §3.6 of APP-RUNTIME-PLAN picks Podman over Docker: firewalld stays as the
# operator left it.
#
# passt is preferred and measurably better (~2100 MB/s against ~50 MB/s on the
# same host), but it does not exist in EL8's repositories, so slirp4netns is the
# fallback there. cp-netout picks by what is installed, not by distro version.
install_app_egress_backend() {
  log "Installing the outbound network backend for applications…"
  if dnf list --available passt &>/dev/null || rpm -q passt &>/dev/null; then
    dnf_safe install passt
    # The policy module ships separately and is what keeps pasta working under
    # Enforcing; absent on EL8, harmless to attempt.
    dnf install -y passt-selinux >/dev/null 2>&1 || true
    return 0
  fi
  if dnf_safe install slirp4netns; then
    return 0
  fi
  warn "Neither passt nor slirp4netns could be installed; applications will start without outbound network access."
}

# ---- Per-account disk quotas -------------------------------------------------
# Disk usage is accounted by the kernel, per uid, and that accounting is a
# property of how the filesystem was MOUNTED. That one fact is why this cannot
# always finish here:
#
#   ext4  takes the option on a remount, so quotas work before this function
#         returns. No reboot, ever.
#   XFS   reads the option only when the filesystem is mounted. A separate /home
#         can be unmounted and mounted again — nothing holds it open on a fresh
#         server — but a ROOT XFS, which is what most installs end up with, can
#         only get it from the kernel command line. That is a reboot.
#
# This installer never reboots: the RPMs, the SELinux domains and the
# `configure_corepanel` wizard all run after this point, and the wizard's
# "CorePanel is ready!" has to be the last thing the operator reads. So when a
# reboot is what is missing, print_summary says so instead of this pretending
# the job is done. An operator who believes accounts are limited when nothing
# limits them is worse off than one who was told to reboot.
#
# Enforcement is turned on together with accounting, here and in
# `corepanel quota enable` alike. Counting without stopping anything is a
# diagnostic state, not a destination: a server that measures but never refuses
# only looks like it has quotas. What protects an account that turns out to be
# over its limit is the grace window — the kernel's hard limit sits above the
# quota, so the account goes over its stated size, and is warned, before a
# single write fails.
DISK_QUOTA_STATE="not configured"
DISK_QUOTA_REBOOT=0
# Overridable so the fstab editing can be exercised against a fixture instead of
# this machine's own mount table.
DISK_QUOTA_FSTAB="${DISK_QUOTA_FSTAB:-/etc/fstab}"

# _quota_option_in <options-csv> — true when a user-quota mount option is present.
_quota_option_in() {
  printf '%s' "$1" | grep -Eq '(^|,)(uquota|usrquota|uqnoenforce|quota)(,|$)'
}

# _quota_accounting_on <mountpoint> <fstype>
# Whether the kernel is actually counting, which the mount options do NOT
# answer: on ext4 they keep saying usrquota after quotas have been turned off at
# runtime, and on XFS they say nothing about whether accounting ever started.
# Reading them instead would report an unquota'd server as already done.
#
# `quotaon -p` answers on stdout and exits NON-ZERO to do it — reporting state
# is not an error, but that is how it reports. This script runs under `set -o
# pipefail`, so piping it straight into grep hands the pipeline quotaon's status
# whatever grep found, and the answer was always "off" on every ext filesystem.
# The output is captured first for that reason; `|| true` keeps the capture from
# tripping `set -e` on the same non-zero.
_quota_accounting_on() {
  local mp="$1" fstype="$2" report
  case "$fstype" in
    xfs)
      command -v xfs_quota >/dev/null 2>&1 || return 1
      report="$(xfs_quota -x -c "state -u" "$mp" 2>/dev/null || true)"
      printf '%s' "$report" | grep -qi "accounting: on" ;;
    *)
      command -v quotaon >/dev/null 2>&1 || return 1
      report="$(quotaon -p -u "$mp" 2>/dev/null || true)"
      printf '%s' "$report" | grep -q "is on" ;;
  esac
}

# _fstab_add_option <mountpoint> <option>
#   0 = added, 1 = already there, 2 = no entry for that mount point
_fstab_add_option() {
  local mp="$1" opt="$2" opts
  opts="$(awk -v mp="$mp" '$0 !~ /^[[:space:]]*#/ && NF>=4 && $2==mp {print $4; exit}' "$DISK_QUOTA_FSTAB")"
  [[ -n "$opts" ]] || return 2
  _quota_option_in "$opts" && return 1

  # One backup, taken before the first edit and never overwritten: it has to
  # stay the version that is known to boot.
  [[ -f "${DISK_QUOTA_FSTAB}.corepanel-quota.bak" ]] || cp -a "$DISK_QUOTA_FSTAB" "${DISK_QUOTA_FSTAB}.corepanel-quota.bak"
  local tmp; tmp="$(mktemp)"
  awk -v mp="$mp" -v opt="$opt" 'BEGIN{OFS="\t"}
    $0 ~ /^[[:space:]]*#/ {print; next}
    NF>=4 && $2==mp {$4 = $4 "," opt; print; next}
    {print}' "$DISK_QUOTA_FSTAB" > "$tmp" || { rm -f "$tmp"; return 2; }
  # Written through cat rather than mv so the file keeps its mode, owner and
  # SELinux label; /etc/fstab mislabelled is a server that does not boot.
  cat "$tmp" > "$DISK_QUOTA_FSTAB"
  rm -f "$tmp"
  return 0
}

_configure_disk_quota_ext() {
  local mp="$1" fstype="$2" fstab_status=0

  # Captured with `||` rather than read from `$?` afterwards. The function
  # answers 1 for "the option was already there", which is a normal outcome on
  # every re-run — and a bare call returning 1 under `set -e` ends the installer
  # right here, after the "Configuring per-account disk quotas…" line and before
  # any other, which is as silent as a failure gets.
  _fstab_add_option "$mp" usrquota || fstab_status=$?
  case "$fstab_status" in
    2) warn "No /etc/fstab entry for ${mp}; disk quotas were not configured."
       DISK_QUOTA_STATE="not configured (no fstab entry for ${mp})"
       return 0 ;;
  esac

  if ! mount -o remount "$mp"; then
    warn "${mp} could not be remounted; disk quotas start at the next reboot."
    DISK_QUOTA_STATE="configured on ${mp} (${fstype}) — reboot required"
    DISK_QUOTA_REBOOT=1
    return 0
  fi

  # Builds the file the kernel keeps the per-uid totals in. A filesystem created
  # with the journalled quota feature has no such file and this fails; quotaon
  # works regardless, so the failure is ignored rather than fatal.
  quotacheck -cum "$mp" >/dev/null 2>&1 || true
  # quotacheck creates that file with whatever type the calling domain implies,
  # which is not quota_db_t. Left wrong, the kernel is denied its own accounting
  # file on the next boot.
  restorecon -F "${mp%/}/aquota.user" >/dev/null 2>&1 || true

  if quotaon -u "$mp" >/dev/null 2>&1 || _quota_accounting_on "$mp" "$fstype"; then
    DISK_QUOTA_STATE="active on ${mp} (${fstype}, accounting + enforcement)"
    log "Disk quotas are active on ${mp}."
  else
    warn "quotaon failed on ${mp}; disk quotas start at the next reboot."
    DISK_QUOTA_STATE="configured on ${mp} (${fstype}) — reboot required"
    DISK_QUOTA_REBOOT=1
  fi
}

_configure_disk_quota_xfs_root() {
  if ! command -v grubby >/dev/null 2>&1; then
    warn "grubby is not available; the root filesystem cannot be configured for disk quotas."
    DISK_QUOTA_STATE="not configured (grubby missing)"
    return 0
  fi

  local args current merged
  args="$(grubby --info=DEFAULT 2>/dev/null | sed -n 's/^args="\(.*\)"$/\1/p' | head -1)"
  current="$(printf '%s\n' $args | sed -n 's/^rootflags=//p' | head -1)"

  if _quota_option_in "$current"; then
    log "The kernel command line already asks for disk quotas."
    DISK_QUOTA_STATE="configured on the kernel command line — reboot required"
    DISK_QUOTA_REBOOT=1
    return 0
  fi

  if [[ -n "$current" ]]; then
    # Removing by bare key drops every rootflags= there is, so running the
    # installer twice cannot leave two of them behind — grubby appends
    # unconditionally, which is exactly how that happens.
    merged="${current},uquota"
    grubby --update-kernel=ALL --remove-args=rootflags >/dev/null 2>&1 || true
  else
    merged="uquota"
  fi

  if grubby --update-kernel=ALL --args="rootflags=${merged}" >/dev/null 2>&1; then
    DISK_QUOTA_STATE="configured on the kernel command line — REBOOT REQUIRED"
    DISK_QUOTA_REBOOT=1
  else
    warn "grubby could not set rootflags=${merged}; disk quotas were not configured."
    DISK_QUOTA_STATE="not configured (grubby failed)"
  fi
}

_configure_disk_quota_xfs_mount() {
  local mp="$1"

  # The mount option is proven to work BEFORE /etc/fstab is touched. Editing
  # first and then failing to mount would leave the filesystem gone AND the file
  # that brings it back holding the option that broke it.
  if ! umount "$mp" >/dev/null 2>&1; then
    local fstab_status=0
    _fstab_add_option "$mp" uquota || fstab_status=$?
    case "$fstab_status" in
      2) warn "No /etc/fstab entry for ${mp}; disk quotas were not configured."
         DISK_QUOTA_STATE="not configured (no fstab entry for ${mp})"
         return 0 ;;
    esac
    DISK_QUOTA_STATE="configured on ${mp} (xfs) — reboot required"
    DISK_QUOTA_REBOOT=1
    return 0
  fi

  if ! mount -o uquota "$mp"; then
    if ! mount "$mp"; then
      error "${mp} could not be mounted with quotas and could not be mounted back. It is UNMOUNTED; fix this before continuing."
      DISK_QUOTA_STATE="FAILED — ${mp} is unmounted"
      return 1
    fi
    warn "${mp} does not accept the uquota mount option; disk quotas were not configured."
    DISK_QUOTA_STATE="not configured (${mp} rejected uquota)"
    return 0
  fi

  # Mounted with quotas already, so accounting IS on — what the fstab line
  # decides is whether it survives a reboot, and that is worth saying out loud
  # rather than swallowing. `|| status=$?` because 1 means "already there",
  # which is the normal answer on every re-run and must not end the installer.
  local fstab_status=0
  _fstab_add_option "$mp" uquota || fstab_status=$?
  if [[ "$fstab_status" -eq 2 ]]; then
    warn "${mp} is mounted with quotas but has no /etc/fstab entry; they will be gone after a reboot."
    DISK_QUOTA_STATE="active on ${mp} (xfs) — not in /etc/fstab, lost on reboot"
    return 0
  fi
  DISK_QUOTA_STATE="active on ${mp} (xfs, accounting + enforcement)"
  log "Disk quotas are active on ${mp}."
}

configure_disk_quota() {
  log "Configuring per-account disk quotas…"

  # A container tenant does not own the kernel that owns the block device, so
  # quotas are not something it can be talked into.
  if systemd-detect-virt -c >/dev/null 2>&1; then
    warn "Running inside a container: filesystem disk quotas are not available. Skipping."
    DISK_QUOTA_STATE="unavailable (container)"
    return 0
  fi

  local probe="/home"; [[ -d /home ]] || probe="/"
  local fstype target
  fstype="$(findmnt -no FSTYPE --target "$probe" 2>/dev/null | head -1)"
  target="$(findmnt -no TARGET --target "$probe" 2>/dev/null | head -1)"
  if [[ -z "$fstype" || -z "$target" ]]; then
    warn "Could not determine which filesystem backs ${probe}; disk quotas were not configured."
    DISK_QUOTA_STATE="not configured (filesystem unknown)"
    return 0
  fi

  if _quota_accounting_on "$target" "$fstype"; then
    log "Disk quotas are already enabled on ${target}."
    DISK_QUOTA_STATE="already enabled on ${target} (${fstype})"
    return 0
  fi

  case "$fstype" in
    ext2|ext3|ext4) _configure_disk_quota_ext "$target" "$fstype" ;;
    xfs)
      if [[ "$target" == "/" ]]; then
        _configure_disk_quota_xfs_root
      else
        _configure_disk_quota_xfs_mount "$target"
      fi ;;
    *)
      warn "CorePanel cannot manage disk quotas on ${fstype}; skipping."
      DISK_QUOTA_STATE="unsupported filesystem (${fstype})" ;;
  esac
}

# ---- CorePanel repo ----------------------------------------------------------
configure_corepanel_repo() {
  cat >/etc/yum.repos.d/corepanel.repo << 'EOF'
[corepanel-x86_64]
name=Corepanel (x86_64)
baseurl=https://repo.pyxsoft.com/corepanel/any/any/x86_64/
enabled=1
gpgcheck=1
gpgkey=https://repo.pyxsoft.com/public-keys/gpg.asc
metadata_expire=7200

[corepanel-noarch]
name=Corepanel (noarch)
baseurl=https://repo.pyxsoft.com/corepanel/any/any/noarch/
enabled=1
gpgcheck=1
gpgkey=https://repo.pyxsoft.com/public-keys/gpg.asc
metadata_expire=7200

EOF
}

# ---- Firewalld ---------------------------------------------------------------
# firewalld stays ON. This server is exposed to the internet on standard ports,
# and turning the firewall off does not just open the ones CorePanel serves — it
# opens every port anything on the box ever binds: PowerDNS' and rspamd's control
# ports, a MariaDB that loses its bind-address after an operator edit, whatever a
# future package starts listening on. What the installer does instead is open
# exactly the ports the stack it just configured needs.
#
# The list comes from the seed table in FIREWALLD-PLAN.md §3.3 — keep both in sync.
#
# This deliberately does NOT create the `corepanel` zone. That zone, its ipsets
# and the brute-force blocking are the Pro+ firewall-management feature, adopted
# explicitly from the UI or the CLI. Seeding the DEFAULT zone here means an
# operator who never buys that feature still ends up with a working firewall, and
# one who adopts it later gets a dedicated zone that takes over cleanly.
#
# Nothing here is fatal: a server that finishes installing with a warning about
# the firewall is recoverable, one that aborts three quarters of the way through
# is not.
FIREWALL_STATE="not configured"

# ssh_listen_ports — the ports sshd actually listens on, so enabling a firewall
# cannot lock the operator out of a box reached on a non-standard port.
#
# It ASKS SSHD first. `sshd -T` prints the daemon's own effective configuration,
# which is the authoritative resolution of `Include`, of first-value-wins and of
# the drop-ins; grepping the config files resolves none of those. A host whose
# sshd_config carries `Include /etc/ssh/custom.d/*.conf` with the real `Port`
# inside reads as 22 to the file parser, and seeding 22 on a server only
# reachable on 2222 is exactly the lockout this seed exists to prevent.
#
# The file parse stays as the fallback for a host where sshd cannot run (absent,
# or a config it refuses), and 22 as the last resort — which is also the stock
# case, since `Port 22` ships commented out.
#
# corepanel-sys resolves it the same way, in Go (services/firewalld/seed.go).
# The two must stay in step: this seeds the default zone at install time, that
# one seeds the corepanel zone when the firewall is adopted.
ssh_listen_ports() {
  local ports=""

  # `|| true` is load-bearing throughout under `set -Eeuo pipefail`: every one of
  # these may legitimately fail on some host, and none of them is worth aborting
  # an install over.
  ports="$(sshd -T 2>/dev/null | awk '/^port[[:space:]]+[0-9]+$/ {print $2}' | sort -un | tr '\n' ' ')" || true

  if [[ -z "${ports// /}" ]]; then
    # The drop-in glob does not expand on a server without
    # /etc/ssh/sshd_config.d, so awk is handed the literal pattern and exits 2.
    ports="$(awk '/^[[:space:]]*[Pp]ort[[:space:]]+[0-9]+/ {print $2}' \
      /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf 2>/dev/null | sort -un | tr '\n' ' ')" || true
  fi

  [[ -n "${ports// /}" ]] || ports="22"
  echo $ports
}

# ftp_passive_range — read back from the config install_pureftpd wrote, so the two
# can never drift; the literal is only the fallback for a server where that file
# is missing.
ftp_passive_range() {
  local lo="" hi=""
  if [[ -r /etc/pure-ftpd/pure-ftpd.conf ]]; then
    read -r lo hi <<<"$(awk '/^[[:space:]]*PassivePortRange/ {print $2, $3; exit}' /etc/pure-ftpd/pure-ftpd.conf)"
  fi
  [[ -n "$lo" && -n "$hi" ]] || { lo=42000; hi=50000; }
  echo "${lo}-${hi}"
}

configure_firewalld() {
  log "Configuring the firewall (firewalld)…"

  # A container tenant does not own the netfilter tables of the host.
  if systemd-detect-virt -c >/dev/null 2>&1; then
    warn "Running inside a container: firewalld is not available. Skipping."
    FIREWALL_STATE="unavailable (container)"
    return 0
  fi

  # `|| true` again, and for a sharper reason: dnf_safe returns non-zero when both
  # its attempts fail, and as the last command of an `||` list that failure IS the
  # function's — which under `set -e` would abort the installer here, at the very
  # end, over a firewall package. Check for the command instead of trusting dnf.
  rpm -q firewalld &>/dev/null || dnf_safe install firewalld || true
  if ! command -v firewall-cmd >/dev/null 2>&1; then
    warn "firewalld could not be installed; this server is left WITHOUT a firewall."
    FIREWALL_STATE="not installed"
    return 0
  fi

  systemctl unmask firewalld >/dev/null 2>&1 || true
  if ! systemctl enable --now firewalld >/dev/null 2>&1; then
    warn "firewalld could not be started; this server is left WITHOUT a firewall."
    FIREWALL_STATE="not running (failed to start)"
    return 0
  fi

  # `systemctl start` returns before firewalld answers on D-Bus, and the first
  # firewall-cmd then fails with a connection error on an otherwise healthy box.
  local i
  for i in $(seq 1 10); do
    firewall-cmd --state >/dev/null 2>&1 && break
    sleep 1
  done
  if ! firewall-cmd --state >/dev/null 2>&1; then
    warn "firewalld is running but not answering; no ports were opened."
    FIREWALL_STATE="running but unreachable"
    return 0
  fi

  local zone
  zone="$(firewall-cmd --get-default-zone 2>/dev/null)" || zone=""
  [[ -n "$zone" ]] || zone="public"

  # SSH goes in first, always: see the anti-lockout invariant in FIREWALLD-PLAN
  # §10. Adding it before the rest — rather than appending — means a failure part
  # way down this list cannot be the one that leaves out the port carrying the
  # operator's own session.
  local -a tcp_ports=()
  local p
  for p in $(ssh_listen_ports); do tcp_ports+=("$p"); done

  tcp_ports+=(
    80 443       # web, panel and webmail (corehttpd, by SNI)
    53           # DNS (PowerDNS authoritative)
    21           # FTP control (pure-ftpd)
    25 465 587   # SMTP, SMTPS, submission
    143 993      # IMAP, IMAPS
    110 995      # POP3, POP3S
  )
  tcp_ports+=("$(ftp_passive_range)")

  local -a udp_ports=(
    53           # DNS (PowerDNS authoritative)
    443          # HTTP/3 (QUIC) — see below
  )

  # 443/udp is not optional here: corehttpd ships with HTTP/3 enabled
  # (EnableHTTP3 defaults to true), so every response advertises h3 through
  # Alt-Svc. Leaving the UDP port closed does not disable HTTP/3, it makes every
  # browser that believes the advertisement try QUIC, fail, and fall back to TCP —
  # strictly worse than never offering it.

  # ManageSieve (4190) is deliberately NOT opened. Users manage their filters and
  # vacation replies from the webmail, and Roundcube reaches ManageSieve over
  # loopback (managesieve_host defaults to localhost), so closing it costs nothing
  # to the normal path. Publishing it would add one more authenticated,
  # brute-forceable daemon on the internet for the sake of the few operators who
  # drive Sieve from a desktop client. Those can open it deliberately:
  #   firewall-cmd --permanent --add-port=4190/tcp && firewall-cmd --reload

  local failed=0
  for p in "${tcp_ports[@]}"; do
    firewall-cmd --permanent --zone="$zone" --add-port="${p}/tcp" >/dev/null 2>&1 \
      || { warn "Could not open ${p}/tcp in zone '${zone}'."; failed=1; }
  done
  for p in "${udp_ports[@]}"; do
    firewall-cmd --permanent --zone="$zone" --add-port="${p}/udp" >/dev/null 2>&1 \
      || { warn "Could not open ${p}/udp in zone '${zone}'."; failed=1; }
  done

  if ! firewall-cmd --reload >/dev/null 2>&1; then
    warn "firewalld did not reload; the rules above take effect on the next restart."
    FIREWALL_STATE="configured on zone '${zone}' — reload failed, restart firewalld"
    return 0
  fi

  if (( failed )); then
    FIREWALL_STATE="active on zone '${zone}' — some ports could not be opened (see the warnings above)"
  else
    FIREWALL_STATE="active on zone '${zone}' (web, mail, DNS, FTP and SSH open; ManageSieve closed)"
  fi
  log "The firewall is active on zone '${zone}'."
}

# ---- Repos: EPEL + Remi + MariaDB ------------------------------------------
enable_epel() {
  log "Enabling EPEL…"
  dnf_safe install epel-release
  # On RHEL bare, enable CodeReady for some deps; harmless on clones.
  subscription-manager repos --enable "codeready-builder-for-rhel-*-rpms" &>/dev/null || true
}

enable_remi() {
  log "Enabling Remi repo (parallel PHP)…"
  if ! rpm -qa | grep -qi '^remi-release'; then
    dnf_safe install "https://rpms.remirepo.net/enterprise/remi-release-${DIST_VER%%.*}.rpm" || dnf_safe install remi-release || true
  fi
  dnf config-manager --set-enabled remi-safe >/dev/null 2>&1 || true
}

disable_mariadb_appstream() {
  log "Disabling AppStream MariaDB module…"
  dnf -y module reset mariadb >/dev/null 2>&1 || true
  dnf -y module disable mariadb || true
}

# MariaDB publishes the same 10.11 tree on several hosts. mirror.mariadb.org is
# a single machine that redirects to regional mirrors, so when it is unreachable
# from a customer's network — a blocked egress, a filtering proxy, or the mirror
# simply being down — there is nothing for dnf to fall back to and the whole
# installation dies here, halfway through, with a dnf traceback the operator
# cannot act on. Trying the alternates in order costs about a second on a
# healthy server and turns a permanent failure into a transient one.
MARIADB_MIRRORS=(
  'https://mirror.mariadb.org/yum/10.11/rhel/$releasever/$basearch'
  'https://dlm.mariadb.com/repo/mariadb-server/10.11/yum/rhel/$releasever/$basearch'
  'https://rpm.mariadb.org/10.11/rhel/$releasever/$basearch'
)

# $releasever and $basearch must reach the file verbatim — dnf expands them, we
# must not. The heredoc is unquoted so ${baseurl} is substituted, and bash does
# not re-expand the result of an expansion, so the dnf variables inside it
# survive untouched.
write_mariadb_repo() {
  local baseurl="$1"
  cat >/etc/yum.repos.d/MariaDB.repo <<EOF
[mariadb]
name = MariaDB 10.11
baseurl = ${baseurl}
gpgkey = https://mariadb.org/mariadb_release_signing_key.pgp
gpgcheck = 1
enabled = 1
module_hotfixes = 1
EOF
}

configure_mariadb_repo() {
  log "Configuring MariaDB 10.11 LTS official repo…"
  local baseurl host
  for baseurl in "${MARIADB_MIRRORS[@]}"; do
    write_mariadb_repo "${baseurl}"
    host="${baseurl#https://}"; host="${host%%/*}"
    # Probe the repo the way dnf itself will use it, so $releasever, the proxy
    # configuration and the mirror's metadata are all exercised before anything
    # is installed. dnf's stock retries=10/timeout=30 would sit on a dead host
    # for minutes; one short attempt is enough to move on to the next mirror.
    # --refresh so a cache left by an earlier run cannot answer for a mirror
    # that is currently down: the probe has to touch the network to mean anything.
    # --repo (not --enablerepo/--disablerepo) because dnf5 on EL10 renamed those
    # to --enable-repo/--disable-repo, while --repo is spelled the same in both.
    if dnf -q --repo=mariadb \
         --setopt=timeout=15 --setopt=retries=1 makecache --refresh >/dev/null 2>&1; then
      log "MariaDB repo ready (${host})."
      return 0
    fi
    warn "MariaDB mirror unreachable: ${host}"
  done

  # Every mirror failed the probe. That is almost always blocked HTTPS egress,
  # but it would also be the outcome on a platform whose dnf rejects the probe
  # itself, so this must not be the thing that aborts an otherwise fine install:
  # restore the primary mirror and let install_mariadb decide. It fails a few
  # seconds later with dnf's own error if the network really is the problem.
  warn "No MariaDB mirror answered. This points at outbound HTTPS being blocked"
  warn "(firewall, proxy or DNS) rather than at a package problem. Continuing;"
  warn "if the install stops at MariaDB, fix egress and re-run this installer —"
  warn "it is idempotent and picks up where it stopped."
  write_mariadb_repo "${MARIADB_MIRRORS[0]}"
}

# ---- MariaDB install & secure ----------------------------------------------
install_mariadb() {
  log "Installing MariaDB 10.11…"
  dnf_safe install MariaDB-server MariaDB-client
  systemctl enable --now mariadb
  # Wait a moment for first start/init
  for i in {1..8}; do
    mysqladmin ping --silent && break || sleep 1
  done
}

# Compute reasonable InnoDB buffer pool by RAM
compute_innodb_buffer() {
  local mem_kb=$(awk '/MemTotal/ {print $2}' /proc/meminfo)
  local mem_mb=$(( mem_kb / 1024 ))
  local buf="256M"
  if   (( mem_mb >= 32768 )); then buf="8G"
  elif (( mem_mb >= 16384 )); then buf="4G"
  elif (( mem_mb >=  8192 )); then buf="2G"
  elif (( mem_mb >=  4096 )); then buf="1G"
  elif (( mem_mb >=  2048 )); then buf="512M"
  else buf="256M"; fi
  echo "$buf"
}

tune_mariadb() {
  log "Applying MariaDB tuning (/etc/my.cnf.d/corepanel-tuning.cnf)…"
  write_mariadb_tuning || exit 1
  local innobuf="$(compute_innodb_buffer)"
  install -o root -g root -m 0644 /dev/null /etc/my.cnf.d/corepanel-tuning.cnf
  cat >/etc/my.cnf.d/corepanel-tuning.cnf <<EOF
# =============================================================================
# CorePanel MariaDB tuning
# -----------------------------------------------------------------------------
# Rationale:
# - innodb_buffer_pool_size: primary cache for data+indexes; sized by RAM.
# - innodb_log_file_size: larger redo logs reduce checkpoints on write-heavy loads.
# - innodb_flush_method=O_DIRECT: avoid double-buffering (fs cache + InnoDB).
# - innodb_flush_log_at_trx_commit=1: full ACID durability (2 is faster, less durable).
# - max_connections: balanced capacity for shared hosting workloads.
# - tmp_table_size / max_heap_table_size: reduce on-disk tmp tables for typical queries.
# - slow_query_log: visibility of outliers; 1s threshold is a sane starting point.
# =============================================================================
[mysqld]
bind-address                   = 127.0.0.1   # Local-only: do not expose MySQL on the network
innodb_buffer_pool_size        = 128M
innodb_log_file_size           = 256M
innodb_flush_method            = O_DIRECT
innodb_flush_log_at_trx_commit = 1
innodb_file_per_table          = 1

max_connections                = 200
table_open_cache               = 4000
tmp_table_size                 = 64M
max_heap_table_size            = 64M
thread_cache_size              = 100

# Slow query log for visibility/troubleshooting
slow_query_log                 = ON
slow_query_log_file            = /var/log/mariadb/slow.log
long_query_time                = 1
log_slow_verbosity             = query_plan,innodb
EOF

  install -d -o mysql -g mysql /var/log/mariadb
  install -o mysql -g mysql -m 0640 /dev/null /var/log/mariadb/slow.log
  systemctl restart mariadb
}

secure_mariadb_root_password() {
  local pw_file="/root/.mariadb_root_password"
  if [[ ! -s "$pw_file" ]]; then
    # Subshell so `umask 077` stays local; otherwise it leaks into the rest of
    # the installer and later config files (e.g. rspamd) become unreadable 0600.
    ( umask 077; openssl rand -hex 24 > "$pw_file" )
    chmod 600 "$pw_file"
  fi
  cat "$pw_file"
}

# -----------------------------------------------------------------------------
# [CorePanel Setup] Idempotent MySQL/MariaDB helpers
# If "log" is not defined by the caller script, define a minimal one.
if ! declare -F log >/dev/null 2>&1; then
  log() { echo "[corepanel-setup] $*"; }
fi

# --- MySQL root exec helper (idempotent) -------------------------------------
mysql_root() {
  # Try with /root/.my.cnf first (preferred & secure)
  if mysql --defaults-file=/root/.my.cnf -e "SELECT 1" >/dev/null 2>&1; then
    mysql --defaults-file=/root/.my.cnf "$@"
    return $?
  fi

  # Try with stored password file (fallback)
  if [[ -s /root/.mariadb_root_password ]]; then
    local pw
    pw="$(cat /root/.mariadb_root_password)"
    MYSQL_PWD="$pw" mysql -uroot "$@"
    return $?
  fi

  # Last resort: if root still uses unix_socket plugin, this will work
  mysql --protocol=socket -uroot "$@"
}

# --- Idempotent MariaDB hardening --------------------------------------------
secure_mariadb() {
  log "Securing MariaDB (root password auth, local-only, remove anon & test)…"

  # Generate/keep root password (idempotent)
  local pw_file="/root/.mariadb_root_password"
  if [[ ! -s "$pw_file" ]]; then
    # Subshell so `umask 077` stays local; otherwise it leaks into the rest of
    # the installer and later config files (e.g. rspamd) become unreadable 0600.
    (
      umask 077
      # 48 hex chars (~192 bits) is plenty strong and avoids special char escaping issues
      if command -v openssl >/dev/null 2>&1; then
        openssl rand -hex 24 > "$pw_file"
      else
        # Fallback if openssl is not available
        head -c 24 /dev/urandom | od -An -tx1 | tr -d '\n' > "$pw_file"
        echo >> "$pw_file"
      fi
    )
    chmod 600 "$pw_file"
  fi
  local ROOT_PW; ROOT_PW="$(cat "$pw_file")"

  # Root must keep unix_socket auth: corepanel-sys connects to MariaDB as the OS
  # root user over the socket with NO password (unix_socket plugin). A plain
  # `IDENTIFIED BY` drops unix_socket and switches root to mysql_native_password,
  # which breaks every corepanel-sys MySQL operation (database/user creation
  # silently fails). We use MariaDB multi-auth ("VIA a OR b", available on the
  # bundled 10.11 LTS) so root authenticates via EITHER the socket (for the
  # daemon) OR a password (for CLI/.my.cnf access).
  local alter_sql="ALTER USER 'root'@'localhost' IDENTIFIED VIA unix_socket OR mysql_native_password USING PASSWORD('${ROOT_PW}'); FLUSH PRIVILEGES;"

  # Detect current auth plugin for root (try socket first — works pre-hardening)
  local plugin
  plugin="$(mysql --protocol=socket -NBe "SELECT plugin FROM mysql.user WHERE user='root' AND host='localhost'" 2>/dev/null || true)"

  if [[ "$plugin" == "unix_socket" || "$plugin" == "auth_socket" || -z "$plugin" ]]; then
    # First-time hardening: still on socket-only auth, apply via the socket.
    mysql --protocol=socket -uroot -e "${alter_sql}"
  else
    # Already password-hardened (possibly by an older installer that used a plain
    # `IDENTIFIED BY` and broke socket login). Re-assert multi-auth via the helper
    # so an already-broken server is repaired on re-run.
    mysql_root -e "${alter_sql}" || true
  fi

  if [[ ! -s /root/.my.cnf ]]; then
    install -m 600 /dev/null /root/.my.cnf
    printf "[client]\nuser=root\npassword=%s\n" "$ROOT_PW" > /root/.my.cnf
    chmod 600 /root/.my.cnf
  fi

  # From here on, always use mysql_root helper (works with .my.cnf or pw file)
  mysql_root -e "DELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost','127.0.0.1','::1'); FLUSH PRIVILEGES;" || true
  mysql_root -e "DELETE FROM mysql.user WHERE User='' OR User IS NULL;" || true
  mysql_root -e "DELETE FROM mysql.db   WHERE Db IN ('test','test\\_%');" || true
  mysql_root -e "DROP DATABASE IF EXISTS test;" || true
  mysql_root -e "FLUSH PRIVILEGES;" || true

  # Optional flag file so we can skip if already secured (extra fast)
  touch /var/lib/mysql/.corepanel_secured 2>/dev/null || true
}

# ---- System user: corepanel --------------------------------------------------
create_system_user_corepanel() {
  log "Creating system user 'corepanel' (no home, no login)…"
  if id corepanel &>/dev/null; then
    log "User 'corepanel' already exists; skipping creation."
  else
    useradd \
      --system \
      --no-create-home \
      --shell /sbin/nologin \
      --comment "CorePanel service account" \
      corepanel
    log "User 'corepanel' created successfully."
  fi

  # Shared service group AND user used by the Dovecot SASL auth worker (see the
  # 99-corepanel-auth.conf written below, which sets `user = corepanel-auth` and
  # `group = corepanel-services`). The corepanel-auth package also declares both
  # via sysusers, but the Dovecot config that references them is written and
  # (re)started (see configure_postfix_dovecot_sasl_maildir) before that package
  # is installed. On AlmaLinux 8/9 Dovecot tolerated the missing user at restart,
  # but AlmaLinux 10's Dovecot validates `service auth { user }` at startup and
  # aborts the whole installer, so create both here to guarantee they exist when
  # Dovecot starts the auth service. When the package's sysusers runs later it
  # adopts these pre-existing entries idempotently.
  if ! getent group corepanel-services >/dev/null; then
    groupadd --system corepanel-services
    log "Group 'corepanel-services' created."
  fi
  if ! id corepanel-auth &>/dev/null; then
    useradd \
      --system \
      --no-create-home \
      --shell /sbin/nologin \
      --gid corepanel-services \
      --comment "CorePanel auth broker" \
      corepanel-auth
    log "User 'corepanel-auth' created successfully."
  fi
}

# ---- Mail stack --------------------------------------------------------------
install_mail_stack() {
  log "Installing Postfix + Dovecot…"
  dnf_safe install postfix dovecot
  local myhostname; myhostname="$(hostname -f 2>/dev/null || hostname)"

  # Postfix reasonable defaults
  postconf -e "myhostname = ${myhostname}"
  postconf -e 'inet_interfaces = all'
  postconf -e 'inet_protocols = all'
  # Accept mail over both families, but SEND over IPv4 first. Postfix's default
  # for smtp_address_preference is "any", which does not mean "whichever works" —
  # it picks at random per destination that publishes both A and AAAA. On a VPS
  # whose IPv6 comes from a provider block with no reputation (the normal case)
  # that is a coin flip between delivery and a 550, and the operator only learns
  # about it from a bounce. IPv4 is also the address whose PTR and SPF the
  # operator actually controls. Postfix still falls back to IPv6 when no IPv4
  # address of a destination connects, so nothing becomes unreachable.
  postconf -e 'smtp_address_preference = ipv4'
  postconf -e 'mynetworks_style = host'
  postconf -e 'smtpd_tls_security_level = may'
  postconf -e 'smtp_tls_security_level = may'
  postconf -e 'smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination'

  # Enable submission (587) + smtps (465).
  #
  # postconf -M/-P edits master.cf the way Postfix itself does: it addresses a
  # service by name instead of by line position, and it is idempotent. The sed
  # this replaces enabled BOTH services but fed the options to only one — its
  # range was /^submission\s+inet/,+5 — so the smtps block kept every "-o"
  # commented out, `smtpd_tls_wrappermode` included. Port 465 then answered
  # "220 ... ESMTP Postfix" in the CLEAR: every client set to implicit TLS (the
  # setting cPanel hands out, and the one migrated users already have) failed
  # its handshake, while 587 kept working and hid how broken 465 was.
  postconf -M 'submission/inet=submission inet n - n - - smtpd'
  postconf -P 'submission/inet/smtpd_tls_security_level=encrypt'
  postconf -P 'submission/inet/smtpd_sasl_auth_enable=yes'
  postconf -M 'smtps/inet=smtps inet n - n - - smtpd'
  postconf -P 'smtps/inet/smtpd_tls_wrappermode=yes'
  postconf -P 'smtps/inet/smtpd_sasl_auth_enable=yes'

  systemctl enable --now postfix

  # Dovecot base protocols. The ManageSieve protocol ("sieve") is added later,
  # after dovecot-pigeonhole is installed — enabling it here would make Dovecot
  # fail to start with "Unknown protocol: sieve".
  sed -ri 's/^#?\s*protocols\s*=.*/protocols = imap pop3 lmtp/' /etc/dovecot/dovecot.conf
  systemctl enable --now dovecot
}

configure_postfix_dovecot_sasl_maildir() {
  log "Configuring Postfix SASL via Dovecot + Maildir…"

  # Maildir
  sed -ri "s|^#?\s*mail_location\s*=.*|mail_location = maildir:~/Maildir|" /etc/dovecot/conf.d/10-mail.conf

  # Dovecot auth mechanisms
  sed -ri 's/^#?\s*disable_plaintext_auth\s*=.*/disable_plaintext_auth = yes/' /etc/dovecot/conf.d/10-auth.conf
  sed -ri 's/^#?\s*auth_mechanisms\s*=.*/auth_mechanisms = plain login/' /etc/dovecot/conf.d/10-auth.conf

  cat >/etc/dovecot/conf.d/auth-checkpassword.conf.ext <<'EOF'
auth_mechanisms = plain login

passdb {
  driver = checkpassword
  args = /usr/libexec/corepanel/dovecot-auth
}

# First try prefetch (uses data from passdb auth)
userdb {
  driver = prefetch
}

# Fallback: checkpassword lookup for LMTP and other non-auth lookups
userdb {
  driver = checkpassword
  args = /usr/libexec/corepanel/dovecot-auth
}
EOF

  local auth_conf="/etc/dovecot/conf.d/10-auth.conf"
  sed -ri '/auth-checkpassword\.conf\.ext/!s|^([[:space:]]*)!include\s+([[:alnum:]-]+\.conf\.ext)|\1#!include \2|' "$auth_conf"
  sed -ri 's|^[[:space:]]*#\s*!include\s+auth-checkpassword\.conf\.ext|!include auth-checkpassword.conf.ext|' "$auth_conf"
  if ! grep -Eq '^[[:space:]]*!include\s+auth-checkpassword\.conf\.ext' "$auth_conf"; then
    printf '\n!include auth-checkpassword.conf.ext\n' >>"$auth_conf"
  fi

  # Expose auth socket to Postfix chroot
  if ! grep -q 'unix_listener /var/spool/postfix/private/auth' /etc/dovecot/conf.d/10-master.conf; then
    awk '1' /etc/dovecot/conf.d/10-master.conf > /etc/dovecot/conf.d/10-master.conf.bak
    cat >>/etc/dovecot/conf.d/10-master.conf <<'EOF'

service auth {
  unix_listener /var/spool/postfix/private/auth {
    mode = 0660
    user = postfix
    group = postfix
  }
}
EOF
  fi

  # CorePanel-specific auth configuration (includes Postfix SASL socket)
  cat >/etc/dovecot/conf.d/99-corepanel-auth.conf <<'EOF'
service auth {
  user = corepanel-auth
  group = corepanel-services
  vsz_limit = 1 G

  # Socket para Postfix SASL authentication
  unix_listener /var/spool/postfix/private/auth {
    mode = 0660
    user = postfix
    group = postfix
  }
}
EOF

  # Dovecot's quota plugin. Without it the per-mailbox limit that corepanel-auth
  # hands over as userdb_quota_rule is accepted and silently ignored, so no
  # mailbox is ever refused delivery for being full.
  #
  # Numbered 90, not 99, on purpose: Dovecot expands $mail_plugins where it is
  # written, so this has to be read BEFORE 99-corepanel-lmtp.conf adds sieve to
  # the delivery path. Renamed above that file, delivery stops being quota-checked
  # and nothing says so. corepanel-sys writes the same file at every start, which
  # is what gives already-installed servers the fix.
  cat >/etc/dovecot/conf.d/90-corepanel-quota.conf <<'EOF'
# Auto-generated by corepanel-sys - DO NOT EDIT
#
# Enables Dovecot's quota plugin. Without it the per-mailbox limit that
# corepanel-auth passes as userdb_quota_rule is accepted and silently ignored,
# and no mailbox is ever refused delivery for being full.
#
# This file is numbered 90 rather than 99 on purpose: Dovecot expands
# $mail_plugins where it is written, so the global setting has to be widened
# before 99-corepanel-lmtp.conf adds sieve to the delivery path. Renaming it
# above that file disables quota enforcement on delivery without any error.

mail_plugins = $mail_plugins quota

# The IMAP QUOTA extension, so a mail client can show the user how full the
# mailbox is instead of only failing once it is.
protocol imap {
  mail_plugins = $mail_plugins imap_quota
}

plugin {
  # Maildir++ backend: usage is kept in the maildirsize file beside the mailbox.
  # The limit comes from userdb_quota_rule; a mailbox that sends none is
  # unlimited, which is how a quota of 0 reaches Dovecot.
  quota = maildir:User quota
}
EOF

  # CorePanel LMTP configuration for Postfix virtual mail delivery
  cat >/etc/dovecot/conf.d/99-corepanel-lmtp.conf <<'EOF'
# CorePanel LMTP configuration for Postfix integration
# Dovecot LMTP handles final delivery to Maildir using userdb lookups
service lmtp {
  unix_listener /var/spool/postfix/private/dovecot-lmtp {
    mode = 0600
    user = postfix
    group = postfix
  }
}

# Run per-user Sieve at delivery (vacation autoresponders + filters).
protocol lmtp {
  mail_plugins = $mail_plugins sieve
}

# Let delivery create missing folders: a Sieve `fileinto "Junk"` on a mailbox
# that never logged in via IMAP would otherwise fail and fall back to INBOX
# (namespace auto=create only materializes folders on IMAP access, not LMTP).
lda_mailbox_autocreate = yes
lda_mailbox_autosubscribe = yes
EOF

  # Special-use folders must actually exist: the stock 15-mailboxes.conf only
  # declares them (special_use) without creating them, so a Sieve
  # `fileinto "Junk"` fails and the message falls through to INBOX, and the
  # imapsieve learn-spam trigger has no Junk folder to watch.
  cat >/etc/dovecot/conf.d/99-corepanel-mailboxes.conf <<'EOF'
# CorePanel: auto-create and subscribe the standard special-use folders so
# Sieve fileinto (e.g. "Junk") never fails and clients see the full folder set.
namespace inbox {
  mailbox Drafts {
    auto = subscribe
    special_use = \Drafts
  }
  mailbox Junk {
    auto = subscribe
    special_use = \Junk
  }
  mailbox Sent {
    auto = subscribe
    special_use = \Sent
  }
  mailbox Trash {
    auto = subscribe
    special_use = \Trash
  }
}
EOF

  # Postfix SASL settings
  postconf -e 'smtpd_sasl_type = dovecot'
  postconf -e 'smtpd_sasl_path = private/auth'
  postconf -e 'smtpd_sasl_auth_enable = yes'
  postconf -e "smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination"

  systemctl restart dovecot postfix

  # --- SELinux: let Postfix and Dovecot reach the corepanel-auth broker -------
  # The mail stack talks to corepanel-auth over Unix sockets under /run
  # (labelled var_run_t):
  #   * the checkpassword helper (dovecot-auth, a Go binary, dovecot_auth_t)
  #     validates SASL logins against the broker, and
  #   * Postfix (smtpd/master/cleanup) queries the broker's socketmap for
  #     virtual domain/mailbox lookups during RCPT.
  # The stock policy denies these, which breaks both authentication and mail
  # acceptance. Grant exactly what the mail path needs.
  local mail_auth_policy='module corepanel_mail_auth 1.0;

require {
    type cgroup_t;
    type var_run_t;
    type dovecot_auth_t;
    type postfix_smtpd_t;
    type postfix_master_t;
    type postfix_cleanup_t;
    type unconfined_service_t;
    class dir search;
    class file { open read };
    class sock_file write;
    class unix_stream_socket connectto;
}

# Dovecot auth worker -> corepanel-auth SASL socket.
allow dovecot_auth_t var_run_t:sock_file write;
# The Go-based checkpassword helper reads its CPU quota from the cgroup fs.
allow dovecot_auth_t cgroup_t:dir search;
allow dovecot_auth_t cgroup_t:file { open read };

# Postfix -> corepanel-auth socketmap (virtual domain/mailbox lookups).
allow postfix_smtpd_t var_run_t:sock_file write;
allow postfix_master_t var_run_t:sock_file write;
allow postfix_cleanup_t var_run_t:sock_file write;

# Connecting to a Unix socket needs both sock_file write (above) AND connectto
# on the listening peer. The corepanel-auth broker has no SELinux policy of its
# own, so it runs as unconfined_service_t; on el10 the stock policy denies the
# connectto and SASL auth / mailbox lookups fail. (el8/el9 allowed it already;
# the rule is harmless there.)
allow dovecot_auth_t unconfined_service_t:unix_stream_socket connectto;
allow postfix_smtpd_t unconfined_service_t:unix_stream_socket connectto;
allow postfix_master_t unconfined_service_t:unix_stream_socket connectto;
allow postfix_cleanup_t unconfined_service_t:unix_stream_socket connectto;'

  if ! apply_selinux_policy "corepanel_mail_auth" "$mail_auth_policy"; then
    warn "Failed to apply mail-auth SELinux policy. Setting the mail domains permissive as fallback."
    for d in dovecot_auth_t postfix_smtpd_t postfix_master_t postfix_cleanup_t; do
      semanage permissive -a "$d" 2>/dev/null || true
    done
  fi
}

# ---- Postfix virtual mailbox via Dovecot LMTP -------------------------------
configure_postfix_virtual_mailbox() {
  log "Configuring Postfix virtual mailbox delivery via Dovecot LMTP…"

  # Virtual mailbox domains lookup via corepanel-auth socketmap
  # Note: socketmap is only allowed for non-security-sensitive lookups (domains, aliases)
  # UID/GID/mailbox lookups are handled by Dovecot LMTP via userdb
  postconf -e 'virtual_mailbox_domains = socketmap:unix:/run/corepanel-auth/corepanel-mail.sock:domains'
  postconf -e 'virtual_alias_maps = socketmap:unix:/run/corepanel-auth/corepanel-mail.sock:alias'

  # Use Dovecot LMTP for virtual mail delivery
  # LMTP handles UID/GID/maildir via Dovecot userdb (checkpassword)
  postconf -e 'virtual_transport = lmtp:unix:private/dovecot-lmtp'

  # Clear any previously set virtual_mailbox_maps/uid_maps/gid_maps
  # (socketmap not allowed for security-sensitive data in Postfix)
  postconf -e 'virtual_mailbox_maps ='
  postconf -e 'virtual_uid_maps ='
  postconf -e 'virtual_gid_maps ='

  # Account suspension, enforced on the ENVELOPE (WHMCS-PLAN.md §3.1 D2).
  #
  # A suspended account may not send and — unless it keeps its mail — may not
  # receive; it can still read what it has. Doing it here rather than by failing
  # the login is what keeps the customer's own address out of the brute-force
  # detector and out of Dovecot's tarpit: their mail client keeps polling with a
  # password that is perfectly correct, and a failed login would eventually get
  # their office blocked.
  #
  # check_sender_access goes FIRST in the sender restrictions: an authenticated
  # suspended customer must be refused, so a permit_sasl_authenticated ahead of
  # it would let every suspended account keep sending.
  postconf -e 'smtpd_sender_restrictions = check_sender_access socketmap:unix:/run/corepanel-auth/corepanel-mail.sock:sender_access'
  postconf -e 'smtpd_recipient_restrictions = check_recipient_access socketmap:unix:/run/corepanel-auth/corepanel-mail.sock:recipient_access, permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination'

  systemctl reload postfix
  log "Postfix virtual mailbox delivery configured (LMTP via Dovecot)."
}

# ---- Dovecot SSL ACLs for corepanel-sys certificates ------------------------
configure_dovecot_ssl_acls() {
  log "Configuring Dovecot SSL certificate ACLs..."

  # corepanel-sys stores certificates in /var/lib/cp-sys/ssl/certs/
  # Dovecot needs read access for SNI to work with per-domain certificates
  local ssl_base="/var/lib/cp-sys/ssl"
  local certs_dir="${ssl_base}/certs"

  # Ensure directories exist
  install -d -m 0750 -o corepanel-sys -g corepanel-sys "$ssl_base"
  install -d -m 0750 -o corepanel-sys -g corepanel-sys "$certs_dir"

  # Set ACLs for dovecot user to read certificates
  # -m: modify ACL, u:dovecot:rx: user dovecot gets read+execute
  setfacl -m u:dovecot:rx "$ssl_base"
  setfacl -R -m u:dovecot:rx "$certs_dir"
  # -d: default ACL for new files/directories
  setfacl -d -m u:dovecot:rx "$certs_dir"

  # Create initial empty Dovecot SSL config (will be populated by corepanel-sys)
  local dovecot_ssl_conf="/etc/dovecot/conf.d/99-corepanel-ssl.conf"
  if [[ ! -f "$dovecot_ssl_conf" ]]; then
    cat >"$dovecot_ssl_conf" <<'EOF'
# Auto-generated by corepanel-sys - DO NOT EDIT
# This file will be populated when SSL certificates are issued

# Default certificate (fallback)
ssl_cert = </etc/pki/dovecot/certs/dovecot.pem
ssl_key = </etc/pki/dovecot/private/dovecot.pem
EOF
    chown root:root "$dovecot_ssl_conf"
    chmod 0644 "$dovecot_ssl_conf"
  fi

  # Restart Dovecot now that the corepanel-auth package (installed earlier in
  # main) has created the corepanel-auth user and the corepanel-services group.
  # Dovecot's auth service is configured to run as user=corepanel-auth
  # group=corepanel-services; the installer's earlier Dovecot restarts happen
  # before that package exists, so the auth worker would otherwise stay dead
  # ("User/Group doesn't exist") and break all SASL authentication.
  systemctl restart dovecot || true

  log "Dovecot SSL ACLs configured."
}

###############################################################################
# MariaDB: idempotent tuning + secure
###############################################################################
ensure_corepanel_dirs() {
  install -d -m 0700 -o root -g root /etc/corepanel/credentials
  install -d -m 0700 -o root -g root /var/lib/corepanel/state
}

mysql_exec() {
  local sql="$1"
  if mysql --defaults-file=/root/.my.cnf -e "$sql" >/dev/null 2>&1; then return 0; fi
  if [[ -f /etc/corepanel/credentials/mysql-root.pw ]]; then
    local pw; pw="$(cat /etc/corepanel/credentials/mysql-root.pw)"
    if mysql -uroot -p"$pw" -e "$sql" >/dev/null 2>&1; then return 0; fi
  fi
  if mysql -uroot -e "$sql" >/dev/null 2>&1; then return 0; fi
  return 1
}

write_mariadb_tuning() {
  local dst="/etc/my.cnf.d/corepanel-tuning.cnf"
  local tmp; tmp="$(mktemp)"
  cat >"$tmp" <<'EOF'
# CorePanel MariaDB tuning (safe defaults)
[mysqld]
bind-address = 127.0.0.1
innodb_buffer_pool_size = 256M
innodb_log_file_size    = 128M
innodb_flush_method     = O_DIRECT
innodb_flush_log_at_trx_commit = 2
max_connections = 200
slow_query_log = ON
slow_query_log_file = /var/log/mariadb/slow.log
long_query_time = 1
character-set-server = utf8mb4
collation-server      = utf8mb4_unicode_ci
EOF
  if [[ ! -f "$dst" ]] || ! cmp -s "$tmp" "$dst"; then
    mv -f "$tmp" "$dst"
    chmod 0644 "$dst"
    systemctl try-restart mariadb 2>/dev/null || systemctl try-restart mysqld 2>/dev/null || true
  else
    rm -f "$tmp"
    echo "[corepanel-setup] MariaDB tuning already applied; skipping."
  fi
}

secure_mariadb_idempotent() {
  ensure_corepanel_dirs
  local state="/var/lib/corepanel/state/mariadb.secured"
  if [[ -f "$state" ]]; then
    echo "[corepanel-setup] MariaDB already secured (state present); skipping."
    return 0
  fi

  systemctl enable --now mariadb 2>/dev/null || systemctl enable --now mysqld 2>/dev/null || true
  sleep 2

  if ! mysql_exec "SELECT 1;"; then
    echo "[corepanel-setup] Cannot auth as root; assuming already secured; skipping."
    return 0
  fi

  local pw_file="/etc/corepanel/credentials/mysql-root.pw"
  local pw
  if [[ -f "$pw_file" ]]; then
    pw="$(cat "$pw_file")"
  else
    pw="$(openssl rand -base64 28 | tr -d '\n')"
    printf '%s' "$pw" > "$pw_file"
    chmod 0600 "$pw_file"
  fi

  # Ensure local-only
  local netcnf="/etc/my.cnf.d/corepanel-network.cnf"
  if [[ ! -f "$netcnf" ]] || ! grep -q 'bind-address' "$netcnf"; then
    cat >"$netcnf" <<'EOF'
[mysqld]
bind-address = 127.0.0.1
EOF
    chmod 0644 "$netcnf"
  fi

  if mysql -uroot -e "SELECT 1;" >/dev/null 2>&1; then
    :
  else
    mysql_exec "ALTER USER 'root'@'localhost' IDENTIFIED BY '${pw}';" || true
  fi
  mysql_exec "DELETE FROM mysql.user WHERE User='' OR User IS NULL;" || true
  mysql_exec "DROP DATABASE IF EXISTS test;" || true
  mysql_exec "DELETE FROM mysql.db WHERE Db IN ('test','test\\_%');" || true
  mysql_exec "FLUSH PRIVILEGES;" || true

  cat >/root/.my.cnf <<EOF
[client]
user = root
password = ${pw}
host = localhost
EOF
  chmod 0600 /root/.my.cnf

  systemctl try-restart mariadb 2>/dev/null || systemctl try-restart mysqld 2>/dev/null || true
  date -u +"%Y-%m-%dT%H:%M:%SZ" > "$state"
  chmod 0600 "$state"
  echo "[corepanel-setup] MariaDB secured and credentials stored."
}

# -------------------------- Antispam stack: Rspamd + Redis + IMAPSieve -----------------------------
install_antispam_stack() {
  log "Installing and configuring antispam stack (Rspamd + Redis)…"

  # RHEL/AlmaLinux 10 dropped the `redis` package (Redis' license change) and
  # ships `valkey` instead — a drop-in, redis-protocol-compatible fork with its
  # own service (we bind it to a Unix socket, not 6379). Select the right
  # package/service per major version; el8/el9 keep redis. The config file path
  # is detected after install (below), since it differs across versions.
  local major="${DIST_VER%%.*}"
  local redis_pkg redis_svc redis_conf
  if [[ "$major" -ge 10 ]]; then
    redis_pkg="valkey"; redis_svc="valkey"
  else
    redis_pkg="redis"; redis_svc="redis"
  fi

  # Rspamd publishes a separate repo per EL major (centos-8/9/10); the el8 build
  # links against OpenSSL 1.1 / ICU 60 and won't install on el10 (OpenSSL 3 / new
  # ICU), so fetch the repo matching this distro's major version.
  curl -sSL "https://rspamd.com/rpm-stable/centos-${major}/rspamd.repo" | sudo tee /etc/yum.repos.d/rspamd.repo
  dnf_safe install rspamd "$redis_pkg" sqlite # sqlite for tools, safe

  systemctl enable --now "$redis_svc"
  systemctl enable --now rspamd

  # Detect the effective Rspamd user/group created by the package
  detect_rspamd_user_group

  # Locate the server config the package shipped: the path differs across
  # versions — el8 redis → /etc/redis.conf, el9 redis → /etc/redis/redis.conf,
  # el10 valkey → /etc/valkey/valkey.conf. Probe rather than hardcode.
  for c in /etc/valkey/valkey.conf /etc/redis/redis.conf /etc/redis.conf; do
    [[ -f "$c" ]] && { redis_conf="$c"; break; }
  done
  if [[ -z "$redis_conf" ]]; then
    error "Could not locate the redis/valkey configuration file after installing ${redis_pkg}"
    exit 1
  fi

  # --- Harden Redis/Valkey: Unix socket only, no TCP, password-protected --------
  # On a shared hosting box `bind 127.0.0.1` does NOT isolate: every tenant shares
  # the loopback, so any customer script could reach a TCP Redis (even without the
  # php-redis extension — by speaking RESP over a raw socket) and FLUSHALL or
  # poison the shared Bayes DB, or read other recipients' tokens. We therefore
  # disable TCP entirely (`port 0`) and expose Redis only through a Unix socket
  # that is group-readable by the Rspamd account and no one else, plus a generated
  # password as defense in depth. Only Rspamd (added to the redis group below) can
  # reach it; tenant scripts cannot.
  local redis_group redis_run redis_sock redis_pass
  redis_group="$redis_svc"
  getent group "$redis_group" >/dev/null || redis_group="$(id -gn "$redis_svc" 2>/dev/null || echo "$redis_svc")"
  redis_run="/run/${redis_svc}"
  redis_sock="${redis_run}/${redis_svc}.sock"
  redis_pass="$(openssl rand -hex 24 2>/dev/null || tr -dc 'a-f0-9' </dev/urandom | head -c 48)"

  # Ensure the socket directory exists and survives reboots (some units ship no
  # RuntimeDirectory=). 0750 dir + 0660 socket means only the redis account and
  # its group (which now includes Rspamd) can even traverse to the socket; tenants
  # cannot so much as stat it.
  install -m 0750 -o "$redis_svc" -g "$redis_group" -d "$redis_run" 2>/dev/null || {
    mkdir -p "$redis_run"; chown "$redis_svc:$redis_group" "$redis_run"; chmod 0750 "$redis_run"
  }
  # The vendor package usually ships its own tmpfiles entry for this directory
  # (valkey.conf: `D /run/valkey 0755 ...`). Declaring the same path with `d`
  # makes systemd-tmpfiles keep whichever file sorts first and warn about the
  # other on every package transaction:
  #   /usr/lib/tmpfiles.d/valkey.conf:2: Duplicate line for path "/run/valkey"
  # `z` is the right verb there: it adjusts the mode of a directory someone else
  # creates, which is all we want, and systemd accepts it alongside the vendor
  # line without complaint. Fall back to `d` when nothing else declares the path,
  # so the socket directory still exists after a reboot (/run is a tmpfs).
  local tmpfiles_verb='d'
  if grep -rqsE "^[a-zA-Z]\+?[[:space:]]+${redis_run}([[:space:]]|$)" /usr/lib/tmpfiles.d/ 2>/dev/null; then
    tmpfiles_verb='z'
  fi
  printf '%s %s 0750 %s %s -\n' "$tmpfiles_verb" "$redis_run" "$redis_svc" "$redis_group" >/etc/tmpfiles.d/corepanel-redis.conf

  # The stock unit declares RuntimeDirectory= with the default 0755 mode, and
  # systemd RE-APPLIES that mode on every service start — silently undoing the
  # 0750 above after the first reboot (tmpfiles.d runs earlier and loses). Pin
  # the mode in a unit drop-in so the hardening survives restarts and reboots.
  mkdir -p "/etc/systemd/system/${redis_svc}.service.d"
  cat >"/etc/systemd/system/${redis_svc}.service.d/10-corepanel-hardening.conf" <<EOF
# Managed by CorePanel. Keep the socket directory tenant-inaccessible (0750)
# across service restarts and reboots; see /etc/tmpfiles.d/corepanel-redis.conf.
[Service]
RuntimeDirectoryMode=0750
EOF
  systemctl daemon-reload

  # Disable TCP, enable the Unix socket, set the password + RDB persistence.
  # Strip any pre-existing socket/password lines first so re-runs stay idempotent.
  sed -ri 's/^#?\s*bind\s+.*/bind 127.0.0.1 -::1/' "$redis_conf"
  sed -ri 's/^#?\s*protected-mode\s+.*/protected-mode yes/' "$redis_conf"
  sed -ri 's/^#?\s*port\s+.*/port 0/' "$redis_conf"
  sed -ri '/^#?\s*(unixsocket|unixsocketperm|requirepass)\s+/d' "$redis_conf"
  {
    echo ""; echo "# --- CorePanel antispam hardening + tuning ---"
    echo "unixsocket ${redis_sock}"
    echo "unixsocketperm 660"
    echo "requirepass ${redis_pass}"
    echo "save 900 1"; echo "save 300 10"; echo "rdbcompression yes"; echo "appendonly no"
  } >>"$redis_conf"

  # Group membership is the DAC gate that lets Rspamd — and only Rspamd — reach
  # the socket. Restart Rspamd afterwards so it picks up the new group.
  usermod -aG "$redis_group" "$RSPAMD_USER" || true

  systemctl restart "$redis_svc"

  # Rspamd configs
  mkdir -p /etc/rspamd/local.d

  # Redis connection for Bayes & modules (Unix socket + password from above)
  cat >/etc/rspamd/local.d/redis.conf <<EOF
servers = "${redis_sock}";
password = "${redis_pass}";
EOF

  # Bayes classifier.
  #
  # `per_user = false` — one corpus for the server, not one per recipient. It
  # used to be per-user, and measured on a live box that meant Bayes did nothing
  # at all: Rspamd refuses to apply the classifier below `min_learns = 200`, and
  # splitting a hosting server's traffic across its mailboxes left every one of
  # them in single digits. 357 learned messages, not one BAYES symbol in 11 days.
  # Shared, the same traffic crosses the floor and then helps every mailbox,
  # including new ones with no history. The cost — one tenant's mistakes nudge a
  # classifier everyone shares — is bounded by that same 200-message floor and by
  # Bayes being one signal among dozens.
  cat >/etc/rspamd/local.d/classifier-bayes.conf <<EOF
backend = "redis";
servers = "${redis_sock}";
password = "${redis_pass}";
per_user = false;
autolearn = true;
EOF

  # Fuzzy: rspamd's stock config already queries the public rspamd.com fuzzy
  # storage (read-only). Do NOT point a fuzzy rule at Redis: the fuzzy protocol
  # is UDP served by a dedicated `worker "fuzzy"`, not by Redis, so such a rule
  # fails on every scan with "Protocol wrong type for socket". A local
  # Redis-backed fuzzy worker is a possible future enhancement, but it needs a
  # learning pipeline (rspamc fuzzy_add) — not just a rule here. The removal
  # also cleans installs provisioned before this fix.
  rm -f /etc/rspamd/local.d/fuzzy_check.conf

  # These fragments carry the Redis password: keep them out of world-read.
  # root owns them; the Rspamd group reads them.
  chmod 0640 /etc/rspamd/local.d/redis.conf \
    /etc/rspamd/local.d/classifier-bayes.conf
  chown "root:${RSPAMD_GROUP}" /etc/rspamd/local.d/redis.conf \
    /etc/rspamd/local.d/classifier-bayes.conf || true
  # Greylisting. `timeout` is how long a sender we have never seen stays
  # greylisted before its retry is accepted, and it is the one number here a
  # user actually feels: nothing from a new sender arrives until it elapses.
  #
  # It used to say 4h, against Rspamd's own 5min default, and a transformed
  # cPanel box showed what that costs — 34 soft rejects against 19 deliveries in
  # the first 15 hours, a real sender retrying 18 times over exactly 4h00m
  # before it got in. Serious MTAs do retry, so little is lost outright; what is
  # lost is every message whose value is time-critical, which on a hosting box
  # is most of them (verification codes, password resets, order confirmations).
  # cPanel's Exim did not greylist at all, so on a freshly migrated server this
  # reads as "CorePanel is eating our mail".
  #
  # The key is `expire`, not `expiry`: the misspelling was silently ignored, so
  # the record lifetime was Rspamd's default all along.
  cat >/etc/rspamd/local.d/greylist.conf <<'EOF'
enabled = true;
timeout = 5min;
expire = 1d;
whitelist_domains = ["localhost"];
EOF

  # User-facing spam policy (per-scope thresholds + allow/deny sender lists) is
  # managed at runtime by corepanel-sys, which renders and overwrites these two
  # local.d fragments and reloads Rspamd:
  #   settings.conf — per-recipient settings (thresholds + allow/deny via the
  #                   settings module, keyed by rcpt/from)
  #   actions.conf  — server-wide (global) action score thresholds
  # They are absent until an operator configures a policy; do not create them
  # here (an empty settings block is unnecessary and CorePanel owns the files).

  # Milter worker (proxy) and controller (local)
  cat >/etc/rspamd/local.d/worker-proxy.inc <<'EOF'
bind_socket = "127.0.0.1:11332";
milter = yes;
timeout = 120s;
upstream "local" { self = yes; }
EOF

  cat >/etc/rspamd/local.d/worker-controller.inc <<'EOF'
bind_socket = "127.0.0.1:11334";
# password = "set-a-strong-password-if-exposed";
EOF

  systemctl restart rspamd

  # Postfix milter integration
  postconf -e 'smtpd_milters = inet:127.0.0.1:11332'
  postconf -e 'non_smtpd_milters = inet:127.0.0.1:11332'
  postconf -e 'milter_default_action = accept'
  postconf -e 'milter_protocol = 6'
  systemctl restart postfix

  # IMAPSieve autolearn
  dnf_safe install dovecot-pigeonhole
  mkdir -p /etc/dovecot/sieve

  cat >/etc/dovecot/conf.d/90-sieve.conf <<'EOF'
plugin {
  sieve_plugins = sieve_imapsieve sieve_extprograms
  sieve_global_extensions = +vnd.dovecot.execute
  sieve_pipe_bin_dir = /usr/local/bin

  # When user moves mail to Junk → learn_spam
  imapsieve_mailbox1_name = Junk
  imapsieve_mailbox1_causes = COPY APPEND
  imapsieve_mailbox1_before = file:/etc/dovecot/sieve/learn-spam.sieve

  # When mail is moved from Junk → learn_ham
  imapsieve_mailbox2_name = *
  imapsieve_mailbox2_from = Junk
  imapsieve_mailbox2_causes = COPY APPEND
  imapsieve_mailbox2_before = file:/etc/dovecot/sieve/learn-ham.sieve
}
EOF

  # sieve_extprograms runs ONLY what exists inside sieve_pipe_bin_dir, and
  # rspamc ships in /usr/bin — so for as long as this pointed at a bare
  # /usr/local/bin, `execute "rspamc"` failed on every message a user dragged
  # into Junk and nothing was ever learned from it. The symlink is what makes
  # these two scripts work; corepanel-mail-filtering-setup creates it again on
  # every package upgrade, which is how servers installed before this fix get it.
  if command -v rspamc >/dev/null 2>&1; then
    ln -sfn "$(command -v rspamc)" /usr/local/bin/rspamc
  else
    warn "rspamc not found; moving a message to Junk will not train the filter."
  fi

  cat >/etc/dovecot/sieve/learn-spam.sieve <<'EOF'
require ["vnd.dovecot.execute"];
execute "rspamc" ["learn_spam"];
EOF
  cat >/etc/dovecot/sieve/learn-ham.sieve <<'EOF'
require ["vnd.dovecot.execute"];
execute "rspamc" ["learn_ham"];
EOF

  # Compile Sieve rules. sievec runs standalone (outside the dovecot config that
  # loads sieve_extprograms), so on el10 `-x vnd.dovecot.execute` alone reports
  # the extension as unknown and aborts. Load the plugin explicitly with `-P` so
  # the extension is registered before enabling it; harmless on el8/el9.
  if command -v sievec >/dev/null 2>&1; then
    sievec -P sieve_extprograms -x 'vnd.dovecot.execute' /etc/dovecot/sieve/learn-spam.sieve
    sievec -P sieve_extprograms -x 'vnd.dovecot.execute' /etc/dovecot/sieve/learn-ham.sieve
  fi

  # Per-user Sieve storage: vacation + filters, rendered by CorePanel and written
  # by corepanel-sys under each mailbox's own maildir. Virtual mailboxes share the
  # account home, so the script must be keyed per mailbox (%d = domain, %n = local
  # part) rather than ~/.dovecot.sieve, which would collide across the account.
  # This location is shared with ManageSieve (port 4190) so power users can also
  # manage scripts with a ManageSieve client.
  #
  # Everything sits under the dot-less "sieve/" subdirectory: the mailbox dir is
  # also the Maildir++ root, where any dot-entry is treated as a mail folder — a
  # ".dovecot.sieve" symlink at the root makes folder listing/autocreation stat
  # "<link>/tmp", fail with "Not a directory" and defer delivery (451).
  cat >/etc/dovecot/conf.d/99-corepanel-sieve.conf <<'EOF'
plugin {
  sieve = file:%h/mail/%d/%n/sieve/scripts;active=%h/mail/%d/%n/sieve/.dovecot.sieve
}
EOF

  # Now that dovecot-pigeonhole is installed, enable the ManageSieve protocol
  # (port 4190). Only add "sieve" once; the sed keeps the line idempotent.
  if ! grep -Eq '^protocols\s*=.*\bsieve\b' /etc/dovecot/dovecot.conf; then
    sed -ri 's/^(protocols\s*=.*)$/\1 sieve/' /etc/dovecot/dovecot.conf
  fi

  systemctl restart dovecot

  # SELinux port labels
  semanage port -a -t milter_port_t -p tcp 11332 2>/dev/null || true
  # ManageSieve (Pigeonhole) listens on 4190; label it so Dovecot can bind it.
  semanage port -a -t sieve_port_t  -p tcp 4190  2>/dev/null || true
  # Redis/Valkey no longer listens on TCP (Unix socket only), so it needs no
  # redis_port_t label. Restore the socket dir's redis_var_run_t context so the
  # daemon can create its socket there under Enforcing.
  restorecon -R "/run/${redis_svc}" >/dev/null 2>&1 || true
  restorecon -Rv /etc/rspamd >/dev/null 2>&1 || true

  log "Rspamd + Redis antispam stack installed and integrated successfully."
}

# ---- Rspamd DKIM/ARC signing -------------------------------------------------
configure_rspamd_dkim() {
  log "Configuring Rspamd DKIM/ARC signing…"

  # Ensure we know the correct service account
  detect_rspamd_user_group

  # Create DKIM dir then set ownership (avoid install -o/-g which would fail on name mismatch)
  install -m 0750 -d /var/lib/rspamd/dkim
  chown "${RSPAMD_USER}:${RSPAMD_GROUP}" /var/lib/rspamd/dkim || true

  # Create empty map files for selector/path mapping (managed by corepanel-sys)
  touch /var/lib/rspamd/dkim/selectors.map
  touch /var/lib/rspamd/dkim/paths.map
  chown "${RSPAMD_USER}:${RSPAMD_GROUP}" \
    /var/lib/rspamd/dkim/selectors.map \
    /var/lib/rspamd/dkim/paths.map || true
  chmod 0640 /var/lib/rspamd/dkim/selectors.map /var/lib/rspamd/dkim/paths.map

  cat >/etc/rspamd/local.d/dkim_signing.conf <<'EOF'
# =============================================================================
# CorePanel - Rspamd DKIM signing
# -----------------------------------------------------------------------------
# Signs outgoing mail per domain using dynamic selector/path maps.
# Maps are managed by corepanel-sys and support key rotation with grace periods.
# Selectors: cp1, cp2, cp3... (incremental for rotation)
# Key files: /var/lib/rspamd/dkim/{domain}.{selector}.key
# Only sign when authenticated or from local networks.
# =============================================================================
selector_map = "/var/lib/rspamd/dkim/selectors.map";
path_map = "/var/lib/rspamd/dkim/paths.map";
allow_hdrfrom_mismatch = false;
allow_hdrfrom_multiple = false;
sign_authenticated = true;
sign_local = true;
try_fallback = false;
use_esld = true;
EOF

  cat >/etc/rspamd/local.d/arc.conf <<'EOF'
# =============================================================================
# CorePanel - Rspamd ARC signing
# =============================================================================
selector_map = "/var/lib/rspamd/dkim/selectors.map";
path_map = "/var/lib/rspamd/dkim/paths.map";
allow_hdrfrom_mismatch = true;
sign_authenticated = true;
sign_local = true;
EOF

  semanage fcontext -a -t rspamd_var_lib_t "/var/lib/rspamd(/.*)?" 2>/dev/null || \
  semanage fcontext -a -t var_lib_t "/var/lib/rspamd(/.*)?"
  restorecon -Rv /var/lib/rspamd >/dev/null 2>&1 || true

  systemctl restart rspamd
  log "Rspamd DKIM/ARC signing configured. Keys managed by corepanel-sys in /var/lib/rspamd/dkim/."
}

# Helper: generate a DKIM key for a domain (for manual use, corepanel-sys handles this normally)
rspamd_add_dkim_domain() {
  local domain="$1"; [[ -z "$domain" ]] && { echo "Usage: rspamd_add_dkim_domain <domain>"; return 1; }
  local selector="${2:-cp1}"
  log "Generating DKIM key for domain: ${domain} (selector: ${selector})"

  detect_rspamd_user_group

  install -m 0750 -d /var/lib/rspamd/dkim
  local keyfile="/var/lib/rspamd/dkim/${domain}.${selector}.key"
  local txtfile="/var/lib/rspamd/dkim/${domain}.${selector}.txt"
  rspamadm dkim_keygen -b 2048 -s "$selector" -d "$domain" -k "$keyfile" >"$txtfile"
  chown "${RSPAMD_USER}:${RSPAMD_GROUP}" "$keyfile" "$txtfile" || true
  chmod 0640 "$keyfile"

  # Update selector and path maps
  local selmap="/var/lib/rspamd/dkim/selectors.map"
  local pathmap="/var/lib/rspamd/dkim/paths.map"
  grep -v "^${domain} " "$selmap" > "${selmap}.tmp" 2>/dev/null || true
  echo "${domain} ${selector}" >> "${selmap}.tmp"
  mv "${selmap}.tmp" "$selmap"
  grep -v "^${domain} " "$pathmap" > "${pathmap}.tmp" 2>/dev/null || true
  echo "${domain} ${keyfile}" >> "${pathmap}.tmp"
  mv "${pathmap}.tmp" "$pathmap"
  chown "${RSPAMD_USER}:${RSPAMD_GROUP}" "$selmap" "$pathmap" || true

  restorecon -Rv /var/lib/rspamd >/dev/null 2>&1 || true
  log "DKIM ready for ${domain}. Publish TXT from ${txtfile} (selector: ${selector})."
  systemctl reload rspamd || true
}

# ---- pure-ftpd ---------------------------------------------------------------
install_pureftpd() {
  log "Installing pure-ftpd…"
  dnf_safe install pure-ftpd
  cat >/etc/pure-ftpd/pure-ftpd.conf <<'EOF'
  # ===== CorePanel pure-ftpd configuration =====
  # ===== Do not edit outside of CorePanel =====
  AllowAnonymousFXP            no
  AllowUserFXP                 no
  AltLog                       clf:/var/log/pureftpd.log
  AntiWarez                    yes
  AutoRename                   no
  BrokenClientsCompatibility   no
  ChrootEveryone               yes
  CustomerProof                yes
  Daemonize                    yes
  DisplayDotFiles              yes
  DontResolve                  yes
  ExtAuth                      /run/corepanel-auth/corepanel-ftp-auth.sock
  IPV4Only                     yes
  LimitRecursion               5000 5
  LogPID                       yes
  MaxClientsNumber             50
  MaxClientsPerIP              5
  MaxDiskUsage                 95
  MaxIdleTime                  10
  MaxLoad                      4
  MinUID                       1000
  NoAnonymous                  yes
  NoTruncate                   yes
  PassivePortRange             42000 50000
  PerUserLimits                5:0
  ProhibitDotFilesRead         no
  ProhibitDotFilesWrite        no
  SyslogFacility               ftp
  TLS                          2
  ExtCert                      /run/corepanel-auth/ftp-getcert.sock
  # Pure-FTPd creates files and directories from a 0777 base (not 0666 for files),
  # so the file umask must also clear the execute bits. 177 -> files 0600
  # (0777 & ~0177), 077 -> dirs 0700 (0777 & ~0077). Account content stays fully
  # private to the owning user: the group is a private per-user group (no other
  # members) and the corehttpd origin web server / per-account PHP-FPM pool read
  # the docroot as the account uid (or via CAP_DAC_READ_SEARCH), so no group or
  # other bits are ever needed to serve the site.
  Umask                        177:077
  VerboseLog                   no
EOF

  systemctl enable --now pure-ftpd || systemctl enable --now pure-ftpd.service || true
}

# ---- PHP stack ---------------------------------------------------------------
# The version × extension set, the placeholder pools, the SELinux booleans and
# labels and the /run/php-fpm tmpfiles entry all live in the corepanel-php
# package now (repos/corepanel-php). This installs it; it does not decide what is
# in it.
#
# The 138 lines this replaced were the single worst instance of the gap this
# whole design exists to close. They ran here, once, on a brand-new server —
# so PHP 8.2 and 8.3, added so a cPanel migration can land an account on the
# version it came from, never reached one existing customer. Neither did the
# php-pecl-zip fix. Do not move a package list back into this script.
#
# `corepanel` recommends corepanel-php weakly, so it would arrive with the panel
# anyway. It is installed explicitly here, and BEFORE install_corepanel, because
# the corepanel-roundcube postinstall writes a pool for php84 and the ordering
# within one transaction is only guaranteed for hard requirements.
install_php_stack() {
  log "Installing the CorePanel PHP stack (corepanel-php)…"
  dnf_safe install corepanel-php
}

# ---- Web server -------------------------------------------------------------
# CorePanel's web server is corehttpd, installed as an RPM via the `corepanel`
# meta-package (see install_corepanel). Its own postinstall creates the corehttpd
# user, the /var/lib/corehttpd state, the host-cert ACLs and the SELinux context
# (httpd_exec_t → httpd_t), and enables the service. Nothing to install here.
# (Caddy has been removed: corehttpd is now the authoritative origin.)

# -----------------------------------------------------------------------------
# apply_selinux_policy <policy_name> <policy_content>
# -----------------------------------------------------------------------------
# Applies a SELinux policy (removes existing, installs new - idempotent)
# Args:
#   $1: Policy name (e.g., "pdns_complete_fix")
#   $2: Complete policy content as a string
# -----------------------------------------------------------------------------
apply_selinux_policy() {
  local policy_name="$1"
  local policy_content="$2"

  [[ -n "$policy_name" && -n "$policy_content" ]] || {
    error "apply_selinux_policy: missing required arguments"
    return 1
  }

  log "Applying SELinux policy: ${policy_name}"

  # 1. Remove module if exists
  if semodule -l | grep -q "^${policy_name}[[:space:]]"; then
    log "Removing existing SELinux policy: ${policy_name}"
    semodule -r "$policy_name" 2>/dev/null || true
  fi

  # 2. Create and install new module
  echo "$policy_content" > "/tmp/${policy_name}.te"

  if checkmodule -M -m -o "/tmp/${policy_name}.mod" "/tmp/${policy_name}.te" && \
     semodule_package -o "/tmp/${policy_name}.pp" -m "/tmp/${policy_name}.mod" && \
     semodule -i "/tmp/${policy_name}.pp"; then
    log "SELinux policy ${policy_name} applied successfully."
    rm -f "/tmp/${policy_name}."{te,mod,pp}
    return 0
  else
    error "Failed to apply SELinux policy ${policy_name}."
    rm -f "/tmp/${policy_name}."{te,mod,pp}
    return 1
  fi
}

# -----------------------------------------------------------------------------
# PowerDNS Authoritative + Recursor + dnsdist (split ports)
# -----------------------------------------------------------------------------
install_powerdns_dnsdist_stack() {
  log "Installing PowerDNS Authoritative + Recursor + dnsdist (split ports)…"

  # --- Repos (official PowerDNS repo-files) ----------------------------------
  rpm -q epel-release >/dev/null 2>&1 || dnf_safe install epel-release
  curl -fsSL -o /etc/yum.repos.d/powerdns-auth-50.repo https://repo.powerdns.com/repo-files/el-auth-50.repo
  curl -fsSL -o /etc/yum.repos.d/powerdns-rec-53.repo  https://repo.powerdns.com/repo-files/el-rec-53.repo

  # --- Authoritative: pdns on 127.0.0.1:5300 (LMDB backend) ------------------
  dnf_safe install pdns pdns-backend-lmdb

  # Create data dir with correct ownership/permissions
  install -d -o pdns -g pdns -m 0750 /var/lib/pdns
  chown pdns:pdns /var/lib/pdns
  semanage fcontext -a -t var_lib_t "/var/lib/pdns(/.*)?" 2>/dev/null || true
  restorecon -Rv /var/lib/pdns

  # Overwrite pdns.conf as requested (hardcoded minimal config)
# API key: reuse the one already in pdns.conf if present, so re-running the
# installer doesn't mint a new key and invalidate the one corepanel-sys read at
# startup — that mismatch makes the PowerDNS API reject sys with "Unauthorized"
# and breaks DNS zone management until sys is restarted. Only generate a fresh
# key on a first install.
# NOTE: read it with `grep -oE` (no pipe). Avoid `tr -dc ... | head` and
# `... | head` in general — under `set -o pipefail` the writer is killed by
# SIGPIPE when head closes the pipe, the pipeline exits 141 and `set -e` aborts
# the installer. openssl likewise produces a key without that hazard.
PDNS_API_KEY="$(grep -m1 -oE '^api-key=[^[:space:]]+' /etc/pdns/pdns.conf 2>/dev/null || true)"
PDNS_API_KEY="${PDNS_API_KEY#api-key=}"
if [[ -z "$PDNS_API_KEY" ]]; then
  PDNS_API_KEY="$(openssl rand -hex 16)"
fi

# Write pdns.conf (Authoritative + LMDB + API on loopback)
cat >/etc/pdns/pdns.conf <<EOF
# =============================================================================
# PowerDNS Authoritative - CorePanel minimal config (LMDB backend)
# =============================================================================

# --- DNS listener (loopback only, custom port) ---
local-address=127.0.0.1
local-port=5300

# --- Backend: LMDB (single local DB file) ---
launch=lmdb
lmdb-filename=/var/lib/pdns/pdns.lmdb

# --- HTTP webserver + REST API (loopback only) ---
webserver=yes
webserver-address=127.0.0.1
webserver-port=8381
webserver-allow-from=127.0.0.1,::1

api=yes
api-key=${PDNS_API_KEY}

# --- Server identity ---
server-id=localhost

# --- Basic logging ---
loglevel=4
log-dns-queries=no
EOF

  chown pdns:pdns /etc/pdns/pdns.conf || true
  # Enable only — do NOT start yet. pdns_t may bind ports labelled dns_port_t and
  # nothing else, and 5300/8381 are not labelled until the semanage block below.
  # Starting here makes every bind fail with "Permission denied", systemd burns
  # through its restart limit and `systemctl` reports a failed job even though the
  # install is fine. Start it once the labels exist.
  systemctl enable pdns || true

  # --- SELinux: PowerDNS LMDB backend policy ---------------------------------
  log "Configuring SELinux for PowerDNS LMDB backend…"

  # Try to fix library file contexts first
  chcon -t lib_t /usr/lib64/pdns/*.so 2>/dev/null || true

  # PowerDNS LMDB backend policy
  local pdns_policy='module corepanel_pdns_fix 1.0;

require {
    type pdns_t;
    type pdns_var_run_t;
    type var_lib_t;
    type lib_t;
    class file { create write read open getattr setattr lock map unlink execute execute_no_trans execmod };
    class dir { write add_name remove_name };
}

# Allow PowerDNS to load and execute backend libraries (both contexts)
allow pdns_t pdns_var_run_t:file { execute execute_no_trans execmod open read getattr map };
allow pdns_t lib_t:file { execute execute_no_trans execmod open read getattr map };

# Allow PowerDNS to work with LMDB files in /var/lib/pdns
allow pdns_t var_lib_t:file { create write read open getattr setattr lock map unlink };
allow pdns_t var_lib_t:dir { write add_name remove_name };'

  if ! apply_selinux_policy "corepanel_pdns_fix" "$pdns_policy"; then
    warn "Failed to apply PowerDNS SELinux policy. Setting pdns_t permissive as fallback."
    semanage permissive -a pdns_t 2>/dev/null || true
  fi

  # --- SELinux: label the custom DNS ports ------------------------------------
  # This must happen BEFORE any of the DNS daemons is started: pdns_t/dnsdist_t
  # can only bind ports typed dns_port_t, and an unlabelled port fails the bind
  # with "Permission denied" instead of anything mentioning SELinux.
  semanage port -a -t dns_port_t -p tcp 5300 2>/dev/null || semanage port -m -t dns_port_t -p tcp 5300 || true
  semanage port -a -t dns_port_t -p udp 5300 2>/dev/null || semanage port -m -t dns_port_t -p udp 5300 || true
  semanage port -a -t dns_port_t -p tcp 5301 2>/dev/null || semanage port -m -t dns_port_t -p tcp 5301 || true
  semanage port -a -t dns_port_t -p udp 5301 2>/dev/null || semanage port -m -t dns_port_t -p udp 5301 || true
  # The PowerDNS HTTP API/webserver listens on 127.0.0.1:8381 (used by corepanel
  # to manage zones and issue certs). pdns_t can only bind dns_port_t, so without
  # labelling 8381 the webserver fails with "binding: Permission denied" and every
  # account creation fails at the DNS-zone / cert-manager step.
  semanage port -a -t dns_port_t -p tcp 8381 2>/dev/null || semanage port -m -t dns_port_t -p tcp 8381 || true
  # dnsdist's control socket listens on 127.0.0.1:5199 so corepanel-sys can purge
  # the packet cache on record edits ('dnsdist -c'). Without the label dnsdist
  # fails to bind it under SELinux enforcing.
  semanage port -a -t dns_port_t -p tcp 5199 2>/dev/null || semanage port -m -t dns_port_t -p tcp 5199 || true

  # Ports are labelled and the LMDB policy is loaded: pdns can bind now.
  systemctl restart pdns || true

  # --- Recursor v5: 127.0.0.1:5301 (YAML config) -----------------------------
  dnf_safe install pdns-recursor
  install -d /etc/pdns-recursor

  cat >/etc/pdns-recursor/recursor.conf <<'EOF'
# =============================================================================
# PowerDNS Recursor v5 - CorePanel default (YAML syntax)
# =============================================================================
recursor:
  include_dir: /etc/pdns-recursor/recursor.d
  setuid: pdns-recursor
  setgid: pdns-recursor

incoming:
  # Listen only on loopback, custom port (no clash with dnsdist)
  listen:
    - 127.0.0.1:5301
  # Restrict recursive access to local host only (tight default)
  allow_from:
    - 127.0.0.1/32

outgoing:
  # Use system routing/IP for outgoing queries
  source_address:
    - 0.0.0.0

dnssec:
  # Process DNSSEC without blocking broken zones (safe default)
  validation: process
EOF

  systemctl enable --now pdns-recursor || true

  # --- dnsdist: public :53, dispatch auth/recursor ----------------------------
  dnf_safe install dnsdist
  install -d -m 0750 /etc/dnsdist

  # Seed authoritative zones list from pdns, if available
  if command -v pdnsutil >/dev/null 2>&1; then
    pdnsutil list-all-zones > /etc/dnsdist/auth-zones.txt || : > /etc/dnsdist/auth-zones.txt
  else
    : > /etc/dnsdist/auth-zones.txt
  fi

  # Random key for the dnsdist control socket (used by 'dnsdist -c' cache purges).
  DNSDIST_KEY="$(openssl rand -base64 32)"

  cat >/etc/dnsdist/dnsdist.conf <<'EOF'
-- =============================================================================
-- dnsdist: split authoritative/recursor for CorePanel
-- Authoritative: 127.0.0.1:5300
-- Recursor:     127.0.0.1:5301
-- Frontend:     :53 (IPv4/IPv6)
-- =============================================================================

-- Listen on public interfaces (IPv4/IPv6)
setLocal('0.0.0.0:53')
addLocal('[::]:53')

-- Backends
newServer({address='127.0.0.1:5300', name='auth',     pool='auth'})
newServer({address='127.0.0.1:5301', name='recursor', pool='recursor'})

-- Load authoritative zones (one per line) into a suffix match tree
local zones = newSuffixMatchNode()
do
  local f = io.open('/etc/dnsdist/auth-zones.txt', 'r')
  if f then
    for line in f:lines() do
      local z = line:match('^%s*(.-)%s*$')
      if z ~= '' and not z:match('^#') then zones:add(z) end
    end
    f:close()
  end
end

-- Clients allowed to use the RECURSOR. Recursion must never be offered to the
-- whole internet: an open resolver is abused for DNS amplification attacks and
-- gets the server's IP blocklisted. Authoritative answers (below) stay public --
-- that is the whole point of hosting the zones -- but anything else is refused
-- unless it comes from this host or a local network.
local recursionAllowed = newNMG()
recursionAllowed:addMask('127.0.0.0/8')
recursionAllowed:addMask('::1/128')
recursionAllowed:addMask('10.0.0.0/8')
recursionAllowed:addMask('172.16.0.0/12')
recursionAllowed:addMask('192.168.0.0/16')
recursionAllowed:addMask('fc00::/7')
recursionAllowed:addMask('fe80::/10')

-- Routing, in order:
--   1. queries for zones we are authoritative for -> auth, for every client
--   2. anything else from an untrusted client     -> REFUSED (no open resolver)
--   3. anything else from a trusted client        -> recursor
addAction(QNameSuffixRule(zones), PoolAction('auth'))
addAction(NotRule(NetmaskGroupRule(recursionAllowed)), RCodeAction(DNSRCode.REFUSED))
addAction(AllRule(),              PoolAction('recursor'))

-- Simple shared packet cache (tune size as needed)
pc = newPacketCache(200000)
getPool('auth'):setCache(pc)
getPool('recursor'):setCache(pc)

-- ACL: the frontend accepts queries from anywhere, because the authoritative
-- zones must answer the public internet. Recursion is gated by the rule above,
-- NOT by this ACL.
addACL('0.0.0.0/0')
addACL('::/0')

-- Optional: local web console
-- webserver('127.0.0.1:8083', 'change-this-very-strong-password')
EOF

  # Control socket for surgical cache purges. Appended with an UNQUOTED heredoc so
  # ${DNSDIST_KEY} expands. corepanel-sys runs 'dnsdist -c -e "..."', which reads
  # the controlSocket address and key from this same config file.
  cat >>/etc/dnsdist/dnsdist.conf <<EOF

-- Control socket (corepanel-sys purges the packet cache on DNS record edits)
controlSocket('127.0.0.1:5199')
setKey('${DNSDIST_KEY}')
EOF

  chmod 0755 /etc/dnsdist
  # The config now holds the control-socket key, so keep it off world-readable.
  # root (dnsdist daemon at startup + 'dnsdist -c' from corepanel-sys) and the
  # dnsdist group can read it; nobody else.
  chown root:dnsdist /etc/dnsdist/dnsdist.conf 2>/dev/null || true
  chmod 0640 /etc/dnsdist/dnsdist.conf
  systemctl enable --now dnsdist || true

  # --- Final checks -----------------------------------------------------------
  if ! systemctl is-active --quiet pdns; then
    warn "pdns is not active; recent logs:"; journalctl -u pdns -n 50 --no-pager || true
  fi
  if ! systemctl is-active --quiet pdns-recursor; then
    warn "pdns-recursor is not active; recent logs:"; journalctl -u pdns-recursor -n 50 --no-pager || true
  fi
  if ! systemctl is-active --quiet dnsdist; then
    warn "dnsdist is not active; recent logs:"; journalctl -u dnsdist -n 50 --no-pager || true
  fi

  log "PowerDNS stack ready: auth@127.0.0.1:5300, recursor@127.0.0.1:5301, dnsdist@:53."
}

install_corepanel() {
  dnf_safe install corepanel
  log "CorePanel successfully installed."
}

# recover_php_fpm brings every installed php*-php-fpm service back to a clean,
# active state after all packages are installed. The corepanel-roundcube RPM's
# %post restarts php-fpm at an unfortunate moment, which can leave the service
# dead with a stale unix socket ("unable to bind ... Address already in use").
# Because php-fpm runs all pools in a single master, that one failure breaks PHP
# for every account, so we stop the service, drop the leaked sockets, make sure
# the runtime dirs exist, and start it again — failing the install if PHP-FPM
# cannot be brought up.
# Restart MariaDB so it picks up the SELinux domain corepanel-sys just enabled.
#
# Ordering makes this necessary, and only here. install_mariadb ran `systemctl
# enable --now mariadb` long before the CorePanel RPMs existed, so mysqld started
# while /usr/sbin/mariadbd was still bin_t: no domain transition, and the daemon
# is unconfined_service_t for the rest of its life. corepanel-sys's postinstall
# (mariadb_confine_setup) has since registered the fcontext and relabelled the
# binary, but a running process does not change domain — only a restart applies it.
#
# It matters beyond tidiness: while mysqld is unconfined, corepanel_app.te's
# mysql_stream_connect() grants connectto on mysqld_t and therefore matches
# nothing, so NO account application can reach the database over the socket, and
# cp-netout's --map-host-loopback none closes the TCP route by design. A fresh
# server would ship with the application runtime unable to use MySQL at all.
#
# The RPM postinstall deliberately does NOT do this — bouncing a customer's
# database from a package upgrade is not acceptable. Here it is: the installer
# owns a brand-new machine with no data and no clients connected yet.
confine_mariadb() {
  command -v getenforce &>/dev/null || return 0
  [[ "$(getenforce)" == "Disabled" ]] && return 0
  systemctl is-active --quiet mariadb 2>/dev/null || return 0

  # Only restart when the label is actually in place; otherwise the restart buys
  # nothing and we would rather say why.
  local want
  want="$(matchpathcon -n /usr/sbin/mariadbd 2>/dev/null || true)"
  if [[ "${want}" != *mysqld_exec_t* ]]; then
    warn "MariaDB stays unconfined: /usr/sbin/mariadbd is not labelled mysqld_exec_t. Account applications will not be able to reach MySQL."
    return 0
  fi

  systemctl restart mariadb || true

  local label
  label="$(ps -o label= -C mariadbd 2>/dev/null | head -n1)"
  if [[ "${label}" != *mysqld_t* ]]; then
    warn "MariaDB restarted but is not confined in mysqld_t (label: ${label:-none}). Account applications may not be able to reach MySQL."
  fi
}

recover_php_fpm() {
  log "Recovering PHP-FPM services to a clean state…"

  # Recreate /run/php-fpm (roundcube's socket dir) in case it was lost. The file
  # ships in the corepanel-php RPM under /usr/lib/tmpfiles.d, the correct place
  # for a package; hosts installed before that package existed have the same
  # content under /etc/tmpfiles.d, so try both.
  systemd-tmpfiles --create /usr/lib/tmpfiles.d/corepanel-php-fpm.conf 2>/dev/null || \
    systemd-tmpfiles --create /etc/tmpfiles.d/corepanel-php-fpm.conf 2>/dev/null || true

  local svc v
  for svc in $(systemctl list-unit-files --type=service --no-legend 'php*-php-fpm.service' 2>/dev/null | awk '{print $1}'); do
    v="${svc#php}"; v="${v%-php-fpm.service}"
    systemctl stop "${svc}" 2>/dev/null || true
    # Drop stale sockets left behind by an unclean stop.
    rm -f "/run/php${v}-fpm.sock" "/run/php${v}-php-fpm/"*.sock 2>/dev/null || true
    rm -f /run/php-fpm/*.sock 2>/dev/null || true
    if systemctl start "${svc}"; then
      log "  ${svc}: active"
    else
      error "  ${svc} failed to start after recovery:"
      systemctl status "${svc}" --no-pager -l 2>&1 | tail -15 || true
      "/opt/remi/php${v}/root/usr/sbin/php-fpm" -t 2>&1 | tail -10 || true
      return 1
    fi
  done
}

set_pureftp_extauth() {
  # Purpose: Set Pure-FTPd external auth to /run/corepanel-auth/corepanel-ftp-auth.sock
  # Supports:
  #   - Monolithic config: /etc/pure-ftpd/pure-ftpd.conf  (ExtAuth <path>)
  #   - Debian-style conf.d: /etc/pure-ftpd/conf/ExtAuth  (file contains the path)

  local TARGET="/run/corepanel-auth/corepanel-ftp-auth.sock"
  local CONF_MONO="/etc/pure-ftpd/pure-ftpd.conf"
  local CONF_DIR="/etc/pure-ftpd/conf"
  local CONF_KEY="ExtAuth"
  local need_restart=0

  # If Debian-style /etc/pure-ftpd/conf exists, prefer that
  if [[ -d "$CONF_DIR" ]]; then
    local f tmp
    f="${CONF_DIR}/${CONF_KEY}"
    mkdir -p "$CONF_DIR"

    tmp="$(mktemp "${f}.XXXXXX")"
    printf "%s\n" "$TARGET" > "$tmp"

    if [[ ! -f "$f" ]] || ! cmp -s "$tmp" "$f"; then
      mv -f "$tmp" "$f"
      chmod 0644 "$f"
      echo "Updated ${f} -> ${TARGET}"
      need_restart=1
    else
      rm -f "$tmp"
      echo "No change needed for ${f}"
    fi
  else
    # Monolithic config mode
    local f tmp ts
    f="$CONF_MONO"

    if [[ ! -f "$f" ]]; then
      echo "# Autogenerated Pure-FTPd config" > "$f"
      chmod 0644 "$f"
    fi

    # modify or add ExtAuth
    if grep -Eiq '^\s*#?\s*ExtAuth\b' "$f"; then
      tmp="$(mktemp "${f}.XXXXXX")"
      # shellcheck disable=SC2016
      sed -E "s|^\s*#?\s*ExtAuth\b.*|ExtAuth ${TARGET}|I" "$f" > "$tmp"
      if ! cmp -s "$tmp" "$f"; then
        mv -f "$tmp" "$f"
        echo "Replaced ExtAuth in ${f} -> ${TARGET}"
        need_restart=1
      else
        rm -f "$tmp"
        echo "No change needed for ${f}"
      fi
    else
      echo "ExtAuth ${TARGET}" >> "$f"
      echo "Appended ExtAuth to ${f} -> ${TARGET}"
      need_restart=1
    fi
  fi

  # Restart service if needed
  if [[ $need_restart -eq 1 ]]; then
    if command -v systemctl >/dev/null 2>&1; then
      systemctl daemon-reload || true
      systemctl try-restart pure-ftpd.service ||  true
      echo "Pure-FTPd restarted."
    else
      echo "Please restart Pure-FTPd manually."
    fi
  else
    echo "No FTP restart required."
  fi

}

configure_corepanel() {
  # --yes means "do not ask me anything", and that has to reach the wizard too.
  # Otherwise `curl … | bash -s -- -y` installs without a single question and then
  # stops dead on the panel-domain prompt, which is the opposite of what was asked
  # for — and hangs any provisioning that happens to have a terminal attached.
  if (( ASSUME_YES )); then
    corepanel system init --no-interactive
    return
  fi

  # Hand the wizard the real terminal, for the same reason confirm_install reads
  # from /dev/tty: the documented way to run this installer is `curl … | bash`, so
  # the script's stdin is the pipe. The wizard decides whether to prompt by asking
  # whether ITS stdin is a character device, sees the pipe, and runs unattended —
  # never asking the operator, who is sitting right there, for the panel domain or
  # the admin email.
  #
  # Only when there is no terminal at all (cloud-init, CI, a provisioning system)
  # does it fall through to the unattended path, which is what those want.
  if [[ ! -t 0 && -t 1 && -r /dev/tty ]]; then
    corepanel system init < /dev/tty
  else
    corepanel system init
  fi
}

# ---- SELinux -----------------------------------------------------------------
# Every CorePanel domain (corehttpd_t, corepanel_app_t, corepanel_cron_t,
# corepanel_phpfpm) is loaded by an RPM postinstall well before this runs, so by
# the time we get here the policy is in place and the box can be switched safely.
#
# This matters even though RHEL ships Enforcing by default: provider and cloud
# images routinely ship /etc/selinux/config already set to permissive, and a
# server built from one of those stays permissive forever unless the installer
# says otherwise. Confinement is not something CorePanel can inherit and hope for.
#
# A Disabled box is left alone: going from Disabled to Enforcing needs a full
# filesystem relabel on the next boot, and this installer never reboots.
configure_selinux_enforcing() {
  if ! command -v getenforce &>/dev/null; then
    warn "SELinux tools are absent; leaving the current mode untouched."
    return 0
  fi
  if [[ "$(getenforce)" == "Disabled" ]]; then
    warn "SELinux is Disabled on this host. CorePanel's policy is installed but not"
    warn "in effect. Enabling it needs a relabel and a reboot:"
    warn "  sed -ri 's/^SELINUX=.*/SELINUX=enforcing/' /etc/selinux/config && touch /.autorelabel && reboot"
    return 0
  fi
  log "Setting SELinux to Enforcing (runtime + persistent)…"
  setenforce 1 || true
  sed -ri 's/^SELINUX=.*/SELINUX=enforcing/' /etc/selinux/config
  setsebool -P ftp_home_dir on || true
}

# selinux_summary_state reports what SELinux is ACTUALLY doing, for the summary.
# Never assert a mode we did not read back: an installer that claims "Enforcing"
# on a permissive box teaches the operator to trust a line that can be wrong.
selinux_summary_state() {
  local mode
  if ! command -v getenforce &>/dev/null; then
    echo "unavailable (SELinux tools absent)"
    return
  fi
  mode="$(getenforce 2>/dev/null || echo unknown)"
  case "$mode" in
    Enforcing)  echo "Enforcing" ;;
    Permissive) echo "Permissive — policy loaded but NOT enforced; denials are only logged" ;;
    Disabled)   echo "Disabled — CorePanel's policy is installed but inactive (needs relabel + reboot)" ;;
    *)          echo "$mode" ;;
  esac
}

# ---- SELinux + mail integration for corehttpd ---------------------------------
# corehttpd ships its own corehttpd_t SELinux domain (refpolicy module, file
# contexts and an NNP-free unit) and loads it in the RPM postinstall, so the
# installer builds no policy here. What remains is the CorePanel-side
# integration:
#  - verify the confinement actually engaged (catches a stale local fcontext —
#    unconfined corehttpd 502s under Enforcing),
#  - grant the mail stack read access to the corehttpd host certificate, a
#    cross-product concern corehttpd itself does not know about (EL8's systemd
#    v239 ignores the tmpfiles `A+` default ACL, so set the ACL explicitly).
configure_selinux_corehttpd() {
  if command -v getenforce &>/dev/null && [[ "$(getenforce)" == "Enforcing" ]]; then
    local label
    label="$(ps -o label= -C corehttpd 2>/dev/null | head -n1)"
    if [[ "${label}" != *corehttpd_t* ]]; then
      warn "corehttpd is not confined in corehttpd_t (label: ${label:-none}); web serving may 502 under Enforcing."
    fi
  fi

  # Let Postfix/Dovecot/corepanel-auth read the host certificate corehttpd
  # writes. Wait briefly for the self-signed bootstrap to land first.
  local i
  for i in 1 2 3 4 5; do
    [ -s /var/lib/corehttpd/hostcert/privkey.pem ] && break
    sleep 1
  done
  if [ -d /var/lib/corehttpd/hostcert ]; then
    setfacl -m u:postfix:r-x,u:dovecot:r-x,u:corepanel-auth:r-x \
      /var/lib/corehttpd/hostcert/*.pem 2>/dev/null || true
  fi

  # Restart mail so it re-reads the now-readable host cert.
  systemctl restart postfix dovecot 2>/dev/null || true
}

# -----------------------------------------------------------------------------
# SELinux: corepanel_cron_t and corepanel_phpfpm are NOT configured here
# -----------------------------------------------------------------------------
# Both modules used to be built on this box from the .te sources, with
# selinux-policy-devel installed just for that. They now ship COMPILED in the
# corepanel-sys RPM (/usr/share/selinux/packages/corepanel/) and its postinstall
# loads them with semodule, on install and on every upgrade.
#
# The reason is not tidiness. This installer runs once, on a brand-new server,
# and never again — so a correction to a .te reached new installs only, never a
# single existing customer. Anything corrective that belongs to a package should
# be loaded by that package's postinstall, which is the one thing that reaches
# every host. Do not move them back here.
#
# cronie is likewise a `depends:` of corepanel-sys now, not a dnf call from here.

# -----------------------------------------------------------------------------
# SELinux: corepanel_app_t is NOT retried here either
# -----------------------------------------------------------------------------
# The module ships compiled in the corepanel-sys RPM and needs a type corehttpd's
# policy declares, so corepanel-sys's own postinstall cannot load it on a fresh
# install (dnf installs sys before corehttpd in the same transaction). The retry
# now lives in the `corepanel` METAPACKAGE's postinstall: it depends on both, and
# rpm runs a package's %post after everything it requires.
#
# That is strictly better than doing it here, for the same reason as above — the
# metapackage's %post runs on every upgrade, this script runs once. Do not add a
# retry back into the installer.

# ---- Summary -----------------------------------------------------------------
print_summary() {
  local pw_file="/root/.mariadb_root_password"
  local pw="<not generated>"; [[ -s "$pw_file" ]] && pw="$(cat "$pw_file")"

  # Read the mode back rather than restating what configure_selinux_enforcing
  # meant to do; on a Disabled host it declines to act and the summary must say so.
  local selinux_state; selinux_state="$(selinux_summary_state)"

  # The reboot notice is built here, before the heredoc, so it can be absent
  # entirely rather than printed as an empty line on the servers that do not
  # need one.
  local quota_notice=""
  if [[ "$DISK_QUOTA_REBOOT" -eq 1 ]]; then
    quota_notice="
${YELLOW}>>> REBOOT REQUIRED for disk quotas <<<${CLEAR}
    This filesystem can only start counting per-account disk usage when it is
    mounted, so nothing is measured or limited until this server reboots.
    Check afterwards with: ${YELLOW}corepanel quota status${CLEAR}
"
  fi

  cat <<EOF
${GREEN}==================== CorePanel Base Environment Ready ====================${CLEAR}
- Firewall (firewalld): ${YELLOW}${FIREWALL_STATE}${CLEAR}
- System updated; base tools installed ${YELLOW}(ipset if available)${CLEAR}
- Postfix + Dovecot installed & enabled (587/465 enabled, SASL via Dovecot)
- Postfix virtual mailbox: ${YELLOW}Dovecot LMTP delivery${CLEAR} (domains/aliases via corepanel-auth socketmap)
- Mail format: ${YELLOW}Maildir${CLEAR}
- Rspamd + Redis installed, milter OK (11332), controller on 11334 (local)
- Redis/Valkey hardened: ${YELLOW}Unix socket only, no TCP, password-protected${CLEAR} (tenant-safe)
- IMAPSieve autolearn configured (Junk → spam, out of Junk → ham)
- User-facing spam policy (per-scope thresholds + allow/deny lists) managed by corepanel-sys
- Per-user Sieve at LMTP + ManageSieve on ${YELLOW}4190${CLEAR} (vacation autoresponders + filters)
  * Used by the webmail over loopback; the port is ${YELLOW}not open to the internet${CLEAR}
- DKIM/ARC signing via Rspamd: keys in ${YELLOW}/var/lib/rspamd/dkim/${CLEAR}
- pure-ftpd installed & enabled (Unix/PAM auth, chroot users)
- MariaDB 10.11 LTS installed from official repo; AppStream MariaDB disabled
- MariaDB secured:
  * Root authentication: ${YELLOW}password${CLEAR} (local socket)
  * Root remote access: ${YELLOW}disabled (localhost only)${CLEAR}
  * Anonymous users removed; test DB removed
  * Tuning applied → ${YELLOW}/etc/my.cnf.d/corepanel-tuning.cnf${CLEAR}
  * Slow queries → ${YELLOW}/var/log/mariadb/slow.log${CLEAR}
  * Root password file: ${YELLOW}${pw_file}${CLEAR}
- PHP-FPM (Remi parallel): 8.4 / 8.3 / 8.2 / 7.4
  * Sites are served by ${YELLOW}per-account ondemand pools${CLEAR} (/run/php<v>-php-fpm/<user>.sock)
  * Stock Remi ${YELLOW}www${CLEAR} pool replaced by an inert placeholder (no idle workers, root-only socket)
- Web server: corehttpd (installed via the corepanel meta-package RPM)
- PowerDNS Authoritative (SQLite) installed & running
- Per-account disk quotas: ${YELLOW}${DISK_QUOTA_STATE}${CLEAR}

- SELinux: ${YELLOW}${selinux_state}${CLEAR}
- Login banner: the panel URL and where the password lives are kept in
  ${YELLOW}/etc/motd${CLEAR}, rewritten whenever the panel domain changes
${quota_notice}
${YELLOW}Next steps:${CLEAR}
1) Provision TLS certs for Postfix/Dovecot and (optionally) pure-ftpd.
2) Create mailboxes/users (Maildir) per CorePanel policies.
3) Use the root MySQL password from ${pw_file} to create application DB/users.
4) corehttpd serves sites from CorePanel-written SiteConfs (/var/lib/corehttpd/sites).
5) For DKIM per domain: run 'rspamd_add_dkim_domain <domain>' and publish the TXT.

${GREEN}==========================================================================${CLEAR}
EOF
}

# ---- Post-install verification -----------------------------------------------
# The script runs under `set -e`, so reaching the end means no command returned
# an error. That is not the same as a working server: a unit can start, serve
# the wizard, and die minutes later. Until this check existed the installer
# printed "Ready" and pinged the activation endpoint regardless, so a machine
# whose core was dead was indistinguishable — to the operator and to us — from a
# healthy one.
#
# Measured on the fleet before this was written: of 14 third-party installs that
# reached notify_activation, 4 never sent a single telemetry check-in. Core had
# been alive (it registered the licence signup) and was gone 15 minutes later.

# Units the panel cannot be used without: corehttpd serves it, core holds the
# data, sys performs every privileged change, api is what the browser talks to.
# With any of these down there is nothing to log into, so their failure fails
# the install.
#
# This list belongs to a full install and to nothing else. Both transform modes
# deliberately leave units masked or stopped while cPanel is still serving the
# machine — corehttpd among them — so running this check there would fail a
# transformation that is going exactly to plan.
verify_required_units=(corepanel-sys corepanel-core corepanel-api corehttpd)

# Units whose failure costs a feature rather than the panel. Reported and
# printed, but they do not fail the run: the operator can fix mail or DNS from a
# panel they can still reach, and withholding a working panel over a stopped
# rspamd would be the wrong trade.
verify_optional_units=(mariadb corepanel-auth postfix dovecot rspamd pure-ftpd pdns pdns-recursor dnsdist)

# What turns "it started" into "it is still running". The failure this exists
# for is a unit that starts, answers the wizard and then dies, so an is-active
# taken the moment the wizard returns would call it healthy. systemd's default
# start limit is 5 restarts in 10s, so a unit stuck in a restart loop has
# already been given up on by the time this elapses.
VERIFY_SETTLE_SECONDS="${COREPANEL_VERIFY_SETTLE:-25}"
# Validated, not trusted: this value reaches `sleep`, and under `set -e` a
# non-numeric one would abort the install on its very last step — reporting a
# failure for a server that is fine, and skipping the check and the ping.
[[ "${VERIFY_SETTLE_SECONDS}" =~ ^[0-9]+$ ]] || VERIFY_SETTLE_SECONDS=25

unit_exists() {
  [[ -n "$(systemctl list-unit-files "$1.service" --no-legend --no-pager 2>/dev/null)" ]]
}

# NRestarts counts only the restarts systemd performed itself under Restart=,
# not the ones this script asked for, so a non-zero value is a unit that fell
# over on its own — the signature we are looking for even when it is up now.
unit_restarts() {
  local n; n="$(systemctl show -p NRestarts --value "$1.service" 2>/dev/null || true)"
  # Forced to a number on purpose: this value reaches an arithmetic test, and an
  # empty or unexpected one there would abort the install under `set -e` — a
  # check that fails the run it was added to protect.
  [[ "$n" =~ ^[0-9]+$ ]] || n=0
  printf '%s' "$n"
}

unit_state() {
  systemctl is-active "$1.service" 2>/dev/null || true
}

# start_socket_activated starts a service that systemd only launches on demand,
# when its socket is listening and the service itself is not running yet. Never
# fails the script: a service that will not start is what verify_services is
# about to report, with the unit's own state and journal.
start_socket_activated() {
  local unit="$1"
  systemctl is-active --quiet "${unit}.socket" 2>/dev/null || return 0
  systemctl is-active --quiet "${unit}.service" 2>/dev/null && return 0
  systemctl start "${unit}.service" >/dev/null 2>&1 || true
}

# systemd's own verdict on why a unit last stopped, which is worth more than any
# guess this script could make from the outside. Two values answer the question
# on their own: `oom-kill` says the kernel took it because the machine ran out
# of memory, and `start-limit-hit` says it failed often enough that systemd gave
# up and will not retry it at all.
unit_result() {
  local r; r="$(systemctl show -p Result --value "$1.service" 2>/dev/null || true)"
  printf '%s' "${r:-unknown}"
}

# unit_verdict says what is actually wrong with a unit, because the two failures
# this check catches do not look alike: one is a unit that is down, the other a
# unit that is up right now precisely because systemd keeps restarting it.
# Printing "not running" for the second would send the operator looking for the
# wrong thing.
unit_verdict() {
  local unit="$1" state restarts
  state="$(unit_state "$unit")"; restarts="$(unit_restarts "$unit")"
  local times="times"; [[ "${restarts:-0}" == "1" ]] && times="time"
  local why=""
  case "$(unit_result "$unit")" in
    oom-kill)       why=" — the kernel killed it: this server ran out of memory" ;;
    start-limit-hit) why=" — it failed too often and systemd has stopped retrying it" ;;
  esac

  if [[ "$state" != "active" ]]; then
    printf '%s (restarted %s %s on its own)%s' "${state:-missing}" "${restarts:-0}" "$times" "$why"
  else
    printf 'running, but systemd restarted it %s %s on its own%s' "${restarts:-0}" "$times" "$why"
  fi
}

# Never let a password this installer generated leave the machine inside a log
# line. Both files hold a single secret in plain text, so the substitution is
# exact rather than a guess at what a secret looks like.
redact_secrets() {
  local text="$1" secret f
  for f in /root/.corepanel_password /root/.mariadb_root_password; do
    [[ -s "$f" ]] || continue
    secret="$(tr -d '\n' <"$f")"
    [[ ${#secret} -ge 8 ]] || continue
    text="${text//"$secret"/[redacted]}"
  done
  printf '%s' "$text"
}

# server_facts reports the size of the machine: cores, memory and the free space
# on the filesystems the panel actually uses.
#
# It is here because the installer has never once looked at them — it will fit
# MariaDB, PowerDNS, Dovecot, Rspamd, four PHP-FPM pools and our four services
# onto whatever it is pointed at, and never says a word about whether they fit.
# A daemon that dies on one VPS and lives on the next is exactly what running
# out of memory looks like from the outside, and until this line existed we had
# no way of telling that apart from a bug.
server_facts() {
  local out="" mem_total mem_avail swap
  mem_total="$(awk '/^MemTotal:/{printf "%d", $2/1024}' /proc/meminfo 2>/dev/null || echo 0)"
  mem_avail="$(awk '/^MemAvailable:/{printf "%d", $2/1024}' /proc/meminfo 2>/dev/null || echo 0)"
  swap="$(awk '/^SwapTotal:/{printf "%d", $2/1024}' /proc/meminfo 2>/dev/null || echo 0)"

  # Defaulted rather than trusted: a kernel without MemAvailable (pre-3.14) makes
  # awk print nothing without failing, which would leave a field with no value.
  out+="cpus=$(nproc 2>/dev/null || echo unknown)"$'\n'
  out+="ram_mb=${mem_total:-0} available_mb=${mem_avail:-0} swap_mb=${swap:-0}"$'\n'

  # Only the mount points the panel writes to, and only if they are distinct:
  # the sizes matter, the layout of somebody's server does not.
  local seen="" path dev
  for path in / /home /var; do
    [[ -d "$path" ]] || continue
    dev="$(df -P "$path" 2>/dev/null | awk 'NR==2{print $1}')"
    [[ -n "$dev" && ",${seen}," != *",${dev},"* ]] || continue
    seen="${seen},${dev}"
    out+="$(df -P -BM "$path" 2>/dev/null | awk -v p="$path" 'NR==2{printf "disk %s total=%s used=%s free=%s", p, $2, $3, $4}')"$'\n'
  done

  printf '%s' "$out"
}

# collect_service_report builds the body sent to the activation endpoint when
# something is wrong. Deliberately narrow: unit states, the versions of our own
# packages, and the tail of the journal for OUR units only. No system journal,
# no configuration files, no account data.
collect_service_report() {
  local -n _bad="$1"
  local -n _degraded="$2"
  local unit out="" result="degraded"
  [[ ${#_bad[@]} -gt 0 ]] && result="failed"

  out+="corepanel-install-report/1"$'\n'
  out+="result=${result}"$'\n'
  out+="when=$(date -u +%Y-%m-%dT%H:%M:%SZ)"$'\n'
  out+="os=${DIST_ID:-unknown} ${DIST_VER:-unknown}"$'\n'
  out+="kernel=$(uname -r 2>/dev/null || echo unknown)"$'\n'

  # server_facts ends in a newline, which the command substitution strips; the
  # explicit one keeps the blank line between sections.
  out+=$'\n[server]\n'
  out+="$(server_facts)"$'\n'

  out+=$'\n[units]\n'
  for unit in "${verify_required_units[@]}" "${verify_optional_units[@]}"; do
    unit_exists "$unit" || continue
    out+="$(printf '%-22s %-10s restarts=%-3s result=%s\n' \
      "$unit" "$(unit_state "$unit")" "$(unit_restarts "$unit")" "$(unit_result "$unit")")"$'\n'
  done

  out+=$'\n[packages]\n'
  out+="$(rpm -qa 'corepanel*' 'corehttpd*' --qf '%{NAME} %{VERSION}-%{RELEASE}\n' 2>/dev/null | sort || true)"$'\n'

  # Only the units that actually went wrong, and only their last lines: the
  # point is the error and the few lines that led to it, not a transcript.
  for unit in "${_bad[@]}" "${_degraded[@]}"; do
    out+=$'\n[journal:'"${unit}"$']\n'
    out+="$(journalctl -u "${unit}.service" -n 50 --no-pager -o short-iso 2>/dev/null || echo '(journal unavailable)')"$'\n'
  done

  # A hard ceiling so a runaway log cannot turn one broken install into a
  # multi-megabyte POST. The endpoint enforces its own limit too.
  out="$(redact_secrets "$out")"
  printf '%s' "${out:0:16384}"
}

# verify_services is the one place that decides whether this install succeeded.
verify_services() {
  local unit state restarts
  # corepanel-api is socket-activated: corepanel-api.socket listens from the
  # moment the package is installed and the service only starts on the first
  # connection. An unattended install makes none — the wizard talks to core
  # directly — so the service is still `inactive` here on a perfectly healthy
  # server, and checking it as it stands failed every clean install (measured on
  # AlmaLinux 8 and 9, 2026-09-17). Starting it is also the better test: an api
  # that cannot run fails now, while the operator is watching, instead of on the
  # first page load. Only a unit whose socket is listening is started; one with
  # no socket, or a socket that is down, is judged exactly as before.
  for unit in "${verify_required_units[@]}" "${verify_optional_units[@]}"; do
    start_socket_activated "$unit"
  done

  log "Verifying services (waiting ${VERIFY_SETTLE_SECONDS}s for them to settle)…"
  sleep "${VERIFY_SETTLE_SECONDS}"

  local -a bad=() degraded=()

  for unit in "${verify_required_units[@]}"; do
    if ! unit_exists "$unit"; then bad+=("$unit"); continue; fi
    state="$(unit_state "$unit")"
    restarts="$(unit_restarts "$unit")"
    if [[ "$state" != "active" ]] || [[ "${restarts:-0}" -gt 0 ]]; then
      bad+=("$unit")
    fi
  done

  for unit in "${verify_optional_units[@]}"; do
    unit_exists "$unit" || continue
    state="$(unit_state "$unit")"
    restarts="$(unit_restarts "$unit")"
    if [[ "$state" != "active" ]] || [[ "${restarts:-0}" -gt 0 ]]; then
      degraded+=("$unit")
    fi
  done

  if [[ ${#bad[@]} -eq 0 && ${#degraded[@]} -eq 0 ]]; then
    log "All services are running."
    notify_activation
    return 0
  fi

  local report; report="$(collect_service_report bad degraded)"

  if [[ ${#degraded[@]} -gt 0 ]]; then
    warn ""
    for unit in "${degraded[@]}"; do
      warn "  ${unit}: $(unit_verdict "$unit")"
    done
  fi

  if [[ ${#bad[@]} -eq 0 ]]; then
    # The panel is up; what failed is a feature behind it. Say so plainly, send
    # the report so the failure is not invisible to us, and let the install
    # stand — the operator can fix these from the panel they can now reach.
    warn "The panel is installed and running, but the services above are not."
    warn "Check them with: systemctl status <service>"
    notify_activation "$report"
    return 0
  fi

  error ""
  error "=========================== INSTALL FAILED ==========================="
  error "CorePanel installed, but these services are not healthy:"
  for unit in "${bad[@]}"; do
    error "  ${unit}: $(unit_verdict "$unit")"
  done
  error ""
  error "This server: $(server_facts | tr '\n' ' ' | tr -s ' ')"
  error ""
  error "The panel is NOT usable in this state. What to look at first:"
  for unit in "${bad[@]}"; do
    error "  journalctl -u ${unit} -n 50"
  done
  error ""
  error "A unit that keeps failing hits systemd's start limit (5 restarts in 10s)"
  error "and is then not retried at all; after fixing the cause, start it with:"
  error "  systemctl reset-failed && systemctl start ${bad[0]}"
  error ""
  error "A diagnostic report has been sent to the CorePanel project: the service"
  error "states above, our package versions, and the last 50 journal lines of"
  error "those services. No configuration, credentials or account data is included."
  error "Set COREPANEL_NO_DIAGNOSTICS=1 to keep it on this machine."
  error "Support: https://corepanel.net/contact"
  error "======================================================================"

  notify_activation "$report"
  exit 1
}

# ---- Activation ping ---------------------------------------------------------
# Tell the CorePanel project that a server finished installing.
#
# On a clean install the endpoint sees nothing but the request's source IP — no
# hostname, no credentials, no server data is sent, and its answer is discarded.
#
# When verify_services found something broken it is also given that report:
# unit states, the versions of our own packages, and the last 50 journal lines
# of the units that failed, with any password this installer generated removed.
# Nothing else — no configuration, no account data, no system journal. That is
# the whole difference, and it only happens on a failed install, because a
# failure nobody can see is one nobody fixes.
#
# Best-effort by design: any failure (no DNS, no egress, endpoint down, no curl)
# is swallowed, and a broken install still reports its own failure to the
# operator on the terminal. Set COREPANEL_NO_ACTIVATE=1 to skip the call
# entirely, COREPANEL_NO_DIAGNOSTICS=1 to keep the report on the machine and
# send only the ping, or point COREPANEL_ACTIVATE_URL elsewhere for testing.
ACTIVATE_URL="${COREPANEL_ACTIVATE_URL:-https://get.corepanel.net/activate.php}"

notify_activation() {
  local body="${1:-}"
  [[ "${COREPANEL_NO_ACTIVATE:-0}" == "1" ]] && return 0
  command -v curl >/dev/null 2>&1 || return 0
  [[ "${COREPANEL_NO_DIAGNOSTICS:-0}" == "1" ]] && body=""

  if [[ -n "$body" ]]; then
    # A longer timeout than the bare ping: this one carries a body, and it is
    # sent from a machine that has just gone wrong.
    printf '%s' "$body" | curl -fsS -m 20 -o /dev/null \
      -H 'Content-Type: text/plain; charset=utf-8' \
      --data-binary @- "${ACTIVATE_URL}" >/dev/null 2>&1 || true
    return 0
  fi

  curl -fsS -m 10 -o /dev/null "${ACTIVATE_URL}" >/dev/null 2>&1 || true
  return 0
}

# ---- Main --------------------------------------------------------------------
# =============================================================================
# PREPARE phase of an in-place transformation (CPANEL-TRANSFORM-PLAN.md §5)
# =============================================================================
#
# The installer normally owns the whole machine. Here it owns nothing yet: cPanel
# is still serving every site, mailbox and zone on this box, and will keep doing
# so until the cutover. So this mode is defined by its refusals, and each one
# below was verified against a live cPanel 11.136 / AlmaLinux 9 server rather
# than assumed:
#
#   * cPanel ships its OWN builds of the daemons we would install, under the same
#     paths: cpanel-dovecot owns /usr/sbin/dovecot, cpanel-pure-ftpd owns
#     /usr/sbin/pure-ftpd, cpanel-pdns owns /usr/sbin/pdns_server AND the
#     /usr/lib/systemd/system/pdns.service unit file. Installing ours "masked"
#     would not be additive at all — it would replace the binaries under a
#     running service.
#   * cPanel writes a global package exclude into BOTH /etc/dnf/dnf.conf and
#     /etc/yum.conf:
#         exclude=bind-chroot dovecot* exim* filesystem p0f php* proftpd* pure-ftpd*
#     which protects it from exactly that. It also blocks OUR PHP stack, since
#     corepanel-php requires php84-php-*, php83-php-* and friends: without the
#     scoped override in transform_install_php_stack, dnf answers "All matches
#     were filtered out by exclude filtering" and the install fails on something
#     that looks like a broken repository.
#
# The result is that PREPARE installs less than the plan first assumed. Mail,
# FTP and DNS are cutover work, because on this machine they cannot be installed
# without touching what is serving. What PREPARE does install is everything that
# genuinely coexists: the panel itself, its web server (masked), the Remi PHP
# stack (parallel to EA4, different prefix), and the base tools.

# transform_our_units are the services WE own that would bind a port cPanel is
# using. They are masked before the packages land, so an RPM postinstall cannot
# start them. Every unit here is one no source panel provides — masking a unit
# cPanel is running would take its service down, which is the one thing this
# mode exists to avoid.
transform_our_units=(corehttpd postfix)

transform_preflight() {
  log "Running transform preflight…"

  if [[ ! -d /usr/local/cpanel && ! -f /etc/wwwacct.conf ]]; then
    error "No cPanel installation found on this server."
    error "--transform-prepare transforms a running cPanel box in place. On a"
    error "fresh server run the installer without it."
    exit 1
  fi
  local cpver="unknown"
  [[ -r /usr/local/cpanel/version ]] && cpver="$(cat /usr/local/cpanel/version)"
  log "  cPanel ${cpver} detected — it stays authoritative until the cutover."

  # A second run is fine and expected (the plan's delta discipline), but a box
  # where the panel has already been configured is past this phase.
  if [[ -f /var/lib/corepanel/db/corepanel.db ]]; then
    warn "  CorePanel is already installed here; re-running PREPARE only tops up packages."
  fi

  # Port map audit: read-only, and it says what the cutover will have to move
  # rather than trying to move anything now.
  local -a busy=()
  local p
  for p in 80 443 25 465 587 143 993 110 995 21 53; do
    if ss -lnt "sport = :${p}" 2>/dev/null | grep -q LISTEN; then busy+=("${p}"); fi
  done
  if (( ${#busy[@]} > 0 )); then
    log "  Ports held by the source panel (released at cutover): ${busy[*]}"
  fi

  local free_kb; free_kb="$(df -Pk / | awk 'NR==2 {print $4}')"
  if (( free_kb < 3145728 )); then
    error "Less than 3 GB free on /. The CorePanel stack does not fit next to cPanel."
    exit 1
  fi

  log "Transform preflight passed."
}

# The typed acknowledgement the plan asks for (§5.1). PREPARE is additive, but it
# is the first step of something that is not, and the operator should have a way
# back that does not depend on us.
transform_confirm() {
  (( ASSUME_YES )) && return 0

  local tty_in=""
  if [[ -t 0 ]]; then tty_in="/dev/stdin"
  elif [[ -t 1 && -r /dev/tty ]]; then tty_in="/dev/tty"
  else log "No terminal attached: proceeding without confirmation."; return 0
  fi

  echo
  echo -e "${YELLOW}${BOLD}  About to PREPARE this server for an in-place transformation${CLEAR}"
  echo
  echo -e "  Target : ${BOLD}$(hostname -f 2>/dev/null || hostname)${CLEAR} (${PRETTY})"
  echo
  echo "  This phase is additive. cPanel keeps serving: no daemon of its is"
  echo "  stopped, reconfigured or replaced, no port it holds is taken, no"
  echo "  database is altered, and the firewall and SELinux mode are left as"
  echo "  they are. What it does is install the CorePanel stack alongside."
  echo
  echo "  It is still the first step of a change that ends by replacing the"
  echo -e "  panel on this machine. ${BOLD}Take a snapshot before continuing.${CLEAR}"
  echo
  printf "  Type 'I-HAVE-A-BACKUP' to continue, anything else to abort: "

  local answer=""
  read -r answer < "${tty_in}" || answer=""
  echo

  if [[ "${answer}" != "I-HAVE-A-BACKUP" ]]; then
    error "Aborted: nothing was installed or modified."
    exit 1
  fi
  log "Acknowledged. Starting PREPARE…"
}

# Masked BEFORE the packages arrive, so no postinstall can start them. corehttpd
# would fail to bind :80 anyway with Apache there, but a service in a restart
# loop is noise an operator has to interpret during a migration.
transform_mask_our_services() {
  log "Masking the services that wait for the cutover…"
  local unit
  for unit in "${transform_our_units[@]}"; do
    systemctl mask "${unit}.service" >/dev/null 2>&1 || true
    log "  ${unit}: masked"
  done
}

# corepanel-php requires php84-php-*/php83-php-* and cPanel's global exclude
# filters every one of them out. The override is scoped to this one transaction
# and names the config it overrides: applied globally it would also lift the
# protection on dovecot*, exim* and pure-ftpd*, which is precisely the accident
# cPanel wrote that line to prevent.
transform_install_php_stack() {
  log "Installing the CorePanel PHP stack (Remi, parallel to cPanel's EA4)…"
  if grep -qsE '^exclude=.*php' /etc/dnf/dnf.conf /etc/yum.conf; then
    log "  cPanel excludes php* from dnf; overriding for this transaction only."
    dnf -y --disableexcludes=main install corepanel-php \
      || { warn "DNF failed → cleaning metadata & retrying…"; dnf -y clean all; \
           dnf -y --disableexcludes=main install corepanel-php; }
  else
    dnf_safe install corepanel-php
  fi
}

# Reporting only. The database is the riskiest step of the whole transformation
# and it belongs to the cutover: a live cPanel MariaDB is serving every site on
# the box, its root credential is cPanel's, and securing or upgrading it here
# would be a change users can see from a phase that promised none.
transform_report_mariadb() {
  local ver=""
  if command -v mariadbd >/dev/null 2>&1; then ver="$(mariadbd --version 2>/dev/null)"
  elif command -v mysqld >/dev/null 2>&1; then ver="$(mysqld --version 2>/dev/null)"
  fi
  if [[ -z "${ver}" ]]; then
    warn "  No database server found — unexpected on a cPanel box; the cutover will install one."
    return 0
  fi
  # e.g. "mariadbd  Ver 10.11.18-MariaDB for Linux on x86_64"
  local num; num="$(sed -nE 's/.*Ver ([0-9]+\.[0-9]+)\..*/\1/p' <<<"${ver}")"
  # 10.11 is a floor, not a target: CorePanel keeps its own data in SQLite and
  # never uses MySQL, so a server already above it is left on what it runs.
  case "${num}" in
    "") warn "  Could not read the database version; the cutover will decide the strategy." ;;
    *)
      if [[ "$(printf '%s\n10.11\n' "${num}" | sort -V | head -n1)" == "10.11" ]]; then
        log "  MariaDB ${num} is at or above the 10.11 CorePanel needs: the data directory and the server running it are both adopted at cutover, not moved."
      else
        warn "  Database is ${num}, below the 10.11 CorePanel needs: the cutover will upgrade it in place (see the migration check)."
      fi
      ;;
  esac
}

transform_summary() {
  echo
  echo -e "${GREEN}${BOLD}  PREPARE complete — cPanel is still serving this machine${CLEAR}"
  echo
  echo "  Installed and running:"
  echo "    corepanel-core, corepanel-sys, corepanel-api, corepanel-auth (:16087, unix sockets)"
  echo "    the CorePanel PHP stack (Remi prefixes, alongside cPanel's EA4)"
  echo
  echo "  Installed and masked until the cutover:"
  local unit
  for unit in "${transform_our_units[@]}"; do echo "    ${unit}"; done
  echo
  echo "  NOT installed, because cPanel owns those binaries and is running them:"
  echo "    Dovecot, Exim/Postfix delivery, pure-ftpd, PowerDNS"
  echo "    (cpanel-dovecot, cpanel-pure-ftpd and cpanel-pdns own the same paths)"
  echo
  echo "  Left exactly as they were:"
  echo "    every cPanel daemon and its config, the database, /etc/shadow,"
  echo "    the firewall, the SELinux mode and the disk-quota mount options"
  echo
  transform_report_mariadb
  echo
  echo "  Next: import the accounts, then schedule the cutover."
  echo
}

run_transform_prepare() {
  transform_preflight
  transform_confirm
  transform_mask_our_services
  install_base_tools
  # Three repositories are added to a machine that is not ours yet. They are
  # additive and none of them can reach what cPanel protects — its own exclude
  # line covers dovecot*, exim*, php*, proftpd* and pure-ftpd* — but an operator
  # auditing this box later should find out from us, not from dnf.
  log "Adding the CorePanel, EPEL and Remi repositories…"
  configure_corepanel_repo
  enable_epel
  enable_remi
  transform_install_php_stack
  install_corepanel
  # Only ever touches php*-php-fpm units, which are Remi's. cPanel's are named
  # ea-phpNN-php-fpm and do not match the glob.
  recover_php_fpm
  transform_summary
}

# =============================================================================
# CUTOVER phase of an in-place transformation (CPANEL-TRANSFORM-PLAN.md §6d)
# =============================================================================
#
# PREPARE installed everything that could coexist with a serving cPanel. Mail,
# FTP and DNS could not: cPanel ships its own builds of those daemons at the
# same paths ours use, so "install ours masked" would have replaced the binaries
# under a running service rather than adding anything.
#
# This mode is the other end of that. By the time it runs, corepanel-transform
# has stopped cPanel's daemons and removed the packages that owned those paths,
# and corehttpd is already serving every site on the machine. What is left is a
# normal installation of four daemons — and it is deliberately the SAME code the
# fresh installer runs, not a second implementation of it. A migration is not
# where a hosting stack should meet its first untested configuration path.
#
# What it refuses to do is as much a part of it as what it does:
#
#   * It does not remove cPanel's packages. That is the transformation's one
#     genuinely irreversible act, it needs a record of what was removed so a
#     reversal knows what to reinstall, and the record belongs beside the run's
#     other state. corepanel-transform owns it; this mode only checks it
#     happened, and refuses to run while those packages are still installed.
#   * It does not touch the firewall. The ports these daemons bind — 25, 587,
#     465, 143, 993, 110, 995, 21 and 53 — are the ports cPanel was answering on
#     five minutes ago, so they are already open. What the operator does have to
#     check by hand is the FTP passive range, which is ours and not cPanel's.
#   * It does not touch the SELinux mode, the database, PHP or the web server.
#
# The dnf exclude cPanel writes is still on this machine and outlives its
# packages. /etc/dnf/dnf.conf and /etc/yum.conf both carry
#     exclude=bind-chroot dovecot* exim* filesystem p0f php* proftpd* pure-ftpd*
# so `dnf install dovecot pure-ftpd` answers "All matches were filtered out by
# exclude filtering" on a machine whose cpanel-dovecot is long gone. Verified on
# cPanel 11.136 / AlmaLinux 9, where the same line is what made the PHP stack
# fail in PREPARE. See transform_cutover_lift_excludes.

# transform_cutover_conflicts are cPanel's builds of the daemons this mode
# installs. Every one of them owns a path ours needs, so every one of them has
# to be gone before this runs — not stopped, gone.
transform_cutover_conflicts=(cpanel-exim cpanel-dovecot cpanel-dovecot-pigeonhole cpanel-pure-ftpd cpanel-pdns)

# transform_cutover_shadow_units are unit files cPanel installs into
# /etc/systemd/system.
#
# That directory outranks /usr/lib/systemd/system, which is where the
# distribution's dovecot and pure-ftpd put theirs. A leftover here is not a
# stale file: it is the unit systemd would run, with cPanel's ExecStart pointing
# at a binary this cutover has just deleted. rpm removes them (none of the four
# is marked %config, checked on the reference server) — this verifies it, because
# the failure mode is a mail server that never starts and a unit file nobody
# thinks to look at.
transform_cutover_shadow_units=(exim.service dovecot.service pure-ftpd.service pure-authd.service)


# port_is_corepanels reports whether one of CorePanel's own daemons is what
# holds a port.
#
# It asks systemd which unit is running rather than reading a process name out
# of `ss -lntp`: Postfix's listener is called "master", which is not a name
# worth deciding anything on, and the unit is the thing the cutover starts and
# stops anyway.
port_is_corepanels() {
  local port="$1" unit
  local -a units=()
  case "${port}" in
    25|587|465)      units=(postfix) ;;
    143|993|110|995) units=(dovecot) ;;
    21)              units=(pure-ftpd) ;;
    53)              units=(dnsdist pdns pdns-recursor) ;;
    *)               return 1 ;;
  esac
  for unit in "${units[@]}"; do
    if systemctl is-active --quiet "${unit}" 2>/dev/null; then return 0; fi
  done
  return 1
}

transform_cutover_preflight() {
  log "Running cutover preflight…"

  if [[ ! -f /var/lib/corepanel/db/corepanel.db ]]; then
    error "CorePanel is not installed on this server."
    error "--transform-cutover is the second half of a transformation whose first"
    error "half installs the panel. Run --transform-prepare first."
    exit 1
  fi

  # The guard that makes the split safe. dnf would not stop us: cpanel-exim
  # Obsoletes postfix and cpanel-pure-ftpd Obsoletes pure-ftpd, so the install
  # below would report "already installed" and do nothing at all, while
  # cpanel-dovecot instead lets dnf resolve and leaves rpm to reject the
  # transaction on dozens of file conflicts. Both failures land mid-window.
  local -a still=()
  local pkg
  for pkg in "${transform_cutover_conflicts[@]}"; do
    rpm -q "${pkg}" >/dev/null 2>&1 && still+=("${pkg}")
  done
  if (( ${#still[@]} > 0 )); then
    error "cPanel still owns the mail, FTP and DNS paths on this server:"
    for pkg in "${still[@]}"; do error "    ${pkg}"; done
    error ""
    error "Those packages own /usr/sbin/dovecot, /usr/sbin/pure-ftpd and"
    error "/usr/sbin/pdns_server, so ours cannot be installed beside them."
    error "Removing them is corepanel-transform's step, not this script's, because"
    error "it is irreversible and the list of what was removed has to be recorded."
    error "Run: corepanel-transform resume  (step cutover.mail-dns-ftp)"
    exit 1
  fi

  local -a shadow=()
  local unit
  for unit in "${transform_cutover_shadow_units[@]}"; do
    [[ -e "/etc/systemd/system/${unit}" ]] && shadow+=("${unit}")
  done
  if (( ${#shadow[@]} > 0 )); then
    error "cPanel unit files are still in /etc/systemd/system:"
    for unit in "${shadow[@]}"; do error "    /etc/systemd/system/${unit}"; done
    error ""
    error "That directory outranks /usr/lib/systemd/system, so these are the units"
    error "systemd would run — pointing at binaries this cutover removed. Delete"
    error "them, run 'systemctl daemon-reload', and start this mode again."
    exit 1
  fi

  # Ports. cPanel's daemons are stopped by the time this runs, so anything still
  # holding one of these is a process nobody has accounted for — and a Postfix
  # that cannot bind :25 fails after the configuration has been written.
  #
  # Unless it is OUR Postfix. A cutover run again after a rollback meets its own
  # destination: the reversal cannot give mail, FTP and DNS back to cPanel,
  # because their cPanel packages were removed so ours could be installed, so
  # CorePanel keeps serving them and its daemons hold these ports the second
  # time round. Refusing then strands a supported path behind a message telling
  # the operator to go and stop their own mail server.
  local -a busy=()
  local p
  for p in 25 587 465 143 993 110 995 21; do
    if ss -lnt "sport = :${p}" 2>/dev/null | grep -q LISTEN && ! port_is_corepanels "${p}"; then
      busy+=("${p}/tcp")
    fi
  done
  # 53 on both protocols: dnsdist binds UDP and TCP, and a resolver holding only
  # the UDP socket is invisible to a TCP-only check.
  if ss -lnt "sport = :53" 2>/dev/null | grep -q LISTEN && ! port_is_corepanels 53; then
    busy+=("53/tcp")
  fi
  # tail -n +2 drops ss's header line, which every version prints and no version
  # spells the same way; anything left is a socket the filter matched.
  if ss -lnu "sport = :53" 2>/dev/null | tail -n +2 | grep -q . && ! port_is_corepanels 53; then
    busy+=("53/udp")
  fi
  if (( ${#busy[@]} > 0 )); then
    error "Ports the CorePanel daemons need are still held: ${busy[*]}"
    error "Find the process with 'ss -lntup' and stop it before continuing."
    exit 1
  fi

  log "Cutover preflight passed — the paths and the ports are free."
}

transform_cutover_confirm() {
  (( ASSUME_YES )) && return 0

  local tty_in=""
  if [[ -t 0 ]]; then tty_in="/dev/stdin"
  elif [[ -t 1 && -r /dev/tty ]]; then tty_in="/dev/tty"
  else log "No terminal attached: proceeding without confirmation."; return 0
  fi

  echo
  echo -e "${YELLOW}${BOLD}  About to install CorePanel's mail, FTP and DNS on this server${CLEAR}"
  echo
  echo -e "  Target : ${BOLD}$(hostname -f 2>/dev/null || hostname)${CLEAR} (${PRETTY})"
  echo
  echo "  cPanel's Exim, Dovecot, pure-ftpd and PowerDNS are already gone from"
  echo "  this machine. This writes CorePanel's configuration for Postfix,"
  echo "  Dovecot, rspamd, pure-ftpd and PowerDNS and starts them, which is what"
  echo "  brings mail, FTP and DNS back."
  echo
  echo "  It overwrites the configuration files of those five services. It does"
  echo "  not touch the web server, PHP, the database, the firewall or SELinux."
  echo
  printf "  Type 'yes' to continue, anything else to abort: "

  local answer=""
  read -r answer < "${tty_in}" || answer=""
  echo

  if [[ "${answer}" != "yes" ]]; then
    error "Aborted: nothing was installed or modified."
    exit 1
  fi
}

# The other half of transform_mask_our_services, and the reason it is a step
# rather than a line: PREPARE masks postfix BEFORE its package lands, so nothing
# can start a second MTA on the port Exim is holding. A masked unit is not a
# unit systemd will enable or start — it answers "Unit file is masked" and exits
# non-zero — so install_mail_stack's `systemctl enable --now postfix` would fail
# and, under `set -e`, take the whole cutover with it.
#
# corehttpd is deliberately not here: it is unmasked by corepanel-transform's
# cutover.start, three steps earlier, because that is where the sites come back.
transform_cutover_unmask_ours() {
  local unit="postfix"
  if [[ "$(systemctl is-enabled "${unit}" 2>/dev/null || true)" == "masked" ]]; then
    log "Unmasking ${unit}, which PREPARE masked so it could not take Exim's port…"
    systemctl unmask "${unit}" >/dev/null 2>&1 || true
  fi
}

# dovecot and pure-ftpd are named in cPanel's dnf exclude, and removing cPanel's
# packages does not remove the line that excludes them. Installed here, once,
# with the exclude lifted for this transaction only and for these two packages
# only: applied to the whole run it would also lift the protection on
# filesystem, and a cutover is not the moment to let dnf update that.
#
# install_mail_stack and install_pureftpd below then find them present and only
# configure, which is why this runs first and why neither of those functions
# needed a cPanel-shaped branch of its own.
transform_cutover_lift_excludes() {
  if ! grep -qsE '^exclude=.*(dovecot|pure-ftpd)' /etc/dnf/dnf.conf /etc/yum.conf; then
    return 0
  fi
  log "cPanel excludes dovecot* and pure-ftpd* from dnf; overriding for this transaction only."
  dnf -y --disableexcludes=main install dovecot pure-ftpd \
    || { warn "DNF failed → cleaning metadata & retrying…"; dnf -y clean all; \
         dnf -y --disableexcludes=main install dovecot pure-ftpd; }
}

transform_cutover_summary() {
  local passive; passive="$(ftp_passive_range)"
  echo
  echo -e "${GREEN}${BOLD}  Mail, FTP and DNS are CorePanel's now${CLEAR}"
  echo
  echo "  Installed, configured and started:"
  echo "    Postfix (25, 465, 587) with Dovecot SASL and Maildir delivery"
  echo "    Dovecot (143, 993, 110, 995) and ManageSieve (4190)"
  echo "    rspamd + Redis, DKIM/ARC signing"
  echo "    pure-ftpd (21) authenticating against corepanel-auth"
  echo "    PowerDNS Authoritative + Recursor behind dnsdist on :53"
  echo
  echo "  Left exactly as they were:"
  echo "    the web server and PHP (already serving), the database, /etc/shadow,"
  echo "    the firewall and the SELinux mode"
  echo
  if [[ "$(getenforce 2>/dev/null || echo Disabled)" == "Disabled" ]]; then
    warn "  SELinux is disabled on this server, which is the usual state of a cPanel"
    warn "  box. CorePanel's policy modules are installed by its RPMs and are inert"
    warn "  until it is enabled. Turning it on is a reboot, so it is not done here."
    echo
  fi
  echo -e "  ${BOLD}Check by hand:${CLEAR} the FTP passive port range is ${passive}, which is"
  echo "  CorePanel's and not cPanel's. If a firewall in front of this server"
  echo "  only opens cPanel's old range, passive transfers will hang."
  echo
}

run_transform_cutover() {
  transform_cutover_preflight
  transform_cutover_confirm
  transform_cutover_unmask_ours
  transform_cutover_lift_excludes
  install_mail_stack
  configure_postfix_dovecot_sasl_maildir
  install_antispam_stack
  configure_rspamd_dkim
  install_pureftpd
  set_pureftp_extauth
  configure_postfix_virtual_mailbox
  configure_dovecot_ssl_acls
  install_powerdns_dnsdist_stack
  # After the recursor: the policy picks a resolver only if one answers, and on
  # this path the recursor is the last piece to come up.
  apply_mail_filtering_policy
  transform_cutover_summary
}

# run_full_install is the installation this script has always performed: it
# assumes it owns the machine. Kept whole and in order — transform-prepare is a
# separate sequence rather than a set of flags threaded through this one, so a
# normal install cannot change behaviour by accident.
run_full_install() {
  preflight_checks
  confirm_install
  update_system
  install_base_tools
  configure_disk_quota
  configure_corepanel_repo
  enable_epel
  enable_remi
  disable_mariadb_appstream
  configure_mariadb_repo
  install_mariadb
  tune_mariadb
  secure_mariadb
  create_system_user_corepanel
  install_mail_stack
  configure_postfix_dovecot_sasl_maildir
  install_antispam_stack
  configure_rspamd_dkim
  install_pureftpd
  install_php_stack
  install_powerdns_dnsdist_stack
  install_corepanel
  # After install_corepanel: it is corepanel-sys's postinstall that labels the
  # MariaDB binary, and the running daemon only enters mysqld_t on a restart.
  confine_mariadb
  recover_php_fpm
  set_pureftp_extauth
  configure_postfix_virtual_mailbox
  configure_dovecot_ssl_acls
  # The RPM's %post already ran this, since install_corepanel comes after the
  # mail stack and the recursor. Repeated here so the policy does not silently
  # depend on that ordering staying true.
  apply_mail_filtering_policy
  # Last of the stack steps, deliberately: it reads the passive port range back
  # from the pure-ftpd config written above, and opening the ports only once every
  # service behind them is up avoids a window where the firewall lets traffic
  # through to something half-configured.
  configure_firewalld
  # No SELinux policy is loaded from here any more: corepanel_cron_t and
  # corepanel_phpfpm come from the corepanel-sys RPM's postinstall, and
  # corepanel_app_t from the `corepanel` metapackage's. See the notes further up.
  # Only the mode is set here, and only once every one of those RPMs is in.
  configure_selinux_enforcing
  print_summary
  configure_corepanel
  # Last, and deliberately after the wizard: it grants the mail stack access to
  # the panel host certificate, and corehttpd only writes that certificate once
  # `corepanel system init` has set the panel hostname. Silent on success.
  configure_selinux_corehttpd
  # Everything above succeeded (the script runs under `set -e`), which means no
  # command failed — not that the server works. verify_services is what decides
  # that, and it owns the activation ping: a machine whose panel is dead must
  # not be reported, to the operator or to us, as a finished install.
  verify_services
}

main() {
  local arg
  for arg in "$@"; do
    case "$arg" in
      -f|--force) FORCE=1 ;;
      -y|--yes|--assume-yes) ASSUME_YES=1 ;;
      --transform-prepare) TRANSFORM_PREPARE=1 ;;
      --transform-cutover) TRANSFORM_CUTOVER=1 ;;
      -h|--help)  print_usage; exit 0 ;;
      *) error "Unknown argument: ${arg}"; print_usage; exit 1 ;;
    esac
  done

  print_banner
  require_root
  detect_distro

  if (( TRANSFORM_PREPARE && TRANSFORM_CUTOVER )); then
    error "--transform-prepare and --transform-cutover are the two halves of a"
    error "transformation and are separated by the operator's decision to commit."
    error "Pass one."
    exit 1
  fi

  if (( TRANSFORM_PREPARE )); then
    if (( FORCE )); then
      # --force is what makes the installer overwrite the panel that is there.
      # Together they read as "prepare carefully, and also steamroll it".
      error "--force and --transform-prepare are opposites; pass only one."
      exit 1
    fi
    run_transform_prepare
    return
  fi

  if (( TRANSFORM_CUTOVER )); then
    if (( FORCE )); then
      error "--force and --transform-cutover are opposites; pass only one."
      exit 1
    fi
    run_transform_cutover
    return
  fi
  run_full_install
}

main "$@"
