#!/opt/alt/python35/bin/python3.5

import argparse
import atexit
import codecs
import datetime
import glob
import itertools
import json
import os
import psutil
import pwd
import queue
import random
import re
import signal
import string
import subprocess
import sys
import threading
import time

from cryptography import x509
from cryptography.hazmat.backends import default_backend


class WrongPanelVersion(Exception):
    pass


def _generate_string(length=6, lower=False):
    """
    Generates random string
    :param length: int -> length of the required string
    :return: str -> generated string
    """
    letters = string.ascii_lowercase if lower else string.ascii_letters
    sample = random.sample(letters + string.digits, length)
    return ''.join(sample)


def _get_common_name_from_certificate(path):
    """
    Tries to get CommonName from a certificate
    :param path: str -> path to the certificate
    :return: str -> common name or None
    """
    try:
        with open(path, 'rb') as f:
            text = f.read()
        cert = x509.load_pem_x509_certificate(text, default_backend())
        return [attr.value for attr in cert.subject.get_attributes_for_oid(
                x509.oid.NameOID.COMMON_NAME)][0]
    except Exception:
        return


class Panel:

    interval = 30
    ssl_cache = '/var/cache/imunify360-webshield/ssl.cache'

    def __init__(self):
        """Constructor"""
        self._cert_paths = set()
        self._certs_data = set()

    @classmethod
    def detect(cls):
        """
        Runs detection command to detect hosting panel environment
        The call is expected to succeed or raise an exception
        """
        subprocess.check_call(cls.detect_cmd,
                              stdout=subprocess.DEVNULL,
                              stderr=subprocess.DEVNULL)

    @staticmethod
    def get_safe_mtime(path):
        if os.path.exists(path):
            return os.path.getmtime(path)
        return 0


    def get_mtime_offset(self):
        """
        Checks if ssl cache update is required. Compares modification times of
        watch file and cache file
        :return: tuple (float, bool) -> max modification time and decision
        """
        cache_mtime = os.path.getmtime(self.ssl_cache)
        watch_mtime = self.get_safe_mtime(self.ssl_watch)
        panel_mtime = self.get_panel_mtime()
        last_mtime = max(watch_mtime, panel_mtime)
        if cache_mtime < last_mtime:
            return last_mtime, True
        return cache_mtime, False

    def _get_cert_from_path(self, q, l):
        """
        Gets tuple of paths from the queue and reads the paths.
        Then appends the produced bundle to list of certificates data
        :param q: obj -> queue instance
        :param l: obj -> lock instance
        """
        while True:
            paths = q.get()     # tuple of domain name and paths
            if paths is None:
                break

            certs = []
            for path in paths[1]:
                try:
                    with open(path, 'rb') as r:
                        cert = r.read()
                    certs.append(cert)
                except FileNotFoundError:
                    continue
            if len(paths[1]) != len(certs):
                q.task_done()
                continue

            bundle = (paths[0].encode('ascii') + b';;' +
                      codecs.escape_encode(b'\n'.join(certs))[0] + b'\n')
            with l:
                self._certs_data.add(bundle)
            q.task_done()

    @staticmethod
    def _process(func, iterable, thread_num=4):
        """
        Apply the passed function to every item in array
        """
        threads = []
        q = queue.Queue()
        l = threading.Lock()
        for _ in range(thread_num):
            t = threading.Thread(target=func, args=(q, l))
            threads.append(t)

        for t in threads:
            t.start()

        for item in iterable:
                q.put(item)

        q.join()

        for _ in threads:
            q.put(None)

        for t in threads:
            t.join()

    def resolve_paths(self):
        """
        Creates set of all host certificate paths. Uses threads
        to reduce processing time
        :return: set -> set of tuples of cert, key, chain paths
        """
        self._cert_paths.clear()

        if isinstance(self.ssl_info_path, (list, tuple)):
            iter_items = self.ssl_info_path
        else:
            iter_items = self.ssl_info_path,

        globs = map(glob.iglob, iter_items)
        iterable = itertools.chain(*globs)
        self._process(self._process_ssl_path, iterable)

        return self._cert_paths


