/opt/cloudlinux/venv/lib/python3.11/site-packages/ssa
NameSizeModeActions
autotracing/-0755rm
configuration/-0755rm
internal/-0755rm
modules/-0755rm
ssa_utils/-0755rm
__pycache__/-0755rm
agent.py554040644editdlrm
clos_ssa_ini.py13190644editdlrm
cloudlinux-autotracing.py3820755editdlrm
cloudlinux-ssa-agent.py3740755editdlrm
cloudlinux-ssa-manager.py3780755editdlrm
db.py123060644editdlrm
manager.py295760644editdlrm
website_isolation.py201430644editdlrm
__init__.py00644editdlrm
Edit: /opt/cloudlinux/venv/lib/python3.11/site-packages/ssa/website_isolation.py (20143B)
# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2021 All Rights Reserved # # Licensed under CLOUD LINUX LICENSE AGREEMENT # http://cloudlinux.com/docs/LICENSE.TXT """ Website isolation support for SSA (clos_ssa.ini) files. This module provides functions to manage clos_ssa.ini files in per-website directories when CageFS website isolation is enabled. """ # Every path handled here is tenant-controlled: a failure on one target (of # whatever exception type the tenant can provoke) must be contained to that # target and never abort the pass for the other tenants, hence the broad catches. # ruff: noqa: BLE001 import logging import os import re import stat from glob import iglob import psutil from clcommon.clpwd import drop_privileges from secureio import disable_quota from .clos_ssa_ini import ( INI_FILE_NAME, INI_USER_LOCATIONS_BASE, INI_USER_LOCATIONS_WEBSITE_ISOLATION, extract_php_version, is_excluded_path, ) # Try to import website isolation checks from securelve (cagefs). Both names # ship together (cagefs >= 7.6.29); when either is missing the feature is # treated as unavailable and every code path below is a no-op. try: from clcagefslib.domain import is_isolation_enabled, is_website_isolation_allowed_server_wide except ImportError: def is_website_isolation_allowed_server_wide(): return False def is_isolation_enabled(user): return False logger = logging.getLogger(__name__) def _write_isolation_ini(ini_file, content, uid, gid, user_context_func) -> None: """ Write content into a per-website clos_ssa.ini under the tenant context. O_NOFOLLOW refuses a tenant-planted symlink at the ini path (raises OSError ELOOP on a final-component symlink). O_NONBLOCK stops a tenant-planted FIFO from blocking the shared regen thread forever on open() (a reader-less O_WRONLY FIFO open fails ENXIO instead of hanging). The fstat check then refuses any non-regular target (FIFO/device/socket) by raising OSError, so it is skipped rather than written (and, like the O_NOFOLLOW/O_NONBLOCK refusals, is never counted by callers as a created ini). O_NONBLOCK has no effect on regular-file writes. Callers handle the raised OSError per-target. """ with user_context_func(uid, gid), disable_quota(): fd = os.open(ini_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW | os.O_NONBLOCK, 0o644) with os.fdopen(fd, 'w') as f: if not stat.S_ISREG(os.fstat(f.fileno()).st_mode): # Refuse a non-regular target (a FIFO with a reader, a device or a # socket) that opened OK. RAISE rather than return so callers treat # it exactly like the O_NOFOLLOW/O_NONBLOCK refusals: the file is not # counted as a created ini and no spurious PHP worker reload runs. raise OSError('refusing to write non-regular ini target (not a regular file)') f.write(content) def _read_isolation_ini(ini_file, uid, gid, user_context_func): """ Read a base clos_ssa.ini under the tenant context, refusing non-regular sources. The base ini path is tenant-controlled, so the same hardening as the write helper applies: O_NOFOLLOW refuses a final-component symlink (ELOOP), and O_NONBLOCK makes an O_RDONLY open of a tenant-planted FIFO return immediately instead of blocking the shared regen thread forever waiting for a writer. The fstat check on the opened fd then refuses any non-regular source (FIFO/device/ socket): it is skipped rather than read. Returns the file content, or None if the source is non-regular. Callers handle the raised OSError per-source. """ with user_context_func(uid, gid): fd = os.open(ini_file, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) with os.fdopen(fd) as f: if not stat.S_ISREG(os.fstat(f.fileno()).st_mode): logger.warning('Refusing to read non-regular ini %s', ini_file) return None return f.read() def copy_inis_to_website_isolation_paths(user_context_func, force: bool = False) -> None: """ Copy clos_ssa.ini files from base user paths to per-website directories. :param user_context_func: Context manager function for user permissions :param force: rewrite per-website inis and reload workers even when the content is unchanged. The reconcile path for a missed reload (see the recovery contract in docs/design/clos-ssa-ini-generation.md): normal runs are best-effort and rely on natural worker turnover, `--force` (regenerate_clos_ssa_ini.py) re-applies with a single restart per tenant. Never wire it into the RPM triggers — that would restore the mass-restart-per-package-install behaviour CLOS-6960 removed. """ if not is_website_isolation_allowed_server_wide(): return # Collect all base ini files: {(user, php_ver): (content, uid, gid)} base_ini_files = {} for location in INI_USER_LOCATIONS_BASE: for dir_path in iglob(location['path']): if is_excluded_path(dir_path): continue try: pw_record = location['user'](dir_path) except Exception: logger.debug("Cannot get pw_record for path: %s", dir_path) continue ini_file = os.path.join(dir_path, INI_FILE_NAME) try: content = _read_isolation_ini(ini_file, pw_record.pw_uid, pw_record.pw_gid, user_context_func) except FileNotFoundError: continue except Exception: logger.warning('Failed to read %s', ini_file) continue if content is None: continue php_ver = extract_php_version(dir_path) if php_ver: base_ini_files[(pw_record.pw_name, php_ver)] = ( content, pw_record.pw_uid, pw_record.pw_gid, ) if not base_ini_files: return created_ini = set() # Copy to per-website directories for location in INI_USER_LOCATIONS_WEBSITE_ISOLATION: for dir_path in iglob(location['path']): if is_excluded_path(dir_path): continue try: pw_record = location['user'](dir_path) except Exception: logger.debug("Cannot get pw_record for path: %s", dir_path) continue if not is_isolation_enabled(pw_record.pw_name): continue php_ver = extract_php_version(dir_path) if not php_ver: continue key = (pw_record.pw_name, php_ver) if key not in base_ini_files: continue content, uid, gid = base_ini_files[key] ini_file = os.path.join(dir_path, INI_FILE_NAME) if not os.path.exists(os.path.dirname(ini_file)): continue if not force and _isolation_ini_up_to_date(ini_file, content, uid, gid, user_context_func): # Nothing changed for this website: do not rewrite the file and, # more importantly, do not restart the tenant's PHP workers. continue try: _write_isolation_ini(ini_file, content, uid, gid, user_context_func) created_ini.add(pw_record.pw_name) except Exception as e: logger.warning('Failed to create %s: %s', ini_file, str(e)) continue for username in created_ini: _reload_user_php_processes(username) def remove_inis_from_website_isolation_paths(user_context_func) -> None: """ Remove clos_ssa.ini files from all per-website directories. :param user_context_func: Context manager function for user permissions """ if not is_website_isolation_allowed_server_wide(): return removed_ini = set() for location in INI_USER_LOCATIONS_WEBSITE_ISOLATION: for dir_path in iglob(location['path']): if is_excluded_path(dir_path): continue try: pw_record = location['user'](dir_path) except Exception: logger.debug("Cannot get pw_record for path: %s", dir_path) continue ini_file = os.path.join(dir_path, INI_FILE_NAME) if os.path.exists(ini_file): try: with user_context_func(pw_record.pw_uid, pw_record.pw_gid): os.unlink(ini_file) removed_ini.add(pw_record.pw_name) except Exception as e: logger.warning('Failed to remove %s: %s', ini_file, str(e)) continue # Outside the per-location loop on purpose (CLOS-6960): `removed_ini` # accumulates across locations, so reloading inside it would restart the # same tenant's workers once per location. Every affected tenant is # reloaded exactly once, after all of its ini files are gone. for username in removed_ini: _reload_user_php_processes(username) def _isolation_ini_up_to_date(ini_file, content, uid, gid, user_context_func) -> bool: """ True when the per-website ini already exists with exactly `content`. Lets the copy pass skip both the rewrite and the PHP worker restart for websites whose ini did not change (e.g. `enable-ssa` on an already enabled server, or the RPM trigger regenerating after a PHP install). Any read problem (missing file, symlink/FIFO refusal, permission error, undecodable tenant-planted content) is reported as "not up to date" so the write path gets to handle it as before; the target is tenant-controlled, so nothing it can make the read raise may abort the copy pass for the other tenants. """ try: return _read_isolation_ini(ini_file, uid, gid, user_context_func) == content except Exception: return False # Grace between SIGTERM and SIGKILL when restarting a tenant's PHP workers — # the same window cagefs' own worker reload gives them. PHP_RELOAD_TERM_GRACE = 5.0 # A genuine isolation marker holds one docroot path, so PATH_MAX is plenty. ISOLATION_MARKER_MAX_SIZE = 4096 def _read_isolation_marker(marker_path): """ Content of a website isolation marker file, or None when it is absent or refused. The path runs through a tenant's filesystem view (/proc//root of a tenant process), so the same hardening as _read_isolation_ini applies: O_NOFOLLOW refuses a planted final-component symlink, O_NONBLOCK keeps a planted FIFO from blocking the root manager on open() (and the read side of a FIFO opens fine without a writer, so the fstat check below is what actually rejects it), the S_ISREG check refuses any non-regular target the open let through (FIFO/device/socket), the read is size-capped, and the decode never raises on tenant-planted bytes. """ try: fd = os.open(marker_path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) except OSError: return None try: if not stat.S_ISREG(os.fstat(fd).st_mode): return None data = os.read(fd, ISOLATION_MARKER_MAX_SIZE) except OSError: return None finally: os.close(fd) return data.decode('utf-8', errors='replace').strip() or None def _process_isolation_docroot(pid): """ Docroot of an already isolated process (the website isolation jail bind- mounts the per-website marker at /var/.cagefs/.cagefs.website), or None for a process outside any isolation jail. """ return _read_isolation_marker(f'/proc/{pid}/root/var/.cagefs/.cagefs.website') # PHP worker executables across panels and handlers: lsphp / lsphpXX # (LiteSpeed, mod_lsapi), php-fpm (master and pool children share the comm), # php-cgi (suphp/cgi), plain php / phpXX CLI-style workers, and the ea-/alt- # prefixed wrapper names. Deliberately anchored: a tenant binary merely # *containing* "php" (e.g. "notphp-daemon") must not match. PHP_WORKER_COMM_RE = re.compile(r'^(?:(?:ea|alt)-)?(?:ls)?php(?:-fpm|-cgi)?[\d.]*$') def _is_php_worker(proc) -> bool: """ True when the process looks like a PHP worker by executable name. Only PHP processes load clos_ssa.so, so only they need a restart to pick up an ini change; anything else of the tenant (node, python, a queue worker) exporting DOCUMENT_ROOT is left alone. Name matching is a heuristic on purpose: a false negative merely delays the ini pickup until the worker recycles naturally, while signalling a non-PHP tenant service would be user-visible collateral. """ try: name = proc.name() except (psutil.Error, OSError): return False return bool(PHP_WORKER_COMM_RE.match(name)) def _reload_user_php_processes(user: str) -> None: """ Restart the user's PHP workers so a clos_ssa.ini creation/deletion is picked up immediately (the ini is only read at worker start): SIGTERM, then SIGKILL after a grace period, for every PHP worker of `user` (executable name matched by PHP_WORKER_COMM_RE) that runs inside a website isolation jail or carries DOCUMENT_ROOT in its environment — the same location markers cagefs' own worker reload matches on, narrowed to PHP: only PHP processes load clos_ssa.so, so restarting anything else is collateral. Any other process of the user (a shell, a cron job, a node/ python service with DOCUMENT_ROOT set) is never signalled. Signals are sent with privileges dropped to the user, so a pid race can at worst hit another process of the same user. Implemented in SSA itself rather than via cagefs' reload_processes_with_docroots() (CLOS-6960): before cagefs 7.6.42-1 (CLOS-5435) that function signalled EVERY process of the user when given an empty docroot filter, and alt-php-ssa deliberately carries no cagefs dependency to pin a version floor on (a packaging floor was tried and dropped: on Ubuntu it entangled alt-php-ssa in the pre-existing cagefs<->lvemanager conflict cycle). Passing an explicit docroot filter is no alternative either: a non-empty filter makes cagefs unlink the cached .cagefs.mnt inside the per-website token dir, which the CLOS-6960 acceptance criteria require untouched. Deliberately NOT `cagefsctl --site-isolation-regenerate` (CLOS-6960): that command also revalidates docroots, rewrites the jail mounts config, re-creates (re-randomizes) the per-website token and reconciles LiteSpeed handlers. Re-creating the token is what re-armed the lsphp re-exec loop of CLOS-6907 and took accounts down with 503s on every SSA sweep. """ try: workers = [] for proc in psutil.process_iter(attrs=['pid', 'username']): if proc.info.get('username') != user: continue if not _is_php_worker(proc): continue try: docroot = _process_isolation_docroot(proc.pid) or proc.environ().get('DOCUMENT_ROOT') except (psutil.Error, OSError): continue if not docroot: continue # %r: docroot is tenant-controlled (DOCUMENT_ROOT env / marker # content) — repr escapes \r\n so a tenant cannot forge log lines. logger.info('Terminating PHP worker %s of %s (docroot %r)', proc.pid, user, docroot) workers.append(proc) if not workers: return with drop_privileges(user): for proc in workers: try: proc.terminate() except psutil.Error: logger.warning('Failed to SIGTERM PHP worker %s of %s', proc.pid, user) # wait_procs identity-checks the Process objects, so a recycled pid # is never SIGKILLed by mistake. _, alive = psutil.wait_procs(workers, timeout=PHP_RELOAD_TERM_GRACE) for proc in alive: try: proc.kill() except psutil.Error: logger.warning('Failed to SIGKILL PHP worker %s of %s', proc.pid, user) except Exception as e: logger.warning('Failed to reload PHP processes for %s: %s', user, str(e)) def regenerate_inis_for_user(user: str, user_context_func, reload_workers: bool = False) -> None: """ Regenerate clos_ssa.ini files for a specific user's website isolation directories. This is called by cagefsctl when enabling website isolation for a user. Only creates per-website ini files if base per-user ini exists. :param user: Username to regenerate ini files for :param user_context_func: Context manager function for user permissions :param reload_workers: restart the user's PHP workers after writing — the per-user reconcile path (`regenerate_clos_ssa_ini.py --user X --force`). Defaults to False because in the cagefsctl invocation path cagefs reloads the workers itself right afterwards, as part of enabling isolation; reloading here too would restart them twice. """ if not is_website_isolation_allowed_server_wide(): return logger.info('Regenerating clos_ssa.ini for user %s website isolation...', user) # First, collect existing base ini files for this user: {php_ver: (content, uid, gid)} base_ini_files = {} for location in INI_USER_LOCATIONS_BASE: for dir_path in iglob(location['path']): if is_excluded_path(dir_path): continue try: pw_record = location['user'](dir_path) if pw_record.pw_name != user: continue except Exception: logger.debug("Cannot get pw_record for path: %s", dir_path) continue ini_file = os.path.join(dir_path, INI_FILE_NAME) try: content = _read_isolation_ini(ini_file, pw_record.pw_uid, pw_record.pw_gid, user_context_func) except FileNotFoundError: continue except Exception: logger.warning('Failed to read %s', ini_file) continue if content is None: continue php_ver = extract_php_version(dir_path) if php_ver: base_ini_files[php_ver] = (content, pw_record.pw_uid, pw_record.pw_gid) if not base_ini_files: logger.info('No base clos_ssa.ini files found for user %s', user) return # Copy to per-website directories created_any = False for location in INI_USER_LOCATIONS_WEBSITE_ISOLATION: for dir_path in iglob(location['path']): if is_excluded_path(dir_path): continue try: pw_record = location['user'](dir_path) if pw_record.pw_name != user: continue except Exception: logger.debug("Cannot get pw_record for path: %s", dir_path) continue php_ver = extract_php_version(dir_path) if not php_ver: continue if php_ver not in base_ini_files: continue content, uid, gid = base_ini_files[php_ver] ini_file = os.path.join(dir_path, INI_FILE_NAME) if not os.path.exists(os.path.dirname(ini_file)): continue try: _write_isolation_ini(ini_file, content, uid, gid, user_context_func) created_any = True logger.info('Created %s', ini_file) except Exception as e: logger.warning('Failed to create %s: %s', ini_file, str(e)) continue if reload_workers and created_any: _reload_user_php_processes(user) logger.info('Finished regenerating for user %s!', user)