PNG  IHDRxsBIT|d pHYs+tEXtSoftwarewww.inkscape.org<,tEXtComment File Manager

File Manager

Path: /opt/cloudlinux/venv/lib/python3.11/site-packages/clcommon/cpapi/plugins/

Viewing File: plesk.py

# -*- coding: utf-8 -*-

import os
import re
import time
import xml.etree.ElementTree as ETree
from collections import defaultdict
from functools import wraps
from traceback import format_exc
from typing import Any, AnyStr, Dict, List, Optional, Tuple, Union  # NOQA
from urllib.parse import urlparse

from clcommon import ClPwd, mysql_lib
from clcommon.clfunc import uid_max
from clcommon.cpapi.cpapicustombin import _docroot_under_user_via_custom_bin, get_domains_via_custom_binary
from clcommon.cpapi.cpapiexceptions import DuplicateData, NoDomain, NoPackage, NoPanelUser, NotSupported
from clcommon.cpapi.GeneralPanel import DomainDescription, GeneralPanelPluginV1, PHPDescription
from clcommon.features import Feature
from clcommon.utils import ExternalProgramFailed, find_module_param_in_config, get_modules_dir_for_alt_php, run_command

PSA_SHADOW_PATH = "/etc/psa/.psa.shadow"
SUPPORTED_CPINFO = {'cplogin', 'package', 'mail', 'reseller', 'dns', 'locale'}
UID_MAX = uid_max()


__cpname__ = 'Plesk'


# WARN: Probably will be deprecated for our "official" plugins.
# See pluginlib.detect_panel_fast()
def detect():
    return os.path.isfile('/usr/local/psa/version')


def db_access(_pass_path=PSA_SHADOW_PATH):
    access = {}
    access['login'] = 'admin'
    with open(_pass_path, 'r', encoding='utf-8') as f:
        access['pass'] = f.read().strip()
    return access


def query_sql(query, data=None, _access=None, _dbname='psa', as_dict=False):
    """
    Return the result of a Plesk database query

    :param query: SQL query string with possible parameters
    :param data: arguments for the SQL parameter insertion
    :param _access: database authentication data
    :param _dbname: the name of the database
    :param as_dict: controls the format of the output data
    :type query: str
    :type _access: dict
    :type as_dict: bool
    :return:
        Tuple of rows according to the query in the format specified by as_dict
    :rtype: tuple(tuple) or tuple(dict)
    """
    # Example of returned data:
    # >>> query_sql('SELECT login from sys_users')
    # ((u'cltest',), (u'cltest3',), (u'user2',), (u'user1tst',))
    # >>> query_sql('SELECT login from sys_users', as_dict=True)
    # ({'login': u'cltest'},
    #  {'login': u'cltest3'},
    #  {'login': u'user2'},
    #  {'login': u'user1tst'})
    access = _access or db_access()
    dbhost = access.get('host', 'localhost')
    dblogin = access['login']
    dbpass = access['pass']
    connector = mysql_lib.MySQLConnector(host=dbhost, user=dblogin, passwd=dbpass,
                                         db=_dbname, use_unicode=True, charset='utf8',
                                         as_dict=as_dict)
    with connector.connect() as db:
        return db.execute_query(query, args=data)


def cpusers(_access=None, _dbname='psa'):
    cpusers_lst = [fetched_one[0] for fetched_one in cpinfo(keyls=('cplogin', ))]
    return cpusers_lst


def resellers():
    sql = "SELECT clients.login FROM clients WHERE clients.type='reseller'"
    return [cplogin for (cplogin, ) in query_sql(sql)]


def admins():
    sql = "SELECT clients.login FROM clients WHERE clients.type='admin'"
    return set([cplogin for (cplogin, ) in query_sql(sql)])


def is_reseller(username):
    sql = "SELECT clients.type FROM clients WHERE clients.login=%s"
    try:
        return query_sql(sql, (username,))[0][0] == 'reseller'
    except IndexError:
        return False


def _sys_users_info(sys_login, keyls):
    # type: (Any[str, None], Tuple[str]) -> List[Tuple]
    # Templates.name can be None and it is ok
    mapping = {
        'cplogin': 'sys_users.login AS cplogin',
        'mail': 'clients.email AS email',
        'reseller': 'reseller.login AS reseller',
        'dns': 'domains.name AS dns',
        'locale': 'clients.locale AS local',
        'package': 'Templates.name AS package'
    }

    select_query = ', '.join([mapping[key] for key in keyls])
    sql = rf"""SELECT {select_query}
                FROM sys_users
                JOIN hosting ON hosting.sys_user_id=sys_users.id
                JOIN domains ON hosting.dom_id=domains.id AND domains.webspace_id=0
                JOIN clients ON clients.id=domains.cl_id
                JOIN clients reseller ON reseller.id=domains.vendor_id
                LEFT JOIN Subscriptions ON Subscriptions.object_type = "domain" AND domains.id = Subscriptions.object_id
                LEFT JOIN PlansSubscriptions ON PlansSubscriptions.subscription_id = Subscriptions.id
                LEFT JOIN Templates AS Templates ON Templates.id = PlansSubscriptions.plan_id AND "domain" = Templates.type
                """

    # make query like "where x in (%s, %s, %s, ...)"
    if isinstance(sys_login, (list, tuple)):
        placeholders = ','.join(['%s'] * len(sys_login))
        sql += rf" WHERE sys_users.login IN ({placeholders})"

    users = query_sql(sql, data=sys_login)
    return users


