Letsencrypt authz deactivation (#36362)

* Allow to deactivate authz objects. Currently only after success.

* Making sure cleanup is done even when module fails (except if fetch_url() fails).

* Make deactivate_authzs eat exceptions so that all authzs are deactivated in case of errors.
This commit is contained in:
Felix Fontein 2018-02-19 11:29:31 +01:00 committed by ansibot
parent 99627ab99d
commit cd9d554186

View file

@ -159,6 +159,17 @@ options:
required: false required: false
default: true default: true
version_added: 2.5 version_added: 2.5
deactivate_authzs:
description:
- "Deactivate authentication objects (authz) after issuing a certificate,
or when issuing the certificate failed."
- "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. "
required: false
default: false
version_added: 2.6
''' '''
EXAMPLES = ''' EXAMPLES = '''
@ -335,6 +346,19 @@ from ansible.module_utils._text import to_native, to_text, to_bytes
from ansible.module_utils.urls import fetch_url as _fetch_url 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.args = args
def do_fail(self, module):
module.fail_json(msg=self.msg, **self.args)
def _lowercase_fetch_url(*args, **kwargs): def _lowercase_fetch_url(*args, **kwargs):
''' '''
Add lowercase representations of the header names as dict keys Add lowercase representations of the header names as dict keys
@ -367,12 +391,12 @@ def simple_get(module, url):
try: try:
result = module.from_json(content.decode('utf8')) result = module.from_json(content.decode('utf8'))
except ValueError: except ValueError:
module.fail_json(msg="Failed to parse the ACME response: {0} {1}".format(url, content)) raise ModuleFailException("Failed to parse the ACME response: {0} {1}".format(url, content))
else: else:
result = content result = content
if info['status'] >= 400: if info['status'] >= 400:
module.fail_json(msg="ACME request failed: CODE: {0} RESULT: {1}".format(info['status'], result)) raise ModuleFailException("ACME request failed: CODE: {0} RESULT: {1}".format(info['status'], result))
return result return result
@ -392,9 +416,9 @@ def get_cert_days(module, cert_file):
not_after_str = re.search(r"\s+Not After\s*:\s+(.*)", out.decode('utf8')).group(1) not_after_str = re.search(r"\s+Not After\s*:\s+(.*)", out.decode('utf8')).group(1)
not_after = datetime.fromtimestamp(time.mktime(time.strptime(not_after_str, '%b %d %H:%M:%S %Y %Z'))) not_after = datetime.fromtimestamp(time.mktime(time.strptime(not_after_str, '%b %d %H:%M:%S %Y %Z')))
except AttributeError: except AttributeError:
module.fail_json(msg="No 'Not after' date found in {0}".format(cert_file)) raise ModuleFailException("No 'Not after' date found in {0}".format(cert_file))
except ValueError: except ValueError:
module.fail_json(msg="Failed to parse 'Not after' date of {0}".format(cert_file)) raise ModuleFailException("Failed to parse 'Not after' date of {0}".format(cert_file))
now = datetime.utcnow() now = datetime.utcnow()
return (not_after - now).days return (not_after - now).days
@ -414,10 +438,10 @@ def write_file(module, dest, content):
except Exception as err: except Exception as err:
try: try:
f.close() f.close()
except: except Exception as e:
pass pass
os.remove(tmpsrc) os.remove(tmpsrc)
module.fail_json(msg="failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc()) raise ModuleFailException("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc())
f.close() f.close()
checksum_src = None checksum_src = None
checksum_dest = None checksum_dest = None
@ -425,34 +449,34 @@ def write_file(module, dest, content):
if not os.path.exists(tmpsrc): if not os.path.exists(tmpsrc):
try: try:
os.remove(tmpsrc) os.remove(tmpsrc)
except: except Exception as e:
pass pass
module.fail_json(msg="Source %s does not exist" % (tmpsrc)) raise ModuleFailException("Source %s does not exist" % (tmpsrc))
if not os.access(tmpsrc, os.R_OK): if not os.access(tmpsrc, os.R_OK):
os.remove(tmpsrc) os.remove(tmpsrc)
module.fail_json(msg="Source %s not readable" % (tmpsrc)) raise ModuleFailException("Source %s not readable" % (tmpsrc))
checksum_src = module.sha1(tmpsrc) checksum_src = module.sha1(tmpsrc)
# check if there is no dest file # check if there is no dest file
if os.path.exists(dest): if os.path.exists(dest):
# raise an error if copy has no permission on dest # raise an error if copy has no permission on dest
if not os.access(dest, os.W_OK): if not os.access(dest, os.W_OK):
os.remove(tmpsrc) os.remove(tmpsrc)
module.fail_json(msg="Destination %s not writable" % (dest)) raise ModuleFailException("Destination %s not writable" % (dest))
if not os.access(dest, os.R_OK): if not os.access(dest, os.R_OK):
os.remove(tmpsrc) os.remove(tmpsrc)
module.fail_json(msg="Destination %s not readable" % (dest)) raise ModuleFailException("Destination %s not readable" % (dest))
checksum_dest = module.sha1(dest) checksum_dest = module.sha1(dest)
else: else:
if not os.access(os.path.dirname(dest), os.W_OK): if not os.access(os.path.dirname(dest), os.W_OK):
os.remove(tmpsrc) os.remove(tmpsrc)
module.fail_json(msg="Destination dir %s not writable" % (os.path.dirname(dest))) raise ModuleFailException("Destination dir %s not writable" % (os.path.dirname(dest)))
if checksum_src != checksum_dest: if checksum_src != checksum_dest:
try: try:
shutil.copyfile(tmpsrc, dest) shutil.copyfile(tmpsrc, dest)
changed = True changed = True
except Exception as err: except Exception as err:
os.remove(tmpsrc) os.remove(tmpsrc)
module.fail_json(msg="failed to copy %s to %s: %s" % (tmpsrc, dest, to_native(err)), exception=traceback.format_exc()) raise ModuleFailException("failed to copy %s to %s: %s" % (tmpsrc, dest, to_native(err)), exception=traceback.format_exc())
os.remove(tmpsrc) os.remove(tmpsrc)
return changed return changed
@ -477,11 +501,11 @@ class ACMEDirectory(object):
if self.version == 1: if self.version == 1:
for key in ('new-reg', 'new-authz', 'new-cert'): for key in ('new-reg', 'new-authz', 'new-cert'):
if key not in self.directory: if key not in self.directory:
self.module.fail_json(msg="ACME directory does not seem to follow protocol ACME v1") raise ModuleFailException("ACME directory does not seem to follow protocol ACME v1")
if self.version == 2: if self.version == 2:
for key in ('newNonce', 'newAccount', 'newOrder'): for key in ('newNonce', 'newAccount', 'newOrder'):
if key not in self.directory: if key not in self.directory:
self.module.fail_json(msg="ACME directory does not seem to follow protocol ACME v2") raise ModuleFailException("ACME directory does not seem to follow protocol ACME v2")
def __getitem__(self, key): def __getitem__(self, key):
return self.directory[key] return self.directory[key]
@ -492,7 +516,7 @@ class ACMEDirectory(object):
url = resource url = resource
dummy, info = fetch_url(self.module, url, method='HEAD') dummy, info = fetch_url(self.module, url, method='HEAD')
if info['status'] not in (200, 204): if info['status'] not in (200, 204):
self.module.fail_json(msg="Failed to get replay-nonce, got status {0}".format(info['status'])) raise ModuleFailException("Failed to get replay-nonce, got status {0}".format(info['status']))
return info['replay-nonce'] return info['replay-nonce']
@ -530,14 +554,14 @@ class ACMEAccount(object):
except Exception as err: except Exception as err:
try: try:
f.close() f.close()
except: except Exception as e:
pass pass
module.fail_json(msg="failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc()) raise ModuleFailException("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc())
f.close() f.close()
error, self.key_data = self._parse_account_key(self.key) error, self.key_data = self._parse_account_key(self.key)
if error: if error:
module.fail_json(msg="error while parsing account key: %s" % error) raise ModuleFailException("error while parsing account key: %s" % error)
self.jwk = self.key_data['jwk'] self.jwk = self.key_data['jwk']
self.jws_header = { self.jws_header = {
"alg": self.key_data['alg'], "alg": self.key_data['alg'],
@ -656,7 +680,7 @@ class ACMEAccount(object):
payload64 = nopad_b64(self.module.jsonify(payload).encode('utf8')) payload64 = nopad_b64(self.module.jsonify(payload).encode('utf8'))
protected64 = nopad_b64(self.module.jsonify(protected).encode('utf8')) protected64 = nopad_b64(self.module.jsonify(protected).encode('utf8'))
except Exception as e: except Exception as e:
self.module.fail_json(msg="Failed to encode payload / headers as JSON: {0}".format(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] 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') sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8')
@ -671,8 +695,8 @@ class ACMEAccount(object):
r"prim:\s+INTEGER\s+:([0-9A-F]{1,%s})\n" % expected_len, r"prim:\s+INTEGER\s+:([0-9A-F]{1,%s})\n" % expected_len,
to_text(der_out, errors='surrogate_or_strict')) to_text(der_out, errors='surrogate_or_strict'))
if len(sig) != 2: if len(sig) != 2:
self.module.fail_json( raise ModuleFailException(
msg="failed to generate Elliptic Curve signature; cannot parse DER output: {0}".format( "failed to generate Elliptic Curve signature; cannot parse DER output: {0}".format(
to_text(der_out, errors='surrogate_or_strict'))) to_text(der_out, errors='surrogate_or_strict')))
sig[0] = (expected_len - len(sig[0])) * '0' + sig[0] sig[0] = (expected_len - len(sig[0])) * '0' + sig[0]
sig[1] = (expected_len - len(sig[1])) * '0' + sig[1] sig[1] = (expected_len - len(sig[1])) * '0' + sig[1]
@ -706,7 +730,7 @@ class ACMEAccount(object):
failed_tries += 1 failed_tries += 1
continue continue
except ValueError: except ValueError:
self.module.fail_json(msg="Failed to parse the ACME response: {0} {1}".format(url, content)) raise ModuleFailException("Failed to parse the ACME response: {0} {1}".format(url, content))
else: else:
result = content result = content
@ -757,7 +781,7 @@ class ACMEAccount(object):
# Account did exist # Account did exist
return False return False
else: else:
self.module.fail_json(msg="Error registering: {0} {1}".format(info['status'], result)) raise ModuleFailException("Error registering: {0} {1}".format(info['status'], result))
def init_account(self): def init_account(self):
''' '''
@ -819,7 +843,7 @@ class ACMEClient(object):
self.finalize_uri = self.data.get('finalize_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): if not os.path.exists(self.csr):
module.fail_json(msg="CSR %s not found" % (self.csr)) raise ModuleFailException("CSR %s not found" % (self.csr))
self._openssl_bin = module.get_bin_path('openssl', True) self._openssl_bin = module.get_bin_path('openssl', True)
self.domains = self._get_csr_domains() self.domains = self._get_csr_domains()
@ -871,7 +895,7 @@ class ACMEClient(object):
result, info = self.account.send_signed_request(self.directory['new-authz'], new_authz) result, info = self.account.send_signed_request(self.directory['new-authz'], new_authz)
if info['status'] not in [200, 201]: if info['status'] not in [200, 201]:
self.module.fail_json(msg="Error requesting challenges: CODE: {0} RESULT: {1}".format(info['status'], result)) raise ModuleFailException("Error requesting challenges: CODE: {0} RESULT: {1}".format(info['status'], result))
else: else:
result['uri'] = info['location'] result['uri'] = info['location']
return result return result
@ -935,7 +959,7 @@ class ACMEClient(object):
error_details += ' DETAILS: {0};'.format(challenge['error']['detail']) error_details += ' DETAILS: {0};'.format(challenge['error']['detail'])
else: else:
error_details += ';' error_details += ';'
self.module.fail_json(msg="{0}: {1}".format(error.format(domain), error_details)) raise ModuleFailException("{0}: {1}".format(error.format(domain), error_details))
def _validate_challenges(self, domain, auth): def _validate_challenges(self, domain, auth):
''' '''
@ -956,7 +980,7 @@ class ACMEClient(object):
} }
result, info = self.account.send_signed_request(uri, challenge_response) result, info = self.account.send_signed_request(uri, challenge_response)
if info['status'] not in [200, 202]: if info['status'] not in [200, 202]:
self.module.fail_json(msg="Error validating challenge: CODE: {0} RESULT: {1}".format(info['status'], result)) raise ModuleFailException("Error validating challenge: CODE: {0} RESULT: {1}".format(info['status'], result))
status = '' status = ''
@ -993,7 +1017,7 @@ class ACMEClient(object):
} }
result, info = self.account.send_signed_request(self.finalize_uri, new_cert) result, info = self.account.send_signed_request(self.finalize_uri, new_cert)
if info['status'] not in [200]: if info['status'] not in [200]:
self.module.fail_json(msg="Error new cert: CODE: {0} RESULT: {1}".format(info['status'], result)) raise ModuleFailException("Error new cert: CODE: {0} RESULT: {1}".format(info['status'], result))
order = info['location'] order = info['location']
@ -1004,7 +1028,7 @@ class ACMEClient(object):
status = result['status'] status = result['status']
if status != 'valid': if status != 'valid':
self.module.fail_json(msg="Error new cert: CODE: {0} STATUS: {1} RESULT: {2}".format(info['status'], status, result)) raise ModuleFailException("Error new cert: CODE: {0} STATUS: {1} RESULT: {2}".format(info['status'], status, result))
return result['certificate'] return result['certificate']
@ -1028,7 +1052,7 @@ class ACMEClient(object):
content = info.get('body') content = info.get('body')
if not content or not info['content-type'].startswith('application/pem-certificate-chain'): if not content or not info['content-type'].startswith('application/pem-certificate-chain'):
self.module.fail_json(msg="Cannot download certificate chain from {0}: {1} (headers: {2})".format(url, content, info)) raise ModuleFailException("Cannot download certificate chain from {0}: {1} (headers: {2})".format(url, content, info))
cert = None cert = None
chain = [] chain = []
@ -1057,7 +1081,7 @@ class ACMEClient(object):
chain.append(self._der_to_pem(chain_result.read())) chain.append(self._der_to_pem(chain_result.read()))
if cert is None or current: if cert is None or current:
self.module.fail_json(msg="Failed to parse certificate chain download from {0}: {1} (headers: {2})".format(url, content, info)) raise ModuleFailException("Failed to parse certificate chain download from {0}: {1} (headers: {2})".format(url, content, info))
return {'cert': cert, 'chain': chain} return {'cert': cert, 'chain': chain}
def _new_cert_v1(self): def _new_cert_v1(self):
@ -1086,7 +1110,7 @@ class ACMEClient(object):
chain = [self._der_to_pem(chain_result.read())] chain = [self._der_to_pem(chain_result.read())]
if info['status'] not in [200, 201]: if info['status'] not in [200, 201]:
self.module.fail_json(msg="Error new cert: CODE: {0} RESULT: {1}".format(info['status'], result)) raise ModuleFailException("Error new cert: CODE: {0} RESULT: {1}".format(info['status'], result))
else: else:
return {'cert': self._der_to_pem(result), 'uri': info['location'], 'chain': chain} return {'cert': self._der_to_pem(result), 'uri': info['location'], 'chain': chain}
@ -1107,7 +1131,7 @@ class ACMEClient(object):
result, info = self.account.send_signed_request(self.directory['newOrder'], new_order) result, info = self.account.send_signed_request(self.directory['newOrder'], new_order)
if info['status'] not in [201]: if info['status'] not in [201]:
self.module.fail_json(msg="Error new order: CODE: {0} RESULT: {1}".format(info['status'], result)) raise ModuleFailException("Error new order: CODE: {0} RESULT: {1}".format(info['status'], result))
for identifier, auth_uri in zip(result['identifiers'], result['authorizations']): for identifier, auth_uri in zip(result['identifiers'], result['authorizations']):
domain = identifier['value'] domain = identifier['value']
@ -1183,7 +1207,7 @@ class ACMEClient(object):
for domain in self.domains: for domain in self.domains:
auth = self.authorizations.get(domain) auth = self.authorizations.get(domain)
if auth is None: if auth is None:
self.module.fail_json(msg='Found no authorization information for "{0}"!'.format(domain)) raise ModuleFailException('Found no authorization information for "{0}"!'.format(domain))
if 'status' not in auth: if 'status' not in auth:
self._fail_challenge(domain, auth, 'Authorization for {0} returned no status') self._fail_challenge(domain, auth, 'Authorization for {0} returned no status')
if auth['status'] != 'valid': if auth['status'] != 'valid':
@ -1211,6 +1235,32 @@ class ACMEClient(object):
if self.chain_dest and write_file(self.module, self.chain_dest, ("\n".join(chain)).encode('utf8')): if self.chain_dest and write_file(self.module, self.chain_dest, ("\n".join(chain)).encode('utf8')):
self.changed = True 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/draft-ietf-acme-acme-09#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(): def main():
module = AnsibleModule( module = AnsibleModule(
@ -1230,6 +1280,7 @@ def main():
chain_dest=dict(required=False, default=None, aliases=['chain'], type='path'), chain_dest=dict(required=False, default=None, aliases=['chain'], type='path'),
remaining_days=dict(required=False, default=10, type='int'), remaining_days=dict(required=False, default=10, type='int'),
validate_certs=dict(required=False, default=True, type='bool'), validate_certs=dict(required=False, default=True, type='bool'),
deactivate_authzs=dict(required=False, default=False, type='bool'),
), ),
required_one_of=( required_one_of=(
['account_key_src', 'account_key_content'], ['account_key_src', 'account_key_content'],
@ -1250,6 +1301,7 @@ def main():
'This should only be done for testing against a local ACME server for ' + 'This should only be done for testing against a local ACME server for ' +
'development purposes, but *never* for production purposes.') 'development purposes, but *never* for production purposes.')
try:
if module.params.get('dest'): if module.params.get('dest'):
cert_days = get_cert_days(module, module.params['dest']) cert_days = get_cert_days(module, module.params['dest'])
else: else:
@ -1269,8 +1321,12 @@ def main():
client.start_challenges() client.start_challenges()
else: else:
# Second run: finish challenges, and get certificate # Second run: finish challenges, and get certificate
try:
client.finish_challenges() client.finish_challenges()
client.get_certificate() client.get_certificate()
finally:
if module.params['deactivate_authzs']:
client.deactivate_authzs()
data, data_dns = client.get_challenges_data() data, data_dns = client.get_challenges_data()
module.exit_json( module.exit_json(
changed=client.changed, changed=client.changed,
@ -1284,6 +1340,8 @@ def main():
) )
else: else:
module.exit_json(changed=False, cert_days=cert_days) module.exit_json(changed=False, cert_days=cert_days)
except ModuleFailException as e:
e.do_fail(module)
if __name__ == '__main__': if __name__ == '__main__':