Add support for excluding local routes on Android 13 (Tiramisu)#142
Add support for excluding local routes on Android 13 (Tiramisu)#142
Conversation
📝 WalkthroughWalkthroughAdds API-guarded logic to exclude device-local routes from the Android VPN builder on TIRAMISU (API 33+) by reading the active Network's LinkProperties and calling Changes
Sequence Diagram(s)sequenceDiagram
participant App as App (IFace)
participant CM as ConnectivityManager
participant Net as Network
participant LP as LinkProperties
participant Builder as VpnService.Builder
App->>CM: getSystemService(CONNECTIVITY_SERVICE)
CM->>App: ConnectivityManager
App->>CM: getActiveNetwork()
CM->>Net: active Network
App->>Net: getLinkProperties(net)
Net->>LP: LinkProperties with Routes
App->>LP: iterate routes (skip prefixLen==0)
loop per route
App->>Builder: excludeRoute(routeDestination)
Builder-->>App: success / IllegalArgumentException
end
App->>App: show toast summarizing excluded routes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
tool/src/main/java/io/netbird/client/tool/IFace.java (1)
135-137: Route exclusion failures should be surfaced at warning/error level.Line 136 logs exclusion failures as
Log.dand suppresses the exception. That makes diagnosing partial VPN routing failures harder in production.Logging refinement
- } catch (Exception e) { - Log.d(LOGTAG, "failed to exclude route: " + dest + " - " + e.getMessage()); + } catch (Exception e) { + Log.w(LOGTAG, "failed to exclude route: " + dest, e); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tool/src/main/java/io/netbird/client/tool/IFace.java` around lines 135 - 137, The catch block in IFace that currently does "catch (Exception e) { Log.d(LOGTAG, ... ) }" suppresses errors; change this to surface failures by logging at warning or error level and include the exception object so the stacktrace is recorded (use Log.w or Log.e with the signature that accepts the Throwable). Specifically update the catch handling that references dest in IFace to call Log.w(LOGTAG, "failed to exclude route: " + dest, e) or Log.e(...) so the exception details are preserved instead of only calling Log.d and swallowing the exception.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@tool/src/main/java/io/netbird/client/tool/IFace.java`:
- Around line 126-134: The loop in IFace that iterates lp.getRoutes() currently
excludes every non-default route via builder.excludeRoute(dest), which can
remove external routes and cause VPN bypass; update the loop to check each
RouteInfo for a gateway and only call builder.excludeRoute(dest) when the route
is directly connected (i.e., routeInfo.hasGateway() == false), skipping routes
with a gateway so external/gateway routes are not excluded; locate the iteration
over lp.getRoutes() and change the logic around builder.excludeRoute(dest) to
include the hasGateway() check on the RouteInfo before excluding.
---
Nitpick comments:
In `@tool/src/main/java/io/netbird/client/tool/IFace.java`:
- Around line 135-137: The catch block in IFace that currently does "catch
(Exception e) { Log.d(LOGTAG, ... ) }" suppresses errors; change this to surface
failures by logging at warning or error level and include the exception object
so the stacktrace is recorded (use Log.w or Log.e with the signature that
accepts the Throwable). Specifically update the catch handling that references
dest in IFace to call Log.w(LOGTAG, "failed to exclude route: " + dest, e) or
Log.e(...) so the exception details are preserved instead of only calling Log.d
and swallowing the exception.
| for (RouteInfo routeInfo : lp.getRoutes()) { | ||
| IpPrefix dest = routeInfo.getDestination(); | ||
| if (dest.getPrefixLength() == 0) { | ||
| continue; | ||
| } | ||
|
|
||
| try { | ||
| builder.excludeRoute(dest); | ||
| Log.d(LOGTAG, "exclude route: " + dest); |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Android LinkProperties getRoutes() RouteInfo API documentation 33+
💡 Result:
-
LinkProperties#getRoutes()(available since API 21) returns an unmodifiableList<RouteInfo>of routes “set on this link”. Starting behavior notes:- Apps targeting Android 12 / API 31 (S) or below: only unicast routes are returned (per the official reference docs). [1]
- On Android 13 / API 33 (T) and above, the platform also has “throw routes” internally; framework code shows
getRoutes()may filter out non-unicast routes (e.g.,RTN_THROW) for apps not opted into the compat change / not targeting T+, effectively returning only unicast routes in that case. [3]
-
android.net.RouteInfo(API 33+) additions relevant to routes fromgetRoutes()RouteInfo#getType()was added in API 33, returning one of:RTN_UNICAST(reachable)RTN_UNREACHABLERTN_THROW(lookup should continue in another table)
[2]
Sources: LinkProperties reference (getRoutes) [1], RouteInfo reference (getType + constants) [2], AOSP LinkProperties.java (filtering behavior) [3].
🌐 Web query:
Android RouteInfo hasGateway() method VPN route exclusion
💡 Result:
RouteInfo.hasGateway() does not control VPN routing or “exclude” anything. It only tells you whether a RouteInfo is directly-connected (on-link) (false) or uses a next-hop gateway (true). (developer.android.com)
How VPN route exclusion actually works (Android VpnService)
API 33+ (Android 13+): use VpnService.Builder.excludeRoute(IpPrefix) to exclude destinations from the VPN tunnel (longest-prefix wins). (developer.android.com)
API 32 and below: there’s no excludeRoute. The common approach is:
- Don’t add
0.0.0.0/0(or::/0) and then “subtract” exclusions (you can’t). - Instead, add a set of more-specific
addRoute()prefixes that cover everything except the excluded range (i.e., the complement routes). (stackoverflow.com)
Where hasGateway() can help (but only as a hint)
If you’re inspecting the underlying network’s routes (e.g., from LinkProperties.getRoutes()):
hasGateway() == trueoften corresponds to routes like the default route via the LAN router.hasGateway() == falseoften corresponds to connected LAN subnets (e.g.,192.168.1.0/24) that people commonly want to keep outside the VPN for local-device access.
But the actual exclusion is still done via excludeRoute() (API 33+) or by computing complement addRoute() sets (API ≤32), not by calling hasGateway(). (developer.android.com)
Citations:
- 1: https://developer.android.com/reference/android/net/RouteInfo?utm_source=openai
- 2: https://developer.android.com/reference/android/net/VpnService.Builder?utm_source=openai
- 3: https://stackoverflow.com/questions/14545800/android-vpnservice-route-exclusion?utm_source=openai
- 4: https://developer.android.com/reference/android/net/RouteInfo?utm_source=openai
🌐 Web query:
Android VPN excludeRoute behavior non-local routes traffic leak
💡 Result:
On Android, VpnService.Builder.excludeRoute(IpPrefix) does not “block” traffic—it removes that destination prefix from the VPN’s route set, so traffic to that prefix won’t be sent to the VPN TUN interface and will instead use the device’s normal (underlying) network routing (i.e., it bypasses the VPN). This is expected behavior and is exactly how split-tunneling exclusions are implemented. [1], [2]
Implications for “non-local routes” and leaks:
- If you exclude a public/non-local prefix (intentionally or by mistake), traffic to that public range will go out directly via Wi‑Fi/cellular rather than through the VPN—i.e., a traffic leak relative to “full-tunnel” expectations, but by design. [1], [2]
- Route selection is longest-prefix-wins, and
addRoute()andexcludeRoute()override each other for the same destination prefix—so a more-specific excluded prefix can punch a hole in a broader included route (e.g.,0.0.0.0/0plusexcludeRoute(8.8.8.8/32)will bypass the VPN for8.8.8.8). [1] - Separately, Android can have DNS leak edge-cases during VPN reconfiguration/crash/reconnect, which are not the same as
excludeRoutebehavior but can look like “leaks” even with always-on/kill-switch enabled. [3]
How to avoid unintended bypass/leaks:
- Don’t use
excludeRoutefor anything except the prefixes you explicitly want to bypass (commonly: local LAN subnets). - Prefer an allow-list routing model (only
addRoute()the private/corporate ranges you want in-VPN) instead of “default-route + exclusions”, unless you truly need full-tunnel. [1] - If you require “nothing goes out unless it’s through VPN”, then any excluded route is incompatible with that requirement (because excluded == bypass, not block). [1], [2]
Sources:
[1] Android Developers: VpnService.Builder (excludeRoute, precedence rules, longest-prefix match, allowFamily behavior) (developer.android.com)
[2] Ivanti Tunnel docs (describes excluded routes directing traffic via native network) (help.ivanti.com)
[3] Reported Android DNS leak scenarios during VPN changes/reconnects (Mullvad-reported) (bleepingcomputer.com)
Citations:
- 1: https://developer.android.com/reference/android/net/VpnService.Builder?utm_source=openai
- 2: https://help.ivanti.com/mi/help/en_US/TUN/4.x.x/gda/TunnelAndroidGuide/Tunnel_configuration_fie.htm?utm_source=openai
- 3: https://www.bleepingcomputer.com/news/security/android-bug-leaks-dns-queries-even-when-vpn-kill-switch-is-enabled/?utm_source=openai
🏁 Script executed:
# Search for Android documentation or references to RouteInfo methods
web_search "Android RouteInfo isDefaultRoute() API method"Repository: netbirdio/android-client
Length of output: 118
🌐 Web query:
Android RouteInfo API methods complete list destination prefix length
💡 Result:
android.net.RouteInfo — complete public method list (Android API)
Public methods on android.net.RouteInfo are:
int describeContents()boolean equals(Object obj)IpPrefix getDestination()InetAddress getGateway()String getInterface()int getType()boolean hasGateway()int hashCode()boolean isDefaultRoute()boolean matches(InetAddress destination)String toString()void writeToParcel(Parcel dest, int flags)[1]
Destination + prefix length (how to get “destination prefix length”)
RouteInfo.getDestination() returns an android.net.IpPrefix, which contains both:
- destination network address:
IpPrefix.getAddress() - prefix length (CIDR bits):
IpPrefix.getPrefixLength()[1][2]
Example:
IpPrefix dst = route.getDestination();
InetAddress network = dst.getAddress();
int prefixLen = dst.getPrefixLength();Sources: [1] RouteInfo API reference, [2] IpPrefix API reference.
🌐 Web query:
"RouteInfo.isDefaultRoute" Android source code
💡 Result:
In AOSP, RouteInfo.isDefaultRoute corresponds to the Java method android.net.RouteInfo#isDefaultRoute() (Kotlin can access it like a boolean property).
Source (Android 11 / API 30 prebuilt SDK sources), android/net/RouteInfo.java:
public boolean isDefaultRoute() {
return mType == RTN_UNICAST && mDestination.getPrefixLength() == 0;
}
``` ([android.googlesource.com](https://android.googlesource.com/platform/prebuilts/fullsdk/sources/android-30/%2B/refs/heads/androidx-main-release/android/net/RouteInfo.java))
The public API reference documents it as “default route” meaning prefix length `0`. ([developer.android.com](https://developer.android.com/reference/android/net/RouteInfo?utm_source=openai))
Citations:
- 1: https://android.googlesource.com/platform/prebuilts/fullsdk/sources/android-30/%2B/refs/heads/androidx-main-release/android/net/RouteInfo.java
- 2: https://developer.android.com/reference/android/net/RouteInfo?utm_source=openai
---
</details>
**Add `hasGateway()` check to avoid excluding external routes.**
The current code skips the default route but then excludes all other routes returned by `LinkProperties.getRoutes()`. Since `getRoutes()` can include external routes (those with a gateway), excluding them would unintentionally bypass the VPN for those destinations—a traffic leak.
Exclude only directly-connected local subnets (`hasGateway() == false`); skip gateway routes to avoid split-tunneling external traffic:
<details>
<summary>Recommended fix</summary>
```diff
for (RouteInfo routeInfo : lp.getRoutes()) {
IpPrefix dest = routeInfo.getDestination();
- if (dest.getPrefixLength() == 0) {
+ if (routeInfo.isDefaultRoute()) {
continue;
}
+ // Skip external routes (with gateways); exclude only on-link local subnets.
+ if (routeInfo.hasGateway()) {
+ continue;
+ }
try {
builder.excludeRoute(dest);
Log.d(LOGTAG, "exclude route: " + dest);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tool/src/main/java/io/netbird/client/tool/IFace.java` around lines 126 - 134,
The loop in IFace that iterates lp.getRoutes() currently excludes every
non-default route via builder.excludeRoute(dest), which can remove external
routes and cause VPN bypass; update the loop to check each RouteInfo for a
gateway and only call builder.excludeRoute(dest) when the route is directly
connected (i.e., routeInfo.hasGateway() == false), skipping routes with a
gateway so external/gateway routes are not excluded; locate the iteration over
lp.getRoutes() and change the logic around builder.excludeRoute(dest) to include
the hasGateway() check on the RouteInfo before excluding.
…are feature to manifest
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
tool/src/main/java/io/netbird/client/tool/IFace.java (1)
137-151:⚠️ Potential issue | 🟠 MajorAdd
hasGateway()check — this concern was previously raised.The loop excludes every non-default route, including routes that have a gateway (i.e., routes to external subnets reachable via a router). Excluding those causes traffic to those destinations to bypass the VPN — a split-tunnel leak. Only on-link, directly-connected subnets (
hasGateway() == false) should be excluded.🐛 Proposed fix (see also prior review comment)
for (RouteInfo routeInfo : lp.getRoutes()) { IpPrefix dest = routeInfo.getDestination(); - if (dest.getPrefixLength() == 0) { + if (routeInfo.isDefaultRoute()) { Log.d(LOGTAG, "excludeLocalRoutes: skipping default route " + dest); continue; } + // Only exclude directly-connected (on-link) subnets; skip gateway routes + // to avoid accidentally bypassing VPN for external destinations. + if (routeInfo.hasGateway()) { + Log.d(LOGTAG, "excludeLocalRoutes: skipping gateway route " + dest); + continue; + } try {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tool/src/main/java/io/netbird/client/tool/IFace.java` around lines 137 - 151, The loop currently excludes every non-default route; modify it to only exclude directly-connected (on-link) routes by checking the route's gateway before calling builder.excludeRoute: use RouteInfo.hasGateway() (or equivalent RouteInfo.getGateway() != null) and skip exclusion when a gateway is present, logging that the route is skipped due to having a gateway; keep the existing default-route check, exclusion logic (builder.excludeRoute, excluded.add, Log.i) and exception handling (Log.e) intact.
🧹 Nitpick comments (1)
tool/src/main/java/io/netbird/client/tool/IFace.java (1)
154-158: Remove production Toast from VPN tunnel setup.Surfacing a Toast that lists excluded routes on every tunnel establishment is noisy and leaks internal network topology to the screen. This is debug instrumentation and should be removed or gated behind a debug flag before release.
🗑️ Suggested change
Log.i(LOGTAG, "excludeLocalRoutes: total excluded=" + excluded.size() + " routes=" + excluded); - if (!excluded.isEmpty()) { - String msg = "Excluding " + excluded.size() + " local routes: " + String.join(", ", excluded); - new Handler(Looper.getMainLooper()).post(() -> - Toast.makeText(vpnService, msg, Toast.LENGTH_LONG).show() - ); - }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tool/src/main/java/io/netbird/client/tool/IFace.java` around lines 154 - 158, The Toast in IFace (inside the block checking excluded.isEmpty()) leaks internal route info and should be removed or guarded; edit the code in the IFace method containing the excluded variable so it no longer posts a Toast on the main Looper when excluded routes exist — either delete the new Handler(...).post(...) / Toast.makeText(...) call entirely, or wrap that posting logic behind a debug flag (e.g., a buildConfig or IFace.isDebug boolean) so it only executes in non-production builds.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@tool/src/main/java/io/netbird/client/tool/IFace.java`:
- Around line 122-126: excludeLocalRoutes currently uses cm.getActiveNetwork()
which can return the VPN network during reconnects; detect and skip networks
that carry TRANSPORT_VPN by calling getNetworkCapabilities(activeNetwork) and
checking hasTransport(NetworkCapabilities.TRANSPORT_VPN) before using
getLinkProperties. In excludeLocalRoutes, when cm.getActiveNetwork() yields a
network with TRANSPORT_VPN, ignore it (log/warn) and either find the next
non-VPN active network or return early so you use the physical underlying
interface routes instead of the VPN's routes; reference getActiveNetwork(),
getNetworkCapabilities(), hasTransport(NetworkCapabilities.TRANSPORT_VPN),
getLinkProperties(), and the excludeLocalRoutes method to locate where to add
this check.
---
Duplicate comments:
In `@tool/src/main/java/io/netbird/client/tool/IFace.java`:
- Around line 137-151: The loop currently excludes every non-default route;
modify it to only exclude directly-connected (on-link) routes by checking the
route's gateway before calling builder.excludeRoute: use RouteInfo.hasGateway()
(or equivalent RouteInfo.getGateway() != null) and skip exclusion when a gateway
is present, logging that the route is skipped due to having a gateway; keep the
existing default-route check, exclusion logic (builder.excludeRoute,
excluded.add, Log.i) and exception handling (Log.e) intact.
---
Nitpick comments:
In `@tool/src/main/java/io/netbird/client/tool/IFace.java`:
- Around line 154-158: The Toast in IFace (inside the block checking
excluded.isEmpty()) leaks internal route info and should be removed or guarded;
edit the code in the IFace method containing the excluded variable so it no
longer posts a Toast on the main Looper when excluded routes exist — either
delete the new Handler(...).post(...) / Toast.makeText(...) call entirely, or
wrap that posting logic behind a debug flag (e.g., a buildConfig or
IFace.isDebug boolean) so it only executes in non-production builds.
| Network activeNetwork = cm.getActiveNetwork(); | ||
| if (activeNetwork == null) { | ||
| Log.w(LOGTAG, "excludeLocalRoutes: no active network found, skipping"); | ||
| return; | ||
| } |
There was a problem hiding this comment.
getActiveNetwork() may return the VPN network itself during reconnects, yielding wrong routes.
When this app is reconnecting (e.g., network change triggers a re-key), an existing VPN interface can still be the "active" default network at the moment excludeLocalRoutes is called — before builder.establish() creates the replacement tunnel. In that scenario, getActiveNetwork() returns the VPN network, and getNetworkCapabilities would show it has TRANSPORT_VPN, so getLinkProperties() returns the VPN's own routes rather than the physical interface routes, completely defeating the exclusion logic.
Filter for the physical underlying network by skipping any network that carries TRANSPORT_VPN:
🐛 Proposed fix
- Network activeNetwork = cm.getActiveNetwork();
- if (activeNetwork == null) {
- Log.w(LOGTAG, "excludeLocalRoutes: no active network found, skipping");
- return;
+ Network activeNetwork = null;
+ for (Network net : cm.getAllNetworks()) {
+ NetworkCapabilities caps = cm.getNetworkCapabilities(net);
+ if (caps != null
+ && !caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
+ && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
+ activeNetwork = net;
+ break;
+ }
}
+ if (activeNetwork == null) {
+ Log.w(LOGTAG, "excludeLocalRoutes: no non-VPN active network found, skipping");
+ return;
+ }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tool/src/main/java/io/netbird/client/tool/IFace.java` around lines 122 - 126,
excludeLocalRoutes currently uses cm.getActiveNetwork() which can return the VPN
network during reconnects; detect and skip networks that carry TRANSPORT_VPN by
calling getNetworkCapabilities(activeNetwork) and checking
hasTransport(NetworkCapabilities.TRANSPORT_VPN) before using getLinkProperties.
In excludeLocalRoutes, when cm.getActiveNetwork() yields a network with
TRANSPORT_VPN, ignore it (log/warn) and either find the next non-VPN active
network or return early so you use the physical underlying interface routes
instead of the VPN's routes; reference getActiveNetwork(),
getNetworkCapabilities(), hasTransport(NetworkCapabilities.TRANSPORT_VPN),
getLinkProperties(), and the excludeLocalRoutes method to locate where to add
this check.
Summary by CodeRabbit