def _resellers_info(sys_login, keyls):
    # type: (Any[str, None], Tuple[str]) -> List[Tuple]
    # items with 'NULL' are not available for this panel
    mapping = {
        'cplogin': 'clients.login AS cplogin',
        'mail': 'clients.email AS email',
        'reseller': 'NULL as reseller',
        'dns': 'NULL as dns',
        'locale': 'clients.locale AS local',
        'package': 'NULL as package'
    }

    select_query = ', '.join([mapping[key] for key in keyls])
    sql = f"SELECT {select_query} FROM clients WHERE clients.type IN (\"reseller\", \"admin\")"

    # make query like "where x in (%s, %s, %s, ...)"
    if isinstance(sys_login, (list, tuple)):
        placeholders = ','.join(['%s'] * len(sys_login))
        sql += rf" AND clients.login IN ({placeholders})"

    users = query_sql(sql, data=sys_login)
    return users


def cpinfo(cpuser=None, keyls=('cplogin', 'package', 'mail', 'reseller', 'dns', 'locale'),
           search_sys_users=True):
    """
    Get info about user[s] or about reseller[s].
    :param str|None cpuser: get info about specified login, None for all
    :param list|tuple keyls: keys to return
    :param bool search_sys_users: work with sys users or with resellers
    :rtype: tuple[tuple]
    """
    if isinstance(cpuser, str):
        cpuser = [cpuser]

    # just for developers
    for key in keyls:
        if key not in SUPPORTED_CPINFO:
            raise NotSupported(f'Key {key} is not supported for this control panel. '
                               f'Available keys: {SUPPORTED_CPINFO}')

    if search_sys_users:
        return _sys_users_info(cpuser, keyls)
    return _resellers_info(cpuser, keyls)


def get_admin_email(*args, **kwargs):
    try:
        return query_sql(r"SELECT val FROM misc WHERE param='admin_email';")[0][0]
    except IndexError:
        return None


def docroot_basic(domain):
    # type: (str) -> Any[None, Tuple[str, str]]
    sql = r"""
    SELECT hosting.www_root, sys_users.login
      FROM hosting
      JOIN domains ON hosting.dom_id=domains.id
      JOIN sys_users ON hosting.sys_user_id=sys_users.id
    WHERE domains.name=%s
    """
    try:
        return query_sql(sql, data=(domain,))[0]
    except IndexError as e:
        raise NoDomain(f'Cannot obtain document root for {domain}') from e


def docroot(domain):
    # type: (str) -> Any[None, Tuple[str, str]]
    res = None
    domain = domain.strip()

    uid = os.getuid()
    euid = os.geteuid()
    if euid == 0 and uid == 0:
        res = docroot_basic(domain)
    else:
        res = _docroot_under_user_via_custom_bin(domain)

    # If there was successful result, res object will have
    # (doc_root, domain_user) format. If there wasn't found any correct
    # doc_roots, res will be None.
    if res is not None:
        return res
    raise NoDomain(f"Can't obtain document root for domain '{domain}'")


def reseller_users(resellername):
    """
    Return reseller users
    :param resellername: reseller name; return empty list if None
    :return list[str]: user names list
    """
    if resellername is None:
        return []
    sql = """
        SELECT sys_users.login
        FROM clients as reseller
        JOIN domains ON domains.vendor_id=reseller.id
        JOIN hosting ON hosting.dom_id=domains.id
        JOIN sys_users ON hosting.sys_user_id=sys_users.id
        WHERE domains.webspace_id=0 AND reseller.login=%s;
    """
    return [sys_login for (sys_login,) in query_sql(sql, data=(resellername,))]


def memoize(f):

    cache = {'userdomains_map': {}}

    @wraps(f)
    def wrapper(cpuser, *args, **kwargs):
        if cpuser not in cache['userdomains_map']:
            cache['userdomains_map'] = f(cpuser, *args, **kwargs)
        return cache['userdomains_map'][cpuser]

    return wrapper


@memoize
def userdomains_basic(cpuser, _access=None, _dbname='psa'):
    """
    Return domains of given user

    :param str cpuser: Username
    :param str _dbname: Database name where is located data
    :return:
        List of domains pairs such as (domain_name, None) to be suitable for
        domain_lib, starting from a main domain.
    :rtype: list of tuples
    :raises NoPanelUser: User is not found in Plesk database.
    """
    # WARN: ORDER BY columns must be present in SELECT columns for newer Mysql
    # webspace_id == 0 is main domain
    sql = r"""
    SELECT DISTINCT su.login, d.name, h.www_root, d.webspace_id
    FROM domains as d, hosting as h, sys_users as su
    WHERE h.sys_user_id = su.id AND h.dom_id = d.id
    ORDER BY d.webspace_id ASC;
    """
    # data:
    # (
    #   (u'customer1', u'customer1.org', 10L),
    #   (u'customer1', u'mk.customer1.org.customer1.org', 10L)
    # )
    data = query_sql(sql, as_dict=True, _access=_access)
    # _user_to_domains_map:
    # { 'user1': [('user1.org', '/var/www/vhosts/user1.com/httpdocs'),
    #             ('mk.user1.org', '/var/www/vhosts/user1.com/mk.user1.org')] }
    _user_to_domains_map = defaultdict(list)
    for data_dict in data:
        _user_to_domains_map[data_dict['login']].append(
            (data_dict['name'], data_dict['www_root']))
    if cpuser not in _user_to_domains_map:
        raise NoPanelUser(
            f'User {cpuser} not found in the database')
    return _user_to_domains_map


