pax_global_header00006660000000000000000000000064133214731420014512gustar00rootroot0000000000000052 comment=86400fe523f926c5f30a16532fad4c2636ef6269 dehydrated-hook-ddns-tsig-0.1.4/000077500000000000000000000000001332147314200164615ustar00rootroot00000000000000dehydrated-hook-ddns-tsig-0.1.4/COPYING000066400000000000000000000015771332147314200175260ustar00rootroot00000000000000# 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 . dehydrated-hook-ddns-tsig-0.1.4/README.md000066400000000000000000000072641332147314200177510ustar00rootroot00000000000000# 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. dehydrated-hook-ddns-tsig-0.1.4/dehydrated-hook-ddns-tsig.conf000066400000000000000000000037001332147314200242750ustar00rootroot00000000000000## 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=dehydrated-hook-ddns-tsig-0.1.4/dehydrated-hook-ddns-tsig.py000077500000000000000000000647251332147314200240210ustar00rootroot00000000000000#!/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'(?