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
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions build-android.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 12 additions & 10 deletions docs/Android-App.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):

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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
36 changes: 24 additions & 12 deletions src/transport/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand All @@ -201,8 +210,11 @@ pub async fn probe_custom_relay_health(relay_config: &RelayConfig) -> Vec<Custom
if urls.is_empty() {
return Vec::new();
}
ensure_crypto_provider();
let client = match reqwest::Client::builder().timeout(HEALTHZ_TIMEOUT).build() {
let client = match reqwest::Client::builder()
.tls_backend_preconfigured(healthz_tls_config())
.timeout(HEALTHZ_TIMEOUT)
.build()
{
Ok(client) => client,
Err(e) => {
let err = format!("failed to build health-check client: {e}");
Expand Down
Loading