acme_dns_tiny.py 15.9 KB
Newer Older
1
#!/usr/bin/env python3
2
import argparse, subprocess, requests, json, sys, base64, binascii, time, hashlib, re, copy, logging, configparser
3
import dns.resolver, dns.tsigkeyring, dns.update
4

5
LOGGER = logging.getLogger('acme_dns_tiny')
6 7
LOGGER.addHandler(logging.StreamHandler())

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

13 14 15 16 17 18 19 20
    # 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
21

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

Adrien Dorsaz's avatar
Adrien Dorsaz committed
34
    # helper function to send signed requests
35
    def _send_signed_request(url, payload):
36
        nonlocal jws_nonce
37
        payload64 = _b64(json.dumps(payload).encode("utf8"))
38
        protected = copy.deepcopy(jws_header)
39
        protected["nonce"] = jws_nonce or requests.get(acme_config["newNonce"]).headers['Replay-Nonce']
40
        protected["url"] = url
41
        if url == acme_config["newAccount"]:
42 43 44
            del protected["kid"]
        else:
            del protected["jwk"]
45
        protected64 = _b64(json.dumps(protected).encode("utf8"))
46 47
        signature = _openssl("dgst", ["-sha256", "-sign", config["acmednstiny"]["AccountKeyFile"]],
                             "{0}.{1}".format(protected64, payload64).encode("utf8"))
48
        jose = {
49
            "protected": protected64, "payload": payload64,"signature": _b64(signature)
50
        }
51
        try:
52 53 54
            resp = requests.post(url, json=jose, headers=joseheaders)
        except requests.exceptions.RequestException as error:
            resp = error.response
55
        finally:
56
            jws_nonce = resp.headers['Replay-Nonce']
57 58 59 60
            if resp.text != '':
                return resp.status_code, resp.json(), resp.headers
            else:
                return resp.status_code, json.dumps({}), resp.headers
61

62
    # main code
63 64 65 66 67
    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'
68

Adrien Dorsaz's avatar
Adrien Dorsaz committed
69
    log.info("Fetch informations from the ACME directory.")
70 71
    directory = requests.get(config["acmednstiny"]["ACMEDirectory"], headers=adtheaders)
    acme_config = directory.json()
72
    terms_service = acme_config.get("meta", {}).get("termsOfService", "")
73

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

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

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

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

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

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

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

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

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

        # get new challenge
175 176 177 178
        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))
179
        domain = authorization["identifier"]["value"]
Daniel Roesler's avatar
Daniel Roesler committed
180

Adrien Dorsaz's avatar
Adrien Dorsaz committed
181
        log.info("Install DNS TXT resource for domain: {0}".format(domain))
182
        challenge = [c for c in authorization["challenges"] if c["type"] == "dns-01"][0]
183
        token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge["token"])
184
        keyauthorization = "{0}.{1}".format(token, thumbprint)
185 186
        keydigest64 = _b64(hashlib.sha256(keyauthorization.encode("utf8")).digest())
        dnsrr_domain = "_acme-challenge.{0}.".format(domain)
187 188 189
        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]
190
            log.info("  - A CNAME resource has been found for this domain, will install TXT on {0}".format(dnsrr_domain))
191
        except dns.exception.DNSException as dnsexception:
192
            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__))
193
        dnsrr_set = dns.rrset.from_text(dnsrr_domain, config["DNS"].getint("TTL"), "IN", "TXT",  '"{0}"'.format(keydigest64))
Daniel Roesler's avatar
Daniel Roesler committed
194
        try:
195
            _update_dns(dnsrr_set, "add")
196
        except dns.exception.DNSException as dnsexception:
197
            raise ValueError("Error updating DNS records: {0} : {1}".format(type(dnsexception).__name__, str(dnsexception)))
Daniel Roesler's avatar
Daniel Roesler committed
198

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

218
        log.info("Asking ACME server to validate challenge.")
219 220
        code, result, headers = _send_signed_request(challenge["url"], {"keyAuthorization": keyauthorization})
        if code != 200:
221
            raise ValueError("Error triggering challenge: {0} {1}".format(code, result))
222 223 224
        try:
            while True:
                try:
