Trouble with wireguard and asymmetric routing

I am experimenting with the setup shown in the following diagram. I am running into some asymmetric routing issues. I am pretty sure I know exactly what the problem is (connections to the wireguard server can arrive along either interface meaning the multi wan example rules in WireGuard - RouterOS - MikroTik Documentation don’t work), however I am struggling with the solution.

Given the following network diagram as an example with clients accessing the wireguard server via the loopback address of router B:

A sample config from router B:

/interface wireguard
add listen-port=51822 mtu=1420 name=wg1
/interface wireguard peers
add allowed-address=10.20.30.40/32, interface=wg1 name=peer2 preshared-key="xxxxx" public-key="xxxxx"

The following two packet captures illustrate the issue with the asymmetric routing nicely.

When both links are up the response is sent along the other link with a different source port causing issues.

"No.","Time","Source","UDP Src Port","Destination","UDP Dst Port","Protocol","Interface","Length","Info","Comments"
"269","2025-12-31 00:40:16.774483520","192.168.1.16","49932","172.21.16.80","51822","WireGuard","ether9 rx","190","Handshake Initiation, sender=0x4781507B","cpu:1 fp:0"
"270","2025-12-31 00:40:16.776033920","172.21.16.80","38151","192.168.1.16","49932","WireGuard","ether10 tx","134","Handshake Response, sender=0x51772FCB, receiver=0x4781507B","cpu:0 fp:0"

When only one link is up (i.e. no equal costs) the reply is sent along the same link with 51822 as the source port, as expected and thus not breaking connections.

"No.","Time","Source","UDP Src Port","Destination","UDP Dst Port","Protocol","Interface","Length","Info","Comments"
"2378","2025-12-31 00:59:19.423622080","192.168.1.16","48568","172.21.16.80","51822","WireGuard","ether9 rx","190","Handshake Initiation, sender=0xDFF34FB5","cpu:1 fp:0"
"2379","2025-12-31 00:59:19.425100640","172.21.16.80","51822","192.168.1.16","48568","WireGuard","ether9 tx","134","Handshake Response, sender=0x1656643F, receiver=0xDFF34FB5","cpu:0 fp:0"

In terms of what I have tried, applying similar rules as detailed in WireGuard - RouterOS - MikroTik Documentation does succeed in ensuring traffic is returned on the same interface the client originally connected to, however subsequent traffic may arrive at either interface but is then returned on the original interface thus breaking the connection again. I know a simple solution would to be just alter the costs of the links to make them not equal, however I would like to find a solution that allows this setup to work with equally costed links.

I am thinking that I could write similar rules on router A to always force wireguard traffic down a particular link, however this seems messy and prone to breaking?

I am wondering if there is a cleaner solution to this issue?

(Edits: I meant asymmetric not asynchronous)

To understand the behavior, it is necessary to know a couple of features of RouterOS firewall and Wireguard:

  1. the Wireguard stack itself never changes the local port, so the reason why the “responses” from 172.21.16.80 can be seen on the wire with a source port other than 51822 must be someaction=masquerade (oraction=src-nat) rule.
  2. unless expicitly configured otherwise, the src-nat and masqueraderules in RouterOS firewall only change the source port if keeping it unchanged would cause the resulting socket pair to be equal to one representing an already existing connection
  3. unlike all the other services that use UDP transport (DNS, NTP), the Wireguard stack does not respond from the same socket (address:port) at which it has received the corresponding request; instead, it handles every packet it sends as a standalone one not related to any existing “connection”. In particular this means that it first looks for a gateway for that packet, and only depending on theout-interfacechosen by routing it assigns a source address to it. To make things even more complicated, it even used to ignore thepref-srcvalue if any was assigned to a route - I’m not sure whether it is still the case several months later.

Taking all the above into account, the following is what I suspect to happen:

  • the initial handshake packet from 192.168.1.16:49932 arrives to 172.21.16.80:51822 via whichever of the two interfaces, creating a tracked connection without any local NAT treatment between these two sockets in the conntrack module of the firewall
  • the Wireguard stack sends a response packet to 192.168.1.16:19932, but since the ECMP chooses a route via another out-interface than the one to which 172.21.16.80 is attached, the source address of the response is taken from that interface and the source port remains 51822
  • from the point of view of the conntrack module, this response packet does not match any existing connection, hence it is treated as an initial packet of a new connection, thesrcnatchain is checked, and asrc-nat(masquerade) treatment is applied to the new connection; however, since the reply-src-address would become 172.21.16.80:51822 and thus a duplicate row in the connection matching table would be created, the port gets changed to 38151.

