The simple web-stack

Published on in OpenBSD by .

A webserver does not need to rely on a language interpreter, a relational database, nodejs with all the modules just to minify some CSS, etc. Not to mention all the queries and internal requests that happen, on every single request, before the client get the response. When the client final have the HTML, it sends a few AJAX requests to populate all the placeholders on the page.

This is a waste of both time and resources and adds extra security risks to your server. I rather keep my web-stack as simple as possible and generate this website as static HTML pages.

httpd(8) and relayd(8)

The OpenBSD httpd(8) is my obvious choice. It's small, easy to configure and designed from the ground up to use privilege separation. It does however lack a way to add custom HTTP-headers. To solve this problem, I run relayd(8) infront of httpd(8) and lets it handle TLS acceleration and adding proper caching headers.

/etc/httpd.conf

I want all traffic to use TLS and the non-www version of my domain. If that's not the case, redirect it to the proper address while keeping the path. My httpd.conf(5) looks like this:

default type text/plain
prefork 1
types {
	include "/usr/share/misc/mime.types"
}

server "example.com" {
	listen on 127.0.0.1 port 8080
	listen on ::1 port 8080

	tcp nodelay

	location "/.well-known/acme-challenge/*" {
		root "/acme"
		request strip 2
	}

	directory index index.html
	root "/htdocs/example.com/"

	log style forwarded
	log access "example.com-tls-access.log"
	log error "example.com-tls-error.log"
}

server "example.com" {
	listen on 127.0.0.1 port 80
	listen on ::1 port 80

	alias www.example.com

	location "/.well-known/acme-challenge/*" {
		root "/acme"
		request strip 2
	}

	block return 301 "https://example.com$DOCUMENT_URI"

	log style forwarded
	log access "example.com-access.log"
	log error "example.com-error.log"
}

server "www.example.com" {
	listen on 127.0.0.1 port 8080
	listen on ::1 port 8080

	location "/.well-known/acme-challenge/*" {
		root "/acme"
		request strip 2
	}

	block return 301 "https://example.com$DOCUMENT_URI"

	log style forwarded
	log access "example.com-tls-access.log"
	log error "example.com-tls-error.log"
}

All server-blocks listen on 127.0.0.1, where I use 80 and 8080 to differentiate between http and https with relayd(8).

The first server-block serves the actual page. The second server-block is the non-TLS page, redirecting to the TLS version. This is also needed so acme-client(1) can update my TLS certificate. The third server-block does the same as the second, but redirects from www to non-www.

/etc/relayd.conf

I use one of the examples in relayd.conf(5), with some minor changes:

table <httpd> { 127.0.0.1 }
table <httpd6> { ::1 }

http protocol "http" {
	return error

	block

	match header set "X-Forwarded-For" \
	    value "$REMOTE_ADDR"
	match header set "X-Forwarded-By" \
	    value "$SERVER_ADDR:$SERVER_PORT"
	match header set "Keep-Alive" value "$TIMEOUT"
	match query hash "sessid"

	match request tag "html"
	match request path file "/etc/relayd.assets" tag "assets"

	match response tagged "html" header set "Cache-Control" \
	    value "public, no-cache, must-revalidate, max-age=1814400"
	match response tagged "assets" header set "Cache-Control" \
	    value "public, max-age=31536000"
	match response header set "Strict-Transport-Security" \
	    value "max-age=31536000; includeSubDomains; preload"

	pass request header "Host" value "example.com"
	pass request header "Host" value "www.example.com"

	tls keypair example.com
	tcp { nodelay, sack }
}

relay "https" {
	listen on 0.0.0.0 port 443 tls
	protocol "http"
	forward to <httpd> port 8080
}
relay "https6" {
	listen on :: port 443 tls
	protocol "http"
	forward to <httpd6> port 8080
}

relay "http" {
	listen on 0.0.0.0 port 80
	protocol "http"
	forward to <httpd> port 80
}
relay "http6" {
	listen on :: port 80
	protocol "http"
	forward to <httpd6> port 80
}

I match all incoming requests and check the Host header against the domain. I also tag the connection as "html" or "assets" depending on the file extension the client requested. Then I match the tagged response and add the proper header. If it's a HTML-page, force the browser to revalidates the cache. I rarely rename a HTML-file when correcting spelling errors and I don't want to lose control of the cache. If it's an image or a CSS-file however, cache it for a year and don't bother revalidating. These files can always be renamed if I need to invalidate your cache. To keep things clean, I keep the paths to match in a separate file, /etc/relayd.assets:

*.css
*.ico
*.png

If your website has any other assets, just add them as well.

acme-client(1)

To keep my TLS certificate valid, I simply use the acme-client(1). Just add the following to your acme-client.conf(5):

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

domain example.com {
	alternative names { www.example.com }
	domain key "/etc/ssl/private/example.com.key"
	domain full chain certificate "/etc/ssl/example.com.crt"
	sign with letsencrypt
}

Then just run acme-client -v example.com to generate your certificate.

Conclusion

With the ease of httpd(8), relayd(8) and acme-client(1), you can have a very robust and secure web-stack up and running in no time. Sure, it would be easier if httpd(8) allowed me to send those extra headers. But then again, with relayd(8) infront of it, I have everything ready to just extend my <httpd> table in relayd.conf(5) and make my site scale horizontally.

As for those of you who really need your favorite language interpreter, you can always configure httpd(8) to use FastCGI. Note that httpd(8) runs chrooted to /var/www/ by default.

Also, SNI support and the keypair keyword in relayd.conf(5) will be available in OpenBSD 6.6, so unless you run on -current, you will have to wait patiently.

Updates

Update 1:

@gonzalo suggested adding return error in my relayd.conf(5). This causes relayd(8) to send a 500 Internal Server Error if httpd(8) is unreachable. In my scenario, both servers run on the same machine, but I'd say it's best practice to send an error to the user instead of just dropping the connection. He also suggested adding the Host header checks to avoid forwarding any domain to httpd(8).

Thanks for pointing this out!

Update 2:

I also received a mail from Nathanael Dalliard, pointing out that my blog was not reachable through IPv6. This has now been fixed and the configuration examples has been updated. Note that you need to copy your TLS keys to /etc/ssl/address.crt and /etc/ssl/private/address.key, where address means 0.0.0.0 and ::, or the actual IP that's listed in your relayd.conf(5).