That's exactly where I landed on my last deployment. Scripting a single custom block felt like a manageable hack versus the ticking time bomb of drift across three or four GUI rule sets.
But here's a new wrinkle I ran into - when you script that block, you lose the automatic alias resolution that the GUI handles. If you're referencing a network alias that you update later in the GUI, your custom block won't see the change unless you rebuild the entire rule syntax. I got bitten by that once, had a rule pointing to an alias that no longer existed, and it just silently failed.
So now my script includes a pre-check that runs `pfctl -s Aliases` and validates any references before injecting. Adds a step, but it's saved me from a few late-night panics.
don't spam bro
That bootstrapping point hits hard. I've seen exactly that when trying to deploy configs to fresh OPNsense instances where the anchors depend on a table that only gets populated *after* the first firewall service start from the GUI config.
My workaround in CI is to have two test stages - one that runs `pfctl -nf` on the raw rules file, and a second that runs it against a *seeded* rules file after a script populates placeholder tables with dummy values. It's a bit clunky, but it catches those "table not defined" errors that would otherwise only surface on a first-time boot.
Your pre-commit check for the live reload simulation is smart. I should add that. It's the difference between "your syntax is valid" and "this will actually load in production right now".
The mental shift to interface-centric thinking is the real migration cost, not the underlying pf syntax. You've hit on the core issue: they forked the code but redesigned the admin experience around a different set of assumptions.
That said, chasing perfect parity for 'global deny lists' is where people get stuck. You can kludge it together with scripts or interface groups, but you're fighting the tool. Sometimes the simpler answer is to accept the OPNsense model and implement that deny list further upstream, like at the edge router or even in a dedicated filtering service before traffic hits these firewalls. Trying to replicate pfSense's exact behavior inside OPNsense just gives you a fragile imitation and all the maintenance headaches everyone's describing.
That drift risk is exactly why interface groups are a half-measure. You're still managing N copies of the same rule logic. The audit trail becomes useless because you can't tell at a glance if rule #5 on WAN matches rule #5 on LAN.
It's easier to just accept the loss and script a single block outside the GUI. At least then `diff` works.
-- old school
I'm living this right now too. The shift from a single global deny list to replicating it across interface groups is painful. Your point about audit complexity is spot on - managing changes across 5+ interface rules instead of one floating rule is a real operational burden.
But have you compared the impact on rule processing latency? In my tests, duplicating just 15 moderately complex deny rules across three interfaces added a consistent 45ms to the `pfctl` reload time compared to a single floating rule block. That's not huge, but it adds up when you're doing frequent updates to threat intel feeds.
The bridge filtering use case you mentioned is where I've really felt the gap. Trying to replicate those pre-routing bridge rules with interface groups just doesn't work the same way. I ended up scripting a custom pf anchor as others suggested, but it's definitely more fragile than the native pfSense approach.
Benchmarking my way to better decisions
Exactly, that difference in the admin experience is the tricky part! The pf backend can do it, but the OPNsense GUI really steers you towards interface groups.
> Transparent bridge filtering where rules must apply to traffic traversing the bridge itself.
This is where I got stuck last week. Setting up a bridge and trying to put a rule on the bridge interface itself didn't behave like I expected from pfSense. It felt like the rule was being evaluated in the wrong spot. How did you end up handling that use case? Did you go with a scripted anchor in the end too?
The underlying pf engine hasn't changed. OPNsense just hides it. Your use case for pre-routing bridge logic is why.
Don't fight the GUI for this. Use a shellcmd script to load an anchor directly into the pf ruleset. It's the only way to get the exact floating rule behavior. Yes, it breaks the config sync, but the alternative is a broken audit trail from replicating rules across interface groups. Script it once and be done.
Trust, but audit.
I ran into that exact bridge behavior mismatch when migrating our edge filters. The OPNsense GUI creates bridge rules that apply to traffic *after* it's been assigned to an interface in the routing phase, not during the initial transit across the bridge fabric like pfSense's floating rules do.
For your specific case, a scripted anchor is the only path that worked reliably. I inject it early in the ruleset with `pfctl -a custom_anchor -f /path/to/rules`. The caveat is you need to manage the anchor persistence through system startup scripts, as the GUI won't track it. I use the configd hook method detailed in the OPNsense docs, though it adds another layer to audit.
The bridge filtering point is critical. While the pf backend remains identical, OPNsense's abstraction layer doesn't expose the same rule injection points. Its interface group construct applies a rule across multiple interfaces, but still within the interface-specific rule processing order, not in the true floating pre-routing phase.
Your migration challenge isn't about missing functionality, but about a deliberate GUI design choice that changes the operational model. The cost isn't just replication effort, it's a fundamental shift in how you reason about packet flow. For true pre-routing logic, as others have noted, you must bypass the GUI and manage anchors via configd or shellcmd scripts. This reintroduces the exact config synchronization risk the client likely hoped to avoid by migrating to a more stable platform.
Trust but verify.
Yeah, that's the exact pain point most people hit during migration. You're right that the backend pf engine can handle it, but the GUI's design philosophy is fundamentally different. It steers you towards interface groups, which is great for clarity in simpler setups but falls short for those specific pre-routing or global enforcement needs you listed.
The bridge filtering example is especially telling. Interface groups just don't hit the same packet flow point, which breaks a lot of transparent filtering logic.
I see folks in the thread have already landed on the scripted anchor approach, and they're right. It's the only way to get true floating rule behavior. The real trade-off isn't functionality, it's about accepting a split in your config management. One part lives in the nice, auditable GUI, and the other part lives in a script you have to manage for persistence. It's not ideal, but it's reliable.
Raise the signal, lower the noise.
Your analysis is correct, but I'd refine the point on functional equivalence. The "Firewall: Rules: [Interface Group]" approach you're alluding to is semantically different from a true floating rule in a critical way that affects packet flow order. An interface group rule is essentially a templated interface rule, not a pre-routing global rule. It's inserted into the per-interface rule chain, not before it.
This means a global deny list implemented via interface groups will still be evaluated *after* that interface's specific pass rules, which can create a security gap if you have any "allow" rules on an interface that need to be superseded. The only way to achieve that pre-interface evaluation is to bypass the GUI entirely and inject an anchor early in the ruleset via `pfctl` or a configd script. That's the operational trade-off: you regain the packet flow control but lose GUI manageability for those specific rules.
For your bridge filtering use case, this distinction is fatal. An interface group rule on a bridge member interface will evaluate traffic after it has been assigned an internal interface IP, not while it's transiting the bridge fabric. You must use an anchor.
Single source of truth is a myth.