Such a scenario expects, however, that you do not useaction=masqueraderules in thesrcnatchain but ratheraction=src-natones specifying a particular address usingto-addresses, as an action=masqueraderule would choose the same address like the routing; but in such case, already the original packet would match the existing connection created by the incoming request, so it would not be treated as an initial one and thus it would not be sent through thesrcnatchain at all. So either your configuration differs from the one suggested by the manual or something behaves different than I expect.

So rather than referring to a manual page and stating that you have configured your machines according to it, post complete (anonymized!) exports of the actual configurations of routers A and B for analysis.

In general, the best way to deal with the issue in point 3. above in scenarios where there is external NAT on the path between the Wireguard peers is the one described by @lurker888: RouterOS blatantly ignores pref-src. Can this really be a bug? - #72 by lurker888

If there is no external NAT in your setup as the diagram suggests, you may try to exclude thesrcnathandling from your existing configuration, meaning that the response from router B will leave with the other address as source, so the Wireguard stack on router A should adapt to it and continue talking to that address rather than the 172.21.16.80 used initially, because Wireguard is designed to seamlessly accommodate to changes of peer addresses (which is also the reason why it treats each packet individually rather than as a part of a pre-existing UDP “session”). But that’s just an experienced guess, I haven’t set up any testbed to check that.

1 Like

Thank you for that detailed reply. That has helped me understand why the port is changing.

There is one src-nat rule configured for outgoing wireguard traffic.

/ip firewall nat
add action=src-nat chain=srcnat comment="Force wireguard from loopback" protocol=udp src-address-list=\
    link_local_addresses src-port=51822 to-addresses=172.21.16.80

This is to force the traffic to originate from the loopback address rather than the interface addresses (ether9 and 10 have link local addresses only (169.254.0.0/16 range) as they are p2p links). Is there a potentially better way to force the src address for wireguard such that conntrack recognises it as the same connection?

Would I also be correct in asserting that this is not so much an asymmetric routing problem as it is an issue with how to get conntrack to recognise the reply as related to the original request and thus not change the port?

The mangle rules from the manual as configured on B, which I don’t think are particularly helpful given what I now know about the issue.

/ip firewall mangle
add action=add-src-to-address-list address-list=ether9_wg0_clients address-list-timeout=1m chain=\
    prerouting dst-port=51822 in-interface=ether9 protocol=udp
add action=mark-connection chain=output dst-address-list=ether9_wg0_clients dst-port=51822 \
    new-connection-mark=ether9 protocol=udp
add action=mark-routing chain=output connection-mark=ether9 dst-port=51822 new-routing-mark=ether9 \
    protocol=udp
add action=add-src-to-address-list address-list=ether10_wg0_clients address-list-timeout=1m chain=\
    prerouting dst-port=51822 in-interface=ether10 protocol=udp
add action=mark-connection chain=output dst-address-list=ether10_wg0_clients dst-port=51822 \
    new-connection-mark=ether10 protocol=udp
add action=mark-routing chain=output connection-mark=ether10 dst-port=51822 new-routing-mark=ether10 \
    protocol=udp

Also, one slight detail, router A isn’t the device connecting to the wg server, a client device (road warrior type setup) connects from some network behind A (be it the internet or another local network)

You would not need to deal with the connection tracking behavior if it wasn’t for the difference between the destination address of the incoming handshake request and the IP address chosen as source for the reply based on the outgoing interface, so I would say the non-elementary routing scheme is an intrinsic part of the overall issue.

well, this means that you do have to deal with connection tracking, as there is dst-nat on router A, i.e. on the path between the actual initiator (client) somewhere in the internet and router B, hence the connection tracking on router A would also treat the response from router B as an initial packet of a new connection if it came from another address of router B than 172.21.16.80.