class Cpanel(Panel):

    detect_cmd = '/usr/local/cpanel/cpanel', '-V'
    ssl_info_path = '/var/cpanel/userdata/*/*_SSL.cache'
    panel_cert_dir = '/var/cpanel/ssl/cpanel'

    @classmethod
    def force_detect(cls, patt=re.compile(r'(?P<version>[0-9.]+)')):
        p = subprocess.check_output(cls.detect_cmd, stderr=subprocess.DEVNULL)
        m = patt.match(p.decode('utf-8'))
        if not m:
            raise WrongPanelVersion
        version = float(m.group('version'))
        if not cls.is_correct_version(version):
            raise WrongPanelVersion

    @classmethod
    def detect(cls):
        """
        Raises 'WrongPanelVersion' unless 'watch file' exists
        """
        if not os.path.exists(cls.ssl_watch):
            raise WrongPanelVersion

    def get_panel_mtime(self):
        """
        Returns modification time of the most recent certificate file
        :return: float -> modification time
        """
        domain, path, mtime = self._get_panel_cert()
        if domain and path and mtime:
            self.panel_cert_domain = domain
            self.panel_cert_path = path
            return mtime
        return 0

    def get_panel_cert_path(self):
        """
        Returns panel cert path
        :return: tuple -> panel cert domain and path
        """
        if (hasattr(self, 'panel_cert_path') and
                hasattr(self, 'panel_cert_domain')):
            return (self.panel_cert_domain, (self.panel_cert_path,))
        domain, path, _ = self._get_panel_cert()
        if domain and path:
            return (domain, (path,))

    @classmethod
    def _get_panel_cert(cls):
        """
        Iterates through '.pem' files in panel certificate folder and returns
        path to the most recent file and its modification time
        :return: tuple -> domain, path to certificate and modification time
        """
        last = 0
        path = None
        domain = None

        for item in os.listdir(cls.panel_cert_dir):
            if not item.endswith('pem'):
                continue
            fullpath = os.path.join(cls.panel_cert_dir, item)
            mtime = os.path.getmtime(fullpath)
            if mtime > last:
                last = mtime
                path = fullpath

        if path is None:            # Nothing found. Unsuccessful return
            return None, None, 0

        domain = _get_common_name_from_certificate(path)

        return domain, path, last


class NewCpanel(Cpanel):

    tls_path = '/var/cpanel/ssl/apache_tls'
    ssl_watch = '/var/cpanel/ssl/apache_tls/.index.sqlite'

    @staticmethod
    def is_correct_version(value):
        return value > 66

    def _process_ssl_path(self, q, l):
        """
        Gets paths of certificate from config files
        :param q: obj -> queue instance
        :param l: obj -> lock instance
        """
        while True:
            path = q.get()
            if path is None:
                break

            try:
                with open(path) as f:
                    data = json.load(f)
            except Exception:
                q.task_done()
                continue

            if data['ssl'] not in (1, '1'):
                q.task_done()
                continue

            if 'servername' not in data:
                q.task_done()
                continue

            path = os.path.join(
                self.tls_path, data['servername'], 'combined')

            with l:
                self._cert_paths.add((data['servername'], (path,)))
            q.task_done()


