httpd and relayd on OpenBSD

I wanted to host some websites on an OpenBSD VM. I had some static content as well as some web apps. I also wanted to handle traffic for certain well-known paths separately (for Let's Encrypt). I reviewed the manual pages and it sounded like httpd would be a good option for serving static content while relayd was what I needed for filtering and forwarding traffic to the right places.

Name resolution

In experimenting and testing my configuration, I wanted to have particular hostnames resolve to localhost so I could see how my setup was working. I learned that /etc/resolv.conf controls the search order for gethostbyname(3). On other systems I've used, this is controlled by /etc/nsswitch.conf. Unless a different order is given in /etc/resolv.conf, DNS is searched first, then /etc/hosts. I added lookup file bind to /etc/resolv.conf to have /etc/hosts searched first while I was experimenting so I could put some hostnames in /etc/hosts to have them resolve to localhost.

httpd

httpd serves static content over HTTP. It can also do FastCGI and TLS. I don't plan to use FastCGI (my web apps include their own HTTP servers) and relayd will handle TLS termination since it's out front.

I created /etc/httpd.conf and started out with a couple of macros: one for the local interface and one for the port I wanted to use for HTTPS redirects.

local="lo0"
https_redirect="4221"
static_sites="4222"

I added a server section for handling ACME challenges from Let's Encrypt. This should work for any server name.

server "*" {
        listen on $local port 4220
        location "/.well-known/acme-challenge/*" {
                root "/acme"
                request strip 2
        }
        location "*" {
                block drop
        }
}

I also added a server section for a static site I wanted to serve:

server "parksdigital.com" {
        listen on 127.0.0.1 port $static_sites
        root "/htdocs/parksdigital.com"
}

Many clients try HTTP if no protocol is specified, and it's common for folks to type www in front of a web address out of habbit. I added a section to handle redirects to the canonical host name and protocol for these cases. The manual page says that you can use patterns in the server name and I found this to be true, but it seems like capture groups aren't currently implemented in this context. I think if I had capture groups I could simplify this part of the configuration. Until then, I think I'll need to add an additional server sections for each hostname.

server "parksdigital.com" {
        alias "www.parksdigital.com"
        listen on $local port $https_redirect
        block return 301 "https://parksdigital.com$DOCUMENT_URI"
}

I noticed in testing that other host names would also get the redirect shown above. It seems like the first server section for an address and port combination serves as the default when SNI doesn't get a match. To handle that case, I added a catch-all section listening on the same address and port that just drops the connection.

server "*" {
        listen on $local port $https_redirect
        block
}

I needed a place to put the files for the static site and I wanted to be able to update the site by sftp without logging in as the superuser, so I created a directory and changed the ownership to a non-privileged user. The directory needs to be under /var/www because (by default) httpd chroots to /var/www.

mkdir /var/www/htdocs/parksdigital.com
chown user.user /var/www/htdocs/parksdigital.com

I can add more static sites to this configuration by adding additional server sections and creating the directories for them.

Finally, I had to start up httpd. I also wanted it to start automatically if the VM should get happen to get restarted. rcctl(8) is used to control system services, both for enabling or disabling as well as starting, stopping, reloading, and such.

rcctl enable httpd
rcctl start httpd

And if you're fussing around with the configuration, you can reload it with:

rcctl reload httpd

relayd part one

relayd can relay HTTP connections. It can do TLS termination, high-availability pools with health-checks and lots of other neat stuff. I mostly wanted to use it to relay connections to different servers depending on the host name specified by the client.

I created /etc/relayd.conf and made sure it had permissions 0600 (as suggested by security(8).

touch /etc/relayd.conf
chmod 0600 /etc/relayd.conf

I started out /etc/relayd.conf with a couple of macros for the public and private interfaces, with that plan that I'd have relayd listen on the public interface and communicate with the servers on the private (local) interface.

public="vio0"
private="lo0"

I set up a few pools that all point to the private interface. I wanted separate pool names (even though they point to the same address) so I could configure each one with a different port in the relay sections.

table <acme-challenge> { $private }
table <https-redirect> { $private }
table <static-sites> { $private }
table <hitcounter> { $private }

Most incoming traffic should be HTTPS, but I also wanted to relay HTTP traffic in a couple of cases: to redirect from HTTP to HTTPS and to handle ACME challenges from Let's Encrypt. I'm used to the OpenBSD manual pages being top-notch, but I found the page for relayd.conf a little thin in a couple of spots. After some puzzling, I figured out that the filter “action” (block, match, or pass) isn't the only action that will be taken if the filter matches: several of the items listed as "filter parameters" (label, no, tag, forward to, and others) represent additional actions to be taken only if the filter matches.

I put together a filter rule to identify ACME challenges (for any host) and forward them to the httpd port I had set up for handling them. This goes into a protocol section that I can reference from the relay section.

http protocol "http" {
        pass request quick path "/.well-known/acme-challenge/*" \
                forward to <acme-challenge>
}

The relay section lists the port for HTTPS redirects first: this is the default. If the filter rule detects and ACME challenge, it will override this default and the connection will be relayed to the ACME challenge port.

relay "http" {
        listen on $public port http
        protocol "http"
        forward to <https-redirect> port 4221
        forward to <acme-challenge> port 4220
}

acme-client

At this point, I figured I had enough of a setup to use acme-client to request certificates. At the same time, I couldn't press forward much farther in configuring relayd without certificates.

I checked with my DNS host and made sure that I had address records for each host name set up and pointing at my server.

I set up /etc/acme-client.conf with a section for Let's Encrypt and a section for each host name.

authority letsencrypt {
        account key "/etc/acme/letsencrypt-privkey.pem"
        api url "https://acme-v02.api.letsencrypt.org/directory"
}

domain "parksdigital.com" {
        domain key "/etc/ssl/private/parksdigital.com.key"
        domain full chain certificate "/etc/ssl/parksdigital.com.crt"
}

domain "hitcounter.parksdigital.com" {
        domain key "/etc/ssl/private/hitcounter.parksdigital.com.key"
        domain full chain certificate "/etc/ssl/hitcounter.parksdigital.com.crt"
}

That and running acme-client for each host name was enough to get me a Let's Encrypt account and certificates.

Let's Encrypt certificates have a short expiration window to encourage automated renewal. Automation is as simple as adding a line to crontab(5) for each certificate:

0 * * * * sleep $((RANDOM % 2048)) && /usr/sbin/acme-client parksdigital.com
0 * * * * sleep $((RANDOM % 2048)) && /usr/sbin/acme-client hitcounter.parksdigital.com

I'll need to add such a line for each certificate. If I end up with a lot, maybe a small script will be a better way to go.

relayd part two

With a certificate available, I was able to add a relay section to relayd.conf for relaying HTTPS connections. By default connections will be relayed to httpd for serving static sites. Filter rules in the protocol section can forward to web app pools based on host name or other criteria. I also added directives for the Let's Encrypt certificates to the protocol section. I can add additional certificates here if I want to serve content for more host names.

http protocol "https" {
	tls keypair "parksdigital.com"
	tls keypair "hitcounter.parksdigital.com"
	pass request quick header "host" value "hitcounter.parksdigital.com" \
		forward to <hitcounter>
}

relay "https" {
	listen on $public port https tls
	protocol "https"
	forward to <static-sites> port 4222
	forward to <hitcounter> port 4200
}

Uploading static sites

I've been using rsync to deploy static site content. On the server side, we have openrsync so it's necessary to specify the path to the remote rsync binary when calling rsync locally or to install rsync server-side using pkg_add.

rsync --rsync-path=/usr/bin/openrsync -r --delete . vultr:/var/www/htdocs/parksdigital.com

In closing

I hope that you found this helpful. If this is the kind of thing you're into, you may enjoy my other work. If you have any questions or comments, please feel free to drop me an e-mail.

Aaron D. Parks
Parks Digital LLC
4784 Pine Hill Drive, Potterville, Michigan
support@parksdigital.com

hit counter