#!/opt/alt/python38/bin/python3.8
#
# imunify360-pam        Python script to manage imunify360 pam module
#                       enabled/diabled state.
#

import argparse
from configparser import ConfigParser
from contextlib import closing, suppress
from collections import OrderedDict
from distutils.version import LooseVersion
from functools import lru_cache, wraps
import os
from pathlib import Path
import re
import shutil
import signal
import sys
import subprocess
from typing import Iterable, Tuple
import traceback

import yaml
from pam_i360.internals import getLogger, pam_imunify_config, logger_init

CONFIG_DOVECOT = '/etc/dovecot/dovecot.conf'
CONFIG_DOVECOT_DATASTORE = '/var/cpanel/conf/dovecot/main'
CONFIG_DOVECOT_BASEDIR = '/var/cpanel/templates/dovecot'
CONFIG_DOVECOT_DEFAULT_SUFFIX = '2.3'
CONFIG_DOVECOT_TMPL = 'main.default'
CONFIG_DOVECOT_LOCAL = 'main.local'
CONFIG_PAM_DOVECOT = '/etc/pam.d/dovecot_imunify'
CONFIG_PAM_DOVECOT_DOMAINOWNER = '/etc/pam.d/dovecot_imunify_domainowner'
CONFIG_PROFTPD = '/etc/proftpd.conf'
CONFIG_PAM_PROFTPD = '/etc/pam.d/proftpd_imunify'
CONFIG_PUREFTPD = '/etc/pure-ftpd.conf'
CONFIG_TEMPLATE_PUREFTPD = '/var/cpanel/conf/pureftpd/local'
CONFIG_PAM_PUREFTPD = '/etc/pam.d/pure-ftpd'

LEVELDB = '/opt/i360_pam_imunify/db/leveldb'

PAM_UNIX_REGEX = re.compile(r'auth\s+.+?\s+pam_unix\.so')

# logger late init in order to let sigterm_handler() to break
# logger_init() if needed
logger = None


class Output:
    def status_changed(self, services):
        enabled = []
        already_enabled = []
        disabled = []
        already_disabled = []

        services = OrderedDict(sorted(services.items(), key=lambda x: x[0]))
        for key, value in services.items():
            enabled_prev, enabled_now = value
            if enabled_now:
                if enabled_prev:
                    already_enabled.append(key)
                else:
                    enabled.append(key)
            else:
                if not enabled_prev:
                    already_disabled.append(key)
                else:
                    disabled.append(key)

        message = None
        if len(enabled) > 0:
            message = 'imunify360-pam (%s) is now enabled.' \
                % ', '.join(enabled)

        if len(already_enabled) > 0:
            message = 'imunify360-pam (%s) is already enabled.' \
                % ', '.join(already_enabled)

        if len(disabled) > 0:
            message = 'imunify360-pam (%s) is now disabled.' \
                % ', '.join(disabled)

        if len(already_disabled) > 0:
            message = 'imunify360-pam (%s) is already disabled.' \
                % ', '.join(already_disabled)

        if message:
            self._print(message)

    def status(self, services):
        services = OrderedDict(sorted(services.items(), key=lambda x: x[1]))
        enabled = [key for key, value in services.items() if value]
        if len(enabled) > 0:
            self._print('status: enabled (%s)' % ', '.join(enabled))
        else:
            self._print('status: disabled')

    def warning(self, *args, **kwargs):
        self._print('[WARNING]', *args, **kwargs)

    def error(self, *args, **kwargs):
        self._print('[ERROR]', *args, **kwargs)

    def run_and_log(self, *args, **kwargs):
        subprocess.run(*args, **kwargs)

    def flush(self):
        pass

    def _print(self, *args, **kwargs):
        print(*args, **kwargs)

        # duplicate to pam.log
        if args[0] == '[WARNING]':
            logfun = logger.warning
            args = args[1:]
        elif args[0] == '[ERROR]':
            logfun = logger.error
            args = args[1:]
        else:
            logfun = logger.info
        logfun(' '.join(args))


