
­­­­­­­­­­­­­­­­­­
<!DOCTYPE html>
<html>
import asyncio
import base64
import configparser
import json
import logging
import os
import re
import shlex
import subprocess
import distro
from abc import abstractmethod
from typing import List, Optional

from defence360agent.contracts.config import Core, Packaging
from defence360agent.contracts.license import LicenseCLN
from defence360agent.subsys.features.abstract_feature import (
    AbstractFeature,
    FeatureError,
    FeatureNotice,
    FeatureStatus,
    ea4_only,
)
from defence360agent.subsys.panels.cpanel import cPanel
from defence360agent.utils import (
    OsReleaseInfo,
    check_run,
    check_run_outside_sandbox,
    run,
    run_cmd_and_log_outside_sandbox,
    _wrap_outside_sandbox_shell,
    os_version,
)

logger = logging.getLogger(__name__)

_ELS_SETUP_TMPL = (
    "export PATH=/opt/imunify360/venv/bin:$PATH;"
    " _els_tmp=$(mktemp)"
    ' && curl -sf -o "$_els_tmp" {url}'
    ' && sh "$_els_tmp" -i;'
    " _els_rc=$?;"
    ' rm -f "$_els_tmp";'
    " exit $_els_rc"
)
_ELS_RPM_SETUP = _ELS_SETUP_TMPL.format(
    url=(
        "https://repo.alt.tuxcare.com/alt-php-els/"
        "install-els-alt-php-rpm-repo.sh"
    )
)
_ELS_DEB_SETUP = _ELS_SETUP_TMPL.format(
    url=(
        "https://repo.alt.tuxcare.com/alt-php-els/"
        "install-els-alt-php-deb-repo.sh"
    )
)


class SimpleInstallerMixIn:
    """This is a mixin class implementing common case installation scenario.

    Installation is supposed to be through a single command cls.INSTALL_CMD.

    Removal is done through interpolating a space separated list of package
    names to remove into cls.REMOVE_CMD_TMPL. List of packages to remove is
    obtained by collecting all installed alt-php* packages except those we want
    to keep (as returned by required_packages()).
    """

    INSTALL_CMD = "/bin/false"
    REMOVE_CMD_TMPL = "/bin/false"
    # The only evidence that the alt-php packages on this host are ours: the
    # rpm/dpkg glob cannot tell an admin's or a TuxCare subscription's stack
    # apart from one we installed.
    INSTALL_MARKER = "/var/imunify360/hardened-php-installed-by-imunify"
    UNMANAGED_MSG = (
        "alt-php packages are present but were not installed by Imunify360,"
        " so they are not managed by it"
    )

    @abstractmethod
    def generate_repo(self, enabled: Optional[bool] = None):
        return

    @abstractmethod
    async def pre_install_cmd(self, enabled: bool):
        return

    @abstractmethod
    def remove_repo(self):
        return

    @staticmethod
    @abstractmethod
    async def _list_alt_php_packages() -> set:
        """Set of installed package names matching alt-php*"""
        return set()

    @classmethod
    def _keep_installed(cls, pkg):
        # this packages should not be managed by this class, as required by
        # Imunify360 to work. Should be updated every time major php version
        # used by ai-bolit is updated
        return (
            pkg.startswith("alt-php-internal")
            or pkg == "alt-php-config"
            or pkg == "alt-php-hyperscan"
            # ELS repo markers arrive as deps of other packages (e.g.
            # imunify-ui Recommends) and carry no interpreters; counting
            # them as feature packages turns install() into a no-op
            or pkg.startswith("alt-php-els")
        )

    @classmethod
    async def _feature_packages(cls) -> set:
        """Set of installed alt-php packages except those we keep installed"""
        all_alt_php = await cls._list_alt_php_packages()
        return set(pkg for pkg in all_alt_php if not cls._keep_installed(pkg))

    @classmethod
    def _install_marker_exists(cls) -> bool:
        return os.path.exists(cls.INSTALL_MARKER)

    @classmethod
    def _install_cmd_with_marker(cls) -> str:
        # Appended to the install command instead of written from here: the
        # command is detached, so a marker written up front would outlive a
        # transaction that never landed any package and would then adopt
        # whatever alt-php stack appears next.
        return "{} && install -D -m 0600 /dev/null {}".format(
            cls.INSTALL_CMD, shlex.quote(cls.INSTALL_MARKER)
        )

    @classmethod
    def _drop_install_marker(cls):
        try:
            os.remove(cls.INSTALL_MARKER)
        except FileNotFoundError:
            pass

    @AbstractFeature.raise_if_shouldnt_install_now
    async def install(self):
        if (
            not self._install_marker_exists()
            and await self._feature_packages()
        ):
            raise FeatureError(self.UNMANAGED_MSG)
        # generate_repo can block on subprocess (rpm -q, dpkg-query,
        # dpkg --remove, curl|sh) for several seconds; run it off the
        # event loop thread.
        await asyncio.to_thread(self.generate_repo, enabled=True)
        await self.pre_install_cmd(enabled=True)
        # DEF-41613: yum groupinstall runs RPM %prein/%post scriptlets
        # whose LSM transitions on exec are refused under NNP.
        return await run_cmd_and_log_outside_sandbox(
            self._install_cmd_with_marker(), self.INSTALL_LOG_FILE_MASK
        )

    async def remove(self, force=False):
        # AbstractFeature.raise_if_shouldnt_remove_now has no notion of
        # forcing, and it ships in a different package: repeating its three
        # checks here keeps the feature removable with an agent upgrade alone.
        if self.installation_live_log:
            raise FeatureError("Wait until installation is finished!")
        if not force and not self.is_installed:
            raise FeatureNotice(
                "Can't delete {}, because it's not installed".format(self.NAME)
            )
        if self.removal_live_log:
            return self.removal_live_log
        # Dropped before the detached removal starts: a removal that dies
        # half-way must leave the remaining packages unclaimed rather than
        # keep them queued for a later automatic removal.
        await asyncio.to_thread(self._drop_install_marker)
        await asyncio.to_thread(self.remove_repo)
        cmd = self.REMOVE_CMD_TMPL.format(
            " ".join(map(shlex.quote, await self._feature_packages()))
        )
        await self.pre_install_cmd(enabled=False)
        return await run_cmd_and_log_outside_sandbox(
            cmd, self.REMOVE_LOG_FILE_MASK
        )

    async def _check_installed_impl(self) -> bool:
        if not self._install_marker_exists():
            return False
        return bool(await self._feature_packages())


