Mwan3 port to nftables

Ok, let's treat all of these things in order. This round robin one first as there's no point checking the other issues until we determine what's happening here.

I'll look at the speedtest and PBR issues after we've resolved this one.

Looking at the kernel implementation in nft_numgen.c, numgen uses an atomic CAS loop:

static u32 nft_ng_inc_gen(struct nft_ng_inc *priv)
{
    u32 nval, oval;

    do {
        oval = atomic_read(priv->counter);
        nval = (oval + 1 < priv->modulus) ? oval + 1 : 0;
    } while (atomic_cmpxchg(priv->counter, oval, nval) != oval);

    return nval + priv->offset;
}

It's a single global counter. Every CPU increments the same atomic value. So perfect round-robin is expected behaviour and is what I'm getting.

Possible (and likely) explanations why you're not seeing this behaviour:

The DNS record for ifconfig.me has both an IPv6 address and an IPv4 address. If you don't have IPv6 connectivity and it resolves only to a single IPv4 address for you then you should get perfect round robin. But you're not, so either the DNS is resolving to multiple IPv4 addresses for you or you have IPv6 connectivity or both.

1. ifconfig.me is resolving to multiple different IPv4 addresses for you

if DNS returns different IPs across queries and only 34.160.111.145 matches the dest_ip rule, unmatched connections fall through to default routing (one fixed wan egress). That would produce exactly this kind of clustered pattern. nslookup ifconfig.me will tell you this as it returns all A records in one shot.

Use an nft set / ip set in the rule instead of a destination ip address. Add an ipset to dnsmasq for ifconfig.me and filter on the nft set in the mwan3 rule instead. Just remember to restart dnsmasq and make sure whatever lan client you're doing the test from will resolve its ip from dnsmasq or just do a nslookup or dig on the openwrt host to force dnsmasq to resolve the query and populate the set.

You can check the set is populated on the openwrt host with

nft list set inet fw4 <setname>

Using an nftset will also resolve the potential IPv6 issue below, so changing the rule in this way is desirable. I've set my own host that I was testing from to prefer ipv4 because I get ipv6 through a tunnel, so it wasn't necessary in my case.

2. Your device has ipv6 connectivity and curl connects via ipv6 some of the time

curl's algorithm tries IPv6 first, only falling back to IPv4 after a 200ms timeout. ifconfig.me has an AAAA record and if you have IPv6 connectivity on one or both wans, connections where IPv6 completes in under 200ms bypass the mwan3 rule entirely (since it is IPv4-only) and exit via the IPv6 default route.

If IPv6 latency varies, for example one wan has consistently low IPv6 latency while the other is higher or has occasional spikes, then sometimes IPv6 wins the race and sometimes it doesn't, causing an unpredictable mix of IPv6-routed connections ignoring mwan3 and IPv4-routed connections load balanced by mwan3. The result would be the irregular pattern you see.

Again, fix it by changing the rule to use an nft set and add ifconfig.me to that nft set in the dnsmasq configuation.

You can also add a -4 parameter to the curl invocation just to be sure on an initial test. If you add the nft set and that produces round robin behaviour, then try again without the -4 parameter