class YamlOutput(Output):
    def __init__(self):
        self._buffer = {}

    def flush(self):
        print(yaml.safe_dump(self._buffer, default_flow_style=False))

        # duplicate to pam.log
        for k in ['status_changed',
                  'status']:
            if k in self._buffer:
                logger.info("%s=%r", k, self._buffer[k])

    def status_changed(self, services):
        for service, value in services.items():
            enabled_prev, enabled_now = value
            self._buffer.setdefault('status_changed', {})[service] = {
                'from': 'enabled' if enabled_prev else 'disabled',
                'to': 'enabled' if enabled_now else 'disabled'
            }

    def status(self, services):
        for service, enabled in services.items():
            self._buffer.setdefault('status', {})[service] =  \
                'enabled' if enabled else 'disabled'

    def warning(self, *args, **kwargs):
        self._buffer.setdefault('warnings', []).append(' '.join(args))
        logger.warning(' '.join(args))

    def error(self, *args, **kwargs):
        self._buffer.setdefault('errors', []).append(' '.join(args))
        # catch message and backtrace for sentry
        logger.error(' '.join(args))

    def run_and_log(self, cmd, *args, **kwargs):
        proc = subprocess.run(cmd, *args, **kwargs, stdin=subprocess.DEVNULL,
                              # capture and combine both streams into one
                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT)

        if proc.returncode != 0:
            self.warning("%s exit code %d" % (' '.join(cmd), proc.returncode))

        if (proc.returncode != 0 or
                options.verbose or
                pam_imunify_config().getboolean('verbose')):

            self._buffer.setdefault('subprocess_call', []).append({
                'cmd': ' '.join(cmd),
                'returncode': proc.returncode,
                # .decode('ascii', errors='ignore') is to suppress
                # cPanel tools output colors
                'output': proc.stdout.decode('ascii', errors='ignore')
                })


# This function get CP name only
@lru_cache(1)
def get_cp_name():
    panel = None

    # cPanel check
    if os.path.isfile('/usr/local/cpanel/cpanel'):
        panel = 'cpanel'

    # Plesk check
    elif os.path.isfile('/usr/local/psa/version'):
        panel = 'plesk'

    # DirectAdmin check
    elif os.path.isfile('/usr/local/directadmin/directadmin'):
        panel = 'directadmin'

    return panel


def readlink_f(filename):
    """
    Pythonic way of doing /bin/readlink --canonicalize filename
    and is needed for cPanel /etc/pam.d symlinks.
    """
    try:
        result = os.readlink(filename)
    except OSError:
        # not a symlink
        return filename

    if os.path.isabs(result):
        return result
    else:
        return os.path.join(os.path.dirname(filename), result)


def detect_conffiles(output=None):
    if not output:
        output = Output()

    if os.path.exists('/etc/pam.d/common-auth'):
        # debian, ubuntu
        conffiles = '/etc/pam.d/common-auth',
    else:
        conffiles = '/etc/pam.d/password-auth', '/etc/pam.d/system-auth'

    if not all(os.path.exists(conf) for conf in conffiles):
        output.error("PAM configuration file(s) not found: %s" %
                     ' '.join(conffiles))
        sys.exit(1)

    return [readlink_f(fn) for fn in conffiles]


def atomic_rewrite(filename, content):
    """
    Atomically rewrites filename with given content to
    avoid possible "No space left on device"
    or unintenrional PAM module break.

    Backup original file content to {filename}.i360bak
    """
    if os.path.exists(filename):
        shutil.copy(filename, filename + '.i360bak')

    tmp = filename + '.i360edit'
    with open(tmp, 'wb' if isinstance(content, bytes) else 'w') as tf:
        tf.write(content)
        try:
            st = os.stat(filename)
        except FileNotFoundError:
            pass
        else:
            os.fchmod(tf.fileno(), st.st_mode)
            os.fchown(tf.fileno(), st.st_uid, st.st_gid)
    ext = 3
    while ext > 0:
        try:
            os.rename(tmp, filename)
        except OSError:
            ext = ext - 1
            if ext == 0:
                output.error("Trouble in renaming of %s to %s" % (tmp,
                                                                  filename))
                sys.exit(1)
        else:
            ext = 0


