\FF\D8\FF\E0\00JFIF\00\00\00d\00d\00\00\FF\FE\00\border bs:0 bc:#000000 ps:0 pc:#ffffff es:0 ec:#000000 ck:feee6c715d26fd9f38b0ca4278c05026\FF\DB\00C\00P7<F<2PFAFZUP_xxnnx\F5\AF\B9\91\C8\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\DB\00CUZZxix‚\EB\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\FF\C0\00\00b\00d\00\FF\C4\00\00\00\00\00\00\00\00\00\00\00\00\00\00\FF\C4\00\00\00\00\00\00\00\00\00\00\001A!Q\FF\C4\00\00\00\00\00\00\00\00\00\00\00\00\00\FF\C4\00\00\00\00\00\00\00\00\00\00!1AQ\FF\DA\00\00\00?\00\F6\00\00\00\00\00\00\00\00\00\00\A0\00\00\C5\CF\F8\E7Ó\FCjD~\B9^\AD\%\B3]\D8cs-\BBs,-\A0\00"\80\00%\B83rÏ^K~F\A4ea\00E\00\C6\EE=u\B1\9B\D1\00@\00r\BE8\F9:\FE5#.M\00\E7\CB\C9q\CBq\D6\F2\BE\B7\C7>\C9n5×\D6?\BDê\9Ds\EBp\9F[`8m\B7)o\B5\E8\E6I\99\FE3]]A2\BA\8Cw\D6E\93\\DEv\C8\009\F2\F1NI?uc\\F5\EA\96k\xN<~buv\EA\C8\D7	\8B\84\CEcxI\BBg\AE\9E=\D6+n\EC\80\C8A\8C\AE\EB\CF\D5\DA\E9"2\A4\B9j5\EB\F3W\B63\96\B30Yu\DA\FC8\ED\DF\E7Ms\FB\F1\8E\B3\FA\EA\E8\E6(\883zs\F2_\8DFk\8Bh
\00\8C\DCw\D3R\B5+6X\BA\B2\C4j\AB0\B4\FCMw\C2I\8E\9B\E3\A9~9u\FA\D3l\80\C8%p\EE\FDn2€
\00$\FEj\C4e\A9\DB\~\95\A7\A5\80EK\BB\8DDsP\00@@AD'k\CF\E8\DB\D2(\80\9AK\D3\85\D6lb\F2\BA\8C*\80\00)\95
59\A3R:\F3\CE"\B6\80\88\00\00i1u4ê\E9\F2á\A6\A2\FACM\93WMb*\E0*\00\00\00\00\00(\A8\80\00\00\80\00\00\00\00\00\00\00\00\00\FF\D9<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>C///</title>
</head>
import asyncio
import json
import logging
import os
import socket
import urllib.error
import urllib.parse
import urllib.request
from collections import defaultdict
from pathlib import Path
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse

import psutil

from defence360agent.contracts.config import ANTIVIRUS_MODE
from defence360agent.contracts.license import LicenseCLN
from defence360agent.subsys.panels.hosting_panel import HostingPanel
from defence360agent.utils import CheckRunError, async_lru_cache, check_run
from defence360agent.utils.common import get_hostname

_TIMEOUT = 300  # timeout for network operations
_IMUNIFY_EMAIL_CONFIG_EXECUTABLE = Path("/usr/sbin/ie-config")
logger = logging.getLogger(__name__)
IE_SUPPORTED_CMD = (
    "wget -qq -O  -"
    " https://repo.imunify360.cloudlinux.com/defence360/imunifyemail-deploy.sh"
    " | bash -s 'is-supported'"
)


@async_lru_cache(maxsize=1)
async def is_imunify_email_supported() -> bool:
    try:
        await check_run(IE_SUPPORTED_CMD, shell=True)
    except CheckRunError as e:
        if e.returncode != 100:
            logger.error(f"imunify-email check failed {str(e)}")
        return False
    return True


async def get_imunify_email_status():
    """Try to get imunify-email status"""
    if ANTIVIRUS_MODE:
        return False
    if not _IMUNIFY_EMAIL_CONFIG_EXECUTABLE.exists():
        return False
    try:
        output = await check_run(
            [str(_IMUNIFY_EMAIL_CONFIG_EXECUTABLE), "status"]
        )
    except CheckRunError:
        return False
    return "spamfilter exim configuration: enabled" in output.decode()


class CLNError(Exception):
    def __init__(self, status=None, message=None):
        self.message = message
        self.status = status

    def __str__(self):
        if self.message:
            return self.message

        return "Unexpected status code from CLN: {}".format(self.status)


