Tested with OpenBSD 6.4

httpd supports TLS 1.2 and works well with acme-client. In this example, relayd(8) only adds some HTTP headers to get higher grades from the following tests:

A+ Observatory by Mozilla
A+ SSL Labs by Qualys
CryptCheck
A+ Security Headers
+ HSTS Preload
100 Lighthouse by Google

There are some drawbacks:

Because relayd(8) is fronting httpd(8): REMOTE_ADDR in access.log is always 127.0.0.1. Here is a diff for httpd(8) to include X-Forwarded-For and X-Forwarded-Port to the log.

Also httpd(8) doesn’t support gzip compression for static files. You can use gzip via FastCGI, if needed.

Set up a web server with httpd(8) and relayd(8) on OpenBSD

httpd(8) listens on ports 80 and 8080, serves plain HTTP, redirects //www.tld to //tld and http://tld:80 to https://tld:443.

relayd(8) listens on ports 443 and terminates TLS for IPv4 and IPv6 addresses, acme-client(1) issues a certificate via Let’s Encrypt, cron(8) runs acme-client(1) to check and renew the certifictate.

In this example, TLD is romanzolotarev.com, IPv4 address of the server is 46.23.88.178 and IPv6 is 2a03:6000:1015::178.

   https://romanzolotarev.comrelayd 46.23.88.178       :443
or relayd 2a03:6000:1015::178:443  →
   httpd  127.0.0.1          :8080 HTTP 200 OK

   https://www.romanzolotarev.comrelayd *                  :443 →
   httpd  127.0.0.1          :8080 HTTP 301 https://romanzolotarev.com

   http://romanzolotarev.com
or http://www.romanzolotarev.comhttpd  *                  :80   HTTP 301 https://romanzolotarev.com

Configure httpd(8)

