Skip to content

Commit f0440a0

Browse files
therealalephclaude
andcommitted
v1.2.14: Usage today (estimated) card + Persian guide localization
Adds a daily-budget visualization for users worried about hitting the Apps Script free-tier quota (20,000 UrlFetchApp calls/day). Usage today card (desktop + Android): - today_calls / today_bytes / today_key / today_reset_secs atomics on DomainFronter, hooked into the bytes_relayed fetch_add path so we only count successful relays (matching what Google actually billed) - Daily rollover at 00:00 UTC, std-only date math (Hinnant's civil_from_days) — no chrono/time dep pull - StatsSnapshot extended with the four new fields + to_json() for the Android JNI bridge - Desktop UI renders the card right under the existing Traffic stats with a hyperlink to https://script.google.com/home/usage for the authoritative Google-side number - Android UI renders the same card via Compose, polling Native.statsJson(handle) once a second only while the proxy is up, with an Intent(ACTION_VIEW, …) opening the dashboard URL JNI / state plumbing: - New Java_…_statsJson reads the Arc<DomainFronter> kept in slot_map - VpnState.proxyHandle StateFlow so HomeScreen knows which handle to poll without poking into the service's internal state - MhrvVpnService publishes the handle on start, zeroes on teardown Persian localization: - HowToUseBody (5-step guide + Cloudflare Turnstile note) was hardcoded English even when locale=FA. Ported to a string resource with a full FA translation in values-fa/strings.xml. Persian users no longer drop to English at the bottom of the screen. Also lands the deferred Android ConfigStore.kt wiring for passthrough_hosts (commit 889f94c shipped the Rust + desktop side). 82 tests pass (added: unix_to_ymd_utc_handles_known_epochs, seconds_until_utc_midnight_is_bounded). Built and visually verified on both desktop and Android emulator (mhrv_test AVD). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 836ce19 commit f0440a0

14 files changed

Lines changed: 539 additions & 31 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.2.13"
3+
version = "1.2.14"
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 = 133
18-
versionName = "1.2.13"
17+
versionCode = 134
18+
versionName = "1.2.14"
1919

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

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,16 @@ data class MhrvConfig(
9494
val parallelRelay: Int = 1,
9595
val upstreamSocks5: String = "",
9696

97+
/**
98+
* User-configured hostnames that bypass Apps Script relay entirely
99+
* and plain-TCP passthrough (via upstreamSocks5 if set). Each entry
100+
* is either an exact hostname ("example.com") or a leading-dot
101+
* suffix (".example.com" → matches example.com + any subdomain).
102+
* See `src/config.rs` `passthrough_hosts` for semantics.
103+
* Issues #39, #127.
104+
*/
105+
val passthroughHosts: List<String> = emptyList(),
106+
97107
/** VPN_TUN (everything routed) vs PROXY_ONLY (user configures per-app). */
98108
val connectionMode: ConnectionMode = ConnectionMode.VPN_TUN,
99109

@@ -173,6 +183,9 @@ data class MhrvConfig(
173183
if (upstreamSocks5.isNotBlank()) {
174184
put("upstream_socks5", upstreamSocks5.trim())
175185
}
186+
if (passthroughHosts.isNotEmpty()) {
187+
put("passthrough_hosts", JSONArray().apply { passthroughHosts.forEach { put(it) } })
188+
}
176189

177190
// Phone-scoped scan defaults. We don't expose these in the UI
178191
// because a phone isn't where you'd run a full /16 scan; users
@@ -249,6 +262,9 @@ object ConfigStore {
249262
logLevel = obj.optString("log_level", "info"),
250263
parallelRelay = obj.optInt("parallel_relay", 1),
251264
upstreamSocks5 = obj.optString("upstream_socks5", ""),
265+
passthroughHosts = obj.optJSONArray("passthrough_hosts")?.let { arr ->
266+
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
267+
}?.filter { it.isNotBlank() }.orEmpty(),
252268
connectionMode = when (obj.optString("connection_mode", "vpn_tun")) {
253269
"proxy_only" -> ConnectionMode.PROXY_ONLY
254270
else -> ConnectionMode.VPN_TUN // default for unknown/missing

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ class MhrvVpnService : VpnService() {
137137
// backgrounding. Issue #37.
138138
if (cfg.connectionMode == ConnectionMode.PROXY_ONLY) {
139139
Log.i(TAG, "PROXY_ONLY mode: listeners up, skipping VpnService/TUN")
140+
VpnState.setProxyHandle(proxyHandle)
140141
VpnState.setRunning(true)
141142
return
142143
}
@@ -258,6 +259,7 @@ class MhrvVpnService : VpnService() {
258259
// to observe. Only flipped true once everything above succeeded —
259260
// if we'd flipped it earlier the button would light up green for
260261
// a failed-to-establish run.
262+
VpnState.setProxyHandle(proxyHandle)
261263
VpnState.setRunning(true)
262264
}
263265

@@ -339,6 +341,7 @@ class MhrvVpnService : VpnService() {
339341
}
340342
// Flip UI state last — the button reverts to Connect only after
341343
// the native-side cleanup actually happened, not optimistically.
344+
VpnState.setProxyHandle(0L)
342345
VpnState.setRunning(false)
343346
Log.i(TAG, "teardown: done")
344347
}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,21 @@ object Native {
7878
* Same check the desktop UI runs — same result format.
7979
*/
8080
external fun checkUpdate(): String
81+
82+
/**
83+
* Live traffic/usage counters for a running proxy handle. Returns a
84+
* JSON blob with the StatsSnapshot fields — or an empty string if the
85+
* handle is unknown or the proxy isn't using the Apps Script relay
86+
* (google_only / full-only modes).
87+
*
88+
* Schema (all integer fields unless noted):
89+
* relay_calls, relay_failures, coalesced, bytes_relayed,
90+
* cache_hits, cache_misses, cache_bytes,
91+
* blacklisted_scripts, total_scripts,
92+
* today_calls, today_bytes, today_key (string "YYYY-MM-DD"),
93+
* today_reset_secs (seconds until 00:00 UTC rollover)
94+
*
95+
* Cheap — just reads atomics. Safe to poll on a second-scale timer.
96+
*/
97+
external fun statsJson(handle: Long): String
8198
}

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,20 @@ object VpnState {
2828
private val _isRunning = MutableStateFlow(false)
2929
val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
3030

31+
/**
32+
* Current native proxy handle for stats polling, or 0 if nothing is up.
33+
* The service publishes this alongside `isRunning` so the Compose UI can
34+
* call `Native.statsJson(handle)` without poking into the service's
35+
* internal state. Reset to 0 on teardown so polling stops cleanly.
36+
*/
37+
private val _proxyHandle = MutableStateFlow(0L)
38+
val proxyHandle: StateFlow<Long> = _proxyHandle.asStateFlow()
39+
3140
fun setRunning(running: Boolean) {
3241
_isRunning.value = running
3342
}
43+
44+
fun setProxyHandle(handle: Long) {
45+
_proxyHandle.value = handle
46+
}
3447
}

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

Lines changed: 163 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,16 @@ fun HomeScreen(
450450
Text(stringResource(R.string.btn_install_mitm))
451451
}
452452

453+
// "Usage today (estimated)" — visible only while a proxy is
454+
// actually running (the handle is non-zero). Polls the native
455+
// stats counter once a second; cheap (just reads atomics on
456+
// the Rust side) and gives users a live feel for how close
457+
// they are to the Apps Script daily quota. Also links out to
458+
// Google's dashboard for the authoritative number — the
459+
// client-side estimate only sees what this device relayed,
460+
// not what other devices on the same deployment consumed.
461+
UsageTodayCard()
462+
453463
CollapsibleSection(title = stringResource(R.string.sec_live_logs), initiallyExpanded = false) {
454464
LiveLogPane()
455465
}
@@ -1311,37 +1321,166 @@ private fun CollapsibleSection(
13111321
}
13121322
}
13131323

1324+
/**
1325+
* "Usage today (estimated)" card. Polls `Native.statsJson(handle)` every
1326+
* second while the proxy is up and renders today's relay calls vs. the
1327+
* Apps Script free-tier quota (20,000/day), today's bytes, UTC day key,
1328+
* and a countdown to the 00:00 UTC reset. Also shows a "View quota on
1329+
* Google" button that opens Google's Apps Script dashboard — the
1330+
* authoritative number, since the client-side estimate only sees what
1331+
* this device relayed.
1332+
*
1333+
* Hidden when the handle is 0 (proxy not running) or the JSON comes back
1334+
* empty (google_only / full-only configs don't run a DomainFronter and so
1335+
* have nothing to report).
1336+
*/
1337+
@Composable
1338+
private fun UsageTodayCard() {
1339+
// Free-tier Apps Script UrlFetchApp daily quota. Workspace / paid
1340+
// tiers get 100k but most users are on free.
1341+
val freeQuotaPerDay = 20_000
1342+
1343+
val handle by VpnState.proxyHandle.collectAsState()
1344+
val isRunning by VpnState.isRunning.collectAsState()
1345+
1346+
// Nothing to poll until the proxy is up.
1347+
if (!isRunning || handle == 0L) return
1348+
1349+
var statsJson by remember { mutableStateOf("") }
1350+
LaunchedEffect(handle) {
1351+
// Drop any stale snapshot from a previous run.
1352+
statsJson = ""
1353+
while (true) {
1354+
statsJson = withContext(Dispatchers.IO) {
1355+
runCatching { Native.statsJson(handle) }.getOrDefault("")
1356+
}
1357+
delay(1000)
1358+
}
1359+
}
1360+
1361+
val obj = remember(statsJson) {
1362+
if (statsJson.isBlank()) null
1363+
else runCatching { JSONObject(statsJson) }.getOrNull()
1364+
}
1365+
// Still booting / not an apps-script config — stay silent.
1366+
if (obj == null) return
1367+
1368+
val todayCalls = obj.optLong("today_calls", 0L)
1369+
val todayBytes = obj.optLong("today_bytes", 0L)
1370+
val todayKey = obj.optString("today_key", "")
1371+
val resetSecs = obj.optLong("today_reset_secs", 0L)
1372+
val pct = if (freeQuotaPerDay > 0) {
1373+
(todayCalls.toDouble() / freeQuotaPerDay) * 100.0
1374+
} else 0.0
1375+
1376+
val ctx = LocalContext.current
1377+
1378+
Spacer(Modifier.height(8.dp))
1379+
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
1380+
Column(
1381+
modifier = Modifier.padding(12.dp),
1382+
verticalArrangement = Arrangement.spacedBy(6.dp),
1383+
) {
1384+
Text(
1385+
stringResource(R.string.sec_usage_today),
1386+
style = MaterialTheme.typography.titleSmall,
1387+
)
1388+
1389+
UsageRow(
1390+
label = stringResource(R.string.label_calls_today),
1391+
value = stringResource(
1392+
R.string.usage_calls_of_quota,
1393+
todayCalls.toInt(),
1394+
freeQuotaPerDay,
1395+
pct,
1396+
),
1397+
)
1398+
UsageRow(
1399+
label = stringResource(R.string.label_bytes_today),
1400+
value = fmtBytes(todayBytes),
1401+
)
1402+
UsageRow(
1403+
label = stringResource(R.string.label_utc_day),
1404+
value = todayKey,
1405+
)
1406+
UsageRow(
1407+
label = stringResource(R.string.label_resets_in),
1408+
value = stringResource(
1409+
R.string.usage_resets_hm,
1410+
(resetSecs / 3600).toInt(),
1411+
((resetSecs / 60) % 60).toInt(),
1412+
),
1413+
)
1414+
1415+
Spacer(Modifier.height(4.dp))
1416+
TextButton(
1417+
onClick = {
1418+
// Open the Google-side Apps Script quota dashboard in
1419+
// the user's browser. Uses ACTION_VIEW with a https://
1420+
// URI — the OS picks whatever default browser is set.
1421+
val intent = android.content.Intent(
1422+
android.content.Intent.ACTION_VIEW,
1423+
android.net.Uri.parse("https://script.google.com/home/usage"),
1424+
)
1425+
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
1426+
runCatching { ctx.startActivity(intent) }
1427+
},
1428+
modifier = Modifier.fillMaxWidth(),
1429+
) {
1430+
Text(stringResource(R.string.btn_view_quota_on_google))
1431+
}
1432+
Text(
1433+
stringResource(R.string.usage_today_note),
1434+
style = MaterialTheme.typography.labelSmall,
1435+
color = MaterialTheme.colorScheme.onSurfaceVariant,
1436+
)
1437+
}
1438+
}
1439+
}
1440+
1441+
@Composable
1442+
private fun UsageRow(label: String, value: String) {
1443+
Row(
1444+
modifier = Modifier.fillMaxWidth(),
1445+
horizontalArrangement = Arrangement.SpaceBetween,
1446+
) {
1447+
Text(
1448+
label,
1449+
style = MaterialTheme.typography.bodyMedium,
1450+
color = MaterialTheme.colorScheme.onSurfaceVariant,
1451+
)
1452+
Text(
1453+
value,
1454+
style = MaterialTheme.typography.bodyMedium,
1455+
fontFamily = FontFamily.Monospace,
1456+
)
1457+
}
1458+
}
1459+
1460+
private fun fmtBytes(b: Long): String {
1461+
val k = 1024L
1462+
val m = k * k
1463+
val g = m * k
1464+
return when {
1465+
b >= g -> String.format("%.2f GB", b.toDouble() / g)
1466+
b >= m -> String.format("%.2f MB", b.toDouble() / m)
1467+
b >= k -> String.format("%.1f KB", b.toDouble() / k)
1468+
else -> "$b B"
1469+
}
1470+
}
1471+
13141472
@Composable
13151473
private fun HowToUseBody(listenPort: Int) {
13161474
// Used inside the collapsible "How to use" CollapsibleSection. The
13171475
// card + title are provided by the section wrapper, so this body
13181476
// just renders the body text.
1477+
//
1478+
// Text is sourced from string resources (values/strings.xml +
1479+
// values-fa/strings.xml) so the Persian locale gets a translated
1480+
// guide instead of falling back to English.
13191481
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
13201482
Text(
1321-
"1. Paste one or more Apps Script deployment URLs (or bare IDs) and your auth_key.\n" +
1322-
"2. Tap Install MITM certificate. Confirm the dialog — the cert is saved to " +
1323-
"Downloads/mhrv-ca.crt and the Settings app opens. Use Settings' search bar " +
1324-
"to find \"CA certificate\", tap that result (NOT \"VPN & app user certificate\" " +
1325-
"or \"Wi-Fi\"), and pick mhrv-ca.crt from Downloads. You'll be asked to set a " +
1326-
"screen lock if you don't have one (Android requirement).\n" +
1327-
"3. Before tapping Start, expand \"SNI pool + tester\" and hit \"Test all\". If " +
1328-
"every entry times out, your google_ip is unreachable — replace it with one that " +
1329-
"resolves locally (e.g. `nslookup www.google.com` on any working device).\n" +
1330-
"4. Tap Start. Accept the VPN prompt. The full TUN bridge routes every app on the " +
1331-
"device through the proxy — no per-app setup needed.\n" +
1332-
"5. If Chrome shows \"504 Relay timeout\": your Apps Script deployment isn't " +
1333-
"responding. Redeploy the script, grab the new /exec URL, and paste it above. " +
1334-
"Watch Live logs for \"Relay timeout\" vs \"connect:\" errors to tell which layer " +
1335-
"is failing.\n" +
1336-
"\n" +
1337-
"Known limitation — Cloudflare Turnstile (\"Verify you are human\") will loop " +
1338-
"endlessly on most CF-protected sites. Every Apps Script request uses a rotating " +
1339-
"Google-datacenter egress IP + a fixed \"Google-Apps-Script\" User-Agent + a " +
1340-
"Google TLS fingerprint. The cf_clearance cookie is bound to the (IP, UA, JA3) " +
1341-
"tuple the challenge was solved against, so the NEXT request — from a different " +
1342-
"egress IP — gets re-challenged. Nothing in this app can fix that; it's inherent " +
1343-
"to Apps Script as a relay. Sites that only gate the initial page load (not every " +
1344-
"request) will work after one solve.",
1483+
text = stringResource(R.string.help_how_to_use),
13451484
style = MaterialTheme.typography.bodyMedium,
13461485
)
13471486
}

android/app/src/main/res/values-fa/strings.xml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,18 @@
7878
<string name="snack_google_ip_updated">google_ip به %1$s به‌روزرسانی شد</string>
7979
<string name="snack_google_ip_current">google_ip قبلاً به‌روز است (%1$s)</string>
8080
<string name="snack_dns_lookup_failed">خطای DNS — اتصال شبکه را بررسی کنید</string>
81+
82+
<!-- Usage today card -->
83+
<string name="sec_usage_today">مصرف امروز (تخمینی)</string>
84+
<string name="label_calls_today">درخواست‌های امروز</string>
85+
<string name="label_bytes_today">بایت امروز</string>
86+
<string name="label_utc_day">روز (UTC)</string>
87+
<string name="label_resets_in">ریست تا</string>
88+
<string name="usage_calls_of_quota">%1$d / %2$d (%3$.1f%%)</string>
89+
<string name="usage_resets_hm">%1$d ساعت و %2$d دقیقه</string>
90+
<string name="btn_view_quota_on_google">مشاهدهٔ سهمیه در گوگل ←</string>
91+
<string name="usage_today_note">تخمینی — این همان چیزی است که از این دستگاه رد شده. عدد دقیق در داشبورد گوگل قابل مشاهده است.</string>
92+
93+
<!-- "How to use" guide body. Localized — EN copy lives in values. -->
94+
<string name="help_how_to_use">۱. یک یا چند آدرس deployment از Apps Script (یا فقط ID خام) و همراه آن auth_key خود را جای‌گذاری کنید.\n۲. روی «نصب گواهی MITM» بزنید و پیام تأیید را قبول کنید — گواهی در Downloads/mhrv-ca.crt ذخیره می‌شود و برنامهٔ Settings باز می‌شود. داخل Settings از نوار جست‌وجو «CA certificate» را پیدا کنید و روی همان نتیجه بزنید (نه «VPN &amp; app user certificate» و نه «Wi-Fi»)، سپس mhrv-ca.crt را از Downloads انتخاب کنید. اگر قفل صفحه ندارید، اندروید می‌خواهد یکی تنظیم کنید (الزام سیستم).\n۳. قبل از Start، بخش «مجموعهٔ SNI + تستر» را باز کنید و «تست همه» را بزنید. اگر همه تایم‌اوت شدند یعنی google_ip در دسترس نیست — آن را با یک IP جایگزین کنید که روی شبکهٔ سالم resolve می‌شود (مثلاً `nslookup www.google.com` روی هر دستگاه سالم).\n۴. Start را بزنید و درخواست VPN را تأیید کنید. پل TUN کامل، تمام برنامه‌های دستگاه را خودکار از پروکسی رد می‌کند — نیاز به تنظیم per-app نیست.\n۵. اگر Chrome پیام «504 Relay timeout» نشان داد: deployment شما پاسخ نمی‌دهد. اسکریپت را دوباره deploy کنید، URL جدید /exec را بگیرید و بالا جای‌گذاری کنید. در «لاگ زنده» ببینید خطا از نوع «Relay timeout» است یا «connect:» — نوع خطا مشخص می‌کند کدام لایه مقصر است.\n\nمحدودیت شناخته‌شده — Cloudflare Turnstile («Verify you are human») روی اکثر سایت‌های پشت Cloudflare به‌طور بی‌پایان loop می‌زند. هر درخواست Apps Script از یک IP خروجی چرخشی دیتاسنتر گوگل + یک User-Agent ثابت «Google-Apps-Script» + اثرانگشت TLS گوگل عبور می‌کند. کوکی cf_clearance به tuple (IP, UA, JA3) مربوط به زمان حل چالش گره خورده است، پس درخواست بعدی — از یک IP خروجی متفاوت — دوباره چالش می‌خورد. این مسئله در این برنامه قابل‌حل نیست؛ ذات رلهٔ Apps Script است. سایت‌هایی که فقط بارگذاری اولیه را gate می‌کنند (نه هر درخواست) بعد از یک بار حل، کار خواهند کرد.</string>
8195
</resources>

0 commit comments

Comments
 (0)