OpenBSD: HTTPS Web Server With Let's Encrypt


This article explains how to set up a secure Web server that responds only to HTTPS requests using the default software of the OpenBSD operating system, i.e. httpd(8), and acme-client(8). Please mind however, that only to HTTPS is not entirely correct: the Web server has to answer to plain HTTP requests for the sake of renewal of X.509 certificates issued by Let's Encrypt, but all the rest of requests will be served using HTTPS.

This article discusses HTTPS in particular, however certificates from the Let's Encrypt project have ‘TLS Web Server Authentication’ and ‘TLS Web Client Authentication’ as their Extended Key Usage, hence, they can be used for securing any application that communicates over the TLS protocol, i.e. not necessarily a Web server. I can testify that I successfully use such a certificate with an OpenSMTPD server for securing incoming mail of a domain that I manage.

Before You Begin

Your host must meet certain conditions during the enrollment (i.e. the very first time when the acme-client(1) is run for a particular domain) and renewals (i.e subsequent periodic executions of acme-client(1) for the purpose of validation and re-issuing) of certificates.

DNS

Every host name that is listed in the alternative names clause of acme-client.conf(5) must resolve to an IP address using the DNS protocol.

HTTP server

Every host name or IP address that is listed in the alternative names clause of acme-client.conf(5) must be reachable using the HTTP (at the time of this writing, the acme-client(1) supports only the HTTP Challenge, a.k.a http-01, as defined by the ACME Working Group). To satisfy that, you must configure, enable, and verify the proper operation of the HTTP server— prior to enrollment.

Important: when acme-client(1) requests the enrollment, the Let's Encrypt's server first creates an unauthorised account for the domain being enrolled, then sends it an HTTP Challenge, and then authorizes the account—if the challenge was properly answered by the domain (i.e. by the HTTP server). HTTP server's failure to answer the challenge during the enrollment will bring the domain's account (at the Let's Encrypt's server) into an intermediate state that will prevent subsequent attempts of enrollment. Therefore, do validate that the HTTP server responds prior to launching acme-client(1) for the first time. Use another computer, preferably from another network, to ensure that the DNS and the HTTP server are configured properly and the external traffic to this HTTP server is not blocked by a firewall. Do not proceed unless you are absolutely sure that everything is in order.

Agreement URL

While it may seem not to be an issue of the utmost importance, the default acme-client.conf(5) contains the clause ‘agreement url’ (at least, that is the case on OpenBSD 6.1 and OpenBSD 6.2). If this URL is specified, the acme-client(1) will send it to the Let's Encrypt's server for validation and—if the URL is incorrect—the enrollment will be aborted, leaving the domain's account in the same unauthorised state as described above.

Important: you must determine the proper agreement URL prior to launching acme-client(1) for the first time.

The answer to the question ‘How do I obtain the current agreement URL?’ is found in an unexpected place, therefore I will quote it here:

curl -I https://acme-v01.api.letsencrypt.org/terms 2>&1 \
    | grep '^Location: ' \
    | cut '-d ' -f 2
Considering the above, the agreement URL for the staging API of Let's Encrypt can be found as follows:
curl -I https://acme-staging.api.letsencrypt.org/terms 2>&1 \
    | grep '^Location: ' \
    | cut '-d ' -f 2
Needless to say, these URLs can be different and should be treated as such.

Configuration

For the purpose of explanation, I assume below that you have a recent version of OpenBSD (6.1 or newer) installed on a machine that is known both as www.nxdomain.net and mx.nxdomain.net. I further assume that you intend to apply minimal changes to the default configuration of the system.

The very basic httpd.conf(5) that supports running the acme-client(1) can be created as follows

cat <<EOF | doas tee /etc/httpd.conf
server "default" {
    listen on egress port 80
    root "/htdocs"
    directory no index
    location "/.well-known/acme-challenge/*" {
        root {"/acme", strip 2}
    }
}
EOF

Now start httpd(8):

doas rcctl -f start httpd
(Here I use the flag -f to force starting httpd(8) without enabling it first, i.e. httpd(8) will not be started on the next boot. To me this is a matter of precaution because I perceive this configuration as temporary.)

Since I do not want to give you the problematic advice of installing curl on a production system, please use another machine to retrieve the values of the two agreement URLs—as described above in the section ‘Agreement URL’. As soon as you have them, go back to www.nxdomain.net and edit acme-client.conf(5) as follows:

