#!/bin/bash
################################################################################
# HiTechCloud Panel - native installer (no containers in the backend)
#
# Usage:
#   curl -sSL https://raw.githubusercontent.com/hitechcloud-vietnam/HiTechCloud-Panel/main/INSTALL.sh | bash
#
#   bash INSTALL.sh --dry-run     report what would change; change nothing
#   bash INSTALL.sh --adopt       update a server that already has customers
#   bash INSTALL.sh --reinstall   re-run every step against an existing server
#
# Everything the panel needs runs as a host service under systemd: the panel
# itself, the 80/443 front-end, the WebEngines, PHP-FPM, MariaDB, Redis, DNS,
# mail and FTP. Podman is installed only so that users can run applications
# from the App Store - no part of the control panel depends on it.
#
# This script was written for an empty machine and is now expected to run on
# servers that have customers on them, so every step that used to assume the
# machine was empty has been made idempotent or a no-op. The rules it works to:
#
#   * A file that already has the right content is not rewritten. A re-run is
#     quiet, and --dry-run can honestly list what it would touch.
#   * A file the operator owns is not overwritten by the one a release ships.
#   * Nothing that cannot be regenerated is changed before deploy/adopt-server.sh
#     has copied it. The backup path is printed before any of it happens.
#   * When a step cannot be done safely the script stops and says why. An
#     installer that refuses is fine; one that gets halfway is not.
#
# The previous container-based installer is kept at legacy/INSTALL-docker.sh.
################################################################################

set -euo pipefail

REPO="hitechcloud-vietnam/HiTechCloud-Panel"
BRANCH="${BRANCH:-main}"
# Overridable so a dry run can be pointed at a checkout other than the one the
# server installed from - the deploy pipeline ships this release to a staging
# directory and runs the installer against that.
INSTALL_DIR="${INSTALL_DIR:-/opt/hitechcloud}"
RUNTIME_DIR="${RUNTIME_DIR:-/usr/local/hitechcloud}"
PANEL_PORT="${PANEL_PORT:-2083}"
ADMIN_PORT="${ADMIN_PORT:-2087}"
DEFAULT_PHP="${DEFAULT_PHP:-8.3}"
ENGINES="${ENGINES:---all-engines}"
# Everything, unless the caller says otherwise.
#
# The default used to be a subset, so a new server came up without mail, the
# antivirus, fail2ban, the search engines or a certificate client, and an
# operator had to know which extra commands to run to get what the product
# advertises. A hosting panel that installs half a hosting stack is not a
# hosting panel; the switches exist to leave things out deliberately, not to
# make including them the operator's homework.
WITH_COMPONENTS="${WITH_COMPONENTS:-db,postgres,redis,memcached,valkey,varnish,node,opensearch,elasticsearch,mail,dns,ftp,fail2ban,clamav,goaccess,acme,firewall}"
BACKUP_ROOT="${HITECHCLOUD_BACKUP_ROOT:-/var/backups/hitechcloud}"

# ---- what the one-line installer asked for ---------------------------------
#
# The bootstrap at https://hitechcloud.org accepts flags and passes them here
# as environment, so the two do not have to agree on an argument order. Every
# one of these is optional; unset means the default the rest of this script
# already had.
# panel_fqdn is this machine's own fully-qualified name, when it has one worth
# using.
#
# A fresh install left force_domain empty and every certificate request had no
# hostname to ask for - so a server whose FQDN was already correct got no
# certificate and the panel answered on an IP address with a warning. The
# hostname is the operator's own statement of what this machine is called; when
# it is a real name, using it is what they meant.
#
# Refused: anything without a dot, localhost, and an address - a certificate
# cannot be issued for any of them, and guessing one produces a request that
# fails for a reason nothing explains.
panel_fqdn() {
    local name; name=$(hostname -f 2>/dev/null || hostname 2>/dev/null)
    case "$name" in
        ""|localhost|localhost.*|*.local|*.localdomain) return 1 ;;
        *[!0-9.]*) ;;                       # not a bare IPv4
        *) return 1 ;;
    esac
    case "$name" in *.*) ;; *) return 1 ;; esac
    printf '%s' "$name"
}

PANEL_DOMAIN="${HITECHCLOUD_PANEL_DOMAIN:-${HITECHCLOUD_DOMAIN:-$(panel_fqdn || true)}}"
ADMIN_DOMAIN="${HITECHCLOUD_DOMAIN:-}"
LICENSE_KEY="${HITECHCLOUD_LICENSE_KEY:-}"
ADMIN_USERNAME="${HITECHCLOUD_ADMIN_USERNAME:-}"
ADMIN_PASSWORD="${HITECHCLOUD_ADMIN_PASSWORD:-}"
SWAP_GB="${HITECHCLOUD_SWAP_GB:-}"
POST_INSTALL="${HITECHCLOUD_POST_INSTALL:-}"

# The components list is assembled from the switches rather than spelled out
# twice: "--skip-firewall" and "--skip-dns-server" have to actually remove
# something from what native-install is told to do, or they are decoration.
[ "${HITECHCLOUD_SKIP_DNS:-0}" = "1" ] &&
    WITH_COMPONENTS="$(printf '%s' "$WITH_COMPONENTS" | tr ',' '\n' | grep -vx dns | paste -sd, -)"
# The default list already contains firewall, so appending it here printed
# "Extras : ... firewall firewall" and installed it twice. --skip-firewall has
# to remove it, the same way --skip-dns-server removes dns; adding it when it
# is already there was never what made the flag work.
[ "${HITECHCLOUD_SKIP_FIREWALL:-0}" = "1" ] &&
    WITH_COMPONENTS="$(printf '%s' "$WITH_COMPONENTS" | tr ',' '\n' | grep -vx firewall | paste -sd, -)"
[ "${HITECHCLOUD_WITH_IMUNIFY:-0}" = "1" ] && WITH_COMPONENTS="${WITH_COMPONENTS},clamav"
WITH_MYSQL="${HITECHCLOUD_WITH_MYSQL:-false}"

# ---- how this run behaves --------------------------------------------------
# install    the original behaviour, for a machine with nothing on it. It now
#            refuses rather than running over an existing installation.
# adopt      an existing installation is expected: survey it, back it up, and
#            update it in place.
# reinstall  adopt, plus the steps that would otherwise be skipped as already
#            done - the package installation and the native stack.
# dry-run    report and change nothing.
MODE="${HITECHCLOUD_MODE:-install}"
DRY_RUN=false
FORCE=false
DO_BACKUP=true
BACKUP_PATH=""
CHANGED_FILES=""

usage() {
    cat <<EOF

Usage: bash INSTALL.sh [options]

  --dry-run       Report what would change on this machine and change nothing.
                  Combines with either mode below; on its own it reports what
                  an --adopt would do.
  --adopt         Update an existing installation in place: everything except
                  the package installation, which is already done. Refuses if
                  there is nothing installed.
  --reinstall     Re-run every step against an existing installation, the
                  package and Podman steps included.
  --force         Proceed past the safety refusals: a dirty checkout, a
                  version downgrade, a changed MySQL root password. Read what
                  it is refusing before you use this.
  --no-backup     Skip the pre-flight backup. On a server with customer data
                  this is not advisable and the run will say so.
  --with-mysql    Also install Oracle MySQL 8.4 on port 3307, beside MariaDB
                  on 3306. Both run; each account picks the one it wants. Off
                  by default - MariaDB is this panel's database server.
  -h, --help      This text.

Environment: BRANCH, PANEL_PORT, ADMIN_PORT, DEFAULT_PHP, ENGINES,
WITH_COMPONENTS, HITECHCLOUD_MIRROR, HITECHCLOUD_GITHUB_TOKEN,
HITECHCLOUD_BACKUP_ROOT.

Set by the one-line installer at https://hitechcloud.org, and usable
directly: HITECHCLOUD_DOMAIN, HITECHCLOUD_PANEL_DOMAIN,
HITECHCLOUD_LICENSE_KEY, HITECHCLOUD_ADMIN_USERNAME,
HITECHCLOUD_ADMIN_PASSWORD, HITECHCLOUD_SWAP_GB, HITECHCLOUD_POST_INSTALL,
HITECHCLOUD_SKIP_FIREWALL, HITECHCLOUD_SKIP_DNS, HITECHCLOUD_WITH_IMUNIFY,
HITECHCLOUD_WITH_MYSQL,
HITECHCLOUD_NO_WAF, HITECHCLOUD_SKIP_APT_UPDATE, HITECHCLOUD_SELFSIGNED,
HITECHCLOUD_FROM.

EOF
}

# --dry-run is a separate switch from the mode, not a fourth value of it.
# Sharing one variable meant `--dry-run --adopt` set the mode last and did a
# real installation - the exact opposite of what was typed, on the exact
# machine where being wrong costs the most.
[ "$MODE" = "dry-run" ] && { DRY_RUN=true; MODE=install; }
while [[ $# -gt 0 ]]; do
    case "$1" in
        --dry-run)   DRY_RUN=true; shift ;;
        --adopt)     MODE=adopt; shift ;;
        --reinstall) MODE=reinstall; shift ;;
        --force)     FORCE=true; shift ;;
        --no-backup) DO_BACKUP=false; shift ;;
        # Oracle MySQL 8.4 beside MariaDB, on 3307. Off by default: MariaDB is
        # this panel's database server and a second one is a second thing to
        # keep running and patched, wanted only by accounts whose applications
        # need Oracle MySQL specifically.
        --with-mysql) WITH_MYSQL=true; shift ;;
        -h|--help)   usage; exit 0 ;;
        *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
    esac
done

# Appended after the flags are parsed, so --with-mysql works whether it arrives
# on the command line or as HITECHCLOUD_WITH_MYSQL=1 in the environment.
case "$WITH_MYSQL" in
    true|1|yes) WITH_COMPONENTS="${WITH_COMPONENTS},mysql" ;;
esac

RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; GRAY='\033[0;90m'; NC='\033[0m'
log()  { echo -e "${GREEN}[✓]${NC} $*"; }
# Where a run records what it has finished, so a second one can say what the
# first got through. An interrupted install used to start again from the top
# with no acknowledgement that it had ever run - the operator could not tell a
# resumed install from a first attempt, and had no idea which step had been the
# one that stopped.
STATE_FILE="${HITECHCLOUD_STATE_FILE:-/var/lib/hitechcloud/install-state}"
STEP_STARTED=0
CURRENT_STEP=""

# step - announce a phase, timestamp it, and close off the previous one.
#
# The timestamp and the elapsed time are not decoration. Several of these take
# minutes; without them the screen sits unchanged and there is no way to tell a
# slow step from a hung one, which is what an operator reported on the first
# AlmaLinux install.
step() {
    step_finish
    CURRENT_STEP="$*"
    STEP_STARTED=$(date +%s)
    echo -e "\n${BLUE}==>${NC} ${*} ${GRAY}[$(date '+%H:%M:%S')]${NC}"
}

# step_finish records the step that just ended, with how long it took.
step_finish() {
    [ -n "$CURRENT_STEP" ] || return 0
    local elapsed=$(( $(date +%s) - STEP_STARTED ))
    if ! $DRY_RUN; then
        mkdir -p "$(dirname "$STATE_FILE")" 2>/dev/null
        printf '%s\t%s\t%ss\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$CURRENT_STEP" "$elapsed" >> "$STATE_FILE" 2>/dev/null
    fi
    # Only the slow ones get a line of their own. Reporting "took 0s" after
    # every heading is noise that hides the two that took four minutes.
    (( elapsed >= 20 )) && echo -e "${GRAY}    ${CURRENT_STEP} took ${elapsed}s${NC}"
    CURRENT_STEP=""
    return 0
}

# report_previous_run says what a previous attempt got through, before this one
# starts repeating it. Every step is safe to re-run, so nothing is skipped on
# the strength of this - it is there so the operator knows where the last run
# stopped, which is the first thing anybody wants after an install is cut off.
report_previous_run() {
    [ -s "$STATE_FILE" ] || return 0
    local last count
    count=$(wc -l < "$STATE_FILE" 2>/dev/null | tr -d ' ')
    last=$(tail -n1 "$STATE_FILE" 2>/dev/null | cut -f2)
    echo
    echo -e "${BLUE}==>${NC} A previous run of this installer was found"
    echo "    ${count} step(s) completed, the last being: ${last}"
    echo "    Every step is safe to repeat, so this run redoes them; anything"
    echo "    already installed is detected and left alone."
    echo "    Full history: ${STATE_FILE}"
}
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
err()  { echo -e "${RED}[✗]${NC} $*" >&2; exit 1; }

# run - the dry-run gate for anything that changes the machine. It returns 0
# either way, so the success branches around a call still run; did() is what
# keeps their wording honest.
run() {
    if $DRY_RUN; then echo "    would run: $*"; return 0; fi
    "$@"
}
did() {
    if $DRY_RUN; then echo -e "${YELLOW}[~]${NC} would: $*"; else log "$*"; fi
}

# keep_copy FILE - a copy of a file before it is replaced. It goes into the
# pre-flight backup when there is one, so everything this run overwrote is in
# one directory; without one it lands beside the original, which is better
# than nowhere.
keep_copy() {
    local f="$1" dest
    [ -f "$f" ] || return 0
    if [ -n "$BACKUP_PATH" ] && [ -d "$BACKUP_PATH" ]; then
        dest="${BACKUP_PATH}/replaced${f}"
        mkdir -p "$(dirname "$dest")"
        cp -a "$f" "$dest"
    else
        cp -a "$f" "${f}.hitechcloud-$(date +%Y%m%d-%H%M%S).bak"
    fi
}

# write_file DEST [MODE]  (content on stdin)
# The one way this script writes a configuration file. A file that already has
# exactly this content is left alone - which is what makes a re-run quiet, a
# dry run truthful, and a diff of "what did the installer change" short enough
# to read. Anything it does replace is copied away first.
write_file() {
    local dest="$1" mode="${2:-0644}" tmp
    tmp=$(mktemp)
    cat > "$tmp"
    if [ -f "$dest" ] && cmp -s "$tmp" "$dest"; then
        rm -f "$tmp"
        return 0
    fi
    CHANGED_FILES="${CHANGED_FILES}${dest}"$'\n'
    if $DRY_RUN; then
        if [ -f "$dest" ]; then
            warn "would rewrite ${dest}"
            diff -u "$dest" "$tmp" 2>/dev/null | tail -n +3 | head -n 12 | sed 's/^/      /' || true
        else
            warn "would create ${dest}"
        fi
        rm -f "$tmp"
        return 0
    fi
    [ -f "$dest" ] && keep_copy "$dest"
    mkdir -p "$(dirname "$dest")"
    install -m "$mode" "$tmp" "$dest"
    rm -f "$tmp"
    log "wrote ${dest}"
}

# merge_env DEST KEY=VALUE...
# panel.env and admin.env used to be rewritten wholesale on every run, which
# silently discarded anything an operator had added to them. Only the keys
# this installer owns are set; every other line is kept exactly as it was.
merge_env() {
    local dest="$1"; shift
    local tmp kv key
    tmp=$(mktemp)
    [ -f "$dest" ] && cat "$dest" > "$tmp"
    for kv in "$@"; do
        key="${kv%%=*}"
        if grep -qE "^${key}=" "$tmp" 2>/dev/null; then
            sed -i -E "s|^${key}=.*|${kv}|" "$tmp"
        else
            echo "$kv" >> "$tmp"
        fi
    done
    write_file "$dest" 0640 < "$tmp"
    rm -f "$tmp"
}

# install_stock SRC DEST [SKIP...]
# The files a release ships, copied into a configuration directory without
# overwriting the ones the operator owns. SKIP names a path relative to SRC
# that this installer must never write over.
install_stock() {
    local src="$1" dest="$2"; shift 2
    local skip=" $* " rel f
    [ -d "$src" ] || return 0
    $DRY_RUN || mkdir -p "$dest"
    while IFS= read -r f; do
        rel="${f#"$src"/}"
        case "$skip" in
            *" ${rel} "*) [ -f "${dest}/${rel}" ] && { log "keeping the existing ${dest}/${rel}"; continue; } ;;
        esac
        write_file "${dest}/${rel}" 0644 < "$f"
    done < <(find "$src" -type f 2>/dev/null)
}

