#!/usr/bin/python3
#
# Copyright (c) 2010 Red Hat, Inc.
#
# This software is licensed to you under the GNU General Public License,
# version 2 (GPLv2). There is NO WARRANTY for this software, express or
# implied, including the implied warranties of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2
# along with this software; if not, see
# http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt.
#
# Red Hat trademarks are not licensed under GPLv2. No permission is
# granted to use or replicate Red Hat trademarks that are incorporated
# in this software or its documentation.
#

"""
Script to set up a Candlepin server.

This script should be idempotent, as puppet will re-run this to keep
the server functional.
"""

from optparse import OptionParser
from subprocess import getstatusoutput
from http.client import HTTPSConnection

import sys
import os.path, os
import socket
import ssl
import re
import time
import xml.etree.ElementTree as ET

CANDLEPIN_CONF = '/etc/candlepin/candlepin.conf'

TOMCAT = 'tomcat'


def run_command(command):
    (status, output) = getstatusoutput(command)
    if status > 0:
        sys.stderr.write("\n########## ERROR ############\n")
        sys.stderr.write("Error running command: %s\n" % command)
        sys.stderr.write("Status code: %s\n" % status)
        sys.stderr.write("Command output: %s\n" % output)
        raise Exception("Error running command")
    return output


# run with 'sudo' if not running as root
def run_command_with_sudo(command, **kwargs):
    if os.geteuid()==0:
        output = run_command(command.format(**kwargs))
    else:
        output = run_command('sudo %s' % command.format(**kwargs))

    return output