class i360RPatch:
    def __init__(self, conf_filename, output=None):
        self._conf_filename = conf_filename
        self.output = Output() if not output else output

    def filename(self):
        return os.path.join(
            os.path.dirname(self._conf_filename),
            '.%s.i360patch' % os.path.basename(self._conf_filename))

    def create_upon(self, content):
        cmd = ['/usr/bin/diff', '--unified=1', self._conf_filename, '-']
        proc = subprocess.Popen(cmd,
                                stdin=subprocess.PIPE,
                                stdout=open(self.filename(), 'w'))
        proc.communicate(content.encode())

        if proc.returncode != 1:
            # not a big deal: will use .i360bak as the last resort
            self.output.warning("'diff -u' error", file=sys.stderr)
            os.unlink(self.filename())

    def apply(self):
        """
        :raise CalledProcessError:
        """
        cmd = ['/usr/bin/patch', '--reverse', self._conf_filename]
        subprocess.check_call(cmd,
                              stdin=open(self.filename()),
                              stdout=open('/dev/null', 'w'))
        os.unlink(self.filename())


def pam_unix_patch_around(pamconfig_lines, pam_unix_ln):
    match_offset = re.search(r'success=(\d)\s+default=ignore',
                             pamconfig_lines[pam_unix_ln])

    patch_simple(pamconfig_lines, pam_unix_ln)
    pam_unix_ln += 1

    if match_offset:
        fix_offset(pamconfig_lines,
                   pam_unix_ln,
                   int(match_offset.group(1)))


def dovecot_manyconfigs_basedir() -> Tuple[Iterable[str], str]:
    """
    As per DEF-18259 it comes out that dovecot config main.default and main.local
    files may reside in a directory /var/cpanel/conf/dovecot/main refers it via
    '_use_target_version: ...'

    :return: dovecot multiple configs basedir list and
             warning message if any
    """
    result = set()
    warning_msg = None
    def check_and_use(path_):
        # unwind '/var/cpanel/templates/dovecot -> /var/cpanel/templates/dovecot2.3'
        path_ = readlink_f(path_)

        if os.path.exists(os.path.join(path_, CONFIG_DOVECOT_TMPL)):
            result.add(path_)
            return True
        return False

    check_and_use(CONFIG_DOVECOT_BASEDIR)
    check_and_use(CONFIG_DOVECOT_BASEDIR + CONFIG_DOVECOT_DEFAULT_SUFFIX)

    try:
        with open(CONFIG_DOVECOT_DATASTORE) as f:
            target_version = yaml.safe_load(f)['_use_target_version']
    except (FileNotFoundError, UnicodeError, yaml.YAMLError) as e:
        warning_msg = "%s: %s" % (CONFIG_DOVECOT_DATASTORE, e)
    except KeyError:
        pass
    else:
        path_ = CONFIG_DOVECOT_BASEDIR + target_version
        if not check_and_use(path_):
            warning_msg = "%s '_use_target_version: %s' refers a non existent " \
                          "configuration file %r" % (CONFIG_DOVECOT_DATASTORE,
                                                     target_version,
                                                     path_)

    return result, warning_msg


def patch_dovecot_config_template(enable: bool, config_basedir: str):
    config_template = os.path.join(config_basedir, CONFIG_DOVECOT_TMPL)
    config_local = os.path.join(config_basedir, CONFIG_DOVECOT_LOCAL)
    passdb_regex = re.compile(r'^\s*passdb\s*\{.*?\}\s*$',
                              re.DOTALL | re.MULTILINE)
    if enable:
        def passdb_replace(match):
            repl = re.sub(r'driver\s*=.*', 'driver = pam', match.group(0))
            repl = re.sub(r'args\s*=.*',
                          r'args = '
                          r'[% IF allow_domainowner_mail_pass %]'
                          r'dovecot_imunify_domainowner'
                          r'[% ELSE %]dovecot_imunify[% END %]',
                          repl)
            return repl

        data = Path(config_template).read_text()
        data = re.sub(passdb_regex, passdb_replace, data)

        if not options.dry:
            with open(config_local, 'w') as f:
                f.write(data)
        else:
            return
    else:
        with suppress(FileNotFoundError):
            os.unlink(config_local)


