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:
parent
99627ab99d
commit
cd9d554186
1 changed files with 125 additions and 67 deletions
|
@ -159,6 +159,17 @@ options:
|
|||
required: false
|
||||
default: true
|
||||
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 = '''
|
||||
|
@ -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
|
||||
|
||||
|
||||
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):
|
||||
'''
|
||||
Add lowercase representations of the header names as dict keys
|
||||
|
@ -367,12 +391,12 @@ def simple_get(module, url):
|
|||
try:
|
||||
result = module.from_json(content.decode('utf8'))
|
||||
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:
|
||||
result = content
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
@ -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 = 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))
|
||||
raise ModuleFailException("No 'Not after' date found in {0}".format(cert_file))
|
||||
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()
|
||||
return (not_after - now).days
|
||||
|
||||
|
@ -414,10 +438,10 @@ def write_file(module, dest, content):
|
|||
except Exception as err:
|
||||
try:
|
||||
f.close()
|
||||
except:
|
||||
except Exception as e:
|
||||
pass
|
||||
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()
|
||||
checksum_src = None
|
||||
checksum_dest = None
|
||||
|
@ -425,34 +449,34 @@ def write_file(module, dest, content):
|
|||
if not os.path.exists(tmpsrc):
|
||||
try:
|
||||
os.remove(tmpsrc)
|
||||
except:
|
||||
except Exception as e:
|
||||
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):
|
||||
os.remove(tmpsrc)
|
||||
module.fail_json(msg="Source %s not readable" % (tmpsrc))
|
||||
raise ModuleFailException("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))
|
||||
raise ModuleFailException("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))
|
||||
raise ModuleFailException("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)))
|
||||
raise ModuleFailException("Destination dir %s not writable" % (os.path.dirname(dest)))
|
||||
if checksum_src != checksum_dest:
|
||||
try:
|
||||
shutil.copyfile(tmpsrc, dest)
|
||||
changed = True
|
||||
except Exception as err:
|
||||
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)
|
||||
return changed
|
||||
|
||||
|
@ -477,11 +501,11 @@ class ACMEDirectory(object):
|
|||
if self.version == 1:
|
||||
for key in ('new-reg', 'new-authz', 'new-cert'):
|
||||
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:
|
||||
for key in ('newNonce', 'newAccount', 'newOrder'):
|
||||
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):
|
||||
return self.directory[key]
|
||||
|
@ -492,7 +516,7 @@ class ACMEDirectory(object):
|
|||
url = resource
|
||||
dummy, info = fetch_url(self.module, url, method='HEAD')
|
||||
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']
|
||||
|
||||
|
||||
|
@ -530,14 +554,14 @@ class ACMEAccount(object):
|
|||
except Exception as err:
|
||||
try:
|
||||
f.close()
|
||||
except:
|
||||
except Exception as e:
|
||||
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()
|
||||
|
||||
error, self.key_data = self._parse_account_key(self.key)
|
||||
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.jws_header = {
|
||||
"alg": self.key_data['alg'],
|
||||
|
@ -656,7 +680,7 @@ class ACMEAccount(object):
|
|||
payload64 = nopad_b64(self.module.jsonify(payload).encode('utf8'))
|
||||
protected64 = nopad_b64(self.module.jsonify(protected).encode('utf8'))
|
||||
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]
|
||||
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,
|
||||
to_text(der_out, errors='surrogate_or_strict'))
|
||||
if len(sig) != 2:
|
||||
self.module.fail_json(
|
||||
msg="failed to generate Elliptic Curve signature; cannot parse DER output: {0}".format(
|
||||
raise ModuleFailException(
|
||||
"failed to generate Elliptic Curve signature; cannot parse DER output: {0}".format(
|
||||
to_text(der_out, errors='surrogate_or_strict')))
|
||||
sig[0] = (expected_len - len(sig[0])) * '0' + sig[0]
|
||||
sig[1] = (expected_len - len(sig[1])) * '0' + sig[1]
|
||||
|
@ -706,7 +730,7 @@ class ACMEAccount(object):
|
|||
failed_tries += 1
|
||||
continue
|
||||
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:
|
||||
result = content
|
||||
|
||||
|
@ -757,7 +781,7 @@ class ACMEAccount(object):
|
|||
# Account did exist
|
||||
return False
|
||||
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):
|
||||
'''
|
||||
|
@ -819,7 +843,7 @@ class ACMEClient(object):
|
|||
self.finalize_uri = self.data.get('finalize_uri') if self.data else None
|
||||
|
||||
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.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)
|
||||
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:
|
||||
result['uri'] = info['location']
|
||||
return result
|
||||
|
@ -935,7 +959,7 @@ class ACMEClient(object):
|
|||
error_details += ' DETAILS: {0};'.format(challenge['error']['detail'])
|
||||
else:
|
||||
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):
|
||||
'''
|
||||
|
@ -956,7 +980,7 @@ class ACMEClient(object):
|
|||
}
|
||||
result, info = self.account.send_signed_request(uri, challenge_response)
|
||||
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 = ''
|
||||
|
||||
|
@ -993,7 +1017,7 @@ class ACMEClient(object):
|
|||
}
|
||||
result, info = self.account.send_signed_request(self.finalize_uri, new_cert)
|
||||
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']
|
||||
|
||||
|
@ -1004,7 +1028,7 @@ class ACMEClient(object):
|
|||
status = result['status']
|
||||
|
||||
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']
|
||||
|
||||
|
@ -1028,7 +1052,7 @@ class ACMEClient(object):
|
|||
content = info.get('body')
|
||||
|
||||
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
|
||||
chain = []
|
||||
|
@ -1057,7 +1081,7 @@ class ACMEClient(object):
|
|||
chain.append(self._der_to_pem(chain_result.read()))
|
||||
|
||||
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}
|
||||
|
||||
def _new_cert_v1(self):
|
||||
|
@ -1086,7 +1110,7 @@ class ACMEClient(object):
|
|||
chain = [self._der_to_pem(chain_result.read())]
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
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']):
|
||||
domain = identifier['value']
|
||||
|
@ -1183,7 +1207,7 @@ class ACMEClient(object):
|
|||
for domain in self.domains:
|
||||
auth = self.authorizations.get(domain)
|
||||
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:
|
||||
self._fail_challenge(domain, auth, 'Authorization for {0} returned no status')
|
||||
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')):
|
||||
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():
|
||||
module = AnsibleModule(
|
||||
|
@ -1230,6 +1280,7 @@ def main():
|
|||
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'),
|
||||
deactivate_authzs=dict(required=False, default=False, type='bool'),
|
||||
),
|
||||
required_one_of=(
|
||||
['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 ' +
|
||||
'development purposes, but *never* for production purposes.')
|
||||
|
||||
try:
|
||||
if module.params.get('dest'):
|
||||
cert_days = get_cert_days(module, module.params['dest'])
|
||||
else:
|
||||
|
@ -1269,8 +1321,12 @@ def main():
|
|||
client.start_challenges()
|
||||
else:
|
||||
# Second run: finish challenges, and get certificate
|
||||
try:
|
||||
client.finish_challenges()
|
||||
client.get_certificate()
|
||||
finally:
|
||||
if module.params['deactivate_authzs']:
|
||||
client.deactivate_authzs()
|
||||
data, data_dns = client.get_challenges_data()
|
||||
module.exit_json(
|
||||
changed=client.changed,
|
||||
|
@ -1284,6 +1340,8 @@ def main():
|
|||
)
|
||||
else:
|
||||
module.exit_json(changed=False, cert_days=cert_days)
|
||||
except ModuleFailException as e:
|
||||
e.do_fail(module)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
Loading…
Reference in a new issue