acme_dns_tiny.py 17.9 KB
Newer Older
1
#!/usr/bin/env python3
2 3 4 5 6
#pylint: disable=multiple-imports
"""ACME client to met DNS challenge and receive TLS certificate"""
import argparse, base64, binascii, configparser, copy, hashlib, json, logging
import re, sys, subprocess, time
import requests, dns.resolver, dns.tsigkeyring, dns.update
7

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

11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
def _base64(text):
    """"Encodes string as base64 as specified in the ACME RFC."""
    return base64.urlsafe_b64encode(text).decode("utf8").rstrip("=")

def _openssl(command, options, communicate=None):
    """Run openssl command line and raise IOError on non-zero return."""
    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

#pylint: disable=too-many-locals,too-many-branches,too-many-statements
26
def get_crt(config, log=LOGGER):
27
    """Get ACME certificate by resolving DNS challenge"""
28

29
    def _update_dns(rrset, action):
30
        """Updates DNS resource by adding or deleting resource."""
31
        algorithm = dns.name.from_text("{0}".format(config["TSIGKeyring"]["Algorithm"].lower()))
32 33
        dns_update = dns.update.Update(config["DNS"]["zone"],
                                       keyring=private_keyring, keyalgorithm=algorithm)
34 35 36 37
        if action == "add":
            dns_update.add(rrset.name, rrset)
        elif action == "delete":
            dns_update.delete(rrset.name, rrset)
38 39
        response = dns.query.tcp(dns_update, config["DNS"]["Host"],
                                 port=config.getint("DNS", "Port"))
40
        dns_update = None
41
        return response
42

43
    def _send_signed_request(url, payload, extra_headers=None):
44
        """Sends signed requests to ACME server."""
45
        nonlocal nonce
46 47 48
        if payload == "": # on POST-as-GET, final payload has to be just empty string
            payload64 = ""
        else:
49 50 51
            payload64 = _base64(json.dumps(payload).encode("utf8"))
        protected = copy.deepcopy(private_acme_signature)
        protected["nonce"] = nonce or requests.get(acme_config["newNonce"]).headers['Replay-Nonce']
52
        protected["url"] = url
53
        if url == acme_config["newAccount"]:
54 55
            if "kid" in protected:
                del protected["kid"]
56 57
        else:
            del protected["jwk"]
58
        protected64 = _base64(json.dumps(protected).encode("utf8"))
59 60
        signature = _openssl("dgst", ["-sha256", "-sign", config["acmednstiny"]["AccountKeyFile"]],
                             "{0}.{1}".format(protected64, payload64).encode("utf8"))
61
        jose = {
62
            "protected": protected64, "payload": payload64, "signature": _base64(signature)
63
        }
64 65
        joseheaders = {'Content-Type': 'application/jose+json'}
        joseheaders.update(adtheaders)
66
        joseheaders.update(extra_headers or {})
67
        try:
68
            response = requests.post(url, json=jose, headers=joseheaders)
69
        except requests.exceptions.RequestException as error:
70
            response = error.response
71
        finally:
72
            nonce = response.headers['Replay-Nonce']
73 74 75
        try:
            return response, response.json()
        except ValueError:  # if body is empty or not JSON formatted
76
            return response, json.dumps({})
77

78
    # main code
79
    adtheaders = {'User-Agent': 'acme-dns-tiny/2.2',
80 81
                  'Accept-Language': config["acmednstiny"].get("Language", "en")}
    nonce = None
Adrien Dorsaz's avatar
Adrien Dorsaz committed
82

83 84 85 86 87 88 89 90 91 92 93 94 95 96 97
    log.info("Find domains to validate from the Certificate Signing Request (CSR) file.")
    csr = _openssl("req", ["-in", config["acmednstiny"]["CSRFile"],
                           "-noout", "-text"]).decode("utf8")
    domains = set()
    common_name = re.search(r"Subject:.*?\s+?CN\s*?=\s*?([^\s,;/]+)", csr)
    if common_name is not None:
        domains.add(common_name.group(1))
    subject_alt_names = re.search(r"X509v3 Subject Alternative Name: \r?\n +([^\r\n]+)\r?\n", csr,
                                  re.MULTILINE | re.DOTALL)
    if subject_alt_names is not None:
        for san in subject_alt_names.group(1).split(", "):
            if san.startswith("DNS:"):
                domains.add(san[4:])
    if len(domains) == 0: #pylint: disable=len-as-condition
        raise ValueError("Didn't find any domain to validate in the provided CSR.")
98

99 100 101 102
    log.info("Configure DNS client tools.")
    # That keyring is used to authenticate with the DNS server, it needs to be safely kept
    private_keyring = dns.tsigkeyring.from_text({config["TSIGKeyring"]["KeyName"]:
                                                 config["TSIGKeyring"]["KeyValue"]})
