Skip to content

Add support for excluding local routes on Android 13 (Tiramisu)#142

Open
mlsmaycon wants to merge 2 commits intomainfrom
fix/chromeos
Open

Add support for excluding local routes on Android 13 (Tiramisu)#142
mlsmaycon wants to merge 2 commits intomainfrom
fix/chromeos

Conversation

@mlsmaycon
Copy link
Contributor

@mlsmaycon mlsmaycon commented Feb 25, 2026

Summary by CodeRabbit

  • Bug Fixes
    • Improved VPN on Android TIRAMISU (API 33+) to exclude local routes from the tunnel, reducing routing conflicts and improving network stability.
  • New Features
    • Displays a brief notification summarizing which local routes were excluded during tunnel setup.
  • Chores
    • Declared optional PC device capability in the app manifest for broader device compatibility.

@coderabbitai
Copy link

coderabbitai bot commented Feb 25, 2026

📝 Walkthrough

Walkthrough

Adds 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 VpnService.Builder.excludeRoute() for non-default routes during tunnel creation, with logging and a user toast.

Changes

Cohort / File(s) Summary
Local Route Exclusion Implementation
tool/src/main/java/io/netbird/client/tool/IFace.java
Added excludeLocalRoutes(VpnService.Builder) annotated @RequiresApi(TIRAMISU) that obtains ConnectivityManager -> active Network -> LinkProperties, iterates routes (skips prefixLen==0), and calls builder.excludeRoute(dest) with logging and toast; invoked during Tun creation when Build.VERSION.SDK_INT >= TIRAMISU. Several connectivity/network imports added.
Manifest: Optional PC Feature
app/src/main/AndroidManifest.xml
Added uses-feature entry for android.hardware.type.pc with android:required="false".

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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰
I nibbled routes beneath the moonlit view,
Skipping defaults, I hopped the VPN through.
LinkProperties whispered each path to exclude,
Builder accepted—my small code-made feud.
Hooray for tunnels tidy and new! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main change: adding support for excluding local routes on Android 13 (Tiramisu), which matches the primary implementation in IFace.java.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/chromeos

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.d and 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.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 98a2ee7 and fed2a7e.

📒 Files selected for processing (1)
  • tool/src/main/java/io/netbird/client/tool/IFace.java

Comment on lines 126 to 134
for (RouteInfo routeInfo : lp.getRoutes()) {
IpPrefix dest = routeInfo.getDestination();
if (dest.getPrefixLength() == 0) {
continue;
}

try {
builder.excludeRoute(dest);
Log.d(LOGTAG, "exclude route: " + dest);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Android LinkProperties getRoutes() RouteInfo API documentation 33+

💡 Result:

  • LinkProperties#getRoutes() (available since API 21) returns an unmodifiable List<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 from getRoutes()

    • RouteInfo#getType() was added in API 33, returning one of:
      • RTN_UNICAST (reachable)
      • RTN_UNREACHABLE
      • RTN_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() == true often corresponds to routes like the default route via the LAN router.
  • hasGateway() == false often 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:


🌐 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() and excludeRoute() 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/0 plus excludeRoute(8.8.8.8/32) will bypass the VPN for 8.8.8.8). [1]
  • Separately, Android can have DNS leak edge-cases during VPN reconfiguration/crash/reconnect, which are not the same as excludeRoute behavior but can look like “leaks” even with always-on/kill-switch enabled. [3]

How to avoid unintended bypass/leaks:

  • Don’t use excludeRoute for 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:


🏁 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 asdefault routemeaning 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 destinationsa 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.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
tool/src/main/java/io/netbird/client/tool/IFace.java (1)

137-151: ⚠️ Potential issue | 🟠 Major

Add 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.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fed2a7e and b58518b.

📒 Files selected for processing (2)
  • app/src/main/AndroidManifest.xml
  • tool/src/main/java/io/netbird/client/tool/IFace.java

Comment on lines +122 to +126
Network activeNetwork = cm.getActiveNetwork();
if (activeNetwork == null) {
Log.w(LOGTAG, "excludeLocalRoutes: no active network found, skipping");
return;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

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