From bb6504c8d351ea188712c6c4976fe521ec3f189c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Gr=C3=BCner?= Date: Wed, 1 Jun 2016 23:02:05 +0200 Subject: [PATCH] Add Let's Encrypt module to manage ssl certificates (#1962) Fixes #1545 --- web_infrastructure/letsencrypt.py | 785 ++++++++++++++++++++++++++++++ 1 file changed, 785 insertions(+) create mode 100644 web_infrastructure/letsencrypt.py diff --git a/web_infrastructure/letsencrypt.py b/web_infrastructure/letsencrypt.py new file mode 100644 index 00000000000..35d521a8509 --- /dev/null +++ b/web_infrastructure/letsencrypt.py @@ -0,0 +1,785 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2016 Michael Gruener +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +import binascii +import copy +import textwrap +from datetime import datetime + +DOCUMENTATION = ''' +--- +module: letsencrypt +author: "Michael Gruener (@mgruener)" +version_added: "2.2" +short_description: Create SSL certificates with Let's Encrypt +description: + - "Create and renew SSL certificates 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 current + implementation supports the http-01, tls-sni-02 and dns-01 challenges." + - "To use this module, it has to be executed at least twice. Either as two + different tasks in the same run or during multiple runs." + - "Between these two tasks you have to fulfill the required steps for the + choosen challenge by whatever means necessary. For http-01 that means + creating the necessary challenge file on the destination webserver. For + dns-01 the necessary dns record has to be created. tls-sni-02 requires + you to create a SSL certificate with the appropriate subjectAlternativeNames. + It is I(not) the responsibility of this module to perform these steps." + - "For details on how to fulfill these challenges, you might have to read through + U(https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-7)" + - "Although the defaults are choosen so that the module can be used with + the Let's Encrypt CA, the module can be used with any service using the ACME + protocol." +options: + account_key: + description: + - "File containing the the Let's Encrypt account RSA key." + - "Can be created with C(openssl rsa ...)." + required: true + account_email: + description: + - "The email address associated with this account." + - "It will be used for certificate expiration warnings." + required: false + default: null + 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. + This will create technically correct, but untrusted certifiactes." + required: false + default: https://acme-staging.api.letsencrypt.org/directory + agreement: + description: + - "URI to a terms of service document you agree to when using the + ACME service at C(acme_directory)." + required: false + default: 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf' + challenge: + description: The challenge to be performed. + required: false + choices: [ 'http-01', 'dns-01', 'tls-sni-02'] + default: 'http-01' + csr: + description: + - "File containing the CSR for the new certificate." + - "Can be created with C(openssl csr ...)." + - "The CSR may contain multiple Subject Alternate Names, but each one + will lead to an individual challenge that must be fulfilled for the + CSR to be signed." + required: true + alias: ['src'] + data: + description: + - "The data to validate ongoing challenges." + - "The value that must be used here will be provided by a previous use + of this module." + required: false + default: null + dest: + description: The destination file for the certificate. + required: true + alias: ['cert'] + remaining_days: + description: + - "The number of days the certificate must have left being valid before it + will be renewed." + required: false + default: 10 +''' + +EXAMPLES = ''' +- letsencrypt: + account_key: /etc/pki/cert/private/account.key + csr: /etc/pki/cert/csr/sample.com.csr + dest: /etc/httpd/ssl/sample.com.crt + register: sample_com_challenge + +# perform the necessary steps to fulfill the challenge +# for example: +# +# - copy: +# dest: /var/www/html/{{ sample_com_http_challenge['challenge_data']['sample.com']['http-01']['resource'] }} +# content: "{{ sample_com_http_challenge['challenge_data']['sample.com']['http-01']['resource_value'] }}" +# when: sample_com_challenge|changed + +- letsencrypt: + account_key: /etc/pki/cert/private/account.key + csr: /etc/pki/cert/csr/sample.com.csr + dest: /etc/httpd/ssl/sample.com.crt + data: "{{ sample_com_challenge }}" +''' + +RETURN = ''' +cert_days: + description: the number of days the certificate remains valid. + returned: success +challenge_data: + description: per domain / challenge type challenge data + returned: changed + type: dictionary + contains: + resource: + description: the challenge resource that must be created for validation + returned: changed + type: string + sample: .well-known/acme-challenge/evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA + resource_value: + description: the value the resource has to produce for the validation + returned: changed + type: string + sample: IlirfxKKXA...17Dt3juxGJ-PCt92wr-oA +authorizations: + description: ACME authorization data. + returned: changed + type: list + contains: + authorization: + description: ACME authorization object. See https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.1.2 + returned: success + type: dict +''' + +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 = None + try: + content = resp.read() + if info['content-type'].startswith('application/json'): + result = module.from_json(content.decode('utf8')) + else: + result = content + except AttributeError: + result = None + except ValueError: + module.fail_json(msg="Failed to parse the ACME response: {0} {1}".format(url,content)) + + if info['status'] >= 400: + module.fail_json(msg="ACME request failed: CODE: {0} RESULT:{1}".format(info['status'],result)) + return result + +def get_cert_days(module,cert_file): + ''' + Return the days the certificate in cert_file remains valid and -1 + if the file was not found. + ''' + _cert_file = os.path.expanduser(cert_file) + if not os.path.exists(_cert_file): + return -1 + + openssl_bin = module.get_bin_path('openssl', True) + openssl_cert_cmd = [openssl_bin, "x509", "-in", _cert_file, "-noout", "-text"] + _, out, _ = module.run_command(openssl_cert_cmd,check_rc=True) + try: + not_after_str = re.search(r"\s+Not After\s*:\s+(.*)",out.decode('utf8')).group(1) + not_after = datetime.datetime.fromtimestamp(time.mktime(time.strptime(not_after_str,'%b %d %H:%M:%S %Y %Z'))) + except AttributeError: + module.fail_json(msg="No 'Not after' date found in {0}".format(cert_file)) + except ValueError: + module.fail_json(msg="Faild to parse 'Not after' date of {0}".format(cert_file)) + now = datetime.datetime.utcnow() + 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 with some test content + _, tmpsrc = tempfile.mkstemp() + f = open(tmpsrc, 'wb') + try: + f.write(content) + except Exception, err: + os.remove(tmpsrc) + module.fail_json(msg="failed to create temporary content file: %s" % str(err)) + f.close() + checksum_src = None + checksum_dest = None + # raise an error if there is no tmpsrc file + if not os.path.exists(tmpsrc): + os.remove(tmpsrc) + module.fail_json(msg="Source %s does not exist" % (tmpsrc)) + if not os.access(tmpsrc, os.R_OK): + os.remove(tmpsrc) + module.fail_json( msg="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) + module.fail_json(msg="Destination %s not writable" % (dest)) + if not os.access(dest, os.R_OK): + os.remove(tmpsrc) + module.fail_json(msg="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) + module.fail_json(msg="Destination dir %s not writable" % (os.path.dirname(dest))) + if checksum_src != checksum_dest: + try: + shutil.copyfile(tmpsrc, dest) + changed = True + except Exception, err: + os.remove(tmpsrc) + module.fail_json(msg="failed to copy %s to %s: %s" % (tmpsrc, dest, str(err))) + 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-02#section-6.2 + ''' + def __init__(self, module): + self.module = module + self.directory_root = module.params['acme_directory'] + + self.directory = simple_get(self.module,self.directory_root) + + def __getitem__(self, key): return self.directory[key] + + def get_nonce(self,resource=None): + url = self.directory_root + if resource is not None: + url = resource + _, info = fetch_url(self.module, url, method='HEAD') + if info['status'] != 200: + self.module.fail_json(msg="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 accound bound information like + the currently active authorizations and valid certificates + ''' + def __init__(self,module): + self.module = module + self.agreement = module.params['agreement'] + self.key = os.path.expanduser(module.params['account_key']) + self.email = module.params['account_email'] + self.data = module.params['data'] + self.directory = ACMEDirectory(module) + self.uri = None + self.changed = False + + self._authz_list_uri = None + self._certs_list_uri = None + + if not os.path.exists(self.key): + module.fail_json(msg="Account key %s not found" % (self.key)) + + self._openssl_bin = module.get_bin_path('openssl', True) + + pub_hex, pub_exp = self._parse_account_key(self.key) + self.jws_header = { + "alg": "RS256", + "jwk": { + "e": nopad_b64(binascii.unhexlify(pub_exp.encode("utf-8"))), + "kty": "RSA", + "n": nopad_b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))), + }, + } + 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-02#section-7.1 + ''' + accountkey_json = json.dumps(self.jws_header['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 key file in PEM format and returns the modulus + and public exponent of the key + ''' + openssl_keydump_cmd = [self._openssl_bin, "rsa", "-in", key, "-noout", "-text"] + _, out, _ = self.module.run_command(openssl_keydump_cmd,check_rc=True) + + pub_hex, pub_exp = re.search( + r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)", + out.decode('utf8'), 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 pub_hex, pub_exp + + 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-02#section-5.2 + ''' + protected = copy.deepcopy(self.jws_header) + protected["nonce"] = self.directory.get_nonce() + + try: + payload64 = nopad_b64(self.module.jsonify(payload).encode('utf8')) + protected64 = nopad_b64(self.module.jsonify(protected).encode('utf8')) + except Exception, e: + self.module.fail_json(msg="Failed to encode payload / headers as JSON: {0}".format(e)) + + openssl_sign_cmd = [self._openssl_bin, "dgst", "-sha256", "-sign", self.key] + sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8') + _, out, _ = self.module.run_command(openssl_sign_cmd,data=sign_payload,check_rc=True, binary_data=True) + + data = self.module.jsonify({ + "header": self.jws_header, + "protected": protected64, + "payload": payload64, + "signature": nopad_b64(out), + }) + + resp, info = fetch_url(self.module, url, data=data, method='POST') + result = None + try: + content = resp.read() + if info['content-type'].startswith('application/json'): + result = self.module.from_json(content.decode('utf8')) + else: + result = content + except AttributeError: + result = None + except ValueError: + self.module.fail_json(msg="Failed to parse the ACME response: {0} {1}".format(url,content)) + + return result,info + + def _new_reg(self,contact=[]): + ''' + 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-02#section-6.3 + ''' + if self.uri is not None: + return True + + new_reg = { + 'resource': 'new-reg', + 'agreement': self.agreement, + 'contact': contact + } + + result, info = self.send_signed_request(self.directory['new-reg'], new_reg) + if 'location' in info: + self.uri = info['location'] + + 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: + self.module.fail_json(msg="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-02#section-6.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, _ = self.send_signed_request(self.uri, {'resource':'reg'}) + + # XXX: letsencrypt/boulder#1435 + if 'authorizations' in result: + self._authz_list_uri = result['authorizations'] + if 'certificates' in result: + self._certs_list_uri = result['certificates'] + + # ...and check if update is necessary + do_update = False + if 'contact' in result: + if cmp(contact,result['contact']) != 0: + do_update = True + elif len(contact) > 0: + do_update = True + + if do_update: + upd_reg = result + upd_reg['contact'] = contact + result, _ = self.send_signed_request(self.uri, upd_reg) + self.changed = True + + def get_authorizations(self): + ''' + Return a list of currently active authorizations + https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.4 + ''' + authz_list = {'authorizations': []} + if self._authz_list_uri is None: + # XXX: letsencrypt/boulder#1435 + # Workaround, retrieve the known authorization urls + # from the data attribute + # It is also a way to limit the queried authorizations, which + # might become relevant at some point + if (self.data is not None) and ('authorizations' in self.data): + for auth in self.data['authorizations']: + authz_list['authorizations'].append(auth['uri']) + else: + return [] + else: + # TODO: need to handle pagination + authz_list = simple_get(self.module, self._authz_list_uri) + + authz = [] + for auth_uri in authz_list['authorizations']: + auth = simple_get(self.module,auth_uri) + auth['uri'] = auth_uri + authz.append(auth) + + return authz + +class ACMEClient(object): + ''' + ACME client class. Uses an ACME account object and a CSR to + start and validate ACME challenges and download the respective + certificates. + ''' + def __init__(self,module): + self.module = module + self.challenge = module.params['challenge'] + self.csr = os.path.expanduser(module.params['csr']) + self.dest = os.path.expanduser(module.params['dest']) + self.account = ACMEAccount(module) + self.directory = self.account.directory + self.authorizations = self.account.get_authorizations() + self.cert_days = -1 + self.changed = self.account.changed + + if not os.path.exists(self.csr): + module.fail_json(msg="CSR %s not found" % (self.csr)) + + self._openssl_bin = module.get_bin_path('openssl', True) + self.domains = self._get_csr_domains() + + def _get_csr_domains(self): + ''' + Parse the CSR and return the list of requested domains + ''' + openssl_csr_cmd = [self._openssl_bin, "req", "-in", self.csr, "-noout", "-text"] + _, out, _ = self.module.run_command(openssl_csr_cmd,check_rc=True) + + domains = set([]) + common_name = re.search(r"Subject:.*? CN=([^\s,;/]+)", out.decode('utf8')) + if common_name is not None: + domains.add(common_name.group(1)) + subject_alt_names = re.search(r"X509v3 Subject Alternative Name: \n +([^\n]+)\n", out.decode('utf8'), re.MULTILINE|re.DOTALL) + if subject_alt_names is not None: + for san in subject_alt_names.group(1).split(", "): + if san.startswith("DNS:"): + domains.add(san[4:]) + return domains + + + def _get_domain_auth(self,domain): + ''' + Get the status string of the first authorization for the given domain. + Return None if no active authorization for the given domain was found. + ''' + if self.authorizations is None: + return None + + for auth in self.authorizations: + if (auth['identifier']['type'] == 'dns') and (auth['identifier']['value'] == domain): + return auth + return None + + def _add_or_update_auth(self,auth): + ''' + Add or update the given authroization in the global authorizations list. + Return True if the auth was updated/added and False if no change was + necessary. + ''' + for index,cur_auth in enumerate(self.authorizations): + if (cur_auth['uri'] == auth['uri']): + # does the auth parameter contain updated data? + if cmp(cur_auth,auth) != 0: + # yes, update our current authorization list + self.authorizations[index] = auth + return True + else: + return False + # this is a new authorization, add it to the list of current + # authorizations + self.authorizations.append(auth) + return True + + def _new_authz(self,domain): + ''' + Create a new authorization for the given domain. + Return the authorization object of the new authorization + https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.4 + ''' + if self.account.uri is None: + return + + new_authz = { + "resource": "new-authz", + "identifier": {"type": "dns", "value": domain}, + } + + result, info = self.account.send_signed_request(self.directory['new-authz'], new_authz) + if info['status'] not in [200,201]: + self.module.fail_json(msg="Error requesting challenges: CODE: {0} RESULT: {1}".format(info['status'], result)) + else: + result['uri'] = info['location'] + return result + + def _get_challenge_data(self,auth): + ''' + Returns a dict with the data for all proposed (and supported) challenges + of the given authorization. + ''' + + data = {} + # no need to choose a specific challenge here as this module + # is not responsible for fulfilling the challenges. Calculate + # and return the required information for each challenge. + for challenge in auth['challenges']: + type = challenge['type'] + token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) + keyauthorization = self.account.get_keyauthorization(token) + + # NOTE: tls-sni-01 is not supported by choice + # too complex to be usefull and tls-sni-02 is an alternative + # as soon as it is implemented server side + if type == 'http-01': + # https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-7.2 + resource = '.well-known/acme-challenge/' + token + value = keyauthorization + elif type == 'tls-sni-02': + # https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-7.3 + token_digest = hashlib.sha256(token.encode('utf8')).hexdigest() + ka_digest = hashlib.sha256(keyauthorization.encode('utf8')).hexdigest() + len_token_digest = len(token_digest) + len_ka_digest = len(ka_digest) + resource = 'subjectAlternativeNames' + value = [ + "{0}.{1}.token.acme.invalid".format(token_digest[:len_token_digest/2],token_digest[len_token_digest/2:]), + "{0}.{1}.ka.acme.invalid".format(ka_digest[:len_ka_digest/2],ka_digest[len_ka_digest/2:]), + ] + elif type == 'dns-01': + # https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-7.4 + resource = '_acme-challenge' + value = nopad_b64(hashlib.sha256(keyauthorization).digest()).encode('utf8') + else: + continue + + data[type] = { 'resource': resource, 'resource_value': value } + return data + + def _validate_challenges(self,auth): + ''' + Validate the authorization provided in the auth dict. Returns True + when the validation was successfull and False when it was not. + ''' + for challenge in auth['challenges']: + if self.challenge != challenge['type']: + continue + + uri = challenge['uri'] + token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) + keyauthorization = self.account.get_keyauthorization(token) + + challenge_response = { + "resource": "challenge", + "keyAuthorization": keyauthorization, + } + result, info = self.account.send_signed_request(uri, challenge_response) + if info['status'] != 200: + self.module.fail_json(msg="Error validating challenge: CODE: {0} RESULT: {1}".format(info['status'], result)) + + status = '' + + while status not in ['valid','invalid','revoked']: + result = simple_get(self.module,auth['uri']) + result['uri'] = auth['uri'] + if self._add_or_update_auth(result): + self.changed = True + # draft-ietf-acme-acme-02 + # "status (required, string): ... + # If this field is missing, then the default value is "pending"." + if 'status' not in result: + status = 'pending' + else: + status = result['status'] + time.sleep(2) + + if status == 'invalid': + error_details = '' + # multiple challenges could have failed at this point, gather error + # details for all of them before failing + for challenge in result['challenges']: + if challenge['status'] == 'invalid': + error_details += ' CHALLENGE: {0}'.format(challenge['type']) + if 'error' in challenge: + error_details += ' DETAILS: {0};'.format(challenge['error']['detail']) + else: + error_details += ';' + self.module.fail_json(msg="Authorization for {0} returned invalid: {1}".format(result['identifier']['value'],error_details)) + + return status == 'valid' + + def _new_cert(self): + ''' + Create a new certificate based on the csr. + Return the certificate object as dict + https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.5 + ''' + openssl_csr_cmd = [self._openssl_bin, "req", "-in", self.csr, "-outform", "DER"] + _, out, _ = self.module.run_command(openssl_csr_cmd,check_rc=True) + + new_cert = { + "resource": "new-cert", + "csr": nopad_b64(out), + } + result, info = self.account.send_signed_request(self.directory['new-cert'], new_cert) + if info['status'] not in [200,201]: + self.module.fail_json(msg="Error new cert: CODE: {0} RESULT: {1}".format(info['status'], result)) + else: + return {'cert': result, 'uri': info['location']} + + def _der_to_pem(self,der_cert): + ''' + Convert the DER format certificate in der_cert to a PEM format + certificate and return it. + ''' + return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format( + "\n".join(textwrap.wrap(base64.b64encode(der_cert).decode('utf8'), 64))) + + def do_challenges(self): + ''' + Create new authorizations for all domains of the CSR and return + the challenge details for the choosen challenge type. + ''' + data = {} + for domain in self.domains: + auth = self._get_domain_auth(domain) + if auth is None: + new_auth = self._new_authz(domain) + self._add_or_update_auth(new_auth) + data[domain] = self._get_challenge_data(new_auth) + self.changed = True + elif (auth['status'] == 'pending') or ('status' not in auth): + # draft-ietf-acme-acme-02 + # "status (required, string): ... + # If this field is missing, then the default value is "pending"." + self._validate_challenges(auth) + # _validate_challenges updates the global authrozation dict, + # so get the current version of the authorization we are working + # on to retrieve the challenge data + data[domain] = self._get_challenge_data(self._get_domain_auth(domain)) + + return data + + def get_certificate(self): + ''' + Request a new certificate and write it to the destination file. + Only do this if a destination file was provided and if all authorizations + for the domains of the csr are valid. No Return value. + ''' + if self.dest is None: + return + + for domain in self.domains: + auth = self._get_domain_auth(domain) + if auth is None or ('status' not in auth) or (auth['status'] != 'valid'): + return + + cert = self._new_cert() + if cert['cert'] is not None: + pem_cert = self._der_to_pem(cert['cert']) + if write_file(self.module,self.dest,pem_cert): + self.cert_days = get_cert_days(self.module,self.dest) + self.changed = True + +def main(): + module = AnsibleModule( + argument_spec = dict( + account_key = dict(required=True, type='str'), + account_email = dict(required=False, default=None, type='str'), + acme_directory = dict(required=False, default='https://acme-staging.api.letsencrypt.org/directory', type='str'), + agreement = dict(required=False, default='https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf', type='str'), + challenge = dict(required=False, default='http-01', choices=['http-01', 'dns-01', 'tls-sni-02'], type='str'), + csr = dict(required=True, aliases=['src'], type='str'), + data = dict(required=False, no_log=True, default=None, type='dict'), + dest = dict(required=True, aliases=['cert'], type='str'), + remaining_days = dict(required=False, default=10, type='int'), + ), + supports_check_mode = True, + ) + + cert_days = get_cert_days(module,module.params['dest']) + if cert_days < module.params['remaining_days']: + # If checkmode is active, base the changed state solely on the status + # of the certificate file as all other actions (accessing an account, checking + # the authorization status...) would lead to potential changes of the current + # state + if module.check_mode: + module.exit_json(changed=True,authorizations={}, + challenge_data={},cert_days=cert_days) + else: + client = ACMEClient(module) + client.cert_days = cert_days + data = client.do_challenges() + client.get_certificate() + module.exit_json(changed=client.changed,authorizations=client.authorizations, + challenge_data=data,cert_days=client.cert_days) + else: + module.exit_json(changed=False,cert_days=cert_days) + +# import module snippets +from ansible.module_utils.basic import * +from ansible.module_utils.urls import * + +if __name__ == '__main__': + main()