pax_global_header 0000666 0000000 0000000 00000000064 13155673113 0014517 g ustar 00root root 0000000 0000000 52 comment=a216d0461150ab97d08ca8ebbb772ba34afc67b5
dehydrated-hook-ddns-tsig-0.1.2/ 0000775 0000000 0000000 00000000000 13155673113 0016464 5 ustar 00root root 0000000 0000000 dehydrated-hook-ddns-tsig-0.1.2/COPYING 0000664 0000000 0000000 00000001577 13155673113 0017531 0 ustar 00root root 0000000 0000000 # 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.2/README.md 0000664 0000000 0000000 00000007264 13155673113 0017754 0 ustar 00root root 0000000 0000000 # 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.2/dehydrated-hook-ddns-tsig.conf 0000664 0000000 0000000 00000003700 13155673113 0024300 0 ustar 00root root 0000000 0000000 ## 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.2/dehydrated-hook-ddns-tsig.py 0000775 0000000 0000000 00000062727 13155673113 0024024 0 ustar 00root root 0000000 0000000 #!/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 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('\"')
f.close()
return (key_name, 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:
for ns in [rdata.target.to_unicode()
for rdata in dns.resolver.query('.'.join(name_list[i:]),
'NS')]:
nameservers += [_.to_text() for _ in dns.resolver.query(ns)]
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(1, 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(1, 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'(?