acme_dns_tiny.py 15.2 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(10, config["acmednstiny"].getint("CheckChallengeDelay"))))
        time.sleep(max(10, 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 274
        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
275

276 277 278 279
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
280
    )
281
    parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="suppress output except for errors")
282
    parser.add_argument("--csr", help="specifies CSR file path to use instead of the CSRFile option from the configuration file.")
283
    parser.add_argument("configfile", help="path to your configuration file")
Daniel Roesler's avatar
Daniel Roesler committed
284
    args = parser.parse_args(argv)
285 286

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

292 293 294
    if args.csr :
        config["acmednstiny"]["csrfile"] = args.csrfile

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:])