DNS over TLS with LetsEncrypt

6 min read Original article ↗

6 months ago LetsEncrypt announced that they would start issuing certificates for IP addresses. Last week I was curious if they had actually enabled it yet for general consumption, it turned out to be not yet available for everybody, but there was a forum thread you could ask to be added to the testing list (I’ve not linked to it as they have said no more testing, it will go live RSN).

When it was announced it was available on their Staging environment. This behaves just like their production environment, just that the root certificate is not included in all the public trust stores (e.g. in your browser or shipped with your OS). But it does allow you to test things. So after the initial announcement I had tried to have a play to see if I got get a certificate to use with my DNS server to support DNS over TLS.

ACME Clients

LetsEncrypt issue certificates using a protocol called ACME. I’ve talked about ACME before, but mainly on the Server side as I run my own internal private Certificate Authority which can issue certificates using ACME. But there are also a number of different client side implementations available.

The standard one is called certbot which is maintained by the EFF, but as of December 2025 doesn’t support requesting certificates using IP addresses just yet.

Back in August when I was first looking one of the clients that had already implemented support for the new shortlived profile that supports IP address based certificates was lego, which is written in go.

LEGO

lego supports a bunch of different ACME authentication mechanisms to prove ownership of the domain/IP address that the certificate is being requested for, but the shortlived profile when combined with a request for a IP address SAN entry requires that either the http-01 or the tls-alpn-01 method is used (because this proves you can run a HTTP server on the IP address you are requesting a certificate for)

The following command uses and existing HTTP server running on the machine that is serving static content from the /var/www/html directory and uses the LetsEncrypt staging server.

The -d values specifies the hostnames and IP addresses that should be included in the certificate.

--profile shortlived indicates which LetsEncrypt profile to use

lego -d dns.hardill.me.uk -d 81.187.174.10 \
  -d 2001:8b0:2c1:4b4e::2 --http.webroot /var/www/html \
  -m ben@example.com --http \
  -s https://acme-staging-v02.api.letsencrypt.org/directory run \
  --profile shortlived

It creates/uses a LetsEncrypt an account using the ben@example.com email address.

The certificates are stored under the ./lego/certificates directory in the user that runs the commands home page.

This all worked but because the root certificate for the staging server is not trusted by default on most peoples devices I didn’t actually deploy the certificate. Now I’m on the early testing list. The command was modified as follows:

lego -d dns.hardill.me.uk -d 81.187.174.10 \
  -d 2001:8b0:2c1:4b4e::2 --http.webroot /var/www/html \
  -m ben@example.com --http run --profile shortlived

The difference is removing the -s option that changed the default server to contact to the staging environment.

Bind9

Now I have real certificates, time to setup my DNS server.

I’m running bind v9 on a debian derived Linux distribution so all the configuration files live in /etc/bind by default. There are 3 default files named.conf, named.conf.options and named.conf.local.

To enable DNS over TLS first we need to load the certificate and key so I created a new file called named.conf.tls

tls local-tls {
  key-file "/etc/bind/dns.hardill.me.uk.key";
  cert-file "/etc/bind/dns.hardill.me.uk.crt";
};

I had copied the certificate and key file to the /etc/bind directory and made them readable by the bind user.

named.conf just includes the other files, so I added the new named.conf.tls file to the list.

include "/etc/bind/named.conf.tls";
include "/etc/bind/named.conf.options";
include "/etc/bind/named.conf.local";

named.conf.options is where a bunch of configured, but this is where we will add the TLS listener on port 853.

options {
  dnssec-validation yes;
  allow-query { any; };
  // recursive internal only
  allow-query-cache { 127.0.0.1; 192.168.1.0/24; };
  allow-recursion { 127.0.0.1; 192.168.1.0/24; };
  listen-on { any; };
  listen-on-v6 { any; };
  //TLS
  listen-on port 853 tls local-tls { any; };
  listen-on-v6 port 853 tls local-tls { any; };
  rate-limit {
    responses-per-second 40;
  };
};

The changes are the listen-on and listen-on-v6 with port 853 which is the default DNS over TLS port, the tls flag says to use the certificates that where loaded with the local-tls name in the earlier section.

named.conf.local is where my actual zones are configures and no changes are required here.

Renewing Certificates

I’ve mentioned a few times now that LetsEncrypt’s IP based certificates use the shortlived profile. Certificates issued under the default profile have a 90 day life time (this is due to come down to 45 days 2028), but the certificates issued by the shortlived profile only last 160 hours (just short of 7 days).

This means that automating renewing is pretty much required, as having to manually renew them once a week (really every 5 days) is going to get boring really quick.

The lego command to renew looks like this

lego -d dns.hardill.me.uk -d 81.187.174.10 -d 2001:8b0:2c1:4b4e::2 --http.webroot /var/www/html -m ben@example.com --http renew --dynamic --profile shortlived --renew-hook="/home/ben/bin/new-dns-cert.sh"

The change is from action run to renew and adding the --renew-hook which is a script to run after the certificate has been renewed. The --dynamic signals to only renew the certificate if more than 50% of it’s life has past for shortlived certs..

This renew hook script copies the files to the /etc/bind directory and then triggers bind to reload.

I placed that command in a file called /home/ben/bin/renew-dns-cert.sh, now we have the renewal scripted we need to run this every day to make sure the cert is renewed before it expires.

Systemd Timers

The old way to do this would be to run the script would be to setup a cron job, but the new approach is to set up a systemd timer.

These can be global or on a user basis, in this case I’ll setup a user time. To do this I need to create 2 files in the /home/ben/.config/systemd/user directory.

The first is lego.service which says to run the shell script /home/ben/bin/renew-dns-cert.sh which runs the renewal command I mentioned earlier.

[Unit]
Description=Renew DNS Certificate

[Service]
ExecStart=/home/ben/bin/renew-dns-cert.sh

The second is the actual timer.

[Unit]
Description=Renew DNS certificates

[Timer]
Persistent=true

# instead, use a randomly chosen time:
OnCalendar=*-*-* 3:35
# add extra delay, here up to 1 hour:
RandomizedDelaySec=1h

[Install]
WantedBy=timers.target

The important bit here is the RanomizedDelaySec=1h which adds a random number of seconds to the `3:35 time to help spread things out a bit.

This is all enabled with

systemclt --user enable lego.timer

Testing

The dig tool can be used to test the new DNS over TLS listener.

dig +tls www.hardill.me.uk @dns.hardill.me.uk

Or using the raw IPv6 address.

dig +tls-ca www.hardill.me.uk @2001:8b0:2c1:4b4e::2

The +tls-ca tells dig to validate the certificate, +tls will use DNS over TLS but not validate the certificate matches the DNS server.

Fediverse Reactions