#!/usr/bin/python
# -*- coding: utf-8 -*-

# Authors:
#   Sam Morris <sam@robots.org.uk>
#   Rafael Guterres Jeffman <rjeffman@redhat.com>
#
# Copyright (C) 2021 Red Hat
# see file 'COPYING' for use and warranty information
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

from __future__ import (absolute_import, division, print_function)

__metaclass__ = type

ANSIBLE_METADATA = {
    "metadata_version": "1.0",
    "supported_by": "community",
    "status": ["preview"],
}

DOCUMENTATION = """
---
module: ipacert
short_description: Manage FreeIPA certificates
description: Manage FreeIPA certificates
extends_documentation_fragment:
  - ipamodule_base_docs
options:
  csr:
    description: |
      X509 certificate signing request, in RFC 7468 PEM encoding.
      Only available if `state: requested`, required if `csr_file` is not
      provided.
    type: str
  csr_file:
    description: |
      Path to file with X509 certificate signing request, in RFC 7468 PEM
      encoding. Only available if `state: requested`, required if `csr_file`
      is not provided.
    type: str
  principal:
    description: |
      Host/service/user principal for the certificate.
      Required if `state: requested`. Only available if `state: requested`.
    type: str
  add:
    description: |
      Automatically add the principal if it doesn't exist (service
      principals only). Only available if `state: requested`.
    type: bool
    aliases: ["add_principal"]
    required: false
  ca:
    description: Name of the issuing certificate authority.
    type: str
    required: false
  chain:
    description: Include certificate chain in output.
    type: bool
    required: false
  serial_number:
    description: |
      Certificate serial number. Cannot be used with `state: requested`.
      Required for all states, except `requested`.
    type: int
  profile:
    description: Certificate Profile to use.
    type: str
    aliases: ["profile_id"]
    required: false
  revocation_reason:
    description: |
      Reason for revoking the certificate. Use one of the reason strings,
      or the corresponding value: "unspecified" (0), "keyCompromise" (1),
      "cACompromise" (2), "affiliationChanged" (3), "superseded" (4),
      "cessationOfOperation" (5), "certificateHold" (6), "removeFromCRL" (8),
      "privilegeWithdrawn" (9), "aACompromise" (10).
      Use only if `state: revoked`. Required if `state: revoked`.
    type: raw
    aliases: ['reason']
  certificate_out:
    description: |
      Write certificate (chain if `chain` is set) to this file, on the target
      node.. Use only when `state` is `requested` or `retrieved`.
    type: str
    required: false
  state:
    description: |
      The state to ensure. `held` is the same as revoke with reason
      "certificateHold" (6). `released` is the same as `cert-revoke-hold`
      on IPA CLI, releasing the hold status of a certificate.
    choices: ["requested", "held", "released", "revoked", "retrieved"]
    required: true
    type: str
author:
  - Sam Morris (@yrro)
  - Rafael Guterres Jeffman (@rjeffman)
"""