def toggle_dovecot_support(enable=True, output=None):
    """
    Enable or disable pam_imunify support for Dovecot
    """
    if not output:
        output = Output()

    manyconfigs, warn = dovecot_manyconfigs_basedir()
    if warn:
        output.warning(warn)
    if len(manyconfigs) == 0:
        output.error('Dovecot config template file not found. Aborting.')
        sys.exit(1)
    for config_basedir in manyconfigs:
        patch_dovecot_config_template(enable, config_basedir)

    if not options.norestart:
        if os.path.isfile("/scripts/builddovecotconf"):
            output.run_and_log(["/scripts/builddovecotconf"])
        if os.path.isfile("/scripts/restartsrv_dovecot"):
            output.run_and_log(["/scripts/restartsrv_dovecot"])


def service_incompatibility_panic(msg):
    """
    So far we decided to report service incompatibility error as warning,
    with the only exception for --dry-run option.

    Otherwise we break agent PAM subsystem loop with the error when
    a client copied imunify360-merged.config from one server to another server
    in a case when the first server is compatible with that PAM integration feature
    but the second server is not capable with.
    """
    if options.dry:
        output.error(msg)
        sys.exit(1)
    else:
        output.warning(msg)
        sys.exit(0)


def cpanel_only_feature(service):
    def decorator(fun):
        @wraps(fun)
        def wrapper(*args, **kwargs):
            if get_cp_name() != 'cpanel':
                service_incompatibility_panic(
                    "%s is not supported for %s." % (
                    service, get_cp_name().capitalize()
                    ))
            else:
                return fun(*args, **kwargs)
        return wrapper
    return decorator


def toggle_proftpd_support(enable=True, output=None):
    """
    Enable or disable pam_imunify support for ProFTPd
    """
    conf = CONFIG_PROFTPD
    if not os.path.isfile(conf):
        output.error('ProFTPD config file not found. Aborting.')
        sys.exit(1)

    if enable:
        version_output = subprocess.check_output(
            ['in.proftpd' if get_cp_name() == 'plesk' else 'proftpd',
             '--version-status'],
            stderr=subprocess.DEVNULL).decode(sys.stdout.encoding)
        if 'mod_auth_pam' not in version_output:
            service_incompatibility_panic(
                'ProFTPD built without PAM support. '
                'pam_imunify for FTP is NOT enabled.')

        version_regex = re.compile(r'ProFTPD Version:\s([0-9a-z\.]+)')
        version_found = version_regex.search(version_output)
        if version_found:
            version = LooseVersion(version_found.group(1))
            if version < LooseVersion('1.3.6c') \
               or version.vstring.startswith('1.3.6rc'):
                if get_cp_name() == 'cpanel':
                    service_incompatibility_panic(
                        'ProFTPD needs to be upgraded to '
                        'cPanel version 88 or higher. '
                        'pam_imunify for FTP is NOT enabled.')
                else:
                    service_incompatibility_panic(
                        'ProFTPD needs to be upgraded. '
                        'pam_imunify for FTP is NOT enabled.')

    if not output:
        output = Output()

    authpam_imunify = 'AuthOrder mod_auth_pam.c* mod_auth_file.c\n' \
                      'AuthPAM on\n' \
                      'AuthPAMConfig proftpd_imunify\n'

    authpam_regex = re.compile(r'(^AuthPAM.*\n?)+', re.MULTILINE)

    data = open(conf).read()
    authpam_found = authpam_regex.search(data)

    if enable:
        if authpam_found:
            authpam_span = authpam_found.span()
            data = data[:authpam_span[0]] + authpam_imunify \
                + data[authpam_span[1]:]
        else:
            data = authpam_imunify + '\n' + data

        if not options.dry:
            atomic_rewrite(conf, data)
        else:
            return
    else:
        conf_bak = conf + '.i360bak'
        if os.path.isfile(conf_bak):
            os.rename(conf_bak, conf)
        else:
            output.error("Failed to disable proftpd integration: %s not found"
                         % conf_bak)
            sys.exit(1)
    if os.path.isfile("/scripts/restartsrv_ftpd"):
        output.run_and_log(["/scripts/restartsrv_ftpd"])


