�PNG  IHDR22?�� pHYs  �� OiCCPPhotoshop ICC profilexڝSgTS�=���BK���KoR RB���&*! J�!��Q�EEȠ�����Q, � ��!���������{�kּ������>���� �H3Q5� �B�������.@� $p�d!s�#�~<<+"��x� �M��0���B�\���t�8K�@z�B�@F���&S�`�cb�P-`'������{[�!�� e�Dh;��V�EX0fK�9�-0IWfH���� � 0Q��){`�##x��F�W<�+��*x��<�$9E�[-qWW.(�I+6aa�@.�y�2�4���������x����6��_-��"bb���ϫp@�t~��,/��;�m��%�h^ �u��f�@����W�p�~<�5�j>{�-�]c�K'Xt����o��(�h���w��?�G�%�fI�q^D$.Tʳ?�D��*�A��,���� �`6�B$��BB d�r`)��B(�Ͱ*`/�@4�Qh��p.�U�=p�a��(�� A�a!ڈb�X#����!�H�$ ɈQ"K�5H1R�T UH�=r9�\F��;�2����G1���Q=� �C��7�F� �dt1�����r�=�6��Ыhڏ>C�0��3�l0.��B�8, �c˱"� ���V����cϱw�E� 6wB aAHXLXN�H� $4� 7 �Q�'"��K�&���b21�XH,#��/{�C�7$�C2'��I��T��F�nR#�,��4H#���dk�9�, +ȅ����3��!�[ �b@q��S�(R�jJ��4�e�2AU��Rݨ�T5�ZB���R�Q��4u�9̓IK�����hh�i��t�ݕN��W���G���w ��Ljg(�gw��L�Ӌ�T071���oUX*�*|�� �J�&�*/T����ު U�U�T��^S}�FU3S� Ԗ�U��P�SSg�;���g�oT?�~Y��Y�L�OC�Q��_�� c�x,!k ��u�5�&���|v*�����=���9C3J3W�R�f?�q��tN �(���~���)�)�4L�1e\k����X�H�Q�G�6������E�Y��A�J'\'Gg����S�Sݧ �M=:��.�k���Dw�n��^��Lo��y���}/�T�m���G X� $� �<�5qo</���QC]�@C�a�a�ᄑ��<��F�F�i�\�$�m�mƣ&&!&KM�M�RM��)�;L;L���͢�֙5�=1�2��כ߷`ZxZ,����eI��Z�Yn�Z9Y�XUZ]�F���%ֻ�����N�N���gð�ɶ�����ۮ�m�}agbg�Ů��}�}��= ���Z~s�r:V:ޚΜ�?}����/gX���3��)�i�S��Ggg�s�󈋉K��.�>.���Ƚ�Jt�q]�z���������ۯ�6�i�ܟ�4�)�Y3s���C�Q��? ��0k߬~OCO�g��#/c/�W�װ��w��a�>�>r��>�<7�2�Y_�7��ȷ�O�o�_��C#�d�z����%g��A�[��z|!��?:�e����A���AA�������!h�쐭!��Α�i�P~���a�a��~ '���W�?�p�X�1�5w��Cs�D�D�Dޛg1O9�-J5*>�.j<�7�4�?�.fY��X�XIlK9.*�6nl������� �{�/�]py�����.,:�@L�N8��A*��%�w%� y��g"/�6ш�C\*N�H*Mz�쑼5y$�3�,幄'���L Lݛ:��v m2=:�1����qB�!M��g�g�fvˬe����n��/��k���Y- �B��TZ(�*�geWf�͉�9���+��̳�ې7�����ᒶ��KW-X潬j9�������(�x��oʿ�ܔ���Ĺd�f�f���-�[����n �ڴ �V����E�/��(ۻ��C���<��e����;?T�T�T�T6��ݵa��n��{��4���[���>ɾ�UUM�f�e�I���?�������m]�Nmq����#�׹���=TR��+�G�����w- 6 U����#pDy��� �� :�v�{���vg/jB��F�S��[b[�O�>����z�G��499�?r����C�d�&����ˮ/~�����јѡ�򗓿m|������������x31^�V���w�w��O�| (�h���SЧ��������c3-� cHRMz%������u0�`:�o�_�F5IDATx��ytUս�?�;���fN$$��@B�!�k��� -*�gE���j�O�kQ�ʠ`U�B_ �AÐ0%�Ȕ�@r3ޛ;߳��$�@��v����^�s����������$��ߢ !�W��5�̱'-٘������4�>�Rƫ$�G��0U�H}�_ _� #��k�MJ/=�9Du *ڲ�k�PQ;0��j|�*�$�^컂脛y���>�z×� B��� ��(�:$�ʔ_�6��C!B����MVx��a��ζyY����j��ƋY2�9�F�{r��r�딟F��Q��hY6��� x��[l��RC��������nt@��;P��3��� F�w��7��:�P���ɋ'���mj���Bƪ�$�Q�L��y��߷g��\���z����v5�p�v$�v櫻�`6~K �Y�X����,�:��"�o�e0II"(��,������l_�C���/4�+f�E�!q��:����u����c��樼.UE�@� �]K�W���$-�h]�JG���Dz)�`��.�A/ ���mᏣc�3jK� �!�>���$�Q�!�_�? \�C2(E �M$Z I{�U���s�ǣ���=��@Բe ޻��W�����@ �w����}�9v,)��.}*|"�s��di���,���G�]W�AC�������|<����[��ߕ������K�*�G nߴ!��‹��J�!��6(L�a)��Am��������;adf��:�xm��nh�;֦I��(���o�C~ډa?�=��s����h��E������xp����)���_���t��W�����´;8���`dz=(��@@�+,�;�`�fhl��XȌ�3,uI�~z(J����� �ZX�A����H'�À�㜦�82|��a�n�z2��I���� ���EY�h�ε<�0�q�{��C��v�˅��?"@��χ�x�WS+��N�~�KQ�J�8ل�� _.W��� !TU{�����,��"�� �;�������HI S� ��))�o�1�H�D� ���� (� ;����"Ҕ��,���� �F=���O��&�Ph>�`���y��߲ &S��P�<��'��1s�1cĂ�N����2)����@�u��tv��<�+�-&�����h��[!FSG���' � z z0��<����8d&�����E0�,��<��"!P�b4��� �{S�����X����.�囤���T��hEF��X�q0"-<����h���]�Py*����F⩍��@v�˂�Y08�� .q��a��s����(!<̫1�3�L�H��7sYW�`��qtu�\7��?�@�Y�� %Ka�x�GZ�*W�P��y�����N0r9��Z�c�%���q,��k�����!!,��>���IB��� �x1�Xu�Y���v-���ֺ��N������JP��a�2`�e5��w��u;�6�i���̃��(K���ťa��(l51��JL��O'%���]���ٖ뿉!��{*�p+f ���i8��D�ȑ���ܩ�M��V��ӟz��x�7Z�1i}�^͜���6e����x��)u�d�ˏ�V�eG�\�X1�����=n�U'�~Y����L�\,�(d!x ,�:�TW8� M`r���@��r+v�Q0��>^;˴�VbF��('����� �*O��i��j�Ӆ����^�S3&� ���{������.ی�� N;��NU%d0�Ce\r"�C�b��&t7N�7��� �ȪD�[%l�۪�$��'m?�A9tI�9>��NVL ��� 6v~M�����ъ�B���0����9$&=ώm��W�0���L���&��E���f�f��'+�9��hv��7,���M��6�D�&�^� �����8 D֮�����o��k^z��O��hP�8q�5d*��YVq3�{��������#LX4�?��jP=3�~�؅c�ݰ�̱�ұ��8̲��2�G�����j��έ�9��w7���U�X�w�85C��?!I&���o&����L���I#�^�eV�,�^t5�k� �xs˛\��U�8��."���{���ǵ�l4��ݼ�;��r"���G�/~���gJV�N糦��ZZ^k+NN�D��I$�=OE�z����|*�*{��%k���G��>�`9��b;�s���8,�qs��c��.b��q�%�>r��n�R�Ƨ�%�,�A��le#�8���}; 21�0�b�&�QX�f���q�00����$>�h�N�'�a3��A���/�r ��L5�8�GԲ'�����w;��c#�q1���h$*��V^�k��QĽ�b:i������� ����H F#�_���,��]g����Ը����`RUU���� ��"�qD���>-��ϤI�~��3�̴�����NZ[[555�eeek7�m����YF#[��Đ��R��i��L�H�r ��@��J-�Pŝ��_|�-��6y��ks�V�U~��6���w.����(��0A� 4��f��� /���C�xŊ�ٳ����z=6�����rg���mKo��o�/����a�g���?'9%9��P��斓��~���L{�� �H��{AW��B��$����v�1b�&Mb�ر��ر#�0`�`xMBp! )Z�Nmo�mZ�g�}�eذaWTVV ���'99��C5��&�&+5�bT>|8��PUUETT����&���t��~TE%��'������b�tEGG;RSS�S�N�v��ѯ��t����u1@�ޚ"f��͈���r'L�Ph��m��ƌ�74o��`�x�q�P���NRSS�X,�����l&&&��MFFA!-��`��ҷ�n��Yuu�����x<I�LB%����KԒ50�!�3�bz�����ʸKL���;---�l6[��`��������Ȏ;�����egg4��!D�T��jWujyZ��������K����%����p�^���X,6��sV=�ܖ����6�3�X ���4ҿ��A2�&����_k�;d�� �VfOIEND�B`� 0x0x
PHP 8.1.34
Preview: cagefsctl-user Size: 23.27 KB
/usr/sbin/cagefsctl-user
#!/opt/cloudlinux/venv/bin/python3 -sbb
# -*- coding: utf-8 -*-
#
# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2025 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENCE.TXT
#
"""
User-level CLI utility for managing website isolation.

This script runs via proxyexec with root privileges but operates on behalf
of the calling user. It validates that the user only manages domains they own.

Commands:
    site-isolation-enable --domain DOMAIN[,DOMAIN2,...]   Enable site isolation for domain(s)
    site-isolation-disable --domain DOMAIN[,DOMAIN2,...]  Disable site isolation for domain(s)
    site-isolation-list                                   List domains with site isolation enabled

All commands output JSON:
    Success: {"result": "success", "enabled_sites": ["domain1", "domain2"]}
    Error:   {"result": "ERROR_CODE"}
"""

import argparse
import json
import logging
import os
import sys
import pwd

from clcommon.cpapi import domain_owner, userdomains
from clcommon.cpapi.cpapiexceptions import NoDomain

from clcagefslib.cli import (
    call_via_proxyexec,
    in_cagefs,
    is_running_via_proxyexec,
)
from clcagefslib.domain import (
    enable_website_isolation,
    disable_website_isolation,
    get_websites_with_enabled_isolation,
    isolation_moves,
    forget_panel_caches,
    is_website_isolation_allowed_server_wide,
    is_website_isolation_allowed_for_user,
)
from clcagefslib.fs import user_exists
from clcagefslib.multiphp_sync import (
    converge,
    refusal,
    notify_account,
    step_aside,
    sync_steps_aside,
)

# Logging configuration
LOG_FILE = "/var/log/cloudlinux/cagefsctl-user.log"

# Proxyexec alias for all cagefsctl-user commands
PROXYEXEC_ALIAS = "CAGEFSCTL_USER"


def _safe_for_log(value):
    """Escape control characters before interpolating untrusted values into a log line.

    The cagefsctl-user log uses a single-prefix formatter ('[LEVEL | ts]: %(message)s'),
    so an unescaped '\\n' inside a user-supplied value (e.g. `domain`) yields a forged
    second line that an admin reads as a separately-logged event. Apply to every
    user-controlled value reaching a logger.* call in this module.
    """
    if value is None:
        return value
    if isinstance(value, (list, tuple)):
        return type(value)(_safe_for_log(v) for v in value)
    if not isinstance(value, str):
        value = str(value)
    return value.encode("unicode_escape").decode("ascii")


def setup_logger():
    """
    Set up logging to file only (no console output).

    Returns:
        logging.Logger: Configured logger instance
    """
    logger = logging.getLogger("cagefsctl-user")
    logger.setLevel(logging.INFO)
    # Disable propagation to root logger to prevent console output
    logger.propagate = False
    try:
        fh = logging.FileHandler(LOG_FILE)
        fh.setFormatter(logging.Formatter(
            "[%(levelname)s | %(asctime)s]: %(message)s"
        ))
        logger.addHandler(fh)
    except (IOError, OSError):
        # Cannot write to log file, continue without file logging
        pass
    return logger


logger = setup_logger()


class ErrorCodes:
    """Error codes for JSON responses."""
    SITE_ISOLATION_NOT_ALLOWED = "Site isolation feature is not allowed"
    # CLOS-7001: distinct from NOT_ALLOWED and INTERNAL_ERROR — the
    # request was well-formed and permitted, but one or more domains
    # were skipped, so isolation is not in effect for them. `message`
    # carries "<domain>: <reason>" per skipped domain.
    SITE_ISOLATION_SKIPPED = "Site isolation was not applied for one or more domains"
    DOMAIN_NOT_FOUND = "Specified domain is not found"
    USER_NOT_FOUND = "User not found"
    INTERNAL_ERROR = "Internal error"
    MISSING_DOMAIN = "Domain is not specified"
    ROOT_NOT_ALLOWED = "Utility cannot be run as root"


# CLOS-7520: a machine-readable `code` for each ErrorCodes value. The values
# above are sentences that callers also show to people, so they are kept as
# `result` unchanged; `code` is what a caller branches on. Add entries, never
# rename one -- the same rule as isolatectl's codes, whose responses carry
# these through `isolatectl site-isolation`.
STABLE_CODES = {
    ErrorCodes.SITE_ISOLATION_NOT_ALLOWED: "not_allowed",
    ErrorCodes.SITE_ISOLATION_SKIPPED: "not_applied",
    ErrorCodes.DOMAIN_NOT_FOUND: "domain_not_found",
    ErrorCodes.USER_NOT_FOUND: "user_not_found",
    ErrorCodes.INTERNAL_ERROR: "internal_error",
    ErrorCodes.MISSING_DOMAIN: "missing_domain",
    ErrorCodes.ROOT_NOT_ALLOWED: "root_not_allowed",
}


def get_calling_user():
    """
    Get the username of the calling user from proxyexec environment.

    When running via proxyexec, PROXYEXEC_UID contains the original user's UID.
    Falls back to current process UID if not set.

    Returns:
        str: Username of the calling user
        None: If user cannot be determined
    """
    proxyexec_uid = os.environ.get("PROXYEXEC_UID")
    if not proxyexec_uid:
        return None
    try:
        uid = int(proxyexec_uid)
        pw = pwd.getpwuid(uid)
        return pw.pw_name
    except (ValueError, KeyError):
        return None


def json_response(result, enabled_sites=None, message=None):
    """
    Create a JSON response dictionary.

    Args:
        result: "success" or error code
        enabled_sites: Optional list of the account's currently isolated
            sites. Carried on SITE_ISOLATION_SKIPPED responses too, not
            only on success — a partial run has to report both halves
            (CLOS-7001).
        message: Optional error message with additional details

    Returns:
        dict: Response dictionary; an error also carries its STABLE_CODES
        ``code``
    """
    response = {"result": result}
    if result != "success":
        response["code"] = STABLE_CODES.get(result, "internal_error")
    if enabled_sites is not None:
        response["enabled_sites"] = enabled_sites
    if message is not None:
        response["message"] = message
    return response


def output_json(response):
    """Print JSON response to stdout."""
    print(json.dumps(response))


def validate_domain_ownership(username, domain):
    """
    Validate that a domain belongs to the specified user.

    Args:
        username: The username to check ownership for
        domain: The domain to validate

    Returns:
        tuple: (is_valid, error_code)
            is_valid: True if domain belongs to user
            error_code: Error code if validation fails, None otherwise
    """
    try:
        owner = domain_owner(domain)
        if owner is None:
            # Fallback: domain_owner() may not resolve subdomains
            # on some panels (e.g. DirectAdmin). Check via userdomains().
            user_domains = [d for d, _ in userdomains(username)]
            if domain in user_domains:
                return True, None
            return False, ErrorCodes.DOMAIN_NOT_FOUND
        if owner != username:
            return False, ErrorCodes.DOMAIN_NOT_FOUND
        return True, None
    except NoDomain:
        return False, ErrorCodes.DOMAIN_NOT_FOUND
    except Exception:
        return False, ErrorCodes.INTERNAL_ERROR


def get_validated_user():
    """
    Get the calling user and validate they exist.

    Returns:
        tuple: (username, error_code)
            username: The validated username, or None if validation failed
            error_code: Error code if validation failed, or None if successful
    """
    username = get_calling_user()
    if not username:
        logger.error("User not found")
        return None, ErrorCodes.USER_NOT_FOUND

    if not user_exists(username):
        logger.error("User %s does not exist", username)
        return None, ErrorCodes.USER_NOT_FOUND

    return username, None


def validate_domain_for_user(username, domain):
    """
    Validate domain argument and ownership for a user.

    Args:
        username: The username to check ownership for
        domain: The domain to validate

    Returns:
        tuple: (is_valid, error_code)
            is_valid: True if domain is valid and belongs to user
            error_code: Error code if validation failed, None otherwise
    """
    if not domain:
        logger.error("Missing domain argument")
        return False, ErrorCodes.MISSING_DOMAIN

    is_valid, error_code = validate_domain_ownership(username, domain)
    if not is_valid:
        logger.error("Domain validation failed: user=%s, domain=%s, error=%s",
                     username, _safe_for_log(domain), error_code)
        return False, error_code

    return True, None


def parse_domains(domain_arg):
    """
    Parse comma-separated domain argument into a list of domains.

    Args:
        domain_arg: Comma-separated domain string (e.g., "domain1.com,domain2.com")

    Returns:
        list: List of domain names, with whitespace stripped
    """
    if not domain_arg:
        return []
    return [d.strip() for d in domain_arg.split(",") if d.strip()]


def _step_aside(args, username, enabling):
    """CLOS-4727: have the MultiPHP sync hand its pins on the account back
    before an isolation change (a no-op without it), unless the caller
    converges the account itself. Returns the change to converge() after
    the toggle, and whether the toggle must not happen: a pin of the sync's
    left on the version a website is leaving serves it with the server-wide
    extensions until the sync re-mirrors it. Only for a toggle that moves a
    website (isolation_moves), the one it names or one the rewrite of the
    account's jail config moves: found in review, a no-op one was handed
    back and re-pinned, and could be refused where it used to succeed. A
    refusal names the domains whose toggle moves a website only: one that
    moves nothing is never refused, beside one that moves either (found in
    review). With no sync to step aside from, nothing is weighed, as in
    cagefsctl (found in the final review)."""
    if getattr(args, "no_multiphp_sync", False) or not sync_steps_aside():
        return None, False
    moving = [domain for domain in parse_domains(args.domain)
              if isolation_moves(username, [domain], enabling=enabling)]
    if not moving:
        return None, False
    change, refused = step_aside([username], paced=True)
    # the paced wait can take minutes: what the panel said of the document
    # roots before it is stale (found in review)
    forget_panel_caches()
    if refused:
        # CLOS-7001's skip format: "<domain>: <reason>" per domain
        # the sync's reason, not root's log, which the tenant cannot read
        # (found in review)
        why = refusal(refused[username], root=False)
        message = "; ".join(f"{domain}: {why}" for domain in moving)
        # CLOS-7001: a SKIPPED answer says what stays isolated; guarded as
        # the error paths read it
        try:
            enabled_sites = get_websites_with_enabled_isolation(username)
        except Exception:  # pylint: disable=broad-except
            enabled_sites = None
        logger.error("Site isolation not changed: user=%s, %s", username, why)
        output_json(json_response(ErrorCodes.SITE_ISOLATION_SKIPPED,
                                  enabled_sites, message=message))
    return change, bool(refused)


def cmd_site_isolation_enable(args):
    """Handle site-isolation-enable command."""
    domains = parse_domains(args.domain)
    logger.info("site-isolation-enable called: domains=%s", _safe_for_log(domains))

    if not domains:
        logger.error("No domains specified")
        output_json(json_response(ErrorCodes.MISSING_DOMAIN))
        return 1

    username, error = get_validated_user()
    if error:
        output_json(json_response(error))
        return 1

    if not is_website_isolation_allowed_server_wide():
        logger.error("Site isolation not allowed server-wide")
        output_json(json_response(ErrorCodes.SITE_ISOLATION_NOT_ALLOWED))
        return 1

    if not is_website_isolation_allowed_for_user(username):
        logger.error("Site isolation not allowed for user %s", username)
        output_json(json_response(ErrorCodes.SITE_ISOLATION_NOT_ALLOWED))
        return 1

    # Validate all domains first
    for domain in domains:
        is_valid, error = validate_domain_for_user(username, domain)
        if not is_valid:
            output_json(json_response(error))
            return 1

    change, refused = _step_aside(args, username, enabling=True)
    if refused:
        return 1
    # CLOS-7001: enable_website_isolation returns a reason string for
    # a domain it SKIPPED (invalid docroot, missing user). This path
    # already echoed the real enabled set rather than the requested
    # one, but still answered "success" with status 0 — so a panel
    # keying off either saw the operation as done.
    # Collected outside the `try` so the error path below can still
    # report what was skipped before the failure.
    skipped = []
    try:
        if change is not None and \
                not is_website_isolation_allowed_for_user(username):
            # denied while the sync stepped aside, a paced wait: the deny
            # stands (found in review), and the change still ends below
            logger.error("Site isolation not allowed for user %s", username)
            output_json(json_response(ErrorCodes.SITE_ISOLATION_NOT_ALLOWED))
            return 1
        for domain in domains:
            skip_reason = enable_website_isolation(username, domain)
            if skip_reason:
                skipped.append(f"{domain}: {skip_reason}")
        if change is None and len(skipped) < len(domains) and \
                not getattr(args, "no_multiphp_sync", False):
            # the domains now run PHP Selector versions of their own: have
            # lvemanager's MultiPHP sync mirror them (a no-op without it);
            # once per invocation, whatever the number of domains. A domain
            # CLOS-7001 skipped changed nothing to mirror, so a run that
            # skipped every one of them asks for no pass at all.
            notify_account(username, paced=True)
        enabled_sites = get_websites_with_enabled_isolation(username)
        if skipped:
            logger.error("Site isolation skipped: user=%s, skipped=%s, enabled_sites=%s",
                         username, _safe_for_log(skipped), _safe_for_log(enabled_sites))
            output_json(json_response(ErrorCodes.SITE_ISOLATION_SKIPPED,
                                      enabled_sites, message='; '.join(skipped)))
            return 1
        logger.info("Site isolation enabled: user=%s, domains=%s, enabled_sites=%s",
                    username, _safe_for_log(domains), _safe_for_log(enabled_sites))
        output_json(json_response("success", enabled_sites))
        return 0
    except Exception as e:
        # CLOS-7001: a hard failure on one domain must not discard the
        # report for the domains already processed — the guarantee the
        # `finally` gives cagefsctl's --isolates-enable. enabled_sites is
        # read back from config rather than echoed, so it is accurate
        # mid-loop; guarded, because the config the loop choked on is the
        # one this read touches and a second failure here must not
        # replace the failure being reported.
        try:
            enabled_sites = get_websites_with_enabled_isolation(username)
        except Exception:
            enabled_sites = None
        # Use logger.error (not logger.exception) so the auto-appended traceback
        # line — ExceptionType: str(exc) — does not bypass _safe_for_log when
        # the exception carries an attacker-controlled value in its message.
        logger.error("Failed to enable site isolation: user=%s, domains=%s, "
                     "skipped=%s, enabled_sites=%s, error=%s",
                     username, _safe_for_log(domains), _safe_for_log(skipped),
                     _safe_for_log(enabled_sites), _safe_for_log(e))
        output_json(json_response(ErrorCodes.INTERNAL_ERROR, enabled_sites,
                                  message='; '.join(skipped + [str(e)])))
        return 1
    finally:
        # the change the sync stepped aside for ends with its pass,
        # whatever happened to the toggle
        if change is not None:
            converge(change, paced=True)


def cmd_site_isolation_disable(args):
    """Handle site-isolation-disable command."""
    domains = parse_domains(args.domain)
    logger.info("site-isolation-disable called: domains=%s", _safe_for_log(domains))

    if not domains:
        logger.error("No domains specified")
        output_json(json_response(ErrorCodes.MISSING_DOMAIN))
        return 1

    username, error = get_validated_user()
    if error:
        output_json(json_response(error))
        return 1

    # Validate all domains first
    for domain in domains:
        is_valid, error = validate_domain_for_user(username, domain)
        if not is_valid:
            output_json(json_response(error))
            return 1

    change, refused = _step_aside(args, username, enabling=False)
    if refused:
        return 1
    try:
        for domain in domains:
            disable_website_isolation(username, domain)
        if change is None and not getattr(args, "no_multiphp_sync", False):
            # the domains fold back onto the account's PHP Selector
            # version: have the MultiPHP sync re-mirror them
            notify_account(username, paced=True)
        enabled_sites = get_websites_with_enabled_isolation(username)
        logger.info("Site isolation disabled: user=%s, domains=%s, enabled_sites=%s",
                    username, _safe_for_log(domains), _safe_for_log(enabled_sites))
        output_json(json_response("success", enabled_sites))
        return 0
    except Exception as e:
        logger.error("Failed to disable site isolation: user=%s, domains=%s, error=%s",
                     username, _safe_for_log(domains), _safe_for_log(e))
        output_json(json_response(ErrorCodes.INTERNAL_ERROR, message=str(e)))
        return 1
    finally:
        if change is not None:
            converge(change, paced=True)


def cmd_site_isolation_list(args):
    """Handle site-isolation-list command."""
    logger.info("site-isolation-list called")

    username, error = get_validated_user()
    if error:
        output_json(json_response(error))
        return 1

    try:
        enabled_sites = get_websites_with_enabled_isolation(username)
        logger.info("Site isolation list: user=%s, enabled_sites=%s",
                    username, _safe_for_log(enabled_sites))
        output_json(json_response("success", enabled_sites))
        return 0
    except Exception as e:
        logger.error("Failed to list site isolation: user=%s, error=%s",
                     username, _safe_for_log(e))
        output_json(json_response(ErrorCodes.INTERNAL_ERROR, message=str(e)))
        return 1


def _wrap_deprecated(func, old_name, new_name):
    """Return a wrapper that prints a deprecation warning then delegates."""
    def wrapper(args):
        print(f"WARNING: {old_name} is deprecated, use {new_name}",
              file=sys.stderr)
        return func(args)
    return wrapper


def _add_no_multiphp_sync(parser):
    parser.add_argument(
        "--no-multiphp-sync",
        action="store_true",
        help="Do not have lvemanager's MultiPHP <-> PHP Selector sync hand "
             "its versions back before the change, nor re-mirror the "
             "account after it; the caller does both itself (the LVE "
             "Manager UI does, once per bulk action)",
    )


def create_parser():
    """Create argument parser for cagefsctl-user."""
    parser = argparse.ArgumentParser(
        prog="cagefsctl-user",
        description="User-level CLI utility for managing CloudLinux Isolates.",
    )

    subparsers = parser.add_subparsers(
        title="commands",
        dest="command",
        help="Available commands",
    )

    # isolates-enable command (primary)
    enable_parser = subparsers.add_parser(
        "isolates-enable",
        help="Enable isolation for domain(s)",
    )
    enable_parser.add_argument(
        "--domain",
        required=True,
        help="Domain name(s) to enable isolation for (comma-separated)",
    )
    _add_no_multiphp_sync(enable_parser)
    enable_parser.set_defaults(func=cmd_site_isolation_enable)

    # isolates-disable command (primary)
    disable_parser = subparsers.add_parser(
        "isolates-disable",
        help="Disable isolation for domain(s)",
    )
    disable_parser.add_argument(
        "--domain",
        required=True,
        help="Domain name(s) to disable isolation for (comma-separated)",
    )
    _add_no_multiphp_sync(disable_parser)
    disable_parser.set_defaults(func=cmd_site_isolation_disable)

    # isolates-list command (primary)
    list_parser = subparsers.add_parser(
        "isolates-list",
        help="List domains with isolation enabled",
    )
    list_parser.set_defaults(func=cmd_site_isolation_list)

    # Deprecated aliases (backward compatible, print warning)
    dep_enable = subparsers.add_parser(
        "site-isolation-enable",
        help="(deprecated, use isolates-enable)",
    )
    dep_enable.add_argument("--domain", required=True, help=argparse.SUPPRESS)
    _add_no_multiphp_sync(dep_enable)
    dep_enable.set_defaults(func=_wrap_deprecated(
        cmd_site_isolation_enable, "site-isolation-enable", "isolates-enable"))

    dep_disable = subparsers.add_parser(
        "site-isolation-disable",
        help="(deprecated, use isolates-disable)",
    )
    dep_disable.add_argument("--domain", required=True, help=argparse.SUPPRESS)
    _add_no_multiphp_sync(dep_disable)
    dep_disable.set_defaults(func=_wrap_deprecated(
        cmd_site_isolation_disable, "site-isolation-disable", "isolates-disable"))

    dep_list = subparsers.add_parser(
        "site-isolation-list",
        help="(deprecated, use isolates-list)",
    )
    dep_list.set_defaults(func=_wrap_deprecated(
        cmd_site_isolation_list, "site-isolation-list", "isolates-list"))

    return parser


def main(argv=None):
    """Main entry point."""
    parser = create_parser()
    args = parser.parse_args(argv)

    # Guard: do not allow running as root unless via proxyexec
    # When running via proxyexec, PROXYEXEC_UID is set
    if os.getuid() == 0 and not is_running_via_proxyexec():
        logger.error("Direct root invocation not allowed")
        output_json(json_response(ErrorCodes.ROOT_NOT_ALLOWED))
        return 1

    # If running as user (not root via proxyexec)
    if os.getuid() != 0:
        if not in_cagefs():
            print("This utility is only available inside CageFS.\n"
                  "Please run it via: cagefs_enter cagefsctl-user <command>",
                  file=sys.stderr)
            return 1

        # Inside CageFS - call via proxyexec to get root privileges
        if not args.command:
            parser.print_help()
            return 1

        # Build args list for proxyexec
        args_list = sys.argv[1:]  # Pass all original args
        result = call_via_proxyexec(PROXYEXEC_ALIAS, args_list)
        if result is None:
            output_json(json_response(
                ErrorCodes.INTERNAL_ERROR,
                message="Failed to execute via proxyexec"
            ))
            return 1
        return result

    # Running as root via proxyexec - execute the command
    if not hasattr(args, "func"):
        parser.print_help()
        return 1

    return args.func(args)


if __name__ == "__main__":
    sys.exit(main())

Directory Contents

Dirs: 0 × Files: 39
Name Size Perms Modified Actions
23.27 KB lrwxr-xr-x 2026-10-16 11:37:02
Edit Download
1.83 KB lrwxr-xr-x 2026-10-16 11:37:02
Edit Download
39.55 KB lrwxr-xr-x 2026-09-10 17:32:18
Edit Download
654 B lrwxr-xr-x 2026-10-16 11:40:35
Edit Download
15.13 KB lrwxr-xr-x 2025-05-14 14:50:08
Edit Download
15.11 KB lrwxr-xr-x 2026-04-09 05:18:58
Edit Download
255 B lrwxr-xr-x 2026-04-09 05:18:52
Edit Download
15.11 KB lrwxr-xr-x 2026-04-09 05:18:58
Edit Download
15.10 KB lrwxr-xr-x 2026-04-09 05:18:58
Edit Download
994 B lrwxr-xr-x 2015-08-18 18:41:16
Edit Download
27.26 KB lrwxr-xr-x 2026-10-26 10:51:26
Edit Download
4.41 KB lrwxr-xr-x 2026-09-21 22:19:16
Edit Download
23.18 KB lrwxr-xr-x 2026-09-08 12:43:08
Edit Download
774.32 KB lrwxr-xr-x 2026-04-05 08:31:01
Edit Download
13.04 KB lrwxr-xr-x 2026-10-16 09:06:37
Edit Download
1.12 MB lrwxr-xr-x 2026-10-08 20:49:33
Edit Download
683 B lrwxr-xr-x 2026-10-16 09:06:37
Edit Download
23.21 KB lrwxr-xr-x 2026-09-08 12:43:08
Edit Download
39.35 KB lrwxr-xr-x 2026-10-26 10:51:26
Edit Download
39.35 KB lrwxr-xr-x 2026-10-26 10:51:26
Edit Download
15.12 KB lrwxr-xr-x 2026-10-26 10:51:26
Edit Download
15.20 KB lrwxr-xr-x 2026-10-26 10:51:26
Edit Download
43.51 KB lrwxr-xr-x 2026-09-08 12:43:08
Edit Download
471 B lrwxr-xr-x 2026-09-08 12:42:58
Edit Download
15.13 KB lrwxr-xr-x 2026-09-08 12:43:08
Edit Download
19.39 KB lrwxr-xr-x 2025-09-25 11:46:37
Edit Download
24.01 KB lr-xr-xr-x 2026-08-17 19:25:18
Edit Download
19.20 KB lrwxr-xr-x 2026-09-08 12:43:08
Edit Download
88.33 KB lrwxr-xr-x 2025-09-25 11:46:37
Edit Download
15.27 KB lrwxr-xr-x 2025-09-25 11:46:37
Edit Download
15.24 KB lrwxr-xr-x 2025-09-25 11:46:37
Edit Download
4.42 KB lrwxr-xr-x 2026-09-21 22:19:17
Edit Download
35.22 KB lrwxr-xr-x 2026-04-10 02:30:12
Edit Download
35.33 KB lrwxr-xr-x 2026-04-10 02:30:12
Edit Download
15.16 KB lrwxr-xr-x 2025-09-25 11:46:37
Edit Download
36.03 KB lrwxr-xr-x 2022-02-11 11:52:55
Edit Download
27.26 KB lrwxr-xr-x 2026-10-26 10:51:26
Edit Download
23.29 KB lrwxr-xr-x 2026-09-08 12:43:08
Edit Download
31.33 KB lrwx------ 2026-09-08 12:43:08
Edit Download
If ZipArchive is unavailable, a .tar will be created (no compression).
© 2026 0xNothings — Secure File Manager. All rights reserved. Built with ❤️ & Tailwind x Dark UI