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)
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'), private_key_src=dict(type='path'), private_key_content=dict(type='str', no_log=True), certificate=dict(required=True, type='path'), revoke_reason=dict(required=False, type='int'), ), required_one_of=([ 'account_key_src', 'account_key_content', 'private_key_src', 'private_key_content' ], ), mutually_exclusive=([ 'account_key_src', 'account_key_content', 'private_key_src', 'private_key_content' ], ), supports_check_mode=False, ) 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.') try: account = ACMEAccount(module) # Load certificate certificate_lines = [] try: with open(module.params.get('certificate'), "rt") as f: header_line_count = 0 for line in f: if line.startswith('-----'): header_line_count += 1 if header_line_count == 2: # If certificate file contains other certs appended # (like intermediate certificates), ignore these. break continue certificate_lines.append(line.strip()) except Exception as err: raise ModuleFailException("cannot load certificate file: %s" % to_native(err), exception=traceback.format_exc()) certificate = nopad_b64(base64.b64decode(''.join(certificate_lines))) # Construct payload payload = {'certificate': certificate} if module.params.get('revoke_reason') is not None: payload['reason'] = module.params.get('revoke_reason') # Determine endpoint if module.params.get('acme_version') == 1: endpoint = account.directory['revoke-cert'] payload['resource'] = 'revoke-cert' else: endpoint = account.directory['revokeCert'] # Get hold of private key (if available) and make sure it comes from disk private_key = module.params.get('private_key_src') if module.params.get('private_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( module.params.get('private_key_content').encode('utf-8')) private_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() # Revoke certificate if private_key: # Step 1: load and parse private key error, private_key_data = account.parse_account_key(private_key) if error: raise ModuleFailException( "error while parsing private key: %s" % error) # Step 2: sign revokation request with private key jws_header = { "alg": private_key_data['alg'], "jwk": private_key_data['jwk'], } result, info = account.send_signed_request( endpoint, payload, key=private_key, key_data=private_key_data, jws_header=jws_header) else: # Step 1: get hold of account URI changed = account.init_account( [], allow_creation=False, update_contact=False, ) if changed: raise AssertionError('Unwanted account change') # Step 2: sign revokation request with account key result, info = account.send_signed_request(endpoint, payload) if info['status'] != 200: if module.params.get('acme_version') == 1: error_type = 'urn:acme:error:malformed' else: error_type = 'urn:ietf:params:acme:error:malformed' if result.get('type') == error_type and result.get( 'detail') == 'Certificate already revoked': # Fallback: boulder returns this in case the certificate was already revoked. module.exit_json(changed=False) raise ModuleFailException( 'Error revoking certificate: {0} {1}'.format( info['status'], result)) module.exit_json(changed=True) except ModuleFailException as e: e.do_fail(module)