EXAMPLES = """
- name: Request a certificate for a web server
  ipacert:
    ipaadmin_password: SomeADMINpassword
    state: requested
    csr: |
      -----BEGIN CERTIFICATE REQUEST-----
      MIGYMEwCAQAwGTEXMBUGA1UEAwwOZnJlZWlwYSBydWxlcyEwKjAFBgMrZXADIQBs
      HlqIr4b/XNK+K8QLJKIzfvuNK0buBhLz3LAzY7QDEqAAMAUGAytlcANBAF4oSCbA
      5aIPukCidnZJdr491G4LBE+URecYXsPknwYb+V+ONnf5ycZHyaFv+jkUBFGFeDgU
      SYaXm/gF8cDYjQI=
      -----END CERTIFICATE REQUEST-----
    principal: HTTP/www.example.com
  register: cert

- name: Request certificate for a user, with an appropriate profile.
  ipacert:
    ipaadmin_password: SomeADMINpassword
    csr: |
      -----BEGIN CERTIFICATE REQUEST-----
      MIIBejCB5AIBADAQMQ4wDAYDVQQDDAVwaW5reTCBnzANBgkqhkiG9w0BAQEFAAOB
      jQAwgYkCgYEA7uChccy1Is1FTM0SF23WPYW472E3ozeLh2kzhKR9Ni6FLmeEGgu7
      /hicR1VwvXHYkNwI1tpW9LqxRVvgr6vheqHySljrBcoRfshfYvKejp03l2327Bfq
      BNxXqLcHylNEyg8SH0u63bWyxtgoDBfdZwdGAhYuJ+g4ev79J5eYoB0CAwEAAaAr
      MCkGCSqGSIb3DQEJDjEcMBowGAYHKoZIzlYIAQQNDAtoZWxsbyB3b3JsZDANBgkq
      hkiG9w0BAQsFAAOBgQADCi5BHDv1mrBFDWqYytFpQ1mrvr/mdax3AYXxNL2UEV8j
      AqZAFTEnJXL/u1eVQtI1yotqxakyUBN4XZBP2CBgJRO93Mtry8cgvU1sPdU8Mavx
      5gSnlP74Hio2ziscWWydlxpYxFx0gkKvu+0nyIpz954SVYwQ2wwk5FRqZnxI5w==
      -----END CERTIFICATE REQUEST-----
    principal: pinky
    profile_id: IECUserRoles
    state: requested

- name: Temporarily hold a certificate
  ipacert:
    ipaadmin_password: SomeADMINpassword
    serial_number: 12345
    state: held

- name: Remove a certificate hold
  ipacert:
    ipaadmin_password: SomeADMINpassword
    state: released
    serial_number: 12345

- name: Permanently revoke a certificate issued by a lightweight sub-CA
  ipacert:
    ipaadmin_password: SomeADMINpassword
    state: revoked
    ca: vpn-ca
    serial_number: 0x98765
    reason: keyCompromise

- name: Retrieve a certificate
  ipacert:
    ipaadmin_password: SomeADMINpassword
    serial_number: 12345
    state: retrieved
  register: cert_retrieved
"""

RETURN = """
certificate:
  description: Certificate fields and data.
  returned: |
    if `state` is `requested` or `retrived` and `certificate_out`
    is not defined.
  type: dict
  contains:
    certificate:
      description: |
        Issued X509 certificate in PEM encoding. Will include certificate
        chain if `chain: true` is used.
      type: list
      elements: str
      returned: always
    issuer:
      description: X509 distinguished name of issuer.
      type: str
      sample: CN=Certificate Authority,O=EXAMPLE.COM
      returned: always
    serial_number:
      description: Serial number of the issued certificate.
      type: int
      sample: 902156300
      returned: always
    valid_not_after:
      description: |
        Time when issued certificate ceases to be valid,
        in GeneralizedTime format (YYYYMMDDHHMMSSZ).
      type: str
      returned: always
    valid_not_before:
      description: |
        Time when issued certificate becomes valid, in
        GeneralizedTime format (YYYYMMDDHHMMSSZ).
      type: str
      returned: always
    subject:
      description: X509 distinguished name of certificate subject.
      type: str
      sample: CN=www.example.com,O=EXAMPLE.COM
      returned: always
    san_dnsname:
      description: X509 Subject Alternative Name.
      type: list
      elements: str
      sample: ['www.example.com', 'other.example.com']
      returned: |
        when DNSNames are present in the Subject Alternative Name
        extension of the issued certificate.
    revoked:
      description: Revoked status of the certificate.
      type: bool
      returned: always
    owner_user:
      description: The username that owns the certificate.
      type: str
      returned: when `state` is `retrieved`
    owner_host:
      description: The host that owns the certificate.
      type: str
      returned: when `state` is `retrieved`
    owner_service:
      description: The service that owns the certificate.
      type: str
      returned: when `state` is `retrieved`
"""

import base64
import time
import ssl

from ansible.module_utils import six
from ansible.module_utils._text import to_text
from ansible.module_utils.ansible_freeipa_module import (
    IPAAnsibleModule, certificate_loader, write_certificate_list,
)

if six.PY3:
    unicode = str

# Reasons are defined in RFC 5280 sec. 5.3.1; removeFromCRL is not present in
# this list; run the module with state=released instead.
REVOCATION_REASONS = {
    'unspecified': 0,
    'keyCompromise': 1,
    'cACompromise': 2,
    'affiliationChanged': 3,
    'superseded': 4,
    'cessationOfOperation': 5,
    'certificateHold': 6,
    'removeFromCRL': 8,
    'privilegeWithdrawn': 9,
    'aACompromise': 10,
}