class TomcatSetup(object):
    def __init__(self, conf_dir):
        self.conf_dir = conf_dir
        self.main_conf = '/etc/tomcat/tomcat.conf'
        self.main_comment_pattern = r'^#\s*JAVA_HOME.*'
        self.main_existing_pattern = '^JAVA_HOME.*'
        self.new_java_home = 'JAVA_HOME="/usr/lib/jvm/jre-25-openjdk"'

    @staticmethod
    def _backup_config(conf_dir):
        run_command('cp %s/server.xml %s/server.xml.original' % (conf_dir, conf_dir))

    @staticmethod
    def _build_connector_element():
        connector = ET.Element('Connector')
        connector.set('port', '8443')
        connector.set('protocol', 'org.apache.coyote.http11.Http11NioProtocol')
        connector.set('scheme', 'https')
        connector.set('secure', 'true')
        connector.set('SSLEnabled', 'true')
        connector.set('maxThreads', '150')
        connector.set('compression', 'on')
        connector.set('compressableMimeType', 'application/json,text/html,text/xml')

        ssl_host_config = ET.SubElement(connector, 'SSLHostConfig')
        ssl_host_config.set('certificateVerification', 'optional')
        ssl_host_config.set('protocols', '+TLSv1.2,+TLSv1.3')
        ssl_host_config.set('caCertificateFile',
            '/etc/candlepin/certs/candlepin-ca-bundle.crt')

        cert_mldsa = ET.SubElement(ssl_host_config, 'Certificate')
        cert_mldsa.set('certificateFile',
            '/etc/candlepin/certs/candlepin-mldsa-65-ca.crt')
        cert_mldsa.set('certificateKeyFile',
            '/etc/candlepin/certs/candlepin-mldsa-65-ca.key')
        cert_mldsa.set('type', 'MLDSA')

        cert_rsa = ET.SubElement(ssl_host_config, 'Certificate')
        cert_rsa.set('certificateFile',
            '/etc/candlepin/certs/candlepin-rsa-ca.crt')
        cert_rsa.set('certificateKeyFile',
            '/etc/candlepin/certs/candlepin-rsa-ca.key')
        cert_rsa.set('type', 'RSA')

        return connector

    def _replace_current_main(self, original):
        regex = re.compile(self.main_existing_pattern, re.MULTILINE)
        return regex.sub(self.new_java_home, original, 1)

    def _replace_commented_main(self, original):
        regex = re.compile(self.main_comment_pattern, re.MULTILINE)
        return regex.sub(self.new_java_home, original, 1)

    def _write_disable_fips_conf(self):
        filename = "candlepin_disable_fips.conf"
        confd_dir = os.path.join(self.conf_dir, "conf.d")
        target_file = os.path.join(confd_dir, filename)

        with open(target_file, 'w') as file:
            file.write("JAVA_OPTS=\"$JAVA_OPTS -Dcom.redhat.fips=false\"\n")

    def update_config(self):
        # Edit server.xml using an XML parser instead of fragile regex
        self._backup_config(self.conf_dir)
        server_xml = os.path.join(self.conf_dir, 'server.xml')
        with open(server_xml, 'rb') as f:
            data = f.read()

        if b'<!DOCTYPE' in data:
            raise ValueError('DOCTYPE declarations are not allowed in server.xml')

        parser = ET.XMLParser(
            target=ET.TreeBuilder(insert_comments=True))
        tree = ET.ElementTree(ET.fromstring(data, parser=parser))
        root = tree.getroot()

        service = root.find('.//Service')
        if service is None:
            raise Exception("Could not find <Service> element in server.xml")

        for connector in service.findall('Connector[@port="8443"]'):
            service.remove(connector)

        service.insert(0, self._build_connector_element())

        # Ensure the OpenSSLLifecycleListener is present (required for
        # NIO connector with ML-DSA certificates). Remove any commented-out
        # version first, then add the real element if missing.
        openssl_listener = 'org.apache.catalina.core.OpenSSLLifecycleListener'
        for child in list(root):
            if callable(child.tag) and openssl_listener in (child.text or ''):
                root.remove(child)

        if not root.findall("Listener[@className='%s']" % openssl_listener):
            listener = ET.Element('Listener')
            listener.set('className', openssl_listener)
            listeners = root.findall('Listener')
            if listeners:
                last_idx = list(root).index(listeners[-1])
                root.insert(last_idx + 1, listener)
            else:
                root.insert(0, listener)

        ET.indent(tree, space='    ')
        tree.write(server_xml, encoding='UTF-8', xml_declaration=True)

        # Edit tomcat.conf
        with open(self.main_conf, 'r') as original_main_file:
            original_main = original_main_file.read()

        if re.search(self.main_existing_pattern, original_main, re.MULTILINE):
            updated_main = self._replace_current_main(original_main)
        elif re.search(self.main_comment_pattern, original_main, re.MULTILINE):
            updated_main = self._replace_commented_main(original_main)
        else:
            updated_main = original_main + os.linesep + self.new_java_home

        with open(self.main_conf, 'w') as main_config:
            main_config.write(updated_main)

        # Write extra config file to conf.d to disable FIPS in this TC instance:
        self._write_disable_fips_conf()

    @staticmethod
    def fix_perms():
        run_command("chmod g+x /var/log/" + TOMCAT)
        run_command("chmod g+x /etc/" + TOMCAT + "/")
        run_command("chown tomcat:tomcat -R /var/lib/" + TOMCAT)
        run_command("chown tomcat:tomcat -R /var/cache/" + TOMCAT)

    @staticmethod
    def stop():
        run_command("/sbin/service " + TOMCAT + " stop")

    @staticmethod
    def restart():
        run_command("/sbin/service " + TOMCAT + " restart")

    @staticmethod
    def wait_for_startup():
        print("Waiting for tomcat to restart...")
        context = ssl.create_default_context()
        context.check_hostname = False
        context.verify_mode = ssl.CERT_NONE
        for x in range(1, 5):
            time.sleep(5)
            try:
                conn = HTTPSConnection('localhost', 8443, context=context)
                conn.request('GET', "/candlepin/")
                if conn.getresponse().status == 200:
                    break
            except:
                print("Waiting for tomcat to restart...")


