HEX
Server: Apache
System: Linux server2.voipitup.com.au 4.18.0-553.104.1.lve.el8.x86_64 #1 SMP Tue Feb 10 20:07:30 UTC 2026 x86_64
User: posscale (1027)
PHP: 8.2.29
Disabled: exec,passthru,shell_exec,system
Upload Files
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 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__)

# 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

            try:
                with open(ini_file) as f:
                    php_ver_dir = os.path.basename(dir_path)
                    base_ini_files[php_ver_dir] = f.read()
            except OSError as e:
                logger.error("Cannot read xray.ini for path: %s, error=%s", dir_path, str(e))
                continue

    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)