Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 <serial>` 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 <serial>` 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 <tag>` 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`).
31 changes: 13 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand Down Expand Up @@ -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-<version>.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=<serial> scripts/install-release-apk.sh # dist/ezvpn-android-<version>.apk
RELEASE_DEVICE_SERIAL=<serial> scripts/install-release-apk.sh --build # build it first
RELEASE_DEVICE_SERIAL=<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

Expand All @@ -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

Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion app/src/main/kotlin/dev/flexaccess/ezvpn/EzvpnVpnService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.")
Expand All @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ android.nonTransitiveRClass=true
# Bump both together with scripts/bump-jnilibs.sh <tag>, 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
18 changes: 9 additions & 9 deletions scripts/install-release-apk.sh
Original file line number Diff line number Diff line change
@@ -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=<serial> in front of each):
# scripts/install-release-apk.sh # dist/ezvpn-android-<versionName>.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=<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 ...).
Expand All @@ -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=<serial> is required (see 'adb devices')" >&2; exit 1; }

BUILD=0
LAUNCH=0
Expand Down Expand Up @@ -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
Expand Down
12 changes: 2 additions & 10 deletions scripts/run-device.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IpPrefix> {
if (!overlaps(excluded)) return listOf(this)
Expand Down
Loading
Loading