#!/opt/imunify360/venv/bin/python3

import glob
import ipaddress
import os
import random
import re
import shutil
import string
import subprocess
import sys


def _canonical_ip(ip):
    """Render ip in the form lua/ssl.lua builds its lookup key in.

    >>> _canonical_ip('198.51.100.1')
    '198.51.100.1'
    >>> _canonical_ip('2001:db8::2')
    '2001:0db8:0000:0000:0000:0000:0000:0002'
    >>> _canonical_ip('::ffff:1.2.3.4')
    '0000:0000:0000:0000:0000:ffff:0102:0304'
    >>> _canonical_ip('kittens')
    'kittens'
    """
    try:
        addr = ipaddress.ip_address(ip)
    except ValueError:
        return ip
    if addr.version == 4:
        return str(addr)
    # .exploded keeps the dotted tail of an IPv4-mapped address, which never
    # matches the eight hex groups lua/ssl.lua formats every IPv6 address into.
    packed = addr.packed.hex()
    return ':'.join(packed[i:i + 4] for i in range(0, 32, 4))


class Panel:

    def __init__(self, host_addresses):
        self.host_addresses = set()
        if host_addresses:
            self.host_addresses.update(host_addresses)

    @classmethod
    def detect(cls):
        if not cls.detect_path:
            return False
        return os.path.exists(cls.detect_path)

    def get_destinations(self):
        data = {}
        for ip, domains in self.additional.items():
            for domain in domains:
                data[domain] = ip
        return data

    def get_ssl_mapping(self):
        data = {}
        for ip, domains in self.additional.items():
            data[_canonical_ip(ip)] = sorted(domains)[0]
        if self.main_ip and self.first_domain:
            data[_canonical_ip(self.main_ip)] = self.first_domain
        return data


class cPanel(Panel):

    detect_path = '/usr/local/cpanel/cpanel'
    domain_ips_path = '/etc/domainips'
    main_address_path = '/var/cpanel/mainip'
    domain_users_path = '/etc/domainusers'

    def prepare(self):
        all_domains = self._get_domains()
        self.main_ip = self._get_main_ip()
        self.additional = self._get_additional()
        if not all_domains:
            self.first_domain = None
            return
        diff = sorted(
            all_domains.difference(
                *self.additional.values()))
        self.first_domain = diff[0] if diff else None

    @classmethod
    def _get_domains(cls):
        domains = set()
        try:
            with open(cls.domain_users_path) as f:
                for line in f:
                    _, domain = [i.strip() for i in line.split(':')]
                    domains.add(domain)
        except Exception:
            pass
        return domains

    def _get_additional(self):
        addresses = {}
        if not self.host_addresses:
            # The file names addresses this host may no longer answer on, so
            # without the host's own addresses to check against there is
            # nothing trustworthy to publish.
            sys.stderr.write('no host addresses detected, skipping {}\n'.format(
                self.domain_ips_path))
            return addresses
        if not os.path.exists(self.domain_ips_path):
            return addresses
        try:
            with open(self.domain_ips_path) as f:
                for line in f:
                    if line.startswith('#'):
                        continue
                    if ':' not in line:
                        continue
                    ip, domain = [i.strip() for i in line.split(':', 1)]
                    if not ip:
                        continue
                    if ip not in self.host_addresses:
                        continue
                    if ip not in addresses:
                        addresses[ip] = []
                    addresses[ip].append(domain)
        except Exception:
            pass
        return addresses

    @classmethod
    def _get_main_ip(cls):
        try:
            with open(cls.main_address_path) as f:
                return f.read().strip()
        except Exception:
            return


