From dec392793b85b4091c92abbf1c7add6025d37259 Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Fri, 25 May 2018 07:55:24 +0200 Subject: [PATCH] Letsencrypt: add account management module (#37275) * Removed superfluous space. * Separating account init code from ACMEAccount constructor. * Extracted module utils and docs fragment. * Added new letsencrypt_account module. * Ignore pre-1.0.0 versions of OpenSSL. * Added account key rollover. * Renaming letsencrypt_account -> acme_account * Simplifying check for updating contact information. * Rewriting docstring for ACMEDirectory. * Changing license according to permissions given by individual authors in https://github.com/ansible/ansible/pull/37275. * Updating BOTMETA. * Preparing for change of ACME protocol currently discussed in ietf-wg-acme/acme. * Updating documentation. --- .github/BOTMETA.yml | 3 + lib/ansible/module_utils/letsencrypt.py | 528 +++++++++++++++++ .../web_infrastructure/acme_account.py | 259 +++++++++ .../web_infrastructure/acme_certificate.py | 544 +----------------- .../module_docs_fragments/letsencrypt.py | 67 +++ test/integration/targets/acme_account/aliases | 2 + .../targets/acme_account/meta/main.yml | 2 + .../targets/acme_account/tasks/main.yml | 109 ++++ .../targets/acme_account/tests/validate.yml | 51 ++ 9 files changed, 1042 insertions(+), 523 deletions(-) create mode 100644 lib/ansible/module_utils/letsencrypt.py create mode 100644 lib/ansible/modules/web_infrastructure/acme_account.py create mode 100644 lib/ansible/utils/module_docs_fragments/letsencrypt.py create mode 100644 test/integration/targets/acme_account/aliases create mode 100644 test/integration/targets/acme_account/meta/main.yml create mode 100644 test/integration/targets/acme_account/tasks/main.yml create mode 100644 test/integration/targets/acme_account/tests/validate.yml diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 0e19faf049..1a19271541 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -686,6 +686,7 @@ files: $modules/utilities/logic/set_stats.py: bcoca $modules/utilities/logic/wait_for.py: AnderEnder gregswift jarv jhoekx $modules/web_infrastructure/_letsencrypt.py: mgruener resmo felixfontein + $modules/web_infrastructure/acme_account.py: mgruener resmo felixfontein $modules/web_infrastructure/acme_certificate.py: mgruener resmo felixfontein $modules/web_infrastructure/ansible_tower/: $team_tower $modules/web_infrastructure/apache2_mod_proxy.py: oboukili @@ -864,6 +865,8 @@ files: labels: clustering $module_utils/keycloak.py: maintainers: eikef + $module_utils/letsencrypt.py: + maintainers: mgruener resmo felixfontein $module_utils/manageiq.py: maintainers: $team_manageiq $module_utils/network/meraki: diff --git a/lib/ansible/module_utils/letsencrypt.py b/lib/ansible/module_utils/letsencrypt.py new file mode 100644 index 0000000000..0e17e32e0c --- /dev/null +++ b/lib/ansible/module_utils/letsencrypt.py @@ -0,0 +1,528 @@ +# -*- coding: utf-8 -*- + +# This code is part of Ansible, but is an independent component. +# This particular file snippet, and this file snippet only, is BSD licensed. +# Modules you write using this snippet, which is embedded dynamically by Ansible +# still belong to the author of the module, and may assign their own license +# to the complete work. +# +# Copyright (c), Michael Gruener , 2016 +# +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import base64 +import binascii +import copy +import hashlib +import json +import os +import re +import shutil +import tempfile +import traceback + +from ansible.module_utils._text import to_native, to_text, to_bytes +from ansible.module_utils.urls import fetch_url as _fetch_url + + +class ModuleFailException(Exception): + ''' + If raised, module.fail_json() will be called with the given parameters after cleanup. + ''' + def __init__(self, msg, **args): + super(ModuleFailException, self).__init__(self, msg) + 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 _lowercase_fetch_url(*args, **kwargs): + ''' + Add lowercase representations of the header names as dict keys + + ''' + response, info = _fetch_url(*args, **kwargs) + + info.update(dict((header.lower(), value) for (header, value) in info.items())) + return response, info + + +fetch_url = _lowercase_fetch_url + + +def nopad_b64(data): + return base64.urlsafe_b64encode(data).decode('utf8').replace("=", "") + + +def simple_get(module, url): + resp, info = fetch_url(module, url, method='GET') + + result = {} + try: + content = resp.read() + except AttributeError: + content = info.get('body') + + if content: + if info['content-type'].startswith('application/json'): + try: + result = module.from_json(content.decode('utf8')) + except ValueError: + raise ModuleFailException("Failed to parse the ACME response: {0} {1}".format(url, content)) + else: + result = content + + if info['status'] >= 400: + raise ModuleFailException("ACME request failed: CODE: {0} RESULT: {1}".format(info['status'], result)) + return result + + +# function source: network/basics/uri.py +def write_file(module, dest, content): + ''' + Write content to destination file dest, only if the content + has changed. + ''' + changed = False + # create a tempfile + fd, tmpsrc = tempfile.mkstemp(text=False) + f = os.fdopen(fd, 'wb') + try: + f.write(content) + except Exception as err: + try: + f.close() + except Exception as e: + pass + os.remove(tmpsrc) + raise ModuleFailException("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc()) + f.close() + checksum_src = None + checksum_dest = None + # raise an error if there is no tmpsrc file + if not os.path.exists(tmpsrc): + try: + os.remove(tmpsrc) + except Exception as e: + pass + raise ModuleFailException("Source %s does not exist" % (tmpsrc)) + if not os.access(tmpsrc, os.R_OK): + os.remove(tmpsrc) + raise ModuleFailException("Source %s not readable" % (tmpsrc)) + checksum_src = module.sha1(tmpsrc) + # check if there is no dest file + if os.path.exists(dest): + # raise an error if copy has no permission on dest + if not os.access(dest, os.W_OK): + os.remove(tmpsrc) + raise ModuleFailException("Destination %s not writable" % (dest)) + if not os.access(dest, os.R_OK): + os.remove(tmpsrc) + raise ModuleFailException("Destination %s not readable" % (dest)) + checksum_dest = module.sha1(dest) + else: + if not os.access(os.path.dirname(dest), os.W_OK): + os.remove(tmpsrc) + raise ModuleFailException("Destination dir %s not writable" % (os.path.dirname(dest))) + if checksum_src != checksum_dest: + try: + shutil.copyfile(tmpsrc, dest) + changed = True + except Exception as err: + os.remove(tmpsrc) + raise ModuleFailException("failed to copy %s to %s: %s" % (tmpsrc, dest, to_native(err)), exception=traceback.format_exc()) + os.remove(tmpsrc) + return changed + + +class ACMEDirectory(object): + ''' + The ACME server directory. Gives access to the available resources, + and allows to obtain a Replay-Nonce. The acme_directory URL + needs to support unauthenticated GET requests; ACME endpoints + requiring authentication are not supported. + https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.1.1 + ''' + + def __init__(self, module): + self.module = module + self.directory_root = module.params['acme_directory'] + self.version = module.params['acme_version'] + + self.directory = simple_get(self.module, self.directory_root) + + # Check whether self.version matches what we expect + if self.version == 1: + for key in ('new-reg', 'new-authz', 'new-cert'): + if key not in self.directory: + raise ModuleFailException("ACME directory does not seem to follow protocol ACME v1") + if self.version == 2: + for key in ('newNonce', 'newAccount', 'newOrder'): + if key not in self.directory: + raise ModuleFailException("ACME directory does not seem to follow protocol ACME v2") + + def __getitem__(self, key): + return self.directory[key] + + def get_nonce(self, resource=None): + url = self.directory_root if self.version == 1 else self.directory['newNonce'] + if resource is not None: + url = resource + dummy, info = fetch_url(self.module, url, method='HEAD') + if info['status'] not in (200, 204): + raise ModuleFailException("Failed to get replay-nonce, got status {0}".format(info['status'])) + return info['replay-nonce'] + + +class ACMEAccount(object): + ''' + ACME account object. Handles the authorized communication with the + ACME server. Provides access to account bound information like + the currently active authorizations and valid certificates + ''' + + def __init__(self, module): + self.module = module + self.version = module.params['acme_version'] + # account_key path and content are mutually exclusive + self.key = module.params['account_key_src'] + self.key_content = module.params['account_key_content'] + self.directory = ACMEDirectory(module) + + self.uri = None + + self._openssl_bin = module.get_bin_path('openssl', True) + + # Create a key file from content, key (path) and key content are mutually exclusive + if self.key_content is not None: + fd, tmpsrc = tempfile.mkstemp() + module.add_cleanup_file(tmpsrc) # Ansible will delete the file on exit + f = os.fdopen(fd, 'wb') + try: + f.write(self.key_content.encode('utf-8')) + self.key = tmpsrc + except Exception as err: + try: + f.close() + except Exception as e: + pass + raise ModuleFailException("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc()) + f.close() + + error, self.key_data = self.parse_account_key(self.key) + if error: + raise ModuleFailException("error while parsing account key: %s" % error) + self.jwk = self.key_data['jwk'] + self.jws_header = { + "alg": self.key_data['alg'], + "jwk": self.jwk, + } + + def get_keyauthorization(self, token): + ''' + Returns the key authorization for the given token + https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-8.1 + ''' + accountkey_json = json.dumps(self.jwk, sort_keys=True, separators=(',', ':')) + thumbprint = nopad_b64(hashlib.sha256(accountkey_json.encode('utf8')).digest()) + return "{0}.{1}".format(token, thumbprint) + + def parse_account_key(self, key): + ''' + Parses an RSA or Elliptic Curve key file in PEM format and returns a pair + (error, key_data). + ''' + account_key_type = None + with open(key, "rt") as f: + for line in f: + m = re.match(r"^\s*-{5,}BEGIN\s+(EC|RSA)\s+PRIVATE\s+KEY-{5,}\s*$", line) + if m is not None: + account_key_type = m.group(1).lower() + break + if account_key_type is None: + # This happens for example if openssl_privatekey created this key + # (as opposed to the OpenSSL binary). For now, we assume this is + # an RSA key. + # FIXME: add some kind of auto-detection + account_key_type = "rsa" + if account_key_type not in ("rsa", "ec"): + return 'unknown key type "%s"' % account_key_type, {} + + openssl_keydump_cmd = [self._openssl_bin, account_key_type, "-in", key, "-noout", "-text"] + dummy, out, dummy = self.module.run_command(openssl_keydump_cmd, check_rc=True) + + if account_key_type == 'rsa': + pub_hex, pub_exp = re.search( + r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)", + to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL).groups() + pub_exp = "{0:x}".format(int(pub_exp)) + if len(pub_exp) % 2: + pub_exp = "0{0}".format(pub_exp) + + return None, { + 'type': 'rsa', + 'alg': 'RS256', + 'jwk': { + "kty": "RSA", + "e": nopad_b64(binascii.unhexlify(pub_exp.encode("utf-8"))), + "n": nopad_b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))), + }, + 'hash': 'sha256', + } + elif account_key_type == 'ec': + pub_data = re.search( + r"pub:\s*\n\s+04:([a-f0-9\:\s]+?)\nASN1 OID: (\S+)(?:\nNIST CURVE: (\S+))?", + to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL) + if pub_data is None: + return 'cannot parse elliptic curve key', {} + pub_hex = binascii.unhexlify(re.sub(r"(\s|:)", "", pub_data.group(1)).encode("utf-8")) + asn1_oid_curve = pub_data.group(2).lower() + nist_curve = pub_data.group(3).lower() if pub_data.group(3) else None + if asn1_oid_curve == 'prime256v1' or nist_curve == 'p-256': + bits = 256 + alg = 'ES256' + hash = 'sha256' + point_size = 32 + curve = 'P-256' + elif asn1_oid_curve == 'secp384r1' or nist_curve == 'p-384': + bits = 384 + alg = 'ES384' + hash = 'sha384' + point_size = 48 + curve = 'P-384' + elif asn1_oid_curve == 'secp521r1' or nist_curve == 'p-521': + # Not yet supported on Let's Encrypt side, see + # https://github.com/letsencrypt/boulder/issues/2217 + bits = 521 + alg = 'ES512' + hash = 'sha512' + point_size = 66 + curve = 'P-521' + else: + return 'unknown elliptic curve: %s / %s' % (asn1_oid_curve, nist_curve), {} + bytes = (bits + 7) // 8 + if len(pub_hex) != 2 * bytes: + return 'bad elliptic curve point (%s / %s)' % (asn1_oid_curve, nist_curve), {} + return None, { + 'type': 'ec', + 'alg': alg, + 'jwk': { + "kty": "EC", + "crv": curve, + "x": nopad_b64(pub_hex[:bytes]), + "y": nopad_b64(pub_hex[bytes:]), + }, + 'hash': hash, + 'point_size': point_size, + } + + def sign_request(self, protected, payload, key_data, key): + try: + payload64 = nopad_b64(self.module.jsonify(payload).encode('utf8')) + 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)) + + openssl_sign_cmd = [self._openssl_bin, "dgst", "-{0}".format(key_data['hash']), "-sign", key] + sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8') + dummy, out, dummy = self.module.run_command(openssl_sign_cmd, data=sign_payload, check_rc=True, binary_data=True) + + if key_data['type'] == 'ec': + dummy, der_out, dummy = self.module.run_command( + [self._openssl_bin, "asn1parse", "-inform", "DER"], + data=out, binary_data=True) + expected_len = 2 * key_data['point_size'] + sig = re.findall( + r"prim:\s+INTEGER\s+:([0-9A-F]{1,%s})\n" % expected_len, + to_text(der_out, errors='surrogate_or_strict')) + if len(sig) != 2: + raise ModuleFailException( + "failed to generate Elliptic Curve signature; cannot parse DER output: {0}".format( + to_text(der_out, errors='surrogate_or_strict'))) + sig[0] = (expected_len - len(sig[0])) * '0' + sig[0] + sig[1] = (expected_len - len(sig[1])) * '0' + sig[1] + out = binascii.unhexlify(sig[0]) + binascii.unhexlify(sig[1]) + + return { + "protected": protected64, + "payload": payload64, + "signature": nopad_b64(to_bytes(out)), + } + + def send_signed_request(self, url, payload): + ''' + Sends a JWS signed HTTP POST request to the ACME server and returns + the response as dictionary + https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-6.2 + ''' + failed_tries = 0 + while True: + protected = copy.deepcopy(self.jws_header) + protected["nonce"] = self.directory.get_nonce() + if self.version != 1: + protected["url"] = url + + data = self.sign_request(protected, payload, self.key_data, self.key) + if self.version == 1: + data["header"] = self.jws_header + data = self.module.jsonify(data) + + headers = { + 'Content-Type': 'application/jose+json', + } + resp, info = fetch_url(self.module, url, data=data, headers=headers, method='POST') + result = {} + try: + content = resp.read() + except AttributeError: + content = info.get('body') + + if content: + if info['content-type'].startswith('application/json') or 400 <= info['status'] < 600: + try: + result = self.module.from_json(content.decode('utf8')) + # In case of badNonce error, try again (up to 5 times) + # (https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-6.6) + if (400 <= info['status'] < 600 and + result.get('type') == 'urn:ietf:params:acme:error:badNonce' and + failed_tries <= 5): + failed_tries += 1 + continue + except ValueError: + raise ModuleFailException("Failed to parse the ACME response: {0} {1}".format(url, content)) + else: + result = content + + return result, info + + def set_account_uri(self, uri): + ''' + Set account URI. For ACME v2, it needs to be used to sending signed + requests. + ''' + self.uri = uri + if self.version != 1: + self.jws_header.pop('jwk') + self.jws_header['kid'] = self.uri + + def _new_reg(self, contact=None, agreement=None, terms_agreed=False, allow_creation=True): + ''' + Registers a new ACME account. Returns True if the account was + created and False if it already existed (e.g. it was not newly + created). + https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-7.3 + ''' + contact = [] if contact is None else contact + + if self.version == 1: + new_reg = { + 'resource': 'new-reg', + 'contact': contact + } + if agreement: + new_reg['agreement'] = agreement + else: + new_reg['agreement'] = self.directory['meta']['terms-of-service'] + url = self.directory['new-reg'] + else: + new_reg = { + 'contact': contact + } + if not allow_creation: + new_reg['onlyReturnExisting'] = True + if terms_agreed: + new_reg['termsOfServiceAgreed'] = True + url = self.directory['newAccount'] + + result, info = self.send_signed_request(url, new_reg) + if 'location' in info: + self.set_account_uri(info['location']) + + if info['status'] in ([200, 201] if self.version == 1 else [201]): + # Account did not exist + return True + elif info['status'] == (409 if self.version == 1 else 200): + # Account did exist + return False + elif info['status'] == 400 and result['type'] == 'urn:ietf:params:acme:error:accountDoesNotExist' and not allow_creation: + # Account does not exist (and we didn't try to create it) + return False + else: + raise ModuleFailException("Error registering: {0} {1}".format(info['status'], result)) + + def get_account_data(self): + ''' + Retrieve account information. Can only be called when the account + URI is already known (such as after calling init_account). + Return None if the account was deactivated, or a dict otherwise. + ''' + if self.uri is None: + raise ModuleFailException("Account URI unknown") + data = {} + 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': + 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): + ''' + 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 + account exists is to try and create one with the provided account + key, this method will always result in an account being present + (except on error situations). For ACME v2, a new account will + only be created if allow_creation is set to True. + + For ACME v2, check_mode is fully respected. For ACME v1, the account + might be created if it does not yet exist. + + If the account already exists and if update_contact is set to + True, this method will update the contact information. + + Return True in case something changed (account was created, contact + info updated) or would be changed (check_mode). The account URI + will be stored in self.uri; if it is None, the account does not + exist. + + https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-7.3 + ''' + + new_account = True + changed = False + if self.uri is not None: + new_account = False + else: + new_account = self._new_reg( + contact, + agreement=agreement, + terms_agreed=terms_agreed, + allow_creation=allow_creation and not self.module.check_mode + ) + if self.module.check_mode and self.uri is None and allow_creation: + return True + if not new_account and self.uri and update_contact: + result = self.get_account_data() + if result is None: + if not allow_creation: + self.uri = None + return False + raise ModuleFailException("Account is deactivated!") + + # ...and check if update is necessary + if result.get('contact', []) != contact: + if not self.module.check_mode: + upd_reg = result + upd_reg['contact'] = contact + result, dummy = self.send_signed_request(self.uri, upd_reg) + changed = True + return new_account or changed diff --git a/lib/ansible/modules/web_infrastructure/acme_account.py b/lib/ansible/modules/web_infrastructure/acme_account.py new file mode 100644 index 0000000000..b03015d5a7 --- /dev/null +++ b/lib/ansible/modules/web_infrastructure/acme_account.py @@ -0,0 +1,259 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2016 Michael Gruener +# 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 +author: "Felix Fontein (@felixfontein)" +version_added: "2.6" +short_description: Create, modify or delete accounts with Let's Encrypt +description: + - "Allows to create, modify or delete 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)." + - "This module only works with the ACME v2 protocol." +extends_documentation_fragment: + - letsencrypt +options: + state: + description: + - "The state of the account, to be identified by its account key." + - "If the state is C(absent), the account will either not exist or be + deactivated." + - "If the state is C(changed_key), the account must exist. The account + key will be changed; no other information will be touched." + required: true + choices: + - present + - absent + - changed_key + allow_creation: + description: + - "Whether account creation is allowed (when state is C(present))." + default: yes + type: bool + contact: + description: + - "A list of contact URLs." + - "Email addresses must be prefixed with C(mailto:)." + - "See https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-7.1.2 + for what is allowed." + - "Must be specified when state is C(present). Will be ignored + if state is C(absent) or C(changed_key)." + default: [] + terms_agreed: + description: + - "Boolean indicating whether you agree to the terms of service document." + - "ACME servers can require this to be true." + default: no + type: bool + new_account_key_src: + description: + - "Path to a file containing the Let's Encrypt account RSA or Elliptic Curve + key to change to." + - "Same restrictions apply as to C(account_key_src)." + - "Mutually exclusive with C(new_account_key_content)." + - "Required if C(new_account_key_content) is not used and state is C(changed_key)." + new_account_key_content: + description: + - "Content of the Let's Encrypt account RSA or Elliptic Curve key to change to." + - "Same restrictions apply as to C(account_key_content)." + - "Mutually exclusive with C(new_account_key_src)." + - "Required if C(new_account_key_src) is not used and state is C(changed_key)." +''' + +EXAMPLES = ''' +- name: Make sure account exists and has given contacts. We agree to TOS. + acme_account: + account_key_src: /etc/pki/cert/private/account.key + state: present + terms_agreed: yes + contact: + - mailto:me@example.com + - mailto:myself@example.org + +- name: Make sure account has given email address. Don't create account if it doesn't exist + acme_account: + account_key_src: /etc/pki/cert/private/account.key + state: present + allow_creation: no + contact: + - mailto:me@example.com + +- name: Change account's key to the one stored in the variable new_account_key + acme_account: + account_key_src: /etc/pki/cert/private/account.key + new_account_key_content: '{{ new_account_key }}' + state: changed_key + +- name: Delete account (we have to use the new key) + acme_account: + account_key_content: '{{ new_account_key }}' + state: absent +''' + +RETURN = ''' +account_uri: + description: ACME account URI, or None if account does not exist. + returned: always + type: string +''' + +from ansible.module_utils.letsencrypt import ( + ModuleFailException, ACMEAccount +) + +import os +import tempfile +import traceback + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native + + +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), + 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'), + terms_agreed=dict(required=False, default=False, type='bool'), + state=dict(required=True, choices=['absent', 'present', 'changed_key'], type='str'), + allow_creation=dict(required=False, default=True, type='bool'), + contact=dict(required=False, type='list', default=[]), + new_account_key_src=dict(type='path'), + new_account_key_content=dict(type='str', no_log=True), + ), + required_one_of=( + ['account_key_src', 'account_key_content'], + ), + mutually_exclusive=( + ['account_key_src', 'account_key_content'], + ['new_account_key_src', 'new_account_key_content'], + ), + required_if=( + # Make sure that for state == changed_key, one of + # 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, + ) + + 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) + state = module.params.get('state') + if state == 'absent': + changed = account.init_account( + [], + allow_creation=False, + update_contact=False, + ) + if changed: + raise AssertionError('Unwanted account change') + if account.uri is not None: + # Account does exist + account_data = account.get_account_data() + if account_data is not None: + # Account is not yet deactivated + if not module.check_mode: + # Deactivate it + payload = { + 'status': 'deactivated' + } + result, info = account.send_signed_request(account.uri, payload) + if info['status'] != 200: + raise ModuleFailException('Error deactivating account: {0} {1}'.format(info['status'], result)) + module.exit_json(changed=True, account_uri=account.uri) + module.exit_json(changed=False, account_uri=account.uri) + elif state == 'present': + allow_creation = module.params.get('allow_creation') + contact = module.params.get('contact') + terms_agreed = module.params.get('terms_agreed') + changed = account.init_account( + contact, + terms_agreed=terms_agreed, + allow_creation=allow_creation, + ) + if account.uri is None: + raise ModuleFailException(msg='Account does not exist or is deactivated.') + module.exit_json(changed=changed, account_uri=account.uri) + elif state == 'changed_key': + # Get hold of new account key + new_key = module.params.get('new_account_key_src') + if new_key is None: + fd, tmpsrc = tempfile.mkstemp() + module.add_cleanup_file(tmpsrc) # Ansible will delete the file on exit + f = os.fdopen(fd, 'wb') + try: + f.write(module.params.get('new_account_key_content').encode('utf-8')) + new_key = tmpsrc + except Exception as err: + try: + f.close() + except Exception as e: + pass + raise ModuleFailException("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc()) + f.close() + # Parse new account key + error, new_key_data = account.parse_account_key(new_key) + if error: + raise ModuleFailException("error while parsing account key: %s" % error) + # Verify that the account exists and has not been deactivated + changed = account.init_account( + [], + allow_creation=False, + update_contact=False, + ) + if changed: + raise AssertionError('Unwanted account change') + if account.uri is None or account.get_account_data() is None: + raise ModuleFailException(msg='Account does not exist or is deactivated.') + # Now we can start the account key rollover + if not module.check_mode: + # Compose inner signed message + # https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-7.3.6 + url = account.directory['keyChange'] + protected = { + "alg": new_key_data['alg'], + "jwk": new_key_data['jwk'], + "url": url, + } + payload = { + "account": account.uri, + "newKey": new_key_data['jwk'], # specified in draft 12 + "oldKey": account.jwk, # discussed in https://github.com/ietf-wg-acme/acme/pull/425, + # might be required in draft 13 + } + data = account.sign_request(protected, payload, new_key_data, new_key) + # Send request and verify result + result, info = account.send_signed_request(url, data) + if info['status'] != 200: + raise ModuleFailException('Error account key rollover: {0} {1}'.format(info['status'], result)) + module.exit_json(changed=True, account_uri=account.uri) + except ModuleFailException as e: + e.do_fail(module) + + +if __name__ == '__main__': + main() diff --git a/lib/ansible/modules/web_infrastructure/acme_certificate.py b/lib/ansible/modules/web_infrastructure/acme_certificate.py index 07cd9d2454..354239266e 100644 --- a/lib/ansible/modules/web_infrastructure/acme_certificate.py +++ b/lib/ansible/modules/web_infrastructure/acme_certificate.py @@ -42,58 +42,13 @@ description: - "At least one of C(dest) and C(fullchain_dest) must be specified." - "Note: this module was called C(letsencrypt) before Ansible 2.6. The usage did not change." -requirements: - - "python >= 2.6" - - openssl +extends_documentation_fragment: + - letsencrypt options: - account_key_src: - description: - - "Path to a file containing the ACME account RSA or Elliptic Curve - key." - - "RSA keys can be created with C(openssl rsa ...). Elliptic curve keys can - be created with C(openssl ecparam -genkey ...)." - - "Mutually exclusive with C(account_key_content)." - - "Required if C(account_key_content) is not used." - aliases: [ account_key ] - account_key_content: - description: - - "Content of the ACME account RSA or Elliptic Curve key." - - "Mutually exclusive with C(account_key_src)." - - "Required if C(account_key_src) is not used." - - "Warning: the content will be written into a temporary file, which will - be deleted by Ansible when the module completes. Since this is an - important private key — it can be used to change the account key, - or to revoke your certificates without knowing their private keys - —, this might not be acceptable." - version_added: "2.5" account_email: description: - "The email address associated with this account." - "It will be used for certificate expiration warnings." - acme_directory: - description: - - "The ACME directory to use. This is the entry point URL to access - CA server API." - - "For safety reasons the default is set to the Let's Encrypt staging - server (for the ACME v1 protocol). This will create technically correct, - but untrusted certificates." - - "For Let's Encrypt, all staging endpoints can be found here: - U(https://letsencrypt.org/docs/staging-environment/)" - - "For Let's Encrypt, the production directory URL for ACME v1 is - U(https://acme-v01.api.letsencrypt.org/directory), and the production - directory URL for ACME v2 is U(https://acme-v02.api.letsencrypt.org/directory)." - - "I(Warning): So far, the module has only been tested against Let's Encrypt - (staging and production) and against the Pebble testing server - (U(https://github.com/letsencrypt/Pebble))." - default: https://acme-staging.api.letsencrypt.org/directory - acme_version: - description: - - "The ACME version of the endpoint." - - "Must be 1 for the classic Let's Encrypt ACME endpoint, or 2 for the - new standardized ACME v2 endpoint." - default: 1 - choices: [1, 2] - version_added: "2.5" agreement: description: - "URI to a terms of service document you agree to when using the @@ -160,14 +115,6 @@ options: If the certificate is not renewed, module return values will not include C(challenge_data)." default: 10 - validate_certs: - description: - - Whether calls to the ACME directory will validate TLS certificates. - - I(Warning:) Should I(only ever) be set to C(no) for testing purposes, - for example when testing against a local Pebble server. - type: bool - default: 'yes' - version_added: 2.5 deactivate_authzs: description: - "Deactivate authentication objects (authz) after issuing a certificate, @@ -175,7 +122,7 @@ options: - "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. " + concern." type: bool default: 'no' version_added: 2.6 @@ -344,78 +291,21 @@ account_uri: version_added: "2.5" ''' +from ansible.module_utils.letsencrypt import ( + ModuleFailException, fetch_url, write_file, nopad_b64, simple_get, ACMEAccount +) + import base64 -import binascii -import copy import hashlib -import json import locale import os import re -import shutil -import tempfile import textwrap import time -import traceback from datetime import datetime from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils._text import to_native, to_text, to_bytes -from ansible.module_utils.urls import fetch_url as _fetch_url - - -class ModuleFailException(Exception): - ''' - If raised, module.fail_json() will be called with the given parameters after cleanup. - ''' - def __init__(self, msg, **args): - super(ModuleFailException, self).__init__(self, msg) - 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 _lowercase_fetch_url(*args, **kwargs): - ''' - Add lowercase representations of the header names as dict keys - - ''' - response, info = _fetch_url(*args, **kwargs) - - info.update(dict((header.lower(), value) for (header, value) in info.items())) - return response, info - - -fetch_url = _lowercase_fetch_url - - -def nopad_b64(data): - return base64.urlsafe_b64encode(data).decode('utf8').replace("=", "") - - -def simple_get(module, url): - resp, info = fetch_url(module, url, method='GET') - - result = {} - try: - content = resp.read() - except AttributeError: - content = info.get('body') - - if content: - if info['content-type'].startswith('application/json'): - try: - result = module.from_json(content.decode('utf8')) - except ValueError: - raise ModuleFailException("Failed to parse the ACME response: {0} {1}".format(url, content)) - else: - result = content - - if info['status'] >= 400: - raise ModuleFailException("ACME request failed: CODE: {0} RESULT: {1}".format(info['status'], result)) - return result +from ansible.module_utils._text import to_text, to_bytes def get_cert_days(module, cert_file): @@ -441,408 +331,6 @@ def get_cert_days(module, cert_file): return (not_after - now).days -# function source: network/basics/uri.py -def write_file(module, dest, content): - ''' - Write content to destination file dest, only if the content - has changed. - ''' - changed = False - # create a tempfile - fd, tmpsrc = tempfile.mkstemp(text=False) - f = os.fdopen(fd, 'wb') - try: - f.write(content) - except Exception as err: - try: - f.close() - except Exception as e: - pass - os.remove(tmpsrc) - raise ModuleFailException("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc()) - f.close() - checksum_src = None - checksum_dest = None - # raise an error if there is no tmpsrc file - if not os.path.exists(tmpsrc): - try: - os.remove(tmpsrc) - except Exception as e: - pass - raise ModuleFailException("Source %s does not exist" % (tmpsrc)) - if not os.access(tmpsrc, os.R_OK): - os.remove(tmpsrc) - raise ModuleFailException("Source %s not readable" % (tmpsrc)) - checksum_src = module.sha1(tmpsrc) - # check if there is no dest file - if os.path.exists(dest): - # raise an error if copy has no permission on dest - if not os.access(dest, os.W_OK): - os.remove(tmpsrc) - raise ModuleFailException("Destination %s not writable" % (dest)) - if not os.access(dest, os.R_OK): - os.remove(tmpsrc) - raise ModuleFailException("Destination %s not readable" % (dest)) - checksum_dest = module.sha1(dest) - else: - if not os.access(os.path.dirname(dest), os.W_OK): - os.remove(tmpsrc) - raise ModuleFailException("Destination dir %s not writable" % (os.path.dirname(dest))) - if checksum_src != checksum_dest: - try: - shutil.copyfile(tmpsrc, dest) - changed = True - except Exception as err: - os.remove(tmpsrc) - raise ModuleFailException("failed to copy %s to %s: %s" % (tmpsrc, dest, to_native(err)), exception=traceback.format_exc()) - os.remove(tmpsrc) - return changed - - -class ACMEDirectory(object): - ''' - The ACME server directory. Gives access to the available resources - and the Replay-Nonce for a given URI. This only works for - URIs that permit GET requests (so normally not the ones that - require authentication). - https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.1.1 - ''' - - def __init__(self, module): - self.module = module - self.directory_root = module.params['acme_directory'] - self.version = module.params['acme_version'] - - self.directory = simple_get(self.module, self.directory_root) - - # Check whether self.version matches what we expect - if self.version == 1: - for key in ('new-reg', 'new-authz', 'new-cert'): - if key not in self.directory: - raise ModuleFailException("ACME directory does not seem to follow protocol ACME v1") - if self.version == 2: - for key in ('newNonce', 'newAccount', 'newOrder'): - if key not in self.directory: - raise ModuleFailException("ACME directory does not seem to follow protocol ACME v2") - - def __getitem__(self, key): - return self.directory[key] - - def get_nonce(self, resource=None): - url = self.directory_root if self.version == 1 else self.directory['newNonce'] - if resource is not None: - url = resource - dummy, info = fetch_url(self.module, url, method='HEAD') - if info['status'] not in (200, 204): - raise ModuleFailException("Failed to get replay-nonce, got status {0}".format(info['status'])) - return info['replay-nonce'] - - -class ACMEAccount(object): - ''' - ACME account object. Handles the authorized communication with the - ACME server. Provides access to account bound information like - the currently active authorizations and valid certificates - ''' - - def __init__(self, module): - self.module = module - self.version = module.params['acme_version'] - # account_key path and content are mutually exclusive - self.key = module.params['account_key_src'] - self.key_content = module.params['account_key_content'] - self.email = module.params['account_email'] - self.directory = ACMEDirectory(module) - self.agreement = module.params.get('agreement') - self.terms_agreed = module.params.get('terms_agreed') - - self.uri = None - self.changed = False - - self._openssl_bin = module.get_bin_path('openssl', True) - - # Create a key file from content, key (path) and key content are mutually exclusive - if self.key_content is not None: - fd, tmpsrc = tempfile.mkstemp() - module.add_cleanup_file(tmpsrc) # Ansible will delete the file on exit - f = os.fdopen(fd, 'wb') - try: - f.write(self.key_content.encode('utf-8')) - self.key = tmpsrc - except Exception as err: - try: - f.close() - except Exception as e: - pass - raise ModuleFailException("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc()) - f.close() - - error, self.key_data = self._parse_account_key(self.key) - if error: - raise ModuleFailException("error while parsing account key: %s" % error) - self.jwk = self.key_data['jwk'] - self.jws_header = { - "alg": self.key_data['alg'], - "jwk": self.jwk, - } - self.init_account() - - def get_keyauthorization(self, token): - ''' - Returns the key authorization for the given token - https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-8.1 - ''' - accountkey_json = json.dumps(self.jwk, sort_keys=True, separators=(',', ':')) - thumbprint = nopad_b64(hashlib.sha256(accountkey_json.encode('utf8')).digest()) - return "{0}.{1}".format(token, thumbprint) - - def _parse_account_key(self, key): - ''' - Parses an RSA or Elliptic Curve key file in PEM format and returns a pair - (error, key_data). - ''' - account_key_type = None - with open(key, "rt") as f: - for line in f: - m = re.match(r"^\s*-{5,}BEGIN\s+(EC|RSA)\s+PRIVATE\s+KEY-{5,}\s*$", line) - if m is not None: - account_key_type = m.group(1).lower() - break - if account_key_type is None: - # This happens for example if openssl_privatekey created this key - # (as opposed to the OpenSSL binary). For now, we assume this is - # an RSA key. - # FIXME: add some kind of auto-detection - account_key_type = "rsa" - if account_key_type not in ("rsa", "ec"): - return 'unknown key type "%s"' % account_key_type, {} - - openssl_keydump_cmd = [self._openssl_bin, account_key_type, "-in", key, "-noout", "-text"] - dummy, out, dummy = self.module.run_command(openssl_keydump_cmd, check_rc=True) - - if account_key_type == 'rsa': - pub_hex, pub_exp = re.search( - r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)", - to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL).groups() - pub_exp = "{0:x}".format(int(pub_exp)) - if len(pub_exp) % 2: - pub_exp = "0{0}".format(pub_exp) - - return None, { - 'type': 'rsa', - 'alg': 'RS256', - 'jwk': { - "kty": "RSA", - "e": nopad_b64(binascii.unhexlify(pub_exp.encode("utf-8"))), - "n": nopad_b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))), - }, - 'hash': 'sha256', - } - elif account_key_type == 'ec': - pub_data = re.search( - r"pub:\s*\n\s+04:([a-f0-9\:\s]+?)\nASN1 OID: (\S+)(?:\nNIST CURVE: (\S+))?", - to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL) - if pub_data is None: - return 'cannot parse elliptic curve key', {} - pub_hex = binascii.unhexlify(re.sub(r"(\s|:)", "", pub_data.group(1)).encode("utf-8")) - asn1_oid_curve = pub_data.group(2).lower() - nist_curve = pub_data.group(3).lower() if pub_data.group(3) else None - if asn1_oid_curve == 'prime256v1' or nist_curve == 'p-256': - bits = 256 - alg = 'ES256' - hash = 'sha256' - point_size = 32 - curve = 'P-256' - elif asn1_oid_curve == 'secp384r1' or nist_curve == 'p-384': - bits = 384 - alg = 'ES384' - hash = 'sha384' - point_size = 48 - curve = 'P-384' - elif asn1_oid_curve == 'secp521r1' or nist_curve == 'p-521': - # Not yet supported on Let's Encrypt side, see - # https://github.com/letsencrypt/boulder/issues/2217 - bits = 521 - alg = 'ES512' - hash = 'sha512' - point_size = 66 - curve = 'P-521' - else: - return 'unknown elliptic curve: %s / %s' % (asn1_oid_curve, nist_curve), {} - bytes = (bits + 7) // 8 - if len(pub_hex) != 2 * bytes: - return 'bad elliptic curve point (%s / %s)' % (asn1_oid_curve, nist_curve), {} - return None, { - 'type': 'ec', - 'alg': alg, - 'jwk': { - "kty": "EC", - "crv": curve, - "x": nopad_b64(pub_hex[:bytes]), - "y": nopad_b64(pub_hex[bytes:]), - }, - 'hash': hash, - 'point_size': point_size, - } - - def send_signed_request(self, url, payload): - ''' - Sends a JWS signed HTTP POST request to the ACME server and returns - the response as dictionary - https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-6.2 - ''' - failed_tries = 0 - while True: - protected = copy.deepcopy(self.jws_header) - protected["nonce"] = self.directory.get_nonce() - if self.version != 1: - protected["url"] = url - - try: - payload64 = nopad_b64(self.module.jsonify(payload).encode('utf8')) - 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)) - - openssl_sign_cmd = [self._openssl_bin, "dgst", "-{0}".format(self.key_data['hash']), "-sign", self.key] - sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8') - dummy, out, dummy = self.module.run_command(openssl_sign_cmd, data=sign_payload, check_rc=True, binary_data=True) - - if self.key_data['type'] == 'ec': - dummy, der_out, dummy = self.module.run_command( - [self._openssl_bin, "asn1parse", "-inform", "DER"], - data=out, binary_data=True) - expected_len = 2 * self.key_data['point_size'] - sig = re.findall( - r"prim:\s+INTEGER\s+:([0-9A-F]{1,%s})\n" % expected_len, - to_text(der_out, errors='surrogate_or_strict')) - if len(sig) != 2: - raise ModuleFailException( - "failed to generate Elliptic Curve signature; cannot parse DER output: {0}".format( - to_text(der_out, errors='surrogate_or_strict'))) - sig[0] = (expected_len - len(sig[0])) * '0' + sig[0] - sig[1] = (expected_len - len(sig[1])) * '0' + sig[1] - out = binascii.unhexlify(sig[0]) + binascii.unhexlify(sig[1]) - - data = { - "protected": protected64, - "payload": payload64, - "signature": nopad_b64(to_bytes(out)), - } - if self.version == 1: - data["header"] = self.jws_header - data = self.module.jsonify(data) - - headers = { - 'Content-Type': 'application/jose+json', - } - resp, info = fetch_url(self.module, url, data=data, headers=headers, method='POST') - result = {} - try: - content = resp.read() - except AttributeError: - content = info.get('body') - - if content: - if info['content-type'].startswith('application/json') or 400 <= info['status'] < 600: - try: - result = self.module.from_json(content.decode('utf8')) - # In case of badNonce error, try again (up to 5 times) - # (https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-6.6) - if (400 <= info['status'] < 600 and - result.get('type') == 'urn:ietf:params:acme:error:badNonce' and - failed_tries <= 5): - failed_tries += 1 - continue - except ValueError: - raise ModuleFailException("Failed to parse the ACME response: {0} {1}".format(url, content)) - else: - result = content - - return result, info - - def _new_reg(self, contact=None): - ''' - Registers a new ACME account. Returns True if the account was - created and False if it already existed (e.g. it was not newly - created) - https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.3 - ''' - contact = [] if contact is None else contact - - if self.uri is not None: - return True - - if self.version == 1: - new_reg = { - 'resource': 'new-reg', - 'contact': contact - } - if self.agreement: - new_reg['agreement'] = self.agreement - else: - new_reg['agreement'] = self.directory['meta']['terms-of-service'] - url = self.directory['new-reg'] - else: - new_reg = { - 'contact': contact - } - if self.terms_agreed: - new_reg['termsOfServiceAgreed'] = True - url = self.directory['newAccount'] - - result, info = self.send_signed_request(url, new_reg) - if 'location' in info: - self.uri = info['location'] - if self.version != 1: - self.jws_header.pop('jwk') - self.jws_header['kid'] = self.uri - - if info['status'] in [200, 201]: - # Account did not exist - self.changed = True - return True - elif info['status'] == 409: - # Account did exist - return False - else: - raise ModuleFailException("Error registering: {0} {1}".format(info['status'], result)) - - def init_account(self): - ''' - Create or update an account on the ACME server. As the only way - (without knowing an account URI) to test if an account exists - is to try and create one with the provided account key, this - method will always result in an account being present (except - on error situations). If the account already exists, it will - update the contact information. - https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.3 - ''' - - contact = [] - if self.email: - contact.append('mailto:' + self.email) - - # if this is not a new registration (e.g. existing account) - if not self._new_reg(contact): - # pre-existing account, get account data... - result, dummy = self.send_signed_request(self.uri, {'resource': 'reg'}) - - # ...and check if update is necessary - do_update = False - if 'contact' in result: - if contact != result['contact']: - do_update = True - elif len(contact) > 0: - do_update = True - - if do_update: - upd_reg = result - upd_reg['contact'] = contact - result, dummy = self.send_signed_request(self.uri, upd_reg) - self.changed = True - - class ACMEClient(object): ''' ACME client class. Uses an ACME account object and a CSR to @@ -863,10 +351,20 @@ class ACMEClient(object): self.data = module.params['data'] self.authorizations = None self.cert_days = -1 - self.changed = self.account.changed self.order_uri = self.data.get('order_uri') if self.data else None self.finalize_uri = self.data.get('finalize_uri') if self.data else None + # Make sure account exists + contact = [] + if module.params['account_email']: + contact.append('mailto:' + module.params['account_email']) + self.changed = self.account.init_account( + contact, + agreement=module.params.get('agreement'), + terms_agreed=module.params.get('terms_agreed') + ) + + # Extract list of domains from CSR if not os.path.exists(self.csr): raise ModuleFailException("CSR %s not found" % (self.csr)) @@ -1279,9 +777,10 @@ def main(): argument_spec=dict( account_key_src=dict(type='path', aliases=['account_key']), account_key_content=dict(type='str', no_log=True), - account_email=dict(required=False, default=None, 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'), + account_email=dict(required=False, default=None, type='str'), agreement=dict(required=False, type='str'), terms_agreed=dict(required=False, default=False, type='bool'), challenge=dict(required=False, default='http-01', choices=['http-01', 'dns-01'], type='str'), @@ -1291,7 +790,6 @@ def main(): fullchain_dest=dict(aliases=['fullchain'], type='path'), chain_dest=dict(required=False, default=None, aliases=['chain'], type='path'), remaining_days=dict(required=False, default=10, type='int'), - validate_certs=dict(required=False, default=True, type='bool'), deactivate_authzs=dict(required=False, default=False, type='bool'), force=dict(required=False, default=False, type='bool'), ), diff --git a/lib/ansible/utils/module_docs_fragments/letsencrypt.py b/lib/ansible/utils/module_docs_fragments/letsencrypt.py new file mode 100644 index 0000000000..52c928dd45 --- /dev/null +++ b/lib/ansible/utils/module_docs_fragments/letsencrypt.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- + +# (c) 2016 Michael Gruener +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +class ModuleDocFragment(object): + + # Standard files documentation fragment + DOCUMENTATION = """ +requirements: + - "python >= 2.6" + - openssl +options: + account_key_src: + description: + - "Path to a file containing the ACME account RSA or Elliptic Curve + key." + - "RSA keys can be created with C(openssl rsa ...). Elliptic curve keys can + be created with C(openssl ecparam -genkey ...)." + - "Mutually exclusive with C(account_key_content)." + - "Required if C(account_key_content) is not used." + aliases: [ account_key ] + account_key_content: + description: + - "Content of the ACME account RSA or Elliptic Curve key." + - "Mutually exclusive with C(account_key_src)." + - "Required if C(account_key_src) is not used." + - "Warning: the content will be written into a temporary file, which will + be deleted by Ansible when the module completes. Since this is an + important private key — it can be used to change the account key, + or to revoke your certificates without knowing their private keys + —, this might not be acceptable." + version_added: "2.5" + acme_version: + description: + - "The ACME version of the endpoint." + - "Must be 1 for the classic Let's Encrypt ACME endpoint, or 2 for the + new standardized ACME v2 endpoint." + default: 1 + choices: [1, 2] + version_added: "2.5" + acme_directory: + description: + - "The ACME directory to use. This is the entry point URL to access + CA server API." + - "For safety reasons the default is set to the Let's Encrypt staging + server (for the ACME v1 protocol). This will create technically correct, + but untrusted certificates." + - "For Let's Encrypt, all staging endpoints can be found here: + U(https://letsencrypt.org/docs/staging-environment/)" + - "For Let's Encrypt, the production directory URL for ACME v1 is + U(https://acme-v01.api.letsencrypt.org/directory), and the production + directory URL for ACME v2 is U(https://acme-v02.api.letsencrypt.org/directory)." + - "I(Warning): So far, the module has only been tested against Let's Encrypt + (staging and production) and against the Pebble testing server + (U(https://github.com/letsencrypt/Pebble))." + default: https://acme-staging.api.letsencrypt.org/directory + validate_certs: + description: + - Whether calls to the ACME directory will validate TLS certificates. + - I(Warning:) Should I(only ever) be set to C(no) for testing purposes, + for example when testing against a local Pebble server. + type: bool + default: 'yes' + version_added: 2.5 +""" diff --git a/test/integration/targets/acme_account/aliases b/test/integration/targets/acme_account/aliases new file mode 100644 index 0000000000..7978f4a6ce --- /dev/null +++ b/test/integration/targets/acme_account/aliases @@ -0,0 +1,2 @@ +posix/ci/group1 +destructive diff --git a/test/integration/targets/acme_account/meta/main.yml b/test/integration/targets/acme_account/meta/main.yml new file mode 100644 index 0000000000..800aff6428 --- /dev/null +++ b/test/integration/targets/acme_account/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - setup_openssl diff --git a/test/integration/targets/acme_account/tasks/main.yml b/test/integration/targets/acme_account/tasks/main.yml new file mode 100644 index 0000000000..83b74d9c86 --- /dev/null +++ b/test/integration/targets/acme_account/tasks/main.yml @@ -0,0 +1,109 @@ +--- +- block: + - debug: var=openssl_version.stdout + + - 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: Do not try to create account + acme_account: + account_key_src: "{{ output_dir }}/accountkey.pem" + acme_version: 2 + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + state: present + allow_creation: no + ignore_errors: yes + register: account_not_created + + - name: Create it now + acme_account: + account_key_src: "{{ output_dir }}/accountkey.pem" + acme_version: 2 + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + state: present + allow_creation: yes + terms_agreed: yes + contact: + - mailto:example@example.org + register: account_created + + - name: Change email address + acme_account: + account_key_src: "{{ output_dir }}/accountkey.pem" + acme_version: 2 + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + state: present + # allow_creation: no + contact: + - mailto:example@example.com + register: account_modified + + - name: Change email address (idempotent) + acme_account: + account_key_src: "{{ output_dir }}/accountkey.pem" + acme_version: 2 + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + state: present + # allow_creation: no + contact: + - mailto:example@example.com + register: account_modified_idempotent + + - name: Generate new account key + command: openssl ecparam -name secp384r1 -genkey -out {{ output_dir }}/accountkey2.pem + + - name: Parse account key (to ease debugging some test failures) + command: openssl ec -in {{ output_dir }}/accountkey2.pem -noout -text + + - name: Change account key + acme_account: + account_key_src: "{{ output_dir }}/accountkey.pem" + acme_version: 2 + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + new_account_key_src: "{{ output_dir }}/accountkey2.pem" + state: changed_key + contact: + - mailto:example@example.com + register: account_change_key + + - name: Deactivate account + acme_account: + account_key_src: "{{ output_dir }}/accountkey2.pem" + acme_version: 2 + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + state: absent + register: account_deactivate + + - name: Deactivate account (idempotent) + acme_account: + account_key_src: "{{ output_dir }}/accountkey2.pem" + acme_version: 2 + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + state: absent + register: account_deactivate_idempotent + + - name: Do not try to create account II + acme_account: + account_key_src: "{{ output_dir }}/accountkey2.pem" + acme_version: 2 + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + state: present + allow_creation: no + ignore_errors: yes + register: account_not_created_2 + + - name: Do not try to create account III + acme_account: + account_key_src: "{{ output_dir }}/accountkey.pem" + acme_version: 2 + acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + state: present + allow_creation: no + ignore_errors: yes + register: account_not_created_3 + + # Old 0.9.8 versions have insufficient CLI support for signing with EC keys + when: openssl_version.stdout is version('1.0.0', '>=') diff --git a/test/integration/targets/acme_account/tests/validate.yml b/test/integration/targets/acme_account/tests/validate.yml new file mode 100644 index 0000000000..cb0edd8f60 --- /dev/null +++ b/test/integration/targets/acme_account/tests/validate.yml @@ -0,0 +1,51 @@ +--- +- name: Validate that account wasn't created in the first step + assert: + that: + - account_not_created is failed + +- name: Validate that account was created in the second step + assert: + that: + - account_created is changed + - account_created.account_uri is not none + +- name: Validate that email address was changed + assert: + that: + - account_modified is changed + - account_modified.account_uri is not none + +- name: Validate that email address was not changed a second time (idempotency) + assert: + that: + - account_modified_idempotent is not changed + - account_modified_idempotent.account_uri is not none + +- name: Validate that the account key was changed + assert: + that: + - account_change_key is changed + - account_change_key.account_uri is not none + +- name: Validate that the account was deactivated + assert: + that: + - account_deactivate is changed + - account_deactivate.account_uri is not none + +- name: Validate that the account was really deactivated (idempotency) + assert: + that: + - account_deactivate_idempotent is not changed + - account_deactivate_idempotent.account_uri is not none + +- name: Validate that the account is gone (new account key) + assert: + that: + - account_not_created_2 is failed + +- name: Validate that the account is gone (old account key) + assert: + that: + - account_not_created_3 is failed