From 2ebb611b502d56cb91f7e33314e827f580be1ada Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Tue, 30 Jan 2018 23:39:58 +0100 Subject: [PATCH] Changing letsencrypt module to use ACME v2 protocol (update) (#35283) * Adding warnings. * Forgot choices for acme_version in code. * Removed 'errors' support again. * For DNS challenges, also return complete record to simplify wildcard generation (see #35283). * Also returning order URI and account URI. This is mainly for debugging purposes. * Adding more clear separation between the two calls. Avoids problems where code during second call thinks it is during first call and doesn't stop with error, but doesn't obtain a new certificate either. * Added validate_certs parameter. * Actively discouraging from setting validate_certs to false. * Fixing DNS challenge example. * Adding new output challenge_data_dns, which simplifies DNS challenges. --- .../modules/web_infrastructure/letsencrypt.py | 211 +++++++++++++----- 1 file changed, 157 insertions(+), 54 deletions(-) diff --git a/lib/ansible/modules/web_infrastructure/letsencrypt.py b/lib/ansible/modules/web_infrastructure/letsencrypt.py index f47e115e9a1..68039f0e2c2 100644 --- a/lib/ansible/modules/web_infrastructure/letsencrypt.py +++ b/lib/ansible/modules/web_infrastructure/letsencrypt.py @@ -36,7 +36,8 @@ description: U(https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-8)" - "Although the defaults are chosen so that the module can be used with the Let's Encrypt CA, the module can be used with any service using the ACME - v1 or v2 protocol." + v1 or v2 protocol. I(Warning): ACME v2 support is currently experimental, as + the Let's Encrypt production ACME v2 endpoint is still under development." - "At least one of C(dest) and C(fullchain_dest) must be specified." requirements: - "python >= 2.6" @@ -82,6 +83,11 @@ options: - "The ACME version of the endpoint." - "Must be 1 for the classic Let's Encrypt ACME endpoint, or 2 for the new ACME v2 endpoint." + - "I(Warning): ACME v2 support is currently experimental, as the Let's Encrypt + production ACME v2 endpoint is still under development. The code is tested + against the latest staging endpoint as well as the Pebble testing server, + but there could be bugs which will only appear with a newer version of these + or with the production ACME v2 endpoint." default: 1 choices: [1, 2] version_added: "2.5" @@ -145,6 +151,14 @@ 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(false) for testing purposes, + for example when testing against a local Pebble server. + required: false + default: true + version_added: 2.5 ''' EXAMPLES = ''' @@ -210,10 +224,25 @@ EXAMPLES = ''' # # - route53: # zone: sample.com -# record: "{{ item.value[challenge].resource }}.sample.com" +# record: "{{ sample_com_challenge.challenge_data['sample.com']['dns-01'].record }}" # type: TXT # ttl: 60 -# value: '"{{ item.value[challenge].resource_value }}"' +# # Note: route53 requires TXT entries to be enclosed in quotes +# value: "{{ sample_com_challenge.challenge_data['sample.com']['dns-01'].resource_value }}" +# when: sample_com_challenge is changed +# +# Alternative way: +# +# - route53: +# zone: sample.com +# record: "{{ item.key }}" +# type: TXT +# ttl: 60 +# # Note: item.value is a list of TXT entries, and route53 +# # requires every entry to be enclosed in quotes +# value: "{{ item.value | map('regex_replace', '^(.*)$', '\'\\1\'' ) | list }}" +# with_dict: sample_com_challenge.challenge_data_dns +# when: sample_com_challenge is changed - name: Let the challenge be validated and retrieve the cert and intermediate certificate letsencrypt: @@ -249,6 +278,17 @@ challenge_data: returned: changed type: string sample: IlirfxKKXA...17Dt3juxGJ-PCt92wr-oA + record: + description: the full DNS record's name for the challenge + returned: changed and challenge is dns-01 + type: string + sample: _acme-challenge.example.com + version_added: "2.5" +challenge_data_dns: + description: list of TXT values per DNS record, in case challenge is dns-01 + returned: changed + type: dict + version_added: "2.5" authorizations: description: ACME authorization data. returned: changed @@ -258,10 +298,21 @@ authorizations: description: ACME authorization object. See https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.1.4 returned: success type: dict +order_uri: + description: ACME order URI. + returned: changed + type: string + version_added: "2.5" finalization_uri: description: ACME finalization URI. returned: changed type: string + version_added: "2.5" +account_uri: + description: ACME account URI. + returned: changed + type: string + version_added: "2.5" ''' import base64 @@ -758,6 +809,7 @@ class ACMEClient(object): 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 if not os.path.exists(self.csr): @@ -818,7 +870,7 @@ class ACMEClient(object): result['uri'] = info['location'] return result - def _get_challenge_data(self, auth): + def _get_challenge_data(self, auth, domain): ''' Returns a dict with the data for all proposed (and supported) challenges of the given authorization. @@ -839,7 +891,7 @@ class ACMEClient(object): if type == 'http-01': # https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-8.3 resource = '.well-known/acme-challenge/' + token - value = keyauthorization + data[type] = {'resource': resource, 'resource_value': keyauthorization} elif type == 'tls-sni-02': # https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-8.4 token_digest = hashlib.sha256(token.encode('utf8')).hexdigest() @@ -851,16 +903,34 @@ class ACMEClient(object): "{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:]), ] + data[type] = {'resource': resource, 'resource_value': value} elif type == 'dns-01': # https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-8.5 resource = '_acme-challenge' value = nopad_b64(hashlib.sha256(to_bytes(keyauthorization)).digest()) + record = (resource + domain[1:]) if domain.startswith('*.') else (resource + '.' + domain) + data[type] = {'resource': resource, 'resource_value': value, 'record': record} else: continue - data[type] = {'resource': resource, 'resource_value': value} return data + def _fail_challenge(self, domain, auth, error): + ''' + Aborts with a specific error for a challenge. + ''' + error_details = '' + # multiple challenges could have failed at this point, gather error + # details for all of them before failing + for challenge in auth['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="{0}: {1}".format(error.format(domain), error_details)) + def _validate_challenges(self, domain, auth): ''' Validate the authorization provided in the auth dict. Returns True @@ -899,19 +969,7 @@ class ACMEClient(object): 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 'errors' in challenge: - error_details += ' DETAILS: {0};'.format('; '.join([error['detail'] for error in challenge['errors']])) - elif '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)) + self._fail_challenge(domain, result, 'Authorization for {0} returned invalid') return status == 'valid' @@ -998,7 +1056,7 @@ class ACMEClient(object): def _new_cert_v1(self): ''' - Create a new certificate based on the csr. + Create a new certificate based on the CSR (ACME v1 protocol). Return the certificate object as dict https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.5 ''' @@ -1027,6 +1085,10 @@ class ACMEClient(object): return {'cert': self._der_to_pem(result), 'uri': info['location'], 'chain': chain} def _new_order_v2(self): + ''' + Start a new certificate order (ACME v2 protocol). + https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.4 + ''' identifiers = [] for domain in self.domains: identifiers.append({ @@ -1047,60 +1109,86 @@ class ACMEClient(object): auth_data['uri'] = auth_uri self.authorizations[domain] = auth_data + self.order_uri = info['location'] self.finalize_uri = result['finalize'] - def do_challenges(self): + def is_first_step(self): ''' - Create new authorizations for all domains of the CSR and return - the challenge details for the chosen challenge type. + Return True if this is the first execution of this module, i.e. if a + sufficient data object from a first run has not been provided. + ''' + if (self.data is None) or ('authorizations' not in self.data): + return True + if self.finalize_uri is None and self.version != 1: + return True + return False + + def start_challenges(self): + ''' + Create new authorizations for all domains of the CSR, + respectively start a new order for ACME v2. ''' self.authorizations = {} - if (self.data is None) or ('authorizations' not in self.data): - # First run: start new order - if self.version == 1: - for domain in self.domains: - new_auth = self._new_authz_v1(domain) - self._add_or_update_auth(domain, new_auth) - else: - self._new_order_v2() - self.changed = True + if self.version == 1: + for domain in self.domains: + new_auth = self._new_authz_v1(domain) + self._add_or_update_auth(domain, new_auth) else: - # Second run: verify challenges - for domain, auth in self.data['authorizations'].items(): - self.authorizations[domain] = auth - if auth['status'] == 'pending': - self._validate_challenges(domain, auth) + self._new_order_v2() + self.changed = True + def get_challenges_data(self): + ''' + Get challenge details for the chosen challenge type. + Return a tuple of generic challenge details, and specialized DNS challenge details. + ''' + # Get general challenge data data = {} for domain, auth in self.authorizations.items(): - # _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.authorizations[domain]) + data[domain] = self._get_challenge_data(self.authorizations[domain], domain) + # Get DNS challenge data + data_dns = {} + if self.challenge == 'dns-01': + for domain, challenges in data.items(): + if self.challenge in challenges: + values = data_dns.get(challenges[self.challenge]['record']) + if values is None: + values = [] + data_dns[challenges[self.challenge]['record']] = values + values.append(challenges[self.challenge]['resource_value']) + return data, data_dns - return data + def finish_challenges(self): + ''' + Verify challenges for all domains of the CSR. + ''' + self.authorizations = {} + for domain, auth in self.data['authorizations'].items(): + self.authorizations[domain] = auth + if auth['status'] == 'pending': + self._validate_challenges(domain, auth) 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. + First verifies whether all authorizations are valid; if not, aborts + with an error. ''' - if self.dest is None and self.fullchain_dest is None: - return - if self.finalize_uri is None and self.version != 1: - return - for domain in self.domains: auth = self.authorizations.get(domain) - if auth is None or ('status' not in auth) or (auth['status'] != 'valid'): - return + if auth is None: + self.module.fail_json(msg='Found no authorization information for "{0}"!'.format(domain)) + if 'status' not in auth: + self._fail_challenge(domain, auth, 'Authorization for {0} returned no status') + if auth['status'] != 'valid': + self._fail_challenge(domain, auth, 'Authorization for {0} returned status ' + str(auth['status'])) if self.version == 1: cert = self._new_cert_v1() else: cert_uri = self._finalize_cert() cert = self._download_cert(cert_uri) + if cert['cert'] is not None: pem_cert = cert['cert'] @@ -1125,7 +1213,7 @@ def main(): 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, type='int'), + acme_version=dict(required=False, default=1, choices=[1, 2], type='int'), 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', 'tls-sni-02'], type='str'), @@ -1135,6 +1223,7 @@ 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'), ), required_one_of=( ['account_key_src', 'account_key_content'], @@ -1150,6 +1239,11 @@ def main(): module.run_command_environ_update = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C', LC_CTYPE='C') locale.setlocale(locale.LC_ALL, 'C') + 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('dest'): cert_days = get_cert_days(module, module.params['dest']) else: @@ -1164,13 +1258,22 @@ def main(): else: client = ACMEClient(module) client.cert_days = cert_days - data = client.do_challenges() - client.get_certificate() + if client.is_first_step(): + # First run: start challenges / start new order + client.start_challenges() + else: + # Second run: finish challenges, and get certificate + client.finish_challenges() + client.get_certificate() + data, data_dns = client.get_challenges_data() module.exit_json( changed=client.changed, authorizations=client.authorizations, finalize_uri=client.finalize_uri, + order_uri=client.order_uri, + account_uri=client.account.uri, challenge_data=data, + challenge_data_dns=data_dns, cert_days=client.cert_days ) else: