acme_dns_tiny.py 15.8 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):
Daniel Roesler's avatar
Daniel Roesler committed
9
    def _b64(b):
10
        """"Encodes string as base64 as specified in ACME RFC """
11
        return base64.urlsafe_b64encode(b).decode("utf8").rstrip("=")
12

13
    def _openssl(command, options, communicate=None):
14
        """Run openssl command line and raise IOError on non-zero return."""
15 16 17 18 19 20
        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
    def _update_dns(rrset, action):
23
        """Updates DNS resource by adding or deleting resource."""
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
        response = dns.query.tcp(dns_update, config["DNS"]["Host"], port=config.getint("DNS", "Port"))
31
        dns_update = None
32
        return response
33 34

    def _send_signed_request(url, payload):
35
        """Sends signed requests to ACME server."""
36
        nonlocal jws_nonce
37 38 39 40
        if payload == "": # on POST-as-GET, final payload has to be just empty string
            payload64 = ""
        else:
            payload64 = _b64(json.dumps(payload).encode("utf8"))
41
        protected = copy.deepcopy(jws_header)
42
        protected["nonce"] = jws_nonce or requests.get(acme_config["newNonce"]).headers['Replay-Nonce']
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
        jose = {
52
            "protected": protected64, "payload": payload64,"signature": _b64(signature)
53
        }
54
        try:
55
            response = requests.post(url, json=jose, headers=joseheaders)
56
        except requests.exceptions.RequestException as error:
57
            response = error.response
58
        finally:
59 60 61 62 63
            jws_nonce = response.headers['Replay-Nonce']
            try:
                return response, response.json()
            except ValueError as error:
                return response, json.dumps({})
64

65
    # main code
66
    adtheaders =  {'User-Agent': 'acme-dns-tiny/2.1',
67 68 69 70
        'Accept-Language': config["acmednstiny"].get("Language", "en")
    }
    joseheaders=copy.deepcopy(adtheaders)
    joseheaders['Content-Type']='application/jose+json'
71

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

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

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

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

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

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

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

146
    log.info("Update contact information if needed.")
147
    if (set(account_request["contact"]) != set(account_info["contact"])):
148 149
        http_response, result = _send_signed_request(jws_header["kid"], account_request)
        if http_response.status_code == 200:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
150
            log.debug("  - Account updated with latest contact informations.")
151
        else:
152
            raise ValueError("Error registering updates for the account: {0} {1}".format(http_response.status_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 158 159
    http_response, order = _send_signed_request(acme_config["newOrder"], new_order)
    if http_response.status_code == 201:
        order_location = http_response.headers['Location']
Adrien Dorsaz's avatar
Adrien Dorsaz committed
160
        log.debug("  - Order received: {0}".format(order_location))
161 162
        if order["status"] != "pending":
            raise ValueError("Order status is not pending, we can't use it: {0}".format(order))
163
    elif (http_response.status_code == 403
164
        and order["type"] == "urn:ietf:params:acme:error:userActionRequired"):
165
        raise ValueError("Order creation failed ({0}). Read Terms of Service ({1}), then follow your CA instructions: {2}".format(order["detail"], http_response.headers['Link'], order["instance"]))
166
    else:
167
        raise ValueError("Error getting new Order: {0} {1}".format(http_response.status_code, result))
168

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

        # get new challenge
174 175 176
        http_response, authorization = _send_signed_request(authz, "")
        if http_response.status_code != 200:
            raise ValueError("Error fetching challenges: {0} {1}".format(http_response.status_code, 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, jwk_thumbprint)
183 184
        keydigest64 = _b64(hashlib.sha256(keyauthorization.encode("utf8")).digest())
        dnsrr_domain = "_acme-challenge.{0}.".format(domain)
185 186 187
        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]
188
            log.info("  - A CNAME resource has been found for this domain, will install TXT on {0}".format(dnsrr_domain))
189
        except dns.exception.DNSException as dnsexception:
190
            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__))
191
        dnsrr_set = dns.rrset.from_text(dnsrr_domain, config["DNS"].getint("TTL"), "IN", "TXT",  '"{0}"'.format(keydigest64))
Daniel Roesler's avatar
Daniel Roesler committed
192
        try:
193
            _update_dns(dnsrr_set, "add")
194
        except dns.exception.DNSException as dnsexception:
195
            raise ValueError("Error updating DNS records: {0} : {1}".format(type(dnsexception).__name__, str(dnsexception)))
Daniel Roesler's avatar
Daniel Roesler committed
196

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

216
        log.info("Asking ACME server to validate challenge.")
217 218 219
        http_response, result = _send_signed_request(challenge["url"], {"keyAuthorization": keyauthorization})
        if http_response.status_code != 200:
            raise ValueError("Error triggering challenge: {0} {1}".format(http_response.status_code, result))
220 221
        try:
            while True:
222 223
                http_response, challenge_status = _send_signed_request(challenge["url"], "")
                if http_response.status_code != 200:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
224
                    raise ValueError("Error during challenge validation: {0} {1}".format(
225
                        http_response.status_code, challenge_status))
226 227 228
                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
    csr_der = _b64(_openssl("req", ["-in", config["acmednstiny"]["CSRFile"], "-outform", "DER"]))
239 240
    http_response, result = _send_signed_request(order["finalize"], {"csr": csr_der})
    if http_response.status_code != 200:
241
        raise ValueError("Error while sending the CSR: {0} {1}".format(http_response.status_code, result))
242

243
    while True:
244
        http_response, order = _send_signed_request(order_location, "")
245

246
        if order["status"] == "processing":
247 248
            if http_response.headers["Retry-After"]:
                time.sleep(http_response.headers["Retry-After"])
249 250
            else:
                time.sleep(2)
251
        elif order["status"] == "valid":
252 253 254 255
            log.info("Order finalized!")
            break
        else:
            raise ValueError("Finalizing order {0} got errors: {1}".format(
256
                domain, order))
257
    
258 259
    http_response, result = _send_signed_request(order["certificate"], "")
    if http_response.status_code != 200:
260
        raise ValueError("Finalizing order {0} got errors: {1}".format(http_response.status_code, result))
261

262
    log.info("Certificate signed and chain received: {0}".format(order["certificate"]))
263
    return http_response.text
Daniel Roesler's avatar
Daniel Roesler committed
264

265
def main(argv):
Daniel Roesler's avatar
Daniel Roesler committed
266 267
    parser = argparse.ArgumentParser(
        formatter_class=argparse.RawDescriptionHelpFormatter,
268 269 270
        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
271

272 273 274 275
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
276
    )
277 278
    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")
279
    parser.add_argument("--csr", help="specifies CSR file path to use instead of the CSRFile option from the configuration file.")
280
    parser.add_argument("configfile", help="path to your configuration file")
281
    args = parser.parse_args(argv)
282

283
    config = configparser.ConfigParser()
284 285 286
    config.read_dict({"acmednstiny": {"ACMEDirectory": "https://acme-staging-v02.api.letsencrypt.org/directory"},
                      "DNS": {"Port": 53,
                              "TTL": 10}})
287 288
    config.read(args.configfile)

289
    if args.csr :
290
        config.set("acmednstiny", "csrfile", args.csr)
291

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

297
    LOGGER.setLevel(args.verbose or args.quiet or logging.INFO)
298
    signed_crt = get_crt(config, log=LOGGER)
Daniel Roesler's avatar
Daniel Roesler committed
299
    sys.stdout.write(signed_crt)
300

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