Skip to content

Commit b734f41

Browse files
therealalephclaude
andauthored
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>
1 parent 91015b0 commit b734f41

12 files changed

Lines changed: 350 additions & 17 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mhrv-rs"
3-
version = "1.0.0"
3+
version = "1.0.1"
44
edition = "2021"
55
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
66
license = "MIT"

android/app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ android {
1414
applicationId = "com.therealaleph.mhrv"
1515
minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
1616
targetSdk = 34
17-
versionCode = 100
18-
versionName = "1.0.0"
17+
versionCode = 101
18+
versionName = "1.0.1"
1919

2020
// Ship all four mainstream Android ABIs:
2121
// - arm64-v8a — 95%+ of real-world Android phones since 2019

android/app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
1010

1111
<application
12+
android:name=".MhrvApp"
1213
android:allowBackup="false"
1314
android:dataExtractionRules="@xml/data_extraction_rules"
1415
android:fullBackupContent="false"

android/app/src/main/java/com/therealaleph/mhrv/MainActivity.kt

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ class MainActivity : ComponentActivity() {
9191
}
9292

9393
HomeScreen(
94+
// MainActivity's onStart is intentionally dumb: it only
95+
// launches the VpnService. The auto-resolve that used to
96+
// live here ran load-modify-save directly on disk, which
97+
// left HomeScreen's in-memory Compose `cfg` stale — a
98+
// subsequent UI edit would then persist the stale cfg back
99+
// over the fresh IP we just wrote. HomeScreen now owns the
100+
// auto-resolve (it uses the same persist() flow the UI uses
101+
// for text-field edits, so there's one source of truth).
94102
onStart = {
95103
val prepareIntent = VpnService.prepare(this)
96104
if (prepareIntent == null) {
@@ -100,9 +108,30 @@ class MainActivity : ComponentActivity() {
100108
}
101109
},
102110
onStop = {
103-
val i = Intent(this, MhrvVpnService::class.java)
111+
// Three-step teardown. Each step is defensive against a
112+
// different failure mode we've actually hit in testing:
113+
//
114+
// 1. ACTION_STOP — graceful path. The service receives it,
115+
// runs its teardown (stops tun2proxy, closes the TUN
116+
// fd, shuts down the Rust runtime) and stopSelf()'s.
117+
// This is what we want 99% of the time.
118+
//
119+
// 2. stopService() — covers the "force-closed then
120+
// reopened" zombie case. Android may auto-restart our
121+
// START_STICKY service in a fresh process after the
122+
// user swipes us away from Recents, and the user's
123+
// next Stop tap needs to actually unbind even if our
124+
// in-memory TUN fd reference is gone. stopService is
125+
// idempotent so it's safe to follow the graceful path.
126+
//
127+
// 3. We do NOT touch the VpnService permission — that's
128+
// the OS-wide VPN grant and the user approved it
129+
// deliberately. Revoking it would force a re-prompt
130+
// on next Start, which is worse UX.
131+
val stopAction = Intent(this, MhrvVpnService::class.java)
104132
.setAction(MhrvVpnService.ACTION_STOP)
105-
startService(i)
133+
startService(stopAction)
134+
stopService(Intent(this, MhrvVpnService::class.java))
106135
},
107136
onInstallCaConfirmed = {
108137
// The flow is (1) export cert, (2) copy it to Downloads so
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.therealaleph.mhrv
2+
3+
import android.app.Application
4+
import android.util.Log
5+
6+
/**
7+
* Application-level setup. The only job here right now is to catch
8+
* uncaught JVM exceptions and route them through logcat under the
9+
* `mhrv-crash` tag BEFORE the process dies. Without this the crashes
10+
* appear as opaque "App closed unexpectedly" with no line number in
11+
* `adb logcat` — we re-raise the exception afterwards so the default
12+
* handler still prints its stack trace and Android still shows the
13+
* dialog, but at least the chain-of-events is searchable.
14+
*
15+
* Registering the handler in `Application.onCreate()` (rather than
16+
* `Activity.onCreate()`) catches crashes on ALL process threads,
17+
* including the tun2proxy worker and the log-drain coroutine —
18+
* important because those don't have an activity in scope.
19+
*/
20+
class MhrvApp : Application() {
21+
override fun onCreate() {
22+
super.onCreate()
23+
val previous = Thread.getDefaultUncaughtExceptionHandler()
24+
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
25+
Log.e(
26+
CRASH_TAG,
27+
"uncaught on thread=${thread.name} (id=${thread.id}): ${throwable.message}",
28+
throwable,
29+
)
30+
// Let the default handler still terminate the process and
31+
// show the system "app closed" dialog — we just wanted to
32+
// get a log line out the door first.
33+
previous?.uncaughtException(thread, throwable)
34+
}
35+
}
36+
37+
companion object {
38+
private const val CRASH_TAG = "mhrv-crash"
39+
}
40+
}

android/app/src/main/java/com/therealaleph/mhrv/Native.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,17 @@ object Native {
6565
* BLOCKS (does a TLS handshake); call from a background dispatcher.
6666
*/
6767
external fun testSni(googleIp: String, sni: String): String
68+
69+
/**
70+
* Ask GitHub's Releases API whether a newer version of mhrv-rs is
71+
* out. Returns a JSON blob, one of:
72+
* - `{"kind":"upToDate","current":"1.0.0","latest":"1.0.0"}`
73+
* - `{"kind":"updateAvailable","current":"1.0.0","latest":"1.1.0","url":"https://..."}`
74+
* - `{"kind":"offline","reason":"..."}`
75+
* - `{"kind":"error","reason":"..."}`
76+
*
77+
* BLOCKS (HTTPS round-trip); call from a background dispatcher.
78+
* Same check the desktop UI runs — same result format.
79+
*/
80+
external fun checkUpdate(): String
6881
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.therealaleph.mhrv
2+
3+
import java.net.Inet4Address
4+
import java.net.InetAddress
5+
6+
/**
7+
* Helpers for figuring out which IP to actually connect to when the user
8+
* left the config on a stale `google_ip`.
9+
*
10+
* Google rotates the A record for `www.google.com` across their global
11+
* anycast pool; an IP that answered a year ago often 100% packet-drops
12+
* today from networks that are geo-homed somewhere else. Hardcoding any
13+
* single value in the config breaks new installs on all but one region.
14+
*
15+
* At Start time we ask Android's resolver for the current A record and
16+
* use that, falling back to whatever the user had configured only if the
17+
* resolver itself fails (no connectivity, DNS blocked, etc.). We
18+
* deliberately do this on the Kotlin side rather than inside the proxy:
19+
* - It happens before we open the VPN TUN — so the resolver uses the
20+
* underlying network, not our own VPN's Virtual DNS (which would
21+
* loop).
22+
* - The resolved IP gets persisted into `config.json`, so the next
23+
* launch has a warm value even before auto-detection re-runs.
24+
*/
25+
object NetworkDetect {
26+
27+
/**
28+
* Resolve `www.google.com` and return the first IPv4 A record as a
29+
* dotted-quad string, or null if resolution failed. IPv6 is skipped —
30+
* the outbound leg of our proxy is IPv4-only for now.
31+
*
32+
* BLOCKING — call from a background coroutine (Dispatchers.IO).
33+
*/
34+
fun resolveGoogleIp(hostname: String = "www.google.com"): String? {
35+
return try {
36+
InetAddress.getAllByName(hostname)
37+
.filterIsInstance<Inet4Address>()
38+
.firstOrNull()
39+
?.hostAddress
40+
} catch (_: Throwable) {
41+
null
42+
}
43+
}
44+
}

android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt

Lines changed: 150 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import com.therealaleph.mhrv.ConfigStore
3434
import com.therealaleph.mhrv.DEFAULT_SNI_POOL
3535
import com.therealaleph.mhrv.MhrvConfig
3636
import com.therealaleph.mhrv.Native
37+
import com.therealaleph.mhrv.NetworkDetect
3738
import com.therealaleph.mhrv.ui.theme.OkGreen
3839
import kotlinx.coroutines.Dispatchers
3940
import kotlinx.coroutines.delay
@@ -130,11 +131,32 @@ fun HomeScreen(
130131
TopAppBar(
131132
title = { Text("mhrv-rs") },
132133
actions = {
133-
Text(
134-
text = "v" + runCatching { Native.version() }.getOrDefault("?"),
135-
style = MaterialTheme.typography.labelMedium,
136-
modifier = Modifier.padding(end = 12.dp),
137-
)
134+
// Tap the version label to check for updates. Keeps
135+
// the top bar visually quiet (no explicit menu) but
136+
// is discoverable because the cursor-style ripple
137+
// makes it obvious it's interactive.
138+
var checking by remember { mutableStateOf(false) }
139+
TextButton(
140+
onClick = {
141+
if (checking) return@TextButton
142+
checking = true
143+
scope.launch {
144+
val json = withContext(Dispatchers.IO) {
145+
runCatching { Native.checkUpdate() }.getOrNull()
146+
}
147+
val msg = summarizeUpdateCheck(json)
148+
snackbar.showSnackbar(msg, withDismissAction = true)
149+
checking = false
150+
}
151+
},
152+
modifier = Modifier.padding(end = 4.dp),
153+
) {
154+
Text(
155+
text = if (checking) "checking…"
156+
else "v" + runCatching { Native.version() }.getOrDefault("?"),
157+
style = MaterialTheme.typography.labelMedium,
158+
)
159+
}
138160
},
139161
)
140162
},
@@ -191,6 +213,43 @@ fun HomeScreen(
191213
modifier = Modifier.weight(1f),
192214
)
193215
}
216+
// "Auto-detect" forces a fresh DNS resolution now. Start also
217+
// auto-resolves transparently, but exposing a button makes the
218+
// "I'm getting connect timeouts, is my google_ip stale?" case
219+
// a one-tap fix without needing to look up nslookup output.
220+
TextButton(
221+
onClick = {
222+
scope.launch {
223+
val fresh = withContext(Dispatchers.IO) {
224+
NetworkDetect.resolveGoogleIp()
225+
}
226+
if (!fresh.isNullOrBlank()) {
227+
var updated = cfg
228+
if (fresh != updated.googleIp) {
229+
updated = updated.copy(googleIp = fresh)
230+
}
231+
// Same repair logic as the Start button —
232+
// if front_domain has been corrupted into an
233+
// IP we can't use it for SNI, so put the
234+
// default hostname back.
235+
if (updated.frontDomain.isBlank() ||
236+
updated.frontDomain.parseAsIpOrNull() != null
237+
) {
238+
updated = updated.copy(frontDomain = "www.google.com")
239+
}
240+
if (updated !== cfg) {
241+
persist(updated)
242+
snackbar.showSnackbar("google_ip updated to $fresh")
243+
} else {
244+
snackbar.showSnackbar("google_ip already current ($fresh)")
245+
}
246+
} else {
247+
snackbar.showSnackbar("DNS lookup failed — check network")
248+
}
249+
}
250+
},
251+
modifier = Modifier.align(Alignment.End),
252+
) { Text("Auto-detect google_ip") }
194253

195254
// SNI pool: collapsed by default. Users without a reason to
196255
// touch it should leave Rust's auto-expansion to handle it.
@@ -217,8 +276,42 @@ fun HomeScreen(
217276
) {
218277
Button(
219278
onClick = {
279+
// Start flow: (1) auto-resolve google_ip so we
280+
// don't hand the proxy a stale anycast target,
281+
// (2) repair front_domain if it got corrupted into
282+
// an IP (has to be a hostname — that's what goes
283+
// into the TLS SNI on the outbound leg),
284+
// (3) fire the VpnService. All three steps live
285+
// here (rather than in MainActivity) so they go
286+
// through the same persist() used for text edits
287+
// — otherwise the Compose cfg would go stale and
288+
// a subsequent field edit would overwrite our
289+
// fresh values with the pre-resolve ones.
220290
transitionCooldown = true
221-
onStart()
291+
scope.launch {
292+
val fresh = withContext(Dispatchers.IO) {
293+
NetworkDetect.resolveGoogleIp()
294+
}
295+
var updated = cfg
296+
if (!fresh.isNullOrBlank() && fresh != updated.googleIp) {
297+
updated = updated.copy(googleIp = fresh)
298+
}
299+
// Defensive front_domain repair. An IP literal
300+
// here breaks the outbound leg: TLS SNI
301+
// must be a hostname, and the Apps Script
302+
// dispatcher uses front_domain as the SNI
303+
// when rewriting www.google.com-bound TCP
304+
// flows. If the field got corrupted (bad
305+
// paste, previous bug, etc.) reset to the
306+
// safe default.
307+
if (updated.frontDomain.isBlank() ||
308+
updated.frontDomain.parseAsIpOrNull() != null
309+
) {
310+
updated = updated.copy(frontDomain = "www.google.com")
311+
}
312+
if (updated !== cfg) persist(updated)
313+
onStart()
314+
}
222315
},
223316
enabled = cfg.hasDeploymentId && cfg.authKey.isNotBlank() && !transitionCooldown,
224317
modifier = Modifier.weight(1f),
@@ -536,6 +629,57 @@ private fun ProbeBadge(state: ProbeState) {
536629
}
537630
}
538631

632+
/**
633+
* Turn the JSON blob from `Native.checkUpdate()` into a one-line
634+
* snackbar message. Parsing is lenient — if the shape is anything other
635+
* than what we expect we fall back to "check failed" rather than
636+
* spewing the raw JSON at the user.
637+
*/
638+
private fun summarizeUpdateCheck(json: String?): String {
639+
if (json.isNullOrBlank()) return "Update check failed (no response)"
640+
return try {
641+
val obj = JSONObject(json)
642+
when (obj.optString("kind")) {
643+
"upToDate" -> "Up to date (running v${obj.optString("current")})"
644+
"updateAvailable" -> {
645+
val cur = obj.optString("current")
646+
val latest = obj.optString("latest")
647+
val url = obj.optString("url")
648+
"Update available: v$cur → v$latest $url"
649+
}
650+
"offline" -> "Offline: ${obj.optString("reason", "no details")}"
651+
"error" -> "Check failed: ${obj.optString("reason", "no details")}"
652+
else -> "Check failed (unknown response)"
653+
}
654+
} catch (_: Throwable) {
655+
"Check failed (bad json)"
656+
}
657+
}
658+
659+
/**
660+
* Try to parse a string as an IPv4 or IPv6 literal. Returns null if it
661+
* looks like a hostname (or bogus) — which is what we want for
662+
* front_domain, where a hostname is required (goes into the TLS SNI on
663+
* the outbound leg).
664+
*
665+
* Intentionally strict: must be a valid literal AND must not contain a
666+
* letter anywhere. Plain `InetAddress.getByName(...)` would succeed for
667+
* hostnames too (it'd do a DNS lookup and return an IP), which would
668+
* false-positive every normal value like "www.google.com".
669+
*/
670+
private fun String.parseAsIpOrNull(): java.net.InetAddress? {
671+
val s = trim()
672+
if (s.isEmpty() || s.any { it.isLetter() }) return null
673+
return try {
674+
// Literal-only parse: rejects anything that would need DNS.
675+
java.net.InetAddress.getByName(s).takeIf {
676+
it.hostAddress?.let { addr -> addr == s || addr.contains(s) } == true
677+
}
678+
} catch (_: Throwable) {
679+
null
680+
}
681+
}
682+
539683
private fun parseProbeResult(json: String?): ProbeState {
540684
if (json.isNullOrBlank()) return ProbeState.Err("no response")
541685
return try {

0 commit comments

Comments
 (0)