def file_patchline(config: str, pattern, repl: bytes, reverse: bool):
    """
    Patch config file line inplace and backup config to '%s.i360bak' % config
    :param config: file path
    :param pattern: re.compile(b'...') result type
    :param reverse: revert back the previos operation on the same config file
    """
    if reverse:
        # lookup replacement in config_i360bak
        try:
            with open('%s.i360bak' % config, 'rb') as f:
                repl = next(ln for ln in f if re.match(pattern, ln))
        except (FileNotFoundError, StopIteration):
            # there were no such entry before us
            repl = b''

    if os.path.exists(config):
        with open(config, 'rb') as f:
            conf_before = f.read()
            conf_after = re.sub(pattern,
                                repl,
                                conf_before,
                                count=1)
            if conf_after == conf_before and not repl in conf_after:
                conf_after = conf_before + (b'' if conf_before.endswith(b'\n')
                                            else b'\n') + repl
        if conf_after != conf_before:
            atomic_rewrite(config, conf_after)
    else:
        atomic_rewrite(config, repl)


def is_pureftpd_supported():
    # Pure-FTPd writes output to stdin, so we have to use
    # pipes to read from stdin afterwards...
    pipe_r, pipe_w = map(os.fdopen, os.pipe())
    def finalize():
        pipe_w.close()
        pipe_r.close()

    try:
        subprocess.check_output(
            ['pure-ftpd', '-l', 'pam'],
            stdin=pipe_w,
            stderr=subprocess.STDOUT,
            timeout=1)
    except subprocess.CalledProcessError:
        # after pipe_w.close() we can do pipe_r.read()
        pipe_w.close()
        with closing(pipe_r):
            # 421 Unknown authentication method: pam
            if pipe_r.read().startswith('421 '):
                return False
    except subprocess.TimeoutExpired:
        # This could happen if pam is supported
        # and pure-ftpd has started
        finalize()
    else:
        finalize()

    return True


def toggle_pureftpd_support(enable=True, output=None):
    """
    Enable or disable pam_imunify support for PureFTPd
    """
    conf = CONFIG_PUREFTPD
    if not os.path.isfile(conf):
        output.error('Pure-FTPd config file not found. Aborting.')
        sys.exit(1)
    if enable:
        if not is_pureftpd_supported():
            service_incompatibility_panic(
                'Pure-FTPd built without PAM support. '
                'pam_imunify for FTP is NOT enabled.')

    if not output:
        output = Output()
    if options.dry:
        return

    extauth_regex = re.compile(br'^\s*ExtAuth\s.*$', re.MULTILINE)
    extauth_imunify = b'ExtAuth /var/run/ftpd.imunify360.sock'
    for config in [CONFIG_PUREFTPD,
                   CONFIG_TEMPLATE_PUREFTPD]:
        file_patchline(config,
                       extauth_regex,
                       extauth_imunify,
                       reverse=enable is False)

    if os.path.isfile("/scripts/restartsrv_ftpd"):
        output.run_and_log(["/scripts/restartsrv_ftpd"])
# /usr/local/cpanel/scripts/setupftpserver pure-ftpd --force might be an option


def toggle_sshd_support(conffiles, enable=True, output=None):
    """
    Enable or disable pam_imunify module for sshd authentication
    """
    if not output:
        output = Output()

    if enable:
        for conf in conffiles:
            lines = open(conf).readlines()

            try:
                pam_unix_ln = next(
                    ln for ln, line in enumerate(lines)
                    if PAM_UNIX_REGEX.search(line)
                    )
            except StopIteration:
                output.error("PAM configuration file %s parse error" % conf)
                sys.exit(1)

            pam_unix_patch_around(lines, pam_unix_ln)

            content = ''.join(lines)
            i360RPatch(conf, output).create_upon(content)
            if not options.dry:
                atomic_rewrite(conf, content)
    else:
        for conf in conffiles:
            rpatch = i360RPatch(conf, output)
            if os.path.exists(rpatch.filename()):
                try:
                    rpatch.apply()
                    continue
                except subprocess.CalledProcessError as e:
                    output.warning("'patch -R' was not successful: %s" % e,
                                   file=sys.stderr)
            else:
                output.warning("File not found: %s" % rpatch.filename(),
                               file=sys.stderr)

            atomic_rewrite(conf, open(conf + '.i360bak').read())


