def main(): argument_spec = get_default_argspec() argument_spec.update( dict( url=dict(type='str'), method=dict(type='str', choices=['get', 'post', 'directory-only'], default='get'), content=dict(type='str'), fail_on_acme_error=dict(type='bool', default=True), )) module = AnsibleModule( argument_spec=argument_spec, mutually_exclusive=(['account_key_src', 'account_key_content'], ), required_if=( ['method', 'get', ['url']], ['method', 'post', ['url', 'content']], [ 'method', 'get', ['account_key_src', 'account_key_content'], True ], [ 'method', 'post', ['account_key_src', 'account_key_content'], True ], ), ) handle_standard_module_arguments(module) result = dict() changed = False try: # Get hold of ACMEAccount object (includes directory) account = ACMEAccount(module) method = module.params['method'] result['directory'] = account.directory.directory # Do we have to do more requests? if method != 'directory-only': url = module.params['url'] fail_on_acme_error = module.params['fail_on_acme_error'] # Do request if method == 'get': data, info = account.get_request(url, parse_json_result=False, fail_on_error=False) elif method == 'post': changed = True # only POSTs can change data, info = account.send_signed_request( url, to_bytes(module.params['content']), parse_json_result=False, encode_payload=False) # Update results result.update(dict( headers=info, output_text=to_native(data), )) # See if we can parse the result as JSON try: result['output_json'] = json.loads(data) except Exception as dummy: pass # Fail if error was returned if fail_on_acme_error and info['status'] >= 400: raise ModuleFailException( "ACME request failed: CODE: {0} RESULT: {1}".format( info['status'], data)) # Done! module.exit_json(changed=changed, **result) except ModuleFailException as e: e.do_fail(module, **result)
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.version = module.params['acme_version'] self.challenge = module.params['challenge'] self.csr = module.params['csr'] self.dest = module.params.get('dest') self.fullchain_dest = module.params.get('fullchain_dest') self.chain_dest = module.params.get('chain_dest') self.account = ACMEAccount(module) self.directory = self.account.directory self.data = module.params['data'] self.authorizations = None self.cert_days = -1 self.order_uri = self.data.get('order_uri') if self.data else None self.finalize_uri = None # Make sure account exists modify_account = module.params['modify_account'] if modify_account or self.version > 1: 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'), allow_creation=modify_account, update_contact=modify_account) else: # This happens if modify_account is False and the ACME v1 # protocol is used. In this case, we do not call init_account() # to avoid accidental creation of an account. This is OK # since for ACME v1, the account URI is not needed to send a # signed ACME request. pass # Extract list of domains from CSR if not os.path.exists(self.csr): raise ModuleFailException("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 ''' if HAS_CURRENT_CRYPTOGRAPHY: return cryptography_get_csr_domains(self.module, self.csr) openssl_csr_cmd = [ self._openssl_bin, "req", "-in", self.csr, "-noout", "-text" ] dummy, out, dummy = self.module.run_command(openssl_csr_cmd, check_rc=True) domains = set([]) common_name = re.search(r"Subject:.*? CN\s?=\s?([^\s,;/]+)", to_text(out, errors='surrogate_or_strict')) if common_name is not None: domains.add(common_name.group(1)) subject_alt_names = re.search( r"X509v3 Subject Alternative Name: (?:critical)?\n +([^\n]+)\n", to_text(out, errors='surrogate_or_strict'), 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 _add_or_update_auth(self, domain, 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. ''' if self.authorizations.get(domain) == auth: return False self.authorizations[domain] = auth return True def _new_authz_v1(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]: raise ModuleFailException( "Error requesting challenges: CODE: {0} RESULT: {1}".format( info['status'], result)) else: result['uri'] = info['location'] return result def _get_challenge_data(self, auth, domain): ''' 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) if type == 'http-01': # https://tools.ietf.org/html/rfc8555#section-8.3 resource = '.well-known/acme-challenge/' + token data[type] = { 'resource': resource, 'resource_value': keyauthorization } elif type == 'dns-01': # https://tools.ietf.org/html/rfc8555#section-8.4 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 } elif type == 'tls-alpn-01': # https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-05#section-3 resource = domain value = base64.b64encode( hashlib.sha256(to_bytes(keyauthorization)).digest()) data[type] = {'resource': resource, 'resource_value': value} else: continue 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 += ';' raise ModuleFailException("{0}: {1}".format(error.format(domain), error_details)) def _validate_challenges(self, domain, auth): ''' Validate the authorization provided in the auth dict. Returns True when the validation was successful and False when it was not. ''' for challenge in auth['challenges']: if self.challenge != challenge['type']: continue uri = challenge['uri'] if self.version == 1 else challenge['url'] challenge_response = {} if self.version == 1: token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) keyauthorization = self.account.get_keyauthorization(token) challenge_response["resource"] = "challenge" challenge_response["keyAuthorization"] = keyauthorization result, info = self.account.send_signed_request( uri, challenge_response) if info['status'] not in [200, 202]: raise ModuleFailException( "Error validating challenge: CODE: {0} RESULT: {1}".format( info['status'], result)) status = '' while status not in ['valid', 'invalid', 'revoked']: result, dummy = self.account.get_request(auth['uri']) result['uri'] = auth['uri'] if self._add_or_update_auth(domain, result): self.changed = True # https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.1.2 # "status (required, string): ... # If this field is missing, then the default value is "pending"." if self.version == 1 and 'status' not in result: status = 'pending' else: status = result['status'] time.sleep(2) if status == 'invalid': self._fail_challenge(domain, result, 'Authorization for {0} returned invalid') return status == 'valid' def _finalize_cert(self): ''' Create a new certificate based on the csr. Return the certificate object as dict https://tools.ietf.org/html/rfc8555#section-7.4 ''' csr = pem_to_der(self.csr) new_cert = { "csr": nopad_b64(csr), } result, info = self.account.send_signed_request( self.finalize_uri, new_cert) if info['status'] not in [200]: raise ModuleFailException( "Error new cert: CODE: {0} RESULT: {1}".format( info['status'], result)) order = info['location'] status = result['status'] while status not in ['valid', 'invalid']: time.sleep(2) result, dummy = self.account.get_request(order) status = result['status'] if status != 'valid': raise ModuleFailException( "Error new cert: CODE: {0} STATUS: {1} RESULT: {2}".format( info['status'], status, result)) return result['certificate'] 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 _download_cert(self, url): ''' Download and parse the certificate chain. https://tools.ietf.org/html/rfc8555#section-7.4.2 ''' content, info = self.account.get_request( url, parse_json_result=False, headers={'Accept': 'application/pem-certificate-chain'}) if not content or not info['content-type'].startswith( 'application/pem-certificate-chain'): raise ModuleFailException( "Cannot download certificate chain from {0}: {1} (headers: {2})" .format(url, content, info)) cert = None chain = [] # Parse data lines = content.decode('utf-8').splitlines(True) current = [] for line in lines: if line.strip(): current.append(line) if line.startswith('-----END CERTIFICATE-----'): if cert is None: cert = ''.join(current) else: chain.append(''.join(current)) current = [] # Process link-up headers if there was no chain in reply if not chain and 'link' in info: link = info['link'] parsed_link = re.match(r'<(.+)>;rel="(\w+)"', link) if parsed_link and parsed_link.group(2) == "up": chain_link = parsed_link.group(1) chain_result, chain_info = self.account.get_request( chain_link, parse_json_result=False) if chain_info['status'] in [200, 201]: chain.append(self._der_to_pem(chain_result)) if cert is None or current: raise ModuleFailException( "Failed to parse certificate chain download from {0}: {1} (headers: {2})" .format(url, content, info)) return {'cert': cert, 'chain': chain} def _new_cert_v1(self): ''' 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 ''' csr = pem_to_der(self.csr) new_cert = { "resource": "new-cert", "csr": nopad_b64(csr), } result, info = self.account.send_signed_request( self.directory['new-cert'], new_cert) chain = [] if 'link' in info: link = info['link'] parsed_link = re.match(r'<(.+)>;rel="(\w+)"', link) if parsed_link and parsed_link.group(2) == "up": chain_link = parsed_link.group(1) chain_result, chain_info = self.account.get_request( chain_link, parse_json_result=False) if chain_info['status'] in [200, 201]: chain = [self._der_to_pem(chain_result)] if info['status'] not in [200, 201]: raise ModuleFailException( "Error new cert: CODE: {0} RESULT: {1}".format( info['status'], result)) else: 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/rfc8555#section-7.4 ''' identifiers = [] for domain in self.domains: identifiers.append({ 'type': 'dns', 'value': domain, }) new_order = {"identifiers": identifiers} result, info = self.account.send_signed_request( self.directory['newOrder'], new_order) if info['status'] not in [201]: raise ModuleFailException( "Error new order: CODE: {0} RESULT: {1}".format( info['status'], result)) for auth_uri in result['authorizations']: auth_data, dummy = self.account.get_request(auth_uri) auth_data['uri'] = auth_uri domain = auth_data['identifier']['value'] if auth_data.get('wildcard', False): domain = '*.{0}'.format(domain) self.authorizations[domain] = auth_data self.order_uri = info['location'] self.finalize_uri = result['finalize'] def is_first_step(self): ''' 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: return True if self.version == 1: # As soon as self.data is a non-empty object, we are in the second stage. return not self.data else: # We are in the second stage if data.order_uri is given (which has been # stored in self.order_uri by the constructor). return self.order_uri is None 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.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 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(): 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 def finish_challenges(self): ''' Verify challenges for all domains of the CSR. ''' self.authorizations = {} # Step 1: obtain challenge information if self.version == 1: # For ACME v1, we attempt to create new authzs. Existing ones # will be returned instead. for domain in self.domains: new_auth = self._new_authz_v1(domain) self._add_or_update_auth(domain, new_auth) else: # For ACME v2, we obtain the order object by fetching the # order URI, and extract the information from there. result, info = self.account.get_request(self.order_uri) if not result: raise ModuleFailException( "Cannot download order from {0}: {1} (headers: {2})". format(self.order_uri, result, info)) if info['status'] not in [200]: raise ModuleFailException( "Error on downloading order: CODE: {0} RESULT: {1}".format( info['status'], result)) for auth_uri in result['authorizations']: auth_data, dummy = self.account.get_request(auth_uri) auth_data['uri'] = auth_uri domain = auth_data['identifier']['value'] if auth_data.get('wildcard', False): domain = '*.{0}'.format(domain) self.authorizations[domain] = auth_data self.finalize_uri = result['finalize'] # Step 2: validate challenges for domain, auth in self.authorizations.items(): if auth['status'] == 'pending': self._validate_challenges(domain, auth) def get_certificate(self): ''' Request a new certificate and write it to the destination file. First verifies whether all authorizations are valid; if not, aborts with an error. ''' for domain in self.domains: auth = self.authorizations.get(domain) if auth is None: raise ModuleFailException( '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'] chain = [link for link in cert.get('chain', [])] if self.dest and write_file(self.module, self.dest, pem_cert.encode('utf8')): self.cert_days = get_cert_days(self.module, self.dest) self.changed = True if self.fullchain_dest and write_file( self.module, self.fullchain_dest, (pem_cert + "\n".join(chain)).encode('utf8')): self.cert_days = get_cert_days(self.module, self.fullchain_dest) self.changed = True if self.chain_dest and write_file( self.module, self.chain_dest, ("\n".join(chain)).encode('utf8')): self.changed = True def deactivate_authzs(self): ''' Deactivates all valid authz's. Does not raise exceptions. https://community.letsencrypt.org/t/authorization-deactivation/19860/2 https://tools.ietf.org/html/rfc8555#section-7.5.2 ''' authz_deactivate = {'status': 'deactivated'} if self.version == 1: authz_deactivate['resource'] = 'authz' if self.authorizations: for domain in self.domains: auth = self.authorizations.get(domain) if auth is None or auth.get('status') != 'valid': continue try: result, info = self.account.send_signed_request( auth['uri'], authz_deactivate) if 200 <= info['status'] < 300 and result.get( 'status') == 'deactivated': auth['status'] = 'deactivated' except Exception as e: # Ignore errors on deactivating authzs pass if auth.get('status') != 'deactivated': self.module.warn( warning='Could not deactivate authz object {0}.'. format(auth['uri']))
def main(): module = AnsibleModule( argument_spec=dict( account_key_src=dict(type='path', aliases=['account_key']), account_key_content=dict(type='str', no_log=True), account_uri=dict(required=False, type='str'), acme_directory=dict( required=False, default='https://acme-staging.api.letsencrypt.org/directory', type='str'), acme_version=dict(required=False, default=1, choices=[1, 2], type='int'), validate_certs=dict(required=False, default=True, type='bool'), url=dict(required=False, type='str'), method=dict(required=False, type='str', choices=['get', 'post', 'directory-only'], default='get'), content=dict(required=False, type='str'), fail_on_acme_error=dict(required=False, type='bool', default=True), select_crypto_backend=dict( required=False, choices=['auto', 'openssl', 'cryptography'], default='auto', type='str'), ), mutually_exclusive=(['account_key_src', 'account_key_content'], ), required_if=( ['method', 'get', ['url']], ['method', 'post', ['url', 'content']], [ 'method', 'get', ['account_key_src', 'account_key_content'], True ], [ 'method', 'post', ['account_key_src', 'account_key_content'], True ], ), ) set_crypto_backend(module) if not module.params.get('validate_certs'): module.warn( warning= 'Disabling certificate validation for communications with ACME endpoint. ' + 'This should only be done for testing against a local ACME server for ' + 'development purposes, but *never* for production purposes.') result = dict() changed = False try: # Get hold of ACMEAccount object (includes directory) account = ACMEAccount(module) method = module.params['method'] result['directory'] = account.directory.directory # Do we have to do more requests? if method != 'directory-only': url = module.params['url'] fail_on_acme_error = module.params['fail_on_acme_error'] # Do request if method == 'get': data, info = account.get_request(url, parse_json_result=False, fail_on_error=False) elif method == 'post': changed = True # only POSTs can change data, info = account.send_signed_request( url, to_bytes(module.params['content']), parse_json_result=False, encode_payload=False) # Update results result.update(dict( headers=info, output_text=to_native(data), )) # See if we can parse the result as JSON try: result['output_json'] = json.loads(data) except Exception as dummy: pass # Fail if error was returned if fail_on_acme_error and info['status'] >= 400: raise ModuleFailException( "ACME request failed: CODE: {0} RESULT: {1}".format( info['status'], data)) # Done! module.exit_json(changed=changed, **result) except ModuleFailException as e: e.do_fail(module, **result)