### Copyright 1999-2015. Parallels IP Holdings GmbH. All Rights Reserved.

from optparse import OptionParser
import os
import sys
import logging as log
import plesk_subprocess as subprocess
import re
import fileinput
import shutil
from textwrap import dedent
from distutils.version import StrictVersion

def get_dhparams_path(dh_size):
    return "/usr/local/psa/etc/dhparams%d.pem" % DHPARAMS_LEN

DHPARAMS_LEN = 2048
DHPARAMS_PEM = get_dhparams_path(DHPARAMS_LEN)

class NotInstalledError(Exception):
    pass

def config_set(pattern, value, config, prepend=None, append=None):
    found = False
    if not os.path.isfile(config):
        # create empty file
        open(config, "w").close()

    for line in fileinput.input(config, inplace=True):
        if not found and line.strip().startswith(pattern):
            found = True
            sys.stdout.write("%s\n" % value)
        else:
            sys.stdout.write(line)

    if not found:
        with open(config, 'a') as f:
            if prepend:
                f.write("%s\n" % prepend)
            f.write("%s\n" % value)
            if append:
                f.write("%s\n" % append)

def normalize_ciphers(ciphers):
    return ciphers.strip()

def normalize_protocols(protocols_string):
    protocols = []
    exclude = []
    supported_protocols = dict(zip(map(str.lower, SUPPORTED_PROTOCOLS), SUPPORTED_PROTOCOLS))

    for p in re.split(r"\s+|:", protocols_string):
        try:
            if p.startswith('!'):
                exclude.append(supported_protocols[p[1:].lower()])
            else:
                protocols.append(supported_protocols[p.lower()])
        except KeyError:
            log.warning("Ignoring unsuppored protocol %s", p)

    if protocols and exclude:
        raise ValueError("Illegal mix of enable and disable(!) protocols in %s", protocols_string)

    if protocols:
        return protocols

    if exclude:
        return [x for x in SUPPORTED_PROTOCOLS if not x in exclude]

def condrestart(service):
    """ restart service if it started, or do nothing """
    try:
        PRODUCT_ROOT_D = "/opt/psa"
        pleskrc = os.path.join(PRODUCT_ROOT_D, 'admin/sbin/pleskrc')
        subprocess.check_call([pleskrc, service, "status"])
    except subprocess.CalledProcessError, e:
        log.debug("Service %s stopped (%d), no reload", service, e.returncode)
        return

    log.debug("reload service %s", service)
    subprocess.check_call([pleskrc, service, "reload"])

def get_ciphers():
    r = re.compile(r'^ssl-ciphers:\s(.*?)\s*$', re.MULTILINE)
    output = subprocess.check_output(['/usr/local/psa/bin/server_pref', '--show'])
    m = r.search(output)
    return m.group(1)

def get_protocols():
    r = re.compile(r'^ssl-protocols:\s(.*?)\s*$', re.MULTILINE)
    output = subprocess.check_output(['/usr/local/psa/bin/server_pref', '--show'])
    m = r.search(output)
    return m.group(1)

def flush_custom_options(service):
    cfg_path = "/var/lib/plesk/ssl_" + service + ".conf"
    try:
        os.unlink(cfg_path)
    except OSError, e:
        if e.errno == 2:
            return
        raise Exception("Unable to remove saved cache for '%s' service: %s (%i)" % (service, e.strerror, e.errno))

def show_customized(service):
    cfg_path = "/var/lib/plesk/ssl_" + service + ".conf"
    if not os.path.exists(cfg_path):
        print service.ljust(12) + ": default"
        return

    print service.ljust(12) + ": custom"
    ciphers, protocols = get_custom_options(service, "", "", verbose = False)
    log.info("    protocols: " + protocols)
    log.info("    ciphers:   " + ciphers + "\n")

    return

def get_custom_options(service, ciphers, protocols, update_cache = False, verbose = True):
    cfg_path = "/var/lib/plesk/ssl_" + service + ".conf"

    if not update_cache:
        res = {}
        try:
            with open(cfg_path, 'r') as fobj:
                if verbose:
                    print "Ciphers and protocols were not changed for '" + service + "' because of custom configuration is used."
                for line in fobj.readlines():
                    data = line.strip().split()
                    res[data[0]] = ' '.join(data[1:])
                fobj.close()
        except IOError, e:
            if e.errno == 2:
                return ciphers, ' '.join(protocols)
            raise Exception("Unable to read data from file '%s': %s (%i)" % (cfg_path, e.strerror, e.errno))

        return res["ciphers"], res["protocols"]

    proto = ' '.join(protocols)
    config_set("ciphers", "ciphers %s" % ciphers, cfg_path)
    config_set("protocols", "protocols %s" % proto, cfg_path)

    return ciphers, proto