def set_panel_integration(confs, output=None):
    imunify_regex = re.compile(r'auth\s+sufficient\s+pam_imunify\.so')
    assert get_cp_name() == 'cpanel', "The only supported integration so far."

    if not output:
        output = Output()

    for conf in confs:
        lines = open(conf).readlines()

        try:
            imunify_ln = next(
                ln for ln, line in enumerate(lines)
                if imunify_regex.search(line)
                )
        except StopIteration:
            output.error("PAM configuration file %s parse error" % conf)
            sys.exit(1)

        match_count = 0

        def panel_replace(match):
            nonlocal match_count
            if match.group() in ['cpanel', 'plesk', 'directadmin']:
                match_count += 1
                return get_cp_name()
            return match.group()

        lines[imunify_ln] = re.sub(
            r'\b([^\s]+)\b', panel_replace, lines[imunify_ln])

        if match_count == 0:
            lines[imunify_ln] = '%s %s\n' % (lines[imunify_ln].strip(),
                                             get_cp_name())

        content = ''.join(lines)
        atomic_rewrite(conf, content)
        os.unlink(conf + '.i360bak')


def patch_simple(pamconfig_lines, pam_unix_ln):
    pamconfig_lines.insert(pam_unix_ln + 1,
                           'auth\trequired\tpam_imunify.so\n')
    pamconfig_lines.insert(pam_unix_ln,
                           'auth\trequired\tpam_imunify.so\tcheck_only\n')


def fix_offset(pamconfig_lines, pam_unix_ln, pam_unix_success_offset):
    bump_to = pam_unix_success_offset + 1
    pamconfig_lines[pam_unix_ln] = re.sub(r'success=\d',
                                          'success=%d' % bump_to,
                                          pamconfig_lines[pam_unix_ln])


