#!/usr/bin/env python3
"""\
CA hook script for the SvxReflector server application.

This script will be called by the SvxReflector application on some CA events.
The type of event will be available in the CA_OP environment variable. The
following event types are available for CA_OP.

- PENDING_CSR_CREATE: A new CSR (Certificate Signing Request) is received
- PENDING_CSR_UPDATED: An updated CSR is received
- CSR_SIGNED: A CSR is signed
- CRT_RENEWED: A certificate is renewed

The PEM conents is available in the following environment variables.

- CA_CSR_PEM  -- The CSR PEM contents
- CA_CRT_PEM  -- The certificate PEM contents, including intermediates

"""

import os
import os.path
import sys
import socket

import yaml
from cryptography import x509
from cryptography.x509.oid import NameOID, ExtensionOID
from cryptography.x509.extensions import ExtensionNotFound
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend

from email.message import EmailMessage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
import smtplib


config = {}


def load_config() -> None:
    """\
    Find the configuration file and load it into the config dict
    """
    global config
    FILENAME = "ca-hook.yaml"
    path = f"@SVX_SYSCONF_INSTALL_DIR@/{FILENAME}"
    if os.path.exists(FILENAME):
        path = FILENAME
    elif home := os.getenv("HOME"):
        p = f"{home}/.svxlink/{FILENAME}"
        if os.path.exists(p):
            path = p
    with open(path, "r") as f:
        config = yaml.safe_load(f)


def config_format(varname, values):
    """\
    Get the value of the specified configuration variable. If it is a string,
    pass it through formatting. Otherwise return the config variable as is.
    """
    if not varname in config:
        return None
    return (
        config[varname].format(**values)
        if isinstance(config[varname], str)
        else config[varname]
    )


def config_is_set(varnames: set) -> bool:
    for varname in varnames:
        if not varname in config or config[varname] is None:
            return False
    return True


def send_email(**kwargs) -> int:
    """\
    Send and email using the following keyword arguments.

      to:         A list of destination addresses
      bcc:        A list of BCC addresses
      subject:    The subject of the message
      content:    The contents of the message

    The global variable EMAIL_FROM will be used as the sender.
    The global variable EMAIL_SMTP_SERVER will be used as the SMTP server to
    send the message to.
    """
    msg = MIMEMultipart()
    msg["From"] = config["EMAIL_FROM"]
    msg["To"] = (
        ", ".join(kwargs["to"])
        if isinstance(kwargs["to"], list)
        else kwargs["to"]
    )
    if kwargs.get("bcc"):
        msg["BCC"] = (
            ", ".join(kwargs["bcc"])
            if isinstance(kwargs["bcc"], list)
            else kwargs["bcc"]
        )
    msg["Subject"] = kwargs["subject"]
    msg.attach(MIMEText(kwargs["content"]))
    if "attachments" in kwargs:
        for attachment in kwargs["attachments"]:
            part = MIMEText(attachment["content"])
            part["Content-Disposition"] = (
                f"attachment; filename=\"{attachment['name']}\""
            )
            msg.attach(part)

    if config["EMAIL_DEBUG"]:
        print(msg)
    else:
        s = smtplib.SMTP(config["EMAIL_SMTP_SERVER"])
        s.send_message(msg)
        s.quit()
    return 0


def pending_csr_create(csr) -> int:
    """\
    Called when a pending CSR is created.
    """
    if not config_is_set(
        {
            "CSR_CREATE_MAIL_TO",
            "CSR_CREATE_MAIL_SUBJECT",
            "CSR_CREATE_MAIL_CONTENT",
        }
    ):
        return 0

    cn = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
    callsign = cn[0].value if len(cn) > 0 else ""
    email = []
    san = None
    try:
        san = csr.extensions.get_extension_for_oid(
            ExtensionOID.SUBJECT_ALTERNATIVE_NAME
        )
        email = san.value.get_values_for_type(x509.RFC822Name)
    except ExtensionNotFound:
        pass

    # print(f"### Create CSR: callsign={callsign} email={email}")

    values = {
        "callsign": callsign,
        "subject": csr.subject.rfc4514_string(),
        "hostname": socket.gethostname(),
        "email": ", ".join(email),
    }

    send_email(
        to=config_format("CSR_CREATE_MAIL_TO", values),
        bcc=config_format("CSR_CREATE_MAIL_BCC", values),
        subject=config["CSR_CREATE_MAIL_SUBJECT"].format(**values),
        content=config["CSR_CREATE_MAIL_CONTENT"].format(**values),
        attachments=[
            {
                "name": f"{callsign}.csr",
                "content": csr.public_bytes(
                    serialization.Encoding.PEM
                ).decode(),
            }
        ],
    )
    return 0


def pending_csr_update(csr) -> int:
    """\
    Called when a pending CSR is updated.
    """
    if not config_is_set(
        {
            "CSR_UPDATE_MAIL_TO",
            "CSR_UPDATE_MAIL_SUBJECT",
            "CSR_UPDATE_MAIL_CONTENT",
        }
    ):
        return 0

    cn = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
    callsign = cn[0].value if len(cn) > 0 else ""
    email = []
    san = None
    try:
        san = csr.extensions.get_extension_for_oid(
            ExtensionOID.SUBJECT_ALTERNATIVE_NAME
        )
        email = san.value.get_values_for_type(x509.RFC822Name)
    except ExtensionNotFound:
        pass

    # print(f"### Update CSR: callsign={callsign} email={email}")

    values = {
        "callsign": callsign,
        "subject": csr.subject.rfc4514_string(),
        "hostname": socket.gethostname(),
        "email": ", ".join(email),
    }

    send_email(
        to=config_format("CSR_UPDATE_MAIL_TO", values),
        bcc=config_format("CSR_UPDATE_MAIL_BCC", values),
        subject=config["CSR_UPDATE_MAIL_SUBJECT"].format(**values),
        content=config["CSR_UPDATE_MAIL_CONTENT"].format(**values),
        attachments=[
            {
                "name": f"{callsign}.csr",
                "content": csr.public_bytes(
                    serialization.Encoding.PEM
                ).decode(),
            }
        ],
    )
    return 0


def csr_signed(cert) -> int:
    """\
    Called when a CSR is signed.
    """
    if not config_is_set(
        {
            "CSR_SIGNED_MAIL_TO",
            "CSR_SIGNED_MAIL_SUBJECT",
            "CSR_SIGNED_MAIL_CONTENT",
        }
    ):
        return 0

    cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
    callsign = cn[0].value if len(cn) > 0 else ""
    email = []
    try:
        san = cert.extensions.get_extension_for_oid(
            ExtensionOID.SUBJECT_ALTERNATIVE_NAME
        )
        email = san.value.get_values_for_type(x509.RFC822Name)
    except ExtensionNotFound:
        pass

    # print(f"### Sign CSR: callsign={callsign} email={email}")

    values = {
        "callsign": callsign,
        "serial_number": cert.serial_number,
        "issuer": cert.issuer.rfc4514_string(),
        "subject": cert.subject.rfc4514_string(),
        "not_before": cert.not_valid_before,
        "not_after": cert.not_valid_after,
        "email": ", ".join(email),
        "hostname": socket.gethostname(),
    }
    send_email(
        to=config_format("CSR_SIGNED_MAIL_TO", values),
        bcc=config_format("CSR_SIGNED_MAIL_BCC", values),
        subject=config["CSR_SIGNED_MAIL_SUBJECT"].format(**values),
        content=config["CSR_SIGNED_MAIL_CONTENT"].format(**values),
    )
    return 0


def crt_renewed(cert) -> int:
    """\
    Called when a certificate is renewed.
    """
    if not config_is_set(
        {
            "CRT_RENEWED_MAIL_TO",
            "CRT_RENEWED_MAIL_SUBJECT",
            "CRT_RENEWED_MAIL_CONTENT",
        }
    ):
        return 0

    cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
    callsign = cn[0].value if len(cn) > 0 else ""
    email = []
    try:
        san = cert.extensions.get_extension_for_oid(
            ExtensionOID.SUBJECT_ALTERNATIVE_NAME
        )
        email = san.value.get_values_for_type(x509.RFC822Name)
    except ExtensionNotFound:
        pass

    # print(f"### Renew cert: callsign={callsign} email={email}")

    values = {
        "callsign": callsign,
        "serial_number": cert.serial_number,
        "issuer": cert.issuer.rfc4514_string(),
        "subject": cert.subject.rfc4514_string(),
        "not_before": cert.not_valid_before,
        "not_after": cert.not_valid_after,
        "email": ", ".join(email),
        "hostname": socket.gethostname(),
    }
    send_email(
        to=config_format("CRT_RENEWED_MAIL_TO", values),
        bcc=config_format("CRT_RENEWED_MAIL_BCC", values),
        subject=config["CRT_RENEWED_MAIL_SUBJECT"].format(**values),
        content=config["CRT_RENEWED_MAIL_CONTENT"].format(**values),
    )
    return 0


def main() -> int:
    """\
    The main entry point for the this script.
    """
    load_config()

    op = os.getenv("CA_OP")
    if not op:
        print(f"*** ERROR: The CA_OP environment variable is not set or empty")
        return 1

    if op != "CSR_SIGNED" and op != "CRT_RENEWED":
        csr_pem = os.getenv("CA_CSR_PEM")
        if not csr_pem:
            print("*** ERROR: CA_CSR_PEM not set")
            return 1
        if csr_pem:
            csr = x509.load_pem_x509_csr(csr_pem.encode(), default_backend())
        else:
            print("*** ERROR: Invalid CSR PEM")
            return 1

    if op == "PENDING_CSR_CREATE":
        return pending_csr_create(csr)
    elif op == "PENDING_CSR_UPDATE":
        return pending_csr_update(csr)
    elif op == "CSR_SIGNED":
        certs_pem = os.getenv("CA_CRT_PEM")
        if not certs_pem:
            print("*** ERROR: CA_CRT_PEM not set")
            return 1
        cert = x509.load_pem_x509_certificate(
            certs_pem.encode(), default_backend()
        )
        return csr_signed(cert)
    elif op == "CRT_RENEWED":
        certs_pem = os.getenv("CA_CRT_PEM")
        if not certs_pem:
            print("*** ERROR: CA_CRT_PEM not set")
            return 1
        cert = x509.load_pem_x509_certificate(
            certs_pem.encode(), default_backend()
        )
        return crt_renewed(cert)
    return 0


if __name__ == "__main__":
    sys.exit(main())
