mirror of
https://github.com/ansible-collections/community.crypto.git
synced 2026-03-27 05:43:22 +00:00
acme_certificate and acme_certificate_create_order: add order_creation_error_strategy and order_creation_max_retries options (#842)
* Provide error information. * Add helper function for order creation retrying. * Improve existing documentation. * Document 'replaces' return value. * Add order_creation_error_strategy and order_creation_max_retries options. * Add changelog fragment. * Fix authz deactivation for finalizing step. * Fix profile handling on order creation. * Improve existing tests. * Add ARI and profile tests. * Warn when 'replaces' is removed when retrying to create an order.
This commit is contained in:
@@ -19,6 +19,7 @@ from ansible_collections.community.crypto.plugins.module_utils.acme.account impo
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.challenges import (
|
||||
Authorization,
|
||||
wait_for_validation,
|
||||
)
|
||||
|
||||
@@ -63,6 +64,8 @@ class ACMECertificateClient(object):
|
||||
account = ACMEAccount(self.client)
|
||||
self.account = account
|
||||
self.order_uri = module.params.get('order_uri')
|
||||
self.order_creation_error_strategy = module.params.get('order_creation_error_strategy', 'auto')
|
||||
self.order_creation_max_retries = module.params.get('order_creation_max_retries', 3)
|
||||
|
||||
# Make sure account exists
|
||||
dummy, account_data = self.account.setup_account(allow_creation=False)
|
||||
@@ -102,7 +105,15 @@ class ACMECertificateClient(object):
|
||||
'''
|
||||
if self.identifiers is None:
|
||||
raise ModuleFailException('No identifiers have been provided')
|
||||
order = Order.create(self.client, self.identifiers, replaces_cert_id=replaces_cert_id, profile=profile)
|
||||
order = Order.create_with_error_handling(
|
||||
self.client,
|
||||
self.identifiers,
|
||||
error_strategy=self.order_creation_error_strategy,
|
||||
error_max_retries=self.order_creation_max_retries,
|
||||
replaces_cert_id=replaces_cert_id,
|
||||
profile=profile,
|
||||
message_callback=self.module.warn,
|
||||
)
|
||||
self.order_uri = order.url
|
||||
order.load_authorizations(self.client)
|
||||
return order
|
||||
@@ -248,11 +259,22 @@ class ACMECertificateClient(object):
|
||||
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))
|
||||
if len(order.authorization_uris) > len(order.authorizations):
|
||||
for authz_uri in order.authorization_uris:
|
||||
authz = None
|
||||
try:
|
||||
authz = Authorization.deactivate_url(self.client, authz_uri)
|
||||
except Exception:
|
||||
# ignore errors
|
||||
pass
|
||||
if authz is None or authz.status != 'deactivated':
|
||||
self.module.warn(warning='Could not deactivate authz object {0}.'.format(authz_uri))
|
||||
else:
|
||||
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))
|
||||
|
||||
@@ -322,6 +322,23 @@ class Authorization(object):
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def deactivate_url(cls, client, url):
|
||||
'''
|
||||
Deactivates this authorization.
|
||||
https://community.letsencrypt.org/t/authorization-deactivation/19860/2
|
||||
https://tools.ietf.org/html/rfc8555#section-7.5.2
|
||||
'''
|
||||
authz = cls(url)
|
||||
authz_deactivate = {
|
||||
'status': 'deactivated'
|
||||
}
|
||||
if client.version == 1:
|
||||
authz_deactivate['resource'] = 'authz'
|
||||
result, info = client.send_signed_request(url, authz_deactivate, fail_on_error=True)
|
||||
authz._setup(client, result)
|
||||
return authz
|
||||
|
||||
|
||||
def wait_for_validation(authzs, client):
|
||||
'''
|
||||
|
||||
@@ -84,6 +84,8 @@ class ACMEProtocolException(ModuleFailException):
|
||||
pass
|
||||
|
||||
extras = extras or dict()
|
||||
error_code = None
|
||||
error_type = None
|
||||
|
||||
if msg is None:
|
||||
msg = 'ACME request failed'
|
||||
@@ -94,7 +96,9 @@ class ACMEProtocolException(ModuleFailException):
|
||||
code = info['status']
|
||||
extras['http_url'] = url
|
||||
extras['http_status'] = code
|
||||
error_code = code
|
||||
if code is not None and code >= 400 and content_json is not None and 'type' in content_json:
|
||||
error_type = content_json['type']
|
||||
if 'status' in content_json and content_json['status'] != code:
|
||||
code_msg = 'status {problem_code} (HTTP status: {http_code})'.format(
|
||||
http_code=format_http_status(code), problem_code=content_json['status'])
|
||||
@@ -134,6 +138,8 @@ class ACMEProtocolException(ModuleFailException):
|
||||
)
|
||||
self.problem = {}
|
||||
self.subproblems = []
|
||||
self.error_code = error_code
|
||||
self.error_type = error_type
|
||||
for k, v in extras.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
|
||||
@@ -87,6 +87,55 @@ class Order(object):
|
||||
client.directory['newOrder'], new_order, error_msg='Failed to start new order', expected_status_codes=[201])
|
||||
return cls.from_json(client, result, info['location'])
|
||||
|
||||
@classmethod
|
||||
def create_with_error_handling(
|
||||
cls,
|
||||
client,
|
||||
identifiers,
|
||||
error_strategy='auto',
|
||||
error_max_retries=3,
|
||||
replaces_cert_id=None,
|
||||
profile=None,
|
||||
message_callback=None,
|
||||
):
|
||||
"""
|
||||
error_strategy can be one of the following strings:
|
||||
|
||||
* ``fail``: simply fail. (Same behavior as ``Order.create()``.)
|
||||
* ``retry_without_replaces_cert_id``: if ``replaces_cert_id`` is not ``None``, set it to ``None`` and retry.
|
||||
The only exception is an error of type ``urn:ietf:params:acme:error:alreadyReplaced``, that indicates that
|
||||
the certificate was already replaced.
|
||||
* ``auto``: try to be clever. Right now this is identical to ``retry_without_replaces_cert_id``, but that can
|
||||
change at any time in the future.
|
||||
* ``always``: always retry until ``error_max_retries`` has been reached.
|
||||
"""
|
||||
tries = 0
|
||||
while True:
|
||||
tries += 1
|
||||
try:
|
||||
return cls.create(client, identifiers, replaces_cert_id=replaces_cert_id, profile=profile)
|
||||
except ACMEProtocolException as exc:
|
||||
if tries <= error_max_retries + 1 and error_strategy != 'fail':
|
||||
if error_strategy == 'always':
|
||||
continue
|
||||
|
||||
if (
|
||||
error_strategy in ('auto', 'retry_without_replaces_cert_id') and
|
||||
replaces_cert_id is not None and
|
||||
not (exc.error_code == 409 and exc.error_type == 'urn:ietf:params:acme:error:alreadyReplaced')
|
||||
):
|
||||
replaces_cert_id = None
|
||||
if message_callback:
|
||||
message_callback(
|
||||
'Stop passing `replaces` due to error {code} {type} when creating ACME order'.format(
|
||||
code=exc.error_code,
|
||||
type=exc.error_type,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
raise
|
||||
|
||||
def refresh(self, client):
|
||||
result, dummy = client.get_request(self.url)
|
||||
changed = self.data != result
|
||||
|
||||
@@ -243,8 +243,6 @@ options:
|
||||
- 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
|
||||
@@ -252,15 +250,19 @@ options:
|
||||
- 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, or
|
||||
if it takes care to deactivate orders whose processing is aborted. Orders can be deactivated with the
|
||||
to create a new one on the next run, creating the new order might fail. If O(order_creation_error_strategy=fail)
|
||||
this will make the module fail. O(order_creation_error_strategy=auto) and
|
||||
O(order_creation_error_strategy=retry_without_replaces_cert_id) will avoid this by leaving away C(replaces)
|
||||
on retries.
|
||||
- If O(order_creation_error_strategy=fail), for the above 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, 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
|
||||
choices:
|
||||
- never
|
||||
- when_ari_supported
|
||||
- always
|
||||
never: Never send the certificate ID of the certificate to renew.
|
||||
when_ari_supported: Only send the certificate ID if the ARI endpoint is found in the ACME directory.
|
||||
always: Will always send the certificate ID of the certificate to renew.
|
||||
default: never
|
||||
version_added: 2.20.0
|
||||
profile:
|
||||
@@ -271,6 +273,32 @@ options:
|
||||
for more information.
|
||||
type: str
|
||||
version_added: 2.24.0
|
||||
order_creation_error_strategy:
|
||||
description:
|
||||
- Selects the error handling strategy for ACME protocol errors if creating a new ACME order fails.
|
||||
type: str
|
||||
choices:
|
||||
auto:
|
||||
- An unspecified algorithm that tries to be clever.
|
||||
- Right now identical to V(retry_without_replaces_cert_id).
|
||||
always:
|
||||
- Always retry, until the limit in O(order_creation_max_retries) has been reached.
|
||||
fail:
|
||||
- Simply fail in case of errors. Do not attempt to retry.
|
||||
- This has been the default before community.crypto 2.24.0.
|
||||
retry_without_replaces_cert_id:
|
||||
- If O(include_renewal_cert_id) is present, creating the order will be tried again without C(replaces).
|
||||
- The only exception is an error of type C(urn:ietf:params:acme:error:alreadyReplaced), that indicates that
|
||||
the certificate was already replaced. This usually means something went wrong and the user should investigate.
|
||||
default: auto
|
||||
version_added: 2.24.0
|
||||
order_creation_max_retries:
|
||||
description:
|
||||
- Depending on the strategy selected in O(order_creation_error_strategy), will retry creating new orders
|
||||
for at most the specified amount of times.
|
||||
type: int
|
||||
default: 3
|
||||
version_added: 2.24.0
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
@@ -613,6 +641,8 @@ class ACMECertificateClient(object):
|
||||
self.select_chain_matcher = []
|
||||
self.include_renewal_cert_id = module.params['include_renewal_cert_id']
|
||||
self.profile = module.params['profile']
|
||||
self.order_creation_error_strategy = module.params['order_creation_error_strategy']
|
||||
self.order_creation_max_retries = module.params['order_creation_max_retries']
|
||||
|
||||
if self.module.params['select_chain']:
|
||||
for criterium_idx, criterium in enumerate(self.module.params['select_chain']):
|
||||
@@ -712,7 +742,15 @@ class ACMECertificateClient(object):
|
||||
cert_info=cert_info,
|
||||
none_if_required_information_is_missing=True,
|
||||
)
|
||||
self.order = Order.create(self.client, self.identifiers, replaces_cert_id, profile=self.profile)
|
||||
self.order = Order.create_with_error_handling(
|
||||
self.client,
|
||||
self.identifiers,
|
||||
error_strategy=self.order_creation_error_strategy,
|
||||
error_max_retries=self.order_creation_max_retries,
|
||||
replaces_cert_id=replaces_cert_id,
|
||||
profile=self.profile,
|
||||
message_callback=self.module.warn,
|
||||
)
|
||||
self.order_uri = self.order.url
|
||||
self.order.load_authorizations(self.client)
|
||||
self.authorizations.update(self.order.authorizations)
|
||||
@@ -899,6 +937,8 @@ def main():
|
||||
)),
|
||||
include_renewal_cert_id=dict(type='str', choices=['never', 'when_ari_supported', 'always'], default='never'),
|
||||
profile=dict(type='str'),
|
||||
order_creation_error_strategy=dict(type='str', default='auto', choices=['auto', 'always', 'fail', 'retry_without_replaces_cert_id']),
|
||||
order_creation_max_retries=dict(type='int', default=3),
|
||||
)
|
||||
argument_spec.update(
|
||||
required_one_of=[
|
||||
|
||||
@@ -113,15 +113,18 @@ options:
|
||||
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
|
||||
It is returned as return value 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
|
||||
creating the new order might fail. If O(order_creation_error_strategy=fail) this will make the module fail.
|
||||
O(order_creation_error_strategy=auto) and O(order_creation_error_strategy=retry_without_replaces_cert_id)
|
||||
will avoid this by leaving away C(replaces) on retries.
|
||||
- If O(order_creation_error_strategy=fail), for the above 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
|
||||
profile:
|
||||
@@ -131,6 +134,29 @@ options:
|
||||
L(draft-aaron-acme-profiles-00, https://datatracker.ietf.org/doc/draft-aaron-acme-profiles/)
|
||||
for more information.
|
||||
type: str
|
||||
order_creation_error_strategy:
|
||||
description:
|
||||
- Selects the error handling strategy for ACME protocol errors if creating a new ACME order fails.
|
||||
type: str
|
||||
choices:
|
||||
auto:
|
||||
- An unspecified algorithm that tries to be clever.
|
||||
- Right now identical to V(retry_without_replaces_cert_id).
|
||||
always:
|
||||
- Always retry, until the limit in O(order_creation_max_retries) has been reached.
|
||||
fail:
|
||||
- Simply fail in case of errors. Do not attempt to retry.
|
||||
retry_without_replaces_cert_id:
|
||||
- If O(replaces_cert_id) is present, creating the order will be tried again without C(replaces).
|
||||
- The only exception is an error of type C(urn:ietf:params:acme:error:alreadyReplaced), that indicates that
|
||||
the certificate was already replaced. This usually means something went wrong and the user should investigate.
|
||||
default: auto
|
||||
order_creation_max_retries:
|
||||
description:
|
||||
- Depending on the strategy selected in O(order_creation_error_strategy), will retry creating new orders
|
||||
for at most the specified amount of times.
|
||||
type: int
|
||||
default: 3
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
@@ -370,6 +396,8 @@ def main():
|
||||
deactivate_authzs=dict(type='bool', default=True),
|
||||
replaces_cert_id=dict(type='str'),
|
||||
profile=dict(type='str'),
|
||||
order_creation_error_strategy=dict(type='str', default='auto', choices=['auto', 'always', 'fail', 'retry_without_replaces_cert_id']),
|
||||
order_creation_max_retries=dict(type='int', default=3),
|
||||
)
|
||||
module = argument_spec.create_ansible_module()
|
||||
if module.params['acme_version'] == 1:
|
||||
@@ -382,7 +410,7 @@ def main():
|
||||
|
||||
profile = module.params['profile']
|
||||
if profile is not None:
|
||||
meta_profiles = (client.directory.get('meta') or {}).get('profiles') or {}
|
||||
meta_profiles = (client.client.directory.get('meta') or {}).get('profiles') or {}
|
||||
if not meta_profiles:
|
||||
raise ModuleFailException(msg='The ACME CA does not support profiles. Please omit the "profile" option.')
|
||||
if profile not in meta_profiles:
|
||||
|
||||
@@ -175,6 +175,13 @@ order:
|
||||
- A URL for the certificate that has been issued in response to this order.
|
||||
type: str
|
||||
returned: when the certificate has been issued
|
||||
replaces:
|
||||
description:
|
||||
- If the order was created to replace an existing certificate using the C(replaces) mechanism from
|
||||
L(draft-ietf-acme-ari, https://datatracker.ietf.org/doc/draft-ietf-acme-ari/), this provides the
|
||||
certificate ID of the certificate that will be replaced by this order.
|
||||
type: str
|
||||
returned: when the certificate order is replacing a certificate through draft-ietf-acme-ari
|
||||
authorizations_by_identifier:
|
||||
description:
|
||||
- A dictionary mapping identifiers to their authorization objects.
|
||||
|
||||
Reference in New Issue
Block a user