D7net
Home
Console
Upload
information
Create File
Create Folder
About
Tools
:
/
opt
/
cloudlinux
/
venv
/
lib64
/
python3.11
/
site-packages
/
ssa
/
Filename :
manager.py
back
Copy
# -*- coding: utf-8 -*- # Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2021 All Rights Reserved # # Licensed under CLOUD LINUX LICENSE AGREEMENT # http://cloudlinux.com/docs/LICENSE.TXT """ This module contains classes implementing SSA Manager behaviour """ import json import logging import os import pwd import subprocess from contextlib import contextmanager from glob import iglob from secureio import disable_quota from typing import Optional, Tuple from .configuration import load_validated_parser, load_configuration from .internal.constants import flag_file from .internal.exceptions import SSAManagerError from .internal.utils import ssa_version from .modules.autotracer import AutoTracer from .modules.decision_maker import DecisionMaker from .clos_ssa_ini import ( INI_FILE_NAME, INI_USER_LOCATIONS_BASE, is_excluded_path, ) from .website_isolation import ( copy_inis_to_website_isolation_paths, remove_inis_from_website_isolation_paths, regenerate_inis_for_user as _regenerate_inis_for_user_isolation, ) class Manager: """ SSA Manager class. """ def __init__(self): self.logger = logging.getLogger('manager') self.ini_file_name = INI_FILE_NAME # Module search patterns for different distributions # CloudLinux uses lib64, Ubuntu uses lib/x86_64-linux-gnu self.module_patterns_with_usr = [ 'usr/lib64/php/modules/clos_ssa.so', # CloudLinux alt-php, cPanel 'usr/lib/x86_64-linux-gnu/php/modules/clos_ssa.so', # Ubuntu alt-php, cPanel ] self.module_patterns_no_usr = [ 'lib64/php/modules/clos_ssa.so', # CloudLinux Plesk 'lib/x86_64-linux-gnu/php/modules/clos_ssa.so', # Reserved for future use ] self.module_glob_pattern_directadmin = 'lib/php/extensions/*/clos_ssa.so' # DirectAdmin dynamic path self.wildcard_ini_locations = ( '/opt/alt/php[0-9][0-9]/link/conf', '/opt/cpanel/ea-php[0-9][0-9]/root/etc/php.d', '/opt/plesk/php/[0-9].[0-9]/etc/php.d', '/usr/local/php[0-9][0-9]/lib/php.conf.d', '/usr/share/cagefs/.cpanel.multiphp/opt/cpanel/ea-php[0-9][0-9]/root/etc/php.d', '/usr/share/cagefs-skeleton/usr/local/php[0-9][0-9]/lib/php.conf.d' ) # same as above, but ini which are writeable by users self.wildcard_ini_user_locations = INI_USER_LOCATIONS_BASE self.subprocess_errors = ( OSError, ValueError, subprocess.SubprocessError ) def _audit(self, operation, status, **details): try: uid = os.getuid() try: username = pwd.getpwuid(uid).pw_name except Exception: username = '<unknown>' detail_parts = ' '.join( '%s=%r' % (k, v) for k, v in details.items()) self.logger.info( '[audit] operation=%s status=%s uid=%d user=%s %s', operation, status, uid, username, detail_parts) except Exception: pass @staticmethod def response(*args, **kwargs) -> str: """ Form a success json response with given kwargs """ raw_response = {'result': 'success'} raw_response.update({k: v for k, v in kwargs.items()}) return json.dumps(raw_response) @property def _enabled(self) -> bool: """ Is SSA enabled """ return os.path.isfile(flag_file) @property def _restart_required_settings(self) -> set: """ Configuration settings required Request Processor restart """ return {'requests_duration', 'ignore_list'} @property def solo_filtered_settings(self) -> set: return {'correlation', 'correlation_coefficient', 'request_number', 'time', 'domains_number'} def _restart_required(self, settings: dict) -> set: """ SSA Agent requires restart in case of changing these configuration: - requests_duration - ignore_list """ return self._restart_required_settings.intersection(settings) def run_service_utility(self, command: str, check_retcode=False) -> subprocess.CompletedProcess: """ Run /sbin/service utility to make given operation with SSA Agent service :command: command to invoke :check_retcode: whether to run with check or not :return: subprocess info about completed process """ try: result = subprocess.run(['/sbin/service', 'ssa-agent', command], capture_output=True, text=True, check=check_retcode) self.logger.info(f'ssa-agent {command} succeeded') except subprocess.CalledProcessError as e: self.logger.error( 'SSA Agent %s failed with code %s: %s', str(e.cmd), str(e.returncode), str(e.stdout), extra={'cmd': e.cmd, 'retcode': e.returncode, 'stdout': e.stdout, 'stderr': e.stderr}) raise SSAManagerError( f'SSA Agent {e.cmd} failed with code {e.returncode}: {e.stdout or e.stderr}') except self.subprocess_errors as e: self.logger.error('Failed to run %s command for SSA Agent', str(command), extra={'err': str(e)}) raise SSAManagerError( f'Failed to run {command} for SSA Agent: {e}') return result def run_systemctl(self, command: str, unit: str) -> Optional[subprocess.CompletedProcess]: """ Run systemctl on a specific unit. Failures are logged but never raised — the socket unit may be absent on legacy / non-systemd installs, and a best-effort attempt is enough for shutdown paths. :return: the CompletedProcess on success (any return code, since ``check=False``), or ``None`` if systemctl could not be spawned at all (e.g. missing binary). Callers must guard ``.returncode`` access against ``None``. """ try: result = subprocess.run( ['/bin/systemctl', command, unit], capture_output=True, text=True, check=False) self.logger.info('systemctl %s %s rc=%d', command, unit, result.returncode) return result except self.subprocess_errors as e: self.logger.warning('Failed to run systemctl %s %s: %s', command, unit, str(e)) return None def set_config(self, args: dict) -> str: """ Change SSA config and restart it. :args: dict to override current option values :return: JSON encoded result of the action """ config = load_validated_parser() previous_values = {k: v for k, v in dict(config.items()).items() if k in args} config.override(args) try: config.write_ssa_conf() except OSError as e: self.logger.error('Failed to update SSA config file', extra={'err': str(e)}) self._audit('set_config', 'failure', previous=previous_values, requested=args, error=str(e)) raise SSAManagerError(f'Failed to update SSA config file: {e}') self._audit('set_config', 'success', previous=previous_values, new=args) if self._restart_required(args): self.run_service_utility('restart', check_retcode=True) return self.response() def get_config(self) -> str: """ Get current SSA config. :return: JSON encoded current config """ full_config = load_configuration() return self.response(config=full_config) def get_ssa_status(self) -> str: """ Get current status of SSA. :return: JSON encoded current status """ status = 'enabled' if self._enabled else 'disabled' return self.response(ssa_status=status) def enable_ssa(self) -> str: """ Enable SSA: - add clos_ssa extension for each PHP version on server - add clos_ssa extension into cagefs for each user and each ver - start SSA Agent (if it is not already started) - restart Apache (etc.) and FPM, reset CRIU images - create flag_file indicating that SSA is enabled successfully :return: JSON encoded current status """ was_enabled = self._enabled if not was_enabled: try: self.generate_inis() self.start_ssa_agent() self.create_flag() except Exception as e: self._audit('enable_ssa', 'failure', previous_state='disabled', error=str(e)) raise self._audit('enable_ssa', 'success', previous_state='enabled' if was_enabled else 'disabled') return self.get_ssa_status() def disable_ssa(self) -> str: """ Disable SSA, authoritatively, regardless of the flag file state. Historically this method short-circuited when the flag file was absent, which left ssa-agent.service running via socket activation whenever the flag file had been deleted out-of-band (CLPRO-3076). We now always reconcile real state: stop+disable the agent service AND its socket, remove any clos_ssa.ini files present, and clear the flag file if it exists. Inconsistency between the flag file and the runtime state is logged as a warning so future drift can be traced. Ordering: ``stop_ssa_agent`` runs **first**, so a later failure (e.g. ini removal raising on a filesystem error) cannot leave the OOM-prone daemon running — stopping the agent is the core guarantee this method exists to provide. - stop+disable SSA Agent service and ssa-agent.socket - remove clos_ssa extension for each PHP version on server - remove clos_ssa extension from cagefs for each user and each ver - remove flag_file indicating that SSA is enabled :return: JSON encoded current status """ was_enabled = self._enabled agent_active = self.status_ssa_agent() == 'active' if not was_enabled and agent_active: self.logger.warning( 'disable_ssa: inconsistent state — flag file %s is missing ' 'but ssa-agent is active; reconciling from real state', flag_file) try: self.stop_ssa_agent() self.remove_clos_inis() self.remove_flag() except Exception as e: self._audit('disable_ssa', 'failure', previous_state='enabled' if was_enabled else 'disabled', agent_active=agent_active, error=str(e)) raise self._audit('disable_ssa', 'success', previous_state='enabled' if was_enabled else 'disabled', agent_active=agent_active) return self.get_ssa_status() def get_stats(self) -> str: """ Get SSA statistics. Includes: - config values - version - SSA status (enabled|disabled) - SSA Agent status (active|inactive) :return: JSON encoded current statistics """ _config = {key: str(value).lower() for key, value in load_configuration().items()} return self.response( config=_config, version=ssa_version(), status='enabled' if self._enabled else 'disabled', agent_status=self.status_ssa_agent(), autotracing=AutoTracer().get_stats() ) def unused_dir_path(self, dir_path: str) -> bool: """ Check if directory path should be excluded. """ return is_excluded_path(dir_path) def _find_module_in_root(self, php_root: str, patterns: list) -> str: """ Search for clos_ssa.so module in php_root using a list of patterns. Returns the first found module path, or the first pattern as expected path if none exist. """ for pattern in patterns: module_path = os.path.join(php_root, pattern) if os.path.exists(module_path): return module_path # If no module found, return the first pattern as expected path return os.path.join(php_root, patterns[0]) if patterns else '' def get_module_path(self, ini_path: str) -> str: """ Determine the path to clos_ssa.so module based on ini_path. Returns the expected module path, or empty string if not found. """ # For CloudLinux alt-php: /opt/alt/phpXX/link/conf # CloudLinux: /opt/alt/phpXX/usr/lib64/php/modules/clos_ssa.so # Ubuntu: /opt/alt/phpXX/usr/lib/x86_64-linux-gnu/php/modules/clos_ssa.so if ini_path.startswith('/opt/alt/php') and '/link/conf' in ini_path: php_root = ini_path.split('/link/conf')[0] return self._find_module_in_root(php_root, self.module_patterns_with_usr) # For CageFS cPanel multiphp: /usr/share/cagefs/.cpanel.multiphp/opt/cpanel/ea-phpXX/root/etc/php.d # Check this BEFORE regular cPanel (more specific path) # This is typically a symlink/mount to cagefs-skeleton, check the skeleton location # /usr/share/cagefs/.cpanel.multiphp/opt/... → /usr/share/cagefs-skeleton/opt/... if ini_path.startswith('/usr/share/cagefs/.cpanel.multiphp/opt/cpanel/ea-php') and '/root/etc/php.d' in ini_path: skeleton_path = ini_path.replace('/usr/share/cagefs/.cpanel.multiphp', '/usr/share/cagefs-skeleton') php_root = skeleton_path.split('/etc/php.d')[0] return self._find_module_in_root(php_root, self.module_patterns_with_usr) # For cPanel EA4: /opt/cpanel/ea-phpXX/root/etc/php.d # cPanel uses lib64 on both CloudLinux and Ubuntu if ini_path.startswith('/opt/cpanel/ea-php') and '/root/etc/php.d' in ini_path: php_root = ini_path.split('/etc/php.d')[0] return self._find_module_in_root(php_root, self.module_patterns_with_usr) # For Plesk: /opt/plesk/php/X.X/etc/php.d # CloudLinux: /opt/plesk/php/X.X/lib64/php/modules/clos_ssa.so if ini_path.startswith('/opt/plesk/php/') and '/etc/php.d' in ini_path: php_root = ini_path.split('/etc/php.d')[0] return self._find_module_in_root(php_root, self.module_patterns_no_usr) # For CageFS skeleton DirectAdmin: /usr/share/cagefs-skeleton/usr/local/phpXX/lib/php.conf.d # Check this BEFORE regular DirectAdmin (more specific path) # DirectAdmin copies modules to CageFS skeleton with dynamic extension_dir # Path example: /usr/share/cagefs-skeleton/usr/local/php83/lib/php/extensions/no-debug-non-zts-20230831/clos_ssa.so if ini_path.startswith('/usr/share/cagefs-skeleton/usr/local/php') and '/lib/php.conf.d' in ini_path: php_root = ini_path.split('/lib/php.conf.d')[0] # Use glob to find module in dynamic extensions directory possible_paths = list(iglob(os.path.join(php_root, self.module_glob_pattern_directadmin))) if possible_paths: return possible_paths[0] return '' # For DirectAdmin: /usr/local/phpXX/lib/php.conf.d # DirectAdmin uses modules copied from alt-php to dynamic extension_dir # Path example: /usr/local/php83/lib/php/extensions/no-debug-non-zts-20230831/clos_ssa.so if ini_path.startswith('/usr/local/php') and '/lib/php.conf.d' in ini_path: php_root = ini_path.split('/lib/php.conf.d')[0] # Use glob to find module in dynamic extensions directory possible_paths = list(iglob(os.path.join(php_root, self.module_glob_pattern_directadmin))) if possible_paths: return possible_paths[0] return '' # For CageFS user paths: /var/cagefs/user/home/etc/cl.php.d/alt-phpXX # CageFS users see modules from skeleton, not from global /opt/alt/ if ini_path.startswith('/var/cagefs/') and '/etc/cl.php.d/alt-php' in ini_path: php_ver = ini_path.split('/etc/cl.php.d/alt-php')[1].split('/')[0] skeleton_root = f'/usr/share/cagefs-skeleton/opt/alt/php{php_ver}' return self._find_module_in_root(skeleton_root, self.module_patterns_with_usr) # Default fallback - return empty string if pattern not recognized return '' def existing_paths(self) -> Tuple[Tuple[int, int], str]: """ Generator of existing paths (matching known wildcard locations) for additional ini files Returns tuple of (uid, gid) and path. """ for location in self.wildcard_ini_locations: for dir_path in iglob(location): if self.unused_dir_path(dir_path): continue yield (0, 0), dir_path for location in self.wildcard_ini_user_locations: for dir_path in iglob(location['path']): if self.unused_dir_path(dir_path): continue try: pw_record = location['user'](dir_path) except: self.logger.info('Unable to get information about user ' 'owning %s directory (maybe he`s already terminated?), ' 'skip updating', dir_path) continue else: yield (pw_record.pw_uid, pw_record.pw_gid), dir_path @contextmanager def _user_context(self, uid, gid): """ Dive into user context by dropping permissions to avoid most of the security issues. Does not cover cagefs case because it also requires nsenter, which is only available with execve() call in our system """ try: os.setegid(gid) os.seteuid(uid) yield finally: os.seteuid(0) os.setegid(0) def generate_single_ini(self, uid: int, gid: int, ini_path: str) -> None: """ Enable SSA extension for single ini_path (given) """ # Check if module exists before generating ini file module_path = self.get_module_path(ini_path) if not module_path: self.logger.warning('Cannot determine module path for %s, skipping ini generation', ini_path) return if not os.path.exists(module_path): self.logger.info('Module %s does not exist, skipping ini generation for %s', module_path, ini_path) # Remove ini file if it exists but module is missing ini_file_path = os.path.join(ini_path, self.ini_file_name) if os.path.exists(ini_file_path): try: with self._user_context(uid, gid): os.unlink(ini_file_path) self.logger.info('Removed ini file %s (module not found)', ini_file_path) except Exception as e: self.logger.warning('Failed to remove ini file %s: %s', ini_file_path, str(e)) return path = os.path.join(ini_path, self.ini_file_name) with self._user_context(uid, gid), \ disable_quota(), \ open(path, 'w') as ini: self.logger.info('Generating %s file...', path) ini.write('extension=clos_ssa.so\n') def generate_inis(self) -> None: """ Place clos_ssa.ini into each existing Additional ini path, including cagefs ones and per-website directories """ self.logger.info('Generating clos_ssa.ini files...') for (uid, gid), ini_path in self.existing_paths(): try: self.generate_single_ini(uid, gid, ini_path) except PermissionError: self.logger.info('Unable to update file %s, ' 'possible permission misconfiguration', ini_path) continue except Exception as e: self.logger.error('Exception on generating clos_ssa.ini: "%s", error: "%s"', ini_path, str(e)) continue # Also copy to per-website directories if website isolation is enabled copy_inis_to_website_isolation_paths(self._user_context) self.logger.info('Finished!') def find_clos_inis(self) -> Tuple[Tuple[int, int], str]: """ Generator function searching for clos_ssa.ini files in all existing Additional ini paths Returns tuple of (uid, gid) and path. """ for (uid, gid), ini_path in self.existing_paths(): for name in os.listdir(ini_path): if self.ini_file_name not in name: continue yield (uid, gid), os.path.join(ini_path, name) def remove_clos_inis(self) -> None: """ Remove all gathered clos_ssa.ini files, including per-website directories """ self.logger.info('Removing clos_ssa.ini files...') for (uid, gid), clos_ini in self.find_clos_inis(): try: with self._user_context(uid, gid): os.unlink(clos_ini) except Exception as e: self.logger.exception('Exception on removing clos_ssa.ini: "%s", error: "%s"', clos_ini, str(e)) continue # Also remove from per-website directories remove_inis_from_website_isolation_paths(self._user_context) self.logger.info('Finished!') def start_ssa_agent(self) -> None: """ Start SSA Agent service or restart it if it is accidentally already running, and (re-)enable it so it survives a reboot. ``enable`` is the symmetric counterpart of the ``disable`` performed in ``stop_ssa_agent``: ``disable_ssa`` disables the unit, so ``enable_ssa`` must re-enable it, otherwise enabling SSA after a previous disable would not persist across the next boot (CLPRO-3076). On a fresh install the unit is already enabled, so this is a no-op there. """ self.run_systemctl('enable', 'ssa-agent.service') agent_status = self.run_service_utility('status') if agent_status.returncode: self.run_service_utility('start', check_retcode=True) else: self.run_service_utility('restart', check_retcode=True) def stop_ssa_agent(self) -> None: """ Stop SSA Agent and make the disabled state survive a reboot. Stopping the service alone is not enough on systemd, for two reasons: * ``ssa-agent.socket`` keeps listening and will re-spawn the service the moment a PHP worker writes to /opt/alt/clos_ssa/run/ssa.sock. * ``ssa-agent.service`` is shipped *enabled* (``WantedBy=multi-user.target``) and pulls the socket back up via ``Requires=ssa-agent.socket`` on the next boot — so a plain stop reverts at reboot, and the agent's periodic ssa.db maintenance can OOM again (CLPRO-3076 / CLPRO-3077). We therefore stop *and disable* both units. ``disable`` only removes boot-time wants symlinks, so it is what makes the change persistent; disabling the socket also prevents socket-activation from starting a disabled service (``disable`` does not block socket activation on its own). ``enable_ssa`` re-enables the service via ``start_ssa_agent``. Belt-and-suspenders ordering: 1. **Socket-first** stops socket activation so nothing can spawn a fresh agent process between the service stop and our own socket stop (closes a narrow race window). The ``run_systemctl`` helper is best-effort and never raises. 2. **try/finally** around the service-utility calls re-asserts the full stop+disable of *both* units on the failure path, so if ``run_service_utility`` throws ``SSAManagerError`` (e.g. systemd stop timeout, ``/sbin/service`` missing) the unit cleanup still runs — guaranteeing the CLPRO-3076 fix even on degraded systems. The fallback ``stop`` goes through ``/bin/systemctl`` (a different binary from ``/sbin/service``), so it can succeed where the primary stop failed. """ # 1. Quiesce socket activation first so nothing can resurrect the # service while we are bringing it down. self.run_systemctl('stop', 'ssa-agent.socket') self.run_systemctl('disable', 'ssa-agent.socket') try: agent_status = self.run_service_utility('status') if not agent_status.returncode: self.run_service_utility('stop', check_retcode=True) finally: # 2. Re-assert full teardown on the failure path. No-op on the # happy path (systemctl reports "already stopped/disabled"); # load-bearing if the primary /sbin/service stop raised before # completing. Stop both units (a `disable` alone does not kill # a running unit), then disable both so the off-state survives # a reboot. Socket is stopped before the service so activation # cannot re-spawn it during teardown. self.run_systemctl('stop', 'ssa-agent.socket') self.run_systemctl('stop', 'ssa-agent.service') self.run_systemctl('disable', 'ssa-agent.socket') self.run_systemctl('disable', 'ssa-agent.service') def status_ssa_agent(self) -> str: """ Get SSA Agent status: active or inactive """ try: self.run_service_utility('status', check_retcode=True) except SSAManagerError: return 'inactive' return 'active' def create_flag(self) -> None: """ Create a flag file indicating successful enablement """ with open(flag_file, 'w'): pass self.logger.info(f'Flag file {flag_file} created') def remove_flag(self) -> None: """ Remove a flag file indicating enablement """ try: os.unlink(flag_file) self.logger.info(f'Flag file {flag_file} removed') except OSError as e: self.logger.warning( f'Flag file {flag_file} removal failed: {str(e)}') def get_report(self) -> str: """ Get last report. :return: JSON encoded report """ report = DecisionMaker().get_json_report() return self.response(**report) def regenerate_inis(self) -> None: """ Regenerates clos_ssa inis while SSA is enabled """ if self._enabled: self.generate_inis() def regenerate_inis_for_user(self, user: str) -> None: """ Regenerate clos_ssa.ini files for a specific user's website isolation directories. This is called by cagefsctl when enabling website isolation for a user. Only creates per-website ini files if base per-user ini exists. :param user: Username to regenerate ini files for """ if not self._enabled: return _regenerate_inis_for_user_isolation(user, self._user_context) def initialize_manager() -> 'Manager instance': """ Factory function for appropriate manager initialization :return: appropriate manager instance """ return Manager()