Current File : //opt/imunify360/venv/lib64/python3.11/site-packages/defence360agent/internals/cln.py |
import asyncio
import json
import logging
import os
import socket
import urllib.error
import urllib.parse
import urllib.request
from collections import defaultdict
from functools import lru_cache
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.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 socket.timeout:
raise TimeoutError("Timed out receiving response")
except OSError as e:
if hasattr(e, "code"): # HTTPError
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
else:
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:
_BASE_URL = os.environ.get(
"IM360_CLN_API_BASE_URL", "https://cln.cloudlinux.com/api/im/"
)
_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 register(cls, key: str) -> dict:
"""
Register server with key
:param key: registration key
:return: license token in case of success
"""
_, token = await cls._request(cls._REGISTER_URL, data={"key": key})
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()
req = {
"id": server_id,
"hostname": hostname,
"im": {
"users": users_count,
"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(key) and not ANTIVIRUS_MODE:
raise InvalidLicenseError(
"Imunify360 can not be registered with ImunifyAV+ key"
)
license = await RestCLN.register(key)
# in case of IP license, we have to register to know if license is
# valid for server (i.e. Imunify360 license is used for Imunify360)
if not LicenseCLN.is_valid(license):
# release registered server id
await RestCLN.unregister(license["id"])
raise InvalidLicenseError("License is invalid for this server")
LicenseCLN.update(license)
await cls.run_callbacks_for("register")
@classmethod
async def unregister(cls):
await RestCLN.unregister()
LicenseCLN.delete()
await cls.run_callbacks_for("unregister")
@classmethod
async def refresh_token(cls, token):
"""Refreshes token and returns new one on success, None otherwise"""
if LicenseCLN.is_free():
# noop: free license can not be refreshed
return LicenseCLN.get_token()
if LicenseCLN.get_token().get("is_alternative"):
# self-signed licenses are refreshed by customer
return LicenseCLN.get_token()
new_token = await RestCLN.checkin(token["id"], LicenseCLN.users_count)
logger.info("Got new token from CLN: %s", new_token)
if new_token is None:
await CLN.unregister()
else:
LicenseCLN.update(new_token)
await cls.run_callbacks_for("refresh_token")
return LicenseCLN.get_token()
def subscribe_to_license_changes(coro):
for method_name in ["register", "unregister", "refresh_token"]:
CLN.add_callback_for(method_name, coro_callback=coro)