class CertSetup(object):
    def __init__(self):
        self.cert_home = '/etc/candlepin/certs'
        self.ca_trust_anchors = '/etc/pki/ca-trust/source/anchors'
        self.ca_chain_bundle = os.path.join(self.ca_trust_anchors,
            'cp-ca-chain.pem')

        self.rsa_cert = os.path.join(self.cert_home, 'candlepin-rsa-ca.crt')
        self.rsa_key = os.path.join(self.cert_home, 'candlepin-rsa-ca.key')

        self.mldsa_cert = os.path.join(self.cert_home,
            'candlepin-mldsa-65-ca.crt')
        self.mldsa_key = os.path.join(self.cert_home,
            'candlepin-mldsa-65-ca.key')

        self.ca_bundle = os.path.join(self.cert_home,
            'candlepin-ca-bundle.crt')
        self.legacy_cert = os.path.join(self.cert_home, 'candlepin-ca.crt')
        self.legacy_key = os.path.join(self.cert_home, 'candlepin-ca.key')

        self.ca_cert_days = 365
        self.key_bits = 4096

    @staticmethod
    def _build_openssl_config():
        hostname = socket.gethostname()
        return (
            "[ req ]\n"
            "string_mask = utf8only\n"
            "distinguished_name = req_dn\n"
            "prompt = no\n"
            "x509_extensions = v3_ext\n"
            "\n"
            "[ req_dn ]\n"
            "CN=Candlepin Server CA\n"
            "OU=Candlepin\n"
            "O=Red Hat\n"
            "\n"
            "[ v3_ext ]\n"
            "subjectKeyIdentifier = hash\n"
            "authorityKeyIdentifier = keyid:always, issuer:always\n"
            "basicConstraints = critical, CA:true\n"
            "keyUsage = critical, keyCertSign, digitalSignature, cRLSign\n"
            "subjectAltName = IP:127.0.0.1,DNS:localhost,DNS:%s\n"
            % hostname
        )

    def generate(self):
        if not os.path.exists(self.cert_home):
            run_command_with_sudo('mkdir -p %s' % self.cert_home)

        if os.path.exists(self.rsa_cert):
            print("Certificates already exist, skipping...")
            return

        print("Generating CA certs and keys...")

        run_command_with_sudo('rm -rf %s' % self.cert_home)
        run_command_with_sudo('mkdir -p %s' % self.cert_home)

        config_file = os.path.join(self.cert_home, 'req.cnf')
        with open(config_file, 'w') as f:
            f.write(self._build_openssl_config())

        # Generate self-signed RSA certificate
        run_command_with_sudo(
            'openssl req -new -x509 -days %d -out %s'
            ' -newkey rsa:%d -nodes -keyout %s'
            ' -config %s'
            % (self.ca_cert_days, self.rsa_cert,
               self.key_bits, self.rsa_key, config_file))

        # Generate self-signed ML-DSA-65 certificate
        run_command_with_sudo(
            'openssl req -new -x509 -days %d -out %s'
            ' -newkey mldsa65 -nodes -keyout %s'
            ' -config %s'
            % (self.ca_cert_days, self.mldsa_cert,
               self.mldsa_key, config_file))

        # Legacy symlinks pointing to RSA certs
        run_command_with_sudo(
            'ln -sf candlepin-rsa-ca.crt %s' % self.legacy_cert)
        run_command_with_sudo(
            'ln -sf candlepin-rsa-ca.key %s' % self.legacy_key)

        # Combined CA certificate bundle for client verification
        run_command_with_sudo(
            'sh -c "cat %s %s > %s"'
            % (self.rsa_cert, self.mldsa_cert, self.ca_bundle))

        run_command_with_sudo('chmod a+r %s/*' % self.cert_home)

        # Add both certs to system CA trust
        run_command_with_sudo(
            'sh -c "cat %s %s > %s"'
            % (self.rsa_cert, self.mldsa_cert, self.ca_chain_bundle))
        run_command_with_sudo('update-ca-trust')

        os.remove(config_file)


class PostgresqlConf(object):
    def __init__(self, options):
        self.options = options
        self.dialect = "org.hibernate.dialect.PostgreSQLDialect"
        self.driver = "org.postgresql.Driver"

        # Build up the correct jdbc URL:
        # TODO: duplicated within cpdb:
        self.jdbc_url = "jdbc:postgresql:"
        if self.options.dbhost is not None:
            self.jdbc_url = "%s//%s" % (self.jdbc_url, self.options.dbhost)
            # Requires host:
            if self.options.dbport is not None:
                self.jdbc_url = "%s:%s" % (self.jdbc_url, self.options.dbport)
            # Append / for the database name:
            self.jdbc_url = "%s/" % (self.jdbc_url)
        self.jdbc_url = "%s%s" % (self.jdbc_url, self.options.db)