class Plesk(Panel):

    detect_path = '/usr/sbin/plesk'
    # Plesk publishes each address's default site as one webserver config in an
    # ip_default directory, so the directory itself is the marker: everything
    # in it is a default site. The trees are in the order the ssl cache daemon
    # prefers them, because only the tree it reads has its names in ssl.cache.
    ip_default_globs = (
        '/etc/nginx/plesk.conf.d/ip_default/*.conf',
        '/etc/apache2/plesk.conf.d/ip_default/*.conf',
        '/etc/httpd/conf/plesk.conf.d/ip_default/*.conf',
    )
    # Apache accepts any casing for its directive names, so match without
    # regard to it; nginx's are lower case by definition.
    block_patt = re.compile(
        r"""^\s*(?:server\s*\{|<VirtualHost)""", re.VERBOSE | re.IGNORECASE)
    addr_patt = re.compile(
        r"""^\s*
            (?:listen|<VirtualHost)\s+     # nginx and apache spellings
            "?\[?                          # Plesk quotes values; [] wraps IPv6
            (?P<ip>[0-9a-fA-F.:]+)
            \]?:\d+                       # a port is what makes this an address
            """, re.VERBOSE | re.IGNORECASE)
    name_patt = re.compile(
        r"""^\s*
            (?:server_name|ServerName)\s+  # anchored: proxy_ssl_server_name is not this
            "?(?P<name>[^"\s;]+)
            """, re.VERBOSE | re.IGNORECASE)

    @classmethod
    def _read_default_sites(cls, path):
        """Return one (addresses, name) pair per virtual host block in path,
        with name None for a block that declares none.

        A block's own addresses and its own name are kept together: a config
        holds a block per port, and nothing guarantees they all describe the
        same site. Blocks are split on the opening directive alone, never by
        counting braces -- a nested one-line block such as nginx's "types { }"
        makes brace counting run past the end of the block it is in.

        Plesk emits a block's base name ahead of its www./ipv4./ipv6. aliases,
        so the first name in a block is the one its certificate is issued for.
        """
        sites = []
        addresses = []
        name = None
        with open(path) as f:
            for line in f:
                if cls.block_patt.match(line):
                    sites.append((addresses, name))
                    addresses, name = [], None
                    # falls through: <VirtualHost carries the address too
                m = cls.addr_patt.match(line)
                if m:
                    addresses.append(m.group('ip'))
                elif name is None:
                    m = cls.name_patt.match(line)
                    if m:
                        name = m.group('name')
        sites.append((addresses, name))
        return sites

    def prepare(self):
        # Nothing here is guessed: every entry is an address Plesk itself
        # designated a default site for, which is the site its own webserver
        # answers a connection carrying no SNI with. So there is no main
        # address to single out and no shared address to pick a tenant from.
        self.main_ip = None
        self.first_domain = None
        self.additional = {}
        for pattern in self.ip_default_globs:
            paths = sorted(glob.glob(pattern))
            if not paths:
                continue
            for path in paths:
                try:
                    sites = self._read_default_sites(path)
                except (OSError, UnicodeDecodeError) as e:
                    sys.stderr.write('cannot read {}: {}\n'.format(path, e))
                    continue
                for addresses, name in sites:
                    if not name:
                        continue
                    for addr in set(addresses):
                        try:
                            # Where Plesk drives both webservers, the apache
                            # copy of a site binds 127.0.0.1 and nginx proxies
                            # to it. No certificate belongs to that address.
                            if ipaddress.ip_address(addr).is_loopback:
                                continue
                        except ValueError:
                            # a name of hex letters and dots, such as
                            # abc.def, also matches addr_patt
                            continue
                        self.additional.setdefault(addr, []).append(name)
            # One webserver faces the network, and only its names are in
            # ssl.cache. Merging a second tree would put two names on one
            # address, which get_ssl_mapping resolves alphabetically. A tree
            # holding files that name no address to serve is not that
            # webserver's: where Plesk drives both, the copy belonging to the
            # one behind is a comment-only stub, or binds loopback.
            if self.additional:
                break


