acme_dns_tiny.py 15.1 KB
Newer Older
1
#!/usr/bin/env python3
2
import os, argparse, subprocess, json, sys, base64, binascii, time, hashlib, re, copy, textwrap, logging
3
import dns.resolver, dns.tsigkeyring, dns.update
4
from configparser import ConfigParser
5
import urllib.request
6
from urllib.error import HTTPError
7

Adrien Dorsaz's avatar
Adrien Dorsaz committed
8
LOGGER = logging.getLogger('acme_dns_tiny')
9 10 11
LOGGER.addHandler(logging.StreamHandler())
LOGGER.setLevel(logging.INFO)

12
def get_crt(config, log=LOGGER):
Adrien Dorsaz's avatar
Adrien Dorsaz committed
13
    # helper function base64 encode as defined in acme spec
Daniel Roesler's avatar
Daniel Roesler committed
14
    def _b64(b):
Adrien Dorsaz's avatar
Adrien Dorsaz committed
15
        return base64.urlsafe_b64encode(b).decode("utf8").rstrip("=")
16

17 18 19 20 21 22 23 24
    # helper function to run openssl command
    def _openssl(command, options, communicate=None):
        openssl = subprocess.Popen(["openssl", command] + options,
                                   stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        out, err = openssl.communicate(communicate)
        if openssl.returncode != 0:
            raise IOError("OpenSSL Error: {0}".format(err))
        return out
25

26
    # helper function to send DNS dynamic update messages
27
    def _update_dns(rrset, action):
28
        algorithm = dns.name.from_text("{0}".format(config["TSIGKeyring"]["Algorithm"].lower()))
29
        dns_update = dns.update.Update(config["DNS"]["zone"], keyring=keyring, keyalgorithm=algorithm)
30 31 32 33
        if action == "add":
            dns_update.add(rrset.name, rrset)
        elif action == "delete":
            dns_update.delete(rrset.name, rrset)
34
        resp = dns.query.tcp(dns_update, config["DNS"]["Host"], port=config.getint("DNS", "Port"))
35 36 37
        dns_update = None
        return resp

Adrien Dorsaz's avatar
Adrien Dorsaz committed
38
    # helper function to send signed requests
39
    def _send_signed_request(url, payload):
40
        nonlocal jws_nonce
41
        payload64 = _b64(json.dumps(payload).encode("utf8"))
42
        protected = copy.deepcopy(jws_header)
43
        protected["nonce"] = jws_nonce or webclient.open(acme_config["newNonce"]).getheader("Replay-Nonce", None)
44
        protected["url"] = url
45
        if url == acme_config["newAccount"]:
46 47 48
            del protected["kid"]
        else:
            del protected["jwk"]
49
        protected64 = _b64(json.dumps(protected).encode("utf8"))
50 51
        signature = _openssl("dgst", ["-sha256", "-sign", config["acmednstiny"]["AccountKeyFile"]],
                             "{0}.{1}".format(protected64, payload64).encode("utf8"))
52
        data = json.dumps({
53
            "protected": protected64, "payload": payload64,"signature": _b64(signature)
54 55
        })
        try:
56
            resp = webclient.open(url, data.encode("utf8"))
57
        except HTTPError as httperror:
58 59 60 61
            resp = httperror
        finally:
            jws_nonce = resp.getheader("Replay-Nonce", None)
            return resp.getcode(), resp.read(), resp.getheaders()
62

63
    # main code
64
    webclient = urllib.request.build_opener()
Adrien Dorsaz's avatar
Adrien Dorsaz committed
65 66
    webclient.addheaders = [('User-Agent', 'acme-dns-tiny/2.0'), ('Accept-Language', config["acmednstiny"].get("Language", "en"))]

Adrien Dorsaz's avatar
Adrien Dorsaz committed
67
    log.info("Fetch informations from the ACME directory.")
68
    directory = webclient.open(config["acmednstiny"]["ACMEDirectory"])
69
    acme_config = json.loads(directory.read().decode("utf8"))
70
    terms_service = acme_config.get("meta", {}).get("termsOfService", "")
71

72
    log.info("Prepare DNS keyring and resolver.")
73
    keyring = dns.tsigkeyring.from_text({config["TSIGKeyring"]["KeyName"]: config["TSIGKeyring"]["KeyValue"]})
74 75
    resolver = dns.resolver.Resolver(configure=False)
    resolver.retry_servfail = True
76
    nameserver = []
77 78
    try:
        nameserver = [ipv4_rrset.to_text() for ipv4_rrset in dns.resolver.query(config["DNS"]["Host"], rdtype="A")]
79
        nameserver = nameserver + [ipv6_rrset.to_text() for ipv6_rrset in dns.resolver.query(config["DNS"]["Host"], rdtype="AAAA")]
80
    except dns.exception.DNSException as e:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
81
        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.")
82 83
    if not nameserver:
        nameserver = [config["DNS"]["Host"]]
84
    resolver.nameservers = nameserver
Daniel Roesler's avatar
Daniel Roesler committed
85

Adrien Dorsaz's avatar
Adrien Dorsaz committed
86
    log.info("Read account key.")
87
    accountkey = _openssl("rsa", ["-in", config["acmednstiny"]["AccountKeyFile"], "-noout", "-text"])
Daniel Roesler's avatar
Daniel Roesler committed
88
    pub_hex, pub_exp = re.search(
89
        r"modulus:\r?\n\s+00:([a-f0-9\:\s]+?)\r?\npublicExponent: ([0-9]+)",
90
        accountkey.decode("utf8"), re.MULTILINE | re.DOTALL).groups()
Daniel Roesler's avatar
Daniel Roesler committed
91
    pub_exp = "{0:x}".format(int(pub_exp))
92
    pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp
93
    jws_header = {
Daniel Roesler's avatar
Daniel Roesler committed
94 95
        "alg": "RS256",
        "jwk": {
96
            "e": _b64(binascii.unhexlify(pub_exp.encode("utf-8"))),
Daniel Roesler's avatar
Daniel Roesler committed
97
            "kty": "RSA",
98
            "n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))),
Daniel Roesler's avatar
Daniel Roesler committed
99
        },
100
        "kid": None,
Daniel Roesler's avatar
Daniel Roesler committed
101
    }
102
    accountkey_json = json.dumps(jws_header["jwk"], sort_keys=True, separators=(",", ":"))
103
    thumbprint = _b64(hashlib.sha256(accountkey_json.encode("utf8")).digest())
104
    jws_nonce = None
Daniel Roesler's avatar
Daniel Roesler committed
105

Adrien Dorsaz's avatar
Adrien Dorsaz committed
106
    log.info("Read CSR to find domains to validate.")
107
    csr = _openssl("req", ["-in", config["acmednstiny"]["CSRFile"], "-noout", "-text"]).decode("utf8")
Daniel Roesler's avatar
Daniel Roesler committed
108
    domains = set([])
109
    common_name = re.search(r"Subject:\s*?CN\s*?=\s*?([^\s,;/]+)", csr)
Daniel Roesler's avatar
Daniel Roesler committed
110 111
    if common_name is not None:
        domains.add(common_name.group(1))
112
    subject_alt_names = re.search(r"X509v3 Subject Alternative Name: \r?\n +([^\r\n]+)\r?\n", csr, re.MULTILINE | re.DOTALL)
Daniel Roesler's avatar
Daniel Roesler committed
113 114 115 116
    if subject_alt_names is not None:
        for san in subject_alt_names.group(1).split(", "):
            if san.startswith("DNS:"):
                domains.add(san[4:])
117 118
    if len(domains) == 0:
        raise ValueError("Didn't find any domain to validate in the provided CSR.")
Daniel Roesler's avatar
Daniel Roesler committed
119

Adrien Dorsaz's avatar
Adrien Dorsaz committed
120
    log.info("Register ACME Account.")
121
    account_request = {}
122 123 124
    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))
