diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2021-09-25 05:22:00 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2021-09-25 05:22:00 +0000 |
commit | e8f1973be087808bca32b05fb3367fcd0518a3c0 (patch) | |
tree | d8ff1309bf24cd37e8ad10d56c874f547d823216 | |
parent | Initial commit. (diff) | |
download | dehydrated-hook-ddns-tsig-e8f1973be087808bca32b05fb3367fcd0518a3c0.tar.xz dehydrated-hook-ddns-tsig-e8f1973be087808bca32b05fb3367fcd0518a3c0.zip |
Adding upstream version 0.1.4.upstream/0.1.4upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
-rw-r--r-- | COPYING | 20 | ||||
-rw-r--r-- | README.md | 81 | ||||
-rw-r--r-- | dehydrated-hook-ddns-tsig.conf | 50 | ||||
-rwxr-xr-x | dehydrated-hook-ddns-tsig.py | 874 |
4 files changed, 1025 insertions, 0 deletions
@@ -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 <http://www.gnu.org/licenses/>. + 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_<stagename>' +## 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 <zmoelnig@iem.at> +# +# 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 <http://www.gnu.org/licenses/>. +# +############################################################################ + +# callbacks: +# *deploy_challenge <DOMAIN> <TOKEN_FILENAME> <TOKEN_VALUE>. +# *clean_challenge <DOMAIN> <FILENAME> <TOKEN_VALUE>. +# deploy_cert <DOMAIN> <KEYFILE> <CERTFILE> <FULLCHAIN> <CHAINFILE> <TSTAMP>. +# unchanged_cert DOMAIN> <KEYFILE> <CERTFILE> <FULLCHAINFILE> <CHAINFILE>. +# invalid_challenge <DOMAIN> <RESPONSE>. +# request_failure <STATUSCODE> <REASON> <REQTYPE>. +# 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 <name> + +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 <rtype> record for <domain_name> is present on +all IPs listed in <nameservers>. +If <rdata> is not None, this also verifies that at least one <rtype> field +in each nameserver is <rdata>. + +If <invert> 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'(?<![^\\]\\)/', sed) + if cmd != 's': + logger.warn( + "invalid string-transformation '%s', must be 's/PTRN/REPL/'", + sed) + return None + regex = re.compile(pattern) + return lambda s: regex.sub( + re.sub(r'\\/', '/', repl), + s, + count='g' not in options) + except Exception as e: + logger.debug("", exc_info=True) + logger.warn("invalid string-transformation '%s'" % (sed)) + return + + +def ensure_config_dns(cfg): + """make sure that the configuration can be used to update the DNS +(e.g. read rndc-key if missing; fix some values if present) +""" + # (str)key_name + # (str)key_secret + # (str)key_algorithm + # (str)name_server_ip + # (int)ttl + # (float)wait + + try: + key_name = cfg["key_name"] + key_secret = cfg["key_secret"] + except KeyError: + (key_name, key_algorithm, key_secret) = get_isc_key() + + keyringd = {key_name: key_secret} + keyring = dns.tsigkeyring.from_text(keyringd) + cfg["keyring"] = keyring + + try: + algo = cfg["key_algorithm"] + except KeyError: + algo = key_algorithm if key_algorithm else "" + algo = get_key_algo(algo) + cfg["keyalgorithm"] = algo + + if "ttl" in cfg: + cfg["ttl"] = int(float(cfg["ttl"])) + else: + cfg["ttl"] = defaults["ttl"] + + if "wait" in cfg: + cfg["wait"] = float(cfg["wait"]) + else: + cfg["wait"] = defaults["sleep"] + + if "name_server_ip" not in cfg: + cfg["name_server_ip"] = defaults["name_server_ip"] + + cfg["dns_rewrite"] = rewriter( + cfg.get("dns_rewrite") + or defaults.get("dns_rewrite")) + + return cfg + + +def read_config(args): + """ +read configuration file (as specified in args), +merge it with the things specified in args +and return a list of config-dictionaries. + +e.g. [{'domain': 'example.com', 'tokenfile': '-', 'token': 'secret', + 'verbosity': 1, 'key_name': 'bla', 'key_value': '...'},] +""" + try: + import configparser + except ImportError: + import ConfigParser as configparser + + cfgfiles = defaults["configfiles"] + if args.config: + cfgfiles = args.config + + config = configparser.ConfigParser() + config.read(cfgfiles) + + # now merge args and conf + # we need to remove all the private args (used for building the argparser) + # args has the sub-command arguments as lists, + # because they can be given multiple times (hook-chain) + # we zip these dictionaries-of-lists into a list-of-dictionaries, + # and then iterate over the list, filling in more info from the config + + # remove some unwanted keys + argdict = dict((k, v) + for k, v in vars(args).items() + if not k.startswith("_")) + for k in ['config', ]: + try: + del argdict[k] + except KeyError: + pass + + # zip the dict-of-lists int o list-of-dicts + result = [_ + for _ in map(dict, zip(*[[(k, v[0]) for v in value] + for k, value in argdict.items() + if type(value) is list]))] + + # fill in the values from the configfile + for res in result: + domain = res['domain'] + if domain in config.sections(): + cfg = config[domain] + else: + cfg = config.defaults() + + for c in cfg: + res[c] = cfg[c] + + # special handling of 'verbosity': + # base_verbosity (configfile) + offset (cmdline) + verbosity = 0 + if args.verbose: + verbosity += args.verbose + if "verbosity" in cfg: + verbosity += float(cfg["verbosity"]) + res['verbosity'] = verbosity + + return result + + +def parse_args(): + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument( + "-c", "--config", + help="Read options from configuration files [%s]" + % (", ".join(defaults["configfiles"])), + action='append', + metavar="FILE") + parser.add_argument( + "-v", "--verbose", + help="Raise verbosity", + action='count', default=0) + parser.add_argument( + "-q", "--quiet", + help="Lower verbosity", + action='count', default=0) + + subparsers = parser.add_subparsers(help='sub-command help', dest='_hook') + + parser_deploychallenge = subparsers.add_parser( + 'deploy_challenge', + prefix_chars='+', + help='make ACME challenge available via DNS') + parser_deploychallenge.set_defaults( + _func=deploy_challenge, + _parser=parser_deploychallenge) + parser_deploychallenge.add_argument( + 'domain', + nargs=1, action='append', + help="domain name to request certificate for") + parser_deploychallenge.add_argument( + 'tokenfile', + nargs=1, action='append', + help="IGNORED") + parser_deploychallenge.add_argument( + 'token', + nargs=1, action='append', + help="ACME-provided token") + parser_deploychallenge.add_argument( + '_extra', + nargs='*', + metavar='...', + action='append', + help="domain1 tokenfile1 token1 ...", + ) + + parser_cleanchallenge = subparsers.add_parser( + 'clean_challenge', + prefix_chars='+', + help='remove ACME challenge from DNS') + parser_cleanchallenge.set_defaults( + _func=clean_challenge, + _parser=parser_cleanchallenge) + parser_cleanchallenge.add_argument( + 'domain', + nargs=1, action='append', + help="domain name for which to remove cetificate challenge") + parser_cleanchallenge.add_argument( + 'tokenfile', + nargs=1, action='append', + help="IGNORED") + parser_cleanchallenge.add_argument( + 'token', + nargs=1, action='append', + help="ACME-provided token") + parser_cleanchallenge.add_argument( + '_extra', + nargs='*', + metavar='...', + action='append', + help="domain1 tokenfile1 token1 ...", + ) + + parser_deploycert = subparsers.add_parser( + 'deploy_cert', + prefix_chars='+', + help='deploy certificate obtained from ACME [NO-OP]') + parser_deploycert.set_defaults( + _func=deploy_cert, + _parser=parser_deploycert) + parser_deploycert.add_argument( + 'domain', + nargs=1, action='append', + help="domain name to deploy certificate for") + parser_deploycert.add_argument( + 'keyfile', + nargs=1, action='append', + help="private certificate") + parser_deploycert.add_argument( + 'certfile', + nargs=1, action='append', + help="public certificate") + parser_deploycert.add_argument( + 'fullchainfile', + nargs=1, action='append', + help="full certificate chain") + parser_deploycert.add_argument( + 'chainfile', + nargs=1, action='append', + help="certificate chain") + parser_deploycert.add_argument( + 'timestamp', + nargs=1, action='append', + help="time stamp") + parser_deploycert.add_argument( + '_extra', + nargs='*', + metavar='...', + action='append', + help="domain1 keyfile1 certfile1 fullchainfile1 chainfile1 ts1 ...", + ) + + parser_unchangedcert = subparsers.add_parser( + 'unchanged_cert', + prefix_chars='+', + help='unchanged certificate obtained from ACME [NO-OP]') + parser_unchangedcert.set_defaults( + _func=unchanged_cert, + _parser=parser_unchangedcert) + parser_unchangedcert.add_argument( + 'domain', + nargs=1, action='append', + help="domain name, for which the certificate hasn't changed") + parser_unchangedcert.add_argument( + 'keyfile', + nargs=1, action='append', + help="private certificate") + parser_unchangedcert.add_argument( + 'certfile', + nargs=1, action='append', + help="public certificate") + parser_unchangedcert.add_argument( + 'fullchainfile', + nargs=1, action='append', + help="full certificate chain") + parser_unchangedcert.add_argument( + 'chainfile', + nargs=1, action='append', + help="certificate chain") + parser_unchangedcert.add_argument( + '_extra', + nargs='*', + metavar='...', + action='append', + help="domain1 keyfile1 certfile1 fullchainfile1 chainfile1 ...", + ) + + parser_invalid_challenge = subparsers.add_parser( + 'invalid_challenge', + prefix_chars='+', + help='challenge response has failed [NO-OP]') + parser_invalid_challenge.set_defaults( + _func=invalid_challenge, + _parser=parser_invalid_challenge) + parser_invalid_challenge.add_argument( + 'domain', + nargs=1, action='append', + help="The primary domain name, i.e. the certificate common name (CN)") + parser_invalid_challenge.add_argument( + 'response', + nargs=1, action='append', + help="The response that the verification server returned.") + parser_invalid_challenge.add_argument( + '_extra', + nargs='*', + metavar='...', + action='append', + help="domain1 response1 ...", + ) + + parser_request_failure = subparsers.add_parser( + 'request_failure', + prefix_chars='+', + help='challenge response has failed [NO-OP]') + parser_request_failure.set_defaults( + _func=request_failure, + _parser=parser_request_failure) + parser_request_failure.add_argument( + 'statuscode', + nargs=1, action='append', + help="The HTML status code that originated the error.") + parser_request_failure.add_argument( + 'reason', + nargs=1, action='append', + help="The specified reason for the error.") + parser_request_failure.add_argument( + 'reqtype', + nargs=1, action='append', + help="The kind of request that was made (GET, POST...)") + parser_request_failure.add_argument( + '_extra', + nargs='*', + metavar='...', + action='append', + help="statuscode1 reason1 reqtype1 ...", + ) + + parser_startup_hook = subparsers.add_parser( + 'startup_hook', + prefix_chars='+', + help='dehydrated is starting up (do some initial tasks) [NO-OP]') + parser_startup_hook.set_defaults( + _func=startup_hook, + _parser=parser_startup_hook) + + parser_exit_hook = subparsers.add_parser( + 'exit_hook', + prefix_chars='+', + help='dehydrated is shutting down (do some final tasks) [NO-OP]') + parser_exit_hook.set_defaults( + _func=exit_hook, + _parser=parser_exit_hook) + + # prepare to ignore unrecognised hooks + fallback_parser = argparse.ArgumentParser() + fallback_parser.add_argument('_hook_arg', nargs='*') + subparsers._name_parser_map = collections.defaultdict( + lambda: fallback_parser, + subparsers._name_parser_map) + subparsers.choices = None + + args = parser.parse_args() + try: + while(args._extra[0]): + extra = args._extra[0] + args._extra = [] + args = args._parser.parse_args(extra, args) + except AttributeError: + # no '_extra' attribute in this sub-parser + pass + + verbosity = args.verbose - args.quiet + args.verbose = None + args.quiet = None + if verbosity: + args.verbose = verbosity + + set_verbosity(verbosity) + + cfg = read_config(args) + + # ignore unrecognised hooks + if not hasattr(args, '_func'): + logger.debug("ignoring unknown hook: %s", args._hook) + return (lambda cfg: None, cfg) + + return (args._func, cfg) + + +if __name__ == '__main__': + (fun, cfgs) = parse_args() + for cfg in cfgs: + set_verbosity(cfg['verbosity']) + fun(cfg) + sys.exit(0) |