Inhaltsverzeichnis
Delegating Let's Encrypt dns-01 to a dedicated zone
Problem
Let's Encryt offers ACME dns-01 challenges. Implementing them is straightforward, but has one drawback: since the zone is changed, the next time the zone file is written to disk, it is rewritten - and bind has really weird formatting.
Concept
Instead of responding to a challenge in individual zones, create one „delegate zone“ that receives the challenges via CNAME records. We don't care if that zone file gets trashed.
For this example, we'll use the following setup:
ns1.example.com
- running bind9, and is theNS
for the other domains.ns2.example.com
is a secondary.client1.example
- a domain we want to create certs forclient2.test
- another one- dehydrate to manage certs
Setup
Domain Setup
First, we need the zone that will handle all our ACME challenges. This is a regular delegate domain (you may want to add appropriate DNSSEC to it):
- acme.example.com.zone
$ORIGIN . $TTL 3600 ; 1 hour acme.example.com IN SOA ns1.example.com. admin.example.com. ( 2023040100 ; serial 3600 ; refresh (1 hour) 3600 ; retry (1 hour) 86400 ; expire (1 day) 3600 ; minimum (1 hour) ) NS ns1.example.com. NS ns2.example.com. MX 0 . TXT "v=spf1 -all" CAA 0 issue "letsencrypt.org" CAA 0 issuewild "letsencrypt.org"
Next, we'll need some TSIG keys used for the nsupdate
calls.
tsig-keygen -a hmac-sha512 client1.example.acme > Kexample.client1._acme-challenge.key tsig-keygen -a hmac-sha512 client2.test.acme > Ktest.client2._acme-challenge.key cat *._acme-challenge.key >> /etc/bind/named.conf.keys
Now we can use those to enable updates to the ACME delegate zone. The general pattern will be that each certificate target will be prepended as a subdomain to the delegate, so it becomes ${domain}.acme.example.com
. We will create one of these entries for each client zone at the level where that zone's SOA
record sits, not for its subdomains. This is just a convention to make things clearer/self-documenting, no technical requirement.
- acme.example.com.cfg
zone "acme.example.com" IN { type master; file "/etc/bind/zones/acme.example.com.zone"; update-policy { grant client1.example.acme. subdomain client1.example.acme.example.com. TXT; grant client2.test.acme. subdomain client2.test.acme.example.com. TXT; }; };
On the side of the client domains, we need to tell ACME that it needs to use these zones for challenges. We do that by setting CNAME
records in the client zones:
- client1.example.zone
$ORIGIN client1.example. _acme-challenge CNAME _acme-challenge.client1.example.acme.example.com. $ORIGIN client2.example. _acme-challenge CNAME _acme-challenge.client1.example.acme.example.com.
Note that we point them all to the same location defined by the SOA+delegate-pattern explained above. In the second case, we even point a different domain to the same target. This could be used to share key material between multiple domains owned by the same entity.
Next, we can tell dehydrated to use this setup for challenges.
Dehydrated Setup
The dns-01
challenge is handled by a hook script based on this one which uses nsupdate
to send the zone changes to our name server.
- /etc/dehydated/hooks/nsupdate
#!/usr/bin/env bash # # Deployment script for DNS challenge using nsupdate # # Arguments: hook ACTION DOMAIN CTOKEN VTOKEN # # ACTION: The action the hook SHALL perform # clean_challenge Clean the validation token from the domain # deploy_challenge Deploy a new validation token for a domain # deploy_cert Deploy a certificate for a domain # invalid_challenge ??? # request_failure The request for a certificate failed # # DOMAIN: The domain to validate # # CTOKEN: The challenge token (unused with DNS-01) # # VTOKEN: The validation token that needs to be inserted into DNS set -e set -u set -o pipefail KEYDIR="/etc/dehydrated/nsupdate" SOA="${2:-default}" SOALIST="${SOA}" while [[ "${SOA}" == *"."* ]]; do SOA="${SOA#*.}" SOALIST="${SOALIST} ${SOA}" done for SOA in ${SOALIST}; do KEYFILE="${KEYDIR}/${SOA}.acme.conf" if [ -r "${KEYFILE}" ]; then if [ -L "${KEYFILE}" ]; then KEYFILE="$(readlink -m ${KEYFILE})" SOA="${KEYFILE##*/}" SOA="${SOA%.acme.conf}" fi break fi unset KEYFILE done KEYFILE="${KEYFILE:-/dev/null}" NSUPDATE="nsupdate -k ${KEYFILE}" DNSSERVER="ns1.example.com" ZONE=".acme.example.com" TTL=600 CHALLENGE=$(printf "_acme-challenge.%s%s." "${SOA}" "${ZONE}") case "$1" in deploy_challenge) printf "server %s\nupdate add %s %d IN TXT \"%s\"\nsend\n" "${DNSSERVER}" "${CHALLENGE}" "${TTL}" "${4}" | $NSUPDATE ;; clean_challenge) printf "server %s\nupdate delete %s %d IN TXT \"%s\"\nsend\n" "${DNSSERVER}" "${CHALLENGE}" "${TTL}" "${4}" | $NSUPDATE ;; deploy_cert) # optional: # /path/to/deploy_cert.sh "$@" ;; unchanged_cert) # do nothing for now ;; startup_hook) # do nothing for now ;; exit_hook) # do nothing for now ;; esac exit 0
This script will take the incoming primary domain name requested for the new certificate and try to locate the TSIG key to use with it. These are expected to be present in the path defined by KEYDIR
, (/etc/dehydrated/nsupdate
by default) and have the name ${domain}.acme.conf
. Subdomains are automatically recursed.
In the case of multiple domains using the same key, the actual file is named for the SOA-root as defined above and other domains sharing it are symlinks to this file. In the example:
cp Kexample.client1._acme-challenge.key client1.example.acme.conf ln -s client2.example.acme.conf client1.example.acme.conf
Examples:
client1.example
→ found asclient1.example.acme.conf
subdomain.client1.example
, tryclient1.example
→ found asclient1.example.acme.conf
subdomain2.client2.example
, tryclient2.example
, follow link → found asclient1.example.acme.conf
The nsupdate
request is then generated from the final key file name, in all of the examples this will be _acme-challenge.client1.example.acme.example.com
. This must be the same as configured in the CNAME
records! This is why we're always using SOA roots, this makes it easier to check.
Summary
This is everything required to run dehydrated in a way that doesn't pollute zone files. This even works across machines, for example if some domains are physically located on different hosts. Only dehydrated, the hook and key files for the domains managed by that host's dehydrated need to be copied.
If something doesn't work, check grant
rules in update-policy
, validate that keys are known to bind9, check the requests performed by changing the NSUPDATE
commandline to nsupdate -d …
.