#!/bin/sh set -eu umask 077 PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C export PATH LC_ALL unset CDPATH ENV BASH_ENV PERL5LIB PERLLIB PERL5OPT \ http_proxy https_proxy ftp_proxy all_proxy \ HTTP_PROXY HTTPS_PROXY FTP_PROXY ALL_PROXY NO_PROXY no_proxy TMPDIR || true DCSF_PUBLIC_RELEASE="DH20.01" DCSF_EXPECTED_VERSION="15.10.4" DCSF_EXPECTED_SOURCE_COMMIT="fbceb3467ea5bbbd3d01df9c1ff3b70f7546767e" DCSF_EXPECTED_ARTIFACT_SHA256="d3bad5a7cabf03288d65753de5e3817f4b9b5309d13485376b1e4fc896728dfe" DCSF_EXPECTED_ARTIFACT_SIZE="5712959" DCSF_EXPECTED_INVENTORY_SHA256="a29494c33ab8c12421b3b2f8354386ed8ef56e1339b836456887f0994ad2bf18" DCSF_EXPECTED_INVENTORY_ENTRIES="536" DCSF_UPDATE_BASE_URL="https://update.dcsf.net/v1" DCSF_UPDATE_CHANNEL="stable" DCSF_UPDATE_KEY_ID="dcsf-release-2026-09-rsa3072" DCSF_UPDATE_KEY_FILE="dcsf-release-2026-09-rsa3072.pub" DCSF_UPDATE_KEY_SHA256="b65c53cc4b38d3bf5217273808c258a0410bb6b642c05e190cb26585e9779e03" DCSF_INVENTORY_NAME="dcsf-package.inventory" DCSF_CHECKER_NAME="dcsf-package-inventory.pl" DCSF_PAYLOAD_CAPABILITIES="dcsf-installer-v1 dryrun detect testing production" DCSF_MAX_KEY_BYTES=8192 DCSF_MAX_MANIFEST_BYTES=16384 DCSF_MAX_ARTIFACT_BYTES=134217728 DCSF_MAX_ARCHIVE_ENTRIES=25000 DCSF_CPANEL_CONFIG_NAMES="csf.conf csf.allow csf.deny csf.ignore csf.pignore csf.fignore csf.signore csf.rignore csf.mignore csf.suignore csf.sips csf.smtpauth csf.syslogs csf.syslogusers csf.uidignore csf.dyndns csf.dirwatch csf.logfiles csf.logignore csf.blocklists csf.cloudflare csf.rblconf csf.redirect csf.resellers" profile="" operation="install" assume_yes=0 install_kind="fresh" installed_version="" legacy_version="" cpanel_rpm_nvr="" cpanel_rpm_removed=0 migration_backup="" cpanel_config_backup="" firewall_backup_v4="" firewall_backup_v6="" firewall_snapshot_count=0 workdir="" work_parent="/var/lib/dcsf-bootstrap" work_parent_created=0 lockdir="/run/lock/dcsf-bootstrap.lock" lock_acquired=0 say() { printf '%s\n' "DCSF bootstrap: $*" } warn() { printf '%s\n' "DCSF bootstrap warning: $*" >&2 } fail() { printf '%s\n' "DCSF bootstrap error: $*" >&2 exit 1 } usage() { cat <<'EOF' DataHouse CSF DH20.01 public installer Usage: sh install.sh --testing [--dryrun] sh install.sh --production [--dryrun] sh install.sh [--yes] # detected upgrade or migration sh install.sh --testing --detect Profiles: --testing Install with TESTING=1. Required for a fresh first run. --production Install with TESTING=0. Required for a fresh first run. Existing valid configuration is preserved when omitted. Modes: --dryrun Verify the release and simulate package installation. --detect Verify the release and report the selected package installer. --yes Confirm an existing-installation upgrade non-interactively. --version Print the public and technical release identifiers. --help Show this help. The installer accepts only the signed DCSF 15.10.4 package mapped to public release DH20.01. It detects an existing DCSF or CSF installation, asks before an upgrade, and migrates cPanel's cpanel-csf RPM only after package validation. EOF } cleanup() { if [ -n "${workdir}" ] && [ -d "${workdir}" ]; then case "${workdir}" in /var/lib/dcsf-bootstrap/work.*) rm -rf -- "${workdir}" ;; *) printf '%s\n' "DCSF bootstrap warning: refusing unexpected cleanup path: ${workdir}" >&2 ;; esac fi if [ "${work_parent_created}" -eq 1 ] && [ -d "${work_parent}" ]; then rmdir -- "${work_parent}" 2>/dev/null || true fi if [ "${lock_acquired}" -eq 1 ] && [ -d "${lockdir}" ]; then rmdir -- "${lockdir}" 2>/dev/null || true fi } trap cleanup 0 1 2 15 while [ "$#" -gt 0 ]; do case "$1" in --testing|--production) requested_profile=${1#--} [ -z "${profile}" ] || fail "select only one installation profile" profile="${requested_profile}" ;; --dryrun) [ "${operation}" = "install" ] || fail "select only one operation mode" operation="dryrun" ;; --detect) [ "${operation}" = "install" ] || fail "select only one operation mode" operation="detect" ;; --yes) assume_yes=1 ;; --version) [ "$#" -eq 1 ] || fail "--version cannot be combined with another argument" printf '%s\n' "${DCSF_PUBLIC_RELEASE} (DCSF ${DCSF_EXPECTED_VERSION})" exit 0 ;; -h|--help) [ "$#" -eq 1 ] || fail "--help cannot be combined with another argument" usage exit 0 ;; *) usage >&2 fail "unsupported argument: $1" ;; esac shift done [ "$(id -u)" -eq 0 ] || fail "run this installer as root" for required in awk base64 cat chmod chown cp curl date grep id mkdir mktemp mv openssl perl rm rmdir sed sha256sum stat tar tr wc; do command -v "${required}" >/dev/null 2>&1 || fail "required command not found: ${required}" done compare_versions() { awk -v current="$1" -v available="$2" 'BEGIN { split(current, c, "."); split(available, a, "."); for (i = 1; i <= 3; i++) { if ((c[i] + 0) < (a[i] + 0)) { print -1; exit } if ((c[i] + 0) > (a[i] + 0)) { print 1; exit } } print 0 }' } if command -v rpm >/dev/null 2>&1 && rpm -q cpanel-csf >/dev/null 2>&1; then [ -r /usr/local/cpanel/version ] \ || fail "cpanel-csf RPM is installed but the cPanel version marker is missing" cpanel_rpm_nvr=$(rpm -q --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}' cpanel-csf 2>/dev/null) \ || fail "cannot identify the installed cpanel-csf RPM" printf '%s\n' "${cpanel_rpm_nvr}" | grep -Eq '^cpanel-csf-[A-Za-z0-9._+~-]{1,96}$' \ || fail "installed cpanel-csf package identity is invalid" install_kind="cpanel-rpm" say "detected cPanel-managed CSF package ${cpanel_rpm_nvr}" elif [ -e /etc/csf/dcsf-version.txt ] || [ -L /etc/csf/dcsf-version.txt ]; then [ -f /etc/csf/dcsf-version.txt ] && [ ! -L /etc/csf/dcsf-version.txt ] \ || fail "existing DCSF version marker is unsafe" installed_version=$(sed -n '1{s/[[:space:]]//g;p;}' /etc/csf/dcsf-version.txt) printf '%s\n' "${installed_version}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ || fail "existing DCSF version marker is invalid" version_comparison=$(compare_versions "${installed_version}" "${DCSF_EXPECTED_VERSION}") [ "${version_comparison}" -le 0 ] \ || fail "refusing rollback from installed DCSF ${installed_version} to ${DCSF_EXPECTED_VERSION}" install_kind="dcsf" if [ "${version_comparison}" -eq 0 ]; then say "detected installed DCSF ${installed_version}; ${DCSF_PUBLIC_RELEASE} would reinstall the current technical release" else say "detected installed DCSF ${installed_version}; ${DCSF_PUBLIC_RELEASE} will upgrade it to ${DCSF_EXPECTED_VERSION}" fi elif [ -e /usr/local/sbin/dcsf-update ] || [ -L /usr/local/sbin/dcsf-update ]; then fail "DCSF update client exists without a valid version marker; repair the installation before continuing" elif [ -e /etc/csf/csf.conf ] || [ -e /usr/sbin/csf ] || [ -d /usr/local/csf ]; then [ ! -L /etc/csf/csf.conf ] || fail "existing CSF configuration is a symbolic link" install_kind="legacy-csf" if [ -f /etc/csf/version.txt ] && [ ! -L /etc/csf/version.txt ]; then legacy_version=$(sed -n '1{s/[[:space:]]//g;p;}' /etc/csf/version.txt) printf '%s\n' "${legacy_version}" | grep -Eq '^[A-Za-z0-9._+-]{1,32}$' || legacy_version="unknown" else legacy_version="unknown" fi say "detected an existing non-DCSF CSF installation (version=${legacy_version})" fi if [ "${install_kind}" = "fresh" ] && [ -z "${profile}" ]; then usage >&2 fail "fresh installation requires exactly one profile: --testing or --production" fi [ -d /run/lock ] || fail "required lock directory is missing: /run/lock" [ -d /var/lib ] && [ ! -L /var/lib ] || fail "required state directory is unsafe: /var/lib" if [ -e "${work_parent}" ] || [ -L "${work_parent}" ]; then [ -d "${work_parent}" ] && [ ! -L "${work_parent}" ] \ || fail "private work parent is unsafe: ${work_parent}" [ "$(stat -Lc '%u' -- "${work_parent}")" -eq 0 ] \ || fail "private work parent is not root-owned: ${work_parent}" work_parent_mode=$(stat -Lc '%a' -- "${work_parent}") \ || fail "cannot inspect the private work parent" case "${work_parent_mode}" in ''|*[!0-7]*) fail "private work parent mode is invalid" ;; esac [ $((0${work_parent_mode} & 0022)) -eq 0 ] \ || fail "private work parent is group- or world-writable" else mkdir -- "${work_parent}" || fail "cannot create the private work parent" chmod 0700 "${work_parent}" work_parent_created=1 fi if ! mkdir -- "${lockdir}" 2>/dev/null; then fail "another DCSF bootstrap operation is already running (${lockdir})" fi lock_acquired=1 workdir=$(mktemp -d "${work_parent}/work.XXXXXXXX") || fail "cannot create a private work directory" [ -d "${workdir}" ] && [ ! -L "${workdir}" ] || fail "private work path is unsafe" chmod 0700 "${workdir}" channel_url="${DCSF_UPDATE_BASE_URL}/${DCSF_UPDATE_CHANNEL}" fetch() { url="$1" destination="$2" timeout="$3" max_size="$4" case "${url}" in "${channel_url}/"*) ;; *) fail "refusing URL outside the pinned update channel" ;; esac curl --disable --silent --show-error --fail \ --proto '=https' --tlsv1.2 \ --connect-timeout 10 --max-time "${timeout}" \ --max-filesize "${max_size}" \ --output "${destination}" "${url}" \ || fail "download failed: ${url}" } file_size() { wc -c < "$1" | tr -d '[:space:]' } public_key="${workdir}/${DCSF_UPDATE_KEY_FILE}" public_der="${workdir}/trusted-key.der" manifest="${workdir}/manifest.txt" signature_b64="${workdir}/manifest.txt.sig" signature_bin="${workdir}/manifest.sig.bin" fetch "${channel_url}/${DCSF_UPDATE_KEY_FILE}" "${public_key}" 45 "${DCSF_MAX_KEY_BYTES}" fetch "${channel_url}/manifest.txt" "${manifest}" 45 "${DCSF_MAX_MANIFEST_BYTES}" fetch "${channel_url}/manifest.txt.sig" "${signature_b64}" 45 1024 [ "$(file_size "${public_key}")" -le "${DCSF_MAX_KEY_BYTES}" ] || fail "public key exceeds the size limit" openssl pkey -pubin -in "${public_key}" -outform DER -out "${public_der}" >/dev/null 2>&1 \ || fail "release public key cannot be parsed" actual_key_sha256=$(sha256sum "${public_der}" | awk '{print $1}') [ "${actual_key_sha256}" = "${DCSF_UPDATE_KEY_SHA256}" ] \ || fail "release public key fingerprint does not match this installer" manifest_size=$(file_size "${manifest}") [ "${manifest_size}" -gt 0 ] || fail "manifest is empty" [ "${manifest_size}" -le "${DCSF_MAX_MANIFEST_BYTES}" ] || fail "manifest exceeds the size limit" LC_ALL=C grep -q '[^ -~]' "${manifest}" && fail "manifest contains non-ASCII or control characters" LC_ALL=C grep -Eq '^[A-Za-z0-9_]+=[ -~]+$' "${manifest}" \ || fail "manifest contains an invalid line" base64 -d "${signature_b64}" > "${signature_bin}" 2>/dev/null \ || fail "manifest signature is not valid base64" [ "$(file_size "${signature_bin}")" -eq 384 ] || fail "manifest signature has an invalid length" openssl dgst -sha256 -verify "${public_der}" -keyform DER \ -signature "${signature_bin}" "${manifest}" >/dev/null 2>&1 \ || fail "manifest signature verification failed" manifest_value() { field="$1" count=$(awk -F= -v key="${field}" '$1 == key {count++} END {print count + 0}' "${manifest}") [ "${count}" -eq 1 ] || fail "manifest field must occur exactly once: ${field}" awk -F= -v key="${field}" '$1 == key {print substr($0, length(key) + 2)}' "${manifest}" } while IFS= read -r line || [ -n "${line}" ]; do name=${line%%=*} case "${name}" in format|channel|version|artifact|sha256|size|inventory|inventory_sha256|inventory_entries|source_commit|published|key_id) ;; *) fail "manifest contains an unsupported field: ${name}" ;; esac done < "${manifest}" format=$(manifest_value format) channel=$(manifest_value channel) available_version=$(manifest_value version) artifact_name=$(manifest_value artifact) expected_sha256=$(manifest_value sha256) expected_size=$(manifest_value size) inventory_name=$(manifest_value inventory) inventory_sha256=$(manifest_value inventory_sha256) inventory_entries=$(manifest_value inventory_entries) source_commit=$(manifest_value source_commit) published=$(manifest_value published) key_id=$(manifest_value key_id) [ "${format}" = "dcsf-update-v2" ] || fail "unsupported manifest format: ${format}" [ "${channel}" = "${DCSF_UPDATE_CHANNEL}" ] || fail "manifest channel mismatch" [ "${key_id}" = "${DCSF_UPDATE_KEY_ID}" ] || fail "manifest key identifier mismatch" printf '%s\n' "${available_version}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ || fail "invalid release version: ${available_version}" [ "${available_version}" = "${DCSF_EXPECTED_VERSION}" ] \ || fail "manifest is not the ${DCSF_PUBLIC_RELEASE} technical release" [ "${artifact_name}" = "dcsf-${DCSF_EXPECTED_VERSION}.tar.gz" ] \ || fail "artifact name does not match the release version" printf '%s\n' "${expected_sha256}" | grep -Eq '^[0-9a-f]{64}$' || fail "invalid artifact SHA-256" [ "${expected_sha256}" = "${DCSF_EXPECTED_ARTIFACT_SHA256}" ] || fail "artifact digest is not pinned to ${DCSF_PUBLIC_RELEASE}" printf '%s\n' "${expected_size}" | grep -Eq '^[0-9]+$' || fail "invalid artifact size" [ "${expected_size}" = "${DCSF_EXPECTED_ARTIFACT_SIZE}" ] || fail "artifact size is not pinned to ${DCSF_PUBLIC_RELEASE}" [ "${expected_size}" -gt 0 ] && [ "${expected_size}" -le "${DCSF_MAX_ARTIFACT_BYTES}" ] \ || fail "artifact size is outside the accepted range" [ "${inventory_name}" = "${DCSF_INVENTORY_NAME}" ] || fail "manifest package inventory name is invalid" printf '%s\n' "${inventory_sha256}" | grep -Eq '^[0-9a-f]{64}$' || fail "invalid package inventory SHA-256" [ "${inventory_sha256}" = "${DCSF_EXPECTED_INVENTORY_SHA256}" ] || fail "package inventory digest is not pinned to ${DCSF_PUBLIC_RELEASE}" printf '%s\n' "${inventory_entries}" | grep -Eq '^[0-9]{1,5}$' || fail "invalid package inventory entry count" [ "${inventory_entries}" = "${DCSF_EXPECTED_INVENTORY_ENTRIES}" ] || fail "package inventory count is not pinned to ${DCSF_PUBLIC_RELEASE}" [ "${inventory_entries}" -gt 0 ] && [ "${inventory_entries}" -le "${DCSF_MAX_ARCHIVE_ENTRIES}" ] \ || fail "package inventory entry count is outside the accepted range" printf '%s\n' "${source_commit}" | grep -Eq '^[0-9a-f]{40}$' || fail "invalid source commit identifier" [ "${source_commit}" = "${DCSF_EXPECTED_SOURCE_COMMIT}" ] || fail "source commit is not pinned to ${DCSF_PUBLIC_RELEASE}" printf '%s\n' "${published}" | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$' \ || fail "invalid publication timestamp" say "signed manifest verified (${DCSF_UPDATE_KEY_ID})" say "public=${DCSF_PUBLIC_RELEASE} technical=${available_version} source=${source_commit}" artifact_path="${workdir}/${artifact_name}" fetch "${channel_url}/${artifact_name}" "${artifact_path}" 600 "${expected_size}" actual_size=$(file_size "${artifact_path}") [ "${actual_size}" = "${expected_size}" ] \ || fail "artifact size mismatch: expected ${expected_size}, got ${actual_size}" actual_sha256=$(sha256sum "${artifact_path}" | awk '{print $1}') [ "${actual_sha256}" = "${expected_sha256}" ] || fail "artifact SHA-256 mismatch" archive_list="${workdir}/archive.list" archive_types="${workdir}/archive.types" topdir="dcsf-${available_version}" tar -tzf "${artifact_path}" > "${archive_list}" || fail "cannot list the authenticated archive" entry_count=$(wc -l < "${archive_list}" | tr -d '[:space:]') [ "${entry_count}" -gt 0 ] || fail "authenticated archive is empty" [ "${entry_count}" -le "${DCSF_MAX_ARCHIVE_ENTRIES}" ] || fail "archive contains too many entries" LC_ALL=C grep -q '[^ -~]' "${archive_list}" && fail "archive contains a non-ASCII path" while IFS= read -r entry || [ -n "${entry}" ]; do case "${entry}" in "${topdir}"|"${topdir}/"|"${topdir}/"*) ;; *) fail "archive entry escapes the expected top-level directory: ${entry}" ;; esac case "/${entry}/" in */../*|*/./*|*\\*) fail "archive contains an unsafe path: ${entry}" ;; esac done < "${archive_list}" tar -tvzf "${artifact_path}" > "${archive_types}" || fail "cannot inspect archive entry types" awk 'substr($0, 1, 1) != "-" && substr($0, 1, 1) != "d" { exit 1 }' "${archive_types}" \ || fail "archive contains links, devices, or another unsupported entry type" inventory_path="${workdir}/${DCSF_INVENTORY_NAME}" checker_path="${workdir}/${DCSF_CHECKER_NAME}" tar -xOzf "${artifact_path}" "${topdir}/${DCSF_INVENTORY_NAME}" > "${inventory_path}" \ || fail "cannot read the authenticated package inventory" tar -xOzf "${artifact_path}" "${topdir}/${DCSF_CHECKER_NAME}" > "${checker_path}" \ || fail "cannot read the authenticated package inventory checker" [ "$(sha256sum "${inventory_path}" | awk '{print $1}')" = "${inventory_sha256}" ] \ || fail "package inventory SHA-256 differs from the signed manifest" chmod 0600 "${inventory_path}" chmod 0700 "${checker_path}" [ "$(stat -Lc '%u' -- "${checker_path}")" -eq 0 ] || fail "authenticated package checker is not root-owned" [ "$(stat -Lc '%h' -- "${checker_path}")" -eq 1 ] || fail "authenticated package checker has an unsafe link count" exec 9< "${checker_path}" || fail "cannot open the authenticated package checker" checker_fd="/proc/$$/fd/9" [ -f "${checker_fd}" ] || fail "authenticated package checker descriptor is invalid" /usr/bin/perl -T "${checker_fd}" validate \ --inventory "${inventory_path}" \ --expected-sha256 "${inventory_sha256}" \ --expected-count "${inventory_entries}" \ || fail "authenticated package inventory is invalid" /usr/bin/perl -T "${checker_fd}" verify-listing \ --archive-list "${archive_list}" \ --archive-types "${archive_types}" \ --top "${topdir}" \ --inventory "${inventory_path}" \ --expected-sha256 "${inventory_sha256}" \ --expected-count "${inventory_entries}" \ || fail "authenticated archive does not match its closed package inventory" extract_root="${workdir}/extract" mkdir -p -- "${extract_root}" tar --no-same-owner --no-same-permissions -xzf "${artifact_path}" -C "${extract_root}" \ || fail "cannot extract the authenticated archive" payload_root="${extract_root}/${topdir}" [ -d "${payload_root}" ] || fail "expected package root is missing" [ -f "${payload_root}/install.sh" ] && [ ! -L "${payload_root}/install.sh" ] \ || fail "authenticated package does not contain a regular install.sh" [ -f "${payload_root}/dcsf-version.txt" ] && [ ! -L "${payload_root}/dcsf-version.txt" ] \ || fail "authenticated package does not contain dcsf-version.txt" package_version=$(sed -n '1{s/[[:space:]]//g;p;}' "${payload_root}/dcsf-version.txt") [ "${package_version}" = "${available_version}" ] || fail "package version does not match the manifest" /usr/bin/perl -T "${checker_fd}" verify-tree \ --root "${payload_root}" \ --inventory "${inventory_path}" \ --expected-sha256 "${inventory_sha256}" \ --expected-count "${inventory_entries}" \ || fail "extracted package does not match its closed package inventory" exec 9<&- payload_capability_error="${workdir}/payload-capabilities.error" if payload_capabilities=$( cd "${payload_root}" DCSF_VERIFIED_UPDATE=1 DCSF_UPDATE_VERSION="${available_version}" \ /bin/sh ./install.sh --capabilities 2> "${payload_capability_error}" ); then [ "${payload_capabilities}" = "${DCSF_PAYLOAD_CAPABILITIES}" ] \ || fail "verified package installer capability contract mismatch" say "verified payload capability contract (${DCSF_PAYLOAD_CAPABILITIES})" elif [ "${available_version}" = "15.10.4" ] \ && [ "${source_commit}" = "fbceb3467ea5bbbd3d01df9c1ff3b70f7546767e" ] \ && [ "${actual_sha256}" = "d3bad5a7cabf03288d65753de5e3817f4b9b5309d13485376b1e4fc896728dfe" ]; then # DH20.01 predates --capabilities, but its exact signed bytes have a # release-qualified dryrun/detect/profile contract. No other legacy # package is accepted through this compatibility path. say "verified exact DH20.01 legacy payload contract by pinned SHA-256" else [ ! -s "${payload_capability_error}" ] || cat "${payload_capability_error}" >&2 fail "verified package does not expose the required installer capability contract" fi invoke_payload() { payload_operation="$1" ( cd "${payload_root}" case "${payload_operation}:${profile}" in install:) DCSF_VERIFIED_UPDATE=1 DCSF_UPDATE_VERSION="${available_version}" /bin/sh ./install.sh ;; install:testing|install:production) DCSF_VERIFIED_UPDATE=1 DCSF_UPDATE_VERSION="${available_version}" /bin/sh ./install.sh "--${profile}" ;; dryrun:) DCSF_VERIFIED_UPDATE=1 DCSF_UPDATE_VERSION="${available_version}" /bin/sh ./install.sh --dryrun ;; dryrun:testing|dryrun:production) DCSF_VERIFIED_UPDATE=1 DCSF_UPDATE_VERSION="${available_version}" /bin/sh ./install.sh "--${profile}" --dryrun ;; detect:) DCSF_VERIFIED_UPDATE=1 DCSF_UPDATE_VERSION="${available_version}" /bin/sh ./install.sh --detect ;; detect:testing|detect:production) DCSF_VERIFIED_UPDATE=1 DCSF_UPDATE_VERSION="${available_version}" /bin/sh ./install.sh "--${profile}" --detect ;; *) exit 1 ;; esac ) } confirm_existing_install() { [ "${operation}" = "install" ] || return 0 [ "${install_kind}" != "fresh" ] || return 0 case "${install_kind}" in dcsf) description="upgrade installed DCSF ${installed_version} to ${DCSF_EXPECTED_VERSION}" [ "${installed_version}" != "${DCSF_EXPECTED_VERSION}" ] \ || description="reinstall DCSF ${DCSF_EXPECTED_VERSION}" ;; cpanel-rpm) description="remove ${cpanel_rpm_nvr} and migrate its configuration to DCSF ${DCSF_EXPECTED_VERSION}" ;; legacy-csf) description="upgrade the existing CSF ${legacy_version} installation to DCSF ${DCSF_EXPECTED_VERSION}" ;; *) fail "unsupported existing installation type: ${install_kind}" ;; esac if [ "${assume_yes}" -eq 1 ]; then say "upgrade confirmed by --yes: ${description}" return 0 fi if [ -c /dev/tty ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then printf '%s' "DCSF bootstrap: ${description}. Continue? [y/N] " > /dev/tty answer="" IFS= read -r answer < /dev/tty || true case "${answer}" in y|Y|yes|YES|Yes) say "upgrade confirmed" return 0 ;; esac fail "upgrade cancelled; no installed files were changed" fi fail "an existing installation was detected; run again interactively or pass --yes" } snapshot_firewall_family() { label="$1" save_command="$2" restore_command="$3" destination="$4" "${save_command}" > "${destination}" \ || fail "cannot save the existing ${label} firewall rules" [ -s "${destination}" ] || fail "the existing ${label} firewall snapshot is empty" "${restore_command}" --test < "${destination}" >/dev/null 2>&1 \ || fail "the existing ${label} firewall snapshot cannot be validated" chmod 0600 "${destination}" } snapshot_cpanel_configuration() { [ "${install_kind}" = "cpanel-rpm" ] || return 0 [ -d /etc/csf ] && [ ! -L /etc/csf ] \ || fail "cpanel-csf configuration directory is missing or unsafe" [ "$(stat -Lc '%u' -- /etc/csf)" -eq 0 ] \ || fail "cpanel-csf configuration directory is not root-owned" cpanel_config_mode=$(stat -Lc '%a' -- /etc/csf) \ || fail "cannot inspect the cpanel-csf configuration directory" case "${cpanel_config_mode}" in ''|*[!0-7]*) fail "cpanel-csf configuration directory mode is invalid" ;; esac [ $((0${cpanel_config_mode} & 0022)) -eq 0 ] \ || fail "cpanel-csf configuration directory is group- or world-writable" cpanel_config_backup="${migration_backup}/cpanel-config" mkdir -- "${cpanel_config_backup}" || fail "cannot create the cPanel configuration snapshot" chmod 0700 "${cpanel_config_backup}" for config_name in ${DCSF_CPANEL_CONFIG_NAMES}; do config_source="/etc/csf/${config_name}" if [ -e "${config_source}" ] || [ -L "${config_source}" ]; then [ -f "${config_source}" ] && [ ! -L "${config_source}" ] \ || fail "cpanel-csf configuration entry is not a regular file: ${config_source}" [ "$(stat -Lc '%u:%h' -- "${config_source}")" = "0:1" ] \ || fail "cpanel-csf configuration entry has unsafe ownership or links: ${config_source}" config_mode=$(stat -Lc '%a' -- "${config_source}") \ || fail "cannot inspect ${config_source}" case "${config_mode}" in ''|*[!0-7]*) fail "cpanel-csf configuration mode is invalid: ${config_source}" ;; esac [ $((0${config_mode} & 0022)) -eq 0 ] \ || fail "cpanel-csf configuration entry is group- or world-writable: ${config_source}" cp -p -- "${config_source}" "${cpanel_config_backup}/${config_name}" \ || fail "cannot snapshot ${config_source}" chmod go-w -- "${cpanel_config_backup}/${config_name}" chown root:root -- "${cpanel_config_backup}/${config_name}" fi done [ -f "${cpanel_config_backup}/csf.conf" ] \ || fail "cpanel-csf configuration snapshot does not contain csf.conf" ( cd "${cpanel_config_backup}" : > SHA256SUMS for config_name in ${DCSF_CPANEL_CONFIG_NAMES}; do [ ! -f "${config_name}" ] || sha256sum "${config_name}" >> SHA256SUMS done chmod 0600 SHA256SUMS sha256sum -c SHA256SUMS >/dev/null ) || fail "cannot validate the cPanel configuration snapshot" } restore_cpanel_configuration() { [ "${install_kind}" = "cpanel-rpm" ] || return 0 [ -d "${cpanel_config_backup}" ] && [ ! -L "${cpanel_config_backup}" ] || return 1 ( cd "${cpanel_config_backup}" sha256sum -c SHA256SUMS >/dev/null ) || return 1 if [ -e /etc/csf ] || [ -L /etc/csf ]; then [ -d /etc/csf ] && [ ! -L /etc/csf ] || return 1 [ "$(stat -Lc '%u' -- /etc/csf)" -eq 0 ] || return 1 config_parent_mode=$(stat -Lc '%a' -- /etc/csf) || return 1 case "${config_parent_mode}" in ''|*[!0-7]*) return 1 ;; esac [ $((0${config_parent_mode} & 0022)) -eq 0 ] || return 1 else mkdir -- /etc/csf || return 1 chmod 0700 /etc/csf || return 1 fi for config_name in ${DCSF_CPANEL_CONFIG_NAMES}; do config_snapshot="${cpanel_config_backup}/${config_name}" [ -f "${config_snapshot}" ] || continue config_destination="/etc/csf/${config_name}" [ ! -L "${config_destination}" ] || return 1 if [ -e "${config_destination}" ]; then [ -f "${config_destination}" ] \ && [ "$(stat -Lc '%u:%h' -- "${config_destination}")" = "0:1" ] \ || return 1 fi config_temporary=$(mktemp "/etc/csf/.dcsf-restore.${config_name}.XXXXXXXX") || return 1 if ! cp -p -- "${config_snapshot}" "${config_temporary}"; then rm -f -- "${config_temporary}" return 1 fi if ! chown root:root -- "${config_temporary}"; then rm -f -- "${config_temporary}" return 1 fi [ "$(sha256sum "${config_temporary}" | awk '{print $1}')" = \ "$(sha256sum "${config_snapshot}" | awk '{print $1}')" ] \ || { rm -f -- "${config_temporary}"; return 1; } mv -f -- "${config_temporary}" "${config_destination}" \ || { rm -f -- "${config_temporary}"; return 1; } done [ -f /etc/csf/csf.conf ] && [ ! -L /etc/csf/csf.conf ] || return 1 [ "$(sha256sum /etc/csf/csf.conf | awk '{print $1}')" = \ "$(sha256sum "${cpanel_config_backup}/csf.conf" | awk '{print $1}')" ] } backup_existing_install() { [ "${install_kind}" != "fresh" ] || return 0 backup_parent="/var/backups/dcsf" if [ -e "${backup_parent}" ] || [ -L "${backup_parent}" ]; then [ -d "${backup_parent}" ] && [ ! -L "${backup_parent}" ] \ || fail "migration backup parent is unsafe: ${backup_parent}" [ "$(stat -Lc '%u' -- "${backup_parent}")" -eq 0 ] \ || fail "migration backup parent is not root-owned: ${backup_parent}" else mkdir -p -- "${backup_parent}" || fail "cannot create ${backup_parent}" fi chmod 0700 "${backup_parent}" migration_backup=$(mktemp -d "${backup_parent}/migration.XXXXXXXX") \ || fail "cannot create the migration backup directory" [ -d "${migration_backup}" ] && [ ! -L "${migration_backup}" ] \ || fail "migration backup directory is unsafe" chmod 0700 "${migration_backup}" metadata="${migration_backup}/migration.txt" { printf '%s\n' "public_release=${DCSF_PUBLIC_RELEASE}" printf '%s\n' "technical_version=${DCSF_EXPECTED_VERSION}" printf '%s\n' "created=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" printf '%s\n' "install_kind=${install_kind}" printf '%s\n' "installed_dcsf_version=${installed_version:-none}" printf '%s\n' "installed_legacy_version=${legacy_version:-none}" printf '%s\n' "installed_cpanel_rpm=${cpanel_rpm_nvr:-none}" } > "${metadata}" || fail "cannot write migration metadata" chmod 0600 "${metadata}" backup_list="${workdir}/migration-backup.list" : > "${backup_list}" for candidate in etc/csf usr/local/csf var/lib/csf var/lib/dcsf; do if [ -e "/${candidate}" ] || [ -L "/${candidate}" ]; then printf '%s\n' "${candidate}" >> "${backup_list}" fi done if [ -s "${backup_list}" ]; then configuration_backup="${migration_backup}/configuration.tar.gz" tar -C / -czpf "${configuration_backup}" -T "${backup_list}" \ || fail "cannot create the existing-installation configuration backup" tar -tzf "${configuration_backup}" >/dev/null \ || fail "the existing-installation configuration backup is unreadable" chmod 0600 "${configuration_backup}" sha256sum "${configuration_backup}" > "${configuration_backup}.sha256" \ || fail "cannot hash the configuration backup" chmod 0600 "${configuration_backup}.sha256" fi snapshot_cpanel_configuration if command -v iptables-save >/dev/null 2>&1 && command -v iptables-restore >/dev/null 2>&1; then firewall_backup_v4="${migration_backup}/iptables.v4" snapshot_firewall_family "IPv4" "$(command -v iptables-save)" \ "$(command -v iptables-restore)" "${firewall_backup_v4}" firewall_snapshot_count=$((firewall_snapshot_count + 1)) fi if command -v ip6tables-save >/dev/null 2>&1 && command -v ip6tables-restore >/dev/null 2>&1; then firewall_backup_v6="${migration_backup}/iptables.v6" snapshot_firewall_family "IPv6" "$(command -v ip6tables-save)" \ "$(command -v ip6tables-restore)" "${firewall_backup_v6}" firewall_snapshot_count=$((firewall_snapshot_count + 1)) fi ( cd "${migration_backup}" : > backup-files.sha256 for backup_file in migration.txt configuration.tar.gz configuration.tar.gz.sha256 cpanel-config/SHA256SUMS iptables.v4 iptables.v6; do [ ! -f "${backup_file}" ] || sha256sum "${backup_file}" >> backup-files.sha256 done chmod 0600 backup-files.sha256 ) say "migration backup created: ${migration_backup}" } restore_firewall_snapshots() { restore_failed=0 if [ -n "${firewall_backup_v4}" ] && [ -s "${firewall_backup_v4}" ] \ && command -v iptables-restore >/dev/null 2>&1; then iptables-restore --test < "${firewall_backup_v4}" >/dev/null 2>&1 \ && iptables-restore < "${firewall_backup_v4}" \ || restore_failed=1 fi if [ -n "${firewall_backup_v6}" ] && [ -s "${firewall_backup_v6}" ] \ && command -v ip6tables-restore >/dev/null 2>&1; then ip6tables-restore --test < "${firewall_backup_v6}" >/dev/null 2>&1 \ && ip6tables-restore < "${firewall_backup_v6}" \ || restore_failed=1 fi return "${restore_failed}" } restore_cpanel_rpm() { [ "${cpanel_rpm_removed}" -eq 1 ] || return 0 rollback_log="${migration_backup}/cpanel-rpm-rollback.log" if command -v dnf >/dev/null 2>&1; then dnf -y install "${cpanel_rpm_nvr}" > "${rollback_log}" 2>&1 || return 1 elif command -v yum >/dev/null 2>&1; then yum -y install "${cpanel_rpm_nvr}" > "${rollback_log}" 2>&1 || return 1 else return 1 fi chmod 0600 "${rollback_log}" rpm -q --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}' cpanel-csf 2>/dev/null \ | grep -Fxq "${cpanel_rpm_nvr}" } verify_cpanel_rpm_rollback_source() { [ "${install_kind}" = "cpanel-rpm" ] || return 0 if command -v dnf >/dev/null 2>&1; then dnf -q repoquery --available --qf '%{name}-%{version}-%{release}.%{arch}' cpanel-csf 2>/dev/null \ | grep -Fxq "${cpanel_rpm_nvr}" elif command -v repoquery >/dev/null 2>&1; then repoquery --qf '%{name}-%{version}-%{release}.%{arch}' cpanel-csf 2>/dev/null \ | grep -Fxq "${cpanel_rpm_nvr}" else return 1 fi } remove_cpanel_rpm() { [ "${install_kind}" = "cpanel-rpm" ] || return 0 command -v rpm >/dev/null 2>&1 \ || { warn "rpm disappeared after the migration preflight"; return 1; } rpm -q --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}' cpanel-csf 2>/dev/null \ | grep -Fxq "${cpanel_rpm_nvr}" \ || { warn "installed cpanel-csf package changed during verification"; return 1; } rpm -e --test cpanel-csf \ || { warn "cpanel-csf cannot be removed without breaking RPM dependencies"; return 1; } if ! rpm -e cpanel-csf; then if ! rpm -q cpanel-csf >/dev/null 2>&1; then cpanel_rpm_removed=1 fi warn "cpanel-csf uninstall returned an error" return 1 fi if rpm -q cpanel-csf >/dev/null 2>&1; then warn "cpanel-csf still appears in the RPM database after uninstall" return 1 fi cpanel_rpm_removed=1 if ! restore_cpanel_configuration; then warn "could not restore the authenticated cpanel-csf configuration snapshot" return 1 fi say "restored the pre-removal cpanel-csf configuration" say "removed ${cpanel_rpm_nvr}; starting the authenticated DCSF installation" } emergency_rollback() { if [ "${cpanel_rpm_removed}" -eq 1 ]; then if ! restore_cpanel_rpm; then warn "could not reinstall ${cpanel_rpm_nvr} automatically" else say "restored ${cpanel_rpm_nvr}" fi fi if [ "${firewall_snapshot_count}" -gt 0 ]; then if ! restore_firewall_snapshots; then warn "one or more firewall snapshots could not be restored" else say "restored the pre-migration firewall snapshots" fi else warn "no iptables firewall snapshot was available for automatic restoration" fi } say "authenticated ${DCSF_PUBLIC_RELEASE} package is ready; selected profile=${profile:-preserve} mode=${operation}" if [ "${operation}" = "install" ] && [ "${install_kind}" != "fresh" ]; then say "running a no-change package preflight for the detected ${install_kind} installation" invoke_payload dryrun || fail "DCSF package preflight returned an error; no installed files were changed" confirm_existing_install backup_existing_install verify_cpanel_rpm_rollback_source \ || fail "the exact ${cpanel_rpm_nvr} rollback package is not available; no RPM was removed" if ! remove_cpanel_rpm; then emergency_rollback fail "could not complete cpanel-csf removal; recovery evidence is in ${migration_backup}" fi fi payload_status=0 invoke_payload "${operation}" || payload_status=$? if [ "${operation}" = "install" ] && [ "${payload_status}" -eq 0 ]; then if [ ! -f /etc/csf/dcsf-version.txt ] || [ -L /etc/csf/dcsf-version.txt ]; then warn "installation completed without a safe DCSF version file" payload_status=1 else installed_after=$(sed -n '1{s/[[:space:]]//g;p;}' /etc/csf/dcsf-version.txt) if [ "${installed_after}" != "${available_version}" ]; then warn "installed version does not match the authenticated release" payload_status=1 fi fi if [ "${cpanel_rpm_removed}" -eq 1 ] && rpm -q cpanel-csf >/dev/null 2>&1; then warn "cpanel-csf reappeared during the DCSF installation" payload_status=1 fi fi if [ "${payload_status}" -ne 0 ]; then if [ "${operation}" = "install" ] && [ "${install_kind}" != "fresh" ]; then warn "DCSF package installation failed; attempting emergency rollback" emergency_rollback fail "DCSF package installer returned an error; recovery evidence is in ${migration_backup}" fi fail "DCSF package installer returned an error" fi if [ "${operation}" = "install" ]; then say "${DCSF_PUBLIC_RELEASE} (DCSF ${available_version}) installed successfully" else say "${operation} completed successfully; no DCSF release was installed" fi exit 0