Using Certbot with Knot DNS (knsupdate)

This is a note about the integration of the Certbot with Let’s encrypt DNS–01 authentication mechanism. Will not use any cloud services (fuck cloud), just self-hosted DNS instances, like good old times. I’ll show how to configure Knot DNS to accept dynamic DNS updates from knsupdate and how to create a rudimentary hook for Certbot which will use knsupdate to set TXT records with _acme-challenge.

Knot-specific configuration

Say we have two DNS servers:

  • ns1.example.com
  • ns2.example.com

We need a key which will be used to sign our dynamic DNS updates. Here is how to generate it with keymgr (which is part of Knot):

$ keymgr tsig generate -t dyndns hmac-sha512 | tee /secrets/dyndns
# hmac-sha512:dyndns:Me2/vFaNQp7dNXeNt3NuMgRWujxO5g1HwyV8nmn1YerdulKhMoFe8UjBvEfFVTYJmBywSoX4VIDzuaCdlfTbXA==
key:
  - id: dyndns
    algorithm: hmac-sha512
    secret: Me2/vFaNQp7dNXeNt3NuMgRWujxO5g1HwyV8nmn1YerdulKhMoFe8UjBvEfFVTYJmBywSoX4VIDzuaCdlfTbXA=

For our example we saving this into /secrets/dyndns and will include this secret in configuration. Field with label id is mandatory and will be used to reference this secret from configuration.

Here is part of Knot configuration:

I assume you have Knot configured and running. If not then this is out of scope for this article. See Knot documentation.

include: /secrets/dyndns
acl:
  - id: dyndns
    key: dyndns
    action: update
  ...

template:
  - id: main
    acl: [ dyndns ]
    ...
  ...
zone:
  - domain: _acme-challenge.example.com
    template: main
    file: /data/_acme-challenge.example.com.zone

We define ACL which references previously generated key, then we reference ACL in template which will be used to control updates for _acme-challenge.example.com zone.

Why separate zone for _acme-challenge?
Because my configuration does not include master and slave roles so we have no notifications between servers, I have reasons to do this to mitigate possible zone serial collisions which may break whole zone (my zones are static read-only files provisioned with configuration managements system)

Now let’s describe /data/_acme-challenge.example.com.zone file:

$TTL 86400

_acme-challenge.example.com. 10 IN SOA ns1.example.com. root.example.com. (2022021700 86400 600 864000 60)

_acme-challenge.example.com. IN NS ns1.example.com.
_acme-challenge.example.com. IN NS ns2.example.com.

For any subdomains like service.example.com we should provide same NS records as we have in example.com zone or Let’s Encrypt API may complain.

This configuration should be enough for knsupdate to work. Will check in next step.

Dynamic DNS updates with knsupdate

You could find knsupdate manual here.

Let’s setup some env variables which we will use in code snippets below:

Pay attention to KNOT_KEY it will have value:
hmac-sha512:dyndns:Me2/vFaNQp7dNXeNt3NuMgRWujxO5g1HwyV8nmn1YerdulKhMoFe8UjBvEfFVTYJmBywSoX4VIDzuaCdlfTbXA==

export KNOT_SERVER=ns1.example.com
export KNOT_KEY=$(cat /secrets/dyndns | head -n 1 | sed 's/^#\s*//g')

export KNOT_ZONE=_acme-challenge.example.com
export KNOT_SUBJECT=example.com
export KNOT_SUBJECT_VALUE=test-txt-record-value

Set TXT record value with TTL = 60:

$ cat <<EOF | knsupdate
server ${KNOT_SERVER}
key ${KNOT_KEY}
zone ${KNOT_ZONE}.
update add _acme-challenge.${KNOT_SUBJECT}. 60 TXT "${KNOT_SUBJECT_VALUE}"
send
quit
EOF

This should silently exit with 0 code. In case of error you will get some messages.

Now let’s request our TXT record directly from the nameserver:

$ host -t TXT ${KNOT_ZONE}. ${KNOT_SERVER} | grep ${KNOT_ZONE}
_acme-challenge.example.com descriptive text "test-txt-record-value"

We have a value. Now let’s delete record:

$ cat <<EOF | knsupdate
server ${KNOT_SERVER}
key ${KNOT_KEY}
zone ${KNOT_ZONE}.
update del _acme-challenge.${KNOT_SUBJECT}. TXT
send
quit
EOF

Make sure it is really deleted:

$ host -t TXT ${KNOT_ZONE}. ${KNOT_SERVER} | grep ${KNOT_ZONE}
_acme-challenge.example.com has no TXT record

Good, we are ready to make Certbot hook.

