File Manager

Path: /opt/cloudlinux/venv/lib/python3.11/site-packages/xray/reconfiguration/

Viewing File: website_isolation.py

# -*- coding: utf-8 -*-

# 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 X-Ray INI files.

This module provides functions to manage xray.ini files in per-website
directories when CageFS website isolation is enabled.
"""

import errno
import logging
import os
import pwd
import stat
from glob import iglob
from typing import Optional

from secureio import disable_quota
from clcommon.cpapi import docroot as get_docroot

from xray.internal.utils import user_context, cagefsctl_get_prefix
from .xray_ini import (
    is_excluded_path,
    get_domain_php_version_from_selector,
    INI_USER_LOCATIONS,
)

logger = logging.getLogger(__name__)

# Upper bound on the per-PHP-version xray.ini that root will slurp during
# website-isolation regeneration. Real xray.ini files are well under 1 KiB
# (a handful of "extension=" / "xray.tasks=" lines). 64 KiB is generous and
# blocks the user-staged sparse/oversized xray.ini that would otherwise drive
# the root daemon's RSS into the gigabytes when each PHP version is loaded
# into ``base_ini_files`` via ``f.read()``.
MAX_INI_SIZE = 64 * 1024

# Upper bound on the number of preserved lines folded back into a per-website
# xray.ini. A real xray.ini is a handful of lines (the ";xray.tasks=" counter,
# "extension=" and maybe a few user directives); 4096 is generous above any
# legitimate file while still refusing a tenant-staged file of millions of
# tiny lines whose per-string Python overhead would dwarf MAX_INI_SIZE.
MAX_INI_LINES = 4096

# Try to import website isolation check from securelve (cagefs)
# This is optional - if securelve is not installed, we assume website isolation is not available
try:
    from clcagefslib.domain import (
        is_website_isolation_allowed_server_wide,
        is_isolation_enabled,
        get_websites_with_enabled_isolation,
    )
    from clcagefslib.webisolation.jail_utils import get_website_id
except ImportError:

    def is_website_isolation_allowed_server_wide():
        return False

    def is_isolation_enabled(user):
        return False

    def get_websites_with_enabled_isolation(user):
        return []

    get_website_id = None


def is_per_website_php_selector(user: str, domain: str):
    if not is_website_isolation_allowed_server_wide():
        return False
    return domain in get_websites_with_enabled_isolation(user)


def _get_per_website_ini_path(user: str, website_id: str, php_ver_dir: str) -> Optional[str]:
    """
    Build path to xray.ini in per-website directory.
    :param user: Username
    :param website_id: Website ID hash
    :param php_ver_dir: PHP version directory (e.g., 'alt-php80')
    :return: Full path to xray.ini or None if cagefs prefix not available
    """
    prefix = cagefsctl_get_prefix(user)
    if prefix is None:
        return None
    return f'/var/cagefs/{prefix}/{user}/etc/cl.php.d/{website_id}/{php_ver_dir}/xray.ini'


def regenerate_ini_for_website_isolation(user: str, domain: str) -> None:
    """
    Copy xray.ini files from base user locations to per-website directories.

    This function is called by cagefsctl when enabling website isolation for a user.
    If xray.ini exists in base location (meaning user has active tasks), copy it
    to per-website directories. Overwrites existing files for consistency.

    Each domain may have a different PHP version set via cloudlinux-selector,
    so we determine the domain's actual PHP version from cl.selector symlinks.

    :param user: Username to regenerate ini files for
    """
    if not is_per_website_php_selector(user, domain):
        return

    # Collect existing base ini files for this user: {php_ver_dir: content}
    base_ini_files = {}
    uid = None
    gid = None
    for location in INI_USER_LOCATIONS:
        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
                uid = pw_record.pw_uid
                gid = pw_record.pw_gid
            except Exception:
                logger.debug("Cannot get pw_record for path: %s", dir_path)
                continue

            ini_file = os.path.join(dir_path, 'xray.ini')
            if not os.path.exists(ini_file):
                continue

            # O_NOFOLLOW + fstat owner check: the parent dir lives inside the
            # user's CageFS jail, so the user could replace xray.ini with a
            # symlink to a root-readable file (e.g. /etc/shadow) and have root
            # slurp its bytes here. Refuse to follow symlinks at the leaf, and
            # require the opened inode to be owned by the target user.
            # O_NONBLOCK makes opening a user-planted FIFO return immediately
            # instead of blocking the root worker (no-op for regular files);
            # the S_ISREG guard below then rejects the non-regular file.
            try:
                fd = os.open(ini_file, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
            except OSError as e:
                if e.errno == errno.ELOOP:
                    logger.warning("Refusing to follow symlink at %s", ini_file)
                    continue
                logger.error("Cannot open xray.ini for path: %s, error=%s", dir_path, str(e))
                continue
            try:
                st = os.fstat(fd)
                if not stat.S_ISREG(st.st_mode):
                    logger.warning(
                        "Refusing to read %s: not a regular file",
                        ini_file,
                    )
                    continue
                if st.st_uid != uid:
                    logger.warning(
                        "Refusing to read %s: owner uid %d != user uid %d",
                        ini_file,
                        st.st_uid,
                        uid,
                    )
                    continue
                if st.st_size > MAX_INI_SIZE:
                    logger.warning(
                        "Refusing to read %s: size %d bytes exceeds limit %d",
                        ini_file,
                        st.st_size,
                        MAX_INI_SIZE,
                    )
                    continue
                with os.fdopen(fd, 'r') as f:
                    fd = -1  # ownership transferred to fdopen
                    php_ver_dir = os.path.basename(dir_path)
                    # Bound the read explicitly: defends against a TOCTOU race
                    # where the file grows between fstat and read.
                    base_ini_files[php_ver_dir] = f.read(MAX_INI_SIZE)
            except OSError as e:
                logger.error("Cannot read xray.ini for path: %s, error=%s", dir_path, str(e))
                continue
            finally:
                if fd != -1:
                    try:
                        os.close(fd)
                    except OSError:
                        pass

    if not base_ini_files or uid is None:
        return

    docroot_result = get_docroot(domain)
    document_root = docroot_result[0]
    website_id = get_website_id(document_root)

    # Get domain's actual PHP version from cl.selector symlinks
    domain_php_ver = get_domain_php_version_from_selector(user, website_id)

    content = base_ini_files.get(domain_php_ver)
    if not content:
        # Fallback: use any available base ini content for domain-specific version
        content = next(iter(base_ini_files.values()), None)

    ini_path = _get_per_website_ini_path(user, website_id, domain_php_ver)

    # Ensure directory exists: it is configured normally once enabling per domain php version
    ini_dir = os.path.dirname(ini_path)
    if not os.path.exists(ini_dir):
        logger.info(f"Per-website ini directory does not exist: {ini_dir}")
        return
    try:
        with user_context(uid, gid), disable_quota(), open(ini_path, 'w') as f:
            f.write(content)
        logger.debug('Created %s for domain %s', ini_path, domain)
    except Exception as e:
        logger.error('Failed to create %s: %s', ini_path, e)


def _generate_ini_with_counter(existing_contents: Optional[list], counter: int, php_version: str = None) -> str:
    """
    Generate xray.ini content with a specific task counter value.

    :param existing_contents: Existing ini file lines or None for new file
    :param counter: Task counter value to set
    :param php_version: PHP version for extension path (used only for new files)
    :return: Generated ini file content
    """
    # Determine extension path based on PHP version
    # Short 2-digit versions get full path, others use generic xray.so
    if php_version is None or len(php_version) > 2:
        so_path = "xray.so"
    else:
        so_path = f"/opt/alt/php{php_version}/usr/lib64/php/modules/xray.so"

    if existing_contents is None:
        return f"""extension={so_path}
