[PR #199] feat: drop outbound packets of apps that bypass split tunneling #200

Open
opened 2026-10-13 21:04:31 +00:00 by bsakaguchi · 0 comments
Owner

📋 Pull Request Information

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

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


📝 Commits (9)

  • 4873714 feat: drop outbound packets of apps that bypass split tunneling
  • 4fa1ba9 perf(uidfilter): judge new flows off the tun reader, keyed on the full 5-tuple
  • 5864fd3 fix(uidfilter): per-device state, fresh SYNs, a clock that survives idling
  • 2a9f1f1 fix(uidfilter): keep allowed verdicts when the verdict cache is full
  • ba14bc1 fix(uidfilter): do not lock workers to their OS threads
  • 195e166 fix(device): hold the encryption queue open while releasing a held packet
  • 35f643f fix(uidfilter): hold up to 16 packets of a flow while it is judged
  • 9c673ef fix(uidfilter): judge an active UDP flow again before its verdict expires
  • 4215308 fix(uidfilter): age a verdict from its lookup, not from when it is collected

📊 Changes

8 files changed (+1425 additions, -0 deletions)

View changed files

📝 device/device.go (+4 -0)
📝 device/send.go (+13 -0)
➕ device/uidfilter.go (+62 -0)
➕ device/uidfilter_test.go (+33 -0)
➕ uidfilter/supported_android.go (+8 -0)
➕ uidfilter/supported_other.go (+8 -0)
➕ uidfilter/uidfilter.go (+585 -0)
➕ uidfilter/uidfilter_test.go (+712 -0)

📄 Description

This is the Go half of a fix for amnezia-vpn/amnezia-client#2457 on Android, the first of three PRs that depend on each other (order below).

The problem

With per-app split tunneling, an app excluded from the VPN can still bind a socket to the tunnel interface and send traffic through it. The destination then sees the VPN server's address. From the issue, with Termux excluded:

curl -s https://ifconfig.me/ip --interface tun0   # prints the VPN server's IP

The same works with SO_BINDTODEVICE on UDP, for example a STUN request or a DNS TXT query to o-o.myaddr.l.google.com. It also works with ICMP echo from an unprivileged ping socket. Android enforces split tunneling through routing, and binding to the device bypasses routing.

What this adds

A uidfilter package, a one-line hook in RoutineReadFromTUN, and a field on Device:

  • uidfilter.Set(f) installs a PacketFilter. The platform injects the policy and Set(nil) removes it. On Android, the policy looks up the owning app with ConnectivityManager.getConnectionOwnerUid and applies the split-tunnel lists (amneziawg-android and amnezia-client PRs).
  • Each device has a uidfilter.Gate (Device.tun.uidGate), whose zero value is ready. Gate.AllowOutboundPacket parses the 5-tuple of each packet read from tun and checks it against the verdict for that flow. A denied packet is dropped exactly like the existing peer == nil path. The hook sits after elem.padding = padding, so the element is fully initialised.
  • The lookup never runs on the tun reader. Resolving an owner is a binder call with netlink requests behind it, and when the owner cannot be found, two dumps of the socket table. When a packet's flow has no verdict yet, the packet is copied and held, and 4 workers ask the filter. The verdict then sends what was held, in order, through Device.ReleaseOutboundPacket, or drops it. No packet of a flow leaves before its verdict, and flows already judged never wait. Holding is bounded per device: 16 packets per flow, 256 flows, 1 MiB, and 2 s per packet. Past those limits, packets of new flows are dropped (fail-closed); established TCP connections never are, and allowed UDP flows keep their cached verdicts (below).
  • Off by default, free when off, and absent off Android. uidfilter.Supported is a build-time constant, so if uidfilter.Supported && !device.tun.uidGate.AllowOutboundPacket(...) leaves no call behind on other platforms — go tool nm finds the method in the android build alone. On Android with no filter installed the hook is one atomic load that returns true, and nothing is parsed or allocated.
  • One decision per connection. A TCP packet is judged only when it has SYN set, and every SYN is judged afresh, since a new socket may reuse a 5-tuple. A connection whose SYN was denied never exists, so later packets always belong to an allowed one. Judging them again would also be wrong: once an app closes a socket, the kernel reports it as owned by UID 0, and its FIN would be dropped. UDP verdicts are cached per full 5-tuple for 10 s, so the cross-language lookup runs about once per flow. A flow still sending once its allowed verdict is 2 s old is judged again in the background while the verdict holds, so an active flow never waits at the expiry, and a socket that takes over the 5-tuple of a closed allowed flow keeps its verdict for about 2 s, not 10. The cache holds 4096 verdicts. When it is full it evicts expired and denied ones first, and allowed ones only while they alone fill seven eighths of it, so an app that floods tun0 with denied flows cannot push allowed flows out.
  • Fail-closed. While a filter is installed, a packet whose owner cannot be resolved is dropped: protocols other than TCP and UDP, IPv4 fragments, and IPv6 with extension headers. The platform can only resolve TCP and UDP owners. Without this, an app outside the VPN could still ping through tun0 and reach a host it controls. The same holds for a UDP datagram whose socket was closed before its owner could be looked up, even from an allowed app. That is expected: the platform cannot name the owner of a socket that no longer exists.
  • What it costs. The cache belongs to the device's tun reader and takes no lock. It reads a coarse clock that a ticker advances once a second, because time.Now() costs more than the lookup it guards. Set replaces the filter and invalidates every cache in one atomic store, and no packet is sent on a replaced filter's verdict. go test -bench on linux/amd64 (i5-4210U), per packet: filter off 4 ns (and no code at all off Android); a packet of an established TCP connection 14 ns; a packet of a cached UDP flow 62 ns, no allocations.

Changes since the second review (2026-09-28)

Measured on the same phone with an experimental build in which each change could be switched on and off within one tunnel. The commits implement the same logic as the default.

  • ba14bc1: workers are no longer locked to their OS threads. This fixes a crash. A locked goroutine that exits takes its thread with it, and the thread's exit handlers run with every signal blocked (runtime.mexit). The JNI bridge detaches the thread from the JVM in such a handler (amneziawg-android#104), and a fault the JVM would normally handle there kills the process, with no tombstone. Workers exit whenever the filter is replaced or removed, that is on every disconnect and reconnect. Removing and restoring the filter under a steady 3000 new flows/s killed the VPN service once in 60 toggles, 28–97 ms after the removal; with this commit, none in 300. It also happened once on an ordinary reconnect. This corrects what we wrote in the reply to the review: the workers are not pinned to four threads any more, and a few more threads may end up attached.
  • 9c673ef: an active UDP flow is judged again before its verdict expires. Before, every active flow met its expiry once per 10 s, and the packets past the hold cap were dropped while it was judged again. One socket sending 20 DNS queries every 100 ms lost 16 about every 10 s, 0.8–1.0%. Now 0.2–0.4%, the same as with the filter off, at one background lookup every 2–3 s for that flow.
  • 4215308: a verdict is aged from its lookup, not from when it is collected. A worker's verdict reaches the cache when the tun reader next sees the flow or any new one, and was stamped with the reader's clock at that moment. On a quiet tunnel a verdict looked up a minute earlier was taken as fresh, so a socket that had taken over the 5-tuple meanwhile passed on it for 2 s more. Now the cache ages it from the lookup, a first verdict collected more than 2 s late is not used, and a late refresh starts the next lookup at once. On a phone new flows collect verdicts within milliseconds, so this closes the quiet-tunnel case only.
  • 35f643f: 16 packets are held per flow, not 4. A new socket sending 20 datagrams back to back got 4 through, 3 times out of 3; now 16. Memory is still bounded by the 1 MiB per device.
  • 195e166: a release holds the encryption queue open. Found by reading the code: every other caller of SendStagedPackets holds a reference on the queue, the workers did not. A stress test of 3000 releases racing Close did not reproduce a failure.

Known limitations, with fixes on separate branches

Kept out of this PR so it does not grow; each is one commit with tests, on top of this branch:

  • Connections coming in through the tunnel to an app inside the VPN never complete when only listed apps use the VPN. A SYN-ACK is judged like a new connection, and its request socket is reported as owned by uid 0, which is outside the VPN in that mode. Measured: 0 of 5, twice. Fix: ask about the listening socket instead, followup/inbound-synack (5 of 5, twice). An excluded app cannot use this: Android routes its SYN-ACK away from tun0 in any case.
  • UDP datagrams larger than the tunnel MTU are dropped even from allowed apps: the kernel fragments them, and fragments carry no ports. Measured with 1400-byte DNS queries to 9.9.9.9 at MTU 1280: 0 of 20, twice. Fix: later fragments follow the verdict on the first one, followup/fragments (16 and 20 of 20; an excluded app's fragments still get nothing through).
  • ICMP the phone's kernel sends (an echo reply to a ping from another peer, "port unreachable") is dropped with the rest of ICMP. Not addressed: passing it means passing packets without an owner.
  • TCP of an allowed app bound to tun0 is denied: the platform's lookup does not find sockets bound to a device. No fix on the app side; stated in amnezia-client#3199.

With every one of these changes switched on at once, an app outside the VPN still got 0 of 6 attempts through, and ICMP and fragmented UDP via tun0 got no answer.

Relation to #174

@makekryl opened #174 for the same issue in August, and this PR owes it more than we first credited:

  • its choice to drop every packet it cannot identify made us re-check ICMP. Our first version passed non-TCP/UDP traffic, and on a device it did leak through ping;
  • its build tags are where the compile-time exclusion off Android comes from;
  • two choices #174 made from the start, we reached only after @izhddm's review: a verdict keyed on the full 5-tuple rather than the source alone, and filter state kept per device rather than per process.

Thank you for all of that.

The two differ mainly in scope. #174 is the Go side only, and the Android wrapper there is a patch attached to the PR. This series covers the whole path, from this filter through a JNI bridge in amneziawg-android to a setting in the client, and it was measured on a device (below). The Go sides still differ in a few details:

  • the owner lookup runs off the tun reader, with the flow's first packets held, instead of synchronously on it;
  • TCP is judged on SYN only and every SYN afresh, which avoids the closing-socket problem above; UDP verdicts expire after 10 s instead of staying cached until evicted;
  • the filter is swapped atomically at runtime instead of through a UAPI key.

If you prefer the shape of #174 on the Go side, we are glad to adapt the bridge and the client to it instead. The rest of the series does not depend on how this part is shaped.

The same owner check exists in TeapodStream's own tun2socks and, by its README, in OlConnect. sing-box can block flows with an unknown owner, but only through a rule written by hand. v2rayNG closed the same report as not planned, and we found nothing from WireGuard for Android, the commercial clients or AOSP. Sources and details: a comment on amnezia-vpn/amnezia-client#2457.

Testing

  • go test -race ./uidfilter covers parsing, the 5-tuple key, holding and releasing in order, the limits, fresh SYNs, expiry after an idle period, a burst passing while an active flow is judged again, a taken-over 5-tuple denied within 2 s, a full cache flooded with denied verdicts, independent gates, and a late verdict after Set. go test ./device covers ReleaseOutboundPacket on a device pair, and passes except TestAWGDevicePing, which fails the same way on a clean master here.
  • On a device (Poco F7, Android 16, kernel 6.6, an AmneziaWG server (protocol 3.1)), with the full series built into the client, probing from an app outside the VPN:
Mode Strict split tunneling TCP/UDP via tun0 (6 attempts) ICMP echo via tun0
Exclude off 6/6 reach the server replies
Exclude on 0/6 times out
Include off 6/6 replies
Include on 0/6 times out

(All rows re-run on the current commits except include/off. With the switch off no filter is registered, so that path is unchanged.)

  • Load, on the same phone: 40 sequential TCP connects through the tunnel while another process inside the VPN opens new UDP flows. The flows are sent either with the socket held open for 2 s, so that every owner resolves, or with the socket closed right after sending, so that owners are mostly not found. p50 / max:
New flows/s Socket Filter off Filter on, previous version (lookup on the tun reader) Filter on, this PR
1000 held 197 / 507 ms 471 ms / 1.9 s 198 / 222 ms
1000 closed 199 / 613 ms 1.1 s / 4.3 s, 2 of 40 failed 202 / 325 ms
5000 held 194 / 306 ms — 202 / 264 ms
5000 closed 201 / 507 ms — 222 / 352 ms

Traffic of apps inside the VPN kept working with the filter on: browsing, video, DNS.

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

Reproducing the measurements

Everything here was measured with tools that are public in the notes; docs/references/device-testing.md says how they fit together.

  • The test build takes commands from adb, so a check needs no taps: adb shell setprop debug.awg.ctl "cmd=reconnect mode=include add=com.termux strict=1", then adb shell am start -n org.amnezia.vpn.strict/org.amnezia.vpn.AmneziaActivity --ez adb_ctl true (wrapped by tools/awgctl.sh). It connects, disconnects, and changes the split-tunnel mode, the app list and strict mode. Only the shell can set that property, so another app can at most repeat the last command. This is fork-only and not part of any of the three PRs.
  • Termux, driven over SSH through adb forward, plays the app outside or inside the VPN, depending on the list. The probes are standard-library Python: leak_probe.py (6 bypass attempts), icmp_probe.py, udp_flow_probe.py (loss on one long UDP flow, fragmented datagrams), inbound_probe.py (connections into the phone through the tunnel). churn_probe, in Go, generates load from adb shell.
  • The numbers under "Changes since the second review" come from an experimental build, the exp/lab branch of this fork, in which each change could be switched on and off within one tunnel and the filter logged counters every 5 s. It can also remove and restore the filter live, which is how the crash was reproduced.

Series and merge order

  1. this PR, amneziawg-go
  2. amnezia-vpn/amneziawg-android#104, the JNI bridge; bumps go.mod once this is released
  3. amnezia-vpn/amnezia-client#3199, the setting and the wiring; bumps awg-android once (2) is released

🤖 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/amneziawg-go/pull/199 **Author:** [@Fast-and-Pythonic](https://github.com/Fast-and-Pythonic) **Created:** 9/22/2026 **Status:** 🔄 Open **Base:** `master` ← **Head:** `pr/strict-split-tunneling` --- ### 📝 Commits (9) - [`4873714`](https://github.com/amnezia-vpn/amneziawg-go/commit/48737148d3c51f0f25caa94d4767a3609d9ebf43) feat: drop outbound packets of apps that bypass split tunneling - [`4fa1ba9`](https://github.com/amnezia-vpn/amneziawg-go/commit/4fa1ba90bdfa5a70eac8704d688e76993f289cfa) perf(uidfilter): judge new flows off the tun reader, keyed on the full 5-tuple - [`5864fd3`](https://github.com/amnezia-vpn/amneziawg-go/commit/5864fd37ef99943a2e1ceb4809ee8bbb30103c57) fix(uidfilter): per-device state, fresh SYNs, a clock that survives idling - [`2a9f1f1`](https://github.com/amnezia-vpn/amneziawg-go/commit/2a9f1f12f08711f66e6f9ce54f8010764394c0f9) fix(uidfilter): keep allowed verdicts when the verdict cache is full - [`ba14bc1`](https://github.com/amnezia-vpn/amneziawg-go/commit/ba14bc168c60598aa86772e6503ea8edb47bfffc) fix(uidfilter): do not lock workers to their OS threads - [`195e166`](https://github.com/amnezia-vpn/amneziawg-go/commit/195e166b4b6334c168a68ae31b3a865cd773d6d0) fix(device): hold the encryption queue open while releasing a held packet - [`35f643f`](https://github.com/amnezia-vpn/amneziawg-go/commit/35f643fd999e6ff0ff9f92ffaa884ee7a39d01bd) fix(uidfilter): hold up to 16 packets of a flow while it is judged - [`9c673ef`](https://github.com/amnezia-vpn/amneziawg-go/commit/9c673ef796d3e9bf7eaeb3d4bd219f6d6d8775e0) fix(uidfilter): judge an active UDP flow again before its verdict expires - [`4215308`](https://github.com/amnezia-vpn/amneziawg-go/commit/4215308dc74ce7c934187e74aef3d8263d7e8f76) fix(uidfilter): age a verdict from its lookup, not from when it is collected ### 📊 Changes **8 files changed** (+1425 additions, -0 deletions) <details> <summary>View changed files</summary> 📝 `device/device.go` (+4 -0) 📝 `device/send.go` (+13 -0) ➕ `device/uidfilter.go` (+62 -0) ➕ `device/uidfilter_test.go` (+33 -0) ➕ `uidfilter/supported_android.go` (+8 -0) ➕ `uidfilter/supported_other.go` (+8 -0) ➕ `uidfilter/uidfilter.go` (+585 -0) ➕ `uidfilter/uidfilter_test.go` (+712 -0) </details> ### 📄 Description This is the Go half of a fix for amnezia-vpn/amnezia-client#2457 on Android, the first of three PRs that depend on each other (order below). ## The problem With per-app split tunneling, an app excluded from the VPN can still bind a socket to the tunnel interface and send traffic through it. The destination then sees the VPN server's address. From the issue, with Termux excluded: ``` curl -s https://ifconfig.me/ip --interface tun0 # prints the VPN server's IP ``` The same works with `SO_BINDTODEVICE` on UDP, for example a STUN request or a DNS `TXT` query to `o-o.myaddr.l.google.com`. It also works with ICMP echo from an unprivileged ping socket. Android enforces split tunneling through routing, and binding to the device bypasses routing. ## What this adds A `uidfilter` package, a one-line hook in `RoutineReadFromTUN`, and a field on `Device`: - `uidfilter.Set(f)` installs a `PacketFilter`. The platform injects the policy and `Set(nil)` removes it. On Android, the policy looks up the owning app with `ConnectivityManager.getConnectionOwnerUid` and applies the split-tunnel lists (amneziawg-android and amnezia-client PRs). - Each device has a `uidfilter.Gate` (`Device.tun.uidGate`), whose zero value is ready. `Gate.AllowOutboundPacket` parses the 5-tuple of each packet read from tun and checks it against the verdict for that flow. A denied packet is dropped exactly like the existing `peer == nil` path. The hook sits after `elem.padding = padding`, so the element is fully initialised. - **The lookup never runs on the tun reader.** Resolving an owner is a binder call with netlink requests behind it, and when the owner cannot be found, two dumps of the socket table. When a packet's flow has no verdict yet, the packet is copied and held, and 4 workers ask the filter. The verdict then sends what was held, in order, through `Device.ReleaseOutboundPacket`, or drops it. No packet of a flow leaves before its verdict, and flows already judged never wait. Holding is bounded per device: 16 packets per flow, 256 flows, 1 MiB, and 2 s per packet. Past those limits, packets of new flows are dropped (fail-closed); established TCP connections never are, and allowed UDP flows keep their cached verdicts (below). - **Off by default, free when off, and absent off Android.** `uidfilter.Supported` is a build-time constant, so `if uidfilter.Supported && !device.tun.uidGate.AllowOutboundPacket(...)` leaves no call behind on other platforms — `go tool nm` finds the method in the android build alone. On Android with no filter installed the hook is one atomic load that returns `true`, and nothing is parsed or allocated. - **One decision per connection.** A TCP packet is judged only when it has SYN set, and every SYN is judged afresh, since a new socket may reuse a 5-tuple. A connection whose SYN was denied never exists, so later packets always belong to an allowed one. Judging them again would also be wrong: once an app closes a socket, the kernel reports it as owned by UID 0, and its FIN would be dropped. UDP verdicts are cached per full 5-tuple for 10 s, so the cross-language lookup runs about once per flow. A flow still sending once its allowed verdict is 2 s old is judged again in the background while the verdict holds, so an active flow never waits at the expiry, and a socket that takes over the 5-tuple of a closed allowed flow keeps its verdict for about 2 s, not 10. The cache holds 4096 verdicts. When it is full it evicts expired and denied ones first, and allowed ones only while they alone fill seven eighths of it, so an app that floods `tun0` with denied flows cannot push allowed flows out. - **Fail-closed.** While a filter is installed, a packet whose owner cannot be resolved is dropped: protocols other than TCP and UDP, IPv4 fragments, and IPv6 with extension headers. The platform can only resolve TCP and UDP owners. Without this, an app outside the VPN could still ping through `tun0` and reach a host it controls. The same holds for a UDP datagram whose socket was closed before its owner could be looked up, even from an allowed app. That is expected: the platform cannot name the owner of a socket that no longer exists. - **What it costs.** The cache belongs to the device's tun reader and takes no lock. It reads a coarse clock that a ticker advances once a second, because `time.Now()` costs more than the lookup it guards. `Set` replaces the filter and invalidates every cache in one atomic store, and no packet is sent on a replaced filter's verdict. `go test -bench` on linux/amd64 (i5-4210U), per packet: filter off 4 ns (and no code at all off Android); a packet of an established TCP connection 14 ns; a packet of a cached UDP flow 62 ns, no allocations. ## Changes since the second review (2026-09-28) Measured on the same phone with an experimental build in which each change could be switched on and off within one tunnel. The commits implement the same logic as the default. - **ba14bc1: workers are no longer locked to their OS threads.** This fixes a crash. A locked goroutine that exits takes its thread with it, and the thread's exit handlers run with every signal blocked (`runtime.mexit`). The JNI bridge detaches the thread from the JVM in such a handler (amneziawg-android#104), and a fault the JVM would normally handle there kills the process, with no tombstone. Workers exit whenever the filter is replaced or removed, that is on every disconnect and reconnect. Removing and restoring the filter under a steady 3000 new flows/s killed the VPN service once in 60 toggles, 28–97 ms after the removal; with this commit, none in 300. It also happened once on an ordinary reconnect. This corrects what we wrote in the reply to the review: the workers are not pinned to four threads any more, and a few more threads may end up attached. - **9c673ef: an active UDP flow is judged again before its verdict expires.** Before, every active flow met its expiry once per 10 s, and the packets past the hold cap were dropped while it was judged again. One socket sending 20 DNS queries every 100 ms lost 16 about every 10 s, 0.8–1.0%. Now 0.2–0.4%, the same as with the filter off, at one background lookup every 2–3 s for that flow. - **4215308: a verdict is aged from its lookup, not from when it is collected.** A worker's verdict reaches the cache when the tun reader next sees the flow or any new one, and was stamped with the reader's clock at that moment. On a quiet tunnel a verdict looked up a minute earlier was taken as fresh, so a socket that had taken over the 5-tuple meanwhile passed on it for 2 s more. Now the cache ages it from the lookup, a first verdict collected more than 2 s late is not used, and a late refresh starts the next lookup at once. On a phone new flows collect verdicts within milliseconds, so this closes the quiet-tunnel case only. - **35f643f: 16 packets are held per flow, not 4.** A new socket sending 20 datagrams back to back got 4 through, 3 times out of 3; now 16. Memory is still bounded by the 1 MiB per device. - **195e166: a release holds the encryption queue open.** Found by reading the code: every other caller of `SendStagedPackets` holds a reference on the queue, the workers did not. A stress test of 3000 releases racing `Close` did not reproduce a failure. ## Known limitations, with fixes on separate branches Kept out of this PR so it does not grow; each is one commit with tests, on top of this branch: - **Connections coming in through the tunnel to an app inside the VPN** never complete when only listed apps use the VPN. A SYN-ACK is judged like a new connection, and its request socket is reported as owned by uid 0, which is outside the VPN in that mode. Measured: 0 of 5, twice. Fix: ask about the listening socket instead, [`followup/inbound-synack`](https://github.com/amnezia-tun0-fix/amneziawg-go/commits/followup/inbound-synack) (5 of 5, twice). An excluded app cannot use this: Android routes its SYN-ACK away from `tun0` in any case. - **UDP datagrams larger than the tunnel MTU** are dropped even from allowed apps: the kernel fragments them, and fragments carry no ports. Measured with 1400-byte DNS queries to 9.9.9.9 at MTU 1280: 0 of 20, twice. Fix: later fragments follow the verdict on the first one, [`followup/fragments`](https://github.com/amnezia-tun0-fix/amneziawg-go/commits/followup/fragments) (16 and 20 of 20; an excluded app's fragments still get nothing through). - **ICMP the phone's kernel sends** (an echo reply to a ping from another peer, "port unreachable") is dropped with the rest of ICMP. Not addressed: passing it means passing packets without an owner. - **TCP of an allowed app bound to `tun0`** is denied: the platform's lookup does not find sockets bound to a device. No fix on the app side; stated in amnezia-client#3199. With every one of these changes switched on at once, an app outside the VPN still got 0 of 6 attempts through, and ICMP and fragmented UDP via `tun0` got no answer. ## Relation to #174 @makekryl opened #174 for the same issue in August, and this PR owes it more than we first credited: - its choice to drop every packet it cannot identify made us re-check ICMP. Our first version passed non-TCP/UDP traffic, and on a device it did leak through ping; - its build tags are where the compile-time exclusion off Android comes from; - two choices #174 made from the start, we reached only after @izhddm's review: a verdict keyed on the full 5-tuple rather than the source alone, and filter state kept per device rather than per process. Thank you for all of that. The two differ mainly in scope. #174 is the Go side only, and the Android wrapper there is a patch attached to the PR. This series covers the whole path, from this filter through a JNI bridge in amneziawg-android to a setting in the client, and it was measured on a device (below). The Go sides still differ in a few details: - the owner lookup runs off the tun reader, with the flow's first packets held, instead of synchronously on it; - TCP is judged on SYN only and every SYN afresh, which avoids the closing-socket problem above; UDP verdicts expire after 10 s instead of staying cached until evicted; - the filter is swapped atomically at runtime instead of through a UAPI key. If you prefer the shape of #174 on the Go side, we are glad to adapt the bridge and the client to it instead. The rest of the series does not depend on how this part is shaped. ## Related work outside Amnezia The same owner check exists in TeapodStream's own tun2socks and, by its README, in OlConnect. sing-box can block flows with an unknown owner, but only through a rule written by hand. v2rayNG closed the same report as not planned, and we found nothing from WireGuard for Android, the commercial clients or AOSP. Sources and details: [a comment on amnezia-vpn/amnezia-client#2457](https://github.com/amnezia-vpn/amnezia-client/issues/2457#issuecomment-5852959288). ## Testing - `go test -race ./uidfilter` covers parsing, the 5-tuple key, holding and releasing in order, the limits, fresh SYNs, expiry after an idle period, a burst passing while an active flow is judged again, a taken-over 5-tuple denied within 2 s, a full cache flooded with denied verdicts, independent gates, and a late verdict after `Set`. `go test ./device` covers `ReleaseOutboundPacket` on a device pair, and passes except `TestAWGDevicePing`, which fails the same way on a clean `master` here. - On a device (Poco F7, Android 16, kernel 6.6, an AmneziaWG server (protocol 3.1)), with the full series built into the client, probing from an app outside the VPN: | Mode | Strict split tunneling | TCP/UDP via `tun0` (6 attempts) | ICMP echo via `tun0` | |---|---|---|---| | Exclude | off | 6/6 reach the server | replies | | Exclude | on | 0/6 | times out | | Include | off | 6/6 | replies | | Include | on | 0/6 | times out | (All rows re-run on the current commits except include/off. With the switch off no filter is registered, so that path is unchanged.) - Load, on the same phone: 40 sequential TCP connects through the tunnel while another process inside the VPN opens new UDP flows. The flows are sent either with the socket held open for 2 s, so that every owner resolves, or with the socket closed right after sending, so that owners are mostly not found. p50 / max: | New flows/s | Socket | Filter off | Filter on, previous version (lookup on the tun reader) | Filter on, this PR | |---|---|---|---|---| | 1000 | held | 197 / 507 ms | 471 ms / 1.9 s | 198 / 222 ms | | 1000 | closed | 199 / 613 ms | 1.1 s / 4.3 s, 2 of 40 failed | 202 / 325 ms | | 5000 | held | 194 / 306 ms | — | 202 / 264 ms | | 5000 | closed | 201 / 507 ms | — | 222 / 352 ms | Traffic of apps inside the VPN kept working with the filter on: browsing, video, DNS. Notes, the traps found on the way and the probes used: https://github.com/amnezia-tun0-fix/strict-split-tunneling-notes ## Reproducing the measurements Everything here was measured with tools that are public in the [notes](https://github.com/amnezia-tun0-fix/strict-split-tunneling-notes); [docs/references/device-testing.md](https://github.com/amnezia-tun0-fix/strict-split-tunneling-notes/blob/main/docs/references/device-testing.md) says how they fit together. - **The test build takes commands from adb**, so a check needs no taps: `adb shell setprop debug.awg.ctl "cmd=reconnect mode=include add=com.termux strict=1"`, then `adb shell am start -n org.amnezia.vpn.strict/org.amnezia.vpn.AmneziaActivity --ez adb_ctl true` (wrapped by `tools/awgctl.sh`). It connects, disconnects, and changes the split-tunnel mode, the app list and strict mode. Only the shell can set that property, so another app can at most repeat the last command. This is fork-only and not part of any of the three PRs. - **Termux, driven over SSH through `adb forward`,** plays the app outside or inside the VPN, depending on the list. The probes are standard-library Python: `leak_probe.py` (6 bypass attempts), `icmp_probe.py`, `udp_flow_probe.py` (loss on one long UDP flow, fragmented datagrams), `inbound_probe.py` (connections into the phone through the tunnel). `churn_probe`, in Go, generates load from `adb shell`. - **The numbers under "Changes since the second review"** come from an experimental build, the `exp/lab` branch of this fork, in which each change could be switched on and off within one tunnel and the filter logged counters every 5 s. It can also remove and restore the filter live, which is how the crash was reproduced. ## Series and merge order 1. **this PR**, amneziawg-go 2. amnezia-vpn/amneziawg-android#104, the JNI bridge; bumps `go.mod` once this is released 3. amnezia-vpn/amnezia-client#3199, the setting and the wiring; bumps `awg-android` once (2) is released 🤖 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/amneziawg-go#200
No description provided.