347 lines
16 KiB
Markdown
347 lines
16 KiB
Markdown
---
|
|
date: "2023-03-24T10:56:54Z"
|
|
title: 'Case Study: Let''s Encrypt DNS-01'
|
|
aliases:
|
|
- /s/articles/2023/03/24/lego-dns01.html
|
|
---
|
|
|
|
Last week I shared how IPng Networks deployed a loadbalanced frontend cluster of NGINX webservers
|
|
that have public IPv4 / IPv6 addresses, but talk to a bunch of internal webservers that are in a
|
|
private network which isn't directly connected to the internet, so called _IPng Site Local_
|
|
[[ref]({{< ref "2023-03-11-mpls-core" >}})] with addresses **198.19.0.0/16** and
|
|
**2001:678:d78:500::/56**.
|
|
|
|
I wrote in [[that article]({{< ref "2023-03-17-ipng-frontends" >}})] that IPng will be using
|
|
_ACME_ HTTP-01 validation, which asks the certificate authority, in this case Let's Encrypt, to
|
|
contact the webserver on a well-known URI for each domain that I'm requesting a certificate for.
|
|
Unsurprisingly, several folks reached out to me asking "well what about DNS-01", and one sentence
|
|
caught their eye:
|
|
|
|
> Some SSL certificate providers allow for wildcards (ie. `*.ipng.ch`), but I'm going to keep it
|
|
> relatively simple and use [[Let's Encrypt](https://letsencrypt.org/)] which offers free
|
|
> certificates with a validity of three months.
|
|
|
|
I could've seen this one coming! The sentence can be read to imply it doesn't, but **of course**
|
|
Let's Encrypt offers wildcard certificates. It just doesn't satisfy my _relatively simple_ qualifier
|
|
of the second part of the sentence ... So here I go, down the rabbit hole that is understanding
|
|
(for myself, and possibly for readers of this article), how the DNS-01 challenge works, in greater
|
|
detail. Hopefully after writing this (me) and reading this (you), we can all agree that I was
|
|
wrong, and that using DNS-01 ***is*** relatively simple after all.
|
|
|
|
## Overview
|
|
|
|
I've installed three frontend NGINX servers (running at Coloclue AS8283, IPng AS8298 and IP-Max
|
|
AS25091), and one LEGO certificate machine (running in the internal _IPng Site Local_ network).
|
|
In the [[previous article]({{< ref "2023-03-17-ipng-frontends" >}})], I described the setup and
|
|
the use of Let's Encrypt with HTTP-01 challenges. I'll skip that here.
|
|
|
|
#### HTTP-01 vs DNS-01
|
|
|
|
{{< image width="200px" float="right" src="/assets/ipng-frontends/lego-logo.min.svg" alt="LEGO" >}}
|
|
|
|
Today, most SSL authorities and their customers use the Automatic Certificate Management Environment
|
|
or _ACME protocol_ which is described in [[RFC8555](https://www.rfc-editor.org/rfc/rfc8555)]. It
|
|
defines a way for certificate authorities to check the websites that they are asked to issue a
|
|
certificate for using so-called challenges. One popular challenge is the so-called `HTTP-01`, in
|
|
which the certificate authority will visit a well-known URI on the website domain for which the
|
|
certificate is being requested, namely `/.well-known/acme-challenge/`, which described in
|
|
[[RFC5785](https://www.rfc-editor.org/rfc/rfc5785)]. The CA will expect the webserver to respond
|
|
with an agreed upon string of numbers at that location, in which case proof of ownership is
|
|
established and a certificate is issued.
|
|
|
|
In some situations, this `HTTP-01` challenge can be difficult to perform:
|
|
|
|
* If the webserver is not reachable from the internet, or not reachable from the Let's Encrypt
|
|
servers, for example if it is on an intranet, such as _IPng Site Local_ itself.
|
|
* If the operator would prefer a wildcard certificate, proving ownership of all possible
|
|
sub-domains is no longer feasible with `HTTP-01` but proving ownership of the parent domain is.
|
|
|
|
|
|
One possible solution for these cases is to use the ACME challenge `DNS-01`, which doesn't use the
|
|
webserver running on `go.ipng.ch` to prove ownership, but the _nameserver_ that serves `ipng.ch`
|
|
instead. The Let's Encrypt GO client [[ref](https://go-acme.github.io/lego/)] supports both
|
|
challenges types.
|
|
|
|
The flow of requests in a `DNS-01` challenge is as follows:
|
|
|
|
{{< image width="400px" float="right" src="/assets/ipng-frontends/acme-flow-dns01.svg" alt="ACME Flow DNS01" >}}
|
|
|
|
1. First, the _LEGO_ client registers itself with the ACME-DNS server running on `auth.ipng.ch`.
|
|
After successful registration, _LEGO_ is given a username, password, and access to one DNS
|
|
recordname $(RRNAME).
|
|
It is expected that the operator sets up a CNAME for a well-known record `_acme-challenge.ipng.ch`
|
|
which points to that `$(RRNAME).auth.ipng.ch`. This happens only once.
|
|
|
|
1. When a certificate is needed, the _LEGO_ client contacts the Certificate Authority and requests
|
|
validation for the hostname `go.ipng.ch`. The CA will will inform the client of a random
|
|
number $(RANDOM) that it expects to see in a a well-known TXT record for `_acme-challenge.ipng.ch`
|
|
(which is the CNAME set up previously).
|
|
|
|
1. The _LEGO_ client now uses the username and password it received in step 1, to update the TXT
|
|
record of its `$(RRNAME).auth.ipng.ch` record to contain the $(RANDOM) number it learned in step 2.
|
|
|
|
1. The CA will issue a TXT query for `_acme-challenge.ipng.ch`, which is a CNAME to
|
|
`$(RRNAME).auth.ipng.ch`, which ultimately responds to the TXT query with the $(RANDOM) number.
|
|
|
|
1. After validating that the response on the TXT records contains the agreed upon random number, the
|
|
CA knows that the operator of the nameserver is the same as the certificate requestor for the domain.
|
|
It issues a certificate to the _LEGO_ client, which stores it on its local filesystem.
|
|
|
|
1. Similar to any other challenge, the _LEGO_ machine can now distribute the private key and
|
|
certificate to all NGINX machines, which are now capable of serving SSL traffic under the given names.
|
|
|
|
One thing worth noting, is that the TXT query is for _domain_ names, not _hostnames_, in other
|
|
words, anything in the `ipng.ch` domain will solicit a query to `_acme-challenge.ipng.ch` by the
|
|
`DNS-01` challenge. It is for this reason, that the challenge allows for wildcard certificates,
|
|
which can greatly reduce operational complexity and the total number of certificates needed.
|
|
|
|
### ACME DNS
|
|
|
|
Originally, DNS providers were expected to give the ability for their clients to _directly_ update the
|
|
well-known `_acme-challenge` TXT record, and while many commercial providers allow for this, IPng
|
|
Networks runs just plain-old [[NSD](https://nlnetlabs.nl/projects/nsd/about)] as authoritative
|
|
nameservers (shown above as `nsd0`, `nsd1` and `nsd2`). So what todo? Luckily, it was quickly
|
|
understood by the community that if there is a lookup for TXT record of `_acme-challenge.ipng.ch`,
|
|
that it would be absolutely OK to make some form of DNS-symlink by means of a CNAME.
|
|
|
|
One really great solution that leverages this ability is written by Joona Hoikkala, called
|
|
[[ACME-DNS](https://github.com/joohoi/acme-dns)]. It's sole purpose is to allow for an API, served
|
|
over https, to register new clients, let those clients update their TXT record(s), and then serve
|
|
them out in DNS. It's meant to be a multi-tenant system, by which I mean one ACME-DNS instance can
|
|
host millions of domains from thousands of distinct users.
|
|
|
|
#### Installing
|
|
|
|
I noticed that ACME-DNS relies on features in relatively modern Go, and the standard version that
|
|
comes with Debian Bullseye is a tad old, so first I need to install Go v1.19 from backports, before
|
|
I can continue with the build of the binary:
|
|
|
|
```
|
|
lego@lego:~$ sudo apt -t bullseye-backports install golang
|
|
lego@lego:~/src$ git clone https://github.com/joohoi/acme-dns
|
|
lego@lego:~/src/acme-dns$ export GOPATH=/tmp/acme-dns
|
|
lego@lego:~/src/acme-dns$ go build
|
|
lego@lego:~/src/acme-dns$ sudo cp acme-dns /usr/local/bin/acme-dns
|
|
lego@lego:~/src/acme-dns$ cat << EOF | sudo tee /lib/systemd/system/acme-dns.service
|
|
[Unit]
|
|
Description=Limited DNS server with RESTful HTTP API to handle ACME DNS challenges easily and
|
|
securely
|
|
After=network.target
|
|
|
|
[Service]
|
|
User=lego
|
|
Group=lego
|
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
|
WorkingDirectory=~
|
|
ExecStart=/usr/local/bin/acme-dns -c /home/lego/acme-dns/config.cfg
|
|
Restart=on-failure
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
```
|
|
|
|
This authoritative nameserver will want to listen on UDP and TCP ports 53, for which it either needs to
|
|
run as root, or perhaps better, run as non-privileged user with the `CAP_NET_BIND_SERVICE`
|
|
capability. The only other difference with the provided unit file, is that I'll be running this as
|
|
the `lego` user, with a configuration file and working path in its home-directory.
|
|
|
|
#### Configuring
|
|
|
|
***Step 1. Delegate auth.ipng.ch***
|
|
|
|
The first thing I should do is configure the subdomain for ACME-DNS, which I decide will be hosted on
|
|
`auth.ipng.ch`. I assign it an NS, an A and a AAAA record, and then update the `ipng.ch` domain:
|
|
|
|
```
|
|
$ORIGIN ipng.ch.
|
|
$TTL 86400
|
|
@ IN SOA ns.paphosting.net. hostmaster.ipng.ch. ( 2023032401 28800 7200 604800 86400)
|
|
NS ns.paphosting.nl.
|
|
NS ns.paphosting.net.
|
|
NS ns.paphosting.eu.
|
|
|
|
; ACME DNS
|
|
auth NS auth.ipng.ch.
|
|
A 194.1.163.93
|
|
AAAA 2001:678:d78:3::93
|
|
```
|
|
|
|
This snippet will make a DNS delegation for sub-domain `auth.ipng.ch` to the server also called
|
|
`auth.ipng.ch` and because the downstream delegation is in the same domain, I need to provide _glue_
|
|
records, that tell clients who are querying for `auth.ipng.ch` where to find that nameserver. At
|
|
this point, any request for `*.auth.ipng.ch` will end up being forwarded to the authoritative
|
|
nameserver, which can be found at either 194.1.163.93 or 2001:678:d78:3::93.
|
|
|
|
***Step 2. Start ACME DNS***
|
|
|
|
After having built the acme-dns server and given it a suitable systemd unit file, and knowing that
|
|
it's going to be responsible for the sub-domain `auth.ipng.ch`, I give it the following straight
|
|
forward configuration file:
|
|
|
|
```
|
|
lego@lego:~$ mkdir ~/acme-dns/
|
|
lego@lego:~$ cat << EOF > acme-dns/config.cfg
|
|
[general]
|
|
listen = "[::]:53"
|
|
protocol = "both"
|
|
domain = "auth.ipng.ch"
|
|
nsname = "auth.ipng.ch"
|
|
nsadmin = "hostmaster.ipng.ch"
|
|
records = [
|
|
"auth.ipng.ch. NS auth.ipng.ch.",
|
|
"auth.ipng.ch. A 194.1.163.93",
|
|
"auth.ipng.ch. AAAA 2001:678:d78:3::93",
|
|
]
|
|
debug = false
|
|
|
|
[database]
|
|
engine = "sqlite3"
|
|
connection = "/home/lego/acme-dns/acme-dns.db"
|
|
|
|
[api]
|
|
ip = "[::]"
|
|
disable_registration = false
|
|
port = "443"
|
|
tls = "letsencrypt"
|
|
acme_cache_dir = "/home/lego/acme-dns/api-certs"
|
|
notification_email = "hostmaster+dns-auth@ipng.ch"
|
|
corsorigins = [ "*" ]
|
|
use_header = false
|
|
header_name = "X-Forwarded-For"
|
|
|
|
[logconfig]
|
|
loglevel = "debug"
|
|
logtype = "stdout"
|
|
logformat = "text"
|
|
EOF
|
|
lego@lego:~$ sudo systemctl enable acme-dns
|
|
lego@lego:~$ sudo systemctl start acme-dns
|
|
```
|
|
|
|
The first part of this tells the server how to construct the SOA record (domain, nsname and
|
|
nsadmin), and which records to put in the apex, nominally the NS/A/AAAA records that describe the
|
|
nameserver which is authoritative for the `auth.ipng.ch` domain. Then, the database part is where
|
|
user credentials will be stored, and the API portion shows how users will be able to interact with
|
|
the controlplane part of the service, notably registering new clients, and updating nameserver TXT
|
|
records for existing clients.
|
|
|
|
{{< image width="200px" float="right" src="/assets/ipng-frontends/turtles.png" alt="Turtles" >}}
|
|
|
|
Interestingly, the API is served on HTTPS port 443, and for that it needs, you guessed it, a
|
|
certificate! ACME-DNS eats its own dogfood, which I can appreciate: it will use `DNS-01` validation
|
|
to get a certificate for `auth.ipng.ch` _itself_, by serving the challenge for well known record
|
|
`_acme-challenge.auth.ipng.ch`, so it's turtles all the way down!
|
|
|
|
***Step 3. Register a new client***
|
|
|
|
Seeing as many public DNS providers allow programmatic setting of the contents of the zonefiles, for
|
|
them it's a matter of directly being driven by _LEGO_. But for me, running NSD, I am going to be using
|
|
the ACME DNS server to fulfill that purpose, so I have to configure it to do that for me.
|
|
|
|
In the explanation of `DNS-01` challenges above, you'll remember I made a mention of registering. Here's
|
|
a closer look at what that means:
|
|
|
|
```
|
|
lego@lego:~$ curl -s -X POST https://auth.ipng.ch/register | json_pp
|
|
{
|
|
"allowfrom" : [],
|
|
"fulldomain" : "76f88564-740b-4483-9bc0-86d1fb531e20.auth.ipng.ch",
|
|
"password" : "<redacted>",
|
|
"subdomain" : "76f88564-740b-4483-9bc0-86d1fb531e20",
|
|
"username" : "e4608fdf-9a69-4930-8cf1-57218738792d"
|
|
}
|
|
```
|
|
|
|
What happened here is that, using the HTTPS endpoint, I asked the ACME-DNS server to create for me an empty
|
|
DNS record, which it did on `76f88564-740b-4483-9bc0-86d1fb531e20.auth.ipng.ch`. Further, if I offer
|
|
the given username and password, I am able to update that record's value. Let's take a look:
|
|
|
|
```
|
|
lego@lego:~$ dig +short TXT 02e3acfc-bbca-46bb-9cee-8eab52c73c30.auth.ipng.ch
|
|
|
|
lego@lego:~$ curl -s -X POST -H "X-Api-User: 5f3591d1-0d13-4816-a329-7965a8639ab5" \
|
|
-H "X-Api-Key: <redacted>" \
|
|
-d '{"subdomain": "02e3acfc-bbca-46bb-9cee-8eab52c73c30", \
|
|
"txt": "___Hello_World_token_______________________"}' \
|
|
https://auth.ipng.ch/update
|
|
```
|
|
|
|
Numbers everywhere, but I learned a lot here! Notice how the first time I sent the `dig` request for
|
|
the `02e3acfc-bbca-46bb-9cee-8eab52c73c30.auth.ipng.ch` it did not respond anything (an empty
|
|
record). But then, using the username/password I could update the record with a 41 character
|
|
string, and I was informed of the `fulldomain` key there, which is the one that I should be
|
|
configuring in the domain(s) for which I want to get a certificate.
|
|
|
|
I configure it in the `ipng.ch` and `ipng.nl` domain as follows (taking `ipng.nl` as an example):
|
|
|
|
```
|
|
$ORIGIN ipng.nl.
|
|
$TTL 86400
|
|
@ IN SOA ns.paphosting.net. hostmaster.ipng.nl. ( 2023032401 28800 7200 604800 86400)
|
|
IN NS ns.paphosting.nl.
|
|
IN NS ns.paphosting.net.
|
|
IN NS ns.paphosting.eu.
|
|
CAA 0 issue "letsencrypt.org"
|
|
CAA 0 issuewild "letsencrypt.org"
|
|
CAA 0 iodef "mailto:hostmaster@ipng.ch"
|
|
_acme-challenge CNAME 8ee2969b-571c-4b3a-b6a0-6d6221130c96.auth.ipng.ch.
|
|
```
|
|
|
|
The records here are a `CAA` which is a type of DNS record used to provide additional confirmation
|
|
for the Certificate Authority when validating an SSL certificate. This record allows me to specify
|
|
which certificate authorities are authorized to deliver SSL certificates for the domain. Then, the
|
|
well known `_acme-challenge.ipng.nl` record is merely telling the client by means of a `CNAME` to go
|
|
ask for `8ee2969b-571c-4b3a-b6a0-6d6221130c96.auth.ipng.ch` instead.
|
|
|
|
Putting this part all together now, I can issue a query for that ipng.nl domain ...
|
|
|
|
```
|
|
lego@lego:~$ dig +short TXT _acme-challenge.ipng.nl.
|
|
"___Hello_World_token_______________________"
|
|
```
|
|
|
|
... and would you look at that! The query for the ipng.nl domain, is a CNAME to the specific uuid
|
|
record in the auth.ipng.ch domain, where ACME-DNS is serving it with the response that I can
|
|
programmatically set to different values, yee-haw!
|
|
|
|
***Step 4. Run LEGO***
|
|
|
|
The _LEGO_ client has all sorts of challenge providers linked in. Once again, Debian is a bit behind
|
|
on things, shipping version 3.2.0-3.1+b5 in Bullseye, although upstream is much further along. So I
|
|
purge the Debian package and download the v4.10.2 amd64 package directly from its
|
|
[[Github](https://github.com/go-acme/lego/releases)] releases page. The ACME-DNS handler was only
|
|
added in v4 of the client. But now all that's left for me to do is run it:
|
|
|
|
```
|
|
lego@lego:~$ export ACME_DNS_API_BASE=https://auth.ipng.ch/
|
|
lego@lego:~$ export ACME_DNS_STORAGE_PATH=/home/lego/acme-dns/credentials.json
|
|
lego@lego:~$ /home/lego/bin/lego --path /etc/lego/ --email noc@ipng.ch --accept-tos --dns acme-dns \
|
|
--domains ipng.ch --domains *.ipng.ch \
|
|
--domains ipng.nl --domains *.ipng.nl \
|
|
run
|
|
```
|
|
|
|
The LEGO client goes through the ACME flow that I described at the top of this article, and ends up
|
|
spitting out a certificate \o/
|
|
|
|
```
|
|
lego@lego:~$ openssl x509 -noout -text -in /etc/lego/certificates/ipng.ch.crt
|
|
Certificate:
|
|
Data:
|
|
Version: 3 (0x2)
|
|
Serial Number:
|
|
03:58:8f:c1:25:00:e2:f3:d3:3f:d6:ed:ba:bc:1d:0d:54:ea
|
|
Signature Algorithm: sha256WithRSAEncryption
|
|
Issuer: C = US, O = Let's Encrypt, CN = R3
|
|
Validity
|
|
Not Before: Mar 21 20:24:08 2023 GMT
|
|
Not After : Jun 19 20:24:07 2023 GMT
|
|
Subject: CN = ipng.ch
|
|
X509v3 extensions:
|
|
X509v3 Subject Alternative Name:
|
|
DNS:*.ipng.ch, DNS:*.ipng.nl, DNS:ipng.ch, DNS:ipng.nl
|
|
```
|
|
|
|
Et voila! Wildcard certificates for multiple domains using ACME-DNS.
|