Benutzer-Werkzeuge

Webseiten-Werkzeuge


pr:le-dns-delegate

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 the NS for the other domains. ns2.example.com is a secondary.
  • client1.example - a domain we want to create certs for
  • client2.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 as client1.example.acme.conf
  • subdomain.client1.example, try client1.example → found as client1.example.acme.conf
  • subdomain2.client2.example, try client2.example, follow link → found as client1.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 ….

Resources

pr/le-dns-delegate.txt · Zuletzt geändert: 2023/05/03 22:25 von martok