MOON
Server: Apache
System: Linux kvm.asjudinet.com 5.14.0-611.54.6.el9_7.x86_64 #1 SMP PREEMPT_DYNAMIC Fri May 15 04:23:18 EDT 2026 x86_64
User: asjudine (1001)
PHP: 8.0.30
Disabled: exec,passthru,shell_exec,system
Upload Files
File: //opt/cloudlinux/venv/lib/python3.11/site-packages/ssa/website_isolation.py
# 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/<pid>/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)