diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddc6c9c..fb55d0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: run: cargo test # The host clippy compiles the Apple-gated modules (src/ffi.rs, - # src/tunnel/ios.rs), which Linux CI never type-checks. + # src/tunnel/mobile.rs), which Linux CI never type-checks. macos: runs-on: macos-latest steps: @@ -43,6 +43,27 @@ jobs: - name: Build Apple xcframework + header (debug) run: ./build-apple.sh debug + # Clippy for the Android-gated modules (src/ffi_android.rs and the Android + # branches of src/ffi.rs / src/tunnel/mobile.rs), plus a verify-only build of + # libezvpn.so for every release ABI (debug profile for speed), mirroring the + # release workflow's build-android-lib job. Nothing is published. + android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + - name: Install cargo-ndk + run: cargo install cargo-ndk --locked + - name: Clippy (arm64-v8a) + run: | + export ANDROID_NDK_HOME="${ANDROID_NDK_LATEST_HOME:?runner image has no Android NDK}" + rustup target add aarch64-linux-android + cargo ndk -t arm64-v8a --platform 29 clippy --lib -- -D warnings + - name: Build libezvpn.so (debug) + run: | + export ANDROID_NDK_HOME="${ANDROID_NDK_LATEST_HOME:?runner image has no Android NDK}" + ABIS="arm64-v8a armeabi-v7a x86_64" ./build-android.sh debug + # The host clippy compiles the Windows-gated module (src/ffi_windows.rs). windows: runs-on: windows-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ee57b9..cbe4014 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -135,6 +135,27 @@ jobs: name: release-apple path: dist/apple/libezvpn-apple.xcframework.zip + build-android-lib: + needs: setup + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + - name: Install cargo-ndk + run: cargo install cargo-ndk --locked + - name: Build libezvpn.so per ABI + zip + # build-android.sh installs the Android Rust targets if missing, builds + # one libezvpn.so per ABI with the runner's preinstalled NDK, and stages + # dist/android/jniLibs + libezvpn-android.zip. That zip becomes the + # release asset the Android app's Gradle build downloads by default. + run: | + export ANDROID_NDK_HOME="${ANDROID_NDK_LATEST_HOME:?runner image has no Android NDK}" + ./build-android.sh release + - uses: actions/upload-artifact@v4 + with: + name: release-android + path: dist/android/libezvpn-android.zip + build-windows: needs: setup if: needs.setup.outputs.is_prerelease == 'false' @@ -175,8 +196,8 @@ jobs: path: dist/windows/ezvpn-windows.dll.zip publish-release: - needs: [setup, build-linux, build-macos, build-apple, build-windows, build-windows-lib] - if: always() && needs.setup.result == 'success' && needs.build-linux.result == 'success' && needs.build-macos.result == 'success' && needs.build-apple.result == 'success' && needs.build-windows-lib.result == 'success' && (needs.build-windows.result == 'success' || needs.build-windows.result == 'skipped') + needs: [setup, build-linux, build-macos, build-apple, build-android-lib, build-windows, build-windows-lib] + if: always() && needs.setup.result == 'success' && needs.build-linux.result == 'success' && needs.build-macos.result == 'success' && needs.build-apple.result == 'success' && needs.build-android-lib.result == 'success' && needs.build-windows-lib.result == 'success' && (needs.build-windows.result == 'success' || needs.build-windows.result == 'skipped') runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 diff --git a/CLAUDE.md b/CLAUDE.md index 4d081b7..860ab9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,3 +21,15 @@ address lookup, the per-relay startup probe, relay auth tokens, relay self-hosting — is documented once in https://github.com/flexaccessdev/iroh-common-architecture. Do not duplicate it in this repo; update it there and link to it. + +The mobile apps live in sibling repos: `../ezvpn-apple` (Swift, see +`docs/Apple-App.md`) and `../ezvpn-android` (Kotlin, see `docs/Android-App.md`). +Both drive the fd-based `MobileSession` in `src/tunnel/mobile.rs` through +`src/ffi.rs`; Android adds the JNI layer `src/ffi_android.rs`, whose symbol +names are bound to the Kotlin class `dev.flexaccess.ezvpn.EzvpnNative` — do not +rename either side alone. The in-tunnel split-DNS forwarder +(`src/tunnel/dns_proxy.rs`) is an Android-only workaround for the platform +having no per-domain VPN DNS; every other platform keeps OS-level conditional +forwarding, so never wire it up elsewhere. Verify Android changes with +`cargo ndk -t arm64-v8a --platform 29 clippy --lib -- -D warnings` (the module +is cfg-gated out of the host clippy). diff --git a/Cargo.lock b/Cargo.lock index c5a6dbc..4855acc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,23 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter 0.1.4", + "log", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -824,6 +841,16 @@ dependencies = [ "syn", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + [[package]] name = "env_filter" version = "2.0.0" @@ -842,7 +869,7 @@ checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", - "env_filter", + "env_filter 2.0.0", "jiff", "log", ] @@ -895,8 +922,9 @@ dependencies = [ [[package]] name = "ezvpn" -version = "0.0.40" +version = "0.0.41" dependencies = [ + "android_logger", "anyhow", "base64", "bytes", @@ -911,11 +939,13 @@ dependencies = [ "if-addrs", "ipnet", "iroh", + "jni 0.21.1", "known-folders", "libc", "log", "mock_instant", "n0-watcher", + "ndk-context", "noq-proto", "rand", "rand_chacha", diff --git a/Cargo.toml b/Cargo.toml index 744fd62..f51efd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ezvpn" -version = "0.0.40" +version = "0.0.41" edition = "2024" description = "IP-over-QUIC VPN tunnel via iroh P2P" readme = "README.md" @@ -12,6 +12,8 @@ name = "ezvpn" # staticlib: linked into Apple Network Extensions (iOS and native macOS). # cdylib: `ezvpn.dll`, P/Invoked by the native Windows GUI (`ezvpn-windows`) # via the C FFI in `src/ffi_windows.rs`. Built by `build-windows.ps1`. +# Also `libezvpn.so` for Android, loaded by the `ezvpn-android` app through +# the JNI surface in `src/ffi_android.rs`. Built by `build-android.sh`. crate-type = ["rlib", "staticlib", "cdylib"] [dependencies] @@ -75,11 +77,20 @@ windows-sys = { version = "0.61", features = [ ] } # Desktop-only: on-link subnet enumeration for the connect-time split-tunnel -# overlap refusal (src/net/local_networks.rs). iOS has its own Swift port of -# this check in ezvpn-apple TunnelCore. -[target.'cfg(not(target_os = "ios"))'.dependencies] +# overlap refusal (src/net/local_networks.rs). The mobile apps carry their own +# ports of this check (ezvpn-apple TunnelCore, ezvpn-android tunnelcore). +[target.'cfg(not(any(target_os = "ios", target_os = "android")))'.dependencies] if-addrs = "0.15" +# Android-only: the JNI bridge for the `ezvpn-android` VpnService, and logcat +# output for `log` (stderr is discarded on Android). +[target.'cfg(target_os = "android")'.dependencies] +jni = "0.21" +android_logger = "0.15" +# iroh's Android DNS/interface discovery (hickory-resolver, netwatch) reaches +# the JVM through this global; the app registers its Context via `EzvpnNative.init`. +ndk-context = "0.1" + [dev-dependencies] mock_instant = "0.6" rand_chacha = "0.10" diff --git a/README.md b/README.md index 51bb56b..dd54d19 100644 --- a/README.md +++ b/README.md @@ -107,9 +107,10 @@ that bridges two sites with stable subnets, WireGuard is the right choice, not client do one thing — tunneling. Firewall, forwarding/NAT, and DNS configuration (e.g. conditional forwarding for an internal zone, see [docs/Client-Split-DNS.md](docs/Client-Split-DNS.md)) are expected to be -managed outside the VPN connector. The iOS app is the one deliberate -exception: it applies split DNS in-app (`NEDNSSettings`), because on iOS -that is the only way to accomplish it. +managed outside the VPN connector. The mobile apps are the deliberate +exception: iOS applies split DNS in-app (`NEDNSSettings`) because that is the +only way to accomplish it there, and Android forwards DNS in-tunnel because the +platform has no per-domain DNS for VPNs at all. Also do not use `ezvpn` when the goal is anonymity. iroh's relays can see relay metadata when they are involved, even though the VPN payload remains encrypted. @@ -622,9 +623,11 @@ Two things you do **not** need firewall rules for: On the client side, DNS is likewise managed outside the tunnel: to resolve an internal zone through a resolver reachable over the VPN, set OS-level conditional forwarding on each client — see -[docs/Client-Split-DNS.md](docs/Client-Split-DNS.md). The exception is iOS, -where the app applies DNS conditional forwarding in-tunnel itself (see -[docs/Apple-App.md](docs/Apple-App.md)). +[docs/Client-Split-DNS.md](docs/Client-Split-DNS.md). The exceptions are the +mobile apps: iOS applies DNS conditional forwarding through `NEDNSSettings` +(see [docs/Apple-App.md](docs/Apple-App.md)), and Android — which has no +split-DNS API for VPNs — runs an in-tunnel forwarder in the Rust core (see +[docs/Android-App.md](docs/Android-App.md)). ## Protocol, MTU, and GSO @@ -778,6 +781,20 @@ Swift app consumes via a Swift package binary target. See [`docs/Apple-App.md`](docs/Apple-App.md) for scope, how it reuses the core, the C interface, and build steps. +## Android App + +[`ezvpn-android`](https://github.com/flexaccessdev/ezvpn-android) is a native +Kotlin/Compose client for Android that connects to an `ezvpn` server built from +this repo (dual-stack split tunnel, optional tunnel DNS including split-DNS +match domains via an in-tunnel forwarder, always-on support; no full tunnel or +Play Store distribution). The tunnel runs in a `VpnService` that is handed the +OS tun fd, like the Apple extension. The Rust core builds into one +`libezvpn.so` per ABI here (`./build-android.sh`, released as +`libezvpn-android.zip`), which the app loads through a small JNI surface. + +See [`docs/Android-App.md`](docs/Android-App.md) for scope, how it reuses the +core, the JNI interface, the split-DNS forwarder, and build steps. + ## Windows App [`ezvpn-windows`](https://github.com/flexaccessdev/ezvpn-windows) is a native diff --git a/build-android.sh b/build-android.sh new file mode 100755 index 0000000..b6fa7c3 --- /dev/null +++ b/build-android.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# +# Build libezvpn.so for Android and stage it in the jniLibs layout the sibling +# Android app (../ezvpn-android) consumes: +# +# dist/android/jniLibs//libezvpn.so (one per ABI below) +# dist/android/libezvpn-android.zip (the jniLibs tree, for releases) +# +# The app loads it through the JNI surface in src/ffi_android.rs. By default the +# Android project downloads the pinned release zip; for local FFI dev it links +# this dist/android tree when EZVPN_LOCAL_JNILIBS is exactly 1 (see that repo's +# README). This script only produces dist/android; it does not write into +# ../ezvpn-android. +# +# Requires the Android NDK (ANDROID_NDK_HOME, or ANDROID_HOME/ndk/) and +# `cargo ndk` (cargo install cargo-ndk); the Rust targets are added on demand. +# +# Hosts without an NDK (Google ships Linux NDKs for x86_64 only, so e.g. an +# arm64 Linux build box has none): copy the `toolchains/llvm/prebuilt/*/sysroot` +# directory of any NDK there, add its per-arch `libunwind.a` (see the check +# below), and set EZVPN_NDK_SYSROOT to it. The script then drives the system +# `clang` + `lld` (same LLVM major as the NDK works best; Debian 13's clang 19 +# matches NDK r28) against that sysroot instead of cargo-ndk — the sysroot is +# host-independent (headers + bionic stubs only). Apple-silicon Macs need none +# of this: the macOS NDK is universal and cargo-ndk works natively. +# +# Usage: +# ./build-android.sh # release build (default), all ABIs +# ./build-android.sh debug # debug build (faster compile, huge .so) +# ABIS="armeabi-v7a" ./build-android.sh # override the ABI list +# EZVPN_NDK_SYSROOT=~/ndk-sysroot ./build-android.sh # no-NDK host +# +set -euo pipefail + +PROFILE="${1:-release}" +# arm64-v8a is every current phone; armeabi-v7a covers 32-bit-only devices +# (e.g. the 2013 Nexus 7 used for on-device testing); x86_64 is the 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. +ANDROID_API="${ANDROID_API:-29}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +case "$PROFILE" in + release) CARGO_FLAGS="--release" ;; + debug) CARGO_FLAGS="" ;; + *) echo "unknown profile '$PROFILE' (use 'release' or 'debug')" >&2; exit 1 ;; +esac + +SYSROOT="${EZVPN_NDK_SYSROOT:-}" +if [ -z "$SYSROOT" ]; then + if ! command -v cargo-ndk >/dev/null 2>&1; then + echo "cargo-ndk not found: install it with 'cargo install cargo-ndk'" >&2 + exit 1 + fi + # cargo-ndk finds the NDK via ANDROID_NDK_HOME / ANDROID_NDK_ROOT, or the + # newest one under ANDROID_HOME/ndk. Resolve the latter explicitly so the path + # used is printed and reproducible. + if [ -z "${ANDROID_NDK_HOME:-}" ] && [ -z "${ANDROID_NDK_ROOT:-}" ]; then + SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}" + if [ -d "$SDK/ndk" ]; then + ANDROID_NDK_HOME="$(ls -d "$SDK"/ndk/* | sort -V | tail -1)" + export ANDROID_NDK_HOME + fi + fi + echo "NDK: ${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}" +else + for tool in clang ld.lld llvm-ar; do + command -v "$tool" >/dev/null 2>&1 || { echo "$tool not found (needed with EZVPN_NDK_SYSROOT)" >&2; exit 1; } + done + # Rust's Android std links -lunwind, which the NDK keeps in its clang + # resource dir rather than the sysroot; a plain sysroot copy lacks it. Check + # the sysroot and libunwind.a for every ABI selected, not just one. + for abi in $ABIS; do + case "$abi" in + arm64-v8a) libdir="aarch64-linux-android"; clangarch="aarch64" ;; + armeabi-v7a) libdir="arm-linux-androideabi"; clangarch="arm" ;; + x86_64) libdir="x86_64-linux-android"; clangarch="x86_64" ;; + x86) libdir="i686-linux-android"; clangarch="i386" ;; + *) echo "unknown ABI '$abi'" >&2; exit 1 ;; + esac + [ -d "$SYSROOT/usr/lib/$libdir" ] || { + echo "EZVPN_NDK_SYSROOT=$SYSROOT does not look like an NDK sysroot (no usr/lib/$libdir for $abi)" >&2; exit 1; } + if [ ! -e "$SYSROOT/usr/lib/$libdir/libunwind.a" ]; then + cat >&2 </toolchains/llvm/prebuilt/*/lib/clang//lib/linux/$clangarch/libunwind.a -> $SYSROOT/usr/lib/$libdir/ +HINT + exit 1 + fi + done + echo "NDK sysroot: $SYSROOT (system $(clang --version | head -1))" +fi + +# ABI -> (Rust target, clang triple with API level, env-var suffix) +abi_target() { + case "$1" in + arm64-v8a) echo "aarch64-linux-android" ;; + armeabi-v7a) echo "armv7-linux-androideabi" ;; + x86_64) echo "x86_64-linux-android" ;; + x86) echo "i686-linux-android" ;; + *) echo "unknown ABI '$1'" >&2; exit 1 ;; + esac +} +abi_clang_triple() { + case "$1" in + arm64-v8a) echo "aarch64-linux-android${ANDROID_API}" ;; + armeabi-v7a) echo "armv7a-linux-androideabi${ANDROID_API}" ;; + x86_64) echo "x86_64-linux-android${ANDROID_API}" ;; + x86) echo "i686-linux-android${ANDROID_API}" ;; + esac +} + +for abi in $ABIS; do + target="$(abi_target "$abi")" + if ! rustup target list --installed | grep -q "^${target}$"; then + echo "Installing Rust target ${target}..." + rustup target add "$target" + fi +done + +DIST="$SCRIPT_DIR/dist/android" +JNILIBS="$DIST/jniLibs" +rm -rf "$JNILIBS" +mkdir -p "$JNILIBS" + +if [ -z "$SYSROOT" ]; then + # shellcheck disable=SC2086 + cargo ndk \ + $(for abi in $ABIS; do printf -- '-t %s ' "$abi"; done) \ + --platform "$ANDROID_API" \ + -o "$JNILIBS" \ + build --lib ${CARGO_FLAGS} + # cargo-ndk stages every cdylib it finds; only libezvpn.so is wanted. + find "$JNILIBS" -type f ! -name 'libezvpn.so' -delete +else + # No cargo-ndk: do what it does by hand. Per target, a clang wrapper that + # pins --target/--sysroot/lld serves as both the Rust linker and the `cc` + # crate's C compiler (ring and friends), with llvm-ar as the archiver. The + # 16 KiB max-page-size matches cargo-ndk's default (required by Android 15+ + # on arm64). + WRAP_DIR="$SCRIPT_DIR/target/android-clang-wrappers" + mkdir -p "$WRAP_DIR" + case "$PROFILE" in release) OUT_SUBDIR="release" ;; *) OUT_SUBDIR="debug" ;; esac + for abi in $ABIS; do + target="$(abi_target "$abi")" + triple="$(abi_clang_triple "$abi")" + wrapper="$WRAP_DIR/$triple-clang" + printf '#!/bin/sh\nexec clang --target=%s --sysroot=%s -fuse-ld=lld -Wl,-z,max-page-size=16384 "$@"\n' \ + "$triple" "$SYSROOT" > "$wrapper" + chmod +x "$wrapper" + env_suffix="$(echo "$target" | tr 'a-z-' 'A-Z_')" + cc_suffix="$(echo "$target" | tr '-' '_')" + echo "Building libezvpn.so [$PROFILE] for $target via $wrapper ..." + # shellcheck disable=SC2086 + env "CARGO_TARGET_${env_suffix}_LINKER=$wrapper" \ + "CC_${cc_suffix}=$wrapper" \ + "AR_${cc_suffix}=llvm-ar" \ + "RANLIB_${cc_suffix}=llvm-ranlib" \ + cargo build --lib ${CARGO_FLAGS} --target "$target" + mkdir -p "$JNILIBS/$abi" + cp "$SCRIPT_DIR/target/$target/$OUT_SUBDIR/libezvpn.so" "$JNILIBS/$abi/libezvpn.so" + done +fi + +echo "Creating libezvpn-android.zip ..." +rm -f "$DIST/libezvpn-android.zip" +(cd "$DIST" && zip -qr libezvpn-android.zip jniLibs) + +echo "Staged: $JNILIBS" +find "$JNILIBS" -name 'libezvpn.so' -exec ls -la {} \; +echo " $DIST/libezvpn-android.zip" +echo +echo "For local Android FFI dev, build the app against this tree with:" +echo " cd ../ezvpn-android" +echo " EZVPN_LOCAL_JNILIBS=1 ./gradlew :app:installDebug" +echo "Done." diff --git a/docs/Android-App.md b/docs/Android-App.md new file mode 100644 index 0000000..427bc7c --- /dev/null +++ b/docs/Android-App.md @@ -0,0 +1,218 @@ +# Android App + +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 as `libezvpn.so` per ABI + (`arm64-v8a`, `armeabi-v7a`, `x86_64`) 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 + Gradle project: a Compose app, the `VpnService`, and a pure-Kotlin + `tunnelcore` module (IP/CIDR math, profile model and validation, the + interface plan) with JVM unit tests. Build/install/run instructions live in + that repo's README. + +## Scope + +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](#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. +- **Real-device testing** — developed against a physical device over adb. + +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. + +## How it reuses the core + +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 | 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 | +| 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 object + `dev.flexaccess.ezvpn.EzvpnNative` (the symbol names encode that class name; + it must not move even if the `applicationId` changes). +- `src/tunnel/dns_proxy.rs` — the Android-only split-DNS forwarder. +- `src/net/device.rs` — `TunDevice::from_raw_fd` for Android (the `tun` crate's + `raw_fd` configuration; no offload — `VpnService` tun devices have none). +- `build-android.sh` — builds one `libezvpn.so` per ABI with `cargo ndk` and + stages `dist/android/jniLibs//libezvpn.so` plus + `dist/android/libezvpn-android.zip` (the release asset the app downloads by + URL + sha256). + +## JNI interface + +`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`](../ios/ezvpn.h), plus one Android-only config object: + +```json +"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). + +## Underlay bypass in the Android app + +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. + +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. + +## Split DNS on Android + +`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: + +1. It tells the OS the VPN's DNS server is a **proxy address** inside the + tunnel — `198.18.0.53` and/or `fd7e: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. +2. The data path intercepts UDP packets to `:53` before 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 `LinkProperties` before `establish()`), through UDP + sockets the service `protect()`ed and handed over as fds, so they never + loop into the VPN even under a wide route. +3. The answer is written back into the tun as a UDP packet from `:53` + with 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 `:53` (a stub retrying a truncated +answer) or `: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 changes, secrets, local-network refusal + +- **Network change → disconnect.** A `ConnectivityManager` callback 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 `AndroidKeyStore` key in a + private `SharedPreferences` file (the Keychain's counterpart). Public keys + are re-derived from the secret on load, never stored. + +## Building + +```bash +# Android NDK (r28+) via the SDK's ndk/, 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 debug +``` + +Output: `dist/android/jniLibs//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 ` 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//lib/linux//libunwind.a` files into +`/usr/lib//`, 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): + +```bash +cargo ndk -t arm64-v8a --platform 29 clippy --lib -- -D warnings +``` diff --git a/docs/Apple-App.md b/docs/Apple-App.md index 3352f2d..70b4072 100644 --- a/docs/Apple-App.md +++ b/docs/Apple-App.md @@ -61,8 +61,9 @@ The handshake (`perform_handshake`) and data-stream loop Key source in this repo: -- `src/tunnel/ios.rs` — `IosSession` (connect → handshake → run) and the - network-config it returns to the extension. +- `src/tunnel/mobile.rs` — `MobileSession` (connect → handshake → run) and the + network-config it returns to the extension (shared with the Android + `VpnService`, see `docs/Android-App.md`). - `src/ffi.rs` — the C entry points. - `src/net/device.rs` — `TunDevice::from_raw_fd` and the shared Darwin fd I/O. - `ios/ezvpn.h` — the C header (also the authoritative JSON config/result shapes). diff --git a/docs/Architecture.md b/docs/Architecture.md index 378a81e..9117536 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -277,9 +277,26 @@ When both `network` and `network6` are configured, each client normally receives | Windows | `wintun.dll` | `netsh interface route` (VPN routes); `NetTCPIP` PowerShell cmdlets `Find-NetRoute`/`New-NetRoute` (underlay bypass host routes) | Administrator | The GUI clients build on this same per-platform TUN/route code. On Apple the OS -hands the extension a `utun` fd (`docs/Apple-App.md`); on Windows the native GUI -P/Invokes `ezvpn.dll`, which drives the desktop `VpnClient` (wintun + `netsh`) -in-process (`docs/Windows-App.md`). +hands the extension a `utun` fd (`docs/Apple-App.md`); on Android the +`VpnService` hands the core its `establish()`ed tun fd and owns routes/DNS +itself (`docs/Android-App.md`); on Windows the native GUI P/Invokes +`ezvpn.dll`, which drives the desktop `VpnClient` (wintun + `netsh`) in-process +(`docs/Windows-App.md`). + +#### Android Split DNS (In-Tunnel Forwarder) + +Android's `VpnService` has no per-domain DNS: `addDnsServer` replaces the +resolvers for every name, and an app cannot bind port 53. The Android app +therefore implements match domains the way Tailscale's MagicDNS does: the VPN's +DNS server is a proxy address routed into the tun (`198.18.0.53` / +`fd7e:7a00:d45::53`), and the client data path (`src/tunnel/dns_proxy.rs`) +intercepts UDP/53 to it, forwards matched names to the tunnel's resolvers +(ordinary sockets — the OS routes them back through the tunnel) and everything +else to the underlying network's resolvers through `protect()`ed sockets the +service hands over, rewriting DNS ids per in-flight query and writing answers +back into the tun. TCP SYNs to the proxy's 53/853 get a RST; oversized answers +are truncated (TC). The hook is a `None` on every non-Android caller of +`run_tunnel`; the design and limits are in `docs/Android-App.md`. ### Split-Tunnel Overlap Refusal (Client) diff --git a/docs/Windows-App.md b/docs/Windows-App.md index 48abd64..551e31b 100644 --- a/docs/Windows-App.md +++ b/docs/Windows-App.md @@ -49,7 +49,7 @@ There is **no Windows equivalent of `NEPacketTunnelProvider`** that hands an app a ready TUN fd. So the Windows FFI wraps the desktop [`VpnClient`](../src/tunnel/client.rs) — which already creates the wintun adapter, installs routes, auto-reconnects, and publishes a status snapshot on -Windows — instead of the slim fd-driven `IosSession`. That also means the FFI is +Windows — instead of the slim fd-driven `MobileSession`. That also means the FFI is a *start / status / stop* shape rather than *connect / run(fd) / stop*, and the GUI reads status **in-process** rather than over the named-pipe control endpoint. diff --git a/ios/ezvpn.h b/ios/ezvpn.h index deefdc7..5dbded5 100644 --- a/ios/ezvpn.h +++ b/ios/ezvpn.h @@ -76,6 +76,9 @@ int ezvpn_client_public_key(const char *secret_key, char *out_buf, size_t out_le * auth_key is the client's ed25519 secret key; its public half must be on * the server's authorized_keys file. It and server_node_id are required; * relay_urls, relay_auth_token, routes, and routes6 are optional. + * An optional "dns_proxy" object is accepted only by the Android build (the + * in-tunnel split-DNS forwarder, see docs/Android-App.md); Apple callers + * must not send it. * relay_auth_token is the shared bearer token sent to the custom relays as * "Authorization: Bearer "; it is valid ONLY together with relay_urls * and is rejected with the default relays. diff --git a/src/config/file_config.rs b/src/config/file_config.rs index a724708..07c1d0e 100644 --- a/src/config/file_config.rs +++ b/src/config/file_config.rs @@ -281,10 +281,10 @@ pub fn expand_tilde(path: &Path) -> PathBuf { path.to_path_buf() } -// On-disk TOML loading is desktop-only (the iOS runtime config arrives as JSON -// through the FFI; the default paths resolve via `crate::runtime::config_dir`, -// a module not built on iOS). -#[cfg(not(target_os = "ios"))] +// On-disk TOML loading is desktop-only (the mobile runtime config arrives as +// JSON through the FFI; the default paths resolve via +// `crate::runtime::config_dir`, a module not built on iOS/Android). +#[cfg(not(any(target_os = "ios", target_os = "android")))] fn load_config Deserialize<'de>>(path: &Path) -> Result { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read config file: {}", path.display()))?; @@ -295,17 +295,17 @@ fn load_config Deserialize<'de>>(path: &Path) -> Result { /// Default config path: the machine-global system config directory (see /// [`crate::runtime::config_dir`]), not a per-user home directory — `ezvpn` runs /// as root/LocalSystem. -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] fn default_vpn_server_config_path() -> PathBuf { crate::runtime::config_dir().join("vpn_server.toml") } -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] fn default_vpn_client_config_path() -> PathBuf { crate::runtime::config_dir().join("vpn_client.toml") } -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] pub fn load_vpn_server_config(path: Option<&Path>) -> Result { let config_path = match path { Some(p) => expand_tilde(p), @@ -314,7 +314,7 @@ pub fn load_vpn_server_config(path: Option<&Path>) -> Result { load_config(&config_path) } -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] pub fn load_vpn_client_config(path: Option<&Path>) -> Result { let config_path = match path { Some(p) => expand_tilde(p), diff --git a/src/ffi.rs b/src/ffi.rs index 856d381..a964f90 100644 --- a/src/ffi.rs +++ b/src/ffi.rs @@ -1,4 +1,6 @@ -//! C FFI surface for iOS and macOS Network Extension app extensions. +//! C FFI surface for the fd-based mobile clients: the iOS and macOS Network +//! Extension app extensions link it directly, and the Android JNI layer +//! ([`crate::ffi_android`]) wraps the same handle and lifecycle. //! //! The extension links `libezvpn.a` and drives the tunnel in three calls: //! @@ -6,7 +8,7 @@ //! connect, and handshake. Returns an opaque handle and writes the assigned //! network config (IPv4 and/or IPv6, as JSON) to the caller's buffer so the //! extension can build `NEPacketTunnelNetworkSettings`. -//! 2. [`ezvpn_run`] — hand back the `utun` fd (obtained after applying the +//! 2. [`ezvpn_run`] — hand back the tun fd (obtained after applying the //! network settings); spawns the data-stream loop on the embedded runtime. //! 3. [`ezvpn_stop`] — abort the loop, close the endpoint, free the handle. //! @@ -15,7 +17,7 @@ //! //! All functions are null-safe and never unwind across the FFI boundary (the //! release profile is `panic = "abort"`, so a panic terminates the extension -//! process rather than crossing into Swift). +//! process rather than crossing into Swift/Kotlin). //! //! ## Config JSON (input to `ezvpn_connect`) //! @@ -62,8 +64,11 @@ //! ``` use std::ffi::{CStr, c_char, c_int}; +use std::net::{IpAddr, SocketAddr, SocketAddrV4, SocketAddrV6}; use std::os::fd::{AsRawFd, BorrowedFd}; use std::ptr; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use ipnet::{Ipv4Net, Ipv6Net}; use serde::Deserialize; @@ -71,16 +76,28 @@ use serde::Deserialize; use crate::error::VpnResult; use crate::transport::endpoint::RelayConfig; use crate::transport::paths::{ConnPathKind, connection_snapshot}; -use crate::tunnel::ios::{IosConfig, IosSession}; +use crate::tunnel::dns_proxy::DnsProxyConfig; +use crate::tunnel::mobile::{MobileConfig, MobileSession}; -/// Opaque handle owned by the Swift side. Created by [`ezvpn_connect`], freed by +/// Callback run on the embedded runtime when the data loop started by +/// [`EzvpnHandle::run`] ends on its own (peer close, idle timeout, fatal I/O +/// error). Never invoked once [`EzvpnHandle::stop`] has been called — the +/// caller initiated that and needs no notification — even if the loop happens +/// to end on its own at the same moment. +pub(crate) type ExitHook = Box) + Send + 'static>; + +/// Opaque handle owned by the app side. Created by [`ezvpn_connect`], freed by /// [`ezvpn_stop`]. pub struct EzvpnHandle { runtime: tokio::runtime::Runtime, /// The connected session, taken by [`ezvpn_run`]. - session: Option, + session: Option, /// The running tunnel task, present after [`ezvpn_run`]. task: Option>>, + /// Set by [`EzvpnHandle::stop`] before the task is aborted; the task checks + /// it before running the [`ExitHook`], so a loop that ends concurrently + /// with `stop` stays silent as documented. + stopped: Arc, /// Clone of the live iroh connection, kept so [`ezvpn_conn_path`] can /// snapshot its paths on demand after `ezvpn_run` consumed the session. connection: iroh::endpoint::Connection, @@ -107,6 +124,93 @@ struct FfiConfig { /// IPv6 routed prefixes (CIDR strings). #[serde(default)] routes6: Vec, + /// Android only: the in-tunnel split-DNS forwarder. Absent (or null) on + /// every other platform, which get conditional forwarding from the OS. + #[serde(default)] + dns_proxy: Option, +} + +/// The `dns_proxy` object of the config JSON (see [`crate::tunnel::dns_proxy`]). +#[derive(Deserialize)] +struct FfiDnsProxy { + /// Proxy IP literals the app points the VPN's DNS at (≤ 1 per family). + addresses: Vec, + /// Domain suffixes resolved through the tunnel. + #[serde(default)] + match_domains: Vec, + /// The tunnel's resolver IP literals (port 53). + servers: Vec, + /// The underlying network's resolver IP literals (port 53); an IPv6 + /// link-local one carries its scope as `fe80::1%`. + #[serde(default)] + fallback_servers: Vec, + /// UDP socket fds the `VpnService` has `protect()`ed, at most one per + /// family, for the fallback upstreams. `dup`ed here; the app keeps and + /// closes its own. + #[serde(default)] + fallback_fds: Vec, +} + +/// Parse a resolver literal with an optional `%` suffix into a port-53 +/// socket address. +fn parse_resolver(raw: &str) -> Result { + let (host, scope) = match raw.split_once('%') { + Some((h, s)) => ( + h, + s.trim() + .parse::() + .map_err(|_| format!("invalid resolver scope in {raw:?} (expected a numeric interface index)"))?, + ), + None => (raw, 0), + }; + let ip: IpAddr = host + .trim() + .parse() + .map_err(|_| format!("invalid resolver address {raw:?}"))?; + Ok(match ip { + IpAddr::V4(_) if scope != 0 => { + return Err(format!("invalid resolver address {raw:?} (IPv4 addresses take no scope)")); + } + IpAddr::V4(v4) => SocketAddr::V4(SocketAddrV4::new(v4, crate::tunnel::dns_proxy::DNS_PORT)), + IpAddr::V6(v6) => SocketAddr::V6(SocketAddrV6::new(v6, crate::tunnel::dns_proxy::DNS_PORT, 0, scope)), + }) +} + +fn parse_dns_proxy(raw: FfiDnsProxy) -> Result { + let addresses = raw + .addresses + .iter() + .map(|a| a.trim().parse::().map_err(|_| format!("invalid dns_proxy address {a:?}"))) + .collect::, _>>()?; + if addresses.is_empty() { + return Err("dns_proxy.addresses must not be empty".to_string()); + } + let servers = raw.servers.iter().map(|s| parse_resolver(s)).collect::, _>>()?; + if servers.is_empty() { + return Err("dns_proxy.servers must not be empty".to_string()); + } + let fallback_servers = raw + .fallback_servers + .iter() + .map(|s| parse_resolver(s)) + .collect::, _>>()?; + let mut fallback_sockets = Vec::new(); + for fd in raw.fallback_fds { + // SAFETY: the app passes fds of sockets it owns and keeps open across + // this call; we only take our own dup. + let owned = unsafe { BorrowedFd::borrow_raw(fd) } + .try_clone_to_owned() + .map_err(|e| format!("cannot dup dns_proxy fallback fd {fd}: {e}"))?; + fallback_sockets.push(std::net::UdpSocket::from(owned)); + } + Ok(DnsProxyConfig { + addresses, + match_domains: raw.match_domains, + servers, + fallback_servers, + fallback_sockets, + } + .normalized()) } /// Parse CIDR strings into typed prefixes, failing on the first malformed entry @@ -121,19 +225,38 @@ where .collect() } +/// Default log filter for the mobile clients (overridable via `RUST_LOG` where +/// the platform lets the app set environment variables). +const DEFAULT_LOG_FILTER: &str = "info,iroh=warn,tracing=warn"; + /// Initialize logging. Safe to call multiple times; subsequent calls are no-ops. /// -/// Reads `RUST_LOG` (defaults to `info,iroh=warn,tracing=warn`). On iOS the output goes to stderr, -/// which the system captures into the unified log / Console. +/// Reads `RUST_LOG` (defaults to `info,iroh=warn,tracing=warn`). On Apple +/// platforms the output goes to stderr, which the system captures into the +/// unified log / Console. On Android stderr is discarded, so the output goes to +/// logcat under the tag `ezvpn` instead. /// /// # Safety /// No arguments; always safe to call. #[unsafe(no_mangle)] pub extern "C" fn ezvpn_init_logging() { - let _ = env_logger::Builder::from_env( - env_logger::Env::default().default_filter_or("info,iroh=warn,tracing=warn"), - ) - .try_init(); + #[cfg(target_os = "android")] + { + let filter = std::env::var("RUST_LOG").unwrap_or_else(|_| DEFAULT_LOG_FILTER.to_string()); + android_logger::init_once( + android_logger::Config::default() + .with_max_level(log::LevelFilter::Trace) + .with_tag("ezvpn") + .with_filter(android_logger::FilterBuilder::new().parse(&filter).build()), + ); + } + #[cfg(not(target_os = "android"))] + { + let _ = env_logger::Builder::from_env( + env_logger::Env::default().default_filter_or(DEFAULT_LOG_FILTER), + ) + .try_init(); + } } /// Generate a fresh client authentication keypair. Writes @@ -260,7 +383,10 @@ pub unsafe extern "C" fn ezvpn_connect( } } -fn connect_inner(json: &str) -> Result<(EzvpnHandle, String), String> { +/// The shared connect path behind [`ezvpn_connect`] and the Android JNI +/// `connect`: parse the config JSON, connect + handshake on a fresh runtime, and +/// render the network-config JSON. Errors are ready-to-display messages. +pub(crate) fn connect_inner(json: &str) -> Result<(EzvpnHandle, String), String> { let cfg: FfiConfig = serde_json::from_str(json).map_err(|e| format!("invalid config JSON: {e}"))?; @@ -268,12 +394,13 @@ fn connect_inner(json: &str) -> Result<(EzvpnHandle, String), String> { .map_err(|e| format!("{e:#}"))?; let client_key = crate::auth::ClientKey::from_secret_str(cfg.auth_key.trim()) .map_err(|e| format!("invalid auth key: {e:#}"))?; - let ios_config = IosConfig { + let ios_config = MobileConfig { server_node_id: cfg.server_node_id, client_key, relay_config: relay_config.clone(), routes: parse_routes::(&cfg.routes, "IPv4 route")?, routes6: parse_routes::(&cfg.routes6, "IPv6 route")?, + dns_proxy: cfg.dns_proxy.map(parse_dns_proxy).transpose()?, }; let runtime = tokio::runtime::Builder::new_multi_thread() @@ -282,7 +409,7 @@ fn connect_inner(json: &str) -> Result<(EzvpnHandle, String), String> { .map_err(|e| format!("failed to build tokio runtime: {e}"))?; let session = runtime - .block_on(IosSession::connect(&ios_config)) + .block_on(MobileSession::connect(ios_config)) .map_err(|e| format!("connect failed: {e}"))?; let net = session @@ -310,6 +437,7 @@ fn connect_inner(json: &str) -> Result<(EzvpnHandle, String), String> { runtime, session: Some(session), task: None, + stopped: Arc::new(AtomicBool::new(false)), connection, relay_config, }, @@ -355,28 +483,91 @@ pub unsafe extern "C" fn ezvpn_conn_path( return -1; } let handle = unsafe { &*handle }; - // The relay health check performs on-demand HTTP, so drive the async - // snapshot on the embedded runtime. Called from the extension's own thread - // (never a runtime worker), so `block_on` is safe and does not stall the - // running tunnel task. - let snapshot = handle - .runtime - .block_on(connection_snapshot(&handle.connection, &handle.relay_config)); - let paths: Vec<_> = snapshot.paths - .into_iter() - .map(|p| { - let kind = match p.kind { - ConnPathKind::Direct => "direct", - ConnPathKind::Relay => "relay", - ConnPathKind::Other => "other", - }; - serde_json::json!({ "kind": kind, "display": p.display, "selected": p.selected }) - }) - .collect(); - let json = serde_json::json!({ "paths": paths, "custom_relays": snapshot.custom_relays }).to_string(); + let json = handle.conn_path_json(); if write_cstr(out_buf, out_len, &json) { 1 } else { 0 } } +impl EzvpnHandle { + /// The [`ezvpn_conn_path`] JSON document for this session. + pub(crate) fn conn_path_json(&self) -> String { + // The relay health check performs on-demand HTTP, so drive the async + // snapshot on the embedded runtime. Called from the app's own thread + // (never a runtime worker), so `block_on` is safe and does not stall + // the running tunnel task. + let snapshot = self + .runtime + .block_on(connection_snapshot(&self.connection, &self.relay_config)); + let paths: Vec<_> = snapshot + .paths + .into_iter() + .map(|p| { + let kind = match p.kind { + ConnPathKind::Direct => "direct", + ConnPathKind::Relay => "relay", + ConnPathKind::Other => "other", + }; + serde_json::json!({ "kind": kind, "display": p.display, "selected": p.selected }) + }) + .collect(); + serde_json::json!({ "paths": paths, "custom_relays": snapshot.custom_relays }).to_string() + } + + /// The shared body of [`ezvpn_run`]: `dup` the tun fd synchronously, then + /// spawn the data loop on the embedded runtime. `on_exit`, when given, runs + /// on the runtime once the loop ends on its own (see [`ExitHook`]). + pub(crate) fn run(&mut self, tun_fd: c_int, on_exit: Option) -> Result<(), String> { + let Some(session) = self.session.take() else { + return Err("no pending session (already running or never connected)".to_string()); + }; + + // Take our own owned dup now, on the caller's thread, so the library + // holds a valid fd regardless of when the caller closes its copy. The + // dup is moved into the task and closed when the tunnel ends. + let owned_fd = match unsafe { BorrowedFd::borrow_raw(tun_fd) }.try_clone_to_owned() { + Ok(fd) => fd, + Err(e) => { + // Put the session back so the handle can still be stopped/freed. + self.session = Some(session); + return Err(format!("failed to dup tun fd: {e}")); + } + }; + + let stopped = self.stopped.clone(); + let task = self.runtime.spawn(async move { + // `owned_fd` is owned by this task and closed when it ends; `run` + // dups it again into the TunDevice, so our copy outlives that + // internal dup setup. + let result = session.run(owned_fd.as_raw_fd()).await; + drop(owned_fd); + // `stop` sets the flag before aborting, so a loop that ends on its + // own in the same instant still honors "stop never notifies". + if let Some(hook) = on_exit + && !stopped.load(Ordering::Acquire) + { + hook(result.as_ref().map(|_| ()).map_err(|e| e.to_string())); + } + result + }); + self.task = Some(task); + Ok(()) + } + + /// The shared body of [`ezvpn_stop`]: abort the loop (if any) and shut the + /// runtime down without blocking the caller. Consumes (frees) the handle. + pub(crate) fn stop(self: Box) { + // Silence the exit hook first: the abort below only lands at the + // task's next await point, and the loop may already be past its last. + self.stopped.store(true, Ordering::Release); + if let Some(task) = &self.task { + task.abort(); + } + // Drop any still-pending (never-run) session and shut the runtime down + // without blocking the caller; tasks are aborted above. + self.runtime.shutdown_background(); + // `self` (Box) drops here, freeing the allocation. + } +} + /// Start the tunnel data loop on `tun_fd` (the extension's `utun` fd). /// /// Spawns the loop on the embedded runtime and returns immediately: `0` on @@ -396,32 +587,13 @@ pub unsafe extern "C" fn ezvpn_run(handle: *mut EzvpnHandle, tun_fd: c_int) -> c return -1; } let handle = unsafe { &mut *handle }; - let Some(session) = handle.session.take() else { - return -1; - }; - - // Take our own owned dup now, on the caller's thread, so the library holds a - // valid fd regardless of when the caller closes its copy. The dup is moved - // into the task and closed when the tunnel ends. - let owned_fd = match unsafe { BorrowedFd::borrow_raw(tun_fd) }.try_clone_to_owned() { - Ok(fd) => fd, + match handle.run(tun_fd, None) { + Ok(()) => 0, Err(e) => { - log::error!("ezvpn_run: failed to dup utun fd: {e}"); - // Put the session back so the handle can still be stopped/freed. - handle.session = Some(session); - return -1; + log::error!("ezvpn_run: {e}"); + -1 } - }; - - let task = handle.runtime.spawn(async move { - // `owned_fd` is owned by this task and closed when it ends; `run` dups it - // again into the TunDevice, so our copy outlives that internal dup setup. - let result = session.run(owned_fd.as_raw_fd()).await; - drop(owned_fd); - result - }); - handle.task = Some(task); - 0 + } } /// Stop the tunnel and free the handle. @@ -437,14 +609,7 @@ pub unsafe extern "C" fn ezvpn_stop(handle: *mut EzvpnHandle) { if handle.is_null() { return; } - let handle = unsafe { Box::from_raw(handle) }; - if let Some(task) = &handle.task { - task.abort(); - } - // Drop any still-pending (never-run) session and shut the runtime down - // without blocking the caller; tasks are aborted above. - handle.runtime.shutdown_background(); - // `handle` (Box) drops here, freeing the allocation. + unsafe { Box::from_raw(handle) }.stop(); } /// Write `s` (always NUL-terminated) into the caller buffer. Returns `true` if @@ -457,7 +622,7 @@ fn write_cstr(buf: *mut c_char, len: usize, s: &str) -> bool { // Reserve one byte for the trailing NUL. let copy = bytes.len().min(len - 1); unsafe { - ptr::copy_nonoverlapping(bytes.as_ptr(), buf as *mut u8, copy); + ptr::copy_nonoverlapping(bytes.as_ptr(), buf.cast::(), copy); *buf.add(copy) = 0; } copy == bytes.len() diff --git a/src/ffi_android.rs b/src/ffi_android.rs new file mode 100644 index 0000000..746b9e4 --- /dev/null +++ b/src/ffi_android.rs @@ -0,0 +1,310 @@ +//! JNI surface for the Android app (`ezvpn-android`). +//! +//! Android has no C-callable app boundary: Kotlin reaches native code only +//! through JNI, so this module exposes the fd-based lifecycle of [`crate::ffi`] +//! as the `external fun`s of one Kotlin object, `dev.flexaccess.ezvpn.EzvpnNative` +//! (the JNI symbol names below encode that class; renaming it on either side +//! breaks the link at load time). It adds nothing of its own: every entry point +//! converts Java strings, delegates to the shared [`EzvpnHandle`] methods, and +//! converts the result back. The JSON config/result shapes are exactly the ones +//! documented in `ios/ezvpn.h` and [`crate::ffi`]. +//! +//! The `VpnService` plays the role the `NEPacketTunnelProvider` plays on Apple +//! platforms: it owns the tun interface, addresses, routes, and MTU +//! (`VpnService.Builder`), calls `connect` first to learn the assigned +//! addresses and bypass set, `establish()`es the interface, then hands the fd +//! to `run`. One Android-specific addition: `run` takes no callback argument, +//! but when the data loop ends on its own (peer close, idle timeout, fatal I/O +//! error) the library calls back into Kotlin — +//! `EzvpnNative.onTunnelExit(handle: Long, error: String?)` — so the service +//! can tear the interface down; a `stop` never triggers that callback. +//! +//! Handles cross the boundary as `jlong` (the raw `*mut EzvpnHandle`); `0` is +//! null. As with the C API, the Kotlin side must call `stop` exactly once per +//! successful `connect` and never use a handle after it. +//! +//! Never unwinds into the JVM: the release profile is `panic = "abort"`, so a +//! panic terminates the app process instead. + +use std::ffi::{c_int, c_void}; +use std::sync::OnceLock; + +use jni::JNIEnv; +use jni::objects::{GlobalRef, JClass, JObject, JObjectArray, JString, JValue}; +use jni::sys::{jint, jlong, jstring}; + +use crate::ffi::{EzvpnHandle, connect_inner, ezvpn_init_logging}; + +/// Set once `EzvpnNative.init` has registered the app context; later calls +/// are no-ops (ndk-context aborts on a second registration). +static ANDROID_CONTEXT: OnceLock<()> = OnceLock::new(); + +/// The JVM and a global ref to the `EzvpnNative` class, captured by `init` for +/// the exit callback. Process-lifetime statics (the class never unloads), so +/// the hook closure owns no JNI references of its own — dropping a `GlobalRef` +/// on a runtime thread that is not attached to the JVM would cost an +/// attach/detach round trip per session. +static JVM: OnceLock<(jni::JavaVM, GlobalRef)> = OnceLock::new(); + +/// `EzvpnNative.init(context: Context)`: one-time process setup, to be called +/// from `Application.onCreate` before anything else. Routes `log` output to +/// logcat (tag `ezvpn`) and registers the JVM + application context with +/// `ndk-context`, which iroh's dependencies (hickory-resolver's system DNS +/// lookup, netwatch's interface enumeration) use to reach +/// `ConnectivityManager` through JNI — without it the first connect aborts +/// the process with "android context was not initialized". Idempotent. +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_flexaccess_ezvpn_EzvpnNative_init<'local>( + env: JNIEnv<'local>, + class: JClass<'local>, + context: JObject<'local>, +) { + ezvpn_init_logging(); + if ANDROID_CONTEXT.get().is_some() { + return; + } + let (vm, context_ref, class_ref) = + match (env.get_java_vm(), env.new_global_ref(&context), env.new_global_ref(&class)) { + (Ok(vm), Ok(context_ref), Ok(class_ref)) => (vm, context_ref, class_ref), + (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => { + log::error!("ezvpn init: cannot capture the JVM/context/class: {e}"); + return; + } + }; + let vm_ptr = vm.get_java_vm_pointer().cast::(); + let context_ptr = context_ref.as_obj().as_raw().cast::(); + // The global ref must outlive every later JNI call through ndk-context, + // i.e. the process: leak it on purpose. + std::mem::forget(context_ref); + // SAFETY: both pointers are valid for the life of the process (the JVM + // pointer by construction, the context through the leaked global ref), and + // the OnceLock guarantees a single registration. + unsafe { ndk_context::initialize_android_context(vm_ptr, context_ptr) }; + let _ = JVM.set((vm, class_ref)); + let _ = ANDROID_CONTEXT.set(()); +} + +/// `EzvpnNative.generateClientKey(): String`: the +/// `{"created":…,"public_key":…,"secret_key":…}` document. Throws +/// `RuntimeException` (and returns null) if the system RNG was unavailable. +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_flexaccess_ezvpn_EzvpnNative_generateClientKey<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, +) -> jstring { + match crate::ffi_common::generate_client_key_json() { + Ok(json) => new_jstring(&mut env, &json), + Err(msg) => { + throw(&mut env, &msg); + std::ptr::null_mut() + } + } +} + +/// `EzvpnNative.clientPublicKey(secret: String): String?`: the `ed25519-pub:…` +/// half of a secret key, or null when the secret does not parse — which also +/// makes this the validator for pasted keys. +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_flexaccess_ezvpn_EzvpnNative_clientPublicKey<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + secret: JString<'local>, +) -> jstring { + let Some(secret) = get_string(&mut env, &secret) else { + return std::ptr::null_mut(); + }; + match crate::ffi_common::client_public_key(&secret) { + Ok(public) => new_jstring(&mut env, &public), + Err(_) => std::ptr::null_mut(), + } +} + +/// `EzvpnNative.connect(configJson: String, out: Array): Long`: +/// connect + handshake (blocks for the duration, bounded by the core's connect +/// timeout — call it off the main thread). Returns the handle and stores the +/// network-config JSON in `out[0]`; on failure returns `0` and stores the error +/// message in `out[0]` instead. +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_flexaccess_ezvpn_EzvpnNative_connect<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + config_json: JString<'local>, + out: JObjectArray<'local>, +) -> jlong { + let Some(json) = get_string(&mut env, &config_json) else { + set_out(&mut env, &out, "config_json is not a valid string"); + return 0; + }; + match connect_inner(&json) { + Ok((handle, result_json)) => { + set_out(&mut env, &out, &result_json); + Box::into_raw(Box::new(handle)) as jlong + } + Err(msg) => { + set_out(&mut env, &out, &msg); + 0 + } + } +} + +/// `EzvpnNative.run(handle: Long, tunFd: Int): Int`: start the data loop on +/// the fd from `VpnService.Builder.establish()`. Returns `0` on success, `-1` +/// on error (null handle, no pending session, dup failure). The fd is `dup`ed +/// before this returns, so the caller may close its `ParcelFileDescriptor` +/// right after. When the loop later ends on its own, the library calls +/// `EzvpnNative.onTunnelExit(handle, error)` on a background thread; `error` +/// is null for a clean end. The callback carries the same handle value so the +/// Kotlin side can ignore a stale one. +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_flexaccess_ezvpn_EzvpnNative_run<'local>( + _env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, + tun_fd: jint, +) -> jint { + let Some(handle_ref) = handle_mut(handle) else { + return -1; + }; + // The exit hook runs on a tokio worker thread, which must attach to the JVM + // and must reach the class through a global ref: `FindClass` from a + // native-spawned thread uses the system class loader, which cannot see app + // classes. Both were captured by `init`. + if JVM.get().is_none() { + log::error!("ezvpn run: EzvpnNative.init was not called"); + return -1; + } + let hook = Box::new(move |result: Result<(), String>| { + if let Some((vm, class_ref)) = JVM.get() { + notify_tunnel_exit(vm, class_ref, handle, result); + } + }); + match handle_ref.run(tun_fd as c_int, Some(hook)) { + Ok(()) => 0, + Err(e) => { + log::error!("ezvpn run: {e}"); + -1 + } + } +} + +/// Deliver the end-of-loop notification to `EzvpnNative.onTunnelExit`. +fn notify_tunnel_exit(vm: &jni::JavaVM, class: &GlobalRef, handle: jlong, result: Result<(), String>) { + match &result { + Ok(()) => log::info!("tunnel loop ended"), + Err(e) => log::warn!("tunnel loop ended with error: {e}"), + } + let mut env = match vm.attach_current_thread() { + Ok(env) => env, + Err(e) => { + log::error!("cannot attach tunnel thread to the JVM for onTunnelExit: {e}"); + return; + } + }; + let error = match &result { + Ok(()) => JString::default(), + Err(msg) => match env.new_string(msg) { + Ok(s) => s, + Err(e) => { + log::error!("cannot build onTunnelExit message: {e}"); + JString::default() + } + }, + }; + if let Err(e) = env.call_static_method( + class, + "onTunnelExit", + "(JLjava/lang/String;)V", + &[JValue::Long(handle), JValue::Object(&error)], + ) { + log::error!("onTunnelExit callback failed: {e}"); + // A pending Java exception would abort the next JNI call on this + // thread; clear it since the thread is about to detach anyway. + let _ = env.exception_clear(); + } +} + +/// `EzvpnNative.connPath(handle: Long): String?`: the `ezvpn_conn_path` JSON +/// snapshot (paths + custom-relay health), or null for a null handle. +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_flexaccess_ezvpn_EzvpnNative_connPath<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + handle: jlong, +) -> jstring { + let Some(handle_ref) = handle_ref(handle) else { + return std::ptr::null_mut(); + }; + let json = handle_ref.conn_path_json(); + new_jstring(&mut env, &json) +} + +/// `EzvpnNative.stop(handle: Long)`: abort the loop, close the endpoint, free +/// the handle. `0` is a no-op; the handle is invalid afterwards. +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_flexaccess_ezvpn_EzvpnNative_stop( + _env: JNIEnv, + _class: JClass, + handle: jlong, +) { + if handle == 0 { + return; + } + // SAFETY: the Kotlin side passes back a value obtained from `connect` and + // never reuses it after `stop` (see the module docs for the contract). + unsafe { Box::from_raw(handle as *mut EzvpnHandle) }.stop(); +} + +// --------------------------------------------------------------------------- +// JNI helpers + +/// Borrow the handle behind a `jlong`, or `None` for `0`. +fn handle_ref<'a>(handle: jlong) -> Option<&'a EzvpnHandle> { + if handle == 0 { + return None; + } + // SAFETY: see `Java_…_stop`; a non-zero value is a live handle from `connect`. + Some(unsafe { &*(handle as *const EzvpnHandle) }) +} + +fn handle_mut<'a>(handle: jlong) -> Option<&'a mut EzvpnHandle> { + if handle == 0 { + return None; + } + // SAFETY: as above; the Kotlin side serializes calls on one handle. + Some(unsafe { &mut *(handle as *mut EzvpnHandle) }) +} + +/// Copy a Java string out, or `None` for null / non-UTF-8 input. +fn get_string(env: &mut JNIEnv, s: &JString) -> Option { + if s.is_null() { + return None; + } + env.get_string(s).ok().map(|js| js.into()) +} + +/// Build a Java string, or null (with a pending `OutOfMemoryError`) when the +/// JVM could not allocate it. +fn new_jstring(env: &mut JNIEnv, s: &str) -> jstring { + env.new_string(s) + .map(|js| js.into_raw()) + .unwrap_or(std::ptr::null_mut()) +} + +/// Store `value` in `out[0]`. Failures (null array, zero length) are logged: +/// the caller already reports success/failure through its return value. +fn set_out(env: &mut JNIEnv, out: &JObjectArray, value: &str) { + let Ok(js) = env.new_string(value) else { + log::error!("cannot build JNI out string"); + return; + }; + if let Err(e) = env.set_object_array_element(out, 0, &js) { + log::error!("cannot store JNI out string: {e}"); + let _ = env.exception_clear(); + } +} + +fn throw(env: &mut JNIEnv, msg: &str) { + if let Err(e) = env.throw_new("java/lang/RuntimeException", msg) { + log::error!("cannot throw RuntimeException({msg}): {e}"); + } +} diff --git a/src/lib.rs b/src/lib.rs index f6e89a9..8feb8dc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,18 +7,21 @@ //! This is the library crate. The desktop CLI (`src/main.rs`) and the Apple //! Network Extension FFI (`src/ffi.rs`, built into a `staticlib`) both consume //! it. Desktop platforms (Linux/macOS/Windows) get the full client/server with -//! TUN creation, routing, single-instance lock, and the control socket. Apple -//! app extensions get the portable data plane (iroh connect + handshake + -//! data-stream loop) and drive an OS-provided `utun` fd — routing and IP -//! configuration are owned by the `NEPacketTunnelProvider`, not this crate. +//! TUN creation, routing, single-instance lock, and the control socket. The +//! mobile clients — Apple app extensions (`src/ffi.rs`) and the Android +//! `VpnService` (`src/ffi_android.rs`) — get the portable data plane (iroh +//! connect + handshake + data-stream loop) and drive an OS-provided tun fd; +//! routing and IP configuration are owned by the `NEPacketTunnelProvider` / +//! `VpnService.Builder`, not this crate. #[cfg(not(any( target_os = "linux", target_os = "macos", target_os = "windows", - target_os = "ios" + target_os = "ios", + target_os = "android" )))] -compile_error!("ezvpn only supports Linux, macOS, Windows, and iOS"); +compile_error!("ezvpn only supports Linux, macOS, Windows, iOS, and Android"); // Re-exported so downstream consumers (the FFI layers, the CLI) can name the // shared key types without depending on the git crate themselves. @@ -33,21 +36,28 @@ pub mod transport; pub mod tunnel; // Desktop modules: the single-instance lock and Unix/Windows control socket are -// omitted from iOS. The gates remain broader on macOS because the same library -// crate also serves the native CLI there. -#[cfg(not(target_os = "ios"))] +// omitted from the mobile targets (iOS, Android). The gates remain broader on +// macOS because the same library crate also serves the native CLI there. +#[cfg(not(any(target_os = "ios", target_os = "android")))] pub mod control; -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] pub mod runtime; // Key helpers shared by the two (target-disjoint) FFI surfaces below. Built // everywhere so the desktop test run covers it. pub mod ffi_common; -// Apple Network Extension C FFI surface consumed by the iOS/macOS app extension. -#[cfg(any(target_os = "ios", target_os = "macos"))] +// fd-based C FFI surface: consumed by the iOS/macOS Network Extension directly, +// and wrapped by the JNI layer below on Android (same lifecycle, same JSON). +#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))] pub mod ffi; +// Android JNI surface consumed by the `ezvpn-android` app's `VpnService`. Thin +// wrapper over `ffi`: the VpnService owns the tun interface and routes, exactly +// like the Apple provider, and hands over the fd. +#[cfg(target_os = "android")] +pub mod ffi_android; + // Windows C FFI surface consumed by the native Windows GUI (`ezvpn-windows`), // P/Invoked from .NET. Wraps the desktop `VpnClient` (which owns the wintun // adapter and routing table), unlike the fd-based Apple `ffi` module. diff --git a/src/net/device.rs b/src/net/device.rs index ec15db2..ef1d23b 100644 --- a/src/net/device.rs +++ b/src/net/device.rs @@ -11,9 +11,10 @@ // neither creates a utun nor configures routes (the NEPacketTunnelProvider // does), so it only reuses the shared fd I/O. // -// On iOS the device is built from an OS-provided fd (`TunDevice::from_raw_fd`), -// so the `tun`-crate creation imports are unused there; silence the noise. -#![cfg_attr(target_os = "ios", allow(unused_imports, dead_code))] +// On iOS and Android the device is built from an OS-provided fd +// (`TunDevice::from_raw_fd`), so the `tun`-crate creation imports are unused +// there; silence the noise. +#![cfg_attr(any(target_os = "ios", target_os = "android"), allow(unused_imports, dead_code))] use crate::error::{VpnError, VpnResult}; #[cfg(not(target_vendor = "apple"))] @@ -51,12 +52,14 @@ use std::os::windows::process::CommandExt as _; #[cfg(target_os = "windows")] const CREATE_NO_WINDOW: u32 = 0x0800_0000; -#[cfg(target_vendor = "apple")] +#[cfg(any(target_vendor = "apple", target_os = "android"))] use std::io; #[cfg(any(target_os = "linux", target_vendor = "apple"))] use std::os::fd::AsRawFd; -#[cfg(target_vendor = "apple")] +#[cfg(any(target_vendor = "apple", target_os = "android"))] use std::os::fd::{FromRawFd, OwnedFd, RawFd}; +#[cfg(target_os = "android")] +use std::os::fd::IntoRawFd; #[cfg(target_vendor = "apple")] use std::sync::Arc; #[cfg(target_vendor = "apple")] @@ -238,9 +241,10 @@ pub struct TunDevice { impl TunDevice { /// Create a new TUN device with the given configuration. /// - /// Desktop-only: iOS receives an already-created `utun` fd from the - /// Network Extension and must use [`Self::from_raw_fd`] instead. - #[cfg(not(target_os = "ios"))] + /// Desktop-only: iOS and Android receive an already-created tun fd from + /// the Network Extension / `VpnService` and must use [`Self::from_raw_fd`] + /// instead. + #[cfg(not(any(target_os = "ios", target_os = "android")))] pub fn create(config: TunConfig) -> VpnResult { let mut tun_config = Configuration::default(); @@ -345,6 +349,40 @@ impl TunDevice { }) } + /// Wrap the tun file descriptor an Android `VpnService` established. + /// + /// `VpnService.Builder.establish()` returns a plain Linux TUN fd opened + /// with `IFF_NO_PI` and no vnet header: frames are bare IP packets, which + /// is exactly the `tun`-crate Standard reader/writer path with both + /// `packet_information` and `vnet_hdr` off. The service owns addresses, + /// routes, and MTU (`VpnService.Builder`); this constructor takes a private + /// `dup` of the fd, so the caller may close its copy once it returns. + /// + /// No offload: the app sandbox cannot issue `TUNSETOFFLOAD` on the fd the + /// system handed over, so the data path uses software segmentation. + #[cfg(target_os = "android")] + pub fn from_raw_fd(fd: RawFd, mtu: u16) -> VpnResult { + let owned = duplicate_fd(fd)?; + let mut tun_config = Configuration::default(); + // The `tun` crate takes ownership of the dup and closes it on drop. + tun_config + .raw_fd(owned.into_raw_fd()) + .close_fd_on_drop(true) + .mtu(mtu); + let device = tun::create_as_async(&tun_config).map_err(|e| { + VpnError::tun_device_with_source("Failed to wrap VpnService tun fd", e) + })?; + Ok(Self { + device, + name: "tun".to_string(), + mtu, + vnet_hdr_enabled: false, + offload_status: TunOffloadStatus::disabled( + "Android VpnService tun has no kernel offload", + ), + }) + } + /// Get the device name. pub fn name(&self) -> &str { &self.name @@ -459,7 +497,7 @@ const TUN_PERMISSION_HELP: &str = /// Only a *permission* failure is fatal here; any other failure (or Windows, /// whose wintun driver reports privilege problems through a different path) is /// left for [`TunDevice::create`] to report with the full device configuration. -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] pub fn ensure_tun_permission() -> VpnResult<()> { // Windows (wintun) surfaces privilege problems through a driver-specific // error path and needs the wintun runtime just to attempt creation; skip the @@ -562,12 +600,12 @@ impl DarwinTunHalves { } } -#[cfg(target_vendor = "apple")] +#[cfg(any(target_vendor = "apple", target_os = "android"))] fn duplicate_fd(fd: RawFd) -> VpnResult { let duplicated = unsafe { libc::dup(fd) }; if duplicated < 0 { return Err(VpnError::tun_device_with_source( - "Failed to duplicate utun fd", + "Failed to duplicate tun fd", io::Error::last_os_error(), )); } diff --git a/src/net/mod.rs b/src/net/mod.rs index 65b9666..09e7cfd 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -2,5 +2,5 @@ pub mod buffer; pub mod device; -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] pub mod local_networks; diff --git a/src/tunnel/client.rs b/src/tunnel/client.rs index 20a8c47..c809859 100644 --- a/src/tunnel/client.rs +++ b/src/tunnel/client.rs @@ -5,18 +5,18 @@ //! IP-over-QUIC tunnel. IP packets are framed and sent directly over the //! encrypted iroh QUIC connection for automatic NAT traversal. -// On iOS only the portable data plane (run_tunnel + the handshake) is used; -// VpnClient, routing, the bypass manager and the reconnect wrapper are all -// desktop-only (they need the gated-out control/runtime modules) and compile +// On iOS and Android only the portable data plane (run_tunnel + the handshake) +// is used; VpnClient, routing, the bypass manager and the reconnect wrapper are +// all desktop-only (they need the gated-out control/runtime modules) and compile // here as dead code. Silence the resulting unused-import / dead-code noise on -// iOS rather than finely gating every line. -#![cfg_attr(target_os = "ios", allow(unused_imports, dead_code))] +// the mobile targets rather than finely gating every line. +#![cfg_attr(any(target_os = "ios", target_os = "android"), allow(unused_imports, dead_code))] use crate::net::buffer::uninitialized_vec; -// Not iOS-gated: `perform_handshake` is the shared connect path, used by the -// iOS session too. +// Not mobile-gated: `perform_handshake` is the shared connect path, used by the +// mobile session too. use crate::auth::ClientKey; -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] use crate::config::VpnClientConfig; use crate::tunnel::stream::{ FRAME_ARENA_CHUNK, Frame, MAX_FRAME_BODY, classify, read_frame, send_ip_datagrams, @@ -25,14 +25,15 @@ use crate::net::device::{ BypassRouteGuard, Route6Guard, RouteGuard, TunConfig, TunDevice, UnderlayGateway, add_bypass_route, add_routes, add_routes6_with_src, query_default_gateway, }; -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] use crate::control::{ClientConnectedInfo, ClientStatusHandle}; -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] use crate::net::local_networks::{has_refusable_routes, local_networks, overlap_error}; use crate::error::{VpnError, VpnResult}; -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] use crate::runtime::{LockRole, VpnLock}; use crate::tunnel::offload::VirtioNetHdr; +use crate::tunnel::dns_proxy::{self, DnsProxyConfig}; use crate::transport::paths::watch_connection_paths; use crate::config::VPN_MTU; use crate::tunnel::signaling::{ @@ -75,9 +76,9 @@ const SERVER_ADDR_CHANNEL_SIZE: usize = 8; const RESOLVE_RELAY_TIMEOUT: Duration = Duration::from_secs(5); /// A decoded inbound packet queued for the dedicated TUN writer task. -struct InboundTunWrite { - packet: Bytes, - offload: Option, +pub(crate) struct InboundTunWrite { + pub(crate) packet: Bytes, + pub(crate) offload: Option, } /// Enqueue a decoded inbound packet on the TUN writer channel. @@ -86,7 +87,7 @@ struct InboundTunWrite { /// the lock-free `try_send` fast path and only `.await`s when the channel is /// full (backpressure), avoiding a guaranteed task wake-up per received packet. /// Returns `false` if the channel is closed. -async fn enqueue_inbound_tun_write(tx: &mpsc::Sender, req: InboundTunWrite) -> bool { +pub(crate) async fn enqueue_inbound_tun_write(tx: &mpsc::Sender, req: InboundTunWrite) -> bool { match tx.try_send(req) { Ok(()) => true, Err(mpsc::error::TrySendError::Full(req)) => tx.send(req).await.is_ok(), @@ -97,9 +98,9 @@ async fn enqueue_inbound_tun_write(tx: &mpsc::Sender, req: Inbo /// VPN client instance. /// /// Desktop-only: holds the single-instance lock and the control-socket status -/// handle. On iOS the connect path lives in [`crate::tunnel::ios`] and drives an +/// handle. On iOS the connect path lives in [`crate::tunnel::mobile`] and drives an /// OS-provided utun fd instead. -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] pub struct VpnClient { /// Client configuration. config: VpnClientConfig, @@ -253,7 +254,7 @@ fn check_params_against( ))) } -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] impl VpnClient { /// Create a new VPN client. /// @@ -581,6 +582,7 @@ impl VpnClient { bypass_route_guard, server_addr_tx, local_iroh_udp_ports, + None, ) .await; @@ -785,7 +787,7 @@ impl VpnClient { /// stays open as the reliable data stream. /// /// Shared by the desktop [`VpnClient`] and the iOS connect path -/// ([`crate::tunnel::ios`]): both open a bi-stream, send a [`VpnHandshake`] +/// ([`crate::tunnel::mobile`]): both open a bi-stream, send a [`VpnHandshake`] /// (advertising data-channel GSO), and parse the [`VpnHandshakeResponse`]. The /// `device_id` keys the server's idempotent IP allocation; `client_key` signs /// `own_id` (this client's own ephemeral endpoint id) so the credential is @@ -888,6 +890,12 @@ pub(crate) async fn perform_handshake( /// function shovels packets between the TUN device and datagrams, and drains /// control frames off the stream. Peer liveness is detected by /// `Connection::closed()` (QUIC keep-alive + idle timeout) — no app heartbeat. +/// +/// `dns_proxy` enables the Android in-tunnel split-DNS forwarder +/// ([`crate::tunnel::dns_proxy`]): packets to its proxy address are diverted +/// from the outbound path to the forwarder task, whose replies enter the TUN +/// writer channel like inbound packets. Every other caller passes `None`. +#[allow(clippy::too_many_arguments)] // one optional hook per platform concern; a struct would only rename them pub(crate) async fn run_tunnel( tun_device: TunDevice, connection: Connection, @@ -896,6 +904,7 @@ pub(crate) async fn run_tunnel( bypass_route_guard: Option, server_addr_tx: Option>>, local_iroh_udp_ports: Arc>, + dns_proxy: Option, ) -> VpnResult<()> { // Split TUN device. This is the last point setup can return early, so the // bypass guard stays armed across it; once the split succeeds we disarm it @@ -912,6 +921,22 @@ pub(crate) async fn run_tunnel( // frames. Hold the send half so the bi-stream stays established. let _data_send = data_send; + // Create channel for inbound packets to decouple frame receipt from TUN + // write syscalls. The TUN writer task owns the TunWriter. + let (tun_write_tx, mut tun_write_rx) = + mpsc::channel::(INBOUND_TUN_CHANNEL_SIZE); + + // Android split-DNS forwarder (see `dns_proxy`): the outbound task hands it + // packets for the proxy address; it answers through the TUN writer channel. + let (dns_intercept, dns_proxy_handle) = match dns_proxy { + Some(cfg) => { + let (intercept, rx) = dns_proxy::intercept_channel(&cfg); + let handle = tokio::spawn(dns_proxy::run_dns_proxy(cfg, rx, tun_write_tx.clone())); + (Some(intercept), Some(handle)) + } + None => (None, None), + }; + // Spawn outbound task (TUN -> unreliable QUIC datagrams). Each TUN packet // maps to one datagram; an offload super-frame is software-segmented into // per-MSS packets, each its own datagram. Returns a disconnect reason on a @@ -951,6 +976,13 @@ pub(crate) async fn run_tunnel( continue; } + if let Some(intercept) = &dns_intercept + && intercept.wants(packet) + { + intercept.capture(packet); + continue; + } + let outcome = send_ip_datagrams( &conn_out, &mut arena, @@ -987,11 +1019,6 @@ pub(crate) async fn run_tunnel( } }); - // Create channel for inbound packets to decouple frame receipt from TUN - // write syscalls. The TUN writer task owns the TunWriter. - let (tun_write_tx, mut tun_write_rx) = - mpsc::channel::(INBOUND_TUN_CHANNEL_SIZE); - // Spawn dedicated TUN writer task. Batched channel receives reduce // task wakeups; write_batch coalesces consecutive same-flow TCP // segments into GSO super-frames on Linux, and otherwise issues one @@ -1177,6 +1204,9 @@ pub(crate) async fn run_tunnel( if let Some(ref task) = bypass_route_task { task.abort(); } + if let Some(task) = &dns_proxy_handle { + task.abort(); + } // Await all remaining handles to ensure cleanup (aborted tasks return Cancelled) let mut all_results = vec![(first_task, first_result)]; @@ -1224,7 +1254,7 @@ pub(crate) async fn run_tunnel( Err(VpnError::ConnectionLost(reason)) } -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] impl VpnClient { /// Connect to the VPN server with automatic reconnection on failure. /// @@ -1553,7 +1583,7 @@ impl Drop for AbortOnDropTask { /// Polling is deliberate: the event-driven watcher crates are event-driven /// only on the deprioritized platforms and would poll on macOS anyway (see /// docs/Desktop-Overlap-and-Network-Change-Plan.md §2). -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] const LOCAL_NETWORK_POLL_INTERVAL: Duration = Duration::from_secs(5); /// Spawn the mid-session overlap watcher (§2): poll the on-link networks and, @@ -1567,7 +1597,7 @@ const LOCAL_NETWORK_POLL_INTERVAL: Duration = Duration::from_secs(5); /// /// The returned guard aborts the watcher on drop; `connect()` holds it across /// `run_tunnel` so the watcher never outlives the session. -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] fn spawn_local_network_overlap_watch( routes4: Vec, routes6: Vec, @@ -1799,7 +1829,7 @@ fn private_scope(ip: &IpAddr) -> bool { /// Only the iOS connect path consumes this (desktop applies the same /// `private_scope` filter inside `BypassRouteManager::update`), so it is dead /// code on non-iOS builds outside the unit tests. -#[cfg_attr(not(any(target_os = "ios", test)), allow(dead_code))] +#[cfg_attr(not(any(target_os = "ios", target_os = "android", test)), allow(dead_code))] pub(crate) fn overlapping_underlay_excludes( server_addrs: &[IpAddr], routes4: &[Ipv4Net], diff --git a/src/tunnel/dns_proxy.rs b/src/tunnel/dns_proxy.rs new file mode 100644 index 0000000..e869bbd --- /dev/null +++ b/src/tunnel/dns_proxy.rs @@ -0,0 +1,785 @@ +//! In-tunnel split-DNS forwarder for Android (the "magic DNS" workaround). +//! +//! Every other platform gets conditional forwarding from the OS: iOS through +//! `NEDNSSettings.matchDomains`, the desktop clients through their resolver +//! configuration. Android's `VpnService.Builder` has only `addDnsServer`, which +//! replaces the resolvers for *all* names of every app the VPN applies to, and +//! an app cannot bind port 53 — so per-domain forwarding cannot be expressed to +//! the OS at all. This module does what Tailscale's MagicDNS does instead: the +//! app points the VPN's DNS at a *proxy address* that is routed into the tun +//! (a host route, e.g. `198.18.0.53/32`), and the data path answers those +//! packets itself: +//! +//! * a UDP query to `:53` is parsed, its first question name is matched +//! against the profile's match domains, and it is forwarded either to the +//! tunnel's private resolvers (plain sockets — the resolvers sit inside a +//! tunnel route, so the OS routes the query back into the tun and through +//! the tunnel like any other app traffic) or to the underlying network's +//! resolvers through sockets the `VpnService` has `protect()`ed, so they +//! never loop into the VPN even under a full-tunnel route; +//! * the reply is written back into the tun as a UDP packet from `:53`; +//! * a TCP SYN to `:53` (a stub retrying a truncated answer) or +//! `:853` (Android's opportunistic DNS-over-TLS probe) gets a RST, so +//! the stub falls back immediately instead of timing out. TCP DNS is not +//! proxied; an answer that would not fit the tunnel MTU is returned +//! truncated (TC) with the question only. +//! +//! DNS ids are rewritten per in-flight query so one upstream socket per family +//! can multiplex every app's queries without collisions; the original id is +//! restored on the way back. The module is platform-independent Rust (and unit +//! tested on the host), but only the Android app configures it — every other +//! caller passes `None` to `run_tunnel` and nothing here runs. + +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use etherparse::{NetSlice, PacketBuilder, SlicedPacket, TransportSlice}; +use rand::rngs::StdRng; +use rand::{RngExt, SeedableRng}; +use tokio::net::UdpSocket; +use tokio::sync::mpsc; + +use crate::config::VPN_MTU; +use crate::tunnel::client::{InboundTunWrite, enqueue_inbound_tun_write}; + +pub const DNS_PORT: u16 = 53; +const DOT_PORT: u16 = 853; +/// How long a forwarded query may wait for its answer before its id is freed. +const QUERY_TIMEOUT: Duration = Duration::from_secs(5); +/// Upper bound on in-flight queries; beyond it the oldest are dropped first. +const MAX_PENDING: usize = 1024; +/// Captured packets queued from the tun reader to the proxy task. +const CAPTURE_QUEUE: usize = 256; +/// Largest DNS message read back from an upstream (EDNS-sized). +const UPSTREAM_BUF: usize = 4096; + +/// What the app decided: where the proxy listens, what it matches, and where +/// it forwards to. +pub struct DnsProxyConfig { + /// Proxy addresses the OS was pointed at (at most one per family); packets + /// to `
:53` are intercepted. Both families may be listed even when + /// only one is routed — an unrouted one simply sees no traffic. + pub addresses: Vec, + /// Suffixes (no leading/trailing dot, lowercase) whose names resolve via + /// `servers`; everything else goes to `fallback_servers`. + pub match_domains: Vec, + /// The tunnel's resolvers (inside a tunnel route). + pub servers: Vec, + /// The underlying network's resolvers. Empty means "unknown": every name + /// then goes to `servers`, which degrades to all-DNS-through-tunnel rather + /// than breaking resolution. + pub fallback_servers: Vec, + /// UDP sockets the app has `protect()`ed, one per family at most, used for + /// the fallback upstreams (switched to non-blocking here). A family without + /// one gets a plain socket (fine unless a tunnel route captures the + /// fallback resolver). + pub fallback_sockets: Vec, +} + +impl std::fmt::Debug for DnsProxyConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DnsProxyConfig") + .field("addresses", &self.addresses) + .field("match_domains", &self.match_domains) + .field("servers", &self.servers) + .field("fallback_servers", &self.fallback_servers) + .field("fallback_sockets", &self.fallback_sockets.len()) + .finish() + } +} + +impl DnsProxyConfig { + /// Normalize the match domains (trim dots and whitespace, lowercase) and + /// drop empties, so matching is a plain suffix comparison. + pub fn normalized(mut self) -> Self { + self.match_domains = self + .match_domains + .iter() + .map(|d| d.trim().trim_matches('.').to_ascii_lowercase()) + .filter(|d| !d.is_empty()) + .collect(); + self + } +} + +/// The tun reader's side of the proxy: a cheap "is this for the proxy?" test +/// on the raw IP header plus the handoff channel. Cloned into the outbound +/// task; the per-packet cost for non-DNS traffic is one version-nibble check +/// and a 4- or 16-byte compare per proxy address. +#[derive(Clone)] +pub(crate) struct DnsIntercept { + addrs4: Vec<[u8; 4]>, + addrs6: Vec<[u8; 16]>, + tx: mpsc::Sender, +} + +impl DnsIntercept { + /// True when the packet's destination is one of the proxy addresses. + pub(crate) fn wants(&self, packet: &[u8]) -> bool { + match packet.first().map(|b| b >> 4) { + Some(4) if packet.len() >= 20 => self.addrs4.iter().any(|a| a[..] == packet[16..20]), + Some(6) if packet.len() >= 40 => self.addrs6.iter().any(|a| a[..] == packet[24..40]), + _ => false, + } + } + + /// Hand a packet to the proxy task. Never blocks the tun reader: when the + /// queue is full the query is dropped and the stub resolver retries. + pub(crate) fn capture(&self, packet: &[u8]) { + if self.tx.try_send(Bytes::copy_from_slice(packet)).is_err() { + log::debug!("DNS proxy queue full or closed; dropping a captured packet"); + } + } +} + +/// Build the intercept handle and the receiver the proxy task consumes. +pub(crate) fn intercept_channel(cfg: &DnsProxyConfig) -> (DnsIntercept, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(CAPTURE_QUEUE); + let mut addrs4 = Vec::new(); + let mut addrs6 = Vec::new(); + for addr in &cfg.addresses { + match addr { + IpAddr::V4(a) => addrs4.push(a.octets()), + IpAddr::V6(a) => addrs6.push(a.octets()), + } + } + (DnsIntercept { addrs4, addrs6, tx }, rx) +} + +// --------------------------------------------------------------------------- +// Packet classification and construction (pure) + +/// A captured packet the proxy acts on. +#[derive(Debug, PartialEq, Eq)] +enum Captured<'a> { + /// UDP datagram to `:53` carrying a DNS message. + Query { + client: SocketAddr, + proxy: SocketAddr, + dns: &'a [u8], + }, + /// TCP connection attempt to the proxy's DNS or DoT port. + TcpSyn { + client: SocketAddr, + proxy: SocketAddr, + seq: u32, + }, +} + +fn classify(packet: &[u8]) -> Option> { + let sliced = SlicedPacket::from_ip(packet).ok()?; + let (src, dst): (IpAddr, IpAddr) = match sliced.net? { + NetSlice::Ipv4(v4) => ( + Ipv4Addr::from(v4.header().source()).into(), + Ipv4Addr::from(v4.header().destination()).into(), + ), + NetSlice::Ipv6(v6) => ( + Ipv6Addr::from(v6.header().source()).into(), + Ipv6Addr::from(v6.header().destination()).into(), + ), + _ => return None, + }; + match sliced.transport? { + TransportSlice::Udp(udp) if udp.destination_port() == DNS_PORT => Some(Captured::Query { + client: SocketAddr::new(src, udp.source_port()), + proxy: SocketAddr::new(dst, DNS_PORT), + dns: udp.payload(), + }), + TransportSlice::Tcp(tcp) + if tcp.syn() + && !tcp.ack() + && (tcp.destination_port() == DNS_PORT || tcp.destination_port() == DOT_PORT) => + { + Some(Captured::TcpSyn { + client: SocketAddr::new(src, tcp.source_port()), + proxy: SocketAddr::new(dst, tcp.destination_port()), + seq: tcp.sequence_number(), + }) + } + _ => None, + } +} + +/// The end of the question section of a DNS message (offset just past the +/// first question's type/class), or None if the header or question is +/// malformed. Only the first question is examined — that is all stubs send. +fn question_end(msg: &[u8]) -> Option { + if msg.len() < 12 { + return None; + } + let qdcount = u16::from_be_bytes([msg[4], msg[5]]); + if qdcount == 0 { + return None; + } + let mut pos = 12; + let mut total = 0usize; + loop { + let len = *msg.get(pos)? as usize; + pos += 1; + if len == 0 { + break; + } + // Compression never appears in a query's question section. + if len & 0xC0 != 0 { + return None; + } + total += len + 1; + if total > 255 { + return None; + } + pos += len; + msg.get(pos.checked_sub(1)?)?; + } + let end = pos + 4; + (end <= msg.len()).then_some(end) +} + +/// The lowercased first question name (no trailing dot) of a DNS *query*; +/// None for a response, an empty question section, or a malformed message. +fn question_name(msg: &[u8]) -> Option { + question_end(msg)?; + if msg[2] & 0x80 != 0 { + return None; + } + let mut labels: Vec = Vec::new(); + let mut pos = 12; + loop { + let len = msg[pos] as usize; + pos += 1; + if len == 0 { + break; + } + labels.push(String::from_utf8_lossy(&msg[pos..pos + len]).to_ascii_lowercase()); + pos += len; + } + Some(labels.join(".")) +} + +/// Whether `name` equals or sits under one of the (normalized) domains. +fn matches_domain(name: &str, domains: &[String]) -> bool { + domains.iter().any(|d| { + name == d + || name + .strip_suffix(d.as_str()) + .is_some_and(|rest| rest.ends_with('.')) + }) +} + +/// Shrink an answer to its header + question with TC set, for replies that +/// would not fit a tunnel-MTU datagram. The stub retries over TCP, which the +/// proxy refuses with a RST — a fast, explicit failure for the rare oversized +/// answer rather than a silently lost one. +fn truncate_reply(msg: &mut Vec) { + if let Some(end) = question_end(msg) { + msg.truncate(end); + msg[2] |= 0x02; + msg[6..12].fill(0); + } +} + +/// Largest DNS payload that fits one tunnel-MTU datagram for the family. +fn max_payload(proxy: &SocketAddr) -> usize { + let headers = if proxy.is_ipv4() { 20 + 8 } else { 40 + 8 }; + usize::from(VPN_MTU).saturating_sub(headers) +} + +/// UDP reply packet `proxy -> client` carrying `payload`. None when the two +/// ends are not the same family (never the case for a captured query). +fn build_udp_reply(proxy: SocketAddr, client: SocketAddr, payload: &[u8]) -> Option> { + let mut out = Vec::with_capacity(payload.len() + 48); + match (proxy.ip(), client.ip()) { + (IpAddr::V4(s), IpAddr::V4(d)) => PacketBuilder::ipv4(s.octets(), d.octets(), 64) + .udp(proxy.port(), client.port()) + .write(&mut out, payload) + .ok()?, + (IpAddr::V6(s), IpAddr::V6(d)) => PacketBuilder::ipv6(s.octets(), d.octets(), 64) + .udp(proxy.port(), client.port()) + .write(&mut out, payload) + .ok()?, + _ => return None, + } + Some(out) +} + +/// TCP RST/ACK answering a SYN to the proxy (acknowledging `seq + 1`). +fn build_tcp_rst(proxy: SocketAddr, client: SocketAddr, seq: u32) -> Option> { + let mut out = Vec::with_capacity(60); + let ack = seq.wrapping_add(1); + match (proxy.ip(), client.ip()) { + (IpAddr::V4(s), IpAddr::V4(d)) => PacketBuilder::ipv4(s.octets(), d.octets(), 64) + .tcp(proxy.port(), client.port(), 0, 0) + .rst() + .ack(ack) + .write(&mut out, &[]) + .ok()?, + (IpAddr::V6(s), IpAddr::V6(d)) => PacketBuilder::ipv6(s.octets(), d.octets(), 64) + .tcp(proxy.port(), client.port(), 0, 0) + .rst() + .ack(ack) + .write(&mut out, &[]) + .ok()?, + _ => return None, + } + Some(out) +} + +// --------------------------------------------------------------------------- +// The forwarder task + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +enum UpstreamKind { + Tunnel, + Fallback, +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +enum Family { + V4, + V6, +} + +fn family_of(addr: &SocketAddr) -> Family { + if addr.is_ipv4() { Family::V4 } else { Family::V6 } +} + +/// One forwarded query awaiting its answer. +struct Pending { + client: SocketAddr, + proxy: SocketAddr, + upstream: SocketAddr, + original_id: u16, + sent_at: Instant, +} + +/// Lazily created upstream sockets, one per (kind, family); the fallback ones +/// come pre-protected from the app. Each socket has a reader task feeding +/// `reply_tx`; the tasks are aborted with the proxy so the sockets close then, +/// not after the next stray datagram. +struct Upstreams { + sockets: HashMap<(UpstreamKind, Family), Arc>, + readers: Vec>, + reply_tx: mpsc::Sender<(Bytes, SocketAddr)>, +} + +impl Drop for Upstreams { + fn drop(&mut self) { + for reader in &self.readers { + reader.abort(); + } + } +} + +impl Upstreams { + async fn socket(&mut self, kind: UpstreamKind, family: Family) -> Option> { + if let Some(s) = self.sockets.get(&(kind, family)) { + return Some(s.clone()); + } + let bind: SocketAddr = match family { + Family::V4 => (Ipv4Addr::UNSPECIFIED, 0).into(), + Family::V6 => (Ipv6Addr::UNSPECIFIED, 0).into(), + }; + let socket = match UdpSocket::bind(bind).await { + Ok(s) => Arc::new(s), + Err(e) => { + log::warn!("DNS proxy: cannot bind {kind:?} {family:?} upstream socket: {e}"); + return None; + } + }; + if kind == UpstreamKind::Fallback { + log::warn!( + "DNS proxy: no protected {family:?} socket from the app; using an unprotected one \ + (fallback resolvers inside a tunnel route will not be reachable)" + ); + } + self.install(kind, family, socket.clone()); + Some(socket) + } + + fn install(&mut self, kind: UpstreamKind, family: Family, socket: Arc) { + let reader = socket.clone(); + let reply_tx = self.reply_tx.clone(); + self.readers.push(tokio::spawn(async move { + let mut buf = vec![0u8; UPSTREAM_BUF]; + loop { + match reader.recv_from(&mut buf).await { + Ok((n, from)) => { + if reply_tx.send((Bytes::copy_from_slice(&buf[..n]), from)).await.is_err() { + return; + } + } + Err(e) => { + // Transient (ICMP unreachable surfacing as an error on + // Linux); keep serving. + log::debug!("DNS proxy: upstream recv error: {e}"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + } + })); + self.sockets.insert((kind, family), socket); + } +} + +/// Run the forwarder until the capture channel closes (the tun reader ended) +/// or the task is aborted by `run_tunnel`'s cleanup. +pub(crate) async fn run_dns_proxy( + cfg: DnsProxyConfig, + mut captured: mpsc::Receiver, + tun_tx: mpsc::Sender, +) { + let (reply_tx, mut replies) = mpsc::channel::<(Bytes, SocketAddr)>(CAPTURE_QUEUE); + let mut upstreams = Upstreams { + sockets: HashMap::new(), + readers: Vec::new(), + reply_tx, + }; + for std_socket in cfg.fallback_sockets { + let family = match std_socket.local_addr() { + Ok(addr) => family_of(&addr), + Err(e) => { + log::warn!("DNS proxy: ignoring a fallback socket with no local address: {e}"); + continue; + } + }; + // tokio requires the socket to be non-blocking before adopting it. + if let Err(e) = std_socket.set_nonblocking(true) { + log::warn!("DNS proxy: cannot make the protected {family:?} socket non-blocking: {e}"); + continue; + } + match UdpSocket::from_std(std_socket) { + Ok(s) => upstreams.install(UpstreamKind::Fallback, family, Arc::new(s)), + Err(e) => log::warn!("DNS proxy: cannot adopt the protected {family:?} socket: {e}"), + } + } + + let tunnel_servers = cfg.servers; + let fallback_servers = cfg.fallback_servers; + let match_domains = cfg.match_domains; + log::info!( + "DNS proxy on {:?}: {} domain(s) -> {:?}, others -> {}", + cfg.addresses, + match_domains.len(), + tunnel_servers, + if fallback_servers.is_empty() { + "tunnel resolvers (no fallback resolvers known)".to_string() + } else { + format!("{fallback_servers:?}") + } + ); + + let mut pending: HashMap = HashMap::new(); + let mut rotation: u64 = 0; + let mut sweep = tokio::time::interval(Duration::from_secs(1)); + // `ThreadRng` is `!Send`; a `StdRng` seeded from it keeps the task spawnable. + let mut rng = StdRng::from_rng(&mut rand::rng()); + + loop { + tokio::select! { + packet = captured.recv() => { + let Some(packet) = packet else { break }; + match classify(&packet) { + Some(Captured::TcpSyn { client, proxy, seq }) => { + if let Some(rst) = build_tcp_rst(proxy, client, seq) { + write_tun(&tun_tx, rst).await; + } + } + Some(Captured::Query { client, proxy, dns }) => { + let Some(name) = question_name(dns) else { + log::trace!("DNS proxy: ignoring a non-query or malformed message from {client}"); + continue; + }; + let kind = if fallback_servers.is_empty() || matches_domain(&name, &match_domains) { + UpstreamKind::Tunnel + } else { + UpstreamKind::Fallback + }; + let servers = match kind { + UpstreamKind::Tunnel => &tunnel_servers, + UpstreamKind::Fallback => &fallback_servers, + }; + if servers.is_empty() { + log::trace!("DNS proxy: no {kind:?} resolver for {name}"); + continue; + } + rotation = rotation.wrapping_add(1); + let upstream = servers[(rotation % servers.len() as u64) as usize]; + let Some(socket) = upstreams.socket(kind, family_of(&upstream)).await else { + continue; + }; + if pending.len() >= MAX_PENDING { + evict_oldest(&mut pending); + } + let original_id = u16::from_be_bytes([dns[0], dns[1]]); + let id = loop { + let candidate: u16 = rng.random(); + if !pending.contains_key(&candidate) { + break candidate; + } + }; + let mut out = dns.to_vec(); + out[0..2].copy_from_slice(&id.to_be_bytes()); + match socket.send_to(&out, upstream).await { + Ok(_) => { + log::trace!("DNS proxy: {name} -> {kind:?} {upstream} (id {original_id:#06x} -> {id:#06x})"); + pending.insert(id, Pending { client, proxy, upstream, original_id, sent_at: Instant::now() }); + } + Err(e) => log::debug!("DNS proxy: send to {upstream} failed: {e}"), + } + } + None => log::trace!("DNS proxy: ignoring a packet to the proxy address that is not DNS"), + } + } + reply = replies.recv() => { + let Some((msg, from)) = reply else { break }; + if msg.len() < 12 { + continue; + } + let id = u16::from_be_bytes([msg[0], msg[1]]); + let Some(entry) = pending.get(&id) else { + log::trace!("DNS proxy: unexpected reply id {id:#06x} from {from}"); + continue; + }; + if entry.upstream.ip() != from.ip() || entry.upstream.port() != from.port() { + log::debug!("DNS proxy: reply for id {id:#06x} from {from}, expected {}", entry.upstream); + continue; + } + let entry = pending.remove(&id).expect("checked above"); + let mut out = msg.to_vec(); + out[0..2].copy_from_slice(&entry.original_id.to_be_bytes()); + if out.len() > max_payload(&entry.proxy) { + truncate_reply(&mut out); + } + if let Some(packet) = build_udp_reply(entry.proxy, entry.client, &out) { + write_tun(&tun_tx, packet).await; + } + } + _ = sweep.tick() => { + expire(&mut pending, QUERY_TIMEOUT); + } + } + } + log::debug!("DNS proxy task exiting"); +} + +fn expire(pending: &mut HashMap, max_age: Duration) { + let now = Instant::now(); + let before = pending.len(); + pending.retain(|_, p| now.duration_since(p.sent_at) <= max_age); + let dropped = before - pending.len(); + if dropped > 0 { + log::trace!("DNS proxy: expired {dropped} unanswered query(ies)"); + } +} + +/// Make room for one more in-flight query by dropping the oldest one. +fn evict_oldest(pending: &mut HashMap) { + if let Some(id) = pending.iter().min_by_key(|(_, p)| p.sent_at).map(|(id, _)| *id) { + pending.remove(&id); + log::trace!("DNS proxy: pending table full; evicted the oldest query (id {id:#06x})"); + } +} + +async fn write_tun(tun_tx: &mpsc::Sender, packet: Vec) { + let req = InboundTunWrite { + packet: Bytes::from(packet), + offload: None, + }; + if !enqueue_inbound_tun_write(tun_tx, req).await { + log::trace!("DNS proxy: tun writer closed"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn query(name: &str, id: u16) -> Vec { + let mut msg = vec![0u8; 12]; + msg[0..2].copy_from_slice(&id.to_be_bytes()); + msg[2] = 0x01; // RD + msg[5] = 1; // QDCOUNT + for label in name.split('.') { + msg.push(label.len() as u8); + msg.extend_from_slice(label.as_bytes()); + } + msg.push(0); + msg.extend_from_slice(&[0, 1, 0, 1]); // A IN + msg + } + + fn udp_packet(src: SocketAddr, dst: SocketAddr, payload: &[u8]) -> Vec { + build_udp_reply(src, dst, payload).unwrap() + } + + #[test] + fn question_name_parses_and_lowercases() { + assert_eq!(question_name(&query("Host.Corp.Example", 7)).as_deref(), Some("host.corp.example")); + let mut response = query("a.b", 1); + response[2] |= 0x80; + assert_eq!(question_name(&response), None); + assert_eq!(question_name(&[0u8; 11]), None); + let mut no_question = query("a.b", 1); + no_question[5] = 0; + assert_eq!(question_name(&no_question), None); + let mut truncated = query("a.b", 1); + truncated.truncate(16); + assert_eq!(question_name(&truncated), None); + } + + #[test] + fn domain_matching_is_suffix_on_label_boundaries() { + let domains = DnsProxyConfig { + addresses: vec![], + match_domains: vec![" Corp.Example. ".into(), "".into(), "lab".into()], + servers: vec![], + fallback_servers: vec![], + fallback_sockets: vec![], + } + .normalized() + .match_domains; + assert_eq!(domains, vec!["corp.example".to_string(), "lab".to_string()]); + assert!(matches_domain("corp.example", &domains)); + assert!(matches_domain("host.corp.example", &domains)); + assert!(matches_domain("deep.host.lab", &domains)); + assert!(!matches_domain("notcorp.example", &domains)); + assert!(!matches_domain("example", &domains)); + assert!(!matches_domain("corp.example.com", &domains)); + } + + #[test] + fn classifies_udp_queries_and_tcp_syns() { + let client: SocketAddr = "10.124.0.2:40000".parse().unwrap(); + let proxy: SocketAddr = "198.18.0.53:53".parse().unwrap(); + let dns = query("x.corp.example", 9); + let packet = udp_packet(client, proxy, &dns); + assert_eq!( + classify(&packet), + Some(Captured::Query { client, proxy, dns: &dns }) + ); + + let other_port = udp_packet(client, "198.18.0.53:5353".parse().unwrap(), &dns); + assert_eq!(classify(&other_port), None); + + let mut syn = Vec::new(); + PacketBuilder::ipv4([10, 124, 0, 2], [198, 18, 0, 53], 64) + .tcp(41000, 853, 1234, 65535) + .syn() + .write(&mut syn, &[]) + .unwrap(); + assert_eq!( + classify(&syn), + Some(Captured::TcpSyn { + client: "10.124.0.2:41000".parse().unwrap(), + proxy: "198.18.0.53:853".parse().unwrap(), + seq: 1234 + }) + ); + let rst = build_tcp_rst(proxy, client, 1234).unwrap(); + let parsed = SlicedPacket::from_ip(&rst).unwrap(); + match parsed.transport.unwrap() { + TransportSlice::Tcp(tcp) => { + assert!(tcp.rst() && tcp.ack()); + assert_eq!(tcp.acknowledgment_number(), 1235); + } + other => panic!("unexpected transport {other:?}"), + } + } + + #[test] + fn intercept_filter_matches_only_proxy_addresses() { + let cfg = DnsProxyConfig { + addresses: vec!["198.18.0.53".parse().unwrap(), "fd7e:7a00:d45::53".parse().unwrap()], + match_domains: vec![], + servers: vec![], + fallback_servers: vec![], + fallback_sockets: vec![], + }; + let (intercept, _rx) = intercept_channel(&cfg); + let dns = query("a", 1); + let to_proxy = udp_packet("10.124.0.2:1".parse().unwrap(), "198.18.0.53:53".parse().unwrap(), &dns); + let to_proxy6 = udp_packet("[fd7a::2]:1".parse().unwrap(), "[fd7e:7a00:d45::53]:53".parse().unwrap(), &dns); + let elsewhere = udp_packet("10.124.0.2:1".parse().unwrap(), "10.124.0.1:53".parse().unwrap(), &dns); + assert!(intercept.wants(&to_proxy)); + assert!(intercept.wants(&to_proxy6)); + assert!(!intercept.wants(&elsewhere)); + assert!(!intercept.wants(&[0x45, 0x00])); + } + + #[test] + fn truncation_keeps_header_and_question() { + let mut msg = query("host.corp.example", 3); + let question_len = msg.len(); + msg[7] = 1; // ANCOUNT + msg.extend_from_slice(&[0xc0, 0x0c, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 10, 0, 0, 1]); + truncate_reply(&mut msg); + assert_eq!(msg.len(), question_len); + assert_eq!(msg[2] & 0x02, 0x02); + assert_eq!(&msg[6..12], &[0, 0, 0, 0, 0, 0]); + assert_eq!(max_payload(&"198.18.0.53:53".parse().unwrap()), 1280 - 28); + assert_eq!(max_payload(&"[fd7e::53]:53".parse().unwrap()), 1280 - 48); + } + + /// End to end on the host: a fake resolver on loopback answers through the + /// proxy task, and the reply comes back as a tun write to the original + /// client with the original id. + #[tokio::test] + async fn forwards_and_restores_ids() { + let resolver = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let resolver_addr = resolver.local_addr().unwrap(); + tokio::spawn(async move { + let mut buf = [0u8; 512]; + loop { + let (n, from) = resolver.recv_from(&mut buf).await.unwrap(); + let mut reply = buf[..n].to_vec(); + reply[2] |= 0x80; + // Echo the name back in a fake answer so sizes differ from the query. + reply.extend_from_slice(&[0xc0, 0x0c, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 10, 0, 0, 1]); + reply[7] = 1; + resolver.send_to(&reply, from).await.unwrap(); + } + }); + + let cfg = DnsProxyConfig { + addresses: vec!["198.18.0.53".parse().unwrap()], + match_domains: vec!["corp.example".into()], + servers: vec![resolver_addr], + fallback_servers: vec![], + fallback_sockets: vec![], + } + .normalized(); + let (intercept, rx) = intercept_channel(&cfg); + let (tun_tx, mut tun_rx) = mpsc::channel::(8); + tokio::spawn(run_dns_proxy(cfg, rx, tun_tx)); + + let client: SocketAddr = "10.124.0.2:40000".parse().unwrap(); + let proxy: SocketAddr = "198.18.0.53:53".parse().unwrap(); + let dns = query("host.corp.example", 0xBEEF); + let packet = udp_packet(client, proxy, &dns); + assert!(intercept.wants(&packet)); + intercept.capture(&packet); + + let written = tokio::time::timeout(Duration::from_secs(5), tun_rx.recv()) + .await + .expect("reply in time") + .expect("channel open"); + match classify(&written.packet) { + // The reply flows proxy -> client, so from classify's point of view + // the "client" port is 53 and it is not a Query (dst port 40000). + None => {} + other => panic!("reply misclassified as {other:?}"), + } + let parsed = SlicedPacket::from_ip(&written.packet).unwrap(); + let TransportSlice::Udp(udp) = parsed.transport.unwrap() else { + panic!("not udp"); + }; + assert_eq!(udp.source_port(), 53); + assert_eq!(udp.destination_port(), 40000); + let reply = udp.payload(); + assert_eq!(u16::from_be_bytes([reply[0], reply[1]]), 0xBEEF); + assert_eq!(reply[2] & 0x80, 0x80); + assert_eq!(u16::from_be_bytes([reply[6], reply[7]]), 1); + } +} diff --git a/src/tunnel/ios.rs b/src/tunnel/mobile.rs similarity index 81% rename from src/tunnel/ios.rs rename to src/tunnel/mobile.rs index 2543507..12976df 100644 --- a/src/tunnel/ios.rs +++ b/src/tunnel/mobile.rs @@ -1,19 +1,23 @@ -//! Slim iOS connect path. +//! Slim mobile connect path (iOS/macOS Network Extension, Android VpnService). //! -//! iOS VPNs run inside a `NEPacketTunnelProvider` app extension. Unlike the -//! desktop [`crate::tunnel::client::VpnClient`], this path: +//! Mobile VPNs run inside an OS-managed container — a `NEPacketTunnelProvider` +//! app extension on Apple platforms, a `VpnService` on Android — that owns the +//! tunnel interface. Unlike the desktop [`crate::tunnel::client::VpnClient`], +//! this path: //! -//! - does **not** create a `utun` or configure routes/IP/MTU — the extension -//! owns that via `NEPacketTunnelNetworkSettings`, then hands us the tunnel's -//! `utun` fd; -//! - does **not** install OS bypass routes itself. Instead [`IosSession::connect`] +//! - does **not** create a tun device or configure routes/IP/MTU — the +//! extension owns that via `NEPacketTunnelNetworkSettings` (the Android +//! service via `VpnService.Builder`), then hands us the tunnel's fd; +//! - does **not** install OS bypass routes itself. Instead [`MobileSession::connect`] //! computes the underlay-bypass set the desktop `BypassRouteManager` would pin //! (every relay IP plus the server's handshake-advertised candidate underlay //! addresses, filtered to the **global-scope** ones a routed prefix would //! capture — including the server's advertised host prefix, which the -//! extension always routes) and [`IosSession::network_config`] returns them as +//! extension always routes) and [`MobileSession::network_config`] returns them as //! host routes (`/32` / `/128`) for the extension to apply as -//! `excludedRoutes`. Private-scope server addresses (RFC1918/ULA/link-local) +//! `excludedRoutes` (Android, which has no exclude API before 13, subtracts +//! them from the routed prefixes instead). Private-scope server addresses +//! (RFC1918/ULA/link-local) //! are never bypassed: the app refuses to start when a routed prefix overlaps //! the local network, so they are unreachable off-tunnel in any session that //! starts, and bypassing them would blackhole tunnel destinations sharing the @@ -30,12 +34,12 @@ //! //! The flow is two-phase because the extension needs the server-assigned //! addresses (IPv4 and/or IPv6), MTU, and excluded routes to build its network -//! settings *before* it can produce the `utun` fd: +//! settings *before* it can produce the tun fd: //! -//! 1. [`IosSession::connect`] — create an iroh endpoint, connect, handshake. -//! 2. read [`IosSession::network_config`], apply it as -//! `NEPacketTunnelNetworkSettings`, obtain the `utun` fd. -//! 3. [`IosSession::run`] — drive the tunnel over that fd until it ends. +//! 1. [`MobileSession::connect`] — create an iroh endpoint, connect, handshake. +//! 2. read [`MobileSession::network_config`], apply it as +//! `NEPacketTunnelNetworkSettings` / `VpnService.Builder`, obtain the fd. +//! 3. [`MobileSession::run`] — drive the tunnel over that fd until it ends. use std::collections::HashSet; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; @@ -52,14 +56,15 @@ use crate::config::VPN_MTU; use crate::error::{VpnError, VpnResult}; use crate::net::device::TunDevice; use crate::transport::endpoint::{RelayConfig, connect_with_timeout, create_client_endpoint}; +use crate::tunnel::dns_proxy::DnsProxyConfig; use crate::tunnel::client::{ ServerInfo, collect_local_iroh_udp_ports, collect_relay_ips, overlapping_underlay_excludes, perform_handshake, run_tunnel, }; -/// Connection parameters supplied by the iOS app (built from the FFI JSON). -#[derive(Debug, Clone)] -pub struct IosConfig { +/// Connection parameters supplied by the mobile app (built from the FFI JSON). +#[derive(Debug)] +pub struct MobileConfig { /// Server's iroh endpoint id (node id), as a string. pub server_node_id: String, /// Client authentication keypair; its public half must be on the server's @@ -73,14 +78,18 @@ pub struct IosConfig { pub routes: Vec, /// IPv6 prefixes routed through the tunnel. pub routes6: Vec, + /// Android only: the in-tunnel split-DNS forwarder (see + /// [`crate::tunnel::dns_proxy`]). `None` everywhere else. + pub dns_proxy: Option, } -/// The network parameters the extension needs for `NEPacketTunnelNetworkSettings`. +/// The network parameters the extension (or VpnService) needs to configure the +/// tunnel interface. /// /// Each family is optional, mirroring the server's assignment: IPv4-only, /// IPv6-only, or dual-stack. #[derive(Debug, Clone)] -pub struct IosNetworkConfig { +pub struct MobileNetworkConfig { /// Assigned client VPN IPv4 address. pub assigned_ip: Option, /// Netmask for the assigned IPv4 address. Always the host mask @@ -105,8 +114,8 @@ pub struct IosNetworkConfig { pub excluded_routes6: Vec, } -/// A connected, handshaked-but-not-yet-running iOS tunnel session. -pub struct IosSession { +/// A connected, handshaked-but-not-yet-running mobile tunnel session. +pub struct MobileSession { endpoint: Endpoint, connection: Connection, /// Send half of the data stream (the handshake bi-stream, kept open). @@ -119,14 +128,16 @@ pub struct IosSession { excluded_routes: Vec, /// IPv6 underlay `/128`s overlapping a routed prefix. excluded_routes6: Vec, + /// Forwarder configuration handed to `run_tunnel` by [`Self::run`]. + dns_proxy: Option, } -impl IosSession { +impl MobileSession { /// Create an iroh endpoint, connect to the server, and perform the /// handshake. The endpoint identity is ephemeral (a fresh key per session), /// so the server may assign a different IP on each connect — acceptable for /// the MVP. - pub async fn connect(cfg: &IosConfig) -> VpnResult { + pub async fn connect(cfg: MobileConfig) -> VpnResult { let endpoint = create_client_endpoint(&cfg.relay_config, None) .await .map_err(|e| VpnError::Signaling(format!("Failed to create iroh endpoint: {e}")))?; @@ -186,7 +197,7 @@ impl IosSession { } log::info!( - "iOS handshake OK: ip={:?} net={:?} gw={:?} ip6={:?} net6={:?} gw6={:?} mtu={}", + "mobile handshake OK: ip={:?} net={:?} gw={:?} ip6={:?} net6={:?} gw6={:?} mtu={}", server_info.assigned_ip, server_info.network, server_info.server_ip, @@ -204,6 +215,7 @@ impl IosSession { server_info, excluded_routes, excluded_routes6, + dns_proxy: cfg.dns_proxy, }) } @@ -220,9 +232,9 @@ impl IosSession { /// The network parameters for the extension's tunnel settings, for whichever /// families the server assigned (IPv4, IPv6, or both). - pub fn network_config(&self) -> VpnResult { + pub fn network_config(&self) -> VpnResult { let info = &self.server_info; - Ok(IosNetworkConfig { + Ok(MobileNetworkConfig { assigned_ip: info.assigned_ip, netmask: info.network.map(|n| n.netmask()), gateway: info.server_ip, @@ -235,7 +247,7 @@ impl IosSession { }) } - /// Drive the tunnel over the extension-provided `utun` fd until it ends + /// Drive the tunnel over the OS-provided tun fd until it ends /// (peer close, idle timeout, or a fatal I/O error). Consumes the session. /// /// The two `run_tunnel` bypass hooks are `None`: the dynamic in-data-path @@ -257,6 +269,7 @@ impl IosSession { None, None, local_iroh_udp_ports, + self.dns_proxy, ) .await } diff --git a/src/tunnel/mod.rs b/src/tunnel/mod.rs index 5af900e..ddefa2c 100644 --- a/src/tunnel/mod.rs +++ b/src/tunnel/mod.rs @@ -2,21 +2,24 @@ //! offload handling, and the handshake signaling protocol. pub mod client; +pub mod dns_proxy; pub mod offload; pub mod signaling; pub mod stream; // The server data plane creates a TUN, manages an IP pool, and routes between -// clients — none of which an iOS client app extension does. Desktop-only. -#[cfg(not(target_os = "ios"))] +// clients — none of which a mobile client (iOS extension, Android VpnService) +// does. Desktop-only. +#[cfg(not(any(target_os = "ios", target_os = "android")))] pub mod server; -// The slim Apple Network Extension connect path: drives an OS-provided utun fd, -// with routing and interface configuration owned by the iOS/macOS app extension. -#[cfg(any(target_os = "ios", target_os = "macos"))] -pub mod ios; +// The slim mobile connect path: drives an OS-provided tun fd, with routing and +// interface configuration owned by the iOS/macOS app extension or the Android +// VpnService. +#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))] +pub mod mobile; -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] pub use client::VpnClient; -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] pub use server::VpnServer;