#!/opt/cloudlinux/venv/bin/python3 -bb
#
# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2024 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT

import argparse
import enum
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Callable

import logs
from clflags.config import DEFAULT_FLAGS_STORAGE_PATH

ENABLED_FEATURES_DIR = '/etc/cagefs/enabled_features'

logger = logs.setup_logger('feature_manager',
                           '/var/log/cloudlinux/cagefs.log')


class FlagBehavior(enum.Enum):
    """
    Defines the behavior of a CloudLinux Server Flag
    based on the presence of a file. The existence of this flag
    not only indicates that the 'feature' is enabled
    but could also mean it is explicitly disabled by creating the file.
    """
    ENABLE_ON_PRESENT = enum.auto()
    DISABLE_ON_PRESENT = enum.auto()


@dataclass
class Flag:
    """
    Represents a CloudLinux Server Flag
    typically located at /opt/cloudlinux/flags/enabled-flags.d/
    with a specific behavior based on a file existence.
    """
    flag_name: str
    flag_behavior: FlagBehavior

    @property
    def enabled(self) -> bool:
        flag_path = Path(DEFAULT_FLAGS_STORAGE_PATH, f'{self.flag_name}.flag')
        flag_exists = flag_path.exists()

        if self.flag_behavior == FlagBehavior.ENABLE_ON_PRESENT:
            return flag_exists
        elif self.flag_behavior == FlagBehavior.DISABLE_ON_PRESENT:
            return not flag_exists
        else:
            raise RuntimeError('Invalid value for the "flag_behavior" attribute')


@dataclass
class CageFsFeature:
    """
    Represents a feature within the CageFS file system.
    """
    name: str
    description: str
    handler: Callable
    flag: Flag

    @property
    def feature_marker_path(self) -> Path:
        """
        Returns the path to the CageFS feature marker file.
        """
        return Path(ENABLED_FEATURES_DIR, self.name)

    @property
    def enabled(self) -> bool:
        """
        Determines if the CageFS feature is enabled based on the existence
        of its feature marker file and CloudLinux Server Flag status.
        """
        return self.feature_marker_path.exists() and self.flag.enabled


class CageFsFeatureManager:
    """
    Manages a collection of CageFS features.
    """

    def __init__(self, features: list[CageFsFeature]):
        self.features: list[CageFsFeature] = features

    def enable(self):
        for feature in self.features:
            self._enable_feature(feature)

    def sync(self):
        for feature in self.features:
            self._sync_feature(feature)

    def _enable_feature(self, feature: CageFsFeature):
        feature.feature_marker_path.touch()

    def _sync_feature(self, feature: CageFsFeature):
        feature.handler(feature.enabled)


# Packaged source of the dbus-hardening policy (a full <busconfig> document).
DBUS_POLICY_SRC = Path('/usr/share/cagefs/configs/dbus/cagefs-dbus-hardening.conf')


def _reload_dbus():
    """Ask the running message bus to re-read its policy files.

    The classic dbus-daemon (cl7/cl7h/cl8, Ubuntu) reads its config only at
    startup or on an explicit reload; dbus-broker (cl9/cl10) reloads drop-ins on
    its own when a policy file changes, but not when an *account* referenced by a
    user= rule is created later. Both implement org.freedesktop.DBus.ReloadConfig,
    which re-reads every policy file and re-resolves user=/group= names from NSS.

    Invoke it over the bus (busctl) rather than `systemctl reload <unit>`: the
    bus unit name is not portable (messagebus.service on cl7/cl7h/cl8,
    dbus-broker.service on cl9/cl10, dbus.service on Ubuntu), but the
    ReloadConfig method is. Best-effort: a failure here must not abort the caller.
    """
    try:
        subprocess.run(['/usr/bin/busctl', 'call', 'org.freedesktop.DBus',
                        '/org/freedesktop/DBus', 'org.freedesktop.DBus',
                        'ReloadConfig'],
                       check=True, stdout=subprocess.DEVNULL,
                       stderr=subprocess.PIPE)
    except (OSError, subprocess.CalledProcessError) as exc:
        logger.warning(
            'Could not reload the D-Bus message bus after updating the '
            'dbus-hardening policy; it will take effect on the next dbus '
            'restart. Error: %s', exc)


