mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-18 07:34:36 +03:00
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:
committed by
GitHub
parent
91015b0594
commit
b734f41faa
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
Text(
|
||||
text = "v" + runCatching { Native.version() }.getOrDefault("?"),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
)
|
||||
// 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 = if (checking) "checking…"
|
||||
else "v" + runCatching { Native.version() }.getOrDefault("?"),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -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
|
||||
onStart()
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user