Yohohohohohooho | Sanrei Aya
Sanrei Aya


Server : LiteSpeed
System : Linux barito.iixcp.rumahweb.net 5.14.0-611.49.1.el9_7.x86_64 #1 SMP PREEMPT_DYNAMIC Tue Apr 21 16:39:08 EDT 2026 x86_64
User : elvh3918 ( 1528)
PHP Version : 8.2.31
Disable Function : mail
Directory :  /opt/cloudlinux/venv/lib64/python3.11/site-packages/xray/reconfiguration/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Current File : //opt/cloudlinux/venv/lib64/python3.11/site-packages/xray/reconfiguration/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
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

# 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:
                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.
            try:
                fd = os.open(ini_file, os.O_RDONLY | os.O_NOFOLLOW)
            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 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"

    return "".join(list(update_line()))


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
    content = _generate_ini_with_counter(existing_contents, domain_task_count, php_version)

    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)

Yohohohohohooho | Sanrei Aya