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
From man acme-client
, we test this with:
# acme-client -v example.com && rcctl reload httpd
I’ll test the acme-client
part separately and reload
httpd
only once I’ve got the certs.
$ doas acme-client -v gwiki.ratfactor.com
RAW TODO
$ 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. :-)