# merge_ini DEST SRC
# Add to DEST every key SRC has that DEST does not, in the section SRC keeps it
# in. A value already in DEST is the operator's and is never rewritten, and a
# section DEST has never had is appended whole. Comments and ordering in DEST
# survive, because this only ever inserts lines.
merge_ini() {
    local dest="$1" src="$2" tmp added
    [ -f "$src" ] || return 0
    [ -f "$dest" ] || return 0
    tmp=$(mktemp)
    awk -v src="$src" '
      function flush(sec,   i, n, list, k) {
        if (sec == "" || !(sec in keys)) return
        n = split(keys[sec], list, "\n")
        for (i = 1; i <= n; i++) {
          k = list[i]
          # Keyed by name alone, not by section: the panel parses this file
          # flat, so the same key in two sections is one key with two values
          # and whichever is read last wins.
          if (k == "" || (k in have)) continue
          print k "=" srcval[sec, k]
          added++
        }
      }
      BEGIN {
        sec = ""
        while ((getline line < src) > 0) {
          sub(/\r$/, "", line)
          t = line; sub(/^[ \t]+/, "", t); sub(/[ \t]+$/, "", t)
          if (t ~ /^\[.*\]$/) {
            sec = substr(t, 2, length(t) - 2)
            if (!(sec in seen)) { seen[sec] = 1; order[++nsec] = sec }
            continue
          }
          if (t == "" || t ~ /^#/ || sec == "") continue
          p = index(t, "=")
          if (p == 0) continue
          k = substr(t, 1, p - 1); gsub(/[ \t]/, "", k)
          if ((sec SUBSEP k) in srcval) continue
          srcval[sec, k] = substr(t, p + 1)
          keys[sec] = keys[sec] (keys[sec] == "" ? "" : "\n") k
        }
        close(src)
      }
      # First pass: what DEST already has.
      FNR == NR {
        t = $0; sub(/^[ \t]+/, "", t); sub(/[ \t]+$/, "", t)
        if (t ~ /^\[.*\]$/) { cur = substr(t, 2, length(t) - 2); destsec[cur] = 1; next }
        if (t == "" || t ~ /^#/ || cur == "") next
        p = index(t, "=")
        if (p == 0) next
        k = substr(t, 1, p - 1); gsub(/[ \t]/, "", k)
        have[k] = 1
        next
      }
      # Second pass: print DEST, completing each section as it ends.
      {
        t = $0; sub(/^[ \t]+/, "", t); sub(/[ \t]+$/, "", t)
        if (t ~ /^\[.*\]$/) { flush(out); out = substr(t, 2, length(t) - 2) }
        print
      }
      END {
        flush(out)
        for (i = 1; i <= nsec; i++) {
          if (order[i] in destsec) continue
          print ""
          print "[" order[i] "]"
          flush(order[i])
        }
      }
    ' "$dest" "$dest" > "$tmp" 2>/dev/null
    if [ -s "$tmp" ] && ! cmp -s "$tmp" "$dest"; then
        added=$(( $(grep -cE '^[A-Za-z_][A-Za-z0-9_]*=' "$tmp") - $(grep -cE '^[A-Za-z_][A-Za-z0-9_]*=' "$dest") ))
        write_file "$dest" 0644 < "$tmp"
        did "added ${added} missing setting(s) to $(basename "$dest")"
    fi
    rm -f "$tmp"
}

banner() {
    # The version is read, not written into the art. It said 2.1.0 on an
    # install that had just printed "installing 2.1.2" three lines above, which
    # is the kind of detail that makes somebody doubt the whole run. $VERSION is
    # not known this early - it comes from the checkout - so the banner asks for
    # whatever is on disk and says nothing when there is nothing to say.
    # The version being installed, not the one already on disk. The one-line
    # installer knows it - it just printed "installing 2.1.3" - and passes it
    # in; the files on disk are still the previous release at this point, so
    # reading them announced the version being replaced. Falling back to them
    # is still right for a run started from a checkout, where there is no
    # bootstrap and the checkout is what is being installed.
    local shown=""
    if [ -n "${HITECHCLOUD_VERSION:-}" ]; then
        shown=" ${HITECHCLOUD_VERSION}"
    else
        for f in "${INSTALL_DIR:-/opt/hitechcloud}/hitechcloudpanel/version" /usr/local/hitechcloud/version; do
            [ -s "$f" ] && { shown=" $(head -n1 "$f" | tr -d '[:space:]')"; break; }
        done
    fi
    # The box is 58 columns wide between the corners. Padded by measurement
    # rather than by a hand-counted literal, so a longer version string cannot
    # push the right-hand border out of line.
    local line="   HiTechCloud Panel - native installation${shown}"
    echo -e "${BLUE}"
    cat <<BANNER
╔══════════════════════════════════════════════════════════╗
║${line}$(printf '%*s' $((58 - ${#line})) '')║
║   Multi-WebServer Hosting · no containers in the backend ║
║   https://hitechcloud.org                                ║
╚══════════════════════════════════════════════════════════╝
BANNER
    echo -e "${NC}"
}

[ "$EUID" -ne 0 ] && err "Run as root: sudo bash INSTALL.sh"
banner
$DRY_RUN && warn "DRY RUN - nothing on this machine will be changed."

# ---- 0. Supported OS -------------------------------------------------------
. /etc/os-release 2>/dev/null || err "Cannot read /etc/os-release."
case "${ID:-}${ID_LIKE:-}" in
    *debian*|*ubuntu*) OS_FAMILY=debian ;;
    *rhel*|*fedora*|*centos*|*ol*|*oracle*) OS_FAMILY=rhel ;;
    *) err "Unsupported OS '${PRETTY_NAME:-unknown}'. Ubuntu 22.04 and 24.04, Debian 11 and 12, and AlmaLinux, Rocky, RHEL or Oracle Linux 8 and above are supported." ;;
esac
log "Detected ${PRETTY_NAME:-$ID} (${OS_FAMILY})"

# ---- 0b. Is there already something here? ----------------------------------
# This has to be answered before anything is installed, so it is answered from
# the two things a HiTechCloud installation always leaves behind. The full
# survey - accounts, sites, databases - needs deploy/adopt-server.sh, which
# only exists once the repository has been fetched; that comes later.
EXISTING=no
if [ -x /usr/local/bin/hitechcloud ] || [ -d /etc/hitechcloud/hitechcloud ]; then
    EXISTING=yes
fi

# A dry run over a machine that already has an installation is asking what an
# update would do; there is no other useful reading of it, and making the
# operator remember --adopt as well would only earn them the refusal below.
if $DRY_RUN && [ "$MODE" = install ] && [ "$EXISTING" = yes ]; then
    MODE=adopt
fi

case "${MODE}:${EXISTING}" in
    install:yes)
        # Running the empty-machine path over a working server is how a
        # customer's configuration gets replaced by the stock one. It is a
        # refusal rather than a warning because there is no way to undo it
        # from inside the run that caused it.
        err "HiTechCloud is already installed on this machine.

  Running the installer as if the machine were empty would rewrite the
  configuration, the credentials and the systemd units this server is using.

  What to run instead:
    bash INSTALL.sh --dry-run     see exactly what would change
    bash INSTALL.sh --adopt       update this installation in place
    bash INSTALL.sh --reinstall   re-run every step, keeping the data

  Each of those takes a backup of the panel database, /etc/hitechcloud and
  the account list first, and prints where it put it."
        ;;
    adopt:no)
        err "There is no HiTechCloud installation on this machine to adopt.
  Run 'bash INSTALL.sh' without --adopt to install one."
        ;;
esac
[ "$EXISTING" = yes ] && log "Existing installation detected - running in ${MODE} mode"

# The ports the native architecture claims. Finding one busy now is far
# cheaper than finding out after MariaDB has been installed.
#
# A port held by something HiTechCloud itself runs is not a conflict; it is the
# normal state of every server this has already been installed on. Before this,
# a second run on a working machine stopped at "Free these ports first: 2083
# 2087" - naming its own panel as the intruder, on the one kind of machine
# where stopping was least helpful.
report_previous_run

# ---- 0. The machine's own names ---------------------------------------------
#
# Two things go wrong here often enough to be worth checking on every install,
# because both fail far away from their cause.
#
# A VM image that omits "127.0.0.1 localhost" - this one did, defining only the
# IPv6 ::1 - breaks anything that resolves localhost over IPv4, which includes
# the panel's own database connection.
#
# A hostname mapped to 127.0.0.1 makes `hostname -f` resolve to loopback, so
# Postfix introduces the machine as localhost, `getent hosts` disagrees with
# public DNS, and the certificate step is asked to prove control of a name that
# points at the machine's own loopback interface.
step "Checking this machine's own names"
if $DRY_RUN; then
    warn "would check /etc/hosts for localhost and this machine's hostname"
else
    if ! grep -qE '^[[:space:]]*127\.0\.0\.1[[:space:]]+.*\blocalhost\b' /etc/hosts 2>/dev/null; then
        printf '127.0.0.1 localhost localhost.localdomain\n' >> /etc/hosts
        log "added the missing 127.0.0.1 localhost entry to /etc/hosts"
    fi

    HOST_FQDN=$(hostname -f 2>/dev/null || hostname 2>/dev/null)
    case "$HOST_FQDN" in
        ""|localhost|localhost.*) ;;
        *.*)
            # Only a hostname pinned to loopback is rewritten. A mapping to the
            # machine's real address is correct and is left alone; so is one an
            # operator has pointed somewhere deliberately.
            if grep -qE "^[[:space:]]*127\.0\.0\.1[[:space:]]+.*\b${HOST_FQDN}\b" /etc/hosts 2>/dev/null; then
                HOST_IP=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}')
                if [ -n "$HOST_IP" ]; then
                    run sed -i "\|^[[:space:]]*127\.0\.0\.1[[:space:]].*\b${HOST_FQDN}\b|d" /etc/hosts
                    printf '%s %s %s\n' "$HOST_IP" "$HOST_FQDN" "${HOST_FQDN%%.*}" >> /etc/hosts
                    log "${HOST_FQDN} was mapped to 127.0.0.1; it now resolves to ${HOST_IP}"
                else
                    warn "${HOST_FQDN} is mapped to 127.0.0.1 in /etc/hosts and this machine's address could not be determined."
                    warn "Mail and certificates will treat this server as localhost until that line is corrected."
                fi
            fi ;;
    esac
    log "Hostname: ${HOST_FQDN:-not set}"
fi

# ---- 0b. Swap ---------------------------------------------------------------
#
# --swap took a number, validated it, exported it, and nothing ever read it.
# The flag was documented, accepted and inert - a 2GB machine that asked for
# swap got none, and the first sign was the OOM killer.
#
# Never touched when swap already exists: adding a second swap file to a
# machine that has one is not what anybody meant by --swap 2.
# Sized automatically when --swap was not given.
#
# A machine with no swap and 2GB of RAM gets through the install and then meets
# the OOM killer the first time a customer runs a WordPress import next to a
# search engine. The panel installs MariaDB, PostgreSQL, Elasticsearch and
# twelve PHP-FPM masters; that is not a workload for a machine with no swap at
# all. Skipped entirely when the machine already has some.
if [ -z "${HITECHCLOUD_SWAP_GB:-}" ] && ! $DRY_RUN; then
    if [ "$(swapon --show --noheadings 2>/dev/null | wc -l)" -eq 0 ]; then
        mem_mb=$(awk '/^MemTotal:/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0)
        # Half of RAM, between 2GB and 4GB. Enough to absorb a spike without
        # spending a tenth of a small VPS's disk on it.
        if [ "$mem_mb" -gt 0 ]; then
            HITECHCLOUD_SWAP_GB=$(( mem_mb / 2048 ))
            [ "$HITECHCLOUD_SWAP_GB" -lt 2 ] && HITECHCLOUD_SWAP_GB=2
            [ "$HITECHCLOUD_SWAP_GB" -gt 4 ] && HITECHCLOUD_SWAP_GB=4
        fi
    fi
fi

if [ -n "${HITECHCLOUD_SWAP_GB:-}" ]; then
    step "Swap"
    if $DRY_RUN; then
        warn "would create a ${HITECHCLOUD_SWAP_GB}GB swap file at /swapfile"
    elif [ "$(swapon --show --noheadings 2>/dev/null | wc -l)" -gt 0 ]; then
        log "This machine already has swap; leaving it alone"
    elif [ -e /swapfile ] && [ -s /swapfile ]; then
        log "/swapfile already exists"
    else
        # fallocate first, dd as the fallback: fallocate is instant but fails
        # on filesystems that do not support it, and a swap file that is a hole
        # rather than allocated blocks is one the kernel refuses.
        rm -f /swapfile
        if fallocate -l "${HITECHCLOUD_SWAP_GB}G" /swapfile 2>/dev/null ||
           dd if=/dev/zero of=/swapfile bs=1M count=$(( HITECHCLOUD_SWAP_GB * 1024 )) status=none 2>/dev/null; then
            chmod 600 /swapfile
            if mkswap /swapfile >/dev/null 2>&1 && swapon /swapfile 2>/dev/null; then
                grep -q '^/swapfile' /etc/fstab 2>/dev/null ||
                    printf '/swapfile none swap sw 0 0\n' >> /etc/fstab
                log "${HITECHCLOUD_SWAP_GB}GB of swap active at /swapfile"
            else
                # A zero-length or unformatted /swapfile left behind is what
                # produces a permanently failed swapfile.swap unit.
                rm -f /swapfile
                warn "The swap file could not be enabled; it has been removed rather than left broken."
            fi
        else
            rm -f /swapfile
            warn "Could not allocate ${HITECHCLOUD_SWAP_GB}GB for a swap file - is there space on /?"
        fi
    fi
fi

step "Checking required ports"
BUSY=""
for port in 80 443 8188 8189 8190 8288 8289 8290 "$PANEL_PORT" "$ADMIN_PORT"; do
    if ss -Hltn "sport = :${port}" 2>/dev/null | grep -q .; then
        holder=$(ss -Hltnp "sport = :${port}" 2>/dev/null | grep -oE 'users:\(\("[^"]+"' | head -n1 | tr -d '"' | sed 's/users:((//')
        case "$holder" in
            hitechcloud|hitechadmin)
                log "port ${port} held by HiTechCloud's own ${holder} (it will be restarted)" ;;
            nginx|httpd|apache2|litespeed|openlitespeed|caddy)
                log "port ${port} held by ${holder} (will be taken over)" ;;
            *) warn "port ${port} is in use by '${holder:-unknown}'"; BUSY="${BUSY} ${port}" ;;
        esac
    fi
done
[ -n "$BUSY" ] && err "Free these ports first:${BUSY}"
log "All required ports available"

# ---- 1. Base packages ------------------------------------------------------
# apt-get install of a package that is already present is a no-op, so this is
# safe to repeat; a dry run still reports it, because on a machine that is
# missing them it is a real change.
#
# This and the Podman step below are the difference between --adopt and
# --reinstall. Both are the slow part of a run and neither does anything on a
# server that has been installed once, so an adopt - which is an update in
# place, not an installation - skips them. Everything else the two modes do is
# identical, because everything else is now safe to repeat.
# The packages every later step assumes, by role rather than by distribution:
#
#   fetching      git curl wget ca-certificates gnupg
#   parsing       jq
#   crypto        openssl
#   scheduling    cron/cronie
#   copying       rsync tar unzip zip
#   networking    iproute net-tools
#   accounts      quota acl
#   logs          logrotate
#   building      the compiler and headers several PECL extensions need
#
# The last group is new. Without it a PHP extension that has to be built, or a
# Node module with a native addon, failed halfway through an install that had
# already reported the packages installed - and the error came out of a
# compiler nobody expected to be involved.
#   day to day     the tools an administrator opens a shell to use, on a box
#                  that is now theirs to run
#
# The last group is not needed by the panel. It is there because a server
# handed over without htop, an editor or a way to watch a log is a server whose
# owner's first act is to install them - and on a hosting box that is a
# reasonable thing to expect to already be present.
base_packages_debian="git curl wget jq openssl cron rsync ca-certificates gnupg \
    lsb-release iproute2 net-tools quota acl unzip zip tar logrotate \
    bzip2 xz-utils file lsof psmisc procps dnsutils \
    build-essential pkg-config libssl-dev \
    htop nano vim-tiny less tmux screen ncdu tree bc rsyslog"