class DirectAdmin(Panel):

    detect_path = '/usr/local/directadmin/custombuild/build'
    glob_path = '/usr/local/directadmin/data/users/*/domains/*.conf'

    @classmethod
    def _get_paths(cls):
        paths = []
        for path in glob.iglob(cls.glob_path):
            paths.append(path)
        return paths

    @staticmethod
    def _read_config(path):
        domain, ip = None, None
        domain_found, ip_found = False, False
        with open(path) as f:
            for line in f:
                if domain_found and ip_found:
                    break
                if line.startswith('#'):
                    continue
                if '=' not in line:
                    continue
                key, value = [i.strip() for i in line.split('=', 1)]
                if key == 'domain':
                    domain = value
                    domain_found = True
                elif key == 'ip':
                    ip = value
                    ip_found = True
                else:
                    continue
        return ip, domain

    @staticmethod
    def _find_main_ip(ip_map):
        """
        The IP address with maximum domains is the main one
        """
        max_count = 0
        max_ip = None
        for ip, domains in ip_map.items():
            count = len(domains)
            if count > max_count:
                max_count = count
                max_ip = ip
        return max_ip

    @classmethod
    def _get_domains(cls):
        domains = {}
        for path in cls._get_paths():
            try:
                ip, domain = cls._read_config(path)
            except UnicodeDecodeError:
                continue
            if ip and domain:
                if ip not in domains:
                    domains[ip] = []
                domains[ip].append(domain)
        return domains

    def prepare(self):
        all_domains = self._get_domains()
        self.main_ip = self._find_main_ip(all_domains)
        if not all_domains or not self.main_ip:
            self.first_domain = None
            self.additional = {}
            return
        shared_ips = sorted(all_domains.get(self.main_ip, tuple()))
        self.first_domain = shared_ips[0] if shared_ips else None
        self.additional = {k: v for k, v in all_domains.items()
                           if k != self.main_ip}


class AddressHandler:

    map_conf = '/etc/imunify360-webshield/backend-destinations.conf'
    map_file = '/etc/imunify360-webshield/default-destinations.dat'

    @staticmethod
    def _generate(length=8):
        sample = string.ascii_letters + string.digits
        return ''.join(random.sample(sample, length))

    @staticmethod
    def _get_panel():
        for panel in cPanel, Plesk, DirectAdmin:
            if panel.detect():
                return panel

    @staticmethod
    def _get_ip_addresses():
        addresses = set()
        patt = re.compile(
            r"""(?:\d+:\s?)?    # number (e.g. '1:') and optional space
                (?P<if>\S+)     # interface name (e.g. 'eth0')
                    \s+?        # space(s)
                inet6?\s        # word 'inet' or 'inet6'
                (?P<ip>(?:      # start IP capturing
                    \d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}  # for IPv4
                        |                               # or
                    [0-9a-fA-F:]+)                      # for IPv6
                )                                       # end capturing
                (?:/(?P<mask>\d{1,3}))?     # capture mask (e.g.'/24'), if any
                """, re.VERBOSE)
        p = subprocess.Popen(['ip', '-o', 'address', 'show'],
                             stdout=subprocess.PIPE,
                             stderr=subprocess.PIPE,
                             universal_newlines=True)
        out, err = p.communicate()
        if not out:
            return
        for line in out.splitlines():
            m = patt.match(line)
            if not m:
                continue
            iface, ip, mask = m.group('if', 'ip', 'mask')
            if iface == 'lo':
                continue
            if ':' in ip and ipaddress.IPv6Address(ip).is_link_local:
                continue
            addresses.add(ip)
        return addresses

    @classmethod
    def _save(cls, data, path, semicolon=True):
        fmt = "{} {};\n" if semicolon else "{} {}\n"
        tmp_path = '.'.join([path, cls._generate()])
        with open(tmp_path, 'w') as f:
            for key, val in data.items():
                f.write(fmt.format(key, val))
        shutil.move(tmp_path, path)

    @classmethod
    def run(cls):
        panel = cls._get_panel()
        if panel is None:
            sys.stderr.write('We cannot use unknown hosting panels. Skip\n')
            return

        p = panel(cls._get_ip_addresses())
        p.prepare()
        mapping = p.get_ssl_mapping()
        if not mapping:
            sys.stderr.write(
                '{}: no address has a default site, connections without SNI '
                'will get the fallback certificate\n'.format(panel.__name__))
        cls._save(mapping, cls.map_file, False)


if __name__ == '__main__':
    AddressHandler.run()