acme-client(1) stores a challenge in /var/www/acme directory, Let’s Encrypt sends an HTTP request GET /.well-known/acme-challengs/*, and httpd(8) serves static files from that directory on such requests.

Note: httpd(8) is chrooted in /var/www/, so httpd(8) sees it as /acme/.

# > /etc/httpd.conf echo '
server "romanzolotarev.com" {
	listen on 127.0.0.1 port 8080
	location "/.well-known/acme-challenge/*" {
		root "/acme"
		request strip 2
	}
}
server "www.romanzolotarev.com" {
	listen on 127.0.0.1 port 8080
	block return 301 "https://romanzolotarev.com$REQUEST_URI"
}
server "romanzolotarev.com" {
	alias "www.romanzolotarev.com"
	listen on * port 80
	block return 301 "https://romanzolotarev.com$REQUEST_URI"
}
'
#

Verify the configuration, enable and restart httpd(8).

# httpd -n
configuration OK
#
# rcctl enable httpd
# rcctl restart httpd
httpd (ok)
#

Configure relayd(8)

relayd(8) listens on port 443 and relays all HTTP requests to port 8080 to be served by httpd(8).

Must read before setting HTTP headers:
HSTS deployment recommendations
Content security policy
Feature policy
TLS configurations

# > /etc/relayd.conf echo '
ipv4="46.23.88.178"
ipv6="2a03:6000:1015::178"

table <local> { 127.0.0.1 }

http protocol https {
	tls ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256"

	match request header append "X-Forwarded-For" value "$REMOTE_ADDR"
	match request header append "X-Forwarded-Port" value "$REMOTE_PORT"

	match response header set "Content-Security-Policy" value "default-src 'none'; style-src 'self'; img-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
	match response header set "Feature-Policy" value "camera 'none'; microphone 'none'"
	match response header set "Referrer-Policy" value "no-referrer"
	match response header set "Strict-Transport-Security" value "max-age=31536000; includeSubDomains; preload"
	match response header set "X-Content-Type-Options" value "nosniff"
	match response header set "X-Frame-Options" value "deny"
	match response header set "X-XSS-Protection" value "1; mode=block"

	return error
	pass
}
relay wwwtls {
	listen on $ipv4 port 443 tls
	listen on $ipv6 port 443 tls
	protocol https
	forward to <local> port 8080
}
'
#

relayd(8) loads a full-chain certificate for both IPv4 and IPv6 addresses from $address.crt file and private key from private/$address.key from /etc/ssl directory.

Generate a temporary key and certificate, then create symbolic links for IPv4 and IPv6 addresses. Later that key and certificate will be replaced by acme-client(1).

# mkdir -p -m 0700 /etc/ssl/private
#
# openssl req -x509 -newkey rsa:4096 \
-days 365 -nodes \
-subj '/CN=romanzolotarev.com' \
-keyout /etc/ssl/private/romanzolotarev.com.key \
-out /etc/ssl/romanzolotarev.com.pem
Generating a 4096 bit RSA private key
.................................................++
....................................................................++
writing new private key to '/etc/ssl/private/romanzolotarev.com.key'
-----
#
# ln -fs /etc/ssl/private/{romanzolotarev.com,46.23.88.178}.key
# ln -fs /etc/ssl/private/{romanzolotarev.com,2a03:6000:1015::178}.key
# ln -fs /etc/ssl/{romanzolotarev.com.pem,46.23.88.178.crt}
# ln -fs /etc/ssl/{romanzolotarev.com.pem,2a03:6000:1015::178.crt}
#
# chmod 0600 /etc/ssl/private/*.key
#

Verify the configuration, enable and restart relayd(8).

# relayd -n
configuration OK
#
# rcctl enable relayd
# rcctl restart relayd
relayd (ok)
#

Configure acme-client

acme-client(1) generates an account key letsencrypt.key, a domain key romanzolotarev.com.key and stores them in /etc/ssl/private, stores challenges in /var/www/acme directory, a cerficifate in /etc/ssl/romanzolotarev.com.crt (not needed for this setup), a full-chain cerficifate in /etc/ssl/romanzolotarev.com.pem (needed for relayd).

# > /etc/acme-client.conf echo '
authority letsencrypt {
	api url "https://acme-v01.api.letsencrypt.org/directory"
	account key "/etc/ssl/private/letsencrypt.key"
}
domain romanzolotarev.com {
	alternative names { www.romanzolotarev.com }
	domain key "/etc/ssl/private/romanzolotarev.com.key"
	domain certificate "/etc/ssl/romanzolotarev.com.crt"
	domain full chain certificate "/etc/ssl/romanzolotarev.com.pem"
	sign with "letsencrypt"
}
'
#

Remove the temporary cerficifate and keys, if any. Create the directory for challenges.

# rm -f /etc/ssl/romanzolotarev.com.pem
# rm -f /etc/ssl/romanzolotarev.com.crt
# rm -f /etc/ssl/private/romanzolotarev.com.key
# rm -f /etc/ssl/private/letsencrypt.key
#
# mkdir -p -m 755 /var/www/acme
#

Verify the configuration, run acme-client(1), and reload relayd(8).

# acme-client -n romanzolotarev.com
authority letsencrypt {
        api url "https://acme-v01.api.letsencrypt.org/directory"
        account key "/etc/ssl/private/letsencrypt.key"
}

domain romanzolotarev.com {
        domain key "/etc/ssl/private/romanzolotarev.com.key"
        domain certificate "/etc/ssl/romanzolotarev.com.crt"
        domain full chain certificate "/etc/ssl/romanzolotarev.com.pem"
        sign with "letsencrypt"
}
#
# acme-client -vFAD romanzolotarev.com
acme-client: /etc/ssl/private/letsencrypt.key: generated RSA account key
acme-client: /etc/ssl/private/romanzolotarev.com.key: generated RSA domain key
acme-client: https://acme-v01.api.letsencrypt.org/directory: directories
acme-client: acme-v01.api.letsencrypt.org: DNS: 23.15.57.150
acme-client: https://acme-v01.api.letsencrypt.org/acme/new-reg: new-reg
acme-client: https://acme-v01.api.letsencrypt.org/acme/new-authz: req-auth: romanzolotarev.com
acme-client: /var/www/acme/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx: created
acme-client: https://acme-v01.api.letsencrypt.org/acme/challenge/yyyyyyyyyyy_yyyyyyyyyyyyyyyyy-yyyyyyyyyyyyy/yyyyyyyyyyy: challenge
acme-client: https://acme-v01.api.letsencrypt.org/acme/challenge/yyyyyyyyyyy_yyyyyyyyyyyyyyyyy-yyyyyyyyyyyyy/yyyyyyyyyyy: status
acme-client: https://acme-v01.api.letsencrypt.org/acme/new-cert: certificate
acme-client: http://cert.int-x3.letsencrypt.org/: full chain
acme-client: cert.int-x3.letsencrypt.org: DNS: 23.13.65.208
acme-client: /etc/ssl/romanzolotarev.com.crt: created
acme-client: /etc/ssl/romanzolotarev.com.pem: created
#
# rcctl reload relayd
relayd(ok)
#

Schedule a new crontab to check and renew the certificate.

# echo '0 0 * * * acme-client romanzolotarev.com && rcctl reload relayd' |
crontab -
#