mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-18 05:44:35 +03:00
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 fe9328e 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>
This commit is contained in:
Generated
+1
-1
@@ -2186,7 +2186,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mhrv-rs"
|
||||
version = "1.2.13"
|
||||
version = "1.2.14"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mhrv-rs"
|
||||
version = "1.2.13"
|
||||
version = "1.2.14"
|
||||
edition = "2021"
|
||||
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
|
||||
license = "MIT"
|
||||
|
||||
@@ -14,8 +14,8 @@ android {
|
||||
applicationId = "com.therealaleph.mhrv"
|
||||
minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
|
||||
targetSdk = 34
|
||||
versionCode = 133
|
||||
versionName = "1.2.13"
|
||||
versionCode = 134
|
||||
versionName = "1.2.14"
|
||||
|
||||
// Ship all four mainstream Android ABIs:
|
||||
// - arm64-v8a — 95%+ of real-world Android phones since 2019
|
||||
|
||||
@@ -94,6 +94,16 @@ data class MhrvConfig(
|
||||
val parallelRelay: Int = 1,
|
||||
val upstreamSocks5: String = "",
|
||||
|
||||
/**
|
||||
* User-configured hostnames that bypass Apps Script relay entirely
|
||||
* and plain-TCP passthrough (via upstreamSocks5 if set). Each entry
|
||||
* is either an exact hostname ("example.com") or a leading-dot
|
||||
* suffix (".example.com" → matches example.com + any subdomain).
|
||||
* See `src/config.rs` `passthrough_hosts` for semantics.
|
||||
* Issues #39, #127.
|
||||
*/
|
||||
val passthroughHosts: List<String> = emptyList(),
|
||||
|
||||
/** VPN_TUN (everything routed) vs PROXY_ONLY (user configures per-app). */
|
||||
val connectionMode: ConnectionMode = ConnectionMode.VPN_TUN,
|
||||
|
||||
@@ -173,6 +183,9 @@ data class MhrvConfig(
|
||||
if (upstreamSocks5.isNotBlank()) {
|
||||
put("upstream_socks5", upstreamSocks5.trim())
|
||||
}
|
||||
if (passthroughHosts.isNotEmpty()) {
|
||||
put("passthrough_hosts", JSONArray().apply { passthroughHosts.forEach { put(it) } })
|
||||
}
|
||||
|
||||
// Phone-scoped scan defaults. We don't expose these in the UI
|
||||
// because a phone isn't where you'd run a full /16 scan; users
|
||||
@@ -249,6 +262,9 @@ object ConfigStore {
|
||||
logLevel = obj.optString("log_level", "info"),
|
||||
parallelRelay = obj.optInt("parallel_relay", 1),
|
||||
upstreamSocks5 = obj.optString("upstream_socks5", ""),
|
||||
passthroughHosts = obj.optJSONArray("passthrough_hosts")?.let { arr ->
|
||||
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
|
||||
}?.filter { it.isNotBlank() }.orEmpty(),
|
||||
connectionMode = when (obj.optString("connection_mode", "vpn_tun")) {
|
||||
"proxy_only" -> ConnectionMode.PROXY_ONLY
|
||||
else -> ConnectionMode.VPN_TUN // default for unknown/missing
|
||||
|
||||
@@ -137,6 +137,7 @@ class MhrvVpnService : VpnService() {
|
||||
// backgrounding. Issue #37.
|
||||
if (cfg.connectionMode == ConnectionMode.PROXY_ONLY) {
|
||||
Log.i(TAG, "PROXY_ONLY mode: listeners up, skipping VpnService/TUN")
|
||||
VpnState.setProxyHandle(proxyHandle)
|
||||
VpnState.setRunning(true)
|
||||
return
|
||||
}
|
||||
@@ -258,6 +259,7 @@ class MhrvVpnService : VpnService() {
|
||||
// to observe. Only flipped true once everything above succeeded —
|
||||
// if we'd flipped it earlier the button would light up green for
|
||||
// a failed-to-establish run.
|
||||
VpnState.setProxyHandle(proxyHandle)
|
||||
VpnState.setRunning(true)
|
||||
}
|
||||
|
||||
@@ -339,6 +341,7 @@ class MhrvVpnService : VpnService() {
|
||||
}
|
||||
// Flip UI state last — the button reverts to Connect only after
|
||||
// the native-side cleanup actually happened, not optimistically.
|
||||
VpnState.setProxyHandle(0L)
|
||||
VpnState.setRunning(false)
|
||||
Log.i(TAG, "teardown: done")
|
||||
}
|
||||
|
||||
@@ -78,4 +78,21 @@ object Native {
|
||||
* Same check the desktop UI runs — same result format.
|
||||
*/
|
||||
external fun checkUpdate(): String
|
||||
|
||||
/**
|
||||
* Live traffic/usage counters for a running proxy handle. Returns a
|
||||
* JSON blob with the StatsSnapshot fields — or an empty string if the
|
||||
* handle is unknown or the proxy isn't using the Apps Script relay
|
||||
* (google_only / full-only modes).
|
||||
*
|
||||
* Schema (all integer fields unless noted):
|
||||
* relay_calls, relay_failures, coalesced, bytes_relayed,
|
||||
* cache_hits, cache_misses, cache_bytes,
|
||||
* blacklisted_scripts, total_scripts,
|
||||
* today_calls, today_bytes, today_key (string "YYYY-MM-DD"),
|
||||
* today_reset_secs (seconds until 00:00 UTC rollover)
|
||||
*
|
||||
* Cheap — just reads atomics. Safe to poll on a second-scale timer.
|
||||
*/
|
||||
external fun statsJson(handle: Long): String
|
||||
}
|
||||
|
||||
@@ -28,7 +28,20 @@ object VpnState {
|
||||
private val _isRunning = MutableStateFlow(false)
|
||||
val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
|
||||
|
||||
/**
|
||||
* Current native proxy handle for stats polling, or 0 if nothing is up.
|
||||
* The service publishes this alongside `isRunning` so the Compose UI can
|
||||
* call `Native.statsJson(handle)` without poking into the service's
|
||||
* internal state. Reset to 0 on teardown so polling stops cleanly.
|
||||
*/
|
||||
private val _proxyHandle = MutableStateFlow(0L)
|
||||
val proxyHandle: StateFlow<Long> = _proxyHandle.asStateFlow()
|
||||
|
||||
fun setRunning(running: Boolean) {
|
||||
_isRunning.value = running
|
||||
}
|
||||
|
||||
fun setProxyHandle(handle: Long) {
|
||||
_proxyHandle.value = handle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,6 +450,16 @@ fun HomeScreen(
|
||||
Text(stringResource(R.string.btn_install_mitm))
|
||||
}
|
||||
|
||||
// "Usage today (estimated)" — visible only while a proxy is
|
||||
// actually running (the handle is non-zero). Polls the native
|
||||
// stats counter once a second; cheap (just reads atomics on
|
||||
// the Rust side) and gives users a live feel for how close
|
||||
// they are to the Apps Script daily quota. Also links out to
|
||||
// Google's dashboard for the authoritative number — the
|
||||
// client-side estimate only sees what this device relayed,
|
||||
// not what other devices on the same deployment consumed.
|
||||
UsageTodayCard()
|
||||
|
||||
CollapsibleSection(title = stringResource(R.string.sec_live_logs), initiallyExpanded = false) {
|
||||
LiveLogPane()
|
||||
}
|
||||
@@ -1311,37 +1321,166 @@ private fun CollapsibleSection(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Usage today (estimated)" card. Polls `Native.statsJson(handle)` every
|
||||
* second while the proxy is up and renders today's relay calls vs. the
|
||||
* Apps Script free-tier quota (20,000/day), today's bytes, UTC day key,
|
||||
* and a countdown to the 00:00 UTC reset. Also shows a "View quota on
|
||||
* Google" button that opens Google's Apps Script dashboard — the
|
||||
* authoritative number, since the client-side estimate only sees what
|
||||
* this device relayed.
|
||||
*
|
||||
* Hidden when the handle is 0 (proxy not running) or the JSON comes back
|
||||
* empty (google_only / full-only configs don't run a DomainFronter and so
|
||||
* have nothing to report).
|
||||
*/
|
||||
@Composable
|
||||
private fun UsageTodayCard() {
|
||||
// Free-tier Apps Script UrlFetchApp daily quota. Workspace / paid
|
||||
// tiers get 100k but most users are on free.
|
||||
val freeQuotaPerDay = 20_000
|
||||
|
||||
val handle by VpnState.proxyHandle.collectAsState()
|
||||
val isRunning by VpnState.isRunning.collectAsState()
|
||||
|
||||
// Nothing to poll until the proxy is up.
|
||||
if (!isRunning || handle == 0L) return
|
||||
|
||||
var statsJson by remember { mutableStateOf("") }
|
||||
LaunchedEffect(handle) {
|
||||
// Drop any stale snapshot from a previous run.
|
||||
statsJson = ""
|
||||
while (true) {
|
||||
statsJson = withContext(Dispatchers.IO) {
|
||||
runCatching { Native.statsJson(handle) }.getOrDefault("")
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
val obj = remember(statsJson) {
|
||||
if (statsJson.isBlank()) null
|
||||
else runCatching { JSONObject(statsJson) }.getOrNull()
|
||||
}
|
||||
// Still booting / not an apps-script config — stay silent.
|
||||
if (obj == null) return
|
||||
|
||||
val todayCalls = obj.optLong("today_calls", 0L)
|
||||
val todayBytes = obj.optLong("today_bytes", 0L)
|
||||
val todayKey = obj.optString("today_key", "")
|
||||
val resetSecs = obj.optLong("today_reset_secs", 0L)
|
||||
val pct = if (freeQuotaPerDay > 0) {
|
||||
(todayCalls.toDouble() / freeQuotaPerDay) * 100.0
|
||||
} else 0.0
|
||||
|
||||
val ctx = LocalContext.current
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.sec_usage_today),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
|
||||
UsageRow(
|
||||
label = stringResource(R.string.label_calls_today),
|
||||
value = stringResource(
|
||||
R.string.usage_calls_of_quota,
|
||||
todayCalls.toInt(),
|
||||
freeQuotaPerDay,
|
||||
pct,
|
||||
),
|
||||
)
|
||||
UsageRow(
|
||||
label = stringResource(R.string.label_bytes_today),
|
||||
value = fmtBytes(todayBytes),
|
||||
)
|
||||
UsageRow(
|
||||
label = stringResource(R.string.label_utc_day),
|
||||
value = todayKey,
|
||||
)
|
||||
UsageRow(
|
||||
label = stringResource(R.string.label_resets_in),
|
||||
value = stringResource(
|
||||
R.string.usage_resets_hm,
|
||||
(resetSecs / 3600).toInt(),
|
||||
((resetSecs / 60) % 60).toInt(),
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
TextButton(
|
||||
onClick = {
|
||||
// Open the Google-side Apps Script quota dashboard in
|
||||
// the user's browser. Uses ACTION_VIEW with a https://
|
||||
// URI — the OS picks whatever default browser is set.
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse("https://script.google.com/home/usage"),
|
||||
)
|
||||
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching { ctx.startActivity(intent) }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.btn_view_quota_on_google))
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.usage_today_note),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UsageRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fmtBytes(b: Long): String {
|
||||
val k = 1024L
|
||||
val m = k * k
|
||||
val g = m * k
|
||||
return when {
|
||||
b >= g -> String.format("%.2f GB", b.toDouble() / g)
|
||||
b >= m -> String.format("%.2f MB", b.toDouble() / m)
|
||||
b >= k -> String.format("%.1f KB", b.toDouble() / k)
|
||||
else -> "$b B"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HowToUseBody(listenPort: Int) {
|
||||
// Used inside the collapsible "How to use" CollapsibleSection. The
|
||||
// card + title are provided by the section wrapper, so this body
|
||||
// just renders the body text.
|
||||
//
|
||||
// Text is sourced from string resources (values/strings.xml +
|
||||
// values-fa/strings.xml) so the Persian locale gets a translated
|
||||
// guide instead of falling back to English.
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"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 " +
|
||||
"Downloads/mhrv-ca.crt and the Settings app opens. Use Settings' search bar " +
|
||||
"to find \"CA certificate\", tap that result (NOT \"VPN & app user certificate\" " +
|
||||
"or \"Wi-Fi\"), and pick mhrv-ca.crt from Downloads. You'll be asked to set a " +
|
||||
"screen lock if you don't have one (Android requirement).\n" +
|
||||
"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 " +
|
||||
"resolves locally (e.g. `nslookup www.google.com` on any working device).\n" +
|
||||
"4. Tap Start. Accept the VPN prompt. The full TUN bridge routes every app on the " +
|
||||
"device through the proxy — no per-app setup needed.\n" +
|
||||
"5. If Chrome shows \"504 Relay timeout\": your Apps Script deployment isn't " +
|
||||
"responding. Redeploy the script, grab the new /exec URL, and paste it above. " +
|
||||
"Watch Live logs for \"Relay timeout\" vs \"connect:\" errors to tell which layer " +
|
||||
"is failing.\n" +
|
||||
"\n" +
|
||||
"Known limitation — Cloudflare Turnstile (\"Verify you are human\") will loop " +
|
||||
"endlessly on most CF-protected sites. Every Apps Script request uses a rotating " +
|
||||
"Google-datacenter egress IP + a fixed \"Google-Apps-Script\" User-Agent + a " +
|
||||
"Google TLS fingerprint. The cf_clearance cookie is bound to the (IP, UA, JA3) " +
|
||||
"tuple the challenge was solved against, so the NEXT request — from a different " +
|
||||
"egress IP — gets re-challenged. Nothing in this app can fix that; it's inherent " +
|
||||
"to Apps Script as a relay. Sites that only gate the initial page load (not every " +
|
||||
"request) will work after one solve.",
|
||||
text = stringResource(R.string.help_how_to_use),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,4 +78,18 @@
|
||||
<string name="snack_google_ip_updated">google_ip به %1$s بهروزرسانی شد</string>
|
||||
<string name="snack_google_ip_current">google_ip قبلاً بهروز است (%1$s)</string>
|
||||
<string name="snack_dns_lookup_failed">خطای DNS — اتصال شبکه را بررسی کنید</string>
|
||||
|
||||
<!-- Usage today card -->
|
||||
<string name="sec_usage_today">مصرف امروز (تخمینی)</string>
|
||||
<string name="label_calls_today">درخواستهای امروز</string>
|
||||
<string name="label_bytes_today">بایت امروز</string>
|
||||
<string name="label_utc_day">روز (UTC)</string>
|
||||
<string name="label_resets_in">ریست تا</string>
|
||||
<string name="usage_calls_of_quota">%1$d / %2$d (%3$.1f%%)</string>
|
||||
<string name="usage_resets_hm">%1$d ساعت و %2$d دقیقه</string>
|
||||
<string name="btn_view_quota_on_google">مشاهدهٔ سهمیه در گوگل ←</string>
|
||||
<string name="usage_today_note">تخمینی — این همان چیزی است که از این دستگاه رد شده. عدد دقیق در داشبورد گوگل قابل مشاهده است.</string>
|
||||
|
||||
<!-- "How to use" guide body. Localized — EN copy lives in values. -->
|
||||
<string name="help_how_to_use">۱. یک یا چند آدرس deployment از Apps Script (یا فقط ID خام) و همراه آن auth_key خود را جایگذاری کنید.\n۲. روی «نصب گواهی MITM» بزنید و پیام تأیید را قبول کنید — گواهی در Downloads/mhrv-ca.crt ذخیره میشود و برنامهٔ Settings باز میشود. داخل Settings از نوار جستوجو «CA certificate» را پیدا کنید و روی همان نتیجه بزنید (نه «VPN & 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>
|
||||
</resources>
|
||||
|
||||
@@ -78,4 +78,18 @@
|
||||
<string name="snack_google_ip_updated">google_ip updated to %1$s</string>
|
||||
<string name="snack_google_ip_current">google_ip already current (%1$s)</string>
|
||||
<string name="snack_dns_lookup_failed">DNS lookup failed — check network</string>
|
||||
|
||||
<!-- Usage today card -->
|
||||
<string name="sec_usage_today">Usage today (estimated)</string>
|
||||
<string name="label_calls_today">calls today</string>
|
||||
<string name="label_bytes_today">bytes today</string>
|
||||
<string name="label_utc_day">UTC day</string>
|
||||
<string name="label_resets_in">resets in</string>
|
||||
<string name="usage_calls_of_quota">%1$d / %2$d (%3$.1f%%)</string>
|
||||
<string name="usage_resets_hm">%1$dh %2$dm</string>
|
||||
<string name="btn_view_quota_on_google">View quota on Google →</string>
|
||||
<string name="usage_today_note">Estimate — this is what this device relayed. Google\'s dashboard has the authoritative number.</string>
|
||||
|
||||
<!-- "How to use" guide body. Localized — FA copy lives in values-fa. -->
|
||||
<string name="help_how_to_use">1. Paste one or more Apps Script deployment URLs (or bare IDs) and your auth_key.\n2. Tap Install MITM certificate. Confirm the dialog — the cert is saved to Downloads/mhrv-ca.crt and the Settings app opens. Use Settings\' search bar to find \"CA certificate\", tap that result (NOT \"VPN & app user certificate\" or \"Wi-Fi\"), and pick mhrv-ca.crt from Downloads. You\'ll be asked to set a screen lock if you don\'t have one (Android requirement).\n3. 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 resolves locally (e.g. `nslookup www.google.com` on any working device).\n4. Tap Start. Accept the VPN prompt. The full TUN bridge routes every app on the device through the proxy — no per-app setup needed.\n5. If Chrome shows \"504 Relay timeout\": your Apps Script deployment isn\'t responding. Redeploy the script, grab the new /exec URL, and paste it above. Watch Live logs for \"Relay timeout\" vs \"connect:\" errors to tell which layer is failing.\n\nKnown limitation — Cloudflare Turnstile (\"Verify you are human\") will loop endlessly on most CF-protected sites. Every Apps Script request uses a rotating Google-datacenter egress IP + a fixed \"Google-Apps-Script\" User-Agent + a Google TLS fingerprint. The cf_clearance cookie is bound to the (IP, UA, JA3) tuple the challenge was solved against, so the NEXT request — from a different egress IP — gets re-challenged. Nothing in this app can fix that; it\'s inherent to Apps Script as a relay. Sites that only gate the initial page load (not every request) will work after one solve.</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<!-- see docs/changelog/v1.1.0.md for the file format: Persian, then `---`, then English. -->
|
||||
• کارت «مصرف امروز (تخمینی)»: در UI دسکتاپ و اندروید، یک کارت زنده اضافه شد که تعداد relay calls امروز نسبت به سهمیهٔ روزانهٔ Apps Script (۲۰٬۰۰۰ درخواست در ردهٔ رایگان)، حجم بایت رد شده، روز UTC فعلی، و زمان باقیمانده تا ریست ۰۰:۰۰ UTC را نشان میدهد. شمارندهها بر اساس atomic counterهای خود mhrv-rs هستند — هیچ API key از گوگل نمیخواهد. عدد فقط چیزی است که این دستگاه رد کرده؛ برای عدد دقیق سمت گوگل، دکمهٔ «مشاهدهٔ سهمیه در گوگل» داشبورد Apps Script را در مرورگر باز میکند
|
||||
• پل JNI آمار: `Native.statsJson(handle)` بهصورت سبک (فقط خواندن چند atomic) StatsSnapshot را به JSON سریالایز میکند. VpnState هم اکنون handle جاری proxy را منتشر میکند تا UI بتواند هر ثانیه poll کند بدون اینکه به state داخلی service دست بزند
|
||||
• ترجمهٔ فارسی راهنمای داخلی برنامهٔ اندروید: متن «راهنمای استفاده» که قبلاً ثابت روی انگلیسی بود، به string resource منتقل و ترجمهٔ فارسی برای هر ۵ مرحله + توضیح محدودیت Cloudflare Turnstile اضافه شد. زبان فارسی دیگر ته صفحه به انگلیسی نمیافتد
|
||||
---
|
||||
• "Usage today (estimated)" card on both desktop and Android. Shows live count of today's relay calls vs the Apps Script free-tier daily quota (20,000), bytes relayed today, current UTC day, and countdown to the 00:00 UTC reset. Counters come from mhrv-rs's own atomic counters — no Google API key needed. The number reflects only what this device relayed; the "View quota on Google" button opens the Apps Script dashboard in the user's browser for the authoritative cross-account number
|
||||
• Stats JNI bridge: `Native.statsJson(handle)` cheaply (just reads atomics) serializes `StatsSnapshot` to JSON. `VpnState` now publishes the running proxy handle so the Compose UI can poll once a second without poking into the service's internal state
|
||||
• Persian translation of the in-app guide on Android. The "How to use" body was previously hardcoded English; ported to a string resource with FA translation covering all 5 numbered steps + the Cloudflare Turnstile limitation note. Persian users no longer drop to English at the bottom of the screen
|
||||
@@ -40,6 +40,10 @@ struct Running {
|
||||
shutdown: Option<oneshot::Sender<()>>,
|
||||
/// Own the runtime so it outlives the server. Dropped last.
|
||||
rt: Option<Runtime>,
|
||||
/// Keep an Arc to the DomainFronter so `statsJson(handle)` can read the
|
||||
/// live stats without going through the async server. `None` for
|
||||
/// google-only / full-only configs where the fronter isn't used.
|
||||
fronter: Option<Arc<crate::domain_fronter::DomainFronter>>,
|
||||
}
|
||||
|
||||
static HANDLE_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
@@ -225,6 +229,10 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_startProxy(
|
||||
}
|
||||
};
|
||||
|
||||
// Grab the fronter Arc BEFORE we move `server` into the async task —
|
||||
// so `statsJson(handle)` can read counters without cross-task plumbing.
|
||||
let fronter = server.fronter();
|
||||
|
||||
let (tx, rx) = oneshot::channel::<()>();
|
||||
|
||||
rt.spawn(async move {
|
||||
@@ -239,6 +247,7 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_startProxy(
|
||||
Running {
|
||||
shutdown: Some(tx),
|
||||
rt: Some(rt),
|
||||
fronter,
|
||||
},
|
||||
);
|
||||
handle as jlong
|
||||
@@ -445,3 +454,31 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_testSni<'a>(
|
||||
}));
|
||||
env.new_string(result_json).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
/// `Native.statsJson(long handle)` -> String. Returns a JSON blob with the
|
||||
/// live `StatsSnapshot` for a running proxy, or an empty string if the
|
||||
/// handle is unknown or the proxy has no fronter (google_only / full modes).
|
||||
///
|
||||
/// Cheap — just reads a handful of atomics. The Kotlin UI polls this on a
|
||||
/// timer to render the "Usage today (estimated)" card.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_therealaleph_mhrv_Native_statsJson<'a>(
|
||||
env: JNIEnv<'a>,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
) -> jstring {
|
||||
let out = safe(String::new(), AssertUnwindSafe(|| {
|
||||
let map = match slot_map().lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
let Some(running) = map.get(&(handle as u64)) else {
|
||||
return String::new();
|
||||
};
|
||||
let Some(f) = running.fronter.as_ref() else {
|
||||
return String::new();
|
||||
};
|
||||
f.snapshot_stats().to_json()
|
||||
}));
|
||||
env.new_string(out).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
+81
-2
@@ -952,7 +952,7 @@ impl eframe::App for App {
|
||||
(
|
||||
s.running,
|
||||
s.started_at,
|
||||
s.last_stats,
|
||||
s.last_stats.clone(),
|
||||
s.ca_trusted,
|
||||
s.last_test_msg.clone(),
|
||||
s.last_per_site.clone(),
|
||||
@@ -966,7 +966,7 @@ impl eframe::App for App {
|
||||
"Traffic · (not running)".to_string()
|
||||
};
|
||||
section(ui, &status_title, |ui| {
|
||||
if let Some(s) = stats {
|
||||
if let Some(s) = &stats {
|
||||
// Compact two-column layout so 7 metrics fit in ~4 rows
|
||||
// instead of a tall vertical strip.
|
||||
let rows: Vec<(&str, String)> = vec![
|
||||
@@ -1030,6 +1030,85 @@ impl eframe::App for App {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Usage today (estimated) — daily budget tracker ───────────────
|
||||
// Client-side estimate from our own atomic counters. Counts only
|
||||
// successful relay calls this process saw since 00:00 UTC. Google's
|
||||
// actual quota bucket is per-Apps-Script-project and per-Google
|
||||
// account — if multiple devices share the same deployment, each
|
||||
// client only sees its own share. We link to the Google dashboard
|
||||
// for the authoritative number.
|
||||
if let Some(s) = &stats {
|
||||
ui.add_space(2.0);
|
||||
section(ui, "Usage today (estimated)", |ui| {
|
||||
// Free-tier Apps Script UrlFetchApp quota. Workspace /
|
||||
// paid accounts get 100k but most users are on free.
|
||||
const FREE_QUOTA_PER_DAY: u64 = 20_000;
|
||||
let pct = if FREE_QUOTA_PER_DAY > 0 {
|
||||
(s.today_calls as f64 / FREE_QUOTA_PER_DAY as f64) * 100.0
|
||||
} else { 0.0 };
|
||||
let reset = s.today_reset_secs;
|
||||
let reset_str = format!(
|
||||
"{}h {}m",
|
||||
reset / 3600,
|
||||
(reset / 60) % 60,
|
||||
);
|
||||
let rows: Vec<(&str, String)> = vec![
|
||||
(
|
||||
"calls today",
|
||||
format!(
|
||||
"{} / {} ({:.1}%)",
|
||||
s.today_calls, FREE_QUOTA_PER_DAY, pct
|
||||
),
|
||||
),
|
||||
("bytes today", fmt_bytes(s.today_bytes)),
|
||||
("UTC day", s.today_key.clone()),
|
||||
("resets in", reset_str),
|
||||
];
|
||||
egui::Grid::new("usage_today")
|
||||
.num_columns(4)
|
||||
.spacing([16.0, 4.0])
|
||||
.show(ui, |ui| {
|
||||
for chunk in rows.chunks(2) {
|
||||
for (label, value) in chunk.iter() {
|
||||
ui.add_sized(
|
||||
[110.0, 18.0],
|
||||
egui::Label::new(
|
||||
egui::RichText::new(*label)
|
||||
.color(egui::Color32::from_gray(150)),
|
||||
),
|
||||
);
|
||||
ui.add_sized(
|
||||
[140.0, 18.0],
|
||||
egui::Label::new(
|
||||
egui::RichText::new(value).monospace(),
|
||||
),
|
||||
);
|
||||
}
|
||||
if chunk.len() == 1 {
|
||||
ui.label("");
|
||||
ui.label("");
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.hyperlink_to(
|
||||
egui::RichText::new("View quota on Google →"),
|
||||
"https://script.google.com/home/usage",
|
||||
);
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
" (authoritative — estimate is what this device relayed)",
|
||||
)
|
||||
.color(egui::Color32::from_gray(130))
|
||||
.italics()
|
||||
.small(),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if !per_site.is_empty() {
|
||||
ui.add_space(2.0);
|
||||
egui::CollapsingHeader::new(format!("Per-site ({} hosts)", per_site.len()))
|
||||
|
||||
+169
-1
@@ -105,6 +105,19 @@ pub struct DomainFronter {
|
||||
/// on the slow path (once per relayed request), so a plain Mutex is
|
||||
/// fine.
|
||||
per_site: Arc<std::sync::Mutex<HashMap<String, HostStat>>>,
|
||||
/// Daily-scoped counters, reset at 00:00 UTC. Tracks what *this
|
||||
/// mhrv-rs process* has observed today — NOT the authoritative
|
||||
/// Apps Script quota bucket on Google's side (which counts across
|
||||
/// every client hitting the same deployment). Useful as a local
|
||||
/// "budget used today" estimate in the UI.
|
||||
///
|
||||
/// Both counters rebase to zero the first time any recording call
|
||||
/// crosses a UTC date boundary. `day_key` holds "YYYY-MM-DD" of
|
||||
/// the currently-counted day; when we see a new date we swap and
|
||||
/// clear the counters.
|
||||
today_calls: AtomicU64,
|
||||
today_bytes: AtomicU64,
|
||||
today_key: std::sync::Mutex<String>,
|
||||
}
|
||||
|
||||
/// Aggregated stats for one remote host.
|
||||
@@ -236,9 +249,30 @@ impl DomainFronter {
|
||||
relay_failures: AtomicU64::new(0),
|
||||
bytes_relayed: AtomicU64::new(0),
|
||||
per_site: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
today_calls: AtomicU64::new(0),
|
||||
today_bytes: AtomicU64::new(0),
|
||||
today_key: std::sync::Mutex::new(current_utc_day_key()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Record one relay call toward the daily budget. Called once per
|
||||
/// outbound Apps Script fetch. Rolls over both daily counters at
|
||||
/// 00:00 UTC.
|
||||
fn record_today(&self, bytes: u64) {
|
||||
let today = current_utc_day_key();
|
||||
// Fast path: same day as what we last saw. No lock.
|
||||
let mut guard = self.today_key.lock().unwrap();
|
||||
if *guard != today {
|
||||
// Date rolled over — reset counters before this call is counted.
|
||||
*guard = today;
|
||||
self.today_calls.store(0, Ordering::Relaxed);
|
||||
self.today_bytes.store(0, Ordering::Relaxed);
|
||||
}
|
||||
drop(guard);
|
||||
self.today_calls.fetch_add(1, Ordering::Relaxed);
|
||||
self.today_bytes.fetch_add(bytes, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment the per-site counters. Called on every logical request
|
||||
/// (both cache hits and relay roundtrips).
|
||||
fn record_site(&self, url: &str, cache_hit: bool, bytes: u64, latency_ns: u64) {
|
||||
@@ -267,6 +301,20 @@ impl DomainFronter {
|
||||
|
||||
pub fn snapshot_stats(&self) -> StatsSnapshot {
|
||||
let bl = self.blacklist.lock().unwrap();
|
||||
// Read today_key under lock and cheaply check rollover so the
|
||||
// UI never sees stale "today_calls=1847" on a day where no
|
||||
// traffic has flowed yet (e.g. user left the app open past
|
||||
// midnight UTC).
|
||||
let today_now = current_utc_day_key();
|
||||
let today_key = {
|
||||
let mut guard = self.today_key.lock().unwrap();
|
||||
if *guard != today_now {
|
||||
*guard = today_now.clone();
|
||||
self.today_calls.store(0, Ordering::Relaxed);
|
||||
self.today_bytes.store(0, Ordering::Relaxed);
|
||||
}
|
||||
guard.clone()
|
||||
};
|
||||
StatsSnapshot {
|
||||
relay_calls: self.relay_calls.load(Ordering::Relaxed),
|
||||
relay_failures: self.relay_failures.load(Ordering::Relaxed),
|
||||
@@ -277,6 +325,10 @@ impl DomainFronter {
|
||||
cache_bytes: self.cache.size(),
|
||||
blacklisted_scripts: bl.len(),
|
||||
total_scripts: self.script_ids.len(),
|
||||
today_calls: self.today_calls.load(Ordering::Relaxed),
|
||||
today_bytes: self.today_bytes.load(Ordering::Relaxed),
|
||||
today_key,
|
||||
today_reset_secs: seconds_until_utc_midnight(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,6 +806,10 @@ impl DomainFronter {
|
||||
}
|
||||
};
|
||||
self.bytes_relayed.fetch_add(bytes.len() as u64, Ordering::Relaxed);
|
||||
// Daily-budget counters (reset at 00:00 UTC). Only counts
|
||||
// successful relays — the two error branches above don't reach
|
||||
// here, matching what Google actually billed to quota.
|
||||
self.record_today(bytes.len() as u64);
|
||||
|
||||
if let Some(k) = cache_key_opt {
|
||||
if let Some(ttl) = parse_ttl(&bytes, url) {
|
||||
@@ -1406,6 +1462,60 @@ fn normalize_x_graphql_url(url: &str) -> String {
|
||||
format!("{}{}{}?{}", scheme, host, path, new_query)
|
||||
}
|
||||
|
||||
/// "YYYY-MM-DD" of the current UTC date. Used as the daily-reset
|
||||
/// boundary for `today_calls` / `today_bytes`. We format manually so
|
||||
/// this stays std-only and doesn't pull `time` or `chrono` for a
|
||||
/// ~20-line helper.
|
||||
fn current_utc_day_key() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let (y, m, d) = unix_to_ymd_utc(secs);
|
||||
format!("{:04}-{:02}-{:02}", y, m, d)
|
||||
}
|
||||
|
||||
/// Seconds until the next 00:00 UTC. Used by the UI to render a
|
||||
/// "resets in Xh Ym" countdown without the UI having to import time
|
||||
/// libraries. Conservative: if the system clock is broken we return
|
||||
/// 0 instead of a huge negative-looking number.
|
||||
fn seconds_until_utc_midnight() -> u64 {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let day = 86_400u64;
|
||||
let rem = secs % day;
|
||||
if rem == 0 {
|
||||
day
|
||||
} else {
|
||||
day - rem
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a Unix timestamp (seconds since 1970-01-01 UTC) to a
|
||||
/// (year, month, day) tuple, UTC. Standalone so we can stay
|
||||
/// std-only — no chrono/time/jiff dependency pulled for one caller.
|
||||
///
|
||||
/// Algorithm: Howard Hinnant's civil_from_days, widely cited and
|
||||
/// simple enough to audit by eye. Works for years 1970–9999 which
|
||||
/// we'll outlive.
|
||||
fn unix_to_ymd_utc(secs: u64) -> (i64, u32, u32) {
|
||||
let days = (secs / 86_400) as i64;
|
||||
// Shift so day 0 is 0000-03-01 (Hinnant's era-based trick).
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = (z - era * 146_097) as u64; // [0, 146096]
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||
let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
(y, m as u32, d as u32)
|
||||
}
|
||||
|
||||
fn extract_host(url: &str) -> Option<String> {
|
||||
let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
|
||||
let authority = after_scheme.split('/').next().unwrap_or("");
|
||||
@@ -1844,7 +1954,7 @@ fn parse_relay_json(body: &[u8]) -> Result<Vec<u8>, FronterError> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StatsSnapshot {
|
||||
pub relay_calls: u64,
|
||||
pub relay_failures: u64,
|
||||
@@ -1855,6 +1965,17 @@ pub struct StatsSnapshot {
|
||||
pub cache_bytes: usize,
|
||||
pub blacklisted_scripts: usize,
|
||||
pub total_scripts: usize,
|
||||
/// Relay calls attributed to the current UTC day. Resets at 00:00 UTC.
|
||||
/// This is what-this-process-has-done today, not the Google-side bucket.
|
||||
pub today_calls: u64,
|
||||
/// Response bytes from relay calls attributed to the current UTC day.
|
||||
pub today_bytes: u64,
|
||||
/// "YYYY-MM-DD" of the day `today_calls` / `today_bytes` refer to.
|
||||
/// Useful for cross-referencing against Google's dashboard.
|
||||
pub today_key: String,
|
||||
/// Seconds until the next 00:00 UTC rollover. Convenient for the UI
|
||||
/// to render "Resets in Xh Ym" without importing time libraries.
|
||||
pub today_reset_secs: u64,
|
||||
}
|
||||
|
||||
impl StatsSnapshot {
|
||||
@@ -1882,6 +2003,32 @@ impl StatsSnapshot {
|
||||
self.total_scripts,
|
||||
)
|
||||
}
|
||||
|
||||
/// Hand-rolled JSON serialization so the Android side can read the
|
||||
/// snapshot via JNI without pulling `serde_derive` through this struct.
|
||||
/// Field names match the Rust side verbatim so Kotlin can `JSONObject`
|
||||
/// parse them directly.
|
||||
pub fn to_json(&self) -> String {
|
||||
fn esc(s: &str) -> String {
|
||||
s.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
format!(
|
||||
r#"{{"relay_calls":{},"relay_failures":{},"coalesced":{},"bytes_relayed":{},"cache_hits":{},"cache_misses":{},"cache_bytes":{},"blacklisted_scripts":{},"total_scripts":{},"today_calls":{},"today_bytes":{},"today_key":"{}","today_reset_secs":{}}}"#,
|
||||
self.relay_calls,
|
||||
self.relay_failures,
|
||||
self.coalesced,
|
||||
self.bytes_relayed,
|
||||
self.cache_hits,
|
||||
self.cache_misses,
|
||||
self.cache_bytes,
|
||||
self.blacklisted_scripts,
|
||||
self.total_scripts,
|
||||
self.today_calls,
|
||||
self.today_bytes,
|
||||
esc(&self.today_key),
|
||||
self.today_reset_secs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn should_blacklist(status: u16, body: &str) -> bool {
|
||||
@@ -2018,6 +2165,27 @@ mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{duplex, AsyncWriteExt};
|
||||
|
||||
#[test]
|
||||
fn unix_to_ymd_utc_handles_known_epochs() {
|
||||
// Anchors chosen to catch the common off-by-one errors (pre/post
|
||||
// leap day, pre/post epoch, year-end rollover).
|
||||
assert_eq!(unix_to_ymd_utc(0), (1970, 1, 1)); // epoch
|
||||
assert_eq!(unix_to_ymd_utc(86_399), (1970, 1, 1)); // one sec before day 2
|
||||
assert_eq!(unix_to_ymd_utc(86_400), (1970, 1, 2)); // day 2 starts at midnight
|
||||
assert_eq!(unix_to_ymd_utc(951_782_400), (2000, 2, 29)); // leap day (Feb 29, 2000)
|
||||
assert_eq!(unix_to_ymd_utc(951_868_800), (2000, 3, 1)); // day after leap Feb
|
||||
assert_eq!(unix_to_ymd_utc(1_583_020_800), (2020, 3, 1)); // day after a leap Feb
|
||||
assert_eq!(unix_to_ymd_utc(1_735_689_599), (2024, 12, 31)); // last sec of 2024
|
||||
assert_eq!(unix_to_ymd_utc(1_735_689_600), (2025, 1, 1)); // first sec of 2025
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seconds_until_utc_midnight_is_bounded() {
|
||||
let n = seconds_until_utc_midnight();
|
||||
// Must be in (0, 86400] for any valid system clock.
|
||||
assert!(n > 0 && n <= 86_400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_forwarded_headers_strips_identity_revealing_headers() {
|
||||
// Issue #104: any proxy/extension that inserts these must not
|
||||
|
||||
Reference in New Issue
Block a user