diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 991b5e0..7459335 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -249,9 +249,10 @@ jobs: - run: npm ci - # The Android core is libXray's prebuilt gomobile AAR (Xray-core in-process); no - # tun2socks and no per-ABI native build - the fd goes straight to Xray's tun inbound. - - name: Fetch the Android Xray core (libXray AAR) + # The same official Xray-core binary the desktop builds use, one per shipped + # ABI, packaged as libxray.so; no tun2socks and no per-ABI native build - the + # tun fd goes straight to Xray's tun inbound. + - name: Fetch the Android Xray core run: npm run fetch-core:android - name: Assemble the APK diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a8f1e05..354c368 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -148,9 +148,10 @@ jobs: - run: npm ci - # The Android core is libXray's prebuilt gomobile AAR (Xray-core in-process); no - # tun2socks and no per-ABI native build - the fd goes straight to Xray's tun inbound. - - name: Fetch the Android Xray core (libXray AAR) + # The same official Xray-core binary the desktop builds use, one per shipped + # ABI, packaged as libxray.so; no tun2socks and no per-ABI native build - the + # tun fd goes straight to Xray's tun inbound. + - name: Fetch the Android Xray core run: npm run fetch-core:android # Android refuses to install an unsigned APK. When these secrets are absent the diff --git a/.gitignore b/.gitignore index 1ef02a9..314c857 100644 --- a/.gitignore +++ b/.gitignore @@ -25,9 +25,9 @@ src-tauri/.updater-key.pem.pub # against its .dgst), not committed. The Windows core (app-xray.exe + wintun.dll) stays # committed for now. src-tauri/assets/app-xray -# Android runs Xray in-process via libXray's gomobile AAR (fetch-core.mjs --android). It is -# ~90 MB and versioned upstream, so it is fetched into app/libs, not committed. -src-tauri/gen/android/app/libs/libXray.aar +# Android ships the same Xray-core binary as libxray.so, one per ABI (fetch-core.mjs +# --android). Fetched into app/xrayLibs like every other core, not committed. +src-tauri/gen/android/app/xrayLibs/ # Android release signing. The keystore and its passwords must never be committed - CI # supplies them from secrets instead. diff --git a/docs/CROSS-PLATFORM.md b/docs/CROSS-PLATFORM.md index e2cc4c2..639076f 100644 --- a/docs/CROSS-PLATFORM.md +++ b/docs/CROSS-PLATFORM.md @@ -10,7 +10,7 @@ platforms get built. (For *releasing*, see `RELEASING.md`.) | **Windows** | `platform/windows.rs` | shipping - NSIS installer, signed, auto-updates | | **Linux** | `platform/unix.rs` (pkexec) | shipping - `.deb` on the release | | **macOS** | `platform/unix.rs` (osascript) | supported, but no release artifact yet - the release workflow has no macOS job, because an unsigned, un-notarized `.app` is Gatekeeper-blocked. Build from source until an Apple Developer account is in place. | -| **Android** | `vpn` module + libXray AAR | shipping - `.apk` on the release | +| **Android** | `vpn` module + spawned Xray binary | shipping - `.apk` on the release | | **iOS** | - | not supported | ## How it works @@ -65,33 +65,39 @@ macOS]`). --- -## Android (implemented — in-process libXray) +## Android (implemented — the stock Xray binary, spawned with the tun fd) Android can't reuse the desktop model (the OS owns the VPN via `VpnService`, and apps can't freely exec binaries). See `src-tauri/mobile/README.md` for the implemented design; this is the architecture rationale. -**Why in-process, not the tun2socks split we first planned:** the original plan was a -v2rayNG-style bridge — `VpnService` establishes the tun, a tun2socks lib (loaded in-process -via JNI) forwards the fd into a spawned Xray's SOCKS inbound. It was abandoned once the real -constraint was measured: a spawned Xray can't receive the tun fd at all. Android's -`ProcessBuilder` closes every fd ≥ 3 across `exec` (verified on-device, even with -`FD_CLOEXEC` cleared), so `XRAY_TUN_FD` is meaningless to a child process. That left two -in-process options, and running Xray itself in-process (via libXray) is strictly simpler -than keeping a spawned Xray plus an in-process tun2socks — and lets Xray's own `tun` inbound -do the layer-3 work, so no tun2socks at all. +**Why not the tun2socks split we first planned:** the original plan was a v2rayNG-style +bridge — `VpnService` establishes the tun, a tun2socks lib (loaded in-process via JNI) +forwards the fd into a spawned Xray's SOCKS inbound. Unnecessary: Xray's own `tun` inbound +does the layer-3 work given the fd, which `XRAY_TUN_FD` names. + +**Why the spawn is native, not Kotlin:** Java's `ProcessBuilder` closes every fd ≥ 3 across +`exec` (measured on-device, even with `FD_CLOEXEC` cleared), so a core started from Kotlin +would find nothing behind the number. That is a `ProcessBuilder` behaviour, not a kernel one +— a native `fork`/`exec` inherits whatever isn't marked `FD_CLOEXEC`, and `dup()` returns a +copy with that flag cleared. So the spawn lives in Rust, and the app ships the same official +Xray-core binary as every other platform instead of a gomobile binding. **Architecture (as built):** -- Xray runs in-process through [libXray](https://github.com/XTLS/libXray)'s gomobile AAR - (`fetch-core.mjs --android` → `gen/android/app/libs/libXray.aar`, gitignored). No jniLibs - Xray binary, no tun2socks, no NDK build for the core. +- The core is `Xray-android-` from the official release, installed as + `gen/android/app/xrayLibs//libxray.so` by `fetch-core.mjs --android` (gitignored) and + packaged with `useLegacyPackaging` so it lands in `nativeLibraryDir` — since API 29 the + only directory an app may exec from. No tun2socks, no NDK build for the core. - `TunnelService` (Kotlin): consent → `Builder` (addr/route `0.0.0.0/0`, DNS, MTU) → - `establish()` → tun fd → injected into the config root `env` as `xray.tun.fd` → - `LibXray.invoke(runXrayFromJson)`. Xray's `tun` inbound reads the fd and proxies out; - its outbound sockets are `protect()`-ed via libXray's `DialerController` callback. + `establish()` → tun fd → `XrayCore.start` → `android_core.rs` dups the fd and execs the + core with `XRAY_TUN_FD` and `XRAY_LOCATION_ASSET` set. Xray's `tun` inbound reads the fd + and proxies out. +- The core's own uplink stays out of the tunnel by uid, not by socket: `protect()` can't + reach another process, so `TunnelService` excludes this package from the VPN and the child + inherits that exclusion. - `buildMobileConfig` (`config.ts`) emits the `tun` inbound (not a SOCKS inbound) plus a loopback `metrics` listener; `TunnelService` polls `/debug/vars` for traffic counters, - since there's no binary to run `xray api statsquery` against. + which works across the process boundary because it is loopback. - Plugin `disruptor-vpn` (`vpn.rs` inline plugin + `VpnPlugin.kt`) exposes `start/stop/traffic`; `capabilities/mobile.json` grants it. TCP ping stays a socket connect. diff --git a/scripts/fetch-core.mjs b/scripts/fetch-core.mjs index 01b2536..530bb07 100644 --- a/scripts/fetch-core.mjs +++ b/scripts/fetch-core.mjs @@ -1,20 +1,19 @@ #!/usr/bin/env node // Fetches the Xray core, verified against the release's own .dgst checksum. // -// Desktop cores are the Xray-core CLI binary and land in src-tauri/assets. Android is -// different: a VpnService tun fd can only reach Xray inside the app's own process (a -// spawned binary never inherits it - Android's ProcessBuilder closes it across exec), so -// Android runs Xray IN-PROCESS via libXray's gomobile AAR instead of a libxray.so binary. -// `--android` fetches that AAR into gen/android/app/libs. The Windows core is committed -// already; everything else is fetched (and gitignored) by CI and a fresh clone before a -// build. iOS uses an xcframework built with gomobile, so it is out of scope here. +// Desktop cores are the Xray-core CLI binary and land in src-tauri/assets. Android gets the +// same official binary, one per shipped ABI, installed as `libxray.so` into a jniLibs source +// dir so the APK carries it into nativeLibraryDir - the only place Android lets an app exec a +// file. `--android` does that for both ABIs at once. The Windows core is committed already; +// everything else is fetched (and gitignored) by CI and a fresh clone before a build. iOS +// uses an xcframework built with gomobile, so it is out of scope here. // // Usage: // node scripts/fetch-core.mjs # the host desktop platform // node scripts/fetch-core.mjs --target linux-64 -// node scripts/fetch-core.mjs --android # libXray AAR -> app/libs +// node scripts/fetch-core.mjs --android # both Android ABIs -> app/xrayLibs // node scripts/fetch-core.mjs --target linux-64 --version v1.8.4 -// node scripts/fetch-core.mjs --android --libxray-version v26.7.11 +// node scripts/fetch-core.mjs --android --version v26.3.27 import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; @@ -26,15 +25,16 @@ import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ASSETS = path.join(ROOT, 'src-tauri', 'assets'); -const LIBS = path.join(ROOT, 'src-tauri', 'gen', 'android', 'app', 'libs'); +const XRAY_LIBS = path.join(ROOT, 'src-tauri', 'gen', 'android', 'app', 'xrayLibs'); const RELEASES = 'https://github.com/XTLS/Xray-core/releases'; -// libXray ships Xray-core as a prebuilt gomobile AAR. Pinned rather than "latest" so a -// build is reproducible and an upstream change can't silently alter the bundled core; -// bump deliberately (and re-test the tunnel on a device - the tun/fd path is version- -// sensitive). Overridable with --libxray-version. -const LIBXRAY_RELEASES = 'https://github.com/XTLS/libXray/releases'; -const LIBXRAY_VERSION = 'v26.7.11'; +// The ABIs the APK ships, and the release asset that carries the core for each. Kept in +// step with `abiFilters` in gen/android/app/build.gradle.kts: an ABI with no core here +// would install fine and then fail to start the tunnel. +const ANDROID_ABIS = [ + { abi: 'arm64-v8a', target: 'android-arm64-v8a' }, + { abi: 'x86_64', target: 'android-amd64' } +]; function fail(message) { @@ -158,37 +158,35 @@ async function install(target, version) } } -/** Fetches the libXray gomobile AAR and installs it into gen/android/app/libs. Unlike the - * desktop cores, libXray publishes no .dgst, so this pins the version and records the - * sha256 of what it fetched over HTTPS rather than verifying against a published digest. */ -async function installAndroidAar(version) +/** Installs the Android core for every shipped ABI into app/xrayLibs, named `libxray.so`. + * The `.so` name is not a library - it is the plain Xray-core executable wearing the only + * extension Android's packager will carry into nativeLibraryDir, which since API 29 is the + * one directory an app is allowed to exec from. */ +async function installAndroid(version) { - const tag = version ?? LIBXRAY_VERSION; - const zipName = 'libxray-android.zip'; - - console.log(`fetch-core: ${ zipName } (${ tag })`); - - const zip = await download(`${ LIBXRAY_RELEASES }/download/${ tag }/${ zipName }`); - console.log(`fetch-core: sha256 ${ createHash('sha256').update(zip).digest('hex') }`); - - const work = mkdtempSync(path.join(tmpdir(), 'libxray-')); - - try + for (const { abi, target } of ANDROID_ABIS) { - const zipPath = path.join(work, zipName); - writeFileSync(zipPath, zip); - unzip(zipPath, work); - - // The archive nests the AAR one directory down (libxray-android/libXray.aar). - const aar = path.join(work, 'libxray-android', 'libXray.aar'); - mkdirSync(LIBS, { recursive: true }); - copyFileSync(aar, path.join(LIBS, 'libXray.aar')); + const work = mkdtempSync(path.join(tmpdir(), 'xray-core-')); - console.log('fetch-core: wrote gen/android/.../app/libs/libXray.aar'); - } - finally - { - rmSync(work, { recursive: true, force: true }); + try + { + const core = await fetchAndVerify(target, version, work); + const dir = path.join(XRAY_LIBS, abi); + mkdirSync(dir, { recursive: true }); + const dest = path.join(dir, 'libxray.so'); + copyFileSync(core, dest); + + if (process.platform !== 'win32') + { + chmodSync(dest, 0o755); + } + + console.log(`fetch-core: wrote gen/android/.../app/xrayLibs/${ abi }/libxray.so`); + } + finally + { + rmSync(work, { recursive: true, force: true }); + } } } @@ -196,7 +194,7 @@ async function main() { if (process.argv.includes('--android')) { - await installAndroidAar(arg('--libxray-version')); + await installAndroid(arg('--version')); return; } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2562474..c8e8343 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -642,6 +642,8 @@ dependencies = [ name = "disruptor-proxy" version = "1.7.0" dependencies = [ + "jni 0.21.1", + "libc", "reqwest 0.12.28", "rustls", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5596c96..7965d24 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,13 @@ reqwest = { version = "0.12", default-features = false, features = [ "socks", "r # the updater already pulls in, so this adds nothing to the build. rustls = { version = "0.23", default-features = false, features = [ "ring" ] } +# The Xray core runs as a child process holding the VpnService tun fd, which Kotlin cannot +# hand it (see src/android_core.rs). `jni` matches the version tauri already builds against; +# `libc` is only here for dup/close on the fd. +[target.'cfg(target_os = "android")'.dependencies] +jni = "0.21" +libc = "0.2" + [target.'cfg(windows)'.dependencies] windows = { version = "0.62", features = [ "Win32_Foundation", diff --git a/src-tauri/gen/android/app/build.gradle.kts b/src-tauri/gen/android/app/build.gradle.kts index 0b8501f..5c5da69 100644 --- a/src-tauri/gen/android/app/build.gradle.kts +++ b/src-tauri/gen/android/app/build.gradle.kts @@ -39,13 +39,24 @@ android { targetSdk = 36 versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") - // The two ABIs fetch-core installs the core for. The libXray AAR ships libgojni.so - // for all four, so without this the APK would also carry ~100 MB of armeabi-v7a and - // x86 native code no shipped device uses. + // The two ABIs fetch-core installs the core for (see ANDROID_ABIS there). The Rust + // lib builds for four; shipping the other two would mean an APK that installs on a + // device and then has no libxray.so to run. ndk { abiFilters += listOf("arm64-v8a", "x86_64") } } + + // The Xray core, fetched per ABI by `npm run fetch-core:android`. Kept out of + // src/main/jniLibs, which the Tauri gradle plugin owns (it symlinks the Rust lib in). + sourceSets.getByName("main").jniLibs.srcDir("xrayLibs") + + packaging { + // libxray.so is an executable, not a library: it has to exist as a real file in + // nativeLibraryDir for the core to be exec'd, and that only happens when native + // libs are extracted at install time rather than left compressed in the APK. + jniLibs.useLegacyPackaging = true + } signingConfigs { create("release") { if (keystorePath != null) { @@ -92,20 +103,7 @@ rust { rootDirRel = "../../../" } -repositories { - // The libXray gomobile AAR is a local artifact fetched by scripts/fetch-core.mjs into - // app/libs (gitignored, not committed), so it is resolved from there rather than a - // remote repository. - flatDir { dirs("libs") } -} - dependencies { - // Xray-core, in-process. On Android a VpnService fd can only reach Xray inside the app's - // own process (a spawned binary never inherits it - ProcessBuilder closes it across - // exec), so the core runs through libXray's gomobile bindings rather than as an exec'd - // libxray.so. TunnelService drives it via LibXray.invoke(...). - implementation(":libXray@aar") - implementation("androidx.webkit:webkit:1.14.0") implementation("androidx.appcompat:appcompat:1.7.1") implementation("androidx.activity:activity-ktx:1.10.1") diff --git a/src-tauri/gen/android/app/src/main/java/io/disruptorproxy/client/TunnelService.kt b/src-tauri/gen/android/app/src/main/java/io/disruptorproxy/client/TunnelService.kt index 6cfed5d..08fb4e7 100644 --- a/src-tauri/gen/android/app/src/main/java/io/disruptorproxy/client/TunnelService.kt +++ b/src-tauri/gen/android/app/src/main/java/io/disruptorproxy/client/TunnelService.kt @@ -2,20 +2,19 @@ package io.disruptorproxy.client // The Android VPN tunnel for Disruptor Proxy. // -// Xray runs IN THIS PROCESS, via libXray's gomobile bindings (see app/libs/libXray.aar, -// fetched by scripts/fetch-core.mjs). That is not a preference but a hard constraint: the -// tun fd VpnService hands us is only valid inside our own process, and Android's -// ProcessBuilder closes every fd >= 3 across exec - so a spawned libxray.so binary could -// never receive it, no matter how XRAY_TUN_FD is passed. Running the core in-process lets -// Xray's own `tun` inbound read the fd (from the config's `env`) and do the layer-3 work -// itself, which is why this app no longer needs a tun2socks bridge. +// The core is the stock Xray-core binary, shipped as libxray.so and run as a child process +// (see XrayCore / src-tauri/src/android_core.rs). Xray's own `tun` inbound does the layer-3 +// work, reading the descriptor VpnService hands us out of XRAY_TUN_FD, so this app needs no +// tun2socks bridge. Passing a live fd to a child is the one thing Kotlin cannot do here - +// ProcessBuilder closes every descriptor above stderr before exec - which is why the spawn +// happens natively on the Rust side. // -// VpnService.establish() -> tun fd -> config env xray.tun.fd -> in-process Xray tun inbound +// VpnService.establish() -> tun fd -> XRAY_TUN_FD -> Xray tun inbound (child process) // // Xray's outbound sockets (its connection to the proxy server) must stay OUT of the tunnel -// they serve, or the connection loops back on itself. VpnService.protect() marks a socket to -// bypass the VPN; libXray calls back into `protect()` for every outbound socket through the -// DialerController registered below. +// they serve, or the connection loops back on itself. VpnService.protect() cannot reach +// another process's sockets, so the tunnel excludes this package instead - a uid rule, and +// the core runs under our uid, so it covers the core too. See startTunnel. import android.app.Notification import android.app.NotificationChannel @@ -26,9 +25,8 @@ import android.net.VpnService import android.os.Build import android.os.ParcelFileDescriptor import androidx.core.app.ServiceCompat -import libXray.DialerController -import libXray.LibXray import org.json.JSONObject +import java.io.File import java.net.HttpURLConnection import java.net.URL @@ -45,6 +43,14 @@ class TunnelService : VpnService() { private const val NOTIFICATION_ID = 1 private const val CHANNEL_ID = "vpn" + /** The core, as packaged by scripts/fetch-core.mjs. An `.so` name is what gets it + * into nativeLibraryDir, the only directory Android lets an app exec from. */ + private const val CORE_LIB = "libxray.so" + + /** Written under filesDir (app-private) because the core is a separate process and + * reads its config from a path, not from memory. It holds server credentials. */ + private const val CONFIG_FILE = "tunnel-config.json" + @Volatile var running: Boolean = false private set @@ -81,11 +87,6 @@ class TunnelService : VpnService() { private var tun: ParcelFileDescriptor? = null - // Kept as a field so gomobile's Java-side reference survives for the tunnel's lifetime. - // Returns whether the socket was successfully excluded from the VPN; xray-core logs and - // proceeds either way, but a false here would mean a routing loop, so it should hold. - private val dialerController = DialerController { fd -> protect(fd.toInt()) } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_STOP -> { @@ -105,8 +106,9 @@ class TunnelService : VpnService() { } // Route the whole device, but exclude ourselves: Xray's own connection to the server - // must not be pulled back into the tunnel it is serving (protect() guards the - // individual sockets; this keeps our other traffic out too). + // must not be pulled back into the tunnel it is serving. The exclusion is by uid, so + // it covers the core's child process as well - which is the only thing keeping that + // loop from forming, since protect() cannot reach another process's sockets. val builder = Builder() .setSession("Disruptor Proxy") .setMtu(1500) @@ -117,7 +119,11 @@ class TunnelService : VpnService() { try { builder.addDisallowedApplication(packageName) } catch (_: Exception) { - // Our own package is always installed; ignore. + // Our own package is always installed, so this should not be reachable - but a + // tunnel without the exclusion would route the core's uplink into itself, so + // give up rather than bring up something that cannot work. + stopSelf() + return } val descriptor = builder.establish() @@ -138,57 +144,38 @@ class TunnelService : VpnService() { } ServiceCompat.startForeground(this, NOTIFICATION_ID, notification(), type) - // libXray asks us to protect each outbound socket it dials, so Xray's uplink bypasses - // the tunnel. Registered before the core starts so the very first dial is covered. - LibXray.registerDialerController(dialerController) - - val configWithEnv = injectRuntimeEnv(configJson, descriptor.fd) + val config = File(filesDir, CONFIG_FILE) + config.writeText(configJson) running = true - // invoke(runXrayFromJson) returns once the core has started, but loading geo data can - // take a moment, so keep it off the service's main thread. A start failure (bad - // config, missing geo files) tears the half-open tunnel back down. + // start() blocks long enough to see the core reject its config, and loading geo data + // can take a moment more, so keep it off the service's main thread. A start failure + // (bad config, missing geo files) tears the half-open tunnel back down. Thread({ - val response = LibXray.invoke(runXrayFromJsonRequest(configWithEnv)) - if (!JSONObject(response).optBoolean("success", false)) { + val started = XrayCore.start( + File(applicationInfo.nativeLibraryDir, CORE_LIB).absolutePath, + config.absolutePath, + filesDir.absolutePath, + descriptor.fd + ) + + if (!started) { stopTunnel() stopSelf() } }, "xray").start() } - /** Adds the two runtime-only values Xray needs into the config root `env` (which - * xray-core applies with os.Setenv at load): the tun fd, known only after establish(), - * and the asset dir where geoip/geosite live. */ - private fun injectRuntimeEnv(configJson: String, fd: Int): String { - val config = JSONObject(configJson) - val env = config.optJSONObject("env") ?: JSONObject() - env.put("xray.tun.fd", fd.toString()) - env.put("xray.location.asset", applicationContext.filesDir.absolutePath) - config.put("env", env) - return config.toString() - } - - private fun runXrayFromJsonRequest(configJson: String): String = - JSONObject() - .put("apiVersion", 1) - .put("method", "runXrayFromJson") - .put("payload", JSONObject().put("configJSON", configJson)) - .toString() - - private fun stopXrayRequest(): String = - JSONObject() - .put("apiVersion", 1) - .put("method", "stopXray") - .toString() - private fun stopTunnel() { if (running) { - LibXray.invoke(stopXrayRequest()) + XrayCore.stop() } running = false + // The core is gone, so the config has no reader left; it holds server credentials. + File(filesDir, CONFIG_FILE).delete() + try { tun?.close() } catch (_: Exception) { diff --git a/src-tauri/gen/android/app/src/main/java/io/disruptorproxy/client/XrayCore.kt b/src-tauri/gen/android/app/src/main/java/io/disruptorproxy/client/XrayCore.kt new file mode 100644 index 0000000..f7fda80 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/io/disruptorproxy/client/XrayCore.kt @@ -0,0 +1,32 @@ +package io.disruptorproxy.client + +// The Xray core, run as a child process. +// +// Everything here is implemented in src-tauri/src/android_core.rs. It has to be: the core +// needs the VpnService tun fd, and Java's ProcessBuilder closes every descriptor above +// stderr before exec, so a core started from Kotlin could never receive it. The Rust side +// spawns natively and passes the fd through as XRAY_TUN_FD. +// +// The library is the app's own Tauri lib, already loaded by the time the UI runs; +// loadLibrary here covers the case where the system restarts TunnelService on its own. + +object XrayCore { + init { + System.loadLibrary("disruptor_proxy_lib") + } + + /** + * Starts `binary` on the config at `configPath`, with `assetDir` holding geoip/geosite + * and `tunFd` the descriptor from VpnService.establish(). The fd is only duplicated, + * so the caller keeps ownership of it. + * + * Returns false if the core failed to start or exited straight away on a bad config. + * Blocks for a moment to find that out, so call it off the main thread. + */ + @JvmStatic + external fun start(binary: String, configPath: String, assetDir: String, tunFd: Int): Boolean + + /** Stops the core if one is running; a no-op otherwise. */ + @JvmStatic + external fun stop() +} diff --git a/src-tauri/mobile/README.md b/src-tauri/mobile/README.md index 51c242c..a531280 100644 --- a/src-tauri/mobile/README.md +++ b/src-tauri/mobile/README.md @@ -2,20 +2,21 @@ ## Android — implemented and verified on a device -The Android tunnel is wired end to end and has run on hardware: consent dialog → foreground -service → in-process Xray (`core: Xray … started`) → the native `tun` inbound accepting the -VpnService fd's packets (`[tun-in -> proxy]`) with real traffic flowing. +The Android tunnel is wired end to end: consent dialog → foreground service → the Xray core +as a child process → its native `tun` inbound accepting the VpnService fd's packets +(`[tun-in -> proxy]`) with real traffic flowing. | Piece | Where | | --- | --- | | Plugin commands (Rust) | `src-tauri/src/vpn.rs` — inline plugin, registered in `lib.rs` | +| Core spawner (Rust) | `src-tauri/src/android_core.rs` — JNI, called from `XrayCore.kt` | | Permission declaration | `src-tauri/build.rs` (`InlinedPlugin`) → `disruptor-vpn:*` | | ACL grant | `src-tauri/capabilities/mobile.json` | | Plugin bridge (Kotlin) | `gen/android/app/src/main/java/io/disruptorproxy/client/VpnPlugin.kt` | | The tunnel (Kotlin) | `…/TunnelService.kt` | | Manifest service + permissions | `gen/android/app/src/main/AndroidManifest.xml` | | Release signing | `gen/android/app/build.gradle.kts` (`keystore.properties` or env) | -| Core (libXray AAR) | `npm run fetch-core:android` → `gen/android/app/libs/libXray.aar` | +| Core (per ABI) | `npm run fetch-core:android` → `gen/android/app/xrayLibs//libxray.so` | ### The command contract (unchanged, already wired on the JS side) @@ -28,33 +29,48 @@ VpnService fd's packets (`[tun-in -> proxy]`) with real traffic flowing. ### How the packets actually flow ``` -VpnService.establish() → tun fd → config env xray.tun.fd → in-process Xray `tun` inbound → server +VpnService.establish() → tun fd → dup() → exec libxray.so with XRAY_TUN_FD → Xray `tun` inbound → server ``` -Xray runs **in this app's process**, via [libXray](https://github.com/XTLS/libXray)'s -gomobile AAR. That is forced, not chosen: the tun fd from `VpnService.establish()` is only -valid inside our own process, and Android's `ProcessBuilder` closes every fd ≥ 3 across -`exec` (verified on-device — even with `FD_CLOEXEC` cleared), so a *spawned* `libxray.so` -could never receive it through `XRAY_TUN_FD`. In-process, Xray's own `tun` inbound reads the -fd (from the config root `env` that `TunnelService` injects) and does the layer-3 work -itself — so there is **no tun2socks bridge** anymore. +The core is the **stock Xray-core binary** — the same artifact the desktop builds use, +shipped per ABI as `libxray.so` — run as a child process. Xray's own `tun` inbound reads the +descriptor named by `XRAY_TUN_FD` and does the layer-3 work itself, so there is **no +tun2socks bridge** and no gomobile binding. -Xray's outbound sockets (its connection to the server) must stay out of the tunnel they -serve. libXray calls back through a `DialerController` for every outbound socket; the -callback runs `VpnService.protect()` on it. Stats can't use the desktop `xray api -statsquery` path (no binary to exec), so `buildMobileConfig` adds a loopback `metrics` -listener and `TunnelService` polls its `/debug/vars` expvar for `outbound>>>proxy>>>traffic`. +Getting a live fd to that child is the whole trick, and it is why the spawn happens in Rust +(`android_core.rs`) rather than Kotlin. Java's `ProcessBuilder` closes every fd ≥ 3 before +`exec`, so a core started from Kotlin would find nothing behind the number no matter what +`XRAY_TUN_FD` says — that much was measured on-device. A native `fork`/`exec` does not close +anything: `dup()` hands back a copy with `FD_CLOEXEC` cleared, the copy survives `exec` under +the same number, and that number is what goes into `XRAY_TUN_FD`. + +`libxray.so` is an executable wearing a library's name. Since API 29 the only directory an +app may `exec` from is `nativeLibraryDir`, and the only way into it is the APK's `lib//` +— hence the name, plus `useLegacyPackaging = true` so the file is actually extracted at +install instead of being left compressed inside the APK. -The AAR is libXray's prebuilt, self-contained gomobile binding (`libgojni.so` per ABI, Xray -statically linked). `npm run fetch-core:android` downloads and unpacks it into `app/libs` -(gitignored); no NDK and no per-ABI native build are involved. +Xray's outbound sockets (its connection to the server) must stay out of the tunnel they +serve. `VpnService.protect()` cannot reach another process's sockets, so the exclusion is +done once, by uid: `TunnelService` calls `addDisallowedApplication(packageName)`, and the +core runs under this app's uid. If that call ever fails the tunnel is abandoned rather than +brought up, because the alternative is a routing loop. Stats can't use the desktop `xray api +statsquery` path, so `buildMobileConfig` adds a loopback `metrics` listener and +`TunnelService` polls its `/debug/vars` expvar for `outbound>>>proxy>>>traffic` — loopback, +so the process boundary doesn't matter. + +`npm run fetch-core:android` downloads `Xray-android-arm64-v8a.zip` and +`Xray-android-amd64.zip`, verifies each against its published `.dgst`, and installs the +binaries into `app/xrayLibs//libxray.so` (gitignored). No NDK, no gomobile, no per-ABI +native build. ### What to expect on first run Verify, in order: the VPN consent dialog appears → key icon in the status bar → your public IP changes → traffic counters move → `stop` tears the tunnel down → it survives backgrounding -and rotation. Watch `adb`/`npm run android-log` for `RustStdoutStderr`: a bad config or -missing geo data surfaces as a libXray `invoke` returning `success:false` with the Xray error. +and rotation. The core's own output goes nowhere (its stdio is `/dev/null`), so a bad config +or missing geo data shows up as the core exiting during its startup grace period — +`XrayCore.start` returns false and the half-open tunnel is torn back down. Watch +`adb`/`npm run android-log` for the service's side of that. ### Signing diff --git a/src-tauri/src/android_core.rs b/src-tauri/src/android_core.rs new file mode 100644 index 0000000..bcce48b --- /dev/null +++ b/src-tauri/src/android_core.rs @@ -0,0 +1,122 @@ +//! Runs the Xray core as a child process on Android, with the VpnService tun fd attached. +//! +//! Xray's `tun` inbound takes the fd from the `XRAY_TUN_FD` environment variable - it can't +//! create the interface itself on Android, the OS owns it. Handing a live fd to a child is +//! the whole problem this module solves: Java's `ProcessBuilder` closes every descriptor +//! above stderr before exec, so a core spawned from Kotlin would find nothing behind the +//! number. Spawning natively does work, because `dup()` returns a copy with `FD_CLOEXEC` +//! cleared, and that copy survives exec under the same number `XRAY_TUN_FD` names. +//! +//! VpnService.establish() -> fd -> dup() -> exec libxray.so with XRAY_TUN_FD= +//! +//! The core's own uplink must stay out of the tunnel it serves, and `VpnService.protect()` +//! only reaches sockets in the app's own process. It doesn't need to: the child runs under +//! this app's uid, and `TunnelService` excludes this package from the VPN, which is a uid +//! rule - so the exclusion covers the core's sockets and the routing loop never forms. +//! +//! Called from Kotlin through `XrayCore` (gen/android/.../XrayCore.kt). + +use std::process::{Child, Command, Stdio}; +use std::sync::{Mutex, MutexGuard}; +use std::time::Duration; + +use jni::objects::{JClass, JString}; +use jni::sys::{jboolean, jint}; +use jni::JNIEnv; + +/// A bad config or a missing geo file makes Xray exit almost immediately. Waiting this long +/// before declaring the core up turns that into a `false` from `start`, so the caller can +/// tear the half-open tunnel back down instead of leaving the device with no route. +const STARTUP_GRACE: Duration = Duration::from_millis(500); + +/// The running core, kept so `stop` can kill and reap the child it started. +static CORE: Mutex> = Mutex::new(None); + +/// `panic = "abort"` makes a poisoned lock unreachable, but unwinding across the JNI +/// boundary would be worse than working with whatever state is there. +fn core() -> MutexGuard<'static, Option> { + CORE.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn read(env: &mut JNIEnv, value: &JString) -> Option { + env.get_string(value).ok().map(Into::into) +} + +fn stop_core(slot: &mut Option) { + if let Some(mut child) = slot.take() { + let _ = child.kill(); + let _ = child.wait(); + } +} + +/// Starts `binary` on `config`, giving it `tun_fd` and the directory holding geoip/geosite. +/// Returns whether the core was still alive once it had a chance to reject its config. +/// +/// The fd stays owned by the caller - only a dup of it is handed on. +#[no_mangle] +pub extern "system" fn Java_io_disruptorproxy_client_XrayCore_start( + mut env: JNIEnv, + _class: JClass, + binary: JString, + config: JString, + asset_dir: JString, + tun_fd: jint, +) -> jboolean { + let (Some(binary), Some(config), Some(asset_dir)) = ( + read(&mut env, &binary), + read(&mut env, &config), + read(&mut env, &asset_dir), + ) else { + return 0; + }; + + let mut slot = core(); + + // A second start with a core already running would orphan the first one. + stop_core(&mut slot); + + // The copy the child inherits: dup() clears FD_CLOEXEC, which is exactly what lets it + // through exec. Same number on both sides, so XRAY_TUN_FD can just name it. + let fd = unsafe { libc::dup(tun_fd) }; + + if fd < 0 { + return 0; + } + + let spawned = Command::new(binary) + .arg("run") + .arg("-c") + .arg(config) + .env("XRAY_TUN_FD", fd.to_string()) + .env("XRAY_LOCATION_ASSET", asset_dir) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + + // Ours was only ever the template for the child's copy; holding it open would keep the + // tun alive past `ParcelFileDescriptor.close()`. + unsafe { libc::close(fd) }; + + let Ok(mut child) = spawned else { + return 0; + }; + + std::thread::sleep(STARTUP_GRACE); + + // Some(status) means it has already exited - it read the config and refused it. + if matches!(child.try_wait(), Ok(Some(_)) | Err(_)) { + let _ = child.wait(); + return 0; + } + + *slot = Some(child); + + 1 +} + +/// Stops the core, if one is running. Safe to call when none is. +#[no_mangle] +pub extern "system" fn Java_io_disruptorproxy_client_XrayCore_stop(_env: JNIEnv, _class: JClass) { + stop_core(&mut core()); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index eba19d9..40864b4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,12 @@ mod platform; #[cfg(target_os = "android")] mod vpn; +/// Spawning the core with the tunnel's fd attached. Android only, and called from Kotlin +/// over JNI rather than from anything here - see the module docs for why it isn't in +/// `vpn`'s plugin commands. +#[cfg(target_os = "android")] +mod android_core; + use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Mutex; diff --git a/src-tauri/src/vpn.rs b/src-tauri/src/vpn.rs index 9ed7488..6ae566d 100644 --- a/src-tauri/src/vpn.rs +++ b/src-tauri/src/vpn.rs @@ -6,9 +6,9 @@ //! TunnelService.kt, which own the VpnService lifecycle. //! //! Android is the mirror image of desktop: there, the app elevates a core that creates its -//! own tun device; here, the OS owns the tunnel and hands the fd to Xray (running in-process -//! via libXray), so nothing is elevated and there is no stop-file protocol - stopping is just -//! a message to the service. See TunnelService.kt for the fd/in-process details. +//! own tun device; here, the OS owns the tunnel and hands the fd to Xray, so nothing is +//! elevated and there is no stop-file protocol - stopping is just a message to the service. +//! The core itself is spawned by `android_core`, not from here. See TunnelService.kt. use serde::{Deserialize, Serialize}; use tauri::plugin::{Builder, PluginHandle, TauriPlugin}; diff --git a/src/lib/xray/config.ts b/src/lib/xray/config.ts index b6ba85d..87efd8e 100644 --- a/src/lib/xray/config.ts +++ b/src/lib/xray/config.ts @@ -53,8 +53,8 @@ const PROBE_IN_TAG = 'probe-in'; const API_PORT = 10085; /** * Loopback port Xray's metrics service serves expvar on (`/debug/vars`) during a mobile - * connection. Android runs Xray in-process via libXray, so it can't shell out to - * `xray api statsquery` the way desktop does; instead `TunnelService` reads the cumulative + * connection. Android has no writable, exec-able place to run a second `xray api statsquery` + * from the way desktop does; instead `TunnelService` reads the cumulative * `outbound>>>proxy>>>traffic` counters over HTTP from here. Loopback-only. */ const METRICS_PORT = 10086; @@ -551,16 +551,15 @@ export const buildConnectConfig = (config: ProxyConfig, rules: Rule[], allConfig /** * The mobile (Android/iOS) connect config. Like desktop it uses Xray's native `tun` * inbound, but the OS owns the interface: Android `VpnService` (and iOS - * `NEPacketTunnelProvider`) create the tun and hand Xray the fd, which the native layer - * injects into the config root as `env: { "xray.tun.fd": }` at connect time - the fd - * is only known at runtime and can't be baked in here. So the tun settings are minimal - * (just `mtu`): `name`/`gateway`/`autoSystemRoutingTable` are desktop concerns the OS owns - * on mobile, and `autoOutboundsInterface` is unsupported on Android - the proxy outbound is - * instead kept out of the tunnel by `VpnService.protect()`, wired through libXray's - * DialerController in `TunnelService`. + * `NEPacketTunnelProvider`) create the tun and hand Xray the fd through the `XRAY_TUN_FD` + * environment variable, which the native layer sets at connect time - the fd is only known + * at runtime and can't be baked in here. So the tun settings are minimal (just `mtu`): + * `name`/`gateway`/`autoSystemRoutingTable` are desktop concerns the OS owns on mobile, and + * `autoOutboundsInterface` is unsupported on Android - the proxy outbound is instead kept + * out of the tunnel by excluding this app's uid from the VPN (see `TunnelService`). * - * Stats can't go through the desktop `xray api statsquery` path (no binary to exec - * in-process), so a loopback `METRICS` listener exposes the same counters over expvar. + * Stats can't go through the desktop `xray api statsquery` path, so a loopback `METRICS` + * listener exposes the same counters over expvar. * Everything else - outbounds, routing, DNS, and the shared probe layer for testing while * connected - matches the desktop config, so one routing model serves every platform. */ @@ -578,7 +577,7 @@ export const buildMobileConfig = (config: ProxyConfig, rules: Rule[], allConfigs inbounds: [ // `port` is ignored by the tun inbound (it never listens on one), but the schema - // wants it present. The fd arrives via the root `env` the native layer injects. + // wants it present. The fd arrives via `XRAY_TUN_FD` in the core's environment. { tag: 'tun-in', protocol: 'tun', port: 0, settings: { mtu: 1500 }, sniffing: { enabled: true, destOverride: ['http', 'tls', 'quic'] } }, API_INBOUND, probe.inbound