acme_dns_tiny.py 15.6 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

8
LOGGER = logging.getLogger('acme_dns_tiny')
9 10
LOGGER.addHandler(logging.StreamHandler())

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

16 17 18 19 20 21 22 23
    # 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
24

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

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

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

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

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

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

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

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

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

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

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

151
    # new order
Adrien Dorsaz's avatar
Adrien Dorsaz committed
152
    log.info("Request to the ACME server an order to validate domains.")
153
    new_order = { "identifiers": [{"type": "dns", "value": domain} for domain in domains]}
154 155 156 157
    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
158
        log.debug("  - Order received: {0}".format(order_location))
159 160
        if order["status"] != "pending":
            raise ValueError("Order status is not pending, we can't use it: {0}".format(order))
161 162
    elif (code == 403
        and order["type"] == "urn:ietf:params:acme:error:userActionRequired"):
163
        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"]))
164 165
    else:
        raise ValueError("Error getting new Order: {0} {1}".format(code, result))
166

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

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

Adrien Dorsaz's avatar
Adrien Dorsaz committed
178
        log.info("Install DNS TXT resource for domain: {0}".format(domain))
179
        challenge = [c for c in authorization["challenges"] if c["type"] == "dns-01"][0]
180
        token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge["token"])
181
        keyauthorization = "{0}.{1}".format(token, thumbprint)
182 183
        keydigest64 = _b64(hashlib.sha256(keyauthorization.encode("utf8")).digest())
        dnsrr_domain = "_acme-challenge.{0}.".format(domain)
184 185 186 187 188
        try: # a CNAME resource can be used for advanced TSIG configuration, trying to follow it
            dnsrr_domain = (response.to_text() for response in resolver.query(dnsrr_domain, rdtype="CNAME"))
            log.info("  - A CNAME resource has been found for this domain, will install TXT on {0}".format(dnsrr_domain))
        except dns.resolver.NoAnswer as noAnswer:
            log.debug("  - Not any CNAME resource has been found for this domain, will install TXT directly on {0}".format(dnsrr_domain))
189
        dnsrr_set = dns.rrset.from_text(dnsrr_domain, config["DNS"].getint("TTL"), "IN", "TXT",  '"{0}"'.format(keydigest64))
Daniel Roesler's avatar
Daniel Roesler committed
190
        try:
191
            _update_dns(dnsrr_set, "add")
192
        except dns.exception.DNSException as dnsexception:
193
            raise ValueError("Error updating DNS records: {0} : {1}".format(type(dnsexception).__name__, str(dnsexception)))
Daniel Roesler's avatar
Daniel Roesler committed
194

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

214
        log.info("Asking ACME server to validate challenge.")
215 216
        code, result, headers = _send_signed_request(challenge["url"], {"keyAuthorization": keyauthorization})
        if code != 200:
217
            raise ValueError("Error triggering challenge: {0} {1}".format(code, result))
218 219 220
        try:
            while True:
                try:
221
                    resp = webclient.open(challenge["url"])
222 223
                    challenge_status = json.loads(resp.read().decode("utf8"))
                except IOError as e:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
224
                    raise ValueError("Error during challenge validation: {0} {1}".format(
225 226 227 228
                        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
229
                    log.info("ACME has verified challenge for domain: {0}".format(domain))
230 231
                    break
                else:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
232
                    raise ValueError("Challenge for domain {0} did not pass: {1}".format(
233 234 235
                        domain, challenge_status))
        finally:
            _update_dns(dnsrr_set, "delete")
Daniel Roesler's avatar
Daniel Roesler committed
236

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

245
    while True:
246
        try:
247
            resp = webclient.open(order_location)
248 249 250 251 252
            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"))))

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

Daniel Roesler's avatar
Daniel Roesler committed
271
def main(argv):
Daniel Roesler's avatar
Daniel Roesler committed
272 273
    parser = argparse.ArgumentParser(
        formatter_class=argparse.RawDescriptionHelpFormatter,
274 275 276
        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) !
jomo's avatar
jomo committed
277

278 279 280 281
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."""
jomo's avatar
jomo committed
282
    )
283 284
    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")
285
    parser.add_argument("--csr", help="specifies CSR file path to use instead of the CSRFile option from the configuration file.")
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 291 292
    config.read_dict({"acmednstiny": {"ACMEDirectory": "https://acme-staging-v02.api.letsencrypt.org/directory"},
                      "DNS": {"Port": 53,
                              "TTL": 10}})
293 294
    config.read(args.configfile)

295
    if args.csr :
296
        config.set("acmednstiny", "csrfile", args.csr)
297

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

303
    LOGGER.setLevel(args.verbose or args.quiet or logging.INFO)
304
    signed_crt = get_crt(config, log=LOGGER)
Daniel Roesler's avatar
Daniel Roesler committed
305
    sys.stdout.write(signed_crt)
Daniel Roesler's avatar
Daniel Roesler committed
306

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