Kanishk

I hacked split tunneling into my Mac’s VPN setup

One app needed a different country's IP. Getting that to work across VPN providers took a change inside wireguard-go.

I wanted CapCut to use a Singapore IP while my browser, email, and other apps kept their normal connection. Turning on my usual VPN moved the whole Mac onto it.

That became VPNonly: a Mac app that sends the applications I choose through a VPN while leaving the Mac's default route alone.

I built it with Claude, which handled most of the implementation and debugging described here. This account uses packet captures and test output from my Mac. Claude and Codex also helped write and edit the post.

The goal was simple:

CapCut     -> WireGuard tunnel -> Singapore
Other apps -> normal connection

The first setup worked with NordVPN. Importing a Mullvad configuration exposed a problem: 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 meant to fix each packet's source address had never matched.

The fix ended up inside wireguard-go, between reading the packet from macOS and encrypting it.

Getting one app onto the VPN

VPNonly opens a WireGuard tunnel using the provider's configuration or credentials. The tunnel doesn't become the Mac's default route. Selected apps need a separate way into it.

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. The working NordVPN connection had made that rule look effective.

A working handshake, but no working connection

The earlier tests had used 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 in these tests 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. A likely explanation was that translation was being evaluated against en0, the Wi-Fi interface the OS originally routed toward, before the filter rule's route-to redirected the packet to utun8. That would explain why the nat on utun8 rule never matched.

The next experiment matched 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)

Another experiment added a Unix group condition to the NAT rule. pfctl rejected that syntax, so the NAT rule couldn't use the same selector as the filter rule.

Those experiments showed that these 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 a reason to move the address rewrite.

Rewriting packets inside wireguard-go

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 provided a place for the fix: the process already saw each selected packet after PF redirected it and before WireGuard encrypted it. The source address could be rewritten 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 patch linked below 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. For VPNonly, it provided a place to fix the source address after the per-app routing decision.

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 these 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.

DNS lookups through the system resolver also stay outside the tunnel. They use the Mac's normal connection, so the VPN doesn't hide those lookups from the normal network. Changing an app's exit IP doesn't establish that every part of its activity is tunneled.

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 the first provider had succeeded. Neither established 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.

Back to the app I wanted

VPNonly became a menu bar app for choosing which applications use the VPN. CapCut was the reason to build it; the address rewrite let the tested Mullvad connection work as well as the NordVPN connection, without changing the Mac's default route.

The Mac app is paid. Its privileged engine, including the patch and tests discussed here, is available in the open repository. The repository also has a free CLI, but its current setup uses stock wireguard-go without this patch and is effectively NordVPN-only. The fix described here ships with the Mac app.

I don't post often, but when I do it's worth your time. No spam.