# -*- 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 absolute_import
from __future__ import division
import json
import os
import sys
import uuid
import signal
import secureio
from future.moves import configparser as ConfigParser
from stat import S_IRUSR, S_IWUSR, S_IRGRP, S_IROTH, S_ISREG
from future.utils import iteritems
from pathlib import Path
import psutil
from .clselect import ClSelect
from .clselectexcept import ClSelectExcept
from clcommon import ClPwd, clcaptain
from .clselectprint import clprint
from . import utils
from clcommon import clcagefs
from clcommon.utils import ExternalProgramFailed
from clcommon.cpapi import docroot, domain_owner, userdomains
from clcommon.cpapi.cpapiexceptions import NoDomain, NoPanelUser, NotSupported
try:
from clcagefslib.const import BASEDIR
from clcagefslib.fs import get_user_prefix
from clcagefslib.selector.configure import is_ea4_enabled, read_cpanel_ea4_php_conf, configure_alt_php
from clcagefslib.selector.panel.da import da_change_user_php_ini
except ImportError:
# clcagefslib is absent in the offline unit-test environment (and any
# non-cagefs context). Provide a safe fallback for is_ea4_enabled so the
# cPanel-MultiPHP configuration path below degrades gracefully instead of
# raising NameError.
def is_ea4_enabled(*args, **kwargs):
return False
try:
# CLOS-5724: single source of truth for the absolute system-native `.cagefs`
# targets of a per-domain (website) `native` selection, shared with cagefsctl
# so `set` here and CageFS rebuild/repair pin identical links. Guarded on its
# own so an older cagefs without the helper (or the offline test env) degrades
# a per-domain native to the standard /usr/selector links — the cagefs version
# dependency in the .spec / debian control guarantees it at runtime.
from clcagefslib.selector.configure import native_website_targets
except ImportError:
def native_website_targets(*args, **kwargs):
return {}
try:
from clcagefslib.webisolation.jail_utils import get_website_id
from clcagefslib.webisolation.config import load_user_config
except ImportError:
get_website_id = lambda _: None
load_user_config = None
class ClUserSelect(ClSelect):
CAGEFS_PATH = "/var/cagefs"
SELECTOR_PATH = "/usr/selector"
NATIVE_PATH = SELECTOR_PATH if clcagefs.in_cagefs() else "/usr/share/cagefs-skeleton/usr/selector"
CAGEFS_EXCLUDE = "/etc/cagefs/exclude"
SELECTOR2_DIR = ".cl.selector/selector.path"
def clean_crui_images(self, users=None):
"""
Creates flags mod_lsapi_reset_me in users' home directories in order
to recreate CRIU images when php version/extensions/options have changed
For details see LVEMAN-1210
:param users: list of usernames (strings)
"""
# There is not reliable way to check if CRIU is enabled inside CageFS
# So let's always create the "mod_lsapi_reset_me" flag
if not clcagefs.in_cagefs() and not os.path.isfile("/var/run/mod_lsapi/criu.enabled"):
return
for user in users:
pw = self._clpwd.get_pw_by_name(user)
path = os.path.join(pw.pw_dir, "mod_lsapi_reset_me")
if not os.path.isfile(path):
previous_user_data = self._change_uid(user)
try:
clcaptain.write(path)
except (OSError, ExternalProgramFailed) as e:
raise ClSelectExcept.UnableToSaveData(path, e)
finally:
ClUserSelect._restore_uid(previous_user_data)
def switch_symlink_for_alt_php(self, version, pw, document_root=None, exit_on_error=True, configure_multiphp=True):
"""
Switch symlink for alt php.
Create .cagefs directory if not created
Rerurn True if error has occured
"""
if not os.path.isdir(BASEDIR) and not clcagefs.in_cagefs():
print("ERROR: CageFS not installed.")
if exit_on_error:
sys.exit(1)
else:
return True
if configure_multiphp and is_ea4_enabled():
conf = read_cpanel_ea4_php_conf()
if conf:
try:
# get default system php version selected via MultiPHP Manager in cPanel WHM
default_php = conf["default"]
# LVEMAN-1170: do not configure PHP Selector when system default version is alt-php
if not default_php.startswith("ea-php"):
print(
"ERROR: system default PHP version is alt-php. "
"PHP Selector is disabled. Use cPanel MultiPHP manager instead."
)
if exit_on_error:
sys.exit(1)
else:
return True
except KeyError:
pass
# configure alt php - create .cagefs dir and create symlink
error = configure_alt_php(
pw, version, document_root,
write_log=False,
drop_perm=(os.geteuid() == 0),
configure_multiphp=configure_multiphp
)
if error and exit_on_error:
sys.exit(1)
# When setting global version (document_root=None), sync isolated websites
# so the ones that follow user-level settings pick up this version.
if not document_root and load_user_config is not None:
isolated_domains = set(load_user_config(pw.pw_name).enabled_websites)
# in some tests (e.g selectorctl.backup.robot) user is created just via useradd ->
# plesk cpapi raises NoPanelUser; ispmanager/interworx inherit GeneralPanel
# and raise NotSupported because they never implemented userdomains
try:
target_domains = userdomains(pw.pw_name)
except (NoPanelUser, NotSupported):
target_domains = []
for domain, domain_docroot in target_domains:
if domain not in isolated_domains:
continue
# CLOS-6990: a website pinned to its own PHP version must be
# configured with THAT version. `configure_alt_php` decides,
# per installed alt-php dir, whether that dir's `link/conf`
# points at the user's PHP Selector config or at the bare
# alt-php package defaults, by comparing the dir against the
# version it is handed. Passing the account-level version to a
# pinned website therefore pointed the website's own version at
# the package defaults: the site kept running the right engine
# but loaded zero selector config, so PDO and every other
# selected extension vanished -> `Class 'PDO' not found` -> 500.
# This block predates CLOS-3932, when per-domain PHP Selector
# was opt-in and no isolated website could be pinned; once
# CLOS-3932 made it default-on for every isolated website the
# unconditional account-level push became a clobber.
# Websites with no per-domain selection still follow the
# account version, which is the sync this block exists for.
configure_alt_php(
pw,
self._website_selected_version(pw.pw_name, domain_docroot) or version,
domain_docroot,
write_log=False,
drop_perm=(os.geteuid() == 0),
configure_multiphp=configure_multiphp
)
return error
def apply_symlinks_rules(self):
if self.without_cagefs:
print('ERROR: this option does not work in "single user" mode (when CageFS is disabled)')
sys.exit(1)
if os.geteuid() != 0:
print("ERROR: root privileges required")
sys.exit(1)
users_vers_dict = self.get_user_version_map()
for user, version in iteritems(users_vers_dict):
print("Processing user", user)
pw = self._clpwd.get_pw_by_name(user)
self.switch_symlink_for_alt_php(version, pw, exit_on_error=False, configure_multiphp=False)
def __init__(self, item="php", exclude_pid_list=None):
ClSelect.__init__(self, item)
self._clpwd = ClPwd()
self._user_excludes = set()
if exclude_pid_list:
self.exclude_pid_list = exclude_pid_list
else:
self.exclude_pid_list = []
def get_version(self, user, domain=None, show_native_version=False):
"""
Returns alternative version for a user
@param user: string
@return: string
"""
self._check_user_in_cagefs(user)
alt_path = self._compose_user_alt_path(user)
# A per-website selector directory is only what the domain RUNS while
# the domain is isolated -- outside the isolated namespace the account
# selector is what serves the site. The directory outlives isolation
# (`cagefsctl --isolates-disable` keeps it on purpose, so re-enabling
# restores the settings), so reading it unconditionally reports a
# version the site is not running. Answer with the account version
# until the domain is isolated again.
if domain and self.website_isolation_enabled(user, domain):
website_path = self._compose_user_alt_path(user, docroot(domain)[0])
if os.path.isdir(website_path):
alt_path = website_path
return self._version_at_alt_path(alt_path, show_native_version)
def _version_at_alt_path(self, alt_path, show_native_version=False):
"""Resolves the alternative a selector directory currently points at.
Split out of ``get_version`` so the per-website scope can be read from
a document root the caller already has, without re-resolving a domain
through the panel API. ``alt_path`` is an account-level
``etc/cl.selector`` or a per-website ``etc/cl.selector/``.
"""
if not os.path.isdir(alt_path):
return self._compose_native_info(show_native_version)
full_path = os.path.join(alt_path, self._item)
if not os.path.islink(full_path):
return self._compose_native_info(show_native_version)
return self._version_for_link_target(os.readlink(full_path), show_native_version)
def _version_for_link_target(self, link_dst, show_native_version=False):
"""Resolves the alternative a primary ``- `` link target names.
Split off from ``_version_at_alt_path`` so a caller holding a target it
has already read can resolve *that* target instead of re-reading the
link. The re-read is not equivalent: CageFS rewrites these links
unlink-then-symlink, so a second look can find the link gone and answer
``native`` -- the reading for a selector directory that holds no
selection -- where the target in hand still names the alternative the
website ran.
"""
native = self._compose_native_info(show_native_version)
if self.without_cagefs:
if not self._native_contents:
self._load_native_contents(self._item)
if link_dst == self._native_contents[self._item]:
return native
if os.path.dirname(link_dst) == self.SELECTOR_PATH:
return native
alternatives = self.get_all_alternatives_data()
try:
version = list(
filter((lambda i: alternatives[i]["data"][self._item] == link_dst), list(alternatives.keys()))
)[0]
return (version, alternatives[version]["version"], alternatives[version]["data"][self._item])
except (IndexError, KeyError):
return native
def _website_selected_version(self, user, document_root):
"""PHP version a website has selected for itself, or None to follow the
account-level one.
Reads the website's own selector directory -- the very tree
``configure_alt_php`` is about to write ``link/conf`` into -- so the
answer is what the website *runs*, which is what those symlinks have to
agree with. The primary ``
- `` link is the evidence of a per-domain
selection: ``_set_version`` creates it for a per-domain set, and CageFS
re-creates it on its own sweep. Its absence (no directory at all, or a
directory holding only the cPanel-MultiPHP ``ea-*`` aliases that an
account-level save writes there) means the website has made no
selection, so the account-level version is the right input. So does a
primary link routed through ``/usr/selector`` -- see below: that
encoding resolves to the account's own selection rather than pinning
anything, so such a website follows the account too.
CLOS-3932 made per-domain PHP Selector default-on for every isolated
website, so isolation alone no longer implies a selection -- which is
why this has to be asked per website rather than inferred.
"""
alt_path = self._compose_user_alt_path(user, document_root)
link = os.path.join(alt_path, self._item)
if not os.path.islink(link):
return None
try:
link_dst = os.readlink(link)
except OSError:
# The link passed `islink` and was gone by the time it was read:
# CageFS rewrites these unlink-then-symlink
# (`_setup_alt_php_symlinks`), so its sweep can land in that
# window. Nothing catches OSError up this stack, and
# `apply_symlinks_rules` walks every user on the server in one
# uncaught loop, so a single raced website would abort the sweep
# and leave every user after it unprocessed.
#
# Unreadable is not evidence of a pin, so it takes the same
# fallback as a missing link: follow the account. The website that
# lost the race is the one CageFS is mid-rewrite of, and CageFS
# moves the selector dir and the per-website `link/conf` together,
# so it settles that website itself.
return None
if os.path.dirname(link_dst) == self.SELECTOR_PATH:
# Account-following `native`, not a pin. `_set_version` writes this
# encoding whenever `native_website_targets()` is empty -- EA4 off,
# unreadable ea4 conf, an alt-php MultiPHP default, or a non-cPanel
# panel -- and CLOS-4351 binds the ACCOUNT selector over
# /usr/selector inside the jail so those links resolve to a real
# binary. The website therefore runs whatever the account runs;
# `native_website_targets()`'s own docstring records it as
# "silently follows the account version after a global selector
# change (while the API still reports `native`)".
#
# So `native` is what the API reports here, not what the site runs,
# and it is not a usable input: `configure_alt_php` would derive
# `selected_php_dir = 'phpnative'`, match no installed alt-php dir,
# and point every dir -- the one the site actually runs included --
# at the bare package defaults. The account version is the answer
# this link resolves to, so it is the answer to hand on.
#
# CLOS-5724's per-domain native is the other encoding: an absolute
# system-native `.cagefs` target, self-contained by construction,
# which falls through below and resolves to `native` correctly.
return None
# Resolved from the target already in hand, NOT by handing `alt_path`
# back to `_version_at_alt_path` to read a second time. The re-read
# loses the same race in a way no `except OSError` can catch: by then
# CageFS has unlinked the link, so the re-read's own `islink` guard
# answers False and returns `native` without raising at all. And
# `native` is the one answer that must not escape this method --
# `configure_alt_php` derives `selected_php_dir = 'phpnative'` from it,
# matches no installed alt-php dir, and points every dir, the one the
# site runs included, at the bare package defaults. That is the
# CLOS-6990 clobber itself. One read, one answer: the link either named
# an alternative or it did not.
return self._version_for_link_target(link_dst)[0]
def create_dir(self, path, user):
if not os.path.isdir(path):
previous_user_data = self._change_uid(user)
try:
clcaptain.mkdir(path)
except (OSError, ExternalProgramFailed) as e:
raise ClSelectExcept.UnableToSaveData(path, e)
finally:
ClUserSelect._restore_uid(previous_user_data)
def create_selector_symlinks(self, user):
"""
Creates additional directory and symlinks for use in "without CageFS" mode
"""
homedir = self._clpwd.get_homedir(user)
path_in_home = os.path.join(homedir, self.SELECTOR2_DIR)
cur_user = self._change_uid(user)
self.create_dir(path_in_home, user)
self._create_symlink("../php-cli", path_in_home + "/php", check_existence=True)
self._create_symlink("../php", path_in_home + "/php-cgi", check_existence=True)
self._restore_uid(cur_user)
def get_default_version(self):
if os.path.isfile(ClSelect.DEFAULTS_PATH):
try:
return self._dh.get("versions", self._item)
except (ConfigParser.Error, IOError, KeyError):
return "native"
return "native"
def set_version_from_backup(self, user):
user_backup_path = os.path.join(self._clpwd.get_homedir(user), ".cl.selector", "defaults.cfg")
# DiD (F-26, CLOS-5540): os.path.isfile follows symlinks. If a tenant
# planted defaults.cfg as a symlink to /etc/shadow or another user's
# file, ConfigParser.read below would try to parse it, and a
# ParsingError.__str__ would embed the first offending line into
# the printed message. Use lstat + S_ISREG to require a plain
# regular file at the backup path; anything else (symlink, socket,
# dir, missing) falls through to the default-version branch.
# Refutation still holds — all callers first invoke self._change_uid
# so the read runs at the target user's euid — but the sink-side
# check closes the symlink-read class regardless of caller euid.
try:
st = os.lstat(user_backup_path)
except OSError:
st = None
if st is None or not S_ISREG(st.st_mode):
self.set_version(user, self.get_default_version())
else:
try:
dh = self._get_default_config_handler(user_backup_path)
self.set_version(user, dh.get("versions", self._item))
except (ConfigParser.Error, IOError, KeyError) as e:
print("Error while restoring settings from backup", str(e))
sys.exit(1)
def _version_enabled(self, version):
""""
Checks if version is enabled,
a version is considered "enabled" if there is no "state" option in the config.
The presence of the "state" option always means it is disabled, regardless of its value.
@param version: string
@return: bool - True if version is enabled, False otherwise
"""
if not self._dh.has_option("%s%s" % (self._item, version), "state"):
return True
else:
return False
def set_version(self, user, version,
domain=None, return_summary=False, show_native_version=False, exit_on_error=True):
"""
Sets alternative version for a users with the same uid
@param user: string
@return: None
"""
if os.geteuid() != 0 and (message := self.get_version_selection_disabled_msg(user)):
raise ClSelectExcept.VersionModificationBlocked(message)
# This check is needed to prevent setting a disabled version as selected version for a user
if not self._version_enabled(version):
raise ClSelectExcept.VersionModificationBlocked('Version %s is disabled by administrator' % version)
if domain:
try:
owner = domain_owner(domain)
except NoDomain:
owner = None
if owner != user:
raise ClSelectExcept.SelectorException("Domain %s does not belong to user %s" % (domain, user))
self._require_website_isolation(user, domain)
data = utils.apply_for_at_least_one_user(
self._set_version,
self._clpwd.get_names(self._clpwd.get_uid(user)),
ClSelectExcept.NoUserSelector,
version,
domain,
return_summary,
show_native_version,
exit_on_error,
)
if return_summary:
return data
def _set_version(self, user, version, domain=None, return_summary=False, show_native_version=False, exit_on_error=True):
"""
Sets alternative version for a user
@param user: string
@return: None
"""
if self.without_cagefs:
previous_user_data = self._change_uid(user)
self._check_user_in_cagefs(user)
document_root = ""
if domain:
document_root = docroot(domain)[0]
alt_path = self._compose_user_alt_path(user, document_root)
if not os.path.isdir(alt_path):
if self.without_cagefs or domain:
self.create_dir(alt_path, user)
else:
raise ClSelectExcept.NoUserSelector(user)
alternatives = self.get_all_alternatives_data()
if version not in alternatives and version != "native":
raise ClSelectExcept.NoSuchAlternativeVersion(version)
self._remove_alternatives_links(alt_path)
pw = self._clpwd.get_pw_by_name(user)
if version == "native":
if self.without_cagefs:
if not self._native_contents:
self._load_native_contents(self._item)
for item, native_path in iteritems(self._native_contents):
self._create_symlink(native_path, alt_path + "/" + item, user, version)
else:
# CLOS-5724: a per-domain (website) `native` must be self-contained
# system-native — the PRIMARY links pinned at the absolute
# system-native `.cagefs` binary, NOT routed through /usr/selector.
# Under Website Isolation the jail binds /usr/selector to the
# ACCOUNT selector, so a per-domain native via /usr/selector (or
# via the `ea-*` alias entries, which the cPanel-MultiPHP re-sync
# rewrites to follow the account) silently follows — then dangles
# → HTTP 500 — after a global selector change. `native_targets` is
# keyed by primary alias; empty on non-cPanel / alt-php default →
# /usr/selector fallback (unchanged non-isolated behaviour). The
# helper is shared with cagefsctl so `set` here and CageFS rebuild
# pin identical links.
native_targets = native_website_targets() if domain else {}
ini = "php.ini"
new_ini_created = False
new_ini_path = os.path.join("%s.etc" % (self.NATIVE_PATH,), ini)
if os.path.exists(new_ini_path):
if ini in native_targets:
src = native_targets[ini]
else:
src = os.path.join("%s.etc" % self.SELECTOR_PATH, ini)
dst = os.path.join(alt_path, ini)
self._create_symlink(src, dst, user, version)
new_ini_created = True
for filename in os.listdir(self.NATIVE_PATH):
if self._item not in filename:
continue
if filename.endswith(".ini") and new_ini_created:
continue
dst = os.path.join(alt_path, filename)
if filename in native_targets:
src = native_targets[filename]
else:
src = os.path.join(self.SELECTOR_PATH, filename)
self._create_symlink(src, dst, user, version)
else:
for item, path in iteritems(alternatives[version]["data"]):
self._create_symlink(path, os.path.join(alt_path, item), user, version)
if self.without_cagefs:
ClUserSelect._restore_uid(previous_user_data)
else:
self.switch_symlink_for_alt_php(version, pw,
document_root=document_root,
exit_on_error=exit_on_error)
self._switch_php_da_isp(user, version)
self._reload_processes(user)
self._backup_settings(user, document_root=document_root or None, domain=domain)
if return_summary:
return self.get_summary(user, show_native_version)
def get_version_selection_disabled_msg(self, username: str) -> str:
"""
Returns a message indicating that the selection of the PHP version
is disabled for the user, based on the configuration file.
Args:
username (str): The username for which to check the configuration.
Returns:
str: The message indicating that version selection is disabled,
or an empty string if the configuration file does not exist
or does not contain the message.
"""
uid = self._clpwd.get_uid(username)
config_file = Path(f"/var/cloudlinux/cl.selector/uids/{uid}/version_selection_conf.json")
if not config_file.exists():
return ""
try:
with config_file.open(encoding="utf-8") as f:
config_data = json.load(f)
return config_data.get("version_selection_disabled_msg", "")
except (OSError, ValueError):
return ""
def get_summary(self, user, show_native_version=False):
"""
Returns a summary of available alternative versions for the given user.
Only versions that are enabled, default, or selected are included.
The label for the "native" version is replaced if show_native_version is True.
@param user: str - Username inside CageFS.
@param show_native_version: bool - Whether to replace the 'native' version label.
@return: tuple of (str, (bool, bool, bool)) - Each entry contains the version name and a tuple with flags:
(enabled, default, selected).
"""
self._check_user_in_cagefs(user)
# Retrieve all alternatives data and "native"
alternatives = self.get_all_alternatives_data()
native_info = self._compose_native_info(show_native_version)
summary = {"native": {"enabled": True, "default": False, "selected": False}}
alt_versions = sorted(alternatives.keys())
alt_versions.append("native")
selected_version = self.get_version(user)[0]
# Populate summary with alternative versions
for version in alt_versions:
if version not in summary:
summary[version] = {}
summary[version]["enabled"] = self._version_enabled(version)
summary[version]["default"] = False
summary[version]["selected"] = False
try:
default_version = self._dh.get("versions", self._item)
except (ConfigParser.NoSectionError, ConfigParser.NoOptionError):
default_version = "native"
try:
summary[default_version]["default"] = True
summary[selected_version]["selected"] = True
except KeyError:
raise ClSelectExcept.NoSuchAlternativeVersion(default_version)
summary[native_info[0]] = summary.pop("native")
alt_versions.remove("native")
alt_versions.append(native_info[0])
result = [] # Final list for summary output
# Include only versions where at least one flag is True
for idx, v in enumerate(alt_versions):
if not any((summary[v]["enabled"], summary[v]["default"], summary[v]["selected"])):
continue
result.append((v, (summary[v]["enabled"], summary[v]["default"], summary[v]["selected"])))
return tuple(result)
def change_to_version(self, new_version, current_version):
"""
Changes users of a supplied version to specified_version
@param version: string
@param current_version: string
"""
users = self.list_users(current_version)
for user in users:
try:
self.set_version(user, new_version, exit_on_error=False)
except Exception as e: # catch every errors, print it and go to the next user
clprint.print_diag("text", {"status": "ERROR", "message": str(e)})
pass
self.clean_crui_images(users)
def list_users(self, version):
"""
Returns users of a certain alternative
"""
data = self.get_version_user_map()
if version in data:
return data[version]
return []
def list_all_users(self):
"""
Returns all valid system users
@return: list
"""
if self.without_cagefs:
from .clselectctlphp import get_cpanel_user
return [get_cpanel_user()]
return list(self._get_system_users().difference(self._get_user_excludes()))
def cagefs_copy_etc(self, user):
config = dict()
config["init"] = 0
config["reinit"] = 0
config["verbose"] = 0
LIBDIR = "/usr/share/cagefs"
sys.path.append(LIBDIR)
try:
import cagefsctl
except ImportError:
print("ERROR: CageFS not installed.")
sys.exit(1)
cagefs_etc_path = os.path.join(BASEDIR, get_user_prefix(user), user, "etc")
if not os.path.exists(cagefs_etc_path + "/cl.selector") or not os.path.exists(cagefs_etc_path + "/cl.php.d"):
cagefsctl.cpetc_for_user(user, config)
def get_user_version_map(self):
"""
Returns user version map as dict
@return: dict
"""
actual_users = self.list_all_users()
data = {}
for user in actual_users:
try:
data[user] = self.get_version(user, False)[0]
except ClSelectExcept.NotCageFSUser:
continue
return data
def get_version_user_map(self, user_names=None, domain=None):
"""
Returns users grouped by version
@return: dict
"""
actual_users = user_names or self.list_all_users()
data = {}
for user in actual_users:
try:
version = self.get_version(user, domain, False)[0]
if not version in data:
data[version] = []
data[version].append(user)
except ClSelectExcept.NotCageFSUser:
continue
return data
def _create_symlink(src, dst, user=None, version=None, check_existence=False):
"""
Creates symlink from src to dst
@param src: string
@param dst: string
@param user: string
@param version: string
@param check_existence: bool
@return: None
"""
try:
if check_existence:
if os.path.islink(dst):
if os.readlink(dst) != src:
os.unlink(dst)
else:
return
else:
utils.remove_file_or_dir(dst)
clcaptain.symlink(src, dst)
except Exception as e:
if user is not None and version is not None:
raise ClSelectExcept.UnableToSetAlternative(user, version, e)
raise ClSelectExcept.SelectorException("Cannot create symlink from %s to %s (%s)" % (src, dst, e))
_create_symlink = staticmethod(_create_symlink)
def _get_user_excludes(self):
"""
Returns list of user excludes
@return: list
"""
if self._user_excludes:
return self._user_excludes
if not os.path.isdir(self.CAGEFS_EXCLUDE):
return set()
for item in os.listdir(self.CAGEFS_EXCLUDE):
full_item_path = os.path.join(self.CAGEFS_EXCLUDE, item)
self._user_excludes.update(
set(map((lambda x: x.strip()), utils.read_file_as_string(full_item_path).splitlines()))
)
return self._user_excludes
def _check_user_in_cagefs(self, user):
"""
Check that cagefs enabled for user
"""
if self.without_cagefs:
return
if clcagefs.in_cagefs():
return
LIBDIR = "/usr/share/cagefs"
sys.path.append(LIBDIR)
try:
import cagefsctl
except ImportError:
print("ERROR: CageFS not installed.")
sys.exit(1)
try:
if not cagefsctl.is_user_enabled(user):
raise ClSelectExcept.NotCageFSUser(user)
except AttributeError:
print("ERROR: CageFS version is unsupported. Please update CageFS.")
sys.exit(1)
def website_isolation_enabled(self, user, domain):
"""Is CloudLinux Isolates enabled for ``domain`` of ``user``?
The per-website scope only exists for an isolated website: CageFS
binds that website's ``cl.selector/`` and
``cl.php.d/`` over the account-level ones inside its
namespace, and no other domain ever reads them. So this is the
precondition for both reading and writing a per-domain setting.
Reads ``enabled_websites`` from
``/var/clwebisolate/users//.json`` -- the same source the
admin UI reports ``isolated`` from (`cloudlinux_cli._site_isolation_user`)
and the same one the account-level sync loop in
``switch_symlink_for_alt_php`` already uses. ``cagefs.mp`` mounts that
directory split-by-uid and the file is root: 0640, so the
answer is the same for root and for the user inside the jail.
Fails closed: in single-user mode (CageFS off) and without the cagefs
package there is no isolated website at all.
"""
if self.without_cagefs or load_user_config is None:
return False
return domain in load_user_config(user).enabled_websites
def _require_website_isolation(self, user, domain):
"""Guard every per-domain WRITE. Raises before anything is touched.
CLOS-7489: without this, a per-domain write on a non-isolated domain
got half-way -- the per-website selector directory was created and
reported back as the domain's version -- and then died on the
``~/.cagefs/websites/`` overlay that only isolate-enable
creates. Refusing up front is what keeps "failed" and "recorded"
from disagreeing.
"""
if domain and not self.website_isolation_enabled(user, domain):
raise ClSelectExcept.WebsiteIsolationDisabled(domain)
def _require_isolated_website(self, user, website):
"""``Website``-object flavour of :meth:`_require_website_isolation`,
for the extension and option writers, which carry the scope as a
``Website`` rather than a domain name. ``None`` is account scope.
"""
self._require_website_isolation(user, website.domain if website else None)
def _remove_alternatives_links(self, path):
"""
Removes all symlinks from directory
@param path: string
@return: None
"""
for filename in os.listdir(path):
if self._item not in filename:
continue
full_path = os.path.join(path, filename)
if not os.path.islink(full_path):
continue
os.unlink(full_path)
def _compose_user_alt_path(self, user, document_root=None):
"""
Composes and returns user alternative directory path
@param user: string
@return: string
"""
if self.without_cagefs:
homedir = self._clpwd.get_homedir(user)
return homedir + "/.cl.selector"
uid = str(self._clpwd.get_uid(user))
document_root_id = ""
if document_root:
document_root_id = get_website_id(document_root) if document_root else ""
base = (
os.path.join(self.CAGEFS_PATH, uid[-2:], user, "etc", "cl.selector")
if not clcagefs.in_cagefs()
else "/etc/cl.selector"
)
return os.path.join(base, document_root_id) if document_root_id else base
def _get_system_users(self):
"""
Returns set of system users
@return: set
"""
users_dict = self._clpwd.get_user_dict()
return set(users_dict.keys())
def _delete_if_symlink(file_path):
"""
Deletes file to be written if it is a symlink
"""
if os.path.islink(file_path):
try:
os.unlink(file_path)
except OSError:
raise ClSelectExcept.UnableToSaveData(file_path, "Cannot delete symlink while saving data")
_delete_if_symlink = staticmethod(_delete_if_symlink)
def _change_uid(self, user):
"""
Changes to another uid and returns tuple of previous euid and egid
@param user: string
@return: tuple
"""
entry = self._clpwd.get_pw_by_name(user)
new_uid = entry.pw_uid
new_gid = entry.pw_gid
cur_euid = os.geteuid()
cur_egid = os.getegid()
if cur_euid == new_uid:
return cur_euid, cur_egid
try:
os.setegid(new_gid)
os.seteuid(new_uid)
secureio.set_capability()
return cur_euid, cur_egid
except OSError as e:
raise ClSelectExcept.UnableToChangeToAnotherUser(user, e)
def _restore_uid(uid_and_gid):
"""
Restores changed uid and gid to original ones
@param uid_and_gid: tuple
@return: None
"""
if uid_and_gid[0] != os.geteuid():
secureio.set_capability(clear=True)
try:
os.setegid(uid_and_gid[1])
os.seteuid(uid_and_gid[0])
except OSError as e:
raise ClSelectExcept.UnableToChangeToAnotherUser(str(uid_and_gid[0]), e)
_restore_uid = staticmethod(_restore_uid)
def _write_to_file(self, user, file_contents, file_path, create=True):
"""
Saves data to file
@param user: string
@param file_contents: string
@param file_path: string
@return: None
"""
previous_user_data = self._change_uid(user)
try:
if not create and not os.path.exists(file_path):
return
self._delete_if_symlink(file_path)
file_directory = os.path.dirname(file_path)
# CLOS-6923: pre-create the leaf. For a per-domain write
# ``file_directory`` is ``cl.php.d//alt-php/``,
# provisioned by cagefsctl at isolation enable but not
# refreshed when the subdomain's docroot (and therefore its
# FNV website_id) changes afterward. Without this the
# ``clcaptain.write`` below raises ENOENT and the per-domain
# change silently drops. Symmetric with ``_backup_settings``'
# pre-create of the sibling ``~/.cl.selector//``.
if not os.path.isdir(file_directory):
try:
clcaptain.mkdir(file_directory, mode=0o755, recursive=True)
except (OSError, ExternalProgramFailed) as e:
raise ClSelectExcept.UnableToSaveData(file_directory, e)
try:
# Replace tempfile.mkstemp with str(uuid.uuid4())
dirname = "clseltmp_%s" % str(uuid.uuid4())
temp_path = os.path.join(file_directory, dirname)
clcaptain.write(temp_path, "%s\n" % (file_contents,))
except (IOError, OSError, ExternalProgramFailed) as e:
try:
if os.path.exists(temp_path):
os.unlink(temp_path)
except:
pass
raise ClSelectExcept.UnableToSaveData(file_path, e)
else:
try:
mask = S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH
os.rename(temp_path, file_path)
os.chmod(file_path, mask)
except OSError:
pass
finally:
ClUserSelect._restore_uid(previous_user_data)
def _reload_processes(self, user):
"""
Reloads user process
"""
try:
next_parent = psutil.Process()
for i in range(2):
next_parent = next_parent.parent()
if next_parent is not None:
self.exclude_pid_list.append(next_parent.pid)
else:
break
except psutil.NoSuchProcess:
pass
try:
uid = ClPwd().get_uid(user)
except (ClPwd.NoSuchUserException,):
# no such user
return
try:
for proc in psutil.process_iter():
try:
if uid not in [proc.uids().real, proc.uids().effective] or proc.name().find(self._item) == -1:
continue
pid = proc.pid
except psutil.NoSuchProcess:
continue
try:
if pid not in self.exclude_pid_list:
os.kill(pid, signal.SIGHUP)
except (OSError,):
continue
except (OSError, IOError):
# psutil reads /proc FS as usual FS, skip read errors
pass
def _skim_over_extensions(path):
"""
Get extension names from user extensions file comments
"""
extensions = []
try:
ini = open(path)
for line in ini:
if line.startswith(";---"):
ext = line[4 : line.rfind("---")]
extensions.append(ext)
ini.close()
except (OSError, IOError):
pass
return extensions
_skim_over_extensions = staticmethod(_skim_over_extensions)
def _backup_settings(self, user, document_root=None, domain=None):
"""
Scans all user settings and backups'em in homedir as INI file
When ``document_root`` is given (an isolated per-domain config), the
backup is written to the per-website ``~/.cl.selector//``
directory and reflects the per-website ``cl.php.d/`` inis,
so ``cagefsctl`` regenerates the isolated config from it. Otherwise the
account-level backup is written as before. Without this, per-domain
extension changes were lost on ``cagefsctl --force-update`` (CLOS-4702).
@param user: string
@param document_root: str or None - domain docroot for the per-website backup
@param domain: str or None - domain name, used to record its PHP version
"""
self._check_user_in_cagefs(user)
per_website = bool(document_root) and not self.without_cagefs
website_id = get_website_id(document_root) if per_website else ""
backup_contents = []
user_alt_path = self._compose_user_alt_path(user, document_root)
# cl.php.d sits beside the account-level cl.selector dir; per-website inis
# live in its subdir (mirroring cl.selector/).
user_ext_path = os.path.join(os.path.dirname(self._compose_user_alt_path(user)), "cl.php.d")
if per_website:
user_ext_path = os.path.join(user_ext_path, website_id)
alternatives = self.get_all_alternatives_data()
user_backup_path = os.path.join(self._clpwd.get_homedir(user), ".cl.selector")
if per_website:
user_backup_path = os.path.join(user_backup_path, website_id)
if not os.path.isdir(user_backup_path):
previous_user_data = self._change_uid(user)
try:
clcaptain.mkdir(user_backup_path)
except (OSError, ExternalProgramFailed) as e:
ClUserSelect._restore_uid(previous_user_data)
raise ClSelectExcept.UnableToSaveData(user_backup_path, e)
ClUserSelect._restore_uid(previous_user_data)
user_backup_file = os.path.join(user_backup_path, "defaults.cfg")
if os.path.isdir(user_alt_path):
version = "[versions]\n%s = %s\n" % (self._item, self.get_version(user, domain=domain)[0])
else:
version = "[versions]\n%s = native\n" % (self._item,)
backup_contents.append(version)
for alt in sorted(alternatives.keys()):
if self.without_cagefs:
curr_ext_path = user_alt_path + "/alt_php" + alt.replace(".", "") + ".ini"
else:
curr_ext_path = os.path.join(user_ext_path, "alt-php%s" % ((alt.replace(".", ""),)), "alt_php.ini")
extensions = self._skim_over_extensions(curr_ext_path)
backup_contents.append("[%s%s]\nmodules = %s\n" % (self._item, alt, ",".join(sorted(extensions))))
self._write_to_file(user, "\n".join(backup_contents), user_backup_file)
def _switch_php_da_isp(self, user, version):
if self.without_cagefs:
return
da_change_user_php_ini(user, version)