Gateway groups fail to compile into policy-based routing (route-to) rules
Important notices
- [x] I have read the contributing guide lines at https://github.com/opnsense/core/blob/master/CONTRIBUTING.md
- [x] I am convinced that my issue is new after having checked both open and closed issues at https://github.com/opnsense/core/issues?q=is%3Aissue
Describe the bug
Gateway groups configured for load balancing fail to compile into policy-based routing (route-to) directives in pf firewall rules. When a firewall rule is configured with a gateway group assigned, the gateway setting is stored in /conf/config.xml but does not generate the corresponding route-to statement in /tmp/rules.debug or the active pf ruleset. This renders gateway groups unusable for policy-based routing scenarios.
Last known working version: Unknown - issue appears to be long-standing based on forum discussions.
To Reproduce
Steps to reproduce the behavior:
- Go to System > Gateways > Single and verify 3+ gateways are online (e.g., three OpenVPN client gateways)
- Go to System > Gateways > Group and create a gateway group with all gateways at Tier 1 (load balance mode), Trigger Level: "Member Down"
- Go to Firewall > Rules > [Interface] (e.g., Lan2Vpn interface)
- Add a new rule: Action=Pass, Source=[Interface] net, Destination=any, Gateway=[GatewayGroup]
- Save and Apply Changes
- SSH to firewall and run:
pfctl -vsr | grep "[interface]" | grep "route-to" - Observe: No route-to directive present in output
Expected behavior
The firewall rule should compile into pf with a route-to directive containing all gateways from the group:
pass in quick on vlan02 route-to { ovpnc2 ovpnc3 ovpnc4 } round-robin sticky-address inet from (vlan02:network) to any keep state
Actual behavior
The rule compiles without any route-to directive:
pass in quick on vlan02 inet from (vlan02:network) to any keep state label "..."
Traffic follows the default system route instead of being policy-routed through the gateway group.
Describe alternatives you considered
Workaround that works: Creating three separate firewall rules, each with an individual gateway (not a group), and disabling the "quick" flag. This generates three route-to rules that pf can use, though without proper round-robin load balancing:
pass in on vlan02 route-to ovpnc2 inet from (vlan02:network) to any keep state
pass in on vlan02 route-to ovpnc3 inet from (vlan02:network) to any keep state
pass in on vlan02 route-to ovpnc4 inet from (vlan02:network) to any keep state
Manual fix: Editing /tmp/rules.debug directly after each configuration change to insert proper round-robin syntax, but this is lost on every firewall reload.
Relevant log files
Evidence from configuration and debug files:
Gateway group exists in config.xml:
<gateway_group>
<name>ProtonVPN_LoadBalanced</name>
<item>PROTONVPN_T2_VPNV4|1</item>
<item>PROTONVPN_T1_VPNV4|1</item>
<item>PROTONVPN_T0_VPNV4|1</item>
<trigger>down</trigger>
<descr>ProtonVPN 3x OpenVPN Load Balanced</descr>
</gateway_group>
Firewall rule references gateway group in config.xml:
<rule uuid="5a108279-673e-42d2-b50a-52193a788d30">
<type>pass</type>
<interface>opt3</interface>
<ipprotocol>inet</ipprotocol>
<statetype>keep state</statetype>
<descr>Force Lan2Vpn through VPN</descr>
<gateway>ProtonVPN_LoadBalanced</gateway>
<direction>in</direction>
<quick>1</quick>
<source>
<network>opt3</network>
</source>
<destination>
<any>1</any>
</destination>
<updated>
<username>[email protected]</username>
<time>1763729423.64</time>
<description>/firewall_rules_edit.php made changes</description>
</updated>
<created>
<username>[email protected]</username>
<time>1763729423.64</time>
<description>/firewall_rules_edit.php made changes</description>
</created>
</rule>
rules.debug shows rule WITHOUT route-to:
$ cat /tmp/rules.debug | grep -i "vlan02" | grep -i "pass"
pass in quick on vlan02 inet from {(vlan02:network)} to {any} keep state label "c5485d3ce1611f2b430fd57713f0ca3f"
Active pf rules confirm no route-to:
$ pfctl -vsr | grep "vlan02" | grep "route-to"
(no output)
Gateway monitoring confirms all gateways are online:
$ ps aux | grep dpinger | grep -i proton
root 49415 dpinger -f -S -r 0 -i PROTONVPN_T2_VPNV4 -B 10.98.0.4
root 75000 dpinger -f -S -r 0 -i PROTONVPN_T1_VPNV4 -B 10.98.0.27
root 78027 dpinger -f -S -r 0 -i PROTONVPN_T0_VPNV4 -B 10.98.0.6
Additional context
- Single gateways assigned to firewall rules compile correctly with route-to directives
- Gateway groups work properly for system-level multi-WAN failover/load balancing
- The issue specifically affects policy-based routing via firewall rules
- Multiple users have reported similar issues in forums but no official bug tracking found
- Tested after OPNsense 25.7.7_4 update and reboot - issue persists
Environment
Software: OPNsense 25.7.7_4 (amd64)
Hardware: Intel NUC-style device with Intel NICs
Network: 3x OpenVPN client connections to ProtonVPN, multiple VLANs requiring different gateway routing (some direct WAN, some through VPN)
This is a duplicate of https://github.com/opnsense/core/issues/6486 and cause by missing target addresses, I made an effort some years ago to implement it (https://github.com/opnsense/core/commit/362b08622a9f7da00cd8e701f421b8c063a44010), but since the protocol is a requirement and should match the ones in the rules, it didn't make any release.
One day if we will rewrite the groups, there might be another opportunity to fix this, but given the complexity (some gateways may have their address set, some may not, gateways can be changed manually or by dhcp type interfaces), my hopes aren't high.
The main issue is, one broken rule will invalidate the whole set, which may result in a broken firewall at boot.
@MiceMiceRabies I had some additional inspiration, can you try https://github.com/opnsense/core/commit/75e67641305488c33f798ee6aba192cda334d809 ?
You can patch your current system using:
opnsense-patch 75e6764
We likely keep this change on the master branch for a while, it tries to workaround pf's limitations, but certainly isn't perfect. This is partly caused by missing validations in gateway groups (which have to wait until migrated into the mvc framework).