Skip to content

macOS: support multiple simultaneous tunnels - #63

Open
cremenescu wants to merge 10 commits into
WireGuard:masterfrom
cremenescu:macos-multiple-tunnels
Open

macOS: support multiple simultaneous tunnels#63
cremenescu wants to merge 10 commits into
WireGuard:masterfrom
cremenescu:macos-multiple-tunnels

Conversation

@cremenescu

Copy link
Copy Markdown

macOS, unlike iOS, can run multiple packet tunnel provider sessions at the same time, but the app serializes activation on both platforms: activating one tunnel deactivates the tunnel already in operation. On iOS that mirrors an OS restriction. On macOS it is purely app policy, and macOS users routinely need several disjoint split tunnels up at once.

Lifting the policy alone is not enough. When two sessions run in one extension process, each WireGuardAdapter locates its tunnel device by taking the first utun control socket in the process, so the second tunnel attaches to the first tunnel's device and passes no traffic. That matches the report on the mailing list from July 2021 ("Running multiple tunnels simultaneously on macOS"), where two tunnels both reached Connected but only one passed traffic.

The series, in order:

  1. WireGuardKitC: include sys/types.h for BSD type definitions — build fix for the strict module verifier in current Xcode
  2. TunnelsManager: drop the restart intent when deactivation is requested
  3. WireGuardKit: attach each adapter to its own utun device — the fix that makes concurrent tunnels actually pass traffic
  4. TunnelsManager: restrict single-tunnel activation policy to iOS
  5. PacketTunnelProvider: only exit on stop of last tunnel in the process
  6. macOS: support multiple simultaneous tunnels in the menu bar UI
  7. Add opt-in split DNS support — a per-tunnel toggle, so simultaneous tunnels do not fight over the system resolver
  8. macOS: activate the app before showing a modal alert without a window
  9. macOS: don't crash opening the editor for an unreadable tunnel
  10. macOS: reset peer endpoints after a network change

iOS behaviour is unchanged throughout, and every commit builds for both macOS and iOS on its own, so the series stays bisectable.

Tested on macOS 26 with Xcode 26: multiple concurrent tunnels passing traffic simultaneously on disjoint AllowedIPs, deactivating one tunnel leaving the others up, and DNS resolution verified with and without DNS= entries as well as with split DNS enabled. The last commit was measured in daily use across network changes: handshake completion for the affected tunnel went from 2/130 to 83/129 once the peer stopped being pinned to an address that only existed on the previous network.

This was sent to wireguard@lists.zx2c4.com on July 10 and July 25, which is the project's documented contribution channel, but the posts have not come out of the list's moderation queue. Opening it here as well so the work is findable by anyone hitting the same problem.

The ctl_info and sockaddr_ctl definitions use u_char, u_int16_t and
u_int32_t without including <sys/types.h>. Newer clang versions with
strict module verification (Xcode 16 and later) refuse to build the
module because these declarations must be imported before use.

Signed-off-by: Razvan Cremenescu <razvan.cremenescu@gmail.com>
A tunnel in the .restarting state is automatically reactivated by the
status observer when its connection reaches .disconnected. If the user
asks for deactivation while the restart is still pending, the tunnel
would go down and immediately come back up. Reset the status from the
actual connection status before stopping, so an explicit deactivation
request always wins over a pending restart.

Signed-off-by: Razvan Cremenescu <razvan.cremenescu@gmail.com>
The adapter located its tunnel device by scanning the process's file
descriptors and taking the first utun control socket found. With a
single tunnel per extension process that was always the right one, but
macOS runs multiple simultaneous tunnel sessions in one extension
process: the second adapter would attach to the first tunnel's utun,
leaving its own device without a reader. The second tunnel then passed
no traffic at all, while the first suffered from two backends
competing for the same descriptor.

Collect all utun control sockets instead, exclude the ones already
claimed by other adapters in the process, and prefer the device that
already carries this tunnel's addresses, which
setTunnelNetworkSettings has assigned by the time the backend starts.
The claim is released if the backend fails to start, on stop, and on
deinit.

This makes simultaneous tunnels actually pass traffic on macOS.

Signed-off-by: Razvan Cremenescu <razvan.cremenescu@gmail.com>
On iOS the OS allows only one active VPN configuration at a time, so
activating a tunnel first parks it in the .waiting state, deactivates
the tunnel currently in operation, and completes the activation once
the latter is fully down.

On macOS there is no such OS-level restriction: each tunnel is its own
NETunnelProviderManager session and multiple sessions can be active
simultaneously. Make the deactivate-then-activate handoff iOS-only, so
that on macOS activating a tunnel no longer tears down the tunnels
already in operation.

Also add tunnelsInOperation(), returning every non-inactive tunnel,
for use by the macOS UI. On multi-user systems the tunnels list
deliberately retains other users' tunnels; those are excluded, since
they are not this user's to display or deactivate.

