My per-app VPN worked until I switched providers
Debugging macOS packet routing led me to put address translation inside wireguard-go.
I was building VPNonly, a Mac app that sends one application through a VPN while everything else keeps its normal connection.
It worked with NordVPN. Then I imported a Mullvad configuration. The tunnel connected, the handshake succeeded, and no pages loaded.
The first useful clue was a firewall counter: 32,065 evaluations, zero translated packets. The rule I thought was fixing each packet's source address had never matched.
The fix ended up somewhere I hadn't expected: inside wireguard-go, between reading the packet from macOS and encrypting it.
The setup
macOS's built-in packet filter, PF, has no app-name selector, but it can match TCP and UDP traffic by Unix group. VPNonly launches the chosen program in a dedicated group and loads these rules into a PF anchor:
pass out quick on ! lo0 route-to (utun8 10.69.46.118) inet proto { tcp udp } \
from any to any group vpn_bfa04b72c56a keep state
block return out quick on ! lo0 from any to any group vpn_bfa04b72c56a
The first rule redirects the group's matching traffic into a dedicated WireGuard interface. The second blocks other non-loopback traffic from that group. The app leaves the Mac's default route alone.
There was also a NAT rule:
nat on utun8 inet from any to any -> 10.69.46.118
NAT means network address translation. Here, its job was to replace the Mac's LAN source address with the address the VPN provider assigned to the tunnel. I assumed that rule was doing its job.
A working handshake, but no working connection
Until then, I'd tested with NordVPN. The Mullvad connection looked healthy enough to be confusing:
peer: 138.199.60.2:51820
latest handshake: 24 seconds ago
transfer: 740 B received, 558.26 KiB sent
Over half a megabyte sent, with very little received. A handshake showed that the peers could establish an encrypted session. It didn't show that the server would accept the packets inside it.
tcpdump on the tunnel interface showed a request entering with the Mac's LAN address:
IP 192.168.1.13.50982 > 1.1.1.1.80: Flags [S]
The source was 192.168.1.13. It should have been the assigned tunnel address, 10.69.46.118.
WireGuard's cryptokey routing checks a decrypted packet's source against the addresses allowed for its peer. A source outside that list is dropped. The wrong source address, lack of replies, and successful retest after rewriting it are consistent with that check rejecting the traffic. The evidence here comes from the client side.
The NordVPN endpoint I'd been using worked with the same client setup, masking the missing translation. Nord documents a custom double NAT system, but that description doesn't establish how it handles every possible inner source address. The observation here is narrower: the old setup worked with the Nord endpoint I tested and failed with this Mullvad endpoint.
The NAT rule that matched nothing
PF keeps counters per rule. This was the counter for the tunnel's NAT rule:
nat on utun8 inet all -> 10.69.46.118
[ Evaluations: 32065 Packets: 0 Bytes: 0 States: 0 ]
Zero matches alone wouldn't prove that the packets were wrong; they might already have the right source address. The packet capture supplied the missing evidence. These packets still carried the LAN address.
The behavior pointed to the order of operations. The OS originally routed the packet toward en0, my Wi-Fi interface. Translation was being evaluated against that interface; the filter rule's route-to redirected the packet to utun8 afterwards. My nat on utun8 rule never matched it.
So I tried matching NAT on en0 instead. This time translation matched, but the redirected traffic didn't reach the tunnel. The translated packet appeared on Wi-Fi:
en0: IP 10.69.46.118.48178 > 1.1.1.1.80: Flags [S]
utun8: (nothing)
I also tried adding a Unix group condition to the NAT rule. pfctl rejected that syntax, so I couldn't give it the same selector as the filter rule.
Those experiments showed that my rules weren't combining translation and redirection as intended. They don't establish that every possible NAT and route-to configuration fails on every macOS release. They did give me a reason to move the address rewrite.
The packet was already passing through my code
VPNonly already builds wireguard-go from source. That process reads packets from the tunnel interface and encrypts them. It also decrypts replies and writes them back to the interface.
That was the useful discovery: the process I already controlled saw each selected packet after PF redirected it and before WireGuard encrypted it. I could rewrite the source there.
Selected app
|
v
PF route-to -> tunnel interface -> source rewrite -> WireGuard encryption
Replies: WireGuard decryption -> restore original destination -> macOS
The fix is a wrapper around the TUN device. The current patch adds a 311-line Go implementation, including comments and whitespace, plus a 290-line test file.
On outbound IPv4 traffic, the wrapper replaces the source address with the tunnel address. It remembers the original address using the protocol, local port, remote address, and remote port, then repairs the IP and TCP or UDP checksums. For a matching reply, it restores the original destination address before returning the packet to macOS.
The patch also handles ICMP echo and tracks fragmented replies by source address and IP ID. A later fragment can be reverse-mapped once the first fragment has established the mapping. Packets the wrapper cannot safely parse pass through unchanged.
The engine enables the wrapper with WG_NAT_SOURCE, set to the tunnel's IPv4 address. The patch and its tests are public and applied to pinned upstream source.
Address translation at this boundary has prior art: Tailscale's TUN wrapper also performs source and destination translation. What I discovered while building VPNonly was that this boundary solved my per-app routing problem.
The same endpoint, after the fix
With the wrapper enabled and the PF NAT rule removed, the Mullvad endpoint that had failed returned traffic:
direct : 182.69.177.30
exit IP : 138.199.60.8 (Mullvad, Singapore)
raw-IP : HTTP 301 in 0.2s
counters: rx 92 -> 5124
The NordVPN connection still worked too. Both tested connections now used the assigned tunnel source address.
What this still doesn't cover
The wrapper translates IPv4. It doesn't add IPv6 translation, and unsupported packets pass through unchanged.
ICMP errors aren't reverse-mapped yet. That leaves a gap in path MTU discovery: the tunnel MTU has worked in my tests, but the successful requests above don't establish that every path will work. Fragmented replies also depend on the first fragment arriving before the fragments that need its mapping.
VPNonly still needs to relaunch an app to give it the routing group. It doesn't support Safari with this approach because Safari's networking runs in shared system processes outside that group.
Check the packet, not just the connection
If you're using PF to redirect traffic into a tunnel, check both the translation counters and the packets' inner source addresses. A rule with no matches tells you it isn't translating traffic. A capture tells you whether that matters.
The handshake had succeeded. Browsing through my first provider had succeeded. Neither told me that the source rewrite was working.
The fix came from following the packet one step further: into the userspace process that was already about to encrypt it.
The privileged engine, patch, and tests are available in the open repository.
Author note: I built VPNonly with Claude, which did most of the debugging described here. The measurements came from tests I ran on my own Mac. Claude and Codex helped write and edit this post.
I don't post often, but when I do it's worth your time. No spam.