class HardenedPHPCentos(SimpleInstallerMixIn, AbstractFeature):
    LEGACY_REPO_FILE = "/etc/yum.repos.d/imunify360-alt-php.repo"
    ELS_REPO_FILE = "/etc/yum.repos.d/php-els.repo"
    TOKEN_FILE = "/etc/yum/vars/phpelstoken"
    NAME = "Hardened-PHP"
    LOG_DIR = "/var/log/%s" % Core.PRODUCT
    INSTALL_LOG_FILE_MASK = "%s/install-hardenedphp.log.*" % LOG_DIR
    REMOVE_LOG_FILE_MASK = "%s/remove-hardenedphp.log.*" % LOG_DIR
    INSTALL_CMD = "yum group mark remove alt-php; yum -y groupinstall alt-php"
    REMOVE_CMD_TMPL = "yum group mark install alt-php; yum -y remove {}"
    ENABLE_CRB_CMD = "dnf config-manager --enable crb"
    DISABLE_CRB_CMD = "dnf config-manager --disable crb"
    _CMD_LIST = [INSTALL_CMD, REMOVE_CMD_TMPL.format("")]

    def _remove_legacy_repo(self):
        try:
            os.remove(self.LEGACY_REPO_FILE)
        except FileNotFoundError:
            pass
        except OSError:
            logger.error("Can't delete %s", self.LEGACY_REPO_FILE)

    @classmethod
    def _els_php_release_installed(cls):
        if (
            subprocess.call(
                ["rpm", "-q", "els-php-release"],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            != 0
        ):
            return False
        return os.path.exists(cls.ELS_REPO_FILE)

    def _install_els_repo(self):
        cmd = _wrap_outside_sandbox_shell(_ELS_RPM_SETUP)
        result = subprocess.run(cmd, shell=True, stderr=subprocess.PIPE)
        if result.returncode != 0:
            logger.error(
                "ELS RPM repo setup failed (rc=%d): %s",
                result.returncode,
                result.stderr.decode(errors="replace").strip(),
            )

    def _write_token(self):
        if not self._els_php_release_installed():
            return
        server_id = LicenseCLN.get_server_id()
        if not server_id:
            return
        try:
            os.makedirs(os.path.dirname(self.TOKEN_FILE), exist_ok=True)
            fd = os.open(
                self.TOKEN_FILE,
                os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
                0o640,
            )
            with os.fdopen(fd, "w") as f:
                f.write(server_id)
            os.chmod(self.TOKEN_FILE, 0o640)
        except OSError:
            logger.error("Can't write %s", self.TOKEN_FILE)

    def generate_repo(self, enabled: Optional[bool] = None):
        if os.path.exists(self.LEGACY_REPO_FILE):
            self._remove_legacy_repo()
        if not self._els_php_release_installed():
            self._install_els_repo()
        self._write_token()

    async def pre_install_cmd(self, enabled: bool):
        if not os_version().startswith("9"):
            return
        elif enabled:
            await check_run(self.ENABLE_CRB_CMD.split())
        else:
            await check_run(self.DISABLE_CRB_CMD.split())

    def remove_repo(self):
        self._remove_legacy_repo()

    @staticmethod
    async def _list_alt_php_packages() -> set:
        raw_output = await check_run(
            ["rpm", "-qa", "--queryformat", "%{NAME}\n", "alt-php*"]
        )
        return set(raw_output.decode().split())


class HardenedPHPUbuntu(SimpleInstallerMixIn, AbstractFeature):
    ELS_REPO_FILE = "/etc/apt/sources.list.d/php-els.list"
    AUTH_CONF_FILE = "/etc/apt/auth.conf.d/php-els.conf"
    NAME = "Hardened-PHP"
    LOG_DIR = "/var/log/%s" % Core.PRODUCT
    INSTALL_LOG_FILE_MASK = "%s/install-hardenedphp.log.*" % LOG_DIR
    REMOVE_LOG_FILE_MASK = "%s/remove-hardenedphp.log.*" % LOG_DIR
    INSTALL_CMD = "apt-get install -y alt-php"
    REMOVE_CMD_TMPL = "apt-get purge -y {}"
    _CMD_LIST = [INSTALL_CMD, REMOVE_CMD_TMPL.format("")]

    @classmethod
    def _els_php_release_installed(cls):
        result = subprocess.run(
            [
                "dpkg-query",
                "-W",
                "-f=${db:Status-Status}",
                "els-php-release",
            ],
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
        )
        if not (
            result.returncode == 0 and result.stdout.strip() == b"installed"
        ):
            return False
        return os.path.exists(cls.ELS_REPO_FILE)

    def _install_els_repo(self):
        self._clear_reinstreq_if_stuck()
        cmd = _wrap_outside_sandbox_shell(_ELS_DEB_SETUP)
        result = subprocess.run(cmd, shell=True, stderr=subprocess.PIPE)
        if result.returncode != 0:
            logger.error(
                "ELS DEB repo setup failed (rc=%d): %s",
                result.returncode,
                result.stderr.decode(errors="replace").strip(),
            )

    def _write_token(self):
        if not self._els_php_release_installed():
            return
        server_id = LicenseCLN.get_server_id()
        if not server_id:
            return
        try:
            os.makedirs(os.path.dirname(self.AUTH_CONF_FILE), exist_ok=True)
            fd = os.open(
                self.AUTH_CONF_FILE,
                os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
                0o640,
            )
            with os.fdopen(fd, "w") as f:
                f.write(
                    "machine repo.alt.tuxcare.com/alt-php-els/\n"
                    "login %s\npassword\n" % server_id
                )
            os.chmod(self.AUTH_CONF_FILE, 0o640)
        except OSError:
            logger.error("Can't write %s", self.AUTH_CONF_FILE)

    def generate_repo(self, enabled: Optional[bool] = None):
        if not self._els_php_release_installed():
            self._install_els_repo()
        self._write_token()

    async def pre_install_cmd(self, enabled: bool):
        return

    @staticmethod
    def _els_php_release_reinstreq() -> bool:
        result = subprocess.run(
            [
                "dpkg-query",
                "-W",
                "-f=${db:Status-Eflag}",
                "els-php-release",
            ],
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
        )
        return result.returncode == 0 and result.stdout.strip() == b"reinstreq"

    @classmethod
    def _clear_reinstreq_if_stuck(cls) -> bool:
        if not cls._els_php_release_reinstreq():
            return False
        logger.error(
            "els-php-release is wedged at reinstreq from a prior"
            " interrupted install; force-removing to unblock apt"
        )
        subprocess.run(
            [
                "dpkg",
                "--remove",
                "--force-remove-reinstreq",
                "els-php-release",
            ],
            check=False,
        )
        return True

    def remove_repo(self):
        self._clear_reinstreq_if_stuck()

    @staticmethod
    async def _list_alt_php_packages() -> set:
        pkgs_in_dpkg_db = (
            (
                await check_run(
                    [
                        "dpkg-query",
                        "-W",
                        "-f",
                        "${Package} ${db:Status-Status}\n",
                        "alt-php*",
                    ]
                )
            )
            .decode()
            .strip()
            .split("\n")
        )
        return set(
            pkg
            for line in pkgs_in_dpkg_db
            for pkg, status in [line.split()]
            if status == "installed"
        )


class HardenedPHPCloudLinux(AbstractFeature):
    MSG = "HardenedPHP is managed by lvemanager in CloudLinuxOS"
    INSTALL_LOG_FILE_MASK = "empty"
    REMOVE_LOG_FILE_MASK = "empty"

    async def init(self):
        return self

    async def status(self):
        rc, _, _ = await run(["rpm", "-q", "lvemanager"])
        return {
            "items": {
                "status": FeatureStatus.MANAGED_BY_LVE,
                "lve_installed": rc == 0,
                "message": self.MSG,
            }
        }

    async def install(self):
        raise FeatureError(self.MSG)

    async def remove(self):
        raise FeatureError(self.MSG)

    async def _check_installed_impl(self) -> bool:
        # does not matter
        return True


class HardenedPHPCloudLinuxSolo(HardenedPHPCloudLinux):
    MSG = "HardenedPHP is not supported in CloudLinuxOS Solo"

    async def status(self):
        return {
            "items": {
                "status": FeatureStatus.NOT_SUPPORTED_BY_CL_SOLO,
                "message": self.MSG,
            }
        }


class EaPHPCentos(HardenedPHPCentos):
    REPO_FILE = "/etc/yum.repos.d/imunify360-ea-php-hardened.repo"
    LOG_DIR = "/var/log/%s" % Core.PRODUCT
    INSTALL_LOG_FILE_MASK = "%s/install-ea_php.log.*" % LOG_DIR
    REMOVE_LOG_FILE_MASK = "%s/remove-ea_php.log.*" % LOG_DIR
    INSTALL_CMD = "yum -y groupremove ea-php; yum -y groupinstall ea-php"

    REMOVE_SCRIPT = "/opt/imunify360/venv/share/imunify360/scripts/remove_hardened_php.py"  # noqa: E501
    REPO_NAME = "imunify360-ea-php-hardened"
    # Template installed by the RPM into Packaging.DATADIR. Named
    # separately from REPO_FILE because EL10 renders a different body
    # into the very same destination path.
    REPO_TMPL_NAME = "imunify360-ea-php-hardened.repo"
    _CMD_LIST = [INSTALL_CMD, REMOVE_SCRIPT]

    @classmethod
    def _repo_tmpl_filepath(cls):
        return os.path.join(Packaging.DATADIR, cls.REPO_TMPL_NAME)

    @classmethod
    def _prepare_token(cls, token):
        try:
            sep = ":"
            fields = "".join(
                str(token[k]) + sep for k in LicenseCLN.VERIFY_FIELDS_V1
            )
        except KeyError as e:
            raise FeatureError(
                f"License token can not be created by error {e}"
            )
        sign_bytes = base64.b64decode(token["sign"])
        data = fields.encode() + sign_bytes
        return base64.urlsafe_b64encode(data).decode()

    @classmethod
    def _prepare_repo_conf(cls, token, enabled: bool):
        enabled_flag = "1" if enabled else "0"
        try:
            token = cls._prepare_token(token)
        except FeatureError as e:
            if not enabled:
                token_placeholder = "unregister-token-placeholder"
                token = base64.urlsafe_b64encode(
                    token_placeholder.encode()
                ).decode()
            else:
                raise e

        with open(cls._repo_tmpl_filepath(), "r") as repo_template:
            template = repo_template.read()
            return template.format(token=token, enabled=enabled_flag)

    def _render_repo_conf(self, enabled: bool) -> str:
        """Repo body for a license-gated mirror: the URL embeds a token,
        so an unregistered server cannot be given a working repo."""
        token = LicenseCLN.get_token()
        server_id = LicenseCLN.get_server_id()
        if not server_id:
            if enabled:
                raise FeatureError(
                    "tried to enable repo but server_id is empty (not"
                    " registered?)"
                )
            logger.warning(
                "server_id is empty (not registered?) ignoring due to removal"
                " of repo"
            )
        return self._prepare_repo_conf(token, enabled)

    def generate_repo(self, enabled: Optional[bool] = None):
        # Only an explicit enable may create the file. Both other callers
        # run on a CLN license tick - refresh (None) and disable (False) -
        # and neither has anything to act on when the file is absent, so
        # without this they drop a repo on a host that never installed
        # the feature.
        if enabled is not True and not os.path.exists(self.REPO_FILE):
            return
        if enabled is None:
            repo = configparser.ConfigParser()
            try:
                repo.read(self.REPO_FILE)
                enabled = repo[self.REPO_NAME]["enabled"] == "1"
            except Exception:
                enabled = True
        # Rendered before the destination is opened: a template read or a
        # license error must leave the existing repo file untouched
        # rather than truncated.
        content = self._render_repo_conf(enabled)
        with open(self.REPO_FILE, "w") as repo_file:
            repo_file.write(content)
        os.chmod(self.REPO_FILE, os.stat(self._repo_tmpl_filepath()).st_mode)

    def remove_repo(self):
        self.generate_repo(enabled=False)

    @staticmethod
    async def _query_eaphp_versions() -> List[dict]:
        raw_output = await check_run(
            'rpm -qa --queryformat "%{NAME} %{RELEASE}\n" "ea-php*"',
            shell=True,
        )
        words = raw_output.decode().split()
        return [
            {"name": words[i], "release": words[i + 1]}
            for i in range(0, len(words), 2)
        ]

    async def _check_installed_impl(self) -> bool:
        versioned_re = re.compile(r"ea-php\d+")
        for pkg in await self._query_eaphp_versions():
            if (
                versioned_re.search(pkg["name"]) is not None
                and "cloudlinux" in pkg["release"]
            ):
                return True
        return False

    @ea4_only
    async def status(self):
        return await super().status()

    @ea4_only
    @AbstractFeature.raise_if_shouldnt_install_now
    async def install(self):
        self.generate_repo(enabled=True)
        return await run_cmd_and_log_outside_sandbox(
            self.INSTALL_CMD, self.INSTALL_LOG_FILE_MASK
        )

    @ea4_only
    @AbstractFeature.raise_if_shouldnt_remove_now
    async def remove(self):
        self.generate_repo(enabled=False)
        return await run_cmd_and_log_outside_sandbox(
            self.REMOVE_SCRIPT, self.REMOVE_LOG_FILE_MASK
        )


_CPANEL_HARDENED_PHP_MSG = (
    "For EL9/Ubuntu20 cpanel servers use cPanel Profile to configure harden"
    " php.\nMore info:\n\t"
    " https://docs.cpanel.net/ea4/basics/the-ea-cpanel-tools-package-scripts/\n\t"  # noqa: E501
    " https://docs.cpanel.net/whm/software/easyapache-4-interface/"
)


def _parse_whmapi1_output(raw: bytes, command: str) -> dict:
    # whmapi1 always exits 0; failures live in metadata.result/reason.
    output = json.loads(raw)
    if not output.get("metadata", {}).get("result"):
        reason = output.get("metadata", {}).get("reason", "unknown")
        raise FeatureError("whmapi1 {} failed: {}".format(command, reason))
    # `data` may legitimately be missing or null when a command returns
    # no payload; normalise to dict so callers can probe keys safely.
    data = output.get("data")
    return data if isinstance(data, dict) else {}


# Mirrors the mutual Conflicts: declared by the ea-apache24 MPM/cgi
# packages themselves (one MPM per server; cgi pairs with prefork, cgid
# with the threaded MPMs).
_EA4_MUTUAL_CONFLICTS = {
    "ea-apache24-mod_mpm_prefork": frozenset(
        (
            "ea-apache24-mod_mpm_worker",
            "ea-apache24-mod_mpm_event",
            "ea-apache24-mod_cgid",
            "ea-apache24-mod_http2",
        )
    ),
    "ea-apache24-mod_mpm_worker": frozenset(
        (
            "ea-apache24-mod_mpm_prefork",
            "ea-apache24-mod_mpm_event",
            "ea-apache24-mod_cgi",
        )
    ),
    "ea-apache24-mod_mpm_event": frozenset(
        (
            "ea-apache24-mod_mpm_prefork",
            "ea-apache24-mod_mpm_worker",
            "ea-apache24-mod_cgi",
        )
    ),
    "ea-apache24-mod_cgi": frozenset(
        (
            "ea-apache24-mod_cgid",
            "ea-apache24-mod_mpm_worker",
            "ea-apache24-mod_mpm_event",
        )
    ),
    "ea-apache24-mod_cgid": frozenset(
        (
            "ea-apache24-mod_cgi",
            "ea-apache24-mod_mpm_prefork",
        )
    ),
}


class EaPHPCentosEL9(EaPHPCentos):
    MSG = _CPANEL_HARDENED_PHP_MSG

    IMUNIFY_PROFILE_FILENAME = "imunify_hardened_php.json"
    IMUNIFY_PROFILE_PATH = (
        "/etc/cpanel/ea4/profiles/custom/" + IMUNIFY_PROFILE_FILENAME
    )
    BASELINE_PROFILE_PATH = (
        "/etc/cpanel/ea4/profiles/vendor/cloudlinux/allphp.json"
    )
    # On failure ea_install_profile exits non-zero with the resolver
    # error only in cPanel's packman log, so pull it into the install
    # log the agent hands to the user.
    INSTALL_PROFILE_CMD = (
        "/usr/local/bin/ea_install_profile --install "
        + IMUNIFY_PROFILE_PATH
        + "; _rc=$?;"
        ' if [ "$_rc" -ne 0 ]; then'
        ' echo "ea_install_profile failed with exit code $_rc";'
        ' tail -n 20 "$(ls -t /usr/local/cpanel/logs/packman/errors/*'
        ' 2>/dev/null | head -1)" 2>/dev/null;'
        " fi; exit $_rc"
    )

    @ea4_only
    @AbstractFeature.raise_if_shouldnt_install_now
    async def install(self):
        # Union of installed EA4 + cloudlinux baseline = superset shape;
        # ea_install_profile --install never removes customer packages.
        self.generate_repo(enabled=True)
        try:
            # generate_repo only configures the ea-php hardened repo; the
            # allphp baseline also lists alt-php interpreters, which are
            # served by the separate els-alt-php repo. Without it those
            # entries silently resolve to "package does not exist" and
            # only ea-php ends up hardened.
            await asyncio.to_thread(self._setup_alt_php_repo)
            # yum RPM %post/%prein scriptlets need LSM domain transitions
            # that the agent's NoNewPrivileges sandbox blocks with EPERM
            # on SELinux hosts (EL9+, exactly this code path's target).
            await check_run_outside_sandbox(
                ["yum", "-y", "install", "ea-profiles-cloudlinux"]
            )
            await self._save_imunify_profile()
            return await run_cmd_and_log_outside_sandbox(
                self.INSTALL_PROFILE_CMD, self.INSTALL_LOG_FILE_MASK
            )
        except BaseException:
            # Nothing else would ever switch the repo back off: a license
            # tick keeps a body we wrote, and remove() refuses while the
            # feature still reports not_installed. On EL10 that repo is
            # public, so leaving it on hands the host .cloudlinux ea-php
            # upgrades it never asked for. BaseException, not Exception:
            # asyncio.CancelledError is not an Exception, and a shutdown
            # part way through must roll back too. Nothing below awaits,
            # so the cleanup still completes while cancelling. Best
            # effort: a failure here must not replace the install error
            # the user has to act on.
            try:
                self.generate_repo(enabled=False)
            except Exception as exc:
                logger.error(
                    "Could not disable %s after a failed install: %s: %s",
                    self.REPO_NAME,
                    type(exc).__name__,
                    exc,
                )
            raise

    def _setup_alt_php_repo(self):
        if os.path.exists(self.LEGACY_REPO_FILE):
            self._remove_legacy_repo()
        if not self._els_php_release_installed():
            self._install_els_repo()
        self._write_token()

    @ea4_only
    @AbstractFeature.raise_if_shouldnt_remove_now
    async def remove(self):
        self.generate_repo(enabled=False)
        try:
            os.remove(self.IMUNIFY_PROFILE_PATH)
        except FileNotFoundError:
            pass
        return "Repo imunify360-ea-php-hardened removed.\n" + self.MSG

    async def _check_installed_impl(self) -> bool:
        # Two independent signals, both required:
        #   - IMUNIFY_PROFILE_PATH: imunify's intent to manage this host
        #     (created by install(), deleted by remove()).
        #   - parent's rpm -qa ea-php* "cloudlinux" check: at least one
        #     hardened ea-php interpreter is actually present.
        # Intent alone would lie if ea_install_profile failed in the
        # background; rpm check alone would lie after remove() (which
        # intentionally leaves packages in place so a customer's running
        # sites are not broken).
        if not os.path.exists(self.IMUNIFY_PROFILE_PATH):
            return False
        return await super()._check_installed_impl()

    async def _save_imunify_profile(self):
        installed = await self._get_installed_ea4_packages()
        # 30 KB JSON read off the event loop, matching the asyncio.to_thread
        # pattern SimpleInstallerMixIn.install() uses for generate_repo.
        baseline = await asyncio.to_thread(self._load_baseline_packages)
        pkgs = self._merge_profile_packages(installed, baseline)
        args = [
            "/usr/sbin/whmapi1",
            "--output=json",
            "ea4_save_profile",
            "filename=" + self.IMUNIFY_PROFILE_FILENAME,
            "name=Imunify Hardened PHP",
            "desc=Imunify-managed Hardened PHP profile.",
            "overwrite=1",
        ] + ["pkg=" + p for p in pkgs]
        raw = await check_run(args)
        _parse_whmapi1_output(raw, "ea4_save_profile")

    @staticmethod
    def _merge_profile_packages(
        installed: List[str], baseline: List[str]
    ) -> List[str]:
        # The baseline assumes the threaded stack (mpm_worker + cgid);
        # the host's installed MPM/cgi choice must win, otherwise the
        # profile demands both alternatives and PackMan's resolver dies.
        installed_set = set(installed)
        blocked = set()
        for pkg in installed_set:
            blocked |= _EA4_MUTUAL_CONFLICTS.get(pkg, frozenset())
        return sorted(installed_set | (set(baseline) - blocked))

    @staticmethod
    async def _get_installed_ea4_packages() -> List[str]:
        raw = await check_run(
            [
                "/usr/sbin/whmapi1",
                "--output=json",
                "ea4_get_currently_installed_packages",
            ]
        )
        data = _parse_whmapi1_output(
            raw, "ea4_get_currently_installed_packages"
        )
        if "packages" not in data:
            raise FeatureError(
                "whmapi1 ea4_get_currently_installed_packages returned "
                "no 'packages' field: "
                + repr(data)
            )
        return data["packages"]

    @classmethod
    def _load_baseline_packages(cls) -> List[str]:
        # Missing/corrupt baseline must surface — the caller's flow runs
        # yum -y install ea-profiles-cloudlinux beforehand, so the file
        # must exist; silently degrading to [] would make install() a
        # no-op while still returning success.
        with open(cls.BASELINE_PROFILE_PATH) as f:
            return json.load(f).get("pkgs") or []


class EaPHPCentosEL10(EaPHPCentosEL9):
    """EL10 sources hardened ea-php from the public EA4 repo.

    repo.alt.cloudlinux.com never published an ea-php mirror for
    $releasever=10 (the tree answers but is empty, so installs fail with
    "Unable to find a match: ea-profiles-cloudlinux") and it will not:
    the el10 duplicate is being retired in favour of the single
    repo.cloudlinux.com/cloudlinux/EA4/10 tree, which carries the same
    `.cloudlinux`-tagged builds. That tree needs no license token, so
    nothing here consults CLN - same arrangement Ubuntu+cPanel has run on
    for years via EaPHPUbuntuCpanel.
    """

    REPO_TMPL_NAME = "imunify360-ea-php-hardened-el10.repo"
    PUBLIC_BASEURL = "https://repo.cloudlinux.com/cloudlinux/EA4/10/"

    @classmethod
    def _repo_written_by_us(cls) -> bool:
        """Whether the repo file on disk is one this class rendered.

        Older agents resolved EL10 to the EL9 class and left a token-gated
        body here; that is the one case a refresh must not carry over.
        """
        try:
            with open(cls.REPO_FILE) as repo_file:
                return cls.PUBLIC_BASEURL in repo_file.read()
        except OSError:
            return False

    def generate_repo(self, enabled: Optional[bool] = None):
        # Older agents left an *enabled* token repo here for a feature
        # that was never installed. That URL has no el10 content, so it
        # was inert - but a refresh would re-render it with the public
        # body and hand the host a working hardened ea-php repo it never
        # asked for, swapping its ea-php stack on the next yum update.
        # Both signals are needed: install() enables the repo minutes
        # before it writes the profile, so the profile alone would let a
        # tick switch off an install in flight; and an upgrade of a host
        # that *does* have the feature must still migrate its token body
        # to the public one, so a foreign body alone would disable that.
        if (
            enabled is None
            and not self._repo_written_by_us()
            and not os.path.exists(self.IMUNIFY_PROFILE_PATH)
        ):
            enabled = False
        super().generate_repo(enabled)

    def _render_repo_conf(self, enabled: bool) -> str:
        with open(self._repo_tmpl_filepath()) as repo_template:
            return repo_template.read().format(enabled="1" if enabled else "0")


class EaPHPUbuntuCpanel(AbstractFeature):
    REPO_FILE = "/etc/apt/sources.list.d/cloudlinux-ea4.list"
    REPO_URL = (
        "https://repo.cloudlinux.com/cloudlinux-ubuntu/cloudlinux-ea4/stable/"
    )
    REPO_NAME = "cloudlinux-ea4"
    MSG = _CPANEL_HARDENED_PHP_MSG
    INSTALL_LOG_FILE_MASK = "empty"
    REMOVE_LOG_FILE_MASK = "empty"

    def _generate_repo(self):
        with open(self.REPO_FILE, "w") as f:
            f.write(f"deb {self.REPO_URL} ./\n")

    def _remove_repo(self):
        try:
            os.remove(self.REPO_FILE)
        except FileNotFoundError:
            pass
        except OSError:
            logger.error("Can't delete %s", self.REPO_FILE)

    @ea4_only
    @AbstractFeature.raise_if_shouldnt_install_now
    async def install(self):
        self._generate_repo()
        return f"Repo {self.REPO_NAME} activated.\n" + self.MSG

    @ea4_only
    @AbstractFeature.raise_if_shouldnt_remove_now
    async def remove(self):
        self._remove_repo()
        return f"Repo {self.REPO_NAME} removed.\n" + self.MSG

    @ea4_only
    async def status(self):
        return await super().status()

    async def _check_installed_impl(self) -> bool:
        return os.path.isfile(self.REPO_FILE)


def _is_cloudlinux_release_installed() -> bool:
    return os.path.exists("/etc/cloudlinux-release")


def get_hardened_php_feature() -> Optional[AbstractFeature]:
    """
    :return: AbstractFeature subclass: feature that implements Hardened PHP
             installation for current environment.
    """
    has_cpanel = cPanel.is_installed()
    # CloudLinux 10+ runs on an AlmaLinux base and reports ID=almalinux
    # in os-release; only the cloudlinux-release marker tells them apart
    is_el_family = (
        OsReleaseInfo.is_centos()
        or OsReleaseInfo.is_rhel()
        or OsReleaseInfo.is_oracle_linux()
        or OsReleaseInfo.is_almalinux()
        or OsReleaseInfo.is_rockylinux()
    )
    if is_el_family and not _is_cloudlinux_release_installed():
        if has_cpanel and int(distro.major_version()) >= 10:
            return EaPHPCentosEL10
        elif has_cpanel and int(distro.major_version()) >= 9:
            return EaPHPCentosEL9
        elif has_cpanel:
            return EaPHPCentos
        else:
            return HardenedPHPCentos
    if OsReleaseInfo.is_cloudlinux() or (
        is_el_family and _is_cloudlinux_release_installed()
    ):
        if OsReleaseInfo.is_cloudlinux_solo():
            return HardenedPHPCloudLinuxSolo
        # CL regular
        return HardenedPHPCloudLinux
    if OsReleaseInfo.is_ubuntu() or OsReleaseInfo.is_debian():
        if has_cpanel:
            if _is_cloudlinux_release_installed():
                return HardenedPHPCloudLinux
            return EaPHPUbuntuCpanel
        return HardenedPHPUbuntu
    return None