def nginx_action(ciphers, protocols):
    """ configure nginx by writing specific config """
    if not os.path.isfile("/etc/nginx/nginx.conf"):
        raise NotInstalledError("nginx")

    config = "/etc/nginx/conf.d/ssl.conf"
    config_set("ssl_ciphers", "ssl_ciphers %s;" % ciphers, config)
    config_set("ssl_protocols", "ssl_protocols %s;" % protocols, config)
    config_set("ssl_prefer_server_ciphers", "ssl_prefer_server_ciphers on;", config)

    condrestart("nginx")

def nginx_strong_dh():
    if not os.path.isfile("/etc/nginx/nginx.conf"):
        raise NotInstalledError("nginx")

    config = "/etc/nginx/conf.d/ssl.conf"
    config_set("ssl_dhparam", "ssl_dhparam %s;" % DHPARAMS_PEM, config)

    condrestart("nginx")

def sw_cp_server_action(ciphers, protocols):
    """ configure sw-cp-server (nginx) by writing specific config """
    config = "/etc/sw-cp-server/conf.d/ssl.conf"
    config_set("ssl_ciphers", "ssl_ciphers %s;" % ciphers, config)
    config_set("ssl_protocols", "ssl_protocols %s;" % protocols, config)
    config_set("ssl_prefer_server_ciphers", "ssl_prefer_server_ciphers on;", config)

    condrestart("sw-cp-server")

def sw_cp_server_strong_dh():
    """ configure sw-cp-server (nginx) by writing specific config """
    config = "/etc/sw-cp-server/conf.d/ssl.conf"
    config_set("ssl_dhparam", "ssl_dhparam %s;" % DHPARAMS_PEM, config)
    condrestart("sw-cp-server")

def courier_action(ciphers, protocols):
    """ configure courier-imap, by writing proper data in its config. Note, it doesn't support protocols list, and
        we write only minimal protocol """

    courier_protos = {
        "SSLv3" : "SSL3+",
        "TLSv1" : "TLSv1",
        "TLSv1.1" : "TLSv1.1+",
        "TLSv1.2" : "TLSv1.2+"
    }

    courier_confdir = "/etc/courier-imap"
    if not os.path.isfile(os.path.join(courier_confdir, "imapd")):
        raise NotInstalledError("courier-imap")

    proto = courier_protos.get(protocols.split()[0])
    for service in ["imapd-ssl", "pop3d-ssl"]:
        config_set("TLS_PROTOCOL", "TLS_PROTOCOL=%s" % proto, os.path.join(courier_confdir, service))
        config_set("TLS_CIPHER_LIST", "TLS_CIPHER_LIST=%s" % ciphers, os.path.join(courier_confdir, service))
        config_set("TLS_STARTTLS_PROTOCOL", "TLS_STARTTLS_PROTOCOL=%s" % protocols, os.path.join(courier_confdir, service))

    for service in ["courier-imapd", "courier-imaps", "courier-pop3d", "courier-pop3s"]:
        condrestart(service)

def courier_strong_dh():
    courier_confdir = "/etc/courier-imap"
    if not os.path.isfile(os.path.join(courier_confdir, "imapd")):
        raise NotInstalledError("courier-imap")

    shutil.copyfile(DHPARAMS_PEM, "/usr/share/dhparams.pem")

    for service in ["courier-imapd", "courier-imaps", "courier-pop3d", "courier-pop3s"]:
        condrestart(service)

def dovecot_action(ciphers, protocols):
    dovecot_confdir = "/etc/dovecot"
    dovecot_include_dir = "/etc/dovecot/conf.d"

    if not os.path.isfile(os.path.join(dovecot_confdir, "dovecot.conf")):
        raise NotInstalledError("dovecot")

    config = os.path.join(dovecot_include_dir, "11-plesk-security-ssl.conf")
    config_set("ssl_cipher_list", "ssl_cipher_list = %s" % ciphers, config)
    config_set("ssl_protocols", "ssl_protocols = %s" % protocols, config)

    condrestart("dovecot")

def dovecot_strong_dh():
    dovecot_confdir = "/etc/dovecot"
    dovecot_include_dir = "/etc/dovecot/conf.d"

    if not os.path.isfile(os.path.join(dovecot_confdir, "dovecot.conf")):
        raise NotInstalledError("dovecot")

    config = os.path.join(dovecot_include_dir, "11-plesk-security-ssl.conf")
    config_set("ssl_dh_parameters_length", "ssl_dh_parameters_length = %d" % DHPARAMS_LEN, config)

    condrestart("dovecot")

def apache_action(ciphers, protocols):
    if True:
