ok

Mini Shell

Direktori : /opt/imunify360/venv/lib/python3.11/site-packages/im360/plugins/protector/
Upload File :
Current File : //opt/imunify360/venv/lib/python3.11/site-packages/im360/plugins/protector/lazy_init.py

"""This plugin periodically checks set of rules and ipsets,
and recreates it if needed, process block/unblock messages."""

import asyncio
import json
import logging
import os
import time
from contextlib import suppress
from pathlib import Path
from typing import Set

from defence360agent import utils
from defence360agent.contracts.config import UserConfig
from defence360agent.contracts.messages import MessageType
from defence360agent.contracts.plugins import MessageSink, expect
from defence360agent.internals.global_scope import g
from defence360agent.utils import log_error_and_ignore, recurring_check, timeit
from defence360agent.utils.common import DAY, ServiceBase, rate_limit
from im360.contracts.config import Firewall, NetworkInterface
from im360.contracts.config import Protector as Config
from im360.contracts.config import Webshield
from im360.files import GEO, WHITELISTS
from im360.internals.core import IPSetNoRedirectPort, RuleSet, ip_versions
from im360.internals.core.ipset.libipset import IPSetError
from im360.internals.core.firewall import get_firewall
from im360.internals.core.firewall.base import (
    FirewallBatchCommandError,
    FirewallError,
    FirewallTemporaryError,
)
from im360.internals.core.ipset.country import IPSetCountry
from im360.internals.core.ipset.ip import (
    IPSetBlack,
    IPSetCustomBlacklist,
    IPSetCustomWhitelist,
    IPSetGray,
    IPSetGraySplashScreen,
    IPSetIgnore,
    IPSetStatic,
    IPSetStaticRemoteProxy,
    IPSetWhite,
)
from im360.internals.core.ipset.port_deny import (
    InputPortBlockingDenyModeIPSet,
    OutputPortBlockingDenyModeIPSet,
)
from im360.model.firewall import IPList
from im360.subsys.webshield_mode import Mode as WebshieldMode
from im360.utils.validate import IP, IPVersion

from im360.subsys import smtp_blocking

logger = logging.getLogger(__name__)


class VersionState:
    def __init__(self):
        self.errors = 0
        self.next_try_time = 0.0
        self.running = True


