Skip to content

Feature/macos splittunnel - #648

Merged
stenya merged 37 commits into
developmentfrom
feature/macos_splittunnel
Oct 6, 2026
Merged

stenya merged 37 commits into
developmentfrom
feature/macos_splittunnel

Conversation

@stenya

@stenya stenya commented Oct 6, 2026

Copy link
Copy Markdown
Member

PR type

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • Documentation content changes
  • Other... Please describe:

PR checklist

Please check if your PR fulfills the following requirements:

  • I have read the CONTRIBUTING.md doc
  • The Git workflow follows our guidelines: CONTRIBUTING.md#git
  • I have added necessary documentation (if appropriate)

What is the current behavior?

Issue number: N/A

What is the new behavior?

Does this PR introduce a breaking change?

  • Yes
  • No

Other information

Fixes:
- The daemon-resolved physical interface is no longer passed to the extension
- Session restart storm - no change detection
- reconcileSessionWithOptions uses a fixed 0.5 s sleep between stop and start
- AppKit icon rendering inside a root LaunchDaemon
- ~/Applications is never enumerated
- SplitTunnelling_SetDisabledReason is a single shared slot
daemon do not need to notify Extension about default interface
Daemon (daemon/service/service_darwin.go):
- macAppBundlePath(): walk with filepath.Clean/filepath.Dir instead of
  hand-rolled LastIndex slicing.
- Extract isMacOwnAppBundlePath(): compare with a trailing separator so
  "/Applications/IVPN.app.bak" is no longer treated as our own bundle.

Extension (SplitTunnelExtension):
- Add STBundleIdentifiersForPaths(), which reads each excluded ".app"
  bundle's CFBundleIdentifier once, at proxy start (per-flow Info.plist
  reads would be far too expensive), into STProxyProvider's new
  excludedBundleIdentifiers.
- handleNewFlow: now matches on the executable path OR on
  flow.metaData.sourceAppSigningIdentifier. The signing identifier comes
  from the kernel with the flow, so it still matches when the pid ->
  proc_pidpath lookup fails (short-lived process, pid reuse) and when the
  app was relocated or updated in place. It is an additional match only:
  the internal IVPN bypass and the fail-closed paths still key off the
  path, so a wrong path can never cause our own traffic to be relayed.
… OpenVPN

- openvpn_darwin.go: Implements interface-scoped default route management on connection/disconnection to support Split Tunnel with OpenVPN's redirect-gateway routing
- STPhysicalInterfaceSelector.m: Adds STIsPhysicalInterface() to distinguish physical interfaces from VPN tunnels and improves STDefaultRouteInterfaceName() to prefer unscoped routes with scoped fallback
- STProxyProvider+TCPRelay.m & STProxyProvider+UDPRelay.m: Minor related updates