def gen_args(
    module, principal=None, add_principal=None, ca=None, chain=None,
    profile=None, certificate_out=None, reason=None
):
    args = {}
    if principal is not None:
        args['principal'] = principal
    if add_principal is not None:
        args['add'] = add_principal
    if ca is not None:
        args['cacn'] = ca
    if profile is not None:
        args['profile_id'] = profile
    if certificate_out is not None:
        args['out'] = certificate_out
    if chain:
        args['chain'] = True
    if ca:
        args['cacn'] = ca
    if reason is not None:
        args['revocation_reason'] = get_revocation_reason(module, reason)
    return args


def get_revocation_reason(module, reason):
    """Ensure revocation reasion is a valid integer code."""
    reason_int = -1

    try:
        reason_int = int(reason)
    except ValueError:
        reason_int = REVOCATION_REASONS.get(reason, -1)

    if reason_int not in REVOCATION_REASONS.values():
        module.fail_json(msg="Invalid revocation reason: %s" % reason)

    return reason_int


def parse_cert_timestamp(dt):
    """Ensure time is in GeneralizedTime format (YYYYMMDDHHMMSSZ)."""
    return time.strftime(
        "%Y%m%d%H%M%SZ",
        time.strptime(dt, "%a %b %d %H:%M:%S %Y UTC")
    )


def result_handler(_module, result, _command, _name, _args, exit_args, chain):
    """Split certificate into fields."""
    if chain:
        exit_args['certificate'] = [
            ssl.DER_cert_to_PEM_cert(c)
            for c in result['result'].get('certificate_chain', [])
        ]
    else:
        exit_args['certificate'] = [
            ssl.DER_cert_to_PEM_cert(
                base64.b64decode(result['result']['certificate'])
            )
        ]

    exit_args['san_dnsname'] = [
        str(dnsname)
        for dnsname in result['result'].get('san_dnsname', [])
    ]

    exit_args.update({
        key: result['result'][key]
        for key in [
            'issuer', 'subject', 'serial_number',
            'revoked', 'revocation_reason'
        ]
        if key in result['result']
    })
    exit_args.update({
        key: result['result'][key][0]
        for key in ['owner_user', 'owner_host', 'owner_service']
        if key in result['result']
    })

    exit_args.update({
        key: parse_cert_timestamp(result['result'][key])
        for key in ['valid_not_after', 'valid_not_before']
        if key in result['result']
    })


def do_cert_request(
    module, csr, principal, add_principal=None, ca=None, profile=None,
    chain=None, certificate_out=None
):
    """Request a certificate."""
    args = gen_args(
        module, principal=principal, ca=ca, chain=chain,
        add_principal=add_principal, profile=profile,
    )
    exit_args = {}
    commands = [[to_text(csr), "cert_request", args]]
    changed = module.execute_ipa_commands(
        commands,
        result_handler=result_handler,
        exit_args=exit_args,
        chain=chain
    )

    if certificate_out is not None:
        certs = (
            certificate_loader(cert.encode("utf-8"))
            for cert in exit_args['certificate']
        )
        write_certificate_list(certs, certificate_out)
        exit_args = {}

    return changed, exit_args


def do_cert_revoke(ansible_module, serial_number, reason=None, ca=None):
    """Revoke a certificate."""
    _ign, cert = do_cert_retrieve(ansible_module, serial_number, ca)
    if not cert or cert.get('revoked', False):
        return False, cert

    args = gen_args(ansible_module, ca=ca, reason=reason)

    commands = [[serial_number, "cert_revoke", args]]
    changed = ansible_module.execute_ipa_commands(commands)

    return changed, cert


def do_cert_release(ansible_module, serial_number, ca=None):
    """Release hold on certificate."""
    _ign, cert = do_cert_retrieve(ansible_module, serial_number, ca)
    revoked = cert.get('revoked', True)
    reason = cert.get('revocation_reason', -1)
    if cert and not revoked:
        return False, cert

    if revoked and reason != 6:  # can only release held certificates
        ansible_module.fail_json(
            msg="Cannot release hold on certificate revoked with"
                " reason: %d" % reason
        )
    args = gen_args(ansible_module, ca=ca)

    commands = [[serial_number, "cert_remove_hold", args]]
    changed = ansible_module.execute_ipa_commands(commands)

    return changed, cert


