#!/usr/bin/python # -*- coding: utf-8 -*- # # Copyright (c) 2019 Ansible Project # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) # # This module 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 software 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 software. If not, see . # # Author: # - Matthew Davis # on behalf of Telstra Corporation Limited from __future__ import (absolute_import, division, print_function) __metaclass__ = type DOCUMENTATION = ''' module: aws_acm short_description: Upload and delete certificates in the AWS Certificate Manager service version_added: 1.0.0 description: - Import and delete certificates in Amazon Web Service's Certificate Manager (AWS ACM). - > This module does not currently interact with AWS-provided certificates. It currently only manages certificates provided to AWS by the user. - The ACM API allows users to upload multiple certificates for the same domain name, and even multiple identical certificates. This module attempts to restrict such freedoms, to be idempotent, as per the Ansible philosophy. It does this through applying AWS resource "Name" tags to ACM certificates. - > When I(state=present), if there is one certificate in ACM with a C(Name) tag equal to the C(name_tag) parameter, and an identical body and chain, this task will succeed without effect. - > When I(state=present), if there is one certificate in ACM a I(Name) tag equal to the I(name_tag) parameter, and a different body, this task will overwrite that certificate. - > When I(state=present), if there are multiple certificates in ACM with a I(Name) tag equal to the I(name_tag) parameter, this task will fail. - > When I(state=absent) and I(certificate_arn) is defined, this module will delete the ACM resource with that ARN if it exists in this region, and succeed without effect if it doesn't exist. - > When I(state=absent) and I(domain_name) is defined, this module will delete all ACM resources in this AWS region with a corresponding domain name. If there are none, it will succeed without effect. - > When I(state=absent) and I(certificate_arn) is not defined, and I(domain_name) is not defined, this module will delete all ACM resources in this AWS region with a corresponding I(Name) tag. If there are none, it will succeed without effect. - Note that this may not work properly with keys of size 4096 bits, due to a limitation of the ACM API. options: certificate: description: - The body of the PEM encoded public certificate. - Required when I(state) is not C(absent). - If your certificate is in a file, use C(lookup('file', 'path/to/cert.pem')). type: str certificate_arn: description: - The ARN of a certificate in ACM to delete - Ignored when I(state=present). - If I(state=absent), you must provide one of I(certificate_arn), I(domain_name) or I(name_tag). - > If I(state=absent) and no resource exists with this ARN in this region, the task will succeed with no effect. - > If I(state=absent) and the corresponding resource exists in a different region, this task may report success without deleting that resource. type: str aliases: [arn] certificate_chain: description: - The body of the PEM encoded chain for your certificate. - If your certificate chain is in a file, use C(lookup('file', 'path/to/chain.pem')). - Ignored when I(state=absent) type: str domain_name: description: - The domain name of the certificate. - > If I(state=absent) and I(domain_name) is specified, this task will delete all ACM certificates with this domain. - Exactly one of I(domain_name), I(name_tag) and I(certificate_arn) must be provided. - > If I(state=present) this must not be specified. (Since the domain name is encoded within the public certificate's body.) type: str aliases: [domain] name_tag: description: - The unique identifier for tagging resources using AWS tags, with key I(Name). - This can be any set of characters accepted by AWS for tag values. - > This is to ensure Ansible can treat certificates idempotently, even though the ACM API allows duplicate certificates. - If I(state=preset), this must be specified. - > If I(state=absent), you must provide exactly one of I(certificate_arn), I(domain_name) or I(name_tag). type: str aliases: [name] private_key: description: - The body of the PEM encoded private key. - Required when I(state=present). - Ignored when I(state=absent). - If your private key is in a file, use C(lookup('file', 'path/to/key.pem')). type: str state: description: - > If I(state=present), the specified public certificate and private key will be uploaded, with I(Name) tag equal to I(name_tag). - > If I(state=absent), any certificates in this region with a corresponding I(domain_name), I(name_tag) or I(certificate_arn) will be deleted. choices: [present, absent] default: present type: str author: - Matthew Davis (@matt-telstra) on behalf of Telstra Corporation Limited extends_documentation_fragment: - amazon.aws.aws - amazon.aws.ec2 ''' EXAMPLES = ''' - name: upload a self-signed certificate community.aws.aws_acm: certificate: "{{ lookup('file', 'cert.pem' ) }}" privateKey: "{{ lookup('file', 'key.pem' ) }}" name_tag: my_cert # to be applied through an AWS tag as "Name":"my_cert" region: ap-southeast-2 # AWS region - name: create/update a certificate with a chain community.aws.aws_acm: certificate: "{{ lookup('file', 'cert.pem' ) }}" privateKey: "{{ lookup('file', 'key.pem' ) }}" name_tag: my_cert certificate_chain: "{{ lookup('file', 'chain.pem' ) }}" state: present region: ap-southeast-2 register: cert_create - name: print ARN of cert we just created ansible.builtin.debug: var: cert_create.certificate.arn - name: delete the cert we just created community.aws.aws_acm: name_tag: my_cert state: absent region: ap-southeast-2 - name: delete a certificate with a particular ARN community.aws.aws_acm: certificate_arn: "arn:aws:acm:ap-southeast-2:123456789012:certificate/01234567-abcd-abcd-abcd-012345678901" state: absent region: ap-southeast-2 - name: delete all certificates with a particular domain name community.aws.aws_acm: domain_name: acm.ansible.com state: absent region: ap-southeast-2 ''' RETURN = ''' certificate: description: Information about the certificate which was uploaded type: complex returned: when I(state=present) contains: arn: description: The ARN of the certificate in ACM type: str returned: when I(state=present) and not in check mode sample: "arn:aws:acm:ap-southeast-2:123456789012:certificate/01234567-abcd-abcd-abcd-012345678901" domain_name: description: The domain name encoded within the public certificate type: str returned: when I(state=present) sample: acm.ansible.com arns: description: A list of the ARNs of the certificates in ACM which were deleted type: list elements: str returned: when I(state=absent) sample: - "arn:aws:acm:ap-southeast-2:123456789012:certificate/01234567-abcd-abcd-abcd-012345678901" ''' from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule from ansible_collections.amazon.aws.plugins.module_utils.acm import ACMServiceManager from ansible.module_utils._text import to_text import base64 import re # regex library # Takes in two text arguments # Each a PEM encoded certificate # Or a chain of PEM encoded certificates # May include some lines between each chain in the cert, e.g. "Subject: ..." # Returns True iff the chains/certs are functionally identical (including chain order) def chain_compare(module, a, b): chain_a_pem = pem_chain_split(module, a) chain_b_pem = pem_chain_split(module, b) if len(chain_a_pem) != len(chain_b_pem): return False # Chain length is the same for (ca, cb) in zip(chain_a_pem, chain_b_pem): der_a = PEM_body_to_DER(module, ca) der_b = PEM_body_to_DER(module, cb) if der_a != der_b: return False return True # Takes in PEM encoded data with no headers # returns equivilent DER as byte array def PEM_body_to_DER(module, pem): try: der = base64.b64decode(to_text(pem)) except (ValueError, TypeError) as e: module.fail_json_aws(e, msg="Unable to decode certificate chain") return der # Store this globally to avoid repeated recompilation pem_chain_split_regex = re.compile(r"------?BEGIN [A-Z0-9. ]*CERTIFICATE------?([a-zA-Z0-9\+\/=\s]+)------?END [A-Z0-9. ]*CERTIFICATE------?") # Use regex to split up a chain or single cert into an array of base64 encoded data # Using "-----BEGIN CERTIFICATE-----" and "----END CERTIFICATE----" # Noting that some chains have non-pem data in between each cert # This function returns only what's between the headers, excluding the headers def pem_chain_split(module, pem): pem_arr = re.findall(pem_chain_split_regex, to_text(pem)) if len(pem_arr) == 0: # This happens if the regex doesn't match at all module.fail_json(msg="Unable to split certificate chain. Possibly zero-length chain?") return pem_arr def main(): argument_spec = dict( certificate=dict(), certificate_arn=dict(aliases=['arn']), certificate_chain=dict(), domain_name=dict(aliases=['domain']), name_tag=dict(aliases=['name']), private_key=dict(no_log=True), state=dict(default='present', choices=['present', 'absent']) ) required_if = [ ['state', 'present', ['certificate', 'name_tag', 'private_key']], ] module = AnsibleAWSModule(argument_spec=argument_spec, supports_check_mode=True, required_if=required_if) acm = ACMServiceManager(module) # Check argument requirements if module.params['state'] == 'present': if module.params['certificate_arn']: module.fail_json(msg="Parameter 'certificate_arn' is only valid if parameter 'state' is specified as 'absent'") else: # absent # exactly one of these should be specified absent_args = ['certificate_arn', 'domain_name', 'name_tag'] if sum([(module.params[a] is not None) for a in absent_args]) != 1: for a in absent_args: module.debug("%s is %s" % (a, module.params[a])) module.fail_json(msg="If 'state' is specified as 'absent' then exactly one of 'name_tag', certificate_arn' or 'domain_name' must be specified") if module.params['name_tag']: tags = dict(Name=module.params['name_tag']) else: tags = None client = module.client('acm') # fetch the list of certificates currently in ACM certificates = acm.get_certificates(client=client, module=module, domain_name=module.params['domain_name'], arn=module.params['certificate_arn'], only_tags=tags) module.debug("Found %d corresponding certificates in ACM" % len(certificates)) if module.params['state'] == 'present': if len(certificates) > 1: msg = "More than one certificate with Name=%s exists in ACM in this region" % module.params['name_tag'] module.fail_json(msg=msg, certificates=certificates) elif len(certificates) == 1: # update the existing certificate module.debug("Existing certificate found in ACM") old_cert = certificates[0] # existing cert in ACM if ('tags' not in old_cert) or ('Name' not in old_cert['tags']) or (old_cert['tags']['Name'] != module.params['name_tag']): # shouldn't happen module.fail_json(msg="Internal error, unsure which certificate to update", certificate=old_cert) if 'certificate' not in old_cert: # shouldn't happen module.fail_json(msg="Internal error, unsure what the existing cert in ACM is", certificate=old_cert) # Are the existing certificate in ACM and the local certificate the same? same = True same &= chain_compare(module, old_cert['certificate'], module.params['certificate']) if module.params['certificate_chain']: # Need to test this # not sure if Amazon appends the cert itself to the chain when self-signed same &= chain_compare(module, old_cert['certificate_chain'], module.params['certificate_chain']) else: # When there is no chain with a cert # it seems Amazon returns the cert itself as the chain same &= chain_compare(module, old_cert['certificate_chain'], module.params['certificate']) if same: module.debug("Existing certificate in ACM is the same, doing nothing") domain = acm.get_domain_of_cert(client=client, module=module, arn=old_cert['certificate_arn']) module.exit_json(certificate=dict(domain_name=domain, arn=old_cert['certificate_arn']), changed=False) else: module.debug("Existing certificate in ACM is different, overwriting") if module.check_mode: arn = old_cert['certificate_arn'] # note: returned domain will be the domain of the previous cert else: # update cert in ACM arn = acm.import_certificate(client, module, certificate=module.params['certificate'], private_key=module.params['private_key'], certificate_chain=module.params['certificate_chain'], arn=old_cert['certificate_arn'], tags=tags) domain = acm.get_domain_of_cert(client=client, module=module, arn=arn) module.exit_json(certificate=dict(domain_name=domain, arn=arn), changed=True) else: # len(certificates) == 0 module.debug("No certificate in ACM. Creating new one.") if module.check_mode: domain = 'example.com' module.exit_json(certificate=dict(domain_name=domain), changed=True) else: arn = acm.import_certificate(client=client, module=module, certificate=module.params['certificate'], private_key=module.params['private_key'], certificate_chain=module.params['certificate_chain'], tags=tags) domain = acm.get_domain_of_cert(client=client, module=module, arn=arn) module.exit_json(certificate=dict(domain_name=domain, arn=arn), changed=True) else: # state == absent for cert in certificates: if not module.check_mode: acm.delete_certificate(client, module, cert['certificate_arn']) module.exit_json(arns=[cert['certificate_arn'] for cert in certificates], changed=(len(certificates) > 0)) if __name__ == '__main__': # tests() main()