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

from __future__ import print_function
from configparser import ConfigParser
import os
import platform
import re
import shutil
import sys
import subprocess

import yaml

CONFIG = '/etc/pam_imunify/i360.ini'


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():
    distname = platform.linux_distribution()[0].split()[0].upper()
    if distname in ('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):
        sys.exit("PAM configuration file(s) not found error: %s" %
                 ' '.join(conffiles))

    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"
    and unintenrional PAM module break.
    """
    shutil.copy(filename, filename + '.i360bak')
    tmp = filename + '.i360edit'
    with open(tmp, 'w') as tf:
        tf.write(content)
        st = os.stat(filename)
        os.fchmod(tf.fileno(), st.st_mode)
        os.fchown(tf.fileno(), st.st_uid, st.st_gid)
    os.rename(tmp, filename)


class i360RPatch:
    def __init__(self, conf_filename):
        self._conf_filename = conf_filename

    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
            print("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 read_config():
    ini_pairs = open(CONFIG).read()
    config = ConfigParser()
    config.read_string("[DEFAULT]\n" + ini_pairs)
    return config['DEFAULT']


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 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:
    PAM_UNIX_REGEX = re.compile(r'auth\s+.+?\s+pam_unix\.so')

    @classmethod
    def enable(cls, conffiles):
        if any('pam_imunify.so' in open(conf).read() for conf in conffiles):
            cls._cphulk_check()
            print("imunify360-pam is already enabled.")
            return

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

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

            pam_unix_patch_around(lines, pam_unix_ln)

            content = ''.join(lines)
            i360RPatch(conf).create_upon(content)
            atomic_rewrite(conf, content)

        cls._cphulk_check()
        print("imunify360-pam is now enabled.")

    @staticmethod
    def disable(conffiles):
        if not any('pam_imunify.so' in open(conf).read()
                   for conf in conffiles):
            print("imunify360-pam is already disabled.")
            return

        for conf in conffiles:
            rpatch = i360RPatch(conf)
            if os.path.exists(rpatch.filename()):
                try:
                    rpatch.apply()
                    continue
                except subprocess.CalledProcessError as e:
                    print("WARNING: 'patch -R' was not successful: %s" % e,
                          file=sys.stderr)
            else:
                print("WARNING: file not found: %s" % rpatch.filename(),
                      file=sys.stderr)

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

        print("imunify360-pam is now disabled.")

    @classmethod
    def status(cls, conffiles):
        if any('pam_imunify.so' in open(conf).read() for conf in conffiles):
            cls._cphulk_check()
            print("status: enabled")
        else:
            print("status: disabled")

    @staticmethod
    def _cphulk_check():
        if not os.path.isfile("/usr/sbin/whmapi1"):
            return
        if not read_config().getboolean('verbose'):
            return

        proc = subprocess.run(['/usr/sbin/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.load(proc.stdout)
            if status['data']['service'][0]['enabled']:
                print("WARNING: cPHulk is enabled", file=sys.stderr)
        except (yaml.YAMLError, IndexError, KeyError) as e:
            print("WARNING: whmapi error:", e, file=sys.stderr)


if __name__ == '__main__':
    try:
        cmd = getattr(Cmd, sys.argv[1])
    except (AttributeError, IndexError):
        sys.exit("Usage: %s {enable|disable|status}" % sys.argv[0])
    else:
        cmd(detect_conffiles())
