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 absolute_import
from __future__ import print_function
from __future__ import division
import fcntl
import pwd
import syslog
from datetime import datetime
from future.utils import iteritems
from future.moves import configparser as ConfigParser
import io
import logging
import os
import re
import subprocess
from clcommon import clcaptain, utils
from clcommon.cpapi import userdomains
from clcommon.utils import get_file_system_in_which_file_is_stored_on
from clcommon.utils import get_file_lines, write_file_lines
from clcommon.utils import mod_makedirs
from clquota import QuotaWrapper, NoSuchUserException, InsufficientPrivilegesException, IncorrectLimitFormatException, \
GeneralException, NoSuchPackageException, QuotaDisabledException
from lveapi import PyLve, PyLveError
from secureio import set_user_perm, set_root_perm
from typing import Dict, Union # NOQA
from .clselectexcept import ClSelectExcept
from .utils import file_readlines, file_write, s_partition
from .utils import get_abs_rel, mkdir_p, file_read, file_writelines
from .utils import get_using_realpath_keys, realpaths_are_equal
# logger for clpassenger module
logger = logging.getLogger(__name__)
logger.setLevel(logging.ERROR)
# disable output of logs to console
null_handler = logging.StreamHandler(open('/dev/null', 'w'))
logger.addHandler(null_handler)
HTACCESS_BEGIN = '# DO NOT REMOVE. CLOUDLINUX PASSENGER CONFIGURATION BEGIN'
HTACCESS_END = '# DO NOT REMOVE. CLOUDLINUX PASSENGER CONFIGURATION END'
RACK_PATH = 'config.ru'
RACK_TEMPLATE = r'''app = proc do |env|
message = "It works!\n"
version = "Ruby %s\n" % RUBY_VERSION
response = [message, version].join("\n")
[200, {"Content-Type" => "text/plain"}, [response]]
end
run app
'''
RESTART_PATH = 'tmp/restart.txt'
WSGI_PATH = 'passenger_wsgi.py'
WSGI_TEMPLATE = r'''import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
def application(environ, start_response):
start_response('200 OK', [('Content-Type', 'text/plain')])
message = 'It works!\n'
version = 'Python %s\n' % sys.version.split()[0]
response = '\n'.join([message, version])
return [response.encode()]
'''
APPJS_PATH = 'app.js'
APPJS_TEMPLATE = r'''var http = require('http');
var server = http.createServer(function(req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
var message = 'It works!\n',
version = 'NodeJS ' + process.versions.node + '\n',
response = [message, version].join('\n');
res.end(response);
});
server.listen();
'''
def drop_root_perm(user):
userpwd = pwd.getpwnam(user)
set_user_perm(userpwd.pw_uid, userpwd.pw_gid, exit = False)
def get_config_lock(config_path, mode):
try:
conf_file = open(config_path, mode, errors='surrogateescape')
fcntl.flock(conf_file.fileno(), fcntl.LOCK_EX)
return conf_file
except IOError:
return None
def release_lock(lock_file):
try:
lock_file.close()
except:
pass
def write_config(user, config_path, config):
"""
Write config with locking.
Drop permissions if method called as root.
"""
permissions_dropped = False
# we must drop & restore permissions only when we
# call this method as root, otherwise we can get unexpected
# behavior when we drop permission on higher level,
# and restore them here, in deep tree of method calls
if os.getegid() == 0 or os.geteuid() == 0:
drop_root_perm(user)
permissions_dropped = True
config_file = None
try:
check_and_createdir(config_path)
config_file = get_config_lock(config_path, 'r')
file_content = io.StringIO()
config.write(file_content)
clcaptain.write(config_path, file_content.getvalue())
# clcaptain raises exception from clcommon.utils.ExternalProgramFailes, IOError
# exception ClSelectExcept.ExternalProgramFailed has the same name but different
except (IOError, OSError, ClSelectExcept.UnableToSaveData, utils.ExternalProgramFailed) as e:
syslog.syslog(syslog.LOG_WARNING,
"Can't write {}: {}".format(config_path, e))
finally:
release_lock(config_file)
if permissions_dropped:
set_root_perm(exit=False)
def check_and_createdir(path):
user_backup_path = os.path.dirname(path)
if not os.path.isdir(user_backup_path):
try:
clcaptain.mkdir(user_backup_path)
# clcaptain raises exception from clcommon.utils.ExternalProgramFailes
# exception ClSelectExcept.ExternalProgramFailed has the same name but different
except (OSError, ClSelectExcept.ExternalProgramFailed, utils.ExternalProgramFailed) as e:
raise ClSelectExcept.UnableToSaveData(user_backup_path, e)
def get_htaccess_cache_path(user):
userpwd = pwd.getpwnam(user)
return os.path.join(userpwd.pw_dir, '.cl.selector', 'htaccess_cache')
def _get_info_about_htaccess_cache_file(path_to_file):
# type: (str) -> Dict
"""
Get info (stat, first n symbols and file system in which file is stored)
about htaccess_cache file
"""
time_format = '%Y-%m-%d %H:%M:%S'
number_of_symbols = 100
file_info = {}
if os.path.exists(path_to_file):
try:
file_stat = os.stat(path_to_file)
file_info['file_size'] = file_stat.st_size
file_info['gid'] = file_stat.st_gid
file_info['uid'] = file_stat.st_uid
file_info['permissions'] = oct(file_stat.st_mode)
file_info['last_access'] = datetime.fromtimestamp(file_stat.st_atime).strftime(time_format)
file_info['last_modification'] = datetime.fromtimestamp(file_stat.st_mtime).strftime(time_format)
# Not necessary to read file and get file system if it's empty
if file_info['file_size'] == 0:
return file_info
try:
with open(path_to_file, 'r') as f:
file_info['first_symbols'] = f.read(number_of_symbols)
# move to n symbol from end file
f.seek(-number_of_symbols, 2)
file_info['last_symbols'] = f.read(number_of_symbols)
except (OSError, IOError) as err:
file_info['error'] = 'We cannot get first and last %s symbols from "%s" file. Exception: %s' % (
number_of_symbols,
path_to_file,
err,
)
file_info['file_system'] = get_file_system_in_which_file_is_stored_on(path_to_file)['details']
except (OSError, IOError) as err:
file_info['error'] = 'We cannot get info about "%s" file. Exception: %s' % (
path_to_file,
err,
)
return file_info
def _get_user_lve_limits(user_uid):
# type: (int) -> Union[Dict[int, int, int, int, int, int, int], Dict[str]]
"""
Getting user lve limits for logging those for next debug
"""
result = dict()
try:
py_lve = PyLve()
py_lve.initialize()
user_limits = py_lve.lve_info(user_uid)
result['cpu'] = user_limits.ls_cpu / user_limits.ls_cpu_weight
result['pmem'] = user_limits.ls_memory_phy
result['vmem'] = user_limits.ls_memory
result['io'] = user_limits.ls_io
result['iops'] = user_limits.ls_iops
result['ep'] = user_limits.ls_enters
result['nproc'] = user_limits.ls_nproc
except PyLveError as err:
result['error'] = 'We cannot get lve limits for user with uid "%s". Exception: %s' % (
user_uid,
err,
)
return result
def _get_user_quota_limits(user_uid):
# type: (int) -> Union[Dict[str, str, str], Dict[str]]
"""
Getting user quota limits for logging those for next debug
"""
result = dict()
user_uid = str(user_uid)
try:
quota_wrapper = QuotaWrapper()
user_quotas = quota_wrapper.get_user_limits(user_uid)[user_uid]
result = user_quotas
except (
NoSuchUserException,
NoSuchPackageException,
InsufficientPrivilegesException,
GeneralException,
IncorrectLimitFormatException,
QuotaDisabledException,
IOError,
OSError
) as err:
result['error'] = 'We cannot get quota limits for user with uid "%s". Exception: %s' % (
user_uid,
err,
)
return result
def _log_debug_info_about_user_and_config_file(user, config_path, error):
# type: (str, str, Exception) -> None
"""
Logging info (lve & quota limits) about user and
info (stat info, first & last n symbols) about config file
"""
file_info = _get_info_about_htaccess_cache_file(config_path)
debug_info = dict()
debug_info['config_file_info'] = file_info
debug_info['user_info'] = dict()
try:
user_uid = pwd.getpwnam(user).pw_uid
except KeyError as err:
debug_info['user_info']['error'] = 'User "%s" does not exists. Exception: %s' % (
user,
err,
)
user_uid = None
if user_uid is not None:
debug_info['user_info']['lve_limits'] = dict()
debug_info['user_info']['lve_limits'].update(_get_user_lve_limits(user_uid))
debug_info['user_info']['quota_limits'] = dict()
debug_info['user_info']['quota_limits'].update(_get_user_quota_limits(user_uid))
logger.exception(error, exc_info=True, extra=debug_info)
def read_config(user):
config = ConfigParser.RawConfigParser(strict=False)
config_path = get_htaccess_cache_path(user)
config_file = get_config_lock(config_path, 'r')
if config_file is not None:
try:
config.readfp(config_file)
# LU-1035
except (IOError, OSError) as err:
# Logging additional information for next debug
_log_debug_info_about_user_and_config_file(user, config_path, err)
# LU-1032
except (ConfigParser.ParsingError, ConfigParser.MissingSectionHeaderError):
_unlink(config_path)
syslog.syslog(syslog.LOG_WARNING,
"Config {} is broken.".format(config_path))
# if cought ParsingError - return Empty config
config = ConfigParser.RawConfigParser(strict=False)
finally:
release_lock(config_file)
return config, config_path
def get_htaccess_cache(user, doc_root):
config, _ = read_config(user)
if config.has_section(doc_root):
try:
htaccess_list = config.get(doc_root, 'htaccess_list').split(',')
return htaccess_list
except ConfigParser.NoOptionError:
return None
return None
def write_htaccess_cache(user, doc_root, data):
data = data.split('\n')
data = list(filter(bool, data))
config, config_path = read_config(user)
if not config.has_section(doc_root):
config.add_section(doc_root)
config.set(doc_root, 'htaccess_list', ','.join(data))
write_config(user, config_path, config)
def update_htaccess_cache(user, path_to_file, doc_root):
config, config_path = read_config(user)
if config.has_section(doc_root):
htaccess_list = config.get(doc_root, 'htaccess_list').split(',')
else:
config.add_section(doc_root)
config.set(doc_root, 'htaccess_list', '')
htaccess_list = []
if path_to_file not in htaccess_list:
htaccess_list.append(path_to_file)
htaccess_list = list(filter(bool, htaccess_list))
config.set(doc_root, 'htaccess_list', ','.join(htaccess_list))
write_config(user, config_path, config)
def remove_passenger_lines_from_htaccess(htaccess_filename):
"""
Removes clpassenger lines from .htaccess to stop application
:param htaccess_filename: Application .htaccess path
:return: None
"""
lines = file_readlines(htaccess_filename, errors='surrogateescape')
new_lines = []
in_config = False
for line in lines:
if line.startswith(HTACCESS_BEGIN):
in_config = True
if line.startswith(HTACCESS_END):
in_config = False
continue
if not in_config:
new_lines.append(line)
# write new .htaccess
new_lines = rm_double_empty_lines(new_lines)
file_writelines(htaccess_filename, new_lines, 'w', errors='surrogateescape')
def configure(user, directory, alias, interpreter, binary, populate=True,
action=None, doc_root=None, startup_file=APPJS_PATH, passenger_log_file=None):
"""
Configure passenger application
:param user: name of unix user
:param directory: name of dir in user home
:param alias: alias of application
:param interpreter: interpreter which execute application
:param binary: binary of interpreter that execute application
:param populate: True if application have to be be populated
:param action: action with apllication. can be transit or None
:param doc_root: doc_root
:param startup_file: start application file
:param passenger_log_file: Passenger log filename to write to app's .htaccess
:return: None
"""
abs_dir, _ = get_abs_rel(user, directory)
if os.path.exists(abs_dir) and not os.path.isdir(abs_dir):
raise ClSelectExcept.WebAppError(
'Destination exists and it is not a directory')
if interpreter not in ('python', 'ruby', 'nodejs'):
raise ClSelectExcept.InterpreterError(
"Unsupported interpreter ('%s')" % interpreter)
user_summary = summary(user)
try:
app_summary = get_using_realpath_keys(user, directory, user_summary)
except KeyError:
if doc_root is None:
raise ClSelectExcept.NoSuchApplication(
'No such application (or application not configured) "%s"' % directory)
else:
if action != 'transit':
exists_dir = app_summary['directory']
raise ClSelectExcept.WebAppError("Specified directory already used by '%s'" % exists_dir)
if not doc_root:
doc_root = app_summary['docroot']
# Alias, which is empty, means that user passed uri equaled to doc root
# and we don't want to normalize the alias, because normalized empty
# alias is point and that alias doesn't work in htaccess
if alias != '':
alias = os.path.normpath(alias)
abs_alias, _ = get_abs_rel(user, os.path.join(doc_root, alias))
htaccess = os.path.join(abs_alias, '.htaccess')
htaccess_needs_update = True
if os.path.exists(htaccess):
htaccess_raw = file_read(htaccess, errors='surrogateescape')
if HTACCESS_BEGIN in htaccess_raw:
for item in user_summary.values():
# The condition allows to detect common part of aliases
# For details see commit message
item_alias = os.path.normpath(item['alias']) + os.sep
if os.path.dirname(os.path.commonprefix([item_alias, alias + os.sep])) != '':
exists_dir = item['directory']
if exists_dir != abs_dir:
raise ClSelectExcept.WebAppError(
"Specified alias is already used by the other "
"application: '%s'. Please, specify another application url." % exists_dir)
else:
# Do not write to .htaccess, it is already correct
htaccess_needs_update = False
lines = htaccess_raw.splitlines()
else:
lines = []
if htaccess_needs_update:
lines.append('')
lines.append(HTACCESS_BEGIN)
lines.append('PassengerAppRoot "%s"' % abs_dir)
lines.append('PassengerBaseURI "/%s"' % alias)
lines.append('Passenger%s "%s"' % (interpreter.title(), binary))
# for some reason autodetect of `app.js` is not working
if interpreter == 'nodejs':
lines.append('PassengerAppType node')
lines.append('PassengerStartupFile %s' % startup_file)
# append PassengerAppLogFile directive if need
if passenger_log_file and interpreter in ('python', 'nodejs'):
lines.append('PassengerAppLogFile "%s"' % passenger_log_file)
lines.append(HTACCESS_END)
lines = rm_double_empty_lines(lines)
mkdir_p(abs_alias)
file_writelines(htaccess, ('%s\n' % line for line in lines), errors='surrogateescape')
update_htaccess_cache(user, htaccess, doc_root)
if populate:
# Also creates startup_file
populate_app(user, directory, interpreter, startup_file=startup_file)
def fix_homedir(user):
for domain_alias, data in iteritems(_summary(user)):
_, alias = domain_alias
old_home = os.path.commonprefix((data['directory'], data['binary']))
_, _, directory = s_partition(data['directory'], old_home)
# old python selector has binary as file
# and get_abs_rel does realpath()
# while new selector binary is symlink
# and realpath works wrong
binary_dir = os.path.dirname(data['binary'])
binary_name = os.path.basename(data['binary'])
_, _, _binary = s_partition(binary_dir, old_home)
binary = os.path.join(get_abs_rel(user, _binary)[0], binary_name)
htaccess_path = data['htaccess']
_unconfigure(htaccess_path)
configure(user, directory, alias, data['interpreter'], binary, doc_root=data['docroot'])
def move(user, directory, old_alias, new_alias, old_doc_root=None, new_doc_root=None):
app_data = get_using_realpath_keys(user, directory, summary(user))
old_doc_root = old_doc_root or app_data['docroot']
new_doc_root = new_doc_root or old_doc_root
old_abs_alias = os.path.join(old_doc_root, old_alias)
old_htaccess = os.path.join(old_abs_alias, '.htaccess')
new_abs_alias = os.path.join(new_doc_root, new_alias)
new_htaccess = os.path.join(new_abs_alias, '.htaccess')
if not realpaths_are_equal(user, old_htaccess, new_htaccess):
_unconfigure(old_htaccess)
lines = file_readlines(old_htaccess, errors='surrogateescape')
open(old_htaccess, 'w').close()
file_writelines(new_htaccess, lines, 'a', errors='surrogateescape')
update_htaccess_cache(user, new_htaccess, new_doc_root)
def purge(user):
for directory in summary(user):
unconfigure(user, directory)
def populate_app(user, directory, interpreter, startup_file=APPJS_PATH):
"""
Populate application
:param user: name of unix user
:param directory: application path in user's home
:param interpreter: interpreter which run application
:param startup_file: main application file
:return: None
"""
abs_dir, rel_dir = get_abs_rel(user, directory)
app_public = os.path.join(abs_dir, 'public')
app_tmp = os.path.join(abs_dir, 'tmp')
mkdir_p(app_public)
mkdir_p(app_tmp)
app_configru = os.path.join(abs_dir, RACK_PATH)
app_wsgi = os.path.join(abs_dir, WSGI_PATH)
app_js = os.path.join(abs_dir, startup_file)
configru_installed = os.path.isfile(app_configru)
wsgi_installed = os.path.isfile(app_wsgi)
appjs_installed = os.path.isfile(app_js)
if configru_installed:
configru_unchanged = file_read(app_configru) == RACK_TEMPLATE
if wsgi_installed:
wsgi_unchanged = file_read(app_wsgi) == WSGI_TEMPLATE
if appjs_installed:
appjs_unchanged = file_read(app_js) == APPJS_TEMPLATE
if interpreter == 'python':
if not wsgi_installed:
file_write(app_wsgi, WSGI_TEMPLATE)
if configru_installed and configru_unchanged:
_unlink(app_configru)
_unlink(app_js)
elif interpreter == 'ruby':
if not configru_installed:
file_write(app_configru, RACK_TEMPLATE, 'w')
if wsgi_installed and wsgi_unchanged:
_unlink(app_wsgi)
_unlink(app_js)
elif interpreter == 'nodejs':
if not appjs_installed:
# add ability to specify startup path
# like 'not/existing/subdir/app.js'
dir_path = os.path.dirname(app_js)
if not os.path.isdir(dir_path):
mod_makedirs(dir_path, 0o755)
file_write(app_js, APPJS_TEMPLATE)
if appjs_installed and appjs_unchanged:
_unlink(app_configru)
_unlink(app_wsgi)
restart(user, directory)
def _unlink(path):
try:
os.unlink(path)
except OSError:
pass
def _find_htaccess_files(doc_root):
p = subprocess.Popen(['/bin/find', doc_root, '-name', '.htaccess'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
# Process each line as bytes and attempt decoding to UTF-8
clean_lines = []
for line in p.stdout:
try:
decoded_line = line.decode('utf-8')
clean_lines.append(decoded_line.strip())
except UnicodeDecodeError:
# Skip lines that cannot be decoded to UTF-8
continue
return '\n'.join(clean_lines)
def _summary(user, userdomains_data=None):
# TODO PTCLLIB-132
# it's using cache info about users domains
# this mechanism should be removed after implementing of caching on DA for userdomains cpapi method
domain_docroot_pairs = userdomains(user) if userdomains_data is None else userdomains_data
domain_alias_docroot = []
for domain, doc_root in domain_docroot_pairs:
if doc_root is None:
continue
htaccess_cache = get_htaccess_cache(user, doc_root)
if not htaccess_cache:
stdoutdata = _find_htaccess_files(doc_root)
write_htaccess_cache(user, doc_root, stdoutdata)
htaccess_cache = get_htaccess_cache(user, doc_root)
if htaccess_cache is None: # if write_htaccess_cache was unsuccessful, we would still get None here
continue
for ht_path in htaccess_cache:
if ht_path:
alias = os.path.dirname(ht_path)
domain_alias_docroot.append((domain, alias, doc_root))
return _htaccess_summary(user, domain_alias_docroot)
def _htaccess_summary(user, domain_alias_docroot):
summ = {}
for domain, alias, doc_root in domain_alias_docroot:
htaccess = os.path.join(alias, '.htaccess')
try:
htaccess_raw = file_read(htaccess, errors='surrogateescape')
except (IOError, OSError):
continue
approot = re.search(
'^PassengerAppRoot\s+"?(?P<directory>.+?)"?$',
htaccess_raw, re.MULTILINE)
if not approot:
continue
interpreter = re.search(
'^Passenger(?P<interpreter>Python|Ruby|Nodejs)\s+'
'"?(?P<binary>.+?)"?$',
htaccess_raw, re.MULTILINE)
if not interpreter:
continue
alias_abs, _ = get_abs_rel(user, alias)
doc_root_abs, _ = get_abs_rel(user, doc_root)
_, _, alias = s_partition(alias_abs, doc_root_abs)
alias = alias.lstrip(os.sep)
domain_alias = (domain, alias,)
# detect what alias belongs domain
appuri = re.search('^PassengerBaseURI\s+"?(?P<appuri>.+?)"?$',
htaccess_raw, re.MULTILINE)
if appuri and not compare_aliases(appuri.groupdict()['appuri'], alias):
continue
summ[domain_alias] = {
'htaccess': htaccess,
'domain': domain,
'docroot': doc_root,
'directory': approot.groupdict()['directory'],
'interpreter': interpreter.groupdict()['interpreter'].lower(),
'binary': interpreter.groupdict()['binary'],
}
return summ
def compare_aliases(alias1, alias2):
return os.path.normpath(alias1.strip('/')) == os.path.normpath(alias2.strip('/'))
#FIXME: Need join/rewrite "summary" and "_summary" functions
def summary(user, userdomains_data=None):
summ_result = {}
for domain_alias, value in iteritems(_summary(user, userdomains_data=userdomains_data)):
domain, alias = domain_alias
app_root = value['directory']
try:
_, directory = get_abs_rel(user, app_root)
except ClSelectExcept.WrongData:
syslog.syslog(
syslog.LOG_WARNING,
'{} is broken, directory {} is not in user\'s home.'.format(
os.path.join(alias, '.htaccess'),
app_root
))
continue
value['alias'] = alias
try:
app_summary = get_using_realpath_keys(user, directory, summ_result)
except KeyError:
value['domains'] = [domain]
summ_result[directory] = value
else:
# add domains key if directory has multiple domains
if 'domains' not in app_summary:
app_summary['domains'] = []
else:
app_summary['domains'].append(domain)
return summ_result
def unconfigure(user, directory):
app_data = get_using_realpath_keys(user, directory, summary(user))
htaccess = app_data['htaccess']
_unconfigure(htaccess)
def _unconfigure(htaccess):
htaccess_raw = file_read(htaccess, errors='surrogateescape')
lines = htaccess_raw.splitlines()
new_lines = []
in_config = False
for line in lines:
if line == HTACCESS_BEGIN:
in_config = True
continue
if line == HTACCESS_END:
in_config = False
continue
if in_config:
continue
new_lines.append(line)
lines = rm_double_empty_lines(new_lines)
file_writelines(htaccess, ('%s\n' % line for line in lines), 'w', errors='surrogateescape')
def iter_path(root, sub):
for p in sub.split(os.sep):
root = os.path.join(root, p)
yield root
def restart(user, directory):
abs_dir, _ = get_abs_rel(user, directory)
if not os.path.exists(abs_dir):
raise ClSelectExcept.MissingApprootDirectory("Missing directory %(abs_dir)s" % {'abs_dir': abs_dir})
tmp_dir = os.path.join(abs_dir, 'tmp')
if not os.path.exists(tmp_dir):
os.mkdir(tmp_dir)
app_restart = os.path.join(abs_dir, RESTART_PATH)
# imitation system 'touch'
if os.path.exists(app_restart):
os.utime(app_restart, None)
else:
open(app_restart, 'a').close()
def rm_double_empty_lines(lines):
_lines = []
empty_line = True
for line in lines:
if line.strip():
empty_line = False
elif empty_line:
continue
else:
empty_line = True
_lines.append(line)
if empty_line:
return _lines[:-1]
return _lines
Zerion Mini Shell 1.0