From e8f1973be087808bca32b05fb3367fcd0518a3c0 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sat, 25 Sep 2021 07:22:00 +0200 Subject: Adding upstream version 0.1.4. Signed-off-by: Daniel Baumann --- COPYING | 20 + README.md | 81 ++++ dehydrated-hook-ddns-tsig.conf | 50 +++ dehydrated-hook-ddns-tsig.py | 874 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1025 insertions(+) create mode 100644 COPYING create mode 100644 README.md create mode 100644 dehydrated-hook-ddns-tsig.conf create mode 100755 dehydrated-hook-ddns-tsig.py diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..68d922c --- /dev/null +++ b/COPYING @@ -0,0 +1,20 @@ +# dehydrated-hook-ddns-tsig - dns-01 Challenge Hook Script for dehydrated +# +# This script uses the dnspython API to create and delete TXT records +# in order to prove ownership of a domain. +# +# Copyright (C) 2016 Elizabeth Ferdman https://eferdman.github.io +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb2b909 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# ddns-tsig hook for dehydrated + +This repository contains a python hook for the [dehydrated](https://github.com/lukas2511/dehydrated) project, a Let's Encrypt/ACME client implemented as a shell script. This hook uses the dnspython API to perform dynamic DNS updates and queries to verify. The DNS challenge is outlined in the [ACME protocol](https://letsencrypt.github.io/acme-spec/#rfc.section.7.4). To successfully complete this challenge, the client creates a temporary TXT record containing a secret token for the given domain name, thereby proving ownership of the domain. + +## Required Python libraries +* [dnspython](http://www.dnspython.org/) - a DNS toolkit used for queries, zone transfers, and dynamic updates +* (optional) [iscpy](https://pypi.python.org/pypi/iscpy) - an ISC config file parser (only needed when reading keys from an extra file) + +## Installation +Download the files for installation + +``` sh +$ git clone https://github.com/lukas2511/dehydrated.git +$ mkdir -p dehydrated/hooks/ddns-tsig +$ git clone https://github.com/eferdman/dehydrated-hook-ddns-tsig.git dehydrated/hooks/ddns-tsig +``` + +## Configuration +The script reads a configuration file as specified via the cmdline (using the `--config` flag), +falling back to these default config files: +- `$(pwd)/dehydrated-hook-ddns-tsig.conf` +- `/etc/dehydrate/dehydrated-hook-ddns-tsig.conf` +- `/usr/local/etc/dehydrate/dehydrated-hook-ddns-tsig.conf` + +The configuration file uses a simple `INI`-style syntax, +where you can set the parameters for each domain separately (by creating a section named after the domain), +with default values in the `[DEFAULT]` section. + +The following parameters can be set: +- `name_server_ip` the DNS server IP that will serve the ACME challenge (**required**) +- `TTL` time-to-live value for the challenge (default: *300*) +- `wait` time - in seconds - to wait before verifying that the challenge is really deployed/deleted; use negative values to skip the check (default: *5*) +- `verbosity` verbosity of the script: use negative values to suppress more messages (default: *0*) +- `key_name` name of the key to use for authentication with the DNS server (**required**, see [below](#using-an-extra-key-file)) +- `key_secret` the base64-encoded key secret (**required**, see [below](#using-an-extra-key-file)) +- `key_algorithm` the hashing algorithm of the key (default: *hmac-md5*) +- `dns_rewrite` a regular expression to rewrite the DNS record used to publish the challenge (default: no rewriting) + +A complete example can be found in the `dehydrated-hook-ddns-tsig.conf` file. + +### Using an extra key file +If you do not want to specify key name and key secret in the config file, +you can provide that information in an extra file. + +The script reads the name of this key file from the environmental variable `DDNS_HOOK_KEY_FILE` + +``` sh +$ export DDNS_HOOK_KEY_FILE="path/to/key/file.key" +``` + +The file must be formatted in an [rndc/bind](https://ftp.isc.org/isc/bind9/cur/9.9/doc/arm/man.rndc.conf.html) compatible way, +e.g. like: + +``` isc +key "testkey" { + secret "R3HI8P6BKw9ZwXwN3VZKuQ=="; + algorithm = hmac-md5; +}; +``` + +Only when using *this* method for acquiring the key, +you must have [iscpy](https://pypi.python.org/pypi/iscpy) installed. + + +## Usage +See the [dehydrated script](https://github.com/lukas2511/dehydrated) for more options. + +``` bash +$ cd dehydrated +$ ./dehydrated -c --challenge dns-01 --domain myblog.com --hook ./hooks/ddns-tsig/dehydrated-hook-ddns-tsig.py +``` + +Or to test the script directly: + +``` bash +$ python dehydrated-hook-ddns-tsig.py deploy_challenge yourdomain.com - "Hello World" +$ python dehydrated-hook-ddns-tsig.py clean_challenge yourdomain.com - "Hello World" +``` + +## Contribute +Please open an issue or submit a pull request. diff --git a/dehydrated-hook-ddns-tsig.conf b/dehydrated-hook-ddns-tsig.conf new file mode 100644 index 0000000..4f7d038 --- /dev/null +++ b/dehydrated-hook-ddns-tsig.conf @@ -0,0 +1,50 @@ +## configuration file for dehydrated's ddns-tsig hook +# location: +# - $(pwd)/dehydrated-hook-ddns-tsig.conf +# - /etc/dehydrated/dehydrated-hook-ddns-tsig.conf +# - /usr/local/etc/dehydrated/dehydrated-hook-ddns-tsig.conf +# OR provided via the '--config' cmdline flag + +[DEFAULT] +## the nameserver that will serve the challenge +#name_server_ip = 10.0.0.1 +## time-to-live for the challenge +#TTL = 300 +## how long to wait to check whether the challenge is really served +#wait = 5 +## verbosity of the script: use negative values to suppress more messages) +#verbosity = 0 + +### encryption key +## you MUST have an encryption key to talk to a DNS-server. +## if values are omitted from this config-file, they will instead be read +## from the file specified in the DDNS_HOOK_KEY_FILE envvar. +## name of the key +key_name = testkey +## base64-encoded value of the key +key_secret = "R3HI8P6BKw9ZwXwN3VZKuQ==" +## key-algorithm to use (bind9 only supports hmac-md5) +#key_algorithm = hmac-md5 + +## DNS record rewriting +## If you generally use static zone files, and only have dynamic DNS enabled +## for a few other zones, you can setup a (static) CNAME record to point your +## _acme-challenge record into a dynamic zone. +## E.g. setting up a CNAME from _acme-challenge.domain.ext to +## domain.ext.dynamiczone.otherdomain.ext. +## This lets ACME check the challenge in a static zone, while only allowing +## dehydrated to update the dynamic entry. +#dns_rewrite = s/^_acme-challenge\.(.+)$/\1.dynamiczone.otherdomain.ext/ + + +## you can also call additional hook-scripts after each stage +## the configuration keys are 'post_' +## the arguments (and stagenames) are as documented for 'dehydrated' hooks +#post_deploy_cert = /script/to/dehydrated_hooks/deploy_cert.sh + +################################################### +## you can override values for a given domain +#[example.com] +#name_server_ip = 127.0.0.1 +#key_name = samplekey +#key_secret = 6FMfj43Osz4lyb24OIe2iGEz9lf1llJO+lz= \ No newline at end of file diff --git a/dehydrated-hook-ddns-tsig.py b/dehydrated-hook-ddns-tsig.py new file mode 100755 index 0000000..e72a994 --- /dev/null +++ b/dehydrated-hook-ddns-tsig.py @@ -0,0 +1,874 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# dehydrated-hook-ddns-tsig - dns-01 Challenge Hook Script for dehydrated +# +# This script uses the dnspython API to create and delete TXT records +# in order to prove ownership of a domain. +# +# Copyright (C) 2016 Elizabeth Ferdman https://eferdman.github.io +# Copyright (C) 2016 IOhannes m zmölnig +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +############################################################################ + +# callbacks: +# *deploy_challenge . +# *clean_challenge . +# deploy_cert . +# unchanged_cert DOMAIN> . +# invalid_challenge . +# request_failure . +# startup_hook. +# exit_hook. + +import re +import os +import sys +import time +import logging +import collections +import dns.resolver +import dns.tsig +import dns.tsigkeyring +import dns.update +import dns.query +from dns.exception import DNSException + + +# the default configuration +defaults = { + "configfiles": [ + "/etc/dehydrated/dehydrated-hook-ddns-tsig.conf", + "/usr/local/etc/dehydrated/dehydrated-hook-ddns-tsig.conf", + "dehydrated-hook-ddns-tsig.conf", ], + "name_server_ip": '10.0.0.1', + "ttl": 300, + "sleep": 5, + "loglevel": logging.WARN, + "dns_rewrite": None, + } +# valid key algorithms (but bind9 only supports hmac-md5) +key_algorithms = { + "": dns.tsig.HMAC_MD5, + "hmac-md5": dns.tsig.HMAC_MD5, + "hmac-sha1": dns.tsig.HMAC_SHA1, + "hmac-sha224": dns.tsig.HMAC_SHA224, + "hmac-sha256": dns.tsig.HMAC_SHA256, + "hmac-sha384": dns.tsig.HMAC_SHA384, + "hmac-sha512": dns.tsig.HMAC_SHA512, + } + +# Configure some basic logging +logger = logging.getLogger(__name__) +logger.addHandler(logging.StreamHandler()) + + +def set_verbosity(verbosity): + level = int(defaults["loglevel"] - (10 * verbosity)) + if level <= 0: + level = 1 + logger.setLevel(level) + + +set_verbosity(0) + + +def post_hook(name, cfg, args): + key = "post_%s" % (name,) + if key in cfg: + import subprocess + callargs = [cfg[key], name] + for a in args: + callargs += [cfg[a]] + logger.info(' + Calling post %s hook: %s' % (name, ' '.join(callargs))) + subprocess.call(callargs) + return + + +def get_key_algo(name='hmac-md5'): + try: + return key_algorithms[name] + except KeyError: + logger.debug("", exc_info=True) + logger.fatal("""Invalid key-algorithm '%s' +Only the following algorithms are allowed: %s""" + % (name, " ".join(key_algorithms.keys()))) + sys.exit(1) + + +def get_isc_key(): + try: + import iscpy + except ImportError: + logger.debug("", exc_info=True) + logger.fatal("""The 'iscpy' module is required to read keys from isc-config file. +Alternatively set key_name/key_secret in the configuration file""") + sys.exit(1) + key_file = os.environ.get('DDNS_HOOK_KEY_FILE') + + # Open the key file for reading + try: + f = open(key_file, 'rU') + except IOError: + logger.debug("", exc_info=True) + logger.fatal("""Unable to read isc-config file! +Did you set the DDNS_HOOK_KEY_FILE env? +Alternatively set key_name/key_secret in the configuration file""") + sys.exit(1) + + # Parse the key file + parsed_key_file = iscpy.ParseISCString(f.read()) + + # Grab the keyname, cut out the substring "key " + # and remove the extra quotes + key_name = parsed_key_file.keys()[0][4:].strip('\"') + + # Grab the secret key + secret = parsed_key_file.values()[0]['secret'].strip('\"') + algorithm = parsed_key_file.values()[0]['algorithm'].strip('\"') + f.close() + + return (key_name, algorithm, secret) + + +def query_NS_record(domain_name): + """get the nameservers for + +Return a list of nameserver IPs (might be empty) +""" + name_list = domain_name.split('.') + for i in range(0, len(name_list)): + nameservers = [] + try: + fqdn = '.'.join(name_list[i:]) + for rdata in dns.resolver.query(fqdn, dns.rdatatype.NS): + ns = rdata.target.to_unicode() + nsL = [] + nsL.extend([_.to_text() for _ in dns.resolver.query(ns)]) # default type: A + nsL.extend([_.to_text() for _ in dns.resolver.query(ns, rdtype=dns.rdatatype.AAAA)]) + nameservers.append(nsL) + except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN) as e: + continue + if nameservers: + return nameservers + return list() + + +def verify_record(domain_name, + nameservers, + rtype='A', + rdata=None, + timeout=0, + invert=False): + """verifies that a certain record is present on all nameservers + +Checks whether an record for is present on +all IPs listed in . +If is not None, this also verifies that at least one field +in each nameserver is . + +If is True, the verification is inverted +(the record must NOT be present). + +Return True if the record could be verified, false otherwise. + +""" + resolver = dns.resolver.Resolver(configure=False) + now = None + if timeout and timeout > 0: + now = time.time() + resolver.timeout = timeout + + for ns in nameservers: + if now and ((time.time() - now) > timeout): + return False + logger.info(" + Verifying %s %s %s=%s @%s" + % (domain_name, + "lacks" if invert else "has", + rtype, + rdata if rdata is not None else "*", + ns)) + resolver.nameservers = ns + answer = [] + try: + answer = [_.to_text().strip('"'+"'") + for _ in resolver.query(domain_name, rtype)] + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer) as e: + # probably not there yet... + logger.debug( + "Unable to verify %s record for %s @ %s" % + (rtype, domain_name, ns)) + if not invert: + return False + + if rdata is None: + if not (invert ^ bool(answer)): + return False + else: + if not (invert ^ (rdata in answer)): + return False + return True + + +# Create a TXT record through the dnspython API +# Example code at +# https://github.com/rthalley/dnspython/blob/master/examples/ddns.py +def create_txt_record( + domain_name, token, + name_server_ip, + keyring, keyalgorithm=dns.tsig.HMAC_MD5, + ttl=300, + sleep=5, + timeout=10, + rewrite=None, + ): + domain_name = "_acme-challenge." + domain_name + domain_names = [] + if rewrite: + d2 = rewrite(domain_name) + if d2 != domain_name: + domain_names += [d2] + domain_names += [domain_name] + + def _do_create_txt(dn): + domain_list = dn.split('.') + logger.info(' + Creating TXT record "%s" for the domain %s' + % (token, dn)) + + for i in range(0, len(domain_list)): + head = '.'.join(domain_list[:i]) + tail = '.'.join(domain_list[i:]) + update = dns.update.Update( + tail, + keyring=keyring, + keyalgorithm=keyalgorithm) + update.add(head, ttl, 'TXT', token) + logger.debug(str(update)) + try: + response = dns.query.udp( + update, + name_server_ip, + timeout=timeout) + rcode = response.rcode() + logger.debug(" + Creating TXT record %s -> %s returned %s" % ( + head, tail, + dns.rcode.to_text(rcode))) + if rcode is dns.rcode.NOERROR: + return dn + except DNSException as err: + logger.debug("", exc_info=True) + logger.error( + "Error creating TXT record %s %s: %s" % + (head, tail, err)) + + name = None + for dn in domain_names: + name = _do_create_txt(dn) + if name: + break + + # Wait for DNS record to propagate + if name: + if (sleep < 0): + return + + microsleep = min(1, sleep/3.) + nameservers = query_NS_record(name) + if not nameservers: + nameservers = [name_server_ip] + now = time.time() + while (time.time() - now < sleep): + try: + if verify_record(name, + nameservers, + rtype='TXT', + rdata=token, + timeout=sleep, + invert=False): + logger.info(" + TXT record successfully added!") + return + except Exception: + logger.debug("", exc_info=True) + logger.fatal( + "Unable to check if TXT record was successfully inserted") + sys.exit(1) + time.sleep(microsleep) + + logger.fatal(" + TXT record not added.") + sys.exit(1) + + +# Delete the TXT record using the dnspython API +def delete_txt_record( + domain_name, token, + name_server_ip, + keyring, keyalgorithm=dns.tsig.HMAC_MD5, + ttl=300, + sleep=5, + timeout=10, + rewrite=None, + ): + domain_name = "_acme-challenge." + domain_name + domain_names = [] + if rewrite: + d2 = rewrite(domain_name) + if d2 != domain_name: + domain_names += [d2] + domain_names += [domain_name] + + # Retrieve the specific TXT record + txt_record = dns.rdata.from_text( + dns.rdataclass.IN, + dns.rdatatype.TXT, + token) + + def _do_delete_txt(dn): + domain_list = dn.split('.') + logger.info( + ' + Deleting TXT record "%s" for the domain %s' % (token, dn) + ) + + for i in range(0, len(domain_list)): + head = '.'.join(domain_list[:i]) + tail = '.'.join(domain_list[i:]) + # Attempt to delete the TXT record + update = dns.update.Update( + tail, + keyring=keyring, + keyalgorithm=keyalgorithm) + update.delete(head, txt_record) + logger.debug(str(update)) + try: + response = dns.query.udp( + update, + name_server_ip, + timeout=timeout) + rcode = response.rcode() + logger.debug(" + Removing TXT record %s -> %s returned %s" % ( + head, tail, + dns.rcode.to_text(rcode))) + if rcode is dns.rcode.NOERROR: + return dn + except DNSException as err: + logger.debug("", exc_info=True) + logger.error( + "Error deleting TXT record %s %s: %s" % + (head, tail, err)) + + name = None + for dn in domain_names: + name = _do_delete_txt(dn) + if name: + break + + if (name): + # Wait for DNS record to propagate + if (sleep < 0): + return + + microsleep = min(1, sleep/3.) + nameservers = query_NS_record(name) + if not nameservers: + nameservers = [name_server_ip] + now = time.time() + while (time.time() - now < sleep): + try: + if verify_record(name, + nameservers, + rtype='TXT', + rdata=token, + timeout=sleep, + invert=True): + logger.info(" + TXT record successfully deleted!") + return + except Exception: + logger.debug("", exc_info=True) + logger.fatal( + "Unable to check if TXT record was successfully removed") + sys.exit(1) + time.sleep(microsleep) + + logger.fatal(" + TXT record not deleted.") + sys.exit(1) + + +# callback to show the challenge via DNS +def deploy_challenge(cfg): + ensure_config_dns(cfg) + create_txt_record( + cfg["domain"], cfg["token"], + cfg["name_server_ip"], + cfg["keyring"], cfg["keyalgorithm"], + ttl=cfg["ttl"], + sleep=cfg["wait"], + rewrite=cfg["dns_rewrite"], + ) + return post_hook('deploy_challenge', cfg, ['domain', 'tokenfile', 'token']) + + +# callback to clean the challenge from DNS +def clean_challenge(cfg): + ensure_config_dns(cfg) + delete_txt_record( + cfg["domain"], cfg["token"], + cfg["name_server_ip"], + cfg["keyring"], cfg["keyalgorithm"], + ttl=cfg["ttl"], + sleep=cfg["wait"], + rewrite=cfg["dns_rewrite"], + ) + return post_hook('clean_challenge', cfg, ['domain', 'tokenfile', 'token']) + + +# callback to deploy the obtained certificate +def deploy_cert(cfg): + """deploy obtained certificates [no-op]""" + return post_hook( + 'deploy_cert', cfg, + ['domain', + 'keyfile', 'certfile', + 'fullchainfile', 'chainfile', 'timestamp']) + + +# callback when the certificate has not changed +# (currently unimplemented) +def unchanged_cert(cfg): + """called when certificated is still valid [no-op]""" + return post_hook( + 'unchanged_cert', cfg, + ['domain', 'keyfile', 'certfile', 'fullchainfile', 'chainfile']) + + +# challenge response has failed +def invalid_challenge(cfg): + """challenge response failed [no-op]""" + return post_hook( + 'invalid_challenge', cfg, + ['domain', 'response']) + + +# something went wrong when talking to the ACME-server +def request_failure(cfg): + """called when HTTP requests failed (e.g. ACME server is busy [no-op])""" + return post_hook( + 'request_failure', cfg, + ['statuscode', 'reason', 'reqtype']) + + +def startup_hook(cfg): + """Called at beginning of cron-command, for some initial tasks +(e.g. start a webserver)""" + return post_hook('request_failure', cfg, []) + + +def exit_hook(cfg): + """Called at end of cron command, to do some cleanup""" + return post_hook('request_failure', cfg, []) + + +def rewriter(sed): + if not sed: + return None + try: + cmd, pattern, repl, options = re.split(r'(?