class OldCpanel(Cpanel):

    ssl_watch = '/var/cpanel/ssl/installed/ssl.db.cache'

    @staticmethod
    def is_correct_version(value):
        return value <= 66

    def _process_ssl_path(self, q, l):
        """
        Gets paths of certificate from config files
        :param q: obj -> queue instance
        :param l: obj -> lock instance
        """
        while True:
            path = q.get()
            if path is None:
                break
            try:
                with open(path) as f:
                    data = json.load(f)
            except Exception:
                q.task_done()
                continue

            if data['ssl'] not in (1, '1'):
                q.task_done()
                continue

            if 'servername' not in data:
                q.task_done()
                continue

            files = [
                data.get('sslcertificatekeyfile'),
                data.get('sslcertificatefile')]

            if not all(files):
                q.task_done()
                continue

            if 'sslcacertificatefile' in data:
                files.append(data['sslcacertificatefile'])
            with l:
                self._cert_paths.add((data['servername'], tuple(files)))
            q.task_done()


class Plesk(Panel):

    detect_cmd = '/usr/sbin/plesk', 'version'
    ssl_watch = '/etc/nginx/plesk.conf.d/vhosts'
    ssl_info_path = ('/etc/nginx/plesk.conf.d/vhosts/*.conf',
                     '/etc/nginx/plesk.conf.d/server.conf')
    panel_cert_path = '/usr/local/psa/admin/conf/httpsd.pem'
    path_patt = re.compile('ssl_certificate\s+(.+?);')
    name_patt = re.compile(r'server_name\s+(?:(?:www|ipv4|ipv6)\.)?(\S+);')

    def get_panel_mtime(self):
        """
        Returns max mtime of the panel certificate, if any
        :return: float or zero
        """
        try:
            return os.path.getmtime(self.panel_cert_path)
        except FileNotFoundError:
            return 0

    def get_panel_cert_path(self):
        """
        Thin wrapper returning panel certificate path
        :return: tuple -> certificate domain and path
        """
        common_name = _get_common_name_from_certificate(self.panel_cert_path)
        # unless common name is retrievable, generate random name to avoid
        # any coincidences with existing names
        if not common_name:
            common_name = 'plesk-' + _generate_string(lower=True)

        return (common_name, (self.panel_cert_path,))

    def _process_ssl_path(self, q, l):
        """
        Gets paths of certificate from config files
        :param q: obj -> queue instance
        :param l: obj -> lock instance
        """
        while True:
            path = q.get()
            if path is None:
                break

            try:
                with open(path) as f:
                    data = f.read()
            except Exception:
                q.task_done()
                continue

            match = self.path_patt.search(data)
            if not match:
                q.task_done()
                continue

            cert_path = match.group(1)
            domains = set(self.name_patt.findall(data))
            if not domains:
                q.task_done()
                continue

            with l:
                self._cert_paths.add((domains.pop(), (cert_path,)))

            q.task_done()


