#!/usr/bin/env python3
###################################################
#
# Pandora FMS Autodiscovery plugin.
# Checks the status of the services in list and monitors CPU and Memory for each of them.
#
# (c) A. Kevin Rojas <kevin.rojas@pandorafms.com>
#
# Edited in 2023 by Alejandro Sánchez <alejandro.sanchez@pandorafms.com>
#
# TO DO LIST:
# - Enable child services detection (Windows)
# - Make CPU/Memory usage available for child services (Windows)
#
###################################################
try:
    from sys import argv
    from sys import stderr
    from sys import exit
    import psutil
    from subprocess import Popen
    from subprocess import PIPE
    from subprocess import DEVNULL
    import re
    import signal
except ModuleNotFoundError as err:
    print("{} error: {}. Exiting...".format(argv[0], err), file=stderr)
    exit(1)

# Define a function to handle the SIGTERM signal
def sigterm_handler(signum, frame):
    print("Received SIGTERM signal. Cleaning up...")
    exit(0)
signal.signal(signal.SIGTERM, sigterm_handler)

module_list = []
VERSION = "1.4"

def win_service(servicelist, option=False, memcpu=False):
    modules_default = []
    modules_percentage = []

    # Take all services
    services = psutil.win_service_iter()
    for service in services:
        for pattern in servicelist:
            if re.search(pattern, service.name(), re.IGNORECASE):
                serv = service.as_dict()
                if serv['status'] == 'running':
                    value = 1
                else:
                    value = 0

                # Create module for each service
                parent = build_module("Service " + str(serv['name']) + " - Status", value, "generic_proc")
                modules_default += parent

                # Memory and CPU percentage
                if memcpu and value != 0:
                    try:
                        # Process ID
                        srv_pid = service.pid()
                        if srv_pid is not None:
                            process = psutil.Process(srv_pid)
                            proc_name = process.name()

                            # Filter processes by regexp
                            for pattern in servicelist:
                                if re.search(pattern, proc_name, re.IGNORECASE):
                                    # CPU
                                    value_cpu = process.cpu_percent(interval=1.0)
                                    parent = build_module("Service " + str(proc_name) + " - CPU usage", value_cpu, "generic_data")
                                    parent[0].update([("unit", "%"), ("module_parent", str(serv['name']))])
                                    modules_percentage += parent

                                    # Memory
                                    value_mem = process.memory_percent()
                                    parent = build_module("Service " + str(proc_name) + " - Memory usage", value_mem, "generic_data")
                                    parent[0].update([("unit", "%"), ("module_parent", str(serv['name']))])
                                    modules_percentage += parent
                        else:
                            print(f"PID for service {service.name()} is None.")
                    except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess) as e:
                        print(f"Error accessing process for service {service.name()}: {e}")

    for module in modules_default:
        print_module(module, 1)
    if memcpu:
        for module in modules_percentage:
            print_module(module, 1)

def lnx_service(user_services, memcpu=False):
    """Creates modules for Linux servers"""
    modules = []
    
    if sysd_available:
        service_status_output = Popen(["systemctl", "list-units", "--type=service", "--full", "--all", "--plain", "--no-legend", "--no-pager"], stdout=PIPE, universal_newlines=True)
        service_status_list, _ = service_status_output.communicate()
        service_status_list = service_status_list.split("\n")
        for service in service_status_list:
            module_status = None
            # Search for the services provided by the user
            for target_service in user_services:
                if re.search(r"^{}.service".format(target_service), service, re.IGNORECASE):
                    service_name = re.findall(r'(.*).service', service, re.IGNORECASE)[0]
                    if re.search(r".*running", service, re.IGNORECASE):
                        module_status=1 
                    else:
                        module_status=0
                    modules += build_module("Service " + service_name + " - Status", module_status, "generic_proc")
                      
    if sysv_available and not sysd_available:
        # Systemd not available, switch to service command
        for service in service_status_list:
            module_status = None
            syscall = Popen(["service", service, "status"], stdout=PIPE, stdin=DEVNULL, stderr=DEVNULL, universal_newlines=True)
            result = syscall.communicate()[0].lower()
            if "is running" in result:
                module_status = 1
            elif "is stopped" in result:
                module_status = 0
            else:
                pass
            modules += build_module("Service " + target_service + " - Status", module_status, "generic_proc")
    
    for m in modules:
        print_module(m, 1)