def userdomains(cpuser, _access=None, _dbname='psa', as_root=False):
    """
    Return domains of given user

    :param str cpuser: Username
    :param str _dbname: Database name where is located data
    :return:
        List of domains pairs such as (domain_name, None) to be suitable for
        domain_lib, starting from a main domain.
    :rtype: list of tuples
    :raises NoPanelUser: User is not found in Plesk database.
    """
    euid = os.geteuid()
    if euid == 0 or _access or as_root:
        return userdomains_basic(cpuser, _access, _dbname)

    # this case works the same as above but through the rights escalation binary wrapper
    # call path: here -> binary -> python(diradmin euid) -> userdomains(as_root=True) -> print json result to stdout
    rc, res = get_domains_via_custom_binary()
    if rc == 0:
        return res
    elif rc == 11:
        raise NoPanelUser(f'User {cpuser} not found in the database')
    else:
        raise ExternalProgramFailed(f'Failed to get userdomains: {res}')


def domain_owner(domain, _access=None, _dbname='psa'):
    """
    Return domain owner
    :param str domain: Domain/sub-domain/add-domain name
    :param str _dbname: Database name where is located data
    :return: user name or None if domain not found
    :rtype: str
    """
    sql = r"""
    SELECT DISTINCT `su`.`login`
    FROM `sys_users` `su`, `hosting` `h`, `domains` `d`, `domains` `sd`
    WHERE `h`.`sys_user_id`=`su`.`id` AND `h`.`dom_id`=`d`.`id`
          AND (`d`.`name`=%s OR `d`.`id`=`sd`.`webspace_id` AND `sd`.`name`=%s)"""
    users_list = [u[0] for u in query_sql(sql, (domain, domain))]
    # FIXME: how this possible?
    if len(users_list) > 1:
        raise DuplicateData(
            f"domain {domain} belongs to few users: [{','.join(users_list)}]"
        )
    if len(users_list) == 0:
        return None
    return users_list[0]


def dblogin_cplogin_pairs(cplogin_lst=None, with_system_users=False):
    raise NotSupported('Getting binding credentials in the database to the user name in the system is not currently '
                       'supported.')


def homedirs(_sysusers=None, _cpusers=None):
    """
    Detects and returns list of folders contained the home dirs of users of the Plesk

    :param str|None _sysusers: for testing
    :param str|None _cpusers: for testing
    :return: list of folders, which are parent of home dirs of users of the panel
    """
    homedirs = []

    if _cpusers is None:
        try:
            results = cpusers()
        except NoPackage:
            results = None
    else:
        results = _cpusers

    users = []
    if results is not None:
        users = [line[0] for line in results]

    # Plesk assumes MIN_UID as 10000
    clpwd = ClPwd(10000)
    users_dict = clpwd.get_user_dict()

    # for testing only
    if isinstance(_sysusers, (list, tuple)):
        class pw:
            def __init__(self, name, dir):
                self.pw_name = name
                self.pw_dir = dir

        users_dict = {}
        for (name, dir) in _sysusers:
            users_dict[name] = pw(name, dir)

    for user_name, user_data in users_dict.items():
        if len(users) and user_name not in users:
            continue
        homedir = os.path.dirname(user_data.pw_dir)
        if homedir not in homedirs:
            homedirs.append(homedir)

    return homedirs


def get_user_login_url(domain):
    return f'https://{domain}:8443'


def get_reseller_id_pairs():
    """
    Plesk has no user associated with reseller, but we need some id
    for out internal purposes. Let's take it from database.
    """
    sql = """SELECT clients.login, clients.id + %s FROM clients WHERE clients.type='reseller'"""
    return dict(query_sql(sql, data=[UID_MAX]))


def reseller_domains(resellername):
    # type: (str) -> Dict[str, str]
    if not resellername:
        return {}

    sql = r"""SELECT sys_users.login AS cplogin,
                     domains.name AS dns
                    FROM sys_users
                    JOIN hosting ON hosting.sys_user_id=sys_users.id
                    JOIN domains ON hosting.dom_id=domains.id AND domains.webspace_id=0
                    JOIN clients reseller ON reseller.id=domains.vendor_id
                    WHERE reseller.login=%s
                    """

    users = query_sql(sql, data=[resellername])
    return dict(users)


def _extract_xml_value(xml_string, key):
    """
    Plesk stores some information in simple xml formatted strings.
    """
    try:
        elem = ETree.fromstring(xml_string).find(key)
    except ETree.ParseError:
        return None
    else:
        return elem.text if elem is not None else None


def get_domains_php_info():
    """
    Plesk stores the information about the handler in xml format.
    Return the php version info for each domain.
    Example output:
        {'cltest.com': {'handler_type': 'fpm',
                         'php_version_id': 'plesk-php71-fpm',
                         'username': 'cltest'},`
         'cltest2.com': {'handler_type': 'fastcgi',
                          'php_version_id': 'x-httpd-lsphp-custom',
                          'username': 'kek_2'},
         'cltest3.com': {'handler_type': 'fastcgi',
                          'php_version_id': 'plesk-php56-fastcgi',
                          'username': 'cltest3'},
         'omg.kek': {'handler_type': 'fastcgi',
                      'php_version_id': 'plesk-php71-fastcgi',
                      'username': 'cltest'}}
    :rtype: dict[str, dict]
    """
    sql = r"""
    SELECT sys_users.login, d.name, h.php_handler_id, handlers.value
    FROM domains AS d
        JOIN hosting AS h
            ON h.dom_id=d.id
        JOIN sys_users
            ON h.sys_user_id=sys_users.id
        JOIN (SELECT ServiceNodeEnvironment.*
            FROM ServiceNodeEnvironment
            WHERE (serviceNodeId = '1' AND section = 'phphandlers')) AS handlers
                ON handlers.name=h.php_handler_id
    WHERE h.php='true'
        """

    # Php hanlder info xml example:
    #
    # <?xml version="1.0" encoding="UTF-8"?>
    # <handler>
    #    <id>plesk-php71-fpm</id>
    #    <type>fpm</type>
    #    <typeName>FPM application</typeName>
    #    <version>7.1</version>
    #    <fullVersion>7.1.22</fullVersion>
    #    <displayname>7.1.22</displayname>
    #    <path>/opt/plesk/php/7.1/sbin/php-fpm</path>
    #    <clipath>/opt/plesk/php/7.1/bin/php</clipath>
    #    <phpini>/opt/plesk/php/7.1/etc/php.ini</phpini>
    #    <custom>true</custom>
    #    <registered>true</registered>
    #    <service>plesk-php71-fpm</service>
    #    <poold>/opt/plesk/php/7.1/etc/php-fpm.d</poold>
    #    <outdated />
    # </handler>

    domains_php_info = query_sql(sql)
    # yep, vendor php_handler_id has only "fpm/cgi/fastcgi" w/o version, so additional bicycle needed
    vendor_version_ids = ['cgi', 'fastcgi', 'fpm', 'x-httpd-lsphp-custom']

    php_versions = {}
    for username, domain, php_handler_id, handler_xml in domains_php_info:
        display_version = php_handler_id if php_handler_id not in vendor_version_ids \
            else f'vendor-php{_extract_xml_value(handler_xml, "version")}'.replace('.', '')

        def _cast(handler_name: str, version_id: str) -> str:
            if handler_name == 'fpm':
                return 'php-fpm'
            elif 'x-httpd-lsphp' in version_id:
                return 'lsapi'

            return handler_name

        handler = _extract_xml_value(handler_xml, key='type') or 'unknown'
        handler = _cast(handler, php_handler_id)

        # transform different php variations into some normal form
        display_version = display_version\
            .replace('-dedicated', '')\
            .replace('-fpm', '')\
            .replace('-fastcgi', '')\
            .replace('x-httpd-lsphp-', 'alt-php')

        php_versions[domain] = DomainDescription(
            username=username,
            php_version_id=display_version,  # not a typo
            handler_type=handler,
            display_version=display_version
        )

    return php_versions


def get_main_username_by_uid(uid: int) -> str:
    """
    Get "main" panel username by uid.
    :param uid: uid
    :return Username or 'N/A' if user not found
    """
    if uid == 0:
        return 'root'
    try:
        _clpwd = ClPwd()
        pwd_list = _clpwd.get_pw_by_uid(uid)
        if os.geteuid() == 0:
            for user_pwd in pwd_list:
                username = user_pwd.pw_name
                try:
                    userdomains(username)
                    return username
                except NoPanelUser:
                    pass
        else:
            # Under user cycle implemented in suid binary, see scripts/plesk_suid_caller.py
            username = pwd_list[0].pw_name
            userdomains(username)
            return username
    except (NoPanelUser, ClPwd.NoSuchUserException):
        pass
    return 'N/A'


class PanelPlugin(GeneralPanelPluginV1):
    def __init__(self):
        super().__init__()
        self.HTTPD_MPM_CONFIG = '/etc/httpd/conf.modules.d/01-cgi.conf'
        # Defaults of MaxRequestWorkers for all possible mpm modules
        self.MPM_MODULES = {
            "prefork": 256,
            "worker": 400,
            "event": 400
        }
        # Vars for httpd modules caching
        self.httpd_modules_ts = 0
        self.httpd_modules = ""

    def getCPName(self):
        """
        Return panel name
        :return:
        """
        return __cpname__

    def get_cp_description(self):
        """
        Retrieve panel name and it's version
        :return: dict: { 'name': 'panel_name', 'version': 'panel_version', 'additional_info': 'add_info'}
            or None if can't get info
        """
        try:
            with open("/usr/local/psa/version", "r", encoding="utf-8") as f:
                out = f.read()
            return {'name': __cpname__, 'version': out.split()[0], 'additional_info': None}
        except Exception:
            return None

    def db_access(self):
        """
        Getting root access to mysql database.
        For example {'login': 'root', 'db': 'mysql', 'host': 'localhost', 'pass': '9pJUv38sAqqW'}

        :return: root access to mysql database
        :rtype: dict
        :raises: NoDBAccessData
        """
        return db_access()

    def cpusers(self):
        """
        Generates a list of cpusers registered in the control panel

        :return: list of cpusers registered in the control panel
        :rtype: tuple
        """
        return cpusers()

    def resellers(self):
        """
        Generates a list of resellers in the control panel

        :return: list of cpusers registered in the control panel
        :rtype: tuple
        """
        return resellers()

    def is_reseller(self, username):
        """
        Check if given user is reseller;
        :type username: str
        :rtype: bool
        """
        return is_reseller(username)

    def dblogin_cplogin_pairs(self, cplogin_lst=None, with_system_users=False):
        """
        Get mapping between system and DB users
        @param cplogin_lst :list: list with usernames for generate mapping
        @param with_system_users :bool: add system users to result list or no.
                                        default: False
        """
        return dblogin_cplogin_pairs(cplogin_lst, with_system_users)

    def cpinfo(self, cpuser=None, keyls=('cplogin', 'package', 'mail', 'reseller', 'dns', 'locale'),
               search_sys_users=True):
        """
        Retrieves info about panel user(s)
        :param str|unicode|list|tuple|None cpuser: user login
        :param keyls: list of data which is necessary to obtain the user,
                        the valuescan be:
           cplogin - name/login user control panel
           mail - Email users
           reseller - name reseller/owner users
           locale - localization of the user account
           package - User name of the package
           dns - domain of the user
        :param bool search_sys_users: search for cpuser in sys_users or in control panel users (e.g. for Plesk)
        :return: returns a tuple of tuples of data in the same sequence as specified keys in keylst
        :rtype: tuple
        """
        return cpinfo(cpuser, keyls, search_sys_users=search_sys_users)

    def get_admin_email(self):
        """
        Retrieve admin email address
        :return: Host admin's email
        """
        return get_admin_email()

    def docroot(self, domain):
        """
        Return document root for domain
        :param str|unicode domain:
        :return str: document root for domain
        """
        return docroot(domain)

    @staticmethod
    def useraliases(cpuser, domain):
        """
        Return aliases from user domain
        :param str|unicode cpuser: user login
        :param str|unicode domain:
        :return list of aliases
        """
        sql = """
        SELECT a.name, d.name
        FROM domains AS d
            INNER JOIN domain_aliases AS a
                ON a.dom_id = d.id
            INNER JOIN hosting AS h
                ON h.dom_id = d.id
            INNER JOIN sys_users AS su
                ON h.sys_user_id = su.id
        WHERE su.login = %s AND d.name = %s
        """
        return [item[0] for item in query_sql(sql, (cpuser, domain))]

    def userdomains(self, cpuser):
        """
        Return domain and document root pairs for control panel user
        first domain is main domain
        :param str|unicode cpuser: user login
        :return list of tuples (domain_name, documen_root)
        """
        return userdomains(cpuser)

    def homedirs(self):
        """
        Detects and returns list of folders contained the home dirs of users of the cPanel
        :return: list of folders, which are parent of home dirs of users of the panel
        """
        return homedirs()

    def reseller_users(self, resellername=None):
        """
        Return reseller users
        :param resellername: reseller name; autodetect name if None
        :return list[str]: user names list
        """
        return reseller_users(resellername)

    def reseller_domains(self, resellername=None):
        """
        Get dict[user, domain]
        :param resellername: reseller's name
        :rtype: dict[str, str|None]
        :raises DomainException: if cannot obtain domains
        """
        return reseller_domains(resellername)

    def get_user_login_url(self, domain):
        """
        Get login url for current panel;
        :type domain: str
        :rtype: str
        """
        return get_user_login_url(domain)

    def admins(self):
        """
        List all admins names in given control panel
        :return: list of strings
        """
        return admins()

    def get_reseller_id_pairs(self):
        """
        Plesk has no user associated with reseller, but we need some id
        for out internal purposes. Let's take it from database.
        """
        return get_reseller_id_pairs()

    def domain_owner(self, domain):
        """
        Return domain's owner
        :param domain: Domain/sub-domain/add-domain name
        :rtype: str
        :return: user name or None if domain not found
        """
        return domain_owner(domain)

    def get_domains_php_info(self):
        """
        Return php version information for each domain
        :return: domain to php info mapping
        :rtype: dict[str, dict]
        """
        return get_domains_php_info()

    def get_installed_php_versions(self):
        """
        Get the list of PHP version installed in panel in the form of
        'versionXY', for example alt-php56 or plesk-php80
        "Versions by OS vendor" in Plesk DB have names:
            - module
            - synced
        They are FILTERED from the list
        :return: list
        """
        sql = """
        SELECT ServiceNodeEnvironment.name, ServiceNodeEnvironment.value
        FROM ServiceNodeEnvironment
        WHERE (serviceNodeId = '1' AND section = 'phphandlers')
        """
        # handler list example:
        # ['alt-php-internal-cgi', 'alt-php44-cgi', 'alt-php44-fastcgi',
        # 'alt-php51-cgi', 'alt-php51-fastcgi', 'fpm', 'cgi',
        # 'fastcgi', 'x-httpd-lsphp-custom']
        query_result = query_sql(sql)
        ver_name_pattern = re.compile(r'^(alt-|plesk-)php+\d+', re.IGNORECASE)
        named_php_handlers = [item[0] for item in query_result if ver_name_pattern.match(item[0])]
        vendor_handler_names = ['cgi', 'fastcgi', 'fpm', 'x-httpd-lsphp-custom']
        named_php_handlers.extend([self._cast_to_vendor_name(name, xmlconfig)
                                   for name, xmlconfig in query_result
                                   if name in vendor_handler_names])
        versions_set = set('-'.join(item.split('-')[:2]) for item in named_php_handlers)

        php_description = []
        for php_name in versions_set:
            if php_name.startswith("alt-") or php_name.startswith("x-httpd-lsphp-"):
                php_root_dir = f'/opt/{php_name.replace("-", "/")}/'
                php_description.append(PHPDescription(
                    identifier=php_name,
                    version=f'{php_name[-2]}.{php_name[-1]}',
                    dir=os.path.join(php_root_dir),
                    modules_dir=os.path.join(php_root_dir, get_modules_dir_for_alt_php()),
                    bin=os.path.join(php_root_dir, 'usr/bin/php'),
                    ini=os.path.join(php_root_dir, 'link/conf/default.ini'),
                ))
            elif php_name.startswith("plesk-"):
                php_root_dir = f'/opt/plesk/php/{php_name[-2]}.{php_name[-1]}/'

                php_description.append(PHPDescription(
                    identifier=php_name,
                    version=f'{php_name[-2]}.{php_name[-1]}',
                    modules_dir=os.path.join(php_root_dir, 'lib64/php/modules/'),
                    dir=os.path.join(php_root_dir),
                    bin=os.path.join(php_root_dir, 'bin/php'),
                    ini=os.path.join(php_root_dir, 'etc/php.ini'),
                ))
            elif php_name.startswith("vendor-"):
                php_root_dir = '/'

                php_description.append(PHPDescription(
                    identifier=php_name,
                    version=f'{php_name[-2]}.{php_name[-1]}',
                    modules_dir=os.path.join(php_root_dir, 'usr/lib64/php/modules/'),
                    dir=os.path.join(php_root_dir),
                    bin=os.path.join(php_root_dir, 'bin/php'),
                    ini=os.path.join(php_root_dir, 'etc/php.ini'),
                ))
            else:
                # unknown php, skip
                continue
        return php_description

    def _cast_to_vendor_name(self, name, value):
        return f'vendor-php{_extract_xml_value(value, "version")}-{name}'.replace('.', '')

    def get_unsupported_cl_features(self) -> list[Feature]:
        return [
            Feature.RUBY_SELECTOR,  # Not supported due to low demand
            Feature.PYTHON_SELECTOR,  # Not available for Plesk because Plesk has its own Python selector
            Feature.NODEJS_SELECTOR,  # Not available for Plesk because Plesk has its own NodeJS selector
        ]

    def _get_active_apache_mpm_module(self) -> Optional[AnyStr]:
        """
        Determines active MPM module for Apache Web Server
        :return: apache_active_module_name
                apache_active_module_name: 'prefork', 'event', 'worker'
        """
        try:
            # Caching httpd output and refresh it only one time in hour
            if time.time() - self.httpd_modules_ts > 3600:
                self.httpd_modules = run_command(["httpd", "-M"])
                self.httpd_modules_ts = time.time()
        except (OSError, IOError, ExternalProgramFailed):
            self.httpd_modules = ""
            self.httpd_modules_ts = time.time()
        for mpm_module in self.MPM_MODULES:
            if f"mpm_{mpm_module}_module" in self.httpd_modules:
                return mpm_module
        return None

    def _get_max_request_workers_for_module(self, apache_module_name: str) \
            -> Tuple[int, str]:
        """
        Determine MaxRequestWorkers directive value for specified apache module
        Reads config file /etc/httpd/conf.modules.d/01-cgi.conf
        :param apache_module_name: Current apache's module name:
        'prefork', 'event', 'worker'
        :return: tuple (max_req_num, message)
            max_req_num - Maximum request apache workers number or 0 if error
            message - OK/Error message
        """
        try:
            return find_module_param_in_config(self.HTTPD_MPM_CONFIG,
                                               apache_module_name,
                                               'MaxRequestWorkers',
                                               self.MPM_MODULES[apache_module_name])
        except (OSError, IOError, IndexError, ValueError):
            return 0, format_exc()

    def get_apache_max_request_workers(self) -> Tuple[int, str]:
        """
        Get current maximum request apache workers from httpd's config
        :return: tuple (max_req_num, message)
            max_req_num - Maximum request apache workers number or 0 if error
            message - OK/Error message
        """
        apache_active_module = self._get_active_apache_mpm_module()
        if apache_active_module is None:
            return 0, "httpd service doesn't work or mpm modules are absent"
        return self._get_max_request_workers_for_module(apache_active_module)

    @staticmethod
    def get_main_username_by_uid(uid: int) -> str:
        """
        Get "main" panel username by uid.
        :param uid: uid
        :return Username
        """
        return get_main_username_by_uid(uid)

    @staticmethod
    def get_user_emails_list(username: str, domain: str):
        sql = f"""
        SELECT clients.email
        FROM clients
        WHERE clients.id = (
            SELECT domains.cl_id
            FROM domains
            WHERE domains.name = '{domain}')
        """
        query_result = query_sql(sql)
        return ','.join(item[0] for item in query_result)

    @staticmethod
    def panel_login_link(username):
        link = run_command(['/usr/sbin/plesk', 'login'])
        if not link:
            return ''
        # https://10.51.32.129/login?secret=RZ3NqTqneO0ZQgkIb-QKxyMZkvOgdAS0SGaNnAgN-nKyAYgc -> https://10.51.32.129/
        parsed = urlparse(link)
        return f'{parsed.scheme}://{parsed.netloc}/'

    @staticmethod
    def panel_awp_link(username):
        link = PanelPlugin.panel_login_link(username).rstrip("/")
        if len(link) == 0:
            return ''
        return f'{link}/modules/plesk-lvemanager/index.php/awp/index#/'

    def get_customer_login(self, username):
        """
        In some rare situations we need customer
        login instead of system user name.
        E.g. when communicating with WHMCS.

        This method resolves customer login by his system user name.
        """
        sql = r"""SELECT clients.login
            FROM sys_users
            JOIN hosting ON hosting.sys_user_id=sys_users.id
            JOIN domains ON hosting.dom_id=domains.id AND domains.webspace_id=0
            JOIN clients ON clients.id=domains.cl_id
            WHERE sys_users.login = %s"""

        customers = query_sql(sql, data=[username])
        try:
            return customers[0][0]
        except IndexError as e:
            raise NoPanelUser(f'Unknown user {username}') from e

    def get_domain_login(self, username, domain):
        """
        In some rare situations we need subscription
        login instead of client login.
        E.g. when communicating with WHMCS.

        This method resolves sys_users login by domain.

        One client can create several subscriptions
        Each subscription creates a new login in the sys_users table
        The user can create several domains for one subscription

        upgrade_url requires subscription login from sys_users.
        """
        sql = r"""SELECT sys_users.login
            FROM sys_users
            JOIN hosting ON hosting.sys_user_id=sys_users.id
            JOIN domains ON hosting.dom_id=domains.id AND domains.webspace_id=0 AND domains.name = %s"""

        logins = query_sql(sql, data=[domain])
        try:
            return logins[0][0]
        except IndexError as e:
            raise NoPanelUser(f'Unknown user for domain {domain}') from e

    def get_server_ip(self):
        sql = r"""
        SELECT ip_address FROM IP_Addresses
        WHERE main = 'true'
        """
        ip_addresses = query_sql(sql)
        try:
            return ip_addresses[0][0]
        except IndexError as e:
            raise NotSupported(
                'Unable to detect main ip for this server. '
                'Contact CloudLinux support and report the issue.'
            ) from e

    def suspended_users_list(self):
        """
        Returns list of suspended system users
        suspended means domain status == 2
        """
        sql = r"""
        SELECT su.login FROM sys_users su 
        JOIN hosting h ON su.id = h.sys_user_id 
        JOIN domains d ON h.dom_id = d.id 
        WHERE d.status = 2
        """
        suspended = query_sql(sql)
        return [item[0] for item in suspended]