class DirectAdmin(Panel):
    interval = 60
    detect_cmd = '/usr/local/directadmin/custombuild/build', 'version'
    ssl_watch = '/usr/local/directadmin/data/users'
    ssl_info_path = '/usr/local/directadmin/data/users/*/httpd.conf'
    config = '/usr/local/directadmin/conf/directadmin.conf'
    cert_patt = re.compile(r'SSLCertificateFile\s+?(?P<path>\S+)')
    key_patt = re.compile(r'SSLCertificateKeyFile\s+?(?P<path>\S+)')
    name_patt = re.compile(r'Server(?:Name|Alias)\s+(?:www\.)?(\S+)')

    def get_panel_mtime(self):
        """
        Returns mtime of the panel files (cert, key or chain), if any
        :return: float or zero
        """
        domain, paths = self.get_panel_certs_paths()
        if domain and paths:
            self.panel_cert_domain = domain
            self.panel_cert_paths = paths
            return max(map(os.path.getmtime, self.panel_cert_paths))
        return 0

    def get_panel_cert_path(self):
        """
        Thin wrapper around panel certificate paths getter
        :return: tuple -> certificates paths
        """
        if (hasattr(self, 'panel_cert_domain') and
                hasattr(self, 'panel_cert_paths')):
            return (self.panel_cert_domain, tuple(self.panel_cert_paths))
        domain, paths = self.get_panel_certs_paths()
        if domain and paths:
            return (domain, tuple(paths))

    @classmethod
    def get_panel_certs_paths(cls):
        """
        Gets list of panel certificate [, key, chain] paths
        :return: tuple -> domain and tuple of paths to key, cert, chain
        """
        ssl_patt = re.compile(r'SSL\s*=\s*(?P<ssl>1|0)')
        cert_patt = re.compile(r'cacert\s*=\s*(?P<path>\S+)')
        key_patt = re.compile(r'cakey\s*=\s*(?P<path>\S+)')
        chain_patt = re.compile(r'carootcert\s*=\s*(?P<path>\S+)')

        paths = {}
        ssl = False

        with open(cls.config) as f:
            for line in f:
                if line.startswith('#'):
                    continue
                m = ssl_patt.match(line)
                if m:
                    ssl = True if m.group('ssl') == '1' else False
                    continue
                m = cert_patt.match(line)
                if m:
                    paths['cert'] = m.group('path')
                    continue
                m = key_patt.match(line)
                if m:
                    paths['key'] = m.group('path')
                    continue
                m = chain_patt.match(line)
                if m:
                    paths['chain'] = m.group('path')
                    continue
        if not ssl:
            return None, tuple()

        if not all(paths.get(p) for p in ('key', 'cert')):  # chain is optional
            return None, tuple()

        domain = _get_common_name_from_certificate(paths['cert'])
        if not domain:
            domain = 'directadmin-' + _generate_string(lower=True)

        _paths = tuple(filter(None, map(paths.get, ['key', 'cert', 'chain'])))
        return domain, _paths

    def _process_ssl_path(self, q, l):
        """
        Gets paths of certificate from config files
        :param q: obj -> queue instance
        :param l: obj -> lock instance
        """
        while True:
            path = q.get()
            if path is None:
                break

            try:
                with open(path) as f:
                    data = f.read()
            except Exception:
                q.task_done()
                continue

            paths = []
            for patt in self.key_patt, self.cert_patt:
                m = patt.search(data)
                if m:
                    paths.append(m.group('path'))

            if len(paths) != 2:
                q.task_done()
                continue

            domains = set(self.name_patt.findall(data))
            if not domains:
                q.task_done()
                continue

            with l:
                self._cert_paths.add((domains.pop(), tuple(paths)))

            q.task_done()

    def _get_cert_from_path(self, q, l):
        while True:
            paths = q.get()
            if paths is None:
                break

            certs = []
            for path in paths[1]:
                try:
                    with open(path, 'r') as f:
                        chunk = f.read().replace('\r\n', '\n')
                    certs.append(chunk.encode('ascii'))
                except FileNotFoundError:
                    continue
            if len(paths[1]) != len(certs):
                q.task_done()
                continue

            bundle = (paths[0].encode('ascii') + b';;' +
                      codecs.escape_encode(b'\n'.join(certs))[0] + b'\n')
            with l:
                self._certs_data.add(bundle)
            q.task_done()

    def get_mtime_offset(self):
        cache_mtime = os.path.getmtime(self.ssl_cache)
        last_mtime = self.get_panel_mtime()
        if cache_mtime < last_mtime:
            return last_mtime, True

        if not os.path.exists(self.ssl_watch):
            return cache_mtime, False

        cmd = ['find', self.ssl_watch, '-type', 'f', '-name', 'httpd.conf',
               '-newer', self.ssl_cache]
        p = subprocess.Popen(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            universal_newlines=True)
        out, err = p.communicate()

        if not out:
            return cache_mtime, False

        last_mtime = max(map(os.path.getmtime, out.splitlines()))
        return last_mtime, True