base_packages_rhel="git curl wget jq openssl cronie rsync ca-certificates gnupg2 \
    iproute net-tools quota acl unzip zip tar logrotate \
    bzip2 xz file lsof psmisc procps-ng bind-utils \
    gcc gcc-c++ make pkgconf-pkg-config openssl-devel \
    htop nano vim-minimal less tmux screen ncdu tree bc rsyslog"

step "Updating the system"
# Asked for, and worth the minutes: a machine whose package index is months old
# resolves dependencies against versions its mirrors no longer carry, and the
# failure surfaces later as a package that "cannot be found" while it is
# plainly in the repository. Everything here prints as it runs - an installer
# that goes silent for four minutes is one an operator cannot tell from a hung
# one, which is exactly what happened on the first AlmaLinux run.
if [ "$MODE" = adopt ]; then
    log "Adopting an existing install - the system update is skipped"
elif $DRY_RUN; then
    warn "would refresh the package index and apply pending updates"
elif [ "${HITECHCLOUD_SKIP_APT_UPDATE:-0}" = "1" ]; then
    log "System update skipped (--skip-apt-update)"
elif [ "$OS_FAMILY" = debian ]; then
    export DEBIAN_FRONTEND=noninteractive
    apt-get update || warn "The package index could not be refreshed; installing with what is cached."
    # Held-back packages are left held back: an installer is not the place to
    # decide that somebody's pinned kernel should move.
    apt-get -y upgrade || warn "Some updates could not be applied; continuing."
    log "System updated"
elif command -v dnf >/dev/null 2>&1; then
    dnf -y makecache || warn "The package index could not be refreshed; installing with what is cached."
    dnf -y upgrade || warn "Some updates could not be applied; continuing."
    log "System updated"
else
    yum -y makecache || warn "The package index could not be refreshed; installing with what is cached."
    yum -y update || warn "Some updates could not be applied; continuing."
    log "System updated"
fi

step "Installing base packages"
if [ "$MODE" = adopt ] && command -v git >/dev/null 2>&1 && command -v rsync >/dev/null 2>&1; then
    log "Base packages already present - skipped (--reinstall installs them again)"
elif $DRY_RUN; then
    warn "would install the base packages (git curl wget jq openssl cron rsync ...)"
elif [ "$OS_FAMILY" = debian ]; then
    export DEBIAN_FRONTEND=noninteractive
    # One at a time on failure, not because it is faster - it is slower - but
    # because apt fails the whole transaction when any single name is missing,
    # and one package a given release renamed used to leave the machine with
    # none of the other twenty-five. The retry names what is actually missing.
    if ! apt-get install -y $base_packages_debian; then
        warn "Installing the base packages together failed; retrying one at a time."
        missing=""
        for pkg in $base_packages_debian; do
            apt-get install -y "$pkg" >/dev/null 2>&1 || missing="${missing} ${pkg}"
        done
        [ -n "$missing" ] && warn "Not available on this release:${missing}"
    fi
else
    PKG=yum; command -v dnf >/dev/null 2>&1 && PKG=dnf
    # EPEL first. AlmaLinux and Rocky carry a deliberately small set in their
    # own repositories - htop, ncdu and Redis are all in EPEL rather than
    # AppStream - so without it a third of the list below is simply not
    # findable, and the failures read as though the packages do not exist.
    # CRB as well: several EPEL packages need it and fail to resolve without.
    if ! $PKG repolist enabled 2>/dev/null | grep -qi '^epel'; then
        $PKG install -y epel-release ||
            warn "EPEL could not be added; some packages will not be available."
        $PKG config-manager --set-enabled crb >/dev/null 2>&1 ||
            $PKG config-manager --set-enabled powertools >/dev/null 2>&1 || true
    fi
    if ! $PKG install -y $base_packages_rhel; then
        warn "Installing the base packages together failed; retrying one at a time."
        missing=""
        for pkg in $base_packages_rhel; do
            $PKG install -y "$pkg" >/dev/null 2>&1 || missing="${missing} ${pkg}"
        done
        [ -n "$missing" ] && warn "Not available on this release:${missing}"
    fi
fi
[ "$MODE" = adopt ] || log "Base packages installed"

# ---- 2. Repository ---------------------------------------------------------
# Once the repositories are private, every fetch below needs a credential.
# HITECHCLOUD_GITHUB_TOKEN is read from the environment when set - a token with
# read access to the organisation's repositories - and configured for
# github.com as a whole so the submodules and the release download authenticate
# too. It is deliberately optional: a public install needs nothing.
if [ -n "${HITECHCLOUD_GITHUB_TOKEN:-}" ]; then
    git config --global url."https://x-access-token:${HITECHCLOUD_GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"
fi

step "Fetching HiTechCloud Panel"

# git must never wait for a password.
#
# The repositories are private. On a terminal, git asks for credentials - so
# the installer stopped at "Fetching HiTechCloud Panel" and printed nothing
# further, for as long as anyone left it. There is no prompt to answer in an
# unattended install and no useful one in an attended install either; failing
# immediately, with the reason, is the only honest behaviour.
export GIT_TERMINAL_PROMPT=0
export GIT_ASKPASS=/bin/echo
export GIT_CONFIG_PARAMETERS="'credential.helper='"

# A token, when there is one.
if [ -n "${HITECHCLOUD_GITHUB_TOKEN:-}" ]; then
    git config --global "url.https://x-access-token:${HITECHCLOUD_GITHUB_TOKEN}@github.com/.insteadOf" "https://github.com/" 2>/dev/null || true
fi

# The mirror is tried first and git is the fallback, not the other way round.
# A customer's server has no credential for a private repository, and the
# mirror exists precisely so it does not need one.
#
# What it fetches is the runtime bundle - configuration templates, CLI scripts,
# the vendored webmail - and not source. The binaries are compiled and shipped
# separately; publishing the source beside them would undo the reason they are
# compiled.
fetch_runtime_from_mirror() {
    local version="${HITECHCLOUD_VERSION:-latest}"
    local base="${HITECHCLOUD_MIRROR:-https://mirrors.hitechcloud.org}/releases/${version}"
    local archive="${TMPDIR:-/tmp}/hitechcloud-runtime.tar.gz"

    curl -4fsSL --retry 3 --connect-timeout 20 "${base}/runtime.tar.gz" -o "$archive" 2>/dev/null || return 1
    [ -s "$archive" ] || return 1

    mkdir -p "$INSTALL_DIR"
    # --strip-components=1: the archive has one top-level directory named for
    # the release, and INSTALL_DIR is where its contents belong.
    tar -xzf "$archive" -C "$INSTALL_DIR" --strip-components=1 || { rm -f "$archive"; return 1; }
    rm -f "$archive"
    return 0
}

NEEDS_RESET=false
if [ -d "$INSTALL_DIR/.git" ]; then
    # `git reset --hard` below is how uncommitted work in this checkout
    # disappears. On a server someone cares about that work is usually a
    # deliberate local patch, so the run stops and names the files instead of
    # discarding them.
    #
    # Tracked files only. `reset --hard` leaves untracked files alone, and the
    # deploy pipeline unpacks each release into ${INSTALL_DIR}/incoming - so
    # counting untracked files as "dirty" would make this refuse on every
    # server the pipeline has ever deployed to, over a directory it put there
    # itself and that nothing is about to delete.
    DIRTY=$(git -C "$INSTALL_DIR" status --porcelain --untracked-files=no 2>/dev/null || true)
    if [ -n "$DIRTY" ] && ! $FORCE; then
        err "The checkout at ${INSTALL_DIR} has uncommitted changes:

