""" Here you enumerate rpc endpoints """ import asyncio import json import time from collections import deque from logging import getLogger from typing import Dict from defence360agent import files from defence360agent.api.jwt_issuer import JWTIssuer from defence360agent.api.newsfeed import NewsFeed from defence360agent.api.pam_auth import PamAuth from defence360agent.contracts import config, eula from defence360agent.contracts.config import ( ANTIVIRUS_MODE, Core as CoreConfig, ImmutableMerger, LocalConfig, MutableMerger, effective_user_config, int_from_envvar, ) from defence360agent.contracts.license import LicenseCLN from defence360agent.internals.cln import CLN, CLNError, InvalidLicenseError from defence360agent.myimunify.billing import ( collect_billing_incompatibilities, get_license_type, ) from defence360agent.rpc_tools import ValidationError from defence360agent.rpc_tools.lookup import ( CommonEndpoints, RootEndpoints, bind, ) from defence360agent.simple_rpc import caller_uid_var from defence360agent.subsys.panels.base import PanelException from defence360agent.utils import ( IMUNIFY_PACKAGE_NAMES, CheckRunError, antivirus_mode, check_db, getpwnam, system_packages_info, ) from defence360agent.utils.config import update_config from defence360agent.utils.support import ZendeskAPIError, send_request from defence360agent.utils.whmcs import sync_billing_data from defence360agent.utils.doctor import get_doctor_key if antivirus_mode.disabled: from im360.subsys.panels import hosting_panel else: from defence360agent.subsys.panels import hosting_panel logger = getLogger(__name__) class ConfigEndpoints(CommonEndpoints): @bind("config", "show") async def config_show(self, user=None): full_conf = config.ConfigFile() if user: user_conf_dict = effective_user_config( full_conf, config.ConfigFile(user) ) return {"items": user_conf_dict} else: return {"items": full_conf.config_to_dict()} @bind("config", "show", "defaults") async def config_show_defaults(self): layer_paths = MutableMerger.get_layer_names() return { "items": { "mutable_config": MutableMerger(layer_paths).configs_to_dict(), "local_config": LocalConfig().config_to_dict(normalize=False), "immutable_config": ImmutableMerger( layer_paths ).configs_to_dict(), } } @bind("config", "update") async def config_update(self, items=None, data=None, user=None): # workaround for https://cloudlinux.atlassian.net/browse/DEF-3902 # TODO: remove items from method parameters if items: data = items[0] new_data = json.loads(data) logger.warning("AUDIT config.update user=%r data=%r", user, new_data) await update_config( self._sink, new_data, user, ) return await self.config_show(user) @bind("config", "patch") async def config_update_ui(self, data=None, user=None): logger.warning("AUDIT config.patch user=%r data=%r", user, data) await update_config(self._sink, data, user) return await self.config_show(user) @bind("config", "patch-many") async def config_update_many_ui(self, data=None, users=None): if users is None: users = [] logger.warning("AUDIT config.patch-many users=%r data=%r", users, data) for user in users: await update_config(self._sink, data, user) return {} @bind("config", "get-many") async def config_get_many_ui(self, users=None): if users is None: return {} result = {"items": {}} full_conf = config.ConfigFile() for user in users: user_conf_dict = effective_user_config( full_conf, config.ConfigFile(user) ) result["items"][user] = user_conf_dict return result # Defence-in-depth behind a UID-scoped socket; persistent state would be disproportionate. _LOGIN_PAM_MAX = 5 _LOGIN_PAM_WINDOW = 60.0 _LOGIN_PAM_MAX_TRACKED = 10_000 _login_pam_failures: Dict[str, deque] = {} _LOGIN_PAM_UID_MAX = int_from_envvar("I360_LOGIN_PAM_UID_MAX", 300) _LOGIN_PAM_UID_WINDOW = 60.0 _LOGIN_PAM_UID_MAX_TRACKED = 10_000 _login_pam_uid_failures: Dict[int, deque] = {} def _login_pam_allowed(username: str, now: float) -> bool: history = _login_pam_failures.get(username) if history is None: return True cutoff = now - _LOGIN_PAM_WINDOW while history and history[0] < cutoff: history.popleft() if not history: del _login_pam_failures[username] return True return len(history) < _LOGIN_PAM_MAX def _login_pam_sweep(now: float) -> None: cutoff = now - _LOGIN_PAM_WINDOW stale = [u for u, h in _login_pam_failures.items() if h[-1] < cutoff] for username in stale: del _login_pam_failures[username] def _login_pam_record_failure(username: str, now: float) -> None: if ( username not in _login_pam_failures and len(_login_pam_failures) >= _LOGIN_PAM_MAX_TRACKED ): _login_pam_sweep(now) if len(_login_pam_failures) >= _LOGIN_PAM_MAX_TRACKED: del _login_pam_failures[next(iter(_login_pam_failures))] _login_pam_failures.setdefault(username, deque()).append(now) def _login_pam_reset(username: str) -> None: _login_pam_failures.pop(username, None) def _login_pam_uid_allowed(uid: int, now: float) -> bool: if _LOGIN_PAM_UID_MAX <= 0: return True history = _login_pam_uid_failures.get(uid) if history is None: return True cutoff = now - _LOGIN_PAM_UID_WINDOW while history and history[0] < cutoff: history.popleft() if not history: del _login_pam_uid_failures[uid] return True return len(history) < _LOGIN_PAM_UID_MAX def _login_pam_uid_sweep(now: float) -> None: cutoff = now - _LOGIN_PAM_UID_WINDOW stale = [u for u, h in _login_pam_uid_failures.items() if h[-1] < cutoff] for uid in stale: del _login_pam_uid_failures[uid] def _login_pam_uid_record_failure(uid: int, now: float) -> None: if _LOGIN_PAM_UID_MAX <= 0: return if ( uid not in _login_pam_uid_failures and len(_login_pam_uid_failures) >= _LOGIN_PAM_UID_MAX_TRACKED ): _login_pam_uid_sweep(now) if len(_login_pam_uid_failures) >= _LOGIN_PAM_UID_MAX_TRACKED: del _login_pam_uid_failures[next(iter(_login_pam_uid_failures))] _login_pam_uid_failures.setdefault(uid, deque()).append(now) class LoginEndpoints(CommonEndpoints): @bind("login", "pam") async def login_via_pam(self, username, password): now = time.monotonic() try: caller_uid = caller_uid_var.get() except LookupError: logger.error("AUDIT login.pam REJECTED: caller_uid_var unset") raise RuntimeError("login.pam reached without caller_uid_var set") if caller_uid != 0 and not _login_pam_uid_allowed(caller_uid, now): logger.warning("AUDIT login.pam RATE_LIMITED uid=%r", caller_uid) raise ValidationError("Authentication rate limit exceeded") if not _login_pam_allowed(username, now): logger.warning( "AUDIT login.pam RATE_LIMITED username=%r", username ) raise ValidationError("Authentication rate limit exceeded") pam_auth = PamAuth() authenticated = pam_auth.authenticate(username, password) if not authenticated: _login_pam_record_failure(username, now) if caller_uid != 0: _login_pam_uid_record_failure(caller_uid, now) logger.warning("AUDIT login.pam FAILED username=%r", username) raise ValidationError("Authentication failed") _login_pam_reset(username) logger.info("AUDIT login.pam SUCCESS username=%r", username) return { "items": JWTIssuer().get_token( username, await pam_auth.get_user_type(username) ) } class RootLoginEndpoints(RootEndpoints): @bind("login", "get") async def login_get(self, username): if not getpwnam(username): raise ValidationError("User name not found") return { "items": JWTIssuer().get_token( username, await PamAuth().get_user_type(username) ) } class PackageVersionsEndpoints(CommonEndpoints): @bind("get-package-versions") async def get_package_versions(self, user=None): return {"items": await system_packages_info(IMUNIFY_PACKAGE_NAMES)} class NewsEndpoints(RootEndpoints): @bind("get-news") async def get_news(self): return {"items": await NewsFeed.get()} class Endpoints(RootEndpoints): license_info = LicenseCLN.license_info @bind("register") async def register(self, regkey=None): LicenseCLN.get_token.cache_clear() if LicenseCLN.is_registered(): if LicenseCLN.is_valid(): if not ANTIVIRUS_MODE: raise ValidationError("Agent is already registered") else: logger.info( "Unregistering invalid license: %s" % LicenseCLN.get_token() ) await self.unregister() try: await CLN.register(regkey) except InvalidLicenseError as e: raise ValidationError(str(e)) except CLNError as e: logger.warning( "Can't register %r as imunify360 key. Trying to " "register it as a web panel key instead", regkey, ) try: await CLN.register( await hosting_panel.HostingPanel().retrieve_key() ) except NotImplementedError: logger.warning( "Registration with web panel's key doesn't supported" ) raise ValidationError(str(e)) except PanelException as panel_e: raise ValidationError("{}, {}".format(str(e), str(panel_e))) except (CLNError, InvalidLicenseError) as e: raise ValidationError(str(e)) return {} @bind("unregister") async def unregister(self): if not LicenseCLN.is_registered(): raise ValidationError("Agent is not registered yet") if LicenseCLN.is_free(): raise ValidationError("Free license can not be unregistered") await CLN.unregister() return {} @bind("update-license") async def update_license(self): if not LicenseCLN.is_registered(): raise ValidationError("Unregistered (server-id is not assigned)") token = LicenseCLN.get_token() LicenseCLN.users_count = ( await hosting_panel.HostingPanel().users_count() ) new_token = await CLN.refresh_token(token) if new_token is None: raise ValidationError("License does not exist. Agent unregistered") return {} @bind("rstatus") async def rstatus(self, paid=False): LicenseCLN.get_token.cache_clear() if not LicenseCLN.is_valid(): raise ValidationError("License is invalid for current server") if paid and LicenseCLN.is_free(): raise ValidationError("Free license") return self.license_info() @bind("version") async def version(self): return {"items": CoreConfig.VERSION} @bind("wakeup") async def wakeup(self): """Wake up the agent, so it can process the request, if it's sleeping""" return {} @bind("update") async def update_files( self, subj=None, force=False, list=False, version="latest" ): if subj and subj in config.FilesUpdate.DISABLED: if list: return files.Index(subj).get_list() if version: return await files.Index(subj).update_to(version, force) else: if list or version != "latest": raise ValidationError( "Listing and version are not supported for this files type" ) try: await files.update(subj, force) except (asyncio.TimeoutError, files.UpdateError): pass # the error has been logged in files.update already @bind("eula", "accept") async def eula_accept(self): await eula.accept() @bind("eula", "show") async def eula_show(self): return eula.text() @bind("checkdb") async def checkdb(self, recreate_schema=False): """Check DB consistency and repair if needed. If recreate_schema is set recreate schema for attached DB.""" if recreate_schema: check_db.recreate_schema() else: check_db.check_and_repair() @bind("doctor") async def doctor(self): key = await get_doctor_key() return ( "Please, provide this key:\n%s\nto Imunify360 Support Team\n" % key ) @bind("support", "send") async def send_to_support( self, email, subject, description, cln=None, attachments=None ): # Generating doctor and extracting key from output try: doctor_key = await get_doctor_key() except CheckRunError: doctor_key = None # Sending request via Zendesk API # https://developer.zendesk.com/rest_api/docs/core/requests#anonymous-requests try: ticket_url = await send_request( email, subject, description, doctor_key, cln, attachments ) except ZendeskAPIError as e: logger.error( "Got error from Zendesk API. error=%s, description=%s," " details=%s", e.error, e.description, e.details, ) raise return {"items": [ticket_url]} class WhmcsEndpoint(RootEndpoints): """ Describes all endpoints for interaction with WHMCS """ # needed by WHMCS to know whether it is compatible VERSION = "1" @bind("billing", "sync") async def billing_sync(self, data): try: decoded_data = json.loads(data) except json.JSONDecodeError: raise ValueError("Invalid JSON") result = await sync_billing_data(self._sink, decoded_data) return {"result": "success", "data": result} @bind("billing", "get-config") async def billing_get_config(self): result = dict( version=self.VERSION, billing_license=get_license_type(), issues=await collect_billing_incompatibilities(), ) return {"result": "success", "data": result}