103 104
    resolver = dns.resolver.Resolver(configure=False)
    resolver.retry_servfail = True
105
    nameserver = []
106
    try:
107 108 109 110 111 112 113
        nameserver = [ipv4_rrset.to_text() for ipv4_rrset
                      in dns.resolver.query(config["DNS"]["Host"], rdtype="A")]
        nameserver = nameserver + [ipv6_rrset.to_text() for ipv6_rrset
                                   in dns.resolver.query(config["DNS"]["Host"], rdtype="AAAA")]
    except dns.exception.DNSException:
        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.")
114 115
    if not nameserver:
        nameserver = [config["DNS"]["Host"]]
116
    resolver.nameservers = nameserver
Daniel Roesler's avatar
Daniel Roesler committed
117

118 119 120
    log.info("Get private signature from account key.")
    accountkey = _openssl("rsa", ["-in", config["acmednstiny"]["AccountKeyFile"],
                                  "-noout", "-text"])
Daniel Roesler's avatar
Daniel Roesler committed
121
    pub_hex, pub_exp = re.search(
122
        r"modulus:\r?\n\s+00:([a-f0-9\:\s]+?)\r?\npublicExponent: ([0-9]+)",
123
        accountkey.decode("utf8"), re.MULTILINE | re.DOTALL).groups()
Daniel Roesler's avatar
Daniel Roesler committed
124
    pub_exp = "{0:x}".format(int(pub_exp))
125
    pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp
126 127
    # That signature is used to authenticate with the ACME server, it needs to be safely kept
    private_acme_signature = {
Daniel Roesler's avatar
Daniel Roesler committed
128 129
        "alg": "RS256",
        "jwk": {
130
            "e": _base64(binascii.unhexlify(pub_exp.encode("utf-8"))),
Daniel Roesler's avatar
Daniel Roesler committed
131
            "kty": "RSA",
132
            "n": _base64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))),
Daniel Roesler's avatar
Daniel Roesler committed
133 134
        },
    }
135 136
    private_jwk = json.dumps(private_acme_signature["jwk"], sort_keys=True, separators=(",", ":"))
    jwk_thumbprint = _base64(hashlib.sha256(private_jwk.encode("utf8")).digest())
Daniel Roesler's avatar
Daniel Roesler committed
137

138 139 140
    log.info("Fetch ACME server configuration from the its directory URL.")
    acme_config = requests.get(config["acmednstiny"]["ACMEDirectory"], headers=adtheaders).json()
    terms_service = acme_config.get("meta", {}).get("termsOfService", "")
Daniel Roesler's avatar
Daniel Roesler committed
141

142
    log.info("Register ACME Account to get the account identifier.")
143
    account_request = {}
144
    if terms_service:
145
        account_request["termsOfServiceAgreed"] = True
146 147
        log.warning("Terms of service exist and will be automatically agreed if possible, \
                     you should read them: %s", terms_service)
148
    account_request["contact"] = config["acmednstiny"].get("Contacts", "").split(';')
149
    if account_request["contact"] == [""]:
150
        del account_request["contact"]
151

152 153
    http_response, account_info = _send_signed_request(acme_config["newAccount"], account_request)
    if http_response.status_code == 201:
154 155
        private_acme_signature["kid"] = http_response.headers['Location']
        log.info("  - Registered a new account: '%s'", private_acme_signature["kid"])
156
    elif http_response.status_code == 200:
157 158
        private_acme_signature["kid"] = http_response.headers['Location']
        log.debug("  - Account is already registered: '%s'", private_acme_signature["kid"])
159

160
        http_response, account_info = _send_signed_request(private_acme_signature["kid"], {})
Daniel Roesler's avatar
Daniel Roesler committed
161
    else:
162 163
        raise ValueError("Error registering account: {0} {1}"
                         .format(http_response.status_code, account_info))
Daniel Roesler's avatar
Daniel Roesler committed
164

165
    log.info("Update contact information if needed.")
166 167 168
    if ("contact" in account_request
            and set(account_request["contact"]) != set(account_info["contact"])):
        http_response, result = _send_signed_request(private_acme_signature["kid"], account_request)
169
        if http_response.status_code == 200:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
170
            log.debug("  - Account updated with latest contact informations.")
171
        else:
172 173
            raise ValueError("Error registering updates for the account: {0} {1}"
                             .format(http_response.status_code, result))
174

175
    # new order
Adrien Dorsaz's avatar
Adrien Dorsaz committed
176
    log.info("Request to the ACME server an order to validate domains.")
