Mini Shell
# -*- 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 re
import subprocess
from contextlib import contextmanager
from glob import iglob
from secureio import disable_quota
from typing import 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
class Manager:
"""
SSA Manager class.
"""
def __init__(self):
self.logger = logging.getLogger('manager')
self.ini_file_name = 'clos_ssa.ini'
self.substrings_to_exclude_dir_paths = (
'php44', 'php51', 'php52', 'php53', 'php\d+-imunify', 'php-internal'
)
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 = (
dict(path='/var/cagefs/*/*/etc/cl.php.d/alt-php[0-9][0-9]',
user=lambda path: pwd.getpwnam(path.split('/')[4])),
)
self.subprocess_errors = (
OSError, ValueError, subprocess.SubprocessError
)
@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 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()
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)})
raise SSAManagerError(f'Failed to update SSA config file: {e}')
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
"""
if not self._enabled:
self.generate_inis()
self.start_ssa_agent()
self.create_flag()
return self.get_ssa_status()
def disable_ssa(self) -> str:
"""
Disable SSA:
- remove clos_ssa extension for each PHP version on server
- remove clos_ssa extension from cagefs for each user and each ver
- stop SSA Agent
- restart Apache (etc.) and FPM, reset CRIU images
- remove flag_file indicating that SSA is enabled
:return: JSON encoded current status
"""
if self._enabled:
self.remove_clos_inis()
self.stop_ssa_agent()
self.remove_flag()
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) -> list:
"""
Checking for substrings in a string.
"""
res = [substring for substring in self.substrings_to_exclude_dir_paths
if re.search(substring, dir_path)]
return res
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)
"""
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')
def generate_inis(self) -> None:
"""
Place clos_ssa.ini into each existing Additional ini path,
including cagefs ones
"""
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
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
"""
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
self.logger.info('Finished!')
def start_ssa_agent(self) -> None:
"""
Start SSA Agent service
or restart it if it is accidentally already running
"""
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 service
or do nothing if it is accidentally not running
"""
agent_status = self.run_service_utility('status')
if not agent_status.returncode:
self.run_service_utility('stop', check_retcode=True)
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 initialize_manager() -> 'Manager instance':
"""
Factory function for appropriate manager initialization
:return: appropriate manager instance
"""
return Manager()
Zerion Mini Shell 1.0