def build_module(name, value, module_type, parent=None):
    #print ("service_module BEGIN "+str(now(0,1)))
    module = [{
            "name"              :   name ,
            "type"              :   module_type,
            "value"             :   value,
            "module_parent"     :   parent,
        }]
    #print ("service_module END "+str(now(0,1)))
    return module

def get_memcpu (process, servicename=None):
    """Creates a module for Memory and CPU for a given process. Returns a list of dictionaries."""
    modules = []
    if process:
        if servicename is not None:
            parentname = servicename
        else:
            parentname = process
        modules += [{
                "name"         :    "Service "+ process + " - Memory usage",
                "type"          :    "generic_data",
                "value"         :     proc_percentbyname(process)[0],
                "unit"          :     "%",
                "module_parent" :    "Service "+ parentname + " - Status",
                },
                {"name"         :    "Service "+ process + " - CPU usage",
                "type"          :    "generic_data",
                "value"         :     proc_percentbyname(process)[1],
                "unit"          :     "%",
                "module_parent" :    "Service "+ parentname + " - Status",
                }]
    return modules

def proc_percentbyname(procname):
    """Gets Memory and CPU usage for a given process. Returns a list."""
    procs = [p for p in psutil.process_iter() if re.search(procname, p.name().lower(), re.IGNORECASE)]
    memory = []
    cpu = []
    try:
        for proc in procs:
            if re.search(procname, proc.name(), re.IGNORECASE):
                cpu.append(proc.cpu_percent(interval=0.5))
                memory.append(proc.memory_percent())
            else:
                next
    except psutil.NoSuchProcess:
        next
    return [sum(memory), sum(cpu)]