225 226 227
                    resp = requests.get(challenge["url"], headers=adtheaders)
                    challenge_status = resp.json()
                except requests.exceptions.RequestException as error:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
228
                    raise ValueError("Error during challenge validation: {0} {1}".format(
229
                        error.response.status_code, error.response.text()))
230 231 232
                if challenge_status["status"] == "pending":
                    time.sleep(2)
                elif challenge_status["status"] == "valid":
Adrien Dorsaz's avatar
Adrien Dorsaz committed
233
                    log.info("ACME has verified challenge for domain: {0}".format(domain))
234 235
                    break
                else:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
236
                    raise ValueError("Challenge for domain {0} did not pass: {1}".format(
237 238 239
                        domain, challenge_status))
        finally:
            _update_dns(dnsrr_set, "delete")
Daniel Roesler's avatar
Daniel Roesler committed
240

Adrien Dorsaz's avatar
Adrien Dorsaz committed
241
    log.info("Request to finalize the order (all chalenge have been completed)")
242 243
    resp = requests.get(order_location, headers=adtheaders)
    finalize = resp.json()
244 245
    csr_der = _b64(_openssl("req", ["-in", config["acmednstiny"]["CSRFile"], "-outform", "DER"]))
    code, result, headers = _send_signed_request(order["finalize"], {"csr": csr_der})
246 247
    if code != 200:
        raise ValueError("Error while sending the CSR: {0} {1}".format(code, result))
248

249
    while True:
250
        try:
251 252 253 254
            resp = requests.get(order_location, headers=adtheaders)
            resp.raise_for_status()
            finalize = resp.json()
        except requests.exceptions.RequestException as error:
255
            raise ValueError("Error finalizing order: {0} {1}".format(
256
                error.response.status_code, error.response.text()))
257

258
        if finalize["status"] == "processing":
259 260 261 262
            if resp.headers["Retry-After"]:
                time.sleep(resp.headers["Retry-After"])
            else:
                time.sleep(2)
263 264 265 266 267 268
        elif finalize["status"] == "valid":
            log.info("Order finalized!")
            break
        else:
            raise ValueError("Finalizing order {0} got errors: {1}".format(
                domain, finalize))
269
    
270 271
    resp = requests.get(finalize["certificate"], headers=adtheaders)
    if resp.status_code != 200:
272
        raise ValueError("Finalizing order {0} got errors: {1}".format(
273 274
            resp.status_code, resp.json()))
    certchain = resp.text
275
    
276
    log.info("Certificate signed and chain received: {0}".format(finalize["certificate"]))
277
    return certchain
Daniel Roesler's avatar
Daniel Roesler committed
278

279
def main(argv):
Daniel Roesler's avatar
Daniel Roesler committed
280 281
    parser = argparse.ArgumentParser(
        formatter_class=argparse.RawDescriptionHelpFormatter,
282 283 284
        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
285

286 287 288 289
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
290
    )
291 292
    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")
293
    parser.add_argument("--csr", help="specifies CSR file path to use instead of the CSRFile option from the configuration file.")
294
    parser.add_argument("configfile", help="path to your configuration file")
295
    args = parser.parse_args(argv)
296

297
    config = configparser.ConfigParser()
298 299 300
    config.read_dict({"acmednstiny": {"ACMEDirectory": "https://acme-staging-v02.api.letsencrypt.org/directory"},
                      "DNS": {"Port": 53,
                              "TTL": 10}})
301 302
    config.read(args.configfile)

303
    if args.csr :
304
        config.set("acmednstiny", "csrfile", args.csr)
305

306
    if (set(["accountkeyfile", "csrfile", "acmedirectory"]) - set(config.options("acmednstiny"))
307
        or set(["keyname", "keyvalue", "algorithm"]) - set(config.options("TSIGKeyring"))
308
        or set(["zone", "host", "port", "ttl"]) - set(config.options("DNS"))):
309 310
        raise ValueError("Some required settings are missing.")

311
    LOGGER.setLevel(args.verbose or args.quiet or logging.INFO)
312
    signed_crt = get_crt(config, log=LOGGER)
Daniel Roesler's avatar
Daniel Roesler committed
313
    sys.stdout.write(signed_crt)
314

315
if __name__ == "__main__":  # pragma: no cover
316
    main(sys.argv[1:])