class RulesChecker(ServiceBase):
    """Periodically checks if rules exist and if not, recreate them."""

    #: delay in secs between two iptables rule/ipsets checks
    RULE_CHECK_INTERVAL = int(
        os.environ.get("IMUNIFY360_RULE_CHECK_INTERVAL", 30)
    )
    CONSECUTIVE_ERROR_LIMIT = 10  # retries until giving up
    ERROR_THRESHOLD = 10  # disabled IPv6 support after this many failures
    RETRY_INTERVAL = 3600  # interval between attempts to enable IPv6
    CAPTURE_CSF_LOCK = True
    CSF_LOCK_TIMEOUT = 300  # seconds
    IPSETS_CHECK_INTERVAL = DAY  # seconds

    def __init__(self, loop):
        super().__init__(loop)
        self.ruleset = RuleSet()
        # Prevent rules create/destroy public method calls
        # from overlapping with @recurring_check->create_rules/destroy_rules()
        #
        # Use this lock to operate on rules (add to ipset)
        # while keeping the rules in consistent state (not half created
        # or half destroyed).
        self.lock_rules_create_destroy = Config.RULE_EDIT_LOCK

        self.active_interface_conf = NetworkInterface.get_interface_conf()
        self.versions = {ver: VersionState() for ver in ip_versions.all()}
        self._ipsets_outdated_events = {
            ver: asyncio.Event() for ver in self.versions
        }
        self.outdated_ipsets = {ver: set() for ver in ip_versions.all()}
        self.ipset_lock = asyncio.Lock()

    async def recreate_rules_if_needed(self, recreate_any_way=False) -> None:
        # re-reading config file
        async with self.lock_rules_create_destroy:
            new_conf = NetworkInterface.get_interface_conf()
            if not new_conf == self.active_interface_conf:
                logger.info(
                    "Target & ignore interfaces config was changed,"
                    "recreating rules"
                )
                await self._destroy_rules_and_sets(self.active_interface_conf)
            await self._ensure_rules_exist_for_all_versions(
                new_conf, recreate_any_way
            )
            self.active_interface_conf = new_conf

    @recurring_check(RULE_CHECK_INTERVAL, CONSECUTIVE_ERROR_LIMIT)
    async def _run(self):
        if g.get("shutdown_started"):
            logger.info("Shutdown started, stopping rules checker")
            return
        await self.recreate_rules_if_needed()
        await self.check_ipsets_consistent()  # pragma: no cover
        await self.check_smtp_state_and_reset()
        logger.info("IP sets verification and initialization completed")

    @rate_limit(period=IPSETS_CHECK_INTERVAL)
    @log_error_and_ignore()
    async def check_ipsets_consistent(self):  # pragma: no cover
        await self._check_ipsets_consistent()

    async def _check_ipsets_consistent(self):
        async with self.lock_rules_create_destroy:
            for version, state in self.versions.items():
                with timeit(
                    f"Checking outdated ipsets {version}", log=logger.info
                ):
                    if state.running:
                        if outdated := await self.ruleset.get_outdated_ipsets(
                            version
                        ):
                            self.outdated_ipsets[version] = set(
                                ip_count.name for ip_count in outdated
                            )
                            self._ipsets_outdated_events[version].set()
                            logger.warning(
                                "%s ipsets are outdated. Recreate them",
                                outdated,
                            )
                        else:
                            self.outdated_ipsets[version] = set()

    async def _ensure_rules(
        self, interface_conf, recreate_any_way=False
    ) -> bool:
        now = time.monotonic()
        target_versions = set(
            version
            for version in ip_versions.all()
            if (
                ip_versions.is_enabled(version)
                or self.versions[version].next_try_time < now
            )
        )
        recreated_any = False
        has_failed = False
        for version in target_versions:
            self.versions[version].running = False
            try:
                async with self.ipset_lock:
                    r = await self._ensure_for(
                        version, interface_conf, recreate_any_way
                    )
                recreated_any = recreated_any or r
            except (FirewallTemporaryError, IPSetError) as exc:
                has_failed = True
                logger.info(
                    "Transient error while creating firewall rules for %s: %s",
                    version,
                    exc,
                )
            except FirewallError as exc:
                enabled_ip_version = ip_versions.is_enabled(version)
                if enabled_ip_version:
                    has_failed = True
                    self.versions[version].errors += 1
                    if self.versions[version].errors >= self.ERROR_THRESHOLD:
                        if ip_versions.disable(version):
                            self.versions[version].next_try_time = (
                                now + self.RETRY_INTERVAL
                            )
                            logger.info(
                                "%s firewall support is disabled due"
                                " to multiple consecutive errors",
                                version,
                            )
                else:
                    self.versions[version].next_try_time = (
                        now + self.RETRY_INTERVAL
                    )
                logger.warning(
                    "Failed to recreate firewall rules for %s %s: %s",
                    "enabled" if enabled_ip_version else "disabled",
                    version,
                    exc,
                )
            else:
                self.versions[version].running = True
                self.versions[version].errors = 0
                if not ip_versions.is_enabled(version):
                    ip_versions.enable(version)
                    logger.info("%s firewall support is enabled", version)
        # whether recreated rules for any ip version and no failures
        # in creating rules for enabled ip versions
        return recreated_any and not has_failed

    async def _ensure_rules_exist_for_all_versions(
        self, interface_conf=None, recreate_any_way=False
    ):
        interface_conf = interface_conf or self.active_interface_conf
        if await self._ensure_rules(interface_conf, recreate_any_way):
            logger.info(
                "Rules and sets successfully recreated for enabled ip versions"
            )

    async def _recreate_for(
        self,
        ip_version: IPVersion,
        interface_conf: dict,
        recreate_ipsets: bool,
        outdated_ipsets: Set[str],
        missing_ipsets: Set[str],
        redundant_ipsets: Set[str],
        recreate_rules: bool,
    ):
        if recreate_rules or redundant_ipsets:
            with timeit(f"Destroying rules for {ip_version}", log=logger.info):
                await self._destroy_rules(interface_conf, ip_version)

        if redundant_ipsets:
            with timeit(
                f"Destroying redundant ipsets: {redundant_ipsets}",
                log=logger.info,
            ):
                await self.ruleset.destroy_ipsets(ip_version, redundant_ipsets)

        if missing_ipsets:
            with timeit(
                f"Creating missing ipsets: {missing_ipsets}", log=logger.info
            ):
                await self.ruleset.fill_ipsets(ip_version, missing_ipsets)

        if recreate_rules or redundant_ipsets:
            with timeit(f"Recreating rules for {ip_version}", log=logger.info):
                await self._create_rules(interface_conf, ip_version)

        if recreate_ipsets:
            with timeit(f"Recreating ipsets", log=logger.info):
                await self.ruleset.recreate_ipsets(ip_version, outdated_ipsets)

    async def _ensure_for(
        self,
        ip_version: IPVersion,
        interface_conf: dict,
        recreate_any_way=False,
    ) -> bool:
        """Creates imunify360 ruleset for given IP version in iptables.

        If all required ipsets, rules and chains exist, does nothing.
        Otherwise recreates everything as required.

        Returns True if rules or sets has been (re-)created, False
        otherwise."""
        existing_ipsets = await self.ruleset.existing_ipsets(ip_version)
        required_ipsets = self.ruleset.required_ipsets(ip_version)
        redundant_ipsets = set()
        missing_ipsets = required_ipsets.copy()
        ipsets_ok = (
            existing_ipsets == required_ipsets
            and not self._ipsets_outdated_events[ip_version].is_set()
        )
        redundant_ipsets = existing_ipsets - required_ipsets
        missing_ipsets -= existing_ipsets
        # don't log in case when no existing ipsets, it is OK on start
        if existing_ipsets and existing_ipsets != required_ipsets:
            _log_ipsets_mismatch(
                missing_ipsets=missing_ipsets,
                redundant_ipsets=redundant_ipsets,
                log=logger.warning,
            )
        rules_ok = await self._rules_ok(interface_conf, ip_version)
        # remove redundant ipsets from outdated ipsets, as they will be
        # removed anyway
        # remove missing ipsets from outdated ipsets, as they will be
        # created anyway
        outdated_ipsets = (
            self.outdated_ipsets[ip_version]
            - redundant_ipsets
            - missing_ipsets
        )
        if g.get("DEBUG"):
            logger.info("Required ipsets: %s", required_ipsets)
            logger.info("Existing ipsets: %s", existing_ipsets)
            logger.info("Missing ipsets: %s", missing_ipsets)
            logger.info("Redundant ipsets: %s", redundant_ipsets)
            logger.info("Outdated ipsets: %s", outdated_ipsets)
        if not (rules_ok and ipsets_ok and not recreate_any_way):
            if not ipsets_ok:  # ipsets will be re-created
                self._ipsets_outdated_events[ip_version].clear()
                self.outdated_ipsets[ip_version].clear()
            logger.info(
                "Rules status for %s [rules: %s], [ipset: %s]",
                ip_version,
                "ok" if rules_ok else "bad",
                "ok" if ipsets_ok else "bad",
            )
            await self._recreate_for(
                ip_version,
                interface_conf,
                recreate_ipsets=not ipsets_ok,
                outdated_ipsets=outdated_ipsets,
                redundant_ipsets=redundant_ipsets,
                missing_ipsets=missing_ipsets,
                recreate_rules=not rules_ok or recreate_any_way,
            )
            return True
        return False

    async def _create_rules(
        self, interface_conf, ip_version: IPVersion
    ) -> None:
        async with await get_firewall(ip_version) as firewall:
            batch = await self.ruleset.create_commands(
                firewall, interface_conf, ip_version
            )
            await firewall.commit(batch)

    async def _destroy_rules(
        self, interface_conf, ip_version: IPVersion
    ) -> None:
        async with await get_firewall(ip_version) as firewall:
            for batch in self.ruleset.destroy_commands(
                firewall, interface_conf, ip_version
            ):
                with suppress(FirewallBatchCommandError):
                    # Command may fail and that is ok
                    await firewall.commit(batch)

    async def _rules_ok(self, interface_conf, ip_version: IPVersion) -> bool:
        async with await get_firewall(ip_version) as firewall:
            actions = await self.ruleset.check_commands(
                firewall, interface_conf, ip_version
            )
            try:
                await firewall.commit(actions)
            except FirewallBatchCommandError:
                return False
            else:
                return True

    async def _destroy_rules_and_sets(self, interface_conf):
        errors = []
        for ip_version in ip_versions.enabled():
            self.versions[ip_version].running = False
            try:
                await self._destroy_rules(interface_conf, ip_version)
                await self.ruleset.destroy_ipsets(ip_version)
            except Exception as e:
                errors.append(e)

        if not errors:
            return
        elif len(errors) == 1:
            raise errors[0]
        elif len(errors) == 2:
            # hack: to get "free" readable traceback for both errors
            raise errors[1] from errors[0]
        else:  # pragma: no cover
            assert 0, "max 2 ip versions expected"

    async def clear_everything(self, interface_conf=None) -> None:
        interface_conf = interface_conf or self.active_interface_conf
        async with self.lock_rules_create_destroy:
            await smtp_blocking.reset_rules_for_all_versions()
            await self._destroy_rules_and_sets(interface_conf)

    async def check_smtp_state_and_reset(self, check_settings=False):
        new_settings = smtp_blocking.read_SMTP_settings()
        if check_settings and not self._is_smtp_settings_changed(new_settings):
            return

        if not await smtp_blocking.is_SMTP_blocking_supported():
            return

        if await smtp_blocking.conflicts_exist():
            await smtp_blocking.reset_rules_for_all_versions()
            return

        await smtp_blocking.sync_rules_for_all_versions(new_settings)

    @staticmethod
    def _is_smtp_settings_changed(new_settings):
        return any(
            active_settings != new_settings
            for active_settings in smtp_blocking.get_active_settings_list()
        )


