403Webshell
Server IP : 51.79.230.26  /  Your IP : 216.73.217.128
Web Server : LiteSpeed
System : Linux sg2.exonhost.com 5.14.0-611.55.1.el9_7.x86_64 #1 SMP PREEMPT_DYNAMIC Tue May 19 15:19:29 EDT 2026 x86_64
User : mukutpub ( 1151)
PHP Version : 8.2.33
Disable Function : eval, show_source, system, shell_exec, passthru, exec, popen, proc_open, allow_url_fopen, symlink, mail
MySQL : OFF  |  cURL : ON  |  WGET : ON  |  Perl : ON  |  Python : OFF  |  Sudo : OFF  |  Pkexec : OFF
Directory :  /opt/cloudlinux/venv/lib/python3.11/site-packages/ssa/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /opt/cloudlinux/venv/lib/python3.11/site-packages/ssa/agent.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

"""
This module contains contains classes implementing SSA Agent behaviour
"""

import atexit
import json
import logging
import pwd
import re
import socket as socket_module
import struct
import time
from concurrent.futures import ThreadPoolExecutor
from threading import BoundedSemaphore, Lock, current_thread

from clcommon.cpapi import (
    cpusers,
    domain_owner,
    get_domains_php_info,
    get_main_username_by_uid,
)

from .internal.constants import agent_sock
from .internal.exceptions import SSAError
from .internal.panel_data_snapshot import SnapshotUnavailable, build_panel_auth_snapshot
from .internal.utils import canonical_stored_domain, create_socket
from .modules.processor import RequestProcessor

# Maximum number of concurrent worker threads for handling requests.
# Limits memory usage on high-traffic servers.
MAX_WORKERS = 50

# Upper bound on bytes accepted from a single connection.
# The PHP extension's JSON payload is always under 1 KB; 8 KB gives
# ample headroom while preventing unbounded reads.
MAX_MSG_SIZE = 8192

# Pre-auth read budget: seconds a peer has to finish sending its payload
# before the worker+admission slot it holds is reclaimed. The whole read in
# handle() runs BEFORE any sender validation, so on the world-writable socket
# an idle/drip-feeding peer can pin a worker for this long with O(1) effort
# (slow-read / Slowloris primitive). The per-UID admission cap bounds how many
# such slots one UID holds concurrently, but not how long each is held, so this
# budget is kept tight. The legitimate PHP client writes its <1 KB payload in
# one shot and closes (see src/processors.c: ssa_process_request), so the read
# returns on EOF within milliseconds; the client's own SO_SNDTIMEO/SO_RCVTIMEO
# is SSA_IO_TIMEOUT_SEC=2, so a well-behaved sender never needs more than 2s.
SOCKET_READ_TIMEOUT = 2

# Largest value storable in a SQLite signed 64-bit INTEGER column. Metric
# values above this raise OverflowError on bulk insert and would poison the
# shared flush buffer, so they must be rejected during input validation.
_MAX_SQLITE_INT = 2**63 - 1

# Bounded admission control. ThreadPoolExecutor.submit() enqueues into an
# UNBOUNDED queue.SimpleQueue, so a local connect-flood on the world-writable
# socket would back up accepted-but-unhandled connections without limit and
# grow the agent's memory toward MemoryMax=1G (OOM/restart of the telemetry
# daemon). Cap in-flight + queued work at the worker count plus a small queue:
# past that, the accept loop closes the connection instead of queueing it.
MAX_PENDING = MAX_WORKERS
MAX_INFLIGHT = MAX_WORKERS + MAX_PENDING

# Per-UID admission cap. The socket is world-writable by design, so without
# this a single local UID can hold every MAX_INFLIGHT slot before any sender
# validation runs (the peer UID is only checked inside handle(), after a worker
# and slot are already committed), starving every other tenant's telemetry —
# a cross-principal denial of monitoring. Cap one peer UID at half the worker
# pool so no single UID can monopolise the executor; the remainder always stays
# available to other UIDs. Over-cap connections are dropped (best-effort
# telemetry). Privileged service peers (_PRIVILEGED_SERVICE_UIDS — root
# collectors, the LiteSpeed/Apache master) have no single-tenant identity and
# are exempt.
MAX_INFLIGHT_PER_UID = MAX_WORKERS // 2

# Sentinel default for handle()'s admission-release argument. It distinguishes
# "this connection went through the accept loop and holds an admission slot to
# release" (admitted_uid is an int or None) from "handle() was invoked directly
# without going through listen() and holds no slot" (admitted_uid is
# _NO_ADMISSION), so the finally block never over-releases the semaphore.
_NO_ADMISSION = object()


