From 4e524df2b0f3d887793676dc18f4dcda2fa6ebab Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Sun, 12 May 2024 14:58:09 +0200 Subject: [PATCH] Add new ACME modules for working with orders. --- README.md | 4 + meta/runtime.yml | 6 +- plugins/module_utils/acme/certificate.py | 264 +++++++++++ .../modules/acme_certificate_order_create.py | 393 ++++++++++++++++ .../acme_certificate_order_finalize.py | 438 ++++++++++++++++++ .../modules/acme_certificate_order_info.py | 402 ++++++++++++++++ .../acme_certificate_order_validate.py | 290 ++++++++++++ .../targets/acme_certificate_order/aliases | 16 + .../acme_certificate_order/meta/main.yml | 9 + .../acme_certificate_order/tasks/impl.yml | 340 ++++++++++++++ .../acme_certificate_order/tasks/main.yml | 36 ++ 11 files changed, 2197 insertions(+), 1 deletion(-) create mode 100644 plugins/module_utils/acme/certificate.py create mode 100644 plugins/modules/acme_certificate_order_create.py create mode 100644 plugins/modules/acme_certificate_order_finalize.py create mode 100644 plugins/modules/acme_certificate_order_info.py create mode 100644 plugins/modules/acme_certificate_order_validate.py create mode 100644 tests/integration/targets/acme_certificate_order/aliases create mode 100644 tests/integration/targets/acme_certificate_order/meta/main.yml create mode 100644 tests/integration/targets/acme_certificate_order/tasks/impl.yml create mode 100644 tests/integration/targets/acme_certificate_order/tasks/main.yml diff --git a/README.md b/README.md index 7e8136227..5b60cc87c 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,10 @@ If you use the Ansible package and do not update collections independently, use - acme_ari_info module - acme_certificate module - acme_certificate_deactivate_authz module + - acme_certificate_order_create module + - acme_certificate_order_finalize module + - acme_certificate_order_info module + - acme_certificate_order_validate module - acme_certificate_revoke module - acme_challenge_cert_helper module - acme_inspect module diff --git a/meta/runtime.yml b/meta/runtime.yml index 9c7592dc7..b1f1800a8 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -8,9 +8,13 @@ requires_ansible: '>=2.9.10' action_groups: acme: - acme_inspect + - acme_certificate - acme_certificate_deactivate_authz + - acme_certificate_order_create + - acme_certificate_order_finalize + - acme_certificate_order_info + - acme_certificate_order_validate - acme_certificate_revoke - - acme_certificate - acme_account - acme_account_info diff --git a/plugins/module_utils/acme/certificate.py b/plugins/module_utils/acme/certificate.py new file mode 100644 index 000000000..c50ba5001 --- /dev/null +++ b/plugins/module_utils/acme/certificate.py @@ -0,0 +1,264 @@ +# -*- 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 + + +import os + +from ansible_collections.community.crypto.plugins.module_utils.argspec import ( + ArgumentSpec, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( + ACMEClient, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.account import ( + ACMEAccount, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.challenges import ( + combine_identifier, + split_identifier, + wait_for_validation, + Authorization, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.certificates import ( + CertificateChain, + Criterium, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + ModuleFailException, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.orders import ( + Order, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.io import ( + write_file, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( + pem_to_der, +) + + +class ACMECertificateClient(object): + ''' + ACME v2 client class. Uses an ACME account object and a CSR to + start and validate ACME challenges and download the respective + certificates. + ''' + + def __init__(self, module, backend, client=None, account=None): + self.module = module + self.version = module.params['acme_version'] + self.csr = module.params.get('csr') + self.csr_content = module.params.get('csr_content') + if client is None: + client = ACMEClient(module, backend) + self.client = client + if account is None: + account = ACMEAccount(self.client) + self.account = account + self.order_uri = module.params.get('order_uri') + + # Make sure account exists + dummy, account_data = self.account.setup_account(allow_creation=False) + if account_data is None: + raise ModuleFailException(msg='Account does not exist or is deactivated.') + + if self.csr is not None and not os.path.exists(self.csr): + raise ModuleFailException("CSR %s not found" % (self.csr)) + + # Extract list of identifiers from CSR + if self.csr is not None or self.csr_content is not None: + self.identifiers = self.client.backend.get_ordered_csr_identifiers(csr_filename=self.csr, csr_content=self.csr_content) + else: + self.identifiers = None + + def parse_select_chain(self, select_chain): + select_chain_matcher = [] + if select_chain: + for criterium_idx, criterium in enumerate(select_chain): + try: + select_chain_matcher.append( + self.client.backend.create_chain_matcher(Criterium(criterium, index=criterium_idx))) + except ValueError as exc: + self.module.warn('Error while parsing criterium: {error}. Ignoring criterium.'.format(error=exc)) + return select_chain_matcher + + def load_order(self): + if not self.order_uri: + raise ModuleFailException('The order URI has not been provided') + order = Order.from_url(self.client, self.order_uri) + order.load_authorizations(self.client) + return order + + def create_order(self, replace_cert_id=None): + ''' + Create a new order. + ''' + if self.identifiers is None: + raise ModuleFailException('No identifiers have been provided') + order = Order.create(self.client, self.identifiers, replace_cert_id) + self.order_uri = order.url + order.load_authorizations(self.client) + return order + + def get_challenges_data(self, order): + ''' + Get challenge details. + + Return a tuple of generic challenge details, and specialized DNS challenge details. + ''' + # Get general challenge data + data = [] + for authz in order.authorizations.values(): + # Skip valid authentications: their challenges are already valid + # and do not need to be returned + if authz.status == 'valid': + continue + data.append(dict( + identifier=authz.identifier, + identifier_type=authz.identifier_type, + challenges=authz.get_challenge_data(self.client), + )) + # Get DNS challenge data + data_dns = {} + dns_challenge = 'dns-01' + for entry in data: + dns_challenge = entry['challenges'].get(dns_challenge) + if dns_challenge: + values = data_dns.get(dns_challenge['record']) + if values is None: + values = [] + data_dns[dns_challenge['record']] = values + values.append(dns_challenge['resource_value']) + return data, data_dns + + def check_that_authorizations_can_be_used(self, order): + bad_authzs = [] + for authz in order.authorizations.values(): + if authz.status not in ('valid', 'pending'): + bad_authzs.append('{authz} (status={status!r})'.format( + authz=authz.combined_identifier, + status=authz.status, + )) + if bad_authzs: + raise ModuleFailException( + 'Some of the authorizations for the order are in a bad state, so the order' + ' can no longer be satisfied: {bad_authzs}'.format( + bad_authzs=', '.join(sorted(bad_authzs)), + ), + ) + + def collect_invalid_authzs(self, order): + return [authz for authz in order.authorizations.values() if authz.status == 'invalid'] + + def collect_pending_authzs(self, order): + return [authz for authz in order.authorizations.values() if authz.status == 'pending'] + + def call_validate(self, pending_authzs, get_challenge, wait=True): + authzs_to_wait_for = [] + for authz in pending_authzs: + authz.call_validate(self.client, get_challenge(authz), wait=wait) + authzs_to_wait_for.append(authz) + return authzs_to_wait_for + + def wait_for_validation(self, authzs_to_wait_for): + wait_for_validation(authzs_to_wait_for, self.client) + + def _download_alternate_chains(self, cert): + alternate_chains = [] + for alternate in cert.alternates: + try: + alt_cert = CertificateChain.download(self.client, alternate) + except ModuleFailException as e: + self.module.warn('Error while downloading alternative certificate {0}: {1}'.format(alternate, e)) + continue + if alt_cert.cert is not None: + alternate_chains.append(alt_cert) + else: + self.module.warn('Error while downloading alternative certificate {0}: no certificate found'.format(alternate)) + return alternate_chains + + def download_certificate(self, order, download_all_chains=True): + ''' + Download certificate from a valid oder. + ''' + if order.status != 'valid': + raise ModuleFailException('The order must be valid, but has state {state!r}!'.format(url=order.state)) + + if not order.certificate_uri: + raise ModuleFailException("Order's crtificate URL {url!r} is empty!".format(url=order.certificate_uri)) + + cert = CertificateChain.download(self.client, order.certificate_uri) + if cert.cert is None: + raise ModuleFailException('Certificate at {url} is empty!'.format(url=order.certificate_uri)) + + alternate_chains = None + if download_all_chains: + alternate_chains = self._download_alternate_chains(cert) + + return cert, alternate_chains + + def get_certificate(self, order, download_all_chains=True): + ''' + Request a new certificate and downloads it, and optionally all certificate chains. + First verifies whether all authorizations are valid; if not, aborts with an error. + ''' + if self.csr is None and self.csr_content is None: + raise ModuleFailException('No CSR has been provided') + for identifier, authz in order.authorizations.items(): + if authz.status != 'valid': + authz.raise_error('Status is {status!r} and not "valid"'.format(status=authz.status), module=self.module) + + order.finalize(self.client, pem_to_der(self.csr, self.csr_content)) + + return self.download_certificate(order, download_all_chains=download_all_chains) + + def find_matching_chain(self, chains, select_chain_matcher): + for criterium_idx, matcher in enumerate(select_chain_matcher): + for chain in chains: + if matcher.match(chain): + self.module.debug('Found matching chain for criterium {0}'.format(criterium_idx)) + return chain + return None + + def write_cert_chain(self, cert, cert_dest=None, fullchain_dest=None, chain_dest=None): + changed = False + + if cert_dest and write_file(self.module, cert_dest, cert.cert.encode('utf8')): + changed = True + + if fullchain_dest and write_file(self.module, fullchain_dest, (cert.cert + "\n".join(cert.chain)).encode('utf8')): + changed = True + + if chain_dest and write_file(self.module, chain_dest, ("\n".join(cert.chain)).encode('utf8')): + changed = True + + return changed + + def deactivate_authzs(self, order): + ''' + Deactivates all valid authz's. Does not raise exceptions. + https://community.letsencrypt.org/t/authorization-deactivation/19860/2 + https://tools.ietf.org/html/rfc8555#section-7.5.2 + ''' + for authz in order.authorizations.values(): + try: + authz.deactivate(self.client) + except Exception: + # ignore errors + pass + if authz.status != 'deactivated': + self.module.warn(warning='Could not deactivate authz object {0}.'.format(authz.url)) diff --git a/plugins/modules/acme_certificate_order_create.py b/plugins/modules/acme_certificate_order_create.py new file mode 100644 index 000000000..f1282e5ee --- /dev/null +++ b/plugins/modules/acme_certificate_order_create.py @@ -0,0 +1,393 @@ +#!/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_create +author: Felix Fontein (@felixfontein) +short_description: Create an ACME v2 order +description: + - Creates an ACME v2 order. This is the first 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. + - The current implementation supports the V(http-01), V(dns-01) and V(tls-alpn-01) + challenges. + - This module needs to be used in conjunction with the + M(community.crypto.acme_certificate_order_validate) and. + M(community.crypto.acme_certificate_order_finalize) module. + An order can be effectively deactivated with the + M(community.crypto.acme_certificate_deactivate_authz) module. + Note that both modules require the output RV(order_uri) of this module. + - To create or modify ACME accounts, use the M(community.crypto.acme_account) module. + This module will I(not) create or update ACME accounts. + - Between the call of this module and M(community.crypto.acme_certificate_order_finalize), + you have to fulfill the required steps for the chosen challenge by whatever means necessary. + For V(http-01) that means creating the necessary challenge file on the destination webserver. + For V(dns-01) the necessary dns record has to be created. For V(tls-alpn-01) the necessary + certificate has to be created and served. It is I(not) the responsibility of this module to + perform these steps. + - For details on how to fulfill these challenges, you might have to read through + L(the main ACME specification,https://tools.ietf.org/html/rfc8555#section-8) + and the L(TLS-ALPN-01 specification,https://www.rfc-editor.org/rfc/rfc8737.html#section-3). + Also, consider the examples provided for this module. + - The module includes support for IP identifiers according to + the L(RFC 8738,https://www.rfc-editor.org/rfc/rfc8738.html) ACME extension. +seealso: + - module: community.crypto.acme_certificate_order_validate + description: Validate pending authorizations of 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. + - module: community.crypto.acme_certificate_deactivate_authz + description: Deactivate all authorizations (authz) of an ACME order, effectively deactivating + the order itself. + - module: community.crypto.acme_certificate_renewal_info + description: Determine whether a certificate should be renewed. + - 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.openssl_privatekey + description: Can be used to create private keys (both for certificates and accounts). + - module: community.crypto.openssl_privatekey_pipe + description: Can be used to create private keys without writing it to disk (both for certificates and accounts). + - module: community.crypto.openssl_csr + description: Can be used to create a Certificate Signing Request (CSR). + - module: community.crypto.openssl_csr_pipe + description: Can be used to create a Certificate Signing Request (CSR) without writing it to disk. + - module: community.crypto.acme_account + description: Allows to create, modify or delete an ACME account. + - module: community.crypto.acme_inspect + description: Allows to debug problems. +extends_documentation_fragment: + - community.crypto.acme.basic + - community.crypto.acme.account + - community.crypto.acme.certificate + - community.crypto.attributes + - community.crypto.attributes.actiongroup_acme +attributes: + check_mode: + support: none + diff_mode: + support: none +options: + deactivate_authzs: + description: + - "Deactivate authentication objects (authz) when issuing the certificate + failed." + - "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 + replace_cert_id: + description: + - If provided, will request the order to replace the certificate identified by this certificate ID + according to L(the ACME ARI draft 3, https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html#section-5). + - This certificate ID must be computed as specified in + L(the ACME ARI draft 3, https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html#section-4.1). + It is returned as RV(community.crypto.acme_certificate_renewal_info#module:cert_id) of the + M(community.crypto.acme_certificate_renewal_info) module. + - ACME servers might refuse to create new orders that indicate to replace a certificate for which + an active replacement order already exists. This can happen if this module is used to create an order, + and then the playbook/role fails in case the challenges cannot be set up. If the playbook/role does not + record the order data to continue with the existing order, but tries to create a new one on the next run, + creating the new order might fail. For this reason, this option should only be used if the role/playbook + using it keeps track of order data accross restarts, or if it takes care to deactivate orders whose + processing is aborted. Orders can be deactivated with the + M(community.crypto.acme_certificate_deactivate_authz) module. + 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 = ''' +challenge_data: + description: + - For every identifier, provides the challenge information. + - Only challenges which are not yet valid are returned. + returned: changed + type: list + elements: dict + contains: + identifier: + description: + - The identifier for this challenge. + type: str + sample: example.com + identifier_type: + description: + - The identifier's type. + - V(dns) for DNS names, and V(ip) for IP addresses. + type: str + choices: + - dns + - ip + sample: dns + challenges: + description: + - Information for different challenge types supported for this identifier. + type: dict + contains: + http-01: + description: + - Information for V(http-01) authorization. + - The server needs to make the path RV(challenge_data[].challenges.http-01.resource) + accessible via HTTP (which might redirect to HTTPS). A C(GET) operation to this path + needs to provide the value from RV(challenge_data[].challenges.http-01.resource_value). + returned: if the identifier supports V(http-01) authorization + type: dict + contains: + resource: + description: + - The path the value has to be provided under. + returned: success + type: str + sample: .well-known/acme-challenge/evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA + resource_value: + description: + - The value the resource has to produce for the validation. + returned: success + type: str + sample: IlirfxKKXA...17Dt3juxGJ-PCt92wr-oA + dns-01: + description: + - Information for V(dns-01) authorization. + - A DNS TXT record needs to be created with the record name RV(challenge_data[].challenges.dns-01.record) + and value RV(challenge_data[].challenges.dns-01.resource_value). + returned: if the identifier supports V(dns-01) authorization + type: dict + contains: + resource: + description: + - Always contains the string V(_acme-challenge). + type: str + sample: _acme-challenge + resource_value: + description: + - The value the resource has to produce for the validation. + returned: success + type: str + sample: IlirfxKKXA...17Dt3juxGJ-PCt92wr-oA + record: + description: The full DNS record's name for the challenge. + returned: success + type: str + sample: _acme-challenge.example.com + tls-alpn-01: + description: + - Information for V(tls-alpn-01) authorization. + - A certificate needs to be created for the DNS name RV(challenge_data[].challenges.tls-alpn-01.resource) + with acmeValidation X.509 extension of value RV(challenge_data[].challenges.tls-alpn-01.resource_value). + This certificate needs to be served when the application-layer protocol C(acme-tls/1) is negotiated for + a HTTPS connection to port 443 with the SNI extension for the domain name + (RV(challenge_data[].challenges.tls-alpn-01.resource_original)) being validated. + - See U(https://www.rfc-editor.org/rfc/rfc8737.html#section-3) for details. + returned: if the identifier supports V(tls-alpn-01) authorization + type: dict + contains: + resource: + description: + - The DNS name for DNS identifiers, and the reverse DNS mapping (RFC1034, RFC3596) for IP addresses. + returned: success + type: str + sample: example.com + resource_original: + description: + - The original identifier including type identifier. + returned: success + type: str + sample: dns:example.com + resource_value: + description: + - The value the resource has to produce for the validation. + - "B(Note:) this return value contains a Base64 encoded version of the correct + binary blob which has to be put into the acmeValidation X.509 extension; see + U(https://www.rfc-editor.org/rfc/rfc8737.html#section-3) for details. To do this, + you might need the P(ansible.builtin.b64decode#filter) Jinja filter to extract + the binary blob from this return value." + returned: success + type: str + sample: AAb= +challenge_data_dns: + description: + - List of TXT values per DNS record for V(dns-01) challenges. + - Only challenges which are not yet valid are returned. + returned: success + type: dict +order_uri: + description: ACME order URI. + returned: success + type: str +account_uri: + description: ACME account URI. + returned: success + type: str +''' + +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( + deactivate_authzs=dict(type='bool', default=True), + replace_cert_id=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) + order = None + done = False + try: + order = client.create_order(replace_cert_id=module.params['replace_cert_id']) + client.check_that_authorizations_can_be_used(order) + done = True + finally: + if module.params['deactivate_authzs'] and order and not done: + client.deactivate_authzs(order) + data, data_dns = client.get_challenges_data(order) + module.exit_json( + changed=True, + order_uri=order.url, + account_uri=client.client.account_uri, + challenge_data=data, + challenge_data_dns=data_dns, + ) + except ModuleFailException as e: + e.do_fail(module) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/acme_certificate_order_finalize.py b/plugins/modules/acme_certificate_order_finalize.py new file mode 100644 index 000000000..c005a126e --- /dev/null +++ b/plugins/modules/acme_certificate_order_finalize.py @@ -0,0 +1,438 @@ +#!/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) +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.files + - community.crypto.attributes.actiongroup_acme +attributes: + check_mode: + support: none + diff_mode: + support: none + safe_file_operations: + 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 +''' + +import os + +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() diff --git a/plugins/modules/acme_certificate_order_info.py b/plugins/modules/acme_certificate_order_info.py new file mode 100644 index 000000000..baec12598 --- /dev/null +++ b/plugins/modules/acme_certificate_order_info.py @@ -0,0 +1,402 @@ +#!/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_info +author: Felix Fontein (@felixfontein) +short_description: Obtain information for an ACME v2 order +description: + - Obtain information for an ACME v2 order. + This can be used during the process 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), + M(community.crypto.acme_certificate_order_validate), 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_validate + description: Validate pending authorizations of an ACME order. + - module: community.crypto.acme_certificate_order_finalize + description: Finalize an ACME order after satisfying the challenges. + - 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_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.info_module + - community.crypto.attributes.actiongroup_acme +options: + order_uri: + description: + - The order URI provided by RV(community.crypto.acme_certificate_order_create#module:order_uri). + type: str + required: true +''' + +EXAMPLES = r''' +- 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: order + +- name: Obtain information on the order + community.crypto.acme_certificate_order_info: + account_key_src: /etc/pki/cert/private/account.key + order_uri: "{{ order.order_uri }}" + register: order_info + +- name: Show information + ansible.builtin.debug: + var: order_info +''' + +RETURN = ''' +account_uri: + description: ACME account URI. + returned: success + type: str +order_uri: + description: ACME order URI. + returned: success + type: str +order: + description: + - The order object. + - See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.3) for its specification. + returned: success + type: dict + contains: + status: + description: + - The status of this order. + - See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6) for state changes. + type: str + returned: always + choices: + - pending + - ready + - processing + - valid + - invalid + expires: + description: + - The timestamp after which the server will consider this order invalid. + - Encoded in the format specified in L(RFC 3339, https://www.rfc-editor.org/rfc/rfc3339). + type: str + returned: if RV(order.status) is V(pending) or V(valid), and sometimes in other situations + identifiers: + description: + - An array of identifier objects that the order pertains to. + returned: always + type: list + elements: dict + contains: + type: + description: + - The type of identifier. + - So far V(dns) and V(ip) are defined values. + type: str + returned: always + sample: dns + choices: + - dns + - ip + value: + description: + - The identifier itself. + type: str + returned: always + sample: example.com + notBefore: + description: + - The requested value of the C(notBefore) field in the certificate. + - Encoded in the date format defined in L(RFC 3339, https://www.rfc-editor.org/rfc/rfc3339). + type: str + returned: depending on order + notAfter (optional, string): + description: + - The requested value of the C(notAfter) field in the certificate. + - Encoded in the date format defined in L(RFC 3339, https://www.rfc-editor.org/rfc/rfc3339). + type: str + returned: depending on order + error: + description: + - The error that occurred while processing the order, if any. + - This field is structured as a L(problem document according to RFC 7807, https://www.rfc-editor.org/rfc/rfc7807). + type: dict + returned: sometimes + authorizations: + description: + - For pending orders, the authorizations that the client needs to complete before the + requested certificate can be issued, including unexpired authorizations that the client + has completed in the past for identifiers specified in the order. + - The authorizations required are dictated by server policy; there may not be a 1:1 + relationship between the order identifiers and the authorizations required. + - For final orders (in the V(valid) or V(invalid) state), the authorizations that were + completed. Each entry is a URL from which an authorization can be fetched with a POST-as-GET request. + - The authorizations themselves are returned as RV(authorizations_by_identifier). + type: list + elements: str + returned: always + finalize: + description: + - A URL that a CSR must be POSTed to once all of the order's authorizations are satisfied to finalize the + order. The result of a successful finalization will be the population of the certificate URL for the order. + type: str + returned: always + certificate: + description: + - A URL for the certificate that has been issued in response to this order. + type: str + returned: when the certificate has been issued +authorizations_by_identifier: + description: + - A dictionary mapping identifiers to their authorization objects. + returned: success + type: dict + contains: + identifier: + description: + - The keys in this dictionary are the identifiers. C(identifier) is a placeholder used in the documentation. + - See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.4) for how authorization objects look like. + type: dict + contains: + identifier: + description: + - The identifier that the account is authorized to represent. + type: dict + returned: always + contains: + type: + description: + - The type of identifier. + - So far V(dns) and V(ip) are defined values. + type: str + returned: always + sample: dns + choices: + - dns + - ip + value: + description: + - The identifier itself. + type: str + returned: always + sample: example.com + status: + description: + - The status of this authorization. + - See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6) for state changes. + type: str + choices: + - pending + - valid + - invalid + - deactivated + - expired + - revoked + returned: always + expires: + description: + - The timestamp after which the server will consider this authorization invalid. + - Encoded in the format specified in L(RFC 3339, https://www.rfc-editor.org/rfc/rfc3339). + type: str + returned: if RV(authorizations_by_identifier.identifier.status=valid), and sometimes in other situations + challenges: + description: + - For pending authorizations, the challenges that the client can fulfill in order to prove + possession of the identifier. + - For valid authorizations, the challenge that was validated. + - For invalid authorizations, the challenge that was attempted and failed. + - Each array entry is an object with parameters required to validate the challenge. + A client should attempt to fulfill one of these challenges, and a server should consider + any one of the challenges sufficient to make the authorization valid. + - See U(https://www.rfc-editor.org/rfc/rfc8555#section-8) for the general structure. The structure + of every entry depends on the challenge's type. For C(tls-alpn-01) challenges, the structure is + defined in U(https://www.rfc-editor.org/rfc/rfc8737.html#section-3). + type: list + elements: dict + returned: always + contains: + type: + description: + - The type of challenge encoded in the object. + type: str + returned: always + choices: + - http-01 + - dns-01 + - tls-alpn-01 + url: + description: + - The URL to which a response can be posted. + type: str + returned: always + status: + description: + - The status of this challenge. + - See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6) for state changes. + type: str + choices: + - pending + - processing + - valid + - invalid + returned: always + validated: + description: + - The time at which the server validated this challenge. + - Encoded in the format specified in L(RFC 3339, https://www.rfc-editor.org/rfc/rfc3339). + type: str + returned: always if RV(authorizations_by_identifier.identifier.challenges[].type=valid), otherwise in some situations + error: + description: + - Error that occurred while the server was validating the challenge, if any. + - This field is structured as a L(problem document according to RFC 7807, https://www.rfc-editor.org/rfc/rfc7807). + type: dict + returned: always if RV(authorizations_by_identifier.identifier.challenges[].type=invalid), otherwise in some situations + wildcard: + description: + - This field B(must) be present and true for authorizations created as a result of a + C(newOrder) request containing a DNS identifier with a value that was a wildcard + domain name. For other authorizations, it B(must) be absent. + - Wildcard domain names are described in U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.3) + of the ACME specification. + type: bool + returned: sometimes +authorizations_by_status: + description: + - For every status, a list of identifiers whose authorizations have this status. + returned: success + type: dict + contains: + pending: + description: + - A list of all identifiers whose authorizations are in the C(pending) state. + - See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6) for state changes + of authorizations. + type: list + elements: str + returned: always + invalid: + description: + - A list of all identifiers whose authorizations are in the C(invalid) state. + - See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6) for state changes + of authorizations. + type: list + elements: str + returned: always + valid: + description: + - A list of all identifiers whose authorizations are in the C(valid) state. + - See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6) for state changes + of authorizations. + type: list + elements: str + returned: always + revoked: + description: + - A list of all identifiers whose authorizations are in the C(revoked) state. + - See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6) for state changes + of authorizations. + type: list + elements: str + returned: always + deactivated: + description: + - A list of all identifiers whose authorizations are in the C(deactivated) state. + - See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6) for state changes + of authorizations. + type: list + elements: str + returned: always + expired: + description: + - A list of all identifiers whose authorizations are in the C(expired) state. + - See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6) for state changes + of authorizations. + type: list + elements: str + returned: always +''' + +import os + +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), + ) + module = argument_spec.create_ansible_module(supports_check_mode=True) + 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) + order = client.load_order() + authorizations_by_identifier = dict() + authorizations_by_status = { + 'pending': [], + 'invalid': [], + 'valid': [], + 'revoked': [], + 'deactivated': [], + 'expired': [], + } + for identifier, authz in order.authorizations.items(): + authorizations_by_identifier[identifier] = authz.to_json() + authorizations_by_status[authz.status].append(identifier) + module.exit_json( + changed=False, + account_uri=client.client.account_uri, + order_uri=order.url, + order=order.data, + authorizations_by_identifier=authorizations_by_identifier, + authorizations_by_status=authorizations_by_status, + ) + except ModuleFailException as e: + e.do_fail(module) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/acme_certificate_order_validate.py b/plugins/modules/acme_certificate_order_validate.py new file mode 100644 index 000000000..d7c265b5b --- /dev/null +++ b/plugins/modules/acme_certificate_order_validate.py @@ -0,0 +1,290 @@ +#!/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) +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.files + - community.crypto.attributes.actiongroup_acme +attributes: + check_mode: + support: none + diff_mode: + support: none + safe_file_operations: + 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 +''' + +import os + +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_authz + )), + ) + ) + + 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_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_to_wait_for) > 0, + account_uri=client.client.account_uri, + ) + except ModuleFailException as e: + e.do_fail(module) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/acme_certificate_order/aliases b/tests/integration/targets/acme_certificate_order/aliases new file mode 100644 index 000000000..2d874fe6d --- /dev/null +++ b/tests/integration/targets/acme_certificate_order/aliases @@ -0,0 +1,16 @@ +# Copyright (c) Ansible Project +# 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 + +# This test tests the following four modules: +acme_certificate_order_create +acme_certificate_order_finalize +acme_certificate_order_info +acme_certificate_order_validate + +azp/generic/1 +azp/posix/1 +cloud/acme + +# For some reason connecting to helper containers does not work on the Alpine VMs +skip/alpine diff --git a/tests/integration/targets/acme_certificate_order/meta/main.yml b/tests/integration/targets/acme_certificate_order/meta/main.yml new file mode 100644 index 000000000..84b7f3f97 --- /dev/null +++ b/tests/integration/targets/acme_certificate_order/meta/main.yml @@ -0,0 +1,9 @@ +--- +# Copyright (c) Ansible Project +# 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 + +dependencies: + - setup_acme + - setup_remote_tmp_dir + - prepare_jinja2_compat diff --git a/tests/integration/targets/acme_certificate_order/tasks/impl.yml b/tests/integration/targets/acme_certificate_order/tasks/impl.yml new file mode 100644 index 000000000..6fac07ab3 --- /dev/null +++ b/tests/integration/targets/acme_certificate_order/tasks/impl.yml @@ -0,0 +1,340 @@ +--- +# Copyright (c) Ansible Project +# 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 + +- name: Generate account key + openssl_privatekey: + path: "{{ remote_tmp_dir }}/accountkey.pem" + type: ECC + curve: secp256r1 + force: true + +- name: Parse account keys (to ease debugging some test failures) + openssl_privatekey_info: + path: "{{ remote_tmp_dir }}/accountkey.pem" + return_private_key_data: true + +- name: Create ACME account + acme_account: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: false + account_key_src: "{{ remote_tmp_dir }}/accountkey.pem" + select_crypto_backend: "{{ select_crypto_backend }}" + terms_agreed: true + state: present + register: account + +- name: Generate certificate key + openssl_privatekey: + path: "{{ remote_tmp_dir }}/cert.key" + type: ECC + curve: secp256r1 + force: true + +- name: Generate certificate CSR + openssl_csr: + path: "{{ remote_tmp_dir }}/cert.csr" + privatekey_path: "{{ remote_tmp_dir }}/cert.key" + subject: + commonName: www.example.com + return_content: true + register: csr + +- name: Create certificate order + acme_certificate_order_create: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: false + account_key_src: "{{ remote_tmp_dir }}/accountkey.pem" + select_crypto_backend: "{{ select_crypto_backend }}" + csr: "{{ remote_tmp_dir }}/cert.csr" + register: order + +- name: Show order information + debug: + var: order + +- name: Check order + assert: + that: + - order is changed + - order.order_uri.startswith('https://' ~ acme_host ~ ':14000/') + - order.challenge_data | length == 1 + - order.challenge_data[0].identifier_type == 'dns' + - order.challenge_data[0].identifier == 'www.example.com' + - order.challenge_data[0].challenges | length >= 2 + - "'http-01' in order.challenge_data[0].challenges" + - "'dns-01' in order.challenge_data[0].challenges" + - order.challenge_data[0].challenges['http-01'].resource.startswith('.well-known/acme-challenge/') + - order.challenge_data[0].challenges['http-01'].resource_value is string + - order.challenge_data[0].challenges['dns-01'].record == '_acme-challenge.www.example.com' + - order.challenge_data[0].challenges['dns-01'].resource == '_acme-challenge' + - order.challenge_data[0].challenges['dns-01'].resource_value is string + - order.challenge_data_dns | length == 1 + - order.challenge_data_dns['_acme-challenge.www.example.com'] | length == 1 + - order.account_uri == account.account_uri + +- name: Get order information + acme_certificate_order_info: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: false + account_key_src: "{{ remote_tmp_dir }}/accountkey.pem" + select_crypto_backend: "{{ select_crypto_backend }}" + order_uri: "{{ order.order_uri }}" + register: order_info_1 + +- name: Show order information + debug: + var: order_info_1 + +- name: Check order information + assert: + that: + - order_info_1 is not changed + - order_info_1.authorizations_by_identifier | length == 1 + - order_info_1.authorizations_by_identifier['dns:www.example.com'].identifier.type == 'dns' + - order_info_1.authorizations_by_identifier['dns:www.example.com'].identifier.value == 'www.example.com' + - order_info_1.authorizations_by_identifier['dns:www.example.com'].status == 'pending' + - (order_info_1.authorizations_by_identifier['dns:www.example.com'].challenges | selectattr('type', 'eq', 'http-01') | first).status == 'pending' + - (order_info_1.authorizations_by_identifier['dns:www.example.com'].challenges | selectattr('type', 'eq', 'dns-01') | first).status == 'pending' + - order_info_1.authorizations_by_status['deactivated'] | length == 0 + - order_info_1.authorizations_by_status['expired'] | length == 0 + - order_info_1.authorizations_by_status['invalid'] | length == 0 + - order_info_1.authorizations_by_status['pending'] | length == 1 + - order_info_1.authorizations_by_status['pending'][0] == 'dns:www.example.com' + - order_info_1.authorizations_by_status['revoked'] | length == 0 + - order_info_1.authorizations_by_status['valid'] | length == 0 + - order_info_1.order.authorizations | length == 1 + - order_info_1.order.authorizations[0] == order_info_1.authorizations_by_identifier['dns:www.example.com'].uri + - "'certificate' not in order_info_1.order" + - order_info_1.order.status == 'pending' + - order_info_1.order_uri == order.order_uri + - order_info_1.account_uri == account.account_uri + +- name: Create HTTP challenges + uri: + url: "http://{{ acme_host }}:5000/http/{{ item.identifier }}/{{ item.challenges['http-01'].resource[('.well-known/acme-challenge/'|length):] }}" + method: PUT + body_format: raw + body: "{{ item.challenges['http-01'].resource_value }}" + headers: + content-type: "application/octet-stream" + loop: "{{ order.challenge_data }}" + when: "'http-01' in item.challenges" + +- name: Let the challenge be validated + community.crypto.acme_certificate_order_validate: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: false + account_key_src: "{{ remote_tmp_dir }}/accountkey.pem" + select_crypto_backend: "{{ select_crypto_backend }}" + order_uri: "{{ order.order_uri }}" + challenge: http-01 + register: validate_1 + +- name: Check validation result + assert: + that: + - validate_1 is changed + - validate_1.account_uri == account.account_uri + +- name: Get order information + acme_certificate_order_info: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: false + account_key_src: "{{ remote_tmp_dir }}/accountkey.pem" + select_crypto_backend: "{{ select_crypto_backend }}" + order_uri: "{{ order.order_uri }}" + register: order_info_2 + +- name: Show order information + debug: + var: order_info_2 + +- name: Check order information + assert: + that: + - order_info_2 is not changed + - order_info_2.authorizations_by_identifier | length == 1 + - order_info_2.authorizations_by_identifier['dns:www.example.com'].identifier.type == 'dns' + - order_info_2.authorizations_by_identifier['dns:www.example.com'].identifier.value == 'www.example.com' + - order_info_2.authorizations_by_identifier['dns:www.example.com'].status in ['pending', 'valid'] + - (order_info_2.authorizations_by_identifier['dns:www.example.com'].challenges | selectattr('type', 'eq', 'http-01') | first).status in ['processing', 'valid'] + - (order_info_2.authorizations_by_identifier['dns:www.example.com'].challenges | selectattr('type', 'eq', 'dns-01') | first).status == 'pending' + - order_info_2.authorizations_by_status['deactivated'] | length == 0 + - order_info_2.authorizations_by_status['expired'] | length == 0 + - order_info_2.authorizations_by_status['invalid'] | length == 0 + - order_info_2.authorizations_by_status['pending'] | length <= 1 + - order_info_2.authorizations_by_status['revoked'] | length == 0 + - order_info_2.authorizations_by_status['valid'] | length <= 1 + - (order_info_2.authorizations_by_status['pending'] | length) + (order_info_2.authorizations_by_status['valid'] | length) == 1 + - order_info_2.order.authorizations | length == 1 + - order_info_2.order.authorizations[0] == order_info_2.authorizations_by_identifier['dns:www.example.com'].uri + - "'certificate' not in order_info_2.order" + - order_info_2.order.status == 'pending' + - order_info_2.order_uri == order.order_uri + - order_info_2.account_uri == account.account_uri + +- name: Let the challenge be validated (idempotent) + community.crypto.acme_certificate_order_validate: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: false + account_key_src: "{{ remote_tmp_dir }}/accountkey.pem" + select_crypto_backend: "{{ select_crypto_backend }}" + order_uri: "{{ order.order_uri }}" + challenge: http-01 + register: validate_2 + +- name: Check validation result + assert: + that: + - validate_2 is not changed + - validate_2.account_uri == account.account_uri + +- name: Retrieve the cert and intermediate certificate + community.crypto.acme_certificate_order_finalize: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: false + account_key_src: "{{ remote_tmp_dir }}/accountkey.pem" + select_crypto_backend: "{{ select_crypto_backend }}" + order_uri: "{{ order.order_uri }}" + retrieve_all_alternates: true + csr: "{{ remote_tmp_dir }}/cert.csr" + cert_dest: "{{ remote_tmp_dir }}/cert.pem" + chain_dest: "{{ remote_tmp_dir }}/cert-chain.pem" + fullchain_dest: "{{ remote_tmp_dir }}/cert-fullchain.pem" + register: finalize_1 + +- name: Check finalization result + assert: + that: + - finalize_1 is changed + - finalize_1.account_uri == account.account_uri + - finalize_1.all_chains | length >= 1 + - finalize_1.selected_chain == finalize_1.all_chains[0] + - finalize_1.selected_chain.cert.startswith('-----BEGIN CERTIFICATE-----\nMII') + - finalize_1.selected_chain.chain.startswith('-----BEGIN CERTIFICATE-----\nMII') + - finalize_1.selected_chain.full_chain == finalize_1.selected_chain.cert + finalize_1.selected_chain.chain + +- name: Read files from disk + slurp: + src: "{{ remote_tmp_dir }}/{{ item }}.pem" + loop: + - cert + - cert-chain + - cert-fullchain + register: slurp + +- name: Compare finalization result with files on disk + assert: + that: + - finalize_1.selected_chain.cert == slurp.results[0].content | b64decode + - finalize_1.selected_chain.chain == slurp.results[1].content | b64decode + - finalize_1.selected_chain.full_chain == slurp.results[2].content | b64decode + +- name: Get order information + acme_certificate_order_info: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: false + account_key_src: "{{ remote_tmp_dir }}/accountkey.pem" + select_crypto_backend: "{{ select_crypto_backend }}" + order_uri: "{{ order.order_uri }}" + register: order_info_3 + +- name: Show order information + debug: + var: order_info_3 + +- name: Check order information + assert: + that: + - order_info_3 is not changed + - order_info_3.authorizations_by_identifier['dns:www.example.com'].identifier.type == 'dns' + - order_info_3.authorizations_by_identifier['dns:www.example.com'].identifier.value == 'www.example.com' + - order_info_3.authorizations_by_identifier['dns:www.example.com'].status == 'valid' + - (order_info_3.authorizations_by_identifier['dns:www.example.com'].challenges | selectattr('type', 'eq', 'http-01') | first).status == 'valid' + - order_info_3.authorizations_by_status['deactivated'] | length == 0 + - order_info_3.authorizations_by_status['expired'] | length == 0 + - order_info_3.authorizations_by_status['invalid'] | length == 0 + - order_info_3.authorizations_by_status['pending'] | length == 0 + - order_info_3.authorizations_by_status['revoked'] | length == 0 + - order_info_3.authorizations_by_status['valid'] | length == 1 + - order_info_3.authorizations_by_status['valid'][0] == 'dns:www.example.com' + - order_info_3.order.authorizations | length == 1 + - order_info_3.order.authorizations[0] == order_info_3.authorizations_by_identifier['dns:www.example.com'].uri + - "'certificate' in order_info_3.order" + - order_info_3.order.status == 'valid' + - order_info_3.order_uri == order.order_uri + - order_info_3.account_uri == account.account_uri + +- name: Retrieve the cert and intermediate certificate (idempotent) + community.crypto.acme_certificate_order_finalize: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: false + account_key_src: "{{ remote_tmp_dir }}/accountkey.pem" + select_crypto_backend: "{{ select_crypto_backend }}" + order_uri: "{{ order.order_uri }}" + deactivate_authzs: on_success + retrieve_all_alternates: true + csr: "{{ remote_tmp_dir }}/cert.csr" + cert_dest: "{{ remote_tmp_dir }}/cert.pem" + chain_dest: "{{ remote_tmp_dir }}/cert-chain.pem" + fullchain_dest: "{{ remote_tmp_dir }}/cert-fullchain.pem" + register: finalize_2 + +- name: Check finalization result + assert: + that: + - finalize_2 is not changed + - finalize_2.account_uri == account.account_uri + - finalize_2.all_chains | length >= 1 + - finalize_2.selected_chain == finalize_2.all_chains[0] + - finalize_2.selected_chain.cert.startswith('-----BEGIN CERTIFICATE-----\nMII') + - finalize_2.selected_chain.chain.startswith('-----BEGIN CERTIFICATE-----\nMII') + - finalize_2.selected_chain.full_chain == finalize_2.selected_chain.cert + finalize_2.selected_chain.chain + - finalize_2.selected_chain == finalize_1.selected_chain + +- name: Get order information + acme_certificate_order_info: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: false + account_key_src: "{{ remote_tmp_dir }}/accountkey.pem" + select_crypto_backend: "{{ select_crypto_backend }}" + order_uri: "{{ order.order_uri }}" + register: order_info_4 + +- name: Show order information + debug: + var: order_info_4 + +- name: Check order information + assert: + that: + - order_info_4 is not changed + - order_info_4.authorizations_by_identifier['dns:www.example.com'].identifier.type == 'dns' + - order_info_4.authorizations_by_identifier['dns:www.example.com'].identifier.value == 'www.example.com' + - order_info_4.authorizations_by_identifier['dns:www.example.com'].status == 'deactivated' + - (order_info_4.authorizations_by_identifier['dns:www.example.com'].challenges | selectattr('type', 'eq', 'http-01') | first).status == 'valid' + - order_info_4.authorizations_by_status['deactivated'] | length == 1 + - order_info_4.authorizations_by_status['deactivated'][0] == 'dns:www.example.com' + - order_info_4.authorizations_by_status['expired'] | length == 0 + - order_info_4.authorizations_by_status['invalid'] | length == 0 + - order_info_4.authorizations_by_status['pending'] | length == 0 + - order_info_4.authorizations_by_status['revoked'] | length == 0 + - order_info_4.authorizations_by_status['valid'] | length == 0 + - order_info_4.order.authorizations | length == 1 + - order_info_4.order.authorizations[0] == order_info_4.authorizations_by_identifier['dns:www.example.com'].uri + - "'certificate' in order_info_4.order" + - order_info_4.order.status == 'deactivated' + - order_info_4.order_uri == order.order_uri + - order_info_4.account_uri == account.account_uri diff --git a/tests/integration/targets/acme_certificate_order/tasks/main.yml b/tests/integration/targets/acme_certificate_order/tasks/main.yml new file mode 100644 index 000000000..32f32c5e9 --- /dev/null +++ b/tests/integration/targets/acme_certificate_order/tasks/main.yml @@ -0,0 +1,36 @@ +--- +# Copyright (c) Ansible Project +# 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 + +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +- block: + - name: Running tests with OpenSSL backend + include_tasks: impl.yml + vars: + select_crypto_backend: openssl + + # Old 0.9.8 versions have insufficient CLI support for signing with EC keys + when: openssl_version.stdout is version('1.0.0', '>=') + +- name: Remove output directory + file: + path: "{{ remote_tmp_dir }}" + state: absent + +- name: Re-create output directory + file: + path: "{{ remote_tmp_dir }}" + state: directory + +- block: + - name: Running tests with cryptography backend + include_tasks: impl.yml + vars: + select_crypto_backend: cryptography + + when: cryptography_version.stdout is version('1.5', '>=')