Secure your HTTP services with OpnSense and Caddy
Ever wanted to have more fine-grained control over your internal HTTP services? Ever only wanted to expose a selective handful of URLs to a single external client? In this post I'll explain my way of doing this with instructions and reasoning as to why I did certain things in a certain way.
Info
For this post I'm assuming that you have a OpnSense firewall as well as some external client (VPS, friends house, etc)
Why?
Why add a WAF (web application firewall) when you can just pass trough the HTTP port of the service in the firewall? You're already connected trough a VPN.
Because sometimes you're in the scenario where you want to give users only limited access to a service.
Scenario
Imagine an uptime monitoring system on a VPS that connects to your local network over a VPN and sends an HTTP request to the health endpoint of a sensitive service.
No safety from same-network attacks
This article is about protecting your HTTP services from outside threats, not same-network threats. That is a whole different can of worms which is quite a bit more difficult to deal with since you cannot (easily) block traffic within the same network.
Now imagine that someone manages to take over the monitoring software or even the VPS. Now they have full access to the sensitive service because you opened the port on the firewall which gives them access to the entire service instead of only the healthcheck endpoint trough the WAF.
How?
Used versions
At the time of writing I have OpnSense 25.1.12 and os-caddy 2.0.2 installed. Different versions will probably work, but might have slightly different steps.
The following steps assume a semi-blank slate:
- P2P Wireguard tunnel has been set up (
network: 10.10.200.0/24; OpnSense: 10.10.200.1; VPS: 10.10.200.2). - LAN is configured with subnet
192.168.2.0/24. - Server A runs on 192.168.2.100 with our sensitive application.
Install Caddy on OpnSense
This is the simplest step:
System -> Firmware -> Plugins -> search: os-caddy and hit install.
Configure Caddy
Now we need to configure the settings for Caddy that we can do from the UI of OpnSense.
Services -> Caddy -> General Settings:
Check 'Enable Caddy'
Set 'Auto HTTPS' to 'Off'
Services -> Caddy -> Advanced Settings:
Set 'System User' to 'www' // Enhances security since compromise doesn't compromise the root user
Set 'HTTP Versions' to only 'HTTP/1.1' // Higher versions require TLS which we currently don't support
Set 'HTTP Port' to '8081'
Set 'HTTPS Port' to '8444'
Disable Caddy admin API
login over SSH
The following steps need to be taken from a shell in OpnSense. SSH into OpnSense and open a shell (option 8).
The admin API for Caddy is exposed by default which makes it insecure since it can be used by anyone to modify the configuration. To disable the API, run the following command:
Writing our WAF
With a text editor of choice, you can create the file /usr/local/etc/caddy/caddy.d/waf.conf.
In this file we write the following Caddyfile configuration:
:8080 {
log
# Sensitive service rules
handle_path /sensitive/* {
@health_api {
method GET
path /api/app/health
remote_ip 10.10.200.2
}
handle @health_api {
reverse_proxy 192.168.2.100:9000
}
respond "Not found" 404 {
close
}
}
}
What is happening here?
On the first line we tell that we want to listen for HTTP requests on port 8080.
The second line determines that we want to log all the requests made on this port.
The handle_path directive matches any url starting with /sensitive/ and strips that part of the url before starting to process it.
We then have a named matcher which determines what criteria are required to match, in this case that the request method is GET,
the requested path is /api/app/health and the requester has the IP 10.10.200.2.
After that we have a handle directive that only runs if the matcher has a match, and then proxies the request to our sensitive service.
If the matcher does not match, we return a 404 Not found and close the connection.
This process can be repeated for different rules by adding another handle_path directive with a different path directly under the one we just defined.
Then update the named matcher with the criteria that are required and the upstream location.
What's next?
We have built a WAF to catch and proxy allowed requests from allowed IPs. But we don't authenticate that they are who they say they are. We also still have a gaping hole in remote SSH access since we allow those connections to pass straight trough our firewall to the servers that require it. So further steps are:
- Implement TLS and mTLS for HTTPS and authentication in Caddy.
- Modify OpnSense's built-in SSH server to work as an SSH authentication/firewall server with LDAP for authentication.