# vim: set ts=4 sw=4 expandtab syntax=python: """ fpmstatus.fpm FPM pool status parser @author J. Hipps """ import os import logging import json import socket from glob import glob from configparser import SafeConfigParser from urllib.parse import splitport from pathlib import Path import arrow from fpmstatus.util import * from fpmstatus.fcgi import FCGIApp from fpmstatus import __version__ logger = logging.getLogger('fpmstatus') def fetch_pools(phpver=None): """ Fetch list of pools If @phpver is defined, only return EA4 pools for that version """ logger.info("Enumerating pools for all FPM masters...") conftype = None if os.path.exists('/etc/php/fpm/pool.d'): # Standard Debian pcs = glob('/etc/php/fpm/pool.d/*.conf') conftype = 'debian' logger.debug("Got %d pools from /etc/php/fpm/pool.d", len(pcs)) elif os.path.exists('/etc/php-fpm.d'): # Standard RHEL pcs = glob('/etc/php-fpm.d/*.conf') conftype = 'rhel' logger.debug("Got %d pools from /etc/php-fpm.d", len(pcs)) elif os.path.exists('/etc/systemd/system/supervisord-fpm.service'): # Ngxconf supervisord-fpm pcs = glob('/opt/ngxconf/phpfpm/conf.d/*.conf') conftype = 'ngxconf' logger.debug("Got %d pools from /opt/ngxconf/phpfpm/conf.d", len(pcs)) elif len(glob('/opt/cpanel/ea-php*/root/etc/php-fpm.d')): # cPanel EA4 conftype = 'cpanel' if phpver: pcs = glob('/opt/cpanel/%s/root/etc/php-fpm.d/*.conf' % (phpver)) logger.debug("Got %d pools for %s", len(pcs), phpver) else: pcs = glob('/opt/cpanel/*/root/etc/php-fpm.d/*.conf') logger.debug("Got %d pools for all PHP versions", len(pcs)) elif len(glob('/opt/alt/php-fpm*/usr/etc/php-fpm.d/users/*.conf')): # CWP conftype = 'cwp' if phpver: pcs = glob('/opt/alt/%s/usr/etc/php-fpm.d/users/*.conf' % (phpver)) logger.debug("Got %d pools for %s", len(pcs), phpver) else: pcs = glob('/opt/alt/php-fpm*/usr/etc/php-fpm.d/users/*.conf') logger.debug("Got %d pools for all PHP versions", len(pcs)) else: logger.error("No FPM installation detected. Aborting.") return None if len(pcs) == 0: logger.error("No FPM pools detected. Aborting.") return None tglobal = {} pools = {} for tconf in pcs: cparse = SafeConfigParser() if tconf.endswith('/nobody.conf'): # prevents enumeration of the nobody pool in CWP continue try: with open(tconf, 'r') as f: cparse.readfp(f) except Exception as e: logger.error("Failed to parse %s: %s", tconf, str(e)) continue for tsec in cparse.sections(): if tsec == 'global': tglobal = dict(cparse.items('global')) else: pools[tsec] = dict(cparse.items(tsec)) pools[tsec]['_confpath'] = tconf pools[tsec]['_vhost'] = tsec.replace('_', '.') if conftype == 'ngxconf': try: pools[tsec]['_masterid'] = os.path.splitext(os.path.basename(tconf))[0] pools[tsec]['_masterlog'] = tglobal.get('error_log') except: pools[tsec]['_masterid'] = None pools[tsec]['_masterlog'] = None pass logger.debug("Found pool %s [listen=%s]", tsec, pools[tsec].get('listen')) return pools def get_pool_status(psock, uri='/status', timeout=1.0): """ Get pool status from @socket via FastCGI """ socket.setdefaulttimeout(timeout) # Determine if psock is unix or tcp socket try: shost, sport = splitport(psock) except Exception as e: logger.error("Failed to parse socket path [%s]: %s", psock, str(e)) return None try: if sport is not None: fc = FCGIApp(host=shost, port=int(sport)) else: fc = FCGIApp(psock) fenv = { 'REQUEST_METHOD': "GET", 'REQUEST_URI': uri, 'SCRIPT_NAME': uri, 'SCRIPT_FILENAME': uri, 'QUERY_STRING': "full&json", 'DOCUMENT_ROOT': "/", 'GATEWAY_INTERFACE': "CGI/1.1", 'SERVER_SOFTWARE': "fpmstatus/" + __version__, 'REMOTE_ADDR': "127.0.0.1", 'REMOTE_PORT': "0", 'SERVER_ADDR': "127.0.0.1", 'SERVER_PORT': "0", 'SERVER_NAME': "localhost", 'CONTENT_TYPE': "", 'CONTENT_LENGTH': "0" } resp = fc(fenv) except Exception as e: logger.error("Failed to read from socket %s: %s", psock, str(e)) return None pstat = {} try: if resp[2].startswith(b'File not found'): logger.error("Received 404 when requesting /status. Check FPM pool configuration for %s", psock) return None sraw = json.loads(resp[2]) except Exception as e: logger.error("Failed to parse JSON response from %s:%s: %s", psock, uri, str(e)) return None for tkey, tval in sraw.items(): nkey = tkey.replace(' ', '_') if nkey == 'start_time': pstat[nkey] = arrow.get(tval).to('local').int_timestamp elif nkey == 'processes': procs = tval pstat[nkey] = [] for tproc in procs: tpx = {} for pkey, pval in tproc.items(): npkey = pkey.replace(' ', '_') if npkey == 'script': tpx[npkey] = None if pval == '-' else os.path.realpath(pval) elif npkey == 'start_time': tpx[npkey] = arrow.get(pval).to('local').int_timestamp elif npkey == 'request_duration': tpx[npkey] = float(pval) / 1000000.0 else: tpx[npkey] = pval pstat[nkey].append(tpx) else: pstat[nkey] = tval logger.debug("Got repsonse for pool %s:\n%s", psock, pstat) return pstat def get_domain_pool(domname): """ Get pool by domain @domname """ poolname = domname.replace('.', '_') plist = fetch_pools() logger.info("Fetching status of pool %s...", poolname) pdata = plist.get(poolname) if pdata is None: logger.error("No pool for domain %s found on server", domname) return None pstat = get_pool_status(pdata['listen']) if pstat is None: logger.error("Failed to retrieve pool status for %s", domname) return None else: pstat['_config'] = pdata return pstat def get_user_pools(username): """ Get all pools for user @username """ ustat = [] plist = fetch_pools() logger.info("Fetching status of pools owned by %s...", username) for tpool in filter(lambda x: x.get('user', '') == username, plist.values()): pstat = get_pool_status(tpool['listen']) if pstat is None: logger.warning("Failed to retrieve pool status for %s", tpool.get('pool', '')) continue else: pstat['_config'] = tpool ustat.append(pstat) logger.debug("Got %d pools for user %s", len(ustat), username) return ustat def get_pool_by_name(poolname): """ Get pool named @poolname """ plist = fetch_pools() logger.info("Fetching status of %s pool...", poolname) tpool = plist.get(poolname) if tpool is None: logger.error("No pool named '%s'", poolname) return None pstat = get_pool_status(tpool['listen']) if pstat is None: logger.warning("Failed to retrieve pool status for %s", poolname) return None else: pstat['_config'] = tpool return pstat def get_all_pools(): """ Fetch status for ALL pools on the server """ ustat = [] plist = fetch_pools() if not plist: logger.error("No PHP-FPM pools found on server") return None logger.info("Fetching status of all %d pools...", len(plist)) for pname, tpool in plist.items(): pstat = get_pool_status(tpool['listen']) if pstat is None: logger.warning("Failed to retrieve pool status for %s", tpool.get('pool', '')) continue else: pstat['_config'] = tpool ustat.append(pstat) return ustat