diff options
Diffstat (limited to 'ansible_collections/community/crypto/plugins/modules')
13 files changed, 668 insertions, 144 deletions
diff --git a/ansible_collections/community/crypto/plugins/modules/acme_account.py b/ansible_collections/community/crypto/plugins/modules/acme_account.py index 1e8d64a57..960bad313 100644 --- a/ansible_collections/community/crypto/plugins/modules/acme_account.py +++ b/ansible_collections/community/crypto/plugins/modules/acme_account.py @@ -37,7 +37,8 @@ seealso: - module: community.crypto.acme_inspect description: Allows to debug problems. extends_documentation_fragment: - - community.crypto.acme + - community.crypto.acme.basic + - community.crypto.acme.account - community.crypto.attributes - community.crypto.attributes.actiongroup_acme attributes: @@ -169,11 +170,9 @@ account_uri: import base64 -from ansible.module_utils.basic import AnsibleModule - from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( create_backend, - get_default_argspec, + create_default_argspec, ACMEClient, ) @@ -188,8 +187,8 @@ from ansible_collections.community.crypto.plugins.module_utils.acme.errors impor def main(): - argument_spec = get_default_argspec() - argument_spec.update(dict( + argument_spec = create_default_argspec() + argument_spec.update_argspec( terms_agreed=dict(type='bool', default=False), state=dict(type='str', required=True, choices=['absent', 'present', 'changed_key']), allow_creation=dict(type='bool', default=True), @@ -202,14 +201,9 @@ def main(): alg=dict(type='str', required=True, choices=['HS256', 'HS384', 'HS512']), key=dict(type='str', required=True, no_log=True), )) - )) - module = AnsibleModule( - argument_spec=argument_spec, - required_one_of=( - ['account_key_src', 'account_key_content'], - ), + ) + argument_spec.update( mutually_exclusive=( - ['account_key_src', 'account_key_content'], ['new_account_key_src', 'new_account_key_content'], ), required_if=( @@ -217,8 +211,8 @@ def main(): # new_account_key_src and new_account_key_content are specified ['state', 'changed_key', ['new_account_key_src', 'new_account_key_content'], True], ), - supports_check_mode=True, ) + module = argument_spec.create_ansible_module(supports_check_mode=True) backend = create_backend(module, True) if module.params['external_account_binding']: diff --git a/ansible_collections/community/crypto/plugins/modules/acme_account_info.py b/ansible_collections/community/crypto/plugins/modules/acme_account_info.py index ac4617c90..33313fe75 100644 --- a/ansible_collections/community/crypto/plugins/modules/acme_account_info.py +++ b/ansible_collections/community/crypto/plugins/modules/acme_account_info.py @@ -25,7 +25,8 @@ notes: - "This module was called C(acme_account_facts) before Ansible 2.8. The usage did not change." extends_documentation_fragment: - - community.crypto.acme + - community.crypto.acme.basic + - community.crypto.acme.account - community.crypto.attributes - community.crypto.attributes.actiongroup_acme - community.crypto.attributes.info_module @@ -213,11 +214,9 @@ order_uris: version_added: 1.5.0 ''' -from ansible.module_utils.basic import AnsibleModule - from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( create_backend, - get_default_argspec, + create_default_argspec, ACMEClient, ) @@ -270,20 +269,11 @@ def get_order(client, order_url): def main(): - argument_spec = get_default_argspec() - argument_spec.update(dict( + argument_spec = create_default_argspec() + argument_spec.update_argspec( retrieve_orders=dict(type='str', default='ignore', choices=['ignore', 'url_list', 'object_list']), - )) - module = AnsibleModule( - argument_spec=argument_spec, - required_one_of=( - ['account_key_src', 'account_key_content'], - ), - mutually_exclusive=( - ['account_key_src', 'account_key_content'], - ), - supports_check_mode=True, ) + module = argument_spec.create_ansible_module(supports_check_mode=True) backend = create_backend(module, True) try: diff --git a/ansible_collections/community/crypto/plugins/modules/acme_ari_info.py b/ansible_collections/community/crypto/plugins/modules/acme_ari_info.py new file mode 100644 index 000000000..7783236f0 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/acme_ari_info.py @@ -0,0 +1,142 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2018 Felix Fontein <felix@fontein.de> +# 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_ari_info +author: "Felix Fontein (@felixfontein)" +version_added: 2.20.0 +short_description: Retrieves ACME Renewal Information (ARI) for a certificate +description: + - "Allows to retrieve renewal information on a certificate obtained with the + L(ACME protocol,https://tools.ietf.org/html/rfc8555)." + - "This module only works with the ACME v2 protocol, and requires the ACME server + to support the ARI extension (U(https://datatracker.ietf.org/doc/draft-ietf-acme-ari/)). + This module implements version 3 of the ARI draft." +extends_documentation_fragment: + - community.crypto.acme.basic + - community.crypto.acme.no_account + - community.crypto.attributes + - community.crypto.attributes.info_module +options: + certificate_path: + description: + - A path to the X.509 certificate to request information for. + - Exactly one of O(certificate_path) and O(certificate_content) must be provided. + type: path + certificate_content: + description: + - The content of the X.509 certificate to request information for. + - Exactly one of O(certificate_path) and O(certificate_content) must be provided. + type: str +seealso: + - module: community.crypto.acme_certificate + description: Allows to obtain a certificate using the ACME protocol + - module: community.crypto.acme_certificate_revoke + description: Allows to revoke a certificate using the ACME protocol +''' + +EXAMPLES = ''' +- name: Retrieve renewal information for a certificate + community.crypto.acme_ari_info: + certificate_path: /etc/httpd/ssl/sample.com.crt + register: cert_data + +- name: Show the certificate renewal information + ansible.builtin.debug: + var: cert_data.renewal_info +''' + +RETURN = ''' +renewal_info: + description: The ARI renewal info object (U(https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html#section-4.2)). + returned: success + type: dict + contains: + suggestedWindow: + description: + - Describes the window during which the certificate should be renewed. + type: dict + returned: always + contains: + start: + description: + - The start of the window during which the certificate should be renewed. + - The format is specified in L(RFC 3339,https://www.rfc-editor.org/info/rfc3339). + returned: always + type: str + sample: '2021-01-03T00:00:00Z' + end: + description: + - The end of the window during which the certificate should be renewed. + - The format is specified in L(RFC 3339,https://www.rfc-editor.org/info/rfc3339). + returned: always + type: str + sample: '2021-01-03T00:00:00Z' + explanationURL: + description: + - A URL pointing to a page which may explain why the suggested renewal window is what it is. + - For example, it may be a page explaining the CA's dynamic load-balancing strategy, or a + page documenting which certificates are affected by a mass revocation event. Should be shown + to the user. + returned: depends on the ACME server + type: str + sample: https://example.com/docs/ari + retryAfter: + description: + - A timestamp before the next retry to ask for this information should not be made. + returned: depends on the ACME server + type: str + sample: '2024-04-29T01:17:10.236921+00:00' +''' + +from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( + create_backend, + create_default_argspec, + ACMEClient, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException + + +def main(): + argument_spec = create_default_argspec(with_account=False) + argument_spec.update_argspec( + certificate_path=dict(type='path'), + certificate_content=dict(type='str'), + ) + argument_spec.update( + required_one_of=( + ['certificate_path', 'certificate_content'], + ), + mutually_exclusive=( + ['certificate_path', 'certificate_content'], + ), + ) + module = argument_spec.create_ansible_module(supports_check_mode=True) + backend = create_backend(module, True) + + try: + client = ACMEClient(module, backend) + if not client.directory.has_renewal_info_endpoint(): + module.fail_json(msg='The ACME endpoint does not support ACME Renewal Information retrieval') + renewal_info = client.get_renewal_info( + cert_filename=module.params['certificate_path'], + cert_content=module.params['certificate_content'], + include_retry_after=True, + ) + module.exit_json(renewal_info=renewal_info) + except ModuleFailException as e: + e.do_fail(module) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/acme_certificate.py b/ansible_collections/community/crypto/plugins/modules/acme_certificate.py index 21a6d6ae9..8729996c0 100644 --- a/ansible_collections/community/crypto/plugins/modules/acme_certificate.py +++ b/ansible_collections/community/crypto/plugins/modules/acme_certificate.py @@ -58,7 +58,7 @@ seealso: 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-05 + 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 @@ -77,8 +77,12 @@ seealso: description: Allows to create, modify or delete an ACME account. - 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 + - community.crypto.acme.basic + - community.crypto.acme.account + - community.crypto.acme.certificate - community.crypto.attributes - community.crypto.attributes.files - community.crypto.attributes.actiongroup_acme @@ -138,32 +142,8 @@ options: - 'tls-alpn-01' - 'no challenge' csr: - description: - - "File containing the CSR for the new certificate." - - "Can be created with M(community.crypto.openssl_csr) or C(openssl req ...)." - - "The CSR may contain multiple Subject Alternate Names, but each one - will lead to an individual challenge that must be fulfilled for the - CSR to be signed." - - "I(Note): the private key used to create the CSR I(must not) be the - account key. This is a bad idea from a security point of view, and - the CA should not accept the CSR. The ACME server should return an - error in this case." - - Precisely one of O(csr) or O(csr_content) must be specified. - type: path aliases: ['src'] csr_content: - description: - - "Content of the CSR for the new certificate." - - "Can be created with M(community.crypto.openssl_csr_pipe) or C(openssl req ...)." - - "The CSR may contain multiple Subject Alternate Names, but each one - will lead to an individual challenge that must be fulfilled for the - CSR to be signed." - - "I(Note): the private key used to create the CSR I(must not) be the - account key. This is a bad idea from a security point of view, and - the CA should not accept the CSR. The ACME server should return an - error in this case." - - Precisely one of O(csr) or O(csr_content) must be specified. - type: str version_added: 1.2.0 data: description: @@ -292,6 +272,32 @@ options: - "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, 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 + default: never + version_added: 2.20.0 ''' EXAMPLES = r''' @@ -375,7 +381,7 @@ EXAMPLES = r''' # state: present # wait: true # # Note: route53 requires TXT entries to be enclosed in quotes -# value: "{{ sample_com_challenge.challenge_data['sample.com']['dns-01'].resource_value | regex_replace('^(.*)$', '\"\\1\"') }}" +# value: "{{ sample_com_challenge.challenge_data['sample.com']['dns-01'].resource_value | community.dns.quote_txt(always_quote=true) }}" # when: sample_com_challenge is changed and 'sample.com' in sample_com_challenge.challenge_data # # Alternative way: @@ -390,7 +396,7 @@ EXAMPLES = r''' # 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('regex_replace', '^(.*)$', '\"\\1\"' ) | list }}" +# value: "{{ item.value | map('community.dns.quote_txt', always_quote=true) | list }}" # loop: "{{ sample_com_challenge.challenge_data_dns | dict2items }}" # when: sample_com_challenge is changed @@ -446,39 +452,55 @@ challenge_data: - Per identifier / challenge type challenge data. - Since Ansible 2.8.5, only challenges which are not yet valid are returned. returned: changed - type: list - elements: dict + type: dict contains: - resource: - description: The challenge resource that must be created for validation. - returned: changed - type: str - sample: .well-known/acme-challenge/evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA - resource_original: - description: - - The original challenge resource including type identifier for V(tls-alpn-01) - challenges. - returned: changed and O(challenge) is V(tls-alpn-01) - type: str - sample: DNS:example.com - resource_value: + identifier: description: - - The value the resource has to produce for the validation. - - For V(http-01) and V(dns-01) challenges, the value can be used as-is. - - "For V(tls-alpn-01) challenges, note that this return value contains a - Base64 encoded version of the correct binary blob which has to be put - into the acmeValidation x509 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." + - For every identifier, provides a dictionary of challenge types mapping to challenge data. + - The keys in this dictionary are the identifiers. C(identifier) is a placeholder used in the documentation. + - Note that the keys are not valid Jinja2 identifiers. returned: changed - type: str - sample: IlirfxKKXA...17Dt3juxGJ-PCt92wr-oA - record: - description: The full DNS record's name for the challenge. - returned: changed and challenge is V(dns-01) - type: str - sample: _acme-challenge.example.com + type: dict + contains: + challenge-type: + description: + - Data for every challenge type. + - The keys in this dictionary are the challenge types. C(challenge-type) is a placeholder used in the documentation. + Possible keys are V(http-01), V(dns-01), and V(tls-alpn-01). + - Note that the keys are not valid Jinja2 identifiers. + returned: changed + type: dict + contains: + resource: + description: The challenge resource that must be created for validation. + returned: changed + type: str + sample: .well-known/acme-challenge/evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA + resource_original: + description: + - The original challenge resource including type identifier for V(tls-alpn-01) + challenges. + returned: changed and O(challenge) is V(tls-alpn-01) + type: str + sample: DNS:example.com + resource_value: + description: + - The value the resource has to produce for the validation. + - For V(http-01) and V(dns-01) challenges, the value can be used as-is. + - "For V(tls-alpn-01) challenges, note that this return value contains a + Base64 encoded version of the correct binary blob which has to be put + into the acmeValidation x509 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: changed + type: str + sample: IlirfxKKXA...17Dt3juxGJ-PCt92wr-oA + record: + description: The full DNS record's name for the challenge. + returned: changed and challenge is V(dns-01) + type: str + sample: _acme-challenge.example.com challenge_data_dns: description: - List of TXT values per DNS record, in case challenge is V(dns-01). @@ -547,11 +569,9 @@ all_chains: import os -from ansible.module_utils.basic import AnsibleModule - from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( create_backend, - get_default_argspec, + create_default_argspec, ACMEClient, ) @@ -585,6 +605,7 @@ from ansible_collections.community.crypto.plugins.module_utils.acme.orders impor ) from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( + compute_cert_id, pem_to_der, ) @@ -621,6 +642,7 @@ class ACMECertificateClient(object): 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']): @@ -678,6 +700,15 @@ class ACMECertificateClient(object): # 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, @@ -692,7 +723,19 @@ class ACMECertificateClient(object): 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, + none_if_required_information_is_missing=True, + ) + 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) @@ -854,15 +897,14 @@ class ACMECertificateClient(object): def main(): - argument_spec = get_default_argspec() - argument_spec.update(dict( + argument_spec = create_default_argspec(with_certificate=True) + argument_spec.argument_spec['csr']['aliases'] = ['src'] + argument_spec.update_argspec( modify_account=dict(type='bool', default=True), account_email=dict(type='str'), agreement=dict(type='str'), terms_agreed=dict(type='bool', default=False), challenge=dict(type='str', default='http-01', choices=['http-01', 'dns-01', 'tls-alpn-01', NO_CHALLENGE]), - csr=dict(type='path', aliases=['src']), - csr_content=dict(type='str'), data=dict(type='dict'), dest=dict(type='path', aliases=['cert']), fullchain_dest=dict(type='path', aliases=['fullchain']), @@ -878,20 +920,14 @@ def main(): subject_key_identifier=dict(type='str'), authority_key_identifier=dict(type='str'), )), - )) - module = AnsibleModule( - argument_spec=argument_spec, - required_one_of=( - ['account_key_src', 'account_key_content'], + include_renewal_cert_id=dict(type='str', choices=['never', 'when_ari_supported', 'always'], default='never'), + ) + argument_spec.update( + required_one_of=[ ['dest', 'fullchain_dest'], - ['csr', 'csr_content'], - ), - mutually_exclusive=( - ['account_key_src', 'account_key_content'], - ['csr', 'csr_content'], - ), - supports_check_mode=True, + ], ) + module = argument_spec.create_ansible_module(supports_check_mode=True) backend = create_backend(module, False) try: diff --git a/ansible_collections/community/crypto/plugins/modules/acme_certificate_deactivate_authz.py b/ansible_collections/community/crypto/plugins/modules/acme_certificate_deactivate_authz.py new file mode 100644 index 000000000..133f777d6 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/acme_certificate_deactivate_authz.py @@ -0,0 +1,119 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> +# 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_deactivate_authz +author: "Felix Fontein (@felixfontein)" +version_added: 2.20.0 +short_description: Deactivate all authz for an ACME v2 order +description: + - "Deactivate all authentication objects (authz) for an ACME v2 order, + which effectively deactivates (invalidates) the order itself." + - "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." + - "Another reason to use this module is to deactivate an order whose + processing failed when using O(community.crypto.acme_certificate#module:include_renewal_cert_id)." +seealso: + - module: community.crypto.acme_certificate +extends_documentation_fragment: + - community.crypto.acme.basic + - community.crypto.acme.account + - community.crypto.attributes + - community.crypto.attributes.actiongroup_acme +attributes: + check_mode: + support: full + diff_mode: + support: none +options: + order_uri: + description: + - The ACME v2 order to deactivate. + - Can be obtained from RV(community.crypto.acme_certificate#module:order_uri). + type: str + required: true +''' + +EXAMPLES = r''' +- name: Deactivate all authzs for an order + community.crypto.acme_certificate_deactivate_authz: + account_key_content: "{{ account_private_key }}" + order_uri: "{{ certificate_result.order_uri }}" +''' + +RETURN = '''#''' + +from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( + create_backend, + create_default_argspec, + ACMEClient, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.account import ( + ACMEAccount, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + ModuleFailException, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.orders import ( + Order, +) + + +def main(): + argument_spec = create_default_argspec() + 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 = ACMEClient(module, backend) + account = ACMEAccount(client) + + dummy, account_data = account.setup_account(allow_creation=False) + if account_data is None: + raise ModuleFailException(msg='Account does not exist or is deactivated.') + + order = Order.from_url(client, module.params['order_uri']) + order.load_authorizations(client) + + changed = False + for authz in order.authorizations.values(): + if not authz.can_deactivate(): + continue + changed = True + if module.check_mode: + continue + try: + authz.deactivate(client) + except Exception: + # ignore errors + pass + if authz.status != 'deactivated': + module.warn(warning='Could not deactivate authz object {0}.'.format(authz.url)) + + module.exit_json(changed=changed) + except ModuleFailException as e: + e.do_fail(module) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/acme_certificate_renewal_info.py b/ansible_collections/community/crypto/plugins/modules/acme_certificate_renewal_info.py new file mode 100644 index 000000000..1e2b16918 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/acme_certificate_renewal_info.py @@ -0,0 +1,245 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2018 Felix Fontein <felix@fontein.de> +# 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_renewal_info +author: "Felix Fontein (@felixfontein)" +version_added: 2.20.0 +short_description: Determine whether a certificate should be renewed or not +description: + - Uses various information to determine whether a certificate should be renewed or not. + - If available, the ARI extension (ACME Renewal Information, U(https://datatracker.ietf.org/doc/draft-ietf-acme-ari/)) + is used. This module implements version 3 of the ARI draft." +extends_documentation_fragment: + - community.crypto.acme.basic + - community.crypto.acme.no_account + - community.crypto.attributes + - community.crypto.attributes.info_module +options: + certificate_path: + description: + - A path to the X.509 certificate to determine renewal of. + - In case the certificate does not exist, the module will always return RV(should_renew=true). + - O(certificate_path) and O(certificate_content) are mutually exclusive. + type: path + certificate_content: + description: + - The content of the X.509 certificate to determine renewal of. + - O(certificate_path) and O(certificate_content) are mutually exclusive. + type: str + use_ari: + description: + - Whether to use ARI information, if available. + - Set this to V(false) if the ACME server implements ARI in a way that is incompatible with this module. + type: bool + default: true + ari_algorithm: + description: + - If ARI information is used, selects which algorithm is used to determine whether to renew now. + - V(standard) selects the L(algorithm provided in the the ARI specification, + https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html#name-renewalinfo-objects). + - V(start) returns RV(should_renew=true) once the start of the renewal interval has been reached. + type: str + choices: + - standard + - start + default: standard + remaining_days: + description: + - The number of days the certificate must have left being valid. + - For example, if O(remaining_days=20), this check causes RV(should_renew=true) if the + certificate is valid for less than 20 days. + type: int + remaining_percentage: + description: + - The percentage of the certificate's validity period that should be left. + - For example, if O(remaining_percentage=0.1), and the certificate's validity period is 90 days, + this check causes RV(should_renew=true) if the certificate is valid for less than 9 days. + - Must be a value between 0 and 1. + type: float + now: + description: + - Use this timestamp instead of the current timestamp to determine whether a certificate should be renewed. + - Time can be specified either as relative time or as absolute timestamp. + - Time will always be interpreted as UTC. + - Valid format is C([+-]timespec | ASN.1 TIME) where timespec can be an integer + + C([w | d | h | m | s]) (for example V(+32w1d2h)). + type: str +seealso: + - module: community.crypto.acme_certificate + description: Allows to obtain a certificate using the ACME protocol + - module: community.crypto.acme_ari_info + description: Obtain renewal information for a certificate +''' + +EXAMPLES = ''' +- name: Retrieve renewal information for a certificate + community.crypto.acme_certificate_renewal_info: + certificate_path: /etc/httpd/ssl/sample.com.crt + register: cert_data + +- name: Should the certificate be renewed? + ansible.builtin.debug: + var: cert_data.should_renew +''' + +RETURN = ''' +should_renew: + description: + - Whether the certificate should be renewed. + - If no certificate is provided, or the certificate is expired, will always be V(true). + returned: success + type: bool + sample: true + +msg: + description: + - Information on the reason for renewal. + - Should be shown to the user, as in case of ARI triggered renewal it can contain important + information, for example on forced revocations for misissued certificates. + type: str + returned: success + sample: The certificate does not exist. + +supports_ari: + description: + - Whether ARI information was used to determine renewal. This can be used to determine whether to + specify O(community.crypto.acme_certificate#module:include_renewal_cert_id=when_ari_supported) + for the M(community.crypto.acme_certificate) module. + - If O(use_ari=false), this will always be V(false). + returned: success + type: bool + sample: true + +cert_id: + description: + - The certificate ID according to the L(ARI specification, https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html#section-4.1). + returned: success, the certificate exists, and has an Authority Key Identifier X.509 extension + type: str + sample: aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE +''' + +import os +import random + +from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( + create_backend, + create_default_argspec, + ACMEClient, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException + +from ansible_collections.community.crypto.plugins.module_utils.acme.utils import compute_cert_id + + +def main(): + argument_spec = create_default_argspec(with_account=False) + argument_spec.update_argspec( + certificate_path=dict(type='path'), + certificate_content=dict(type='str'), + use_ari=dict(type='bool', default=True), + ari_algorithm=dict(type='str', choices=['standard', 'start'], default='standard'), + remaining_days=dict(type='int'), + remaining_percentage=dict(type='float'), + now=dict(type='str'), + ) + argument_spec.update( + mutually_exclusive=( + ['certificate_path', 'certificate_content'], + ), + ) + module = argument_spec.create_ansible_module(supports_check_mode=True) + backend = create_backend(module, True) + + result = dict( + changed=False, + msg='The certificate is still valid and no condition was reached', + supports_ari=False, + ) + + def complete(should_renew, **kwargs): + result['should_renew'] = should_renew + result.update(kwargs) + module.exit_json(**result) + + if not module.params['certificate_path'] and not module.params['certificate_content']: + complete(True, msg='No certificate was specified') + + if module.params['certificate_path'] is not None and not os.path.exists(module.params['certificate_path']): + complete(True, msg='The certificate file does not exist') + + try: + cert_info = backend.get_cert_information( + cert_filename=module.params['certificate_path'], + cert_content=module.params['certificate_content'], + ) + cert_id = compute_cert_id(backend, cert_info=cert_info, none_if_required_information_is_missing=True) + if cert_id is not None: + result['cert_id'] = cert_id + + if module.params['now']: + now = backend.parse_module_parameter(module.params['now'], 'now') + else: + now = backend.get_now() + + if now >= cert_info.not_valid_after: + complete(True, msg='The certificate has already expired') + + client = ACMEClient(module, backend) + if cert_id is not None and module.params['use_ari'] and client.directory.has_renewal_info_endpoint(): + renewal_info = client.get_renewal_info(cert_id=cert_id) + window_start = backend.parse_acme_timestamp(renewal_info['suggestedWindow']['start']) + window_end = backend.parse_acme_timestamp(renewal_info['suggestedWindow']['end']) + msg_append = '' + if 'explanationURL' in renewal_info: + msg_append = '. Information on renewal interval: {0}'.format(renewal_info['explanationURL']) + result['supports_ari'] = True + if now > window_end: + complete(True, msg='The suggested renewal interval provided by ARI is in the past{0}'.format(msg_append)) + if module.params['ari_algorithm'] == 'start': + if now > window_start: + complete(True, msg='The suggested renewal interval provided by ARI has begun{0}'.format(msg_append)) + else: + random_time = backend.interpolate_timestamp(window_start, window_end, random.random()) + if now > random_time: + complete( + True, + msg='The picked random renewal time {0} in sugested renewal internal provided by ARI is in the past{1}'.format( + random_time, + msg_append, + ), + ) + + if module.params['remaining_days'] is not None: + remaining_days = (cert_info.not_valid_after - now).days + if remaining_days < module.params['remaining_days']: + complete(True, msg='The certificate expires in {0} days'.format(remaining_days)) + + if module.params['remaining_percentage'] is not None: + timestamp = backend.interpolate_timestamp(cert_info.not_valid_before, cert_info.not_valid_after, 1 - module.params['remaining_percentage']) + if timestamp < now: + complete( + True, + msg="The remaining percentage {0}% of the certificate's lifespan was reached on {1}".format( + module.params['remaining_percentage'] * 100, + timestamp, + ), + ) + + complete(False) + except ModuleFailException as e: + e.do_fail(module) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/acme_certificate_revoke.py b/ansible_collections/community/crypto/plugins/modules/acme_certificate_revoke.py index 022862e60..2661a1525 100644 --- a/ansible_collections/community/crypto/plugins/modules/acme_certificate_revoke.py +++ b/ansible_collections/community/crypto/plugins/modules/acme_certificate_revoke.py @@ -37,7 +37,8 @@ seealso: - module: community.crypto.acme_inspect description: Allows to debug problems. extends_documentation_fragment: - - community.crypto.acme + - community.crypto.acme.basic + - community.crypto.acme.account - community.crypto.attributes - community.crypto.attributes.actiongroup_acme attributes: @@ -127,11 +128,9 @@ EXAMPLES = ''' RETURN = '''#''' -from ansible.module_utils.basic import AnsibleModule - from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( create_backend, - get_default_argspec, + create_default_argspec, ACMEClient, ) @@ -152,24 +151,23 @@ from ansible_collections.community.crypto.plugins.module_utils.acme.utils import def main(): - argument_spec = get_default_argspec() - argument_spec.update(dict( + argument_spec = create_default_argspec(require_account_key=False) + argument_spec.update_argspec( private_key_src=dict(type='path'), private_key_content=dict(type='str', no_log=True), private_key_passphrase=dict(type='str', no_log=True), certificate=dict(type='path', required=True), revoke_reason=dict(type='int'), - )) - module = AnsibleModule( - argument_spec=argument_spec, + ) + argument_spec.update( required_one_of=( ['account_key_src', 'account_key_content', 'private_key_src', 'private_key_content'], ), mutually_exclusive=( ['account_key_src', 'account_key_content', 'private_key_src', 'private_key_content'], ), - supports_check_mode=False, ) + module = argument_spec.create_ansible_module() backend = create_backend(module, False) try: diff --git a/ansible_collections/community/crypto/plugins/modules/acme_challenge_cert_helper.py b/ansible_collections/community/crypto/plugins/modules/acme_challenge_cert_helper.py index 48b65f998..edd2c3331 100644 --- a/ansible_collections/community/crypto/plugins/modules/acme_challenge_cert_helper.py +++ b/ansible_collections/community/crypto/plugins/modules/acme_challenge_cert_helper.py @@ -165,16 +165,16 @@ from ansible_collections.community.crypto.plugins.module_utils.acme.io import ( read_file, ) -from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( - get_now_datetime, -) - from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( CRYPTOGRAPHY_TIMEZONE, set_not_valid_after, set_not_valid_before, ) +from ansible_collections.community.crypto.plugins.module_utils.time import ( + get_now_datetime, +) + CRYPTOGRAPHY_IMP_ERR = None try: import cryptography diff --git a/ansible_collections/community/crypto/plugins/modules/acme_inspect.py b/ansible_collections/community/crypto/plugins/modules/acme_inspect.py index a2c76507e..c7ee49765 100644 --- a/ansible_collections/community/crypto/plugins/modules/acme_inspect.py +++ b/ansible_collections/community/crypto/plugins/modules/acme_inspect.py @@ -42,7 +42,8 @@ seealso: description: The specification of the C(tls-alpn-01) challenge (RFC 8737). link: https://www.rfc-editor.org/rfc/rfc8737.html extends_documentation_fragment: - - community.crypto.acme + - community.crypto.acme.basic + - community.crypto.acme.account - community.crypto.attributes - community.crypto.attributes.actiongroup_acme attributes: @@ -247,12 +248,11 @@ output_json: - ... ''' -from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.common.text.converters import to_native, to_bytes, to_text from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( create_backend, - get_default_argspec, + create_default_argspec, ACMEClient, ) @@ -263,18 +263,14 @@ from ansible_collections.community.crypto.plugins.module_utils.acme.errors impor def main(): - argument_spec = get_default_argspec() - argument_spec.update(dict( + argument_spec = create_default_argspec(require_account_key=False) + argument_spec.update_argspec( url=dict(type='str'), method=dict(type='str', choices=['get', 'post', 'directory-only'], default='get'), content=dict(type='str'), fail_on_acme_error=dict(type='bool', default=True), - )) - module = AnsibleModule( - argument_spec=argument_spec, - mutually_exclusive=( - ['account_key_src', 'account_key_content'], - ), + ) + argument_spec.update( required_if=( ['method', 'get', ['url']], ['method', 'post', ['url', 'content']], @@ -282,6 +278,7 @@ def main(): ['method', 'post', ['account_key_src', 'account_key_content'], True], ), ) + module = argument_spec.create_ansible_module() backend = create_backend(module, False) result = dict() diff --git a/ansible_collections/community/crypto/plugins/modules/ecs_certificate.py b/ansible_collections/community/crypto/plugins/modules/ecs_certificate.py index 2c1238d48..0276556ab 100644 --- a/ansible_collections/community/crypto/plugins/modules/ecs_certificate.py +++ b/ansible_collections/community/crypto/plugins/modules/ecs_certificate.py @@ -938,8 +938,8 @@ def main(): module.fail_json(msg='The cert_expiry field is invalid when request_type="reissue".') elif module.params['cert_lifetime']: module.fail_json(msg='The cert_lifetime field is invalid when request_type="reissue".') - # Only a reissued request can omit the CSR - else: + # Reissued or renew request can omit the CSR + elif module.params['request_type'] != 'renew': module_params_csr = module.params['csr'] if module_params_csr is None: module.fail_json(msg='The csr field is required when request_type={0}'.format(module.params['request_type'])) diff --git a/ansible_collections/community/crypto/plugins/modules/get_certificate.py b/ansible_collections/community/crypto/plugins/modules/get_certificate.py index 6ae9439d3..d4b38afbd 100644 --- a/ansible_collections/community/crypto/plugins/modules/get_certificate.py +++ b/ansible_collections/community/crypto/plugins/modules/get_certificate.py @@ -220,10 +220,6 @@ from ansible.module_utils.common.text.converters import to_bytes from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion -from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( - get_now_datetime, -) - from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( CRYPTOGRAPHY_TIMEZONE, cryptography_oid_to_name, @@ -232,6 +228,10 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptograp get_not_valid_before, ) +from ansible_collections.community.crypto.plugins.module_utils.time import ( + get_now_datetime, +) + MINIMAL_CRYPTOGRAPHY_VERSION = '1.6' CREATE_DEFAULT_CONTEXT_IMP_ERR = None diff --git a/ansible_collections/community/crypto/plugins/modules/x509_certificate_info.py b/ansible_collections/community/crypto/plugins/modules/x509_certificate_info.py index 8379937f7..9e8c20e29 100644 --- a/ansible_collections/community/crypto/plugins/modules/x509_certificate_info.py +++ b/ansible_collections/community/crypto/plugins/modules/x509_certificate_info.py @@ -406,10 +406,6 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.basic impo OpenSSLObjectError, ) -from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( - get_relative_time_option, -) - from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( CRYPTOGRAPHY_TIMEZONE, ) @@ -418,6 +414,10 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.module_bac select_backend, ) +from ansible_collections.community.crypto.plugins.module_utils.time import ( + get_relative_time_option, +) + def main(): module = AnsibleModule( diff --git a/ansible_collections/community/crypto/plugins/modules/x509_crl.py b/ansible_collections/community/crypto/plugins/modules/x509_crl.py index 527975b88..f8eb8d85e 100644 --- a/ansible_collections/community/crypto/plugins/modules/x509_crl.py +++ b/ansible_collections/community/crypto/plugins/modules/x509_crl.py @@ -470,7 +470,6 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.support im load_certificate, parse_name_field, parse_ordered_name_field, - get_relative_time_option, select_message_digest, ) @@ -506,6 +505,10 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.module_bac get_crl_info, ) +from ansible_collections.community.crypto.plugins.module_utils.time import ( + get_relative_time_option, +) + MINIMAL_CRYPTOGRAPHY_VERSION = '1.2' CRYPTOGRAPHY_IMP_ERR = None |