From 96d13527284bbd9eb7c35025d524bb2e9c5d1f51 Mon Sep 17 00:00:00 2001 From: "Shin (Former Aleph)" <67456590+therealaleph@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:44:17 +0300 Subject: [PATCH] Add Android app with full TUN bridge + two proxy fixes the desktop also wants (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app is a Kotlin/Compose front-end that reuses the mhrv-rs crate via JNI. It speaks VpnService to get a TUN fd, hands that to tun2proxy, and funnels every app's traffic through the in-process SOCKS5 listener — no per-app proxy setup on the device. Two fixes in `src/proxy_server.rs` apply to desktop builds too: * SNI peek via `LazyConfigAcceptor`. When a browser uses DoH (Chrome's default), tun2proxy hands us a raw IP in the SOCKS5 CONNECT. Minting a MITM cert for the IP produced `ERR_CERT_COMMON_NAME_INVALID` on Cloudflare-fronted sites. We now read the ClientHello's SNI first and use that both as the cert subject and as the upstream host for the Apps Script relay (fetching `https:///...` with an IP in the Host header gets rejected by CF anyway). * Short-circuit CORS preflight at the MITM boundary. `UrlFetchApp.fetch()` rejects `OPTIONS` with a Swedish "Ett attribut med ogiltigt värde har angetts: method" error, which silently broke every fetch()/XHR preflight and was the root cause of "JS doesn't load" on Discord, Yahoo, and similar. Since we already terminate the TLS the browser talks to, answering the preflight with a permissive 204 is safe — the real request still goes through the relay. Android-side capabilities (feature-parity with `mhrv-rs-ui` where it fits on a phone): * multi-deployment ID editor * SNI rotation pool + per-SNI "Test" + "Test all" (JNI into scan_sni) * live logs panel (JNI ring buffer drained on a 500 ms poll) * Advanced section: verify_ssl, parallel_relay, log_level, upstream_socks5 * CA install flow that matches modern Android's reality: saves `Downloads/mhrv-ca.crt` via MediaStore, deep-links Security settings, then verifies post-hoc by fingerprint lookup in AndroidCAStore (the KeyChain intent dead-ends with a Close-only dialog on Android 11+) * Start/Stop debounced to dodge an emulator EGL renderer crash on rapid taps Theme matches the desktop palette exactly — always-dark, accent `#4678B4`, card fill `#1C1E22`, 4dp button / 6dp card radii. No dynamic color, no light scheme: the desktop is always dark and we follow. Build wiring: * `Cargo.toml`: `cdylib` crate-type added; `jni` + `tun2proxy` scoped to `cfg(target_os = "android")` so desktop builds pay nothing. * `src/data_dir.rs`: `set_data_dir()` override so the Android app's private filesDir replaces the `directories` crate's desktop default. * `src/android_jni.rs`: JNI entry points for start/stop/exportCa plus a ring buffer draining to `Native.drainLogs()` and `testSni()` that wraps `scan_sni::probe_one`. * Gradle task chain runs `cargo ndk` before each assemble; post-step normalizes tun2proxy's hash-suffixed cdylib to a stable filename so `System.loadLibrary("tun2proxy")` works. Verified end-to-end on an API 34 emulator: ipleak, yahoo, discord, cloudflare.com all render; TLS is MITM-ed under our user-installed CA; service survives rapid Stop/Start cycles. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) --- Cargo.lock | 1249 ++++++++++++++++- Cargo.toml | 17 + android/.gitignore | 8 + android/app/build.gradle.kts | 157 +++ android/app/proguard-rules.pro | 5 + android/app/src/main/AndroidManifest.xml | 54 + .../com/github/shadowsocks/bg/Tun2proxy.kt | 67 + .../java/com/therealaleph/mhrv/CaInstall.kt | 226 +++ .../java/com/therealaleph/mhrv/ConfigStore.kt | 172 +++ .../com/therealaleph/mhrv/MainActivity.kt | 142 ++ .../com/therealaleph/mhrv/MhrvVpnService.kt | 280 ++++ .../main/java/com/therealaleph/mhrv/Native.kt | 68 + .../com/therealaleph/mhrv/ui/HomeScreen.kt | 796 +++++++++++ .../com/therealaleph/mhrv/ui/theme/Theme.kt | 97 ++ .../res/drawable/ic_launcher_foreground.xml | 14 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + android/app/src/main/res/values/strings.xml | 4 + android/app/src/main/res/values/themes.xml | 12 + .../main/res/xml/data_extraction_rules.xml | 9 + .../main/res/xml/network_security_config.xml | 24 + android/build.gradle.kts | 6 + android/gradle.properties | 9 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android/gradlew | 248 ++++ android/gradlew.bat | 93 ++ android/settings.gradle.kts | 23 + src/android_jni.rs | 385 +++++ src/data_dir.rs | 20 + src/lib.rs | 3 + src/proxy_server.rs | 130 +- 32 files changed, 4301 insertions(+), 34 deletions(-) create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/com/github/shadowsocks/bg/Tun2proxy.kt create mode 100644 android/app/src/main/java/com/therealaleph/mhrv/CaInstall.kt create mode 100644 android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt create mode 100644 android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt create mode 100644 android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt create mode 100644 android/app/src/main/java/com/therealaleph/mhrv/Native.kt create mode 100644 android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt create mode 100644 android/app/src/main/java/com/therealaleph/mhrv/ui/theme/Theme.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 android/app/src/main/res/xml/network_security_config.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts create mode 100644 src/android_jni.rs diff --git a/Cargo.lock b/Cargo.lock index 8cf49ff..abb928f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,6 +84,82 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "arboard" version = "3.6.1" @@ -101,6 +177,12 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "as-raw-xcb-connection" version = "1.0.1" @@ -146,6 +228,35 @@ dependencies = [ "syn", ] +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -170,6 +281,32 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64easy" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1a336b00b6906085a0c12756be49e24698b3ce6b0dfbe517029be8cee05f84b" + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -185,6 +322,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-sys" version = "0.2.1" @@ -213,6 +359,19 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -251,6 +410,26 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "c2rust-bitfields" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcee50917f9de1a018e3f4f9a8f2ff3d030a288cffa4b18d9b391e97c12e4cfb" +dependencies = [ + "c2rust-bitfields-derive", +] + +[[package]] +name = "c2rust-bitfields-derive" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b457277798202ccd365b9c112ebee08ddd57f1033916c8b8ea52f222e5b715d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "calloop" version = "0.12.4" @@ -332,6 +511,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "cgl" version = "0.3.2" @@ -341,6 +526,69 @@ dependencies = [ "libc", ] +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cidr" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579504560394e388085d0c080ea587dfa5c15f7e251b4d5247d1e1a61d1d6928" +dependencies = [ + "serde", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -350,6 +598,12 @@ dependencies = [ "error-code", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -419,6 +673,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -428,18 +691,54 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctrlc2" +version = "3.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c336ff0159b4b18f0414b51060ee7a41ef7f93703da0862b5a655490be02aab4" +dependencies = [ + "log", + "nix 0.30.1", + "windows-sys 0.60.2", +] + [[package]] name = "cursor-icon" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "daemonize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8bfdaacb3c887a54d41bdf48d3af8873b3f5566469f8ba21b92057509f116e" +dependencies = [ + "libc", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -469,6 +768,29 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "digest_auth" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3054f4e81d395e50822796c5e99ca522e6ba7be98947d6d4b0e5e61640bdb894" +dependencies = [ + "digest", + "hex", + "md-5", + "rand 0.8.6", + "sha2", +] + [[package]] name = "directories" version = "5.0.1" @@ -523,7 +845,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading", + "libloading 0.8.9", ] [[package]] @@ -535,6 +857,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "downcast-rs" version = "1.2.1" @@ -649,6 +977,18 @@ dependencies = [ "serde", ] +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "enumn" version = "0.1.14" @@ -660,6 +1000,39 @@ 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 = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter 1.0.1", + "jiff", + "log", +] + [[package]] name = "epaint" version = "0.28.1" @@ -699,6 +1072,42 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "etherparse" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b119b9796ff800751a220394b8b3613f26dd30c48f254f6837e64c464872d1c7" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fdeflate" version = "0.3.7" @@ -730,6 +1139,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.5.0" @@ -766,12 +1181,75 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -790,12 +1268,27 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -859,7 +1352,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18fcd4ae4e86d991ad1300b8f57166e5be0c95ef1f63f3f5b827f8a164548746" dependencies = [ "bitflags 2.11.1", - "cfg_aliases", + "cfg_aliases 0.1.1", "cgl", "core-foundation 0.9.4", "dispatch", @@ -867,7 +1360,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "icrate", - "libloading", + "libloading 0.8.9", "objc2 0.4.1", "once_cell", "raw-window-handle 0.5.2", @@ -882,7 +1375,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebcdfba24f73b8412c5181e56f092b5eff16671c514ce896b258a0a64bd7735" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.1.1", "glutin", "raw-window-handle 0.5.2", "winit", @@ -936,18 +1429,73 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.4", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + [[package]] name = "http" version = "1.4.0" @@ -964,6 +1512,30 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icrate" version = "0.0.4" @@ -1098,15 +1670,66 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.0", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "ipstack" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d603c9807158f8054f56c3672c8670096580c3ec1d5bab6f27b2aca89be89117" +dependencies = [ + "ahash", + "etherparse", + "log", + "rand 0.9.4", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "jni" version = "0.21.1" @@ -1231,6 +1854,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libredox" version = "0.1.16" @@ -1291,6 +1924,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.0" @@ -1328,8 +1971,9 @@ dependencies = [ "h2", "http", "httparse", + "jni 0.21.1", "libc", - "rand", + "rand 0.8.6", "rcgen", "rustls", "rustls-pemfile", @@ -1342,6 +1986,7 @@ dependencies = [ "tokio-rustls", "tracing", "tracing-subscriber", + "tun2proxy", "webpki-roots 0.26.11", "x509-parser", ] @@ -1414,6 +2059,79 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8919612f6028ab4eacbbfe1234a9a43e3722c6e0915e7ff519066991905092" +dependencies = [ + "bitflags 2.11.1", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", + "memoffset", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -1691,6 +2409,16 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "option-ext" @@ -1717,6 +2445,12 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1740,6 +2474,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem" version = "3.0.6" @@ -1762,6 +2502,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -1801,6 +2552,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1880,8 +2646,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1891,7 +2667,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1903,6 +2689,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.5.2" @@ -1967,6 +2762,18 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1984,6 +2791,12 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "ring" version = "0.17.14" @@ -2010,6 +2823,24 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "rtnetlink" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc19f84f710fa2f337617f9bc0400260a94224bde7bae28fd8879f3771ca5784" +dependencies = [ + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "nix 0.30.1", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -2173,6 +3004,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2323,6 +3165,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "socks5-impl" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eae7c78f163b7805f66493c787d7bad4816146faf0cf655d57c78b90c383ce3" +dependencies = [ + "async-trait", + "bytes", + "percent-encoding", + "serde", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2335,6 +3191,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -2363,6 +3225,50 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2453,6 +3359,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.1" @@ -2462,6 +3383,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -2533,6 +3455,28 @@ dependencies = [ "winnow", ] +[[package]] +name = "tproxy-config" +version = "7.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4b7813b6e0ce19af2e784c14828cc05e80eff850ed4183df7b4a55e6036aeec" +dependencies = [ + "cidr", + "futures", + "libloading 0.9.0", + "log", + "nix 0.31.2", + "regex", + "resolv-conf", + "rtnetlink", + "serde", + "serde_json", + "system-configuration", + "tempfile", + "tokio", + "windows-sys 0.61.2", +] + [[package]] name = "tracing" version = "0.1.44" @@ -2601,6 +3545,90 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "tun" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ebb3e56bb60c1e6650c9317997862ab05864c358add3cfaa34b855ffae583d0" +dependencies = [ + "bytes", + "cfg-if", + "futures", + "futures-core", + "ipnet", + "libc", + "log", + "nix 0.31.2", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "windows-sys 0.61.2", + "wintun-bindings", +] + +[[package]] +name = "tun2proxy" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0576f75fd691ad86cdc4348f29fb8770037ab8140179f1f9f8f6991f7ebd2176" +dependencies = [ + "android_logger", + "async-trait", + "base64easy", + "bincode", + "chrono", + "clap", + "ctrlc2", + "daemonize", + "digest_auth", + "dotenvy", + "env_logger", + "hashlink", + "hickory-proto", + "httparse", + "ipstack", + "jni 0.22.4", + "log", + "nix 0.31.2", + "percent-encoding", + "serde", + "serde_json", + "shlex", + "socks5-impl", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tproxy-config", + "tun", + "udp-stream", + "unicase", + "url", + "windows-service", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "udp-stream" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf021324a3dc10f5b46ab1c1cf5635e6a81b5559971967b806674673a5f2a18e" +dependencies = [ + "bytes", + "log", + "tokio", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2619,6 +3647,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "url" version = "2.5.8" @@ -2637,6 +3671,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "valuable" version = "0.1.1" @@ -2649,6 +3689,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "walkdir" version = "2.5.0" @@ -2943,6 +3989,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -2974,12 +4026,76 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-service" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "193cae8e647981c35bc947fdd57ba7928b1fa0d4a79305f6dd2dc55221ac35ac" +dependencies = [ + "bitflags 2.11.1", + "widestring", + "windows-sys 0.59.0", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -3007,6 +4123,24 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -3055,13 +4189,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -3080,6 +4231,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -3098,6 +4255,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -3116,12 +4279,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -3140,6 +4315,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -3158,6 +4339,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -3176,6 +4363,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -3194,6 +4387,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winit" version = "0.29.15" @@ -3206,7 +4405,7 @@ dependencies = [ "bitflags 2.11.1", "bytemuck", "calloop 0.12.4", - "cfg_aliases", + "cfg_aliases 0.1.1", "core-foundation 0.9.4", "core-graphics", "cursor-icon", @@ -3251,6 +4450,32 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wintun-bindings" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ae04d34b8569174e849128d2e36538329a27daa79c06ed0375f2c5d6704461" +dependencies = [ + "blocking", + "c2rust-bitfields", + "futures", + "libloading 0.9.0", + "log", + "thiserror 2.0.18", + "windows-sys 0.61.2", + "winreg", +] + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -3283,7 +4508,7 @@ dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", - "libloading", + "libloading 0.8.9", "once_cell", "rustix 1.1.4", "x11rb-protocol", diff --git a/Cargo.toml b/Cargo.toml index 21f8c05..064fc05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,10 @@ license = "MIT" [lib] name = "mhrv_rs" path = "src/lib.rs" +# `cdylib` lets the Android app dlopen libmhrv_rs.so via System.loadLibrary. +# `rlib` keeps the desktop binaries linking normally — same .rlib is used +# for `mhrv-rs` and `mhrv-rs-ui` builds on macOS/Linux/Windows. +crate-type = ["rlib", "cdylib"] [[bin]] name = "mhrv-rs" @@ -62,6 +66,19 @@ eframe = { version = "0.28", default-features = false, features = [ [target.'cfg(unix)'.dependencies] libc = "0.2" +# Android-only deps: jni gives us the extern "system" wrappers used in +# src/android_jni.rs; zero cost on any other platform because the whole +# module is `#[cfg(target_os = "android")]`. +# +# tun2proxy is the TUN <-> SOCKS5 bridge — it reads raw IP packets from the +# fd VpnService hands us, runs a userspace TCP/IP stack (smoltcp under the +# hood), and funnels every TCP/UDP flow through our local SOCKS5. Without +# this, VpnService establishes a TUN device nothing reads from and all +# traffic black-holes (symptom: Chrome shows DNS_PROBE_STARTED). +[target.'cfg(target_os = "android")'.dependencies] +jni = { version = "0.21", default-features = false } +tun2proxy = { version = "0.7", default-features = false } + [dev-dependencies] # Used in mitm tests to sanity-check the cert extensions we emit. x509-parser = "0.16" diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..b83442c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,8 @@ +.gradle/ +build/ +app/build/ +app/src/main/jniLibs/ +local.properties +.idea/ +*.iml +.DS_Store diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..cdddf99 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,157 @@ +import org.gradle.api.tasks.Exec + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.therealaleph.mhrv" + compileSdk = 34 + + defaultConfig { + applicationId = "com.therealaleph.mhrv" + minSdk = 24 // Android 7.0 — covers 99%+ of live devices. + targetSdk = 34 + versionCode = 1 + versionName = "0.1.0" + + // Only arm64 for now — we can add armeabi-v7a in a second pass + // if field reports need it. Android emulators on Apple Silicon + // only run arm64 natively, so keeping things aarch64-only makes + // the dev loop fast. + ndk { + abiFilters += listOf("arm64-v8a") + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + buildConfig = true + } + + // libmhrv_rs.so is produced by `cargo ndk` in the repo root and dropped + // under app/src/main/jniLibs//. The cargoBuild task below runs + // that before each assembleDebug / assembleRelease. + sourceSets["main"].jniLibs.srcDirs("src/main/jniLibs") + + packaging { + resources.excludes += setOf( + "META-INF/AL2.0", + "META-INF/LGPL2.1", + ) + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.06.00") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.activity:activity-compose:1.9.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.2") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.2") + + // Compose UI. + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} + +// -------------------------------------------------------------------------- +// Cross-compile the Rust crate to arm64 Android and drop the .so into the +// place Android's packager looks. We hand the work off to `cargo ndk` which +// wraps the right CC / AR / linker env vars for us. +// +// This ties to the `assemble*` task so every debug/release build triggers +// a `cargo ndk` — no manual step. In CI we'd cache the target/ dir to +// avoid full rebuilds. +// -------------------------------------------------------------------------- +val rustCrateDir = rootProject.projectDir.parentFile +val jniLibsDir = file("src/main/jniLibs") + +// After cargo-ndk dumps artifacts into jniLibs/arm64-v8a/, the tun2proxy +// cdylib lands as `libtun2proxy-.so` (rustc's deps/ naming convention, +// because tun2proxy is a transitive dep not a root crate). Android's +// System.loadLibrary expects a stable name, and the hash changes between +// builds, so we normalize it to `libtun2proxy.so` here. Also deletes any +// stale hash-suffixed copies from previous builds. +fun normalizeTun2proxySo() { + val abiDir = file("src/main/jniLibs/arm64-v8a") + if (!abiDir.isDirectory) return + val hashed = abiDir.listFiles { f -> f.name.matches(Regex("libtun2proxy-[0-9a-f]+\\.so")) } + ?: emptyArray() + // Keep only the newest (release build) and rename it. + val newest = hashed.maxByOrNull { it.lastModified() } + if (newest != null) { + val target = abiDir.resolve("libtun2proxy.so") + if (target.exists()) target.delete() + newest.copyTo(target, overwrite = true) + } + hashed.forEach { it.delete() } +} + +tasks.register("cargoBuildDebug") { + group = "build" + // Intentionally ALWAYS uses --release. The Rust debug build is 80+MB + // of unoptimized object code vs 3MB with release; the 20x APK bloat is + // never worth it just for a Rust stack trace you wouldn't see in + // logcat anyway. If you need Rust debug symbols, temporarily drop + // `--release` below and accept the APK size. + description = "Cross-compile mhrv_rs for arm64-v8a (release — same as cargoBuildRelease)" + workingDir = rustCrateDir + commandLine( + "cargo", "ndk", + "-t", "arm64-v8a", + "-o", jniLibsDir.absolutePath, + "build", "--release", + ) + doLast { normalizeTun2proxySo() } +} + +tasks.register("cargoBuildRelease") { + group = "build" + description = "Cross-compile mhrv_rs for arm64-v8a (release)" + workingDir = rustCrateDir + commandLine( + "cargo", "ndk", + "-t", "arm64-v8a", + "-o", jniLibsDir.absolutePath, + "build", "--release", + ) + doLast { normalizeTun2proxySo() } +} + +// Hook the right cargo task in front of each Android build variant. +tasks.configureEach { + when (name) { + "mergeDebugJniLibFolders" -> dependsOn("cargoBuildDebug") + "mergeReleaseJniLibFolders" -> dependsOn("cargoBuildRelease") + } +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..f60d868 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,5 @@ +# Keep our JNI entry points so R8 doesn't strip them in release builds. +# These methods are declared `external` in Kotlin — the JNI linker looks +# them up by exact name at load time. +-keep class com.therealaleph.mhrv.Native { *; } +-keep class com.therealaleph.mhrv.MhrvVpnService { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8cb2a9a --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/github/shadowsocks/bg/Tun2proxy.kt b/android/app/src/main/java/com/github/shadowsocks/bg/Tun2proxy.kt new file mode 100644 index 0000000..03953be --- /dev/null +++ b/android/app/src/main/java/com/github/shadowsocks/bg/Tun2proxy.kt @@ -0,0 +1,67 @@ +package com.github.shadowsocks.bg + +/** + * JNI bridge to the tun2proxy crate's Android entry points. + * + * The tun2proxy Rust crate (already pulled in as an Android-only dep of + * libmhrv_rs) defines its C entry points under this exact package path: + * Java_com_github_shadowsocks_bg_Tun2proxy_run + * Java_com_github_shadowsocks_bg_Tun2proxy_stop + * + * That's why this file lives at `com.github.shadowsocks.bg` — we did NOT + * pick this package. Renaming it would break the JNI name mangling and + * the native functions would fail to resolve at runtime. + * + * The crate is reusing Shadowsocks-Android's original JNI convention. + * + * NOTE: the tun2proxy JNI symbols live in libtun2proxy.so (not libmhrv_rs.so). + * tun2proxy is pulled in as a Rust dep of mhrv-rs, but because nothing in + * mhrv-rs calls these symbols directly, Rust's rlib-level dead-code + * elimination drops them from libmhrv_rs.so. The cdylib variant of tun2proxy + * (which rustc builds alongside the rlib) retains them, so we ship that .so + * separately and load it explicitly here. + */ +object Tun2proxy { + + init { + System.loadLibrary("tun2proxy") + } + + /** + * Start the TUN <-> proxy bridge. + * + * @param proxyUrl e.g. "socks5://127.0.0.1:1081" + * @param tunFd raw fd from VpnService.Builder#establish().detachFd() + * @param closeFdOnDrop whether tun2proxy should close the fd on shutdown. + * We detach and hand over ownership, so this is true. + * @param tunMtu MTU to match the VpnService setMtu() call. + * @param verbosity 0=off, 1=error, 2=warn, 3=info, 4=debug, 5=trace. + * Logs land in logcat under tag "tun2proxy". + * @param dnsStrategy 0=Virtual (fake-IP DNS, tun2proxy resolves via proxy), + * 1=OverTcp (UDP DNS tunneled as TCP via proxy), + * 2=Direct (DNS goes straight through VpnService.protect). + * Virtual is the right default here: app asks DNS for + * example.com, gets a fake 198.18.x.y, tries to connect, + * tun2proxy intercepts, knows the real hostname, opens + * SOCKS5 to our proxy with "example.com:443" as target. + * Our proxy does its own resolution via the Apps Script + * relay, so this gives us end-to-end name resolution + * without leaking plaintext DNS to the ISP. + * + * Returns 0 on normal shutdown, non-zero on error. BLOCKS until the TUN + * is torn down or `stop()` is called — call this from a background thread. + */ + @JvmStatic + external fun run( + proxyUrl: String, + tunFd: Int, + closeFdOnDrop: Boolean, + tunMtu: Char, + verbosity: Int, + dnsStrategy: Int, + ): Int + + /** Signals the running `run()` to shut down. Idempotent. */ + @JvmStatic + external fun stop(): Int +} diff --git a/android/app/src/main/java/com/therealaleph/mhrv/CaInstall.kt b/android/app/src/main/java/com/therealaleph/mhrv/CaInstall.kt new file mode 100644 index 0000000..9052bfb --- /dev/null +++ b/android/app/src/main/java/com/therealaleph/mhrv/CaInstall.kt @@ -0,0 +1,226 @@ +package com.therealaleph.mhrv + +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.provider.Settings +import android.security.KeyChain +import android.util.Base64 +import java.io.File +import java.security.KeyStore +import java.security.MessageDigest +import java.security.cert.CertificateFactory + +/** + * Helpers for the MITM-CA install UX. + * + * The flow has three steps: + * 1. `export()` — copy the CA cert from the Rust-managed dir + * (`/ca/ca.crt`) to a stable location (`/ca.crt`) + * the UI can hand to Android's system picker. + * 2. `buildInstallIntent()` — build a `KeyChain.createInstallIntent` loaded + * with the cert's DER bytes. Passing the bytes via `EXTRA_CERTIFICATE` + * is cleaner than handing Android a file Uri: no content-provider + * plumbing, no external-storage permission, and Android resolves the + * "VPN and apps" / "Wi-Fi" category on its own. + * 3. `isInstalled()` — after the system dialog returns we can't rely on + * the `resultCode` (Android 11+ opens a Settings activity that always + * returns `RESULT_CANCELED`), so we walk `AndroidCAStore` looking for + * a cert whose SHA-256 fingerprint matches ours. That keystore spans + * both system and user-installed CAs, so it's the ground truth. + */ +object CaInstall { + private const val CA_FILENAME = "ca.crt" + private const val CA_FRIENDLY_NAME = "mhrv-rs MITM CA" + + /** Stable path where the UI stages the exported CA. */ + fun caFile(ctx: Context): File = File(ctx.filesDir, CA_FILENAME) + + /** + * Copy the current Rust-side CA cert to a UI-accessible path. + * Returns true only if the file exists and is non-empty on return — + * Native.exportCa is supposed to do that, but we re-check because a + * truncated write would make the install dialog look empty and the + * user would have no idea why. + */ + fun export(ctx: Context): Boolean { + val dest = caFile(ctx) + if (!Native.exportCa(dest.absolutePath)) return false + return dest.exists() && dest.length() > 0 + } + + /** DER-encoded bytes of the exported CA, or null if export hasn't run. */ + fun readDer(ctx: Context): ByteArray? { + val f = caFile(ctx) + if (!f.exists()) return null + val raw = try { f.readBytes() } catch (_: Throwable) { return null } + return pemToDer(raw) ?: raw // fall back to treating it as DER + } + + /** SHA-256 fingerprint of the CA cert (over DER bytes). */ + fun fingerprint(ctx: Context): ByteArray? { + val der = readDer(ctx) ?: return null + return sha256(der) + } + + /** Pretty-print a fingerprint like "AA:BB:CC:...". */ + fun fingerprintHex(bytes: ByteArray): String = + bytes.joinToString(":") { "%02X".format(it) } + + /** + * Build the KeyChain install intent. The intent launches the system + * certificate picker pre-loaded with our cert — the user confirms a + * category (for modern Android that's "VPN and apps" or "Wi-Fi") and + * gives it a display name. + */ + fun buildInstallIntent(ctx: Context): Intent? { + val der = readDer(ctx) ?: return null + return KeyChain.createInstallIntent() + .putExtra(KeyChain.EXTRA_CERTIFICATE, der) + .putExtra(KeyChain.EXTRA_NAME, CA_FRIENDLY_NAME) + } + + /** + * Save a PEM copy of the CA to the user's Downloads folder so they + * can find it in the Files app and pick it from Settings → + * Encryption & credentials → Install a certificate → CA certificate. + * + * On Android 10+ (API 29) this goes through MediaStore so we don't need + * WRITE_EXTERNAL_STORAGE. On older Android we fall back to the app's + * external files dir — visible via Files app but not in the system + * Downloads collection, so the user needs to navigate to + * `Android/data//files/Download/` themselves. + * + * Returns a human-readable location string ("Downloads/mhrv-ca.crt" or + * the filesystem path) on success, null on failure. + */ + fun saveToDownloads(ctx: Context, displayName: String = "mhrv-ca.crt"): String? { + val der = readDer(ctx) ?: return null + // Rewrap as PEM so users can open the file in a text editor and + // verify it's a cert before trusting it — also, the system cert + // installer expects PEM or DER but PEM is the more common form + // for user-visible files. + val pem = derToPem(der) + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveViaMediaStore(ctx, displayName, pem)?.let { "Downloads/$displayName" } + } else { + // Pre-Q fallback: app-private external storage. Does NOT require + // a storage permission. Less discoverable for the user, but + // dodging the runtime-permission dance is worth it here — we + // can still deep-link Settings and tell them the path. + try { + val dir = ctx.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: return null + dir.mkdirs() + val f = File(dir, displayName) + f.writeBytes(pem) + f.absolutePath + } catch (_: Throwable) { null } + } + } + + private fun saveViaMediaStore(ctx: Context, displayName: String, bytes: ByteArray): Boolean? { + val resolver = ctx.contentResolver + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, "application/x-x509-ca-cert") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + } + } + // Delete any previous copy with the same name before inserting, so + // we don't accumulate `mhrv-ca (1).crt`, `mhrv-ca (2).crt` on repeat + // installs (MediaStore appends suffixes instead of overwriting). + try { + val sel = "${MediaStore.MediaColumns.DISPLAY_NAME}=?" + resolver.delete(MediaStore.Downloads.EXTERNAL_CONTENT_URI, sel, arrayOf(displayName)) + } catch (_: Throwable) { /* best-effort */ } + + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) ?: return null + return try { + resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: return null + true + } catch (_: Throwable) { null } + } + + /** + * Intent that opens the system "Security" settings screen. The exact + * landing page depends on OEM and Android version: + * - Pixel / stock AOSP: Settings → Security + * - from there the user navigates Encryption & credentials → + * Install a certificate → CA certificate → pick our .crt file. + * + * We tried KeyChain.createInstallIntent first (nicer flow) but on + * Android 11+ that intent just opens a dialog saying "Install CA + * certificates in Settings" with a Close button and no path forward — + * Google intentionally removed the inline install path. Settings is + * the fallback Google themselves point users at. + */ + fun buildSettingsIntent(): Intent = Intent(Settings.ACTION_SECURITY_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + /** + * True iff a CA with this SHA-256 fingerprint lives in the + * AndroidCAStore (union of system + user-installed CAs). This is how + * we verify install success because the picker activity's result code + * is not reliable across Android versions. + */ + fun isInstalled(targetFingerprint: ByteArray): Boolean { + return try { + val ks = KeyStore.getInstance("AndroidCAStore") + ks.load(null) + val aliases = ks.aliases() + while (aliases.hasMoreElements()) { + val alias = aliases.nextElement() + val cert = ks.getCertificate(alias) ?: continue + val encoded = try { cert.encoded } catch (_: Throwable) { continue } + if (sha256(encoded).contentEquals(targetFingerprint)) return true + } + false + } catch (_: Throwable) { false } + } + + /** Subject CN of the exported CA, for display. */ + fun subjectCn(ctx: Context): String? { + val der = readDer(ctx) ?: return null + return try { + val cf = CertificateFactory.getInstance("X.509") + val cert = cf.generateCertificate(der.inputStream()) as java.security.cert.X509Certificate + val dn = cert.subjectX500Principal.name // RFC 2253, CN=foo,O=bar + Regex("""CN=([^,]+)""").find(dn)?.groupValues?.get(1) + } catch (_: Throwable) { null } + } + + private fun sha256(data: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").digest(data) + + /** + * Rewrap DER bytes as PEM. We intentionally produce a textual cert — + * the user can `cat` it, the Settings cert picker accepts it, and it + * survives any copy/paste or email round-trip without binary mangling. + */ + private fun derToPem(der: ByteArray): ByteArray { + val b64 = Base64.encodeToString(der, Base64.NO_WRAP) + val chunks = b64.chunked(64).joinToString("\n") + val s = "-----BEGIN CERTIFICATE-----\n$chunks\n-----END CERTIFICATE-----\n" + return s.toByteArray(Charsets.US_ASCII) + } + + /** + * Accept either DER or PEM bytes; return DER. PEM files carry a base64 + * payload between -----BEGIN CERTIFICATE----- markers. + */ + private fun pemToDer(bytes: ByteArray): ByteArray? { + val s = try { bytes.toString(Charsets.US_ASCII) } catch (_: Throwable) { return null } + if (!s.contains("BEGIN CERTIFICATE")) return null // caller falls back to treating as DER + val body = s + .substringAfter("-----BEGIN CERTIFICATE-----", "") + .substringBefore("-----END CERTIFICATE-----", "") + .replace(Regex("\\s+"), "") + if (body.isEmpty()) return null + return try { Base64.decode(body, Base64.DEFAULT) } catch (_: Throwable) { null } + } +} diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt new file mode 100644 index 0000000..6af1903 --- /dev/null +++ b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt @@ -0,0 +1,172 @@ +package com.therealaleph.mhrv + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +/** + * Config I/O. The source of truth is a JSON file in the app's files dir — + * the Rust side parses the same file, so we don't maintain two schemas. + * + * What the Android UI exposes is a pragmatic subset of the full mhrv-rs + * config, but we now track parity with the desktop UI on the dimensions + * that actually matter on a phone: + * - multiple deployment IDs (round-robin) + * - an SNI rotation pool + * - log level / verify_ssl / parallel_relay knobs + * Anything else gets phone-appropriate defaults. + */ +data class MhrvConfig( + val listenHost: String = "127.0.0.1", + val listenPort: Int = 8080, + val socks5Port: Int? = 1081, + + /** One Apps Script ID or deployment URL per entry. */ + val appsScriptUrls: List = emptyList(), + val authKey: String = "", + + val frontDomain: String = "www.google.com", + /** Rotation pool of SNI hostnames; empty means "let Rust auto-expand". */ + val sniHosts: List = emptyList(), + val googleIp: String = "142.251.36.68", + + val verifySsl: Boolean = true, + val logLevel: String = "info", + val parallelRelay: Int = 1, + val upstreamSocks5: String = "", +) { + /** + * Extract just the deployment ID from either a full + * `https://script.google.com/macros/s//exec` URL or a bare ID. + * + * Implementation note (this used to be buggy): never use the chained + * `substringBefore(delim, missingDelimiterValue)` form passing the + * original input as the fallback. Example of what that caused: + * "https://.../macros/s/X/exec" + * .substringAfter("/macros/s/", s) -> "X/exec" + * .substringBefore("/", s) -> "X" + * .substringBefore("?", s) -> FALLBACK fires because + * "?" isn't in "X", + * returning the ORIGINAL URL + * → we'd then save the full URL as the "ID", and on reload the UI + * would build `https://.../macros/s//exec`, producing the + * "extra https:// and extra /exec" symptom users reported. Keep the + * extraction linear and don't reach for a fallback. + */ + private fun extractId(input: String): String { + var s = input.trim() + if (s.isEmpty()) return s + val marker = "/macros/s/" + val i = s.indexOf(marker) + if (i >= 0) s = s.substring(i + marker.length) + // Strip /exec or /dev suffix (or any path after the ID). + val slash = s.indexOf('/') + if (slash >= 0) s = s.substring(0, slash) + // Strip query string. + val q = s.indexOf('?') + if (q >= 0) s = s.substring(0, q) + return s.trim() + } + + fun toJson(): String { + val ids = appsScriptUrls + .map { extractId(it) } + .filter { it.isNotEmpty() } + + val obj = JSONObject().apply { + // `mode` is required — without it serde errors with + // "missing field `mode`" and startProxy silently returns 0. + put("mode", "apps_script") + put("listen_host", listenHost) + put("listen_port", listenPort) + socks5Port?.let { put("socks5_port", it) } + + put("script_ids", JSONArray().apply { ids.forEach { put(it) } }) + put("auth_key", authKey) + + put("front_domain", frontDomain) + if (sniHosts.isNotEmpty()) { + put("sni_hosts", JSONArray().apply { sniHosts.forEach { put(it) } }) + } + put("google_ip", googleIp) + + put("verify_ssl", verifySsl) + put("log_level", logLevel) + put("parallel_relay", parallelRelay) + if (upstreamSocks5.isNotBlank()) { + put("upstream_socks5", upstreamSocks5.trim()) + } + + // Phone-scoped scan defaults. We don't expose these in the UI + // because a phone isn't where you'd run a full /16 scan; users + // who need it can do that on the desktop UI and paste the IP. + put("fetch_ips_from_api", false) + put("max_ips_to_scan", 20) + } + return obj.toString(2) + } + + /** Convenience: is there at least one usable deployment ID? */ + val hasDeploymentId: Boolean get() = + appsScriptUrls.any { extractId(it).isNotEmpty() } +} + +object ConfigStore { + private const val FILE = "config.json" + + fun load(ctx: Context): MhrvConfig { + val f = File(ctx.filesDir, FILE) + if (!f.exists()) return MhrvConfig() + return try { + val obj = JSONObject(f.readText()) + + val ids = obj.optJSONArray("script_ids")?.let { arr -> + buildList { for (i in 0 until arr.length()) add(arr.optString(i)) } + }?.filter { it.isNotBlank() }.orEmpty() + // For display we turn each ID back into the full URL form — + // easier to paste-verify, and the Kotlin side doesn't depend + // on it (extractId re-parses on save). + val urls = ids.map { "https://script.google.com/macros/s/$it/exec" } + + val sni = obj.optJSONArray("sni_hosts")?.let { arr -> + buildList { for (i in 0 until arr.length()) add(arr.optString(i)) } + }?.filter { it.isNotBlank() }.orEmpty() + + MhrvConfig( + listenHost = obj.optString("listen_host", "127.0.0.1"), + listenPort = obj.optInt("listen_port", 8080), + socks5Port = obj.optInt("socks5_port", 1081).takeIf { it > 0 }, + appsScriptUrls = urls, + authKey = obj.optString("auth_key", ""), + frontDomain = obj.optString("front_domain", "www.google.com"), + sniHosts = sni, + googleIp = obj.optString("google_ip", "142.251.36.68"), + verifySsl = obj.optBoolean("verify_ssl", true), + logLevel = obj.optString("log_level", "info"), + parallelRelay = obj.optInt("parallel_relay", 1), + upstreamSocks5 = obj.optString("upstream_socks5", ""), + ) + } catch (_: Throwable) { + MhrvConfig() + } + } + + fun save(ctx: Context, cfg: MhrvConfig) { + val f = File(ctx.filesDir, FILE) + f.writeText(cfg.toJson()) + } +} + +/** + * Default SNI rotation pool. Mirrors `DEFAULT_GOOGLE_SNI_POOL` from the + * Rust `domain_fronter` module — keep the lists in sync, or leave the + * user's sniHosts empty and let Rust auto-expand. + */ +val DEFAULT_SNI_POOL: List = listOf( + "www.google.com", + "mail.google.com", + "drive.google.com", + "docs.google.com", + "calendar.google.com", +) diff --git a/android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt b/android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt new file mode 100644 index 0000000..e81c217 --- /dev/null +++ b/android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt @@ -0,0 +1,142 @@ +package com.therealaleph.mhrv + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.net.VpnService +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.therealaleph.mhrv.ui.CaInstallOutcome +import com.therealaleph.mhrv.ui.HomeScreen +import com.therealaleph.mhrv.ui.theme.MhrvTheme + +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Native.setDataDir(filesDir.absolutePath) + + // Android 13+ needs runtime permission for foreground service + // notifications. Ask once at launch — if the user declines the + // service still runs, it just won't surface a notification. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission( + this, Manifest.permission.POST_NOTIFICATIONS, + ) != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + REQ_NOTIF, + ) + } + } + + setContent { + MhrvTheme { + AppRoot() + } + } + } + + @Composable + private fun AppRoot() { + // The system VpnService.prepare() returns an Intent if the user + // hasn't approved VPN access yet; if null, we're already approved + // and can start directly. + val vpnPrepareLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + startVpnService() + } + } + + // CA install flow. We hold the fingerprint of the cert we fired the + // intent with so we can look it up in AndroidCAStore after the + // picker returns — the resultCode itself is unreliable on Android + // 11+ (the system always returns RESULT_CANCELED from the Settings + // shim), so fingerprint verification is our ground truth. + var pendingFingerprint by remember { mutableStateOf(null) } + // Human-readable path where we saved the cert copy (e.g. + // "Downloads/mhrv-ca.crt"). Shown in the outcome snackbar so the + // user knows where to find it if they need to install manually + // or share it. + var pendingDownloadPath by remember { mutableStateOf(null) } + var caOutcome by remember { mutableStateOf(null) } + + val installCaLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { _ -> + val fp = pendingFingerprint + caOutcome = when { + fp == null -> CaInstallOutcome.Failed("Internal error: no fingerprint") + CaInstall.isInstalled(fp) -> CaInstallOutcome.Installed + else -> CaInstallOutcome.NotInstalled(pendingDownloadPath) + } + pendingFingerprint = null + pendingDownloadPath = null + } + + HomeScreen( + onStart = { + val prepareIntent = VpnService.prepare(this) + if (prepareIntent == null) { + startVpnService() + } else { + vpnPrepareLauncher.launch(prepareIntent) + } + }, + onStop = { + val i = Intent(this, MhrvVpnService::class.java) + .setAction(MhrvVpnService.ACTION_STOP) + startService(i) + }, + onInstallCaConfirmed = { + // The flow is (1) export cert, (2) copy it to Downloads so + // the user can find it in the Files app, (3) deep-link to + // Security Settings where they can tap "Install a + // certificate". On return we verify via AndroidCAStore. + // + // We explicitly DO NOT use KeyChain.createInstallIntent — + // on Android 11+ that intent just opens a dead-end + // "Install in Settings" dialog with no path forward, which + // is confusing for users. + val fp = CaInstall.fingerprint(this) + val downloadPath = CaInstall.saveToDownloads(this) + if (fp != null) { + pendingFingerprint = fp + pendingDownloadPath = downloadPath + installCaLauncher.launch(CaInstall.buildSettingsIntent()) + } else { + caOutcome = CaInstallOutcome.Failed( + "Couldn't read the CA cert. Tap Start once so the proxy creates it, then try again.", + ) + } + }, + caOutcome = caOutcome, + onCaOutcomeConsumed = { caOutcome = null }, + ) + } + + private fun startVpnService() { + val i = Intent(this, MhrvVpnService::class.java) + startService(i) + } + + companion object { + private const val REQ_NOTIF = 42 + } +} diff --git a/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt b/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt new file mode 100644 index 0000000..ac03126 --- /dev/null +++ b/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt @@ -0,0 +1,280 @@ +package com.therealaleph.mhrv + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Intent +import android.net.VpnService +import android.os.Build +import android.os.ParcelFileDescriptor +import android.util.Log +import androidx.core.app.NotificationCompat +import com.github.shadowsocks.bg.Tun2proxy +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Foreground VpnService that: + * 1. Runs the mhrv-rs Rust proxy (HTTP + SOCKS5 on 127.0.0.1). + * 2. Establishes a VPN TUN interface capturing all device traffic. + * 3. Spawns tun2proxy in a background thread — it reads IP packets from + * the TUN fd, runs a userspace TCP/IP stack, and funnels every TCP/UDP + * flow through our local SOCKS5. Without step 3 the TUN captures + * traffic but nothing reads it → DNS_PROBE_STARTED in Chrome (the + * symptom that bit us on the first run). + * + * Loop-avoidance note: our own proxy's OUTBOUND connections to + * google_ip:443 would normally be re-captured by the TUN ("traffic goes in + * circles"). We break the loop by excluding this app's UID from the VPN + * via `addDisallowedApplication(packageName)`. Everything else on the + * device still gets routed through us. + */ +class MhrvVpnService : VpnService() { + + private var tun: ParcelFileDescriptor? = null + private var proxyHandle: Long = 0L + private var tun2proxyThread: Thread? = null + private val tun2proxyRunning = AtomicBoolean(false) + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.i(TAG, "onStartCommand action=${intent?.action ?: ""} startId=$startId") + return when (intent?.action) { + ACTION_STOP -> { + // Drop foreground FIRST — that's what makes the status-bar + // key icon disappear and lets the user see "Stop worked" + // even if the native teardown below takes a few seconds + // (e.g. a dozen in-flight Apps Script requests stuck in + // their 30s timeout). The service itself stays alive until + // stopSelf + the background thread below finish. + try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (t: Throwable) { + Log.w(TAG, "stopForeground: ${t.message}") + } + // Teardown can block on native shutdown (rt.shutdown_timeout + // is 5s max, plus 2s for the tun2proxy join). Do it off the + // main thread so we don't ANR. + Thread({ + teardown() + stopSelf() + Log.i(TAG, "teardown done, service stopping") + }, "mhrv-teardown").start() + START_NOT_STICKY + } + else -> { + startEverything() + START_STICKY + } + } + } + + private fun startEverything() { + // 1) Seed native with our app's private dir and boot the proxy. + Native.setDataDir(filesDir.absolutePath) + + val cfg = ConfigStore.load(this) + if (!cfg.hasDeploymentId || cfg.authKey.isBlank()) { + Log.e(TAG, "Config is incomplete — can't start proxy") + stopSelf() + return + } + + // Defensive stop: if a previous startEverything left a handle behind + // (e.g. the user tapped Start twice, or a Stop path errored out + // mid-teardown), release it first. Without this, Native.startProxy + // below binds a brand-new listener while the old one still holds + // :listenPort → "Address already in use" from the Rust side and the + // app looks stuck in a half-configured state. + if (proxyHandle != 0L) { + Log.w(TAG, "startEverything: stale proxyHandle=$proxyHandle; stopping old proxy first") + try { Native.stopProxy(proxyHandle) } catch (_: Throwable) {} + proxyHandle = 0L + } + + proxyHandle = Native.startProxy(cfg.toJson()) + if (proxyHandle == 0L) { + Log.e(TAG, "Native.startProxy returned 0 — see logcat tag mhrv_rs") + stopSelf() + return + } + + val socks5Port = cfg.socks5Port ?: (cfg.listenPort + 1) + + // 2) Establish the TUN. Key Builder calls: + // - addAddress(10.0.0.2/32): our local IP inside the tunnel. + // - addRoute(0.0.0.0/0): capture ALL IPv4 traffic. IPv6 isn't added, + // so v6 leaks stay up the normal route — fine for this app. + // - addDnsServer(1.1.1.1): DNS queries go to this IP, which ALSO + // hits our TUN — tun2proxy intercepts in Virtual DNS mode. + // - addDisallowedApplication(packageName): our OWN outbound + // connections bypass the TUN. Without this, the proxy's + // outbound to google_ip loops back through the TUN forever. + // - setBlocking(false): we're going to hand the fd to tun2proxy, + // which does its own async I/O. + val builder = Builder() + .setSession("mhrv-rs") + .setMtu(MTU) + .addAddress("10.0.0.2", 32) + .addRoute("0.0.0.0", 0) + .addDnsServer("1.1.1.1") + .setBlocking(false) + try { + builder.addDisallowedApplication(packageName) + } catch (e: Throwable) { + // Shouldn't happen for our own package, but don't hard-fail. + Log.w(TAG, "addDisallowedApplication failed: ${e.message}") + } + + val parcelFd = try { + builder.establish() + } catch (t: Throwable) { + Log.e(TAG, "VpnService.establish() failed: ${t.message}") + null + } + + if (parcelFd == null) { + Log.e(TAG, "establish() returned null — is VPN permission granted?") + Native.stopProxy(proxyHandle) + proxyHandle = 0L + stopSelf() + return + } + tun = parcelFd + + // 3) Start tun2proxy on a worker thread. It blocks until stop() or + // shutdown. We detach the fd so ownership transfers cleanly; the + // ParcelFileDescriptor (`tun`) still holds a reference, so closing + // it at teardown reliably tears down the TUN even if tun2proxy + // doesn't cleanly exit. + val detachedFd = parcelFd.detachFd() + tun2proxyRunning.set(true) + tun2proxyThread = Thread({ + try { + val rc = Tun2proxy.run( + "socks5://127.0.0.1:$socks5Port", + detachedFd, + /* closeFdOnDrop = */ true, + MTU.toChar(), + /* verbosity = info */ 3, + /* dnsStrategy = virtual */ 0, + ) + Log.i(TAG, "tun2proxy exited rc=$rc") + } catch (t: Throwable) { + Log.e(TAG, "tun2proxy crashed: ${t.message}", t) + } finally { + tun2proxyRunning.set(false) + } + }, "tun2proxy").apply { start() } + + startForeground(NOTIF_ID, buildNotif(cfg.listenPort)) + } + + /** + * Tear down everything this service owns. Safe to call more than once: + * - `Tun2proxy.stop()` is idempotent on its side. + * - tun2proxyRunning gating means we skip the stop call when the + * worker thread has already exited. + * - `tun` and `proxyHandle` are nulled/zeroed after one pass, so a + * second call is a no-op. + * + * Shutdown order matters. Doing it wrong (we did originally) leaves + * tun2proxy still forwarding packets into a half-dead Rust runtime + * while the runtime is force-aborting its tasks — that's the scenario + * that manifested as "Stop crashes the app" when there were in-flight + * relay requests piled up against a dead Apps Script deployment. The + * correct order is: + * 1. Signal tun2proxy to stop (cooperative). + * 2. Close the TUN fd — forces tun2proxy's read() to return EBADF. + * 3. Join the tun2proxy thread (now it really will exit). + * 4. Shut down the Rust proxy runtime (nothing left to forward to). + */ + private fun teardown() { + Log.i(TAG, "teardown: begin (tun2proxy running=${tun2proxyRunning.get()}, proxyHandle=$proxyHandle)") + + // 1. Cooperative stop signal. + if (tun2proxyRunning.get()) { + try { Tun2proxy.stop() } catch (t: Throwable) { + Log.w(TAG, "Tun2proxy.stop: ${t.message}") + } + } + + // 2. Close the TUN fd. Since we called detachFd earlier the + // ParcelFileDescriptor no longer owns the fd and close() here + // is a no-op; the real fd is owned by tun2proxy (closeFdOnDrop + // = true), which closes it on return from run(). + try { tun?.close() } catch (_: Throwable) {} + tun = null + + // 3. Join the worker. 4s is enough in the happy case; if tun2proxy + // is stuck on something untoward we'd rather move on and force + // the runtime shutdown than hang forever. + try { + tun2proxyThread?.join(4_000) + } catch (_: InterruptedException) {} + val stillAlive = tun2proxyThread?.isAlive == true + tun2proxyThread = null + if (stillAlive) { + Log.w(TAG, "tun2proxy thread still alive after join timeout — proceeding anyway") + } + + // 4. Shut down the Rust proxy. Backed by `rt.shutdown_timeout(3s)` + // on the Rust side, so this is bounded even if the runtime + // has in-flight tasks (common when the Apps Script relay has + // piled up pending 30s timeouts). + if (proxyHandle != 0L) { + Log.i(TAG, "teardown: stopping proxy handle=$proxyHandle") + try { Native.stopProxy(proxyHandle) } catch (t: Throwable) { + Log.e(TAG, "Native.stopProxy threw: ${t.message}", t) + } + proxyHandle = 0L + } + Log.i(TAG, "teardown: done") + } + + override fun onDestroy() { + teardown() + super.onDestroy() + } + + private fun buildNotif(proxyPort: Int): Notification { + val mgr = getSystemService(NotificationManager::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val ch = NotificationChannel( + CHANNEL_ID, + "mhrv-rs", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Status of the mhrv-rs VPN" + setShowBadge(false) + } + mgr.createNotificationChannel(ch) + } + val openIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val stopIntent = PendingIntent.getService( + this, + 1, + Intent(this, MhrvVpnService::class.java).setAction(ACTION_STOP), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("mhrv-rs VPN is active") + .setContentText("Routing via SOCKS5 127.0.0.1:${proxyPort + 1}") + .setSmallIcon(android.R.drawable.presence_online) + .setContentIntent(openIntent) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopIntent) + .setOngoing(true) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .build() + } + + companion object { + private const val TAG = "MhrvVpnService" + private const val CHANNEL_ID = "mhrv.vpn.status" + private const val NOTIF_ID = 0x1001 + private const val MTU = 1500 + const val ACTION_STOP = "com.therealaleph.mhrv.STOP" + } +} diff --git a/android/app/src/main/java/com/therealaleph/mhrv/Native.kt b/android/app/src/main/java/com/therealaleph/mhrv/Native.kt new file mode 100644 index 0000000..cc6fbbc --- /dev/null +++ b/android/app/src/main/java/com/therealaleph/mhrv/Native.kt @@ -0,0 +1,68 @@ +package com.therealaleph.mhrv + +/** + * JNI bindings for the mhrv_rs Rust crate. The crate is compiled to + * libmhrv_rs.so and loaded at app start. + * + * All methods are blocking on a short-lived native call — the proxy itself + * runs on a Rust-side tokio runtime, not on the JVM thread that calls in. + * The returned handles are opaque to Kotlin; pass them back to stop() / + * statsJson() / etc. + * + * Thread-safe: the underlying Rust side guards its state with a mutex. + */ +object Native { + + init { + System.loadLibrary("mhrv_rs") + } + + /** + * Tell the Rust side where to put config + CA + cache. Must be called + * once before any other call. The path we hand over is our app's + * private filesDir — guaranteed writable, auto-cleaned on uninstall. + */ + external fun setDataDir(path: String) + + /** + * Spin up the proxy. `configJson` is the full config.json contents as + * a String. Returns the handle (positive) on success, or 0 on failure + * (inspect logcat for the failure reason). + */ + external fun startProxy(configJson: String): Long + + /** + * Stop a running proxy. Idempotent: returns false if the handle is + * unknown (e.g. already stopped). + */ + external fun stopProxy(handle: Long): Boolean + + /** + * Copy the MITM CA cert to a destination path. Used by the UI to + * surface ca.crt in Downloads so the user can feed it to Android's + * system "Install certificate" picker. + */ + external fun exportCa(destPath: String): Boolean + + /** mhrv_rs crate version. Smoke test for JNI linkage. */ + external fun version(): String + + /** + * Drain the in-memory log ring buffer (populated by the same tracing + * subscriber that feeds logcat). Returns a `\n`-joined blob of any + * events the UI hasn't seen yet, or an empty string. + * + * Cheap to call — the Kotlin side polls this on a timer. Single blob + * instead of `String[]` because one JNI crossing is much faster than N. + */ + external fun drainLogs(): String + + /** + * Probe a single SNI against `googleIp`. Returns a JSON string of the + * form `{"ok":true,"latencyMs":123}` on success or + * `{"ok":false,"error":"..."}` on failure. + * + * BLOCKS (does a TLS handshake); call from a background dispatcher. + */ + external fun testSni(googleIp: String, sni: String): String +} diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt new file mode 100644 index 0000000..c1e81cc --- /dev/null +++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt @@ -0,0 +1,796 @@ +package com.therealaleph.mhrv.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.HourglassBottom +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.therealaleph.mhrv.CaInstall +import com.therealaleph.mhrv.ConfigStore +import com.therealaleph.mhrv.DEFAULT_SNI_POOL +import com.therealaleph.mhrv.MhrvConfig +import com.therealaleph.mhrv.Native +import com.therealaleph.mhrv.ui.theme.OkGreen +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject + +/** + * UI state returned by the Activity after the CA install flow finishes, + * so the screen can show a matching snackbar. Kept as a sum type — a raw + * string message would conflate "installed" vs. "failed to export". + */ +sealed class CaInstallOutcome { + object Installed : CaInstallOutcome() + /** + * Cert not found in the AndroidCAStore after the Settings activity + * returned. Carries an optional downloadPath so the snackbar can tell + * the user where the file landed (Downloads or app-private external). + */ + data class NotInstalled(val downloadPath: String?) : CaInstallOutcome() + data class Failed(val message: String) : CaInstallOutcome() +} + +/** + * Top-level screen. Intentionally one scrollable page rather than tabs — + * first-run users need to see everything (deployment IDs, cert button, + * Start) on one surface. Anything that isn't first-run critical lives in + * collapsible sections (SNI pool, Advanced, Logs) so the default view + * stays short. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + onStart: () -> Unit, + onStop: () -> Unit, + onInstallCaConfirmed: () -> Unit, + caOutcome: CaInstallOutcome?, + onCaOutcomeConsumed: () -> Unit, +) { + val ctx = LocalContext.current + val scope = rememberCoroutineScope() + val snackbar = remember { SnackbarHostState() } + + // Persisted form state. Any edit writes back to disk immediately — + // cheap at this write rate, avoids "I tapped Start before saving" bugs. + var cfg by remember { mutableStateOf(ConfigStore.load(ctx)) } + fun persist(new: MhrvConfig) { + cfg = new + ConfigStore.save(ctx, new) + } + + // CA install dialog visibility. + var showInstallDialog by rememberSaveable { mutableStateOf(false) } + + // Cooldown on Start/Stop. Rapid taps during a VPN transition trigger + // an emulator-specific EGL renderer crash + // (F OpenGLRenderer: EGL_NOT_INITIALIZED during rendering) — the + // service survives, but the Compose UI process dies and the app + // appears to close. On real hardware this is rare, but debouncing + // is useful UX anyway: neither start nor stop is truly instant, + // and the user gets no feedback if they tap while one is in flight. + var transitionCooldown by remember { mutableStateOf(false) } + LaunchedEffect(transitionCooldown) { + if (transitionCooldown) { + delay(2000) + transitionCooldown = false + } + } + + // Surface CA install result as a snackbar. We consume the outcome + // after showing so a recomposition doesn't re-trigger it. + LaunchedEffect(caOutcome) { + val o = caOutcome ?: return@LaunchedEffect + val msg = when (o) { + is CaInstallOutcome.Installed -> + "Certificate installed ✓" + is CaInstallOutcome.NotInstalled -> buildString { + append("Certificate not yet installed.") + if (!o.downloadPath.isNullOrBlank()) { + append(" Saved to ${o.downloadPath}. ") + append("In Settings: Encryption & credentials → Install a certificate → \"CA certificate\" (not VPN, not Wi-Fi) → pick that file.") + } else { + append(" Tap Install again to retry.") + } + } + is CaInstallOutcome.Failed -> o.message + } + snackbar.showSnackbar(msg, withDismissAction = true) + onCaOutcomeConsumed() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("mhrv-rs") }, + actions = { + Text( + text = "v" + runCatching { Native.version() }.getOrDefault("?"), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(end = 12.dp), + ) + }, + ) + }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { inner -> + Column( + modifier = Modifier + .padding(inner) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SectionHeader("Apps Script relay") + + DeploymentIdsField( + urls = cfg.appsScriptUrls, + onChange = { persist(cfg.copy(appsScriptUrls = it)) }, + ) + + OutlinedTextField( + value = cfg.authKey, + onValueChange = { persist(cfg.copy(authKey = it)) }, + label = { Text("auth_key") }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth(), + supportingText = { + Text("The shared secret you set in the Apps Script.") + }, + ) + + Spacer(Modifier.height(4.dp)) + SectionHeader("Network") + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = cfg.googleIp, + onValueChange = { persist(cfg.copy(googleIp = it)) }, + label = { Text("google_ip") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = cfg.frontDomain, + onValueChange = { persist(cfg.copy(frontDomain = it)) }, + label = { Text("front_domain") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.weight(1f), + ) + } + + // SNI pool: collapsed by default. Users without a reason to + // touch it should leave Rust's auto-expansion to handle it. + CollapsibleSection(title = "SNI pool + tester") { + SniPoolEditor( + cfg = cfg, + onChange = ::persist, + ) + } + + // Advanced settings: collapsed by default. + CollapsibleSection(title = "Advanced") { + AdvancedSettings( + cfg = cfg, + onChange = ::persist, + ) + } + + Spacer(Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + transitionCooldown = true + onStart() + }, + enabled = cfg.hasDeploymentId && cfg.authKey.isNotBlank() && !transitionCooldown, + modifier = Modifier.weight(1f), + ) { + Text(if (transitionCooldown) "…" else "Start") + } + OutlinedButton( + onClick = { + transitionCooldown = true + onStop() + }, + enabled = !transitionCooldown, + modifier = Modifier.weight(1f), + ) { + Text(if (transitionCooldown) "…" else "Stop") + } + } + + Spacer(Modifier.height(4.dp)) + // Secondary accent button — FilledTonalButton reads as a lower- + // priority action next to Start/Stop, matching the desktop UI's + // visual hierarchy where Install CA is offered as a helper + // button rather than the headline action. + FilledTonalButton( + onClick = { showInstallDialog = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Install MITM certificate") + } + + CollapsibleSection(title = "Live logs", initiallyExpanded = false) { + LiveLogPane() + } + + Spacer(Modifier.height(16.dp)) + HowToUseCard(cfg.listenPort) + } + } + + // ---- CA install confirmation dialog --------------------------------- + if (showInstallDialog) { + // Export eagerly so we can show the fingerprint in the dialog body + // — builds user confidence ("yes, that's the cert I'm trusting") + // and gives us a usable failure path if the CA doesn't exist yet. + val exported = remember { CaInstall.export(ctx) } + val fp = remember(exported) { if (exported) CaInstall.fingerprint(ctx) else null } + val cn = remember(exported) { if (exported) CaInstall.subjectCn(ctx) else null } + + AlertDialog( + onDismissRequest = { showInstallDialog = false }, + title = { Text("Install MITM certificate?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "mhrv-rs creates a local certificate authority so it can decrypt " + + "and re-encrypt HTTPS traffic before tunnelling it through the Apps " + + "Script relay. Without this CA installed as trusted, apps will show " + + "certificate errors." + ) + Text( + "On Android 11+ the system removed the inline install path, so " + + "tapping Install will: (1) save a PEM copy to Downloads/mhrv-ca.crt, " + + "(2) open Security settings. From there navigate to Encryption & " + + "credentials → Install a certificate → pick \"CA certificate\" (NOT " + + "\"VPN & app user certificate\" or \"Wi-Fi certificate\") → select " + + "mhrv-ca.crt from Downloads. If you don't have a screen lock, Android " + + "will ask you to add one first." + ) + if (fp != null) { + Text("Subject: ${cn ?: "(unknown)"}", style = MaterialTheme.typography.labelMedium) + Text( + text = "SHA-256: ${CaInstall.fingerprintHex(fp)}", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + ) + } else { + Text( + "Could not read the CA cert yet. Tap Start once so the " + + "proxy generates it, then come back.", + color = MaterialTheme.colorScheme.error, + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + showInstallDialog = false + if (fp != null) onInstallCaConfirmed() + }, + enabled = fp != null, + ) { Text("Install") } + }, + dismissButton = { + TextButton(onClick = { showInstallDialog = false }) { Text("Cancel") } + }, + ) + } +} + +// ========================================================================= +// Deployment IDs editor (multi-line, one URL/ID per line). +// ========================================================================= + +@Composable +private fun DeploymentIdsField( + urls: List, + onChange: (List) -> Unit, +) { + // Treat the list as newline-joined text. Keep trailing newlines so the + // cursor behaves naturally while the user is adding a new entry. + var raw by remember(urls) { mutableStateOf(urls.joinToString("\n")) } + + OutlinedTextField( + value = raw, + onValueChange = { + raw = it + val parsed = it.split("\n").map(String::trim).filter(String::isNotBlank) + onChange(parsed) + }, + label = { Text("Deployment URL(s) or script ID(s)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 6, + supportingText = { + Text( + "One per line. Full URLs (https://script.google.com/macros/s/.../exec) " + + "or bare IDs — mix as you like. Multiple IDs are rotated round-robin.", + ) + }, + ) +} + +// ========================================================================= +// SNI pool editor + per-SNI probe. +// ========================================================================= + +private sealed class ProbeState { + object Idle : ProbeState() + object InFlight : ProbeState() + data class Ok(val latencyMs: Int) : ProbeState() + data class Err(val message: String) : ProbeState() +} + +@Composable +private fun SniPoolEditor( + cfg: MhrvConfig, + onChange: (MhrvConfig) -> Unit, +) { + val scope = rememberCoroutineScope() + + // Build the displayed list: union of the default pool + the config's + // sniHosts + the current front_domain. Order: front_domain first, + // defaults, then user customs. Deduped. + val displayed: List = remember(cfg) { + val seen = linkedSetOf() + if (cfg.frontDomain.isNotBlank()) seen.add(cfg.frontDomain.trim()) + DEFAULT_SNI_POOL.forEach { seen.add(it) } + cfg.sniHosts.forEach { if (it.isNotBlank()) seen.add(it.trim()) } + seen.toList() + } + + // A host is enabled if it appears in cfg.sniHosts. Empty sniHosts + // means "let Rust auto-expand" — we reflect that as "default pool + // enabled, customs not". + val enabledSet: Set = remember(cfg.sniHosts) { + if (cfg.sniHosts.isNotEmpty()) cfg.sniHosts.toSet() + else DEFAULT_SNI_POOL.toSet() + setOfNotNull(cfg.frontDomain.takeIf { it.isNotBlank() }) + } + + val probeState = remember { mutableStateMapOf() } + + fun probe(sni: String) { + probeState[sni] = ProbeState.InFlight + scope.launch { + val json = withContext(Dispatchers.IO) { + runCatching { Native.testSni(cfg.googleIp, sni) }.getOrNull() + } + probeState[sni] = parseProbeResult(json) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + "Enabled SNIs are rotated when connecting to google_ip. Leaving all unchecked " + + "lets Rust auto-expand the default Google pool.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + displayed.forEach { sni -> + val enabled = sni in enabledSet + SniRow( + sni = sni, + enabled = enabled, + state = probeState[sni] ?: ProbeState.Idle, + onToggle = { nowEnabled -> + val next = if (nowEnabled) { + (cfg.sniHosts.takeIf { it.isNotEmpty() } ?: emptyList()) + sni + } else { + val current = if (cfg.sniHosts.isNotEmpty()) cfg.sniHosts else enabledSet.toList() + current.filter { it != sni } + } + onChange(cfg.copy(sniHosts = next.distinct())) + }, + onTest = { probe(sni) }, + ) + } + + // Custom-add row. + var custom by remember { mutableStateOf("") } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = custom, + onValueChange = { custom = it }, + label = { Text("Add custom SNI") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.weight(1f), + ) + TextButton( + onClick = { + val s = custom.trim() + if (s.isNotEmpty()) { + val next = (cfg.sniHosts.takeIf { it.isNotEmpty() } ?: enabledSet.toList()) + s + onChange(cfg.copy(sniHosts = next.distinct())) + custom = "" + } + }, + enabled = custom.isNotBlank(), + ) { Text("Add") } + } + + TextButton( + onClick = { displayed.forEach { probe(it) } }, + modifier = Modifier.align(Alignment.End), + ) { Text("Test all") } + } +} + +@Composable +private fun SniRow( + sni: String, + enabled: Boolean, + state: ProbeState, + onToggle: (Boolean) -> Unit, + onTest: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Checkbox(checked = enabled, onCheckedChange = onToggle) + Text( + sni, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + ) + ProbeBadge(state) + Spacer(Modifier.width(4.dp)) + TextButton(onClick = onTest, enabled = state !is ProbeState.InFlight) { + Text("Test") + } + } + // Show the error reason on its own line when the probe failed — + // a red dot with no explanation was confusing ("SNI test also + // fails despite having internet"). Common reasons: "dns: ..." or + // "connect: ...". + if (state is ProbeState.Err) { + Text( + text = state.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(start = 48.dp, bottom = 4.dp), + ) + } + } +} + +@Composable +private fun ProbeBadge(state: ProbeState) { + when (state) { + is ProbeState.Idle -> {} + is ProbeState.InFlight -> { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + ) + } + is ProbeState.Ok -> { + Row(verticalAlignment = Alignment.CenterVertically) { + // Same green the desktop UI uses for OK status (OK_GREEN + // in src/bin/ui.rs line 510) — kept in sync via Theme.kt. + Icon( + Icons.Default.CheckCircle, null, + tint = OkGreen, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(2.dp)) + Text("${state.latencyMs} ms", style = MaterialTheme.typography.labelSmall) + } + } + is ProbeState.Err -> { + Icon( + Icons.Default.ErrorOutline, state.message, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + } + } +} + +private fun parseProbeResult(json: String?): ProbeState { + if (json.isNullOrBlank()) return ProbeState.Err("no response") + return try { + val obj = JSONObject(json) + if (obj.optBoolean("ok", false)) { + ProbeState.Ok(obj.optInt("latencyMs", -1)) + } else { + ProbeState.Err(obj.optString("error", "failed")) + } + } catch (_: Throwable) { + ProbeState.Err("bad json") + } +} + +// ========================================================================= +// Advanced settings. +// ========================================================================= + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AdvancedSettings( + cfg: MhrvConfig, + onChange: (MhrvConfig) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + // verify_ssl + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Verify upstream TLS", style = MaterialTheme.typography.bodyMedium) + Text( + "Off disables cert checks for the Google edge. Only useful for debugging.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = cfg.verifySsl, + onCheckedChange = { onChange(cfg.copy(verifySsl = it)) }, + ) + } + + // log_level dropdown + var expanded by remember { mutableStateOf(false) } + val levels = listOf("trace", "debug", "info", "warn", "error", "off") + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + ) { + OutlinedTextField( + value = cfg.logLevel, + onValueChange = {}, + readOnly = true, + label = { Text("log_level") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + levels.forEach { lvl -> + DropdownMenuItem( + text = { Text(lvl) }, + onClick = { + onChange(cfg.copy(logLevel = lvl)) + expanded = false + }, + ) + } + } + } + + // parallel_relay slider + Column { + Text( + "parallel_relay: ${cfg.parallelRelay}", + style = MaterialTheme.typography.bodyMedium, + ) + Slider( + value = cfg.parallelRelay.toFloat(), + onValueChange = { onChange(cfg.copy(parallelRelay = it.toInt().coerceIn(1, 5))) }, + valueRange = 1f..5f, + steps = 3, // yields 1,2,3,4,5 positions + ) + Text( + "Fan-out per request. 1 is normal; bump to 2-3 on lossy links.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + OutlinedTextField( + value = cfg.upstreamSocks5, + onValueChange = { onChange(cfg.copy(upstreamSocks5 = it)) }, + label = { Text("upstream_socks5 (optional)") }, + placeholder = { Text("host:port") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + supportingText = { + Text("If set, route upstream via this SOCKS5. Leave blank for direct.") + }, + ) + } +} + +// ========================================================================= +// Live log pane — polls Native.drainLogs() on a 500ms tick. +// ========================================================================= + +@Composable +private fun LiveLogPane() { + val lines = remember { mutableStateListOf() } + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + + // Pull from the ring buffer periodically. We pull even while the + // section is collapsed (cheap), so re-expanding shows fresh tail. + LaunchedEffect(Unit) { + while (true) { + val blob = withContext(Dispatchers.IO) { + runCatching { Native.drainLogs() }.getOrNull() + } + if (!blob.isNullOrEmpty()) { + blob.split("\n").forEach { if (it.isNotBlank()) lines.add(it) } + // Cap the visible list so we don't grow unboundedly. + while (lines.size > 500) lines.removeAt(0) + // Follow tail. + if (lines.isNotEmpty()) { + listState.scrollToItem(lines.size - 1) + } + } + delay(500) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "${lines.size} lines", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { lines.clear() }) { Text("Clear") } + } + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = 160.dp, max = 320.dp), + ) { + LazyColumn( + state = listState, + modifier = Modifier.padding(8.dp), + ) { + items(lines) { line -> + Text( + line, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ) + } + } + } + } +} + +// ========================================================================= +// Small shared pieces. +// ========================================================================= + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + ) +} + +/** + * Minimal disclosure widget. Compose has no stock "expandable card" in + * Material3 yet, so we build it from a clickable header + AnimatedVisibility + * wrapping the content. + */ +@Composable +private fun CollapsibleSection( + title: String, + initiallyExpanded: Boolean = false, + content: @Composable ColumnScope.() -> Unit, +) { + var expanded by rememberSaveable(title) { mutableStateOf(initiallyExpanded) } + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + title, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { expanded = !expanded }) { + Icon( + if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (expanded) "Collapse" else "Expand", + ) + } + } + AnimatedVisibility(visible = expanded) { + Column( + modifier = Modifier.padding(top = 4.dp, bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + content = content, + ) + } + } + } +} + +@Composable +private fun HowToUseCard(listenPort: Int) { + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("How to use", style = MaterialTheme.typography.titleMedium) + Text( + "1. Paste one or more Apps Script deployment URLs (or bare IDs) and your auth_key.\n" + + "2. Tap Install MITM certificate. Confirm the dialog — the cert is saved to " + + "Downloads/mhrv-ca.crt and Security settings opens. Navigate: Encryption & " + + "credentials → Install a certificate → \"CA certificate\" (NOT \"VPN & app user " + + "certificate\" or \"Wi-Fi\"). Pick mhrv-ca.crt from Downloads. You'll be asked to " + + "set a screen lock if you don't have one (Android requirement).\n" + + "3. Before tapping Start, expand \"SNI pool + tester\" and hit \"Test all\". If " + + "every entry times out, your google_ip is unreachable — replace it with one that " + + "resolves locally (e.g. `nslookup www.google.com` on any working device).\n" + + "4. Tap Start. Accept the VPN prompt. The full TUN bridge routes every app on the " + + "device through the proxy — no per-app setup needed.\n" + + "5. If Chrome shows \"504 Relay timeout\": your Apps Script deployment isn't " + + "responding. Redeploy the script, grab the new /exec URL, and paste it above. " + + "Watch Live logs for \"Relay timeout\" vs \"connect:\" errors to tell which layer " + + "is failing.\n" + + "\n" + + "Known limitation — Cloudflare Turnstile (\"Verify you are human\") will loop " + + "endlessly on most CF-protected sites. Every Apps Script request uses a rotating " + + "Google-datacenter egress IP + a fixed \"Google-Apps-Script\" User-Agent + a " + + "Google TLS fingerprint. The cf_clearance cookie is bound to the (IP, UA, JA3) " + + "tuple the challenge was solved against, so the NEXT request — from a different " + + "egress IP — gets re-challenged. Nothing in this app can fix that; it's inherent " + + "to Apps Script as a relay. Sites that only gate the initial page load (not every " + + "request) will work after one solve.", + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/theme/Theme.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/theme/Theme.kt new file mode 100644 index 0000000..c21da69 --- /dev/null +++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/theme/Theme.kt @@ -0,0 +1,97 @@ +package com.therealaleph.mhrv.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * Visual theme tuned to match the desktop `mhrv-rs-ui` eframe UI pixel-for-pixel + * where Compose semantics allow. The canonical source lives in `src/bin/ui.rs` + * — these constants are the same `egui::Color32` values, re-expressed as + * `Color(0xAARRGGBB)`. If you change a value here and not there (or vice + * versa) the two builds will drift visibly. + * + * Deliberate choices: + * - ALWAYS dark. The desktop UI is always dark (`egui::Visuals::dark()`), + * so Android follows. Neither light mode nor Android 12+ dynamic color + * is respected — matching the desktop trumps blending with the user's + * wallpaper here. + * - Card corners 6.dp, button corners 4.dp, matching the eframe + * `.rounding(6.0)` / `.rounding(4.0)` pairs in the desktop code. + */ + +// Exact palette from src/bin/ui.rs (line 508+). +// ACCENT / ACCENT_HOVER +val AccentBlue = Color(0xFF4678B4) +val AccentHover = Color(0xFF5A91CD) +// OK_GREEN / ERR_RED +val OkGreen = Color(0xFF50B464) +val ErrRed = Color(0xFFDC6E6E) + +// Card fill and stroke used by section containers in the desktop UI. +val CardFill = Color(0xFF1C1E22) +val CardStroke = Color(0xFF32363C) + +// Backdrop slightly darker than cards so containers pop off the page — +// egui's default dark background sits right around this value. +val BgDark = Color(0xFF111317) + +// Text shades — `egui::Color32::from_gray(200)` etc. +val TextPrimary = Color(0xFFC8C8C8) +val TextSecondary = Color(0xFF8C8C8C) +val TextLabel = Color(0xFFB4B4B4) + +private val MhrvDark = darkColorScheme( + primary = AccentBlue, + onPrimary = Color.White, + primaryContainer = AccentHover, + onPrimaryContainer = Color.White, + + secondary = OkGreen, + onSecondary = Color.Black, + + tertiary = OkGreen, + onTertiary = Color.Black, + + error = ErrRed, + onError = Color.White, + + background = BgDark, + onBackground = TextPrimary, + + surface = CardFill, + onSurface = TextPrimary, + + surfaceVariant = CardFill, + onSurfaceVariant = TextSecondary, + + outline = CardStroke, + outlineVariant = CardStroke, +) + +/** + * Material3 consumes Shapes through component defaults (Button uses + * `shapes.full`, Card uses `shapes.medium`, etc.). Mapping every size to + * tight rounded-rectangles keeps the whole app visually consistent with + * the desktop's squared-off controls instead of Material's default pills. + */ +private val MhrvShapes = Shapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(6.dp), + large = RoundedCornerShape(6.dp), + extraLarge = RoundedCornerShape(8.dp), +) + +@Composable +fun MhrvTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = MhrvDark, + shapes = MhrvShapes, + content = content, + ) +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..3aa7fab --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..16735fb --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..16735fb --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..1a1a1f6 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + mhrv-rs + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4d6c251 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..798f19b --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..e6fdbc6 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + 127.0.0.1 + localhost + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..c308190 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,6 @@ +// Top-level build file. Versions are pinned here and inherited by :app. +plugins { + id("com.android.application") version "8.5.0" apply false + id("org.jetbrains.kotlin.android") version "2.0.0" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..72a520b --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,9 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true +# Pinned below SDK 35 for now — Android 14 (API 34) is fine for an app +# this simple; bumping later is a one-line change once we know the +# minimum-device-tested set. diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d997cfc60f4cff0e7451d19d49a82fa986695d07 GIT binary patch literal 48966 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

`Lem|V$A>eR0uN8Y&sdjtu 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

1bvi{DS7E@q1{$_8->K{_OWv2 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 literal 0 HcmV?d00001 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..09523c0 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..739907d --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..0fd0a42 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "mhrv-android" +include(":app") diff --git a/src/android_jni.rs b/src/android_jni.rs new file mode 100644 index 0000000..6fa224b --- /dev/null +++ b/src/android_jni.rs @@ -0,0 +1,385 @@ +//! JNI entry points for the Android app. +//! +//! The app (Kotlin) calls `Native.setDataDir()` once, then `Native.startProxy()` +//! with the full config.json payload and gets back a handle (u64). Later the +//! app calls `stopProxy(handle)` to stop, `statsJson(handle)` to poll, or +//! `exportCa(dest)` to copy the MITM CA cert to a path the app can hand to +//! Android's system "install certificate" dialog. +//! +//! The proxy runs on an internal tokio runtime that we own (1 worker thread +//! minimum) — we don't piggyback on the JVM thread that calls in. +//! +//! SAFETY: every `extern "system"` entry point catches panics so they never +//! unwind across the JNI boundary (UB otherwise). + +#![cfg(target_os = "android")] + +use std::collections::VecDeque; +use std::panic::AssertUnwindSafe; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use jni::objects::{JClass, JString}; +use jni::sys::{jboolean, jlong, jstring, JNI_FALSE, JNI_TRUE}; +use jni::JNIEnv; +use tokio::runtime::Runtime; +use tokio::sync::{oneshot, Mutex as AsyncMutex}; + +use crate::config::Config; +use crate::mitm::{MitmCertManager, CA_CERT_FILE}; +use crate::proxy_server::ProxyServer; + +/// Running-proxy record. The JNI handle is the index into a slot map we +/// keep in a lazy-initialized global — we can't round-trip a Rust pointer +/// through `jlong` safely if the JVM compacts, but we can hand out an +/// integer key. +struct Running { + /// Dropping this sends the shutdown signal. Optional so we can `take()` + /// it in stop(). + shutdown: Option>, + /// Own the runtime so it outlives the server. Dropped last. + rt: Option, +} + +static HANDLE_COUNTER: AtomicU64 = AtomicU64::new(1); + +fn slot_map() -> &'static Mutex> { + static SLOTS: OnceLock>> = OnceLock::new(); + SLOTS.get_or_init(|| Mutex::new(std::collections::HashMap::new())) +} + +// --------------------------------------------------------------------------- +// Logging bridge. +// +// We fan each tracing event out two ways: +// 1. `__android_log_write` — lands in `adb logcat` under tag `mhrv_rs`. +// 2. An in-memory ring buffer the Kotlin UI drains via `Native.drainLogs()`. +// The first path was enough to get past "startProxy returned 0 — silent +// failure"; the second path gives the user a live log panel without making +// them attach a debugger. +// --------------------------------------------------------------------------- + +extern "C" { + fn __android_log_write(prio: i32, tag: *const std::os::raw::c_char, text: *const std::os::raw::c_char) -> i32; +} + +const ANDROID_LOG_INFO: i32 = 4; +const LOG_RING_CAP: usize = 500; + +fn log_ring() -> &'static Mutex> { + static RING: OnceLock>> = OnceLock::new(); + RING.get_or_init(|| Mutex::new(VecDeque::with_capacity(LOG_RING_CAP))) +} + +/// MakeWriter that forwards each write to `__android_log_write` AND to the +/// in-memory ring buffer. One line per write call; we trim the trailing +/// newline that tracing-subscriber appends so logcat doesn't show blank +/// rows between every event. +struct LogcatWriter; + +impl std::io::Write for LogcatWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + // Skip empty writes — tracing occasionally flushes a bare "\n". + if buf.is_empty() { return Ok(0); } + let trimmed = if buf.ends_with(b"\n") { &buf[..buf.len() - 1] } else { buf }; + + // logcat side. + let mut cstr = Vec::with_capacity(trimmed.len() + 1); + cstr.extend_from_slice(trimmed); + cstr.push(0); + static TAG: &[u8] = b"mhrv_rs\0"; + unsafe { + __android_log_write( + ANDROID_LOG_INFO, + TAG.as_ptr() as *const std::os::raw::c_char, + cstr.as_ptr() as *const std::os::raw::c_char, + ); + } + + // ring-buffer side. Best-effort UTF-8; if there are invalid bytes + // we'd rather show replacement chars than drop the line entirely. + if let Ok(mut g) = log_ring().lock() { + if g.len() >= LOG_RING_CAP { + g.pop_front(); + } + let line = String::from_utf8_lossy(trimmed).into_owned(); + g.push_back(line); + } + + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { Ok(()) } +} + +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogcatWriter { + type Writer = LogcatWriter; + fn make_writer(&'a self) -> Self::Writer { LogcatWriter } +} + +fn install_logging_once() { + use std::sync::Once; + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .with_ansi(false) + .with_writer(LogcatWriter) + .try_init(); + + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + +/// Helper: JString -> String, defaulting to "" on any failure. +fn jstring_to_string(env: &mut JNIEnv, s: &JString) -> String { + env.get_string(s) + .map(|j| j.into()) + .unwrap_or_else(|_| String::new()) +} + +fn safe R + std::panic::UnwindSafe, R>(default: R, f: F) -> R { + std::panic::catch_unwind(f).unwrap_or(default) +} + +/// Build a throwaway tokio runtime for one-shot blocking calls from JNI. +/// Small, single-worker — sufficient for probes and cert ops. +fn one_shot_runtime() -> Option { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .ok() +} + +/// `Native.setDataDir(String)` — must be called once, before `startProxy`. +/// The Kotlin side passes `context.filesDir.absolutePath`. +#[no_mangle] +pub extern "system" fn Java_com_therealaleph_mhrv_Native_setDataDir( + mut env: JNIEnv, + _class: JClass, + path: JString, +) { + let _ = safe((), AssertUnwindSafe(|| { + install_logging_once(); + let p = jstring_to_string(&mut env, &path); + if !p.is_empty() { + crate::data_dir::set_data_dir(PathBuf::from(p)); + } + })); +} + +/// `Native.startProxy(String configJson)` -> `long` handle (0 on failure). +/// The config is parsed and validated; on success the proxy server is +/// spawned on its own tokio runtime and a non-zero handle returned. +#[no_mangle] +pub extern "system" fn Java_com_therealaleph_mhrv_Native_startProxy( + mut env: JNIEnv, + _class: JClass, + config_json: JString, +) -> jlong { + safe(0i64, AssertUnwindSafe(|| { + install_logging_once(); + + let json = jstring_to_string(&mut env, &config_json); + let config: Config = match serde_json::from_str(&json) { + Ok(c) => c, + Err(e) => { + tracing::error!("android: invalid config json: {}", e); + return 0i64; + } + }; + + // Try to build the runtime first — if allocation fails we want to + // know before spinning up anything stateful. + let rt = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .thread_name("mhrv-worker") + .build() + { + Ok(r) => r, + Err(e) => { + tracing::error!("android: tokio runtime build failed: {}", e); + return 0i64; + } + }; + + let base = crate::data_dir::data_dir(); + let mitm = match MitmCertManager::new_in(&base) { + Ok(m) => m, + Err(e) => { + tracing::error!("android: MITM CA init failed: {}", e); + return 0i64; + } + }; + let mitm = Arc::new(AsyncMutex::new(mitm)); + + let server = match ProxyServer::new(&config, mitm) { + Ok(s) => s, + Err(e) => { + tracing::error!("android: ProxyServer::new failed: {}", e); + return 0i64; + } + }; + + let (tx, rx) = oneshot::channel::<()>(); + + rt.spawn(async move { + if let Err(e) = server.run(rx).await { + tracing::error!("android: proxy server exited: {}", e); + } + }); + + let handle = HANDLE_COUNTER.fetch_add(1, Ordering::Relaxed); + slot_map().lock().unwrap().insert( + handle, + Running { + shutdown: Some(tx), + rt: Some(rt), + }, + ); + handle as jlong + })) +} + +/// `Native.stopProxy(long handle)` -> boolean. Idempotent: calling on an +/// unknown handle returns false quietly. +/// +/// Uses `Runtime::shutdown_timeout` instead of letting `drop(rt)` block +/// synchronously. `drop(rt)` waits forever for tokio tasks to finish, and +/// if ANY task is stuck (in-flight TLS handshake, retrying HTTP request, +/// blocked read) the whole thing deadlocks — which is exactly what caused +/// the reported "Stop doesn't disconnect; subsequent Start fails with +/// Address already in use" bug. 3s is enough for a cooperative server to +/// unwind; anything slower, we force-kill (the listener socket is released +/// as part of the forced shutdown). +#[no_mangle] +pub extern "system" fn Java_com_therealaleph_mhrv_Native_stopProxy( + _env: JNIEnv, + _class: JClass, + handle: jlong, +) -> jboolean { + safe(JNI_FALSE, AssertUnwindSafe(|| { + let mut map = slot_map().lock().unwrap(); + let Some(mut running) = map.remove(&(handle as u64)) else { + return JNI_FALSE; + }; + if let Some(tx) = running.shutdown.take() { + let _ = tx.send(()); + } + // Release the map lock BEFORE shutting the runtime down so concurrent + // JNI callers (stats queries, etc.) don't stall behind us. + drop(map); + if let Some(rt) = running.rt.take() { + tracing::info!("android: stopProxy handle={} — shutting runtime down", handle); + rt.shutdown_timeout(std::time::Duration::from_secs(5)); + tracing::info!("android: stopProxy handle={} — runtime shutdown complete", handle); + } + JNI_TRUE + })) +} + +/// `Native.exportCa(String destPath)` -> boolean. Writes the MITM CA's +/// public cert to the given path. Init-safe: creates the CA on first call +/// if it doesn't exist yet. +#[no_mangle] +pub extern "system" fn Java_com_therealaleph_mhrv_Native_exportCa( + mut env: JNIEnv, + _class: JClass, + dest: JString, +) -> jboolean { + safe(JNI_FALSE, AssertUnwindSafe(|| { + install_logging_once(); + let dest_path = jstring_to_string(&mut env, &dest); + if dest_path.is_empty() { + return JNI_FALSE; + } + let base = crate::data_dir::data_dir(); + if MitmCertManager::new_in(&base).is_err() { + return JNI_FALSE; + } + let src = base.join(CA_CERT_FILE); + match std::fs::copy(&src, &dest_path) { + Ok(_) => JNI_TRUE, + Err(e) => { + tracing::error!("android: CA export to {} failed: {}", dest_path, e); + JNI_FALSE + } + } + })) +} + +/// `Native.version()` -> String. Trivial smoke test for the JNI linkage. +#[no_mangle] +pub extern "system" fn Java_com_therealaleph_mhrv_Native_version<'a>( + env: JNIEnv<'a>, + _class: JClass, +) -> jstring { + let v = env!("CARGO_PKG_VERSION"); + env.new_string(v).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut()) +} + +/// `Native.drainLogs()` -> String. Returns the full ring buffer as a single +/// `\n`-joined blob, then clears it. We return one String rather than an +/// array because it's one JNI call vs. N — the Kotlin side splits on `\n` +/// for display. Empty string when there's nothing to read. +#[no_mangle] +pub extern "system" fn Java_com_therealaleph_mhrv_Native_drainLogs<'a>( + env: JNIEnv<'a>, + _class: JClass, +) -> jstring { + let out = safe(String::new(), AssertUnwindSafe(|| { + let mut g = match log_ring().lock() { + Ok(g) => g, + Err(_) => return String::new(), + }; + let lines: Vec = g.drain(..).collect(); + lines.join("\n") + })); + env.new_string(out).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut()) +} + +/// `Native.testSni(googleIp, sni)` -> String. Returns a small JSON blob +/// like `{"ok":true,"latencyMs":123}` or `{"ok":false,"error":"..."}`. +/// Blocking call — Kotlin side should invoke on a background coroutine. +#[no_mangle] +pub extern "system" fn Java_com_therealaleph_mhrv_Native_testSni<'a>( + mut env: JNIEnv<'a>, + _class: JClass, + google_ip: JString, + sni: JString, +) -> jstring { + let result_json = safe(r#"{"ok":false,"error":"panic"}"#.to_string(), AssertUnwindSafe(|| { + install_logging_once(); + let ip = jstring_to_string(&mut env, &google_ip); + let s = jstring_to_string(&mut env, &sni); + if ip.is_empty() || s.is_empty() { + return r#"{"ok":false,"error":"empty google_ip or sni"}"#.to_string(); + } + let Some(rt) = one_shot_runtime() else { + return r#"{"ok":false,"error":"tokio init failed"}"#.to_string(); + }; + let probe = rt.block_on(crate::scan_sni::probe_one(&ip, &s)); + match (probe.latency_ms, probe.error) { + (Some(ms), _) => { + tracing::info!("sni_probe: {} via {} ok in {}ms", s, ip, ms); + format!(r#"{{"ok":true,"latencyMs":{}}}"#, ms) + } + (None, Some(e)) => { + // Surface the reason in logcat too — otherwise users see a + // red dot in the UI with no path to diagnose. Common causes: + // - "dns: ..." -> system resolver can't reach DNS + // - "connect: ..." -> TCP to google_ip:443 blocked + // - "handshake: ..." -> TLS fail (cert, ALPN, etc.) + tracing::warn!("sni_probe: {} via {} FAIL: {}", s, ip, e); + let cleaned = e.replace('\\', "\\\\").replace('"', "\\\""); + format!(r#"{{"ok":false,"error":"{}"}}"#, cleaned) + } + _ => r#"{"ok":false,"error":"unknown"}"#.to_string(), + } + })); + env.new_string(result_json).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut()) +} diff --git a/src/data_dir.rs b/src/data_dir.rs index f2e5792..3051a81 100644 --- a/src/data_dir.rs +++ b/src/data_dir.rs @@ -1,7 +1,21 @@ use std::path::{Path, PathBuf}; +use std::sync::OnceLock; const APP_NAME: &str = "mhrv-rs"; +/// Global override. On Android the app sets this to its private files dir +/// before any other mhrv-rs code runs — avoids `directories` crate returning +/// a questionable path inside `/data/data/...` that the app may not own. +/// On desktop platforms nobody sets this and the normal fallback applies. +static DATA_DIR_OVERRIDE: OnceLock = OnceLock::new(); + +/// Set the data directory. Takes effect ONLY on the first call — later +/// calls are no-ops (OnceLock semantics). Intended for Android's JNI init +/// path; don't call from desktop builds. +pub fn set_data_dir(path: PathBuf) { + let _ = DATA_DIR_OVERRIDE.set(path); +} + /// Returns the platform-appropriate user-data directory for this app, creating /// it if necessary. Falls back to the current directory if the dir can't be /// determined (rare). @@ -9,7 +23,13 @@ const APP_NAME: &str = "mhrv-rs"; /// - macOS: `~/Library/Application Support/mhrv-rs` /// - Linux: `~/.config/mhrv-rs` (or `$XDG_CONFIG_HOME/mhrv-rs`) /// - Windows: `%APPDATA%\mhrv-rs` +/// - Android: whatever the app passed to `set_data_dir()` (typically the +/// app's private `filesDir`). pub fn data_dir() -> PathBuf { + if let Some(p) = DATA_DIR_OVERRIDE.get() { + let _ = std::fs::create_dir_all(p); + return p.clone(); + } let dir = directories::ProjectDirs::from("", "", APP_NAME) .map(|d| d.config_dir().to_path_buf()) .unwrap_or_else(|| PathBuf::from(".")); diff --git a/src/lib.rs b/src/lib.rs index d074ad1..0405fd4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,3 +12,6 @@ pub mod scan_ips; pub mod scan_sni; pub mod test_cmd; pub mod update_check; + +#[cfg(target_os = "android")] +pub mod android_jni; diff --git a/src/proxy_server.rs b/src/proxy_server.rs index 4a1d507..6a10a27 100644 --- a/src/proxy_server.rs +++ b/src/proxy_server.rs @@ -8,7 +8,8 @@ use tokio_rustls::rustls::client::danger::{ }; use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime}; use tokio_rustls::rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme}; -use tokio_rustls::{TlsAcceptor, TlsConnector}; +use tokio_rustls::rustls::server::Acceptor; +use tokio_rustls::{LazyConfigAcceptor, TlsAcceptor, TlsConnector}; use crate::config::Config; use crate::domain_fronter::DomainFronter; @@ -725,42 +726,85 @@ async fn run_mitm_then_relay( mitm: Arc>, fronter: &DomainFronter, ) { - tracing::info!("MITM TLS -> {}:{}", host, port); - - let server_config = { - let mut m = mitm.lock().await; - match m.get_server_config(host) { - Ok(c) => c, - Err(e) => { - tracing::error!("cert gen failed for {}: {}", host, e); - return; - } - } - }; - let acceptor = TlsAcceptor::from(server_config); - - let mut tls = match acceptor.accept(sock).await { - Ok(t) => t, + // Peek the TLS ClientHello BEFORE minting the MITM cert. When the client + // resolves the hostname itself (DoH in Chrome/Firefox) and hands us a raw + // IP via SOCKS5, the only place the real hostname lives is the SNI. If we + // mint a cert for the IP, Chrome rejects with ERR_CERT_COMMON_NAME_INVALID + // — the IP isn't in the cert's SAN. Reading SNI up front and using it as + // both the cert subject and the upstream Host for the Apps Script relay + // is what unblocks Cloudflare-fronted sites and any browser on Android + // where DoH is the default. + let start = match LazyConfigAcceptor::new(Acceptor::default(), sock).await { + Ok(s) => s, Err(e) => { - tracing::debug!("TLS accept failed for {}: {}", host, e); + tracing::debug!("TLS ClientHello peek failed for {}: {}", host, e); return; } }; - // Keep-alive loop: read HTTP requests from the decrypted stream. - // scheme=https because we MITM-terminated TLS. + let sni_hostname = start.client_hello().server_name().map(String::from); + + // Effective host: SNI when present and looks like a hostname (anything + // other than a bare IPv4 literal — IP SNIs exist for weird setups but + // minting a cert for them still triggers ERR_CERT_COMMON_NAME_INVALID, + // so we fall through to the raw host in that case). + let effective_host: String = match sni_hostname.as_deref() { + Some(s) if !looks_like_ip(s) && !s.is_empty() => s.to_string(), + _ => host.to_string(), + }; + + tracing::info!( + "MITM TLS -> {}:{} (socks_host={}, sni={})", + effective_host, + port, + host, + sni_hostname.as_deref().unwrap_or(""), + ); + + let server_config = { + let mut m = mitm.lock().await; + match m.get_server_config(&effective_host) { + Ok(c) => c, + Err(e) => { + tracing::error!("cert gen failed for {}: {}", effective_host, e); + return; + } + } + }; + + let mut tls = match start.into_stream(server_config).await { + Ok(t) => t, + Err(e) => { + tracing::debug!("TLS accept failed for {}: {}", effective_host, e); + return; + } + }; + + // Keep-alive loop: read HTTP requests from the decrypted stream. Pass the + // SNI-derived hostname so the Apps Script relay fetches + // `https:///path` instead of `https:///path` — the + // latter would produce an IP-in-Host request that Cloudflare/etc. reject + // outright. loop { - match handle_mitm_request(&mut tls, host, port, fronter, "https").await { + match handle_mitm_request(&mut tls, &effective_host, port, fronter, "https").await { Ok(true) => continue, Ok(false) => break, Err(e) => { - tracing::debug!("MITM handler error for {}: {}", host, e); + tracing::debug!("MITM handler error for {}: {}", effective_host, e); break; } } } } +/// True if `s` parses as an IPv4 or IPv6 literal. Used to decide whether +/// a string is a hostname we should mint a MITM leaf cert for — IP SANs +/// need their own cert extension and we don't bother emitting those, +/// so fall back to the SOCKS5-provided target in that case. +fn looks_like_ip(s: &str) -> bool { + s.parse::().is_ok() +} + // ---------- Plain HTTP relay on a raw TCP stream (port 80 targets) ---------- async fn relay_http_stream_raw( @@ -959,6 +1003,48 @@ where format!("{}://{}:{}{}", scheme, host, port, path) }; + // Short-circuit CORS preflight at the MITM boundary. + // + // Apps Script's UrlFetchApp.fetch() only accepts methods {get, delete, + // patch, post, put} — OPTIONS triggers the Swedish-localized + // "Ett attribut med ogiltigt värde har angetts: method" error, which + // kills every XHR/fetch preflight and is the root cause of "JS doesn't + // load" on sites like Discord, Yahoo finance widgets, etc. + // + // Answering the preflight ourselves is safe: we already terminate the + // TLS for the browser (we minted the cert), so it's legitimate for us + // to own the wire-level conversation. CORS is a browser-side + // protection, not a network security one — responding 204 with + // permissive ACL headers just tells the browser the *subsequent* real + // request is allowed, and that real request still goes through the + // Apps Script relay where the origin server gets final say on content. + // The origin header is echoed (not "*") so Credentials-true responses + // stay spec-valid. + if method.eq_ignore_ascii_case("OPTIONS") { + tracing::info!("preflight 204 {} (short-circuit, no relay)", url); + let origin = header_value(&headers, "origin").unwrap_or("*"); + let acrm = header_value(&headers, "access-control-request-method") + .unwrap_or("GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD"); + let acrh = header_value(&headers, "access-control-request-headers").unwrap_or("*"); + let resp = format!( + "HTTP/1.1 204 No Content\r\n\ + Access-Control-Allow-Origin: {origin}\r\n\ + Access-Control-Allow-Methods: {acrm}\r\n\ + Access-Control-Allow-Headers: {acrh}\r\n\ + Access-Control-Allow-Credentials: true\r\n\ + Access-Control-Max-Age: 86400\r\n\ + Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers\r\n\ + Content-Length: 0\r\n\ + \r\n", + ); + stream.write_all(resp.as_bytes()).await?; + stream.flush().await?; + let connection_close = headers + .iter() + .any(|(k, v)| k.eq_ignore_ascii_case("connection") && v.eq_ignore_ascii_case("close")); + return Ok(!connection_close); + } + tracing::info!("relay {} {}", method, url); let response = fronter.relay(&method, &url, &headers, &body).await;