#!/opt/alt/python38/bin/python3
from configparser import ConfigParser
from contextlib import suppress
from functools import lru_cache
import gc
import json
import logging
import logging.config
from pathlib import Path
import signal
import subprocess
import sys
import time
from typing import Dict
import urllib.request

import psutil
import sentry_sdk

# to disable PAM ftp integration
MAX_FAILURES_PER_HOUR = 10

TRANSIENT_SIGLIST =(signal.SIGABRT,
                    signal.SIGCONT,
                    signal.SIGHUP,
                    signal.SIGINT,
                    signal.SIGPIPE,
                    signal.SIGQUIT,
                    signal.SIGUSR1,
                    signal.SIGUSR2,
                    signal.SIGTERM)

CONFIG = '/etc/pam_imunify/i360.ini'
SENTRY_DSN = 'https://576732f0a25446c4b1f5b4a706e6b9d5@sentry.cloudlinux.com/52'
PIDFILE = Path('/var/run/pam_imunify_daemon-watchdog.pid')

tool_name = "%s-watchdog" % Path(sys.argv[0]).name
logger = logging.getLogger(tool_name)


@lru_cache(1)
def pam_imunify_config() -> Dict[str, str]:
    try:
        with open(CONFIG) as f:
            conf = ConfigParser(default_section='-')
            conf.read_string("[-]\n" + f.read())
            return conf['-']
    except Exception as e:
        # being robust!
        logger.exception("%s parsing error", CONFIG)
        return {
            'sentry': 'https://sentry.cloudlinux.com/sentry/i360-pam-imunify/',
            'log': '/var/log/imunify360/pam.log'
            }


class SentryTags:

    @staticmethod
    def name():
        return tool_name

    @staticmethod
    def os_version():
        os_release = Path('/etc/os-release')
        if not os_release.exists():
            return 'CloudLinux/CentOS 6'

        conf = ConfigParser(default_section='-')
        conf.read_string("[-]\n" + os_release.read_text())
        return conf['-']['PRETTY_NAME'].strip('"')

    @staticmethod
    def server_id():
        try:
            with open('/var/imunify360/license.json') as f:
                license = json.load(f)
                return license['id']
        except FileNotFoundError:
            return 'N/A'

    @staticmethod
    def server_ip():
        # HTTP currenly is about 0.5 sec faster than HTTPS
        with urllib.request.urlopen("http://api.ipify.org", timeout=2) as r:
            return r.read().decode()

    @classmethod
    def user(cls):
        return cls.server_id()

    @staticmethod
    def version():
        for cmd in [
            ['/usr/bin/dpkg-query', '--showformat=${Version}', '--show', 'imunify360-pam'],
            ['/bin/rpm', '-q', '--queryformat=%{VERSION}-%{RELEASE}', 'imunify360-pam'],
            ]:
            try:
                return subprocess.check_output(cmd, text=True).strip()
            except (FileNotFoundError, subprocess.CalledProcessError) as e:
                last_err = str(e)
        else:
            return last_err


def sentry_init():
    # Sentry error reporting (must be an url or "off")
    if ('sentry' not in pam_imunify_config() or
            not pam_imunify_config()['sentry'].startswith('http')):
        return {
            'level': 'NOTSET',
            'class': 'logging.NullHandler',
        }

    sentry_sdk.init(
        dsn=SENTRY_DSN,
        # release=config.Core.VERSION,
        attach_stacktrace='on')

    with sentry_sdk.configure_scope() as scope:
        for tag in (attr for attr in dir(SentryTags) if not attr.startswith('_')):
            try:
                value = getattr(SentryTags, tag)()
            except Exception as e:
                # It is ok to use uninialized logger to print stderr messages
                logger.exception("sentry_init() '%s' tag error", tag)
                # Being robust!
                value = str(e)
            if tag == 'user':
                scope.user = {'id': value}
            elif tag == 'os_version':
                scope.set_tag('os.version', value)
            else:
                scope.set_tag(tag, value)

    return {
        'level': 'ERROR',
        'class': 'sentry_sdk.integrations.logging.SentryHandler'
    }