125 126
    account_request["contact"] = config["acmednstiny"].get("Contacts", "").split(';')
    if account_request["contact"] == "":
127
        del account_request["contact"]
128

129
    code, result, headers = _send_signed_request(acme_config["newAccount"], account_request)
130
    account_info = {}
Daniel Roesler's avatar
Daniel Roesler committed
131
    if code == 201:
132
        jws_header["kid"] = dict(headers).get("Location")
133 134
        log.debug("  - Registered a new account: '{0}'".format(jws_header["kid"]))
        account_info = json.loads(result.decode("utf8"))
135
    elif code == 200:
136
        jws_header["kid"] = dict(headers).get("Location")
Adrien Dorsaz's avatar
Adrien Dorsaz committed
137
        log.debug("  - Account is already registered: '{0}'".format(jws_header["kid"]))
138

139
        code, result, headers = _send_signed_request(jws_header["kid"], {})
140
        account_info = json.loads(result.decode("utf8"))
Daniel Roesler's avatar
Daniel Roesler committed
141
    else:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
142
        raise ValueError("Error registering account: {0} {1}".format(code, result))
Daniel Roesler's avatar
Daniel Roesler committed
143

144
    log.info("Update contact information if needed.")
145 146
    if (set(account_request["contact"]) != set(account_info["contact"])):
        code, result, headers = _send_signed_request(jws_header["kid"], account_request)
147
        if code == 200:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
148
            log.debug("  - Account updated with latest contact informations.")
149
        else:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
150
            raise ValueError("Error registering updates for the account: {0} {1}".format(code, result))
