acme_dns_tiny.py 18.1 KB
Newer Older
1
#!/usr/bin/env python3
2
# pylint: disable=multiple-imports
3 4 5 6
"""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
def _base64(text):
13
    """Encodes string as base64 as specified in the ACME RFC."""
14 15
    return base64.urlsafe_b64encode(text).decode("utf8").rstrip("=")

16

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

27 28

# pylint: disable=too-many-locals,too-many-branches,too-many-statements
29
def get_crt(config, log=LOGGER):
30
    """Get ACME certificate by resolving DNS challenge."""
31

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

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

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

86 87 88 89 90 91 92
    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))
93
    subject_alt_names = re.search(
94
        r"X509v3 Subject Alternative Name: (?:critical)?\s+([^\r\n]+)\r?\n",
95
        csr, re.MULTILINE)
96 97 98 99
    if subject_alt_names is not None:
        for san in subject_alt_names.group(1).split(", "):
            if san.startswith("DNS:"):
                domains.add(san[4:])
100
    if len(domains) == 0:  # pylint: disable=len-as-condition
101
        raise ValueError("Didn't find any domain to validate in the provided CSR.")
102

103 104 105 106
    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"]})
107 108
    resolver = dns.resolver.Resolver(configure=False)
    resolver.retry_servfail = True
109
    nameserver = []
110
    try:
111 112 113 114 115
        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:
116 117
        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."))
118 119
    if not nameserver:
        nameserver = [config["DNS"]["Host"]]
120
    resolver.nameservers = nameserver
Daniel Roesler's avatar
Daniel Roesler committed
121

122 123 124
    log.info("Get private signature from account key.")
    accountkey = _openssl("rsa", ["-in", config["acmednstiny"]["AccountKeyFile"],
                                  "-noout", "-text"])
Daniel Roesler's avatar
Daniel Roesler committed
125
    pub_hex, pub_exp = re.search(
126
        r"modulus:\s+?00:([a-f0-9\:\s]+?)\r?\npublicExponent: ([0-9]+)",
127
        accountkey.decode("utf8"), re.MULTILINE).groups()
Daniel Roesler's avatar
Daniel Roesler committed
128
    pub_exp = "{0:x}".format(int(pub_exp))
129
    pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp
130 131
    # 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
132 133
        "alg": "RS256",
        "jwk": {
134
            "e": _base64(binascii.unhexlify(pub_exp.encode("utf-8"))),
Daniel Roesler's avatar
Daniel Roesler committed
135
            "kty": "RSA",
136
            "n": _base64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))),
Daniel Roesler's avatar
Daniel Roesler committed
137 138
        },
    }
139 140
    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
141

142 143 144
    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
145

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

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

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

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

180
    # new order
Adrien Dorsaz's avatar
Adrien Dorsaz committed
181
    log.info("Request to the ACME server an order to validate domains.")
182
    new_order = {"identifiers": [{"type": "dns", "value": domain} for domain in domains]}
183 184 185
    http_response, order = _send_signed_request(acme_config["newOrder"], new_order)
    if http_response.status_code == 201:
        order_location = http_response.headers['Location']
186
        log.debug("  - Order received: %s", order_location)
187
        if order["status"] != "pending" and order["status"] != "ready":
188 189
            raise ValueError("Order status is neither pending neither ready, we can't use it: {0}"
                             .format(order))
190
    elif (http_response.status_code == 403
191
          and order["type"] == "urn:ietf:params:acme:error:userActionRequired"):
192 193
        raise ValueError(("Order creation failed ({0}). Read Terms of Service ({1}), then follow "
                          "your CA instructions: {2}")
194 195
                         .format(order["detail"],
                                 http_response.headers['Link'], order["instance"]))
196
    else:
197 198
        raise ValueError("Error getting new Order: {0} {1}"
                         .format(http_response.status_code, order))
199

200 201
    # complete each authorization challenge
    for authz in order["authorizations"]:
202 203
        if order["status"] == "ready":
            log.info("No challenge to process: order is already ready.")
204
            break
Daniel Roesler's avatar
Daniel Roesler committed
205

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

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

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

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

Adrien Dorsaz's avatar
Adrien Dorsaz committed
286
    log.info("Request to finalize the order (all chalenge have been completed)")
287 288
    csr_der = _base64(_openssl("req", ["-in", config["acmednstiny"]["CSRFile"],
                                       "-outform", "DER"]))
289 290
    http_response, result = _send_signed_request(order["finalize"], {"csr": csr_der})
    if http_response.status_code != 200:
291 292
        raise ValueError("Error while sending the CSR: {0} {1}"
                         .format(http_response.status_code, result))
293

294
    while True:
295
        http_response, order = _send_signed_request(order_location, "")
296

297
        if order["status"] == "processing":
298 299
            if http_response.headers["Retry-After"]:
                time.sleep(http_response.headers["Retry-After"])
300 301
            else:
                time.sleep(2)
302
        elif order["status"] == "valid":
303 304 305 306
            log.info("Order finalized!")
            break
        else:
            raise ValueError("Finalizing order {0} got errors: {1}".format(
307
                domain, order))
308

309 310 311 312
    http_response, result = _send_signed_request(
        order["certificate"], "",
        {'Accept': config["acmednstiny"].get("CertificateFormat",
                                             'application/pem-certificate-chain')})
313
    if http_response.status_code != 200:
314 315
        raise ValueError("Finalizing order {0} got errors: {1}"
                         .format(http_response.status_code, result))
316

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

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

323

Daniel Roesler's avatar
Daniel Roesler committed
324
def main(argv):
325
    """Parse arguments and get certificate."""
Daniel Roesler's avatar
Daniel Roesler committed
326 327
    parser = argparse.ArgumentParser(
        formatter_class=argparse.RawDescriptionHelpFormatter,
328 329
        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,
330
so PLEASE READ THROUGH IT (it won't take too long, it's a one-file script) !
jomo's avatar
jomo committed
331

332 333 334 335
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
336
    )
337 338 339 340 341 342 343
    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.")
344
    parser.add_argument("configfile", help="path to your configuration file")
Daniel Roesler's avatar
Daniel Roesler committed
345
    args = parser.parse_args(argv)
346

347
    config = configparser.ConfigParser()
348 349 350
    config.read_dict({
        "acmednstiny": {"ACMEDirectory": "https://acme-staging-v02.api.letsencrypt.org/directory"},
        "DNS": {"Port": 53, "TTL": 10}})
351 352
    config.read(args.configfile)

353
    if args.csr:
354
        config.set("acmednstiny", "csrfile", args.csr)
355

356
    if (set(["accountkeyfile", "csrfile", "acmedirectory"]) - set(config.options("acmednstiny"))
357 358
            or set(["keyname", "keyvalue", "algorithm"]) - set(config.options("TSIGKeyring"))
            or set(["zone", "host", "port", "ttl"]) - set(config.options("DNS"))):
359 360
        raise ValueError("Some required settings are missing.")

361
    LOGGER.setLevel(args.verbose or args.quiet or logging.INFO)
362
    signed_crt = get_crt(config, LOGGER)
Daniel Roesler's avatar
Daniel Roesler committed
363
    sys.stdout.write(signed_crt)
Daniel Roesler's avatar
Daniel Roesler committed
364

365

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