art of the ongoing series on hardening the DMZ stack. This one assumes you already have fail2ban watching your app logs and banning offenders locally — the question this post answers is: why stop at the app server when you could stop them at the front gate?
For a long time, my fail2ban setup did exactly what fail2ban is supposed to do, and I was perfectly happy with it. Watch the logs, count the failures, ban the IP at the host firewall level via nftables. WordPress brute-forcers, badbots scraping for wp-login.php, the usual cast of characters — all of them got banned, all on the backend server itself.
The problem with that picture only became obvious once I actually thought about where the ban was happening. My backend, the box running Apache and ISPConfig and all the actual application logic, sits behind OPNsense and HAProxy, with a WireGuard tunnel carrying the traffic between the two boxes in the first place. Every single request that gets banned by fail2ban has already traveled all the way from the internet, through OPNsense, through HAProxy, before fail2ban even gets a chance to say no. The ban was real, but it was the last possible line of defense, not the first. Every blocked request still cost me a hop through the entire stack before being rejected.
That’s the itch this post scratches: take the bans fail2ban is already correctly deciding on, and push them out to the actual edge — OPNsense itself — so the next attempt from that IP never even reaches HAProxy, let alone the backend. The attacker gets stopped at the door instead of in the living room.
Why this isn’t a five-minute job
The annoying part of this problem is that fail2ban and OPNsense don’t speak the same language. fail2ban lives on a Linux box and bans IPs using nftables or iptables rules, scoped entirely to that machine. OPNsense is a separate firewall appliance running pf, with its own rule engine and its own way of thinking about IP lists, called aliases. There’s no built-in bridge between the two. fail2ban has no idea OPNsense exists, and OPNsense has no idea fail2ban exists. If you want one to inform the other, you have to build that bridge yourself.
OPNsense, fortunately, gives you a reasonable on-ramp: a URL Table (IPs) alias type, found under Firewall → Aliases. Instead of manually typing a static list of IPs into an alias, you point this alias type at a URL, and OPNsense periodically fetches that URL, expecting a plain text file with one IP per line, and uses the contents as a dynamically updating block list. That’s the missing piece — if I can get fail2ban to maintain a text file with all its currently-banned IPs, and serve that file somewhere OPNsense can reach, the rest is just OPNsense doing what it already knows how to do.
So the pipeline has three parts: a script that asks fail2ban “who’s currently banned, across every jail you’re running” and writes that to a file; a way to serve that file so OPNsense can fetch it; and an OPNsense-side alias and firewall rule that actually uses the list to block traffic at the WAN edge.
Part one: asking fail2ban what it knows
fail2ban already tracks every jail’s banned IPs internally — you can query any individual jail with fail2ban-client status <jailname>, and it’ll show you a Banned IP list line. The annoying bit is that if you’re running several jails (and most reasonably hardened setups are — separate jails for WordPress login attempts, badbots, 404 floods, and so on), you need to loop over all of them and merge the results, deduplicated, into one list.
bash
#!/bin/bash
OUTPUT_FILE="/home/user/dmz-stack/caddy/banlist/banned.txt"
TMP_FILE="$(mktemp)"
for jail in $(fail2ban-client status | grep "Jail list" | sed 's/^.*://;s/,//g'); do
fail2ban-client status "$jail" | grep "Banned IP list" | sed 's/^.*://' | tr ' ' '\n'
done | grep -v '^$' | sort -u > "$TMP_FILE"
mv "$TMP_FILE" "$OUTPUT_FILE"
chmod 644 "$OUTPUT_FILE"
This is deliberately unfancy. fail2ban-client status with no jail name lists every active jail; the loop asks each one for its banned IPs, strips the label text fail2ban prepends, and splits the space-separated list onto individual lines. Sorting and deduplicating means it doesn’t matter if the same IP shows up in two jails — it lands in the output file exactly once. The mktemp-then-mv pattern matters more than it looks like it should: writing directly to the final file risks OPNsense fetching a half-written file mid-update, while writing to a temp file and atomically moving it into place guarantees whoever reads banned.txt always sees a complete, consistent snapshot.
Drop this in /usr/local/bin/export-fail2ban-bans.sh, make it executable, and you’ve got the first link in the chain. Run it manually once and cat the output file to sanity-check it’s actually populated with real-looking IPs before moving on.
Part two: serving the file without opening a new can of worms
Now OPNsense needs to fetch this file over HTTP. The lazy option is to drop it somewhere your existing web server already serves — but I didn’t want a list of banned IPs sitting in a publicly browsable directory, even an obscure one, so I gave it its own dedicated, narrowly scoped route instead.
Since I already run Caddy as my reverse proxy in front of everything else, adding one more tightly restricted route was the path of least resistance:
caddyfile
:8090 {
@opnsense remote_ip 172.18.32.1
handle @opnsense {
root * /srv/banlist
file_server
}
handle {
abort
}
}
That remote_ip matcher is doing the actual security work here — only requests originating from OPNsense’s own WireGuard IP get served anything; everyone else hits abort, which just drops the connection outright rather than politely saying “no” with an HTTP error (politely saying no still tells a port-scanner something is listening). Putting this on its own port, 8090, separate from your normal 80/443 traffic, also means a casual look at access logs for your real sites never gets cluttered with OPNsense’s periodic polling requests. Mount the directory containing banned.txt into the container, expose the port in your compose file, and this half is done.
Part three: getting OPNsense to actually act on it
Inside OPNsense’s web UI, go to Firewall → Aliases and create a new alias. Set the type to URL Table (IPs), point the content field at the internal address of wherever you’re serving the file from — in my case http://172.18.32.11:8090/banned.txt, reachable over WireGuard — and set a refresh frequency. I run mine on a 5-minute cadence, which, combined with fail2ban’s own cron job re-running the export script every five minutes too, means worst-case latency between a ban happening and OPNsense enforcing it at the edge is comfortably under ten minutes. That’s not instant, but it’s a different category of protection than “eventually gets blocked after wasting a hop through my whole stack every single time.”
Once the alias exists and OPNsense has successfully fetched it at least once (you can confirm this under the alias’s own details view, which shows the current resolved IP count), the last step is actually using it. Go to Firewall → Rules → WAN, add a new rule with action Block, source set to your new alias, and — this part actually matters — drag the rule to the very top of the WAN rule list. Firewall rules are evaluated top-to-bottom, first match wins, and if some broader “allow” rule sits above your block rule, the block never gets a chance to fire.
Testing it without waiting for a real attacker
I didn’t want to wait around for an actual bot to get banned just to confirm the whole pipeline worked end to end, so I manually banned a couple of made-up test IPs directly through fail2ban (fail2ban-client set <jail> banip 203.0.113.99), ran the export script by hand, and watched the alias refresh in the OPNsense UI to confirm the count ticked up and the test IP appeared in the resolved list. Unbanning the same way and re-running the export confirmed it cleared correctly too. This kind of synthetic round-trip test is worth doing deliberately rather than trusting the pipeline blind — it’s much easier to debug a test IP that you know should or shouldn’t be there than to debug a real attacker’s IP weeks later while also wondering whether the pipeline ever worked at all.
What this actually buys you
It’s tempting to think of this as a marginal optimization — fail2ban was already blocking these IPs, so who cares if the block happens one hop earlier? But the difference compounds in ways that aren’t obvious until you’ve lived with it. Every blocked request that used to traverse OPNsense, HAProxy, and reach the application layer before being rejected was still consuming connection slots, CPU cycles, and log lines at every layer along the way. Pushing the block to the WAN edge means a banned attacker’s retry traffic gets silently dropped before it ever touches HAProxy’s connection table, which matters more than you’d expect once you’re also running Suricata and CrowdSec at that same edge — fewer packets reaching the inspection layers means cleaner signal in those tools too, since they’re not wading through traffic that’s already a known, settled case. Hardening Your Homelab’s Public Edge covers that GeoIP/CrowdSec layer in full — worth noting that post describes a different fail2ban-to-OPNsense sync method (a direct API push from fail2ban itself) than the pull-based, flat-file approach built here, so treat them as two alternative implementations rather than the same pipeline described twice.
There’s also a quieter benefit: once this pipeline exists, it becomes the natural place to plug in anything else that produces a list of bad IPs. CrowdSec’s own community blocklist already feeds OPNsense through a similar mechanism; Suricata’s alert-driven blocks could, in principle, feed the same kind of alias. The pattern — local detection, exported as a flat list, consumed by the edge firewall as a dynamic alias — turns out to be a reusable shape for tying together security tools that were never designed to talk to each other directly. fail2ban doesn’t need to know OPNsense exists, and OPNsense doesn’t need to know fail2ban exists. They just both need to agree on the shape of a text file, and that turns out to be enough.
This same “trust based on network origin, not credentials” idea reappears in two later posts: the network-scoped Authelia bypass for Open WebUI’s search calls, and the self-updating anti-lockout alias for remote firewall management — both are the same alias-driven, dynamically-resolved trust pattern used here, just applied to allowing traffic instead of blocking it.