def do_cert_retrieve(
    module, serial_number, ca=None, chain=None, outfile=None
):
    """Retrieve a certificate with 'cert-show'."""
    args = gen_args(module, ca=ca, chain=chain, certificate_out=outfile)
    exit_args = {}
    commands = [[serial_number, "cert_show", args]]
    module.execute_ipa_commands(
        commands,
        result_handler=result_handler,
        exit_args=exit_args,
        chain=chain,
    )
    if outfile is not None:
        exit_args = {}

    return False, exit_args


def main():
    ansible_module = IPAAnsibleModule(
        argument_spec=dict(
            # requested
            csr=dict(type="str"),
            csr_file=dict(type="str"),
            principal=dict(type="str"),
            add_principal=dict(type="bool", required=False, aliases=["add"]),
            profile_id=dict(type="str", aliases=["profile"], required=False),
            # revoked
            revocation_reason=dict(type="raw", aliases=["reason"]),
            # general
            serial_number=dict(type="int"),
            ca=dict(type="str"),
            chain=dict(type="bool", required=False),
            certificate_out=dict(type="str", required=False),
            # state
            state=dict(
                type="str",
                required=True,
                choices=[
                    "requested", "held", "released", "revoked", "retrieved"
                ]
            ),
        ),
        mutually_exclusive=[["csr", "csr_file"]],
        required_if=[
            ('state', 'requested', ['principal']),
            ('state', 'retrieved', ['serial_number']),
            ('state', 'held', ['serial_number']),
            ('state', 'released', ['serial_number']),
            ('state', 'revoked', ['serial_number', 'revocation_reason']),
        ],
        supports_check_mode=False,
    )

    ansible_module._ansible_debug = True

    # Get parameters

    # requested
    csr = ansible_module.params_get("csr")
    csr_file = ansible_module.params_get("csr_file")
    principal = ansible_module.params_get("principal")
    add_principal = ansible_module.params_get("add_principal")
    profile = ansible_module.params_get("profile_id")

    # revoked
    reason = ansible_module.params_get("revocation_reason")

    # general
    serial_number = ansible_module.params.get("serial_number")
    ca = ansible_module.params_get("ca")
    chain = ansible_module.params_get("chain")
    certificate_out = ansible_module.params_get("certificate_out")

    # state
    state = ansible_module.params_get("state")

    # Check parameters

    if ansible_module.params_get("ipaapi_context") == "server":
        ansible_module.fail_json(
            msg="Context 'server' for ipacert is not yet supported."
        )

    invalid = []
    if state == "requested":
        invalid = ["serial_number", "revocation_reason"]
        if csr is None and csr_file is None:
            ansible_module.fail_json(
                msg="Required 'csr' or 'csr_file' with 'state: requested'.")
    else:
        invalid = [
            "csr", "principal", "add_principal", "profile"
            "certificate_out"
        ]
        if state in ["released", "held"]:
            invalid.extend(["revocation_reason", "certificate_out", "chain"])
        if state == "retrieved":
            invalid.append("revocation_reason")
        if state == "revoked":
            invalid.extend(["certificate_out", "chain"])
        elif state == "held":
            reason = 6  # certificateHold

    ansible_module.params_fail_used_invalid(invalid, state)

    # Init

    changed = False
    exit_args = {}

    # Connect to IPA API
    # If executed on 'server' contexot, cert plugin uses the IPA RA agent
    # TLS client certificate/key, which users are not able to access,
    # resulting in a 'permission denied' exception when attempting to connect
    # the CA service. Therefore 'client' context in forced for this module.
    with ansible_module.ipa_connect(context="client"):

        if state == "requested":
            if csr_file is not None:
                with open(csr_file, "rt") as csr_in:
                    csr = "".join(csr_in.readlines())
            changed, exit_args = do_cert_request(
                ansible_module,
                csr,
                principal,
                add_principal,
                ca,
                profile,
                chain,
                certificate_out
            )

        elif state in ("held", "revoked"):
            changed, exit_args = do_cert_revoke(
                ansible_module, serial_number, reason, ca)

        elif state == "released":
            changed, exit_args = do_cert_release(
                ansible_module, serial_number, ca)

        elif state == "retrieved":
            changed, exit_args = do_cert_retrieve(
                ansible_module, serial_number, ca, chain, certificate_out)

    # Done

    ansible_module.exit_json(changed=changed, certificate=exit_args)


if __name__ == "__main__":
    main()