From 8eb28d8767177f499d13b2abd5245b6165bb9dd9 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Fri, 21 Aug 2026 15:26:46 -0700 Subject: [PATCH 1/2] Verify custom-relay /healthz with webpki roots, not the platform verifier The on-demand connection-path snapshot probes each custom relay's /healthz with reqwest. On the `rustls-no-provider` feature reqwest builds its TLS verifier from rustls-platform-verifier, which on Android requires a JNI initialisation the host app never performs and otherwise panics while the client is being built ("Expect rustls-platform-verifier to be initialized"). That panic unwinds into the JNI `connPath` entry point and aborts the whole app process as soon as a profile with custom relays opens the sheet. Hand reqwest a preconfigured rustls ClientConfig instead: ring plus the embedded Mozilla roots, which is exactly what iroh verifies the relay's own TLS with (its platform-verifier feature is off), so a relay the tunnel trusts is one the health check trusts. The platform verifier is no longer built on any platform, and the process-wide provider install is gone with it. Also drop the Nexus 7 references from the Android build notes: the app is arm64-only now. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QedowxAkQAV7HoeJu8ZYZC --- Cargo.lock | 1 + Cargo.toml | 6 +++++- build-android.sh | 4 ++-- docs/Android-App.md | 22 ++++++++++++---------- src/transport/paths.rs | 36 ++++++++++++++++++++++++------------ 5 files changed, 44 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 04be2d1..fe8b722 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -960,6 +960,7 @@ dependencies = [ "toml", "tun", "url", + "webpki-roots", "windows-sys 0.61.2", ] diff --git a/Cargo.toml b/Cargo.toml index 7f10e41..ddef6e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,9 +42,13 @@ libc = "0.2" rand = "0.10" # Custom-relay `/healthz` checks (see `transport::paths`). `rustls-no-provider` # matches the exact reqwest feature iroh already enables, so this adds no new TLS -# backend (no openssl / aws-lc); the process installs a ring crypto provider. +# backend (no openssl / aws-lc); the probe hands reqwest a ring + webpki-roots +# config so the platform verifier (unusable on Android) is never built. reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +# Embedded Mozilla roots for the `/healthz` probe TLS config (same roots iroh +# verifies the relay connection with; see `transport::paths::healthz_tls_config`). +webpki-roots = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.11" diff --git a/build-android.sh b/build-android.sh index acce12b..85ef1dd 100755 --- a/build-android.sh +++ b/build-android.sh @@ -34,8 +34,8 @@ set -euo pipefail PROFILE="${1:-release}" # arm64-v8a is every current phone and the arm64 Android VM used for -# development/testing; armeabi-v7a covers 32-bit-only devices (e.g. the 2013 -# Nexus 7 that only gets the signed release APK); x86_64 is the stock emulator. +# development/testing; armeabi-v7a covers 32-bit-only devices; x86_64 is the +# stock emulator. ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}" # Minimum Android API level the .so links against (must be <= the app's # minSdk). 29 = Android 10. diff --git a/docs/Android-App.md b/docs/Android-App.md index a8d438a..85a6e15 100644 --- a/docs/Android-App.md +++ b/docs/Android-App.md @@ -8,7 +8,8 @@ debug/release APK built from source. The Android client is split across two repositories: - **This repo (`ezvpn`)** — the Rust core, packaged as `libezvpn.so` per ABI - (`arm64-v8a`, `armeabi-v7a`, `x86_64`) plus a small JNI surface. This is + (`arm64-v8a`, `armeabi-v7a`, `x86_64`; the app packages only `arm64-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`](https://github.com/flexaccessdev/ezvpn-android)** — the @@ -33,8 +34,7 @@ In scope: - **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 `VpnService` cannot run on the JVM); the physical device - only receives the signed release APK. + Android emulator (a `VpnService` cannot run on the JVM). Out of scope (by design): @@ -53,7 +53,7 @@ tun interface, addresses, routes, DNS, and MTU; Rust is handed the fd. |---|---|---| | 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 | no `excludeRoute` before API 33: the app *subtracts* the bypass `/32`s and `/128`s from the routed prefixes (`tunnelcore` `RouteMath.subtract`) and installs the remainder | +| Underlay bypass | `BypassRouteManager` host routes | `Builder.excludeRoute` for the bypass `/32`s and `/128`s 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) | @@ -104,7 +104,7 @@ The config and result JSON are the shapes documented in 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) + TunnelPlan.from(netConfig) (tunnelcore: routes, bypass, DNS, families) Builder…establish() → fd EzvpnNative.run(handle, fd) ─▶ data loop (+ DNS forwarder) state: StateFlow ◀──────── onConnected / onDisconnected @@ -123,11 +123,13 @@ handshake returns. No foreground notification is used: the system binds the 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. Android's `VpnService.Builder` has no -`excludeRoute` before API 33 (the app's `minSdk` is 29), 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) and installs the result. -The detail screen shows both the installed routes and the bypass set. +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 diff --git a/src/transport/paths.rs b/src/transport/paths.rs index b8ddc52..929cfe3 100644 --- a/src/transport/paths.rs +++ b/src/transport/paths.rs @@ -9,6 +9,7 @@ use futures::future::join_all; use iroh::TransportAddr; use iroh::endpoint::{Connection, PathList}; use serde::{Deserialize, Serialize}; +use std::sync::Arc; use std::time::Duration; use tokio::task::JoinHandle; @@ -182,16 +183,24 @@ pub async fn connection_snapshot( } } -/// Install a process-wide ring crypto provider for `reqwest` (built with -/// `rustls-no-provider`, which resolves the provider via -/// [`rustls::crypto::CryptoProvider::get_default`]). Idempotent and safe to call -/// from any thread; a competing install by another component is fine. -fn ensure_crypto_provider() { - use std::sync::Once; - static ONCE: Once = Once::new(); - ONCE.call_once(|| { - let _ = rustls::crypto::ring::default_provider().install_default(); - }); +/// TLS client config for the `/healthz` probe: ring plus the embedded Mozilla +/// roots, i.e. exactly what iroh uses to verify the relay's own TLS (its +/// `platform-verifier` feature is off), so a relay the tunnel trusts is one the +/// health check trusts. reqwest's own default on the `rustls-no-provider` +/// feature is `rustls-platform-verifier`, which on Android needs a JNI +/// initialisation the host app never performs and otherwise panics with +/// "Expect rustls-platform-verifier to be initialized" while the client is being +/// built — a panic that aborts the process at the FFI boundary. Handing reqwest +/// a preconfigured config keeps that verifier out entirely. +fn healthz_tls_config() -> rustls::ClientConfig { + let roots = rustls::RootCertStore { + roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(), + }; + rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions() + .expect("ring supports the default TLS protocol versions") + .with_root_certificates(roots) + .with_no_client_auth() } /// Probe the `/healthz` endpoint of every configured custom relay in parallel. @@ -201,8 +210,11 @@ pub async fn probe_custom_relay_health(relay_config: &RelayConfig) -> Vec client, Err(e) => { let err = format!("failed to build health-check client: {e}"); From ac80a02ebd6ab56a2a522431a1c310118f1e202a Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Fri, 21 Aug 2026 15:26:46 -0700 Subject: [PATCH 2/2] Bump version to 0.0.43 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QedowxAkQAV7HoeJu8ZYZC --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fe8b722..6d1aa0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -922,7 +922,7 @@ dependencies = [ [[package]] name = "ezvpn" -version = "0.0.42" +version = "0.0.43" dependencies = [ "android_logger", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index ddef6e9..2ffe4f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ezvpn" -version = "0.0.42" +version = "0.0.43" edition = "2024" description = "IP-over-QUIC VPN tunnel via iroh P2P" readme = "README.md"