;xray.tasks={counter}\n"""

    def update_line():
        for line in existing_contents:
            if "xray.tasks" in line:
                yield f";xray.tasks={counter}\n"
            else:
                yield line + "\n"

    # Bound the preserved content before it is materialized: existing_contents
    # comes from a tenant-writable per-user xray.ini in the CageFS jail, and the
    # join runs in the root manager process. Accumulate with an early exit so an
    # over-sized file can never allocate more than ~MAX_INI_SIZE here.
    out = []
    total_bytes = 0
    for n, line in enumerate(update_line(), start=1):
        total_bytes += len(line.encode("utf-8", "surrogatepass"))
        if total_bytes > MAX_INI_SIZE or n > MAX_INI_LINES:
            raise ValueError(
                f"existing xray.ini too large to preserve (>{MAX_INI_SIZE} bytes or >{MAX_INI_LINES} lines)"
            )
        out.append(line)

    return "".join(out)


def update_website_isolation_ini(
    user: str,
    uid: int,
    gid: int,
    domain: str,
    domain_task_count: int,
    existing_contents: Optional[list] = None,
    php_version: str = None,
) -> None:
    """
    Update xray.ini file in per-website directory for a SPECIFIC domain.

    This function is called when a tracing task is added/updated for a domain.
    The ini file is placed in the directory matching the domain's PHP version
    as configured via cloudlinux-selector.

    Generates the ini content with domain-specific task counter, which may differ
    from the per-user counter when a user has tasks for multiple domains.

    :param user: Username
    :param uid: User ID for file ownership
    :param gid: Group ID for file ownership
    :param domain: Domain name (e.g., 'example.com') - REQUIRED
    :param domain_task_count: Number of tasks for this specific domain
    :param existing_contents: Existing per-user ini file lines (to preserve settings)
    :param php_version: PHP version for extension path (used only for new files)
    """
    if not is_per_website_php_selector(user, domain):
        return

    # Get website_id from domain's docroot
    try:
        docroot_result = get_docroot(domain)
        if not docroot_result:
            logger.debug('Failed to get docroot for domain %s', domain)
            return
        document_root = docroot_result[0]
        website_id = get_website_id(document_root)
    except Exception as e:
        logger.error('Failed to get website_id for domain %s: %s', domain, e)
        return

    if not website_id:
        return

    # Get domain's actual PHP version from cl.selector symlinks
    domain_php_ver = get_domain_php_version_from_selector(user, website_id)
    if not domain_php_ver:
        logger.debug('No specific PHP version set for domain %s, skipping', domain)
        return

    ini_path = _get_per_website_ini_path(user, website_id, domain_php_ver)
    if not ini_path:
        return

    # Ensure directory exists
    ini_dir = os.path.dirname(ini_path)
    if not os.path.isdir(ini_dir):
        logger.debug('Per-website ini directory does not exist: %s', ini_dir)
        return

    # Generate ini content with domain-specific task counter
    try:
        content = _generate_ini_with_counter(existing_contents, domain_task_count, php_version)
    except ValueError as e:
        logger.warning('Refusing to update %s: %s', ini_path, e)
        return

    try:
        with user_context(uid, gid), disable_quota(), open(ini_path, 'w') as f:
            f.write(content)
        logger.debug('Updated %s for domain %s (PHP %s, tasks=%d)', ini_path, domain, domain_php_ver, domain_task_count)
    except OSError as e:
        logger.error('Failed to update %s: %s', ini_path, e)


def remove_website_isolation_ini(user: str, domain: str) -> None:
    """
    Remove xray.ini file from per-website directory for a SPECIFIC domain.

    This function is called when the last tracing task for a domain is removed.
    The ini file is removed from the directory matching the domain's PHP version
    as configured via cloudlinux-selector.

    :param user: Username
    :param domain: Domain name (e.g., 'example.com') - REQUIRED
    """
    if not is_per_website_php_selector(user, domain):
        return

    # Get website_id from domain's docroot
    try:
        docroot_result = get_docroot(domain)
        if not docroot_result:
            logger.debug('Failed to get docroot for domain %s', domain)
            return
        document_root = docroot_result[0]
        website_id = get_website_id(document_root)
    except Exception as e:
        logger.error('Failed to get website_id for domain %s: %s', domain, e)
        return

    if not website_id:
        return

    # Get domain's actual PHP version from cl.selector symlinks
    domain_php_ver = get_domain_php_version_from_selector(user, website_id)
    if not domain_php_ver:
        logger.debug('No specific PHP version set for domain %s, skipping', domain)
        return

    ini_path = _get_per_website_ini_path(user, website_id, domain_php_ver)
    if not ini_path:
        return

    if not os.path.exists(ini_path):
        return

    try:
        pw_record = pwd.getpwnam(user)
        with user_context(pw_record.pw_uid, pw_record.pw_gid):
            os.unlink(ini_path)
        logger.debug('Removed %s for domain %s (PHP %s)', ini_path, domain, domain_php_ver)
    except Exception as e:
        logger.error('Failed to remove %s: %s', ini_path, e)