151

152
    # new order
Adrien Dorsaz's avatar
Adrien Dorsaz committed
153
    log.info("Request to the ACME server an order to validate domains.")
154
    new_order = { "identifiers": [{"type": "dns", "value": domain} for domain in domains]}
155 156 157 158
    code, result, headers = _send_signed_request(acme_config["newOrder"], new_order)
    order = json.loads(result.decode("utf8"))
    if code == 201:
        order_location = dict(headers).get("Location")
Adrien Dorsaz's avatar
Adrien Dorsaz committed
159
        log.debug("  - Order received: {0}".format(order_location))
160 161
        if order["status"] != "pending":
            raise ValueError("Order status is not pending, we can't use it: {0}".format(order))
162 163
    elif (code == 403
        and order["type"] == "urn:ietf:params:acme:error:userActionRequired"):
164
        raise ValueError("Order creation failed ({0}). Read Terms of Service ({1}), then follow your CA instructions: {2}".format(order["detail"], dict(headers)["Link"], order["instance"]))
165 166
    else:
        raise ValueError("Error getting new Order: {0} {1}".format(code, result))
167

168 169
    # complete each authorization challenge
    for authz in order["authorizations"]:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
170
        log.info("Process challenge for authorization: {0}".format(authz))
Daniel Roesler's avatar
Daniel Roesler committed
171 172

        # get new challenge
173
        resp = webclient.open(authz)
174 175
        authorization = json.loads(resp.read().decode("utf8"))
        if resp.getcode() != 200:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
176
            raise ValueError("Error fetching challenges: {0} {1}".format(resp.getcode(), authorization))
177
        domain = authorization["identifier"]["value"]
Daniel Roesler's avatar
Daniel Roesler committed
178

Adrien Dorsaz's avatar
Adrien Dorsaz committed
179
        log.info("Install DNS TXT resource for domain: {0}".format(domain))
180
        challenge = [c for c in authorization["challenges"] if c["type"] == "dns-01"][0]
181
        token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge["token"])
182
        keyauthorization = "{0}.{1}".format(token, thumbprint)
183 184 185
        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))
Daniel Roesler's avatar
Daniel Roesler committed
186
        try:
187
            _update_dns(dnsrr_set, "add")
188
        except dns.exception.DNSException as dnsexception:
189
            raise ValueError("Error updating DNS records: {0} : {1}".format(type(dnsexception).__name__, str(dnsexception)))
Daniel Roesler's avatar
Daniel Roesler committed
190

191
        log.info("Waiting for {0} seconds before starting self challenge check.".format(config["acmednstiny"].getint("CheckChallengeDelay")))
192
        time.sleep(config["acmednstiny"].getint("CheckChallengeDelay"))
193
        challenge_verified = False
194
        number_check_fail = 1
195 196
        while challenge_verified is False:
            try:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
197
                log.debug('Self test (try: {0}): Check resource with value "{1}" exits on nameservers: {2}'.format(number_check_fail, keydigest64, resolver.nameservers))
198 199
                challenges = resolver.query(dnsrr_domain, rdtype="TXT")
                for response in challenges.rrset:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
200
                    log.debug("  - Found value {0}".format(response.to_text()))
201 202
                    challenge_verified = challenge_verified or response.to_text() == '"{0}"'.format(keydigest64)
            except dns.exception.DNSException as dnsexception:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
203
                log.debug("  - Will retry as a DNS error occurred while checking challenge: {0} : {1}".format(type(dnsexception).__name__, dnsexception))
204 205
            finally:
                if challenge_verified is False:
206 207
                    if number_check_fail >= 10:
                        raise ValueError("Error checking challenge, value not found: {0}".format(keydigest64))
208 209
                    number_check_fail = number_check_fail + 1
                    time.sleep(2)
210

211 212
        log.info("Waiting for {0} seconds before asking ACME server to validate challenge.".format(max(5, config["acmednstiny"].getint("CheckChallengeDelay"))))
        max(5, time.sleep(config["acmednstiny"].getint("CheckChallengeDelay")))
213 214
        code, result, headers = _send_signed_request(challenge["url"], {"keyAuthorization": keyauthorization})
        if code != 200:
215
            raise ValueError("Error triggering challenge: {0} {1}".format(code, result))
216 217 218
        try:
            while True:
                try:
219
                    resp = webclient.open(challenge["url"])
220 221
                    challenge_status = json.loads(resp.read().decode("utf8"))
                except IOError as e:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
222
                    raise ValueError("Error during challenge validation: {0} {1}".format(
223 224 225 226
                        e.code, json.loads(e.read().decode("utf8"))))
                if challenge_status["status"] == "pending":
                    time.sleep(2)
                elif challenge_status["status"] == "valid":