class _DbusHardening:
    """Deploy or remove the cagefs dbus-hardening policy so its blanket
    default-deny is the LAST rule dbus parses for systemd1/login1 (dbus applies
    the last matching rule within a precedence class). Subclasses differ only in
    WHERE the policy must land to achieve that on a given OS layout.

    Root keeps access via systemd's own user="root" allow (a higher precedence
    class than context="default"), wherever our deny lands.
    """

    def __init__(self, source: Path = DBUS_POLICY_SRC):
        self._source = source

    def apply(self, is_enabled: bool) -> None:
        """Bring the policy to the desired state, reloading the bus only when a
        file actually changed (an unconditional reload on every `sync` would
        needlessly churn the bus). The classic dbus-daemon re-reads its config
        only on reload; dbus-broker would pick drop-ins up on its own, but the
        explicit reload is harmless there."""
        changed = self._enable() if is_enabled else self._disable()
        if changed:
            _reload_dbus()

    # -- shared file operations ---------------------------------------------
    def _write(self, target: Path) -> bool:
        """Copy the packaged policy to `target` if it is missing or stale (e.g.
        after a package upgrade, where guarding only on `not exists` would leave
        the old policy in place). Returns True if it changed."""
        if (not target.exists()
                or target.read_bytes() != self._source.read_bytes()):
            shutil.copy2(self._source, target)
            return True
        return False

    @staticmethod
    def _unlink(target: Path) -> bool:
        if target.exists():
            target.unlink()
            return True
        return False

    # -- subclass contract ---------------------------------------------------
    def _enable(self) -> bool:
        raise NotImplementedError

    def _disable(self) -> bool:
        raise NotImplementedError

    def is_deployed(self) -> bool:
        """True if our policy is currently in place, so re-reading the bus would
        actually re-resolve our exemptions (used to gate the postgres trigger)."""
        raise NotImplementedError


class DropinDbusHardening(_DbusHardening):
    """Long-term path (cl8+/Ubuntu). systemd ships its own bus policy under
    /usr/share/dbus-1/system.d, which dbus parses BEFORE /etc/dbus-1/system.d,
    so a cagefs-owned drop-in in /etc/dbus-1/system.d is parsed later and wins.
    The file has a cagefs-specific name, so it is always ours and is %ghost'd by
    the package (RPM removes it on erase)."""

    TARGET = Path('/etc/dbus-1/system.d/cagefs-dbus-hardening.conf')

    def _enable(self) -> bool:
        if not self.TARGET.parent.exists():
            return False
        return self._write(self.TARGET)

    def _disable(self) -> bool:
        return self._unlink(self.TARGET)

    def is_deployed(self) -> bool:
        # The drop-in has a cagefs-specific name, so its presence means it's ours.
        return self.TARGET.exists()


# ===========================================================================
# CL7/CL7H strategy — DELETE THIS CLASS and its branch in
# select_dbus_hardening() when cl7/cl7h support is dropped (cl7 is near EOL);
# the DropinDbusHardening path then stands alone.
#
# Why it exists: on cl7/cl7h systemd ships org.freedesktop.{systemd1,login1}.conf
# in /etc/dbus-1/system.d — the same directory as a drop-in — and the classic
# dbus-daemon loads that directory in undefined (readdir) order, so a sibling
# drop-in cannot reliably override systemd's per-method allowlist (CLOS-4516).
# /etc/dbus-1/system-local.conf is parsed AFTER the whole system.d directory
# (see /usr/share/dbus-1/system.conf), so our deny lands there and wins. That
# file is the admin's shared local-override (owned by no package), so we tag our
# copy with a marker and never touch a file that lacks it. It is NOT %ghost'd
# (RPM would delete an admin file on erase); on uninstall the marker-tagged file
# is simply left in place — fail-safe (the host stays restricted, not opened),
# and an admin can remove it by hand if desired.
class SystemLocalDbusHardening(_DbusHardening):
    TARGET = Path('/etc/dbus-1/system-local.conf')
    MARKER = b'CAGEFS-DBUS-HARDENING-MANAGED'
    # systemd's own policy lives here only on cl7/cl7h (else it's /usr/share).
    SYSTEMD_POLICY = Path('/etc/dbus-1/system.d/org.freedesktop.systemd1.conf')

    @classmethod
    def applies(cls) -> bool:
        """True on cl7/cl7h, where systemd's bus policy shares system.d."""
        return cls.SYSTEMD_POLICY.exists()

    def _owns_target(self) -> bool:
        """True if system-local.conf carries our marker (cagefs wrote it). A
        file without it is admin-authored and must never be touched."""
        try:
            return self.MARKER in self.TARGET.read_bytes()
        except OSError:
            return False

    def _enable(self) -> bool:
        # A drop-in from an older cagefs (CLOS-2704) is inert on cl7 — drop it.
        changed = self._unlink(DropinDbusHardening.TARGET)
        if self.TARGET.exists() and not self._owns_target():
            logger.warning(
                '%s already exists and is not managed by cagefs; leaving it '
                'untouched. CageFS dbus-hardening is INACTIVE on this host until '
                'that file is removed or the deny policy is merged into it by '
                'hand.', self.TARGET)
            return changed
        if self._write(self.TARGET):
            changed = True
        return changed

    def _disable(self) -> bool:
        changed = self._unlink(DropinDbusHardening.TARGET)
        if (self.TARGET.exists() and self._owns_target()
                and self._unlink(self.TARGET)):
            changed = True
        return changed

    def is_deployed(self) -> bool:
        # Only ours if it carries our marker (else it's an admin-authored file).
        return self.TARGET.exists() and self._owns_target()
