Matt Horan's Blog

Automatic TLS certificate rotation with lego on FreeBSD

I’ve been using Let’s Encrypt to manage certificates on my systems for some time now. I started off using the excellent acme-client, which hasĀ  since been integrated into OpenBSD. Previously, there was a portable version which had been ported to FreeBSD, but this is no longer maintained. I continued running it for some time without realizing this. Fortunately the FreeBSD port has since been removed.

One of the difficulties I faced with acme-client was generating the required challenge for a system that had no HTTP daemon listening on port 80, which is required for the HTTP-01 challenge. acme-client had stubs in place to generate bits required for the DNS-01 challenge, a good fit for this use case, but it was a manual process to get the DNS records in place, and was error prone, which meant that my certificates were always expiring.

When I finally got around to figuring out how to get everything automated, I’d discovered that acme-client-portable had been abandoned, so I began looking for an alternative. My requirements were that it had minimal dependencies (my systems are lightweight VMs at ARP Networks, and have minimal resources) and would be able to automate the DNS-01 challenge. I also needed something that could handle the fact that my authoritative DNS servers do not support any sort of dynamic DNS — more on that later.

I came across the excellent lego project. lego is a lightweight Let’s Encrypt client written in — you guessed it — Go. While lego didn’t have a FreeBSD port at the time, it did meet all the requirements above. It is lightweight with minimal dependencies, and handles the DNS-01 challenge. It was the last requirement — working with my authoritative DNS servers — that presented a challenge.

Getting lego up and running with Google’s Cloud DNS, Amazon Route 53, and other providers, is quite easy. However, the domains I would be securing don’t use Cloud DNS or Route 53, so I needed to find another solution. Fortunately, some other Let’s Encrypt clients had pioneered a solution using CNAMEs. I found that someone had started to bring support for this to lego and submitted a pull request to finish the effort.

The pull request introduced a new environment variable, LEGO_EXPERIMENTAL_CNAME_SUPPORT, which would force lego to resolve CNAMEs when looking for the authoritative server when creating DNS records for the associated challenge. This meant that I could use my existing authoritative DNS servers but put a CNAME in place to Cloud DNS (or Route 53) and lego would traverse the CNAME to deploy the required TXT record for the DNS-01 challenge.

Setting this all up was pretty simple. The DNS-01 challenge looks for a TXT record corresponding to _acme-challenge.$DOMAIN. When using LEGO_EXPERIMENTAL_CNAME_SUPPORT, one would configure a CNAME as follows:

_acme-challenge.host.example.com. 3600 IN CNAME _acme-challenge.host-example-com.example.org.

This presumes that example.org is hosted on a DNS provider supported by lego. The CNAME points to host-example-com.example.org, and lego will traverse that CNAME in order to set the corresponding TXT record for _acme-challenge.host-example-com.example.org.

With all this in place, I needed a script to automate running lego using the DNS-01 challenge.

#!/bin/sh -e

# Email used for registration and recovery contact.
EMAIL="root@example.com"

BASEDIR="/usr/local/etc/lego"
SSLDIR="/usr/local/etc/ssl/lego"
DOMAINSFILE="${BASEDIR}/domains.txt"

if [ -z "${EMAIL}" ]; then
	echo "Please set EMAIL to a valid address in ${BASEDIR}/lego.sh"
	exit 1
fi

if [ ! -e "${DOMAINSFILE}" ]; then
	echo "Please create ${DOMAINSFILE} as specified in ${BASEDIR}/lego.sh"
	exit 1
fi

# /usr/local/etc/lego/domains.txt:
# example.com www.example.com

# Generates a certificate with CN=example.com and
# SAN=example.com www.example.com

# Each line will generate a separate certificate.

LEGO_EXPERIMENTAL_CNAME_SUPPORT=true
GCE_PROJECT=example-gce-project
GCE_SERVICE_ACCOUNT_FILE=/usr/local/etc/lego/sa.json
export LEGO_EXPERIMENTAL_CNAME_SUPPORT GCE_PROJECT GCE_SERVICE_ACCOUNT_FILE

while read line ; do
	output=$(/usr/local/bin/lego --path "${SSLDIR}" \
		--email="${EMAIL}" \
		$(printf -- "--domains=%s " $line) \
		--dns="gcloud" \
		renew --days 30) || (echo "$output" && exit 1)
done < "${DOMAINSFILE}"

The above script passes an email address to the lego client to ensure that expiry notices can be sent from Let’s Encrypt if the script fails for any reason. It then reads a list of domains from /usr/local/etc/lego/domains.txt and passes each line to lego’s --domain flag. I’ve set the LEGO_EXPERIMENTAL_CNAME_SUPPORT environment variable and the corresponding Cloud DNS environment variables so that lego can create the corresponding TXT records.

With all this in place, I can run lego on a weekly basis via periodic and it will automatically renew my certificates via the DNS-01 challenge when they will expire within 30 days.

Since I needed to deploy lego across multiple FreeBSD systems, I also created and maintain a FreeBSD port. The port supports using the HTTP-01 challenge out of the box, and will attempt to restart nginx via a deploy script if the corresponding periodic variables are set.

The following is all that needs to be added to /etc/periodic.conf to get up and running via the port on FreeBSD with nginx:

weekly_lego_enable="YES"
weekly_lego_renewscript="/usr/local/etc/lego/lego.sh"
weekly_lego_deployscript="/usr/local/etc/lego/deploy.sh"

You’ll need to edit /usr/local/etc/lego/lego.sh to set EMAIL as appropriate, but otherwise the domains will be read from /usr/local/etc/lego/domains.txt. Each line of domains.txt will be passed to lego, so this can be used to generate certificates for related domains. The packaged deploy script, /usr/local/etc/lego/deploy.sh, will copy the generated certificates from /usr/local/etc/ssl/lego/certificates into /usr/local/etc/ssl/certs. It will also place the corresponding private keys in /usr/local/etc/ssl/lego/private. It will then attempt to reload nginx.

It was great getting the lego ported to FreeBSD and automating my use of the DNS-01 challenge. I no longer have expiring certificates on my systems. In ensure that certificates are being renewed as expected, I set the EMAIL environment variable as discussed above, but also set up Prometheus to monitor my hosts TLS certificates. I was able to put alerting in place such that if certificates are about to expire, I’ll receive a notification.