Three WireGuard hops reach a network that has no public address, and then the middle machine stops answering on the port it was supposed to listen on. The tunnel onward still works, the far host still responds, and only the middle server itself has gone quiet from the outside. That combination is confusing precisely because it is self-inflicted: the middle hop is obeying its own routing table, and the table sends its answers the wrong way.

A chain that works until you talk to the middle box

Take an illustrative chain: client A reaches server B on UDP 500, and B reaches onward to server C on UDP 500. C accepts traffic only from its single peer address, which makes it a leaf and keeps its behaviour simple. B is not a leaf, and everything interesting about this failure happens there.

The distinction matters because leaf and middle roles pull in opposite directions. A leaf can send everything into one tunnel without consequence, since nothing else depends on it. A middle hop is simultaneously an endpoint for clients and a client toward the far side, and those two jobs want different routing.

Why the reply leaves by the tunnel

On B, the interface facing C carries AllowedIPs = 0.0.0.0/0 together with the matching default route. Every packet B originates therefore goes into that tunnel unless something more specific takes precedence.

A packet from A addressed to B arrives and is accepted, because B is the destination and local delivery happens first. The reply is a brand-new locally generated packet aimed at A's public address, and that destination matches the default route into the tunnel. C receives it, does not own that address, and drops it.

The symptom reads as a firewall problem or a key mismatch, and neither is true here. A capture on the tunnel toward C shows the replies heading the wrong direction. That single observation saves more time than any amount of reading configuration, so write it down before you start changing keys. Anyone who has not seen the capture will assume the tunnel itself is broken.

The narrow repair: policy rules that match a source port

Replies from B's listening socket leave with source port 500. A policy rule can match that and route those packets back to the ordinary table:

ip rule add sport 500 table main priority 100
ip rule add sport 8080 table main priority 101

Policy rules are consulted in priority order, and the main table sits at 32766, so a rule at 100 is asked before the tunnel default route gets a say. Add one entry per port that must remain reachable from outside and nothing more, so forwarded traffic keeps using the tunnel it was built for.

Know the boundary of this trick. The sport selector matches locally generated packets, which covers exactly the reply case and deliberately does not cover traffic B merely forwards for somebody else. A service hosted behind B still needs its own answer, and pretending otherwise produces a fix that works for the first service and fails for the second.

Give the rule an owner

A rule typed at a prompt disappears the next time the interface is rebuilt. On a WireGuard setup, put it where the interface is created and removed:

PostUp = ip rule add sport 500 table main priority 100
PostDown = ip rule del sport 500 table main priority 100

If NetworkManager, a distribution hook or a container entrypoint owns the interface, the pair belongs in that owner rather than in a hand-edited file. Check the effective rules after a reconnect, not only after the first start, because the first start is the case that always works.

Compare the alternatives honestly

The source-port rule is one of four ways out, and it is the narrowest of them. It has the advantage of not touching the tunnel policy at all, which is why it tends to survive later changes. Narrow fixes also fail loudly when they stop applying, whereas a broad exception keeps working quietly long after the reason for it disappeared. That difference matters more than elegance when someone else owns the box in two years.

ApproachWhat it costsWhere it breaks
Source-port policy ruleOne rule per exposed portAnything B forwards rather than originates
Destination exception routeA route pinned to specific client addressesClients whose address changes
Stop full-tunnelling on BLoses the catch-all path toward CDesigns that depend on it
Split into two tunnelsMore interfaces and more policyNothing, at the cost of complexity

Verify from both ends once you have chosen. From A, start a connection to B's port and keep it busy for a while. On B, make the kernel justify the reply path with something like ip route get <address-of-A> sport 500, and confirm it names the WAN interface rather than the tunnel. After that, check that something behind C still answers through the chain, which proves the exception stayed narrow instead of turning into a bypass. Finally, record the ports in the design notes, or the next operator rediscovers this on a different service.

འབྲེལ་བའི་རྩོམ་ཡིག