# -*- 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
"""
This module contains contains classes implementing SSA Agent behaviour
"""
import atexit
import json
import logging
import re
import socket as socket_module
import struct
from concurrent.futures import ThreadPoolExecutor
from threading import current_thread
from .internal.constants import agent_sock
from .internal.exceptions import SSAError
from .internal.utils import 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
# Seconds to wait for a peer to finish sending its payload.
# Keeps slow or stalled connections from holding a worker thread open.
SOCKET_READ_TIMEOUT = 10
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)
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 listen(self) -> None:
"""
Start listening socket
"""
_socket = create_socket(agent_sock)
while True:
connection, address = _socket.accept()
self.executor.submit(self.handle, connection)
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?\??://[^\s<>"{}|\\^`\[\]]+\Z')
@staticmethod
def _get_peer_uid(connection: 'socket object') -> 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 data['duration'] < 0:
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 data['throttled_time'] < 0:
return False
if isinstance(data['io_throttled_time'], bool) or \
not isinstance(data['io_throttled_time'], int) or data['io_throttled_time'] < 0:
return False
if not isinstance(data['wordpress'], bool):
return False
return True
def handle(self, connection: 'socket object') -> None:
"""
Handle incoming connection
:param connection: socket object usable to
send and receive data on the connection
"""
try:
peer_uid = self._get_peer_uid(connection)
except (OSError, struct.error) as e:
self.logger.error(
'[%s] Failed to get peer credentials: %s',
current_thread().name, str(e))
connection.close()
return
connection.settimeout(SOCKET_READ_TIMEOUT)
fileobj = connection.makefile(errors='ignore')
try:
input_data = self.read_input(fileobj)
if not self._validate_input(input_data):
self.logger.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
self.request_processor.handle(input_data)
except socket_module.timeout as e:
self.logger.warning('[%s] Connection timed out (peer UID=%d): %s',
current_thread().name, peer_uid, str(e))
except (SSAError, json.JSONDecodeError, ValueError) as e:
self.logger.error('Handled exception in [%s]: %s',
current_thread().name, str(e))
except Exception as e:
self.logger.exception('Unexpected exception in [%s]: %s',
current_thread().name, str(e))
finally:
fileobj.close()
connection.close()
def read_input(self, fileio: 'file object') -> dict:
"""
Read input data and return decoded json
:param fileio: a file-like object providing read method
"""
data = fileio.read(MAX_MSG_SIZE)
self.logger.info('[%s] I received %i bytes',
current_thread().name, len(data.encode()))
self.logger.debug('[%s] payload: %s',
current_thread().name, data)
if data:
return json.loads(data.strip(), strict=False)
else:
return {}