class InvalidLicenseError(Exception):
    pass


class BackupNotFound(CLNError):
    GB = 1024 * 1024 * 1024

    def __init__(self, url):
        self.url = url

    def __str__(self):
        return "Backup not found in CLN"

    def add_used_space(self):
        if self.url is None:
            return

        pu = urlparse(self.url)
        query = dict(parse_qsl(pu.query))
        query["used_space"] = self._disk_usage()

        return urlunparse(
            (
                pu.scheme,
                pu.netloc,
                pu.path,
                pu.params,
                urlencode(query),
                pu.fragment,
            )
        )

    def _disk_usage(self):
        total_used = 0
        partitions = psutil.disk_partitions()
        processed = set()
        for p in partitions:
            if (
                (p.device not in processed)
                and ("noauto" not in p.opts)
                and (not p.device.startswith("/dev/loop"))
            ):
                total_used += psutil.disk_usage(p.mountpoint).used
                processed.add(p.device)
        return round(total_used / self.GB)


def _post_request(url, data=None, headers=None, timeout=None):
    """To be used by RestCLN._request()."""
    kwargs = {}
    if headers is not None:
        kwargs["headers"] = headers
    if data is not None:
        if isinstance(data, bytes):
            kwargs.setdefault(
                "headers", {"Content-type": "application/octet-stream"}
            )
        elif isinstance(data, str):
            data = data.encode("utf-8")
            kwargs.setdefault(
                "headers", {"Content-type": "text/plain; charset=utf-8"}
            )
        else:  # dict
            data = urllib.parse.urlencode(data).encode("ascii")
            kwargs.setdefault(
                "headers",
                {"Content-type": "application/x-www-form-urlencoded"},
            )
        kwargs["data"] = data
    try:
        resp = urllib.request.urlopen(
            urllib.request.Request(url, **kwargs), timeout=timeout
        )
    except urllib.error.HTTPError as e:
        # Handle HTTP errors (400, 500, etc.)
        if e.code < 400:
            raise CLNError(e.code) from e
        # e.code >= 400
        message = None
        if e.fp is not None:
            logger.warning(
                "CLN.post(url=%r, data=%r, headers=%r): %d %s",
                url,
                data,
                headers,
                e.code,
                e.reason,
            )
            try:
                resp_data = e.read()
            except socket.timeout:
                raise TimeoutError("Timed out reading error message")
            # the response may be non-json
            message = resp_data.decode(errors="replace")
        raise CLNError(message=message, status=e.code) from e
    except urllib.error.URLError as e:
        # consider this as a network error (DNS resolution failed)
        raise CLNError(message=str(e)) from e
    except socket.timeout:
        raise TimeoutError("Timed out receiving response")
    except OSError as e:
        logger.warning(
            "CLN.post(url=%r, data=%r, headers=%r, timeout=%r): %s",
            url,
            data,
            headers,
            timeout,
            e,
        )
        raise
    else:
        with resp:
            if resp.code == 204:
                return resp.code, None
            elif resp.code in (200, 244):
                # 244 - /im/ab/check returns link for backup buy page
                try:
                    content = resp.read()
                except socket.timeout:
                    raise TimeoutError("Timed out reading response")
                else:
                    try:
                        return resp.code, json.loads(content.decode())
                    except json.JSONDecodeError as e:
                        raise CLNError(
                            message=(
                                f"Non-json data from CLN: {content} for"
                                f" code={resp.code}"
                            ),
                            status=resp.code,
                        ) from e
            else:
                raise CLNError(resp.code)