def pamlog_handler():
    if 'log' not in pam_imunify_config():
        return {
            'level': 'NOTSET',
            'class': 'logging.NullHandler',
        }

    return {
        'level': 'INFO',
        'class': 'logging.FileHandler',
        'formatter': 'pam_log',
        'filename': pam_imunify_config()['log'],
    }


def logger_init():
    logging.config.dictConfig({
        'version': 1,
        'handlers': {
            'sentry': sentry_init(),
            'pam_log': pamlog_handler(),
            'console': {
                'formatter': 'pam_log',
                'class': 'logging.StreamHandler',
                'stream': 'ext://sys.stderr', 'level': 'INFO'
            },
        },
        'root': {
            'level': 'NOTSET',
            'handlers': [
                'console',
                'pam_log',
                'sentry'
            ]
        },
        'formatters': {
            'pam_log': {
                'format': f'time="%(asctime)s" level=%(levelname)s msg="%(message)s" tool={tool_name}'
            },
        },
        'disable_existing_loggers': False
    })


def disable_pamftp_intergation():
    cmd = ['/usr/bin/imunify360-agent',
           'config',
           'update',
           '{"PAM": {"ftp_protection": false}}']
    try:
        rc = subprocess.check_call(cmd)
    except (OSError, subprocess.CalledProcessError) as e:
        logger.exception("disable_pamftp_intergation error")


def wire_signals(reroute_to: subprocess.Popen=None, undo=False):
    def child_proc_send(sig, _):
        reroute_to.transient_signal = sig
        try:
            reroute_to.send_signal(sig)
        except ProcessLookupError:
            logger.exception("child process died unexpectedly")
    for sig in TRANSIENT_SIGLIST:
        signal.signal(sig, signal.SIG_DFL if undo else child_proc_send)


def kill_orphaned_child(pam_imunify_daemon):
    """ if any. """
    try:
        pid = int(PIDFILE.read_text())
    except (FileNotFoundError, ValueError):
        return

    with suppress(psutil.NoSuchProcess):
        proc = psutil.Process(pid)
        if proc.name() != Path(pam_imunify_daemon).name:
            return

        logger.error("orphaned child process: %s", proc)
        proc.terminate()
        time.sleep(2)
        proc.kill()


def daemon_being_watched() -> bool:
    pam_imunify_daemon = '%s.bin' % sys.argv[0]
    kill_orphaned_child(pam_imunify_daemon)
    child_proc = subprocess.Popen([pam_imunify_daemon] + sys.argv[1:])
    child_proc.transient_signal = None
    PIDFILE.write_text('%s\n' % child_proc.pid)
    logger.info("%s has started.", pam_imunify_daemon)

    wire_signals(reroute_to=child_proc)
    gc.collect()  # to minimize memory footprint while in idle state
    try:
        rc = child_proc.wait()
    except ProcessLookupError:
        logger.exception("child process died unexpectedly")
        return False
    finally:
        wire_signals(undo=True)

    if child_proc.transient_signal is None:
        if rc < 0:
            logger.error("%s died unexpectedly (killed by %d).",
                         pam_imunify_daemon,
                         rc)
        else:
            logger.error("%s stopped unexpectedly (exit code %d).",
                         pam_imunify_daemon,
                         rc)
        return False

    logger.info("%s exited with code %d (stopped by signal %d).",
                pam_imunify_daemon,
                rc,
                child_proc.transient_signal)
    return True


def daemon_being_watched_loop() -> bool:
    failures = []
    while True:
        ok_stopped = daemon_being_watched()
        if ok_stopped:
            return True

        now = time.time()
        failures.append(now)
        if (sum(1 for timestamp in failures
                    if timestamp >= now - 60*60) >=
                MAX_FAILURES_PER_HOUR):

            return False

if __name__ == '__main__':
    try:
        logger_init()
        ok_stopped = daemon_being_watched_loop()
        if not ok_stopped:
            logger.error("PAM Imunify ftp integration is to be disabled "
                         "after %d pam_imunify_daemon failures.",
                         MAX_FAILURES_PER_HOUR)
            disable_pamftp_intergation()
    except Exception as e:
        logger.exception("unexpected error: %s", e)
        sys.exit(1)