def write_candlepin_conf(options):
    """
    Write configuration to candlepin.conf.
    """

    # If the file exists and it's size is not 0 (it will be empty after
    # fresh rpm install), write out a default with database configuration:
    if os.path.exists(CANDLEPIN_CONF) and os.stat(CANDLEPIN_CONF).st_size > 0:
        print("candlepin.conf already exists, skipping...")
        return

    print("Writing configuration file")

    dbconf = PostgresqlConf(options)

    f = open(CANDLEPIN_CONF, 'w')
    f.write('jpa.config.hibernate.dialect=%s\n' % dbconf.dialect)
    f.write('jpa.config.hibernate.connection.driver_class=%s\n' % dbconf.driver)
    f.write('jpa.config.hibernate.connection.url=%s\n' % dbconf.jdbc_url)
    f.write('jpa.config.hibernate.connection.username=%s\n' % options.dbuser)
    f.write('jpa.config.hibernate.connection.password=%s\n' % options.password)
    if options.webapp_prefix:
        f.write('\ncandlepin.export.webapp.prefix=%s\n' % options.webapp_prefix)

    f.write('\ncandlepin.crypto.schemes=mldsa65,rsa\n')
    f.write('candlepin.crypto.default_scheme=legacy\n')

    f.write('\ncandlepin.crypto.scheme.rsa.cert=/etc/candlepin/certs/candlepin-ca.crt\n')
    f.write('candlepin.crypto.scheme.rsa.key=/etc/candlepin/certs/candlepin-ca.key\n')
    f.write('candlepin.crypto.scheme.rsa.signature_algorithm=SHA256withRSA\n')
    f.write('candlepin.crypto.scheme.rsa.key_algorithm=RSA\n')
    f.write('candlepin.crypto.scheme.rsa.key_size=4096\n')

    f.write('\ncandlepin.crypto.scheme.mldsa65.cert=/etc/candlepin/certs/candlepin-mldsa-65-ca.crt\n')
    f.write('candlepin.crypto.scheme.mldsa65.key=/etc/candlepin/certs/candlepin-mldsa-65-ca.key\n')
    f.write('candlepin.crypto.scheme.mldsa65.signature_algorithm=ML-DSA-65\n')
    f.write('candlepin.crypto.scheme.mldsa65.key_algorithm=ML-DSA-65\n')

    f.write('\ncandlepin.db.database_manage_on_startup=Manage\n')
    f.close()


def main(argv):

    parser = OptionParser()
    parser.add_option("-s", "--skipdbcfg",
                  action="store_true", dest="skipdbcfg", default=False,
                  help="don't configure the /etc/candlepin/candlepin.conf file")
    parser.add_option("-u", "--user",
                  dest="dbuser", default=os.getenv('CANDLEPIN_DB_USER', 'candlepin'),
                  help="Database user. When missing environment variable 'CANDLEPIN_DB_USER' is read. Defaults to 'candlepin'.")
    parser.add_option("-d", "--database",
                  dest="db", default=os.getenv('CANDLEPIN_DB_NAME', 'candlepin'),
                  help="Database name. When missing environment variable 'CANDLEPIN_DB_NAME' is read. Defaults to 'candlepin'.")
    parser.add_option("--dbhost",
                  dest="dbhost",
                  help="the database host to use (optional)")
    parser.add_option("--dbport",
                  dest="dbport",
                  help="the database port to use (optional)")
    parser.add_option("--schema-only",
                  action="store_true", dest="schema_only", default=False,
                  help="database already exists, only load the schema.")
    parser.add_option("-w", "--webapp-prefix",
                  dest="webapp_prefix",
                  help="the web application prefix to use for export origin [host:port/prefix]")
    parser.add_option("-p", "--password",
                  dest="password", default=os.getenv('CANDLEPIN_DB_PASSWORD', 'candlepin'),
                  help="Database password. When missing environment variable 'CANDLEPIN_DB_PASSWORD' is read. Defaults to 'candlepin'.")
    parser.add_option("--skip-service", dest="skip_service", action="store_true",
        default=False, help="Skip attempting to stop/restart tomcat service.")

    (options, args) = parser.parse_args()

    # Stop tomcat before we wipe the DB otherwise you get errors from pg
    tsetup = TomcatSetup('/etc/' + TOMCAT)
    tsetup.fix_perms()
    if not options.skip_service:
        tsetup.stop()

    # Call the cpdb script to create the candlepin database. Database creation will be
    # skipped if it already exists. The --schema-only option can be used if the database
    # is expected to have been created by an external script, and only the schema is to
    # be applied.
    script_dir = os.path.dirname(__file__)
    cpdb_script = os.path.join(script_dir, "cpdb")
    command = "%s --create -u %s --database %s --password %s" % (cpdb_script,
        options.dbuser, options.db, options.password)

    if options.schema_only:
        command = " ".join([command, "--schema-only"])

    if options.dbhost:
        command = " ".join([command, "--dbhost %s" % options.dbhost])
    if options.dbport:
        command = " ".join([command, "--dbport %s" % options.dbport])

    print(command)

    run_command(command)

    if not options.skipdbcfg:
        write_candlepin_conf(options)
    else:
        print("** Skipping configuration file setup")

    certsetup = CertSetup()
    certsetup.generate()

    tsetup.update_config()

    if not options.skip_service:
        tsetup.restart()
        tsetup.wait_for_startup()
        run_command("wget -qO- https://localhost:8443/candlepin/status")

    print("Candlepin has been configured.")

if __name__ == "__main__":
    main(sys.argv[1:])
