The official WireGuard client for macOS keeps a single tunnel active: start a second one and the first goes down. On iOS that mirrors a real OS restriction; on macOS it does not — there the system is perfectly able to run several tunnel sessions at once. I wrote the patches that lift the restriction and, more importantly, everything else it took to make it actually work: a utun device per tunnel, DNS that does not fight between tunnels, and endpoints that get rewritten when you move to another network.
The limit is the app's, not the system's
Everything is decided in TunnelsManager.swift. When you
ask for a tunnel to be activated, the app looks for one that is not
inactive; if it finds one, it parks the new tunnel in a state of its own
invention, .waiting, brings the running one down, and only
once that is fully down starts what you asked for:
if let tunnelInOperation = tunnels.first(where: { $0.status != .inactive }) {
tunnel.status = .waiting
activateWaitingTunnelOnDeactivation(of: tunnelInOperation)
startDeactivation(of: tunnelInOperation)
return
}
That code is shared between iOS and macOS, and the only place in the
whole project where the limit is explicitly blamed on the operating
system sits behind #if os(iOS). There is no macOS
equivalent: each tunnel is its own NETunnelProviderManager,
and the system accepts several sessions at a time. The restriction is a
UI decision inherited from iOS, not a technical constraint.
Lifting the policy is not enough: two tunnels, one utun
This is the heart of it, and the reason earlier attempts looked like they worked without working. On macOS several sessions run inside the same extension process. The adapter finds its tunnel device by scanning the process's file descriptors and taking the first utun control socket it finds. With one tunnel per process that choice was always right. With two, the second adapter attaches to the first tunnel's device: its own device is left without a reader, so it passes no traffic at all, while two backends compete over the first one.
The symptom is exactly what was reported on the mailing list back in July 2021: both tunnels reach Connected, but only one carries traffic. Look at the UI and it seems fine; look at the packets and it is not.
The fix keeps a registry of devices already claimed within the
process and, among the free ones, prefers the device that already
carries the current tunnel's addresses — setTunnelNetworkSettings
has assigned them by the time the backend starts, so the match is
reliable. The claim is released on stop, on a failed start, and on
deinit.
The exit(0) hack
On macOS the network extension works around an old Apple bug
(32073323) by killing its own process with exit(0) when a
tunnel stops. With one tunnel per process that is harmless. With
several, stopping one would take all the others down with it.
The patch counts the tunnels started in the process and only exits on the last one. The count covers starts still in flight, not just completed ones: DNS resolution and bringing the interface up can take seconds, and a tunnel stopping meanwhile would have considered itself the last one and killed an activation that was still under way.
DNS: who gets the resolver
When a tunnel lists DNS servers, the app installed them as the
resolver for the whole system — matchDomains set
to the empty string means "every domain". With a single active tunnel
that is defensible. With several, tunnels fight over the system resolver
and the last one started wins, non-deterministically.
I added a per-tunnel Split DNS toggle, off by default. With
it on, the tunnel's DNS servers are used only for that tunnel's search
domains, and everything else stays on the network's resolver. The syntax
stays the one from wg-quick, where non-IP entries are
already treated as domains:
DNS = 10.0.0.53, corp.example.com
The toggle is explicit rather than automatic for a safety reason:
turning it on for everyone who has domains in DNS= would
have turned existing full-tunnel setups into query leaks towards the
local network.
A related trap, unrelated to the patches but confusing to everyone: a
tunnel with AllowedIPs = 0.0.0.0/0 and no DNS=
looks like it leaves DNS alone, yet it still breaks it — not through
settings but through routing, because the default route swallows packets
headed for the local network's resolver too. On a full tunnel,
DNS= is effectively mandatory.
What breaks on a network change
The last problem only showed up in daily use: close the laptop on one network, open it on another, and one tunnel never comes back while the others do. The first hypothesis — a stale address left over from resolution — turned out to be wrong, and the log showed why:
DNS64: mapped vpn.example.com to 198.51.100.7
peer(...) - Failed to send handshake initiation:
write udp4 0.0.0.0:49722->10.20.30.1:51820: sendto: no route to host
The app had resolved the name correctly, to the public address. The backend was sending somewhere else entirely: to the server's internal address. The explanation is WireGuard's roaming — if the server was ever reached at its address on the local network, or through another tunnel, the peer remembers that address. On a different network it no longer exists, and the tunnel keeps handshaking into the void until you stop and start it by hand.
iOS does not suffer from this, because on a network change the app
rewrites the endpoints of every peer — which, as a side effect, undoes
roaming. macOS only did a wgBumpSockets:
#if os(macOS)
if case .started(let handle, _) = self.state {
wgBumpSockets(handle)
}
#endif
The patch makes macOS rewrite the endpoints after a network change as well, re-resolving names where the configuration has a hostname. If the new network has no working DNS, the last known address is kept so the tunnel is not left without an endpoint. The work is delayed and coalesced, because a single network change produces a burst of notifications and resolution blocks the work queue.
The numbers
Measured in real use on macOS 26, moving between networks: completed handshakes against initiations, before and after the last patch.
| Tunnel | Before | After |
|---|---|---|
| full tunnel (0.0.0.0/0), hostname endpoint | 2 / 130 | 83 / 129 |
| split A, IP address endpoint | 11 / 39 | 85 / 129 |
| split B, IP address endpoint | 10 / 34 | 80 / 117 |
The tunnel with a hostname was the only one badly affected — the other two came back anyway, their weaker earlier numbers coming from the windows where the network was genuinely gone. The errors left after the patch target public addresses only, in the gap between losing one network and bringing up the next, and are expected.
Where the code is
The series is ten commits, each building on its own for both macOS
and iOS, with iOS behaviour unchanged throughout. It was sent to
wireguard@lists.zx2c4.com, the project's documented
contribution channel, but the messages never came out of the list's
moderation queue. The code is open as a pull request on the project's
GitHub mirror:
- WireGuard/wireguard-apple#63 — the full series;
- cremenescu/wireguard-apple, branch
macos-multiple-tunnels— to build it directly.
The wireguard-apple project is officially marked as
complete and looking for a new maintainer, and its macOS side has not
been touched since February 2023. Until that changes, the patches stay
within reach of anyone running into the same problems.