v1.0.2: stable release signature, idempotent Stop, top-level Settings for CA install (#33)

Three fixes + one behaviour change from v1.0.1 reports.

APK signature is now stable (release.jks committed)
----------------------------------------------------
v1.0.0 and v1.0.1 signed release APKs with Gradle's
auto-generated debug keystore, which is randomly generated per
machine and per CI runner. Result: every upgrade failed with
INSTALL_FAILED_UPDATE_INCOMPATIBLE and users had to uninstall
first. Unfixable without a stable key.

android/app/release.jks now holds that key, committed to the
repo with the password in plaintext in build.gradle.kts. This
is fine for a FOSS sideload project without a Play Store
identity — the trust model is "trust the source tree you
pulled from," not "trust the key we hold." Anyone forking and
shipping a rebranded build should generate their own key.

One-time cost: v1.0.1 → v1.0.2 STILL requires uninstall,
because we're switching signature keys. Every upgrade from
v1.0.2 onward is clean.

Stop no longer (sometimes) closes the app
-----------------------------------------
teardown() is reachable from three paths on two threads:
  1. ACTION_STOP onStartCommand branch  (mhrv-teardown worker)
  2. onDestroy after stopSelf            (main thread)
  3. VpnService revocation out-of-band   (main thread)
Running the full native cleanup sequence twice races the two
threads through Tun2proxy.stop() → fd.close() →
Native.stopProxy(handle) on state that's already been
nullified — SIGSEGV source, user-visible as "tap Stop, app
disappears."

New AtomicBoolean `tornDown` gates entry: first caller wins,
every subsequent caller logs "teardown: already done" and
returns. onDestroy also wraps the call in try/catch — crashing
out of onDestroy takes the whole process with it, which is
exactly the bug we're trying to fix. Smoke-tested on emulator:
teardown now logs

  teardown: begin caller=mhrv-teardown
  ... clean sequence ...
  teardown: done
  onDestroy entered
  teardown: already done, skipping (caller=main)
  onDestroy done

with PID unchanged throughout.

CA install now routes to the Settings search
--------------------------------------------
Old flow: `Settings.ACTION_SECURITY_SETTINGS` deep-link, then
walk "Encryption & credentials → Install a certificate →
CA certificate". That path varies wildly between OEMs (Samsung
buries it under "Biometrics and security → Other security
settings"; Xiaomi under "Passwords & Security → Privacy"; Pixel
splits it between "More security settings" and "Privacy
controls" depending on Android version). Users got lost.

New flow: open the top-level Settings app
(`Settings.ACTION_SETTINGS`) and instruct the user to use the
Settings search bar to find "CA certificate". Search is
consistent across OEMs and Android versions; the menu paths
are not. Dialog, snackbar, and `docs/android.md` copy all
updated to match.

Version bump: 1.0.1 → 1.0.2 (versionCode 101 → 102).
releases/mhrv-rs-android-universal-v1.0.1.apk replaced with
the v1.0.2 build.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Shin (Former Aleph)
2026-04-23 04:19:52 +03:00
committed by GitHub
parent b734f41faa
commit 64409f6b41
10 changed files with 114 additions and 51 deletions
Generated
+1 -1
View File
@@ -1960,7 +1960,7 @@ dependencies = [
[[package]] [[package]]
name = "mhrv-rs" name = "mhrv-rs"
version = "1.0.1" version = "1.0.2"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "mhrv-rs" name = "mhrv-rs"
version = "1.0.1" version = "1.0.2"
edition = "2021" edition = "2021"
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting" description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
license = "MIT" license = "MIT"
+28 -11
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.therealaleph.mhrv" applicationId = "com.therealaleph.mhrv"
minSdk = 24 // Android 7.0 — covers 99%+ of live devices. minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
targetSdk = 34 targetSdk = 34
versionCode = 101 versionCode = 102
versionName = "1.0.1" versionName = "1.0.2"
// Ship all four mainstream Android ABIs: // Ship all four mainstream Android ABIs:
// - arm64-v8a — 95%+ of real-world Android phones since 2019 // - arm64-v8a — 95%+ of real-world Android phones since 2019
@@ -30,6 +30,31 @@ android {
} }
} }
signingConfigs {
create("release") {
// Committed keystore — fixed signature across machines and
// across CI runs. Using the auto-generated debug keystore
// (as v1.0.0 / v1.0.1 did) makes every release APK fail to
// install over the previous one with
// INSTALL_FAILED_UPDATE_INCOMPATIBLE, because Android treats
// a signature change as "different app": the user has to
// uninstall first. That's awful UX.
//
// The password is in plaintext because this is an
// open-source project without Play Store identity. A
// forked/rebuilt APK signed with a different key is
// fundamentally a different install path anyway — the
// protection model here is "trust the source tree you
// pulled from," not "trust that we hold a key you can't
// see." If you're forking, generate your own key, commit
// it, and ship.
storeFile = file("release.jks")
storePassword = "mhrv-rs-release"
keyAlias = "mhrv-rs"
keyPassword = "mhrv-rs-release"
}
}
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
@@ -37,15 +62,7 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro", "proguard-rules.pro",
) )
// Sign release builds with the debug keystore so users can signingConfig = signingConfigs.getByName("release")
// sideload the APK without us shipping a proper release key.
// The project has no Play Store presence, so signature
// identity per-build doesn't matter — installability does.
// Gradle auto-creates `~/.android/debug.keystore` on first use;
// CI runners inherit that behaviour. Anyone rebuilding from
// source gets their own signature, which is what we want for
// an open-source project: trust the source, not a key we hold.
signingConfig = signingConfigs.getByName("debug")
} }
} }
Binary file not shown.
@@ -147,19 +147,26 @@ object CaInstall {
} }
/** /**
* Intent that opens the system "Security" settings screen. The exact * Intent that opens the TOP-LEVEL system Settings app. The Settings
* landing page depends on OEM and Android version: * search bar is the most portable way to get users to the CA-install
* - Pixel / stock AOSP: Settings → Security * screen across OEMs — every Android vendor ships the CA install
* - from there the user navigates Encryption & credentials * flow under a subtly different menu path (Encryption & credentials,
* Install a certificate → CA certificate → pick our .crt file. * Other security settings, Privacy → Credentials, etc.), but they
* all respond to a search for "CA certificate".
* *
* We tried KeyChain.createInstallIntent first (nicer flow) but on * Earlier versions used `Settings.ACTION_SECURITY_SETTINGS` which
* Android 11+ that intent just opens a dialog saying "Install CA * landed on Security & privacy directly, but on some OEMs (Samsung,
* certificates in Settings" with a Close button and no path forward — * Xiaomi, newer Pixel builds) that screen doesn't have the cert
* Google intentionally removed the inline install path. Settings is * install entry one tap away and users got stuck. Top-level Settings
* the fallback Google themselves point users at. * + "search for CA certificate" is the instruction that actually
* works everywhere.
*
* We DO NOT use KeyChain.createInstallIntent — on Android 11+ that
* intent opens a dialog that just says "Install CA certificates in
* Settings" with a Close button and no forward path. Google
* intentionally removed the inline install flow in that release.
*/ */
fun buildSettingsIntent(): Intent = Intent(Settings.ACTION_SECURITY_SETTINGS) fun buildSettingsIntent(): Intent = Intent(Settings.ACTION_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/** /**
@@ -36,6 +36,18 @@ class MhrvVpnService : VpnService() {
private var tun2proxyThread: Thread? = null private var tun2proxyThread: Thread? = null
private val tun2proxyRunning = AtomicBoolean(false) private val tun2proxyRunning = AtomicBoolean(false)
// Idempotency guard. teardown() is reachable from three paths:
// 1. ACTION_STOP onStartCommand branch (background thread)
// 2. onDestroy() (main thread, fires whenever stopSelf resolves
// OR Android decides to kill the service)
// 3. Android revoking the VPN profile out-of-band (also onDestroy)
// Running the full native cleanup sequence twice races two threads
// through Tun2proxy.stop(), fd.close(), Native.stopProxy() on state
// that's already been nullified — the second pass was the
// SIGSEGV-or-zombie source. This flag makes the second call a
// no-op.
private val tornDown = AtomicBoolean(false)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(TAG, "onStartCommand action=${intent?.action ?: "<null>"} startId=$startId") Log.i(TAG, "onStartCommand action=${intent?.action ?: "<null>"} startId=$startId")
return when (intent?.action) { return when (intent?.action) {
@@ -187,7 +199,21 @@ class MhrvVpnService : VpnService() {
* 4. Shut down the Rust proxy runtime (nothing left to forward to). * 4. Shut down the Rust proxy runtime (nothing left to forward to).
*/ */
private fun teardown() { private fun teardown() {
Log.i(TAG, "teardown: begin (tun2proxy running=${tun2proxyRunning.get()}, proxyHandle=$proxyHandle)") // Idempotency guard. Without this, onDestroy racing the
// ACTION_STOP background thread has been observed to crash the
// process — two threads into Tun2proxy.stop() and
// Native.stopProxy(handle) where handle has already been zeroed
// is a SIGSEGV waiting to happen. First caller wins, subsequent
// callers return immediately.
if (!tornDown.compareAndSet(false, true)) {
Log.i(TAG, "teardown: already done, skipping (caller=${Thread.currentThread().name})")
return
}
Log.i(
TAG,
"teardown: begin caller=${Thread.currentThread().name} " +
"(tun2proxy running=${tun2proxyRunning.get()}, proxyHandle=$proxyHandle)",
)
// 1. Cooperative stop signal. // 1. Cooperative stop signal.
if (tun2proxyRunning.get()) { if (tun2proxyRunning.get()) {
@@ -200,7 +226,9 @@ class MhrvVpnService : VpnService() {
// ParcelFileDescriptor no longer owns the fd and close() here // ParcelFileDescriptor no longer owns the fd and close() here
// is a no-op; the real fd is owned by tun2proxy (closeFdOnDrop // is a no-op; the real fd is owned by tun2proxy (closeFdOnDrop
// = true), which closes it on return from run(). // = true), which closes it on return from run().
try { tun?.close() } catch (_: Throwable) {} try { tun?.close() } catch (t: Throwable) {
Log.w(TAG, "tun.close: ${t.message}")
}
tun = null tun = null
// 3. Join the worker. 4s is enough in the happy case; if tun2proxy // 3. Join the worker. 4s is enough in the happy case; if tun2proxy
@@ -219,19 +247,31 @@ class MhrvVpnService : VpnService() {
// on the Rust side, so this is bounded even if the runtime // on the Rust side, so this is bounded even if the runtime
// has in-flight tasks (common when the Apps Script relay has // has in-flight tasks (common when the Apps Script relay has
// piled up pending 30s timeouts). // piled up pending 30s timeouts).
if (proxyHandle != 0L) { val handle = proxyHandle
Log.i(TAG, "teardown: stopping proxy handle=$proxyHandle") proxyHandle = 0L
try { Native.stopProxy(proxyHandle) } catch (t: Throwable) { if (handle != 0L) {
Log.i(TAG, "teardown: stopping proxy handle=$handle")
try { Native.stopProxy(handle) } catch (t: Throwable) {
Log.e(TAG, "Native.stopProxy threw: ${t.message}", t) Log.e(TAG, "Native.stopProxy threw: ${t.message}", t)
} }
proxyHandle = 0L
} }
Log.i(TAG, "teardown: done") Log.i(TAG, "teardown: done")
} }
override fun onDestroy() { override fun onDestroy() {
teardown() Log.i(TAG, "onDestroy entered")
try {
teardown()
} catch (t: Throwable) {
// Belt-and-suspenders. Crashing out of onDestroy takes the
// whole process with it — user-visible as the app closing
// right when they tap Stop, which is exactly the symptom we
// are trying to fix. Anything that gets here is logged and
// swallowed.
Log.e(TAG, "onDestroy teardown threw: ${t.message}", t)
}
super.onDestroy() super.onDestroy()
Log.i(TAG, "onDestroy done")
} }
private fun buildNotif(proxyPort: Int): Notification { private fun buildNotif(proxyPort: Int): Notification {
@@ -115,7 +115,7 @@ fun HomeScreen(
append("Certificate not yet installed.") append("Certificate not yet installed.")
if (!o.downloadPath.isNullOrBlank()) { if (!o.downloadPath.isNullOrBlank()) {
append(" Saved to ${o.downloadPath}. ") append(" Saved to ${o.downloadPath}. ")
append("In Settings: Encryption & credentials → Install a certificate → \"CA certificate\" (not VPN, not Wi-Fi) → pick that file.") append("In Settings, search for \"CA certificate\" and install from there — NOT \"VPN & app user certificate\" or \"Wi-Fi\".")
} else { } else {
append(" Tap Install again to retry.") append(" Tap Install again to retry.")
} }
@@ -374,11 +374,13 @@ fun HomeScreen(
Text( Text(
"On Android 11+ the system removed the inline install path, so " + "On Android 11+ the system removed the inline install path, so " +
"tapping Install will: (1) save a PEM copy to Downloads/mhrv-ca.crt, " + "tapping Install will: (1) save a PEM copy to Downloads/mhrv-ca.crt, " +
"(2) open Security settings. From there navigate to Encryption & " + "(2) open the Settings app.\n\n" +
"credentials → Install a certificate → pick \"CA certificate\" (NOT " + "Inside Settings, tap the search bar and type \"CA certificate\". " +
"\"VPN & app user certificate\" or \"Wi-Fi certificate\") → select " + "Open the result labelled \"CA certificate\" (NOT \"VPN & app user " +
"mhrv-ca.crt from Downloads. If you don't have a screen lock, Android " + "certificate\" or \"Wi-Fi certificate\"). Pick mhrv-ca.crt from " +
"will ask you to add one first." "Downloads when prompted. If you don't have a screen lock, Android " +
"will ask you to add one first — that's an OS requirement for " +
"installing any user CA."
) )
if (fp != null) { if (fp != null) {
Text("Subject: ${cn ?: "(unknown)"}", style = MaterialTheme.typography.labelMedium) Text("Subject: ${cn ?: "(unknown)"}", style = MaterialTheme.typography.labelMedium)
@@ -911,10 +913,10 @@ private fun HowToUseCard(listenPort: Int) {
Text( Text(
"1. Paste one or more Apps Script deployment URLs (or bare IDs) and your auth_key.\n" + "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 " + "2. Tap Install MITM certificate. Confirm the dialog — the cert is saved to " +
"Downloads/mhrv-ca.crt and Security settings opens. Navigate: Encryption & " + "Downloads/mhrv-ca.crt and the Settings app opens. Use Settings' search bar " +
"credentials → Install a certificate → \"CA certificate\" (NOT \"VPN & app user " + "to find \"CA certificate\", tap that result (NOT \"VPN & app user certificate\" " +
"certificate\" or \"Wi-Fi\"). Pick mhrv-ca.crt from Downloads. You'll be asked to " + "or \"Wi-Fi\"), and pick mhrv-ca.crt from Downloads. You'll be asked to set a " +
"set a screen lock if you don't have one (Android requirement).\n" + "screen lock if you don't have one (Android requirement).\n" +
"3. Before tapping Start, expand \"SNI pool + tester\" and hit \"Test all\". If " + "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 " + "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" + "resolves locally (e.g. `nslookup www.google.com` on any working device).\n" +
+2 -5
View File
@@ -89,12 +89,9 @@ This step is annoying but unavoidable: the proxy terminates TLS on your behalf s
1. In the app, tap **Install MITM certificate**. 1. In the app, tap **Install MITM certificate**.
2. Read the confirmation dialog — it shows the certificate fingerprint (handy to verify later). Tap **Install**. 2. Read the confirmation dialog — it shows the certificate fingerprint (handy to verify later). Tap **Install**.
3. The app saves `Downloads/mhrv-ca.crt` and deep-links Android into **Settings → Security & privacy** (or similar; wording varies by OEM). 3. The app saves `Downloads/mhrv-ca.crt` and opens the top-level **Settings** app.
4. If you don't have a screen lock: Android will prompt you to set one. **You have to.** User CAs require a screen lock, period. Set PIN/pattern/password. You can remove it after install if you really want; the cert stays installed. 4. If you don't have a screen lock: Android will prompt you to set one. **You have to.** User CAs require a screen lock, period. Set PIN/pattern/password. You can remove it after install if you really want; the cert stays installed.
5. In Settings, navigate: **Encryption & credentials → Install a certificate → "CA certificate"**. 5. In Settings, tap the **search bar at the top** and type `CA certificate`. Pick the result labelled **"CA certificate"** (or on some OEMs "Install CA certificate"). The menu path varies wildly between Pixel / Samsung / Xiaomi / etc., which is why searching beats navigating.
- On Pixel / stock Android: `Security → More security settings → Encryption & credentials → Install a certificate → CA certificate`.
- On Samsung: `Biometrics and security → Other security settings → Install from device storage → CA certificate`.
- On Xiaomi/MIUI: `Passwords & Security → Privacy → Encryption & credentials → Install a certificate → CA certificate`.
- **Do NOT** pick "VPN & app user certificate" or "Wi-Fi certificate" — wrong category, won't work. - **Do NOT** pick "VPN & app user certificate" or "Wi-Fi certificate" — wrong category, won't work.
6. Android warns you: **"Your network may be monitored by an unknown third party"**. That's us. Tap **Install anyway**. 6. Android warns you: **"Your network may be monitored by an unknown third party"**. That's us. Tap **Install anyway**.
7. Pick **Downloads** → tap **mhrv-ca.crt**. Give it a friendly name (or accept the default). Tap **OK**. 7. Pick **Downloads** → tap **mhrv-ca.crt**. Give it a friendly name (or accept the default). Tap **OK**.
+5 -5
View File
@@ -2,11 +2,11 @@
This folder contains the prebuilt binaries from the latest release, committed directly to the repository for users who cannot reach the GitHub Releases page. This folder contains the prebuilt binaries from the latest release, committed directly to the repository for users who cannot reach the GitHub Releases page.
Current version: **v1.0.1** Current version: **v1.0.2**
| File | Platform | Contents | | File | Platform | Contents |
|---|---|---| |---|---|---|
| `mhrv-rs-android-universal-v1.0.1.apk` | Android 7.0+ (all ABIs) | Universal APK — arm64-v8a, armeabi-v7a, x86_64, x86 in one file | | `mhrv-rs-android-universal-v1.0.2.apk` | Android 7.0+ (all ABIs) | Universal APK — arm64-v8a, armeabi-v7a, x86_64, x86 in one file |
| `mhrv-rs-linux-amd64.tar.gz` | Linux x86_64 | `mhrv-rs`, `mhrv-rs-ui`, `run.sh` | | `mhrv-rs-linux-amd64.tar.gz` | Linux x86_64 | `mhrv-rs`, `mhrv-rs-ui`, `run.sh` |
| `mhrv-rs-linux-arm64.tar.gz` | Linux aarch64 | `mhrv-rs`, `run.sh` (CLI only) | | `mhrv-rs-linux-arm64.tar.gz` | Linux aarch64 | `mhrv-rs`, `run.sh` (CLI only) |
| `mhrv-rs-raspbian-armhf.tar.gz` | Raspberry Pi / ARMv7 hardfloat | `mhrv-rs`, `run.sh` (CLI only) | | `mhrv-rs-raspbian-armhf.tar.gz` | Raspberry Pi / ARMv7 hardfloat | `mhrv-rs`, `run.sh` (CLI only) |
@@ -45,7 +45,7 @@ Extract `mhrv-rs-windows-amd64.zip`, then double-click `run.bat` inside the extr
### Android ### Android
Copy `mhrv-rs-android-universal-v1.0.1.apk` to your phone, tap it from the Files app, and allow "Install unknown apps" for whichever app is opening the APK (Files, Chrome, etc.). See [the Android guide](../docs/android.md) for the full walk-through of the first-run steps (Apps Script deployment, MITM CA install, VPN permission, SNI tester). Copy `mhrv-rs-android-universal-v1.0.2.apk` to your phone, tap it from the Files app, and allow "Install unknown apps" for whichever app is opening the APK (Files, Chrome, etc.). See [the Android guide](../docs/android.md) for the full walk-through of the first-run steps (Apps Script deployment, MITM CA install, VPN permission, SNI tester).
See the [main README](../README.md) for desktop setup (Apps Script deployment, config, browser proxy settings). See the [main README](../README.md) for desktop setup (Apps Script deployment, config, browser proxy settings).
@@ -55,7 +55,7 @@ See the [main README](../README.md) for desktop setup (Apps Script deployment, c
این پوشه شامل فایل‌های آخرین نسخه است و مستقیماً در ریپو قرار گرفته برای کاربرانی که به صفحهٔ GitHub Releases دسترسی ندارند. این پوشه شامل فایل‌های آخرین نسخه است و مستقیماً در ریپو قرار گرفته برای کاربرانی که به صفحهٔ GitHub Releases دسترسی ندارند.
نسخهٔ فعلی: **v1.0.1** نسخهٔ فعلی: **v1.0.2**
### دانلود از طریق ZIP ### دانلود از طریق ZIP
@@ -73,6 +73,6 @@ cd mhrv-rs-macos-arm64
**ویندوز:** فایل `mhrv-rs-windows-amd64.zip` را extract کنید و داخل پوشه روی `run.bat` دو بار کلیک کنید (UAC را قبول کنید تا گواهی MITM نصب شود). **ویندوز:** فایل `mhrv-rs-windows-amd64.zip` را extract کنید و داخل پوشه روی `run.bat` دو بار کلیک کنید (UAC را قبول کنید تا گواهی MITM نصب شود).
**اندروید:** فایل `mhrv-rs-android-universal-v1.0.1.apk` را روی گوشی کپی کنید، از Files app روی آن tap کنید و اجازهٔ "نصب برنامه‌های ناشناس" را بدهید. راهنمای کامل شروع به کار (دیپلوی Apps Script، نصب CA، اجازهٔ VPN، تستر SNI) در [راهنمای اندروید](../docs/android.md) هست. **اندروید:** فایل `mhrv-rs-android-universal-v1.0.2.apk` را روی گوشی کپی کنید، از Files app روی آن tap کنید و اجازهٔ "نصب برنامه‌های ناشناس" را بدهید. راهنمای کامل شروع به کار (دیپلوی Apps Script، نصب CA، اجازهٔ VPN، تستر SNI) در [راهنمای اندروید](../docs/android.md) هست.
برای راه‌اندازی کامل دسکتاپ (دیپلوی Apps Script، config، تنظیم proxy مرورگر) به [README اصلی](../README.md) مراجعه کنید. برای راه‌اندازی کامل دسکتاپ (دیپلوی Apps Script، config، تنظیم proxy مرورگر) به [README اصلی](../README.md) مراجعه کنید.