$(printf '%s\n' "$DIRTY" | head -20 | sed 's/^/    /')

  Updating would discard them. Commit or copy them out first, or re-run with
  --force if they are genuinely disposable."
    fi
    [ -n "$DIRTY" ] && warn "--force given: ${INSTALL_DIR} has local changes and they will be discarded"
    # A fetch only adds objects to .git; it changes nothing in the working
    # tree, so a dry run does it for real. That is what lets the report below
    # say which repository files an update would bring.
    if $DRY_RUN; then
        git -C "$INSTALL_DIR" fetch --quiet origin 2>/dev/null \
            && log "Fetched origin/${BRANCH} (a fetch only adds git objects)" \
            || warn "Could not fetch origin - this report is against the checkout as it stands"
    else
        git -C "$INSTALL_DIR" fetch --quiet origin \
            || err "Could not fetch ${REPO}. The repositories are private: set
  HITECHCLOUD_GITHUB_TOKEN to a token that can read them, or install from
  ${HITECHCLOUD_MIRROR:-https://mirrors.hitechcloud.org}."
        log "Repository fetched"
    fi
    NEEDS_RESET=true
elif $DRY_RUN; then
    warn "would fetch the panel source into ${INSTALL_DIR}"
elif fetch_runtime_from_mirror; then
    log "Runtime assets for ${HITECHCLOUD_VERSION:-latest} unpacked to ${INSTALL_DIR} from the mirror"
else
    git clone --quiet --branch "$BRANCH" "https://github.com/$REPO.git" "$INSTALL_DIR" \
        || err "Could not fetch the panel source.

  The mirror at ${HITECHCLOUD_MIRROR:-https://mirrors.hitechcloud.org} has no
  runtime bundle for ${HITECHCLOUD_VERSION:-this release}, and ${REPO} is a
  private repository this machine has no credential for.

  Either install a published release - re-run without --version - or set
  HITECHCLOUD_GITHUB_TOKEN to a token that can read the repository."
    log "Repository cloned to ${INSTALL_DIR}"
fi

# ---- 2b. The pre-flight backup ---------------------------------------------
# The last moment before this script writes anything that matters. The fetch
# above only added objects to a git directory; the reset below is the first
# destructive act, so the backup happens between the two.
#
# deploy/adopt-server.sh is what takes it. On a server installed before that
# script existed it is not in the working tree yet, so it is read out of the
# revision that was just fetched.
ADOPT_TOOL="$INSTALL_DIR/deploy/adopt-server.sh"
if [ ! -f "$ADOPT_TOOL" ] && [ -d "$INSTALL_DIR/.git" ]; then
    ADOPT_TOOL="$(mktemp)"
    git -C "$INSTALL_DIR" show "origin/${BRANCH}:deploy/adopt-server.sh" > "$ADOPT_TOOL" 2>/dev/null \
        || rm -f "$ADOPT_TOOL"
fi


# nothing_to_lose reports an installation that never finished: /etc/hitechcloud
# exists, so this counts as an existing install, but the panel database was
# never created and no hosting account was ever made.
#
# It matters because the pre-flight backup below refuses to proceed when it
# cannot dump the panel database - which is right on a server with customers,
# and strands the operator on a machine that was interrupted halfway through
# its first install. That machine cannot be installed (an installation is
# already there) and cannot be repaired (the backup that would let a reinstall
# proceed cannot be taken), and the only way out was --no-backup, which is the
# flag nobody should reach for by habit.
#
# A hosting account is a home directory with database credentials in it; a
# website cannot exist without one. If neither is there, no backup could
# protect anything.
nothing_to_lose() {
    mysql -N -B -e "USE panel" >/dev/null 2>&1 && return 1
    for cnf in /home/*/my.cnf; do
        [ -e "$cnf" ] && return 1
    done
    return 0
}

if [ "$EXISTING" = yes ]; then
    step "Surveying this installation"
    if [ -f "$ADOPT_TOOL" ]; then
        bash "$ADOPT_TOOL" --survey || warn "The survey did not complete"
    else
        warn "deploy/adopt-server.sh is not available; this run cannot survey the machine"
    fi

    if ! $DO_BACKUP; then
        warn "--no-backup: nothing is being copied before this run changes it."
    elif $DRY_RUN; then
        warn "would take a pre-flight backup into ${BACKUP_ROOT}"
    else
        [ -f "$ADOPT_TOOL" ] || err "A pre-flight backup is required on a machine that already has
  an installation, and deploy/adopt-server.sh could not be found or fetched.
  Nothing has been changed. Re-run with --no-backup only if you have a copy of
  the panel database and /etc/hitechcloud somewhere else."
        BK_LOG="$(mktemp)"
        if bash "$ADOPT_TOOL" --backup --backup-dir "$BACKUP_ROOT" >"$BK_LOG" 2>&1; then
            cat "$BK_LOG"
            BACKUP_PATH=$(grep '^BACKUP_PATH=' "$BK_LOG" | tail -n1 | cut -d= -f2-)
        elif nothing_to_lose; then
            cat "$BK_LOG"
            rm -f "$BK_LOG"
            warn "The pre-flight backup did not complete, and this installation has
  no panel database and no hosting accounts - an install that was interrupted
  before it finished. There is nothing here for a backup to protect, so this
  run continues rather than leaving the machine unrepairable."
            BACKUP_PATH=""
        else
            cat "$BK_LOG" >&2
            rm -f "$BK_LOG"
            err "The pre-flight backup did not complete. Nothing has been changed.

  Fix the reason above and run again. --no-backup will skip this check, which
  on a server with customer data is not advisable."
        fi
        rm -f "$BK_LOG" 2>/dev/null || true
        if [ -n "$BACKUP_PATH" ]; then
            log "Pre-flight backup: ${BACKUP_PATH}"
        elif ! nothing_to_lose; then
            err "The pre-flight backup reported no path. Nothing has been changed."
        fi
    fi
fi

if $NEEDS_RESET; then
    if $DRY_RUN; then
        # Worth spelling out: every file-by-file report below this point is
        # produced from the checkout as it stands, because a dry run does not
        # update it. A configuration file that only changes as part of this
        # update therefore appears in the list here and not in that one.
        warn "would update ${INSTALL_DIR} to origin/${BRANCH}:"
        git -C "$INSTALL_DIR" diff --stat HEAD "origin/${BRANCH}" 2>/dev/null | tail -25 | sed 's/^/      /' || true
    else
        git -C "$INSTALL_DIR" reset --quiet --hard "origin/$BRANCH"
        log "Repository updated to origin/${BRANCH}"
    fi
fi

# The panel database schema lives in the `configuration` submodule, so an
# install without it leaves the panel with an empty database and no obvious
# reason why.
#
# Only when it is actually missing. The runtime bundle from the mirror already
# contains it and has no .git at all, so running this there failed and warned
# about a submodule that was sitting right there - the first thing anyone
# installing sees, about a problem they do not have.
if [ -e "$INSTALL_DIR/configuration/hitechcloud" ]; then
    log "Configuration files present"
elif [ -d "$INSTALL_DIR/.git" ]; then
    run git -C "$INSTALL_DIR" submodule update --init --recursive --depth 1 configuration >/dev/null 2>&1 \
        || warn "Could not fetch the configuration submodule - the schema step below will say so."
else
    warn "No configuration files at ${INSTALL_DIR}/configuration - the schema step below will say so."
fi
VERSION=$(cat "$INSTALL_DIR/hitechcloudpanel/version" 2>/dev/null || echo "2.1.0")

# ---- 3. Credentials --------------------------------------------------------
# These live under /etc, not in the checkout. $INSTALL_DIR is a git working
# tree of a public repository, and a generated secret sitting in it is one
# `git add -A` away from being published.
CRED_DIR="/etc/hitechcloud/hitechcloud/conf"
CRED_FILE="${CRED_DIR}/credentials.env"
run mkdir -p "$CRED_DIR"
run chmod 750 "$CRED_DIR"

# Move a file written by an earlier build out of the checkout.
if [ -f "$INSTALL_DIR/.env" ] && [ ! -f "$CRED_FILE" ]; then
    run mv "$INSTALL_DIR/.env" "$CRED_FILE"
    run chmod 600 "$CRED_FILE"
    warn "Moved credentials out of the repository: ${INSTALL_DIR}/.env -> ${CRED_FILE}"
fi
# Only once the credentials are safely at $CRED_FILE. Deleting it
# unconditionally destroyed the only copy on a machine where the move above
# had not run.
[ -f "$CRED_FILE" ] && [ -f "$INSTALL_DIR/.env" ] && run rm -f "$INSTALL_DIR/.env"

# mysql_auth_works PASSWORD - can root authenticate with this password? An
# empty password means "root authenticates over the local socket", which is how
# Debian and Ubuntu ship MariaDB. The password goes into a mode-600 option file
# rather than onto the command line, where `ps` would show it to every user on
# the machine.
mysql_auth_works() {
    local pw="$1" f rc
    if [ -z "$pw" ]; then
        mysql -N -B -e "SELECT 1;" >/dev/null 2>&1
        return $?
    fi
    f=$(mktemp); chmod 600 "$f"
    printf '[client]\nuser="root"\npassword="%s"\n' "$pw" > "$f"
    mysql --defaults-file="$f" -N -B -e "SELECT 1;" >/dev/null 2>&1; rc=$?
    rm -f "$f"
    return $rc
}

if [ ! -f "$CRED_FILE" ]; then
    # Generating a new MySQL root password is right on an empty machine and
    # catastrophic on a running one: step 8 would ALTER USER to the new value
    # and everything still holding the old one - the panel's own option file
    # among them - would stop authenticating. So on a machine that already has
    # a panel database the existing password is recovered, never replaced.
    DB_PASS=""
    if [ "$EXISTING" = yes ]; then
        for optfile in /etc/hitechcloud/hitechcloud/mysql.cnf /root/.my.cnf; do
            [ -f "$optfile" ] || continue
            mysql --defaults-file="$optfile" -N -B -e "SELECT 1;" >/dev/null 2>&1 || continue
            DB_PASS=$(sed -n 's/^password[[:space:]]*=[[:space:]]*//p' "$optfile" | head -n1 | tr -d '"'"'")
            warn "Recovered the working MySQL root credentials from ${optfile}"
            break
        done
        if [ -z "$DB_PASS" ] && ! mysql_auth_works ""; then
            err "${CRED_FILE} is missing and no MySQL root credentials on this machine work.

  Generating a new password here would change the password of a database that
  already has this server's accounts, sites and plans in it, and nothing would
  be able to read it afterwards. Nothing has been changed.

  Restore ${CRED_FILE} (a pre-flight backup under ${BACKUP_ROOT} has it), or
  put working credentials in /root/.my.cnf, and run again."
        fi
        [ -z "$DB_PASS" ] && warn "MySQL root authenticates over the local socket; no password will be set"
    else
        DB_PASS=$(openssl rand -base64 24 | tr -d '/+=')
    fi

    SERVER_IP=$(curl -4s --max-time 5 ifconfig.me 2>/dev/null || curl -4s --max-time 5 api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}')
    # Every value is quoted: this file is sourced, and an unquoted value with a
    # space in it (BRAND_NAME) runs its second word as a command.
    # No admin password is generated or stored: the administrator authenticates
    # with this server's own root password, which the operator already has.
    write_file "$CRED_FILE" 0600 <<EOF
VERSION="${VERSION}"
ARCHITECTURE="native"
SERVER_IP="${SERVER_IP}"
PANEL_PORT="${PANEL_PORT}"
ADMIN_PORT="${ADMIN_PORT}"
MYSQL_ROOT_PASSWORD="${DB_PASS}"
ADMIN_USERNAME="${ADMIN_USERNAME:-root}"
BRAND_NAME="HiTechCloud Panel"
GITHUB_REPO="${REPO}"
GITHUB_BRANCH="${BRANCH}"
EOF
fi

# An .env written by an earlier build may have bare values; quote any that
# contain whitespace before sourcing, or the shell runs the second word as a
# command and the install stops here. The file is copied away first: it is the
# only place the MySQL root password is written down.
if [ -f "$CRED_FILE" ] && grep -qE '^[A-Za-z_][A-Za-z0-9_]*=[^"'"'"'][^\n]*[[:space:]]' "$CRED_FILE"; then
    if $DRY_RUN; then
        warn "would quote the unquoted values in ${CRED_FILE}"
    else
        keep_copy "$CRED_FILE"
        sed -i -E 's/^([A-Za-z_][A-Za-z0-9_]*)=([^"'"'"'][^\n]*[[:space:]][^\n]*)$/\1="\2"/' "$CRED_FILE"
        log "Quoted the unquoted values in ${CRED_FILE}"
    fi
fi

# On a dry run over a machine with no credentials file yet, there is nothing to
# source; the defaults below stand in so the rest of the report can be produced.
if [ -f "$CRED_FILE" ]; then
    # shellcheck disable=SC1090,SC1091
    . "$CRED_FILE"
fi
SERVER_IP="${SERVER_IP:-$(hostname -I | awk '{print $1}')}"
MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-}"

# ---- 4. Host directories and configuration ---------------------------------
step "Creating host configuration"
run mkdir -p /etc/hitechcloud/{hitechcloud/conf,hitechcloud/features,hitechcloud/hooks,hitechadmin/config}
run mkdir -p /etc/hitechcloud/{emails,ssl/certs,ssl/keys,domains,ftp/users,php/ini,bind9,upgrade}

# The installed version, written where hitechcloudcli and the panel look for
# it. Nothing had ever written this file: `hitechcloudcli version` answered
# {"error": "Version file is missing."} on every installation, the panel could
# not say what it was running, and the update path - which picks a
# version/<n>/UPDATE.sh by comparing the installed version to the released one
# - had nothing to compare against.
run mkdir -p "$RUNTIME_DIR"
printf '%s\n' "$VERSION" | write_file "${RUNTIME_DIR}/version" 0644
printf '%s\n' "$VERSION" | write_file /etc/hitechcloud/version 0644
log "Version recorded as ${VERSION}"
run mkdir -p /etc/hitechcloud/native
run mkdir -p /var/log/hitechcloud/{admin,user,native/domains}
run mkdir -p /var/lib/hitechcloud/native "$RUNTIME_DIR"
run touch /etc/hitechcloud/upgrade/skip_versions

if [ ! -f /etc/hitechcloud/hitechcloud/conf/hitechcloud.config ]; then
write_file /etc/hitechcloud/hitechcloud/conf/hitechcloud.config 0644 <<HCCONFIG
[PANEL]
# Every module the admin's catalogue lists, generated from that catalogue
# rather than typed out beside it. The two had drifted by thirty-seven
# entries: features.json offered them, the switch here did not, and a module
# absent from this line is one no account can reach however the plans are
# configured - so the Modules page showed them off with no way to explain why.
#
# This is the server-wide switch. What an individual account may use is still
# decided by its plan's feature set, which is where MSSQL and the vhost editor
# are deliberately absent.
enabled_modules="account,activity,anomaly_alerts,api,app_toolkit,autoinstaller,backup_snapshots,backup_wizard,backups,bimi,capitalize_domains,crons,dav_configure,dav_manage,dav_sharing,disk_usage,dns,dns_zones,dnssec,docroot,dokuwiki,domain_logs,domain_move,domain_suspend,domain_tools,domains,drupal,dynamic_dns,edit_vhost,elasticsearch,email_aliases,email_autoresponder,email_blocklist,email_default,email_deliverability,email_disk_usage,email_export,email_filters,email_forwarders,email_greylisting,email_import,email_send_limits,email_spam_filter,emails,error_pages,favorites,filemanager,fix_permissions,flarum,ftp,git_deploy,goaccess,health_check,hotlink,http3,imunify,info,inodes,ip_blocker,java,joomla,livechat,livechat_admin,locale,log_warnings,login_history,mail_log,mail_trust,malware_scan,matomo,mcp,mediawiki,memcached,mime_types,monitoring,moodle,multiweb,mysql,mysql_conf,mysql_import,mysql_processlist,mysql_root_password,nextcloud,nodejs,notifications,ojs,opencart,opensearch,passkeys,password_protect,pgadmin,php,php_extensions,php_ini,php_options,php_xray,phpbb,phpmyadmin,pm2,postgresql,postgresql_conf,postgresql_import,prestashop,process_manager,python,redirects,redis,remote_mysql,remote_postgresql,resource_reports,ruby,services,sessions,site_tools,sofawiki,spam_quarantine,ssh_keys,ssl,ssl_generate,ssl_hosts,ssl_install,ssl_storage,ssl_tools,temporary_links,terminal,tinyfilemanager,tinyphotogallery,trash,twofa,uptime,usage,valkey,varnish,waf,webmail,webserver_conf,website_builder,wordpress"
brand_name=HiTechCloud Panel
architecture=native
password_reset=yes
api=on
session_duration=10
session_lifetime=300
autoupdate=on
autopatch=on
server_ip=${SERVER_IP}

[LICENSE]
# Empty unless the installer was given one.
#
# This used to default to enterprise-opensource-unlimited, which is the key
# every install made before the editions were separated carries and which the
# panel honours as Enterprise so that an update cannot take a running
# reseller's login away. Writing it here meant every *new* install was
# Enterprise too - a Community download with no licence came up with resellers,
# hosting plans, the admin panel and no limits, and there was no way to tell
# it apart from a server that genuinely predated the split.
#
# A fresh install is Community. That is what the edition is for.
key=${LICENSE_KEY:-}

[SECURITY]
validate_ip_address_cookie=yes

[DEFAULT]
email=admin@localhost
force_domain=${PANEL_DOMAIN}

[SMTP]
mail_security_token=
HCCONFIG
    log "hitechcloud.config created"
else
    # An existing config is the operator's, so it is not rewritten - but a
    # module added by a newer release has to be switched on or its page 404s
    # with nothing to explain why.
    REQUIRED_MODULES="dashboard websites multiweb services filemanager domains php mysql crons info usage account"
    ADDED=""
    for module in $REQUIRED_MODULES; do
        grep -qE "^enabled_modules=.*(^|,)${module}(,|$)" /etc/hitechcloud/hitechcloud/conf/hitechcloud.config && continue
        run sed -i -E "s/^(enabled_modules=.*)$/\1,${module}/" /etc/hitechcloud/hitechcloud/conf/hitechcloud.config
        ADDED="${ADDED} ${module}"
    done
    if [ -n "$ADDED" ]; then
        did "enable new modules in hitechcloud.config:${ADDED}"
    else
        log "hitechcloud.config already present - left as is"
    fi
fi

[ -f /etc/hitechcloud/hitechadmin/config/admin.ini ] || write_file /etc/hitechcloud/hitechadmin/config/admin.ini 0644 <<'EOF'
[PANEL]
login_blocklimit=20
login_ratelimit=5

[SECURITY]
basic_auth=no
EOF

# Feature sets decide which pages a plan may reach. Without them every gated
# page answers 403 with nothing in the UI to say why, so a missing file here is
# not something to shrug off.
if [ -d "$INSTALL_DIR/configuration/hitechcloud/features" ]; then
    install_stock "$INSTALL_DIR/configuration/hitechcloud/features" /etc/hitechcloud/hitechcloud/features
    log "Feature sets installed ($(ls -1 /etc/hitechcloud/hitechcloud/features/*.txt 2>/dev/null | wc -l) sets)"
else
    err "Feature sets not found at ${INSTALL_DIR}/configuration/hitechcloud/features.
  The configuration submodule did not download; run:
    git -C ${INSTALL_DIR} submodule update --init --recursive
  then re-run this installer."
fi

# The rest of the stock configuration the panel and admin read at runtime.
#
# hitechcloud.config is excluded from both of these copies. The block above
# goes to some trouble to keep the operator's copy of it, and then a plain
# `cp -a` of the shipped configuration over the same directory undid every bit
# of that - the enabled module list, the brand name and the server IP with it.
install_stock "$INSTALL_DIR/configuration/hitechcloud/conf" /etc/hitechcloud/hitechcloud/conf hitechcloud.config

# The stock .htaccess the WordPress installer writes into a new site. It was
# never installed, so an install stopped with "no such file or directory" over
# a file that ships in the configuration repository. The panel now falls back
# to its own copy, but an administrator can still change what new sites get by
# editing these - so a changed one is copied away before it is replaced.
install_stock "$INSTALL_DIR/configuration/wordpress" /etc/hitechcloud/wordpress

# WP Helper, the plugin every WordPress this panel installs gets. It is
# mirrored rather than merged: a file dropped from the plugin has to disappear
# from the copy on disk too, because an old class file left behind is still a
# file the plugin's autoloader will happily load.
#
# It is not in configuration/wordpress/sets/plugins.txt, and deliberately not:
# that list is the operator's to edit, its entries are wordpress.org slugs and
# this plugin is not published there, and an account with its own
# wpcli_plugins.txt makes the panel skip the server-wide list entirely - so a
# line in it would have been silently dropped for exactly the customers who
# had customised anything.
if [ -d "$INSTALL_DIR/wp-helper" ]; then
    if command -v rsync >/dev/null 2>&1; then
        run mkdir -p /etc/hitechcloud/wordpress/plugins/wp-helper
        run rsync -a --delete "$INSTALL_DIR/wp-helper/" /etc/hitechcloud/wordpress/plugins/wp-helper/
    else
        run rm -rf /etc/hitechcloud/wordpress/plugins/wp-helper
        run mkdir -p /etc/hitechcloud/wordpress/plugins
        run cp -a "$INSTALL_DIR/wp-helper" /etc/hitechcloud/wordpress/plugins/wp-helper
    fi
    did "WP Helper staged at /etc/hitechcloud/wordpress/plugins/wp-helper"
fi

# services.json is generated per host by `native-install` from the units that
# are actually present; the shipped copy lists the container architecture's
# names and would put every row of the admin Services page back to "Inactive".
# notifications.ini is the operator's.
install_stock "$INSTALL_DIR/hitechcloudpanel/config-admin" /etc/hitechcloud/hitechadmin/config \
    services.json notifications.ini
install_stock "$INSTALL_DIR/hitechcloudpanel/config-core" /etc/hitechcloud/hitechcloud/conf hitechcloud.config
# hitechcloud.config is skipped above because it is the operator's file - but
# "the operator's file" was, on a fresh install, the fifteen-key stub written
# further up, while the admin's Settings page renders ninety-odd fields from
# it. Every key the stub lacked rendered as an empty string, and twenty-eight
# of those fields are marked `required`: the browser then refuses to submit the
# form at all, silently, with the complaint pinned to a field far below the
# fold. "Settings will not save" was that, and no server log could have shown
# it because no request was ever sent.
#
# So the keys are merged in: values already present are the operator's and are
# never touched, and a key a newer release adds arrives on an existing server
# the same way.
merge_ini /etc/hitechcloud/hitechcloud/conf/hitechcloud.config \
    "$INSTALL_DIR/hitechcloudpanel/config-core/hitechcloud.config"
install_stock "$INSTALL_DIR/hitechcloudpanel/config-php/ini" /etc/hitechcloud/php/ini
# features.json is the catalogue the admin's Modules page renders its
# checkboxes from, and the Modules form posts back only the boxes it rendered.
# A stale copy is therefore not a cosmetic problem: every module missing from
# it is invisible in the panel and was being deleted from enabled_modules the
# next time an administrator saved that page. That is exactly how imunify,
# git_deploy and the three mssql modules disappeared from a running server.
#
# There is one copy, in the configuration repository, which `hitechcloudcli
# update` also refreshes from. A second copy under hitechcloudpanel/ used to
# be installed here and had silently fallen five modules behind.
FEATURES_JSON="$INSTALL_DIR/configuration/hitechadmin/config/features.json"
if [ -f "$FEATURES_JSON" ]; then
    write_file /etc/hitechcloud/hitechadmin/config/features.json 0644 < "$FEATURES_JSON"
else
    warn "No features.json in this release; the admin's Modules page will show nothing to enable."
fi

# The webmail source, so `hitechcloudcli native-webmail install` builds from
# what this release shipped rather than cloning upstream - which is not
# reproducible and does not work at all on a machine with no route to GitHub.
if [ -f "$INSTALL_DIR/hitechcloudmail/webmail/backend/package.json" ]; then
    run mkdir -p /etc/hitechcloud/native
    if command -v rsync >/dev/null 2>&1; then
        run rsync -a --delete "$INSTALL_DIR/hitechcloudmail/webmail/" /etc/hitechcloud/native/webmail-src/
    else
        run rm -rf /etc/hitechcloud/native/webmail-src
        run cp -a "$INSTALL_DIR/hitechcloudmail/webmail" /etc/hitechcloud/native/webmail-src
    fi
fi

# The account importers. They ship with the panel and were being git-cloned
# from a private repository on every use, which fails and prints that
# repository's address into the admin. Installed like everything else instead.
for importer in cpanel cyberpanel directadmin plesk aapanel; do
    src="${INSTALL_DIR}/integrations/${importer}-import"
    [ -d "$src" ] || continue
    run mkdir -p "/opt/hitechcloud/integrations/${importer}-import"
    if command -v rsync >/dev/null 2>&1; then
        run rsync -a --delete "${src}/" "/opt/hitechcloud/integrations/${importer}-import/"
    else
        run cp -a "${src}/." "/opt/hitechcloud/integrations/${importer}-import/"
    fi
done

# ---- 5. hitechcloudcli ----------------------------------------------------------
step "Installing hitechcloudcli"
# `rm -rf` here also deleted two things the *server* produced rather than the
# release: the generated aliases.txt and the ImunifyAV integration scripts
# under imunify/. The deploy workflow already excludes both, so this does too -
# an install and a deploy should leave the machine in the same state.
run mkdir -p /usr/local/hitechcloudcli
if command -v rsync >/dev/null 2>&1; then
    run rsync -a --delete --exclude 'imunify/' --exclude 'aliases.txt' \
        "$INSTALL_DIR/hitechcloudpanel/hitechcloudcli-scripts/" /usr/local/hitechcloudcli/
else
    run cp -a "$INSTALL_DIR/hitechcloudpanel/hitechcloudcli-scripts/." /usr/local/hitechcloudcli/
fi
# From hitechcloudcli-scripts, which is the tree CI checks for drift.
#
# There used to be a third copy of the dispatcher at
# hitechcloudpanel/hitechcloudcli, and this line installed that one. It was
# outside the drift check, so it quietly fell a release behind the other two:
# every server was running a dispatcher whose `locale` command still fetched
# from a private GitHub repository - a 404 on any machine but ours. The file
# is gone; there are two copies now, and CI fails if they differ.
run install -m 755 "$INSTALL_DIR/hitechcloudpanel/hitechcloudcli-scripts/hitechcloudcli" /usr/local/bin/hitechcloudcli
$DRY_RUN || find /usr/local/hitechcloudcli -name '*.sh' -exec chmod +x {} \; 2>/dev/null || true

# The restricted shell wrapper: confines hosting accounts to their home
# directory using rbash.  Installed to /usr/local/bin so it can be used
# as a login shell in /etc/passwd.
if [ -f "$INSTALL_DIR/hitechcloudcli/native/restricted-shell.sh" ]; then
    run install -m 755 "$INSTALL_DIR/hitechcloudcli/native/restricted-shell.sh" /usr/local/bin/hitechcloud-shell
    did "restricted shell installed (confines terminal users to \$HOME)"
fi
# The namespace jail the web terminal and jailed SSH logins run in: the
# account's home, the system's tools read-only, and nothing else.
if [ -f "$INSTALL_DIR/hitechcloudcli/native/jail.sh" ]; then
    run install -m 755 "$INSTALL_DIR/hitechcloudcli/native/jail.sh" /usr/local/bin/hitechcloud-jail
    did "terminal jail installed (hitechcloud-jail)"
fi

# The CLI used to be installed as hitechcli. Leave nothing of it behind: both
# panels now shell out to hitechcloudcli, and a stale hitechcli left on PATH
# would keep answering from whatever version it was last written at.
[ -e /usr/local/hitechcli ] || [ -e /usr/local/bin/hitechcli ] && run rm -rf /usr/local/hitechcli /usr/local/bin/hitechcli
did "hitechcloudcli installed (try: hitechcloudcli --help)"

# ---- 6. Native templates ---------------------------------------------------
step "Installing native stack templates"
if command -v rsync >/dev/null 2>&1; then
    run mkdir -p "${RUNTIME_DIR}/configuration-native"
    run rsync -a --delete "$INSTALL_DIR/hitechcloudpanel/configuration-native/" "${RUNTIME_DIR}/configuration-native/"
else
    run rm -rf "${RUNTIME_DIR}/configuration-native"
    run cp -a "$INSTALL_DIR/hitechcloudpanel/configuration-native" "${RUNTIME_DIR}/configuration-native"
fi
did "install templates to ${RUNTIME_DIR}/configuration-native"

# ---- 7. Native service stack ----------------------------------------------
# The templates and the rendered master configurations are snapshotted first.
# A template that nginx rejects takes every website on the machine down, and it
# has done exactly that once; the check after step 11 puts these back when that
# happens rather than leaving the operator to work out which files changed.
WEB_SNAPSHOT=""
if [ "$EXISTING" = yes ] && ! $DRY_RUN; then
    WEB_SNAPSHOT="${BACKUP_PATH:-$(mktemp -d)}/native-config-pre"
    mkdir -p "$WEB_SNAPSHOT"
    for d in templates frontend nginx apache openlitespeed; do
        [ -d "/etc/hitechcloud/native/$d" ] && cp -a "/etc/hitechcloud/native/$d" "$WEB_SNAPSHOT/"
    done
    log "Current web configuration snapshotted to ${WEB_SNAPSHOT}"
fi

step "Installing the native service stack (this takes a few minutes)"
if $DRY_RUN; then
    warn "would run: hitechcloudcli native-install ${ENGINES} --php ${DEFAULT_PHP} --with ${WITH_COMPONENTS}"
    warn "  (it re-seeds /etc/hitechcloud/native/templates and re-renders the master"
    warn "   configurations; it does not touch /etc/hitechcloud/native/sites)"
else
    hitechcloudcli native-install ${ENGINES} --php "$DEFAULT_PHP" --with "$WITH_COMPONENTS" \
        --from "${RUNTIME_DIR}/configuration-native" \
        || err "Native stack installation failed. Re-run with: hitechcloudcli native-install ${ENGINES} --debug"
fi

# ---- 7b. The services the panel drives -------------------------------------
#
# phpMyAdmin, the web application firewall and webmail are part of what this
# product is, not optional extras an operator discovers later from the
# documentation. Installing them here is the difference between a server that
# works when the installer finishes and one that needs three more commands
# nobody was told about.
#
# None of them is fatal. A panel with no webmail is a panel; a panel that
# refused to finish installing because a Node build failed is not.
# Their output is shown, not discarded. Each of these takes minutes - webmail
# builds a React application - and with the output thrown away the installer
# printed a heading and then nothing for as long as it took, which is
# indistinguishable from a hung install. It is also where the reason lives when
# one of them fails: "phpMyAdmin did not install" on its own has never helped
# anybody.
step "phpMyAdmin, the firewall, ImunifyAV and webmail"
if $DRY_RUN; then
    warn "would install phpMyAdmin, the web application firewall and webmail"
else
    if [ "${HITECHCLOUD_NO_WAF:-0}" = "1" ]; then
        log "Web application firewall skipped (--no-waf)"
    elif [ -f "$INSTALL_DIR/deploy/install-waf.sh" ]; then
        REPO_DIR="$INSTALL_DIR" bash "$INSTALL_DIR/deploy/install-waf.sh" \
            && log "Web application firewall installed (off for every site until enabled)" \
            || warn "The firewall did not install; run: bash ${INSTALL_DIR}/deploy/install-waf.sh"
    fi

    hitechcloudcli native-phpmyadmin install \
        && log "phpMyAdmin installed" \
        || warn "phpMyAdmin did not install; run: hitechcloudcli native-phpmyadmin install"

    # pgAdmin, for the same reason as phpMyAdmin: the panel has a page for
    # it on both ports, the plans list it, and nothing installed it.
    if command -v psql >/dev/null 2>&1 && [ "${HITECHCLOUD_NO_PGADMIN:-0}" != "1" ]; then
        hitechcloudcli native-pgadmin install \
            && log "pgAdmin installed" \
            || warn "pgAdmin did not install; run: hitechcloudcli native-pgadmin install"
    fi

    # Sentinel: the health monitor behind the Uptime, Log warnings and Alerts
    # pages and the admin's notifications. deploy/install-services.sh has
    # installed it for a long time and nothing ever called it from here, so no
    # native server had it and every Uptime page read as empty.
    if [ -f "$INSTALL_DIR/deploy/install-services.sh" ]; then
        REPO_DIR="$INSTALL_DIR" bash "$INSTALL_DIR/deploy/install-services.sh" sentinel \
            && log "Sentinel health monitor installed (every 5 minutes)" \
            || warn "Sentinel did not install; run: bash ${INSTALL_DIR}/deploy/install-services.sh sentinel"
    fi

    # The screenshot service behind every site preview in WP Manager and on
    # the Sites page. The same script has installed it for as long as sentinel
    # and, like sentinel, nothing called it: every native server rendered its
    # previews through the project's hosted API, or - once that was
    # unreachable, or set to "local" by an old update - showed "No Screenshot"
    # on every tile. Installs Node and a system chromium (apt or dnf), runs
    # the API on 127.0.0.1:3200 and points the panel's `screenshots` key at
    # it. Not fatal: the panel falls back to the hosted API, and to a
    # placeholder with a retry button, when this is missing.
    if [ "${HITECHCLOUD_NO_SCREENSHOTS:-0}" = "1" ]; then
        log "Screenshot service skipped (HITECHCLOUD_NO_SCREENSHOTS=1)"
    elif [ -f "$INSTALL_DIR/deploy/install-services.sh" ]; then
        REPO_DIR="$INSTALL_DIR" bash "$INSTALL_DIR/deploy/install-services.sh" screenshots \
            && log "Screenshot service installed (site previews render on this server)" \
            || warn "The screenshot service did not install; run: bash ${INSTALL_DIR}/deploy/install-services.sh screenshots"
    fi

    # The ionCube Loader, for every PHP version installed above. Commercial
    # PHP applications ship their source encoded with it and refuse to run
    # without it - WHMCS among them - so a hosting server without it turns a
    # customer's licensed script into a blank page.
    hitechcloudcli native-php ioncube install all \
        && log "ionCube Loader installed for every PHP version" \
        || warn "ionCube did not install; run: hitechcloudcli native-php ioncube install all"

    # ImunifyAV. The panel has a Security page for it, the plans list it as a
    # feature and the CLI ships the integration scripts it needs - and nothing
    # installed it, so every one of those surfaces reported it as missing on a
    # server that had just been told to install everything.
    if [ "${HITECHCLOUD_NO_IMUNIFY:-0}" = "1" ]; then
        log "ImunifyAV skipped (--no-imunify)"
    elif ! uname -m | grep -qE 'x86_64|amd64'; then
        # CloudLinux only builds ImunifyAV for x86-64; on arm64 the vendor's
        # installer fails after pulling half a repository.
        log "ImunifyAV skipped: CloudLinux ships it for x86-64 only ($(uname -m) here). ClamAV covers malware scanning."
    elif [ -f "$INSTALL_DIR/deploy/install-imunifyav.sh" ]; then
        bash "$INSTALL_DIR/deploy/install-imunifyav.sh" \
            && log "ImunifyAV installed" \
            || warn "ImunifyAV did not install; run: bash ${INSTALL_DIR}/deploy/install-imunifyav.sh"
    fi

    # Last, and the slowest by far - it builds a React application. A failure
    # here must not lose everything above it, which is why it is not an err.
    if command -v node >/dev/null 2>&1; then
        hitechcloudcli native-webmail install \
            && log "Webmail installed" \
            || warn "Webmail did not install; run: hitechcloudcli native-webmail install"
    else
        warn "Node 22 is not installed, so webmail was skipped. Install it, then run: hitechcloudcli native-webmail install"
    fi

    # Meetings (video conferencing) - the Go SFU behind the webmail's Meetings
    # page. After the webmail, because install shares its secret with it.
    if [ -z "${HITECHCLOUD_NO_MEET:-}" ]; then
        HITECHCLOUD_REPO_DIR="$INSTALL_DIR" hitechcloudcli native-meet install \
            && log "Meeting service installed" \
            || warn "The meeting service did not install; run: hitechcloudcli native-meet install"
    fi
fi

# ---- 8. Panel database -----------------------------------------------------
# mysql_root runs a statement as the database superuser, with whatever
# credentials actually work on this machine.
#
# A bare `mysql` is right on a machine whose root authenticates over the local
# socket - every fresh Debian and Ubuntu - and wrong on one where a previous
# run of this installer has already set a root password, which is every re-run
# on RHEL. An AlmaLinux 8 server stopped at
#
#     ERROR 1045 (28000): Access denied for user 'root'@'localhost'
#                         (using password: NO)
#
# on the first statement of "Preparing the panel database", locked out of a
# database this installer had itself put the password on. The credentials were
# on the machine the whole time; nothing was reading them.
mysql_root() {
    if mysql -N -B -e "SELECT 1;" >/dev/null 2>&1; then
        mysql "$@"
        return $?
    fi
    if [ -f /root/.my.cnf ] && mysql --defaults-file=/root/.my.cnf -N -B -e "SELECT 1;" >/dev/null 2>&1; then
        mysql --defaults-file=/root/.my.cnf "$@"
        return $?
    fi
    if [ -n "${MYSQL_ROOT_PASSWORD:-}" ]; then
        # --defaults-file, not --defaults-extra-file: the second is read
        # before ~/.my.cnf, so a stale root password in that file would
        # override the one being passed here.
        local f rc
        f=$(mktemp); chmod 600 "$f"
        printf '[client]\nuser="root"\npassword="%s"\n' "$MYSQL_ROOT_PASSWORD" > "$f"
        mysql --defaults-file="$f" "$@"; rc=$?
        rm -f "$f"
        return $rc
    fi
    mysql "$@"
}

step "Preparing the panel database"
run systemctl enable --now mariadb >/dev/null 2>&1 || run systemctl enable --now mysqld >/dev/null 2>&1 || true
run mysql_root -e "CREATE DATABASE IF NOT EXISTS panel CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"

# The check is for a table, not the database: an interrupted earlier run leaves
# an empty `panel` database behind, and treating that as "already installed"
# gives a panel that cannot store a single user.
if mysql_root -N -B -e "SELECT 1 FROM panel.users LIMIT 1;" >/dev/null 2>&1; then
    log "Panel schema already loaded ($(mysql_root -N -B -e 'SELECT COUNT(*) FROM panel.users;' 2>/dev/null || echo '?') accounts)"
elif mysql_root -N -B -e "SELECT 1 FROM panel.users;" >/dev/null 2>&1; then
    # The table is there and empty. That is a fresh install, not a loss.
    log "Panel schema already loaded (no accounts yet)"
else
    SCHEMA="$INSTALL_DIR/configuration/mysql/initialize/1.1/plans.sql"
    [ -f "$SCHEMA" ] || err "Panel schema not found at ${SCHEMA}. The configuration submodule did not download; run:
    git -C ${INSTALL_DIR} submodule update --init --recursive
  then re-run this installer."
    if $DRY_RUN; then
        warn "would load the panel schema from ${SCHEMA}"
    else
        mysql_root panel < "$SCHEMA" || err "Could not load the panel schema from ${SCHEMA}"
        mysql_root -N -B -e "SELECT 1 FROM panel.users;" >/dev/null 2>&1 \
            || err "The schema loaded but panel.users is still missing - the schema file may be truncated."
        log "Panel schema loaded"
    fi
fi

# The Email Pro tier definitions, staged where `hitechcloudcli plan-emailpro`
# can find them. Staged, not loaded: eight plans belong on a server when the
# administrator asks for them, not because an installer ran.
if [ -f "$INSTALL_DIR/configuration/mysql/initialize/1.1/email_pro_plans.sql" ]; then
    write_file /etc/hitechcloud/mysql/email_pro_plans.sql 0644 \
        < "$INSTALL_DIR/configuration/mysql/initialize/1.1/email_pro_plans.sql"
    did "Email Pro tiers available (try: hitechcloudcli plan-emailpro show)"
fi

# The MySQL root password.
#
# This used to run ALTER USER unconditionally with `|| true`, and then write
# the new password into /root/.my.cnf whether or not the ALTER had worked. On a
# server whose credentials file had been lost that changed the root password of
# a live database and then hid the failure, leaving the panel unable to read
# its own accounts. It is now only set when it is not already in force, and the
# result is proven before anything that depends on it is written.
if mysql_auth_works "$MYSQL_ROOT_PASSWORD"; then
    log "The MySQL root credentials on file already work"
elif [ "$EXISTING" = yes ] && ! $FORCE; then
    err "The MySQL root password in ${CRED_FILE} does not authenticate against the
  database this server is using, and changing it would cut every other client
  off from a database that has this server's accounts in it. Nothing further
  has been changed.

  Either put the working password into ${CRED_FILE} (the pre-flight backup at
  ${BACKUP_PATH:-${BACKUP_ROOT}/latest} has the previous copy), or re-run with
  --force to set the password in that file as the new one."
elif $DRY_RUN; then
    warn "would set the MySQL root password to the value in ${CRED_FILE}"
else
    mysql_root -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '${MYSQL_ROOT_PASSWORD}';" \
        || err "Could not set the MySQL root password. Nothing that depends on it has been written."
    mysql_auth_works "$MYSQL_ROOT_PASSWORD" \
        || err "The MySQL root password was changed but does not authenticate. Stopping
  before the client configuration is written with a password that does not work."
    log "MySQL root password set"
fi

# database= matters as much as the credentials here. The CLI's shell scripts
# run plain "mysql -e 'SELECT ... FROM domains'" and rely on root's own client
# defaults to say which schema that is; without it every one of them fails with
# "ERROR 1046 (3D000): No database selected", which surfaces in the panel as
# "Docroot not found for domain" and "Domain already exists".
#
# Quoted: the MySQL client reads an unquoted '#' as a comment, truncating any
# generated password that happens to contain one. An empty password means root
# authenticates over the local socket, and a `password=""` line would break
# that, so the line is left out entirely in that case.
# The panel's own database account.
#
# Not root. On RHEL and its rebuilds MariaDB authenticates root with the
# unix_socket plugin, so a password login as root is refused outright -
# "ERROR 1698 (28000): Access denied for user 'root'@'localhost'" - and the
# panel, which connects over TCP with a password, could not read its own
# database on a machine where `mysql` from a root shell worked perfectly.
#
# A dedicated account with a password of its own works on every configuration,
# needs no plugin change, and is what the panel should have had anyway: it is
# a web application, and a web application holding the database superuser's
# credentials is one exploit away from owning every database on the box.
PANEL_DB_USER="hitechcloud_panel"
PANEL_DB_PASSWORD=""

ensure_panel_db_account() {
    # Recovered rather than regenerated when it already works: rotating it here
    # would leave the running panel holding a password its database no longer
    # accepts.
    if [ -f /etc/hitechcloud/hitechcloud/mysql.cnf ] &&
       mysql --defaults-file=/etc/hitechcloud/hitechcloud/mysql.cnf -N -B -e "SELECT 1;" >/dev/null 2>&1; then
        PANEL_DB_USER=$(sed -n 's/^user[[:space:]]*=[[:space:]]*//p' /etc/hitechcloud/hitechcloud/mysql.cnf | head -n1 | tr -d '"'"'")
        PANEL_DB_PASSWORD=$(sed -n 's/^password[[:space:]]*=[[:space:]]*//p' /etc/hitechcloud/hitechcloud/mysql.cnf | head -n1 | tr -d '"'"'")
        log "The panel's database credentials on file already work"
        return 0
    fi

    PANEL_DB_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=')
    local escaped="${PANEL_DB_PASSWORD//\'/\'\'}"
    # Both hosts: the panel connects over TCP, and hitechcloudcli over the
    # socket. Granting only one of them is a panel that works and a CLI that
    # does not, or the reverse, depending on which was tested.
    local host
    for host in localhost 127.0.0.1; do
        mysql_root -e "CREATE USER IF NOT EXISTS '${PANEL_DB_USER}'@'${host}' IDENTIFIED BY '${escaped}';" 2>/dev/null || true
        mysql_root -e "ALTER USER '${PANEL_DB_USER}'@'${host}' IDENTIFIED BY '${escaped}';" 2>/dev/null || true
        mysql_root -e "GRANT ALL PRIVILEGES ON \`panel\`.* TO '${PANEL_DB_USER}'@'${host}';" 2>/dev/null || true
        # The panel creates and drops customer databases, so it needs the
        # privileges to do that - scoped to the operations, not to every
        # database's contents.
        # Every database-level privilege, and no server-level one.
        #
        # This granted CREATE, DROP, RELOAD, PROCESS, SHOW DATABASES and
        # CREATE USER, which is enough to make a database and a user and not
        # enough to connect them:
        #
        #     Error 1044 (42000): Access denied for user
        #     'hitechcloud_panel'@'localhost' to database 'hitechweb_pvflvcmg'
        #
        # WITH GRANT OPTION only lets an account hand out privileges it holds
        # itself, and `GRANT ALL PRIVILEGES ON <db>.*` means *every*
        # database-level privilege - so one missing entry refuses the whole
        # statement. Creating a WordPress site, or any CMS, failed there.
        #
        # DELETE HISTORY is the one that is easy to miss: MariaDB added it in
        # 10.3.4 for system-versioned tables, it is part of database-level
        # ALL, and leaving it out reproduces the error exactly. Verified by
        # adding it alone to a server that was failing.
        #
        # FILE, SUPER, SHUTDOWN, REPLICATION and CREATE TABLESPACE are
        # deliberately absent. A panel needs full control of its customers'
        # databases; it has no business reading arbitrary files as the
        # database user or stopping the server.
        local db_privs="SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, PROCESS,
             REFERENCES, INDEX, ALTER, SHOW DATABASES, CREATE TEMPORARY TABLES,
             LOCK TABLES, EXECUTE, CREATE VIEW, SHOW VIEW, CREATE ROUTINE,
             ALTER ROUTINE, CREATE USER, EVENT, TRIGGER"
        # MariaDB before 10.3.4 and MySQL have no DELETE HISTORY, and naming
        # it there fails the whole statement - so it is tried first and the
        # older form is the fallback.
        mysql_root -e "GRANT ${db_privs}, DELETE HISTORY ON *.* TO '${PANEL_DB_USER}'@'${host}' WITH GRANT OPTION;" 2>/dev/null ||
            mysql_root -e "GRANT ${db_privs} ON *.* TO '${PANEL_DB_USER}'@'${host}' WITH GRANT OPTION;" 2>/dev/null || true
        # And read access to the grant tables.
        #
        # A control panel's job includes answering "which users can reach this
        # database", and that answer only exists in mysql.user and mysql.db.
        # Without this the Database Users page returned
        #
        #     Error 1142 (42000): SELECT command denied to user
        #     'hitechcloud_panel'@'localhost' for table `mysql`.`db`
        #
        # and so did remote MySQL, the API and the server-info page - every
        # part of the panel that reports on database access at once.
        #
        # SELECT only. The panel changes grants with CREATE USER and GRANT
        # statements, which it already has; it has no business writing the
        # grant tables by hand, and MariaDB would not honour it if it did.
        mysql_root -e "GRANT SELECT ON \`mysql\`.* TO '${PANEL_DB_USER}'@'${host}';" 2>/dev/null || true
    done
    mysql_root -e "FLUSH PRIVILEGES;" 2>/dev/null || true
    # Proven, not assumed.
    #
    # Every statement above is deliberately quiet: CREATE USER IF NOT EXISTS
    # and ALTER USER between them cover the account existing or not, and one
    # of the two is expected to fail. But "expected to fail" and "all of them
    # failed" look identical when the output is discarded, and this used to
    # log "Panel database account created" either way - on a server where the
    # panel then could not read a single one of its own tables.
    local check
    check=$(mktemp); chmod 600 "$check"
    printf '[client]\nuser="%s"\npassword="%s"\nhost="127.0.0.1"\n' \
        "$PANEL_DB_USER" "$PANEL_DB_PASSWORD" > "$check"
    if ! mysql --defaults-file="$check" -N -B -e "SELECT 1 FROM panel.users LIMIT 1;" >/dev/null 2>&1; then
        rm -f "$check"
        err "The panel's database account '${PANEL_DB_USER}' was not created, or cannot
  read the panel database. Every statement above is quiet by design - one of
  CREATE USER and ALTER USER is always expected to fail - so this is checked
  rather than assumed. Nothing that depends on it has been written."
    fi
    rm -f "$check"
    log "Panel database account '${PANEL_DB_USER}' created and verified"
}

mysql_client_conf() {
    printf '[client]\nuser="%s"\n' "$PANEL_DB_USER"
    [ -n "$PANEL_DB_PASSWORD" ] && printf 'password="%s"\n' "$PANEL_DB_PASSWORD"
    [ -n "${1:-}" ] && printf 'host="%s"\n' "$1"
    printf 'database="panel"\n'
}

# root's own option file keeps root, and no host: the socket is how an
# administrator reaches MariaDB, and on a unix_socket install it is the only
# way that works.
mysql_root_conf() {
    printf '[client]\nuser="root"\n'
    [ -n "$MYSQL_ROOT_PASSWORD" ] && printf 'password="%s"\n' "$MYSQL_ROOT_PASSWORD"
    printf 'database="panel"\n'
}
# A config kept from a previous install can still have no hostname in it - the
# machine had none when it was written, or the value was never set. Filling it
# in is what makes the certificate step able to ask for one; overwriting a
# hostname somebody chose is not, so only an empty value is touched.
PANEL_CONF=/etc/hitechcloud/hitechcloud/conf/hitechcloud.config
if [ -f "$PANEL_CONF" ] && [ -n "$PANEL_DOMAIN" ] &&
   grep -qE '^force_domain=[[:space:]]*$' "$PANEL_CONF"; then
    run sed -i "s|^force_domain=.*|force_domain=${PANEL_DOMAIN}|" "$PANEL_CONF"
    log "force_domain set to ${PANEL_DOMAIN}"
fi

ensure_panel_db_account
mysql_root_conf            | write_file /root/.my.cnf 0600
# The panel reads its own credentials from a MySQL option file. Debian and
# Ubuntu package MariaDB to read /etc/mysql/, not /etc/my.cnf, so writing this
# file gives the panel its credentials without the database server picking up a
# second configuration.
run mkdir -p /etc/hitechcloud/hitechcloud
mysql_client_conf 127.0.0.1 | write_file /etc/hitechcloud/hitechcloud/mysql.cnf 0600
log "Panel database credentials in place"

# Redis: the panel talks over a unix socket. The distribution's package does
# not enable one by default. The restart is inside the branch that changed
# something: restarting Redis drops every panel session on the machine, and
# doing that on every re-run for no reason is not acceptable on a live server.
REDIS_SOCK="/run/redis/redis-server.sock"

# Where this distribution keeps the cache's configuration.
#
# This looked only at /etc/redis/redis.conf, which is Debian's layout. RHEL and
# its rebuilds keep /etc/redis.conf, a file, and AlmaLinux 8 has no /etc/redis
# directory at all - so the whole block was skipped there, no socket was ever
# created, and panel.env below was still written pointing at one. Every session
# write then failed, and the symptom was that no account could log in: every
# sign-in redirected straight back to the login page, with nothing anywhere
# connecting that to Redis. Valkey is here too because it is what RHEL 10 ships
# instead of Redis.
REDIS_CONF=""
for candidate in /etc/redis/redis.conf /etc/redis.conf /etc/valkey/valkey.conf; do
    [ -f "$candidate" ] && { REDIS_CONF="$candidate"; break; }
done

if [ -z "$REDIS_CONF" ]; then
    warn "No Redis or Valkey configuration found; the panel will be pointed at 127.0.0.1:6379."
    REDIS_SOCK="127.0.0.1:6379"
elif grep -q "^unixsocket " "$REDIS_CONF" 2>/dev/null; then
    REDIS_SOCK=$(grep -m1 "^unixsocket " "$REDIS_CONF" | awk '{print $2}')
    log "Redis socket already configured at ${REDIS_SOCK}"
elif $DRY_RUN; then
    warn "would add a unix socket to ${REDIS_CONF} and restart Redis"
else
    keep_copy "$REDIS_CONF"
    {
        echo ""
        echo "# Added by HiTechCloud Panel - the panel talks to redis over a socket."
        echo "unixsocket ${REDIS_SOCK}"
        echo "unixsocketperm 770"
    } >> "$REDIS_CONF"
    # The restart is inside the branch that changed something: restarting the
    # cache drops every panel session on the machine, and doing that on every
    # re-run for no reason is not acceptable on a live server.
    systemctl restart redis-server >/dev/null 2>&1 ||
        systemctl restart redis >/dev/null 2>&1 ||
        systemctl restart valkey >/dev/null 2>&1 || true
    log "Redis socket configured at ${REDIS_SOCK} (${REDIS_CONF})"
fi

# Proven, not assumed.
#
# A socket named in panel.env that does not exist is a panel where nobody can
# log in, and the only way to find that out was to try. The daemon takes a
# moment to create it after a restart, so this waits rather than checking once,
# and falls back to TCP - which the panel now also accepts - rather than
# leaving the machine pointed at nothing.
if [ "${REDIS_SOCK#/}" != "$REDIS_SOCK" ] && ! $DRY_RUN; then
    waited=0
    while [ ! -S "$REDIS_SOCK" ] && [ "$waited" -lt 15 ]; do
        sleep 1
        waited=$((waited + 1))
    done
    if [ ! -S "$REDIS_SOCK" ]; then
        warn "Redis created no socket at ${REDIS_SOCK} after ${waited}s. The panel will use 127.0.0.1:6379 instead; without a working cache no account can sign in."
        REDIS_SOCK="127.0.0.1:6379"
    fi
fi

# ---- 9. Panel binaries -----------------------------------------------------
step "Installing the panel binaries"

# A released binary is the normal path. A clean server has no Go toolchain, and
# refusing to install because of that left every other step of this installer
# done and the panel itself missing - the machine configured for a product that
# was not there.
# binary_is_for_this_arch FILE - does a prebuilt ELF match this CPU?
#
# A checkout can carry a binary built elsewhere (the tracked hitechcloudwaf is
# x86-64; encoded-amd64/ says so in its name), and a checkout install on an
# arm64 server picked it up before ever asking the release for the right one.
# The ELF header's e_machine field (offset 18, little-endian) says what a
# binary is for; 0x3e is x86-64, 0xb7 is aarch64.
binary_is_for_this_arch() {
    local f="$1" machine want
    [ -f "$f" ] || return 1
    machine=$(od -An -t x1 -j 18 -N 2 "$f" 2>/dev/null | tr -d ' \n')
    case "$(uname -m)" in
        x86_64|amd64)  want="3e00" ;;
        aarch64|arm64) want="b700" ;;
        *) return 1 ;;
    esac
    [ "$machine" = "$want" ]
}

