diff --git a/CLAUDE.md b/CLAUDE.md index 503e3de..4c36458 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,9 @@ - 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. +- 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 a6034e3..21e77ed 100644 --- a/README.md +++ b/README.md @@ -16,17 +16,17 @@ 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 - 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/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/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 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" 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(