doas vi /etc/acme-client.conf
Using the editor, you should make five changes:
  1. Locate the clause authority letsencrypt and replace the value of sub-clause agreement url with the agreement URL of the stable API.
  2. Locate the clause authority letsencrypt-staging and replace the value of sub-clause agreement url with the agreement URL of the staging API.
  3. Globally replace all appearances of example.org with the name of your domain (in our example, nxdomain.net).
  4. Completely uncomment the clause domain nxdomain.net.
  5. In the same clause, locate the sub-clause alternative names and replace its contents completely with the list of host names and IP addresses that you want to have as the value of the attribute X509v3 Subject Alternative Name of the certificate that you age going to apply for with Let's Encrypt.

Your version of acme-client.conf(5) should be similar to the following (I took the default acme-client.conf from OpenBSD 6.1 for the template, hence the commented CVS tag at the beginning of the file):

#
# $OpenBSD: acme-client.conf,v 1.4 2017/03/22 11:14:14 benno Exp $
#
authority letsencrypt {
        agreement url "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf"
        api url "https://acme-v01.api.letsencrypt.org/directory"
        account key "/etc/acme/letsencrypt-privkey.pem"
}

authority letsencrypt-staging {
        agreement url "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf"
        api url "https://acme-staging.api.letsencrypt.org/directory"
        account key "/etc/acme/letsencrypt-staging-privkey.pem"
}

domain nxdomain.net {
       alternative names { www.nxdomain.net mx.nxdomain.net }
       domain key "/etc/ssl/private/nxdomain.net.key"
       domain certificate "/etc/ssl/nxdomain.net.crt"
       domain full chain certificate "/etc/ssl/nxdomain.net.fullchain.pem"
       sign with letsencrypt
}

Verify the Operation of HTTP

As explained above, you must verify that all hostnames and IP addresses that are listed in the alternative names sub-clause of acme-client.conf are reachable through the HTTP.

To this end, create a small HTML file called test.html under /var/www/acme:

hostname | doas tee /var/www/acme/test.html
Having such a file does not pose a security risk and you can remove this file later.

Use another computer, preferably from another network, to verify the operation of HTTP:

for name in www.nxdomain.net mx.nxdomain.net; do
    if ! curl -f "http://${name}/.well-known/acme-challenge/test.html"; then
        echo "${name} is not reachable. Aborting."
        break
    fi
done
If execution of this script produces anything else but the contents of /var/www/acme/test.html as many times as there are hostnames and IP addresses specified, please stop, investigate the cause of the error, correct it, and run the script again.

Do not proceed any further until this test succeeds completely, for every host name or IP address listed.

If all goes well, you can remove the test file:

doas rm /var/www/acme/test.html

Enrollment

There is no much to it, run the following:

doas acme-client -vAD nxdomain.net
Carefully examine the output (there will be a considerable amount of it): the command must succeed without any errors.

Configuration of the HTTPS Server

Overwrite httpd.conf(5) as follows:

cat <<EOF | doas tee /etc/httpd.conf
server "default" {
    listen on egress port 80
    root "/htdocs"
    directory no index
    location "/.well-known/acme-challenge/*" {
        root {"/acme", strip 2}
    }
    location "/*" {
        block return 301 "https://$HTTP_HOST$REQUEST_URI"
    }
}

server "secure" {
    listen on egress tls port 443 
    tls certificate "/etc/ssl/nxdomain.net.fullchain.pem"
    tls key "/etc/ssl/private/nxdomain.net.key"
    hsts
    root "/htdocs"
    directory index index.html
}
EOF
Please note that location clauses are matched in the order of their appearance and the first clause that matches ‘wins’, i.e. the remaining clauses are not processed. The first location clause of the HTTP server permits ACME HTTP challenges to be processed, while the second redirects all other requests to HTTPS.

Enable httpd(8) and re-start it:

doas rcctl enable httpd
doas rcctl restart httpd

Prepare for Renewals

Append daily invocation of acme-client to daily(8):

cat <<EOF | doas tee -a /etc/daily.local
acme-client nxdomain.net && rcctl reload httpd
EOF

When In Trouble

Should you decide to start from the scratch (as the result of an error or because you wish to modify the list of alternative names in acme-client.conf(5)), you must remove all the keys that are known to Let's Encrypt (and, for the sake of correctness, local secret keys as well):

doas rm -f /etc/acme/letsencrypt-privkey.pem \
    /etc/ssl/nxdomain.net.crt \
    /etc/ssl/nxdomain.net.fullchain.pem \
    /etc/ssl/private/nxdomain.net.key

Vadim Penzin, March 10th, 2018


I hereby place this article into the public domain.
You are welcome to contact me by writing to howto at this domain.
I publish this information in the hope that it will be useful, but without ANY WARRANTY.
You are responsible for any and all consequences that may arise as the result of using this information.