class Cmd:
    @classmethod
    def enable(cls, conffiles, output=None):
        if not output:
            output = Output()

        if any('pam_imunify.so' in open(conf).read() for conf in conffiles):
            cls._cphulk_check()
            output.status_changed({'sshd': (True, True)})
            return

        toggle_sshd_support(conffiles, True, output)

        if not options.dry:
            cls._cphulk_check(output)
            output.status_changed({'sshd': (False, True)})

    @staticmethod
    @cpanel_only_feature("Dovecot")
    def enable_dovecot(conffiles, output=None):
        if not output:
            output = Output()

        set_panel_integration(
            [CONFIG_PAM_DOVECOT, CONFIG_PAM_DOVECOT_DOMAINOWNER], output)

        dovecot_enabled = os.path.isfile(CONFIG_DOVECOT) and \
            'dovecot_imunify' in open(CONFIG_DOVECOT).read()

        if not dovecot_enabled:
            toggle_dovecot_support(True, output)

        if not options.dry:
            output.status_changed({'dovecot': (dovecot_enabled, True)})

    @staticmethod
    @cpanel_only_feature("ProFTPd")
    def enable_proftpd(conffiles, output=None):
        if not output:
            output = Output()

        set_panel_integration([CONFIG_PAM_PROFTPD], output)

        proftpd_enabled = os.path.isfile(CONFIG_PROFTPD) and \
            'proftpd_imunify' in open(CONFIG_PROFTPD).read()

        if not proftpd_enabled:
            toggle_proftpd_support(True, output)

        if not options.dry:
            output.status_changed({'ftp': (proftpd_enabled, True)})

    @staticmethod
    @cpanel_only_feature("Pure-FTPd")
    def enable_pureftpd(conffiles, output=None):
        if not output:
            output = Output()

        panel = get_cp_name()

        pureftpd_enabled = os.path.isfile(CONFIG_PUREFTPD) and \
            b'/var/run/ftpd.imunify360.sock' in open(CONFIG_PUREFTPD, 'rb').read()

        if not pureftpd_enabled:
            toggle_pureftpd_support(True, output)

        if not options.dry:
            output.status_changed({'ftp': (pureftpd_enabled, True)})

    @staticmethod
    def disable_all(conffiles, output=None):
        if not output:
            output = Output()

        dovecot_enabled = os.path.isfile(CONFIG_DOVECOT) and \
            'dovecot_imunify' in open(CONFIG_DOVECOT).read()
        proftpd_enabled = os.path.isfile(CONFIG_PROFTPD) and \
            'proftpd_imunify' in open(CONFIG_PROFTPD).read()
        pureftpd_enabled = os.path.isfile(CONFIG_PUREFTPD) and \
            b'/var/run/ftpd.imunify360.sock' in open(CONFIG_PUREFTPD,
                                                     'rb').read()
        sshd_enabled = any('pam_imunify.so' in open(conf).read()
                           for conf in conffiles)

        if dovecot_enabled:
            toggle_dovecot_support(False, output)

        if proftpd_enabled:
            toggle_proftpd_support(False, output)

        if pureftpd_enabled:
            toggle_pureftpd_support(False, output)

        if sshd_enabled:
            toggle_sshd_support(conffiles, False, output)

        output.status_changed({
            'sshd': (sshd_enabled, False),
            'dovecot': (dovecot_enabled, False),
            'ftp': (proftpd_enabled or pureftpd_enabled, False)
        })

    @staticmethod
    def disable(conffiles, output=None):
        if not output:
            output = Output()

        sshd_enabled = any('pam_imunify.so' in open(conf).read()
                           for conf in conffiles)

        if sshd_enabled:
            toggle_sshd_support(conffiles, False, output)

        output.status_changed({'sshd': (sshd_enabled, False)})

    @staticmethod
    def disable_dovecot(conffiles, output=None):
        if not output:
            output = Output()

        dovecot_enabled = os.path.isfile(CONFIG_DOVECOT) and \
            'dovecot_imunify' in open(CONFIG_DOVECOT).read()
        if dovecot_enabled:
            toggle_dovecot_support(False, output)

        output.status_changed({'dovecot': (dovecot_enabled, False)})

    @staticmethod
    def disable_proftpd(conffiles, output=None):
        if not output:
            output = Output()

        proftpd_enabled = os.path.isfile(CONFIG_PROFTPD) and \
            'proftpd_imunify' in open(CONFIG_PROFTPD).read()
        if proftpd_enabled:
            toggle_proftpd_support(False, output)

        output.status_changed({'ftp': (proftpd_enabled, False)})

    @staticmethod
    def disable_pureftpd(conffiles, output=None):
        if not output:
            output = Output()

        pureftpd_enabled = os.path.isfile(CONFIG_PUREFTPD) and \
            b'/var/run/ftpd.imunify360.sock' in open(CONFIG_PUREFTPD,
                                                     'rb').read()
        if pureftpd_enabled:
            toggle_pureftpd_support(False, output)

        output.status_changed({'ftp': (pureftpd_enabled, False)})

    @staticmethod
    @cpanel_only_feature("FTP service")
    def enable_ftp(conffiles, output=None):
        if not output:
            output = Output()

        with open("/var/cpanel/cpanel.config", "r") as cpcfg:
            data = cpcfg.read()
            if 'ftpserver=proftpd' in data:
                Cmd.enable_proftpd(conffiles, output)
            elif 'ftpserver=pure-ftpd' in data:
                Cmd.enable_pureftpd(conffiles, output)
            else:
                service_incompatibility_panic('No supported FTP found.')

    @staticmethod
    def disable_ftp(conffiles, output=None):
        if not output:
            output = Output()

        proftpd_enabled = os.path.isfile(CONFIG_PROFTPD) and \
            'proftpd_imunify' in open(CONFIG_PROFTPD).read()
        pureftpd_enabled = os.path.isfile(CONFIG_PUREFTPD) and \
            b'/var/run/ftpd.imunify360.sock' in open(CONFIG_PUREFTPD,
                                                     'rb').read()

        if proftpd_enabled:
            toggle_proftpd_support(False, output)

        if pureftpd_enabled:
            toggle_pureftpd_support(False, output)

        output.status_changed({
            'ftp': (proftpd_enabled or pureftpd_enabled, False)
        })

    @classmethod
    def status(cls, conffiles, output=None):
        if not output:
            output = Output()

        dovecot_enabled = os.path.isfile(CONFIG_DOVECOT) and \
            'dovecot_imunify' in open(CONFIG_DOVECOT).read()
        sshd_enabled = any('pam_imunify.so' in open(conf).read()
                           for conf in conffiles)
        proftpd_enabled = os.path.isfile(CONFIG_PROFTPD) and \
            'proftpd_imunify' in open(CONFIG_PROFTPD).read()
        pureftpd_enabled = os.path.isfile(CONFIG_PUREFTPD) and \
            b'/var/run/ftpd.imunify360.sock' in open(CONFIG_PUREFTPD,
                                                     'rb').read()

        if dovecot_enabled or sshd_enabled \
                or proftpd_enabled or pureftpd_enabled:
            cls._cphulk_check(output)

        output.status({'sshd': sshd_enabled,
                       'dovecot': dovecot_enabled,
                       'ftp': proftpd_enabled or pureftpd_enabled})

    @staticmethod
    def state_reset(*_):
        subprocess.check_call(['service', 'imunify360-pam', 'stop'])
        shutil.rmtree(LEVELDB)
        logger.info("rm -rf %s", LEVELDB)
        subprocess.check_call(['service', 'imunify360-pam', 'start'])

    @staticmethod
    def _cphulk_check(output=None):
        if not os.path.isfile("/usr/sbin/whmapi1"):
            return
        if not options.verbose and not pam_imunify_config().getboolean('verbose'):
            return

        if not output:
            output = Output()

        proc = subprocess.run(['whmapi1',
                               'servicestatus',
                               'service=cphulkd'],
                              stdin=subprocess.DEVNULL,
                              stdout=subprocess.PIPE)
        if proc.returncode != 0:
            # we expect err dump is printed to stderr
            return

        try:
            status = yaml.safe_load(proc.stdout)
            if status['data']['service'][0]['enabled']:
                output.warning("cPHulk is enabled", file=sys.stderr)
        except (yaml.YAMLError, IndexError, KeyError) as e:
            output.warning("whmapi error:", e, file=sys.stderr)