Certbot hook

Certbot documentation about manual-auth-hook is here.

Also you can read about variables available inside hook here.

As we have do in previous paragraph, let’s define some variables which will be used in code snippets below:

export KNOT_SERVER=ns1.example.com
export KNOT_KEY=$(cat /secrets/dyndns | head -n 1 | sed 's/^#\s*//g')

export KNOT_ZONE=_acme-challenge.example.com
export KNOT_SUBJECT=example.com

It is time to write our hook. It will use knsupdate to update records on each DNS server of the KNOT_ZONE.

This hook will accept two subcommands/actions:

  • cleanup
  • auth (default, implicitly calls cleanup before execution)

Subcommand auth will be invoked if no subcommand has been provided.

Open certbot-knsupdate-hook.sh and write:

#! /usr/bin/env bash
set -e
set -o pipefail

nameservers=$(host -t NS ${KNOT_ZONE}. ${KNOT_SERVER}   \
                  | grep -F ' name server '             \
                  | awk  -F ' name server ' '{print $2}')

cleanup() {
    if [ ! -z "${CERTBOT_AUTH_OUTPUT}" ]
    then
        echo ${CERTBOT_AUTH_OUTPUT}
    fi

    for nameserver in ${nameservers}
    do
        cat <<EOF | knsupdate || true
server ${nameserver}
key ${KNOT_KEY}
zone ${KNOT_ZONE}.
update del _acme-challenge.${KNOT_SUBJECT}. TXT
send
quit
EOF
    done
}

##

case "$1" in
    cleanup)
        cleanup
        ;;
    auth | *)
        cleanup
        trap cleanup ERR
        for nameserver in ${nameservers}
        do
            cat <<EOF | knsupdate
server ${nameserver}
key ${KNOT_KEY}
zone ${KNOT_ZONE}.
update add _acme-challenge.${KNOT_SUBJECT}. 60 TXT "${CERTBOT_VALIDATION}"
send
quit
EOF
        done
        ;;
esac

We have two subcommands, but certbot may only run scripts, it can not pass any arguments to this scripts. And we need to call cleanup after auth.

Because of this we need to create a separate wrapper for cleanup subcommand, open certbot-knsupdate-cleanup-hook.sh and write:

#! /usr/bin/env bash
exec ./certbot-knsupdate-hook.sh cleanup

Of course all .sh should have x chmod, so don’t forget to chmod +x them

We are ready to run Certbot with this hooks:

Will be using staging certificate authority (--test-cert option)

$ mkdir acme
$ certbot certonly                                                  \
          --manual -n -m root@example.com --agree-tos               \
          --test-cert --expand                                      \
          --config-dir=./acme                                       \
          --work-dir=./acme                                         \
          --logs-dir=./acme                                         \
          --preferred-challenges=dns                                \
          --manual-auth-hook=./certbot-knsupdate-hook.sh            \
          --manual-cleanup-hook=./certbot-knsupdate-cleanup-hook.sh \
          -d "*.${KNOT_SUBJECT}" -d "${KNOT_SUBJECT}"

This will issue a wildcard certificate for KNOT_SUBJECT and add non-wildcard hostname as alternative name into the certificate.

Certbot will output something similar to:

Saving debug log to /home/user/projects/corpix.dev/acme/letsencrypt.log

Successfully received certificate.
Certificate is saved at: /home/user/projects/corpix.dev/acme/live/example.com/fullchain.pem
Key is saved at: /home/user/projects/corpix.dev/acme/live/example.com/privkey.pem

This certificate expires on 2022-05-18.
These files will be updated when the certificate renews.

NEXT STEPS:
- The certificate will need to be renewed before it expires. Certbot can automatically renew the certificate in the background, but you may need to take steps to enable that functionality. See https://certbot.org/renewal-setup for instructions.

If it complains see ./acme/letsencrypt.log, it should contain some information which helps.

Finally we could dump certificate information with openssl to make sure it contains expected data:

$ openssl x509 -noout -text -in ./acme/live/example.com/cert.pem
Certificate:
    Data:
...
        Issuer: C = US, O = (STAGING) Let's Encrypt, CN = (STAGING) Artificial Apricot R3
        Validity
            Not Before: Feb 17 15:06:00 2022 GMT
            Not After : May 18 15:06:00 2022 GMT
        Subject: CN = *.example.com

...

            Authority Information Access:
                OCSP - URI:http://stg-r3.o.lencr.org
                CA Issuers - URI:http://stg-r3.i.lencr.org/

            X509v3 Subject Alternative Name:
                DNS:*.example.com, DNS:example.com
...