The changes specifically address the problem that OpenVPN's redirect-gateway def1 doesn't leave physical interfaces with scoped default routes, breaking Split Tunnel relay functionality on macOS.
nw_connection_state_waiting is never followed by `failed` - Network.framework
retries forever - so a relay connection with no usable path leaked its flow,
connection and dispatch queue for the life of the extension while the app hung
on connect(). Tear the flow down if it never reaches "ready" within 30s (below
the kernel's ~75s SYN-retransmit window). Reuses the flow's existing queue via
dispatch_after; teardown is already idempotent.
Flows were attributed only by the executable path of the process that
opened them, so binaries outside an excluded .app bundle that ran on its
behalf (Terminal -> curl, Steam -> a game, an IDE -> its build tools)
kept going through the VPN.

The extension now attributes a non-matching flow to the process it runs
on behalf of, consulting two kernel sources in order:
  1. the responsible process (the same attribution TCC uses). Survives
     the parent exiting, which re-parents the child to launchd and breaks
     the ppid chain. Private API, resolved via dlsym and skipped silently
     when unavailable.
  2. the parent-pid chain up to launchd, bounded at 16 hops and guarded
     against pid reuse: a "parent" that started after its child is a
     recycled pid.

The internal IVPN bypass is still decided on the flow's own path first
and is unaffected by ancestry. The excluded list is read once per flow
so the whole decision sees a single snapshot.

Not covered, by design of the OS: processes launched through
LaunchServices (open, Finder, XPC services, login items) have launchd as
parent and are their own responsible process. The Settings help text is
updated accordingly.

Adds Tests/run_tests.sh, a standalone test binary (no Xcode project
needed) covering direct child, grandchild through a bare executable,
detached/re-parented child, and the negative cases.
The kernel keeps a process's responsible pid after that process exits, so a
recycled pid could attribute an unrelated flow to an excluded app. Accept the
responsible process only if it did not start after the flow's process, the
same check the parent-chain tier already applies.
The CLI registered 'splittun' only on Windows and Linux, and exec.LookPath
rejected '.app' bundles since they are directories. Add darwin to the
supported platforms, accept an existing directory as the application path
on macOS, and describe bundle paths in the help text.
…e file as an excluded app

Reject anything but a .app bundle or an executable file as an excluded
  app, and prefix-match bundles only in the extension, so a directory such
  as "/" can no longer exclude every process on the machine.
The extension session is controlled only by the UI process. Once the UI
quit, the session kept running with its last configuration: Split Tunnel
off and VPN disconnect no longer reached it, so excluded apps kept
bypassing the tunnel and the kill switch.

- The UI now stops the session before exiting and waits for the extension
  to report it stopped (3 s cap), so a closed UI means no running session.
- On macOS the daemon rejects Split Tunnel configuration changes while no
  UI client is connected, instead of storing a change that has no effect.
… stop, fd limit

- Uninstall: the throwaway deactivation run no longer takes the single
  instance lock, its safety net allows for the OS password prompt, and the
  uninstaller waits for the app to exit before launching it and alerts with
  manual steps if the extension is still active afterwards. The addon
  reports "notInstalled" after a successful deactivation.
- Addon: apply/stop commands carry a generation; completions of superseded
  commands are ignored and a pending restart is cancelled on entry, so the
  last command always wins.
- UI: Split Tunnel status is applied only while the daemon is connected, so
  a reconnect no longer stops the session and closes every relayed connection.
- Extension: raise RLIMIT_NOFILE to the hard limit (capped at OPEN_MAX);
  the default 256 was exhausted by a single browser.
…s made in System Settings

UI status:
- Show a banner only when the user has to act: approval pending, extension
  switched off, restart needed, activation error, or the session could not
  start (e.g. proxy configuration not allowed, which was stuck on
  "starting..."). Drop the progress banners and the kill switch note.

Extension state:
- Report a distinct "disabled" state from the properties probe and tell the
  user to enable the extension in System Settings. Probe on every window
  focus once activation was requested and when the session dies with a
  config applied, so a change made in System Settings is noticed.
- Re-activate the extension when the probe reports it gone after we had
  activated it (e.g. removed with systemextensionsctl), which brings the
  approval prompt back instead of silently doing nothing.

Prompts:
- Register the proxy configuration as soon as the extension is installed,
  once per enable cycle, so the system's "add proxy configurations" prompt
  appears while the user is enabling Split Tunnel, not on the first VPN
  connect.
After the extension was removed with systemextensionsctl, re-activation
failed with OSSystemExtensionErrorDomain error 1 and the settings page
flashed "Split Tunnel Disabled". Two causes, both ours:

- A properties probe submitted just before the activation answered after
  it with a stale "not installed", so a second activation was submitted
  while the first was still validating; sysextd treated that as a
  same-version replacement conflict and failed both.
- While the activation waited for the user's approval, every window focus
  submitted a probe, which the OS used to cancel the activation as
  superseded; that was reported as an extension error.

Addon: track the pending activation request; refuse probes and further
activations while it is pending; ignore probe results that arrive
meanwhile; treat "superseded" as a non-error. Probes in flight are kept in
a set so one that never completes cannot block later probes. Log state
changes and request failures.

UI: probe on Settings window focus too and on every real config change;
do not start or register the proxy configuration until the extension is
installed (the "add proxy configurations" prompt appeared while the
extension still awaited approval); open Login Items & Extensions rather
than Privacy & Security on macOS 13 and later.
On recent macOS, some system apps (e.g. Safari) are only symlinks in
/Applications, pointing into the system cryptex. Two places assumed a
real directory:

- App picker (apps_darwin.go): e.IsDir() ignores symlinks, so these
  apps never showed up as exclusion candidates. Use os.Stat, which
  follows the link, to confirm the target is a directory.
- Extension (STPathMatching): a configured excluded path that is
  itself a symlink never matched, since the kernel reports a process's
  executable path already resolved. STPathsWithResolvedSymlinks adds
  each entry's realpath(3) alongside the literal path, resolved once
  when the excluded-app list changes.

UI: drop the "Safari can't be excluded" limitation (no longer true)
and rewrite the limitations section to describe what's still
accurate - open connections keep their route until restart, inherited
child processes, system-service DNS attribution, raw sockets, and the
brief restart interruption.
…etworks

The physical-interface auto-detection read only the IPv4 routing table, so
on an IPv6-only network it found nothing and fell back to interface-type
detection. Scan the IPv6 table when the IPv4 one has no physical default;
IPv4 behaviour is unchanged. The table scan is exposed for the tests, which
feed it hand-built tables for both families.
…ose, fix peer and icon leaks

- UDP: cap unsent datagrams per peer connection (64); a peer stuck in
  "waiting" no longer buffers every datagram the app sends, and a peer that
  never becomes ready is evicted by the idle watchdog.
- UDP: remove a peer entry only if it still holds the same connection, so a
  failed peer's late callback cannot orphan its replacement; cancel failed
  peers explicitly.
- TCP: a write error toward the app tears the flow down instead of pulling
  the rest of the server's data over the physical interface.
- Daemon: compile the icon renderer with ARC; the bitmap was never released.
…ging, -O2

- UDP: issue the next receive from the write-to-app completion, as TCP does.
- TCP: the connect deadline is a cancellable timer, released once the
  connection is ready, instead of a block that held every torn-down flow for
  30 s.
- Addon: retry a failed session start (3 attempts, 2 s apart) and bind the
  stop-timeout safety net to its command, so an earlier timer cannot start a
  later command's options.
- Extension logs at info by default; debug can be enabled by debugLogging start option.
- Build the extension with -O2.
- Daemon: compare the Darwin kernel major against 21 (macOS 12), not 12.
protocol/types referenced oshelpers.AppInfo, which pulled the whole platform
helper package - on macOS a cgo/AppKit implementation - into every protocol
client. The CLI cross-build (no cgo) failed to link, and the native CLI
linked AppKit for nothing. The type now lives in a leaf package with no
imports; the daemon is unchanged. The CLI is built with CGO_ENABLED=0 on
both architectures.
AMFI refuses to launch a process that carries system-extension.install
together with allow-unsigned-executable-memory, disable-library-validation
or disable-executable-page-protection ("Hardened Runtime relaxation
entitlements disallowed on System Extensions"). Electron needs allow-jit
only. Verified on macOS 15.5 (Intel) and 26 (Apple silicon) with SIP on;
the development machine had SIP disabled, which masked this.
…ow on open failure

An excluded browser closes the losers of every connection race and opens
many one-shot UDP sockets, so "peer reset" and "not connected" flow errors
are routine. Log them at debug level at the four relay sites and keep error
level for anything else. A UDP flow whose open fails is now closed, not only
unregistered.
…ng argument

`open -a IVPN --args st-debug-logging` makes every session the app starts
log at debug level to the extension's unified-log subsystem: one line per
new flow on the machine with its executable path, and the destinations of
excluded apps. Off by default; the coupling to the app's logging setting is
removed.
… macOS 12-14

Login Items & Extensions exists only from macOS 15; the settings link used
it from macOS 13 on, sending users to a pane where nothing could be
approved. The approval banner now says the feature is enabled but inactive
until approved, and that it starts automatically afterwards.
Inverse mode is not implemented on macOS and the daemon rejects it, but the
CLI help still listed its four options and described the feature as able to
restrict VPN usage to selected apps. Register them only on platforms that
support inverse mode, matching the GUI.
On macOS 12 the kernel cannot return a declined UDP flow to normal delivery
when the socket is a dual-stack IPv6 socket sending to an IPv4 address, the
socket type V2Ray's QUIC client uses: the second datagram fails with EINVAL
and the tunnel dies. The extension now excludes the VPN's own endpoint by
network rule, so those flows are never attached to the proxy. Later macOS
versions handle the declined flow correctly; there the rule only saves the
per-flow round trip. The session starts only once the connection details
carrying the endpoint have arrived.

Also: ignore NEVPN status notifications from other connection objects and
report extension readiness to the daemon only on change; together they
turned one status change into dozens of daemon requests.
The bypass rule was built from ConnectedResp's ServerIP/ServerPort. For
V2Ray connections those hold the VPN server's host with the V2Ray port,
an endpoint that never appears on the wire, so the rule did not match
and V2Ray QUIC connections still lost connectivity on macOS 12 and 13.

The daemon already announces the physical remote endpoint (the server,
or the obfuscation proxy in front of it) with ConnectionStarting before
every connection and clears it with ConnectionStopped. The UI now
handles both messages, keeps the endpoint in vpnState.remoteEndpoint
and requests it on Hello (GetActiveRemoteEndpoint) for a UI started
while the VPN is already connected.

The Split Tunnel helper builds the bypass rule from that endpoint. If
it is missing the session still starts, without the rule and with a
warning, since the rule is only needed on macOS 12 and 13 (14/15?).
@stenya
stenya merged commit 2941c39 into development Oct 6, 2026
8 checks passed
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