v1.0.1: auto-resolve google_ip, robust Stop, Check-for-updates, front_domain repair (#31)

Three reported issues from v1.0.0 — one real bug, two UX gaps.

google_ip auto-resolve (THE FIX)
--------------------------------
Google rotates the A record for www.google.com across their anycast
pool. A hardcoded default IP breaks new installs on any network that
isn't geo-homed to the same edge — symptom is "all SNIs time out"
even with a fresh deployment. On Start and via a new "Auto-detect"
button, we now do a JVM-side InetAddress lookup BEFORE establishing
the VPN (so the resolver uses the underlying network, not our own
Virtual-DNS TUN — avoids a loop), update the config, and continue.

The auto-resolve lives in the HomeScreen click handler (not
MainActivity) so it goes through the same `persist(cfg)` the text
fields use. Previous iteration did `ConfigStore.load → modify → save`
directly to disk, which left Compose's in-memory cfg stale and a
subsequent field edit would overwrite the fresh IP. One source of
truth now.

Also defensively repairs front_domain: if it's been corrupted into
an IP literal (bad paste, whatever) we restore "www.google.com" —
the TLS SNI on the outbound leg has to be a hostname or the
handshake lands on the wrong vhost.

Robust Stop
-----------
The Stop button now dispatches both ACTION_STOP (graceful: runs
teardown, stops tun2proxy, closes TUN fd, shuts down Rust runtime)
AND stopService() (defensive: covers force-closed-then-reopened
zombie state where Android auto-restarted our START_STICKY service
in a fresh process and the in-memory TUN reference is gone).

Check-for-updates
-----------------
Tapping the version badge in the top bar now runs the same
update_check that the desktop UI uses, via a new
`Native.checkUpdate()` JNI entry point. Returns a JSON blob the
Kotlin side parses into an "Up to date", "Update available: v→v
<url>", "Offline: ...", or "Check failed: ..." snackbar. Mirrors
the desktop's behavior so a user doesn't have to manually poll
GitHub for new builds.

Crash visibility
----------------
New MhrvApp.kt registers a process-wide uncaught exception handler.
Crashes are now stamped into logcat under the `mhrv-crash` tag with
the thread name before the default handler kills the process —
previously the JVM crash in coroutines / the log drain / the
tun2proxy worker was invisible unless you caught the dropoff in
real time.

Version bump: 1.0.0 → 1.0.1 (versionCode 100 → 101). Release APK
rebuilt and replaces the 1.0.0 copy in releases/; CI will regenerate
on the v1.0.1 tag push.

🤖 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 03:45:08 +03:00
committed by GitHub
parent 91015b0594
commit b734f41faa
12 changed files with 350 additions and 17 deletions
Generated
+1 -1
View File
@@ -1960,7 +1960,7 @@ dependencies = [
[[package]]
name = "mhrv-rs"
version = "1.0.0"
version = "1.0.1"
dependencies = [
"base64 0.22.1",
"bytes",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "mhrv-rs"
version = "1.0.0"
version = "1.0.1"
edition = "2021"
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
license = "MIT"
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.therealaleph.mhrv"
minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
targetSdk = 34
versionCode = 100
versionName = "1.0.0"
versionCode = 101
versionName = "1.0.1"
// Ship all four mainstream Android ABIs:
// - arm64-v8a — 95%+ of real-world Android phones since 2019
+1
View File
@@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".MhrvApp"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
@@ -91,6 +91,14 @@ class MainActivity : ComponentActivity() {
}
HomeScreen(
// MainActivity's onStart is intentionally dumb: it only
// launches the VpnService. The auto-resolve that used to
// live here ran load-modify-save directly on disk, which
// left HomeScreen's in-memory Compose `cfg` stale — a
// subsequent UI edit would then persist the stale cfg back
// over the fresh IP we just wrote. HomeScreen now owns the
// auto-resolve (it uses the same persist() flow the UI uses
// for text-field edits, so there's one source of truth).
onStart = {
val prepareIntent = VpnService.prepare(this)
if (prepareIntent == null) {
@@ -100,9 +108,30 @@ class MainActivity : ComponentActivity() {
}
},
onStop = {
val i = Intent(this, MhrvVpnService::class.java)
// Three-step teardown. Each step is defensive against a
// different failure mode we've actually hit in testing:
//
// 1. ACTION_STOP — graceful path. The service receives it,
// runs its teardown (stops tun2proxy, closes the TUN
// fd, shuts down the Rust runtime) and stopSelf()'s.
// This is what we want 99% of the time.
//
// 2. stopService() — covers the "force-closed then
// reopened" zombie case. Android may auto-restart our
// START_STICKY service in a fresh process after the
// user swipes us away from Recents, and the user's
// next Stop tap needs to actually unbind even if our
// in-memory TUN fd reference is gone. stopService is
// idempotent so it's safe to follow the graceful path.
//
// 3. We do NOT touch the VpnService permission — that's
// the OS-wide VPN grant and the user approved it
// deliberately. Revoking it would force a re-prompt
// on next Start, which is worse UX.
val stopAction = Intent(this, MhrvVpnService::class.java)
.setAction(MhrvVpnService.ACTION_STOP)
startService(i)
startService(stopAction)
stopService(Intent(this, MhrvVpnService::class.java))
},
onInstallCaConfirmed = {
// The flow is (1) export cert, (2) copy it to Downloads so
@@ -0,0 +1,40 @@
package com.therealaleph.mhrv
import android.app.Application
import android.util.Log
/**
* Application-level setup. The only job here right now is to catch
* uncaught JVM exceptions and route them through logcat under the
* `mhrv-crash` tag BEFORE the process dies. Without this the crashes
* appear as opaque "App closed unexpectedly" with no line number in
* `adb logcat` — we re-raise the exception afterwards so the default
* handler still prints its stack trace and Android still shows the
* dialog, but at least the chain-of-events is searchable.
*
* Registering the handler in `Application.onCreate()` (rather than
* `Activity.onCreate()`) catches crashes on ALL process threads,
* including the tun2proxy worker and the log-drain coroutine —
* important because those don't have an activity in scope.
*/
class MhrvApp : Application() {
override fun onCreate() {
super.onCreate()
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
Log.e(
CRASH_TAG,
"uncaught on thread=${thread.name} (id=${thread.id}): ${throwable.message}",
throwable,
)
// Let the default handler still terminate the process and
// show the system "app closed" dialog — we just wanted to
// get a log line out the door first.
previous?.uncaughtException(thread, throwable)
}
}
companion object {
private const val CRASH_TAG = "mhrv-crash"
}
}
@@ -65,4 +65,17 @@ object Native {
* BLOCKS (does a TLS handshake); call from a background dispatcher.
*/
external fun testSni(googleIp: String, sni: String): String
/**
* Ask GitHub's Releases API whether a newer version of mhrv-rs is
* out. Returns a JSON blob, one of:
* - `{"kind":"upToDate","current":"1.0.0","latest":"1.0.0"}`
* - `{"kind":"updateAvailable","current":"1.0.0","latest":"1.1.0","url":"https://..."}`
* - `{"kind":"offline","reason":"..."}`
* - `{"kind":"error","reason":"..."}`
*
* BLOCKS (HTTPS round-trip); call from a background dispatcher.
* Same check the desktop UI runs — same result format.
*/
external fun checkUpdate(): String
}
@@ -0,0 +1,44 @@
package com.therealaleph.mhrv
import java.net.Inet4Address
import java.net.InetAddress
/**
* Helpers for figuring out which IP to actually connect to when the user
* left the config on a stale `google_ip`.
*
* Google rotates the A record for `www.google.com` across their global
* anycast pool; an IP that answered a year ago often 100% packet-drops
* today from networks that are geo-homed somewhere else. Hardcoding any
* single value in the config breaks new installs on all but one region.
*
* At Start time we ask Android's resolver for the current A record and
* use that, falling back to whatever the user had configured only if the
* resolver itself fails (no connectivity, DNS blocked, etc.). We
* deliberately do this on the Kotlin side rather than inside the proxy:
* - It happens before we open the VPN TUN — so the resolver uses the
* underlying network, not our own VPN's Virtual DNS (which would
* loop).
* - The resolved IP gets persisted into `config.json`, so the next
* launch has a warm value even before auto-detection re-runs.
*/
object NetworkDetect {
/**
* Resolve `www.google.com` and return the first IPv4 A record as a
* dotted-quad string, or null if resolution failed. IPv6 is skipped —
* the outbound leg of our proxy is IPv4-only for now.
*
* BLOCKING — call from a background coroutine (Dispatchers.IO).
*/
fun resolveGoogleIp(hostname: String = "www.google.com"): String? {
return try {
InetAddress.getAllByName(hostname)
.filterIsInstance<Inet4Address>()
.firstOrNull()
?.hostAddress
} catch (_: Throwable) {
null
}
}
}
@@ -34,6 +34,7 @@ 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.NetworkDetect
import com.therealaleph.mhrv.ui.theme.OkGreen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -130,11 +131,32 @@ fun HomeScreen(
TopAppBar(
title = { Text("mhrv-rs") },
actions = {
// Tap the version label to check for updates. Keeps
// the top bar visually quiet (no explicit menu) but
// is discoverable because the cursor-style ripple
// makes it obvious it's interactive.
var checking by remember { mutableStateOf(false) }
TextButton(
onClick = {
if (checking) return@TextButton
checking = true
scope.launch {
val json = withContext(Dispatchers.IO) {
runCatching { Native.checkUpdate() }.getOrNull()
}
val msg = summarizeUpdateCheck(json)
snackbar.showSnackbar(msg, withDismissAction = true)
checking = false
}
},
modifier = Modifier.padding(end = 4.dp),
) {
Text(
text = "v" + runCatching { Native.version() }.getOrDefault("?"),
text = if (checking) "checking…"
else "v" + runCatching { Native.version() }.getOrDefault("?"),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(end = 12.dp),
)
}
},
)
},
@@ -191,6 +213,43 @@ fun HomeScreen(
modifier = Modifier.weight(1f),
)
}
// "Auto-detect" forces a fresh DNS resolution now. Start also
// auto-resolves transparently, but exposing a button makes the
// "I'm getting connect timeouts, is my google_ip stale?" case
// a one-tap fix without needing to look up nslookup output.
TextButton(
onClick = {
scope.launch {
val fresh = withContext(Dispatchers.IO) {
NetworkDetect.resolveGoogleIp()
}
if (!fresh.isNullOrBlank()) {
var updated = cfg
if (fresh != updated.googleIp) {
updated = updated.copy(googleIp = fresh)
}
// Same repair logic as the Start button —
// if front_domain has been corrupted into an
// IP we can't use it for SNI, so put the
// default hostname back.
if (updated.frontDomain.isBlank() ||
updated.frontDomain.parseAsIpOrNull() != null
) {
updated = updated.copy(frontDomain = "www.google.com")
}
if (updated !== cfg) {
persist(updated)
snackbar.showSnackbar("google_ip updated to $fresh")
} else {
snackbar.showSnackbar("google_ip already current ($fresh)")
}
} else {
snackbar.showSnackbar("DNS lookup failed — check network")
}
}
},
modifier = Modifier.align(Alignment.End),
) { Text("Auto-detect google_ip") }
// SNI pool: collapsed by default. Users without a reason to
// touch it should leave Rust's auto-expansion to handle it.
@@ -217,8 +276,42 @@ fun HomeScreen(
) {
Button(
onClick = {
// Start flow: (1) auto-resolve google_ip so we
// don't hand the proxy a stale anycast target,
// (2) repair front_domain if it got corrupted into
// an IP (has to be a hostname — that's what goes
// into the TLS SNI on the outbound leg),
// (3) fire the VpnService. All three steps live
// here (rather than in MainActivity) so they go
// through the same persist() used for text edits
// — otherwise the Compose cfg would go stale and
// a subsequent field edit would overwrite our
// fresh values with the pre-resolve ones.
transitionCooldown = true
scope.launch {
val fresh = withContext(Dispatchers.IO) {
NetworkDetect.resolveGoogleIp()
}
var updated = cfg
if (!fresh.isNullOrBlank() && fresh != updated.googleIp) {
updated = updated.copy(googleIp = fresh)
}
// Defensive front_domain repair. An IP literal
// here breaks the outbound leg: TLS SNI
// must be a hostname, and the Apps Script
// dispatcher uses front_domain as the SNI
// when rewriting www.google.com-bound TCP
// flows. If the field got corrupted (bad
// paste, previous bug, etc.) reset to the
// safe default.
if (updated.frontDomain.isBlank() ||
updated.frontDomain.parseAsIpOrNull() != null
) {
updated = updated.copy(frontDomain = "www.google.com")
}
if (updated !== cfg) persist(updated)
onStart()
}
},
enabled = cfg.hasDeploymentId && cfg.authKey.isNotBlank() && !transitionCooldown,
modifier = Modifier.weight(1f),
@@ -536,6 +629,57 @@ private fun ProbeBadge(state: ProbeState) {
}
}
/**
* Turn the JSON blob from `Native.checkUpdate()` into a one-line
* snackbar message. Parsing is lenient — if the shape is anything other
* than what we expect we fall back to "check failed" rather than
* spewing the raw JSON at the user.
*/
private fun summarizeUpdateCheck(json: String?): String {
if (json.isNullOrBlank()) return "Update check failed (no response)"
return try {
val obj = JSONObject(json)
when (obj.optString("kind")) {
"upToDate" -> "Up to date (running v${obj.optString("current")})"
"updateAvailable" -> {
val cur = obj.optString("current")
val latest = obj.optString("latest")
val url = obj.optString("url")
"Update available: v$cur → v$latest $url"
}
"offline" -> "Offline: ${obj.optString("reason", "no details")}"
"error" -> "Check failed: ${obj.optString("reason", "no details")}"
else -> "Check failed (unknown response)"
}
} catch (_: Throwable) {
"Check failed (bad json)"
}
}
/**
* Try to parse a string as an IPv4 or IPv6 literal. Returns null if it
* looks like a hostname (or bogus) — which is what we want for
* front_domain, where a hostname is required (goes into the TLS SNI on
* the outbound leg).
*
* Intentionally strict: must be a valid literal AND must not contain a
* letter anywhere. Plain `InetAddress.getByName(...)` would succeed for
* hostnames too (it'd do a DNS lookup and return an IP), which would
* false-positive every normal value like "www.google.com".
*/
private fun String.parseAsIpOrNull(): java.net.InetAddress? {
val s = trim()
if (s.isEmpty() || s.any { it.isLetter() }) return null
return try {
// Literal-only parse: rejects anything that would need DNS.
java.net.InetAddress.getByName(s).takeIf {
it.hostAddress?.let { addr -> addr == s || addr.contains(s) } == true
}
} catch (_: Throwable) {
null
}
}
private fun parseProbeResult(json: String?): ProbeState {
if (json.isNullOrBlank()) return ProbeState.Err("no response")
return try {
+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.
Current version: **v1.0.0**
Current version: **v1.0.1**
| File | Platform | Contents |
|---|---|---|
| `mhrv-rs-android-universal-v1.0.0.apk` | Android 7.0+ (all ABIs) | Universal APK — arm64-v8a, armeabi-v7a, x86_64, x86 in one file |
| `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-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-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
Copy `mhrv-rs-android-universal-v1.0.0.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.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).
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 دسترسی ندارند.
نسخهٔ فعلی: **v1.0.0**
نسخهٔ فعلی: **v1.0.1**
### دانلود از طریق ZIP
@@ -73,6 +73,6 @@ cd mhrv-rs-macos-arm64
**ویندوز:** فایل `mhrv-rs-windows-amd64.zip` را extract کنید و داخل پوشه روی `run.bat` دو بار کلیک کنید (UAC را قبول کنید تا گواهی MITM نصب شود).
**اندروید:** فایل `mhrv-rs-android-universal-v1.0.0.apk` را روی گوشی کپی کنید، از Files app روی آن tap کنید و اجازهٔ "نصب برنامه‌های ناشناس" را بدهید. راهنمای کامل شروع به کار (دیپلوی Apps Script، نصب CA، اجازهٔ VPN، تستر SNI) در [راهنمای اندروید](../docs/android.md) هست.
**اندروید:** فایل `mhrv-rs-android-universal-v1.0.1.apk` را روی گوشی کپی کنید، از Files app روی آن tap کنید و اجازهٔ "نصب برنامه‌های ناشناس" را بدهید. راهنمای کامل شروع به کار (دیپلوی Apps Script، نصب CA، اجازهٔ VPN، تستر SNI) در [راهنمای اندروید](../docs/android.md) هست.
برای راه‌اندازی کامل دسکتاپ (دیپلوی Apps Script، config، تنظیم proxy مرورگر) به [README اصلی](../README.md) مراجعه کنید.
+62
View File
@@ -342,6 +342,68 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_drainLogs<'a>(
env.new_string(out).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut())
}
/// `Native.checkUpdate()` -> String. Runs the same `update_check::check`
/// the desktop UI uses, serializes the outcome as JSON so Kotlin can
/// pattern-match without needing its own GitHub client.
///
/// Returned shape, one of:
/// {"kind":"upToDate","current":"1.0.0","latest":"1.0.0"}
/// {"kind":"updateAvailable","current":"1.0.0","latest":"1.1.0","url":"https://..."}
/// {"kind":"offline","reason":"..."}
/// {"kind":"error","reason":"..."}
///
/// Blocking — hit from a background dispatcher.
#[no_mangle]
pub extern "system" fn Java_com_therealaleph_mhrv_Native_checkUpdate<'a>(
env: JNIEnv<'a>,
_class: JClass,
) -> jstring {
let result_json = safe(
r#"{"kind":"error","reason":"panic"}"#.to_string(),
AssertUnwindSafe(|| {
install_logging_once();
let Some(rt) = one_shot_runtime() else {
return r#"{"kind":"error","reason":"tokio init failed"}"#.to_string();
};
let outcome = rt.block_on(crate::update_check::check(
crate::update_check::Route::Direct,
));
update_check_to_json(&outcome)
}),
);
env.new_string(result_json)
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut())
}
fn update_check_to_json(u: &crate::update_check::UpdateCheck) -> String {
// Hand-serialized to keep the JNI side free of serde derive noise on
// the inner enum (which would need `#[derive(Serialize)]`). Short
// enough that the hand-rolled version is simpler than pulling
// serde_json in here for one call.
fn esc(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
match u {
crate::update_check::UpdateCheck::UpToDate { current, latest } => format!(
r#"{{"kind":"upToDate","current":"{}","latest":"{}"}}"#,
esc(current), esc(latest),
),
crate::update_check::UpdateCheck::UpdateAvailable { current, latest, release_url, .. } => format!(
r#"{{"kind":"updateAvailable","current":"{}","latest":"{}","url":"{}"}}"#,
esc(current), esc(latest), esc(release_url),
),
crate::update_check::UpdateCheck::Offline(reason) => format!(
r#"{{"kind":"offline","reason":"{}"}}"#,
esc(reason),
),
crate::update_check::UpdateCheck::Error(reason) => format!(
r#"{{"kind":"error","reason":"{}"}}"#,
esc(reason),
),
}
}
/// `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.