Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

acme_certificate: allow to request renewal of a certificate according to ARI #739

Merged
merged 4 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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:/ansible-collections/community.crypto/pull/739)."
6 changes: 5 additions & 1 deletion plugins/module_utils/acme/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand All @@ -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 = []
Expand All @@ -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
Expand All @@ -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'])
Expand Down
46 changes: 45 additions & 1 deletion plugins/modules/acme_certificate.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,30 @@
- "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 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.
- 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.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm currently thinking of how to better handle this situation. The best way would to make it possible to disable orders, which cannot be done directly, but by disabling its authorizations. (If you don't want to wait for the order to expire.) I'll provide a solution for that in another PR, and in that PR I will also update this description accordingly.

(It might be a good idea to split this module into multiple modules anyway, especially the two-step process makes this module really quirky... But that's another topic :) )

type: str
choices:
- never
- when_ari_supported
- always
default: never
version_added: 2.20.0
'''

EXAMPLES = r'''
Expand Down Expand Up @@ -586,6 +610,7 @@
)

from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
compute_cert_id,
pem_to_der,
)

Expand Down Expand Up @@ -622,6 +647,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']):
Expand Down Expand Up @@ -679,6 +705,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.exists(filename):
return None
return self.client.backend.get_cert_information(cert_filename=filename)

def start_challenges(self):
'''
Create new authorizations for all identifiers of the CSR,
Expand All @@ -693,7 +728,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.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)
self.authorizations.update(self.order.authorizations)
Expand Down Expand Up @@ -879,6 +922,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,
Expand Down