Mini Shell
# coding:utf-8
# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2019 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT
from __future__ import print_function
from __future__ import division
from __future__ import absolute_import
import copy
import sys
import json
import argparse
import socket
import base64
import os
import subprocess
import re
from enum import Enum
from dataclasses import dataclass
from typing import Callable, List, Optional
from past.builtins import basestring, unicode # noqa
from future.utils import iteritems
from clcommon.utils import silence_stdout_until_process_exit, get_cl_version
from cllicense import CloudlinuxLicenseLib
from cpanel_api import get_cpanel_api_class
from clcommon.lib.cledition import is_cl_solo_edition, is_container
from clcommon.cpapi import is_hitting_max_accounts_limit, get_main_username_by_uid
LVEMANAGER_PLUGIN_NAMES = {
'python_selector': 'Python Selector',
'nodejs_selector': 'Node.js Selector',
'php_selector': 'PHP Selector',
'resource_usage': 'Resource Usage',
'wpos': 'AccelerateWP'
}
PASSENGER_DEPEND_PLUGINS = ['python_selector', 'nodejs_selector']
DEFAULT_PLUGIN_NAME = 'CloudLinux Manager'
CAGEFS_ENTER_PROXIED_BIN = '/usr/bin/cagefs_enter.proxied'
if not os.path.exists(CAGEFS_ENTER_PROXIED_BIN):
CAGEFS_ENTER_PROXIED_BIN = '/bin/cagefs_enter.proxied'
def is_json(data):
try:
json.loads(data)
return True
except ValueError as error:
return False
class CloudlinuxCliBase(object):
request_data = {}
result = None
available_request_params = [
'owner', 'command', 'method', 'params', 'user_info', 'mockJson', 'attachments', 'plugin_name', 'lang'
]
NOT_FLAGGED_PARAMS = ['config-files', 'content', 'passenger-log-file', 'ignore-list', 'wp-path', 'upgrade-url']
license_is_checked = False
current_plugin_name = ''
licence = CloudlinuxLicenseLib()
def __init__(self):
self.skip_cagefs_check = False
self.user_info = {}
self.parsing_request_data()
self.check_xss()
self.drop_permission()
self.command_methods = {
'spa-ping': self.spa_ping,
'cloudlinux-top': self.cl_top,
'cloudlinux-selector': self.cl_selector,
'cloudlinux-statistics': self.cl_statistics,
'cloudlinux-charts': self.cl_chart,
'cloudlinux-quota': self.cl_quota,
'cpanel-api': self.cpanel_api,
'cloudlinux-xray-user-manager': self.cl_xray_user_manager,
'cloudlinux-statsnotifier': self.cl_statsnotifier,
'cloudlinux-awp-user': self.cloudlinux_awp_user,
'cl-smart-advice-user': self.cl_smart_advice_user,
'cl-install-plugin': self.cl_install_plugin
}
def check_xss(self):
for key in self.request_data.keys():
if key not in self.available_request_params:
self.exit_with_error('BAD REQUEST 1:' + key)
for name, val in iteritems(self.request_data):
if isinstance(val, dict): # if post key is "params"
for key, inner_value in iteritems(val):
self.check_param_key(key)
if self.request_data['command'] == 'cloudlinux-packages' \
and name == 'params' \
and key == 'package':
self.request_data[name][key] = self.escape_param_value(inner_value)
elif self.request_data['command'] == 'cloudlinux-support':
pass
elif self.request_data['command'] == 'cloudlinux-selector' \
and name == 'params' \
and key == 'options':
pass
elif self.request_data['command'] == 'lvectl' \
and name == 'params' \
and key == 'stdin':
pass
elif self.request_data['command'] == 'cloudlinux-selector' \
and name == 'params' \
and key == 'env-vars':
pass
elif self.request_data['command'] == 'cloudlinux-xray-manager' \
and name == 'params' \
and key == 'url':
pass
elif self.request_data['command'] == 'cloudlinux-xray-user-manager' \
and name == 'params' \
and key == 'url':
pass
elif self.request_data['command'] == 'wmt-api' \
and name == 'params' \
and key == 'config-change':
pass
elif self.request_data['command'] == 'cloudlinux-xray-manager' \
and name == 'params' \
and key == 'email':
pass
elif self.request_data['command'] == 'cloudlinux-awp-admin' \
and name == 'params' \
and key == 'upgrade-url':
pass
else:
self.check_param_value(inner_value)
else:
self.check_param_value(val)
def get_env(self):
"""
Get env for subprocess call
"""
env_copy = os.environ.copy()
if self.request_data.get('lang'):
lang = self.request_data.get('lang')
if not re.match(r'^[a-z]{2}$', lang):
lang = 'en'
env_copy['LC_ALL'] = lang
return env_copy
def get_server_ip(self):
"""
Get the server's IP address.
"""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except Exception as e:
return None
def check_param_key(self, key):
if not re.search('^[\w\-]+$', key):
self.exit_with_error('BAD REQUEST 2')
def check_param_value(self, val):
if isinstance(val, basestring):
if re.search('[`\|\$;&\n]', val, re.M):
self.exit_with_error('BAD REQUEST 3')
def escape_param_value(self, val):
chars = "\\\"\'"
for c in chars:
val = val.replace(c, "\\" + c)
return val
def main(self):
command = self.request_data['command']
endpoint = self.command_methods.get(command)
allowed_methods = ['cloudlinux-license', 'external-info', 'spa-get-user-info'] # not requires license check
if endpoint:
if not self.license_is_checked and command not in allowed_methods:
self.check_license()
if 'mockJson' in self.request_data:
self.spa_mock(self.request_data['mockJson'])
endpoint()
else:
if command:
self.exit_with_error("No such module " + command)
else:
self.exit_with_error("Command not defined")
def parsing_request_data(self):
"""
parsing entry data, encode it from base64 to dictionary
:return:
"""
parser = argparse.ArgumentParser()
parser.add_argument('--data')
parser.add_argument('--skip-cagefs-check', action='store_true', default=False)
try:
arguments = parser.parse_args()
except:
self.exit_with_error("Unknown param in request")
if arguments.data:
data_in_base64 = arguments.data
data_in_json = base64.b64decode(data_in_base64).decode("utf-8")
try:
self.request_data = json.loads(data_in_json)
self.skip_cagefs_check = arguments.skip_cagefs_check
except ValueError:
self.exit_with_error("Need json-array")
self.user_info = self.get_user_info()
self.define_current_plugin()
else:
self.exit_with_error("No --data param in request")
def get_user_info(self):
user_info = self.request_data.get('user_info') or {}
if self.request_data['owner'] == 'user' and any(value is None for value in user_info.values()):
euid = os.geteuid()
username = get_main_username_by_uid(euid)
user_info = {'username': username, 'lve-id': euid}
return user_info
def cl_top(self):
# This imports from other package (cagefs), so we turn off pylint import checker for this line
from lvestats.lib.info.cloudlinux_top import CloudLinuxTop #pylint: disable=E0401
import lvestats.lib.config as config #pylint: disable=E0401
list_to_request = self.prepair_params_for_command()
result = ''
try:
result, exitcode = CloudLinuxTop(config.read_config()).main(*list_to_request)
except config.ConfigError as ce:
ce.log_and_exit()
self.exit_with_error(str(ce))
if self.request_data.get('owner') == 'user':
json_result = {}
try:
json_result = json.loads(result)
except:
self.exit_with_error(result)
if json_result.get('result') != 'success':
self.exit_with_error(json_result.get('result'), json_result.get('context'), ignore_errors=True)
print(result)
silence_stdout_until_process_exit()
sys.exit(exitcode)
def cl_quota(self):
list_to_request = self.prepair_params_for_command()
result = self.run_util('/usr/bin/cl-quota', *list_to_request, ignore_errors=True)
print(result)
def cl_xray_user_manager(self):
list_to_request = self.prepair_params_for_command()
list_to_request.remove("--json")
result = self.run_util('/opt/alt/php-xray/cloudlinux-xray-user-manager', *list_to_request, ignore_errors=False)
print(result)
def cl_smart_advice_user(self):
cli_command = '/opt/alt/php-xray/cl-smart-advice-user'
list_to_request = self.prepair_params_for_command(with_json=False)
# Workaround to run the command in background
if '--async' in list_to_request:
subprocess.Popen([cli_command, *list_to_request], stdin=subprocess.PIPE,stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
self.exit_with_success()
result = self.run_util(cli_command, *list_to_request, ignore_errors=False)
print(result)
def cpanel_api(self):
owner = self.request_data.get('owner')
method = self.request_data.pop('method')
list_to_request = self.prepair_params_for_command(with_json=False, add_dash=False)
cpanel_api = get_cpanel_api_class(owner)
self.exit_with_success({'data': cpanel_api.run(method, list_to_request)})
def cl_chart(self):
list_to_request = self.prepair_params_for_command()
try:
list_to_request.remove("--json")
except ValueError:
pass
for param in list_to_request:
if param.startswith('--output'):
self.exit_with_error('BAD REQUEST 2')
list_to_request.insert(0, '/usr/sbin/lvechart')
response = subprocess.check_output(list_to_request, shell=False, text=True)
print(json.dumps({"result": "success", "chart": response}))
silence_stdout_until_process_exit()
sys.exit(0)
def drop_permission(self):
"""
Drop permission to users, if owner of script is user
:return:
"""
data = self.request_data
if data['owner'] in ['reseller', 'user'] and\
('lve-id' not in self.user_info or
'username' not in self.user_info):
self.exit_with_error("User id does not specified")
def prepair_params_for_command(self, with_json=True, escaped_strings=False, add_dash=True):
"""
Method that converts given dict of parameters
into list of strings that should be passed
as arguments command-line application
:param with_json: add --json argument
:param escaped_strings: ONLY FOR BACKWARDS COMPATIBILITY!
SHOULD BE False FOR ALL NEW METHODS!
:param add_dash: if we need to add dashes to params
:return:
"""
value_template = "--{0}={1}" if add_dash else "{0}={1}"
data = copy.deepcopy(self.request_data)
list_to_request = []
if "method" in data:
for method in data["method"].split(' '):
list_to_request.append(method)
if "params" not in data:
data['params'] = {}
if "json" not in data['params'] and with_json:
data['params']['json'] = ''
for param, value in iteritems(data['params']):
if param != 'additional-params':
# TODO: looks like we can remove option escaped_strings
# and always use value.encode('utf-8') here
# same goal may be reached using utils.byteify(json.loads(...))
# but to do that, we need some tests covering unicode params
# (especially for cloudlinux-packages)
# unfortunately, we do not have one ;(
# THIS IS NEEDED ONLY FOR CL-PACKAGES UTILITY
if value and escaped_strings is True:
list_to_request.append(value_template.format(param, value.encode('unicode-escape').decode()))
elif (value or param in self.NOT_FLAGGED_PARAMS) and escaped_strings is False:
list_to_request.append(value_template.format(param, value))
else:
list_to_request.append("--{0}".format(param))
if self.request_data['owner'] == 'reseller':
list_to_request.append('--for-reseller={0}'.format(self.user_info['username']))
if 'additional-params' in data['params'] \
and data['params']['additional-params'] != '':
list_to_request.append("--")
for param in data['params']['additional-params'].split():
list_to_request.append("{0}".format(param))
return list_to_request
def is_edition_migration_available(self):
# check if edition migration is supported
return os.path.isfile('/usr/sbin/clncheck')
def update_license(self):
# Register by broken license
with open(os.devnull, 'w') as devnull:
clnreg_cmd = ['/usr/sbin/clnreg_ks', '--force']
if self.is_edition_migration_available():
clnreg_cmd.append('--migrate-silently')
subprocess.call(clnreg_cmd, stderr=devnull, stdout=devnull, shell=False)
subprocess.call(['/usr/bin/cldetect', '--update-license'], stderr=devnull, stdout=devnull, shell=False)
self.check_license(False)
def check_license(self, with_recovery=True):
if not self.kernel_is_supported():
if self.request_data['owner'] in ['reseller']:
self.exit_with_error(
code=503,
error_id='ERROR.not_available_plugin',
context={'pluginName': LVEMANAGER_PLUGIN_NAMES.get(self.current_plugin_name, DEFAULT_PLUGIN_NAME)},
icon='disabled')
elif self.request_data['owner'] in ['admin']:
self.exit_with_error('Kernel is not supported')
if is_hitting_max_accounts_limit():
if self.request_data['owner'] == 'admin':
self.exit_with_error('ERROR.hitting_max_accounts_limit')
if self.request_data['owner'] == 'user':
self.exit_with_error(
code=503,
error_id='ERROR.not_available_plugin',
context={'pluginName': LVEMANAGER_PLUGIN_NAMES.get(self.current_plugin_name, DEFAULT_PLUGIN_NAME)},
icon='disabled')
if not self.licence.get_license_status():
if self.request_data['owner'] in ['reseller', 'user']:
interpreter = 'nodejs'
if self.request_data.get('params') \
and self.request_data['params'].get('interpreter'):
interpreter = self.request_data['params']['interpreter']
pluginNames = {
'reseller': 'CloudLinux Manager',
'user': {'python': 'Python Selector', 'nodejs':'Node.js Selector'}
.get(interpreter, 'Node.js Selector')
}
self.exit_with_error(
code=503,
error_id='ERROR.not_available_plugin',
context={'pluginName': LVEMANAGER_PLUGIN_NAMES.get(self.current_plugin_name, DEFAULT_PLUGIN_NAME)},
icon='disabled')
else:
if with_recovery:
self.update_license()
else:
self.exit_with_error('License is not valid')
else:
self.license_is_checked = True
def exit_with_error(self, error_string='', context=None,
code=None, error_id=None, icon=None, ignore_errors=False):
result = {"result": error_string}
if context:
result['context'] = context
if code:
result['code'] = code
if error_id:
result['error_id'] = error_id
if icon:
result['icon'] = icon
if ignore_errors:
result['ignore'] = ignore_errors
print(json.dumps(result))
sys.exit(1)
def exit_with_success(self, response=None):
data = copy.deepcopy(response) if response else {}
data['result'] = 'success'
print(json.dumps(data))
sys.exit(0)
def cl_statistics(self):
# This imports from other package (cagefs), so we turn off pylint import checker for this line
from lvestats.lib.cloudlinux_statistics import main #pylint: disable=E0401
import lvestats.lib.config as config #pylint: disable=E0401
from lvestats.lib.dbengine import make_db_engine #pylint: disable=E0401
list_to_request = self.prepair_params_for_command()
try:
cnf = config.read_config()
dbengine = make_db_engine(cnf)
main(dbengine, argv_=list_to_request,server_id=cnf.get('server_id', 'localhost'))
silence_stdout_until_process_exit()
sys.exit(0)
except config.ConfigError as ce:
ce.log_and_exit()
self.exit_with_error(ce)
def spa_mock(self, file):
file_path = '/usr/share/l.v.e-manager/spa/src/jsons/%s.json' % (file)
# check if passed file param doesn't use relative path. E.g.: '../../file'
if os.path.realpath(file_path) != file_path:
self.exit_with_error('BAD REQUEST 3')
with open(file_path, 'r') as f:
print(f.read())
sys.exit(0)
def get_lve_version(self):
try:
ver = subprocess.check_output(
'cat /proc/lve/list | grep -Po \'^\d{1,2}:\'',
shell=True, executable='/bin/bash', text=True
).strip()
return int(ver[:-1])
except:
return 0
def get_cloudlinux_version(self):
return subprocess.check_output(
'uname -r | grep -Po \'el\d\w?\'',
shell=True, executable='/bin/bash', text=True
).strip()
# Common methods
def spa_ping(self):
self.exit_with_success()
def cl_selector(self):
try:
from clselector.cl_selector import CloudlinuxSelector
except:
self.exit_with_error('Module unavailable')
if self.user_info.get('username') and 'interpreter' in self.request_data['params']\
and self.request_data['params']['interpreter'] == 'php':
self.check_php_selector_user_availablility()
list_to_request = self.prepair_params_for_command()
cll = CloudlinuxSelector()
cll.run(list_to_request)
def check_php_selector_user_availablility(self):
"""
Additional check only for php selector
:return:
"""
try:
LIBDIR = '/usr/share/cagefs'
sys.path.append(LIBDIR)
import cagefsctl
if not cagefsctl.cagefs_is_enabled or \
not cagefsctl.is_user_enabled(self.user_info['username']):
raise RuntimeError('Cagefs is disabled or missing')
except (ImportError, RuntimeError):
self.exit_with_error(
code=503,
error_id='ERROR.cagefsDisabled',
)
from clselect.clselectexcept import BaseClSelectException
try:
from clselect import ClSelect
ClSelect.check_multiphp_system_default_version()
except (BaseClSelectException):
self.exit_with_error(
code=503,
error_id='ERROR.systemVersionAltPHP',
)
def define_current_plugin(self):
self.current_plugin_name = self.request_data.get('plugin_name')
def is_error_response_default(self, json_result):
return json_result.get('result') != 'success' and json_result.get('success') != 1
def run_util(self, name, *args, **kwargs):
command = [name] + list(args)
error_checker = kwargs.get('error_checker', self.is_error_response_default)
try:
p = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=self.get_env())
(result, err) = p.communicate(kwargs.pop('stdin', None))
is_error = p.returncode != 0 or not is_json(result)
if not is_error:
json_result = json.loads(result)
is_error = error_checker(json_result)
if is_error:
result = result + err
if is_json(result):
json_result = json.loads(result)
if json_result.get('message'):
json_result['result'] = json_result.pop('message')
result = json.dumps(json_result)
if kwargs.get("ignore_errors", False):
# Check new result concatenated with error
if is_json(result):
result = json.loads(result)
result['ignore'] = True
result = json.dumps(result)
else:
result = self.ignored_error_message(result)
print(result)
exit(1)
return result
except Exception as e:
self.exit_with_error("Can't run %(command)s", context={'command': ' '.join(command)}, ignore_errors=True)
def ignored_error_message(self, message):
return json.dumps({
"result": message,
"ignore": True
})
def kernel_is_supported(self):
try:
if is_container():
return True
if is_cl_solo_edition(skip_jwt_check=True):
# CL9 uses Alma kernel which doesn't have 'lve' in its name
if get_cl_version() == 'cl9':
return True
uname = subprocess.check_output('uname -r', shell=True, executable='/bin/bash', text=True)
return 'lve' in uname
else:
f = open('/proc/lve/list', 'r')
line = f.readline()
f.close()
return bool(line)
except IOError:
return False
def cl_statsnotifier(self):
from lvestats.lib.cloudlinux_statsnotifier import main #pylint: disable=E0401
list_to_request = self.prepair_params_for_command(with_json=True)
exit_code = main(args_=list_to_request) #pylint: disable=E0401
sys.exit(exit_code)
def cloudlinux_awp_user(self):
cli_command = '/usr/bin/cloudlinux-awp-user'
list_to_request = self.prepair_params_for_command(with_json=False)
result = self.run_util(cli_command, *list_to_request, ignore_errors=False)
print(result)
def cl_install_plugin(self):
"""
This method is needed just for dev server to allow work with mocks
"""
self.exit_with_success()
# user cli parts
class CommandType(Enum):
HEAVY = 'heavy'
SIMPLE = 'simple'
class ConfigLimitValue(Enum):
ALL = 'all' # limit all requests
HEAVY = 'heavy' # don't limit white-listed 'simple' requests
UNLIMITED = 'unlimited' # don't limit at all
@classmethod
def _missing_(cls, value):
return cls.ALL
@dataclass
class Rule:
callable: Callable
result: CommandType
class LimitStrategyBase:
"""
Base limits strategy to decide - run incoming request with or without cagefs limits
Strategy execution means that used script (cloudlinux_cli_user.py) will be re-executed with (or not)
additional cagefs flags
"""
cagefs_args: List[str]
def execute(self, command: str, args: List[str], request_data: dict) -> Optional[int]:
full_command = self.get_full_command(command, args, request_data)
p = subprocess.Popen(full_command)
p.communicate()
return p.returncode
def get_full_command(self, command: str, args: List[str], request_data: dict) -> List[str]:
cmd = [*sys.argv, f'--skip-cagefs-check']
return [CAGEFS_ENTER_PROXIED_BIN, *self.cagefs_args, *cmd]
class NoCagefsStrategy(LimitStrategyBase):
"""
Strategy for hardcoded commands, that should always run even without the cagefs with unknown reason
This strategy does not re-executes the script with `cagefs_enter.proxied`, just letting them to finish as is
TODO: LVEMAN-1767
"""
def execute(self, *args, **kwargs) -> Optional[int]:
return None
class AllLimitStrategy(LimitStrategyBase):
"""
Strategy to limit all commands
"""
cagefs_args = []
class NoLimitStrategy(LimitStrategyBase):
"""
Strategy to don't limit all commands
"""
cagefs_args = ['--no-io-and-memory-limit', '--no-cpu-limit', '--no-max-enter']
class LimitStrategyHeavy(LimitStrategyBase):
"""
Strategy to don't limit whitelisted commands
By default - all commands are HEAVY and will be limited
Add `rules` to mark any command as SIMPLE and run without limits
"""
cagefs_args = []
default_rule = Rule(callable=lambda args: True, result=CommandType.HEAVY)
rules = {
'cloudlinux-selector': [
Rule(callable=lambda args: 'get' in args, result=CommandType.SIMPLE),
Rule(callable=lambda args: 'start' in args, result=CommandType.SIMPLE),
Rule(callable=lambda args: 'restart' in args, result=CommandType.SIMPLE),
Rule(callable=lambda args: 'stop' in args, result=CommandType.SIMPLE),
]
}
def _check_rules(self, command: str, args: List[str]) -> CommandType:
command_type = None
for rule in self.rules.get(command, []) + [self.default_rule]:
if rule.callable(args):
command_type = rule.result
break
if command_type == CommandType.SIMPLE:
self.cagefs_args = ['--no-io-and-memory-limit', '--no-cpu-limit', '--no-max-enter']
else:
self.cagefs_args = []
return command_type
def get_full_command(self, command: str, args: List[str], request_data: dict) -> List[str]:
self._check_rules(command, args)
return super().get_full_command(command, args, request_data)
Zerion Mini Shell 1.0