Repository navigation
Feature/macos splittunnel - #648
Merged
Merged
Conversation
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?).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR type
What kind of change does this PR introduce?
PR checklist
Please check if your PR fulfills the following requirements:
What is the current behavior?
Issue number: N/A
What is the new behavior?
Does this PR introduce a breaking change?
Other information