def sigterm_handler(signum, frame):
    """
    generate backtrace on SIGTERM
    """
    traceback.print_stack(frame, file=sys.stderr)
    print("caught SIGTERM.", file=sys.stderr)
    if logger is not None:
        logger.fatal("caught SIGTERM.")
    sys.exit(15)


if __name__ == '__main__':
    signal.signal(signal.SIGTERM, sigterm_handler)
    logger = logger_init(console_stream=None)
    parser = argparse.ArgumentParser()
    parser.add_argument('cmd',
                        choices=sorted(
                            (cmd.replace('_', '-') for cmd in dir(Cmd)
                             if not cmd.startswith('_')),
                            reverse=True))
    parser.add_argument("-r",
                        "--dry-run",
                        dest='dry',
                        action='store_true',
                        help="Dry run the command, whithout changing of state")
    parser.add_argument("-n",
                        "--no-restart",
                        dest='norestart',
                        action='store_true',
                        help="Don't restart dovecot and don't rebuild, just patch local dovecot template (cPanel only)")
    parser.add_argument("--yaml",
                        dest='yaml',
                        action='store_true',
                        help="for YAML output")
    parser.add_argument("-v",
                        "--verbose",
                        dest='verbose',
                        action='store_true')
    options = parser.parse_args()
    output = YamlOutput() if options.yaml else Output()
    try:
        cmd = getattr(Cmd, options.cmd.replace('-', '_'))
        cmd(detect_conffiles(output), output)
    except Exception as e:
        logger.exception("unexpected error: %s", e)
    finally:
        # even if we caught an exception there is still a possibility
        # that stdout is still usable (at least partially)
        output.flush()
