#!/usr/bin/python # 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 annotations 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/). 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: 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. - Support for V(dns-account-01) and V(dns-persist-01) has been added in community.crypto 3.2.0. type: str choices: - http-01 - dns-01 - dns-account-01 - dns-persist-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 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 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 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 """ import typing as t 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.certificate import ( ACMECertificateClient, ) from ansible_collections.community.crypto.plugins.module_utils._acme.errors import ( ModuleFailException, ) if t.TYPE_CHECKING: from ansible_collections.community.crypto.plugins.module_utils._acme.challenges import ( # pragma: no cover Authorization, ) def main() -> t.NoReturn: argument_spec = create_default_argspec(with_certificate=False) argument_spec.update_argspec( order_uri={"type": "str", "required": True}, challenge={ "type": "str", "choices": [ "http-01", "dns-01", "dns-account-01", "dns-persist-01", "tls-alpn-01", ], }, deactivate_authzs={"type": "bool", "default": True}, ) module = argument_spec.create_ansible_module() backend = create_backend(module, needs_acme_v2=False) try: client = ACMECertificateClient(module=module, backend=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: missing_challenge_authzs_str = ", ".join( sorted(missing_challenge_authzs) ) raise ModuleFailException( "The challenge parameter must be supplied if there are pending authorizations." f" The following authorizations are pending: {missing_challenge_authzs_str}" ) bad_challenge_authzs = [ authz.combined_identifier for authz in pending_authzs if authz.find_challenge( challenge_type=challenges[authz.combined_identifier] ) is None ] if bad_challenge_authzs: authz_challenges_pairs = ", ".join( sorted( f"{authz} with {challenges[authz]}" for authz in bad_challenge_authzs ) ) raise ModuleFailException( f"The following authorizations do not support the selected challenges: {authz_challenges_pairs}" ) def is_pending(authz: Authorization) -> bool: challenge_name = challenges[authz.combined_identifier] challenge_obj = authz.find_challenge(challenge_type=challenge_name) return challenge_obj is not None and challenge_obj.status == "pending" really_pending_authzs = [ authz for authz in pending_authzs if is_pending(authz) ] # 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=[ { "identifier": authz.identifier, "identifier_type": authz.identifier_type, "authz_url": authz.url, "challenge_type": challenge_type, "challenge_url": challenge.url if challenge else None, } for authz, challenge_type, challenge in authzs_with_challenges_to_wait_for ], ) except ModuleFailException as e: e.do_fail(module=module) if __name__ == "__main__": main()