Signed-off-by: Razvan Cremenescu <razvan.cremenescu@gmail.com>
stopTunnel() works around Apple bug 32073323 by calling exit(0) on
macOS. If the OS runs multiple simultaneous tunnel sessions in a
single extension process, exiting on the first stop would tear down
the remaining sessions too.

Keep a process-wide count of tunnels and only exit once the last one
is being stopped. The count covers the whole duration of a start
attempt - the adapter can spend seconds in DNS resolution and
interface setup - so that a tunnel stopping meanwhile doesn't consider
itself the last one in the process and kill a concurrent activation;
the failure paths of startTunnel() remove the tunnel from the count
again. When each session runs in its own process, the count is always
0 or 1 and the behavior is unchanged.

Signed-off-by: Razvan Cremenescu <razvan.cremenescu@gmail.com>
Now that TunnelsManager no longer serializes tunnel activation on
macOS, generalize the menu bar UI from tracking a single current
tunnel to tracking every tunnel in operation:

- TunnelsTracker publishes the list of non-inactive tunnels instead of
  a single currentTunnel slot.
- StatusMenu keeps the existing presentation when zero or one tunnel
  is in operation. With more than one, the status line shows the
  number of tunnels and the Deactivate item becomes Deactivate All,
  which deactivates every tunnel in operation, including tunnels that
  are still activating or restarting.
- StatusItemController shows the active icon when all tunnels in
  operation are active, and the progress animation while any of them
  is transitioning.
- AppDelegate's quit and App Store update alerts enumerate all active
  tunnels rather than just one; the App Store alert asks to disable
  on-demand only for the tunnels that actually have it enabled.

Signed-off-by: Razvan Cremenescu <razvan.cremenescu@gmail.com>
When a tunnel lists DNS search domains (the non-IP entries of the
wg-quick DNS= line), the tunnel's DNS servers have so far always been
installed as the system-wide resolver (match domains [""]) while the
search domains only served for completing short names. With multiple
simultaneous tunnels this makes concurrently active tunnels fight over
system DNS, and there was no way to use a tunnel's DNS only for the
internal domains behind that tunnel.

Add a per-tunnel Split DNS toggle, stored in the provider
configuration alongside the existing keys so the wg-quick config text
remains untouched and portable. When enabled and the tunnel has search
domains, the DNS servers are installed with the search domains as
match domains, so only queries for those domains go through the
tunnel; everything else stays on the system resolver. The default
remains the current match-everything behavior, so existing full-tunnel
setups keep their leak-free DNS.

The toggle is exposed as a checkbox in the macOS tunnel editor and as
a switch in the iOS tunnel editor, and toggling it restarts an active
tunnel the same way a configuration edit does.

Signed-off-by: Razvan Cremenescu <razvan.cremenescu@gmail.com>
WireGuard runs as a menu bar accessory app. When an error alert has no
source window to attach to as a sheet - e.g. an activation failure
while the Manage Tunnels window is closed - it falls back to
NSAlert.runModal(). Shown while the app is inactive, the modal window
comes up behind everything with no way to reach it, and the modal loop
freezes the whole app until it is dismissed. Activate the app first so
the alert is actually visible.

Signed-off-by: Razvan Cremenescu <razvan.cremenescu@gmail.com>
TunnelEditViewController.populateFields() force-unwraps the tunnel's
configuration. When the saved configuration cannot be read - for
instance when its keychain entry is inaccessible - that configuration
is nil and opening the editor traps. Check that the configuration is
readable before presenting the editor and report the failure instead.

Signed-off-by: Razvan Cremenescu <razvan.cremenescu@gmail.com>
On iOS a network change sets the peer endpoints again before bumping
the sockets, but on macOS the path update handler only bumps the
sockets, leaving whatever endpoint each peer currently has. That breaks
tunnels across a network change in two ways.

A peer configured with a hostname keeps the address the hostname
resolved to on the previous network, so a dynamic DNS record that moved,
or a name that resolves differently per network, is never picked up.

Worse, a peer that roamed keeps the roamed address. A server reached at
an address that only exists on one network - its address inside another
tunnel, or on the LAN it is hosted on - leaves the peer pinned to that
address once the machine moves, and every handshake then fails with 'no
route to host' until the tunnel is deactivated and activated again.

Set the endpoints of all peers again after a network change,
re-resolving the ones configured with a hostname. Resolution failures
keep the address the hostname last resolved to, so a network without
working DNS does not tear the endpoints down.

The work is delayed and coalesced, since a single network change
produces a burst of path updates and resolution blocks the queue. Peers
configured with a literal address need no resolution at all.

Signed-off-by: Razvan Cremenescu <razvan.cremenescu@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant