# -*- coding: utf-8 -*-
import json
import logging
import os
import pwd
import string
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from collections import OrderedDict
from configparser import ConfigParser, NoOptionError, NoSectionError
from traceback import format_exc
from urllib.parse import urlparse
import requests
import yaml
from clcommon import ClPwd
from clcommon.clconfpars import load_fast
from clcommon.cpapi.cpapiexceptions import (
CPAPIExternalProgramFailed,
CpApiTypeError,
DuplicateData,
EncodingError,
NoDBAccessData,
NoDomain,
NotSupported,
ParsingError,
)
from clcommon.cpapi.GeneralPanel import (
CPAPI_CACHE_STORAGE,
DomainDescription,
GeneralPanelPluginV1,
PHPDescription,
)
from clcommon.cpapi.pluginlib import getuser
from clcommon.features import Feature
from clcommon.lib.whmapi_lib import WhmApiError, WhmApiRequest, WhmNoPhpBinariesError
from clcommon.utils import ExternalProgramFailed, get_file_lines, get_modules_dir_for_alt_php, grep, run_command
__cpname__ = 'cPanel'
DBMAPPING_SCRIPT = "/opt/cloudlinux/venv/bin/cpanel-dbmapping"
UAPI = '/usr/bin/uapi'
logger = logging.getLogger(__name__)
# WARN: Probably will be deprecated for our "official" plugins.
# See pluginlib.detect_panel_fast()
def detect():
return os.path.isfile('/usr/local/cpanel/cpanel')
CPANEL_DB_CONF = '/root/.my.cnf'
CPANEL_USERPLANS_PATH = '/etc/userplans'
CPANEL_DATABASES_PATH = '/var/cpanel/databases/'
CPANEL_USERS_DIR = '/var/cpanel/users/'
CPANEL_RESELLERS_PATH = '/var/cpanel/resellers'
CPANEL_USERDATADOMAINS_PATH = '/etc/userdatadomains;/var/cpanel/userdata/{user}/cache'
CPANEL_USERDATAFOLDER_PATH = '/var/cpanel/userdata/{user}'
CPANEL_ACCT_CONF_PATH = '/etc/wwwacct.conf'
CPANEL_USEROWNERS_FILE = '/etc/trueuserowners'
SYSCONF_CLOUDLINUX_PATH = '/etc/sysconfig/cloudlinux'
CPANEL_CONFIG = '/var/cpanel/cpanel.config'
USERCONF_PARAM_MAP = {
'dns': 'dns',
'package': 'plan',
'reseller': 'owner',
'mail': 'contactemail',
'locale': 'locale',
'cplogin': 'user'
}
SUPPORTED_CPANEL_CPINFO = ('cplogin', 'package', 'mail', 'reseller', 'dns', 'locale')
def db_access(_conf_path=CPANEL_DB_CONF):
access = {}
reg_data_config = ConfigParser(
allow_no_value=True,
interpolation=None,
strict=False
)
opened_files = reg_data_config.read(_conf_path)
if not opened_files:
raise NoDBAccessData(
f"Cannot find database access data for localhost. No such file {_conf_path}"
)
# Options in MySQL config files can be double- or single-quoted, so we strip() them
try:
if reg_data_config.has_option(section="client", option="password"):
access["pass"] = reg_data_config.get(
section="client",
option="password",
raw=True
).strip("\"'")
else:
access["pass"] = reg_data_config.get(
section="client",
option="pass",
raw=True
).strip("\"'")
access["login"] = reg_data_config.get(
section="client",
option="user",
raw=True
).strip("\"'")
except (NoOptionError, NoSectionError) as err:
raise NoDBAccessData(
"Cannot find database access data for localhost from config "
f"file {_conf_path}; {err.message}"
) from err
access["db"] = "mysql"
return access
def cpusers(_userplans_file=CPANEL_USERPLANS_PATH):
"""
Parse the file /etc/userplans, which contains the pairs of user-plan
:param _userplans_file: path to the user's plans file
:return: list of the non-system users
"""
with open(_userplans_file, encoding='utf-8') as stream:
users_list = [line.split(':')[0].strip() for line in stream
if not line.startswith('#') and line.count(':') == 1 and len(line.strip()) > 3]
return tuple(users_list)
def resellers(_resellers_path=CPANEL_RESELLERS_PATH):
if not os.path.isfile(_resellers_path): # on a clean system, this file may not be
return tuple()
with open(_resellers_path, encoding='utf-8') as stream:
# Example of file
# res1res1:add-pkg,add-pkg-ip,add-pkg-shell
# res1root:add-pkg,add-pkg-ip,add-pkg-shell,allow-addoncreate,allow-emaillimits-pkgs
# r:
resellers_list = [line.split(':', 1)[0].strip() for line in stream
if not line.startswith('#') and (':' in line) and len(line.strip()) > 1]
return tuple(resellers_list)
def admins():
return {'root'}
def is_reseller(username, _resellers_path=CPANEL_RESELLERS_PATH):
"""
Check if given user is reseller;
:param _resellers_path: for testing only
:type username: str
:rtype: bool
"""
return any(cplogin == username for cplogin in resellers(_resellers_path))
def dblogin_cplogin_pairs(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
"""
# initialize results list
results = []
# accept only list and tuple parameters
uid_list = []
for username in (cplogin_lst or []):
try:
uid_list.append(str(pwd.getpwnam(username).pw_uid))
except KeyError:
# no user exists - skip it
uid_list.append("-1")
# generate system command
params = [DBMAPPING_SCRIPT]
if not with_system_users:
params.append("--nosys")
params += uid_list
with subprocess.Popen(
params,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
) as p:
output, _ = p.communicate()
# output format: "DBuser user UID"
for line in output.split("\n"):
line = line.strip()
if line:
results.append(line.split()[:2])
return tuple(results)
def _validate_path_component(value, kind='cpuser'):
"""
Reject a value that is unsafe to splice into a filesystem path as a single
component (these helpers are exported public API and run as root). A safe
component equals its own basename and contains no separator, NUL or
traversal, so an absolute or '..'-bearing value cannot escape the base dir.
"""
if (not isinstance(value, str) or not value
or value in ('.', '..')
or '/' in value or '\\' in value or '\0' in value
or os.path.isabs(value)
or os.path.basename(value) != value):
raise CpApiTypeError(funcname='cpinfo',
supportedtypes=f'a safe {kind} (no path separators or traversal)',
received_type='unsafe path component')
def cpinfo(cpuser=None, keyls=('cplogin', 'package', 'mail', 'reseller', 'dns', 'locale'),
_cpanel_users_dir=CPANEL_USERS_DIR, quiet=True, search_sys_users=True):
returned = []
if isinstance(cpuser, str):
cpusers_list = [cpuser]
elif isinstance(cpuser, (list, tuple)):
cpusers_list = tuple(cpuser)
elif cpuser is None:
cpusers_list = cpusers(_userplans_file=CPANEL_USERPLANS_PATH)
else:
raise CpApiTypeError(funcname='cpinfo', supportedtypes='str|unicode|list|tuple',
received_type=type(cpuser).__name__)
for cpuser in cpusers_list:
_validate_path_component(cpuser, kind='cpuser')
user_config_path = os.path.join(_cpanel_users_dir, cpuser)
if not os.path.exists(user_config_path):
if not quiet:
sys.stderr.write(f'WARNING: Can not load data to the user "{cpuser}"; '
'Perhaps there is no such user in cPanel')
continue
# load_fast rather than clconfpars.load (CLOS-6868): these files are flat
# KEY=value, and building a ConfigParser for each one costs 0.284 ms
# against 0.032 ms for a split — 8.9x. cpinfo reads one per account, so
# on a 3,868-account host that is 1.10 s of `lvectl apply all` instead
# of 0.12 s.
#
# lower_keys reproduces ConfigParser's default optionxform, which the
# USERCONF_PARAM_MAP lookups below rely on; skip_comments reproduces its
# comment handling. Verified equivalent to load() across all 3,868
# user files on a test host, with zero differing keys or values.
#
# Bad bytes are still tolerated: load_fast reads with
# errors="surrogateescape", which preserves them losslessly where
# ignore_bad_encoding's "replace" substituted them (LVEMAN-1150).
cpuser_data = load_fast(user_config_path, lower_keys=True,
skip_comments=True)
user_data = []
for data_key in keyls:
data = cpuser_data.get(USERCONF_PARAM_MAP.get(data_key))
# USERCONF_PARAM_MAP.get('cplogin') == user
# check if user tag in user config and if tag is missing - use file name as user name
if data_key == 'cplogin' and data is None:
data = os.path.basename(user_config_path)
user_data.append(data)
returned.append(tuple(user_data))
if 'mail' in keyls: # checking the presence of an additional e-mail
additional_mail = cpuser_data.get('contactemail2')
if additional_mail:
user_data[list(keyls).index('mail')] = additional_mail
user_data_tuple = tuple(user_data)
if user_data_tuple not in returned:
returned.append(tuple(user_data))
return tuple(returned)
def get_admin_email(_conf1=None, _conf2=None, _hostname=None):
"""
:param str|None _conf1: for testing
:param str|None _conf2: for testing
:param str|None _hostname: for testing
:return:
"""
# 1. Try to get admin email from /etc/sysconfig/cloudlinux
lines = []
try:
lines = get_file_lines(_conf1 or SYSCONF_CLOUDLINUX_PATH)
except (OSError, IOError):
pass
for line in lines:
if line.startswith('EMAIL'):
parts = line.split('=')
if len(parts) == 2 and '@' in parts[1].strip():
return parts[1].strip()
# 2. Try to get admin email from /etc/wwwacct.conf
lines = []
try:
lines = get_file_lines(_conf2 or CPANEL_ACCT_CONF_PATH)
except (OSError, IOError):
pass
host = ''
for line in lines:
if line.startswith('CONTACTEMAIL'):
s = line.replace('CONTACTEMAIL', '').strip()
if s:
return s
if line.startswith('HOST'):
s = line.replace('HOST', '').strip()
if s:
host = s
if host:
return 'root@' + host
# Admin email not found in system files, use common address
from clcommon.cpapi.plugins.universal import get_admin_email # pylint: disable=import-outside-toplevel
return get_admin_email(_hostname=_hostname)
def _parse_userdatadomains(_path, parser, quiet=True):
if '{user}' in _path:
call_as_user = pwd.getpwuid(os.geteuid()).pw_name
_path = _path.replace('{user}', call_as_user)
path_list = _path.split(';')
for path_ in path_list:
if not os.path.exists(path_):
continue
try:
with open(path_, encoding='utf-8') as stream:
# example line:
# test.russianguns.ru: russianguns==root==sub==
# russianguns.ru==/home/russianguns/fla==192.168.122.40:80======0
for i, line in enumerate(stream):
if not line.strip(): # ignore the empty string
continue
if line.count(': ') != 1:
if not quiet:
sys.stderr.write(f'Can\'t parse {i} line in file "{path_}"; line was ignored\n')
continue
domain, domain_raw_data = line.split(': ')
domain_data = domain_raw_data.strip().split('==')
parser(path_, domain, domain_data)
except IOError as e:
if not quiet:
sys.stderr.write(f"Can't open file {path_} [{e}]\n")
continue
def _parse_userdataaliases(_path, quiet=True):
path_list = _path.split(';')
aliases = []
for path_ in path_list:
if not os.path.exists(path_):
continue
try:
with open(path_, encoding='utf-8') as stream:
# example line:
# test.russianguns.ru: russianguns==root==sub==
# russianguns.ru==/home/russianguns/fla==192.168.122.40:80======0
for i, line in enumerate(stream):
if not line.strip(): # ignore the empty string
continue
if "serveralias" not in line:
continue
aliases += line.replace("serveralias: ", '').strip().split(' ')
except IOError as e:
if not quiet:
sys.stderr.write(f"Can't open file {path_} [{e}]\n")
continue
return aliases
def useraliases(cpuser, domain, _path=CPANEL_USERDATAFOLDER_PATH, quiet=True):
# use dict to avoid duplicates
_validate_path_component(cpuser, kind='cpuser')
_validate_path_component(domain, kind='domain')
if '{user}' in _path:
_path = _path.replace('{user}', cpuser)
_path = os.path.join(_path, domain)
aliases = _parse_userdataaliases(_path, quiet=quiet)
return list(aliases)
def docroot(domain, _path=CPANEL_USERDATADOMAINS_PATH, quiet=True):
domain = domain.strip()
pathes = set()
result = {'docroot_path': None, 'user': None}
def parser(path, d, domain_data):
pathes.add(path)
if d == domain:
result['docroot_path'] = domain_data[4]
result['user'] = domain_data[0]
_parse_userdatadomains(_path, parser, quiet=quiet)
if not (result['docroot_path'] is None or result['user'] is None):
return result['docroot_path'], result['user']
watched = '; '.join(
['Can\'t find record "%(d)s" in file "%(p)s"' % {'d': domain, 'p': p} for p in pathes]
)
raise NoDomain(f"Can't obtain document root for domain '{domain}'; {watched}")
# User name to domain cache
# Example:
# { 'user1': [('user1.org', '/home/user1/public_html'),
# ('mk.user1.com.user1.org', '/home/user1/public_html/www.mk.user1.com')] }
_user_to_domains_map_cpanel = {}
def userdomains(cpuser, _path=CPANEL_USERDATADOMAINS_PATH, quiet=True):
# If user present in cache, take data from it
if cpuser in _user_to_domains_map_cpanel:
return _user_to_domains_map_cpanel[cpuser]
# use dict to avoid duplicates
domains_tmp = OrderedDict()
domains = OrderedDict()
def parser(path, d, domain_data):
user_ = domain_data[0]
document_root = domain_data[4]
# Update cache
if user_ in _user_to_domains_map_cpanel:
user_data = _user_to_domains_map_cpanel[user_]
else:
user_data = []
if 'main' == domain_data[2]:
# insert main domain to 1st position in list
if (d, document_root) not in user_data:
user_data.insert(0, (d, document_root))
else:
if (d, document_root) not in user_data:
user_data.append((d, document_root))
_user_to_domains_map_cpanel[user_] = user_data
if user_ == cpuser:
if 'main' == domain_data[2]:
# main domain must be first in list
domains.update({d: document_root})
else:
domains_tmp.update({d: document_root})
_parse_userdatadomains(_path, parser, quiet=quiet)
domains.update(domains_tmp)
return list(domains.items())
def _load_main_user_file(cpuser: str, _path=CPANEL_USERDATAFOLDER_PATH, quiet=True) -> dict:
"""
Private helper to load and parse the main file for a given user.
E.g. `/var/cpanel/userdata/{user}/main`
:param cpuser: The cPanel username.
:param _path: A path template containing the "{user}" placeholder.
:param quiet: If False, errors are printed.
:return: The parsed YAML data as a dictionary.
:raises ParsingError: If an error occurs while parsing.
"""
_validate_path_component(cpuser, kind='cpuser')
if '{user}' in _path:
_path = _path.replace('{user}', cpuser)
main_file = os.path.join(_path, "main")
try:
with open(main_file, "rt", encoding="utf-8") as f:
data = yaml.safe_load(f)
return data
except IOError as e:
if not quiet:
sys.stderr.write(f"Can't open file {main_file} [{e}]\n")
return {}
except yaml.YAMLError as e:
raise ParsingError(str(e)) from e
def useraddondomains(cpuser: str, _path=CPANEL_USERDATAFOLDER_PATH, quiet=True) -> dict:
"""
Return all addon domains for the given cPanel user.
It loads the user's main file at `/var/cpanel/userdata/{user}/main` and
parses the YAML content, and then extracts the addon_domains mapping.
:param cpuser: The cPanel username.
:param _path: The path template for the userdata folder
:param quiet: If False, errors might be printed.
:return: A dict mapping addon domain names to their server names.
For example:
{
"aurapoolshop.com.au": "aurapoolshop.thepoolandyardman.com.au"
}
:raises ParsingError: If the main file cannot be parsed.
"""
main_data = _load_main_user_file(cpuser, _path=_path, quiet=quiet)
return main_data.get("addon_domains", {})
def domain_owner(domain, _path=CPANEL_USERDATADOMAINS_PATH, quiet=True):
users_list = []
def parser(path, d, domain_data):
if d == domain:
users_list.append(domain_data[0])
_parse_userdatadomains(_path, parser, quiet=quiet)
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 homedirs(_sysusers=None, _conf_path = CPANEL_ACCT_CONF_PATH):
"""
Detects and returns list of folders contained the home dirs of users of the cPanel
:param str|None _sysusers: for testing
:param str|None _conf_path: for testing
:return: list of folders, which are parent of home dirs of users of the panel
"""
HOMEDIR = 'HOMEDIR '
HOMEMATCH = 'HOMEMATCH '
homedirs = []
users_homedir = ''
users_home_match = ''
if os.path.exists(_conf_path):
lines = get_file_lines(_conf_path)
for line in lines:
if line.startswith(HOMEDIR):
users_homedir = line.split(HOMEDIR)[1].strip()
elif line.startswith(HOMEMATCH):
users_home_match = line.split(HOMEMATCH)[1].strip()
if users_homedir:
homedirs.append(users_homedir)
clpwd = ClPwd()
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_data in users_dict.values():
userdir = user_data.pw_dir
if os.path.exists(userdir + '/public_html') or os.path.exists(userdir + '/www'):
homedir = os.path.dirname(userdir)
if users_home_match and homedir.find('/'+users_home_match) == -1:
continue
if homedir not in homedirs:
homedirs.append(homedir)
return homedirs
def _reseller_users_parser(json_string):
try:
json_serialized = json.loads(json_string)
result = json_serialized['result']
return [item['user'] for item in result['data']]
except (KeyError, ValueError, TypeError) as e:
raise ParsingError(str(e)) from e
def _reseller_users_json(reseller_name=None):
"""
Call UAPI and get json string;
:type reseller_name: str | None
:raises: ParsingError, CPAPIExternalProgramFailed
:rtype: str
"""
reseller_name = reseller_name or getuser()
# Attention!! /usr/bin/uapi utility may works unstable. See PTCLLIB-95 for details
cmd = [UAPI, 'Resellers', 'list_accounts', '--output=json']
# root user MUST specify --user, and reseller CAN'T do that
if reseller_name != getuser() or getuser() == "root":
cmd.append(f'--user={urllib.parse.quote(reseller_name)}')
try:
json_string = run_command(cmd=cmd, return_full_output=True)[1] # take only std_out, ignore std_err
except ExternalProgramFailed as e:
raise CPAPIExternalProgramFailed(str(e)) from e
return json_string
def reseller_users(resellername):
"""
Return reseller users
:param resellername: reseller name; return empty list if None
:return list[str]: user names list
"""
# Attention!! /usr/bin/uapi utility may works unstable. See PTCLLIB-95 for details
# json_string = _reseller_users_json(resellername)
# return _reseller_users_parser(json_string)
# So we read reseller's user list from /etc/trueuserowners
# /etc/trueuserowners example:
# #userowners v1
# cltest1: root
# res: res
# res1: root
# res2: root
# res2user1: res2
# res2usr1: res2
# resnew: resnew
# resnew1: resnew1
# rn1user1: resnew1
if resellername is None:
return []
result = []
userowner_file_data = get_file_lines(CPANEL_USEROWNERS_FILE)
if resellername is not None:
for line in grep(rf'\: {resellername}$',
fixed_string=False,
match_any_position=True,
multiple_search=True,
data_from_file=userowner_file_data):
splitted_line = line.strip().split(': ')
result.append(splitted_line[0])
return result
def _reseller_user_domains_parser(json_string):
try:
json_serialized = json.loads(json_string)
result = json_serialized['result']
users_data = {}
for item in result['data']:
users_data[item['user']] = item['domain']
return users_data
except (KeyError, ValueError, TypeError) as e:
raise ParsingError(str(e)) from e
def reseller_domains(reseller_name=None):
"""
Get dict[user, domain]
Attention!! This function may work unstable. See PTCLLIB-95 for details.
:param reseller_name: reseller's name
:rtype: dict[str, str|None]
:raises DomainException: if cannot obtain domains
"""
json_string = _reseller_users_json(reseller_name)
return _reseller_user_domains_parser(json_string)
def get_user_login_url(domain):
return f'http://{domain}:2083'
def is_no_php_binaries_on_cpanel():
"""
Checks that there are no installed php binaries
only for cpanel
"""
try:
WhmApiRequest('php_get_installed_versions').call()
except WhmNoPhpBinariesError:
return True
return False
class PanelPlugin(GeneralPanelPluginV1):
DEFAULT_LOCALE = 'en'
BAD_CODING_ERROR_CODE = 48
HTTPD_CONFIG_FILE = '/etc/apache2/conf/httpd.conf'
# cPanel publishes Apache's server-status handler at a randomized