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
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: gradle/actions/setup-gradle@v4

- name: tunnelcore unit tests
run: ./gradlew :tunnelcore:test --console=plain

# The APK must carry the pinned release's libezvpn.so: refuse to build one
# without a valid pin rather than publish an artifact with no native
# library in it (pin with scripts/bump-jnilibs.sh <tag>).
- name: Check the ezvpn release pin
run: |
if ! grep -qE '^ezvpn.releaseSha256=[0-9a-f]{64}$' gradle.properties; then
echo "::error::gradle.properties has no valid ezvpn.releaseSha256; pin a published ezvpn release with scripts/bump-jnilibs.sh <tag>"
exit 1
fi

- name: App unit tests + debug APK
run: ./gradlew :app:testDebugUnitTest :app:assembleDebug --console=plain

- uses: actions/upload-artifact@v4
with:
name: app-debug
path: app/build/outputs/apk/debug/app-debug.apk
17 changes: 17 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Gradle
.gradle/
build/
local.properties
.kotlin/

# IDE
.idea/
*.iml
.DS_Store

# Signing
*.jks
*.keystore

tmp/
CLAUDE.local.md
1 change: 1 addition & 0 deletions AGENTS.md
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +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 real device over adb (`adb devices`); a VpnService cannot be exercised meaningfully on the JVM and the emulator's network stack differs. `scripts/run-device.sh` builds the local core, installs, launches, and tails logcat.
- 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.
- 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`).
101 changes: 101 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# ezvpn-android

A native Kotlin/Jetpack Compose client for Android that connects to an
[`ezvpn`](https://github.com/flexaccessdev/ezvpn) server: dual-stack split
tunnel, optional tunnel DNS with split-DNS match domains (implemented by an
in-tunnel forwarder, since Android has no per-domain VPN DNS), underlay bypass,
always-on support. The Rust core (`libezvpn.so`, built from the `ezvpn` repo)
runs the data plane inside a `VpnService`; this repo is the app, the service,
and the pure-Kotlin `tunnelcore` module around it.

Design and the JNI contract are documented in the core repo:
[`docs/Android-App.md`](https://github.com/flexaccessdev/ezvpn/blob/main/docs/Android-App.md).

## Layout

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

## Requirements

- JDK 17, Android SDK with platform 37 and build-tools 37 (the Gradle wrapper
brings Gradle itself; AGP 9 with built-in Kotlin).
- A device running Android 10+ (`minSdk` 29). Development is done against a
physical device over adb — a VPN needs the real network stack.
- For FFI work: the sibling `../ezvpn` checkout, the Android NDK and
`cargo-ndk` (see that repo's `build-android.sh`).

`local.properties` (git-ignored) points Gradle at the SDK:
`sdk.dir=/path/to/Android/Sdk`.

## Building

By default the app downloads the pinned `libezvpn-android.zip` release asset of
the core repo (tag + sha256 in `gradle.properties`) and unpacks the
`jniLibs/<abi>/libezvpn.so` tree into `app/build/ezvpn-jnilibs`:

```bash
./gradlew :tunnelcore:test # pure-Kotlin unit tests
./gradlew :app:testDebugUnitTest # app-module JVM unit tests
./gradlew :app:assembleDebug # app/build/outputs/apk/debug/app-debug.apk
./gradlew :app:installDebug # install on the connected device
```

Pin a newer core release with `scripts/bump-jnilibs.sh <tag>` (rewrites the
tag, the sha256, and the app version in `gradle.properties`).

### Local FFI development

To run against a local build of the core instead of the pinned release, build
it in the sibling checkout and set `EZVPN_LOCAL_JNILIBS=1` (only the exact
value `1` opts in; anything else uses the release):

```bash
(cd ../ezvpn && ./build-android.sh release) # or ABIS="arm64-v8a" ./build-android.sh debug
EZVPN_LOCAL_JNILIBS=1 ./gradlew :app:installDebug
```

`scripts/run-device.sh` does all of it — builds the core for the connected
device's ABI, installs, launches the app, and tails `logcat` for the `ezvpn`
tag (`--pinned` skips the local core and uses the release, `--no-core` skips
rebuilding it).

## Using the app

1. **Auth keys** (key icon): generate a key on the device or paste an
`ed25519-sec:…` secret from another device. Put the shown `ed25519-pub:…`
line on the server's `authorized_keys` file. Keys are shared by all
profiles; deleting one does not affect profiles already saved with it.
2. **Add a profile** (+): server node id, the auth key, optional custom relay
URLs (+ token), split-tunnel CIDRs (the server gateway is always routed),
and optionally DNS servers with match domains.
3. Toggle the profile to connect. The first connect shows Android's VPN
consent dialog. The detail screen shows the applied addresses, routes,
bypass set, DNS setup, and a live "Connection path" readout (direct vs
relay).

Network changes disconnect the tunnel (reconnect on the new network). A
profile whose split-tunnel prefix overlaps the Wi-Fi/Ethernet subnet the device
is on is refused, since routing the local subnet into the tunnel would cut the
tunnel's own underlay off. For always-on VPN (Settings › Network › VPN › ezvpn),
the service connects the last-used profile when the system starts it.

### Split DNS

With DNS servers and match domains set, names under the match domains resolve
through the profile's servers over the tunnel and everything else keeps the
network's normal resolvers. Android's `VpnService` cannot express that, so the
app points the VPN's DNS at a proxy address inside the tunnel (`198.18.0.53` /
`fd7e:7a00:d45::53`) and the Rust core forwards each query to the right
resolver (Tailscale's MagicDNS approach). With servers but no match domains,
the servers answer every name, as on any VPN app.

## Logs

```bash
adb logcat -s ezvpn
```

Both the Kotlin side and the Rust core log under the `ezvpn` tag.
154 changes: 154 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import java.io.FileOutputStream
import java.net.URI
import java.security.MessageDigest
import java.util.zip.ZipInputStream

plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}

// ---------------------------------------------------------------------------
// libezvpn.so delivery.
//
// Default: download the pinned ezvpn release zip (libezvpn-android.zip, the
// jniLibs/<abi>/libezvpn.so tree built by ../ezvpn/build-android.sh) by
// URL + sha256 into build/, mirroring how ezvpn-apple pins its xcframework.
// Local FFI dev: EZVPN_LOCAL_JNILIBS=1 (exactly) points the jniLibs source set
// at ../ezvpn/dist/android/jniLibs instead. Any other value selects the release.
val ezvpnLocalJniLibs = System.getenv("EZVPN_LOCAL_JNILIBS") == "1"
val ezvpnReleaseTag = providers.gradleProperty("ezvpn.releaseTag").get()
val ezvpnReleaseSha256 = providers.gradleProperty("ezvpn.releaseSha256").getOrElse("")
val ezvpnReleaseUrl =
"https://github.com/flexaccessdev/ezvpn/releases/download/$ezvpnReleaseTag/libezvpn-android.zip"
val ezvpnLocalDir = rootProject.file("../ezvpn/dist/android/jniLibs")
val ezvpnFetchedDir = layout.buildDirectory.dir("ezvpn-jnilibs")
val ezvpnJniLibsDir: File =
if (ezvpnLocalJniLibs) ezvpnLocalDir else ezvpnFetchedDir.get().asFile.resolve("jniLibs")

val fetchEzvpnJniLibs by tasks.registering {
description = "Downloads the pinned libezvpn-android.zip release and verifies its sha256."
val url = ezvpnReleaseUrl
val sha256 = ezvpnReleaseSha256
val outDir = ezvpnFetchedDir
val skip = ezvpnLocalJniLibs
inputs.property("url", url)
inputs.property("sha256", sha256)
outputs.dir(outDir)
onlyIf { !skip }
doLast {
require(sha256.matches(Regex("[0-9a-f]{64}"))) {
"ezvpn.releaseSha256 is not set in gradle.properties: run " +
"scripts/bump-jnilibs.sh <tag> to pin a published ezvpn release, " +
"or build ../ezvpn with ./build-android.sh and set EZVPN_LOCAL_JNILIBS=1."
}
val dir = outDir.get().asFile
val stamp = dir.resolve("sha256.txt")
if (stamp.isFile && stamp.readText().trim() == sha256 &&
dir.resolve("jniLibs").isDirectory
) {
return@doLast
}
dir.deleteRecursively()
dir.mkdirs()
logger.lifecycle("Downloading $url")
val bytes = URI(url).toURL().openConnection().run {
connectTimeout = 30_000
readTimeout = 120_000
getInputStream().use { it.readBytes() }
}
val actual = MessageDigest.getInstance("SHA-256").digest(bytes)
.joinToString("") { "%02x".format(it) }
require(actual == sha256) {
"sha256 mismatch for $url: expected $sha256, got $actual"
}
ZipInputStream(bytes.inputStream()).use { zip ->
generateSequence { zip.nextEntry }.forEach { entry ->
val target = dir.resolve(entry.name).canonicalFile
require(target.path.startsWith(dir.canonicalPath)) { "zip entry escapes dir: ${entry.name}" }
if (entry.isDirectory) {
target.mkdirs()
} else {
target.parentFile.mkdirs()
FileOutputStream(target).use { zip.copyTo(it) }
}
}
}
require(dir.resolve("jniLibs").isDirectory) { "zip did not contain a jniLibs/ tree" }
stamp.writeText(sha256)
}
}

android {
namespace = "dev.flexaccess.ezvpn"
compileSdk = 37

defaultConfig {
// The JNI symbols in libezvpn.so (ezvpn/src/ffi_android.rs) are bound to
// the class dev.flexaccess.ezvpn.EzvpnNative; the applicationId can
// change, the package of that class cannot.
applicationId = "dev.flexaccess.ezvpn"
minSdk = 29
targetSdk = 37
versionCode = providers.gradleProperty("ezvpn.versionCode").get().toInt()
versionName = providers.gradleProperty("ezvpn.versionName").get()
}

buildTypes {
release {
isMinifyEnabled = false
}
}

sourceSets["main"].jniLibs.srcDir(ezvpnJniLibsDir)

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

buildFeatures {
compose = true
buildConfig = true
}

packaging {
// The .so files are built against the NDK with 16 KiB page alignment;
// keep them uncompressed and aligned as the system expects.
jniLibs.useLegacyPackaging = false
}

testOptions {
unitTests.isReturnDefaultValues = true
}
}

tasks.named("preBuild") {
dependsOn(fetchEzvpnJniLibs)
}

tasks.withType<Test>().configureEach {
testLogging {
events("passed", "failed", "skipped")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}

dependencies {
implementation(project(":tunnelcore"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.kotlinx.coroutines.android)
debugImplementation(libs.androidx.compose.ui.tooling)

testImplementation(libs.junit)
testImplementation(libs.org.json)
testImplementation(libs.kotlinx.coroutines.test)
}
47 changes: 47 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<application
android:name=".EzvpnApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Ezvpn">

<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<!--
The VPN service. BIND_VPN_SERVICE is a system-only permission: only
the OS may bind (which it does on establish(), keeping the process
alive while the interface is up); the app itself may still start its
own component. SUPPORTS_ALWAYS_ON lets the user pick this app under
Settings > VPN > Always-on; the system then starts the service with a
null intent and it connects the last-used profile.
-->
<service
android:name=".EzvpnVpnService"
android:exported="true"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
<meta-data
android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
android:value="true" />
</service>
</application>
</manifest>
Loading
Loading