Commit 5ff96857 authored by Adrien Dorsaz's avatar Adrien Dorsaz

Merge branch 'v2' into 'master'

V2 : Support of Let's Encrypt API v2

See merge request !12
parents 4e83caf6 d952255e
Pipeline #206 failed with stage
in 2 minutes and 59 seconds
#!/usr/bin/env python3
import os, argparse, subprocess, json, sys, base64, binascii, time, hashlib, re, copy, textwrap, logging
import argparse, subprocess, requests, json, sys, base64, binascii, time, hashlib, re, copy, logging, configparser
import dns.resolver, dns.tsigkeyring, dns.update
from configparser import ConfigParser
from urllib.request import urlopen
from urllib.error import HTTPError
LOGGER = logging.getLogger('acme_dns_tiny')
LOGGER.addHandler(logging.StreamHandler())
LOGGER.setLevel(logging.INFO)
def get_crt(config, log=LOGGER):
# helper function base64 encode as defined in acme spec
......@@ -40,35 +36,40 @@ def get_crt(config, log=LOGGER):
nonlocal jws_nonce
payload64 = _b64(json.dumps(payload).encode("utf8"))
protected = copy.deepcopy(jws_header)
protected["nonce"] = jws_nonce or urlopen(config["acmednstiny"]["ACMEDirectory"]).getheader("Replay-Nonce", None)
protected["nonce"] = jws_nonce or requests.get(acme_config["newNonce"]).headers['Replay-Nonce']
protected["url"] = url
if url == acme_config["newAccount"]:
del protected["kid"]
else:
del protected["jwk"]
protected64 = _b64(json.dumps(protected).encode("utf8"))
signature = _openssl("dgst", ["-sha256", "-sign", config["acmednstiny"]["AccountKeyFile"]],
"{0}.{1}".format(protected64, payload64).encode("utf8"))
data = json.dumps({
"header": jws_header, "protected": protected64,
"payload": payload64, "signature": _b64(signature),
})
jose = {
"protected": protected64, "payload": payload64,"signature": _b64(signature)
}
try:
resp = urlopen(url, data.encode("utf8"))
except HTTPError as httperror:
resp = httperror
resp = requests.post(url, json=jose, headers=joseheaders)
except requests.exceptions.RequestException as error:
resp = error.response
finally:
jws_nonce = resp.getheader("Replay-Nonce", None)
return resp.getcode(), resp.read(), resp.getheaders()
# helper function to get url from Link HTTP headers
def _get_url_link(headers, rel):
log.info("Looking for Link with rel='{0}' in headers".format(rel))
linkheaders = [link.strip() for link in dict(headers)["Link"].split(',')]
url = [re.match(r'<(?P<url>.*)>.*;rel=(' + re.escape(rel) + r'|("([a-z][a-z0-9\.\-]*\s+)*' + re.escape(rel) + r'[\s"]))', link).groupdict()
for link in linkheaders][0]["url"]
return url
jws_nonce = resp.headers['Replay-Nonce']
if resp.text != '':
return resp.status_code, resp.json(), resp.headers
else:
return resp.status_code, json.dumps({}), resp.headers
# main code
log.info("Read ACME directory.")
directory = urlopen(config["acmednstiny"]["ACMEDirectory"])
acme_config = json.loads(directory.read().decode("utf8"))
current_terms = acme_config.get("meta", {}).get("terms-of-service")
adtheaders = {'User-Agent': 'acme-dns-tiny/2.0',
'Accept-Language': config["acmednstiny"].get("Language", "en")
}
joseheaders=copy.deepcopy(adtheaders)
joseheaders['Content-Type']='application/jose+json'
log.info("Fetch informations from the ACME directory.")
directory = requests.get(config["acmednstiny"]["ACMEDirectory"], headers=adtheaders)
acme_config = directory.json()
terms_service = acme_config.get("meta", {}).get("termsOfService", "")
log.info("Prepare DNS keyring and resolver.")
keyring = dns.tsigkeyring.from_text({config["TSIGKeyring"]["KeyName"]: config["TSIGKeyring"]["KeyValue"]})
......@@ -79,12 +80,12 @@ def get_crt(config, log=LOGGER):
nameserver = [ipv4_rrset.to_text() for ipv4_rrset in dns.resolver.query(config["DNS"]["Host"], rdtype="A")]
nameserver = nameserver + [ipv6_rrset.to_text() for ipv6_rrset in dns.resolver.query(config["DNS"]["Host"], rdtype="AAAA")]
except dns.exception.DNSException as e:
log.info("A and/or AAAA DNS resources not found for configured dns host: we will use either resource found if exists or directly the DNS Host configuration.")
log.info("A and/or AAAA DNS resources not found for configured dns host: we will use either resource found if one exists or directly the DNS Host configuration.")
if not nameserver:
nameserver = [config["DNS"]["Host"]]
resolver.nameservers = nameserver
log.info("Parsing account key looking for public key.")
log.info("Read account key.")
accountkey = _openssl("rsa", ["-in", config["acmednstiny"]["AccountKeyFile"], "-noout", "-text"])
pub_hex, pub_exp = re.search(
r"modulus:\r?\n\s+00:([a-f0-9\:\s]+?)\r?\npublicExponent: ([0-9]+)",
......@@ -98,15 +99,16 @@ def get_crt(config, log=LOGGER):
"kty": "RSA",
"n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))),
},
"kid": None,
}
accountkey_json = json.dumps(jws_header["jwk"], sort_keys=True, separators=(",", ":"))
thumbprint = _b64(hashlib.sha256(accountkey_json.encode("utf8")).digest())
jws_nonce = None
log.info("Parsing CSR looking for domains.")
log.info("Read CSR to find domains to validate.")
csr = _openssl("req", ["-in", config["acmednstiny"]["CSRFile"], "-noout", "-text"]).decode("utf8")
domains = set([])
common_name = re.search(r"Subject:.*? CN=([^\s,;/]+)", csr)
common_name = re.search(r"Subject:\s*?CN\s*?=\s*?([^\s,;/]+)", csr)
if common_name is not None:
domains.add(common_name.group(1))
subject_alt_names = re.search(r"X509v3 Subject Alternative Name: \r?\n +([^\r\n]+)\r?\n", csr, re.MULTILINE | re.DOTALL)
......@@ -114,180 +116,199 @@ def get_crt(config, log=LOGGER):
for san in subject_alt_names.group(1).split(", "):
if san.startswith("DNS:"):
domains.add(san[4:])
if len(domains) == 0:
raise ValueError("Didn't find any domain to validate in the provided CSR.")
log.info("Registering ACME Account.")
reg_info = {"resource": "new-reg"}
if current_terms is not None:
reg_info["agreement"] = current_terms
reg_info["contact"] = []
reg_mailto = "mailto:{0}".format(config["acmednstiny"].get("MailContact"))
reg_phone = "tel:{0}".format(config["acmednstiny"].get("PhoneContact"))
if config["acmednstiny"].get("MailContact") is not None:
reg_info["contact"].append(reg_mailto)
if config["acmednstiny"].get("PhoneContact") is not None:
reg_info["contact"].append(reg_phone)
if len(reg_info["contact"]) == 0:
del reg_info["contact"]
log.info("Register ACME Account.")
account_request = {}
if terms_service != "":
account_request["termsOfServiceAgreed"] = True
log.warning("Terms of service exists and will be automatically agreed, please read them: {0}".format(terms_service))
account_request["contact"] = config["acmednstiny"].get("Contacts", "").split(';')
if account_request["contact"] == "":
del account_request["contact"]
code, result, headers = _send_signed_request(acme_config["new-reg"], reg_info)
code, result, headers = _send_signed_request(acme_config["newAccount"], account_request)
account_info = {}
if code == 201:
account_url = dict(headers).get("Location")
log.info("Registered! (account: '{0}')".format(account_url))
reg_received_contact = reg_info.get("contact")
elif code == 409:
account_url = dict(headers).get("Location")
log.info("Already registered! (account: '{0}')".format(account_url))
# Client should send empty payload to query account information
code, result, headers = _send_signed_request(account_url, {"resource":"reg"})
account_info = json.loads(result.decode("utf8"))
reg_info["agreement"] = account_info.get("agreement")
reg_received_contact = account_info.get("contact")
jws_header["kid"] = headers['Location']
log.info(" - Registered a new account: '{0}'".format(jws_header["kid"]))
account_info = result
elif code == 200:
jws_header["kid"] = headers['Location']
log.debug(" - Account is already registered: '{0}'".format(jws_header["kid"]))
code, result, headers = _send_signed_request(jws_header["kid"], {})
account_info = result
else:
raise ValueError("Error registering: {0} {1}".format(code, result))
raise ValueError("Error registering account: {0} {1}".format(code, result))
log.info("Update contact information and terms of service agreement if needed.")
if current_terms is None:
current_terms = _get_url_link(headers, 'terms-of-service')
if (reg_info.get("agreement") != current_terms
or reg_mailto not in reg_received_contact
or reg_phone not in reg_received_contact):
reg_info["resource"] = "reg"
reg_info["agreement"] = current_terms
code, result, headers = _send_signed_request(account_url, reg_info)
if code == 202:
log.info("Account updated (terms of service agreed: '{0}')".format(reg_info.get("agreement")))
log.info("Update contact information if needed.")
if (set(account_request["contact"]) != set(account_info["contact"])):
code, result, headers = _send_signed_request(jws_header["kid"], account_request)
if code == 200:
log.debug(" - Account updated with latest contact informations.")
else:
raise ValueError("Error register update: {0} {1}".format(code, result))
raise ValueError("Error registering updates for the account: {0} {1}".format(code, result))
# verify each domain
for domain in domains:
log.info("Verifying domain: {0}".format(domain))
# new order
log.info("Request to the ACME server an order to validate domains.")
new_order = { "identifiers": [{"type": "dns", "value": domain} for domain in domains]}
code, result, headers = _send_signed_request(acme_config["newOrder"], new_order)
order = result
if code == 201:
order_location = headers['Location']
log.debug(" - Order received: {0}".format(order_location))
if order["status"] != "pending":
raise ValueError("Order status is not pending, we can't use it: {0}".format(order))
elif (code == 403
and order["type"] == "urn:ietf:params:acme:error:userActionRequired"):
raise ValueError("Order creation failed ({0}). Read Terms of Service ({1}), then follow your CA instructions: {2}".format(order["detail"], headers['Link'], order["instance"]))
else:
raise ValueError("Error getting new Order: {0} {1}".format(code, result))
# complete each authorization challenge
for authz in order["authorizations"]:
log.info("Process challenge for authorization: {0}".format(authz))
# get new challenge
code, result, headers = _send_signed_request(acme_config["new-authz"], {
"resource": "new-authz",
"identifier": {"type": "dns", "value": domain},
})
if code != 201:
raise ValueError("Error requesting challenges: {0} {1}".format(code, result))
resp = requests.get(authz, headers=adtheaders)
authorization = resp.json()
if resp.status_code != 200:
raise ValueError("Error fetching challenges: {0} {1}".format(resp.status_code, authorization))
domain = authorization["identifier"]["value"]
log.info("Create and install DNS TXT challenge resource.")
challenge = [c for c in json.loads(result.decode("utf8"))["challenges"] if c["type"] == "dns-01"][0]
log.info("Install DNS TXT resource for domain: {0}".format(domain))
challenge = [c for c in authorization["challenges"] if c["type"] == "dns-01"][0]
token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge["token"])
keyauthorization = "{0}.{1}".format(token, thumbprint)
keydigest64 = _b64(hashlib.sha256(keyauthorization.encode("utf8")).digest())
dnsrr_domain = "_acme-challenge.{0}.".format(domain)
dnsrr_set = dns.rrset.from_text(dnsrr_domain, 300, "IN", "TXT", '"{0}"'.format(keydigest64))
try: # a CNAME resource can be used for advanced TSIG configuration
# Note: the CNAME target has to be of "non-CNAME" type to be able to add TXT records aside it
dnsrr_domain = [response.to_text() for response in resolver.query(dnsrr_domain, rdtype="CNAME")][0]
log.info(" - A CNAME resource has been found for this domain, will install TXT on {0}".format(dnsrr_domain))
except dns.exception.DNSException as dnsexception:
log.debug(" - Not any CNAME resource has been found for this domain ({1}), will install TXT directly on {0}".format(dnsrr_domain, type(dnsexception).__name__))
dnsrr_set = dns.rrset.from_text(dnsrr_domain, config["DNS"].getint("TTL"), "IN", "TXT", '"{0}"'.format(keydigest64))
try:
_update_dns(dnsrr_set, "add")
except dns.exception.DNSException as dnsexception:
raise ValueError("Error updating DNS records: {0} : {1}".format(type(dnsexception).__name__, str(dnsexception)))
log.info("Wait {0} then start self challenge checks.".format(config["acmednstiny"].getint("CheckChallengeDelay")))
time.sleep(config["acmednstiny"].getint("CheckChallengeDelay"))
log.info("Waiting for 1 TTL ({0} seconds) before starting self challenge check.".format(config["DNS"].getint("TTL")))
time.sleep(config["DNS"].getint("TTL"))
challenge_verified = False
number_check_fail = 1
while challenge_verified is False:
try:
log.info('Try {0}: Check ressource with value "{1}" exits on nameservers: {2}'.format(number_check_fail, keydigest64, resolver.nameservers))
challenges = resolver.query(dnsrr_domain, rdtype="TXT")
for response in challenges.rrset:
log.info(".. Found value {0}".format(response.to_text()))
log.debug('Self test (try: {0}): Check resource with value "{1}" exits on nameservers: {2}'.format(number_check_fail, keydigest64, resolver.nameservers))
for response in resolver.query(dnsrr_domain, rdtype="TXT").rrset:
log.debug(" - Found value {0}".format(response.to_text()))
challenge_verified = challenge_verified or response.to_text() == '"{0}"'.format(keydigest64)
except dns.exception.DNSException as dnsexception:
log.info("Info: retry, because a DNS error occurred while checking challenge: {0} : {1}".format(type(dnsexception).__name__, dnsexception))
log.debug(" - Will retry as a DNS error occurred while checking challenge: {0} : {1}".format(type(dnsexception).__name__, dnsexception))
finally:
if number_check_fail >= 10:
raise ValueError("Error checking challenge, value not found: {0}".format(keydigest64))
if challenge_verified is False:
if number_check_fail >= 10:
raise ValueError("Error checking challenge, value not found: {0}".format(keydigest64))
number_check_fail = number_check_fail + 1
time.sleep(2)
time.sleep(config["DNS"].getint("TTL"))
log.info("Ask ACME server to perform checks.")
code, result, headers = _send_signed_request(challenge["uri"], {
"resource": "challenge",
"keyAuthorization": keyauthorization,
})
if code != 202:
log.info("Asking ACME server to validate challenge.")
code, result, headers = _send_signed_request(challenge["url"], {"keyAuthorization": keyauthorization})
if code != 200:
raise ValueError("Error triggering challenge: {0} {1}".format(code, result))
log.info("Waiting challenge to be verified.")
try:
while True:
try:
resp = urlopen(challenge["uri"])
challenge_status = json.loads(resp.read().decode("utf8"))
except IOError as e:
raise ValueError("Error checking challenge: {0} {1}".format(
e.code, json.loads(e.read().decode("utf8"))))
resp = requests.get(challenge["url"], headers=adtheaders)
challenge_status = resp.json()
except requests.exceptions.RequestException as error:
raise ValueError("Error during challenge validation: {0} {1}".format(
error.response.status_code, error.response.text()))
if challenge_status["status"] == "pending":
time.sleep(2)
elif challenge_status["status"] == "valid":
log.info("Domain {0} verified!".format(domain))
log.info("ACME has verified challenge for domain: {0}".format(domain))
break
else:
raise ValueError("{0} challenge did not pass: {1}".format(
raise ValueError("Challenge for domain {0} did not pass: {1}".format(
domain, challenge_status))
finally:
_update_dns(dnsrr_set, "delete")
log.info("Ask to sign certificate.")
csr_der = _openssl("req", ["-in", config["acmednstiny"]["CSRFile"], "-outform", "DER"])
code, result, headers = _send_signed_request(acme_config["new-cert"], {
"resource": "new-cert",
"csr": _b64(csr_der),
})
if code != 201:
raise ValueError("Error signing certificate: {0} {1}".format(code, result))
certificate = os.linesep.join(textwrap.wrap(base64.b64encode(result).decode("utf8"), 64))
log.info("Request to finalize the order (all chalenge have been completed)")
resp = requests.get(order_location, headers=adtheaders)
finalize = resp.json()
csr_der = _b64(_openssl("req", ["-in", config["acmednstiny"]["CSRFile"], "-outform", "DER"]))
code, result, headers = _send_signed_request(order["finalize"], {"csr": csr_der})
if code != 200:
raise ValueError("Error while sending the CSR: {0} {1}".format(code, result))
while True:
try:
resp = requests.get(order_location, headers=adtheaders)
resp.raise_for_status()
finalize = resp.json()
except requests.exceptions.RequestException as error:
raise ValueError("Error finalizing order: {0} {1}".format(
error.response.status_code, error.response.text()))
# get the parent certificate which had created this one
certificate_parent_url = _get_url_link(headers, 'up')
resp = urlopen(certificate_parent_url)
if resp.getcode() not in [200, 201]:
raise ValueError("Error getting certificate chain from {0}: {1} {2}".format(
certificate_parent_url, code, resp.read()))
intermediary_certificate = os.linesep.join(textwrap.wrap(base64.b64encode(resp.read()).decode("utf8"), 64))
if finalize["status"] == "processing":
if resp.headers["Retry-After"]:
time.sleep(resp.headers["Retry-After"])
else:
time.sleep(2)
elif finalize["status"] == "valid":
log.info("Order finalized!")
break
else:
raise ValueError("Finalizing order {0} got errors: {1}".format(
domain, finalize))
chainlist = ["-----BEGIN CERTIFICATE-----{0}{1}{0}-----END CERTIFICATE-----{0}".format(
os.linesep, cert) for cert in [certificate, intermediary_certificate]]
resp = requests.get(finalize["certificate"], headers=adtheaders)
if resp.status_code != 200:
raise ValueError("Finalizing order {0} got errors: {1}".format(
resp.status_code, resp.json()))
certchain = resp.text
log.info("Certificate signed and received.")
return "".join(chainlist)
log.info("Certificate signed and chain received: {0}".format(finalize["certificate"]))
return certchain
def main(argv):
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description="""
This script automates the process of getting a signed TLS certificate
chain from Let's Encrypt using the ACME protocol and its DNS verification.
It will need to have access to your private account key and dns server
so PLEASE READ THROUGH IT!
It's around 300 lines, so it won't take long.
description="Tiny ACME client to get TLS certificate by responding to DNS challenges.",
epilog="""As the script requires access to your private ACME account key and dns server,
so PLEASE READ THROUGH IT (it's about 300 lines, so it won't take long) !
===Example Usage===
python3 acme_dns_tiny.py ./example.ini > chain.crt
See example.ini file to configure correctly this script.
===================
"""
Example: requests certificate chain and store it in chain.crt
python3 acme_dns_tiny.py ./example.ini > chain.crt
See example.ini file to configure correctly this script."""
)
parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="suppress output except for errors")
parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="show only errors on stderr")
parser.add_argument("--verbose", action="store_const", const=logging.DEBUG, help="show all debug informations on stderr")
parser.add_argument("--csr", help="specifies CSR file path to use instead of the CSRFile option from the configuration file.")
parser.add_argument("configfile", help="path to your configuration file")
args = parser.parse_args(argv)
config = ConfigParser()
config.read_dict({"acmednstiny": {"ACMEDirectory": "https://acme-staging.api.letsencrypt.org/directory",
"CheckChallengeDelay": 2},
"DNS": {"Port": "53"}})
config = configparser.ConfigParser()
config.read_dict({"acmednstiny": {"ACMEDirectory": "https://acme-staging-v02.api.letsencrypt.org/directory"},
"DNS": {"Port": 53,
"TTL": 10}})
config.read(args.configfile)
if (set(["accountkeyfile", "csrfile", "acmedirectory", "checkchallengedelay"]) - set(config.options("acmednstiny"))
if args.csr :
config.set("acmednstiny", "csrfile", args.csr)
if (set(["accountkeyfile", "csrfile", "acmedirectory"]) - set(config.options("acmednstiny"))
or set(["keyname", "keyvalue", "algorithm"]) - set(config.options("TSIGKeyring"))
or set(["zone", "host", "port"]) - set(config.options("DNS"))):
or set(["zone", "host", "port", "ttl"]) - set(config.options("DNS"))):
raise ValueError("Some required settings are missing.")
LOGGER.setLevel(args.quiet or LOGGER.level)
LOGGER.setLevel(args.verbose or args.quiet or logging.INFO)
signed_crt = get_crt(config, log=LOGGER)
sys.stdout.write(signed_crt)
......
[acmednstiny]
# Required readable ACME account key
AccountKeyFile = account.key
# Required readable CSR file
# Note: if you use the "--csr" optional argument, this setting is not read and can be omitted
CSRFile = domain.csr
# Optional ACME directory url (default: https://acme-staging.api.letsencrypt.org/directory)
ACMEDirectory = https://acme-staging.api.letsencrypt.org/directory
# Optional time in seconds to wait between DNS update and challenge check (default: 3)
CheckChallengeDelay = 3
# Optional Contact info to send to the ACME provider
MailContact = mail@example.com
# Note that Let's Encrypt servers disallow use of phone numbers
PhoneContact = +11111111111
# Optional ACME directory url
# Default: https://acme-staging-v02.api.letsencrypt.org/directory
ACMEDirectory = https://acme-staging-v02.api.letsencrypt.org/directory
# Optional To be able to be reached by ACME provider (e.g. to warn about
# certificate expicration), you can provide some contact informations.
# Contacts setting is a list of contact URI separated by semicolon (;).
# If ACME provider support contact informations, it must at least support mailto
# URI and can support more of contact.
# For the mailto URI, the email address part must contains only one address
# without header fields (see [RFC6068]).
# Default: none
Contacts = mailto:mail@example.com;mailto:mail2@example.org
# Optional to give hint to the ACME server about your prefered language for errors given by their server
# See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language for more informations
# Default: en
Language = en
[TSIGKeyring]
# Required TSIG key name
KeyName = host-example
# Required TSIG key value in base64
KeyValue = XXXXXXXXXXX==
# Required TSIG algorithm
Algorithm = hmac-sha256
[DNS]
# Required name of zone to update
Zone = dnszone
# Required name or IP of DNS server
Host = dnsserver
# Optional port to connect on DNS server (default: 53)
Port = 53
# Optional time to live (TTL) value used to add DNS entries
# For each domain registered in the CSR, at least 1 TTL is waited before certificate creation.
# If an error occurs while looking for TXT records, we wait up to 10 TTLs by domain.
# That's why the default is only of 10 seconds, to avoid having too long time to wait to receive a new certificate.
# Default: 10 seconds
TTL = 10
FROM debian:jessie-backports
RUN apt-get update
RUN apt-get upgrade -y
# Minimal tools required by acme-dns-tiny CI
RUN apt-get install -y \
python3-coverage \
python3-pip
RUN apt-get install -y \
-t jessie-backports \
python3-configargparse \
python3-dnspython
# Allows run python3-coverage with same command than manual install by pip
RUN update-alternatives --install \
/usr/bin/coverage \
coverage \
/usr/bin/python3-coverage \
1
FROM debian:jessie
RUN apt-get update
RUN apt-get upgrade -y
# Minimal tools required by acme-dns-tiny CI
RUN apt-get install -y \
python3-dnspython \
python3-coverage \
python3-pip
# Allows run python3-coverage with same command than manual install by pip
RUN update-alternatives --install \
/usr/bin/python3-coverage \
coverage \
/usr/bin/python3.4-coverage \
1
RUN ln -s /etc/alternatives/coverage /usr/bin/coverage
FROM debian:stretch
RUN apt-get update
RUN apt-get upgrade -y
# Minimal tools required by acme-dns-tiny CI
RUN apt-get install -y \
python3-dnspython \
python3-coverage \
python3-configargparse \
python3-pip
# Allows run python3-coverage with same command than manual install by pip
RUN update-alternatives --install \
/usr/bin/coverage \
coverage \
/usr/bin/python3-coverage \
1
after_script:
- sleep 10
jessie:
image: adt-jessie_dnspython3_1.11
before_script:
- pip3 install --upgrade -r tests/requirements.txt
script:
- coverage run --source ./ -m unittest -v tests.test_acme_dns_tiny tests.test_acme_account_rollover tests.test_acme_account_delete
- coverage report --include=acme_dns_tiny.py,tools/acme_account_rollover.py,tools/acme_account_delete.py
- coverage run --source ./ -m unittest -v tests.test_acme_dns_tiny tests.test_acme_account_rollover tests.test_acme_account_deactivate
- coverage report --include=acme_dns_tiny.py,tools/acme_account_rollover.py,tools/acme_account_deactivate.py
- coverage html
jessie_backport:
......@@ -15,6 +12,18 @@ jessie_backport:
before_script:
- pip3 install --upgrade -r tests/requirements.txt
script:
- coverage run --source ./ -m unittest -v tests.test_acme_dns_tiny tests.test_acme_account_rollover tests.test_acme_account_delete
- coverage report --include=acme_dns_tiny.py,tools/acme_account_rollover.py,tools/acme_account_delete.py
- coverage run --source ./ -m unittest -v tests.test_acme_dns_tiny tests.test_acme_account_rollover tests.test_acme_account_deactivate
- coverage report --include=acme_dns_tiny.py,tools/acme_account_rollover.py,tools/acme_account_deactivate.py
- coverage html
stretch:
image: adt-stretch_dnspython3_1.15
before_script:
- pip3 install --upgrade -r tests/requirements.txt
script:
- coverage run --source ./ -m unittest -v tests.test_acme_dns_tiny tests.test_acme_account_rollover tests.test_acme_account_deactivate
- coverage report --include=acme_dns_tiny.py,tools/acme_account_rollover.py,tools/acme_account_deactivate.py
- coverage html
artifacts:
paths:
- htmlcov
......@@ -21,7 +21,7 @@ explains how to setup and test acme-tiny yourself.
## List of environment variables
* `GITLABCI_CAURL`: URL of a staging ACME server
* `GITLABCI_ACMEDIRECTORY_V2`: URL of a staging V2 ACME server
* `GITLABCI_CHALLENGEDELAY`: time to wait between dns update and self-check (set it to `0` to cover a bit more code)
* `GITLABCI_DNSHOST`: domain name to reach of your DNS server (e.g. `adorsaz.ch`)
* `GITLABCI_DNSHOSTIP`: IP address to reach of your DNS server
......
......@@ -4,97 +4,136 @@ from subprocess import Popen
# domain with server.py running on it for testing
DOMAIN = os.getenv("GITLABCI_DOMAIN")
ACMEDIRECTORY = os.getenv("GITLABCI_ACMEDIRECTORY", "https://acme-staging.api.letsencrypt.org/directory")
CHALLENGEDELAY = os.getenv("GITLABCI_CHALLENGEDELAY", "3")
ACMEDIRECTORY = os.getenv("GITLABCI_ACMEDIRECTORY_V2", "https://acme-staging-v02.api.letsencrypt.org/directory")
DNSHOST = os.getenv("GITLABCI_DNSHOST")
DNSHOSTIP = os.getenv("GITLABCI_DNSHOSTIP")
DNSZONE = os.getenv("GITLABCI_DNSZONE")
DNSPORT = os.getenv("GITLABCI_DNSPORT", "53")
DNSTTL = os.getenv("GITLABCI_DNSTTL", "10")
TSIGKEYNAME = os.getenv("GITLABCI_TSIGKEYNAME")
TSIGKEYVALUE = os.getenv("GITLABCI_TSIGKEYVALUE")
TSIGALGORITHM = os.getenv("GITLABCI_TSIGALGORITHM")
# generate simple config
def generate_config():
# Account key
account_key = NamedTemporaryFile(delete=False)
Popen(["openssl", "genrsa", "-out", account_key.name, "2048"]).wait()
# Domain key and CSR
domain_key = NamedTemporaryFile(delete=False)
domain_csr = NamedTemporaryFile(delete=False)
Popen(["openssl", "req", "-newkey", "rsa:2048", "-nodes", "-keyout", domain_key.name,
"-subj", "/CN={0}".format(DOMAIN), "-out", domain_csr.name]).wait()
# acme-dns-tiny configuration
parser = configparser.ConfigParser()
parser.read("./example.ini")
parser["acmednstiny"]["AccountKeyFile"] = account_key.name
parser["acmednstiny"]["CSRFile"] = domain_csr.name
parser["acmednstiny"]["ACMEDirectory"] = ACMEDIRECTORY
parser["acmednstiny"]["Contacts"] = "mailto:mail@example.com"
parser["TSIGKeyring"]["KeyName"] = TSIGKEYNAME
parser["TSIGKeyring"]["KeyValue"] = TSIGKEYVALUE
parser["TSIGKeyring"]["Algorithm"] = TSIGALGORITHM
parser["DNS"]["Host"] = DNSHOST
parser["DNS"]["Port"] = DNSPORT
parser["DNS"]["Zone"] = DNSZONE
parser["DNS"]["TTL"] = DNSTTL
config = NamedTemporaryFile(delete=False)
with open(config.name, 'w') as configfile:
parser.write(configfile)
return account_key.name, domain_key.name, domain_csr.name, config.name
# generate account and domain keys
def generate_acme_dns_tiny_config():
# good account key
account_key = NamedTemporaryFile()
Popen(["openssl", "genrsa", "-out", account_key.name, "2048"]).wait()
# Simple good configuration
account_key, domain_key, domain_csr, goodCName = generate_config();
# CSR for good configuration with wildcard domain
wilddomain_csr = NamedTemporaryFile(delete=False)
Popen(["openssl", "req", "-newkey", "rsa:2048", "-nodes", "-keyout", domain_key,
"-subj", "/CN=*.{0}".format(DOMAIN), "-out", wilddomain_csr.name]).wait()
# weak 1024 bit account key
weak_key = NamedTemporaryFile()
weak_key = NamedTemporaryFile(delete=False)
Popen(["openssl", "genrsa", "-out", weak_key.name, "1024"]).wait()
# good domain key
domain_key = NamedTemporaryFile()
domain_csr = NamedTemporaryFile()
Popen(["openssl", "req", "-newkey", "rsa:2048", "-nodes", "-keyout", domain_key.name,
"-subj", "/CN={0}".format(DOMAIN), "-out", domain_csr.name]).wait()
# subject alt-name domain
san_csr = NamedTemporaryFile()
san_conf = NamedTemporaryFile()
# CSR using subject alt-name domain instead of CN (common name)
san_csr = NamedTemporaryFile(delete=False)
san_conf = NamedTemporaryFile(delete=False)
san_conf.write(open("/etc/ssl/openssl.cnf").read().encode("utf8"))
san_conf.write("\n[SAN]\nsubjectAltName=DNS:{0},DNS:www.{0}\n".format(DOMAIN).encode("utf8"))
san_conf.seek(0)
Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key.name,
Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key,
"-subj", "/", "-reqexts", "SAN", "-config", san_conf.name,
"-out", san_csr.name]).wait()