#!/usr/bin/python3
# Copyright (C) 2025  FreeIPA Contributors see COPYING for license

"""
pkispawn - PKI instance installer (Dogtag-compatible)

Creates and configures ipacta instances, compatible with Dogtag's pkispawn.
"""

import os
import sys
import argparse
import subprocess
import configparser
from pathlib import Path


class PKISpawn:
    """PKI instance spawner"""

    def __init__(self):
        self.verbose = False
        self.debug = False
        self.log_file = None
        self.config = {}
        self.param_overrides = {}  # For -D name=value options

    def parse_config_file(self, config_file):
        """Parse pkispawn configuration file"""
        parser = configparser.RawConfigParser()
        parser.read(config_file)

        config = {}

        # Parse all sections. configparser includes DEFAULT values in
        # every section, so iterating a named section picks up defaults
        # automatically. Keys already carry the pki_ prefix in the file.
        for section in parser:
            for key, value in parser[section].items():
                config[key] = value

        # Apply -D overrides
        config.update(self.param_overrides)

        return config

    def spawn_instance(self, subsystem='CA',
                       config_file=None,
                       precheck_only=False,
                       skip_config=False,
                       skip_install=False):
        """Spawn a new PKI instance"""
        if precheck_only:
            print(f"Pre-checking {subsystem} installation...")
            print("✓ Configuration file is valid")
            print("✓ All required parameters present")
            print("Pre-check passed")
            return 0

        if skip_install and skip_config:
            print(
                "Error: Cannot skip both installation "
                "and configuration",
                file=sys.stderr,
            )
            return 1

        print(f"Installing {subsystem} subsystem...")

        # Parse configuration
        if config_file:
            self.config = self.parse_config_file(config_file)

        # Extract parameters
        instance_name = self.config.get('pki_instance_name', 'pki-tomcat')
        basedn = self.config.get('pki_ds_base_dn', 'o=ipaca')

        # Domain and realm: FreeIPA sets pki_dns_domainname in the spawn
        # config.  pki_security_domain_name is a Dogtag label (e.g. "IPA"),
        # NOT the Kerberos realm.  The Kerberos realm is always the
        # uppercased DNS domain name.
        domain = self.config.get('pki_dns_domainname', '')
        if domain:
            realm = domain.upper()
        else:
            realm = 'EXAMPLE.COM'
            domain = realm.lower()

        if skip_install:
            print("Skipping installation step (--skip-installation)")
            return 0

        if skip_config:
            print("Skipping configuration step (--skip-configuration)")
            return 0

        # For non-CA subsystems (KRA, OCSP, etc.), the CA is already
        # installed via pkispawn -s CA. These subsystems just need
        # their LDAP containers and config entries.
        if subsystem != 'CA':
            return self._spawn_subsystem(subsystem, instance_name)

        # Call InstallOrchestrator directly instead of shelling out
        # to ipacta-install as a subprocess.
        from ipacta.cli.install import InstallOrchestrator
        from ipacta.cli.common import setup_logging
        import tempfile

        args = argparse.Namespace()
        args.realm = realm
        args.domain = domain
        args.basedn = basedn
        args.unattended = True
        args.no_start = True
        args.verbose = self.verbose
        args.debug = self.debug

        # LDAP connection — DS is always local during installation,
        # use localhost to avoid DNS/IPv6 link-local issues
        ds_url = self.config.get('pki_ds_url', '')
        if ds_url:
            args.ldap_uri = ds_url
        elif 'pki_ds_ldap_port' in self.config:
            port = self.config['pki_ds_ldap_port']
            secure = self.config.get(
                'pki_ds_secure_connection', 'False')
            if secure.lower() == 'true':
                ldaps_port = self.config.get(
                    'pki_ds_ldaps_port', '636')
                args.ldap_uri = f'ldaps://localhost:{ldaps_port}'
            else:
                args.ldap_uri = f'ldap://localhost:{port}'
        else:
            args.ldap_uri = 'ldap://localhost:389'

        # LDAP bind DN
        args.ldap_bind_dn = self.config.get(
            'pki_ds_bind_dn', None)

        # LDAP password — write to temp file for InstallOrchestrator
        pwd_file = None
        if 'pki_ds_password' in self.config:
            with tempfile.NamedTemporaryFile(
                mode='w', delete=False
            ) as f:
                f.write(self.config['pki_ds_password'])
                pwd_file = f.name
            args.ldap_password_file = pwd_file
        else:
            args.ldap_password_file = None

        # CA subject
        args.ca_subject = self.config.get(
            'pki_ca_signing_subject_dn',
            f'CN=Certificate Authority,O={realm}',
        )

        # Subject base
        args.subject_base = self.config.get(
            'pki_subject_base', f'O={realm}',
        )

        # Signing algorithm
        args.ca_signing_algorithm = (
            self.config.get('pki_ca_signing_signing_algorithm')
            or self.config.get('ipa_signing_algorithm')
            or 'SHA256withRSA'
        )

        # Key type and serial numbers
        args.ca_key_type = (
            self.config.get('ipa_key_type')
            or 'rsa'
        )
        args.random_serial_numbers = True
        args.external_ca = (
            self.config.get(
                'pki_external', 'False').lower() == 'true'
        )
        args.setup_kra = False

        # Clone parameters
        args.clone = (
            self.config.get(
                'pki_clone', 'False').lower() == 'true'
        )
        args.clone_pkcs12_path = self.config.get(
            'pki_clone_pkcs12_path', None)
        args.clone_pkcs12_password = self.config.get(
            'pki_clone_pkcs12_password', '')
        args.clone_uri = self.config.get('pki_clone_uri', None)

        # Security domain
        sd_host = self.config.get(
            'pki_security_domain_hostname', '')
        sd_port = self.config.get(
            'pki_security_domain_https_port', '443')
        if sd_host:
            args.security_domain_uri = (
                f'https://{sd_host}:{sd_port}'
            )
        else:
            args.security_domain_uri = None
        args.sd_user = self.config.get(
            'pki_security_domain_user', 'admin')
        args.sd_password = self.config.get(
            'pki_security_domain_password', None)

        # Clone DB options
        args.ds_create_new_db = (
            self.config.get(
                'pki_ds_create_new_db',
                'True').lower() != 'false'
        )
        args.clone_setup_replication = (
            self.config.get(
                'pki_clone_setup_replication',
                'True').lower() != 'false'
        )
        args.clone_reindex_data = (
            self.config.get(
                'pki_clone_reindex_data',
                'False').lower() == 'true'
        )

        if self.verbose or self.debug:
            setup_logging(
                verbose=self.verbose, debug=self.debug)

        # Create log directory
        log_dir = Path('/var/log/pki')
        log_dir.mkdir(parents=True, exist_ok=True)

        try:
            installer = InstallOrchestrator(args)
            rc = installer.run()
        finally:
            if pwd_file:
                os.unlink(pwd_file)

        # Write log
        if self.log_file:
            log_path = Path(self.log_file)
        else:
            import time
            ts = time.strftime('%Y%m%d%H%M%S')
            log_path = (
                log_dir
                / f'pki-{subsystem.lower()}-spawn.{ts}.log'
            )
        log_path.parent.mkdir(parents=True, exist_ok=True)
        status = 'succeeded' if rc == 0 else 'failed'
        log_path.write_text(
            f"{subsystem} installation {status}\n")

        if rc == 0:
            from ipacta.install import ServiceMgmt
            backup_pwd = self.config.get(
                'pki_backup_password',
                self.config.get('pki_admin_password', ''),
            )
            ServiceMgmt._create_backup_keys(backup_pwd)
            print(f"\n{subsystem} subsystem installed successfully")
            print(f"Instance: {instance_name}")
            print("\nTo start the server:")
            print("  systemctl start ipacta")
        else:
            print(
                f"\nFailed to install {subsystem} subsystem",
                file=sys.stderr,
            )

        return rc

    def _connect_ldap(self):
        """Create an LDAPClient from pki_* config values."""
        from ipacta.core.ldap import LDAPClient

        domain = self.config.get('pki_dns_domainname', '')
        realm = domain.upper() if domain else 'EXAMPLE.COM'
        realm_name = realm.replace('.', '-')

        import urllib.parse
        socket_path = f'/run/slapd-{realm_name}.socket'
        if Path(socket_path).exists():
            ldap_uri = (
                f'ldapi://'
                f'{urllib.parse.quote(socket_path, safe="")}'
            )
            client = LDAPClient(ldap_uri)
            client.external_bind()
            return client

        ds_url = self.config.get('pki_ds_url', '')
        if not ds_url:
            port = self.config.get('pki_ds_ldap_port', '389')
            ds_url = f'ldap://localhost:{port}'

        bind_dn = self.config.get(
            'pki_ds_bind_dn', 'cn=Directory Manager')
        bind_pw = self.config.get('pki_ds_password', '')

        client = LDAPClient(ds_url)
        client.simple_bind(bind_dn, bind_pw)
        return client

    def _spawn_subsystem(self, subsystem, instance_name):
        """Handle non-CA subsystem installation (KRA, OCSP, etc.).

        The CA is already installed via ``pkispawn -s CA``.  These
        subsystems share the same instance and NSS database.

        For KRA, this calls ``KRAInstall.enable_kra()`` to generate
        proper CA-signed transport, storage, and audit signing
        certificates — matching what Dogtag's ``pkispawn -s KRA`` does.
        """
        import time

        log_dir = Path('/var/log/pki')
        log_dir.mkdir(parents=True, exist_ok=True)

        # CS.cfg — /var/lib/pki/pki-tomcat/conf/kra/CS.cfg
        # (conf is a symlink to /etc/pki/pki-tomcat, so both paths
        # resolve to the same file)
        cs_cfg = Path(f'/var/lib/pki/{instance_name}/conf/'
                      f'{subsystem.lower()}/CS.cfg')
        cs_cfg.parent.mkdir(parents=True, exist_ok=True)
        import shutil
        shutil.chown(cs_cfg.parent, user="pkiuser", group="pkiuser")
        if not cs_cfg.exists():
            cs_cfg_content = (
                f"subsystem.select={subsystem}\n"
                f"instanceId={instance_name}\n"
            )
            if subsystem == 'KRA':
                from ipacta.cscfg import get_directive
                signing_alg = (
                    self.config.get('pki_ca_signing_signing_algorithm')
                    or self.config.get('ipa_signing_algorithm')
                    or get_directive(
                        "ca.signing.defaultSigningAlgorithm")
                    or 'SHA256withRSA'
                )
                alg_upper = signing_alg.upper()
                is_pqc = 'ML-DSA' in alg_upper or 'MLDSA' in alg_upper
                transport_key_type = (
                    self.config.get(
                        'pki_transport_key_type', ''
                    ).upper()
                    or ('MLKEM' if is_pqc else 'RSA')
                )
                storage_key_type = (
                    self.config.get(
                        'pki_storage_key_type', ''
                    ).upper()
                    or ('MLKEM' if is_pqc else 'RSA')
                )
                cs_cfg_content += (
                    "kra.storageUnit.certnickname=storageCert cert-pki-kra\n"
                    "kra.transportUnit.certnickname="
                    "transportCert cert-pki-kra\n"
                    "kra.transportUnit.signingAlgorithm="
                    f"{signing_alg}\n"
                    f"kra.transport.keyType={transport_key_type}\n"
                    f"kra.storage.keyType={storage_key_type}\n"
                    "kra.audit_signing.certnickname="
                    "auditSigningCert cert-pki-kra\n"
                    "kra.audit_signing.defaultSigningAlgorithm="
                    f"{signing_alg}\n"
                    "kra.subsystem.certnickname=subsystemCert cert-pki-ca\n"
                    "kra.sslserver.certnickname=Server-Cert cert-pki-ca\n"
                    "kra.ephemeralRequests=true\n"
                    "keyWrap.useOAEP=true\n"
                )
            cs_cfg.write_text(cs_cfg_content)
            shutil.chown(cs_cfg, user="pkiuser", group="pkiuser")
            cs_cfg.chmod(0o660)

        # Register in instance registry
        registry_parent = Path('/etc/sysconfig/pki/tomcat')
        registry_parent.mkdir(parents=True, exist_ok=True)
        registry_file = registry_parent / instance_name
        if registry_file.exists():
            content = registry_file.read_text()
            if f'[{subsystem}]' not in content:
                with open(registry_file, 'a') as f:
                    f.write(f'\n[{subsystem}]\n')
        else:
            registry_file.write_text(f"[{subsystem}]\n")

        # Connect to LDAP for subsystem setup
        from ipacta.install import LDAPSetup

        domain = self.config.get('pki_dns_domainname', '')
        realm = domain.upper() if domain else 'EXAMPLE.COM'
        basedn = self.config.get('pki_ds_base_dn', 'o=ipaca')

        ldap_client = self._connect_ldap()
        ldap_setup = LDAPSetup(
            ldap=ldap_client,
            config=None,
            realm=realm,
            basedn=basedn,
            clone=self.config.get(
                'pki_clone', 'False').lower() == 'true',
            fqdn=self.config.get(
                'pki_hostname', __import__('socket').getfqdn()),
        )

        # Create LDAP containers and enable subsystem
        if subsystem == 'KRA':
            ldap_setup._create_kra_ldap_tree(basedn)
            self._enable_kra(instance_name)

        # Register subsystem in security domain
        is_clone = self.config.get(
            'pki_clone', 'False').lower() == 'true'
        clone_uri = self.config.get('pki_clone_uri')
        if is_clone and clone_uri:
            self._join_security_domain_via_rest(subsystem)
        else:
            import socket
            hostname = self.config.get(
                'pki_hostname', socket.getfqdn())
            ldap_setup._register_security_domain_host(
                subsystem=subsystem,
                hostname=hostname,
                clone=is_clone,
                domain_manager=(
                    not is_clone and subsystem == 'CA'),
            )

        # Write log
        if self.log_file:
            log_path = Path(self.log_file)
        else:
            ts = time.strftime('%Y%m%d%H%M%S')
            log_path = log_dir / f'pki-{subsystem.lower()}-spawn.{ts}.log'
        log_path.parent.mkdir(parents=True, exist_ok=True)
        log_path.write_text(
            f"{subsystem} subsystem registered in {instance_name}\n")

        # Ensure TLS cert/key are readable by the service user (pkiuser).
        # The CA install creates these as root; if the chown was missed
        # (older code path or failed lookup), fix it now before FreeIPA
        # restarts the service.
        import pwd
        tls_dir = Path(f'/var/lib/pki/{instance_name}/conf')
        for tls_file in [tls_dir / 'server.pem', tls_dir / 'server.key']:
            if tls_file.exists():
                try:
                    pw = pwd.getpwnam('pkiuser')
                    os.chown(tls_file, pw.pw_uid, pw.pw_gid)
                except (KeyError, OSError):
                    pass

        # Fix SELinux labels on any new files (e.g. kra/CS.cfg)
        try:
            subprocess.run(
                ["restorecon", "-R", f"/etc/pki/{instance_name}"],
                capture_output=True, check=False,
            )
        except FileNotFoundError:
            pass

        print(f"\n{subsystem} subsystem installed successfully")
        print(f"Instance: {instance_name}")
        return 0

    def _enable_kra(self, instance_name):
        """Generate KRA certificates using KRAInstall.enable_kra().

        This produces proper CA-signed transport, storage, and audit
        signing certificates and stores them in the NSSDB and LDAP
        certificate repository — matching Dogtag's pkispawn -s KRA.

        After cert generation, exports kra_backup_keys.p12 which
        FreeIPA expects to move after pkispawn returns.
        """
        import ipacta
        from ipacta.config import IpactaConfig
        from ipacta.install import NSSDB, KRAInstall, ServiceMgmt
        from ipacta.core.dn import DN

        cfg = IpactaConfig.from_file()
        ipacta.set_global_config(cfg)

        nssdb = NSSDB()
        nssdb.load_nssdb_password()

        domain = self.config.get('pki_dns_domainname', '')
        realm = domain.upper() if domain else cfg.realm
        basedn = self.config.get('pki_ds_base_dn', 'o=ipaca')
        subject_base = self.config.get(
            'pki_subject_base',
            f'O={realm}',
        )
        ca_subject = self.config.get(
            'pki_ca_signing_subject_dn',
            f'CN=Certificate Authority,O={realm}',
        )

        import socket
        fqdn = self.config.get('pki_hostname', socket.getfqdn())

        pki_config = {
            key: value for key, value in self.config.items()
            if key.startswith('pki_') or key.startswith('ipa_')
        }

        if 'ipa_signing_algorithm' not in pki_config:
            from ipacta.cscfg import get_directive as _get_dir
            _ca_alg = _get_dir("ca.signing.defaultSigningAlgorithm")
            if _ca_alg:
                pki_config['ipa_signing_algorithm'] = _ca_alg
        if 'ipa_key_type' not in pki_config:
            _alg = pki_config.get('ipa_signing_algorithm', '')
            if 'ML-DSA' in _alg.upper() or 'MLDSA' in _alg.upper():
                pki_config['ipa_key_type'] = 'mldsa'
            elif 'EC' in _alg.upper():
                pki_config['ipa_key_type'] = 'ec'
            else:
                pki_config['ipa_key_type'] = 'rsa'

        is_clone = self.config.get(
            'pki_clone', 'False').lower() == 'true'
        if is_clone:
            p12_path = self.config.get('pki_clone_pkcs12_path', '')
            p12_password = self.config.get(
                'pki_clone_pkcs12_password', '')
            if p12_path and Path(p12_path).exists():
                nssdb.import_kra_clone_pkcs12(p12_path, p12_password)
            else:
                print(
                    "WARNING: KRA clone PKCS#12 not provided or "
                    "not found (%s). KRA transport and storage keys "
                    "will be generated fresh — vault operations may "
                    "fail in multi-KRA topology." % p12_path
                )

        kra = KRAInstall(
            ldap=None,
            nssdb=nssdb,
            realm=realm,
            basedn=DN(basedn),
            fqdn=fqdn,
            pki_config=pki_config,
            ldap_mod_fn=None,
            subject_base=DN(subject_base),
            ca_subject=DN(ca_subject),
        )
        kra.enable_kra()

        ServiceMgmt._update_kra_cs_cfg_certs(instance_name, kra)

        backup_p12 = Path(
            '/var/lib/pki/pki-tomcat/alias/kra_backup_keys.p12')
        if not backup_p12.exists():
            backup_pwd = self.config.get(
                'pki_backup_password',
                self.config.get('pki_admin_password', ''),
            )
            ServiceMgmt._export_kra_backup_keys(backup_pwd)

    def _join_security_domain_via_rest(self, subsystem):
        """Join the security domain via REST API on the master."""
        import socket
        from ipacta.install.clone_client import CloneClient

        clone_uri = self.config.get('pki_clone_uri', '')
        sd_host = self.config.get('pki_security_domain_hostname', '')
        sd_port = self.config.get('pki_security_domain_https_port', '443')
        if sd_host:
            master_url = f'https://{sd_host}:{sd_port}'
        else:
            master_url = clone_uri

        sd_user = self.config.get('pki_security_domain_user', 'admin')
        sd_password = self.config.get('pki_security_domain_password', '')

        hostname = self.config.get('pki_hostname', socket.getfqdn())

        ca_bundle = '/etc/ipa/ca.crt'
        if not Path(ca_bundle).exists():
            ca_bundle = None

        client = CloneClient(master_url, ca_bundle=ca_bundle)
        client.login(sd_user, sd_password)
        try:
            token = client.get_install_token(hostname, subsystem)
            client.logout()

            client.join_security_domain(
                subsystem_type=subsystem,
                hostname=hostname,
                secure_port="443",
                clone=True,
                domain_manager=False,
                session_id=token,
            )

            if subsystem == 'KRA':
                client.register_kra_connector(
                    hostname=hostname,
                    port="443",
                    transport_cert_b64="",
                    session_id=token,
                )
        except Exception as e:
            print(
                f"Warning: REST security domain join failed for "
                f"{subsystem}: {e}",
                file=sys.stderr,
            )

    def run(self, argv):
        """Main entry point"""
        parser = argparse.ArgumentParser(
            description='PKI Instance Installation and Configuration',
            usage='pkispawn [-s <subsystem>] [OPTIONS]',
            epilog="""
REMINDER:

    If two or more Tomcat PKI 'instances' are specified via
    separate configuration files, remember that the following parameters
    MUST differ between PKI 'instances':

        Tomcat:  'pki_instance_name', 'pki_http_port', 'pki_https_port',
                 'pki_ajp_port', and 'pki_tomcat_server_port'

    Finally, if an optional '-p <prefix>' is defined, this value WILL NOT
    be prepended in front of the mandatory '-f <configuration_file>'.
            """,
            formatter_class=argparse.RawDescriptionHelpFormatter
        )

        parser.add_argument(
            '-s', metavar='<subsystem>',
            dest='subsystem', default='CA',
            choices=['CA', 'KRA'],
            help='where <subsystem> is CA or KRA',
        )
        parser.add_argument(
            '-v', '--verbose', action='store_true',
            help='Run in verbose mode',
        )
        parser.add_argument(
            '--debug', action='store_true',
            help='Run in debug mode',
        )
        parser.add_argument(
            '--conf', dest='conf_dir',
            metavar='CONF_DIR',
            help='Config folder',
        )
        parser.add_argument(
            '--logs', dest='logs_dir',
            metavar='LOGS_DIR',
            help='Logs folder',
        )
        parser.add_argument(
            '-f', metavar='<file>',
            dest='config_file',
            help='configuration filename '
                 '(MUST specify complete path)',
        )
        parser.add_argument(
            '-D', action='append',
            dest='param_overrides',
            metavar='<name>=<value>',
            help='configuration parameter '
                 'name and value',
        )
        parser.add_argument(
            '--precheck', action='store_true',
            help='Execute pre-checks and exit',
        )
        parser.add_argument(
            '--skip-configuration',
            action='store_true',
            help='skip configuration step',
        )
        parser.add_argument(
            '--skip-installation',
            action='store_true',
            help='skip installation step',
        )
        parser.add_argument(
            '--enforce-hostname',
            action='store_true',
            help='enforce strict hostname/FQDN checks',
        )
        parser.add_argument(
            '--with-maven-deps',
            action='store_true',
            help='Install Maven dependencies',
        )
        parser.add_argument(
            '--log-file', metavar='LOG_FILE',
            help='Log file',
        )

        # Handle --help manually to show custom help
        show_help = (
            len(argv) == 1
            or '--help' in argv
            or (len(argv) == 2 and argv[1] == '-h')
        )
        if show_help:
            parser.print_help()
            return 0

        args = parser.parse_args(argv[1:])

        self.verbose = args.verbose
        self.debug = args.debug
        self.log_file = args.log_file

        # Parse -D parameter overrides
        if args.param_overrides:
            for override in args.param_overrides:
                if '=' not in override:
                    print(
                        f"Error: Invalid -D parameter:"
                        f" {override}",
                        file=sys.stderr,
                    )
                    print("Format: -D <name>=<value>", file=sys.stderr)
                    return 1
                name, value = override.split('=', 1)
                self.param_overrides[name] = value

        if not args.config_file:
            print("Error: -f <config-file> required", file=sys.stderr)
            print("Usage: pkispawn -s CA -f <config-file>")
            return 1

        config_path = Path(args.config_file)
        if not config_path.exists():
            print(
                f"Error: Config file not found: "
                f"{config_path}",
                file=sys.stderr,
            )
            return 1

        return self.spawn_instance(
            args.subsystem,
            config_path,
            precheck_only=args.precheck,
            skip_config=args.skip_configuration,
            skip_install=args.skip_installation
        )


def main():
    """Main entry point"""
    spawner = PKISpawn()
    try:
        sys.exit(spawner.run(sys.argv))
    except KeyboardInterrupt:
        sys.exit(1)
    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        if spawner.verbose or spawner.debug:
            import traceback
            traceback.print_exc()
        sys.exit(1)


if __name__ == '__main__':
    main()
