From 853771b6c393289a4f3fb4aa88d98042b63b3a63 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Fri, 21 Aug 2026 15:37:52 -0700 Subject: [PATCH 1/3] Drop the Nexus 7 and make the app arm64-only The 2013 Nexus 7 (32-bit armeabi-v7a) is no longer a target, so the docs, instructions and scripts lose the "physical device" serial and the fixed-adb-port notes. scripts/install-release-apk.sh now requires RELEASE_DEVICE_SERIAL instead of defaulting to that tablet, and scripts/run-device.sh no longer needs to refuse it. The app packages only arm64-v8a (ndk.abiFilters), per Google Play's 64-bit requirement; the core release zip's other ABIs are not shipped. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QedowxAkQAV7HoeJu8ZYZC --- CLAUDE.md | 2 +- README.md | 29 ++++++++++++----------------- app/build.gradle.kts | 5 +++++ scripts/install-release-apk.sh | 18 +++++++++--------- scripts/run-device.sh | 12 ++---------- 5 files changed, 29 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 503e3de..b49e380 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ - strict no backward compatibility (0.0.x): change formats and APIs freely, no migrations. - make changes on the sibling project ../ezvpn (the Rust core this app loads) when needed; its design notes for this app are in ../ezvpn/docs/Android-App.md. Run its `cargo ndk -t arm64-v8a --platform 29 clippy --lib -- -D warnings` after Rust changes touching the Android build. -- always test on the development emulator over adb (`10.22.35.66:5555`, an arm64 Android VM; `adb connect 10.22.35.66`); a VpnService cannot be exercised meaningfully on the JVM. `scripts/run-device.sh` targets it by default (`ADB_SERIAL` overrides), builds the local core, installs, launches, and tails logcat. Never use the physical device (`10.22.38.204:5555`, Nexus 7) for development, debug installs, or testing: it only receives the signed release APK via `scripts/install-release-apk.sh`. When several devices are attached, always pass `-s ` to adb / set `ANDROID_SERIAL` for Gradle install tasks. `scrcpy -s 10.22.35.66:5555` mirrors/controls the emulator screen. +- always test on the development emulator over adb (`10.22.35.66:5555`, an arm64 Android VM; `adb connect 10.22.35.66`); a VpnService cannot be exercised meaningfully on the JVM. `scripts/run-device.sh` targets it by default (`ADB_SERIAL` overrides), builds the local core, installs, launches, and tails logcat. When several devices are attached, always pass `-s ` to adb / set `ANDROID_SERIAL` for Gradle install tasks. `scrcpy -s 10.22.35.66:5555` mirrors/controls the emulator screen. - the Rust artifact (`libezvpn.so` per ABI, zipped as `libezvpn-android.zip`) is delivered by download + sha256 pin in `gradle.properties` (`app/build.gradle.kts` `fetchEzvpnJniLibs`). Bump with `scripts/bump-jnilibs.sh ` after the ezvpn release workflow publishes the asset. For FFI dev against a local build run `../ezvpn/build-android.sh` then set `EZVPN_LOCAL_JNILIBS=1` for every gradle invocation — only the exact value `1` opts in. - `EzvpnNative` must stay at `dev.flexaccess.ezvpn.EzvpnNative`: the JNI symbol names in ../ezvpn/src/ffi_android.rs encode that class. `EzvpnNative.init(context)` must run once before anything else (Application.onCreate): it registers the JVM/context that iroh's Android DNS/interface discovery needs, or the first connect aborts the process. - pure logic (CIDR math, the bypass-by-subtraction route plan, profile model/validation, split-DNS rules, JSON shapes) lives in `tunnelcore` (no Android deps) so it is unit-testable with `./gradlew :tunnelcore:test`. Put new pure helpers there, not in the service. diff --git a/README.md b/README.md index a6034e3..3e2d51a 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,11 @@ Design and the JNI contract are documented in the core repo: - JDK 17, Android SDK with platform 37 and build-tools 37 (the Gradle wrapper brings Gradle itself; AGP 9 with built-in Kotlin). -- An Android 10+ (`minSdk` 29) target. Development is done against the +- An Android 10+ (`minSdk` 29) arm64 device (the app is arm64-v8a only, per + [Play's 64-bit requirement](https://developer.android.com/google/play/requirements/64-bit)). Development is done against the emulator (`10.22.35.66:5555`, an arm64 Android VM bridged onto the LAN like a phone; `adb connect 10.22.35.66`) — a `VpnService` cannot be exercised on the - JVM. The physical device (`10.22.38.204:5555`) is reserved for installing - the signed release APK and is never used for development. + JVM. - For FFI work: the sibling `../ezvpn` checkout, the Android NDK and `cargo-ndk` (see that repo's `build-android.sh`). @@ -66,22 +66,18 @@ first use — back it up, devices only accept updates signed with the same key. A release build cannot be installed over a debug build of the app (different signature); uninstall the other one first. -The signed APK is the only thing that goes on the physical device: +Install the signed APK on a real device (the serial is required — there is +no default device): ```bash -scripts/install-release-apk.sh # dist/ezvpn-android-.apk → 10.22.38.204:5555 -scripts/install-release-apk.sh --build # build it first -scripts/install-release-apk.sh --launch # and start the app +RELEASE_DEVICE_SERIAL= scripts/install-release-apk.sh # dist/ezvpn-android-.apk +RELEASE_DEVICE_SERIAL= scripts/install-release-apk.sh --build # build it first +RELEASE_DEVICE_SERIAL= scripts/install-release-apk.sh --launch # and start the app ``` -The tablet listens on a fixed port because `persist.adb.tcp.port=5555` was -set on it as root (Developer options → Rooted debugging, then `adb root` and -`adb shell setprop persist.adb.tcp.port 5555`); Android's *Wireless debugging* -mode would pick a new random port on every toggle or reboot. - It verifies the signature with `apksigner` and refuses unsigned or -debug-signed APKs, and refuses to target the emulator -(`RELEASE_DEVICE_SERIAL` / `EMULATOR_SERIAL` override the serials). +debug-signed APKs, and refuses to target the emulator (`EMULATOR_SERIAL` +overrides that serial). ### Local FFI development @@ -97,8 +93,7 @@ EZVPN_LOCAL_JNILIBS=1 ANDROID_SERIAL=10.22.35.66:5555 ./gradlew :app:installDebu `scripts/run-device.sh` does all of it on the emulator — builds the core for its ABI, installs the debug APK, launches the app, and tails `logcat` for the `ezvpn` tag (`--pinned` skips the local core and uses the release, `--no-core` -skips rebuilding it, `ADB_SERIAL` picks another emulator). It refuses to target -the physical device. +skips rebuilding it, `ADB_SERIAL` picks another emulator). ### Watching the emulator screen @@ -111,7 +106,7 @@ adb connect 10.22.35.66 scrcpy -s 10.22.35.66:5555 ``` -Always pass `-s`: with the physical device attached too, scrcpy would otherwise +Always pass `-s`: with more than one device attached, scrcpy would otherwise refuse to pick one. ## Using the app diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c576757..4182421 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,6 +92,11 @@ android { targetSdk = 37 versionCode = providers.gradleProperty("ezvpn.versionCode").get().toInt() versionName = providers.gradleProperty("ezvpn.versionName").get() + + // arm64 only (Google Play's 64-bit requirement; 32-bit devices are not + // supported). Only lib/arm64-v8a/libezvpn.so from the core zip is + // packaged, so the APK refuses to install on any other ABI. + ndk { abiFilters.add("arm64-v8a") } } // Release signing comes from the environment (scripts/build-release-apk.sh diff --git a/scripts/install-release-apk.sh b/scripts/install-release-apk.sh index d6aa6fa..46ca53a 100755 --- a/scripts/install-release-apk.sh +++ b/scripts/install-release-apk.sh @@ -1,18 +1,17 @@ #!/usr/bin/env bash # -# Install the signed release APK on the physical device. +# Install the signed release APK on a real device. # -# This is the only thing the physical device (RELEASE_DEVICE_SERIAL below) is -# used for; all development, debug installs, and testing happen on the emulator -# via scripts/run-device.sh. The script refuses unsigned APKs and refuses to -# target the emulator. +# The target is RELEASE_DEVICE_SERIAL (required; no default device). All +# development, debug installs, and testing happen on the emulator via +# scripts/run-device.sh. The script refuses unsigned APKs and refuses to target +# the emulator. # -# Usage: +# Usage (RELEASE_DEVICE_SERIAL= in front of each): # scripts/install-release-apk.sh # dist/ezvpn-android-.apk # scripts/install-release-apk.sh path/to.apk # a specific signed APK # scripts/install-release-apk.sh --build # run scripts/build-release-apk.sh first # scripts/install-release-apk.sh --launch # also start the app afterwards -# RELEASE_DEVICE_SERIAL= scripts/install-release-apk.sh # another phone # # Note: a release-signed build cannot be installed over a debug build of the # same applicationId; uninstall the other one first (adb uninstall ...). @@ -22,7 +21,8 @@ set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." EMULATOR_SERIAL="${EMULATOR_SERIAL:-10.22.35.66:5555}" -RELEASE_DEVICE_SERIAL="${RELEASE_DEVICE_SERIAL:-10.22.38.204:5555}" +RELEASE_DEVICE_SERIAL="${RELEASE_DEVICE_SERIAL:-}" +[ -n "$RELEASE_DEVICE_SERIAL" ] || { echo "RELEASE_DEVICE_SERIAL= is required (see 'adb devices')" >&2; exit 1; } BUILD=0 LAUNCH=0 @@ -71,7 +71,7 @@ if echo "$certs" | grep -q 'CN=Android Debug'; then exit 1 fi -# The physical device is attached over adb-over-TCP; (re)connect if needed. +# A device given as host:port is attached over adb-over-TCP; (re)connect if needed. case "$RELEASE_DEVICE_SERIAL" in *:*) adb connect "$RELEASE_DEVICE_SERIAL" >/dev/null 2>&1 || true ;; esac diff --git a/scripts/run-device.sh b/scripts/run-device.sh index 46187dd..40dd8d4 100755 --- a/scripts/run-device.sh +++ b/scripts/run-device.sh @@ -3,9 +3,7 @@ # Build, install, launch, and watch the app on the development emulator. # # All development happens on the adb-connected emulator (EMULATOR_SERIAL below, -# an arm64 Android VM reachable over TCP). The physical device is reserved for -# installing the signed release APK (scripts/install-release-apk.sh) and this -# script refuses to target it. +# an arm64 Android VM reachable over TCP). # # By default the Rust core is rebuilt from the sibling ../ezvpn checkout for the # emulator's primary ABI (release profile) and the app links it via @@ -37,16 +35,10 @@ for arg in "$@"; do esac done -# The development emulator and the physical device the signed APK goes to. +# The development emulator. EMULATOR_SERIAL="${EMULATOR_SERIAL:-10.22.35.66:5555}" -RELEASE_DEVICE_SERIAL="${RELEASE_DEVICE_SERIAL:-10.22.38.204:5555}" ADB_SERIAL="${ADB_SERIAL:-$EMULATOR_SERIAL}" -if [ "$ADB_SERIAL" = "$RELEASE_DEVICE_SERIAL" ]; then - echo "refusing to target the physical device $RELEASE_DEVICE_SERIAL: development runs on the emulator ($EMULATOR_SERIAL);" >&2 - echo "the physical device only gets the signed release APK via scripts/install-release-apk.sh" >&2 - exit 1 -fi # ANDROID_SERIAL makes Gradle's installDebug (and plain adb) use the same target # instead of failing/fanning out when several devices are attached. export ANDROID_SERIAL="$ADB_SERIAL" From de40e02d8590507764cf1f199f0778a1e43a7ff1 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Fri, 21 Aug 2026 15:37:52 -0700 Subject: [PATCH 2/3] Install the underlay bypass with excludeRoute on Android 13+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core's bypass set (server/relay underlay hosts a routed prefix would capture) was always subtracted from the tunnel routes, because VpnService.Builder had no excludeRoute before API 33. Subtraction splits the containing prefix into every sibling down to the host: a /128 inside a routed /56 became 72 IPv6 routes on the interface and in the detail screen. On API 33+ the plan now keeps the routes whole and the service installs the bypass hosts with Builder.excludeRoute — a throw route inside the routed prefix that wins by longest match. TunnelPlan.from takes the platform's capability as a flag so tunnelcore stays pure; subtraction remains for Android 10–12. DNS-coverage warnings are judged on the effective set under both shapes, so a resolver on a bypassed address still warns. Verified on the arm64 emulator (API 36) with a profile whose server sits inside its routed IPv6 /56: one /56 route plus a kernel throw route for the /128, the direct IPv6 path stays selected, both families reach the gateway. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QedowxAkQAV7HoeJu8ZYZC --- CLAUDE.md | 2 +- README.md | 2 +- .../dev/flexaccess/ezvpn/EzvpnVpnService.kt | 12 ++++- .../flexaccess/ezvpn/tunnelcore/IpPrefix.kt | 5 ++- .../flexaccess/ezvpn/tunnelcore/TunnelPlan.kt | 44 +++++++++++++------ .../ezvpn/tunnelcore/TunnelPlanTest.kt | 26 +++++++++++ 6 files changed, 72 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b49e380..4c36458 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ - always test on the development emulator over adb (`10.22.35.66:5555`, an arm64 Android VM; `adb connect 10.22.35.66`); a VpnService cannot be exercised meaningfully on the JVM. `scripts/run-device.sh` targets it by default (`ADB_SERIAL` overrides), builds the local core, installs, launches, and tails logcat. When several devices are attached, always pass `-s ` to adb / set `ANDROID_SERIAL` for Gradle install tasks. `scrcpy -s 10.22.35.66:5555` mirrors/controls the emulator screen. - the Rust artifact (`libezvpn.so` per ABI, zipped as `libezvpn-android.zip`) is delivered by download + sha256 pin in `gradle.properties` (`app/build.gradle.kts` `fetchEzvpnJniLibs`). Bump with `scripts/bump-jnilibs.sh ` after the ezvpn release workflow publishes the asset. For FFI dev against a local build run `../ezvpn/build-android.sh` then set `EZVPN_LOCAL_JNILIBS=1` for every gradle invocation — only the exact value `1` opts in. - `EzvpnNative` must stay at `dev.flexaccess.ezvpn.EzvpnNative`: the JNI symbol names in ../ezvpn/src/ffi_android.rs encode that class. `EzvpnNative.init(context)` must run once before anything else (Application.onCreate): it registers the JVM/context that iroh's Android DNS/interface discovery needs, or the first connect aborts the process. -- pure logic (CIDR math, the bypass-by-subtraction route plan, profile model/validation, split-DNS rules, JSON shapes) lives in `tunnelcore` (no Android deps) so it is unit-testable with `./gradlew :tunnelcore:test`. Put new pure helpers there, not in the service. +- pure logic (CIDR math, the route plan with its underlay bypass — `excludeRoute` on API 33+, subtraction below, profile model/validation, split-DNS rules, JSON shapes) lives in `tunnelcore` (no Android deps) so it is unit-testable with `./gradlew :tunnelcore:test`. Put new pure helpers there, not in the service. - no foreground-service notification: the system binds the VpnService while its interface is established, which keeps the process alive (WireGuard does the same). Don't add one. - the in-tunnel split-DNS forwarder is an Android-only workaround for the platform having no per-domain VPN DNS; the proxy addresses are `DnsProxy.ADDRESS4/6` in tunnelcore and must match what the core intercepts (they are passed in the config JSON, so changing them here is enough). - run `./gradlew :tunnelcore:test :app:testDebugUnitTest` and build the APK after Kotlin changes. Gradle needs JDK 17 (`JAVA_HOME`). diff --git a/README.md b/README.md index 3e2d51a..21e77ed 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Design and the JNI contract are documented in the core repo: | Module | What | |---|---| | `app` | The Compose app (`MainActivity`, screens under `ui/`), `EzvpnVpnService`, `TunnelsManager`, the encrypted secret/profile stores, and `EzvpnNative` (the JNI binding — its package and name are fixed by the symbols in `libezvpn.so`). | -| `tunnelcore` | Pure Kotlin, no Android dependency: IP/CIDR math (`IpPrefix`, `RouteMath.subtract` for the no-`excludeRoute` bypass), the profile model + editor validation, the `VpnService.Builder` plan (`TunnelPlan`), split-DNS rules (`SplitDns`, `DnsProxy`), and the core's JSON shapes. Unit-tested on the JVM. | +| `tunnelcore` | Pure Kotlin, no Android dependency: IP/CIDR math (`IpPrefix`, `RouteMath.subtract` for the bypass on Android < 13, which has no `excludeRoute`), the profile model + editor validation, the `VpnService.Builder` plan (`TunnelPlan`), split-DNS rules (`SplitDns`, `DnsProxy`), and the core's JSON shapes. Unit-tested on the JVM. | ## Requirements diff --git a/app/src/main/kotlin/dev/flexaccess/ezvpn/EzvpnVpnService.kt b/app/src/main/kotlin/dev/flexaccess/ezvpn/EzvpnVpnService.kt index df21664..4c5fdf6 100644 --- a/app/src/main/kotlin/dev/flexaccess/ezvpn/EzvpnVpnService.kt +++ b/app/src/main/kotlin/dev/flexaccess/ezvpn/EzvpnVpnService.kt @@ -6,6 +6,7 @@ import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.net.VpnService +import android.os.Build import android.os.Handler import android.os.Looper import android.os.ParcelFileDescriptor @@ -22,6 +23,7 @@ import dev.flexaccess.ezvpn.tunnelcore.TunnelConfigJson import dev.flexaccess.ezvpn.tunnelcore.TunnelPlan import dev.flexaccess.ezvpn.tunnelcore.TunnelProfile import java.net.Inet6Address +import java.net.InetAddress import java.util.UUID import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -211,7 +213,7 @@ class EzvpnVpnService : VpnService() { teardown(session, "Bad network config from the server: $result") return } - val plan = TunnelPlan.from(net, profile) + val plan = TunnelPlan.from(net, profile, excludeRoutes = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) plan.warnings.forEach { Log.w(TAG, it) } if (plan.remoteAddress == null) { teardown(session, "The server assigned no address.") @@ -222,6 +224,14 @@ class EzvpnVpnService : VpnService() { plan.address4?.let { builder.addAddress(it.address, 32) } plan.address6?.let { builder.addAddress(it.address, 128) } (plan.routes4 + plan.routes6).forEach { builder.addRoute(it.address, it.prefixLength) } + if (plan.bypassExcluded && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Underlay hosts stay reachable off-tunnel as throw routes inside the + // routed prefixes (longest match wins), instead of the tunnel routes + // being split around them. + (plan.bypass4 + plan.bypass6).forEach { + builder.excludeRoute(android.net.IpPrefix(InetAddress.getByAddress(it.bytes), it.prefixLength)) + } + } plan.dnsServers.forEach { builder.addDnsServer(it) } // An address family with no address on the interface is blocked for // every app by default; we are a split tunnel, so let it bypass instead. diff --git a/tunnelcore/src/main/kotlin/dev/flexaccess/ezvpn/tunnelcore/IpPrefix.kt b/tunnelcore/src/main/kotlin/dev/flexaccess/ezvpn/tunnelcore/IpPrefix.kt index d0d50dd..48fc2ba 100644 --- a/tunnelcore/src/main/kotlin/dev/flexaccess/ezvpn/tunnelcore/IpPrefix.kt +++ b/tunnelcore/src/main/kotlin/dev/flexaccess/ezvpn/tunnelcore/IpPrefix.kt @@ -95,8 +95,9 @@ class IpPrefix private constructor( /** * This prefix minus `excluded`: the (up to `maxPrefixLength - prefixLength`) * prefixes that cover everything here except `excluded`. Android's - * `VpnService.Builder` had no excludeRoute before API 33, so the bypass set - * the core computes is carved out of the routed prefixes this way instead. + * `VpnService.Builder` has no excludeRoute before API 33, so there the + * bypass set the core computes is carved out of the routed prefixes this + * way instead (see [TunnelPlan]). */ fun subtract(excluded: IpPrefix): List { if (!overlaps(excluded)) return listOf(this) diff --git a/tunnelcore/src/main/kotlin/dev/flexaccess/ezvpn/tunnelcore/TunnelPlan.kt b/tunnelcore/src/main/kotlin/dev/flexaccess/ezvpn/tunnelcore/TunnelPlan.kt index cdaaf5c..baa579b 100644 --- a/tunnelcore/src/main/kotlin/dev/flexaccess/ezvpn/tunnelcore/TunnelPlan.kt +++ b/tunnelcore/src/main/kotlin/dev/flexaccess/ezvpn/tunnelcore/TunnelPlan.kt @@ -103,20 +103,25 @@ data class NetworkConfig( /** * What the service will program into `VpnService.Builder`, per family. Routes - * are the *effective* set: interface routes + the profile's split-tunnel - * prefixes, minus the core's bypass set (Android has no excludeRoute before - * API 33, and one code path is easier to trust than two). + * are the interface routes + the profile's split-tunnel prefixes; the core's + * bypass set (server/relay underlay addresses those prefixes would capture) is + * kept out of the tunnel in one of two ways, chosen by [bypassExcluded]: + * `Builder.excludeRoute` where the platform has it (API 33+), else by + * subtracting the bypass hosts from the routes (a /128 carved out of a /56 is + * 72 prefixes, which is why the newer API is preferred when available). */ data class TunnelPlan( val mtu: Int, val address4: IpPrefix?, val address6: IpPrefix?, - /** Routes actually installed (after subtraction), sorted. */ + /** Routes to `addRoute`, sorted: the full set when [bypassExcluded], else the remainder after subtraction. */ val routes4: List, val routes6: List, - /** The bypass set that was carved out (for the debug readout). */ + /** The bypass set: what to `excludeRoute` when [bypassExcluded], otherwise what was carved out (readout only). */ val bypass4: List, val bypass6: List, + /** True when the bypass set is to be installed with `excludeRoute` rather than subtracted from the routes. */ + val bypassExcluded: Boolean, /** What `addDnsServer` gets: the proxy addresses under split DNS, else the profile's servers. */ val dnsServers: List, val dnsMatchDomains: List, @@ -148,14 +153,21 @@ data class TunnelPlan( companion object { /** - * Derive the plan from the handshake result and the profile. Routes for - * a family the server did not assign can't be applied and are reported - * as warnings; DNS servers no route covers are a warning too (usually a - * misconfiguration for a private resolver, but legitimate for a public - * one, so warn instead of refusing). + * Derive the plan from the handshake result and the profile. + * `excludeRoutes` says whether the platform offers + * `Builder.excludeRoute` (API 33+); without it the bypass set is + * subtracted from the routes. Routes for a family the server did not + * assign can't be applied and are reported as warnings; DNS servers no + * route covers are a warning too (usually a misconfiguration for a + * private resolver, but legitimate for a public one, so warn instead + * of refusing). */ - fun from(net: NetworkConfig, profile: TunnelProfile): TunnelPlan { + fun from(net: NetworkConfig, profile: TunnelProfile, excludeRoutes: Boolean = false): TunnelPlan { val warnings = ArrayList() + // What the tunnel effectively covers either way; only the install + // shape differs. + fun install(included: List, bypass: List): List = + if (excludeRoutes) included.toSortedSet().toList() else RouteMath.subtract(included, bypass) val userRoutes4 = profile.routes.mapNotNull { IpPrefix.parse(it)?.takeIf { p -> p.isIpv4 } } val userRoutes6 = profile.routes6.mapNotNull { IpPrefix.parse(it)?.takeIf { p -> !p.isIpv4 } } @@ -171,7 +183,7 @@ data class TunnelPlan( val gateway = net.gateway?.let { IpPrefix.host(it) } val included = RouteMath.interfaceRoutes(assigned4, gateway) + userRoutes4 bypass4 = net.excludedRoutes.mapNotNull { IpPrefix.parse(it) }.filter { it.isIpv4 } - routes4 = RouteMath.subtract(included, bypass4) + routes4 = install(included, bypass4) } else if (profile.routes.isNotEmpty()) { warnings += "ignoring ${profile.routes.size} IPv4 route(s): server assigned no IPv4 address" } @@ -187,12 +199,15 @@ data class TunnelPlan( val gateway6 = net.gateway6?.let { IpPrefix.host(it) } val included = RouteMath.interfaceRoutes(assigned6, gateway6) + userRoutes6 bypass6 = net.excludedRoutes6.mapNotNull { IpPrefix.parse(it) }.filter { !it.isIpv4 } - routes6 = RouteMath.subtract(included, bypass6) + routes6 = install(included, bypass6) } else if (profile.routes6.isNotEmpty()) { warnings += "ignoring ${profile.routes6.size} IPv6 route(s): server assigned no IPv6 address" } - val outside = SplitDns.serversOutsideRoutes(profile.dnsServers, routes4 + routes6) + // Coverage is judged on what the tunnel really carries, so a resolver + // sitting on a bypassed address warns under both install shapes. + val effective = RouteMath.subtract(routes4, bypass4) + RouteMath.subtract(routes6, bypass6) + val outside = SplitDns.serversOutsideRoutes(profile.dnsServers, effective) if (outside.isNotEmpty()) { warnings += "DNS server(s) ${outside.joinToString(", ")} not covered by any tunnel route" } @@ -224,6 +239,7 @@ data class TunnelPlan( routes6 = routes6, bypass4 = bypass4, bypass6 = bypass6, + bypassExcluded = excludeRoutes, dnsServers = dnsServers, dnsMatchDomains = profile.dnsMatchDomains, dnsProxyAddresses = dnsProxyAddresses, diff --git a/tunnelcore/src/test/kotlin/dev/flexaccess/ezvpn/tunnelcore/TunnelPlanTest.kt b/tunnelcore/src/test/kotlin/dev/flexaccess/ezvpn/tunnelcore/TunnelPlanTest.kt index ccb4926..c0acfa4 100644 --- a/tunnelcore/src/test/kotlin/dev/flexaccess/ezvpn/tunnelcore/TunnelPlanTest.kt +++ b/tunnelcore/src/test/kotlin/dev/flexaccess/ezvpn/tunnelcore/TunnelPlanTest.kt @@ -100,6 +100,32 @@ class TunnelPlanTest { assertEquals(1280, plan.runtimeInfo().mtu) } + @Test + fun planExcludesBypassInsteadOfSubtractingWhenThePlatformCan() { + val net = NetworkConfig.parse( + """{"assigned_ip":"10.124.0.2","netmask":"255.255.255.255","gateway":"10.124.0.1", + "assigned_ip6":"fd7a::2","prefix_len6":128,"gateway6":"fd7a::1","mtu":1280, + "excluded_routes":["10.9.9.9/32"],"excluded_routes6":["fd00:1::5/128"]}""", + )!! + val plan = TunnelPlan.from(net, profile, excludeRoutes = true) + assertTrue(plan.bypassExcluded) + // Routes stay whole; the bypass hosts go to excludeRoute. + assertEquals(listOf(IpPrefix.parse("10.0.0.0/8"), IpPrefix.parse("10.124.0.1/32")), plan.routes4) + assertEquals(listOf(IpPrefix.parse("fd00::/8"), IpPrefix.parse("fd7a::1/128")), plan.routes6) + assertEquals(listOf(IpPrefix.parse("10.9.9.9/32")), plan.bypass4) + assertEquals(listOf(IpPrefix.parse("fd00:1::5/128")), plan.bypass6) + assertTrue(plan.warnings.isEmpty()) + // Same input subtracted: the /128 splits fd00::/8 into 120 prefixes. + val subtracted = TunnelPlan.from(net, profile, excludeRoutes = false) + assertTrue(!subtracted.bypassExcluded) + assertEquals(121, subtracted.routes6.size) + assertTrue(subtracted.routes6.none { it.containsAddress(IpLiteral.parse("fd00:1::5")!!) }) + // A resolver on a bypassed address is uncovered under both shapes. + val onBypass = profile.copy(dnsServers = listOf("10.9.9.9")) + assertTrue(TunnelPlan.from(net, onBypass, excludeRoutes = true).warnings.single().contains("10.9.9.9")) + assertTrue(TunnelPlan.from(net, onBypass, excludeRoutes = false).warnings.single().contains("10.9.9.9")) + } + @Test fun planWarnsAboutUnassignedFamilyAndUncoveredDns() { val net = NetworkConfig.parse( From 14a4f6b828472e6d54912f3d99cb035b154375a0 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Fri, 21 Aug 2026 15:37:52 -0700 Subject: [PATCH 3/3] Pin the ezvpn v0.0.43 release for jniLibs The custom-relay /healthz probe no longer builds rustls-platform-verifier, which aborted the app on "Connection path" for profiles with custom relays. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QedowxAkQAV7HoeJu8ZYZC --- gradle.properties | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gradle.properties b/gradle.properties index 48fc550..6e26878 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,9 +11,9 @@ android.nonTransitiveRClass=true # Bump both together with scripts/bump-jnilibs.sh , which also sets the # app versionName below. For local FFI dev set EZVPN_LOCAL_JNILIBS=1 to use # ../ezvpn/dist/android/jniLibs instead (see README). -ezvpn.releaseTag=v0.0.42 -ezvpn.releaseSha256=e8526172a452ce8b20ef4407174be2ffb2e89d1a1e81e7579607c9bdc45b4ebf +ezvpn.releaseTag=v0.0.43 +ezvpn.releaseSha256=60f05b60b10f2f0c8d237a140a4389dd85b544e780daf6e185c69f5eef8b7d0c # App version; versionName follows the pinned ezvpn release (numeric part). -ezvpn.versionName=0.0.42 -ezvpn.versionCode=3 +ezvpn.versionName=0.0.43 +ezvpn.versionCode=4