From 4c8c5cfb82897e27883e7cf84343c7377e167ed5 Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Tue, 30 Apr 2024 09:09:06 +0200 Subject: [PATCH 1/4] Allow to request renewal of a certificate according to ARI in acme_certificate. --- ...me_certificate-include_renewal_cert_id.yml | 2 ++ plugins/module_utils/acme/orders.py | 6 +++- plugins/modules/acme_certificate.py | 36 ++++++++++++++++++- 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 changelogs/fragments/739-acme_certificate-include_renewal_cert_id.yml diff --git a/changelogs/fragments/739-acme_certificate-include_renewal_cert_id.yml b/changelogs/fragments/739-acme_certificate-include_renewal_cert_id.yml new file mode 100644 index 000000000..9c04778ce --- /dev/null +++ b/changelogs/fragments/739-acme_certificate-include_renewal_cert_id.yml @@ -0,0 +1,2 @@ +minor_changes: + - "acme_certificate - add ``include_renewal_cert_id`` option to allow requesting renewal of a specific certificate according to the current ACME Renewal Information specification draft (https://github.com/ansible-collections/community.crypto/pull/739)." diff --git a/plugins/module_utils/acme/orders.py b/plugins/module_utils/acme/orders.py index 732b430df..98c28445f 100644 --- a/plugins/module_utils/acme/orders.py +++ b/plugins/module_utils/acme/orders.py @@ -32,6 +32,7 @@ def _setup(self, client, data): self.identifiers = [] for identifier in data['identifiers']: self.identifiers.append((identifier['type'], identifier['value'])) + self.replaces_cert_id = data.get('replaces') self.finalize_uri = data.get('finalize') self.certificate_uri = data.get('certificate') self.authorization_uris = data['authorizations'] @@ -44,6 +45,7 @@ def __init__(self, url): self.status = None self.identifiers = [] + self.replaces_cert_id = None self.finalize_uri = None self.certificate_uri = None self.authorization_uris = [] @@ -62,7 +64,7 @@ def from_url(cls, client, url): return result @classmethod - def create(cls, client, identifiers): + def create(cls, client, identifiers, replaces_cert_id=None): ''' Start a new certificate order (ACME v2 protocol). https://tools.ietf.org/html/rfc8555#section-7.4 @@ -76,6 +78,8 @@ def create(cls, client, identifiers): new_order = { "identifiers": acme_identifiers } + if replaces_cert_id is not None: + new_order["replaces"] = replaces_cert_id result, info = client.send_signed_request( client.directory['newOrder'], new_order, error_msg='Failed to start new order', expected_status_codes=[201]) return cls.from_json(client, result, info['location']) diff --git a/plugins/modules/acme_certificate.py b/plugins/modules/acme_certificate.py index 909e60a47..8fed37f62 100644 --- a/plugins/modules/acme_certificate.py +++ b/plugins/modules/acme_certificate.py @@ -293,6 +293,20 @@ - "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 + include_renewal_cert_id: + description: + - Determines whether to request renewal of an existing certificate according to + L(the ACME ARI draft 3, https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html#section-5). + - This is only used if the certificate specified in O(dest) or O(fullchain_dest) already exists. + - V(never) never sends the certificate ID of the certificate to renew. V(always) will always send it. + - V(when_ari_supported) only sends the certificate ID if the ARI endpoint is found in the ACME directory. + type: str + choices: + - never + - when_ari_supported + - always + default: never + version_added: 2.20.0 ''' EXAMPLES = r''' @@ -586,6 +600,7 @@ ) from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( + compute_cert_id, pem_to_der, ) @@ -622,6 +637,7 @@ def __init__(self, module, backend): self.order_uri = self.data.get('order_uri') if self.data else None self.all_chains = None self.select_chain_matcher = [] + self.include_renewal_cert_id = module.params['include_renewal_cert_id'] if self.module.params['select_chain']: for criterium_idx, criterium in enumerate(self.module.params['select_chain']): @@ -679,6 +695,15 @@ def is_first_step(self): # stored in self.order_uri by the constructor). return self.order_uri is None + def _get_cert_info_or_none(self): + if self.module.params.get('dest'): + filename = self.module.params['dest'] + else: + filename = self.module.params['fullchain_dest'] + if not os.path.exist(filename): + return None + return self.backend.get_cert_information(cert_filename=filename) + def start_challenges(self): ''' Create new authorizations for all identifiers of the CSR, @@ -693,7 +718,15 @@ def start_challenges(self): authz = Authorization.create(self.client, identifier_type, identifier) self.authorizations[authz.combined_identifier] = authz else: - self.order = Order.create(self.client, self.identifiers) + replaces_cert_id = None + if ( + self.include_renewal_cert_id == 'always' or + (self.include_renewal_cert_id == 'when_ari_supported' and self.client.directory.has_renewal_info_endpoint()) + ): + cert_info = self._get_cert_info_or_none() + if cert_info is not None: + replaces_cert_id = compute_cert_id(self.backend, cert_info=cert_info) + self.order = Order.create(self.client, self.identifiers, replaces_cert_id) self.order_uri = self.order.url self.order.load_authorizations(self.client) self.authorizations.update(self.order.authorizations) @@ -879,6 +912,7 @@ def main(): subject_key_identifier=dict(type='str'), authority_key_identifier=dict(type='str'), )), + include_renewal_cert_id=dict(type='str', choices=['never', 'when_ari_supported', 'always'], default='never'), )) module = AnsibleModule( argument_spec=argument_spec, From d323ada250748ecc008f5ea65e9a64ff9a056df2 Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Tue, 30 Apr 2024 09:37:58 +0200 Subject: [PATCH 2/4] Improve docs. --- plugins/modules/acme_certificate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/modules/acme_certificate.py b/plugins/modules/acme_certificate.py index 8fed37f62..76bf2bce2 100644 --- a/plugins/modules/acme_certificate.py +++ b/plugins/modules/acme_certificate.py @@ -297,9 +297,13 @@ description: - Determines whether to request renewal of an existing certificate according to L(the ACME ARI draft 3, https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html#section-5). - - This is only used if the certificate specified in O(dest) or O(fullchain_dest) already exists. + - This is only used when the certificate specified in O(dest) or O(fullchain_dest) already exists. - V(never) never sends the certificate ID of the certificate to renew. V(always) will always send it. - V(when_ari_supported) only sends the certificate ID if the ARI endpoint is found in the ACME directory. + - Generally you should use V(when_ari_supported) if you know that the ACME service supports a compatible + draft (or final version, once it is out) of the ARI extension. V(always) should never be necessary. + If you are not sure, or if you receive strange errors on invalid C(replaces) values in order objects, + use V(never), which also happens to be the default. type: str choices: - never From bd599ebb28240bbb79aeb6783ddf78d456f4a456 Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Tue, 30 Apr 2024 09:57:41 +0200 Subject: [PATCH 3/4] Fix typo and use right object. --- plugins/modules/acme_certificate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/modules/acme_certificate.py b/plugins/modules/acme_certificate.py index 76bf2bce2..d2c9bec18 100644 --- a/plugins/modules/acme_certificate.py +++ b/plugins/modules/acme_certificate.py @@ -704,9 +704,9 @@ def _get_cert_info_or_none(self): filename = self.module.params['dest'] else: filename = self.module.params['fullchain_dest'] - if not os.path.exist(filename): + if not os.path.exists(filename): return None - return self.backend.get_cert_information(cert_filename=filename) + return self.client.backend.get_cert_information(cert_filename=filename) def start_challenges(self): ''' @@ -729,7 +729,7 @@ def start_challenges(self): ): cert_info = self._get_cert_info_or_none() if cert_info is not None: - replaces_cert_id = compute_cert_id(self.backend, cert_info=cert_info) + replaces_cert_id = compute_cert_id(self.client.backend, cert_info=cert_info) self.order = Order.create(self.client, self.identifiers, replaces_cert_id) self.order_uri = self.order.url self.order.load_authorizations(self.client) From ab38d8622b9ba32f870f78464e22bbb5337fcd2e Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Tue, 30 Apr 2024 10:26:38 +0200 Subject: [PATCH 4/4] Add warning. --- plugins/modules/acme_certificate.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugins/modules/acme_certificate.py b/plugins/modules/acme_certificate.py index d2c9bec18..51be63ec7 100644 --- a/plugins/modules/acme_certificate.py +++ b/plugins/modules/acme_certificate.py @@ -304,6 +304,12 @@ draft (or final version, once it is out) of the ARI extension. V(always) should never be necessary. If you are not sure, or if you receive strange errors on invalid C(replaces) values in order objects, use V(never), which also happens to be the default. + - ACME servers might refuse to create new orders with C(replaces) for certificates that already have an + existing order. 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 set to a value different from V(never) if the + role/playbook using it keeps track of order data accross restarts. type: str choices: - never