Dave's OpenBSD Blog #9: OpenBSD httpd (ACME client for certs)
On OpenBSD 7.6, acme-client is already installed. I’m gonna just follow along
with the instructions.
$ man acme-client
Okay, so it looks like you can just drop this location block into a
server block listening on port 80 in /etc/httpd.conf to respond
to Let’s Encrypt’s challenge request.
While I’m at it, I’m going to add the port 443 (https) listen entry
and port 80 redirect for my test "foo" subdomain. I want this subdomain
to require TLS (aka "SSL", but really TLS) for all but the acme challenge.
I’m pretty much taking this verbatim from /etc/examples/httpd.conf
and replaced "example.com" with "foo.ratfactor.com":
# /etc/httpd.conf
#
# ...
server "foo.ratfactor.com" {
listen on * port 80
location "/.well-known/acme-challenge/*" {
root "/acme"
request strip 2
}
location * {
block return 302 "https://$HTTP_HOST$REQUEST_URI"
}
}
server "foo.ratfactor.com" {
listen on * tls port 443
tls {
certificate "/etc/ssl/foo.ratfactor.com.fullchain.pem"
key "/etc/ssl/private/foo.ratfactor.com.key"
}
}
That will send all requests to the chroot’d dir /var/www/acme/.
That dir sits empty awaiting use. When the acme client requests
a certificate, the issuer tells it which URL it’s going to try
to visit. The client writes a file at that location, indicating
ownership of the web server making the request.
Next, I need these keys to actually exist.
Testing the config with the -n option does complain about
the missing keys:
$ httpd -n /etc/httpd.conf:44: server "foo.ratfactor.com": failed to load public/private keys configuration OK
The manual page says that we also need an acme-client.conf to
configure acme-client and that there’s an example in
the etc examples directory. So I’ll copy the example for editing:
$ doas cp /etc/examples/acme-client.conf /etc/
And then the configuration has its own manual page:
$ man acme-client.conf
(I really like how OpenBSD does this consistently with the examples and the man page for the configuration. Also, how OpenBSD programs use the same configuration format, so you can apply what you already know.)
Then edit the example for my domain:
# ...authority entries...
domain foo.ratfactor.com {
domain key "/etc/ssl/private/foo.ratfactor.com.key"
domain full chain certificate "/etc/ssl/foo.ratfactor.com.fullchain.pem"
# Test with the staging server to avoid aggressive rate-limiting.
sign with letsencrypt-staging
#sign with letsencrypt
}
Above, I’ve changed example.com to foo.ratfactor.com in three places.
Also, I have swapped the commenting on the sign with so that I’ll be using
letsencrypt-staging authority to test instead of the real letsencrypt authority to avoid
making a regrettable mistake (really just a timeout from Let’s Encrypt to
slow down attackers.
Both authorities are defined further up in the acme-client.conf file, so there’s no mystery about which servers these point to.
Test it on staging
First, I needed to reload httpd.conf so that the above
location entry for the acme-challenge request would
be handled correctly by httpd.
Then, with the staging server set in acme-client.conf (see section above), I requested a certificate.
$ doas httpd -n $ doas rcctl reload httpd $ doas acme-client -v foo.ratfactor.com
It worked:
... acme-client: /etc/ssl/foo.ratfactor.com.fullchain.pem: created
The real thing
Now that I know the ACME challenge process is functional, it’s time to get the real certificate from the non-staging Let’s Encrypt server and reload httpd with the new certificate.
Ah, but there’s a catch! The staging certificate was still valid:
$ doas acme-client -v foo.ratfactor.com acme-client: /etc/ssl/ratfactor.com.fullchain.pem: certificate valid: 89 days left
So I first needed to remove the staging certificate:
$ doas rm /etc/ssl/foo.ratfactor.com.fullchain.pem $ doas rm /etc/ssl/private/foo.ratfactor.com.key
Then proceed with getting the real cert and applying it to httpd:
$ doas vim /etc/acme-client.conf # switch to non-staging $ doas acme-client -v foo.ratfactor.com $ doas rcctl reload httpd
Lastly, I set up a cron job to run acme-client for this domain certificate.
Raw notes
My notes got a little messy, but here’s my full process including some false-starts and a service problem with Let’s Encrypt. see what I saw as I figured this out:
$ doas acme-client -v foo.ratfactor.com acme-client: https://acme-staging-v02.api.letsencrypt.org/directory: directories acme-client: acme-staging-v02.api.letsencrypt.org: DNS: ... acme-client: acme-staging-v02.api.letsencrypt.org: DNS: ... acme-client: dochngreq: https://acme-staging-v02.api.letsencrypt.org/acme/... acme-client: challenge, token: ... acme-client: /var/www/acme/... acme-client: https://acme-staging-v02.api.letsencrypt.org/... acme-client: order.status -1 acme-client: dochngreq: https://acme-staging-v02.api.letsencrypt.org/acme/... acme-client: 46.23.93.221: Invalid response from http://foo.ratfactor.com/.well-known/acme-challenge/... acme-client: bad exit: netproc(14790): 1
gotta do first reload to make it answer the acme challenge!
$ doas rcctl reload httpd httpd(ok)
Try again:
$ doas acme-client -v foo.ratfactor.com acme-client: https://acme-staging-v02.api.letsencrypt.org/directory: directories acme-client: acme-staging-v02.api.letsencrypt.org: DNS: ... acme-client: acme-staging-v02.api.letsencrypt.org: DNS: ... acme-client: dochngreq: https://acme-staging-v02.api.letsencrypt.org/... acme-client: challenge, token: ... acme-client: /var/www/acme/... acme-client: https://acme-staging-v02.api.letsencrypt.org/acme/chall/... acme-client: order.status 0 acme-client: dochngreq: https://acme-staging-v02.api.letsencrypt.org... acme-client: challenge, token: ... acme-client: order.status 1 acme-client: https://acme-staging-v02.api.letsencrypt.org/acme/finalize/... acme-client: order.status 2 acme-client: unhandled status: 2 acme-client: bad exit: netproc(60213): 1
looks like that’s okay to get status '2' from staging?
now for the real deal, switch to real deal:
doas vim /etc/acme-client.conf
apparently hit a high load moment:
acme-client: https://acme-v02.api.letsencrypt.org/acme/chall/...
acme-client: transfer buffer: [{"type": "urn:ietf:params:acme:error:rateLimited", "detail": "Service busy; retry later."}] (90 bytes)
acme-client: bad exit: netproc(66829): 1
read up on that at
Then tried again after a few minutes and this time:
... acme-client: order.status 0 acme-client: dochngreq: https://acme-v02.api.letsencrypt.org/acme/authz/... acme-client: challenge, token: ... acme-client: order.status 1 acme-client: https://acme-v02.api.letsencrypt.org/acme/finalize/... acme-client: order.status 3 acme-client: https://acme-v02.api.letsencrypt.org/acme/cert/... acme-client: /etc/ssl/foo.ratfactor.com.fullchain.pem: created
Finally:
$ doas rcctl reload httpd
And then I hit https://foo.ratfactor.com in the browser and viola! there it was
with a real Let’s Encrypt cert.
cron
$ doas crontab -e
here’s my entry:
# Daily check for acme client renewal of certificate. # Random minute sometime after 03:00 in the morning every day. ~ 3 * * * acme-client foo.ratfactor.com && rcctl reload httpd
The ~ in the first minute column picks a random minute.
Update: Six months later, I’ve had no trouble with the cron job and my certificate has continued to work. :-)