Dave's OpenBSD Blog #9: OpenBSD httpd (ACME client for certs)

Page created: 2024-11-25
Updated: 2025-05-24

Go back to my OpenBSD page for more entries.

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. :-)