release_arch() {
    case "$(uname -m)" in
        x86_64|amd64) echo amd64 ;;
        aarch64|arm64) echo arm64 ;;
        *) echo "" ;;
    esac
}

# install_binary SRC DEST - put a binary in place only after finding out what
# it is. The mirror serves "latest", which on a server already running a newer
# build is a downgrade, and a panel rolled back onto a database a newer version
# has already migrated does not start. The candidate is staged next to the
# target, questioned, and only then moved over - the same order the deploy
# workflow uses, so a failed copy leaves the running binary alone.
BINARY_CHANGED=false
# probe_version asks a binary what version it is, under a timeout and with its
# input and output closed off.
#
# A binary that does not recognise --version starts doing its actual job
# instead. hitechadmin did exactly that: the probe launched a second admin
# panel inside a command substitution, which fought for port 2087 and hung the
# installer forever - on precisely the upgrade path this probe exists to
# protect. That is fixed in the program, and bounded here as well, because the
# next binary to be added has to be wrong only once.
probe_version() {
    local binary="$1"
    timeout 5 "$binary" --version </dev/null 2>/dev/null |
        grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true
}

install_binary() {
    local src="$1" dest="$2" running="" incoming="" oldest
    [ -s "$src" ] || return 1
    if [ -x "$dest" ] && cmp -s "$src" "$dest"; then
        log "$(basename "$dest") is already this build"
        return 0
    fi
    if [ -x "$dest" ]; then
        running=$(probe_version "$dest")
    fi
    if $DRY_RUN; then
        warn "would install $(basename "$dest") over the running ${running:-unknown} build"
        BINARY_CHANGED=true
        return 0
    fi
    install -m 755 "$src" "${dest}.new"
    incoming=$(probe_version "${dest}.new")
    if [ -n "$running" ] && [ -n "$incoming" ] && [ "$running" != "$incoming" ]; then
        oldest=$(printf '%s\n%s\n' "$running" "$incoming" | sort -V | head -n1)
        if [ "$oldest" = "$incoming" ] && ! $FORCE; then
            rm -f "${dest}.new"
            err "$(basename "$dest") ${incoming} is older than the ${running} this server is
  running. Downgrading the panel onto a database the newer version has already
  migrated is not something that undoes itself, so this stops here.

  Re-run with --force if the downgrade is what you want."
        fi
    fi
    mv -f "${dest}.new" "$dest"
    BINARY_CHANGED=true
    log "$(basename "$dest") installed${incoming:+ (${incoming})}"
}