# Ubuntu 12 : https://bugs.launchpad.net/ubuntu/+source/apache2/+bug/1400473
        protocols_string = "all " + ' '.join(["-%s" % x for x in SUPPORTED_PROTOCOLS if not x in protocols.split()])
    else:
        protocols_string = ' '.join(["+%s" % x for x in protocols.split()])

    config = "/etc/apache2/mods-available/ssl.conf"

    prepend = "<IfModule mod_ssl.c>"
    append = "</IfModule>"

    config_set("SSLProtocol", "SSLProtocol %s" % protocols_string, config, prepend, append)
    config_set("SSLCipherSuite", "SSLCipherSuite %s" % ciphers, config, prepend, append)
    config_set("SSLHonorCipherOrder", "SSLHonorCipherOrder on", config, prepend, append)

    condrestart("apache2")

def get_apache_version():
    apache_ctl_path = ["/usr/sbin/apache2ctl", "/usr/sbin/apachectl"]
    for apache_ctl in apache_ctl_path:
        if os.path.exists(apache_ctl):
            popen = subprocess.Popen([apache_ctl, "-v"], stdout=subprocess.PIPE)
            (out, err) = popen.communicate()
            result = re.search('Server version:\s+\w+/(\d+\.\d+\.\d+)', out)
            if result is None:
                raise Exception("Unable to parse apache version: %s" % out)
            return [int(x) for x in result.group(1).split('.')]
    raise Exception("Unable to find apachectl unility")

def apache_disable_tls_compression():
    apache_ver = StrictVersion(".".join([str(x) for x in get_apache_version()]))
    if apache_ver < StrictVersion("2.2.24"):
        log.debug("Skip disabling TLS compression on apache %s < 2.2.24", apache_ver)
        return
    config = "/etc/apache2/mods-available/ssl.conf"

    prepend = "<IfModule mod_ssl.c>"
    append = "</IfModule>"

    config_set("SSLCompression", "SSLCompression off", config, prepend, append)

    condrestart("apache2")

def proftpd_action(ciphers, protocols):
    """ configure proftpd by writing specific config into proftpd include dir"""
    if not os.path.isfile("/etc/proftpd.conf"):
        raise NotInstalledError("proftpd")

    template = """\
    <IfModule mod_tls.c>
        TLSCipherSuite %s
        TLSProtocol %s
    </IfModule>
    """

    with open("/etc/proftpd.d/ssl.conf", "w") as f:
        f.write(dedent(template % (ciphers, protocols)))

def postfix_action(ciphers, protocols):
    postconf = "/usr/sbin/postconf"

    if not os.path.isfile(postconf):
        raise NotInstalledError("postfix")

    # See https://bettercrypto.org/static/applied-crypto-hardening.pdf
    subprocess.check_call([postconf, "-e", "smtpd_tls_mandatory_protocols=%s" % protocols])
    subprocess.check_call([postconf, "-e", "smtpd_tls_protocols=%s" % protocols])

    subprocess.check_call([postconf, "-e", "smtpd_tls_ciphers=medium"])
    subprocess.check_call([postconf, "-e", "smtpd_tls_mandatory_ciphers=medium"])
    subprocess.check_call([postconf, "-e", "tls_medium_cipherlist=%s" % ciphers])

    condrestart("postfix")

def postfix_disable_tls_compression():
    postconf = "/usr/sbin/postconf"

    if not os.path.isfile(postconf):
        raise NotInstalledError("postfix")
    subprocess.check_call([postconf, "-e", "tls_ssl_options=no_compression"])

    condrestart("postfix")

def postfix_strong_dh():
    postconf = "/usr/sbin/postconf"

    if not os.path.isfile(postconf):
        raise NotInstalledError("postfix")
    subprocess.check_call([postconf, "-e", "smtpd_tls_dh1024_param_file=%s" % DHPARAMS_PEM])

    condrestart("postfix")

def generate_dh_params(regenerate):
    if regenerate or not os.path.isfile(DHPARAMS_PEM):
        subprocess.check_call(["openssl", "dhparam", "-out", DHPARAMS_PEM, str(DHPARAMS_LEN)])

ACTIONS = {
    "nginx" : nginx_action,
    "sw-cp-server" : sw_cp_server_action,
    "courier" : courier_action,
    "dovecot" : dovecot_action,
    "apache" : apache_action,
    "proftpd" : proftpd_action,
    "postfix" : postfix_action
}

SERVICES = ACTIONS.keys()

ACTIONS_DISABLE_TLS_COMPRESSION = {
    "apache" : apache_disable_tls_compression,
    "postfix" : postfix_disable_tls_compression,
}

ACTIONS_DH_PARAMS = {
    "nginx" : nginx_strong_dh,
    "sw-cp-server" : sw_cp_server_strong_dh,
    "courier" : courier_strong_dh,
    "dovecot" : dovecot_strong_dh,
    "postfix": postfix_strong_dh,
}

