#!/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_validate author: Felix Fontein (@felixfontein) version_added: 2.24.0 short_description: Validate authorizations of an ACME v2 order description: - Validates pending authorizations of an ACME v2 order. This is the second to last 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_finalize) modules. seealso: - module: community.crypto.acme_certificate_order_create description: Create an ACME order. - module: community.crypto.acme_certificate_order_finalize description: Finalize an ACME order after satisfying the challenges. - 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 - name: ACME TLS ALPN Challenge Extension description: The specification of the V(tls-alpn-01) challenge (RFC 8737). link: https://www.rfc-editor.org/rfc/rfc8737.html - module: community.crypto.acme_challenge_cert_helper description: Helps preparing V(tls-alpn-01) challenges. - 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.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: challenge: description: - The challenge to be performed for every pending authorization. - Must be provided if there is at least one pending authorization. - In case of authorization reuse, or in case of CAs which use External Account Binding and other means of validating certificate assurance, it might not be necessary to provide this option. type: str choices: - 'http-01' - 'dns-01' - 'tls-alpn-01' order_uri: description: - The order URI provided by RV(community.crypto.acme_certificate_order_create#module:order_uri). type: str required: true deactivate_authzs: description: - "Deactivate authentication objects (authz) in case an error happens." - "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: bool default: true ''' 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 validating_challenges: description: List of challenges whose validation was triggered. returned: success type: list elements: dict contains: identifier: description: - The identifier the challenge is for. type: str returned: always identifier_type: description: - The identifier's type for the challenge. type: str returned: always choices: - dns - ip authz_url: description: - The URL of the authorization object for this challenge. type: str returned: always challenge_type: description: - The challenge's type. type: str returned: always choices: - http-01 - dns-01 - tls-alpn-01 challenge_url: description: - The URL of the challenge object. 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=False) argument_spec.update_argspec( order_uri=dict(type='str', required=True), challenge=dict(type='str', choices=['http-01', 'dns-01', 'tls-alpn-01']), deactivate_authzs=dict(type='bool', default=True), ) 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) done = False order = None try: # Step 1: load order order = client.load_order() client.check_that_authorizations_can_be_used(order) # Step 2: find all pending authorizations pending_authzs = client.collect_pending_authzs(order) # Step 3: figure out challenges to use challenges = {} for authz in pending_authzs: challenges[authz.combined_identifier] = module.params['challenge'] missing_challenge_authzs = [k for k, v in challenges.items() if v is None] if missing_challenge_authzs: raise ModuleFailException( 'The challenge parameter must be supplied if there are pending authorizations.' ' The following authorizations are pending: {missing_challenge_authzs}'.format( missing_challenge_authzs=', '.join(sorted(missing_challenge_authzs)), ) ) bad_challenge_authzs = [ authz.combined_identifier for authz in pending_authzs if authz.find_challenge(challenges[authz.combined_identifier]) is None ] if bad_challenge_authzs: raise ModuleFailException( 'The following authorizations do not support the selected challenges: {authz_challenges_pairs}'.format( authz_challenges_pairs=', '.join(sorted( '{authz} with {challenge}'.format(authz=authz, challenge=challenges[authz]) for authz in bad_challenge_authzs )), ) ) really_pending_authzs = [ authz for authz in pending_authzs if authz.find_challenge(challenges[authz.combined_identifier]).status == 'pending' ] # Step 4: validate pending authorizations authzs_with_challenges_to_wait_for = client.call_validate( really_pending_authzs, get_challenge=lambda authz: challenges[authz.combined_identifier], wait=False, ) done = True finally: if order and module.params['deactivate_authzs'] and not done: client.deactivate_authzs(order) module.exit_json( changed=len(authzs_with_challenges_to_wait_for) > 0, account_uri=client.client.account_uri, validating_challenges=[ dict( identifier=authz.identifier, identifier_type=authz.identifier_type, authz_url=authz.url, challenge_type=challenge_type, challenge_url=challenge.url, ) for authz, challenge_type, challenge in authzs_with_challenges_to_wait_for ], ) except ModuleFailException as e: e.do_fail(module) if __name__ == '__main__': main()