while true; do echo $(curl -4 https://ifconfig.me 2> /dev/null); done
root@ax6000:~# while true; do echo $(curl -4 https://ifconfig.me 2> /dev/null); done
BBB.BB.42.150
AA.AAA.6.64
AA.AAA.6.64
AA.AAA.6.64
AA.AAA.6.64
AA.AAA.6.64
AA.AAA.6.64
BBB.BB.42.150
AA.AAA.6.64
AA.AAA.6.64
AA.AAA.6.64
AA.AAA.6.64
AA.AAA.6.64
AA.AAA.6.64
BBB.BB.42.150
AA.AAA.6.64
AA.AAA.6.64
BBB.BB.42.150
AA.AAA.6.64
AA.AAA.6.64
AA.AAA.6.64
BBB.BB.42.150
AA.AAA.6.64
AA.AAA.6.64
AA.AAA.6.64
^C
root@ax6000:~# mwan3 status
Interface status:
 interface wan is online and tracking is active (online 00h:00m:32s, uptime 02h:49m:10s)
 interface wan2 is online and tracking is active (online 00h:00m:32s, uptime 02h:49m:07s)

Current policies:
balanced:
 wan2 (23%)
 wan (76%)
wansatuaja:
 wan
wanduaaja:
 wan2

Directly connected ipv4 networks:
10.0.0.0/24
10.5.0.0/16
10.248.208.0/20
224.0.0.0/3

Directly connected ipv6 networks:
fe80::

Active user rules:
 ip daddr @ifconfigg meta mark & 0x00003f00 == 0x00000000 - balanced
 ip daddr @torboxx meta mark & 0x00003f00 == 0x00000000 - wansatuaja
 ip daddr @realdebridd meta mark & 0x00003f00 == 0x00000000 - wansatuaja
 tcp dport 443 meta mark & 0x00003f00 == 0x00000000 S https
 ip daddr 0.0.0.0/0 meta mark & 0x00003f00 == 0x00000000 - balanced

root@ax6000:~# 
root@ax6000:~# nft list set inet fw4 ifconfigg
table inet fw4 {
        set ifconfigg {
                type ipv4_addr
                elements = { 34.160.111.145 }
        }
}
root@ax6000:~# uci show mwan3 | grep ifconfigg
mwan3.ifconfigg=rule
mwan3.ifconfigg.family='ipv4'
mwan3.ifconfigg.proto='all'
mwan3.ifconfigg.sticky='0'
mwan3.ifconfigg.ipset='ifconfigg'
mwan3.ifconfigg.use_policy='balanced'
root@ax6000:~# cat /etc/config/dhcp
config ipset
        list name 'ifconfigg'
        list domain 'ifconfig.me'
        option table 'fw4'
        option table_family 'inet'
root@ax6000:~# cat /etc/config/firewall 
config ipset
        option name 'ifconfigg'
        option family 'ipv4'
        list match 'dest_ip'
1 Like

Load balancing appears to be working correctly, but you look like you have configured unequal member weights?

The 23%/76% split implies weights of 3 and 10 respectively (3×100/13 = 23, 10×100/13 = 76 with integer division).

The curl output points to this as BBB appears ~20% of the time and AAA appears ~80%, matching the percentages.

The curl output showing mostly AA.AAA.6.64 with occasional BBB.BB.42.150 is what you'd expect from a 3:10 weighted policy.

What weights do you have configured on your members? They likely have something like option weight '3' on one member and option weight '10' on the other.

uci show mwan3 | grep weight

If you want equal load balancing, both members need option weight '1' and a restart of mwan3

Because it has different speeds one is 1gbits amd the other 300mbps

Well then you're getting the expected result. The load balancing seems to be working properly for you bearing in mind that you have deliberately unequal weights due to the different link speeds, right?

I'll look into speedtest_cli next.

Thanks - there are two related issues identified in your output

1. Empty policy display

When all members in a load-balancing policy have equal weight of 1, nft normalises the numgen map entry 0-0 : 0xMARK to just 0 : 0xMARK. The regex mwan3 was using to parse the output only matched the N-M form, so it found nothing and showed the policy as empty. The load balancing itself was working correctly, just the status display was wrong.

2. Cross-family member reset (if you have mixed IPv4/IPv6 policies)

When a policy mixes IPv4 and IPv6 members, a single shared member list was being reset whenever a new lowest-metric member was encountered, regardless of the address family. This meant whichever family was processed last would wipe out the other family's members, leaving the policy with only one family's interfaces. The generated nft rules were therefore incomplete.

I've patched and tested a fix for both of these issues and will push a version 3.1.3 later

2 Likes

Ok, I just tested this myself and it works fine for me.

Both my uplinks are 1gbps and the (desktop client) ethernet ports are also 1gbps so I can't aggregate download unless I throttle both uplinks using SQM, which I did, to 200Mbps each.

Testing a policy receiving only on each link, it capped out at 200Mbps per link.

Testing a policy load balancing across the two, it doubled the reported bandwith to 400Mbps.

My connections are both asymmetric and it doubled my upload bandwidth.

So (this not very clean) test of load balancing works for me.

The config you posted earlier in this thread shows a https rule with sticky '1' on port 443, which sits above the default rule. speedtest.net runs over https, so the first connection gets assigned to an interface and the sticky map then pins every subsequent connection from that source IP to the same interface.

With a sticky https rule like this one you have, no aggregation of speedtest is possible regardless of how many parallel connections the test opens.

I've released mwan3 version 3.1.3 for testing

Fix three bugs in mwan3_create_policies_nft and mwan3_report_policies . Equal-weight load balancing policies were incorrectly shown as empty in mwan3 status . Mixed IPv4/IPv6 policies silently lost members from one address family when the other family's members were processed. Single-member and mixed-family policies showed spurious unreachable entries in mwan3 status instead of interface names.

@woffko this should fix the errors you were seeing on the mwan3 status output

3 Likes

ok seems new version runs great, in my setup the only thing that still not working compared to mwan3-iptables is pbr. hope you could take a look into it (i'll be ready when you needs anything). cheers

Ok - I didn't make any changes to mwan3's fundamental mechanisms. The packet marking remains the same, so not sure why it doesn't co-exist peacefully if it did before. I'd need to look at what PBR is doing. They're both doing something similar, so the potential definitely exists for conflict. Might help if you can post your PBR config.

Thanks, I can confirm that it is fixed now, runs great

1 Like

You're right: I did actually do something that breaks the interoperability when looking at the PBR code and where it injects into the fw4 mangle chain.

nftables doesn't allow the same kind of compound two-source bitwise expression used in mwan3-iptables and which previously permitted the two packages to co-exist peacefully in an order-independent fashion.

The nftables equivalent of this compound masked merge just gets straight out rejected by the kernel with "Operation not supported" .

So while the iptables version of mwan3 selectively merged only mwan3's mark bits into the packet mark, leaving other bits in the 0x00ff0000 range unmolested, the nftables port of mwan3 is forced to use an unmasked restore.

PBR runs first and then always gets its bits nuked by mwan3 before the routing decision is made, so PBR's ip rule entries never match.

While the choice of mangle + 1 was architecturally correct for mwan3 in isolation, PBR injects earlier and so the ordering is actually the reverse of what it should be to make both packages co-exist.

mwan3 should actually run first, after which PBR can add its marks so both marks are present at the routing decision, which with the new nftables implementation requires PBR to run after mwan3, not before it.

The pragmatic fix is just to move mwan3 before PBR. mwan3 then restores and saves its mark before PBR has set anything, PBR adds its bits on top, and both sets of marks are present when the routing decision is made, yielding identical behaviour to the iptables variant of mwan3.

[Note that the original iptables version of mwan3 papered over this distinction by making the mark operations order-independent, which we can't do anymore.]

There are a couple of minor disadvantages to moving mwan3 to mangle -1

  • any other package injecting into fw4's mangle_prerouting runs after mwan3 and mwan3 can't see their mark. That's probably not an issue in practice, since packages that need to influence mwan3's routing decision can use mwan3's own rule system
  • fw4 uses mangle_prerouting for things like conntrack helper assignment and DSCP remarking. mwan3 doesn't depend on any of these, so in practice this doesn't matter, but it's a departure from the original design intent of running at mangle + 1 and might limit future mwan3 functionality extensions.
  • Priority mangle + 1 was chosen in the original iptables-to-nftables conversion to ensure mwan3 ran after fw4 established its own mangle state. Changing it could expose unexpected interactions on future fw4 versions.

Of course, we could also see if @stangri would be open to changing the priority of PBR so that it runs at mangle + 2. That would be a tidier overall solution, although it isn't quite as simple for PBR as the change to mangle -1 was for mwan3 as PBR would then need to stop injecting into fw4's chain and start managing its own base chain along with the associated hotplug and reload handling.

I've moved mwan3 so that it runs before PBR and that fully restores interoperability in the same way as the iptables variant (I've tested that interoperability too). Path of least resistance...

1 Like

I've released mwan3 version 3.1.4 for testing

This fixes the interoperability with the policy based routing package (PBR).

The nftables port's unmasked mark restore zeroed pbr's fwmark bits on every packet, causing pbr's ip rule entries to never match. Fixed by running mwan3 at priority mangle - 1 so mwan3 processes before pbr. A postinst migration removes old chains so fw4 can recreate them at the new priority on upgrade.

@4rtz1z over to you for some more testing. I've done some extensive testing of multiple scenarios myself and it all works..

3 Likes

i've tried the latest version, but still using it together makes pbr not working

pbr config, some rules are redacted

root@ax6000:~# cat /etc/config/pbr

config pbr 'config'
option enabled '1'
option fw_mask '00ff0000'
option ipv6_enabled '0'
option nft_rule_counter '0'
option nft_set_auto_merge '1'
option nft_set_counter '0'
option nft_set_flags_interval '1'
option nft_set_flags_timeout '0'
option nft_set_policy 'performance'
option nft_user_set_counter '0'
option procd_boot_trigger_delay '5000'
option procd_reload_delay '0'
option resolver_set 'dnsmasq.nftset'
option strict_enforcement '0'
option uplink_interface 'wan'
option uplink_interface6 'wan6'
option uplink_ip_rules_priority '900'
option uplink_mark '00010000'
list ignored_interface 'vpnserver'
list lan_device 'br-lan'
list resolver_instance '*'
option config_compat '25'
option config_version '1.2.2-r12'
option rule_create_option 'insert'
option verbosity '2'
option webui_show_ignore_target '1'

config policy
option name 'Ignore Local Requests'
option interface 'ignore'
option dest_addr '10.0.0.0/24'

config policy
option name 'Telegram'
option dest_addr 'cdn-telegram.org comments.app contest.com fragment.com graph.org quiz.directory t.me tdesktop.com telega.one telegra.ph telegram-cdn.org telegram.dog telegram.me telegram.org telegram.space telesco.pe tg.dev tx.me usercontent.dev ton.org 91.108.56.0/22 91.108.4.0/22 91.108.8.0/22 91.108.16.0/22 91.108.12.0/22 149.154.160.0/20 91.105.192.0/23 91.108.20.0/22 185.76.151.0/24'
option interface 'wgsg'

config policy
option name 'pbr check'
option dest_addr 'ipleak.net'
option chain 'output'
option interface 'wgsg'

config policy
option name 'pbrcheck2'
option dest_addr 'browserleaks.com'
option chain 'output'
option interface 'wgid'

the way i test if it's working is by "tracerouting", and all my rules is not routed thru wireguard iface when using mwan3-nft, which are 'wgsg'

the policy pbrcheck and pbrcheck2 are for checking by tracerouting inside the router itself.

how do you test it if i may know ?

Here is a definitive demonstration that it actually does work and does co-exist peacefully and correctly with pbr.

I duplicated the setup you described and then performed the tests you described. The traces show clearly that the correct routing decisions are made: pbr overrides mwan3 for configured policies.

It will probably take you a long time to wade through as it's detailed output, but it's divided into sections and commented and the full traces are supplied. The short of it is that I think you have a configuration error.

Your pbr config very likely lacks

list supported_interface 'wgsg'
list supported_interface 'wgid'

Without these, pbr does not create ip rules or routing tables for wireguard interfaces (they have no gateway for auto-discovery). pbr will just silently ignore policies for those interfaces.

Trace output
Your config:

  mwan3: wan + wan2, default_rule_v4 0.0.0.0/0 balanced (catch-all)
  pbr:   uplink_ip_rules_priority='900', strict_enforcement='0'
         policy 'pbr check':  dest_addr='ipleak.net', chain='output', interface='wgsg'
         policy 'pbrcheck2':  dest_addr='browserleaks.com', chain='output', interface='wgid'
  Test:  traceroute from the router to the policy destinations

My equivalents:

  wgsg --> wg0 
  wgid --> wg1 
  policies: pbrcheck_wg0 (ipleak.net --> wg0), pbrcheck_wg1 (browserleaks.com --> wg1)


================================================================================
SECTION 1: INITIAL SYSTEM STATE
================================================================================

root@openwrt:~# mwan3 status
Interface status:
 interface wan is online and tracking is active (online 06h:03m:36s, uptime 96h:06m:48s)
 interface wan2 is online and tracking is active (online 06h:03m:31s, uptime 96h:07m:01s)
 interface wg0 is online and tracking is active (online 06h:03m:36s, uptime 96h:06m:44s)
 interface wg1 is online and tracking is active (online 06h:03m:36s, uptime 96h:06m:44s)

Current policies:
r_only:
 wan
v_only:
 wan2
r_then_v:
 wan
v_then_r:
 wan2
r_and_v:
 wan (50%)
 wan2 (50%)
wireguard:
 wg0
vpn:
 wg1

Directly connected ipv4 networks:
[redacted]

Directly connected ipv6 networks:
[redacted]

Active user rules:
 [redacted]
 meta mark & 0x00003f00 == 0x00000000 - r_and_v

root@openwrt:~# ip rule list
0:	from all lookup local
220:	from all lookup 220
895:	from all sport [redacted] lookup 256
896:	from all sport [redacted] lookup 256
897:	from all fwmark 0x40000/0xff0000 lookup 259
898:	from all fwmark 0x30000/0xff0000 lookup 258
899:	from all fwmark 0x20000/0xff0000 lookup 257
900:	from all fwmark 0x10000/0xff0000 lookup 256
1001:	from all iif pppoe-wan lookup 1
1002:	from all iif eth0 lookup 2
1003:	from all iif wg0 lookup 3
1004:	from all iif wg1 lookup 4
2001:	from all fwmark 0x100/0x3f00 lookup 1
2002:	from all fwmark 0x200/0x3f00 lookup 2
2003:	from all fwmark 0x300/0x3f00 lookup 3
2004:	from all fwmark 0x400/0x3f00 lookup 4
2061:	from all fwmark 0x3d00/0x3f00 blackhole
2062:	from all fwmark 0x3e00/0x3f00 unreachable
3001:	from all fwmark 0x100/0x3f00 unreachable
3002:	from all fwmark 0x200/0x3f00 unreachable
3003:	from all fwmark 0x300/0x3f00 unreachable
3004:	from all fwmark 0x400/0x3f00 unreachable
32766:	from all lookup main
32767:	from all lookup default

root@openwrt:~# nft list sets inet fw4 | grep -A3 "set pbr"
	set pbr_wg0_4_dst_ip_cfg096ff5 {
		type ipv4_addr
		flags interval
		auto-merge
--
	set pbr_wg1_4_dst_ip_cfg0a6ff5 {
		type ipv4_addr
		flags interval
		auto-merge

root@openwrt:~# /etc/init.d/pbr status
pbr - environment
pbr 1.2.2-r12 on OpenWrt 25.12.1 r32768-b21cfa8f8c.
Uplink (IPv4): wan/pppoe-wan/[redacted].

Dnsmasq version 2.91  Copyright (c) 2000-2025 Simon Kelley
Compile time options: IPv6 GNU-getopt no-DBus UBus no-i18n no-IDN DHCP DHCPv6 no-Lua TFTP conntrack ipset nftset auth DNSSEC no-ID loop-detect inotify dumpfile

pbr chains - policies
	chain pbr_forward { # handle 2003
	}
	chain pbr_output { # handle 2004
	}
	chain pbr_prerouting { # handle 2005
	}
	chain pbr_dstnat { # handle 2002
	}

pbr chains - marking
	chain pbr_mark_0x010000 { # handle 2013
	}
	chain pbr_mark_0x020000 { # handle 2016
	}
	chain pbr_mark_0x030000 { # handle 3550
	}
	chain pbr_mark_0x040000 { # handle 3553
	}

pbr nft sets
	set pbr_wg0_4_dst_ip_cfg096ff5 { # handle 5472
		type ipv4_addr
		flags interval
		auto-merge
		comment "pbrcheck_wg0"
		elements = { 95.85.16.212 }
	}
	set pbr_wg1_4_dst_ip_cfg0a6ff5 { # handle 5474
		type ipv4_addr
		flags interval
		auto-merge
		comment "pbrcheck_wg1"
		elements = { 104.236.69.55 }
	}


================================================================================
SECTION 2: CONFIGURE PBR
================================================================================

root@openwrt:~# cp /etc/config/pbr /tmp/pbr-backup-20260408.conf

root@openwrt:~# uci set pbr.config.enabled=1
root@openwrt:~# uci set pbr.config.uplink_ip_rules_priority=900
root@openwrt:~# uci set pbr.config.strict_enforcement=0
root@openwrt:~# uci set pbr.config.resolver_set=dnsmasq.nftset
root@openwrt:~# uci add_list pbr.config.supported_interface=wg0
root@openwrt:~# uci add_list pbr.config.supported_interface=wg1

root@openwrt:~# uci add pbr policy
cfg0a6ff5
root@openwrt:~# uci set pbr.cfg0a6ff5.name=pbrcheck_wg0
root@openwrt:~# uci set pbr.cfg0a6ff5.interface=wg0
root@openwrt:~# uci set pbr.cfg0a6ff5.dest_addr=ipleak.net
root@openwrt:~# uci set pbr.cfg0a6ff5.chain=output
root@openwrt:~# uci set pbr.cfg0a6ff5.enabled=1

root@openwrt:~# uci add pbr policy
cfg0b6ff5
root@openwrt:~# uci set pbr.cfg0b6ff5.name=pbrcheck_wg1
root@openwrt:~# uci set pbr.cfg0b6ff5.interface=wg1
root@openwrt:~# uci set pbr.cfg0b6ff5.dest_addr=browserleaks.com
root@openwrt:~# uci set pbr.cfg0b6ff5.chain=output
root@openwrt:~# uci set pbr.cfg0b6ff5.enabled=1

root@openwrt:~# uci commit pbr


# pbr configured to replicate 4rtz1z scenario:
#   enabled=1, uplink_ip_rules_priority=900, strict_enforcement=0
#   resolver_set=dnsmasq.nftset
#   supported_interface: wg0, wg1 (required for gateway-less wireguard interfaces)
#   policy pbrcheck_wg0: ipleak.net -> wg0, chain=output
#   policy pbrcheck_wg1: browserleaks.com -> wg1, chain=output


================================================================================
SECTION 3: START PBR AND VERIFY STATE
================================================================================

root@openwrt:~# service pbr start
root@openwrt:~# ip rule list
0:	from all lookup local
220:	from all lookup 220
894:	from all lookup main suppress_prefixlength 1
895:	from all sport [wg1 port] lookup pbr_wan
896:	from all sport [wg0 port] lookup pbr_wan
897:	from all fwmark 0x40000/0xff0000 lookup pbr_wg1
898:	from all fwmark 0x30000/0xff0000 lookup pbr_wg0
899:	from all fwmark 0x20000/0xff0000 lookup pbr_wan2
900:	from all fwmark 0x10000/0xff0000 lookup pbr_wan
1001:	from all iif pppoe-wan lookup 1
1002:	from all iif eth0 lookup 2
1003:	from all iif wg0 lookup 3
1004:	from all iif wg1 lookup 4
2001:	from all fwmark 0x100/0x3f00 lookup 1
2002:	from all fwmark 0x200/0x3f00 lookup 2
2003:	from all fwmark 0x300/0x3f00 lookup 3
2004:	from all fwmark 0x400/0x3f00 lookup 4
2061:	from all fwmark 0x3d00/0x3f00 blackhole
2062:	from all fwmark 0x3e00/0x3f00 unreachable
3001:	from all fwmark 0x100/0x3f00 unreachable
3002:	from all fwmark 0x200/0x3f00 unreachable
3003:	from all fwmark 0x300/0x3f00 unreachable
3004:	from all fwmark 0x400/0x3f00 unreachable
32766:	from all lookup main
32767:	from all lookup default

# pbr ip rules inserted at priority 894-900, before mwan3 at 1001-2004.
# wg0 mark 0x30000 -> table pbr_wg0 (rule 898)
# wg1 mark 0x40000 -> table pbr_wg1 (rule 897)
# sport rules for wireguard ports -> table pbr_wan (rules 895-896)
# pbr nftsets empty at this point

================================================================================
SECTION 4: VERIFY DNSMASQ NFTSET CONFIG AND POPULATE SETS
================================================================================

root@openwrt:~# cat /tmp/dnsmasq.d/pbr
nftset=/ipleak.net/4#inet#fw4#pbr_wg0_4_dst_ip_cfg096ff5 # pbrcheck_wg0
nftset=/browserleaks.com/4#inet#fw4#pbr_wg1_4_dst_ip_cfg0a6ff5 # pbrcheck_wg1

root@openwrt:~# nslookup ipleak.net
Server:		[redacted]
Address:	[redacted]

Non-authoritative answer:
Name:	ipleak.net
Address: 95.85.16.212
Name:	ipleak.net
Address: 2a03:b0c0:0:1010::509:d001

root@openwrt:~# nslookup browserleaks.com
Server:		[redacted]
Address:	[redacted]

Non-authoritative answer:
Name:	browserleaks.com
Address: 104.236.69.55

root@openwrt:~# nft list sets inet fw4 | grep -A8 "set pbr"
	set pbr_wg0_4_dst_ip_cfg096ff5 {
		type ipv4_addr
		flags interval
		auto-merge
		comment "pbrcheck_wg0"
		elements = { 95.85.16.212 }
	}
	set pbr_wg1_4_dst_ip_cfg0a6ff5 {
		type ipv4_addr
		flags interval
		auto-merge
		comment "pbrcheck_wg1"
		elements = { 104.236.69.55 }
	}
}

# dnsmasq nftset directives confirmed present for both domains.
# nslookup resolved ipleak.net -> 95.85.16.212, browserleaks.com -> 104.236.69.55.
# Both pbr nftsets now populated with correct IPs.

================================================================================
SECTION 5: VERIFY NFT CHAINS AND ROUTING
================================================================================

root@openwrt:~# nft list chain inet fw4 pbr_output
table inet fw4 {
	chain pbr_output {
		meta mark & 0x00ff0000 != 0x00000000 return
		ip daddr @pbr_wg0_4_dst_ip_cfg096ff5 goto pbr_mark_0x030000 comment "pbrcheck_wg0"
		ip daddr @pbr_wg1_4_dst_ip_cfg0a6ff5 goto pbr_mark_0x040000 comment "pbrcheck_wg1"
	}
}

root@openwrt:~# nft list chain inet fw4 pbr_prerouting
table inet fw4 {
	chain pbr_prerouting {
		meta mark & 0x00ff0000 != 0x00000000 return
	}
}

root@openwrt:~# nft list chain inet fw4 pbr_mark_0x030000
table inet fw4 {
	chain pbr_mark_0x030000 {
		meta mark set meta mark & 0xff03ffff | 0x00030000
		return
	}
}

root@openwrt:~# nft list chain inet fw4 pbr_mark_0x040000
table inet fw4 {
	chain pbr_mark_0x040000 {
		meta mark set meta mark & 0xff04ffff | 0x00040000
		return
	}
}

root@openwrt:~# ip route get 95.85.16.212 mark 0x30000
95.85.16.212 dev wg0 table pbr_wg0 src [redacted] mark 0x30000 uid 0 
    cache

root@openwrt:~# ip route get 104.236.69.55 mark 0x40000
104.236.69.55 dev wg1 table pbr_wg1 src [redacted] mark 0x40000 uid 0 
    cache

# pbr_output chain: two policy rules present (one per domain/set).
# pbr_prerouting: no rules, both policies use chain=output (router-originated traffic only).
# Marking chains set pbr bits 0x030000 (wg0) and 0x040000 (wg1), masking with
# 0xff03ffff and 0xff04ffff respectively (preserve mwan3 bits 0x3f00).
# ip route get confirms: mark 0x30000 -> wg0 via pbr_wg0 table (rule 898).
# ip route get confirms: mark 0x40000 -> wg1 via pbr_wg1 table (rule 897).
# Pre-trace routing verified


================================================================================
SECTION 6: NFT TRACE TEST — ipleak.net (95.85.16.212) via wg0
================================================================================

root@openwrt:~# nft add rule inet fw4 mwan3_output ip daddr 95.85.16.212 meta nftrace set 1
root@openwrt:~# nft add rule inet fw4 mwan3_output ip daddr 104.236.69.55 meta nftrace set 1
root@openwrt:~# nft list chain inet fw4 mwan3_output | tail -6
		meta mark & 0x00003f00 != 0x00003f00 jump mwan3_connected
		meta mark & 0x00003f00 != 0x00003f00 jump mwan3_dynamic
		ip daddr 95.85.16.212 meta nftrace set 1
		ip daddr 104.236.69.55 meta nftrace set 1
	}
}

root@openwrt:~# nft monitor trace > /tmp/trace_ipleak.txt 2>&1 &
root@openwrt:~# ping -c 4 95.85.16.212
PING 95.85.16.212 (95.85.16.212) 56(84) bytes of data.

--- 95.85.16.212 ping statistics ---
4 packets transmitted, 0 received, 100% packet loss, time 3030ms

root@openwrt:~# kill %1
root@openwrt:~# cat /tmp/trace_ipleak.txt
trace id 7da0df71 inet fw4 mwan3_output packet: oif "pppoe-wan" ip saddr [redacted] ip daddr 95.85.16.212 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 15480 ip protocol icmp ip length 84 icmp type echo-request icmp code 0 icmp id 10787 icmp sequence 1 
trace id 7da0df71 inet fw4 mwan3_output rule ip daddr 95.85.16.212 meta nftrace set 1 (verdict continue)
trace id 7da0df71 inet fw4 mwan3_output policy accept meta mark 0x00000100 
trace id 7da0df71 inet fw4 mangle_output packet: oif "pppoe-wan" ip saddr [redacted] ip daddr 95.85.16.212 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 15480 ip protocol icmp ip length 84 icmp type echo-request icmp code 0 icmp id 10787 icmp sequence 1 
trace id 7da0df71 inet fw4 mangle_output rule jump pbr_output (verdict jump pbr_output)
trace id 7da0df71 inet fw4 pbr_output rule ip daddr @pbr_wg0_4_dst_ip_cfg096ff5 goto pbr_mark_0x030000 comment "pbrcheck_wg0" (verdict goto pbr_mark_0x030000)
trace id 7da0df71 inet fw4 pbr_mark_0x030000 rule meta mark set meta mark & 0xff03ffff | 0x00030000 (verdict continue)
trace id 7da0df71 inet fw4 pbr_mark_0x030000 rule return (verdict return)
trace id 7da0df71 inet fw4 mangle_output policy accept meta mark 0x00030100 
trace id 7da0df71 inet fw4 output packet: oif "pppoe-wan" ip saddr [redacted] ip daddr 95.85.16.212 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 15480 ip protocol icmp ip length 84 icmp type echo-request icmp code 0 icmp id 10787 icmp sequence 1 
trace id 7da0df71 inet fw4 output rule oifname { "wg0", "wg1", "eth0", "l2tpeth0", "pppoe-wan" } jump output_wan comment "!fw4: Handle wan IPv4/IPv6 output traffic" (verdict jump output_wan)
trace id 7da0df71 inet fw4 output_wan rule jump accept_to_wan (verdict jump accept_to_wan)
trace id 7da0df71 inet fw4 accept_to_wan rule oifname { "wg0", "wg1", "eth0", "l2tpeth0", "pppoe-wan" } counter packets 761 bytes 62237 accept comment "!fw4: accept wan IPv4/IPv6 traffic" (verdict accept)
trace id 7da0df71 inet fw4 mangle_postrouting packet: oif "wg0" ip saddr [redacted] ip daddr 95.85.16.212 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 15480 ip protocol icmp ip length 84 icmp type echo-request icmp code 0 icmp id 10787 icmp sequence 1 
trace id 7da0df71 inet fw4 mangle_postrouting policy accept meta mark 0x00030100 

[...]

# 4 packets traced, all following identical path. Per packet:
# mwan3_output: mark 0x00000100 (mwan3 assigned wan2 via r_and_v policy).
#   oif "pppoe-wan" at entry - this is the pre-route-lookup oif, set by mwan3's mark.
# mangle_output -> pbr_output: ip daddr matches @pbr_wg0_4_dst_ip_cfg096ff5 (95.85.16.212).
#   goto pbr_mark_0x030000: mark set to 0x00030100 (pbr wg0 bits | mwan3 bits preserved).
# mangle_output policy accept: final mark 0x00030100.
# output chain: filter accepts (oif still shows "pppoe-wan" - pre-reroute value).
# mangle_postrouting: oif "wg0" - kernel rerouted packet via ip rule 898
#   (fwmark 0x30000/0xff0000 -> pbr_wg0 table) BEFORE postrouting. Departure: wg0.
# srcnat: srcnat_wan and upnp_postrouting traversed (no masquerade rule for wg0).
# RESULT: pbr policy for ipleak.net -> wg0 working correctly.

================================================================================
SECTION 7: NFT TRACE TEST — browserleaks.com (104.236.69.55) via wg1
================================================================================

root@openwrt:~# nft monitor trace > /tmp/trace_bleaks.txt 2>&1 &
root@openwrt:~# ping -c 4 104.236.69.55
PING 104.236.69.55 (104.236.69.55) 56(84) bytes of data.

--- 104.236.69.55 ping statistics ---
4 packets transmitted, 0 received, 100% packet loss, time 3026ms

root@openwrt:~# kill %1
root@openwrt:~# cat /tmp/trace_bleaks.txt
trace id d058cfea inet fw4 mwan3_output packet: oif "pppoe-wan" ip saddr [redacted] ip daddr 104.236.69.55 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 57137 ip protocol icmp ip length 84 icmp type echo-request icmp code 0 icmp id 10983 icmp sequence 1 
trace id d058cfea inet fw4 mwan3_output rule ip daddr 104.236.69.55 meta nftrace set 1 (verdict continue)
trace id d058cfea inet fw4 mwan3_output policy accept meta mark 0x00000100 
trace id d058cfea inet fw4 mangle_output packet: oif "pppoe-wan" ip saddr [redacted] ip daddr 104.236.69.55 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 57137 ip protocol icmp ip length 84 icmp type echo-request icmp code 0 icmp id 10983 icmp sequence 1 
trace id d058cfea inet fw4 mangle_output rule jump pbr_output (verdict jump pbr_output)
trace id d058cfea inet fw4 pbr_output rule ip daddr @pbr_wg1_4_dst_ip_cfg0a6ff5 goto pbr_mark_0x040000 comment "pbrcheck_wg1" (verdict goto pbr_mark_0x040000)
trace id d058cfea inet fw4 pbr_mark_0x040000 rule meta mark set meta mark & 0xff04ffff | 0x00040000 (verdict continue)
trace id d058cfea inet fw4 pbr_mark_0x040000 rule return (verdict return)
trace id d058cfea inet fw4 mangle_output policy accept meta mark 0x00040100 
trace id d058cfea inet fw4 output packet: oif "pppoe-wan" ip saddr [redacted] ip daddr 104.236.69.55 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 57137 ip protocol icmp ip length 84 icmp type echo-request icmp code 0 icmp id 10983 icmp sequence 1 
trace id d058cfea inet fw4 output rule oifname { "wg0", "wg1", "eth0", "l2tpeth0", "pppoe-wan" } jump output_wan comment "!fw4: Handle wan IPv4/IPv6 output traffic" (verdict jump output_wan)
trace id d058cfea inet fw4 output_wan rule jump accept_to_wan (verdict jump accept_to_wan)
trace id d058cfea inet fw4 accept_to_wan rule oifname { "wg0", "wg1", "eth0", "l2tpeth0", "pppoe-wan" } counter packets 837 bytes 78460 accept comment "!fw4: accept wan IPv4/IPv6 traffic" (verdict accept)
trace id d058cfea inet fw4 mangle_postrouting packet: oif "wg1" ip saddr [redacted] ip daddr 104.236.69.55 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 57137 ip protocol icmp ip length 84 icmp type echo-request icmp code 0 icmp id 10983 icmp sequence 1 
trace id d058cfea inet fw4 mangle_postrouting policy accept meta mark 0x00040100 

[...]

# 4 packets traced, all following identical path. Per packet:
# mwan3_output: mark 0x00000100 (mwan3 assigned wan via r_and_v policy).
#   oif "pppoe-wan" at entry.
# mangle_output -> pbr_output: ip daddr matches @pbr_wg1_4_dst_ip_cfg0a6ff5 (104.236.69.55).
#   goto pbr_mark_0x040000: mark set to 0x00040100 (pbr wg1 bits | mwan3 bits preserved).
# mangle_output policy accept: final mark 0x00040100.
# output chain: filter accepts.
# mangle_postrouting: oif "wg1" - kernel rerouted via ip rule 897
#   (fwmark 0x40000/0xff0000 -> pbr_wg1 table). Departure: wg1.
# srcnat: srcnat_wan and upnp_postrouting traversed (no masquerade for wg1).
# RESULT: pbr policy for browserleaks.com -> wg1 working correctly.

================================================================================
ANALYSIS - TL;DR
================================================================================

# Test replicates your scenario
#   mwan3: 4 interfaces (wan/wan2/wg0/wg1), catch-all r_and_v policy
#   pbr: uplink_ip_rules_priority=900, strict_enforcement=0
#         resolver_set=dnsmasq.nftset
#         policy ipleak.net -> wg0, chain=output
#         policy browserleaks.com -> wg1, chain=output
#
# PACKET MARKING (per packet):
#   mwan3_output (priority -151): assigns mwan3 mark 0x0100 (wan) or 0x0200 (wan2)
#     into bits 0x3f00. Result saved to ct mark. oif set by mwan3's ip rule.
#   mangle_output / pbr_output (priority -150): pbr_mark_0x030000 OR pbr_mark_0x040000
#     sets pbr bits (0x030000 or 0x040000) into bits 0x00ff0000, preserving lower bits.
#     Combined mark: 0x00030100 (wg0) or 0x00040100 (wg1).
#   Routing decision: ip rule 898 (fwmark 0x30000/0xff0000) or 897 (0x40000/0xff0000)
#     fires at priority 897/898, BEFORE mwan3's rules at 2001-2004.
#     Packet routed via pbr_wg0 or pbr_wg1 table -> wg0/wg1.
#   mangle_postrouting: oif "wg0" / oif "wg1" confirms reroute took effect.
#
# RESULTS: 
#   pbr with uplink_ip_rules_priority=900 and explicit
#   supported_interface for wireguard interfaces works correctly with mwan3
#   nftables v3.1.4. Both output-chain policies route correctly to their
#   respective wireguard interfaces.
#
# LIKELY CAUSE OF YOUR PROBLEM:
#   Your config very likely lacks:
#     list supported_interface 'wgsg'
#     list supported_interface 'wgid'
#   Without these, pbr does not create ip rules or routing tables for
#   gateway-less wireguard interfaces (they have no gateway for auto-discovery).
#   pbr will silently ignore policies targeting those interfaces.
#   This is the most likely explanation for why your setup fails.
#
# NOTE ON oif IN output CHAIN:
#   The filter output chain shows oif "pppoe-wan" throughout - this is the
#   interface assigned by the FIRST routing lookup (mwan3's mark). The kernel
#   performs a SECOND routing lookup after mangle_output when marks change;
#   this second lookup is not visible in the output chain trace. The actual
#   departure interface is confirmed by mangle_postrouting showing oif "wg0"/"wg1".
#
# NOTE ON PACKET LOSS:
#   100% ICMP loss is expected - both ipleak.net and browserleaks.com drop
#   unsolicited pings from unknown sources. Packet loss does not
#   indicate routing failure. The nft trace confirms packets are transmitted
#   via the correct interface.
1 Like

It is a little more complicated.
If there is a WireGuard listen port it is treated as a WireGuard "server", and the interface is ignored and traffic from that interface is routed back via the WAN.

A typical WireGuard client does not need a fixed listen port and if it is not set it will be available as PBR interface

Of course this is fully configurable as described in the PBR guide:

Thanks for the clarification. I didn't really dig through the pbr documentation in depth as I just needed to make sure mwan3 and pbr co-exist with each other and that's a question of making sure that packet marks don't nuke each other and that pbr's routing table rules are higher priority than mwan3's.

I do think pbr adopts a bit of a simplistic view trying to shoehorn things into a client and server model. My two wireguard tunnels on my router could both technically be classified as clients (one is more of a peer to peer relationship with my own server in a data centre and either side can initiate the connection, although usually only openwrt does, while the other is more traditionally a "client" as it connects to a commercial vpn service).

Both these tunnels have listen ports defined in the interface definition; it's easy to find without having to click through a couple of dialogs into the peer definition to find the port.

For the vpn client where the connection is always initiated by openwrt, technically the definition in /etc/config/network doesn't need the listen port, but it has one nonetheless in mine. I just put it in through force of habit, so hit this problem myself when testing as pbr just ignored them without the list supported_interface option.

So rephrasing it in the pbr worldview, I think that having a listen port defined might be the cause of @4rtz1z's problem. Would need to see the /etc/config/network to be sure though

I’m not sure how crazy this sounds. I discussed the whole issue with AI a bit, and this is what it led to. I’m not sure how accurate any of it actually is. Probably, as usual, half of it is nonsense.

Gemini output summary

Technical Analysis: mwan3-nft & PBR Interoperability Fix

1. The Issue: Destructive Mark Operations

Current mwan3-nft (v3.1.x) implementations use non-masked (destructive) ct mark operations in /lib/mwan3/mwan3.sh (Lines 252, 271):

nft add rule inet fw4 mwan3_prerouting ct mark set meta mark

This overwrites the entire 32-bit connection mark, wiping out bits owned by other packages (e.g., PBR).

2. The Failed Workaround (v3.1.4)

To avoid wiping PBR bits, version 3.1.4 moved mwan3 to run before PBR (priority mangle - 1).
Side Effect: This breaks Source IP Binding for local router traffic (Traceroute/WireGuard). Because mwan3 runs first, it forces the initial route/IP selection before PBR can intervene.

3. The Proposed "Surgical" Fix (v2.x Logic)

Re-implement bitwise masked operations to allow mwan3 and PBR to coexist independently, regardless of execution order.

A. Restore Priority

Move mwan3 back to mangle + 10 in /usr/share/nftables.d/table-post/10-mwan3.nft:

chain mwan3_output { type route hook output priority mangle + 10; }

B. Patch mwan3.sh for Masked Save/Restore

Update /lib/mwan3/mwan3.sh to use the same polite logic already present in the PBR package:

Surgical Save (conntrack):

local complement=$(printf "0x%x" $((0xFFFFFFFF ^ MMX_MASK)))
nft add rule ... ct mark set (ct mark & $complement) | (meta mark & $MMX_MASK)

Surgical Restore (packet):

nft add rule ... meta mark set (meta mark & $complement) | (ct mark & $MMX_MASK)

4. Conclusion

Unlike mwan3, the PBR package already uses masked sets:
meta mark set (meta mark & mask) | val
By patching mwan3 to do the same and running it after PBR, we fix the "overwriting" issue while restoring correct Source IP selection for local WireGuard/Traceroute traffic.

root@ax6000:~# cat /etc/config/network

config interface 'loopback'
        option device 'lo'
        option proto 'static'
        option ipaddr '127.0.0.1'
        option netmask '255.0.0.0'

config globals 'globals'
        option packet_steering '2'
        option steering_flows '128'

config device
        option name 'br-lan'
        option type 'bridge'
        list ports 'lan3'
        list ports 'lan4'
        option ipv6 '0'

config device
        option name 'wan'
        option ipv6 '0'

config interface 'lan'
        option proto 'static'
        option device 'br-lan'
        option ipaddr '10.0.0.1'
        option netmask '255.255.255.0'
        option multipath 'off'

config interface 'wan2'
        option proto 'dhcp'
        option device 'lan2'
        option multipath 'off'
        option metric '20'
        option hostname '*'

config device
        option name 'wg'
        option ipv6 '0'

config device
        option name 'lan2'
        option ipv6 '0'
        option macaddr 'redacted'

config interface 'wgid'
        option proto 'wireguard'
        option private_key 'redacted'
        list addresses '10.5.0.2/16'
        list dns '1.1.1.1'
        option multipath 'off'
        option defaultroute '0'

config wireguard_wgid
        option description 'ID'
        option public_key 'redacted'
        list allowed_ips '0.0.0.0/0'
        list allowed_ips '::/0'
        option persistent_keepalive '25'
        option endpoint_host 'redacted'
        option endpoint_port '51820'

config interface 'wgsg'
        option proto 'wireguard'
        option private_key 'redacted'
        list dns '1.1.1.1'
        option multipath 'off'
        option defaultroute '0'
        list addresses '10.5.0.3/16'

config wireguard_wgsg
        option description 'SG'
        option public_key 'redacted'
        list allowed_ips '0.0.0.0/0'
        list allowed_ips '::/0'
        option persistent_keepalive '25'
        option endpoint_host 'redacted'
        option endpoint_port '51820'

config interface 'wan'
        option proto 'pppoe'
        option device 'wan'
        option username 'redacted'
        option password 'redacted'
        option ipv6 '0'
        option metric '10'
        option multipath 'off'

root@ax6000:~#

LOL. All of it is nonsense, not half. Lol. Gemini has hallucinated a vaguely plausible-sounding fix for a problem that doesn't exist, using an nft construct that the kernel rejects.

The "surgical fix" it recommends is not possible on current kernels. Compound two-source bitwise like ct mark set (ct mark & complement) | (meta mark & MMX_MASK) fail with "Operation not supported" because the kernel can't mix two register sources in one bitwise set. pbr gets away with non-destructive masked sets because it uses meta mark set (meta mark & mask) | VAL where VAL is an immediate, not a second register, so it's permitted.

Gemini's "source IP binding break" claim is bollocks. mwan3_output at priority mangle - 1 only marks output traffic that matches a configured mwan3 policy. Router-originated traffic without a matching mwan3 rule is untouched and follows normal source-address selection / pbr marking. There's no mechanism by which running earlier "forces initial route/IP selection before PBR can intervene".

I recently had ChatGPT completely hallucinate non-existing case law when I was researching precedent for a civil case. It literally cooked up stuff from nowhere!

That said, it seems there is a way to make it non-destructive by using vmaps. It's not nearly as simple as the single line operation used by the iptables variant, but it appears to work and it's not expensive (a vmap lookup is O(log n) at worst and in practice the kernel implements these as hash maps, so the dispatch overhead across 63 entries is negligible compared to the conntrack lookup that already happened).

The kernel will not let one rule combine two register sources, but it will let a vmap dispatch on a runtime register value into a chain whose body then ORs a constant into the single register value.

So the solution is to use 126 tiny setter chains (one set of 63 for save and the other set for restore), one per possible mwan3 mark value, each of two statements long, of the form

chain mwan3_or_meta_0x0100 { meta mark set meta mark | 0x0100; return }

Restore and save then become a vmap that looks at the masked source register and dispatches to the matching chain to effect the non-destructive marks rather than just overwriting the whole mask:

Restore (ct mark --> meta mark, non-destructive):

  meta mark & 0x3F00 == 0  ct mark & 0x3F00  vmap {
      0x0100 : jump mwan3_or_meta_0x0100,
      0x0200 : jump mwan3_or_meta_0x0200,
      ...
      0x3f00 : jump mwan3_or_meta_0x3f00
  }
chain mwan3_or_meta_0x0100 { meta mark set meta mark | 0x0100; return }
chain mwan3_or_meta_0x0200 { meta mark set meta mark | 0x0200; return }
[...]
chain mwan3_or_meta_0x3f00 { meta mark set meta mark | 0x3f00; return }

Save (meta mark --> ct mark, non-destructive):

  ct mark set ct mark & 0xFFFFC0FF
  meta mark & 0x3F00  vmap {
      0x0100 : jump mwan3_or_ct_0x0100,
      0x0200 : jump mwan3_or_ct_0x0200,
      ...
      0x3f00 : jump mwan3_or_ct_0x3f00
  }
chain mwan3_or_ct_0x0100 { ct mark set ct mark | 0x0100; return }
chain mwan3_or_ct_0x0200 { ct mark set ct mark | 0x0200; return }
[...]
chain mwan3_or_ct_0x3f00 { ct mark set ct mark | 0x3f00; return }

An initial implementation seems to work. This is great, since it means that mwan3 and pbr then become order independent again and I can move mwan3 back to its more architecturally correct position at mangle + 1 (effectively running after pbr, but that won't matter anymore).

I'm testing it now and it looks good so far. Interoperability with pbr too...

2 Likes