Since there is no way to make conntrack look at anything else but source and destination addresses and ports (exceptrelatedpackets identified using helpers - FTP, TFTP, SIP, …, but that’s not applicable for Wireguard), you have to go the other way round as @lurker888 suggests: src-nat all the incoming Wireguard connections to router B to a single auxiliary address, so that the Wireguard stack would send its responses to that address (thereply-dst-addressof these connections) regardless their actual source addresses. And in your particular case, you have to augment that with a dst-nat rule for the initial packets, which will set the new destination (the reply-src-address) to the address from which the Wireguard stack sends the responses to the auxiliary address. This way, the connection tracking will both properly identify the responses on the internal side (between Wireguard and conntrack) and properly restore their source and destination addresses prior to sending them out the physical interface on the external side.

I must admit I don’t understand the solution from the manual as a whole. The thing is that whilst the Linux netfilter does support stateless (i.e. per-packet) NAT, RouterOS only exposes the stateful (per-connection) one into the configuration, so the solution from the manual must suffer from the same issue that has hit you here (which would not be the case for stateless NAT).

Thank you so much for your help. Using what you said and also RouterOS blatantly ignores pref-src. Can this really be a bug? - #72 by lurker888 I managed to get it working (or at least it seems to work so far).

For anyone stumbling across this issue in future, this is the configuration I used:

/interface bridge
add name=br-wg1 protocol-mode=none

/ip address
add address=169.254.255.253/30 interface=br-wg1 network=169.254.255.252

/ip firewall mangle
add action=mark-connection chain=prerouting connection-mark=no-mark dst-address=172.21.16.80 dst-port=51822 in-interface=ether9 new-connection-mark=wg1-ether9 protocol=udp
add action=mark-connection chain=prerouting connection-mark=no-mark dst-address=172.21.16.80 dst-port=51822 in-interface=ether10 new-connection-mark=wg1-ether10 protocol=udp

/ip firewall nat
add action=src-nat chain=srcnat comment="Force wireguard from loopback" protocol=udp src-address-list=link_local_addresses src-port=51822 to-addresses=172.21.16.80
add action=dst-nat chain=dstnat comment="Force wg1 clients to internal bridge address" connection-mark=wg1-ether9 to-addresses=169.254.255.253
add action=dst-nat chain=dstnat comment="Force wg1 clients to internal bridge address" connection-mark=wg1-ether10 to-addresses=169.254.255.253
add action=src-nat chain=input comment="Force wg1 clients to reply to address" connection-mark=wg1-ether9 to-addresses=169.254.255.254
add action=src-nat chain=input comment="Force wg1 clients to reply to address" connection-mark=wg1-ether10 to-addresses=169.254.255.254

This configuration survives either one of the links being down as well as a link going down whilst a wireguard peer is connected.

1 Like

You can actually simplify these firewall rules, and therefore save a tiny bit of CPU power.

Connection marking makes sense when you need to store the result of some complex condition match against an individual packet into the context of the connection that packet is part of, so that you wouldn’t have to redo that match every time you handle a subsequent packet of the same connection, or when you need to use the match result obtained in one direction to properly handle a packet in the opposite direction.

Neither of those is the case here - the only place where you use the information about in-interface of the initial handshake request, which you store as a connection-mark value, are the src-nat and dst-nat rules.

The thing is that mangle rules handle every single packet, whereas nat rules only handle the initial packet of each connection. The verdict regarding src-nat and dst-nat operation made while handling the initial packet is also stored in the context of the connection (in the form of reply-src-address for dst-nat and reply-dst-address for src-nat), so the conntrack module applies corresponding operations on all subsequent packets of that connection, with regard to their direction.

So if you modify the src-nat and dst-nat rules that currently match on connection-mark and let them match on dst-port=51822and dst-addressinstead (the actual dst-address for the src-natone will be 169.254.255.253 as set by the dst-natone), you can completely remove the action=mark-connection rules from mangle, and you can reduce their number from four to just two. If there is a reason I cannot see for restricting them to packets that came in via ether9 or ether10, it is better to create an interface list of them and let the rules match on the list (since a couple of ROS versions ago, even a src-nat rule is now able to match on in-interface).

The very first src–nat rule that matches on src-address-list=link_local_addresses only makes sense if Router B should be able to initiate outgoing Wireguard connections.

1 Like