diff --git a/lib/ansible/module_utils/acme.py b/lib/ansible/module_utils/acme.py index 0217786e9f4..16837550f41 100644 --- a/lib/ansible/module_utils/acme.py +++ b/lib/ansible/module_utils/acme.py @@ -59,8 +59,8 @@ class ModuleFailException(Exception): self.msg = msg self.module_fail_args = args - def do_fail(self, module): - module.fail_json(msg=self.msg, other=self.module_fail_args) + def do_fail(self, module, **arguments): + module.fail_json(msg=self.msg, other=self.module_fail_args, **arguments) def nopad_b64(data): @@ -518,12 +518,16 @@ class ACMEAccount(object): else: return _parse_key_openssl(self._openssl_bin, self.module, key_file, key_content) - def sign_request(self, protected, payload, key_data): + def sign_request(self, protected, payload, key_data, encode_payload=True): try: if payload is None: + # POST-as-GET payload64 = '' else: - payload64 = nopad_b64(self.module.jsonify(payload).encode('utf8')) + # POST + if encode_payload: + payload = self.module.jsonify(payload).encode('utf8') + payload64 = nopad_b64(to_bytes(payload)) protected64 = nopad_b64(self.module.jsonify(protected).encode('utf8')) except Exception as e: raise ModuleFailException("Failed to encode payload / headers as JSON: {0}".format(e)) @@ -533,7 +537,7 @@ class ACMEAccount(object): else: return _sign_request_openssl(self._openssl_bin, self.module, payload64, protected64, key_data) - def send_signed_request(self, url, payload, key_data=None, jws_header=None, parse_json_result=True): + def send_signed_request(self, url, payload, key_data=None, jws_header=None, parse_json_result=True, encode_payload=True): ''' Sends a JWS signed HTTP POST request to the ACME server and returns the response as dictionary @@ -551,7 +555,7 @@ class ACMEAccount(object): if self.version != 1: protected["url"] = url - data = self.sign_request(protected, payload, key_data) + data = self.sign_request(protected, payload, key_data, encode_payload=encode_payload) if self.version == 1: data["header"] = jws_header data = self.module.jsonify(data) @@ -588,7 +592,7 @@ class ACMEAccount(object): return result, info - def get_request(self, uri, parse_json_result=True, headers=None, get_only=False): + def get_request(self, uri, parse_json_result=True, headers=None, get_only=False, fail_on_error=True): ''' Perform a GET-like request. Will try POST-as-GET for ACMEv2, with fallback to GET if server replies with a status code of 405. @@ -626,7 +630,7 @@ class ACMEAccount(object): else: result = content - if info['status'] >= 400: + if fail_on_error and info['status'] >= 400: raise ModuleFailException("ACME request failed: CODE: {0} RESULT: {1}".format(info['status'], result)) return result, info diff --git a/lib/ansible/modules/crypto/acme/acme_certificate.py b/lib/ansible/modules/crypto/acme/acme_certificate.py index c3eb396805e..0bec2b8ef04 100644 --- a/lib/ansible/modules/crypto/acme/acme_certificate.py +++ b/lib/ansible/modules/crypto/acme/acme_certificate.py @@ -51,6 +51,8 @@ notes: M(acme_challenge_cert_helper) module to prepare the challenge certificate." - "You can use the M(certificate_complete_chain) module to find the root certificate for the returned fullchain." + - "In case you want to debug problems, you might be interested in the M(acme_inspect) + module." extends_documentation_fragment: - acme options: diff --git a/lib/ansible/modules/crypto/acme/acme_inspect.py b/lib/ansible/modules/crypto/acme/acme_inspect.py new file mode 100644 index 00000000000..65abfaccdee --- /dev/null +++ b/lib/ansible/modules/crypto/acme/acme_inspect.py @@ -0,0 +1,319 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2018 Felix Fontein (@felixfontein) +# 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 = r''' +--- +module: acme_inspect +author: "Felix Fontein (@felixfontein)" +version_added: "2.8" +short_description: Send direct requests to an ACME server +description: + - "Allows to send direct requests to an ACME server with the + L(ACME protocol,https://tools.ietf.org/html/draft-ietf-acme-acme-14), + which is supported by CAs such as L(Let's Encrypt,https://letsencrypt.org/)." + - "This module can be used to debug failed certificate request attempts, + for example when M(acme_certificate) fails or encounters a problem which + you wish to investigate." + - "The module can also be used to directly access features of an ACME servers + which are not yet supported by the Ansible ACME modules." +notes: + - "The I(account_uri) option must be specified for properly authenticated + ACME v2 requests (except a C(new-account) request)." + - "Using the C(ansible) tool, M(acme_inspect) can be used to directly execute + ACME requests without the need of writing a playbook. For example, the + following command retrieves the ACME account with ID 1 from Let's Encrypt + (assuming C(/path/to/key) is the correct private account key): + C(ansible localhost -m acme_inspect -a \"account_key_src=/path/to/key + acme_directory=https://acme-v02.api.letsencrypt.org/directory acme_version=2 + account_uri=https://acme-v02.api.letsencrypt.org/acme/acct/1 method=get + url=https://acme-v02.api.letsencrypt.org/acme/acct/1\")" +extends_documentation_fragment: + - acme +options: + url: + description: + - "The URL to send the request to." + - "Must be specified if I(method) is not C(directory-only)." + type: str + method: + description: + - "The method to use to access the given URL on the ACME server." + - "The value C(post) executes an authenticated POST request. The content + must be specified in the I(content) option." + - "The value C(get) executes an authenticated POST-as-GET request for ACME v2, + and a regular GET request for ACME v1." + - "The value C(directory-only) only retrieves the directory, without doing + a request." + choices: + - get + - post + - directory-only + default: get + content: + description: + - "An encoded JSON object which will be sent as the content if I(method) + is C(post)." + - "Required when I(method) is C(post), and not allowed otherwise." + type: str + fail_on_acme_error: + description: + - "If I(method) is C(post) or C(get), make the module fail in case an ACME + error is returned." + type: bool + default: yes +''' + +EXAMPLES = r''' +- name: Get directory + acme_inspect: + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_version: 2 + method: directory-only + register: directory + +- name: Create an account + acme_inspect: + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_version: 2 + account_key_src: /etc/pki/cert/private/account.key + url: "{{ directory.newAccount}}" + method: post + content: '{"termsOfServiceAgreed":true}' + register: account_creation + # account_creation.headers.location contains the account URI + # if creation was successful + +- name: Get account information + acme_inspect: + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_version: 2 + account_key_src: /etc/pki/cert/private/account.key + account_uri: "{{ account_creation.headers.location }}" + url: "{{ account_creation.headers.location }}" + method: get + +- name: Update account contacts + acme_inspect: + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_version: 2 + account_key_src: /etc/pki/cert/private/account.key + account_uri: "{{ account_creation.headers.location }}" + url: "{{ account_creation.headers.location }}" + method: post + content: '{{ account_info | to_json }}' + vars: + account_info: + # For valid values, see + # https://tools.ietf.org/html/draft-ietf-acme-acme-16#section-7.3 + contact: + - mailto:me@example.com + +- name: Create certificate order + acme_certificate: + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_version: 2 + account_key_src: /etc/pki/cert/private/account.key + account_uri: "{{ account_creation.headers.location }}" + csr: /etc/pki/cert/csr/sample.com.csr + fullchain_dest: /etc/httpd/ssl/sample.com-fullchain.crt + challenge: http-01 + register: certificate_request + +# Assume something went wrong. certificate_request.order_uri contains +# the order URI. + +- name: Get order information + acme_inspect: + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_version: 2 + account_key_src: /etc/pki/cert/private/account.key + account_uri: "{{ account_creation.headers.location }}" + url: "{{ certificate_request.order_uri }}" + method: get + register: order + +- name: Get first authz for order + acme_inspect: + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_version: 2 + account_key_src: /etc/pki/cert/private/account.key + account_uri: "{{ account_creation.headers.location }}" + url: "{{ order.output_json.authorizations[0] }}" + method: get + register: authz + +- name: Get HTTP-01 challenge for authz + acme_inspect: + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_version: 2 + account_key_src: /etc/pki/cert/private/account.key + account_uri: "{{ account_creation.headers.location }}" + url: "{{ authz.output_json.challenges | selectattr('type', 'equalto', 'http-01') }}" + method: get + register: http01challenge + +- name: Activate HTTP-01 challenge manually + acme_inspect: + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_version: 2 + account_key_src: /etc/pki/cert/private/account.key + account_uri: "{{ account_creation.headers.location }}" + url: "{{ http01challenge.url }}" + method: post + content: '{}' +''' + +RETURN = ''' +directory: + description: The ACME directory's content + returned: always + type: dict + sample: | + { + "a85k3x9f91A4": "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417", + "keyChange": "https://acme-v02.api.letsencrypt.org/acme/key-change", + "meta": { + "caaIdentities": [ + "letsencrypt.org" + ], + "termsOfService": "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf", + "website": "https://letsencrypt.org" + }, + "newAccount": "https://acme-v02.api.letsencrypt.org/acme/new-acct", + "newNonce": "https://acme-v02.api.letsencrypt.org/acme/new-nonce", + "newOrder": "https://acme-v02.api.letsencrypt.org/acme/new-order", + "revokeCert": "https://acme-v02.api.letsencrypt.org/acme/revoke-cert" + } +headers: + description: The request's HTTP headers (with lowercase keys) + returned: always + type: dict + sample: | + { + "boulder-requester": "12345", + "cache-control": "max-age=0, no-cache, no-store", + "connection": "close", + "content-length": "904", + "content-type": "application/json", + "cookies": {}, + "cookies_string": "", + "date": "Wed, 07 Nov 2018 12:34:56 GMT", + "expires": "Wed, 07 Nov 2018 12:44:56 GMT", + "link": ";rel=\"terms-of-service\"", + "msg": "OK (904 bytes)", + "pragma": "no-cache", + "replay-nonce": "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGH", + "server": "nginx", + "status": 200, + "strict-transport-security": "max-age=604800", + "url": "https://acme-v02.api.letsencrypt.org/acme/acct/46161", + "x-frame-options": "DENY" + } +output_text: + description: The raw text output + returned: always + type: string + sample: "{\\n \\\"id\\\": 12345,\\n \\\"key\\\": {\\n \\\"kty\\\": \\\"RSA\\\",\\n ..." +output_json: + description: The output parsed as JSON + returned: if output can be parsed as JSON + type: dict + sample: + - id: 12345 + - key: + - kty: RSA + - ... +''' + +from ansible.module_utils.acme import ( + ModuleFailException, ACMEAccount, set_crypto_backend, +) + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native, to_bytes + +import json + + +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'), + url=dict(required=False, type='str'), + method=dict(required=False, type='str', choices=['get', 'post', 'directory-only'], default='get'), + content=dict(required=False, type='str'), + fail_on_acme_error=dict(required=False, type='bool', default=True), + select_crypto_backend=dict(required=False, choices=['auto', 'openssl', 'cryptography'], default='auto', type='str'), + ), + mutually_exclusive=( + ['account_key_src', 'account_key_content'], + ), + required_if=( + ['method', 'get', ['url']], + ['method', 'post', ['url', 'content']], + ['method', 'get', ['account_key_src', 'account_key_content'], True], + ['method', 'post', ['account_key_src', 'account_key_content'], 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.') + + result = dict() + changed = False + try: + # Get hold of ACMEAccount object (includes directory) + account = ACMEAccount(module) + method = module.params['method'] + result['directory'] = account.directory.directory + # Do we have to do more requests? + if method != 'directory-only': + url = module.params['url'] + fail_on_acme_error = module.params['fail_on_acme_error'] + # Do request + if method == 'get': + data, info = account.get_request(url, parse_json_result=False, fail_on_error=False) + elif method == 'post': + changed = True # only POSTs can change + data, info = account.send_signed_request(url, to_bytes(module.params['content']), parse_json_result=False, encode_payload=False) + # Update results + result.update(dict( + headers=info, + output_text=to_native(data), + )) + # See if we can parse the result as JSON + try: + result['output_json'] = json.loads(data) + except Exception as dummy: + pass + # Fail if error was returned + if fail_on_acme_error and info['status'] >= 400: + raise ModuleFailException("ACME request failed: CODE: {0} RESULT: {1}".format(info['status'], data)) + # Done! + module.exit_json(changed=changed, **result) + except ModuleFailException as e: + e.do_fail(module, **result) + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/acme_inspect/aliases b/test/integration/targets/acme_inspect/aliases new file mode 100644 index 00000000000..d7936330302 --- /dev/null +++ b/test/integration/targets/acme_inspect/aliases @@ -0,0 +1,2 @@ +shippable/cloud/group1 +cloud/acme diff --git a/test/integration/targets/acme_inspect/meta/main.yml b/test/integration/targets/acme_inspect/meta/main.yml new file mode 100644 index 00000000000..81d1e7e77a5 --- /dev/null +++ b/test/integration/targets/acme_inspect/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - setup_acme diff --git a/test/integration/targets/acme_inspect/tasks/impl.yml b/test/integration/targets/acme_inspect/tasks/impl.yml new file mode 100644 index 00000000000..abab6019f24 --- /dev/null +++ b/test/integration/targets/acme_inspect/tasks/impl.yml @@ -0,0 +1,150 @@ +--- +- name: Generate account key + command: openssl ecparam -name prime256v1 -genkey -out {{ output_dir }}/accountkey.pem + +- name: Parse account key (to ease debugging some test failures) + command: openssl ec -in {{ output_dir }}/accountkey.pem -noout -text + +- name: Get directory + acme_inspect: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: no + method: directory-only + register: directory +- debug: var=directory + +- name: Create an account + acme_inspect: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: no + account_key_src: "{{ output_dir }}/accountkey.pem" + url: "{{ directory.directory.newAccount}}" + method: post + content: '{"termsOfServiceAgreed":true}' + register: account_creation + # account_creation.headers.location contains the account URI + # if creation was successful +- debug: var=account_creation + +- name: Get account information + acme_inspect: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: no + account_key_src: "{{ output_dir }}/accountkey.pem" + account_uri: "{{ account_creation.headers.location }}" + url: "{{ account_creation.headers.location }}" + method: get + register: account_get +- debug: var=account_get + +- name: Update account contacts + acme_inspect: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: no + account_key_src: "{{ output_dir }}/accountkey.pem" + account_uri: "{{ account_creation.headers.location }}" + url: "{{ account_creation.headers.location }}" + method: post + content: '{{ account_info | to_json }}' + vars: + account_info: + # For valid values, see + # https://tools.ietf.org/html/draft-ietf-acme-acme-16#section-7.3 + contact: + - mailto:me@example.com + register: account_update +- debug: var=account_update + +- name: Create certificate order + acme_inspect: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: no + account_key_src: "{{ output_dir }}/accountkey.pem" + account_uri: "{{ account_creation.headers.location }}" + url: "{{ directory.directory.newOrder }}" + method: post + content: '{{ create_order | to_json }}' + vars: + create_order: + # For valid values, see + # https://tools.ietf.org/html/draft-ietf-acme-acme-16#section-7.4 + identifiers: + - type: dns + value: example.com + - type: dns + value: example.org + register: new_order +- debug: var=new_order + +- name: Get order information + acme_inspect: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: no + account_key_src: "{{ output_dir }}/accountkey.pem" + account_uri: "{{ account_creation.headers.location }}" + url: "{{ new_order.headers.location }}" + method: get + register: order +- debug: var=order + +- name: Get authzs for order + acme_inspect: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: no + account_key_src: "{{ output_dir }}/accountkey.pem" + account_uri: "{{ account_creation.headers.location }}" + url: "{{ item }}" + method: get + loop: "{{ order.output_json.authorizations }}" + register: authz +- debug: var=authz + +- name: Get HTTP-01 challenge for authz + acme_inspect: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: no + account_key_src: "{{ output_dir }}/accountkey.pem" + account_uri: "{{ account_creation.headers.location }}" + url: "{{ (item.challenges | selectattr('type', 'equalto', 'http-01') | list)[0].url }}" + method: get + register: http01challenge + loop: "{{ authz.results | map(attribute='output_json') | list }}" +- debug: var=http01challenge + +- name: Activate HTTP-01 challenge manually + acme_inspect: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: no + account_key_src: "{{ output_dir }}/accountkey.pem" + account_uri: "{{ account_creation.headers.location }}" + url: "{{ item.url }}" + method: post + content: '{}' + register: activation + loop: "{{ http01challenge.results | map(attribute='output_json') | list }}" +- debug: var=activation + +- name: Get HTTP-01 challenge results + acme_inspect: + acme_directory: https://{{ acme_host }}:14000/dir + acme_version: 2 + validate_certs: no + account_key_src: "{{ output_dir }}/accountkey.pem" + account_uri: "{{ account_creation.headers.location }}" + url: "{{ item.url }}" + method: get + register: validation_result + loop: "{{ http01challenge.results | map(attribute='output_json') | list }}" + until: "validation_result.output_json.status != 'pending'" + retries: 20 + delay: 1 +- debug: var=validation_result diff --git a/test/integration/targets/acme_inspect/tasks/main.yml b/test/integration/targets/acme_inspect/tasks/main.yml new file mode 100644 index 00000000000..e46c6dc4d0e --- /dev/null +++ b/test/integration/targets/acme_inspect/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_inspect/tests/validate.yml b/test/integration/targets/acme_inspect/tests/validate.yml new file mode 100644 index 00000000000..456ffdc0459 --- /dev/null +++ b/test/integration/targets/acme_inspect/tests/validate.yml @@ -0,0 +1,131 @@ +--- +- name: Check directory output + assert: + that: + - directory is not changed + - "'directory' in directory" + - "'newAccount' in directory.directory" + - "'newOrder' in directory.directory" + - "'newNonce' in directory.directory" + - "'headers' not in directory" + - "'output_text' not in directory" + - "'output_json' not in directory" + +- name: Check account creation output + assert: + that: + - account_creation is changed + - "'directory' in account_creation" + - "'headers' in account_creation" + - "'output_text' in account_creation" + - "'output_json' in account_creation" + - account_creation.headers.status == 201 + - "'location' in account_creation.headers" + - account_creation.output_json.status == 'valid' + - not account_creation.output_json.contact + - account_creation.output_text | from_json == account_creation.output_json + +- name: Check account get output + assert: + that: + - account_get is not changed + - "'directory' in account_get" + - "'headers' in account_get" + - "'output_text' in account_get" + - "'output_json' in account_get" + - account_get.headers.status == 200 + - account_get.output_json == account_creation.output_json + +- name: Check account update output + assert: + that: + - account_update is changed + - "'directory' in account_update" + - "'headers' in account_update" + - "'output_text' in account_update" + - "'output_json' in account_update" + - account_update.output_json.status == 'valid' + - account_update.output_json.contact | length == 1 + - account_update.output_json.contact[0] == 'mailto:me@example.com' + +- name: Check certificate request output + assert: + that: + - new_order is changed + - "'directory' in new_order" + - "'headers' in new_order" + - "'output_text' in new_order" + - "'output_json' in new_order" + - new_order.output_json.authorizations | length == 2 + - new_order.output_json.identifiers | length == 2 + - new_order.output_json.status == 'pending' + - "'finalize' in new_order.output_json" + +- name: Check get order output + assert: + that: + - order is not changed + - "'directory' in order" + - "'headers' in order" + - "'output_text' in order" + - "'output_json' in order" + # The order of identifiers and authorizations is randomized! + # - new_order.output_json == order.output_json + +- name: Check get authz output + assert: + that: + - item is not changed + - "'directory' in item" + - "'headers' in item" + - "'output_text' in item" + - "'output_json' in item" + - item.output_json.challenges | length >= 3 + - item.output_json.identifier.type == 'dns' + - item.output_json.status == 'pending' + loop: "{{ authz.results }}" + +- name: Check get challenge output + assert: + that: + - item is not changed + - "'directory' in item" + - "'headers' in item" + - "'output_text' in item" + - "'output_json' in item" + - item.output_json.status == 'pending' + - item.output_json.type == 'http-01' + - item.output_json.url == item.invocation.module_args.url + - "'token' in item.output_json" + loop: "{{ http01challenge.results }}" + +- name: Check challenge activation output + assert: + that: + - item is changed + - "'directory' in item" + - "'headers' in item" + - "'output_text' in item" + - "'output_json' in item" + - item.output_json.status == 'pending' + - item.output_json.type == 'http-01' + - item.output_json.url == item.invocation.module_args.url + - "'token' in item.output_json" + loop: "{{ activation.results }}" + +- name: Check validation result + assert: + that: + - item is not changed + - "'directory' in item" + - "'headers' in item" + - "'output_text' in item" + - "'output_json' in item" + - item.output_json.status == 'invalid' + - item.output_json.type == 'http-01' + - item.output_json.url == item.invocation.module_args.url + - "'token' in item.output_json" + - "'validated' in item.output_json" + - "'error' in item.output_json" + - item.output_json.error.type == 'urn:ietf:params:acme:error:unauthorized' + loop: "{{ validation_result.results }}"