# download_release_binary <asset-name> <destination>
# Verifies against the release's own SHA256SUMS. An unverifiable download is
# discarded rather than installed: this file is about to run as root.
download_release_binary() {
    local asset="$1" dest="$2" tmp sums want got
    tmp="$(mktemp -d)"
    # shellcheck disable=SC2064
    trap "rm -rf '$tmp'" RETURN

    # The mirror first: the repositories are private, so their release assets
    # are not public either, and an install must not need a credential. GitHub
    # stays as a fallback for a machine that has a token and cannot reach the
    # mirror.
    local base="${HITECHCLOUD_MIRROR:-https://mirrors.hitechcloud.org}/releases/latest"
    local -a auth=()

    if ! curl -fsSL --retry 3 --connect-timeout 15 "${base}/${asset}" -o "${tmp}/${asset}"; then
        base="https://github.com/${REPO}/releases/latest/download"
        [ -n "${HITECHCLOUD_GITHUB_TOKEN:-}" ] && auth=(-H "Authorization: Bearer ${HITECHCLOUD_GITHUB_TOKEN}")
        curl -fsSL --retry 3 --connect-timeout 15 "${auth[@]}" "${base}/${asset}" -o "${tmp}/${asset}" || return 1
    fi
    [ -s "${tmp}/${asset}" ] || return 1

    if curl -fsSL --retry 2 --connect-timeout 15 "${auth[@]}" "${base}/SHA256SUMS" -o "${tmp}/SHA256SUMS" 2>/dev/null; then
        want=$(awk -v a="$asset" '$2 == a || $2 == "*"a { print $1; exit }' "${tmp}/SHA256SUMS")
        if [ -n "$want" ]; then
            got=$(sha256sum "${tmp}/${asset}" | awk '{print $1}')
            [ "$want" = "$got" ] || { warn "checksum mismatch for ${asset}"; return 1; }
        fi
    fi

    install_binary "${tmp}/${asset}" "$dest"
}