177
    new_order = {"identifiers": [{"type": "dns", "value": domain} for domain in domains]}
178 179 180
    http_response, order = _send_signed_request(acme_config["newOrder"], new_order)
    if http_response.status_code == 201:
        order_location = http_response.headers['Location']
181
        log.debug("  - Order received: %s", order_location)
182
        if order["status"] != "pending" and order["status"] != "ready":
183 184
            raise ValueError("Order status is neither pending neither ready, we can't use it: {0}"
                             .format(order))
185
    elif (http_response.status_code == 403
186 187 188 189
          and order["type"] == "urn:ietf:params:acme:error:userActionRequired"):
        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"]))
190
    else:
191 192
        raise ValueError("Error getting new Order: {0} {1}"
                         .format(http_response.status_code, order))
193

194 195
    # complete each authorization challenge
    for authz in order["authorizations"]:
196 197
        if order["status"] == "ready":
            log.info("No challenge to process: order is already ready.")
198
            break
Daniel Roesler's avatar
Daniel Roesler committed
199

200
        log.info("Process challenge for authorization: %s", authz)
Daniel Roesler's avatar
Daniel Roesler committed
201
        # get new challenge
202 203
        http_response, authorization = _send_signed_request(authz, "")
        if http_response.status_code != 200:
204 205
            raise ValueError("Error fetching challenges: {0} {1}"
                             .format(http_response.status_code, authorization))
206
        domain = authorization["identifier"]["value"]
Daniel Roesler's avatar
Daniel Roesler committed
207

208
        log.info("Install DNS TXT resource for domain: %s", domain)
209
        challenge = [c for c in authorization["challenges"] if c["type"] == "dns-01"][0]
210
        token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge["token"])
211
        keyauthorization = "{0}.{1}".format(token, jwk_thumbprint)
212
        keydigest64 = _base64(hashlib.sha256(keyauthorization.encode("utf8")).digest())
213
        dnsrr_domain = "_acme-challenge.{0}.".format(domain)
Adrien Dorsaz's avatar
Adrien Dorsaz committed
214
        try: # a CNAME resource can be used for advanced TSIG configuration
215 216 217 218 219
             # Note: the CNAME target has to be of "non-CNAME" type (recursion isn't managed)
            dnsrr_domain = [response.to_text() for response
                            in resolver.query(dnsrr_domain, rdtype="CNAME")][0]
            log.info("  - A CNAME resource has been found for this domain, will install TXT on %s",
                     dnsrr_domain)
220
        except dns.exception.DNSException as dnsexception:
221 222 223 224
            log.debug("  - Not any CNAME resource has been found for this domain (%s), will install\
                      TXT directly on %s", dnsrr_domain, type(dnsexception).__name__)
        dnsrr_set = dns.rrset.from_text(dnsrr_domain, config["DNS"].getint("TTL"),
                                        "IN", "TXT", '"{0}"'.format(keydigest64))
Daniel Roesler's avatar
Daniel Roesler committed
225
        try:
226
            _update_dns(dnsrr_set, "add")
227
        except dns.exception.DNSException as dnsexception:
228 229
            raise ValueError("Error updating DNS records: {0} : {1}"
                             .format(type(dnsexception).__name__, str(dnsexception)))
Daniel Roesler's avatar
Daniel Roesler committed
230

231 232
        log.info("Wait for 1 TTL (%s seconds) to ensure DNS cache is cleared.",
                 config["DNS"].getint("TTL"))
233
        time.sleep(config["DNS"].getint("TTL"))
234
        challenge_verified = False
235
        number_check_fail = 1
236 237
        while challenge_verified is False:
            try:
238 239
                log.debug('Self test (try: %s): Check resource with value "%s" exits on\
                          nameservers: %s', number_check_fail, keydigest64, resolver.nameservers)
240
                for response in resolver.query(dnsrr_domain, rdtype="TXT").rrset:
241 242 243
                    log.debug("  - Found value %s", response.to_text())
                    challenge_verified = (challenge_verified
                                          or response.to_text() == '"{0}"'.format(keydigest64))
244
            except dns.exception.DNSException as dnsexception:
245 246 247
                log.debug(
                    "  - Will retry as a DNS error occurred while checking challenge: %s : %s",
                    type(dnsexception).__name__, dnsexception)
248 249
            finally:
                if challenge_verified is False:
250
                    if number_check_fail >= 10:
251 252
                        raise ValueError("Error checking challenge, value not found: {0}"
                                         .format(keydigest64))
253
                    number_check_fail = number_check_fail + 1
254
                    time.sleep(config["DNS"].getint("TTL"))
255

256
        log.info("Asking ACME server to validate challenge.")