class RealProtector(MessageSink):
    PROCESSING_ORDER = MessageSink.ProcessingOrder.IPSET_PROTECTOR
    SHUTDOWN_PRIORITY = 200  # shuts down last
    AVAILABLE_ON_FREEMIUM = False

    def __init__(self):
        super().__init__()
        self._port_blocking_mode = Firewall.port_blocking_mode
        self._port_blocking_deny_mode_values = (
            self._get_port_blocking_deny_mode_values()
        )
        # saving webshield status before updating the rules
        # (on _rules_checker.start()), to avoid re-checking
        # the rules on the 1st ConfigUpdate (on start-up)
        self._webshield_status = (
            Webshield.ENABLE,
            WebshieldMode.wants_redirect(WebshieldMode.get()),
            Webshield.SPLASH_SCREEN,
            Webshield.PANEL_PROTECTION,
        )

    async def create_sink(self, loop, rules_checker=None):
        """Create all ipset sets"""
        self._loop = loop
        self._process_config_update_lock = asyncio.Lock()
        self._shutdown_lock = asyncio.Lock()
        self._rules_checker = rules_checker or RulesChecker(loop)
        self._rules_checker.start()

    async def shutdown(self):
        async with self._shutdown_lock:
            self._rules_checker.should_stop()
            await self._rules_checker.wait()
        await self._rules_checker.clear_everything()
        logger.info("IP sets and rules have been removed successfully.")

    def _get_port_blocking_deny_mode_values(self):
        return (
            Firewall.TCP_IN_IPV4,
            Firewall.TCP_OUT_IPV4,
            Firewall.UDP_IN_IPV4,
            Firewall.UDP_OUT_IPV4,
        )

    def _get_current_ttl(self, expiration):
        if expiration:
            return int(expiration - time.time())
        return 0

    def _rules_running(self, ip_version: IPVersion) -> bool:
        return self._rules_checker.versions[ip_version].running

    @expect(MessageType.BlockUnblockList)
    async def process_block_unblock_list(self, message):
        """Block/unblock in batch in ipset"""
        # ipset are reloaded back from the db when rules recreated
        async with self._rules_checker.lock_rules_create_destroy:
            # remove ips with inactive ip versions
            lists = {
                "blocklist": {
                    (ip, listname): properties
                    for (ip, listname), properties in message[
                        "blocklist"
                    ].items()
                    if self._rules_running(IP.type_of(ip))
                },
                "unblocklist": [
                    (ip, listname)
                    for ip, listname in message["unblocklist"]
                    if self._rules_running(IP.type_of(ip))
                ],
            }
            num_tot = sum(map(len, lists.values()))

            async def block_unblock_many(IPSet, listname):
                """
                Add *listname*'s *blocklist* ips to *IPSet*.
                Remove *unblocklist* ips from *IPSet*.
                Remove non-*listname*'s *blocklist* ips from *IPSet*
                """
                ipset = IPSet()
                if not ipset.is_enabled():
                    logger.warning(
                        "%s ipset is disabled. Skip block/unblock.",
                        ipset.__class__.__qualname__,
                    )
                    return
                to_block = (
                    (ip, properties)
                    for (ip, block_listname), properties in lists[
                        "blocklist"
                    ].items()
                    if block_listname == listname
                )

                to_unblock = (
                    ip
                    for ip, unblock_listname in lists["unblocklist"]
                    if unblock_listname == listname
                )
                await ipset.restore(to_block, to_unblock)

            with timeit(
                "Restoring to ipset on synclist response %d ips" % num_tot,
                logger,
            ):
                await block_unblock_many(IPSetIgnore, IPList.IGNORE)
                await block_unblock_many(IPSetGray, IPList.GRAY)
                await block_unblock_many(
                    IPSetGraySplashScreen, IPList.GRAY_SPLASHSCREEN
                )
                await block_unblock_many(IPSetBlack, IPList.BLACK)
                await block_unblock_many(IPSetWhite, IPList.WHITE)

    @expect(MessageType.IpsetUpdate)
    async def process_ipset_update(self, _):
        await self._rules_checker.recreate_rules_if_needed()

    @expect(MessageType.FilesUpdated, files_type=WHITELISTS)
    async def process_global_whitelist_update(self, _):
        # whitelist update will be applied to ipset when rules/ipsets
        # are reloaded back from the db
        async with self._rules_checker.lock_rules_create_destroy:
            logger.info("Applying global white list update")
            for ipset in [IPSetStatic(), IPSetStaticRemoteProxy()]:
                if ipset.is_enabled():
                    await ipset.reset()

    @expect(MessageType.FilesUpdated, files_type=GEO)
    async def update_rules_and_ipset(self, _) -> None:
        async with self._rules_checker.lock_rules_create_destroy:
            logger.info("Updating ipset rules on geo ip update")
            for ip_version in ip_versions.enabled():
                await IPSetCountry().restore(ip_version)

    @expect(MessageType.UpdateCustomLists)
    async def process_custom_lists_update(self, _):
        async with self._rules_checker.lock_rules_create_destroy:
            await IPSetCustomBlacklist().reset()
            await IPSetCustomWhitelist().reset()
            logger.info("IPSet custom black and white lists are updated")

    @expect(MessageType.ConfigUpdate)
    @utils.log_error_and_ignore()
    async def process_config_update(self, message):
        """
        Recreate firewall rules and/or ipsets if corresponding settings change.
        """
        if isinstance(message["conf"], UserConfig):
            return  # do nothing for non-root config updates
        async with self._process_config_update_lock:
            await self._rules_checker.check_smtp_state_and_reset()
            return await self._on_config_update_unlocked(message)

    async def _on_config_update_unlocked(self, message):
        recreate = False
        current_status = (
            Webshield.ENABLE,
            WebshieldMode.wants_redirect(WebshieldMode.get()),
            Webshield.SPLASH_SCREEN,
            Webshield.PANEL_PROTECTION,
        )
        recreate_any_way = False
        if current_status != self._webshield_status:
            logger.info(
                "Webshield status (Webshield.ENABLE, "
                "WebshieldMode.wants_redirect, Webshield.SPLASH_SCREEN, "
                "Webshield.PANEL_PROTECTION) changed from %s to %s",
                self._webshield_status,
                current_status,
            )
            if Webshield.PANEL_PROTECTION != self._webshield_status[-1]:
                recreate_any_way = True
            self._webshield_status = current_status
            recreate = True
        current_port_blocking_mode = Firewall.port_blocking_mode
        if self._port_blocking_mode != current_port_blocking_mode:
            logger.info(
                "Ports blocking mode changed from %s to %s",
                self._port_blocking_mode,
                current_port_blocking_mode,
            )
            self._port_blocking_mode = current_port_blocking_mode
            recreate = True
        if recreate:  # recreate everything
            await self._rules_checker.recreate_rules_if_needed(
                recreate_any_way
            )
            logger.info("Firewall rules recreated due to ConfigUpdate")
        else:  # update just port blocking deny mode
            if await self._update_port_blocking_deny_mode_ipsets_if_needed():
                logger.info("Blocked ports deny mode updated on ConfigUpdate")

    async def _update_port_blocking_deny_mode_ipsets_if_needed(self):
        updated = False
        new_ports_blocking_values = self._get_port_blocking_deny_mode_values()
        if new_ports_blocking_values != self._port_blocking_deny_mode_values:
            logger.info(
                "Port blocking deny mode changed from %s to %s",
                _format_ports(self._port_blocking_deny_mode_values),
                _format_ports(new_ports_blocking_values),
            )
            self._port_blocking_deny_mode_values = new_ports_blocking_values
            updated = True
            async with self._rules_checker.lock_rules_create_destroy:
                sets = [
                    InputPortBlockingDenyModeIPSet(),
                    OutputPortBlockingDenyModeIPSet(),
                    IPSetNoRedirectPort(),
                ]
                for ip_set in sets:
                    # FIREWALL.port_blocking_mode supports only ipv4
                    try:
                        await ip_set.restore(IP.V4)
                    except IPSetError as e:
                        logger.error(
                            "Failed to update ipset %s: %s",
                            ip_set.__class__.__name__,
                            e,
                        )
                        updated = False
                        continue

        return updated

    @expect(MessageType.StrategyChange)
    async def on_strategy_change(self, message: MessageType.StrategyChange):
        await self._rules_checker.recreate_rules_if_needed()
        logger.info(
            "Firewall rules recreated due to StrategyChange %s",
            message.strategy,
        )


def _log_ipsets_mismatch(missing_ipsets, redundant_ipsets, log):
    """Report missing/redundant ipsets."""
    assert missing_ipsets or redundant_ipsets
    log(
        "Detected %s%s%s ipsets while ensuring ipsets/rules%s%s",
        "missing" * bool(missing_ipsets),
        "/" * bool(missing_ipsets and redundant_ipsets),
        "redundant" * bool(redundant_ipsets),
        f"; missing ipsets: {missing_ipsets}" * bool(missing_ipsets),
        f"; redundant ipsets: {redundant_ipsets}" * bool(redundant_ipsets),
    )


def _format_ports(ports):
    """Format ports for logging."""
    # note: the order is asserted in unit tests
    return json.dumps(
        dict(
            zip(
                "TCP_IN_IPV4 TCP_OUT_IPV4 UDP_IN_IPV4 UDP_OUT_IPV4".split(),
                ports,
            )
        )
    )

Zerion Mini Shell 1.0