ARCH="$(release_arch)"
if [ -x "$INSTALL_DIR/hitechcloudpanel/encoded-${ARCH:-amd64}/hitechcloud" ] && binary_is_for_this_arch "$INSTALL_DIR/hitechcloudpanel/encoded-${ARCH:-amd64}/hitechcloud"; then
    install_binary "$INSTALL_DIR/hitechcloudpanel/encoded-${ARCH:-amd64}/hitechcloud" /usr/local/bin/hitechcloud
elif [ -x "$INSTALL_DIR/hitechcloudpanel/hitechcloud" ] && binary_is_for_this_arch "$INSTALL_DIR/hitechcloudpanel/hitechcloud"; then
    install_binary "$INSTALL_DIR/hitechcloudpanel/hitechcloud" /usr/local/bin/hitechcloud
elif [ -n "$ARCH" ] && download_release_binary "hitechcloud-linux-${ARCH}" /usr/local/bin/hitechcloud; then
    log "Panel binary taken from the latest release (${ARCH})"
elif command -v go >/dev/null 2>&1; then
    warn "No released binary available - building from source"
    $DRY_RUN || {
        (cd "$INSTALL_DIR/hitechcloudpanel" && go build -o /tmp/hitechcloud.build ./cmd/hitechcloud)
        install_binary /tmp/hitechcloud.build /usr/local/bin/hitechcloud
        rm -f /tmp/hitechcloud.build
    }
else
    err "No panel binary could be downloaded or built. Install Go and re-run, or fetch
    https://github.com/${REPO}/releases/latest/download/hitechcloud-linux-${ARCH:-amd64}
    to /usr/local/bin/hitechcloud yourself."
fi

if [ -x "$INSTALL_DIR/hitechcloudpanel/hitechcloudadmin/hitechadmin" ] && binary_is_for_this_arch "$INSTALL_DIR/hitechcloudpanel/hitechcloudadmin/hitechadmin"; then
    install_binary "$INSTALL_DIR/hitechcloudpanel/hitechcloudadmin/hitechadmin" /usr/local/bin/hitechadmin
elif [ -n "$ARCH" ] && download_release_binary "hitechadmin-linux-${ARCH}" /usr/local/bin/hitechadmin; then
    log "Admin binary taken from the latest release (${ARCH})"
elif command -v go >/dev/null 2>&1 && [ -d "$INSTALL_DIR/hitechcloudadmin" ]; then
    $DRY_RUN || {
        (cd "$INSTALL_DIR/hitechcloudadmin" && go build -o /tmp/hitechadmin.build ./cmd/hitechadmin) \
            && install_binary /tmp/hitechadmin.build /usr/local/bin/hitechadmin \
            || warn "The admin panel could not be built - the hosting panel works without it"
        rm -f /tmp/hitechadmin.build
    }
else
    warn "No admin binary available - the hosting panel works without it, but there will be no admin panel"
fi

# The compliance recorder, a second binary in the admin's module.
#
# It runs from cron rather than inside the panel so that the record keeps
# being written while the panel is stopped, being upgraded or crash-looping -
# which are exactly the periods somebody would most want a gap in.
if command -v go >/dev/null 2>&1 && [ -d "$INSTALL_DIR/hitechcloudadmin/cmd/hitechcompliance" ]; then
    $DRY_RUN || {
        (cd "$INSTALL_DIR/hitechcloudadmin" && go build -o /tmp/hitechcompliance.build ./cmd/hitechcompliance) \
            && install_binary /tmp/hitechcompliance.build /usr/local/admin/hitechcompliance \
            || warn "The compliance recorder could not be built; the audit chain will not be written"
        rm -f /tmp/hitechcompliance.build
    }
fi

# The unit files. Written through write_file so a unit an operator has edited
# is copied away before it is replaced, and an unchanged one is not rewritten -
# which is also what keeps the daemon-reload below meaningful.
write_file /etc/systemd/system/hitechcloud.service 0644 \
    < "${RUNTIME_DIR}/configuration-native/systemd/hitechcloud.service"
[ -x /usr/local/bin/hitechadmin ] && \
    write_file /etc/systemd/system/hitechadmin.service 0644 \
        < "${RUNTIME_DIR}/configuration-native/systemd/hitechadmin.service"

# merge, not overwrite: an operator who added a variable here (a proxy, a log
# level, a second listen address) had it deleted on every run before this.
merge_env /etc/hitechcloud/hitechcloud/conf/panel.env \
    "LISTEN_ADDR=:${PANEL_PORT}" \
    "HITECHCLOUD_MYSQL_OPTION_FILE=/etc/hitechcloud/hitechcloud/mysql.cnf" \
    "HITECHCLOUD_REDIS_SOCKET=${REDIS_SOCK:-/run/redis/redis-server.sock}"
merge_env /etc/hitechcloud/hitechadmin/config/admin.env \
    "LISTEN_ADDR=:${ADMIN_PORT}" \
    "HITECHCLOUD_MYSQL_OPTION_FILE=/etc/hitechcloud/hitechcloud/mysql.cnf"

run systemctl daemon-reload
run systemctl enable --now hitechcloud >/dev/null 2>&1 || warn "hitechcloud.service did not start - check: journalctl -u hitechcloud"
[ -x /usr/local/bin/hitechadmin ] && { run systemctl enable --now hitechadmin >/dev/null 2>&1 || warn "hitechadmin.service did not start"; }

# `enable --now` starts a stopped service and does nothing to a running one, so
# on a server that was already up the new binary was never actually picked up:
# the panel kept serving from the copy it had open. Restart it when the binary
# on disk changed, and only then.
if $BINARY_CHANGED; then
    run systemctl try-restart hitechcloud.service >/dev/null 2>&1 || true
    [ -x /usr/local/bin/hitechadmin ] && run systemctl try-restart hitechadmin.service >/dev/null 2>&1 || true
    did "restart the panel onto the new binary"
fi
log "Binaries installed"

# ---- 9b. The administrator account ------------------------------------------
# Without this the admin panel starts, serves a login page, and accepts nobody:
# its user table is empty and there is no hint anywhere that it should not be.
step "Creating the administrator account"
command -v sqlite3 >/dev/null 2>&1 || {
    if $DRY_RUN; then warn "would install sqlite3"
    elif [ "$OS_FAMILY" = debian ]; then apt-get install -y -qq sqlite3 >/dev/null
    else (command -v dnf >/dev/null && dnf install -y -q sqlite) >/dev/null 2>&1 || yum install -y -q sqlite >/dev/null 2>&1; fi
}
ADMIN_DB=/etc/hitechcloud/hitechadmin/users.db
if [ -f "$ADMIN_DB" ] && ! command -v sqlite3 >/dev/null 2>&1; then
    # Without sqlite3 the count below reads 0, which on a machine that already
    # has administrators would add a second one rather than skip the step.
    warn "sqlite3 is not available, so the existing administrator table cannot be read"
    warn "- not creating an administrator account. Install sqlite3 and re-run if you need one."
else
    ADMIN_COUNT=$(sqlite3 "$ADMIN_DB" "SELECT COUNT(*) FROM user;" 2>/dev/null || echo 0)
    if [ "${ADMIN_COUNT:-0}" -gt 0 ]; then
        log "Administrator account already exists (${ADMIN_COUNT})"
    elif $DRY_RUN; then
        warn "would create the administrator '${ADMIN_USERNAME:-root}'"
    elif hitechcloudcli admin new "${ADMIN_USERNAME:-root}" '!system' --super >/dev/null 2>&1; then
        log "Administrator '${ADMIN_USERNAME:-root}' created - it authenticates with this server's own password"
    else
        warn "Could not create the administrator account. Create one with:"
        warn "  hitechcloudcli admin new <system-user> '!system' --super"
    fi
fi

# ---- 9c. The administrator's own hosting account ----------------------------
# The administrator manages the server from :2087, but has nowhere to put a
# website of their own without one of these. It gets an Unlimited plan: this is
# the person who sets the limits, and applying a customer's disk quota to them
# is the wrong default.
step "The administrator's hosting account"
ADMIN_PLAN="${ADMIN_PLAN:-Unlimited}"

plan_exists() {
    mysql --defaults-file=/etc/hitechcloud/hitechcloud/mysql.cnf -D panel -sN \
        -e "SELECT COUNT(*) FROM plans WHERE name = '$1';" 2>/dev/null
}

PLAN_COUNT="$(plan_exists "$ADMIN_PLAN" || true)"
if [ -z "$PLAN_COUNT" ]; then
    # An empty answer means the query failed, not that there are no plans.
    # Treating the two the same is how a duplicate plan gets created on a
    # server whose credentials had drifted.
    if [ "$EXISTING" = yes ]; then
        err "The plans table could not be read with the credentials in
  /etc/hitechcloud/hitechcloud/mysql.cnf. Everything up to here has been done;
  fix the credentials and re-run rather than letting this step guess."
    fi
    warn "Could not read the plans table - skipping the '${ADMIN_PLAN}' plan"
elif [ "$PLAN_COUNT" = "0" ]; then
    # 0 means unlimited in every one of these columns.
    if $DRY_RUN; then
        warn "would create the '${ADMIN_PLAN}' plan"
    else
        hitechcloudcli plan-create name="$ADMIN_PLAN" \
            description="No limits - for the server administrator's own account" \
            emails=0 ftp=0 domains=0 websites=0 disk=0 inodes=0 databases=0 \
            cpu=0 ram=0 bandwidth=0 feature_set=default \
            max_email_quota=0 max_hourly_email=0 >/dev/null 2>&1 &&
            log "Plan '${ADMIN_PLAN}' created" ||
            warn "Could not create the '${ADMIN_PLAN}' plan"
    fi
else
    log "Plan '${ADMIN_PLAN}' already exists"
fi

# Confirmed to exist before an account is put on it. `user-add` with a plan
# name nothing matches does not refuse - it leaves a hosting account whose
# plan_id is NULL, which is an account the panel renders with no limits, no
# features and no explanation. On the first server this was seen on, the plan
# had failed to be created because the database credentials were wrong, and
# the only visible symptom was a User Packages page that looked empty.
PLAN_READY=yes
if ! $DRY_RUN; then
    PLAN_COUNT="$(plan_exists "$ADMIN_PLAN" || true)"
    [ "${PLAN_COUNT:-0}" != "0" ] && [ -n "${PLAN_COUNT}" ] || PLAN_READY=no
fi

PANEL_ADMIN_USER="${PANEL_ADMIN_USER:-${ADMIN_USERNAME:-root}}"
# "root" is a system account already; a hosting account of that name cannot be
# created and should not be. The panel account gets its own name.
[ "$PANEL_ADMIN_USER" = "root" ] && PANEL_ADMIN_USER="admin"

# What the closing summary has to say about signing in to the hosting panel.
#
# It had nothing to say at all, which is how a fresh Community install finished
# with nobody able to log in: the password below is generated, and the only
# mention of it was a line halfway up the install scrolling past to say which
# file it had been written to. The summary underneath printed the *admin*
# panel's username against the *hosting* panel's address, so an operator who
# read only the last screen was told to sign in to :2083 as root - an account
# that does not exist there.
PANEL_ADMIN_STATE=missing

# Both sides are checked. A name present in the panel's users table but not in
# /etc/passwd is a half-created account, and running user-add over it produces
# a second row rather than repairing the first.
PANEL_USER_ROW=$(mysql --defaults-file=/etc/hitechcloud/hitechcloud/mysql.cnf -D panel -sN \
    -e "SELECT COUNT(*) FROM users WHERE username = '${PANEL_ADMIN_USER}';" 2>/dev/null || echo "")
if id -u "$PANEL_ADMIN_USER" >/dev/null 2>&1; then
    log "Hosting account '${PANEL_ADMIN_USER}' already exists"
    PANEL_ADMIN_STATE=existing
    [ "${PANEL_USER_ROW:-1}" = "0" ] && \
        warn "...but it has no row in the panel's users table. Check it with: hitechcloudcli user-list"
elif [ "${PANEL_USER_ROW:-0}" != "0" ] && [ -n "${PANEL_USER_ROW}" ]; then
    warn "'${PANEL_ADMIN_USER}' is in the panel's users table but has no system account."
    warn "Not creating it again - repair it with: hitechcloudcli user-check ${PANEL_ADMIN_USER}"
elif $DRY_RUN; then
    warn "would create the hosting account '${PANEL_ADMIN_USER}' on the ${ADMIN_PLAN} plan"
elif [ "$PLAN_READY" = no ]; then
    warn "The '${ADMIN_PLAN}' plan does not exist, so the hosting account was not created."
    warn "An account on a plan that is not there has no limits and no features,"
    warn "and nothing on the page says why. Create the plan, then run:"
    warn "  hitechcloudcli user-add ${PANEL_ADMIN_USER} <password> <email> '${ADMIN_PLAN}'"
else
    PANEL_ADMIN_PASSWORD="$(openssl rand -base64 18 | tr -d '/+=' | head -c 20)"
    if hitechcloudcli user-add "$PANEL_ADMIN_USER" "$PANEL_ADMIN_PASSWORD" \
        "${ADMIN_EMAIL:-admin@$(hostname -f 2>/dev/null || hostname)}" "$ADMIN_PLAN" >/dev/null 2>&1; then
        # Alongside the other credentials this installer generates, mode 0600
        # and outside the repository. Quoted, because this file is sourced back
        # at the top of every re-run.
        {
            echo "PANEL_ADMIN_USER=\"${PANEL_ADMIN_USER}\""
            echo "PANEL_ADMIN_PASSWORD=\"${PANEL_ADMIN_PASSWORD}\""
        } >> "$CRED_FILE"
        chmod 600 "$CRED_FILE"
        log "Hosting account '${PANEL_ADMIN_USER}' created on the ${ADMIN_PLAN} plan"
        PANEL_ADMIN_STATE=created
        # Deliberately not printed here. This is a quarter of the way from the
        # end of a run that takes several minutes and scrolls for hundreds of
        # lines; a password shown at this point has gone by the time anyone is
        # ready to use it. It is printed once, in the summary at the very end.
    else
        # The generated password belongs to no account, so nothing may report
        # it as one. Left set, the summary would print a password that has
        # never been anybody's.
        PANEL_ADMIN_PASSWORD=""
        warn "Could not create the administrator's hosting account. Create one with:"
        warn "  hitechcloudcli user-add <name> <password> <email> '${ADMIN_PLAN}'"
    fi
fi

# The dashboard reads every account's disk and inode usage out of a report
# file. Without something writing it, Storage and Inodes show "NaN%".
for unit in hitechcloud-quota.service hitechcloud-quota.timer; do
    [ -f "${RUNTIME_DIR}/configuration-native/systemd/${unit}" ] &&
        write_file "/etc/systemd/system/${unit}" 0644 < "${RUNTIME_DIR}/configuration-native/systemd/${unit}"