class SimpleAgent:
    """
    SSA Simple Agent class
    """

    def __init__(self):
        self.logger = logging.getLogger('agent')
        self.request_processor = RequestProcessor()
        self.executor = ThreadPoolExecutor(max_workers=MAX_WORKERS)
        # Counts in-flight + queued connections; bounds the executor's
        # otherwise-unbounded work queue (admission control).
        self._admission = BoundedSemaphore(MAX_INFLIGHT)
        # Per-UID in-flight counters (peer UID -> count), guarded by a lock:
        # incremented by the accept loop at admission and decremented by
        # handle()'s finally when a worker completes the connection. Bounds how
        # many admission slots any single UID can hold (MAX_INFLIGHT_PER_UID).
        self._per_uid_lock = Lock()
        self._per_uid_inflight = {}
        # Shared-web service UIDs (nobody/apache) resolved from pwd on first use
        # and cached for the process lifetime; None until resolved. Used to gate
        # the shared-handler owner-mismatch exemption in _authorize_sender.
        self._shared_web_uids_cache = None
        # Rate-limiting state for repetitive per-peer decision logs (accept/
        # reject), keyed by peer UID and guarded by its own lock so one local
        # peer cannot flood the agent log.
        self._log_rate_lock = Lock()
        self._log_rate_state = {}
        # Stat-validated snapshot of the panel authorization data (cPanel
        # only; None elsewhere -> fresh per-payload lookups). See
        # internal/panel_data_snapshot.py and _authorize_sender's docstring.
        self._panel_snapshot = build_panel_auth_snapshot(self.logger)
        atexit.register(self._shutdown)
        # start serving incoming connections
        self.listen()

    def _shutdown(self):
        """Gracefully shutdown the thread pool executor."""
        self.executor.shutdown(wait=False)

    def _try_admit_uid(self, peer_uid: int) -> bool:
        """
        Reserve a per-UID admission slot for peer_uid. Privileged service
        peers (_PRIVILEGED_SERVICE_UIDS — root collectors / the LiteSpeed/
        Apache master) have no single-tenant identity and legitimately serve
        every domain, so they are exempt. Returns False when peer_uid already
        holds MAX_INFLIGHT_PER_UID in-flight connections, so one local UID
        cannot monopolise the executor and deny other tenants' telemetry.
        """
        if peer_uid in self._PRIVILEGED_SERVICE_UIDS:
            return True
        with self._per_uid_lock:
            if self._per_uid_inflight.get(peer_uid, 0) >= MAX_INFLIGHT_PER_UID:
                return False
            self._per_uid_inflight[peer_uid] = self._per_uid_inflight.get(peer_uid, 0) + 1
        return True

    def _release_uid(self, peer_uid: int) -> None:
        """Release a per-UID admission slot reserved by _try_admit_uid."""
        if peer_uid in self._PRIVILEGED_SERVICE_UIDS:
            return
        with self._per_uid_lock:
            remaining = self._per_uid_inflight.get(peer_uid, 0) - 1
            if remaining > 0:
                self._per_uid_inflight[peer_uid] = remaining
            else:
                self._per_uid_inflight.pop(peer_uid, None)

    def _release_admission(self, peer_uid) -> None:
        """Release the global admission slot and, when the connection was
        attributed to a UID, its per-UID slot."""
        self._admission.release()
        if peer_uid is not None:
            self._release_uid(peer_uid)

    # Usernames under which shared/DSO PHP runs as the SHARED web UID rather
    # than the per-account UID (cPanel/LiteSpeed DSO 'nobody', Apache-module
    # 'apache'). Resolved to numeric UIDs once, lazily, from pwd.
    _SHARED_WEB_USERNAMES = ('nobody', 'apache')

    def _shared_web_uids(self) -> frozenset:
        """
        Numeric UIDs of the shared web service accounts (nobody/apache),
        resolved from pwd and cached for the process lifetime. Usernames absent
        from the passwd database are skipped (KeyError tolerated), so this is
        robust on panels/OSes that ship only one of them. The shared-handler
        owner-mismatch exemption in _authorize_sender applies ONLY when the peer
        UID is one of these — a per-user tenant peer (its own account UID) can
        never claim it, closing the cross-tenant spoof where one tenant reports
        another tenant's dso/mod_php/module domain.
        """
        if self._shared_web_uids_cache is None:
            uids = set()
            for name in self._SHARED_WEB_USERNAMES:
                try:
                    uids.add(pwd.getpwnam(name).pw_uid)
                except KeyError:
                    pass
            self._shared_web_uids_cache = frozenset(uids)
        return self._shared_web_uids_cache

    # Repetitive per-connection lines (reject/admission-drop/data-level
    # failures) are rate-limited so local peers cannot flood the agent log:
    # at most _LOG_RATE_MAX lines per key AND _LOG_RATE_CLASS_MAX lines per
    # message class across all keys, per _LOG_RATE_WINDOW seconds, then a
    # one-line per-class suppression summary when that class's window next
    # rolls over. The class ceiling is what bounds the MANY-UID case: per-key
    # budgets alone multiply by the number of active tenant UIDs (!75
    # review). This also bounds Sentry event captures (LoggingIntegration
    # event_level=WARNING captures a full event per WARNING-or-above record).
    # Keys are ('message-class', peer_uid) tuples — one budget per class per
    # peer — or a bare class string for server-global conditions: one peer's
    # flood in a class must never starve another peer's or another class's
    # lines, every class's lines are homogeneous so the rollover summary is
    # emitted at that class's own level (keeping suppressed-WARNING visibility
    # in Sentry), and peer/panel usernames never collide with class-string
    # keys. Every peer-triggerable per-connection line goes through this
    # WHATEVER its level — the accepted-payload INFO, and the
    # malformed-payload ERROR which a flood of garbage bytes fires once per
    # connection. The only unlimited path is the unexpected-exception
    # logger.exception fallback: it is not triggerable by payload data, and
    # losing one would hide agent bugs.
    _LOG_RATE_WINDOW = 60
    _LOG_RATE_MAX = 20
    # Cross-peer aggregate ceiling per message class. Per-key budgets alone
    # scale with the number of active peer UIDs — ~2570 tenants on the
    # GreenGeeks-class box would still permit 2570 × _LOG_RATE_MAX = 51,400
    # lines/min for one class, recreating the logging-lock/Sentry pressure the
    # budgets exist to prevent (!75 review). A class's TOTAL emission across
    # ALL keys is therefore additionally capped per window; the overflow is
    # counted and reported as ONE class-level summary line per window, so the
    # whole-agent worst case is bounded by (number of classes) ×
    # (_LOG_RATE_CLASS_MAX + 1 summary) per window regardless of UID count.
    _LOG_RATE_CLASS_MAX = 100

    def _log_rate_limited(self, key, level: str, msg: str, *args) -> None:
        """
        Emit ``self.logger.<level>(msg, *args)`` under two budgets per
        _LOG_RATE_WINDOW: at most _LOG_RATE_MAX lines per *key* — a
        ``('message-class', peer_uid)`` tuple, or a bare class string for
        server-global conditions (see the class-comment above
        _LOG_RATE_WINDOW) — and at most _LOG_RATE_CLASS_MAX lines per message
        CLASS across all of that class's keys. Suppressed lines are counted
        per class and reported in a single class-level summary line (at the
        same level, since a class's lines are homogeneous) when that class's
        next window opens. Thread-safe (guarded by _log_rate_lock), matching
        the module's lock discipline. State is kept per class (roughly ten of
        them), with the per-key counters reset each window, so the state dict
        cannot grow with the UID space.
        """
        now = time.monotonic()
        cls = key[0] if isinstance(key, tuple) else key
        emit = False
        summary = None
        with self._log_rate_lock:
            state = self._log_rate_state.get(cls)
            if state is None or now - state['start'] >= self._LOG_RATE_WINDOW:
                if state is not None and state['suppressed']:
                    summary = (state['suppressed'], len(state['suppressed_keys']))
                state = {'start': now, 'emitted': 0, 'per_key': {}, 'suppressed': 0, 'suppressed_keys': set()}
                self._log_rate_state[cls] = state
            key_emitted = state['per_key'].get(key, 0)
            if key_emitted < self._LOG_RATE_MAX and state['emitted'] < self._LOG_RATE_CLASS_MAX:
                state['per_key'][key] = key_emitted + 1
                state['emitted'] += 1
                emit = True
            else:
                state['suppressed'] += 1
                state['suppressed_keys'].add(key)
        if summary:
            getattr(self.logger, level)(
                '[%s] suppressed %d repetitive log line(s) from %d key(s) for class %r',
                current_thread().name,
                summary[0],
                summary[1],
                cls,
            )
        if emit:
            getattr(self.logger, level)(msg, *args)

    def listen(self) -> None:
        """
        Start listening socket
        """
        _socket = create_socket(agent_sock)
        while True:
            connection, _address = _socket.accept()
            if not self._admission.acquire(blocking=False):
                # Pool + bounded queue saturated: refuse rather than queue
                # unbounded work. The peer (best-effort telemetry) just drops
                # this sample; the agent's memory stays bounded under a flood.
                # Rate-limited: this fires once PER DROPPED CONNECTION inside
                # the single accept loop, and Sentry's LoggingIntegration
                # (event_level=WARNING) captures a full event per WARNING —
                # unlimited, a connect-flood turned the accept loop into a
                # Sentry serializer and made the overload worse (GreenGeeks
                # support case: the loop spent most of its samples in
                # sentry_sdk during saturation).
                self._log_rate_limited(
                    'admission-saturated',
                    'warning',
                    '[Admission] pool saturated (%d in-flight); dropping connection',
                    MAX_INFLIGHT,
                )
                connection.close()
                continue
            # Resolve the kernel-trusted peer UID cheaply (SO_PEERCRED, no
            # payload read) and enforce a per-UID cap BEFORE a worker is
            # committed: sender validation only happens later inside handle(),
            # so without this one local UID could hold every slot with idle
            # connections and deny all other tenants' telemetry. A connection
            # whose credentials cannot be read is not dropped here (it cannot
            # be attributed to a UID); handle() closes it on the same failure.
            try:
                peer_uid = self._get_peer_uid(connection)
            except (OSError, struct.error) as e:
                self.logger.debug('[Admission] peer credentials unreadable, skipping per-UID cap: %s', str(e))
                peer_uid = None
            if peer_uid is not None and not self._try_admit_uid(peer_uid):
                # Rate-limited for the same reason as the pool-saturated line
                # above (per-drop WARNING in the accept loop + Sentry event per
                # WARNING); keyed by class + the dropped peer's UID so this
                # WARNING keeps its own budget — the same peer's accepted-
                # payload INFO lines must not starve it (the cap only trips on
                # busy peers, exactly when those INFO lines are most frequent).
                self._log_rate_limited(
                    ('admission-uid-cap', peer_uid),
                    'warning',
                    '[Admission] per-UID cap (%d) reached for UID=%d; dropping connection',
                    MAX_INFLIGHT_PER_UID,
                    peer_uid,
                )
                connection.close()
                self._admission.release()
                continue
            try:
                # Pass peer_uid so handle() releases exactly the admission
                # slot(s) reserved above (global + per-UID) in its finally,
                # once per handled connection.
                self.executor.submit(self.handle, connection, peer_uid)
            except RuntimeError as e:
                # CPython's ThreadPoolExecutor.submit() enqueues the work
                # item BEFORE calling _adjust_thread_count(); a RuntimeError
                # from Thread.start() (e.g. the cgroup pids controller
                # rejecting a new thread because TasksMax was hit,
                # CLPRO-3118) therefore leaves the work item in the queue,
                # and existing workers will drain it. Do NOT close the
                # connection here — that would race with a worker that may
                # yet process it and turn a real burst-induced failure into
                # spurious "Bad file descriptor" errors. Do NOT release the
                # admission slot here either: the slot is released by handle()'s
                # finally when a worker actually runs the queued item. If
                # thread-starts keep failing and no worker ever runs it, the
                # slot stays held and the gate correctly REFUSES new connections
                # (bounded, fail-safe) rather than letting the executor's queue
                # grow past the admission bound. Killing the agent would only
                # trigger a restart loop, so just log and keep accepting.
                # Rate-limited even though it is an ERROR: in a partial
                # TasksMax incident (workers still draining and releasing
                # slots) this fires once per admitted connection inside the
                # single accept loop — unlimited, each one is also a Sentry
                # event, the exact amplification bounded everywhere else.
                self._log_rate_limited(
                    'threadpool-submit',
                    'error',
                    '[ThreadPool] submit raised (work item queued for existing workers): %s',
                    str(e),
                )
                continue
            self.logger.debug('[ThreadPool] Submitted task')

    # Fields that the PHP extension sends (see dump.c: ssa_agent_dump)
    _REQUIRED_FIELDS = frozenset(
        {'timestamp', 'url', 'duration', 'hitting_limits', 'throttled_time', 'io_throttled_time', 'wordpress'}
    )

    # 8190 matches Apache's default LimitRequestLine, which is the effective
    # upper bound on URLs reaching the PHP extension in practice.
    _MAX_URL_LENGTH = 8190
    _URL_RE = re.compile(r'^https?\??://[^\x00-\x1f\s<>"{}|\\^`\[\]]+\Z')

    @staticmethod
    def _get_peer_uid(connection: socket_module.socket) -> int:
        """
        Get the UID of the peer process using SO_PEERCRED.
        :param connection: socket object
        :return: UID of the connecting process
        """
        cred = connection.getsockopt(socket_module.SOL_SOCKET, socket_module.SO_PEERCRED, struct.calcsize('3i'))
        _pid, uid, _gid = struct.unpack('3i', cred)
        return uid

    @classmethod
    def _validate_input(cls, data: dict) -> bool:
        """
        Validate that input data contains exactly the expected metric fields
        with the correct value types. The PHP extension always sends all 7
        fields (see dump.c), so we require an exact key match and enforce
        the types produced by the C formatter to reject both malformed and
        spoofed payloads.
        """
        if not isinstance(data, dict) or not data:
            return False
        if set(data.keys()) != cls._REQUIRED_FIELDS:
            return False
        if not isinstance(data['timestamp'], str) or not data['timestamp'].isascii() or not data['timestamp'].isdigit():
            return False
        if not isinstance(data['url'], str) or not data['url']:
            return False
        if len(data['url']) > cls._MAX_URL_LENGTH:
            return False
        if not cls._URL_RE.match(data['url']):
            return False
        if (
            isinstance(data['duration'], bool)
            or not isinstance(data['duration'], int)
            or not 0 <= data['duration'] <= _MAX_SQLITE_INT
        ):
            return False
        if not isinstance(data['hitting_limits'], bool):
            return False
        if (
            isinstance(data['throttled_time'], bool)
            or not isinstance(data['throttled_time'], int)
            or not 0 <= data['throttled_time'] <= _MAX_SQLITE_INT
        ):
            return False
        if (
            isinstance(data['io_throttled_time'], bool)
            or not isinstance(data['io_throttled_time'], int)
            or not 0 <= data['io_throttled_time'] <= _MAX_SQLITE_INT
        ):
            return False
        return isinstance(data['wordpress'], bool)

    # Peer UIDs that legitimately serve every tenant and therefore have no
    # single per-tenant identity to enforce: uid 0 (root) — the LiteSpeed/
    # Apache master and root-owned telemetry collectors. Such a peer is
    # trusted for any domain; every OTHER non-tenant peer (a shared DSO
    # worker such as nobody/apache, or an unmappable UID) is still subjected
    # to the cross-tenant owner check below.
    _PRIVILEGED_SERVICE_UIDS = frozenset({0})

    # PHP handlers under which PHP runs as the SHARED web UID (nobody/apache)
    # rather than the per-account UID. Enumerated across every clcommon.cpapi
    # backend: cPanel 'dso' (WHM php_get_handlers current_handler; mod_php),
    # DirectAdmin 'mod_php' (custombuild php{N}_mode), and Plesk 'module' (the
    # Apache-module handler — plesk.get_domains_php_info passes the raw <type>
    # through verbatim, so PHP runs as the shared `apache` UID). For a domain on
    # one of these a shared-UID peer (nobody/apache) IS the legitimate server,
    # so an owner-UID mismatch is expected and must NOT be treated as a spoof.
    # Every other handler (suphp/cgi/lsapi/fpm/fastcgi/php-fpm/x-httpd-*lsphp)
    # runs PHP as the per-user account UID.
    _SHARED_UID_HANDLERS = frozenset({'dso', 'mod_php', 'module'})

    def _authorize_sender(self, peer_uid: int, url: str) -> bool:
        """
        Authenticate the sender against the domain it reports for.

        The socket is world-writable by design (PHP workers run under
        arbitrary tenant UIDs), so the peer UID is the only sender identity
        available. The sender is classified by panel hosting-account
        membership, NOT by a numeric UID threshold: a UID_MIN cutoff is wrong
        panel/OS-dependently — DirectAdmin's `webapps` (uid 1001) and `admin`
        (uid 1000) sit at/above UID_MIN yet own no per-tenant identity, and
        cPanel/LiteSpeed on EL8/EL9 runs DSO/non-suEXEC PHP as `nobody`
        (uid 65534, not 99), also ≥ UID_MIN. A threshold would false-reject
        all of their legitimate metrics.

        Instead the peer's UID is resolved to a username via the panel
        (get_main_username_by_uid) and checked against the panel's
        authoritative tenant set (cpusers(), via _panel_tenant_users).
        A peer whose UID is a privileged root-owned service
        (_PRIVILEGED_SERVICE_UIDS — uid 0: the LiteSpeed/Apache master,
        root-run collectors) has no per-tenant identity to enforce and is
        trusted for any domain. Every OTHER peer — a hosting account, a
        shared DSO worker (apache, nobody=65534), a service account
        (webapps), or a UID that maps to no panel user — is subjected to a
        POSITIVE cross-tenant mismatch check: the reported domain's owner
        resolves to a system UID different from the peer UID. A mismatch is NOT
        rejected unconditionally, because it is also the legitimate shape of
        DSO/mod_php self-telemetry: on a DSO domain PHP runs as the shared web
        UID (nobody) while the domain's OWNER is the account, so owner_uid !=
        peer_uid is expected. The mismatch is therefore disambiguated by the
        REPORTED domain's own PHP handler (get_domains_php_info keyed on the
        reported domain, see _reported_domain_allows_shared_peer): handlers are
        PER-DOMAIN, so get_domains_php_info maps every domain — main, addon, sub
        and alias — to its own handler_type. A shared/DSO handler ('dso'/
        'mod_php'/'module') on the reported domain means the shared-UID peer
        legitimately serves it -> accept (DSO self-telemetry preserved); a
        per-user handler (suphp/cgi/lsapi/fpm/…) means PHP runs as the account
        UID, so a shared/service peer reporting it is a spoof -> reject. Keying
        on the REPORTED domain rather than the account's main domain is required:
        a mixed account can serve a shared handler on its main domain yet a
        per-user handler on a secondary/addon domain, so classifying by the main
        domain would admit a spoof on the account's per-user secondary domains.
        This closes the mixed-config spoof where a shared/service UID reports
        metrics for a per-user tenant's domain it does not own, without dropping
        legitimate DSO metrics. When ownership
        cannot be established (domain_owner raises, returns nothing, or names a
        user absent from the passwd database — panel DB/file unavailable, domain
        mid-provisioning, parked/alias domains) we fail OPEN (accept + warn).
        This fail-open-on-unresolvable is a deliberate product choice:
        dropping such payloads buys no security over the confirmed spoof
        (which needs a resolvable owner that differs) while silently losing
        legitimate metrics. If cpusers() itself fails the tenant set degrades
        to empty, but the owner check still runs for non-privileged peers, so
        a positive cross-tenant mismatch on a per-user-handler domain is still
        rejected.

        Ownership is compared by UID, NOT by username string (CLPRO-3231).
        The peer identity is the kernel-trusted SO_PEERCRED UID; with per-user
        PHP (lsphp / suEXEC / PHP-FPM-per-user) that UID is the domain owner's
        UID by construction, so mapping the reported domain's owner to a UID
        (pwd.getpwnam) and comparing UIDs can never false-reject a site's own
        traffic. Comparing two independently-resolved name strings —
        getpwuid(peer_uid).pw_name versus the owner recorded in the panel's
        domain DB — is NOT safe: the live passwd DB and the panel DB can spell
        the same account differently (case, account rename, duplicate/alias
        passwd entry, reseller naming), which false-rejected entire legitimate
        sites and flooded Sentry once 0.4-28 shipped the string comparison.

        The shared-handler mismatch exemption is bound to a VERIFIED shared-web
        peer UID: on a positive owner-UID mismatch the reported-domain handler
        check runs only when peer_uid is itself a shared-web service UID
        (nobody/apache, resolved by _shared_web_uids); every other peer — a
        per-user tenant reporting a domain it does not own, even a DSO one — is
        REJECTED. This closes the cross-tenant spoof where a legitimate per-user
        tenant reports telemetry for another tenant's dso/mod_php/module domain.

        Residual limit: fail-open on a mismatch is reserved for the case where
        the OWNER cannot be resolved (domain_owner returns nothing / raises, or
        the owner is not in passwd) — no distinct per-tenant kernel identity to
        bind to, and the threat model here is per-user PHP. Once the owner IS
        resolved to a distinct account, classification keys on the REPORTED
        domain's own handler and errs toward REJECT: a reported domain that is
        absent from an available map, or whose handler is None/unknown, is
        rejected, so an unknown handler no longer fails open. When the panel
        handler API itself is unavailable (get_domains_php_info raises — e.g.
        InterWorx/ispmanager where it is unsupported, or a transient panel-DB
        outage), the classifier now fails CLOSED: a shared-web peer's
        owner-mismatch self-telemetry is DROPPED (rejected) rather than risk a
        spoof, since a resolved distinct owner plus an unavailable classifier
        must not authorize. This narrow residual FLIP is documented in
        docs/design/ssa-agent-socket.md.

        Lookup strategy. The decision inputs (cpusers() and domain_owner)
        are always CURRENT-DATA answers; how they are obtained depends on
        the panel. On cPanel they are served from a stat-validated snapshot
        (self._panel_snapshot, see internal/panel_data_snapshot.py): every
        lookup stat()s the backing panel files (/etc/userplans,
        /etc/userdatadomains) and uses the snapshot only while their
        identity signature (inode/size/mtime_ns/ctime_ns) is unchanged —
        i.e. only while a fresh parse would return byte-identical results —
        rebuilding it in one single-flight parse when a file changes. This
        is NOT the TTL cache that b6aec39 removed: that cache expired by
        time and could serve answers a fresh lookup would no longer give
        (the root of its five staleness/race findings); here there is no
        such window, since validity is re-proven from the files on every
        payload. The snapshot is required on cPanel because clcommon has no
        internal cache there — cpusers()/domain_owner() re-parse the panel
        files IN FULL per call, which pinned the GIL-bound agent at one core
        (~55 ms/payload at 2570 accounts) and silently dropped most
        telemetry on large servers (see the GreenGeeks support case /
        zendesk 291203). On every other panel, and whenever the snapshot is
        unavailable or fails (SnapshotUnavailable), lookups stay FRESH per
        payload — acceptable there because the panel layer memoizes or is
        query-based internally (the DirectAdmin backend caches its domain
        DB; Plesk resolves via local DB queries). The privileged-peer check
        below tests peer_uid (O(1)) BEFORE resolving the peer's panel
        username or tenant-set membership, so non-root payloads — the hot
        path — skip both the per-payload getpwuid/NSS lookup and the
        cpusers() lookup entirely; the decision is unchanged (peer_user
        feeds only the privileged conjunction and warning text, and
        _resolve_domain_owner resolves it itself when a warning fires).

        The owner lookup MUST key on the SAME domain string the processor
        stores, or a crafted URL can authorize as an unresolvable string
        (fail-open) yet be stored under a victim's domain. To guarantee that
        parity by construction, the lookup domain is derived from
        canonical_stored_domain() — the single source of truth for
        RequestProcessor's RequestResult.domain (netloc with every 'www.'
        substring removed) — and then normalized to the panel's lookup form:
        userinfo (user:pass@) and :port that survive in the netloc are
        dropped, the trailing root-label dot is stripped, and the host is
        lowercased (the panel keys ownership on the bare lowercase hostname).
        Because this normalization is a pure function of the stored string,
        every URL that stores as a given victim domain — 'www.' prefix or
        suffix insertion, trailing dot, uppercase, embedded port — resolves to
        the same owner lookup and is forced through the cross-tenant check.
        Deriving from hostname or stripping the trailing dot BEFORE removing
        'www.' broke this parity for the trailing-dot suffix case (e.g.
        victim.comwww.) — see bugbot 95cac397, CLPRO-3190, !45 note 557376.
        (url_split's substring replace can also rewrite domains that merely
        contain 'www.' mid-name; that attribution quirk is pre-existing and
        shared deliberately — parity is the security property.)
        """
        if peer_uid in self._PRIVILEGED_SERVICE_UIDS:
            peer_user = self._peer_panel_user(peer_uid)
            if peer_user is None or peer_user not in self._panel_tenant_users():
                # Privileged root-owned service peer: no per-tenant identity
                # to enforce, trusted for any domain. Every other non-tenant
                # or unmappable peer falls through to the cross-tenant owner
                # check below, so a shared/service UID cannot report a
                # per-user tenant's domain it does not own.
                return True
        domain = self._owner_lookup_domain(url)
        owner = self._resolve_domain_owner(domain, peer_uid)
        if not owner:
            return True
        try:
            owner_uid = pwd.getpwnam(owner).pw_uid
        except KeyError:
            # Rate-limited: a panel/passwd desync repeats this per payload for
            # the affected domains (plus a Sentry event per WARNING). Own
            # class key: the peer's accepted-payload INFO budget must not
            # starve this diagnostic.
            self._log_rate_limited(
                ('owner-passwd-desync', peer_uid),
                'warning',
                '[%s] Owner %r of %r not in passwd (peer UID=%d), accepting',
                current_thread().name,
                owner,
                domain,
                peer_uid,
            )
            return True
        if owner_uid == peer_uid:
            return True
        # Positive owner-UID mismatch. The shared-handler exemption applies ONLY
        # when the PEER is itself a shared web service UID (nobody/apache) — the
        # UID under which shared/DSO PHP actually runs. A per-user tenant peer
        # (its OWN account UID, present in cpusers) can never legitimately serve
        # another tenant's domain, so it is REJECTED here even if that domain
        # runs a shared handler: this closes the cross-tenant spoof where tenant
        # A reports telemetry for tenant B's dso/mod_php/module domain. Only a
        # verified shared-web peer falls through to the per-reported-domain
        # handler check (which keys on the REPORTED domain's own handler, not the
        # owner account's main domain, so a mixed account's per-user secondary
        # domain cannot be spoofed), preserving legitimate DSO self-telemetry.
        if peer_uid in self._shared_web_uids():
            return self._reported_domain_allows_shared_peer(domain)
        return False

    def _reported_domain_allows_shared_peer(self, domain: str) -> bool:
        """
        True if the REPORTED domain serves PHP as a shared web UID, judged by
        that domain's OWN handler in get_domains_php_info. Handlers are
        per-domain: get_domains_php_info maps every domain — main, addon, sub and
        alias — to its own handler_type (a mixed account can serve a shared
        handler on its main domain and a per-user handler on a secondary), so the
        reported domain must be classified directly.

        This method is reached ONLY for a peer that is itself a verified shared-
        web service UID (nobody/apache) on a positive owner-UID mismatch — the
        caller (_authorize_sender) gates it on that. Returns True (accept) only
        when the reported domain's handler is a shared-UID handler
        (_SHARED_UID_HANDLERS). A per-user handler, a None/unknown handler, or a
        reported domain absent from an available map returns False (reject): the
        owner is a resolved DISTINCT per-user account, so even a shared-web peer
        reporting it is a spoof.

        Fails CLOSED (False, reject) when the panel cannot yield the handler map
        at all — get_domains_php_info() raises (e.g. InterWorx/ispmanager where
        it is unsupported, or the panel DB is momentarily down). RESIDUAL FLIP
        (documented in docs/design/ssa-agent-socket.md): previously this branch
        returned True, so a shared-UID self-report was accepted on API-unavailable
        panels and a spoof was possible there. It now DROPS (rejects) a shared-web
        peer's owner-mismatch self-telemetry on those backends rather than risk a
        spoof — with the peer-UID gate above, this branch is reached only for a
        shared-web peer whose reported domain resolves to a distinct owner, so a
        resolved distinct owner plus an unavailable classifier must not authorize.
        """
        try:
            info = get_domains_php_info()
        # The panel layer raises arbitrary exception types; any failure means
        # "classifier unavailable" -> deliberate fail-closed (see docstring).
        except Exception as e:  # noqa: BLE001
            # Rate-limited: on panels where the handler API is permanently
            # unsupported (InterWorx/ispmanager) this fires for EVERY
            # shared-peer owner-mismatch payload (plus a Sentry event per
            # WARNING).
            self._log_rate_limited(
                'handler-api',
                'warning',
                '[%s] Panel handler API unavailable for %r, rejecting (fail closed): %s',
                current_thread().name,
                domain,
                str(e),
            )
            return False
        handler = (info.get(domain) or {}).get('handler_type')
        return handler in self._SHARED_UID_HANDLERS

    @staticmethod
    def _owner_lookup_domain(url: str) -> str:
        """
        Panel owner-lookup key for url, derived from the SAME string the
        processor stores (canonical_stored_domain), so authorization and
        storage cannot diverge. Strips userinfo and :port that survive in the
        netloc, removes the trailing root-label dot, lowercases, then removes
        'www.' once more: the netloc-level strip is case-sensitive, so an
        uppercase 'WWW.' prefix only collapses after lowercasing — this keeps
        authorization at least as strict as the prior hostname-based path
        while preserving stored-domain parity for every clean-apex variant.
        """
        host = canonical_stored_domain(url)
        if '@' in host:
            host = host.rsplit('@', 1)[1]
        if host.startswith('[') and ']' in host:
            # IPv6 literal: keep [..] intact, drop any :port after it
            host = host[: host.index(']') + 1]
        else:
            left, sep, right = host.rpartition(':')
            if sep and right.isdigit():
                host = left
        return host.rstrip('.').lower().replace('www.', '')

    @staticmethod
    def _peer_panel_user(peer_uid: int):
        """
        Resolve the peer UID to its panel main username, or None when it
        cannot be mapped (no such panel user). An unmappable UID is not a
        tenant and is trusted (fail open).
        """
        try:
            return get_main_username_by_uid(peer_uid)
        # The panel layer raises arbitrary exception types; any failure means
        # "not mappable to a tenant" -> deliberate fail-open (see docstring).
        except Exception:  # noqa: BLE001
            return None

    def _snapshot_lookup(self, snapshot_call, fresh_call, subject: str):
        """
        Serve one authorization lookup from the stat-validated panel snapshot
        when one is installed (cPanel), falling back to *fresh_call* when
        there is no snapshot (every other panel) or its machinery fails
        (SnapshotUnavailable). Both lookups (_panel_tenant_users,
        _lookup_domain_owner) share this single fallback contract so their
        behaviour cannot drift; data-level outcomes from the snapshot
        propagate to the caller untouched.
        """
        if self._panel_snapshot is not None:
            try:
                return snapshot_call(self._panel_snapshot)
            except SnapshotUnavailable as e:
                # Rate-limited: a persistently broken panel file would fire
                # this per payload (plus a Sentry event per WARNING).
                self._log_rate_limited(
                    'panel-snapshot',
                    'warning',
                    '[%s] Panel snapshot unavailable for %s, falling back to fresh lookup: %s',
                    current_thread().name,
                    subject,
                    str(e),
                )
        return fresh_call()

    def _panel_tenant_users(self) -> frozenset:
        """Current frozenset(cpusers()) — served from the stat-validated
        snapshot when available (cPanel), fresh otherwise (see
        _authorize_sender's lookup-strategy paragraph). On failure -> empty
        frozenset, so every peer is treated as a non-tenant and trusted (fail
        open), consistent with the unresolvable-owner / nopanel behaviour."""
        try:
            return self._snapshot_lookup(
                lambda snapshot: snapshot.tenant_users(),
                lambda: frozenset(cpusers()),
                'tenant set',
            )
        # The panel layer raises arbitrary exception types; any failure
        # degrades to an empty tenant set -> deliberate fail-open (docstring).
        except Exception as e:  # noqa: BLE001
            # Rate-limited: fires per payload while the panel stays broken.
            self._log_rate_limited(
                'panel-tenants',
                'warning',
                '[%s] Could not resolve panel hosting accounts, treating every peer as non-tenant (fail open): %s',
                current_thread().name,
                str(e),
            )
            return frozenset()

    def _lookup_domain_owner(self, domain: str):
        """
        Owner lookup for *domain*: the stat-validated snapshot when available
        (cPanel), the fresh panel API otherwise (see _authorize_sender's
        lookup-strategy paragraph). Data-level outcomes from the snapshot
        (None for an unknown domain, DuplicateData for a duplicated one)
        are authoritative — identical to what the fresh call would produce —
        and propagate to the caller; only a snapshot MACHINERY failure
        (SnapshotUnavailable) falls back to the fresh call, so a payload
        flood cannot turn data-level outcomes into per-payload re-parses.
        """
        return self._snapshot_lookup(
            lambda snapshot: snapshot.domain_owner(domain),
            lambda: domain_owner(domain),
            repr(domain),
        )

    def _resolve_domain_owner(self, domain: str, peer_uid: int):
        """
        Resolve the owner username of a bare hostname via the panel data
        (snapshot or fresh API — _lookup_domain_owner). Returns the owner
        username, or None for every case where ownership cannot be
        established (the fail-open cases documented in _authorize_sender).

        The peer's panel username appears only in the two fail-open warnings
        below, so it is resolved there and not by the caller: on the hot path
        (owner resolves, decision made on UIDs) no payload pays the
        per-payload getpwuid/NSS lookup — on exactly the large-passwd servers
        this module's snapshot work targets, that lookup is a linear scan.
        """
        try:
            owner = self._lookup_domain_owner(domain)
        # The panel layer raises arbitrary exception types (including
        # DuplicateData); any failure means "ownership cannot be established"
        # -> deliberate fail-open (see _authorize_sender's docstring).
        except Exception as e:  # noqa: BLE001
            # Rate-limited per class + kernel-trusted peer UID: any sender can
            # emit unresolvable domains at payload rate, and each WARNING is
            # also a Sentry event (LoggingIntegration event_level=WARNING).
            # Not keyed by the panel username: clcommon maps every passwd-less
            # UID to the same 'N/A' string (and a raising panel maps all to
            # None), so a username key would merge unrelated peers into one
            # budget and let one of them suppress another's diagnostics.
            self._log_rate_limited(
                ('owner-resolve', peer_uid),
                'warning',
                '[%s] Could not resolve owner of %r for user=%r, accepting: %s',
                current_thread().name,
                domain,
                self._peer_panel_user(peer_uid),
                str(e),
            )
            return None
        if not owner:
            self._log_rate_limited(
                ('owner-resolve', peer_uid),
                'warning',
                '[%s] No owner for %r (user=%r), accepting',
                current_thread().name,
                domain,
                self._peer_panel_user(peer_uid),
            )
            return None
        return owner

    def handle(self, connection: socket_module.socket, admitted_uid=_NO_ADMISSION) -> None:
        """
        Handle incoming connection.

        :param connection: socket object usable to send and receive data on the
            connection.
        :param admitted_uid: the peer UID this connection was admitted under in
            the accept loop (an int; or None when its peer credentials were
            unreadable there but a global admission slot was still taken). The
            admission slot(s) reserved in listen() are released here in the
            finally block — exactly once per handled connection — so a
            queued-but-not-yet-run work item keeps its slot until a worker
            actually runs handle() (F-02 admission accounting). The
            _NO_ADMISSION sentinel means handle() was invoked directly (unit
            tests) without going through the accept loop, so there is no
            admission slot to release.
        """
        try:
            # SO_PEERCRED is fixed by the kernel at connect() time, so when the
            # accept loop already resolved it for admission control, re-reading
            # it here can only return the same value — reuse it and save one
            # getsockopt+unpack per payload. Resolve it only when the accept
            # loop could not (admitted_uid None) or was bypassed (direct
            # invocation, _NO_ADMISSION). Identity checks, stating the
            # three-value contract in the same vocabulary as the finally
            # block below — an isinstance(int) test would also admit bool
            # (False == 0 would then authorize as the privileged uid-0 peer).
            if admitted_uid is not _NO_ADMISSION and admitted_uid is not None:
                peer_uid = admitted_uid
            else:
                try:
                    peer_uid = self._get_peer_uid(connection)
                except (OSError, struct.error) as e:
                    # Rate-limited even though it is an ERROR: if SO_PEERCRED
                    # is persistently unreadable (kernel/LSM anomaly) this
                    # fires once per connection at telemetry rate, each one
                    # also a Sentry event. Server-global key: no UID to
                    # attribute the failure to, by definition.
                    self._log_rate_limited(
                        'peer-credentials',
                        'error',
                        '[%s] Failed to get peer credentials: %s',
                        current_thread().name,
                        str(e),
                    )
                    connection.close()
                    return

            try:
                input_data = self.read_input(connection)
                if not self._validate_input(input_data):
                    self._log_rate_limited(
                        ('rejected-invalid', peer_uid),
                        'warning',
                        '[%s] Rejected invalid payload from UID=%d: keys=%s',
                        current_thread().name,
                        peer_uid,
                        sorted(input_data.keys()) if isinstance(input_data, dict) else type(input_data).__name__,
                    )
                    return
                if not self._authorize_sender(peer_uid, input_data['url']):
                    self._log_rate_limited(
                        ('rejected-cross-tenant', peer_uid),
                        'warning',
                        '[%s] Rejected cross-tenant payload from UID=%d for url=%r',
                        current_thread().name,
                        peer_uid,
                        input_data['url'],
                    )
                    return
                # The sustained-insert / on-disk storage vector is bounded
                # OUTSIDE this dispatch, not by an insert quota here:
                # RETENTION_TIME_DAYS=1 plus the daily cleanup_old_data + VACUUM
                # (py/ssa/db.py) cap on-disk growth, and RequestProcessor's
                # per-sender queue fairness cap (PER_SENDER_QUEUE_SHARE, keyed on
                # this same peer UID) bounds the in-memory backlog. So the only
                # per-request abuse surface left here is log volume, which the
                # rate-limited accept/reject lines below address.
                self.request_processor.handle(input_data, peer_uid)
                # INFO, and rate-limited like every other peer-triggerable
                # line. It was briefly demoted to DEBUG on the grounds that
                # acceptance is already recorded as the ssa.db row and that at
                # INFO the per-key budget multiplies by the number of active
                # tenant UIDs (~2570 => up to 51,400 lines/min class-wide).
                # The second half of that no longer holds: _LOG_RATE_CLASS_MAX
                # caps a class at 100 lines/min across ALL keys regardless of
                # UID count, so the flood is bounded without the demotion. The
                # first half does not hold either — a single payload does not
                # reach the DB before BUFFER_SIZE, so for one payload this line
                # is the ONLY observable signal, and logging is pinned at
                # level=INFO (utils.set_logging_into_file) with no debug knob
                # anywhere in the product. At DEBUG the signal is therefore
                # unobtainable by anyone — operator or test — without patching
                # the service, which broke every clos-ssa nightly cell.
                self._log_rate_limited(
                    ('accepted-payload', peer_uid),
                    'info',
                    '[%s] Accepted payload from UID %d',
                    current_thread().name,
                    peer_uid,
                )
            except TimeoutError as e:
                # Rate-limited: a slow-read peer can trigger this once per
                # connection at will (plus a Sentry event per WARNING). Own
                # class key so other classes' floods cannot starve it.
                self._log_rate_limited(
                    ('read-timeout', peer_uid),
                    'warning',
                    '[%s] Connection timed out (peer UID=%d): %s',
                    current_thread().name,
                    peer_uid,
                    str(e),
                )
            except (SSAError, json.JSONDecodeError, ValueError) as e:
                # Rate-limited even though it is an ERROR: this fires once per
                # connection for any garbage bytes a local peer writes to the
                # world-writable socket, and unlimited it reproduces the exact
                # log/Sentry amplification (one event per connection,
                # serialized on the logging lock) this module bounds
                # everywhere else. The unexpected-exception path below stays
                # unlimited — it is not triggerable by payload data.
                self._log_rate_limited(
                    ('malformed-payload', peer_uid),
                    'error',
                    'Handled exception in [%s] (peer UID=%d): %s',
                    current_thread().name,
                    peer_uid,
                    str(e),
                )
            except Exception:
                self.logger.exception('Unexpected exception in [%s]', current_thread().name)
            finally:
                connection.close()
        finally:
            if admitted_uid is not _NO_ADMISSION:
                self._release_admission(admitted_uid)

    def read_input(self, connection: socket_module.socket) -> dict:
        """
        Read the pre-auth payload from *connection* under a TOTAL wall-clock
        deadline of SOCKET_READ_TIMEOUT seconds, then return the decoded JSON.

        connection.settimeout() is only a PER-recv inactivity timeout, so a peer
        that drips one byte just before each timeout could otherwise pin the
        worker (and the admission slot it holds) indefinitely — a slow-read /
        Slowloris primitive on the world-writable socket. The whole read is
        therefore bounded by an absolute monotonic deadline: before every recv
        the remaining budget is computed and installed as the socket timeout,
        and a non-positive remainder raises socket.timeout. At most MAX_MSG_SIZE
        bytes are read; an empty recv (EOF) ends the read. A legitimate client
        sends its <1 KB payload in one recv well within the budget, so this is
        transparent to well-behaved senders. Decoding / json.loads behaviour is
        unchanged from the previous makefile-based read.
        """
        deadline = time.monotonic() + SOCKET_READ_TIMEOUT
        chunks = []
        total = 0
        while total < MAX_MSG_SIZE:
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                raise TimeoutError('pre-auth read budget exceeded')
            connection.settimeout(remaining)
            chunk = connection.recv(MAX_MSG_SIZE - total)
            if not chunk:
                break
            chunks.append(chunk)
            total += len(chunk)
        data = b''.join(chunks).decode(errors='ignore')
        # DEBUG, not INFO: this fires once per payload, and at INFO it both
        # grew agent.log by tens of MB/day and serialized all 50 workers on
        # the logging lock (466 of 540 py-spy thread samples sat in
        # logging acquire() on a loaded server — GreenGeeks support case).
        # Other per-connection peer-triggerable lines are rate-limited for
        # the same reason (_log_rate_limited). 'total' is the byte count the
        # recv loop already tracked — logging it avoids a per-payload
        # re-encode whose result is discarded at the production INFO level.
        self.logger.debug('[%s] I received %i bytes', current_thread().name, total)
        self.logger.debug('[%s] payload: %s', current_thread().name, data)
        if data:
            return json.loads(data.strip())
        return {}

Youez - 2016 - github.com/yon3zu
LinuXploit