257 258
        http_response, result = _send_signed_request(challenge["url"],
                                                     {"keyAuthorization": keyauthorization})
259
        if http_response.status_code != 200:
260 261
            raise ValueError("Error triggering challenge: {0} {1}"
                             .format(http_response.status_code, result))
262 263
        try:
            while True:
264 265
                http_response, challenge_status = _send_signed_request(challenge["url"], "")
                if http_response.status_code != 200:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
266
                    raise ValueError("Error during challenge validation: {0} {1}".format(
267
                        http_response.status_code, challenge_status))
268 269 270
                if challenge_status["status"] == "pending":
                    time.sleep(2)
                elif challenge_status["status"] == "valid":
271
                    log.info("ACME has verified challenge for domain: %s", domain)
272 273
                    break
                else:
Adrien Dorsaz's avatar
Adrien Dorsaz committed
274
                    raise ValueError("Challenge for domain {0} did not pass: {1}".format(
275 276 277
                        domain, challenge_status))
        finally:
            _update_dns(dnsrr_set, "delete")
Daniel Roesler's avatar
Daniel Roesler committed
278

Adrien Dorsaz's avatar
Adrien Dorsaz committed
279
    log.info("Request to finalize the order (all chalenge have been completed)")
280
    csr_der = _base64(_openssl("req", ["-in", config["acmednstiny"]["CSRFile"], "-outform", "DER"]))
281 282
    http_response, result = _send_signed_request(order["finalize"], {"csr": csr_der})
    if http_response.status_code != 200:
283 284
        raise ValueError("Error while sending the CSR: {0} {1}"
                         .format(http_response.status_code, result))
285

286
    while True:
287
        http_response, order = _send_signed_request(order_location, "")
288

289
        if order["status"] == "processing":
290 291
            if http_response.headers["Retry-After"]:
                time.sleep(http_response.headers["Retry-After"])
292 293
            else:
                time.sleep(2)
294
        elif order["status"] == "valid":
295 296 297 298
            log.info("Order finalized!")
            break
        else:
            raise ValueError("Finalizing order {0} got errors: {1}".format(
299
                domain, order))
300

301 302 303 304
    http_response, result = _send_signed_request(
        order["certificate"], "",
        {'Accept': config["acmednstiny"].get("CertificateFormat",
                                             'application/pem-certificate-chain')})
305
    if http_response.status_code != 200:
306 307
        raise ValueError("Finalizing order {0} got errors: {1}"
                         .format(http_response.status_code, result))
308

309
    if 'link' in http_response.headers:
310
        log.info("  - Certificate links given by server: %s", http_response.headers['link'])
311

312
    log.info("Certificate signed and chain received: %s", order["certificate"])
313
    return http_response.text
Daniel Roesler's avatar
Daniel Roesler committed
314

Daniel Roesler's avatar
Daniel Roesler committed
315
def main(argv):
316
    """Parse arguments and get certificate."""
Daniel Roesler's avatar
Daniel Roesler committed
317 318
    parser = argparse.ArgumentParser(
        formatter_class=argparse.RawDescriptionHelpFormatter,
319 320
        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,
321
so PLEASE READ THROUGH IT (it won't take too long, it's a one-file script) !
jomo's avatar
jomo committed
322

323 324 325 326
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
327
    )
328 329 330 331 332 333 334
    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")
    parser.add_argument("--csr",
                        help="specifies CSR file path to use instead of the CSRFile option \
from the configuration file.")
335
    parser.add_argument("configfile", help="path to your configuration file")
Daniel Roesler's avatar
Daniel Roesler committed
336
    args = parser.parse_args(argv)
337

338
    config = configparser.ConfigParser()
339 340 341
    config.read_dict({
        "acmednstiny": {"ACMEDirectory": "https://acme-staging-v02.api.letsencrypt.org/directory"},
        "DNS": {"Port": 53, "TTL": 10}})
342 343
    config.read(args.configfile)

344
    if args.csr:
345
        config.set("acmednstiny", "csrfile", args.csr)
346

347
    if (set(["accountkeyfile", "csrfile", "acmedirectory"]) - set(config.options("acmednstiny"))
348 349
            or set(["keyname", "keyvalue", "algorithm"]) - set(config.options("TSIGKeyring"))
            or set(["zone", "host", "port", "ttl"]) - set(config.options("DNS"))):
350 351
        raise ValueError("Some required settings are missing.")

352
    LOGGER.setLevel(args.verbose or args.quiet or logging.INFO)
353
    signed_crt = get_crt(config, LOGGER)
Daniel Roesler's avatar
Daniel Roesler committed
354
    sys.stdout.write(signed_crt)
Daniel Roesler's avatar
Daniel Roesler committed
355

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