b IDATxytVսϓ22 A@IR :hCiZ[v*E:WũZA ^dQeQ @ !jZ'>gsV仿$|?g)&x-EIENT ;@xT.i%-X}SvS5.r/UHz^_$-W"w)Ɗ/@Z &IoX P$K}JzX:;` &, ŋui,e6mX ԵrKb1ԗ)DADADADADADADADADADADADADADADADADADADADADADADADADADADADADADADADADADADADADADADADADADA݀!I*]R;I2$eZ#ORZSrr6mteffu*((Pu'v{DIߔ4^pIm'77WEEE;vƎ4-$]'RI{\I&G :IHJ DWBB=\WR޽m o$K(V9ABB.}jѢv`^?IOȅ} ڶmG}T#FJ`56$-ھ}FI&v;0(h;Б38CӧOWf!;A i:F_m9s&|q%=#wZprrrla A &P\\СC[A#! {olF} `E2}MK/vV)i{4BffV\|ۭX`b@kɶ@%i$K z5zhmX[IXZ` 'b%$r5M4º/l ԃߖxhʔ)[@=} K6IM}^5k㏷݆z ΗÿO:gdGBmyT/@+Vɶ纽z񕏵l.y޴it뭷zV0[Y^>Wsqs}\/@$(T7f.InݺiR$푔n.~?H))\ZRW'Mo~v Ov6oԃxz! S,&xm/yɞԟ?'uaSѽb,8GלKboi&3t7Y,)JJ c[nzӳdE&KsZLӄ I?@&%ӟ۶mSMMњ0iؐSZ,|J+N ~,0A0!5%Q-YQQa3}$_vVrf9f?S8`zDADADADADADADADADAdqP,تmMmg1V?rSI꒟]u|l RCyEf٢9 jURbztѰ!m5~tGj2DhG*{H9)꒟ר3:(+3\?/;TUݭʴ~S6lڧUJ*i$d(#=Yݺd{,p|3B))q:vN0Y.jkק6;SɶVzHJJЀ-utѹսk>QUU\޲~]fFnK?&ߡ5b=z9)^|u_k-[y%ZNU6 7Mi:]ۦtk[n X(e6Bb."8cۭ|~teuuw|ήI-5"~Uk;ZicEmN/:]M> cQ^uiƞ??Ңpc#TUU3UakNwA`:Y_V-8.KKfRitv޲* 9S6ֿj,ՃNOMߤ]z^fOh|<>@Å5 _/Iu?{SY4hK/2]4%it5q]GGe2%iR| W&f*^]??vq[LgE_3f}Fxu~}qd-ږFxu~I N>\;͗O֊:̗WJ@BhW=y|GgwܷH_NY?)Tdi'?խwhlmQi !SUUsw4kӺe4rfxu-[nHtMFj}H_u~w>)oV}(T'ebʒv3_[+vn@Ȭ\S}ot}w=kHFnxg S 0eޢm~l}uqZfFoZuuEg `zt~? b;t%>WTkķh[2eG8LIWx,^\thrl^Ϊ{=dž<}qV@ ⠨Wy^LF_>0UkDuʫuCs$)Iv:IK;6ֲ4{^6եm+l3>݆uM 9u?>Zc }g~qhKwڭeFMM~pМuqǿz6Tb@8@Y|jx](^]gf}M"tG -w.@vOqh~/HII`S[l.6nØXL9vUcOoB\xoǤ'T&IǍQw_wpv[kmO{w~>#=P1Pɞa-we:iǏlHo׈꒟f9SzH?+shk%Fs:qVhqY`jvO'ρ?PyX3lх]˾uV{ݞ]1,MzYNW~̈́ joYn}ȚF߾׮mS]F z+EDxm/d{F{-W-4wY듏:??_gPf ^3ecg ҵs8R2מz@TANGj)}CNi/R~}c:5{!ZHӋӾ6}T]G]7W6^n 9*,YqOZj:P?Q DFL|?-^.Ɵ7}fFh׶xe2Pscz1&5\cn[=Vn[ĶE鎀uˌd3GII k;lNmشOuuRVfBE]ۣeӶu :X-[(er4~LHi6:Ѻ@ԅrST0trk%$Č0ez" *z"T/X9|8.C5Feg}CQ%͞ˣJvL/?j^h&9xF`њZ(&yF&Iݻfg#W;3^{Wo^4'vV[[K';+mӍִ]AC@W?1^{එyh +^]fm~iԵ]AB@WTk̏t uR?l.OIHiYyԶ]Aˀ7c:q}ힽaf6Z~қm(+sK4{^6}T*UUu]n.:kx{:2 _m=sAߤU@?Z-Vކеz왍Nэ{|5 pڶn b p-@sPg]0G7fy-M{GCF'%{4`=$-Ge\ eU:m+Zt'WjO!OAF@ik&t݆ϥ_ e}=]"Wz_.͜E3leWFih|t-wZۍ-uw=6YN{6|} |*={Ѽn.S.z1zjۻTH]흾 DuDvmvK.`V]yY~sI@t?/ϓ. m&["+P?MzovVЫG3-GRR[(!!\_,^%?v@ҵő m`Y)tem8GMx.))A]Y i`ViW`?^~!S#^+ѽGZj?Vģ0.))A꨷lzL*]OXrY`DBBLOj{-MH'ii-ϰ ok7^ )쭡b]UXSְmռY|5*cֽk0B7镹%ڽP#8nȎq}mJr23_>lE5$iwui+ H~F`IjƵ@q \ @#qG0".0" l`„.0! ,AQHN6qzkKJ#o;`Xv2>,tێJJ7Z/*A .@fفjMzkg @TvZH3Zxu6Ra'%O?/dQ5xYkU]Rֽkق@DaS^RSּ5|BeHNN͘p HvcYcC5:y #`οb;z2.!kr}gUWkyZn=f Pvsn3p~;4p˚=ē~NmI] ¾ 0lH[_L hsh_ғߤc_њec)g7VIZ5yrgk̞W#IjӪv>՞y睝M8[|]\շ8M6%|@PZڨI-m>=k='aiRo-x?>Q.}`Ȏ:Wsmu u > .@,&;+!!˱tﭧDQwRW\vF\~Q7>spYw$%A~;~}6¾ g&if_=j,v+UL1(tWake:@Ș>j$Gq2t7S?vL|]u/ .(0E6Mk6hiۺzښOrifޱxm/Gx> Lal%%~{lBsR4*}{0Z/tNIɚpV^#Lf:u@k#RSu =S^ZyuR/.@n&΃z~B=0eg뺆#,Þ[B/?H uUf7y Wy}Bwegל`Wh(||`l`.;Ws?V@"c:iɍL֯PGv6zctM̠':wuW;d=;EveD}9J@B(0iհ bvP1{\P&G7D޴Iy_$-Qjm~Yrr&]CDv%bh|Yzni_ˆR;kg}nJOIIwyuL}{ЌNj}:+3Y?:WJ/N+Rzd=hb;dj͒suݔ@NKMԄ jqzC5@y°hL m;*5ezᕏ=ep XL n?מ:r`۵tŤZ|1v`V뽧_csج'ߤ%oTuumk%%%h)uy]Nk[n 'b2 l.=͜E%gf$[c;s:V-͞WߤWh-j7]4=F-X]>ZLSi[Y*We;Zan(ӇW|e(HNNP5[= r4tP &0<pc#`vTNV GFqvTi*Tyam$ߏWyE*VJKMTfFw>'$-ؽ.Ho.8c"@DADADADADADADADADA~j*֘,N;Pi3599h=goضLgiJ5փy~}&Zd9p֚ e:|hL``b/d9p? fgg+%%hMgXosج, ΩOl0Zh=xdjLmhݻoO[g_l,8a]٭+ӧ0$I]c]:粹:Teꢢ"5a^Kgh,&= =՟^߶“ߢE ܹS J}I%:8 IDAT~,9/ʃPW'Mo}zNƍ쨓zPbNZ~^z=4mswg;5 Y~SVMRXUյڱRf?s:w ;6H:ºi5-maM&O3;1IKeamZh͛7+##v+c ~u~ca]GnF'ټL~PPPbn voC4R,ӟgg %hq}@#M4IÇ Oy^xMZx ) yOw@HkN˖-Sǎmb]X@n+i͖!++K3gd\$mt$^YfJ\8PRF)77Wא!Cl$i:@@_oG I{$# 8磌ŋ91A (Im7֭>}ߴJq7ޗt^ -[ԩSj*}%]&' -ɓ'ꫯVzzvB#;a 7@GxI{j޼ƌ.LÇWBB7`O"I$/@R @eee@۷>}0,ɒ2$53Xs|cS~rpTYYY} kHc %&k.], @ADADADADADADADADA@lT<%''*Lo^={رc5h %$+CnܸQ3fҥK}vUVVs9G R,_{xˇ3o߾;TTTd}馛]uuuG~iԩ@4bnvmvfϞ /Peeeq}}za I~,誫{UWW뮻}_~YƍSMMMYχ֝waw\ďcxꩧtEƍկ_?۷5@u?1kNׯWzz/wy>}zj3 k(ٺuq_Zvf̘:~ ABQ&r|!%KҥKgԞ={<_X-z !CyFUUz~ ABQIIIjݺW$UXXDٳZ~ ABQƍecW$<(~<RSSvZujjjԧOZQu@4 8m&&&jԩg$ď1h ͟?_{768@g =@`)))5o6m3)ѣƌJ;wҿUTT /KZR{~a=@0o<*狔iFɶ[ˎ;T]]OX@?K.ۈxN pppppppppppppppppPfl߾] ,{ァk۶mڿo5BTӦMӴiӴ|r DB2e|An!Dy'tkΝ[A $***t5' "!駟oaDnΝ:t֭[gDШQ06qD;@ x M6v(PiizmZ4ew"@̴ixf [~-Fٱc&IZ2|n!?$@{[HTɏ#@hȎI# _m(F /6Z3z'\r,r!;w2Z3j=~GY7"I$iI.p_"?pN`y DD?: _  Gÿab7J !Bx@0 Bo cG@`1C[@0G @`0C_u V1 aCX>W ` | `!<S `"<. `#c`?cAC4 ?c p#~@0?:08&_MQ1J h#?/`7;I  q 7a wQ A 1 Hp !#<8/#@1Ul7=S=K.4Z?E_$i@!1!E4?`P_  @Bă10#: "aU,xbFY1 [n|n #'vEH:`xb #vD4Y hi.i&EΖv#O H4IŶ}:Ikh @tZRF#(tXҙzZ ?I3l7q@õ|ۍ1,GpuY Ꮿ@hJv#xxk$ v#9 5 }_$c S#=+"K{F*m7`#%H:NRSp6I?sIՖ{Ap$I$I:QRv2$Z @UJ*$]<FO4IENDB`