# Note, protocols it should be sorted by strength, as we need to check least sufficient protocol version for courier
SUPPORTED_PROTOCOLS = "SSLv3 TLSv1 TLSv1.1 TLSv1.2".split()

def do_main():
    global DHPARAMS_LEN
    global DHPARAMS_PEM
    parser = OptionParser(usage="Usage: %prog [-v]")
    parser.add_option("-v", "--verbose", action="count", help="Increase verbosity (can be specified multiple times).")
    parser.add_option("-c", "--ciphers", default=get_ciphers(), type="string", help="Set selected SSL ciphers.")
    parser.add_option("-p", "--protocols", default=get_protocols(), type="string", help="Set selected SSL protocols.")
    parser.add_option("-s", "--services", type="string", help="Configure only selected services.")

    parser.add_option("-l", "--show-custom", action="store_true", help="Show customized services list.")
    parser.add_option("-C", "--custom", action="store_true", default=False, help="Save per-service ciphers and protocols")
    parser.add_option("-f", "--no-custom", action="store_true", default=False, help="Reset custom per-service ciphers and protocols to default.")

    parser.add_option("-T", "--disable-tls-compression", action="store_true", default=False, help="Disable TLS compression ([CRIME]).")
    parser.add_option("-D", "--strong-dh", action="store_true", default=False, help="Secure Diffie-Hellman ([Logjam]).")
    parser.add_option("-G", "--regenerate-dhparams", action="store_true", default=False, help="Regenerate dhparams.pem.")
    parser.add_option("-S", "--dhparams-size", type="int", default=DHPARAMS_LEN, help="Specify dh parameters size (default: %d)." % DHPARAMS_LEN)

    (options, args) = parser.parse_args()

    log_level = log.WARNING
    if options.verbose == 1:
        log_level = log.INFO
        log.basicConfig(format='%(message)s', level=log_level)
    elif options.verbose >= 2:
        log_level = log.DEBUG

    log.basicConfig(format='%(levelname)s:%(message)s', level=log_level)

    ciphers = normalize_ciphers(options.ciphers)
    protocols = normalize_protocols(options.protocols)

    services = SERVICES
    if options.services:
        services = options.services.split()

    if not protocols and not options.disable_tls_compression and not options.strong_dh:
        raise ValueError("No supported protocols supplied")

    if not ciphers and not options.disable_tls_compression and not options.strong_dh:
        raise ValueError("No supported ciphers supplied")

    if len(filter(None, [protocols, ciphers])) == 1:
        raise ValueError("Both ciphers and protocols should be specified")

    if options.custom and options.no_custom:
        raise ValueError("Both '--custom' and '--no-custom' options were specified")

    if options.regenerate_dhparams and not options.strong_dh:
        raise ValueError("--regenerate-dhparams should be specified only with --strong-dh")

    log.debug("ciphers: %s", ciphers)
    log.debug("protocols: %s", ', '.join(protocols) if protocols else 'None')

    if options.strong_dh:
        DHPARAMS_LEN = options.dhparams_size
        DHPARAMS_PEM = get_dhparams_path(DHPARAMS_LEN)
        generate_dh_params(options.regenerate_dhparams)

    for service in services:
        try:
            if options.show_custom:
                f = ACTIONS.get(service)
                if f:
                    show_customized(service)
                else:
                    log.warning("Ignore unknown service %s", service)
                continue

            if ciphers and protocols:
                f = ACTIONS.get(service)
                if f:
                    if options.no_custom:
                        flush_custom_options(service)

                    """ Working with cached ciphers and protocols """
                    _ciphers, _protocols = get_custom_options(service, ciphers, protocols, options.custom)

                    """ do action on service """
                    f(_ciphers, _protocols)
                    log.debug("* %s", service)
                else:
                    log.warning("Ignore unknown service %s", service)

            if options.disable_tls_compression:
                f = ACTIONS_DISABLE_TLS_COMPRESSION.get(service)
                if f:
                    f()
                    log.debug("* %s [Disable TSL compression]", service)

            if options.strong_dh:
                f = ACTIONS_DH_PARAMS.get(service)
                if f:
                    f()
                    log.debug("* %s [Strong DH parameters]", service)
        except NotInstalledError, e:
            log.debug("- %s\t\tnot installed", e)
        except Exception, ex:
            log.error("failed to configure %s service: %s", service, ex)
            log.debug(ex, exc_info=True)

    return 0

def main():
    """ entry point """
    try:
        return do_main()
    except SystemExit:
        raise
    except Exception, ex:
        log.error('%s', ex)
        log.debug(ex, exc_info=True)
        sys.exit(1)

if __name__ == '__main__':
    main()

# vim: ts=4 sts=4 sw=4 et :