#########################################################################################
# print_module function
#########################################################################################
def print_module(module, str_flag=False):
    """Returns module in XML format. Accepts only {dict}.\n
    + Only works with one module at a time: otherwise iteration is needed.
    + Module "value" field accepts str type or [list] for datalists.
    + Use not_print_flag to avoid printing the XML (only populates variables).
    """
    data = dict(module)
    module_xml = ("<module>\n"
                  "\t<name><![CDATA[" + str(data["name"]) + "]]></name>\n"
                  "\t<type>" + str(data["type"]) + "</type>\n"
                  )
    #### Strip spaces if module not generic_data_string    
    if type(data["type"]) is not str and "string" not in data["type"]:
        data["value"] = data["value"].strip()
    if isinstance(data["value"], list): # Checks if value is a list
        module_xml += "\t<datalist>\n"
        for value in data["value"]:
            if type(value) is dict and "value" in value:
                module_xml += "\t<data>\n"
                module_xml += "\t\t<value><![CDATA[" + str(value["value"]) + "]]></value>\n"
                if "timestamp" in value:
                    module_xml += "\t\t<timestamp><![CDATA[" + str(value["timestamp"]) + "]]></timestamp>\n"
        module_xml += "\t</data>\n"
    else:
        module_xml += "\t<data><![CDATA[" + str(data["value"]) + "]]></data>\n"
    if "desc" in data:
        module_xml += "\t<description><![CDATA[" + str(data["desc"]) + "]]></description>\n"
    if "unit" in data:
        module_xml += "\t<unit><![CDATA[" + str(data["unit"]) + "]]></unit>\n"
    if "interval" in data:
        module_xml += "\t<module_interval><![CDATA[" + str(data["interval"]) + "]]></module_interval>\n"
    if "tags" in data:
        module_xml += "\t<tags>" + str(data["tags"]) + "</tags>\n"
    if "module_group" in data:
        module_xml += "\t<module_group>" + str(data["module_group"]) + "</module_group>\n"
    if "module_parent" in data and data["module_parent"] != None:
        module_xml += "\t<module_parent>" + str(data["module_parent"]) + "</module_parent>\n"
    if "min_warning" in data:
        module_xml += "\t<min_warning><![CDATA[" + str(data["min_warning"]) + "]]></min_warning>\n"
    if "max_warning" in data:
        module_xml += "\t<max_warning><![CDATA[" + str(data["max_warning"]) + "]]></max_warning>\n"
    if "min_critical" in data:
        module_xml += "\t<min_critical><![CDATA[" + str(data["min_critical"]) + "]]></min_critical>\n"
    if "max_critical" in data:
        module_xml += "\t<max_critical><![CDATA[" + str(data["max_critical"]) + "]]></max_critical>\n"
    if "str_warning" in data:
        module_xml += "\t<str_warning><![CDATA[" + str(data["str_warning"]) + "]]></str_warning>\n"
    if "str_critical" in data:
        module_xml += "\t<str_critical><![CDATA[" + str(data["str_critical"]) + "]]></str_critical>\n"
    if "critical_inverse" in data:
        module_xml += "\t<critical_inverse><![CDATA[" + str(data["critical_inverse"]) + "]]></critical_inverse>\n"
    if "warning_inverse" in data:
        module_xml += "\t<warning_inverse><![CDATA[" + str(data["warning_inverse"]) + "]]></warning_inverse>\n"
    if "max" in data:
        module_xml += "\t<max><![CDATA[" + str(data["max"]) + "]]></max>\n"
    if "min" in data:
        module_xml += "\t<min><![CDATA[" + str(data["min"]) + "]]></min>\n"
    if "post_process" in data:
        module_xml += "\t<post_process><![CDATA[" + str(data["post_process"]) + "]]></post_process>\n"
    if "disabled" in data:
        module_xml += "\t<disabled><![CDATA[" + str(data["disabled"]) + "]]></disabled>\n"
    if "min_ff_event" in data:
        module_xml += "\t<min_ff_event><![CDATA[" + str(data["min_ff_event"]) + "]]></min_ff_event>\n"
    if "status" in data:
        module_xml += "\t<status><![CDATA[" + str(data["status"]) + "]]></status>\n"
    if "timestamp" in data:
        module_xml += "\t<timestamp><![CDATA[" + str(data["timestamp"]) + "]]></timestamp>\n"
    if "custom_id" in data:
        module_xml += "\t<custom_id><![CDATA[" + str(data["custom_id"]) + "]]></custom_id>\n"
    if "critical_instructions" in data:
        module_xml += "\t<critical_instructions><![CDATA[" + str(data["critical_instructions"]) + "]]></critical_instructions>\n"
    if "warning_instructions" in data:
        module_xml += "\t<warning_instructions><![CDATA[" + str(data["warning_instructions"]) + "]]></warning_instructions>\n"
    if "unknown_instructions" in data:
        module_xml += "\t<unknown_instructions><![CDATA[" + str(data["unknown_instructions"]) + "]]></unknown_instructions>\n"
    if "quiet" in data:
        module_xml += "\t<quiet><![CDATA[" + str(data["quiet"]) + "]]></quiet>\n"
    if "module_ff_interval" in data:
        module_xml += "\t<module_ff_interval><![CDATA[" + str(data["module_ff_interval"]) + "]]></module_ff_interval>\n"
    if "crontab" in data:
        module_xml += "\t<crontab><![CDATA[" + str(data["crontab"]) + "]]></crontab>\n"
    if "min_ff_event_normal" in data:
        module_xml += "\t<min_ff_event_normal><![CDATA[" + str(data["min_ff_event_normal"]) + "]]></min_ff_event_normal>\n"
    if "min_ff_event_warning" in data:
        module_xml += "\t<min_ff_event_warning><![CDATA[" + str(data["min_ff_event_warning"]) + "]]></min_ff_event_warning>\n"
    if "min_ff_event_critical" in data:
        module_xml += "\t<min_ff_event_critical><![CDATA[" + str(data["min_ff_event_critical"]) + "]]></min_ff_event_critical>\n"
    if "ff_type" in data:
        module_xml += "\t<ff_type><![CDATA[" + str(data["ff_type"]) + "]]></ff_type>\n"
    if "ff_timeout" in data:
        module_xml += "\t<ff_timeout><![CDATA[" + str(data["ff_timeout"]) + "]]></ff_timeout>\n"
    if "each_ff" in data:
        module_xml += "\t<each_ff><![CDATA[" + str(data["each_ff"]) + "]]></each_ff>\n"
    if "module_parent_unlink" in data:
        module_xml += "\t<module_parent_unlink><![CDATA[" + str(data["parent_unlink"]) + "]]></module_parent_unlink>\n"
    if "global_alerts" in data:
        for alert in data["alert"]:
            module_xml += "\t<alert_template><![CDATA[" + alert + "]]></alert_template>\n"
    module_xml += "</module>\n"

    #### Print flag
    if str_flag is not False:
        print(module_xml)

    return (module_xml)