class Daemon(object):
    """Implements general daemon process"""

    pidfile = '/tmp/daemon.pid'

    def __init__(self):
        """Constructor"""
        signal.signal(signal.SIGTERM, self._sigterm_handler)
        signal.signal(signal.SIGHUP, self._sighup_handler)

    def start(self):
        """
        Invokes the actual '_start' subroutine. Checks if PID file is present
        and exits if it is
        """
        pid = self._check_if_running()
        if pid is not None:
            message = "The process is already running with pid %s. Exit\n"
            raise SystemExit(message % (pid))
        self._start()

    def _check_if_running(self):
        for proc in psutil.process_iter(attrs=['pid', 'cmdline']):
            if sys.argv[0] in proc.cmdline():
                if proc.pid != os.getpid():
                    return proc.pid

    def stop(self):
        """
        Gets PID from PID file and sends 'SIGTERM' to obtained PID
        """
        pid = self._get_pid()
        if pid is None:
            message = "pidfile %s does not exist. Is daemon stopped?\n"
            sys.stderr.write(message % (self.pidfile,))
            return
        os.kill(pid, signal.SIGTERM)

    def restart(self):
        """
        Makes restart
        """
        self.stop()
        self.start()

    def _start(self):
        """
        Subroutine to be redefined by successor
        """
        pass

    def _stop(self):
        """
        Subroutine to be redefined by successor
        """
        pass

    def _daemonize(self):
        """
        Daemonizes. Forks twice and close all file descriptors.
        """
        self._do_fork(1)
        os.chdir('/')
        os.umask(0)
        os.setsid()
        self._do_fork(2)
        sys.stdout.flush()
        sys.stderr.flush()
        with open('/dev/null', 'rb', 0) as f:
            os.dup2(f.fileno(), sys.stdin.fileno())
        with open('/dev/null', 'ab', 0) as f:
            os.dup2(f.fileno(), sys.stdout.fileno())
        with open('/dev/null', 'ab', 0) as f:
            os.dup2(f.fileno(), sys.stderr.fileno())
        self._makepid()
        atexit.register(self._delpid)

    def _sigterm_handler(self, signo, frame):
        """
        When TERM received stops the main loop and deletes PID-file
        """
        self._stop()
        self._delpid()

    def _sighup_handler(self, signo, frame):
        """
        Subroutine to be redefined by successor
        """
        pass

    @staticmethod
    def _do_fork(num):
        """
        Makes a fork. Parent exists normally.
        """
        try:
            if os.fork() > 0:
                raise SystemExit(0)
        except OSError:
            raise RuntimeError('fork #%s failed' % num)

    def _get_pid(self):
        """
        Gets PID from pid-file
        :return: int -> PID or None
        """
        try:
            with open(self.pidfile) as f:
                pid = f.read().strip()
            if pid is not None and pid.isdigit():
                return int(pid)
        except (OSError, IOError):
            return

    def _delpid(self):
        """
        Deletes PID file
        """
        try:
            os.unlink(self.pidfile)
        except OSError:
            return

    def _makepid(self):
        """
        Writes PID into pid file
        """
        try:
            with open(self.pidfile, 'w') as f:
                f.write(str(os.getpid()))
        except Exception:
            sys.exit(1)