done
run systemctl daemon-reload
if $DRY_RUN; then
    warn "would enable hitechcloud-quota.timer"
else
    systemctl enable --now hitechcloud-quota.timer >/dev/null 2>&1 &&
        log "Disk and inode usage will be refreshed every 30 minutes" ||
        warn "hitechcloud-quota.timer could not be enabled - the dashboard's Storage figures will stay empty"
fi

# ---- 10. Podman, for user applications only --------------------------------
step "Installing Podman (user applications only)"
if [ "$MODE" = adopt ] && ! command -v podman >/dev/null 2>&1; then
    # An adopt updates what is here; it does not add a package the operator
    # has not got. --reinstall is the mode that installs it.
    warn "Podman is not installed and --adopt does not install it - App Store applications stay unavailable"
elif ! command -v podman >/dev/null 2>&1; then
    if $DRY_RUN; then
        warn "would install podman"
    elif [ "$OS_FAMILY" = debian ]; then
        apt-get install -y -qq podman crun uidmap slirp4netns fuse-overlayfs >/dev/null 2>&1 || warn "Podman could not be installed"
    else
        (command -v dnf >/dev/null && dnf install -y -q podman crun) >/dev/null 2>&1 || warn "Podman could not be installed"
    fi
fi
command -v podman >/dev/null 2>&1 && log "Podman available for the App Store" || warn "Podman missing - App Store applications will be unavailable"

# ---- 11. Multi-WebServer Hosting -------------------------------------------
# `enable` regenerates every website's configuration. On a machine with
# customers on it that is the single most dangerous thing this script does, so
# a dry run asks for the preflight instead - which reports and changes nothing -
# and a real run treats a failure as fatal rather than a warning. A site whose
# configuration did not regenerate is a website that is down.
step "Enabling Multi-WebServer Hosting"
if $DRY_RUN; then
    hitechcloudcli native-multiweb preflight 2>&1 | sed 's/^/    /' || \
        warn "preflight reported problems - see above; 'enable' would stop on them"
else
    # An empty array is expanded rather than passed as one empty argument;
    # `set -u` on bash 4.3 and older treats "${arr[@]}" of an empty array as an
    # unbound variable and kills the run right here.
    MULTIWEB_ARGS=""
    $FORCE && MULTIWEB_ARGS="--force"
    if ! hitechcloudcli native-multiweb enable ${MULTIWEB_ARGS}; then
        if [ "$EXISTING" = yes ]; then
            err "Multi-WebServer Hosting could not be enabled on a server that has websites
  on it. Their configuration may not have regenerated, which means sites are
  down. The pre-flight backup is at ${BACKUP_PATH:-${BACKUP_ROOT}/latest} and the
  previous web configuration at ${WEB_SNAPSHOT:-(not taken)}.

  Repair with:  hitechcloudcli native-multiweb repair
  If it refuses because generated files were edited by hand, that is the
  message to read - re-running with --force replaces them."
        fi
        warn "Multi-WebServer Hosting could not be enabled - run: hitechcloudcli native-multiweb repair"
    fi
fi

# The web configuration has to still parse. It has not always: a template that
# nginx rejects took every website on this product down once, which is why the
# snapshot above is taken and why this check exists rather than trusting the
# step that wrote the files.
if [ "$EXISTING" = yes ] && ! $DRY_RUN && command -v nginx >/dev/null 2>&1; then
    web_config_ok() {
        local ok=0
        [ -f /etc/hitechcloud/native/frontend/nginx.conf ] &&
            { nginx -p /etc/hitechcloud/native/frontend -c /etc/hitechcloud/native/frontend/nginx.conf -t >/dev/null 2>&1 || ok=1; }
        [ -f /etc/hitechcloud/native/nginx/nginx.conf ] &&
            { nginx -p /etc/hitechcloud/native/nginx -c /etc/hitechcloud/native/nginx/nginx.conf -t >/dev/null 2>&1 || ok=1; }
        return $ok
    }
    if web_config_ok; then
        log "Front-end and WebEngine configuration still parse"
    elif [ -n "$WEB_SNAPSHOT" ] && [ -d "$WEB_SNAPSHOT" ]; then
        warn "The web configuration this run produced does not parse - restoring the previous set"
        for d in templates frontend nginx apache openlitespeed; do
            [ -d "${WEB_SNAPSHOT}/$d" ] && rsync -a --delete "${WEB_SNAPSHOT}/$d/" "/etc/hitechcloud/native/$d/"
        done
        hitechcloudcli native-vhost rebuild all >/dev/null 2>&1 || true
        systemctl reload hitechcloud-frontend >/dev/null 2>&1 ||
            systemctl restart hitechcloud-frontend >/dev/null 2>&1 || true
        err "The web configuration produced by this run was rejected and the previous
  set has been put back from ${WEB_SNAPSHOT}. Check the sites are serving, then
  work out what this release changed before running it again."
    else
        err "The web configuration on this machine does not parse and there is no
  snapshot to restore. Check it with:
    nginx -p /etc/hitechcloud/native/frontend -c /etc/hitechcloud/native/frontend/nginx.conf -t"
    fi
fi

# ---- 11b. The panel's own certificate --------------------------------------
#
# The installer used to finish here with the panel answering on plain HTTP and
# a line in the admin's log suggesting the operator point a DNS record and
# issue a certificate themselves. Nobody reads a service log after an install,
# so what actually happened is that administrators typed their password, and
# their customers typed their mailbox passwords into webmail, over an
# unencrypted connection.
#
# Two outcomes, both encrypted. If the hostname resolves to this machine, a
# trusted certificate; if it does not - which is the normal state of a server
# whose DNS has not been pointed yet - a self-signed one covering the hostname
# and this machine's addresses, replaced automatically the first time the real
# one can be issued. Never fatal: a certificate that could not be obtained is
# a warning, not a reason to abandon an otherwise complete installation.
step "Securing the panel"
if $DRY_RUN; then
    warn "would run: hitechcloudcli native-ssl panel"
    warn "would re-configure phpMyAdmin and webmail against the new certificate"
else
    hitechcloudcli native-ssl panel || warn "The panel has no certificate yet; run: hitechcloudcli native-ssl panel"

    # phpMyAdmin and webmail are set up in step 6, which is four hundred lines
    # and several minutes before this one. On a first install there is no
    # certificate for the hostname yet when they are configured, so both fall
    # back to plain HTTP - and stay there, because nothing came back to them
    # once one existed.
    #
    # The webmail installer says so, in a warning among fifty other lines.
    # phpMyAdmin says nothing at all, and a database password typed into it
    # crosses the network in the clear on a server that has a perfectly good
    # certificate sitting on disk. Two fresh installs, Ubuntu and AlmaLinux,
    # ended with phpMyAdmin on http and no indication anything was wrong.
    #
    # Both are idempotent, so this is a no-op on a server that already had a
    # certificate when they were installed.
    if [ -f /etc/hitechcloud/ssl/certs/hostname.crt ] || hitechcloudcli native-ssl status >/dev/null 2>&1; then
        hitechcloudcli native-phpmyadmin configure >/dev/null 2>&1 &&
            log "phpMyAdmin re-configured against the hostname certificate" ||
            warn "phpMyAdmin could not be re-configured; it may still be served over plain HTTP. Run: hitechcloudcli native-phpmyadmin configure"
        hitechcloudcli native-webmail configure >/dev/null 2>&1 &&
            log "Webmail re-configured against the hostname certificate" ||
            warn "Webmail could not be re-configured; it may still be served over plain HTTP. Run: hitechcloudcli native-webmail configure"
    fi
fi

# ---- 12. Per-account resource metering -------------------------------------
# The dashboard's CPU and Memory rows and the whole usage-history page are read
# out of /home/<account>/resource_usage.txt. Nothing writes that file unless
# this is scheduled, and the rows sit on "Calculating.." until something does.
#
# This used to be a line in /etc/cron.d/hitechcloud. It is a timer now, like
# AutoSSL, the quota report and the Git deployment poll: one place to look at
# when it last ran, why the last run failed, and how long it took.
step "Scheduling per-account resource metering"
for unit in hitechcloud-usage.service hitechcloud-usage.timer; do
    [ -f "${RUNTIME_DIR}/configuration-native/systemd/${unit}" ] &&
        write_file "/etc/systemd/system/${unit}" 0644 < "${RUNTIME_DIR}/configuration-native/systemd/${unit}"
done
run systemctl daemon-reload

# ---- 12b. Automatic updates -------------------------------------------------
#
# Nothing ran them. The panel has had an "Automatic updates" preference with
# four settings and a page to choose between them since before the native
# stack, and the only thing that ever acted on it was a line in a crontab the
# container architecture installed. Moving to systemd timers left it behind, so
# on every native installation the preference was recorded and never read.
#
# `hitechcloudcli update` without --force is the command that honours it: it
# reads autopatch and autoupdate and does nothing when both are off. So the
# timer is enabled unconditionally - the preference, not this unit, is the
# switch.
step "Scheduling automatic updates"
for unit in hitechcloud-update.service hitechcloud-update.timer; do
    [ -f "${RUNTIME_DIR}/configuration-native/systemd/${unit}" ] &&
        write_file "/etc/systemd/system/${unit}" 0644 < "${RUNTIME_DIR}/configuration-native/systemd/${unit}"
done
run systemctl daemon-reload
if $DRY_RUN; then
    warn "would enable hitechcloud-update.timer"
elif systemctl enable --now hitechcloud-update.timer >/dev/null 2>&1; then
    log "Updates are checked daily; the Automatic Updates page decides what is applied"
else
    warn "hitechcloud-update.timer could not be enabled; automatic updates will not run"
fi

# hitechadmin's System Crons page manages this file and reports it as missing
# when it is not there, so a fresh install still creates it - just without the
# sampler, which is a systemd timer now.
if [ ! -f /etc/cron.d/hitechcloud ]; then
    write_file /etc/cron.d/hitechcloud 0644 <<'CRON'
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
CRON
fi

# An upgrade from a release that scheduled the sampler from cron would
# otherwise sample twice a minute, each run halving the other's measurement
# window. Only that one line is taken out, and only when it is there.
if grep -q 'hitechcloudcli native-usage collect' /etc/cron.d/hitechcloud 2>/dev/null; then
    run sed -i '\|hitechcloudcli native-usage collect|d' /etc/cron.d/hitechcloud
    did "remove the cron-scheduled usage sampler (the systemd timer replaces it)"
fi

if $DRY_RUN; then
    warn "would enable hitechcloud-usage.timer"
else
    systemctl enable --now hitechcloud-usage.timer >/dev/null 2>&1 &&
        log "CPU and memory will be sampled for every account once a minute" ||
        warn "hitechcloud-usage.timer could not be enabled - the dashboard's CPU and Memory rows will stay at 0%"
fi

# ---- 12b. Nothing was lost -------------------------------------------------
# The counts taken before this run, compared with the counts now. It is the
# only part of this script that can say "the customer data is still there" as
# something other than an assumption.
if [ -n "$BACKUP_PATH" ] && [ -f "${BACKUP_PATH}/manifest.env" ] && [ -f "$ADOPT_TOOL" ]; then
    step "Checking nothing was lost"
    COMPARE_LOG="$(mktemp)"
    if bash "$ADOPT_TOOL" --compare "${BACKUP_PATH}/manifest.env" >"$COMPARE_LOG" 2>&1; then
        sed 's/^/  /' "$COMPARE_LOG"
        rm -f "$COMPARE_LOG"
    else
        sed 's/^/  /' "$COMPARE_LOG" >&2
        rm -f "$COMPARE_LOG"
        err "Something that existed before this run does not exist now. The pre-flight
  backup is at ${BACKUP_PATH}."
    fi
fi

# ---- 13. Summary -----------------------------------------------------------
echo
if $DRY_RUN; then
    echo -e "${YELLOW}╔══════════════════════════════════════════════════════════╗${NC}"
    echo -e "${YELLOW}║        DRY RUN - nothing on this machine changed         ║${NC}"
    echo -e "${YELLOW}╚══════════════════════════════════════════════════════════╝${NC}"
    echo
    if [ -n "$CHANGED_FILES" ]; then
        echo "  Files a real run would create or rewrite:"
        printf '%s' "$CHANGED_FILES" | sed '/^$/d' | sort -u | sed 's/^/    /'
    else
        echo "  No configuration file would change."
    fi
    echo
    echo "  Run it for real with:  bash INSTALL.sh --adopt"
    echo
    exit 0
fi

# The last step has no successor to close it, so it is closed here - otherwise
# the slowest phase of the install is the one missing from the record.
step_finish

echo -e "${GREEN}╔══════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║        HiTechCloud Panel ${VERSION} installed (native)         ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}"
echo
# Two panels, two sign-ins, and they do not share an account.
#
# This printed one address, then one username, then one password, as though the
# three belonged together. They never did: the username was the *administrator*,
# who signs in at :2087 with this machine's own root password, while the
# address at the top was the *hosting* panel at :2083, whose account is a
# different name with a generated password that this summary did not print at
# all. A fresh Community install therefore ended with an operator holding
# credentials for a page they had not been shown and no credentials for the one
# they had - which reads, from the login form, as an install that produced no
# account at all.
#
# So each panel is printed with the credentials that actually open it, and the
# generated password is shown here, once, at the end, and nowhere else.
if [ -x /usr/local/bin/hitechadmin ]; then
    echo "  Server administration"
    printf '    %-12s https://%s:%s\n' "Address"  "${SERVER_IP}" "${ADMIN_PORT}"
    printf '    %-12s %s\n'            "Username" "${ADMIN_USERNAME:-root}"
    printf '    %-12s %s\n'            "Password" "this server's own ${ADMIN_USERNAME:-root} password"
    echo
fi

echo "  Hosting control panel"
printf '    %-12s https://%s:%s\n' "Address" "${SERVER_IP}" "${PANEL_PORT}"
case "${PANEL_ADMIN_STATE:-missing}" in
    created)
        printf '    %-12s %s\n' "Username" "${PANEL_ADMIN_USER}"
        printf '    %-12s %s\n' "Password" "${PANEL_ADMIN_PASSWORD:-}"
        echo
        echo "  The password above was generated by this install and is shown here only."
        echo "  Write it down now. A copy is at ${CRED_FILE}, readable by root alone;"
        echo "  change it whenever you like with:"
        echo "    hitechcloudcli user-password ${PANEL_ADMIN_USER} <new-password>"
        ;;
    existing)
        printf '    %-12s %s\n' "Username" "${PANEL_ADMIN_USER}"
        # A re-run does not generate a password, so there is nothing new to
        # show - but an operator who missed it the first time needs a way back
        # in that is not "reinstall", and one that does not involve reading a
        # file whose name they would also have to be told.
        if [ -n "${PANEL_ADMIN_PASSWORD:-}" ]; then
            printf '    %-12s %s\n' "Password" "${PANEL_ADMIN_PASSWORD}"
            echo
            echo "  That is the password this account was created with, unchanged since."
        else
            printf '    %-12s %s\n' "Password" "unchanged - this account was not created by this run"
            echo
            echo "  Set a new one with:"
            echo "    hitechcloudcli user-password ${PANEL_ADMIN_USER} <new-password>"
        fi
        ;;
    *)
        printf '    %-12s %s\n' "Username" "none - no hosting account was created"
        echo
        echo "  Nobody can sign in to the hosting panel until one exists. Create it with:"
        echo "    hitechcloudcli user-add ${PANEL_ADMIN_USER} <password> <email> '${ADMIN_PLAN}'"
        ;;
esac
echo
printf '  %-14s %s\n' "Credentials" "${CRED_FILE} (root-only)"
[ -n "$BACKUP_PATH" ] && printf '  %-14s %s\n' "Backup" "${BACKUP_PATH}"
echo
echo "  Architecture:"
hitechcloudcli native-multiweb status 2>/dev/null | sed 's/^/  /'
echo
echo "  Useful commands:"
echo "    hitechcloudcli native-multiweb status        architecture and service state"
echo "    hitechcloudcli native-engine set <d> apache  give one website its own WebEngine"
echo "    hitechcloudcli native-ssl issue <domain>     get a Let's Encrypt certificate"
echo "    hitechcloudcli native-service list           every backend service"
echo