Adrien Dorsaz's avatar
Adrien Dorsaz committed
227
                    log.info("ACME has verified challenge for domain: {0}".format(domain))
228 229
                    break
                else:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
230
                    raise ValueError("Challenge for domain {0} did not pass: {1}".format(
231 232 233
                        domain, challenge_status))
        finally:
            _update_dns(dnsrr_set, "delete")
Daniel Roesler's avatar
Daniel Roesler committed
234

Adrien Dorsaz's avatar
Adrien Dorsaz committed
235
    log.info("Request to finalize the order (all chalenge have been completed)")
236
    resp = webclient.open(order_location)
237
    finalize = json.loads(resp.read().decode("utf8"))
238 239
    csr_der = _b64(_openssl("req", ["-in", config["acmednstiny"]["CSRFile"], "-outform", "DER"]))
    code, result, headers = _send_signed_request(order["finalize"], {"csr": csr_der})
240 241
    if code != 200:
        raise ValueError("Error while sending the CSR: {0} {1}".format(code, result))
242

243
    while True:
244
        try:
245
            resp = webclient.open(order_location)
246 247 248 249 250
            finalize = json.loads(resp.read().decode("utf8"))
        except IOError as e:
            raise ValueError("Error finalizing order: {0} {1}".format(
                e.code, json.loads(e.read().decode("utf8"))))

251
        if finalize["status"] == "processing":
Adrien Dorsaz's avatar
Adrien Dorsaz committed
252
            time.sleep(resp.getheader("Retry-After", 2))
253 254 255 256 257 258
        elif finalize["status"] == "valid":
            log.info("Order finalized!")
            break
        else:
            raise ValueError("Finalizing order {0} got errors: {1}".format(
                domain, finalize))
259
    
260
    resp = webclient.open(finalize["certificate"])
Adrien Dorsaz's avatar
Adrien Dorsaz committed
261
    if resp.getcode() != 200:
262 263
        raise ValueError("Finalizing order {0} got errors: {1}".format(
            resp.getcode(), resp.read.decode("utf8")))
Adrien Dorsaz's avatar
Adrien Dorsaz committed
264
    certchain = resp.read().decode("utf8")
265
    
266
    log.info("Certificate signed and chain received: {0}".format(finalize["certificate"]))
Adrien Dorsaz's avatar
Adrien Dorsaz committed
267
    return certchain
Daniel Roesler's avatar
Daniel Roesler committed
268

Daniel Roesler's avatar
Daniel Roesler committed
269
def main(argv):
Daniel Roesler's avatar
Daniel Roesler committed
270 271
    parser = argparse.ArgumentParser(
        formatter_class=argparse.RawDescriptionHelpFormatter,
272 273
        description="""
This script automates the process of getting a signed TLS certificate
Adrien Dorsaz's avatar
Adrien Dorsaz committed
274 275
chain from any CA using the ACME protocol and its DNS verification.
It will need to have access to your private ACME account key and dns server
276 277
so PLEASE READ THROUGH IT!
It's around 300 lines, so it won't take long.
jomo's avatar
jomo committed
278

279 280 281 282 283
===Example Usage===
python3 acme_dns_tiny.py ./example.ini > chain.crt
See example.ini file to configure correctly this script.
===================
"""
jomo's avatar
jomo committed
284
    )
285
    parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="suppress output except for errors")
286
    parser.add_argument("configfile", help="path to your configuration file")
Daniel Roesler's avatar
Daniel Roesler committed
287
    args = parser.parse_args(argv)
288 289

    config = ConfigParser()
290
    config.read_dict({"acmednstiny": {"ACMEDirectory": "https://acme-staging-v02.api.letsencrypt.org/directory",
Adrien Dorsaz's avatar
Adrien Dorsaz committed
291
                                      "CheckChallengeDelay": 3},
292
                      "DNS": {"Port": "53"}})
293 294
    config.read(args.configfile)

295
    if (set(["accountkeyfile", "csrfile", "acmedirectory", "checkchallengedelay"]) - set(config.options("acmednstiny"))
296
        or set(["keyname", "keyvalue", "algorithm"]) - set(config.options("TSIGKeyring"))
297
        or set(["zone", "host", "port"]) - set(config.options("DNS"))):
298 299
        raise ValueError("Some required settings are missing.")

300
    LOGGER.setLevel(args.quiet or LOGGER.level)
301
    signed_crt = get_crt(config, log=LOGGER)
Daniel Roesler's avatar
Daniel Roesler committed
302
    sys.stdout.write(signed_crt)
Daniel Roesler's avatar
Daniel Roesler committed
303

304
if __name__ == "__main__":  # pragma: no cover
Daniel Roesler's avatar
Daniel Roesler committed
305
    main(sys.argv[1:])