class RestCLN:
    _URL_PATH_TEMPLATE = "https://{domain}/api/im/"
    _BASE_DOMAIN_NAME = "cln.cloudlinux.com"

    _IPV6_DOMAIN_NAME = os.environ.get(
        "IM360_CLN_API_BASE_URL", "ipv6.cln.cloudlinux.com"
    )
    _IPV4_DOMAIN_NAME = os.environ.get(
        "IM360_CLN_API_BASE_URL", "ipv4.cln.cloudlinux.com"
    )
    _BASE_URL = _URL_PATH_TEMPLATE.format(
        domain=os.environ.get("IM360_CLN_API_BASE_URL", _BASE_DOMAIN_NAME)
    )
    _REGISTER_URL = urljoin(_BASE_URL, "register")
    _UNREGISTER_URL = urljoin(_BASE_URL, "unregister")
    _CHECKIN_URL = urljoin(_BASE_URL, "checkin")
    _ACRONIS_CREDENTIALS_URL = urljoin(_BASE_URL, "ab/credentials")
    _ACRONIS_REMOVE_URL = urljoin(_BASE_URL, "ab/remove")
    _ACRONIS_CHECK_URL = urljoin(_BASE_URL, "ab/check")
    STATUS_OK_PAID_LICENSE = "ok"
    STATUS_OK_TRIAL_LICENSE = "ok-trial"

    @classmethod
    async def _request(cls, url, *, data=None, headers=None, timeout=_TIMEOUT):
        return await asyncio.get_event_loop().run_in_executor(
            None, _post_request, url, data, headers, timeout
        )

    @classmethod
    async def process_ipl_licence(cls):
        v4_license_url = urljoin(
            cls._URL_PATH_TEMPLATE.format(domain=cls._IPV4_DOMAIN_NAME),
            "register",
        )
        data = {"key": "IPL", "hostname": get_hostname()}
        try:
            _, token = await cls._request(v4_license_url, data=data)
        except CLNError as cln_error:
            if cln_error.status == 404:
                v6_license_url = urljoin(
                    cls._URL_PATH_TEMPLATE.format(
                        domain=cls._IPV6_DOMAIN_NAME
                    ),
                    "register",
                )

                _, token = await cls._request(v6_license_url, data=data)
            else:
                raise cln_error
        return token

    @classmethod
    async def register(cls, key: str) -> dict:
        """
        Register server with key
        :param key: registration key
        :return: license token in case of success
        """
        if key == "IPL":
            return await cls.process_ipl_licence()
        _, token = await cls._request(
            cls._REGISTER_URL,
            data={"key": key, "hostname": get_hostname()},
        )
        return token

    @classmethod
    async def checkin(
        cls,
        server_id: str,
        users_count: int,
        hostname: str = None,
    ):
        """
        Update license token
        :param str server_id: server id
        :param int users_count: users count
        :param str hostname: current server hostname
        :return: dict new license token
        """
        hostname = hostname or get_hostname()
        imunify_email_status = await get_imunify_email_status()
        panel = HostingPanel()
        try:
            panel_name = await panel.name()
        except Exception as e:
            logger.error(
                "Failed to get panel version: %s", str(e), exc_info=True
            )
            panel_name = panel.NAME

        req = {
            "id": server_id,
            "hostname": hostname,
            "im": {
                "users": users_count,
                "panel": panel_name,
                "imunifyEmail": imunify_email_status,
                "supported_features": {
                    "IM_EMAIL": await is_imunify_email_supported(),
                },
            },
        }
        data = json.dumps(req)
        logger.info("CLN checkin: %s", data)
        _, token = await cls._request(
            cls._CHECKIN_URL,
            data=data,
            headers={"Content-type": "application/json"},
        )
        return token

    @classmethod
    async def acronis_credentials(cls, server_id: str) -> dict:
        """
        Creates Acronis Backup account and get user & password
        :param server_id: server id
        """
        _, creds = await cls._request(
            cls._ACRONIS_CREDENTIALS_URL, data={"id": server_id}
        )
        return creds

    @classmethod
    async def acronis_remove(cls, server_id: str):
        """
        Removes Acronis Backup account
        :param server_id: server id
        """
        await cls._request(cls._ACRONIS_REMOVE_URL, data={"id": server_id})

    @classmethod
    async def acronis_check(cls, server_id: str) -> dict:
        """
        If Acronis account exists return backup size in GB or if backups
        not exists URL for backups
        :param server_id: server id
        """
        status, response = await cls._request(
            cls._ACRONIS_CHECK_URL, data={"id": server_id}
        )
        if status == 244:  # Backup not found
            raise BackupNotFound(url=None)  # Prohibit purchasing a new backup
        return response

    @classmethod
    async def unregister(cls, server_id=None):
        """
        Unregister server id
        :return: None
        """
        server_id = server_id or LicenseCLN.get_server_id()
        await cls._request(cls._UNREGISTER_URL, data={"id": server_id})


class CLN:
    _CALLBACKS = defaultdict(set)

    @classmethod
    def add_callback_for(cls, method_name, coro_callback):
        cls._CALLBACKS[method_name].add(coro_callback)

    @classmethod
    async def run_callbacks_for(cls, method_name):
        for callback in cls._CALLBACKS[method_name]:
            try:
                await callback()
            except asyncio.CancelledError:
                raise
            except Exception as e:
                logger.exception(
                    "Error '{!r}' happened when run callback {} for"
                    "CLN {} method".format(e, callback, method_name)
                )

    @classmethod
    def is_avp_key(cls, key):
        return key.startswith("IMAVP")

    @classmethod
    async def register(cls, key):
        if cls.is_avp_key(ke