diff --git a/plugins/module_utils/acme/acme.py b/plugins/module_utils/acme/acme.py index 4f62357cd..ff91da7c4 100644 --- a/plugins/module_utils/acme/acme.py +++ b/plugins/module_utils/acme/acme.py @@ -390,6 +390,7 @@ def get_request(self, uri, parse_json_result=True, headers=None, get_only=False, def get_renewal_info( self, + cert_info=None, cert_filename=None, cert_content=None, include_retry_after=False, @@ -398,7 +399,7 @@ def get_renewal_info( if not self.directory.has_renewal_info_endpoint(): raise ModuleFailException('The ACME endpoint does not support ACME Renewal Information retrieval') - cert_id = compute_cert_id(self.backend, cert_filename=cert_filename, cert_content=cert_content) + cert_id = compute_cert_id(self.backend, cert_info=cert_info, cert_filename=cert_filename, cert_content=cert_content) url = '{base}{cert_id}'.format(base=self.directory.directory['renewalInfo'], cert_id=cert_id) data, info = self.get_request(url, parse_json_result=True, fail_on_error=True, get_only=True) diff --git a/plugins/module_utils/acme/backend_cryptography.py b/plugins/module_utils/acme/backend_cryptography.py index 9f7770aea..b652240dc 100644 --- a/plugins/module_utils/acme/backend_cryptography.py +++ b/plugins/module_utils/acme/backend_cryptography.py @@ -38,6 +38,10 @@ from ansible_collections.community.crypto.plugins.module_utils.acme.utils import nopad_b64 +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + from ansible_collections.community.crypto.plugins.module_utils.crypto.math import ( convert_int_to_bytes, convert_int_to_hex, @@ -64,6 +68,7 @@ from_epoch_seconds, get_epoch_seconds, get_now_datetime, + get_relative_time_option, UTC, ) @@ -187,6 +192,12 @@ def get_now(self): def parse_acme_timestamp(self, timestamp_str): return _parse_acme_timestamp(timestamp_str, with_timezone=CRYPTOGRAPHY_TIMEZONE) + def parse_module_parameter(self, value, name): + try: + return get_relative_time_option(value, name, backend='cryptography', with_timezone=CRYPTOGRAPHY_TIMEZONE) + except OpenSSLObjectError as exc: + raise BackendException(to_native(exc)) + def interpolate_timestamp(self, timestamp_start, timestamp_end, percentage): start = get_epoch_seconds(timestamp_start) end = get_epoch_seconds(timestamp_end) diff --git a/plugins/module_utils/acme/backends.py b/plugins/module_utils/acme/backends.py index 421c595ad..7c08fae95 100644 --- a/plugins/module_utils/acme/backends.py +++ b/plugins/module_utils/acme/backends.py @@ -15,16 +15,22 @@ import re from ansible.module_utils import six +from ansible.module_utils.common.text.converters import to_native from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( BackendException, ) +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + from ansible_collections.community.crypto.plugins.module_utils.time import ( ensure_utc_timezone, from_epoch_seconds, get_epoch_seconds, get_now_datetime, + get_relative_time_option, remove_timezone, ) @@ -89,6 +95,12 @@ def parse_acme_timestamp(self, timestamp_str): # RFC 3339 (https://www.rfc-editor.org/info/rfc3339) return _parse_acme_timestamp(timestamp_str, with_timezone=False) + def parse_module_parameter(self, value, name): + try: + return get_relative_time_option(value, name, backend='cryptography', with_timezone=False) + except OpenSSLObjectError as exc: + raise BackendException(to_native(exc)) + def interpolate_timestamp(self, timestamp_start, timestamp_end, percentage): start = get_epoch_seconds(timestamp_start) end = get_epoch_seconds(timestamp_end) diff --git a/plugins/modules/acme_certificate_renewal_info.py b/plugins/modules/acme_certificate_renewal_info.py new file mode 100644 index 000000000..4279c75c2 --- /dev/null +++ b/plugins/modules/acme_certificate_renewal_info.py @@ -0,0 +1,266 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2018 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_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 +''' + +import os +import random + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( + create_backend, + get_default_argspec, + ACMEClient, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException + + +def main(): + argument_spec = get_default_argspec(with_account=False) + argument_spec.update(dict( + 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'), + )) + module = AnsibleModule( + argument_spec=argument_spec, + mutually_exclusive=( + ['certificate_path', 'certificate_content'], + ), + supports_check_mode=True, + ) + backend = create_backend(module, True) + + if not module.params['certificate_path'] and not module.params['certificate_content']: + module.exit_json( + changed=False, + should_renew=True, + msg='No certificate was specified', + supports_ari=False, + ) + + if module.params['certificate_path'] is not None and not os.path.exists(module.params['certificate_path']): + module.exit_json( + changed=False, + should_renew=True, + msg='The certificate file does not exist', + supports_ari=False, + ) + + try: + cert_info = backend.get_cert_information( + cert_filename=module.params['certificate_path'], + cert_content=module.params['certificate_content'], + ) + + if module.params['now']: + now = backend.parse_module_parameter(module.params['now'], 'now') + else: + now = backend.get_now() + + no_renewal_msg = 'The certificate is still valid and no condition was reached' + renewal_ari = False + + if now >= cert_info.not_valid_after: + module.exit_json( + changed=False, + should_renew=True, + msg='The certificate already expired', + supports_ari=False, + ) + + client = ACMEClient(module, backend) + if client.directory.has_renewal_info_endpoint(): + renewal_info = client.get_renewal_info(cert_info=cert_info) + 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']) + renewal_ari = True + if now > window_end: + module.exit_json( + changed=False, + should_renew=True, + msg='The suggested renewal interval provided by ARI is in the past{0}'.format(msg_append), + supports_ari=True, + ) + if module.params['ari_algorithm'] == 'start': + if now > window_start: + module.exit_json( + changed=False, + should_renew=True, + msg='The suggested renewal interval provided by ARI has begun{0}'.format(msg_append), + supports_ari=True, + ) + else: + random_time = backend.interpolate_timestamp(window_start, window_end, random.random()) + if now > random_time: + module.exit_json( + changed=False, + should_renew=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), + supports_ari=True, + ) + + # TODO check remaining_days + if module.params['remaining_days'] is not None: + remaining_days = (cert_info.not_valid_after - now).days + if remaining_days < module.params['remaining_days']: + module.exit_json( + changed=False, + should_renew=True, + msg='The certificate expires in {0} days'.format(remaining_days), + supports_ari=False, + ) + + # TODO check remaining_percentage + 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: + module.exit_json( + changed=False, + should_renew=True, + msg="The remaining percentage {0}% of the certificate's lifespan was reached on {1}".format( + module.params['remaining_percentage'] * 100, + timestamp, + ), + supports_ari=False, + ) + + module.exit_json( + changed=False, + should_renew=False, + msg=no_renewal_msg, + supports_ari=renewal_ari, + ) + except ModuleFailException as e: + e.do_fail(module) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/acme_certificate_renewal_info/aliases b/tests/integration/targets/acme_certificate_renewal_info/aliases new file mode 100644 index 000000000..b7f6d4f48 --- /dev/null +++ b/tests/integration/targets/acme_certificate_renewal_info/aliases @@ -0,0 +1,10 @@ +# 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 + +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_renewal_info/meta/main.yml b/tests/integration/targets/acme_certificate_renewal_info/meta/main.yml new file mode 100644 index 000000000..2e8ad10b8 --- /dev/null +++ b/tests/integration/targets/acme_certificate_renewal_info/meta/main.yml @@ -0,0 +1,8 @@ +--- +# 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 diff --git a/tests/integration/targets/acme_certificate_renewal_info/tasks/impl.yml b/tests/integration/targets/acme_certificate_renewal_info/tasks/impl.yml new file mode 100644 index 000000000..2135fd490 --- /dev/null +++ b/tests/integration/targets/acme_certificate_renewal_info/tasks/impl.yml @@ -0,0 +1,114 @@ +--- +# 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 + +## SET UP ACCOUNT KEYS ######################################################################## +- block: + - name: Generate account keys + openssl_privatekey: + path: "{{ remote_tmp_dir }}/{{ item.name }}.pem" + type: "{{ item.type }}" + size: "{{ item.size | default(omit) }}" + curve: "{{ item.curve | default(omit) }}" + force: true + loop: "{{ account_keys }}" + + vars: + account_keys: + - name: account-ec256 + type: ECC + curve: secp256r1 +## CREATE ACCOUNTS AND OBTAIN CERTIFICATES #################################################### +- name: Obtain cert 1 + include_tasks: obtain-cert.yml + vars: + certgen_title: Certificate 1 for renewal check + certificate_name: cert-1 + key_type: rsa + rsa_bits: "{{ default_rsa_key_size }}" + subject_alt_name: "DNS:example.com" + subject_alt_name_critical: false + account_key: account-ec256 + challenge: http-01 + modify_account: true + deactivate_authzs: false + force: true + remaining_days: "{{ omit }}" + terms_agreed: true + account_email: "example@example.org" +## OBTAIN CERTIFICATE INFOS ################################################################### +- name: Obtain certificate information + x509_certificate_info: + path: "{{ remote_tmp_dir }}/cert-1.pem" + register: cert_1_info +- name: Read certificate + slurp: + src: '{{ remote_tmp_dir }}/cert-1.pem' + register: slurp_cert_1 +- name: Obtain certificate information (1/6) + acme_certificate_renewal_info: + select_crypto_backend: "{{ select_crypto_backend }}" + certificate_path: "{{ remote_tmp_dir }}/cert-1.pem" + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: false + # Certificate is valid for ~1826 days + register: cert_1_renewal_1 +- name: Obtain certificate information (2/6) + acme_certificate_renewal_info: + select_crypto_backend: "{{ select_crypto_backend }}" + certificate_path: "{{ remote_tmp_dir }}/cert-1.pem" + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: false + # Certificate is valid for ~1826 days + remaining_days: 1000 + remaining_percentage: 0.5 + register: cert_1_renewal_2 +- name: Obtain certificate information (3/6) + acme_certificate_renewal_info: + select_crypto_backend: "{{ select_crypto_backend }}" + certificate_content: "{{ slurp_cert_1.content | b64decode }}" + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: false + now: +1800d + # Certificate is valid for ~26 days + register: cert_1_renewal_3 +- name: Obtain certificate information (4/6) + acme_certificate_renewal_info: + select_crypto_backend: "{{ select_crypto_backend }}" + certificate_path: "{{ remote_tmp_dir }}/cert-1.pem" + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: false + now: +1800d + # Certificate is valid for ~26 days + remaining_days: 30 + remaining_percentage: 0.1 + register: cert_1_renewal_4 +- name: Obtain certificate information (5/6) + acme_certificate_renewal_info: + select_crypto_backend: "{{ select_crypto_backend }}" + certificate_path: "{{ remote_tmp_dir }}/cert-1.pem" + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: false + now: +1800d + # Certificate is valid for ~26 days + remaining_days: 30 + remaining_percentage: 0.01 + register: cert_1_renewal_5 +- name: Obtain certificate information (6/6) + acme_certificate_renewal_info: + select_crypto_backend: "{{ select_crypto_backend }}" + certificate_path: "{{ remote_tmp_dir }}/cert-1.pem" + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: false + now: +1800d + # Certificate is valid for ~26 days + remaining_days: 10 + remaining_percentage: 0.03 + register: cert_1_renewal_6 diff --git a/tests/integration/targets/acme_certificate_renewal_info/tasks/main.yml b/tests/integration/targets/acme_certificate_renewal_info/tasks/main.yml new file mode 100644 index 000000000..68d47973d --- /dev/null +++ b/tests/integration/targets/acme_certificate_renewal_info/tasks/main.yml @@ -0,0 +1,40 @@ +--- +# 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 + + - import_tasks: ../tests/validate.yml + + # 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 + + - import_tasks: ../tests/validate.yml + + when: cryptography_version.stdout is version('1.5', '>=') diff --git a/tests/integration/targets/acme_certificate_renewal_info/tasks/obtain-cert.yml b/tests/integration/targets/acme_certificate_renewal_info/tasks/obtain-cert.yml new file mode 120000 index 000000000..532df9452 --- /dev/null +++ b/tests/integration/targets/acme_certificate_renewal_info/tasks/obtain-cert.yml @@ -0,0 +1 @@ +../../setup_acme/tasks/obtain-cert.yml \ No newline at end of file diff --git a/tests/integration/targets/acme_certificate_renewal_info/tests/validate.yml b/tests/integration/targets/acme_certificate_renewal_info/tests/validate.yml new file mode 100644 index 000000000..ef3a821d7 --- /dev/null +++ b/tests/integration/targets/acme_certificate_renewal_info/tests/validate.yml @@ -0,0 +1,28 @@ +--- +# 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: Validate results + assert: + that: + - cert_1_renewal_1.should_renew == false + - cert_1_renewal_1.msg == 'The certificate is still valid and no condition was reached' + - cert_1_renewal_1.supports_ari == supports_ari + - cert_1_renewal_2.should_renew == false + - cert_1_renewal_2.msg == 'The certificate is still valid and no condition was reached' + - cert_1_renewal_2.supports_ari == supports_ari + - cert_1_renewal_3.should_renew == false + - cert_1_renewal_3.msg == 'The certificate is still valid and no condition was reached' + - cert_1_renewal_3.supports_ari == supports_ari + - cert_1_renewal_4.should_renew == true + - cert_1_renewal_4.msg == 'The certificate expires in 25 days' + - cert_1_renewal_4.supports_ari == supports_ari + - cert_1_renewal_5.should_renew == true + - cert_1_renewal_5.msg == 'The certificate expires in 25 days' + - cert_1_renewal_5.supports_ari == supports_ari + - cert_1_renewal_6.should_renew == true + - cert_1_renewal_6.msg.startswith("The remaining percentage 3.0% of the certificate's lifespan was reached on ") + - cert_1_renewal_6.supports_ari == supports_ari + vars: + supports_ari: false