[PR #3199] feat: strict split tunneling for AmneziaWG and WireGuard on Android #3129

Open
opened 2026-10-14 21:07:42 +00:00 by bsakaguchi · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/amnezia-vpn/amnezia-client/pull/3199
Author: @Fast-and-Pythonic
Created: 9/22/2026
Status: 🔄 Open

Base: dev ← Head: pr/strict-split-tunneling


📝 Commits (6)

  • 93f9d45 feat: add a per-connection UID guard for strict split tunneling
  • 28fbb00 feat: add the "Strict split tunneling" setting
  • 4b3355e feat: enforce strict split tunneling on AmneziaWG and WireGuard
  • 9663133 fix: match split-tunnel apps by app id, so cloned apps count as listed
  • 4d00910 fix: drop the guard's uid cache and its retry on INVALID_UID
  • 1aa657e fix: log the guard's denials at debug level

📊 Changes

18 files changed (+373 additions, -3 deletions)

View changed files

➕ client/android/utils/src/main/kotlin/net/StrictSplitTunnelGuard.kt (+153 -0)
📝 client/android/wireguard/src/main/kotlin/org/amnezia/awg/GoBackend.kt (+6 -0)
📝 client/android/wireguard/src/main/kotlin/org/amnezia/vpn/protocol/wireguard/Wireguard.kt (+46 -2)
📝 client/core/controllers/settingsController.cpp (+12 -0)
📝 client/core/controllers/settingsController.h (+3 -0)
📝 client/core/repositories/secureAppSettingsRepository.cpp (+10 -0)
📝 client/core/repositories/secureAppSettingsRepository.h (+4 -1)
📝 client/core/utils/constants/configKeys.h (+2 -0)
📝 client/translations/amneziavpn_ru_RU.ts (+37 -0)
📝 client/ui/controllers/serversUiController.cpp (+7 -0)
📝 client/ui/controllers/serversUiController.h (+2 -0)
📝 client/ui/controllers/settingsUiController.cpp (+11 -0)
📝 client/ui/controllers/settingsUiController.h (+5 -0)
📝 client/ui/qml/Components/HomeSplitTunnelingDrawer.qml (+11 -0)
➕ client/ui/qml/Components/StrictSplitTunnelingSwitcher.qml (+49 -0)
📝 client/ui/qml/Pages2/PageSettingsConnection.qml (+12 -0)
📝 client/ui/qml/qml.qrc (+1 -0)
📝 client/vpnConnection.cpp (+2 -0)

📄 Description

Addresses #2457 for AmneziaWG and WireGuard: with per-app split tunneling, an app excluded from the VPN can bind a socket to tun0 (curl --interface tun0, SO_BINDTODEVICE, an unprivileged ping socket) and reach the VPN server, which reveals the server's address. This adds an opt-in setting that blocks that. It is the last of three PRs, and the other two carry the datapath (merge order below).

What the user gets

A "Strict split tunneling" switch, shown on Android only and off by default. It sits under "App-based split tunneling" in the split tunneling drawer on the home screen and in Settings → Connection. The app split tunneling page itself is unchanged.

  • Description: Blocks bypasses of the tunneling rules. Strictly prevents apps from using the VPN tunnel directly unless they are allowed to use the VPN. Supports AmneziaWG and WireGuard.
  • It is enabled only when app split tunneling is on, the default server's protocol is AmneziaWG (either container) or WireGuard, and the VPN is disconnected. The setting is read when the tunnel starts, like the rest of split tunneling.
  • Turning it on first shows a question drawer that explains what it does and the Private DNS caveat below.
Off by default Turning it on explains the trade-off On Locked while connected

How it works

  • StrictSplitTunnelGuard (android/utils) decides per connection. It asks ConnectivityManager.getConnectionOwnerUid (API 29+) for the owning app and applies include or exclude mode to the app lists. An unresolved owner is denied. Android returns INVALID_UID both when no socket matches and when the owner is not covered by this VPN, and an excluded app is exactly the second case. The guard keeps no state: amneziawg-go caches its verdicts per 5-tuple and asks from several threads at once.

  • Wireguard.kt registers the guard with the AmneziaWG backend through GoBackend.awgSetUidFilter right before the tunnel is turned on. It clears the guard on every path that turns the tunnel off. amneziawg-go drops denied packets before encryption.

  • The setting follows the killSwitch chain end to end:

    • a key in configKeys;
    • storage in secureAppSettingsRepository, included in backup and reset;
    • properties on SettingsController and SettingsUiController;
    • a flag in the VPN configuration.

    ServersUiController gains one property that tells the switch whether the default server's protocol enforces it.

  • Off by default, and free when off: no guard is created and the bridge is never called.

Limitations

  • Private DNS by hostname, in "only the apps from the list" mode. The system resolver's own queries are not covered by an include-mode VPN, so the platform hides their owner, and they are denied. Listed apps then resolve nothing, and Android shows "no internet" for the VPN network. With Private DNS off or on "Automatic" everything works: in Automatic, Android falls back to plain DNS. There is no safe exception to make. Allowing the resolver's traffic to the DNS servers would also let an excluded app query whoami.cloudflare through tun0. The question drawer tells the user this. Exclude mode is not affected.
  • TCP bound to tun0 from an app inside the VPN. With strict mode on, such a connection is denied, while the same connect without binding works, and UDP bound to tun0 works too. curl --interface tun0 from an allowed app is exactly this case. The platform's owner lookup sends the exact-match request without an interface, and the kernel does not match a device-bound socket against it; UDP is found by the wildcard dump that follows, TCP is not. getConnectionOwnerUid takes no interface, so the app cannot ask differently. Found by @izhddm.
  • Fire-and-forget UDP. A datagram whose socket is closed before its owner is looked up is denied, even from an allowed app: the platform cannot name the owner of a socket that no longer exists.
  • Ping and traceroute inside the tunnel stop working for listed apps while strict mode is on. The owner of an ICMP packet cannot be resolved, and passing ICMP is exactly the leak (see the amneziawg-go PR).
  • Android 10 and later. On older versions, getConnectionOwnerUid does not exist and the setting has no effect.
  • Only AmneziaWG and WireGuard. Other protocols ignore the flag, and the switch is disabled for them. See "Xray" below.
  • Translations. The new strings are in the Russian catalogue. The other languages need an lupdate run.

Testing

Poco F7, Android 16, an AmneziaWG server (protocol 3.1). From an app outside the VPN (Termux) I bound sockets to tun0, over TCP, UDP (STUN and a DNS TXT "whoami"), curl --interface and ICMP echo, and checked which address the far side saw:

Mode Strict Bypass via tun0 (TCP/UDP, 6 attempts) ICMP echo via tun0
Exclude (Termux, Chrome excluded) off 6/6 show the VPN server's IP not measured
Exclude on 0/6 not measured
Include (Termux not listed) off 6/6 replies
Include on 0/6 times out
  • Apps inside the VPN kept working with strict mode on: browsing in Chrome, YouTube, DNS resolution through the system resolver. DNS was checked with 5 fresh names per run, with Private DNS off, automatic and by hostname; the hostname case is the limitation above.
  • Behaviour change: with strict mode off, there is none. The same probes on a build without this change also gave 6/6.
  • UI: the switch greys out for a non-AmneziaWG server and while connected. Cancelling the question drawer leaves it off.
  • Logs: no JNI errors or crashes in logcat across the sessions.
  • Tools: the test build of the series (strict.7, installed next to the official app) also takes commands from adb — connect, disconnect, split-tunnel mode, app list, strict mode — so checks like the ones above run without taps. That control is fork-only and not in this PR. How it works, the probes, and how Termux is driven over SSH: docs/references/device-testing.md.

Xray

The issue also concerns Xray. An implementation for that datapath exists: hooks in the gVisor forwarders of tun2socks, a gomobile interface in amnezia-libxray, and Xray.kt using the same guard. I have not included it because I could not test it on a device, since Xray does not connect from where I am. The branches are here, if someone who can run a leak test on a working Xray server wants to pick it up or wants me to open PRs:

Notes behind the design, the traps, and the probes used: https://github.com/amnezia-tun0-fix/strict-split-tunneling-notes

Merge order

  1. amnezia-vpn/amneziawg-go#199, the filter
  2. amnezia-vpn/amneziawg-android#104, the JNI bridge
  3. this PR. It needs an awg-android release that contains (2). With the switch off it never touches the bridge, but turning it on before that release would fail to find the native awgSetUidFilter. I will add the recipe bump here once that release exists.

🤖 Generated with Claude Code


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/amnezia-vpn/amnezia-client/pull/3199 **Author:** [@Fast-and-Pythonic](https://github.com/Fast-and-Pythonic) **Created:** 9/22/2026 **Status:** 🔄 Open **Base:** `dev` ← **Head:** `pr/strict-split-tunneling` --- ### 📝 Commits (6) - [`93f9d45`](https://github.com/amnezia-vpn/amnezia-client/commit/93f9d458221c2ac8e8df338caff02e0ccbfe43b6) feat: add a per-connection UID guard for strict split tunneling - [`28fbb00`](https://github.com/amnezia-vpn/amnezia-client/commit/28fbb00ca32880c5f38efe8bc359b80535322549) feat: add the "Strict split tunneling" setting - [`4b3355e`](https://github.com/amnezia-vpn/amnezia-client/commit/4b3355e044668bd8e04697df9f71f390dcde2d36) feat: enforce strict split tunneling on AmneziaWG and WireGuard - [`9663133`](https://github.com/amnezia-vpn/amnezia-client/commit/9663133a790b7e5e0baed54ba6b182c1af5dd674) fix: match split-tunnel apps by app id, so cloned apps count as listed - [`4d00910`](https://github.com/amnezia-vpn/amnezia-client/commit/4d00910fd98f20e0648c7dd431fad41d006321fa) fix: drop the guard's uid cache and its retry on INVALID_UID - [`1aa657e`](https://github.com/amnezia-vpn/amnezia-client/commit/1aa657ee2177f4ebb51da7af27ce1c83a18b3e88) fix: log the guard's denials at debug level ### 📊 Changes **18 files changed** (+373 additions, -3 deletions) <details> <summary>View changed files</summary> ➕ `client/android/utils/src/main/kotlin/net/StrictSplitTunnelGuard.kt` (+153 -0) 📝 `client/android/wireguard/src/main/kotlin/org/amnezia/awg/GoBackend.kt` (+6 -0) 📝 `client/android/wireguard/src/main/kotlin/org/amnezia/vpn/protocol/wireguard/Wireguard.kt` (+46 -2) 📝 `client/core/controllers/settingsController.cpp` (+12 -0) 📝 `client/core/controllers/settingsController.h` (+3 -0) 📝 `client/core/repositories/secureAppSettingsRepository.cpp` (+10 -0) 📝 `client/core/repositories/secureAppSettingsRepository.h` (+4 -1) 📝 `client/core/utils/constants/configKeys.h` (+2 -0) 📝 `client/translations/amneziavpn_ru_RU.ts` (+37 -0) 📝 `client/ui/controllers/serversUiController.cpp` (+7 -0) 📝 `client/ui/controllers/serversUiController.h` (+2 -0) 📝 `client/ui/controllers/settingsUiController.cpp` (+11 -0) 📝 `client/ui/controllers/settingsUiController.h` (+5 -0) 📝 `client/ui/qml/Components/HomeSplitTunnelingDrawer.qml` (+11 -0) ➕ `client/ui/qml/Components/StrictSplitTunnelingSwitcher.qml` (+49 -0) 📝 `client/ui/qml/Pages2/PageSettingsConnection.qml` (+12 -0) 📝 `client/ui/qml/qml.qrc` (+1 -0) 📝 `client/vpnConnection.cpp` (+2 -0) </details> ### 📄 Description Addresses #2457 for AmneziaWG and WireGuard: with per-app split tunneling, an app excluded from the VPN can bind a socket to `tun0` (`curl --interface tun0`, `SO_BINDTODEVICE`, an unprivileged ping socket) and reach the VPN server, which reveals the server's address. This adds an opt-in setting that blocks that. It is the last of three PRs, and the other two carry the datapath (merge order below). ## What the user gets A **"Strict split tunneling"** switch, shown on Android only and off by default. It sits under "App-based split tunneling" in the split tunneling drawer on the home screen and in Settings → Connection. The app split tunneling page itself is unchanged. - Description: *Blocks bypasses of the tunneling rules. Strictly prevents apps from using the VPN tunnel directly unless they are allowed to use the VPN. Supports AmneziaWG and WireGuard.* - It is enabled only when app split tunneling is on, the default server's protocol is AmneziaWG (either container) or WireGuard, and the VPN is disconnected. The setting is read when the tunnel starts, like the rest of split tunneling. - Turning it on first shows a question drawer that explains what it does and the Private DNS caveat below. | <img src="https://raw.githubusercontent.com/amnezia-tun0-fix/strict-split-tunneling-notes/main/screenshots/1-switch-off.jpg" width="200"> | <img src="https://raw.githubusercontent.com/amnezia-tun0-fix/strict-split-tunneling-notes/main/screenshots/2-enable-dialog.jpg" width="200"> | <img src="https://raw.githubusercontent.com/amnezia-tun0-fix/strict-split-tunneling-notes/main/screenshots/3-switch-on.jpg" width="200"> | <img src="https://raw.githubusercontent.com/amnezia-tun0-fix/strict-split-tunneling-notes/main/screenshots/4-locked-while-connected.jpg" width="200"> | |---|---|---|---| | Off by default | Turning it on explains the trade-off | On | Locked while connected | ## How it works - `StrictSplitTunnelGuard` (`android/utils`) decides per connection. It asks `ConnectivityManager.getConnectionOwnerUid` (API 29+) for the owning app and applies include or exclude mode to the app lists. An unresolved owner is denied. Android returns `INVALID_UID` both when no socket matches and when the owner is not covered by this VPN, and an excluded app is exactly the second case. The guard keeps no state: amneziawg-go caches its verdicts per 5-tuple and asks from several threads at once. - `Wireguard.kt` registers the guard with the AmneziaWG backend through `GoBackend.awgSetUidFilter` right before the tunnel is turned on. It clears the guard on every path that turns the tunnel off. amneziawg-go drops denied packets before encryption. - The setting follows the `killSwitch` chain end to end: - a key in `configKeys`; - storage in `secureAppSettingsRepository`, included in backup and reset; - properties on `SettingsController` and `SettingsUiController`; - a flag in the VPN configuration. `ServersUiController` gains one property that tells the switch whether the default server's protocol enforces it. - Off by default, and free when off: no guard is created and the bridge is never called. ## Limitations - **Private DNS by hostname, in "only the apps from the list" mode.** The system resolver's own queries are not covered by an include-mode VPN, so the platform hides their owner, and they are denied. Listed apps then resolve nothing, and Android shows "no internet" for the VPN network. With Private DNS off or on "Automatic" everything works: in Automatic, Android falls back to plain DNS. There is no safe exception to make. Allowing the resolver's traffic to the DNS servers would also let an excluded app query `whoami.cloudflare` through `tun0`. The question drawer tells the user this. Exclude mode is not affected. - **TCP bound to `tun0` from an app inside the VPN.** With strict mode on, such a connection is denied, while the same connect without binding works, and UDP bound to `tun0` works too. `curl --interface tun0` from an allowed app is exactly this case. The platform's owner lookup sends the exact-match request without an interface, and the kernel does not match a device-bound socket against it; UDP is found by the wildcard dump that follows, TCP is not. `getConnectionOwnerUid` takes no interface, so the app cannot ask differently. Found by @izhddm. - **Fire-and-forget UDP.** A datagram whose socket is closed before its owner is looked up is denied, even from an allowed app: the platform cannot name the owner of a socket that no longer exists. - **Ping and traceroute** inside the tunnel stop working for listed apps while strict mode is on. The owner of an ICMP packet cannot be resolved, and passing ICMP is exactly the leak (see the amneziawg-go PR). - **Android 10 and later.** On older versions, `getConnectionOwnerUid` does not exist and the setting has no effect. - **Only AmneziaWG and WireGuard.** Other protocols ignore the flag, and the switch is disabled for them. See "Xray" below. - **Translations.** The new strings are in the Russian catalogue. The other languages need an `lupdate` run. ## Testing Poco F7, Android 16, an AmneziaWG server (protocol 3.1). From an app outside the VPN (Termux) I bound sockets to `tun0`, over TCP, UDP (STUN and a DNS `TXT` "whoami"), `curl --interface` and ICMP echo, and checked which address the far side saw: | Mode | Strict | Bypass via `tun0` (TCP/UDP, 6 attempts) | ICMP echo via `tun0` | |---|---|---|---| | Exclude (Termux, Chrome excluded) | off | 6/6 show the VPN server's IP | not measured | | Exclude | on | **0/6** | not measured | | Include (Termux not listed) | off | 6/6 | replies | | Include | on | **0/6** | **times out** | - **Apps inside the VPN** kept working with strict mode on: browsing in Chrome, YouTube, DNS resolution through the system resolver. DNS was checked with 5 fresh names per run, with Private DNS off, automatic and by hostname; the hostname case is the limitation above. - **Behaviour change:** with strict mode off, there is none. The same probes on a build without this change also gave 6/6. - **UI:** the switch greys out for a non-AmneziaWG server and while connected. Cancelling the question drawer leaves it off. - **Logs:** no JNI errors or crashes in logcat across the sessions. - **Tools:** the test build of the series ([strict.7](https://github.com/amnezia-tun0-fix/amnezia-client/releases/tag/v5.0.3.1-strict.7), installed next to the official app) also takes commands from adb — connect, disconnect, split-tunnel mode, app list, strict mode — so checks like the ones above run without taps. That control is fork-only and not in this PR. How it works, the probes, and how Termux is driven over SSH: [docs/references/device-testing.md](https://github.com/amnezia-tun0-fix/strict-split-tunneling-notes/blob/main/docs/references/device-testing.md). ## Xray The issue also concerns Xray. An implementation for that datapath exists: hooks in the gVisor forwarders of tun2socks, a gomobile interface in amnezia-libxray, and `Xray.kt` using the same guard. I have not included it because I could not test it on a device, since Xray does not connect from where I am. The branches are here, if someone who can run a leak test on a working Xray server wants to pick it up or wants me to open PRs: - amnezia-tun2socks: https://github.com/amnezia-tun0-fix/amnezia-tun2socks/tree/feat/strict-tunnel-isolation - amnezia-libxray: https://github.com/amnezia-tun0-fix/amnezia-libxray/tree/feat/strict-tunnel-isolation - `Xray.kt`: https://github.com/amnezia-tun0-fix/amnezia-client/blob/feat/strict-tunnel-isolation/client/android/xray/src/main/kotlin/Xray.kt Notes behind the design, the traps, and the probes used: https://github.com/amnezia-tun0-fix/strict-split-tunneling-notes ## Merge order 1. amnezia-vpn/amneziawg-go#199, the filter 2. amnezia-vpn/amneziawg-android#104, the JNI bridge 3. **this PR**. It needs an `awg-android` release that contains (2). With the switch off it never touches the bridge, but turning it on before that release would fail to find the native `awgSetUidFilter`. I will add the recipe bump here once that release exists. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
Sign in to join this conversation.
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
amnezia-vpn/amnezia-client#3129
No description provided.