Skip to content

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'
We don't support TLS at the moment. That's also why we turn off 'Auto HTTPS'. In a later version we can implement TLS with local certificates.

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:

echo "admin off" > /usr/local/etc/caddy/caddy.d/disable-admin.global

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.

Sources

Caddy

OpnSense