#########################################################################################
# MAIN
#########################################################################################

def main():
    """Checks OS and calls the discover function."""
    if psutil.WINDOWS:
        OS = "Windows"
        user_services = ["MySQL", "postgresql", "pgsql", "oracle", "MSSQL", "IISADMIN",
                        "apache", "nginx", "W3svc", "NTDS", "DNS", "MSExchangeADTopology",
                        "MSExchangeServiceHost", "MSExchangeSA", "MSExchangeTransport"]

        discover(OS, user_services)
    elif psutil.LINUX:
        OS = "Linux"
        user_services = ["httpd", "apache2", "nginx", "ldap",
                        "postfix", "mysqld", "postgres", "oracle", "mongod"]
        
        global sysd_available, sysv_available
        sysd_available = sysv_available = 0
        
        # Get systemd compatibility
        sysd = Popen(["systemd-notify", "--booted"], stdout=DEVNULL, universal_newlines=True)
        sysd.communicate()
        if sysd.returncode == 0: 
            sysd_available = 1
        else:
            sysd_available = 0
            # Check sytemv compatibility ("command" is a bash tool, so shell=True)
            sysv = Popen(["command", "-v", "service"], shell=True, stdout=DEVNULL, universal_newlines=True)
            sysv.communicate()
            if sysv.returncode == 0:
                sysv_available = 1
            else:
                sysv_available = 0
                print("No systemd or service commands available. Exiting...", file=stderr)
                exit()

        discover(OS, user_services)
    else:
        print("OS not recognized. Exiting...", file=stderr)
        exit()

def discover(osyst, user_services):
    """Shows help and triggers the creation of service modules"""
    if "--usage" in argv:
        memcpu = True
    else:
        memcpu = False
    if len(argv) > 2 and argv[1] == "--list":
        user_services = argv[2].split(",")
        if osyst == "Windows":
            win_service(user_services, False, memcpu) ## False won't get children
        elif osyst == "Linux":
            lnx_service(user_services, memcpu)
    elif len(argv) > 1 and argv[1] == "--default":
        if osyst == "Windows":
            win_service(user_services, False, memcpu) ## False won't get children
        elif osyst == "Linux":
            lnx_service(user_services, memcpu)
    else:
        print("\nPandora FMS Autodiscovery plugin v{}".format(VERSION))
        print("Checks the status of the services in list and monitors CPU and Memory for each of them.\n")
        print("Usage:")
        print("{} [options] [--usage]".format(argv[0]))
        print("--help")
        print("\tPrints this help screen")
        print("--default")
        print("\tRuns this tool with default monitoring.")
        print("\tServices monitored by default for {}:".format(osyst))
        print("\t", ", ".join(user_services))
        print("--list \"<srvc1,srvc2,srvc3>\"")
        print("\tReplaces default services for a given list (comma-separated).")
        if osyst == "Windows":
            print("\tEach element of the list will be treated as a regexp, but they must be over 2 characters.")
            print("\tElements under 2 characters will be discarded.")
        if osyst == "Linux" and sysd_available:
            print("\tEach element of the list will be searched for literally, but regex is allowed (please put") 
            print("\tthe list within quotes). For example:")
            print("\t\t{} --list \"apache.*,my[\\w]+\"".format(argv[0]))
        print("--usage")
        print("\tExperimental. Adds modules for CPU and Memory usage per service/process (optional, can take some time).\n")


##### RUN ####
main()
