summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2021-09-25 05:22:00 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2021-09-25 05:22:00 +0000
commite8f1973be087808bca32b05fb3367fcd0518a3c0 (patch)
treed8ff1309bf24cd37e8ad10d56c874f547d823216
parentInitial commit. (diff)
downloaddehydrated-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--COPYING20
-rw-r--r--README.md81
-rw-r--r--dehydrated-hook-ddns-tsig.conf50
-rwxr-xr-xdehydrated-hook-ddns-tsig.py874
4 files changed, 1025 insertions, 0 deletions
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 <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)