# End cl7/cl7h strategy.
# ===========================================================================


def select_dbus_hardening() -> _DbusHardening:
    """Pick the deployment strategy for the running OS layout."""
    if SystemLocalDbusHardening.applies():   # cl7/cl7h — remove when cl7 dies
        return SystemLocalDbusHardening()
    return DropinDbusHardening()


def handle_dbus_hardening(is_enabled: bool):
    """Entry point for the dbus-hardening CageFsFeature handler."""
    select_dbus_hardening().apply(is_enabled)


def reload_dbus_hardening_policy():
    """Re-read the bus policy if our dbus-hardening policy is currently deployed.

    D-Bus resolves a policy's user= name to a uid only when it reads its config,
    then drops any rule it cannot resolve. The policy exempts the 'postgres'
    system account, but cagefs deploys and reloads it at its own install time --
    normally before postgresql-server creates that account -- so the rule is
    dropped and the exemption never activates. The postgresql-server package
    trigger calls this once the account exists to make the exemption take effect
    (CLOS-4632). No-op when the feature is disabled (policy not deployed)."""
    strategy = select_dbus_hardening()
    if strategy.is_deployed():
        _reload_dbus()


# List of available CageFS features
FEATURES = [
    CageFsFeature(
        name='dbus-hardening',
        description="Restrict user's capability to communicate "
                    "with systemd via D-Bus using certain methods",
        handler=handle_dbus_hardening,
        flag=Flag(
            flag_name='disable-dbus-hardening',
            flag_behavior=FlagBehavior.DISABLE_ON_PRESENT,
        ),
    ),
    # ... Add new features here ...
]


def enable(features: list[CageFsFeature]):
    """
    Enable specified CageFS features.
    """
    manager = CageFsFeatureManager(features)
    manager.enable()


def sync():
    """
    Synchronize the state of CageFS featues based on their current enabled status.
    """
    manager = CageFsFeatureManager(FEATURES)
    manager.sync()


def main():
    parser = argparse.ArgumentParser(
        description='Available manipulations with CageFS features')
    subparsers = parser.add_subparsers(dest='command', help='Available commands')

    # Enable command parser
    parser_enable = subparsers.add_parser('enable', help='Enable CageFS features')
    for feature in FEATURES:
        parser_enable.add_argument(
            f'--{feature.name}',
            action='store_true',
            help=feature.description,
        )

    # Sync command parser
    subparsers.add_parser(
        'sync', help='Sync CageFS features based on the state of the feature flags')

    # Reload-dbus-hardening command parser
    subparsers.add_parser(
        'reload-dbus-hardening',
        help='Re-read the D-Bus policy so an exempted account (e.g. postgres) '
             'created after the policy was deployed gets its user= exemption')

    args = parser.parse_args()

    try:
        match args.command:
            case 'enable':
                features = [feature for feature in FEATURES
                            if getattr(args, feature.name.replace('-', '_'))]
                if not features:
                    parser.error('At least one feature is required for the "enable" command')

                enable(features)
            case 'sync':
                sync()
            case 'reload-dbus-hardening':
                reload_dbus_hardening_policy()
            case _:
                parser.print_help()
                return 1
    except Exception as e:
        logger.error('An error occurred during manipulations with CageFS features:',
                     exc_info=e)
        return 1


if __name__ == '__main__':
    sys.exit(main())
