A native Kotlin/Jetpack Compose client for Android that connects to an ezvpn
server built from this repo. The tunnel runs in a VpnService in the app's
own process; there is no Play Store packaging or signing — it is a sideloaded
debug/release APK built from source.
The Android client is split across two repositories:
- This repo (
ezvpn) — the Rust core, packaged aslibezvpn.soper ABI (arm64-v8a,armeabi-v7a,x86_64; the app packages onlyarm64-v8a) plus a small JNI surface. This is where the Android Rust code, the in-tunnel split-DNS forwarder, and the build script live. ezvpn-android— the Gradle project: a Compose app, theVpnService, and a pure-Kotlintunnelcoremodule (IP/CIDR math, profile model and validation, the interface plan) with JVM unit tests. Build/install/run instructions live in that repo's README.
In scope:
- Dual-stack split tunnel — IPv4, IPv6, or both, to explicit routed prefixes. Both route lists are optional and independent.
- Optional tunnel DNS, including split DNS (match domains) — the same profile fields as iOS. Android has no per-domain DNS for VPNs, so match domains are implemented by an in-tunnel forwarder in the Rust core (see Split DNS on Android); without match domains the servers are handed to the OS and answer every name.
- Optional underlay bypass — the few server underlay addresses that overlap a routed prefix are carved back out of the tunnel (see below).
- Always-on VPN — the service accepts the system's always-on start and connects the last-used profile.
- On-device testing — developed and tested on an adb-connected arm64
Android emulator (a
VpnServicecannot run on the JVM).
Out of scope (by design):
- Full tunnel (
0.0.0.0/0/::/0) is not offered by the app's editor. - Play Store distribution, Play signing, and Android TV/Auto form factors.
- Session migration across networks — like the Apple app, a network change disconnects; the user reconnects on the new network.
The Android data plane is the same portable code the desktop client and the
Apple extension use (src/tunnel/mobile.rs, MobileSession): the OS owns the
tun interface, addresses, routes, DNS, and MTU; Rust is handed the fd.
| Concern | Desktop CLI | Android VpnService |
|---|---|---|
| TUN device | created by ezvpn (TunDevice::create) |
created by the OS (Builder.establish()); ezvpn wraps the fd (TunDevice::from_raw_fd) |
| Routing / IP / MTU / DNS | ip/route/netsh, OS resolver config |
VpnService.Builder (addAddress, addRoute, addDnsServer, setMtu) |
| Underlay bypass | BypassRouteManager host routes |
Builder.excludeRoute for the bypass /32s and /128s on API 33+; below that the app subtracts them from the routed prefixes (tunnelcore RouteMath.subtract) and installs the remainder |
| Split DNS | OS conditional forwarding (docs/Client-Split-DNS.md) |
in-tunnel forwarder (src/tunnel/dns_proxy.rs) |
| Single-instance lock, control socket | yes | not used (one VpnService; the app and service share a process) |
Key source in this repo:
src/tunnel/mobile.rs—MobileSession(connect → handshake → run) and the network config it returns, shared with the Apple extension.src/ffi.rs— the shared connect/run/stop bodies and JSON shapes.src/ffi_android.rs— the JNI entry points bound to the Kotlin objectdev.flexaccess.ezvpn.EzvpnNative(the symbol names encode that class name; it must not move even if theapplicationIdchanges).src/tunnel/dns_proxy.rs— the Android-only split-DNS forwarder.src/net/device.rs—TunDevice::from_raw_fdfor Android (thetuncrate'sraw_fdconfiguration; no offload —VpnServicetun devices have none).build-android.sh— builds onelibezvpn.soper ABI withcargo ndkand stagesdist/android/jniLibs/<abi>/libezvpn.soplusdist/android/libezvpn-android.zip(the release asset the app downloads by URL + sha256).
EzvpnNative (Kotlin, in the app) ↔ src/ffi_android.rs:
| Kotlin | Purpose |
|---|---|
init(context) |
once per process, from Application.onCreate: logcat logging (tag ezvpn) and the JVM/context registration that iroh's Android DNS and interface discovery (hickory-resolver, netwatch, via ndk-context) need — without it the first connect aborts the process |
generateClientKey() / clientPublicKey(secret) |
the shared FlexAccess ed25519 key format, never reimplemented in Kotlin |
connect(configJson, out) → handle |
connect + handshake; out[0] receives the network-config JSON (or the error) |
run(handle, tunFd) |
start the data loop on the establish()ed fd (dup'ed before it returns) |
connPath(handle) |
the live iroh path / custom-relay snapshot JSON |
stop(handle) |
abort, close, free |
onTunnelExit(handle, error) |
callback from the library when the loop ends on its own (never after stop) so the service tears the interface down |
The config and result JSON are the shapes documented in
ios/ezvpn.h, plus one Android-only config object:
"dns_proxy": {
"addresses": ["198.18.0.53", "fd7e:7a00:d45::53"],
"match_domains": ["corp.example"],
"servers": ["10.0.0.53"],
"fallback_servers": ["192.168.1.1", "fe80::1%5"],
"fallback_fds": [41, 42]
}ezvpn app (Compose) EzvpnVpnService (same process)
TunnelsManager.connect ──▶ startService → worker thread:
EzvpnNative.connect(json) ──▶ libezvpn (iroh connect + handshake)
TunnelPlan.from(netConfig) (tunnelcore: routes, bypass, DNS, families)
Builder…establish() → fd
EzvpnNative.run(handle, fd) ─▶ data loop (+ DNS forwarder)
state: StateFlow ◀──────── onConnected / onDisconnected
disconnect ───────────────▶ EzvpnNative.stop(handle); close fd
connect blocks for the handshake and runs on its own thread; everything that
touches the session (the connect continuation, stop, the exit callback, path
queries) is serialized on one worker thread so a handle is never stopped twice.
A disconnect that lands while a connect is in flight is honored when the
handshake returns. No foreground notification is used: the system binds the
VpnService while its interface is established, which keeps the process alive
(the WireGuard app relies on the same).
Same computation as the Apple app: connect returns excluded_routes /
excluded_routes6, the global-scope relay and server underlay addresses a
routed prefix would capture. On API 33+ the app installs them with
VpnService.Builder.excludeRoute, a throw route inside the routed prefix that
wins by longest match. Below that (the app's minSdk is 29) there is no
excludeRoute, so the app subtracts those host prefixes from its route list
(splitting each containing prefix into the sibling prefixes that do not
contain the address — a /128 out of a /56 is 72 routes) and installs the
result. The detail screen shows both the installed routes and the bypass set.
An address family the server did not assign is explicitly allowFamily'd:
a VpnService blocks every family it has no address for by default, which is
the wrong default for a split tunnel.
VpnService.Builder offers only addDnsServer (resolvers for all names of
every app the VPN applies to) and addSearchDomain. There is no equivalent of
iOS NEDNSSettings.matchDomains, and an app cannot bind port 53. So when a
profile names match domains the app does what Tailscale's MagicDNS does:
- It tells the OS the VPN's DNS server is a proxy address inside the
tunnel —
198.18.0.53and/orfd7e:7a00:d45::53(RFC 2544 benchmarking space and a ULA, so they never collide with a real network) — and routes that address as a host route into the interface, for whichever families the server assigned. - The data path intercepts UDP packets to
<proxy>:53before they reach the server (DnsIntercept::wants, a few byte compares per outbound packet), parses the first question name, and forwards the query:- names equal to or under a match domain → the profile's DNS servers, through ordinary sockets — the resolvers sit inside a tunnel route, so the OS routes the query into the tun and through the tunnel like any app traffic;
- everything else → the underlying network's resolvers (read from the
physical network's
LinkPropertiesbeforeestablish()), through UDP sockets the serviceprotect()ed and handed over as fds, so they never loop into the VPN even under a wide route.
- The answer is written back into the tun as a UDP packet from
<proxy>:53with the client's original DNS id restored (ids are rewritten per in-flight query so one upstream socket per family multiplexes every app's queries).
TCP DNS is not proxied: a SYN to <proxy>:53 (a stub retrying a truncated
answer) or <proxy>:853 (Android's opportunistic DNS-over-TLS probe) gets a
RST so the stub falls back at once; an answer that would not fit the tunnel MTU
is returned truncated (TC) with the question only. With no fallback resolvers
known, every name goes to the profile's servers — all-DNS-through-tunnel rather
than broken resolution.
This is a workaround for a platform limitation and is Android-only: the
dns_proxy config object is absent on every other platform, the OS keeps doing
conditional forwarding there (iOS via NEDNSSettings, desktop via OS resolver
configuration, see docs/Client-Split-DNS.md), and nothing in the forwarder
runs unless the object is present.
- Network change → disconnect. A
ConnectivityManagercallback records the physical networks present at connect time; a new Wi-Fi/Ethernet network, or the loss of a baseline network, tears the session down (cellular appearing next to Wi-Fi, or a lingering cellular link dropping while Wi-Fi stays, is ignored). Same policy as the Apple app. - Split-tunnel overlap refusal. Before connecting, the service enumerates
the on-link subnets of the current Wi-Fi/Ethernet networks and refuses to
start when a configured prefix overlaps one (see Split-Tunnel Overlap
Refusal in
docs/Architecture.md). - Secrets. The shared key list and each profile's own copy of its auth key
and relay token are AES-GCM-encrypted under an
AndroidKeyStorekey in a privateSharedPreferencesfile (the Keychain's counterpart). Public keys are re-derived from the secret on load, never stored.
# Android NDK (r28+) via the SDK's ndk/<version>, ANDROID_NDK_HOME, or sysroot mode
cargo install cargo-ndk
./build-android.sh # release, ABIs: arm64-v8a armeabi-v7a x86_64
ABIS="armeabi-v7a" ./build-android.sh debugOutput: dist/android/jniLibs/<abi>/libezvpn.so and
dist/android/libezvpn-android.zip. The release workflow publishes the zip as
a release asset; the app's Gradle build downloads it by tag + sha256
(scripts/bump-jnilibs.sh <tag> in the app repo pins a new one). For local FFI
development the app links ../ezvpn/dist/android/jniLibs directly when
EZVPN_LOCAL_JNILIBS=1.
Hosts without an official NDK (Google ships Linux NDKs for x86_64 only): copy
any NDK's toolchains/llvm/prebuilt/*/sysroot plus its
lib/clang/<ver>/lib/linux/<arch>/libunwind.a files into
<sysroot>/usr/lib/<triple>/, and set EZVPN_NDK_SYSROOT to drive the system
clang + lld against it. Apple-silicon Macs have a native NDK and use the
default cargo ndk path.
Verify on the host with the Android target's clippy (the module is cfg-gated,
so the Linux host clippy never type-checks it):
cargo ndk -t arm64-v8a --platform 29 clippy --lib -- -D warnings