I host different services (primarily httpd(8) and Dendrite) and domains (including this site) on a server from OpenBSD Amsterdam, and I recently added atproto PDS self-hosting to the mix. relayd(8) handles all of the requests to the server, terminates TLS when needed, and routes requests between clients and their ultimate destinations.
I could use a more common reverse proxy like nginx (or something trendier like Caddy), but I generally like OpenBSD’s base system services because the documentation is excellent and the services are always incredibly stable (and they’re usually just fun and a pleasure to work with).
When it comes to relayd, I’ve always been significantly more confused by its configuration than most of the other services that I use. This might be owed to relayd’s flexibility and wide range of functionality (IP and application layer logic, TLS termination, health-checks, load balancing, etc.), or the infrequency with which I change its configuration.
This post is a simple, concise explanation of the relayd features that I use. The examples are a bit contrived, but will demonstrate how the different components of relayd and httpd work together to:
- Redirect HTTP requests to HTTPS
- Redirect subdomains to the apex domain (e.g.,
www.example.com
toexample.com
) - Route requests for certain HTTP paths to different services
- Route requests for certain subdomain to different services
- Add custom headers to HTTP responses
Key terms
Most of these are summaries of definitions from the relayd.conf(5) man page. If you want to read about any of the key terms in more detail, it’s worth checking out. This post will not cover other components of relayd like redirections (layer 3 or IP-based forwarding), global configuration settings (like logging), health-checks, load balancing, and non-HTTP protocols (all of which you can also read about in the man page).
Tables
Tables are lists of hosts (similar to pf tables) that are used for target selection in relays.
Relays
Relays operate on layer 7 (the application layer) of the OSI model (which has its issues, but is a useful shorthand) and allow for advanced functionality like TLS termination and redirection based on HTTP headers and paths. Relays are what allow you to use relayd as an HTTP reverse proxy or do more advanced application layer request routing.
The relay configuration defines a port to listen and accepts client connections on. The forward to
directive of a relay determines which table of hosts and which port to forward connections to.
Protocols
The protocol of a relay tells it which protocol specification to use (protocols are separate blocks in the configuration file). A protocol specification can be one of three types: dns
, http
, or tcp
, and may be named arbitrarily. A protocol directive is essentially a set of rules for traffic received by any relay that uses said protocol.
Filter rules
Filter rules are available in the context of protocols, and allow you to do things like filter or manipulate traffic based on HTTP paths and header values. When you use the forward to
parameter in a filter rule inside of a protocol directive, there must also be a corresponding forward to
declaration in the relay that uses the protocol.
Having multiple forward to
declarations in a relay is one aspect of relayd’s configuration that often confuses me. It’s helpful to think of these as possible paths that a relay will forward a client to, with filter rules determining which path is taken (without filter rules, the first forward to
directive with any healthy hosts will be used).
Filter rules can have an action of block
, match
, or pass
. match
rules do not alter the block
/pass
state of a connection, but allow you to modify other aspects of it (like HTTP headers). The last matching block
or pass
rule determines if a connection is blocked or allowed (the quick
parameter can be used in a rule to stop processing of further rules).
Putting it all together
To understand how tables, relays, protocols, and filter rules work together, let’s walk through a simple example. The example won’t explicitly mention when or how to restart relayd (rcctl restart relayd
) (or how to use relayctl(8)) but generally you should restart it after any configuration change.
Prerequisites
This example assumes that you have a valid TLS certificate for example.com
(with SANs www.example.com
and service2.example.com
), with the certificate and private key stored at /etc/ssl/example.com.crt
and /etc/ssl/private/example.com.key
respectively. You can obtain a certificate using OpenBSD’s acme-client(1) or a tool like acme.sh.
It also assumes the following simple httpd configuration is saved at /etc/httpd.conf
and the httpd service is running (rcctl start httpd
).
server "www.example.com" {
listen on 127.0.0.1 port 8080
listen on 127.0.0.1 port 8443
block return 301 "https://example.com$REQUEST_URI"
}
server "example.com" {
listen on 127.0.0.1 port 8080
block return 301 "https://example.com$REQUEST_URI"
}
server "example.com" {
listen on 127.0.0.1 port 8443
root "/htdocs/example.com"
}
This configures httpd to listen only on localhost (127.0.0.1). Note that there is no TLS configuration here. httpd will assume that traffic arriving on port 8443 was sent by the client over HTTPS and TLS-terminated by relayd, and traffic arriving on port 8080 was sent by the client over HTTP. The latter (HTTP) traffic will be redirected to HTTPS. The first server
section additionally redirects any request (HTTP or HTTPS) for the www
subdomain to the apex domain (this is just a personal preference).
Initial relayd configuration
Save the following as /etc/relayd.conf
(update vio0
to your external network interface name):
table <httpd> { 127.0.0.1 }
http protocol "plaintext" {
pass forward to <httpd>
}
http protocol "encrypted" {
tls { keypair example.com no tlsv1.2 }
pass forward to <httpd>
}
relay "http" {
listen on vio0 port 80
protocol "plaintext"
forward to <httpd> port 8080
}
relay "https" {
listen on vio0 port 443 tls
protocol "encrypted"
forward to <httpd> port 8443
}
This configures relayd with one table, two protocols, and two relays. The protocols have one filter rule each, allowing requests and forwarding them to the <httpd>
table which contains a single host. Note that the filter rules that forward to the <httpd>
table (this name is arbitrary and does not have to be <httpd>
) have matching forward to
declarations in their corresponding relays (the relay names are also arbitrary and do not have to be http
and https
).
This is a very simple configuration that serves a single site over HTTPS. Now we can add tables and filter rules to support more complex request routing.
Routing HTTP paths to services
Say you have a service listening on port 8000 of localhost. Maybe it’s a Node.js or Python service that you’d like to put a TLS reverse proxy in front of, and serve at https://example.com/service1
. Add the following table:
table <service1> { 127.0.0.1 }
For these examples we could technically use the same table (since the only host we’re forwarding connections to is 127.0.0.1
).
Edit the encrypted
protocol:
http protocol "encrypted" {
tls { keypair example.com no tlsv1.2 }
pass quick path "/service1" forward to <service1>
pass forward to <httpd>
}
Without quick
in the filter rule all requests will end up matching the final, default pass
rule that forwards to the <httpd>
table, so this is important to include.
Edit the https
relay:
relay "https" {
listen on vio0 port 443 tls
protocol "encrypted"
forward to <httpd> port 8443
forward to <service1> port 8000
}
Requests to https://example.com/service1
should now be forwarded to your service running on port 8000.
Routing domains to services
You can also easily route a subdomain to a service running locally. This example assumes the service is listening on port 8001 of localhost.
Add a table:
table <service1> { 127.0.0.1 }
Edit the encrypted
protocol:
http protocol "encrypted" {
tls { keypair example.com no tlsv1.2 }
pass request quick header "Host" value "service2.example.com" forward to <service2>
pass quick path "/service1" forward to <service1>
pass forward to <httpd>
}
Note the inclusion of the request
parameter on the filter. This ensures that the filter rule only matches headers on HTTP requests and not responses. This parameter is not necessary on the path
filter rule because this parameter is only applicable with the request
direction.
Update the https
relay:
relay "https" {
listen on vio0 port 443 tls
protocol "encrypted"
forward to <httpd> port 8443
forward to <service1> port 8000
forward to <service2> port 8001
}
Requests to https://service2.example.com
should now be forwarded to your service running on port 8001.
Adding response headers
You may want to add HTTP headers to responses. This can be done by editing the encrypted
protocol:
http protocol "encrypted" {
tls { keypair example.com no tlsv1.2 }
match response header set "X-Frame-Options" value "deny"
pass request quick header "Host" value "example-service.example.com" forward to <example-service-subdomain>
pass quick path "/example-service" forward to <example-service>
pass forward to <httpd>
}
This adds the commonly recommended X-Frame-Options
header to all HTTP responses.
Final notes
This was a really helpful post for me to research and write as I make some minor updates to my relayd.conf
to forward requests to a self-hosted atproto PDS. Hopefully it’s helpful for others as well! Look for a post in the near future about PDS self-hosting on OpenBSD.