#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright (c) 2024 Felix Fontein # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import absolute_import, division, print_function __metaclass__ = type DOCUMENTATION = ''' --- module: acme_certificate_order_finalize author: Felix Fontein (@felixfontein) version_added: 2.24.0 short_description: Finalize an ACME v2 order description: - Finalizes an ACME v2 order and obtains the certificate and certificate chains. This is the final step of obtaining a new certificate with the L(ACME protocol,https://tools.ietf.org/html/rfc8555) from a Certificate Authority such as L(Let's Encrypt,https://letsencrypt.org/) or L(Buypass,https://www.buypass.com/). This module does not support ACME v1, the original version of the ACME protocol before standardization. - This module needs to be used in conjunction with the M(community.crypto.acme_certificate_order_create) and. M(community.crypto.acme_certificate_order_validate) modules. seealso: - module: community.crypto.acme_certificate_order_create description: Create an ACME order. - module: community.crypto.acme_certificate_order_validate description: Validate pending authorizations of an ACME order. - module: community.crypto.acme_certificate_order_info description: Obtain information for an ACME order. - name: The Let's Encrypt documentation description: Documentation for the Let's Encrypt Certification Authority. Provides useful information for example on rate limits. link: https://letsencrypt.org/docs/ - name: Buypass Go SSL description: Documentation for the Buypass Certification Authority. Provides useful information for example on rate limits. link: https://www.buypass.com/ssl/products/acme - name: Automatic Certificate Management Environment (ACME) description: The specification of the ACME protocol (RFC 8555). link: https://tools.ietf.org/html/rfc8555 - module: community.crypto.certificate_complete_chain description: Allows to find the root certificate for the returned fullchain. - module: community.crypto.acme_certificate_revoke description: Allows to revoke certificates. - module: community.crypto.acme_inspect description: Allows to debug problems. - module: community.crypto.acme_certificate_deactivate_authz description: Allows to deactivate (invalidate) ACME v2 orders. extends_documentation_fragment: - community.crypto.acme.basic - community.crypto.acme.account - community.crypto.acme.certificate - community.crypto.attributes - community.crypto.attributes.actiongroup_acme - community.crypto.attributes.files attributes: check_mode: support: none diff_mode: support: none safe_file_operations: support: full idempotent: support: full options: order_uri: description: - The order URI provided by RV(community.crypto.acme_certificate_order_create#module:order_uri). type: str required: true cert_dest: description: - "The destination file for the certificate." type: path fullchain_dest: description: - "The destination file for the full chain (that is, a certificate followed by chain of intermediate certificates)." type: path chain_dest: description: - If specified, the intermediate certificate will be written to this file. type: path deactivate_authzs: description: - "Deactivate authentication objects (authz) after issuing a certificate, or when issuing the certificate failed." - V(never) never deactivates them. - V(always) always deactivates them in cases of errors or when the certificate was issued. - V(on_error) only deactivates them in case of errors. - V(on_success) only deactivates them in case the certificate was successfully issued. - "Authentication objects are bound to an account key and remain valid for a certain amount of time, and can be used to issue certificates without having to re-authenticate the domain. This can be a security concern." type: str choices: - never - on_error - on_success - always default: always retrieve_all_alternates: description: - "When set to V(true), will retrieve all alternate trust chains offered by the ACME CA. These will not be written to disk, but will be returned together with the main chain as RV(all_chains). See the documentation for the RV(all_chains) return value for details." type: bool default: false select_chain: description: - "Allows to specify criteria by which an (alternate) trust chain can be selected." - "The list of criteria will be processed one by one until a chain is found matching a criterium. If such a chain is found, it will be used by the module instead of the default chain." - "If a criterium matches multiple chains, the first one matching will be returned. The order is determined by the ordering of the C(Link) headers returned by the ACME server and might not be deterministic." - "Every criterium can consist of multiple different conditions, like O(select_chain[].issuer) and O(select_chain[].subject). For the criterium to match a chain, all conditions must apply to the same certificate in the chain." - "This option can only be used with the C(cryptography) backend." type: list elements: dict suboptions: test_certificates: description: - "Determines which certificates in the chain will be tested." - "V(all) tests all certificates in the chain (excluding the leaf, which is identical in all chains)." - "V(first) only tests the first certificate in the chain, that is the one which signed the leaf." - "V(last) only tests the last certificate in the chain, that is the one furthest away from the leaf. Its issuer is the root certificate of this chain." type: str default: all choices: [first, last, all] issuer: description: - "Allows to specify parts of the issuer of a certificate in the chain must have to be selected." - "If O(select_chain[].issuer) is empty, any certificate will match." - 'An example value would be V({"commonName": "My Preferred CA Root"}).' type: dict subject: description: - "Allows to specify parts of the subject of a certificate in the chain must have to be selected." - "If O(select_chain[].subject) is empty, any certificate will match." - 'An example value would be V({"CN": "My Preferred CA Intermediate"})' type: dict subject_key_identifier: description: - "Checks for the SubjectKeyIdentifier extension. This is an identifier based on the private key of the intermediate certificate." - "The identifier must be of the form V(A8:4A:6A:63:04:7D:DD:BA:E6:D1:39:B7:A6:45:65:EF:F3:A8:EC:A1)." type: str authority_key_identifier: description: - "Checks for the AuthorityKeyIdentifier extension. This is an identifier based on the private key of the issuer of the intermediate certificate." - "The identifier must be of the form V(C4:A7:B1:A4:7B:2C:71:FA:DB:E1:4B:90:75:FF:C4:15:60:85:89:10)." type: str ''' EXAMPLES = r''' ### Example with HTTP-01 challenge ### - name: Create a challenge for sample.com using a account key from a variable community.crypto.acme_certificate_order_create: account_key_content: "{{ account_private_key }}" csr: /etc/pki/cert/csr/sample.com.csr register: sample_com_challenge # Alternative first step: - name: Create a challenge for sample.com using a account key from Hashi Vault community.crypto.acme_certificate_order_create: account_key_content: >- {{ lookup('community.hashi_vault.hashi_vault', 'secret=secret/account_private_key:value') }} csr: /etc/pki/cert/csr/sample.com.csr register: sample_com_challenge # Alternative first step: - name: Create a challenge for sample.com using a account key file community.crypto.acme_certificate_order_create: account_key_src: /etc/pki/cert/private/account.key csr_content: "{{ lookup('file', '/etc/pki/cert/csr/sample.com.csr') }}" register: sample_com_challenge # Perform the necessary steps to fulfill the challenge. For example: # # - name: Copy http-01 challenges # ansible.builtin.copy: # dest: /var/www/{{ item.identifier }}/{{ item.challenges['http-01'].resource }} # content: "{{ item.challenges['http-01'].resource_value }}" # loop: "{{ sample_com_challenge.challenge_data }}" # when: "'http-01' in item.challenges" - name: Let the challenge be validated community.crypto.acme_certificate_order_validate: account_key_src: /etc/pki/cert/private/account.key order_uri: "{{ sample_com_challenge.order_uri }}" challenge: http-01 - name: Retrieve the cert and intermediate certificate community.crypto.acme_certificate_order_finalize: account_key_src: /etc/pki/cert/private/account.key csr: /etc/pki/cert/csr/sample.com.csr order_uri: "{{ sample_com_challenge.order_uri }}" cert_dest: /etc/httpd/ssl/sample.com.crt fullchain_dest: /etc/httpd/ssl/sample.com-fullchain.crt chain_dest: /etc/httpd/ssl/sample.com-intermediate.crt ### Example with DNS challenge against production ACME server ### - name: Create a challenge for sample.com using a account key file. community.crypto.acme_certificate_order_create: acme_directory: https://acme-v01.api.letsencrypt.org/directory acme_version: 2 account_key_src: /etc/pki/cert/private/account.key csr: /etc/pki/cert/csr/sample.com.csr register: sample_com_challenge # Perform the necessary steps to fulfill the challenge. For example: # # - name: Create DNS records for dns-01 challenges # community.aws.route53: # zone: sample.com # record: "{{ item.key }}" # type: TXT # ttl: 60 # state: present # wait: true # # Note: item.value is a list of TXT entries, and route53 # # requires every entry to be enclosed in quotes # value: "{{ item.value | map('community.dns.quote_txt', always_quote=true) | list }}" # loop: "{{ sample_com_challenge.challenge_data_dns | dict2items }}" - name: Let the challenge be validated community.crypto.acme_certificate_order_validate: acme_directory: https://acme-v01.api.letsencrypt.org/directory acme_version: 2 account_key_src: /etc/pki/cert/private/account.key order_uri: "{{ sample_com_challenge.order_uri }}" challenge: dns-01 - name: Retrieve the cert and intermediate certificate community.crypto.acme_certificate_order_finalize: acme_directory: https://acme-v01.api.letsencrypt.org/directory acme_version: 2 account_key_src: /etc/pki/cert/private/account.key csr: /etc/pki/cert/csr/sample.com.csr order_uri: "{{ sample_com_challenge.order_uri }}" cert_dest: /etc/httpd/ssl/sample.com.crt fullchain_dest: /etc/httpd/ssl/sample.com-fullchain.crt chain_dest: /etc/httpd/ssl/sample.com-intermediate.crt ''' RETURN = ''' account_uri: description: ACME account URI. returned: success type: str all_chains: description: - When O(retrieve_all_alternates=true), the module will query the ACME server for alternate chains. This return value will contain a list of all chains returned, the first entry being the main chain returned by the server. - See L(Section 7.4.2 of RFC8555,https://tools.ietf.org/html/rfc8555#section-7.4.2) for details. returned: success and O(retrieve_all_alternates=true) type: list elements: dict contains: cert: description: - The leaf certificate itself, in PEM format. type: str returned: always chain: description: - The certificate chain, excluding the root, as concatenated PEM certificates. type: str returned: always full_chain: description: - The certificate chain, excluding the root, but including the leaf certificate, as concatenated PEM certificates. type: str returned: always selected_chain: description: - The selected certificate chain. - If O(select_chain) is not specified, this will be the main chain returned by the ACME server. returned: success type: dict contains: cert: description: - The leaf certificate itself, in PEM format. type: str returned: always chain: description: - The certificate chain, excluding the root, as concatenated PEM certificates. type: str returned: always full_chain: description: - The certificate chain, excluding the root, but including the leaf certificate, as concatenated PEM certificates. type: str returned: always ''' from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( create_backend, create_default_argspec, ) from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( ModuleFailException, ) from ansible_collections.community.crypto.plugins.module_utils.acme.certificate import ( ACMECertificateClient, ) def main(): argument_spec = create_default_argspec(with_certificate=True) argument_spec.update_argspec( order_uri=dict(type='str', required=True), cert_dest=dict(type='path'), fullchain_dest=dict(type='path'), chain_dest=dict(type='path'), deactivate_authzs=dict(type='str', default='always', choices=['never', 'always', 'on_error', 'on_success']), retrieve_all_alternates=dict(type='bool', default=False), select_chain=dict(type='list', elements='dict', options=dict( test_certificates=dict(type='str', default='all', choices=['first', 'last', 'all']), issuer=dict(type='dict'), subject=dict(type='dict'), subject_key_identifier=dict(type='str'), authority_key_identifier=dict(type='str'), )), ) module = argument_spec.create_ansible_module() if module.params['acme_version'] == 1: module.fail_json('The module does not support acme_version=1') backend = create_backend(module, False) try: client = ACMECertificateClient(module, backend) select_chain_matcher = client.parse_select_chain(module.params['select_chain']) other = dict() done = False order = None try: # Step 1: load order order = client.load_order() download_all_chains = len(select_chain_matcher) > 0 or module.params['retrieve_all_alternates'] changed = False if order.status == 'valid': # Step 2 and 3: download certificate(s) and chain(s) cert, alternate_chains = client.download_certificate( order, download_all_chains=download_all_chains, ) else: client.check_that_authorizations_can_be_used(order) # Step 2: wait for authorizations to validate pending_authzs = client.collect_pending_authzs(order) client.wait_for_validation(pending_authzs) # Step 3: finalize order, wait, then download certificate(s) and chain(s) cert, alternate_chains = client.get_certificate( order, download_all_chains=download_all_chains, ) changed = True # Step 4: pick chain, write certificates, and provide return values if alternate_chains is not None: # Prepare return value for all alternate chains if module.params['retrieve_all_alternates']: all_chains = [cert.to_json()] for alt_chain in alternate_chains: all_chains.append(alt_chain.to_json()) other['all_chains'] = all_chains # Try to select alternate chain depending on criteria if select_chain_matcher: matching_chain = client.find_matching_chain([cert] + alternate_chains, select_chain_matcher) if matching_chain: cert = matching_chain else: module.debug('Found no matching alternative chain') if client.write_cert_chain( cert, cert_dest=module.params['cert_dest'], fullchain_dest=module.params['fullchain_dest'], chain_dest=module.params['chain_dest'], ): changed = True done = True finally: if ( module.params['deactivate_authzs'] == 'always' or (module.params['deactivate_authzs'] == 'on_success' and done) or (module.params['deactivate_authzs'] == 'on_error' and not done) ): if order: client.deactivate_authzs(order) module.exit_json( changed=changed, account_uri=client.client.account_uri, selected_chain=cert.to_json(), **other ) except ModuleFailException as e: e.do_fail(module) if __name__ == '__main__': main()