class CertCache(Daemon):

    pidfile = '/var/run/imunify360-webshield-ssl-cache.pid'
    polling_interval = 1
    user = 'imunify360-webshield'

    def __init__(self, **config):
        """Constructor"""

        super().__init__()
        _pid_file = config.pop('pid_file', None)

        for k, v in config.items():
            super().__setattr__(k, v)

        if _pid_file:
            self.pidfile = _pid_file
        self._is_running = False

    def make_ssl_cache(self, force=False):
        """
        Makes the ssl cache. Runs main activity in threads to reduce
        processing time.
        :param force: bool -> if True makes the ssl cache regardless
                      if cache is outdated
        """
        last_mtime, update_required = self._panel.get_mtime_offset()

        if not force and not update_required:
            return

        paths = self._panel.resolve_paths()     # Set of tuples
        panel_paths = self._panel.get_panel_cert_path()
        if panel_paths:
            paths.add(panel_paths)

        self._panel._process(self._panel._get_cert_from_path, paths)

        self._save(self._panel._certs_data, last_mtime)
        self._panel._certs_data.clear()

    def _save(self, ssl_data, last_mtime):
        ssl_cache_tmp = os.path.extsep.join([
            self._panel.ssl_cache, _generate_string()])
        with open(ssl_cache_tmp, 'wb') as w:
            w.writelines(ssl_data)

        atime = datetime.datetime.now().timestamp()
        user = pwd.getpwnam(self.user)

        os.chmod(ssl_cache_tmp, 0o600)
        os.chown(ssl_cache_tmp, user.pw_uid, user.pw_gid)
        os.utime(ssl_cache_tmp, (atime, last_mtime))
        os.rename(ssl_cache_tmp, self._panel.ssl_cache)


    def _start(self):
        """
        Does preparation for the main loop and starts it.
        """
        self._is_running = True
        self._panel = get_panel()

        if not self._panel:
            if self.idle_cycle:
                if not self.no_daemon:
                    self._daemonize()
                return self._idle_cycle()
            return

        if self.once:
            self.make_ssl_cache(force=True)
            return

        if not self.no_daemon:
            self._daemonize()

        self.make_ssl_cache(force=True)

        self._run()

    def _stop(self):
        """
        Sets '_is_runing' to False to exit from the main loop
        """
        self._is_running = False

    def _idle_cycle(self):
        """
        When no panel is detected but for some reason we need to have
        ssl-cache working we do idle cycle while '_is_running' is True
        """
        while self._is_running:
            time.sleep(self.polling_interval)

    def _run(self):
        """
        Main routine. Loops until '_is_running' is True
        """
        ticks = 0
        while self._is_running:
            if ticks >= self._panel.interval:
                ticks = 0
                self.make_ssl_cache()
            else:
                ticks += self.polling_interval
            time.sleep(self.polling_interval)


def get_panel():
    """
    Gets current panel object if any or None
    :return: obj -> instance of panel class or None
    """
    # The order matters. We want NewCpanel checked before OldCpane
    for panel in NewCpanel, OldCpanel, Plesk, DirectAdmin:
        try:
            panel.detect()
        except (FileNotFoundError, subprocess.CalledProcessError,
                WrongPanelVersion):
            continue
        else:
            return panel()

    for panel in NewCpanel, OldCpanel:
        try:
            panel.force_detect()
        except (FileNotFoundError, subprocess.CalledProcessError,
                WrongPanelVersion):
            continue
        else:
            return panel()


def _start(opts):
    """Service 'start' action"""
    config = {k: v for k, v in vars(opts).items() if k != 'action'}
    CertCache(**config).start()


def _stop(opts):
    """Service 'stop' action"""
    CertCache().stop()


def parse_opts():
    """
    Parses passed options into 'parser' object
    :return: parser object
    """
    parser = argparse.ArgumentParser("Test")
    subs = parser.add_subparsers(help="Actions to be taken")

    start = subs.add_parser("start", help="Start options")
    start.set_defaults(action=_start)
    start.add_argument(
        "-D", "--no-daemon", action="store_true", help="Do not daemonize")
    start.add_argument(
         "-X", "--idle-cycle", action="store_true",
         help="Do not return immediately if no panel found. "
         "Just do idle cycle")
    start.add_argument("--pid-file", "-p", help="Path to PID file")
    start.add_argument(
        "--once", "-1", action="store_true",
        help="Update cache forcibly and exit")

    stop = subs.add_parser("stop")
    stop.set_defaults(action=_stop)

    return parser.parse_args()


if __name__ == '__main__':
    parsed = parse_opts()
    if not hasattr(parsed, 'action'):
        raise SystemExit("An action ('start|stop) is required!")
    try:
        parsed.action(parsed)
    except KeyboardInterrupt:
        sys.exit(0)
