Letsencrypt: add account management module (#37275)
* Removed superfluous space. * Separating account init code from ACMEAccount constructor. * Extracted module utils and docs fragment. * Added new letsencrypt_account module. * Ignore pre-1.0.0 versions of OpenSSL. * Added account key rollover. * Renaming letsencrypt_account -> acme_account * Simplifying check for updating contact information. * Rewriting docstring for ACMEDirectory. * Changing license according to permissions given by individual authors in https://github.com/ansible/ansible/pull/37275. * Updating BOTMETA. * Preparing for change of ACME protocol currently discussed in ietf-wg-acme/acme. * Updating documentation.
This commit is contained in:
parent
2fc23fdc18
commit
dec392793b
9 changed files with 1042 additions and 523 deletions
3
.github/BOTMETA.yml
vendored
3
.github/BOTMETA.yml
vendored
|
@ -686,6 +686,7 @@ files:
|
||||||
$modules/utilities/logic/set_stats.py: bcoca
|
$modules/utilities/logic/set_stats.py: bcoca
|
||||||
$modules/utilities/logic/wait_for.py: AnderEnder gregswift jarv jhoekx
|
$modules/utilities/logic/wait_for.py: AnderEnder gregswift jarv jhoekx
|
||||||
$modules/web_infrastructure/_letsencrypt.py: mgruener resmo felixfontein
|
$modules/web_infrastructure/_letsencrypt.py: mgruener resmo felixfontein
|
||||||
|
$modules/web_infrastructure/acme_account.py: mgruener resmo felixfontein
|
||||||
$modules/web_infrastructure/acme_certificate.py: mgruener resmo felixfontein
|
$modules/web_infrastructure/acme_certificate.py: mgruener resmo felixfontein
|
||||||
$modules/web_infrastructure/ansible_tower/: $team_tower
|
$modules/web_infrastructure/ansible_tower/: $team_tower
|
||||||
$modules/web_infrastructure/apache2_mod_proxy.py: oboukili
|
$modules/web_infrastructure/apache2_mod_proxy.py: oboukili
|
||||||
|
@ -864,6 +865,8 @@ files:
|
||||||
labels: clustering
|
labels: clustering
|
||||||
$module_utils/keycloak.py:
|
$module_utils/keycloak.py:
|
||||||
maintainers: eikef
|
maintainers: eikef
|
||||||
|
$module_utils/letsencrypt.py:
|
||||||
|
maintainers: mgruener resmo felixfontein
|
||||||
$module_utils/manageiq.py:
|
$module_utils/manageiq.py:
|
||||||
maintainers: $team_manageiq
|
maintainers: $team_manageiq
|
||||||
$module_utils/network/meraki:
|
$module_utils/network/meraki:
|
||||||
|
|
528
lib/ansible/module_utils/letsencrypt.py
Normal file
528
lib/ansible/module_utils/letsencrypt.py
Normal file
|
@ -0,0 +1,528 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This code is part of Ansible, but is an independent component.
|
||||||
|
# This particular file snippet, and this file snippet only, is BSD licensed.
|
||||||
|
# Modules you write using this snippet, which is embedded dynamically by Ansible
|
||||||
|
# still belong to the author of the module, and may assign their own license
|
||||||
|
# to the complete work.
|
||||||
|
#
|
||||||
|
# Copyright (c), Michael Gruener <michael.gruener@chaosmoon.net>, 2016
|
||||||
|
#
|
||||||
|
# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause)
|
||||||
|
|
||||||
|
from __future__ import absolute_import, division, print_function
|
||||||
|
__metaclass__ = type
|
||||||
|
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
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.module_fail_args = args
|
||||||
|
|
||||||
|
def do_fail(self, module):
|
||||||
|
module.fail_json(msg=self.msg, other=self.module_fail_args)
|
||||||
|
|
||||||
|
|
||||||
|
def _lowercase_fetch_url(*args, **kwargs):
|
||||||
|
'''
|
||||||
|
Add lowercase representations of the header names as dict keys
|
||||||
|
|
||||||
|
'''
|
||||||
|
response, info = _fetch_url(*args, **kwargs)
|
||||||
|
|
||||||
|
info.update(dict((header.lower(), value) for (header, value) in info.items()))
|
||||||
|
return response, info
|
||||||
|
|
||||||
|
|
||||||
|
fetch_url = _lowercase_fetch_url
|
||||||
|
|
||||||
|
|
||||||
|
def nopad_b64(data):
|
||||||
|
return base64.urlsafe_b64encode(data).decode('utf8').replace("=", "")
|
||||||
|
|
||||||
|
|
||||||
|
def simple_get(module, url):
|
||||||
|
resp, info = fetch_url(module, url, method='GET')
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
try:
|
||||||
|
content = resp.read()
|
||||||
|
except AttributeError:
|
||||||
|
content = info.get('body')
|
||||||
|
|
||||||
|
if content:
|
||||||
|
if info['content-type'].startswith('application/json'):
|
||||||
|
try:
|
||||||
|
result = module.from_json(content.decode('utf8'))
|
||||||
|
except ValueError:
|
||||||
|
raise ModuleFailException("Failed to parse the ACME response: {0} {1}".format(url, content))
|
||||||
|
else:
|
||||||
|
result = content
|
||||||
|
|
||||||
|
if info['status'] >= 400:
|
||||||
|
raise ModuleFailException("ACME request failed: CODE: {0} RESULT: {1}".format(info['status'], result))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# function source: network/basics/uri.py
|
||||||
|
def write_file(module, dest, content):
|
||||||
|
'''
|
||||||
|
Write content to destination file dest, only if the content
|
||||||
|
has changed.
|
||||||
|
'''
|
||||||
|
changed = False
|
||||||
|
# create a tempfile
|
||||||
|
fd, tmpsrc = tempfile.mkstemp(text=False)
|
||||||
|
f = os.fdopen(fd, 'wb')
|
||||||
|
try:
|
||||||
|
f.write(content)
|
||||||
|
except Exception as err:
|
||||||
|
try:
|
||||||
|
f.close()
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
os.remove(tmpsrc)
|
||||||
|
raise ModuleFailException("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc())
|
||||||
|
f.close()
|
||||||
|
checksum_src = None
|
||||||
|
checksum_dest = None
|
||||||
|
# raise an error if there is no tmpsrc file
|
||||||
|
if not os.path.exists(tmpsrc):
|
||||||
|
try:
|
||||||
|
os.remove(tmpsrc)
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
raise ModuleFailException("Source %s does not exist" % (tmpsrc))
|
||||||
|
if not os.access(tmpsrc, os.R_OK):
|
||||||
|
os.remove(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)
|
||||||
|
raise ModuleFailException("Destination %s not writable" % (dest))
|
||||||
|
if not os.access(dest, os.R_OK):
|
||||||
|
os.remove(tmpsrc)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
raise ModuleFailException("failed to copy %s to %s: %s" % (tmpsrc, dest, to_native(err)), exception=traceback.format_exc())
|
||||||
|
os.remove(tmpsrc)
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
class ACMEDirectory(object):
|
||||||
|
'''
|
||||||
|
The ACME server directory. Gives access to the available resources,
|
||||||
|
and allows to obtain a Replay-Nonce. The acme_directory URL
|
||||||
|
needs to support unauthenticated GET requests; ACME endpoints
|
||||||
|
requiring authentication are not supported.
|
||||||
|
https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.1.1
|
||||||
|
'''
|
||||||
|
|
||||||
|
def __init__(self, module):
|
||||||
|
self.module = module
|
||||||
|
self.directory_root = module.params['acme_directory']
|
||||||
|
self.version = module.params['acme_version']
|
||||||
|
|
||||||
|
self.directory = simple_get(self.module, self.directory_root)
|
||||||
|
|
||||||
|
# Check whether self.version matches what we expect
|
||||||
|
if self.version == 1:
|
||||||
|
for key in ('new-reg', 'new-authz', 'new-cert'):
|
||||||
|
if key not in self.directory:
|
||||||
|
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:
|
||||||
|
raise ModuleFailException("ACME directory does not seem to follow protocol ACME v2")
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return self.directory[key]
|
||||||
|
|
||||||
|
def get_nonce(self, resource=None):
|
||||||
|
url = self.directory_root if self.version == 1 else self.directory['newNonce']
|
||||||
|
if resource is not None:
|
||||||
|
url = resource
|
||||||
|
dummy, info = fetch_url(self.module, url, method='HEAD')
|
||||||
|
if info['status'] not in (200, 204):
|
||||||
|
raise ModuleFailException("Failed to get replay-nonce, got status {0}".format(info['status']))
|
||||||
|
return info['replay-nonce']
|
||||||
|
|
||||||
|
|
||||||
|
class ACMEAccount(object):
|
||||||
|
'''
|
||||||
|
ACME account object. Handles the authorized communication with the
|
||||||
|
ACME server. Provides access to account bound information like
|
||||||
|
the currently active authorizations and valid certificates
|
||||||
|
'''
|
||||||
|
|
||||||
|
def __init__(self, module):
|
||||||
|
self.module = module
|
||||||
|
self.version = module.params['acme_version']
|
||||||
|
# account_key path and content are mutually exclusive
|
||||||
|
self.key = module.params['account_key_src']
|
||||||
|
self.key_content = module.params['account_key_content']
|
||||||
|
self.directory = ACMEDirectory(module)
|
||||||
|
|
||||||
|
self.uri = None
|
||||||
|
|
||||||
|
self._openssl_bin = module.get_bin_path('openssl', True)
|
||||||
|
|
||||||
|
# Create a key file from content, key (path) and key content are mutually exclusive
|
||||||
|
if self.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(self.key_content.encode('utf-8'))
|
||||||
|
self.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()
|
||||||
|
|
||||||
|
error, self.key_data = self.parse_account_key(self.key)
|
||||||
|
if error:
|
||||||
|
raise ModuleFailException("error while parsing account key: %s" % error)
|
||||||
|
self.jwk = self.key_data['jwk']
|
||||||
|
self.jws_header = {
|
||||||
|
"alg": self.key_data['alg'],
|
||||||
|
"jwk": self.jwk,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_keyauthorization(self, token):
|
||||||
|
'''
|
||||||
|
Returns the key authorization for the given token
|
||||||
|
https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-8.1
|
||||||
|
'''
|
||||||
|
accountkey_json = json.dumps(self.jwk, sort_keys=True, separators=(',', ':'))
|
||||||
|
thumbprint = nopad_b64(hashlib.sha256(accountkey_json.encode('utf8')).digest())
|
||||||
|
return "{0}.{1}".format(token, thumbprint)
|
||||||
|
|
||||||
|
def parse_account_key(self, key):
|
||||||
|
'''
|
||||||
|
Parses an RSA or Elliptic Curve key file in PEM format and returns a pair
|
||||||
|
(error, key_data).
|
||||||
|
'''
|
||||||
|
account_key_type = None
|
||||||
|
with open(key, "rt") as f:
|
||||||
|
for line in f:
|
||||||
|
m = re.match(r"^\s*-{5,}BEGIN\s+(EC|RSA)\s+PRIVATE\s+KEY-{5,}\s*$", line)
|
||||||
|
if m is not None:
|
||||||
|
account_key_type = m.group(1).lower()
|
||||||
|
break
|
||||||
|
if account_key_type is None:
|
||||||
|
# This happens for example if openssl_privatekey created this key
|
||||||
|
# (as opposed to the OpenSSL binary). For now, we assume this is
|
||||||
|
# an RSA key.
|
||||||
|
# FIXME: add some kind of auto-detection
|
||||||
|
account_key_type = "rsa"
|
||||||
|
if account_key_type not in ("rsa", "ec"):
|
||||||
|
return 'unknown key type "%s"' % account_key_type, {}
|
||||||
|
|
||||||
|
openssl_keydump_cmd = [self._openssl_bin, account_key_type, "-in", key, "-noout", "-text"]
|
||||||
|
dummy, out, dummy = self.module.run_command(openssl_keydump_cmd, check_rc=True)
|
||||||
|
|
||||||
|
if account_key_type == 'rsa':
|
||||||
|
pub_hex, pub_exp = re.search(
|
||||||
|
r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)",
|
||||||
|
to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL).groups()
|
||||||
|
pub_exp = "{0:x}".format(int(pub_exp))
|
||||||
|
if len(pub_exp) % 2:
|
||||||
|
pub_exp = "0{0}".format(pub_exp)
|
||||||
|
|
||||||
|
return None, {
|
||||||
|
'type': 'rsa',
|
||||||
|
'alg': 'RS256',
|
||||||
|
'jwk': {
|
||||||
|
"kty": "RSA",
|
||||||
|
"e": nopad_b64(binascii.unhexlify(pub_exp.encode("utf-8"))),
|
||||||
|
"n": nopad_b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))),
|
||||||
|
},
|
||||||
|
'hash': 'sha256',
|
||||||
|
}
|
||||||
|
elif account_key_type == 'ec':
|
||||||
|
pub_data = re.search(
|
||||||
|
r"pub:\s*\n\s+04:([a-f0-9\:\s]+?)\nASN1 OID: (\S+)(?:\nNIST CURVE: (\S+))?",
|
||||||
|
to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL)
|
||||||
|
if pub_data is None:
|
||||||
|
return 'cannot parse elliptic curve key', {}
|
||||||
|
pub_hex = binascii.unhexlify(re.sub(r"(\s|:)", "", pub_data.group(1)).encode("utf-8"))
|
||||||
|
asn1_oid_curve = pub_data.group(2).lower()
|
||||||
|
nist_curve = pub_data.group(3).lower() if pub_data.group(3) else None
|
||||||
|
if asn1_oid_curve == 'prime256v1' or nist_curve == 'p-256':
|
||||||
|
bits = 256
|
||||||
|
alg = 'ES256'
|
||||||
|
hash = 'sha256'
|
||||||
|
point_size = 32
|
||||||
|
curve = 'P-256'
|
||||||
|
elif asn1_oid_curve == 'secp384r1' or nist_curve == 'p-384':
|
||||||
|
bits = 384
|
||||||
|
alg = 'ES384'
|
||||||
|
hash = 'sha384'
|
||||||
|
point_size = 48
|
||||||
|
curve = 'P-384'
|
||||||
|
elif asn1_oid_curve == 'secp521r1' or nist_curve == 'p-521':
|
||||||
|
# Not yet supported on Let's Encrypt side, see
|
||||||
|
# https://github.com/letsencrypt/boulder/issues/2217
|
||||||
|
bits = 521
|
||||||
|
alg = 'ES512'
|
||||||
|
hash = 'sha512'
|
||||||
|
point_size = 66
|
||||||
|
curve = 'P-521'
|
||||||
|
else:
|
||||||
|
return 'unknown elliptic curve: %s / %s' % (asn1_oid_curve, nist_curve), {}
|
||||||
|
bytes = (bits + 7) // 8
|
||||||
|
if len(pub_hex) != 2 * bytes:
|
||||||
|
return 'bad elliptic curve point (%s / %s)' % (asn1_oid_curve, nist_curve), {}
|
||||||
|
return None, {
|
||||||
|
'type': 'ec',
|
||||||
|
'alg': alg,
|
||||||
|
'jwk': {
|
||||||
|
"kty": "EC",
|
||||||
|
"crv": curve,
|
||||||
|
"x": nopad_b64(pub_hex[:bytes]),
|
||||||
|
"y": nopad_b64(pub_hex[bytes:]),
|
||||||
|
},
|
||||||
|
'hash': hash,
|
||||||
|
'point_size': point_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
def sign_request(self, protected, payload, key_data, key):
|
||||||
|
try:
|
||||||
|
payload64 = nopad_b64(self.module.jsonify(payload).encode('utf8'))
|
||||||
|
protected64 = nopad_b64(self.module.jsonify(protected).encode('utf8'))
|
||||||
|
except Exception as e:
|
||||||
|
raise ModuleFailException("Failed to encode payload / headers as JSON: {0}".format(e))
|
||||||
|
|
||||||
|
openssl_sign_cmd = [self._openssl_bin, "dgst", "-{0}".format(key_data['hash']), "-sign", key]
|
||||||
|
sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8')
|
||||||
|
dummy, out, dummy = self.module.run_command(openssl_sign_cmd, data=sign_payload, check_rc=True, binary_data=True)
|
||||||
|
|
||||||
|
if key_data['type'] == 'ec':
|
||||||
|
dummy, der_out, dummy = self.module.run_command(
|
||||||
|
[self._openssl_bin, "asn1parse", "-inform", "DER"],
|
||||||
|
data=out, binary_data=True)
|
||||||
|
expected_len = 2 * key_data['point_size']
|
||||||
|
sig = re.findall(
|
||||||
|
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:
|
||||||
|
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]
|
||||||
|
out = binascii.unhexlify(sig[0]) + binascii.unhexlify(sig[1])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"protected": protected64,
|
||||||
|
"payload": payload64,
|
||||||
|
"signature": nopad_b64(to_bytes(out)),
|
||||||
|
}
|
||||||
|
|
||||||
|
def send_signed_request(self, url, payload):
|
||||||
|
'''
|
||||||
|
Sends a JWS signed HTTP POST request to the ACME server and returns
|
||||||
|
the response as dictionary
|
||||||
|
https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-6.2
|
||||||
|
'''
|
||||||
|
failed_tries = 0
|
||||||
|
while True:
|
||||||
|
protected = copy.deepcopy(self.jws_header)
|
||||||
|
protected["nonce"] = self.directory.get_nonce()
|
||||||
|
if self.version != 1:
|
||||||
|
protected["url"] = url
|
||||||
|
|
||||||
|
data = self.sign_request(protected, payload, self.key_data, self.key)
|
||||||
|
if self.version == 1:
|
||||||
|
data["header"] = self.jws_header
|
||||||
|
data = self.module.jsonify(data)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/jose+json',
|
||||||
|
}
|
||||||
|
resp, info = fetch_url(self.module, url, data=data, headers=headers, method='POST')
|
||||||
|
result = {}
|
||||||
|
try:
|
||||||
|
content = resp.read()
|
||||||
|
except AttributeError:
|
||||||
|
content = info.get('body')
|
||||||
|
|
||||||
|
if content:
|
||||||
|
if info['content-type'].startswith('application/json') or 400 <= info['status'] < 600:
|
||||||
|
try:
|
||||||
|
result = self.module.from_json(content.decode('utf8'))
|
||||||
|
# In case of badNonce error, try again (up to 5 times)
|
||||||
|
# (https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-6.6)
|
||||||
|
if (400 <= info['status'] < 600 and
|
||||||
|
result.get('type') == 'urn:ietf:params:acme:error:badNonce' and
|
||||||
|
failed_tries <= 5):
|
||||||
|
failed_tries += 1
|
||||||
|
continue
|
||||||
|
except ValueError:
|
||||||
|
raise ModuleFailException("Failed to parse the ACME response: {0} {1}".format(url, content))
|
||||||
|
else:
|
||||||
|
result = content
|
||||||
|
|
||||||
|
return result, info
|
||||||
|
|
||||||
|
def set_account_uri(self, uri):
|
||||||
|
'''
|
||||||
|
Set account URI. For ACME v2, it needs to be used to sending signed
|
||||||
|
requests.
|
||||||
|
'''
|
||||||
|
self.uri = uri
|
||||||
|
if self.version != 1:
|
||||||
|
self.jws_header.pop('jwk')
|
||||||
|
self.jws_header['kid'] = self.uri
|
||||||
|
|
||||||
|
def _new_reg(self, contact=None, agreement=None, terms_agreed=False, allow_creation=True):
|
||||||
|
'''
|
||||||
|
Registers a new ACME account. Returns True if the account was
|
||||||
|
created and False if it already existed (e.g. it was not newly
|
||||||
|
created).
|
||||||
|
https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-7.3
|
||||||
|
'''
|
||||||
|
contact = [] if contact is None else contact
|
||||||
|
|
||||||
|
if self.version == 1:
|
||||||
|
new_reg = {
|
||||||
|
'resource': 'new-reg',
|
||||||
|
'contact': contact
|
||||||
|
}
|
||||||
|
if agreement:
|
||||||
|
new_reg['agreement'] = agreement
|
||||||
|
else:
|
||||||
|
new_reg['agreement'] = self.directory['meta']['terms-of-service']
|
||||||
|
url = self.directory['new-reg']
|
||||||
|
else:
|
||||||
|
new_reg = {
|
||||||
|
'contact': contact
|
||||||
|
}
|
||||||
|
if not allow_creation:
|
||||||
|
new_reg['onlyReturnExisting'] = True
|
||||||
|
if terms_agreed:
|
||||||
|
new_reg['termsOfServiceAgreed'] = True
|
||||||
|
url = self.directory['newAccount']
|
||||||
|
|
||||||
|
result, info = self.send_signed_request(url, new_reg)
|
||||||
|
if 'location' in info:
|
||||||
|
self.set_account_uri(info['location'])
|
||||||
|
|
||||||
|
if info['status'] in ([200, 201] if self.version == 1 else [201]):
|
||||||
|
# Account did not exist
|
||||||
|
return True
|
||||||
|
elif info['status'] == (409 if self.version == 1 else 200):
|
||||||
|
# Account did exist
|
||||||
|
return False
|
||||||
|
elif info['status'] == 400 and result['type'] == 'urn:ietf:params:acme:error:accountDoesNotExist' and not allow_creation:
|
||||||
|
# Account does not exist (and we didn't try to create it)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
raise ModuleFailException("Error registering: {0} {1}".format(info['status'], result))
|
||||||
|
|
||||||
|
def get_account_data(self):
|
||||||
|
'''
|
||||||
|
Retrieve account information. Can only be called when the account
|
||||||
|
URI is already known (such as after calling init_account).
|
||||||
|
Return None if the account was deactivated, or a dict otherwise.
|
||||||
|
'''
|
||||||
|
if self.uri is None:
|
||||||
|
raise ModuleFailException("Account URI unknown")
|
||||||
|
data = {}
|
||||||
|
if self.version == 1:
|
||||||
|
data['resource'] = 'reg'
|
||||||
|
result, info = self.send_signed_request(self.uri, data)
|
||||||
|
if info['status'] == 403 and result.get('type') == 'urn:ietf:params:acme:error:unauthorized':
|
||||||
|
return None
|
||||||
|
if info['status'] < 200 or info['status'] >= 300:
|
||||||
|
raise ModuleFailException("Error getting account data from {2}: {0} {1}".format(info['status'], result, self.uri))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def init_account(self, contact, agreement=None, terms_agreed=False, allow_creation=True, update_contact=True):
|
||||||
|
'''
|
||||||
|
Create or update an account on the ACME server. For ACME v1,
|
||||||
|
as the only way (without knowing an account URI) to test if an
|
||||||
|
account exists is to try and create one with the provided account
|
||||||
|
key, this method will always result in an account being present
|
||||||
|
(except on error situations). For ACME v2, a new account will
|
||||||
|
only be created if allow_creation is set to True.
|
||||||
|
|
||||||
|
For ACME v2, check_mode is fully respected. For ACME v1, the account
|
||||||
|
might be created if it does not yet exist.
|
||||||
|
|
||||||
|
If the account already exists and if update_contact is set to
|
||||||
|
True, this method will update the contact information.
|
||||||
|
|
||||||
|
Return True in case something changed (account was created, contact
|
||||||
|
info updated) or would be changed (check_mode). The account URI
|
||||||
|
will be stored in self.uri; if it is None, the account does not
|
||||||
|
exist.
|
||||||
|
|
||||||
|
https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-7.3
|
||||||
|
'''
|
||||||
|
|
||||||
|
new_account = True
|
||||||
|
changed = False
|
||||||
|
if self.uri is not None:
|
||||||
|
new_account = False
|
||||||
|
else:
|
||||||
|
new_account = self._new_reg(
|
||||||
|
contact,
|
||||||
|
agreement=agreement,
|
||||||
|
terms_agreed=terms_agreed,
|
||||||
|
allow_creation=allow_creation and not self.module.check_mode
|
||||||
|
)
|
||||||
|
if self.module.check_mode and self.uri is None and allow_creation:
|
||||||
|
return True
|
||||||
|
if not new_account and self.uri and update_contact:
|
||||||
|
result = self.get_account_data()
|
||||||
|
if result is None:
|
||||||
|
if not allow_creation:
|
||||||
|
self.uri = None
|
||||||
|
return False
|
||||||
|
raise ModuleFailException("Account is deactivated!")
|
||||||
|
|
||||||
|
# ...and check if update is necessary
|
||||||
|
if result.get('contact', []) != contact:
|
||||||
|
if not self.module.check_mode:
|
||||||
|
upd_reg = result
|
||||||
|
upd_reg['contact'] = contact
|
||||||
|
result, dummy = self.send_signed_request(self.uri, upd_reg)
|
||||||
|
changed = True
|
||||||
|
return new_account or changed
|
259
lib/ansible/modules/web_infrastructure/acme_account.py
Normal file
259
lib/ansible/modules/web_infrastructure/acme_account.py
Normal file
|
@ -0,0 +1,259 @@
|
||||||
|
#!/usr/bin/python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||||
|
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
|
||||||
|
from __future__ import absolute_import, division, print_function
|
||||||
|
__metaclass__ = type
|
||||||
|
|
||||||
|
|
||||||
|
ANSIBLE_METADATA = {'metadata_version': '1.1',
|
||||||
|
'status': ['preview'],
|
||||||
|
'supported_by': 'community'}
|
||||||
|
|
||||||
|
|
||||||
|
DOCUMENTATION = '''
|
||||||
|
---
|
||||||
|
module: acme_account
|
||||||
|
author: "Felix Fontein (@felixfontein)"
|
||||||
|
version_added: "2.6"
|
||||||
|
short_description: Create, modify or delete accounts with Let's Encrypt
|
||||||
|
description:
|
||||||
|
- "Allows to create, modify or delete accounts with Let's Encrypt.
|
||||||
|
Let's Encrypt is a free, automated, and open certificate authority
|
||||||
|
(CA), run for the public's benefit. For details see U(https://letsencrypt.org)."
|
||||||
|
- "This module only works with the ACME v2 protocol."
|
||||||
|
extends_documentation_fragment:
|
||||||
|
- letsencrypt
|
||||||
|
options:
|
||||||
|
state:
|
||||||
|
description:
|
||||||
|
- "The state of the account, to be identified by its account key."
|
||||||
|
- "If the state is C(absent), the account will either not exist or be
|
||||||
|
deactivated."
|
||||||
|
- "If the state is C(changed_key), the account must exist. The account
|
||||||
|
key will be changed; no other information will be touched."
|
||||||
|
required: true
|
||||||
|
choices:
|
||||||
|
- present
|
||||||
|
- absent
|
||||||
|
- changed_key
|
||||||
|
allow_creation:
|
||||||
|
description:
|
||||||
|
- "Whether account creation is allowed (when state is C(present))."
|
||||||
|
default: yes
|
||||||
|
type: bool
|
||||||
|
contact:
|
||||||
|
description:
|
||||||
|
- "A list of contact URLs."
|
||||||
|
- "Email addresses must be prefixed with C(mailto:)."
|
||||||
|
- "See https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-7.1.2
|
||||||
|
for what is allowed."
|
||||||
|
- "Must be specified when state is C(present). Will be ignored
|
||||||
|
if state is C(absent) or C(changed_key)."
|
||||||
|
default: []
|
||||||
|
terms_agreed:
|
||||||
|
description:
|
||||||
|
- "Boolean indicating whether you agree to the terms of service document."
|
||||||
|
- "ACME servers can require this to be true."
|
||||||
|
default: no
|
||||||
|
type: bool
|
||||||
|
new_account_key_src:
|
||||||
|
description:
|
||||||
|
- "Path to a file containing the Let's Encrypt account RSA or Elliptic Curve
|
||||||
|
key to change to."
|
||||||
|
- "Same restrictions apply as to C(account_key_src)."
|
||||||
|
- "Mutually exclusive with C(new_account_key_content)."
|
||||||
|
- "Required if C(new_account_key_content) is not used and state is C(changed_key)."
|
||||||
|
new_account_key_content:
|
||||||
|
description:
|
||||||
|
- "Content of the Let's Encrypt account RSA or Elliptic Curve key to change to."
|
||||||
|
- "Same restrictions apply as to C(account_key_content)."
|
||||||
|
- "Mutually exclusive with C(new_account_key_src)."
|
||||||
|
- "Required if C(new_account_key_src) is not used and state is C(changed_key)."
|
||||||
|
'''
|
||||||
|
|
||||||
|
EXAMPLES = '''
|
||||||
|
- name: Make sure account exists and has given contacts. We agree to TOS.
|
||||||
|
acme_account:
|
||||||
|
account_key_src: /etc/pki/cert/private/account.key
|
||||||
|
state: present
|
||||||
|
terms_agreed: yes
|
||||||
|
contact:
|
||||||
|
- mailto:me@example.com
|
||||||
|
- mailto:myself@example.org
|
||||||
|
|
||||||
|
- name: Make sure account has given email address. Don't create account if it doesn't exist
|
||||||
|
acme_account:
|
||||||
|
account_key_src: /etc/pki/cert/private/account.key
|
||||||
|
state: present
|
||||||
|
allow_creation: no
|
||||||
|
contact:
|
||||||
|
- mailto:me@example.com
|
||||||
|
|
||||||
|
- name: Change account's key to the one stored in the variable new_account_key
|
||||||
|
acme_account:
|
||||||
|
account_key_src: /etc/pki/cert/private/account.key
|
||||||
|
new_account_key_content: '{{ new_account_key }}'
|
||||||
|
state: changed_key
|
||||||
|
|
||||||
|
- name: Delete account (we have to use the new key)
|
||||||
|
acme_account:
|
||||||
|
account_key_content: '{{ new_account_key }}'
|
||||||
|
state: absent
|
||||||
|
'''
|
||||||
|
|
||||||
|
RETURN = '''
|
||||||
|
account_uri:
|
||||||
|
description: ACME account URI, or None if account does not exist.
|
||||||
|
returned: always
|
||||||
|
type: string
|
||||||
|
'''
|
||||||
|
|
||||||
|
from ansible.module_utils.letsencrypt import (
|
||||||
|
ModuleFailException, ACMEAccount
|
||||||
|
)
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from ansible.module_utils.basic import AnsibleModule
|
||||||
|
from ansible.module_utils._text import to_native
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
|
@ -42,58 +42,13 @@ description:
|
||||||
- "At least one of C(dest) and C(fullchain_dest) must be specified."
|
- "At least one of C(dest) and C(fullchain_dest) must be specified."
|
||||||
- "Note: this module was called C(letsencrypt) before Ansible 2.6. The usage
|
- "Note: this module was called C(letsencrypt) before Ansible 2.6. The usage
|
||||||
did not change."
|
did not change."
|
||||||
requirements:
|
extends_documentation_fragment:
|
||||||
- "python >= 2.6"
|
- letsencrypt
|
||||||
- openssl
|
|
||||||
options:
|
options:
|
||||||
account_key_src:
|
|
||||||
description:
|
|
||||||
- "Path to a file containing the ACME account RSA or Elliptic Curve
|
|
||||||
key."
|
|
||||||
- "RSA keys can be created with C(openssl rsa ...). Elliptic curve keys can
|
|
||||||
be created with C(openssl ecparam -genkey ...)."
|
|
||||||
- "Mutually exclusive with C(account_key_content)."
|
|
||||||
- "Required if C(account_key_content) is not used."
|
|
||||||
aliases: [ account_key ]
|
|
||||||
account_key_content:
|
|
||||||
description:
|
|
||||||
- "Content of the ACME account RSA or Elliptic Curve key."
|
|
||||||
- "Mutually exclusive with C(account_key_src)."
|
|
||||||
- "Required if C(account_key_src) is not used."
|
|
||||||
- "Warning: the content will be written into a temporary file, which will
|
|
||||||
be deleted by Ansible when the module completes. Since this is an
|
|
||||||
important private key — it can be used to change the account key,
|
|
||||||
or to revoke your certificates without knowing their private keys
|
|
||||||
—, this might not be acceptable."
|
|
||||||
version_added: "2.5"
|
|
||||||
account_email:
|
account_email:
|
||||||
description:
|
description:
|
||||||
- "The email address associated with this account."
|
- "The email address associated with this account."
|
||||||
- "It will be used for certificate expiration warnings."
|
- "It will be used for certificate expiration warnings."
|
||||||
acme_directory:
|
|
||||||
description:
|
|
||||||
- "The ACME directory to use. This is the entry point URL to access
|
|
||||||
CA server API."
|
|
||||||
- "For safety reasons the default is set to the Let's Encrypt staging
|
|
||||||
server (for the ACME v1 protocol). This will create technically correct,
|
|
||||||
but untrusted certificates."
|
|
||||||
- "For Let's Encrypt, all staging endpoints can be found here:
|
|
||||||
U(https://letsencrypt.org/docs/staging-environment/)"
|
|
||||||
- "For Let's Encrypt, the production directory URL for ACME v1 is
|
|
||||||
U(https://acme-v01.api.letsencrypt.org/directory), and the production
|
|
||||||
directory URL for ACME v2 is U(https://acme-v02.api.letsencrypt.org/directory)."
|
|
||||||
- "I(Warning): So far, the module has only been tested against Let's Encrypt
|
|
||||||
(staging and production) and against the Pebble testing server
|
|
||||||
(U(https://github.com/letsencrypt/Pebble))."
|
|
||||||
default: https://acme-staging.api.letsencrypt.org/directory
|
|
||||||
acme_version:
|
|
||||||
description:
|
|
||||||
- "The ACME version of the endpoint."
|
|
||||||
- "Must be 1 for the classic Let's Encrypt ACME endpoint, or 2 for the
|
|
||||||
new standardized ACME v2 endpoint."
|
|
||||||
default: 1
|
|
||||||
choices: [1, 2]
|
|
||||||
version_added: "2.5"
|
|
||||||
agreement:
|
agreement:
|
||||||
description:
|
description:
|
||||||
- "URI to a terms of service document you agree to when using the
|
- "URI to a terms of service document you agree to when using the
|
||||||
|
@ -160,14 +115,6 @@ options:
|
||||||
If the certificate is not renewed, module return values will not
|
If the certificate is not renewed, module return values will not
|
||||||
include C(challenge_data)."
|
include C(challenge_data)."
|
||||||
default: 10
|
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(no) for testing purposes,
|
|
||||||
for example when testing against a local Pebble server.
|
|
||||||
type: bool
|
|
||||||
default: 'yes'
|
|
||||||
version_added: 2.5
|
|
||||||
deactivate_authzs:
|
deactivate_authzs:
|
||||||
description:
|
description:
|
||||||
- "Deactivate authentication objects (authz) after issuing a certificate,
|
- "Deactivate authentication objects (authz) after issuing a certificate,
|
||||||
|
@ -175,7 +122,7 @@ options:
|
||||||
- "Authentication objects are bound to an account key and remain valid
|
- "Authentication objects are bound to an account key and remain valid
|
||||||
for a certain amount of time, and can be used to issue certificates
|
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
|
without having to re-authenticate the domain. This can be a security
|
||||||
concern. "
|
concern."
|
||||||
type: bool
|
type: bool
|
||||||
default: 'no'
|
default: 'no'
|
||||||
version_added: 2.6
|
version_added: 2.6
|
||||||
|
@ -344,78 +291,21 @@ account_uri:
|
||||||
version_added: "2.5"
|
version_added: "2.5"
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
from ansible.module_utils.letsencrypt import (
|
||||||
|
ModuleFailException, fetch_url, write_file, nopad_b64, simple_get, ACMEAccount
|
||||||
|
)
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
|
||||||
import copy
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
|
||||||
import locale
|
import locale
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
import textwrap
|
import textwrap
|
||||||
import time
|
import time
|
||||||
import traceback
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from ansible.module_utils.basic import AnsibleModule
|
from ansible.module_utils.basic import AnsibleModule
|
||||||
from ansible.module_utils._text import to_native, to_text, to_bytes
|
from ansible.module_utils._text import 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.module_fail_args = args
|
|
||||||
|
|
||||||
def do_fail(self, module):
|
|
||||||
module.fail_json(msg=self.msg, other=self.module_fail_args)
|
|
||||||
|
|
||||||
|
|
||||||
def _lowercase_fetch_url(*args, **kwargs):
|
|
||||||
'''
|
|
||||||
Add lowercase representations of the header names as dict keys
|
|
||||||
|
|
||||||
'''
|
|
||||||
response, info = _fetch_url(*args, **kwargs)
|
|
||||||
|
|
||||||
info.update(dict((header.lower(), value) for (header, value) in info.items()))
|
|
||||||
return response, info
|
|
||||||
|
|
||||||
|
|
||||||
fetch_url = _lowercase_fetch_url
|
|
||||||
|
|
||||||
|
|
||||||
def nopad_b64(data):
|
|
||||||
return base64.urlsafe_b64encode(data).decode('utf8').replace("=", "")
|
|
||||||
|
|
||||||
|
|
||||||
def simple_get(module, url):
|
|
||||||
resp, info = fetch_url(module, url, method='GET')
|
|
||||||
|
|
||||||
result = {}
|
|
||||||
try:
|
|
||||||
content = resp.read()
|
|
||||||
except AttributeError:
|
|
||||||
content = info.get('body')
|
|
||||||
|
|
||||||
if content:
|
|
||||||
if info['content-type'].startswith('application/json'):
|
|
||||||
try:
|
|
||||||
result = module.from_json(content.decode('utf8'))
|
|
||||||
except ValueError:
|
|
||||||
raise ModuleFailException("Failed to parse the ACME response: {0} {1}".format(url, content))
|
|
||||||
else:
|
|
||||||
result = content
|
|
||||||
|
|
||||||
if info['status'] >= 400:
|
|
||||||
raise ModuleFailException("ACME request failed: CODE: {0} RESULT: {1}".format(info['status'], result))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def get_cert_days(module, cert_file):
|
def get_cert_days(module, cert_file):
|
||||||
|
@ -441,408 +331,6 @@ def get_cert_days(module, cert_file):
|
||||||
return (not_after - now).days
|
return (not_after - now).days
|
||||||
|
|
||||||
|
|
||||||
# function source: network/basics/uri.py
|
|
||||||
def write_file(module, dest, content):
|
|
||||||
'''
|
|
||||||
Write content to destination file dest, only if the content
|
|
||||||
has changed.
|
|
||||||
'''
|
|
||||||
changed = False
|
|
||||||
# create a tempfile
|
|
||||||
fd, tmpsrc = tempfile.mkstemp(text=False)
|
|
||||||
f = os.fdopen(fd, 'wb')
|
|
||||||
try:
|
|
||||||
f.write(content)
|
|
||||||
except Exception as err:
|
|
||||||
try:
|
|
||||||
f.close()
|
|
||||||
except Exception as e:
|
|
||||||
pass
|
|
||||||
os.remove(tmpsrc)
|
|
||||||
raise ModuleFailException("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc())
|
|
||||||
f.close()
|
|
||||||
checksum_src = None
|
|
||||||
checksum_dest = None
|
|
||||||
# raise an error if there is no tmpsrc file
|
|
||||||
if not os.path.exists(tmpsrc):
|
|
||||||
try:
|
|
||||||
os.remove(tmpsrc)
|
|
||||||
except Exception as e:
|
|
||||||
pass
|
|
||||||
raise ModuleFailException("Source %s does not exist" % (tmpsrc))
|
|
||||||
if not os.access(tmpsrc, os.R_OK):
|
|
||||||
os.remove(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)
|
|
||||||
raise ModuleFailException("Destination %s not writable" % (dest))
|
|
||||||
if not os.access(dest, os.R_OK):
|
|
||||||
os.remove(tmpsrc)
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
raise ModuleFailException("failed to copy %s to %s: %s" % (tmpsrc, dest, to_native(err)), exception=traceback.format_exc())
|
|
||||||
os.remove(tmpsrc)
|
|
||||||
return changed
|
|
||||||
|
|
||||||
|
|
||||||
class ACMEDirectory(object):
|
|
||||||
'''
|
|
||||||
The ACME server directory. Gives access to the available resources
|
|
||||||
and the Replay-Nonce for a given URI. This only works for
|
|
||||||
URIs that permit GET requests (so normally not the ones that
|
|
||||||
require authentication).
|
|
||||||
https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.1.1
|
|
||||||
'''
|
|
||||||
|
|
||||||
def __init__(self, module):
|
|
||||||
self.module = module
|
|
||||||
self.directory_root = module.params['acme_directory']
|
|
||||||
self.version = module.params['acme_version']
|
|
||||||
|
|
||||||
self.directory = simple_get(self.module, self.directory_root)
|
|
||||||
|
|
||||||
# Check whether self.version matches what we expect
|
|
||||||
if self.version == 1:
|
|
||||||
for key in ('new-reg', 'new-authz', 'new-cert'):
|
|
||||||
if key not in self.directory:
|
|
||||||
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:
|
|
||||||
raise ModuleFailException("ACME directory does not seem to follow protocol ACME v2")
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
|
||||||
return self.directory[key]
|
|
||||||
|
|
||||||
def get_nonce(self, resource=None):
|
|
||||||
url = self.directory_root if self.version == 1 else self.directory['newNonce']
|
|
||||||
if resource is not None:
|
|
||||||
url = resource
|
|
||||||
dummy, info = fetch_url(self.module, url, method='HEAD')
|
|
||||||
if info['status'] not in (200, 204):
|
|
||||||
raise ModuleFailException("Failed to get replay-nonce, got status {0}".format(info['status']))
|
|
||||||
return info['replay-nonce']
|
|
||||||
|
|
||||||
|
|
||||||
class ACMEAccount(object):
|
|
||||||
'''
|
|
||||||
ACME account object. Handles the authorized communication with the
|
|
||||||
ACME server. Provides access to account bound information like
|
|
||||||
the currently active authorizations and valid certificates
|
|
||||||
'''
|
|
||||||
|
|
||||||
def __init__(self, module):
|
|
||||||
self.module = module
|
|
||||||
self.version = module.params['acme_version']
|
|
||||||
# account_key path and content are mutually exclusive
|
|
||||||
self.key = module.params['account_key_src']
|
|
||||||
self.key_content = module.params['account_key_content']
|
|
||||||
self.email = module.params['account_email']
|
|
||||||
self.directory = ACMEDirectory(module)
|
|
||||||
self.agreement = module.params.get('agreement')
|
|
||||||
self.terms_agreed = module.params.get('terms_agreed')
|
|
||||||
|
|
||||||
self.uri = None
|
|
||||||
self.changed = False
|
|
||||||
|
|
||||||
self._openssl_bin = module.get_bin_path('openssl', True)
|
|
||||||
|
|
||||||
# Create a key file from content, key (path) and key content are mutually exclusive
|
|
||||||
if self.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(self.key_content.encode('utf-8'))
|
|
||||||
self.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()
|
|
||||||
|
|
||||||
error, self.key_data = self._parse_account_key(self.key)
|
|
||||||
if error:
|
|
||||||
raise ModuleFailException("error while parsing account key: %s" % error)
|
|
||||||
self.jwk = self.key_data['jwk']
|
|
||||||
self.jws_header = {
|
|
||||||
"alg": self.key_data['alg'],
|
|
||||||
"jwk": self.jwk,
|
|
||||||
}
|
|
||||||
self.init_account()
|
|
||||||
|
|
||||||
def get_keyauthorization(self, token):
|
|
||||||
'''
|
|
||||||
Returns the key authorization for the given token
|
|
||||||
https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-8.1
|
|
||||||
'''
|
|
||||||
accountkey_json = json.dumps(self.jwk, sort_keys=True, separators=(',', ':'))
|
|
||||||
thumbprint = nopad_b64(hashlib.sha256(accountkey_json.encode('utf8')).digest())
|
|
||||||
return "{0}.{1}".format(token, thumbprint)
|
|
||||||
|
|
||||||
def _parse_account_key(self, key):
|
|
||||||
'''
|
|
||||||
Parses an RSA or Elliptic Curve key file in PEM format and returns a pair
|
|
||||||
(error, key_data).
|
|
||||||
'''
|
|
||||||
account_key_type = None
|
|
||||||
with open(key, "rt") as f:
|
|
||||||
for line in f:
|
|
||||||
m = re.match(r"^\s*-{5,}BEGIN\s+(EC|RSA)\s+PRIVATE\s+KEY-{5,}\s*$", line)
|
|
||||||
if m is not None:
|
|
||||||
account_key_type = m.group(1).lower()
|
|
||||||
break
|
|
||||||
if account_key_type is None:
|
|
||||||
# This happens for example if openssl_privatekey created this key
|
|
||||||
# (as opposed to the OpenSSL binary). For now, we assume this is
|
|
||||||
# an RSA key.
|
|
||||||
# FIXME: add some kind of auto-detection
|
|
||||||
account_key_type = "rsa"
|
|
||||||
if account_key_type not in ("rsa", "ec"):
|
|
||||||
return 'unknown key type "%s"' % account_key_type, {}
|
|
||||||
|
|
||||||
openssl_keydump_cmd = [self._openssl_bin, account_key_type, "-in", key, "-noout", "-text"]
|
|
||||||
dummy, out, dummy = self.module.run_command(openssl_keydump_cmd, check_rc=True)
|
|
||||||
|
|
||||||
if account_key_type == 'rsa':
|
|
||||||
pub_hex, pub_exp = re.search(
|
|
||||||
r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)",
|
|
||||||
to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL).groups()
|
|
||||||
pub_exp = "{0:x}".format(int(pub_exp))
|
|
||||||
if len(pub_exp) % 2:
|
|
||||||
pub_exp = "0{0}".format(pub_exp)
|
|
||||||
|
|
||||||
return None, {
|
|
||||||
'type': 'rsa',
|
|
||||||
'alg': 'RS256',
|
|
||||||
'jwk': {
|
|
||||||
"kty": "RSA",
|
|
||||||
"e": nopad_b64(binascii.unhexlify(pub_exp.encode("utf-8"))),
|
|
||||||
"n": nopad_b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))),
|
|
||||||
},
|
|
||||||
'hash': 'sha256',
|
|
||||||
}
|
|
||||||
elif account_key_type == 'ec':
|
|
||||||
pub_data = re.search(
|
|
||||||
r"pub:\s*\n\s+04:([a-f0-9\:\s]+?)\nASN1 OID: (\S+)(?:\nNIST CURVE: (\S+))?",
|
|
||||||
to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL)
|
|
||||||
if pub_data is None:
|
|
||||||
return 'cannot parse elliptic curve key', {}
|
|
||||||
pub_hex = binascii.unhexlify(re.sub(r"(\s|:)", "", pub_data.group(1)).encode("utf-8"))
|
|
||||||
asn1_oid_curve = pub_data.group(2).lower()
|
|
||||||
nist_curve = pub_data.group(3).lower() if pub_data.group(3) else None
|
|
||||||
if asn1_oid_curve == 'prime256v1' or nist_curve == 'p-256':
|
|
||||||
bits = 256
|
|
||||||
alg = 'ES256'
|
|
||||||
hash = 'sha256'
|
|
||||||
point_size = 32
|
|
||||||
curve = 'P-256'
|
|
||||||
elif asn1_oid_curve == 'secp384r1' or nist_curve == 'p-384':
|
|
||||||
bits = 384
|
|
||||||
alg = 'ES384'
|
|
||||||
hash = 'sha384'
|
|
||||||
point_size = 48
|
|
||||||
curve = 'P-384'
|
|
||||||
elif asn1_oid_curve == 'secp521r1' or nist_curve == 'p-521':
|
|
||||||
# Not yet supported on Let's Encrypt side, see
|
|
||||||
# https://github.com/letsencrypt/boulder/issues/2217
|
|
||||||
bits = 521
|
|
||||||
alg = 'ES512'
|
|
||||||
hash = 'sha512'
|
|
||||||
point_size = 66
|
|
||||||
curve = 'P-521'
|
|
||||||
else:
|
|
||||||
return 'unknown elliptic curve: %s / %s' % (asn1_oid_curve, nist_curve), {}
|
|
||||||
bytes = (bits + 7) // 8
|
|
||||||
if len(pub_hex) != 2 * bytes:
|
|
||||||
return 'bad elliptic curve point (%s / %s)' % (asn1_oid_curve, nist_curve), {}
|
|
||||||
return None, {
|
|
||||||
'type': 'ec',
|
|
||||||
'alg': alg,
|
|
||||||
'jwk': {
|
|
||||||
"kty": "EC",
|
|
||||||
"crv": curve,
|
|
||||||
"x": nopad_b64(pub_hex[:bytes]),
|
|
||||||
"y": nopad_b64(pub_hex[bytes:]),
|
|
||||||
},
|
|
||||||
'hash': hash,
|
|
||||||
'point_size': point_size,
|
|
||||||
}
|
|
||||||
|
|
||||||
def send_signed_request(self, url, payload):
|
|
||||||
'''
|
|
||||||
Sends a JWS signed HTTP POST request to the ACME server and returns
|
|
||||||
the response as dictionary
|
|
||||||
https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-6.2
|
|
||||||
'''
|
|
||||||
failed_tries = 0
|
|
||||||
while True:
|
|
||||||
protected = copy.deepcopy(self.jws_header)
|
|
||||||
protected["nonce"] = self.directory.get_nonce()
|
|
||||||
if self.version != 1:
|
|
||||||
protected["url"] = url
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload64 = nopad_b64(self.module.jsonify(payload).encode('utf8'))
|
|
||||||
protected64 = nopad_b64(self.module.jsonify(protected).encode('utf8'))
|
|
||||||
except Exception as 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')
|
|
||||||
dummy, out, dummy = self.module.run_command(openssl_sign_cmd, data=sign_payload, check_rc=True, binary_data=True)
|
|
||||||
|
|
||||||
if self.key_data['type'] == 'ec':
|
|
||||||
dummy, der_out, dummy = self.module.run_command(
|
|
||||||
[self._openssl_bin, "asn1parse", "-inform", "DER"],
|
|
||||||
data=out, binary_data=True)
|
|
||||||
expected_len = 2 * self.key_data['point_size']
|
|
||||||
sig = re.findall(
|
|
||||||
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:
|
|
||||||
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]
|
|
||||||
out = binascii.unhexlify(sig[0]) + binascii.unhexlify(sig[1])
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"protected": protected64,
|
|
||||||
"payload": payload64,
|
|
||||||
"signature": nopad_b64(to_bytes(out)),
|
|
||||||
}
|
|
||||||
if self.version == 1:
|
|
||||||
data["header"] = self.jws_header
|
|
||||||
data = self.module.jsonify(data)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
'Content-Type': 'application/jose+json',
|
|
||||||
}
|
|
||||||
resp, info = fetch_url(self.module, url, data=data, headers=headers, method='POST')
|
|
||||||
result = {}
|
|
||||||
try:
|
|
||||||
content = resp.read()
|
|
||||||
except AttributeError:
|
|
||||||
content = info.get('body')
|
|
||||||
|
|
||||||
if content:
|
|
||||||
if info['content-type'].startswith('application/json') or 400 <= info['status'] < 600:
|
|
||||||
try:
|
|
||||||
result = self.module.from_json(content.decode('utf8'))
|
|
||||||
# In case of badNonce error, try again (up to 5 times)
|
|
||||||
# (https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-6.6)
|
|
||||||
if (400 <= info['status'] < 600 and
|
|
||||||
result.get('type') == 'urn:ietf:params:acme:error:badNonce' and
|
|
||||||
failed_tries <= 5):
|
|
||||||
failed_tries += 1
|
|
||||||
continue
|
|
||||||
except ValueError:
|
|
||||||
raise ModuleFailException("Failed to parse the ACME response: {0} {1}".format(url, content))
|
|
||||||
else:
|
|
||||||
result = content
|
|
||||||
|
|
||||||
return result, info
|
|
||||||
|
|
||||||
def _new_reg(self, contact=None):
|
|
||||||
'''
|
|
||||||
Registers a new ACME account. Returns True if the account was
|
|
||||||
created and False if it already existed (e.g. it was not newly
|
|
||||||
created)
|
|
||||||
https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.3
|
|
||||||
'''
|
|
||||||
contact = [] if contact is None else contact
|
|
||||||
|
|
||||||
if self.uri is not None:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if self.version == 1:
|
|
||||||
new_reg = {
|
|
||||||
'resource': 'new-reg',
|
|
||||||
'contact': contact
|
|
||||||
}
|
|
||||||
if self.agreement:
|
|
||||||
new_reg['agreement'] = self.agreement
|
|
||||||
else:
|
|
||||||
new_reg['agreement'] = self.directory['meta']['terms-of-service']
|
|
||||||
url = self.directory['new-reg']
|
|
||||||
else:
|
|
||||||
new_reg = {
|
|
||||||
'contact': contact
|
|
||||||
}
|
|
||||||
if self.terms_agreed:
|
|
||||||
new_reg['termsOfServiceAgreed'] = True
|
|
||||||
url = self.directory['newAccount']
|
|
||||||
|
|
||||||
result, info = self.send_signed_request(url, new_reg)
|
|
||||||
if 'location' in info:
|
|
||||||
self.uri = info['location']
|
|
||||||
if self.version != 1:
|
|
||||||
self.jws_header.pop('jwk')
|
|
||||||
self.jws_header['kid'] = self.uri
|
|
||||||
|
|
||||||
if info['status'] in [200, 201]:
|
|
||||||
# Account did not exist
|
|
||||||
self.changed = True
|
|
||||||
return True
|
|
||||||
elif info['status'] == 409:
|
|
||||||
# Account did exist
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
raise ModuleFailException("Error registering: {0} {1}".format(info['status'], result))
|
|
||||||
|
|
||||||
def init_account(self):
|
|
||||||
'''
|
|
||||||
Create or update an account on the ACME server. As the only way
|
|
||||||
(without knowing an account URI) to test if an account exists
|
|
||||||
is to try and create one with the provided account key, this
|
|
||||||
method will always result in an account being present (except
|
|
||||||
on error situations). If the account already exists, it will
|
|
||||||
update the contact information.
|
|
||||||
https://tools.ietf.org/html/draft-ietf-acme-acme-09#section-7.3
|
|
||||||
'''
|
|
||||||
|
|
||||||
contact = []
|
|
||||||
if self.email:
|
|
||||||
contact.append('mailto:' + self.email)
|
|
||||||
|
|
||||||
# if this is not a new registration (e.g. existing account)
|
|
||||||
if not self._new_reg(contact):
|
|
||||||
# pre-existing account, get account data...
|
|
||||||
result, dummy = self.send_signed_request(self.uri, {'resource': 'reg'})
|
|
||||||
|
|
||||||
# ...and check if update is necessary
|
|
||||||
do_update = False
|
|
||||||
if 'contact' in result:
|
|
||||||
if contact != result['contact']:
|
|
||||||
do_update = True
|
|
||||||
elif len(contact) > 0:
|
|
||||||
do_update = True
|
|
||||||
|
|
||||||
if do_update:
|
|
||||||
upd_reg = result
|
|
||||||
upd_reg['contact'] = contact
|
|
||||||
result, dummy = self.send_signed_request(self.uri, upd_reg)
|
|
||||||
self.changed = True
|
|
||||||
|
|
||||||
|
|
||||||
class ACMEClient(object):
|
class ACMEClient(object):
|
||||||
'''
|
'''
|
||||||
ACME client class. Uses an ACME account object and a CSR to
|
ACME client class. Uses an ACME account object and a CSR to
|
||||||
|
@ -863,10 +351,20 @@ class ACMEClient(object):
|
||||||
self.data = module.params['data']
|
self.data = module.params['data']
|
||||||
self.authorizations = None
|
self.authorizations = None
|
||||||
self.cert_days = -1
|
self.cert_days = -1
|
||||||
self.changed = self.account.changed
|
|
||||||
self.order_uri = self.data.get('order_uri') if self.data else None
|
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
|
self.finalize_uri = self.data.get('finalize_uri') if self.data else None
|
||||||
|
|
||||||
|
# Make sure account exists
|
||||||
|
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')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract list of domains from CSR
|
||||||
if not os.path.exists(self.csr):
|
if not os.path.exists(self.csr):
|
||||||
raise ModuleFailException("CSR %s not found" % (self.csr))
|
raise ModuleFailException("CSR %s not found" % (self.csr))
|
||||||
|
|
||||||
|
@ -1279,9 +777,10 @@ def main():
|
||||||
argument_spec=dict(
|
argument_spec=dict(
|
||||||
account_key_src=dict(type='path', aliases=['account_key']),
|
account_key_src=dict(type='path', aliases=['account_key']),
|
||||||
account_key_content=dict(type='str', no_log=True),
|
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_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'),
|
acme_version=dict(required=False, default=1, choices=[1, 2], type='int'),
|
||||||
|
validate_certs=dict(required=False, default=True, type='bool'),
|
||||||
|
account_email=dict(required=False, default=None, type='str'),
|
||||||
agreement=dict(required=False, type='str'),
|
agreement=dict(required=False, type='str'),
|
||||||
terms_agreed=dict(required=False, default=False, type='bool'),
|
terms_agreed=dict(required=False, default=False, type='bool'),
|
||||||
challenge=dict(required=False, default='http-01', choices=['http-01', 'dns-01'], type='str'),
|
challenge=dict(required=False, default='http-01', choices=['http-01', 'dns-01'], type='str'),
|
||||||
|
@ -1291,7 +790,6 @@ def main():
|
||||||
fullchain_dest=dict(aliases=['fullchain'], type='path'),
|
fullchain_dest=dict(aliases=['fullchain'], type='path'),
|
||||||
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'),
|
|
||||||
deactivate_authzs=dict(required=False, default=False, type='bool'),
|
deactivate_authzs=dict(required=False, default=False, type='bool'),
|
||||||
force=dict(required=False, default=False, type='bool'),
|
force=dict(required=False, default=False, type='bool'),
|
||||||
),
|
),
|
||||||
|
|
67
lib/ansible/utils/module_docs_fragments/letsencrypt.py
Normal file
67
lib/ansible/utils/module_docs_fragments/letsencrypt.py
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||||
|
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleDocFragment(object):
|
||||||
|
|
||||||
|
# Standard files documentation fragment
|
||||||
|
DOCUMENTATION = """
|
||||||
|
requirements:
|
||||||
|
- "python >= 2.6"
|
||||||
|
- openssl
|
||||||
|
options:
|
||||||
|
account_key_src:
|
||||||
|
description:
|
||||||
|
- "Path to a file containing the ACME account RSA or Elliptic Curve
|
||||||
|
key."
|
||||||
|
- "RSA keys can be created with C(openssl rsa ...). Elliptic curve keys can
|
||||||
|
be created with C(openssl ecparam -genkey ...)."
|
||||||
|
- "Mutually exclusive with C(account_key_content)."
|
||||||
|
- "Required if C(account_key_content) is not used."
|
||||||
|
aliases: [ account_key ]
|
||||||
|
account_key_content:
|
||||||
|
description:
|
||||||
|
- "Content of the ACME account RSA or Elliptic Curve key."
|
||||||
|
- "Mutually exclusive with C(account_key_src)."
|
||||||
|
- "Required if C(account_key_src) is not used."
|
||||||
|
- "Warning: the content will be written into a temporary file, which will
|
||||||
|
be deleted by Ansible when the module completes. Since this is an
|
||||||
|
important private key — it can be used to change the account key,
|
||||||
|
or to revoke your certificates without knowing their private keys
|
||||||
|
—, this might not be acceptable."
|
||||||
|
version_added: "2.5"
|
||||||
|
acme_version:
|
||||||
|
description:
|
||||||
|
- "The ACME version of the endpoint."
|
||||||
|
- "Must be 1 for the classic Let's Encrypt ACME endpoint, or 2 for the
|
||||||
|
new standardized ACME v2 endpoint."
|
||||||
|
default: 1
|
||||||
|
choices: [1, 2]
|
||||||
|
version_added: "2.5"
|
||||||
|
acme_directory:
|
||||||
|
description:
|
||||||
|
- "The ACME directory to use. This is the entry point URL to access
|
||||||
|
CA server API."
|
||||||
|
- "For safety reasons the default is set to the Let's Encrypt staging
|
||||||
|
server (for the ACME v1 protocol). This will create technically correct,
|
||||||
|
but untrusted certificates."
|
||||||
|
- "For Let's Encrypt, all staging endpoints can be found here:
|
||||||
|
U(https://letsencrypt.org/docs/staging-environment/)"
|
||||||
|
- "For Let's Encrypt, the production directory URL for ACME v1 is
|
||||||
|
U(https://acme-v01.api.letsencrypt.org/directory), and the production
|
||||||
|
directory URL for ACME v2 is U(https://acme-v02.api.letsencrypt.org/directory)."
|
||||||
|
- "I(Warning): So far, the module has only been tested against Let's Encrypt
|
||||||
|
(staging and production) and against the Pebble testing server
|
||||||
|
(U(https://github.com/letsencrypt/Pebble))."
|
||||||
|
default: https://acme-staging.api.letsencrypt.org/directory
|
||||||
|
validate_certs:
|
||||||
|
description:
|
||||||
|
- Whether calls to the ACME directory will validate TLS certificates.
|
||||||
|
- I(Warning:) Should I(only ever) be set to C(no) for testing purposes,
|
||||||
|
for example when testing against a local Pebble server.
|
||||||
|
type: bool
|
||||||
|
default: 'yes'
|
||||||
|
version_added: 2.5
|
||||||
|
"""
|
2
test/integration/targets/acme_account/aliases
Normal file
2
test/integration/targets/acme_account/aliases
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
posix/ci/group1
|
||||||
|
destructive
|
2
test/integration/targets/acme_account/meta/main.yml
Normal file
2
test/integration/targets/acme_account/meta/main.yml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
dependencies:
|
||||||
|
- setup_openssl
|
109
test/integration/targets/acme_account/tasks/main.yml
Normal file
109
test/integration/targets/acme_account/tasks/main.yml
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
---
|
||||||
|
- block:
|
||||||
|
- debug: var=openssl_version.stdout
|
||||||
|
|
||||||
|
- name: Generate account key
|
||||||
|
command: openssl ecparam -name prime256v1 -genkey -out {{ output_dir }}/accountkey.pem
|
||||||
|
|
||||||
|
- name: Parse account key (to ease debugging some test failures)
|
||||||
|
command: openssl ec -in {{ output_dir }}/accountkey.pem -noout -text
|
||||||
|
|
||||||
|
- name: Do not try to create account
|
||||||
|
acme_account:
|
||||||
|
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||||
|
acme_version: 2
|
||||||
|
acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||||
|
state: present
|
||||||
|
allow_creation: no
|
||||||
|
ignore_errors: yes
|
||||||
|
register: account_not_created
|
||||||
|
|
||||||
|
- name: Create it now
|
||||||
|
acme_account:
|
||||||
|
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||||
|
acme_version: 2
|
||||||
|
acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||||
|
state: present
|
||||||
|
allow_creation: yes
|
||||||
|
terms_agreed: yes
|
||||||
|
contact:
|
||||||
|
- mailto:example@example.org
|
||||||
|
register: account_created
|
||||||
|
|
||||||
|
- name: Change email address
|
||||||
|
acme_account:
|
||||||
|
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||||
|
acme_version: 2
|
||||||
|
acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||||
|
state: present
|
||||||
|
# allow_creation: no
|
||||||
|
contact:
|
||||||
|
- mailto:example@example.com
|
||||||
|
register: account_modified
|
||||||
|
|
||||||
|
- name: Change email address (idempotent)
|
||||||
|
acme_account:
|
||||||
|
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||||
|
acme_version: 2
|
||||||
|
acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||||
|
state: present
|
||||||
|
# allow_creation: no
|
||||||
|
contact:
|
||||||
|
- mailto:example@example.com
|
||||||
|
register: account_modified_idempotent
|
||||||
|
|
||||||
|
- name: Generate new account key
|
||||||
|
command: openssl ecparam -name secp384r1 -genkey -out {{ output_dir }}/accountkey2.pem
|
||||||
|
|
||||||
|
- name: Parse account key (to ease debugging some test failures)
|
||||||
|
command: openssl ec -in {{ output_dir }}/accountkey2.pem -noout -text
|
||||||
|
|
||||||
|
- name: Change account key
|
||||||
|
acme_account:
|
||||||
|
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||||
|
acme_version: 2
|
||||||
|
acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||||
|
new_account_key_src: "{{ output_dir }}/accountkey2.pem"
|
||||||
|
state: changed_key
|
||||||
|
contact:
|
||||||
|
- mailto:example@example.com
|
||||||
|
register: account_change_key
|
||||||
|
|
||||||
|
- name: Deactivate account
|
||||||
|
acme_account:
|
||||||
|
account_key_src: "{{ output_dir }}/accountkey2.pem"
|
||||||
|
acme_version: 2
|
||||||
|
acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||||
|
state: absent
|
||||||
|
register: account_deactivate
|
||||||
|
|
||||||
|
- name: Deactivate account (idempotent)
|
||||||
|
acme_account:
|
||||||
|
account_key_src: "{{ output_dir }}/accountkey2.pem"
|
||||||
|
acme_version: 2
|
||||||
|
acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||||
|
state: absent
|
||||||
|
register: account_deactivate_idempotent
|
||||||
|
|
||||||
|
- name: Do not try to create account II
|
||||||
|
acme_account:
|
||||||
|
account_key_src: "{{ output_dir }}/accountkey2.pem"
|
||||||
|
acme_version: 2
|
||||||
|
acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||||
|
state: present
|
||||||
|
allow_creation: no
|
||||||
|
ignore_errors: yes
|
||||||
|
register: account_not_created_2
|
||||||
|
|
||||||
|
- name: Do not try to create account III
|
||||||
|
acme_account:
|
||||||
|
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||||
|
acme_version: 2
|
||||||
|
acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||||
|
state: present
|
||||||
|
allow_creation: no
|
||||||
|
ignore_errors: yes
|
||||||
|
register: account_not_created_3
|
||||||
|
|
||||||
|
# Old 0.9.8 versions have insufficient CLI support for signing with EC keys
|
||||||
|
when: openssl_version.stdout is version('1.0.0', '>=')
|
51
test/integration/targets/acme_account/tests/validate.yml
Normal file
51
test/integration/targets/acme_account/tests/validate.yml
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
---
|
||||||
|
- name: Validate that account wasn't created in the first step
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- account_not_created is failed
|
||||||
|
|
||||||
|
- name: Validate that account was created in the second step
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- account_created is changed
|
||||||
|
- account_created.account_uri is not none
|
||||||
|
|
||||||
|
- name: Validate that email address was changed
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- account_modified is changed
|
||||||
|
- account_modified.account_uri is not none
|
||||||
|
|
||||||
|
- name: Validate that email address was not changed a second time (idempotency)
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- account_modified_idempotent is not changed
|
||||||
|
- account_modified_idempotent.account_uri is not none
|
||||||
|
|
||||||
|
- name: Validate that the account key was changed
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- account_change_key is changed
|
||||||
|
- account_change_key.account_uri is not none
|
||||||
|
|
||||||
|
- name: Validate that the account was deactivated
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- account_deactivate is changed
|
||||||
|
- account_deactivate.account_uri is not none
|
||||||
|
|
||||||
|
- name: Validate that the account was really deactivated (idempotency)
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- account_deactivate_idempotent is not changed
|
||||||
|
- account_deactivate_idempotent.account_uri is not none
|
||||||
|
|
||||||
|
- name: Validate that the account is gone (new account key)
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- account_not_created_2 is failed
|
||||||
|
|
||||||
|
- name: Validate that the account is gone (old account key)
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- account_not_created_3 is failed
|
Loading…
Reference in a new issue