From a99cfc1814ffa0b75c27fbbbdb48d074eda08571 Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Fri, 17 Aug 2018 15:32:09 +0200 Subject: [PATCH] New acme_account_facts module. (#44301) --- lib/ansible/module_utils/acme.py | 16 +- .../modules/crypto/acme/acme_account_facts.py | 154 ++++++++++++++++++ .../targets/acme_account_facts/aliases | 2 + .../targets/acme_account_facts/meta/main.yml | 2 + .../targets/acme_account_facts/tasks/impl.yml | 82 ++++++++++ .../targets/acme_account_facts/tasks/main.yml | 31 ++++ .../acme_account_facts/tests/validate.yml | 38 +++++ 7 files changed, 321 insertions(+), 4 deletions(-) create mode 100644 lib/ansible/modules/crypto/acme/acme_account_facts.py create mode 100644 test/integration/targets/acme_account_facts/aliases create mode 100644 test/integration/targets/acme_account_facts/meta/main.yml create mode 100644 test/integration/targets/acme_account_facts/tasks/impl.yml create mode 100644 test/integration/targets/acme_account_facts/tasks/main.yml create mode 100644 test/integration/targets/acme_account_facts/tests/validate.yml diff --git a/lib/ansible/module_utils/acme.py b/lib/ansible/module_utils/acme.py index 153bbf5982..68f95e3b1c 100644 --- a/lib/ansible/module_utils/acme.py +++ b/lib/ansible/module_utils/acme.py @@ -681,13 +681,17 @@ class ACMEAccount(object): if self.version == 1: data['resource'] = 'reg' result, info = self.send_signed_request(self.uri, data) - if info['status'] == 403 and result.get('type') == 'urn:ietf:params:acme:error:unauthorized': + if info['status'] in (400, 403) and result.get('type') == 'urn:ietf:params:acme:error:unauthorized': + # Returned when account is deactivated + return None + if info['status'] in (400, 404) and result.get('type') == 'urn:ietf:params:acme:error:accountDoesNotExist': + # Returned when account does not exist return None if info['status'] < 200 or info['status'] >= 300: raise ModuleFailException("Error getting account data from {2}: {0} {1}".format(info['status'], result, self.uri)) return result - def init_account(self, contact, agreement=None, terms_agreed=False, allow_creation=True, update_contact=True): + def init_account(self, contact, agreement=None, terms_agreed=False, allow_creation=True, update_contact=True, remove_account_uri_if_not_exists=False): ''' Create or update an account on the ACME server. For ACME v1, as the only way (without knowing an account URI) to test if an @@ -717,7 +721,11 @@ class ACMEAccount(object): if not update_contact: # Verify that the account key belongs to the URI. # (If update_contact is True, this will be done below.) - self.get_account_data() + if self.get_account_data() is None: + if remove_account_uri_if_not_exists and not allow_creation: + self.uri = None + return False + raise ModuleFailException("Account is deactivated or does not exist!") else: new_account = self._new_reg( contact, @@ -733,7 +741,7 @@ class ACMEAccount(object): if not allow_creation: self.uri = None return False - raise ModuleFailException("Account is deactivated!") + raise ModuleFailException("Account is deactivated or does not exist!") # ...and check if update is necessary if result.get('contact', []) != contact: diff --git a/lib/ansible/modules/crypto/acme/acme_account_facts.py b/lib/ansible/modules/crypto/acme/acme_account_facts.py new file mode 100644 index 0000000000..34b369c3be --- /dev/null +++ b/lib/ansible/modules/crypto/acme/acme_account_facts.py @@ -0,0 +1,154 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2018 Felix Fontein +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + + +DOCUMENTATION = ''' +--- +module: acme_account_facts +author: "Felix Fontein (@felixfontein)" +version_added: "2.7" +short_description: Retrieves information on ACME accounts. +description: + - "Allows to retrieve information on accounts with Let's Encrypt. + Let's Encrypt is a free, automated, and open certificate authority + (CA), run for the public's benefit. For details see U(https://letsencrypt.org)." + - "The M(acme_account) module allows to modify, create and delete ACME accounts." + - "This module only works with the ACME v2 protocol." +extends_documentation_fragment: + - acme +''' + +EXAMPLES = ''' +- name: Check whether an account with the given account key exists + acme_account_facts: + account_key_src: /etc/pki/cert/private/account.key + register: account_data +- name: Verify that account exists + assert: + that: + - account_data.exists +- name: Print account URI + debug: var=account_data.account_uri +- name: Print account contacts + debug: var=account_data.account.contact + +- name: Check whether the account exists and is accessible with the given account key + acme_account_facts: + account_key_content: "{{ acme_account_key }}" + account_uri: "{{ acme_account_uri }}" + register: account_data +- name: Verify that account exists + assert: + that: + - account_data.exists +- name: Print account contacts + debug: var=account_data.account.contact +''' + +RETURN = ''' +exists: + description: Whether the account exists. + returned: always + type: bool + +account_uri: + description: ACME account URI, or None if account does not exist. + returned: always + type: string + +account: + description: The account information, as retrieved from the ACME server. + returned: if account exists + type: complex + contains: + contact: + description: the challenge resource that must be created for validation + returned: always + type: list + sample: "['mailto:me@example.com', 'tel:00123456789']" + status: + description: the account's status + returned: always + type: str + choices: ['valid', 'deactivated', 'revoked'] + sample: valid + orders: + description: a URL where a list of orders can be retrieved for this account + returned: always + type: str + sample: https://example.ca/account/1/orders +''' + +from ansible.module_utils.acme import ( + ModuleFailException, ACMEAccount, set_crypto_backend, +) + +from ansible.module_utils.basic import AnsibleModule + + +def main(): + module = AnsibleModule( + argument_spec=dict( + account_key_src=dict(type='path', aliases=['account_key']), + account_key_content=dict(type='str', no_log=True), + account_uri=dict(required=False, type='str'), + acme_directory=dict(required=False, default='https://acme-staging.api.letsencrypt.org/directory', type='str'), + acme_version=dict(required=False, default=1, choices=[1, 2], type='int'), + validate_certs=dict(required=False, default=True, type='bool'), + select_crypto_backend=dict(required=False, choices=['auto', 'openssl', 'cryptography'], default='auto', type='str'), + ), + required_one_of=( + ['account_key_src', 'account_key_content'], + ), + mutually_exclusive=( + ['account_key_src', 'account_key_content'], + ), + supports_check_mode=True, + ) + set_crypto_backend(module) + + if not module.params.get('validate_certs'): + module.warn(warning='Disabling certificate validation for communications with ACME endpoint. ' + + 'This should only be done for testing against a local ACME server for ' + + 'development purposes, but *never* for production purposes.') + if module.params.get('acme_version') < 2: + module.fail_json(msg='The acme_account module requires the ACME v2 protocol!') + + try: + account = ACMEAccount(module) + # Check whether account exists + changed = account.init_account( + [], + allow_creation=False, + update_contact=False, + remove_account_uri_if_not_exists=True, + ) + if changed: + raise AssertionError('Unwanted account change') + if account.uri is None: + # Account does exist + module.exit_json(changed=False, exists=False, account_uri=None) + else: + # Account exists: retrieve account information + data = account.get_account_data() + # Make sure promised data is there + if 'contact' not in data: + data['contact'] = [] + module.exit_json(changed=False, exists=True, account_uri=account.uri, account=data) + except ModuleFailException as e: + e.do_fail(module) + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/acme_account_facts/aliases b/test/integration/targets/acme_account_facts/aliases new file mode 100644 index 0000000000..d793633030 --- /dev/null +++ b/test/integration/targets/acme_account_facts/aliases @@ -0,0 +1,2 @@ +shippable/cloud/group1 +cloud/acme diff --git a/test/integration/targets/acme_account_facts/meta/main.yml b/test/integration/targets/acme_account_facts/meta/main.yml new file mode 100644 index 0000000000..81d1e7e77a --- /dev/null +++ b/test/integration/targets/acme_account_facts/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - setup_acme diff --git a/test/integration/targets/acme_account_facts/tasks/impl.yml b/test/integration/targets/acme_account_facts/tasks/impl.yml new file mode 100644 index 0000000000..bc8678bebc --- /dev/null +++ b/test/integration/targets/acme_account_facts/tasks/impl.yml @@ -0,0 +1,82 @@ +--- +- name: Generate account key + command: openssl ecparam -name prime256v1 -genkey -out {{ output_dir }}/accountkey.pem + +- name: Generate second account key + command: openssl ecparam -name prime256v1 -genkey -out {{ output_dir }}/accountkey2.pem + +- name: Parse account key (to ease debugging some test failures) + command: openssl ec -in {{ output_dir }}/accountkey.pem -noout -text + +- name: Check that account does not exist + acme_account_facts: + select_crypto_backend: "{{ select_crypto_backend }}" + account_key_src: "{{ output_dir }}/accountkey.pem" + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no + register: account_not_created + +- name: Create it now + acme_account: + select_crypto_backend: "{{ select_crypto_backend }}" + account_key_src: "{{ output_dir }}/accountkey.pem" + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no + state: present + allow_creation: yes + terms_agreed: yes + contact: + - mailto:example@example.org + +- name: Check that account exists + acme_account_facts: + select_crypto_backend: "{{ select_crypto_backend }}" + account_key_src: "{{ output_dir }}/accountkey.pem" + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no + register: account_created + +- name: Clear email address + acme_account: + select_crypto_backend: "{{ select_crypto_backend }}" + account_key_content: "{{ lookup('file', output_dir ~ '/accountkey.pem') }}" + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no + state: present + allow_creation: no + contact: [] + +- name: Check that account was modified + acme_account_facts: + select_crypto_backend: "{{ select_crypto_backend }}" + account_key_src: "{{ output_dir }}/accountkey.pem" + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no + account_uri: "{{ account_created.account_uri }}" + register: account_modified + +- name: Check with wrong account URI + acme_account_facts: + select_crypto_backend: "{{ select_crypto_backend }}" + account_key_src: "{{ output_dir }}/accountkey.pem" + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no + account_uri: "{{ account_created.account_uri }}test1234doesnotexists" + register: account_not_exist + +- name: Check with wrong account key + acme_account_facts: + select_crypto_backend: "{{ select_crypto_backend }}" + account_key_src: "{{ output_dir }}/accountkey2.pem" + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no + account_uri: "{{ account_created.account_uri }}" + ignore_errors: yes + register: account_wrong_key diff --git a/test/integration/targets/acme_account_facts/tasks/main.yml b/test/integration/targets/acme_account_facts/tasks/main.yml new file mode 100644 index 0000000000..e46c6dc4d0 --- /dev/null +++ b/test/integration/targets/acme_account_facts/tasks/main.yml @@ -0,0 +1,31 @@ +--- +- 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: "{{ output_dir }}" + state: absent + +- name: Re-create output directory + file: + path: "{{ output_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/test/integration/targets/acme_account_facts/tests/validate.yml b/test/integration/targets/acme_account_facts/tests/validate.yml new file mode 100644 index 0000000000..f44008c546 --- /dev/null +++ b/test/integration/targets/acme_account_facts/tests/validate.yml @@ -0,0 +1,38 @@ +--- +- name: Validate that account wasn't there + assert: + that: + - not account_not_created.exists + - account_not_created.account_uri is none + - "'account' not in account_not_created" + +- name: Validate that account was created + assert: + that: + - account_created.exists + - account_created.account_uri is not none + - "'account' in account_created" + - "'contact' in account_created.account" + - account_created.account.contact | length == 1 + - "account_created.account.contact[0] == 'mailto:example@example.org'" + +- name: Validate that account email was removed + assert: + that: + - account_modified.exists + - account_modified.account_uri is not none + - "'account' in account_modified" + - "'contact' in account_modified.account" + - account_modified.account.contact | length == 0 + +- name: Validate that account does not exist with wrong account URI + assert: + that: + - not account_not_exist.exists + - account_not_exist.account_uri is none + - "'account' not in account_not_exist" + +- name: Validate that account cannot be accessed with wrong key + assert: + that: + - account_wrong_key is failed