diff --git a/Cargo.lock b/Cargo.lock index 76ffd78..2a3cbeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2222,7 +2222,7 @@ dependencies = [ [[package]] name = "mhrv-rs" -version = "1.7.11" +version = "1.8.0" dependencies = [ "base64 0.22.1", "bytes", diff --git a/Cargo.toml b/Cargo.toml index ebb1b3d..a63b78d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mhrv-rs" -version = "1.7.11" +version = "1.8.0" edition = "2021" description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting" license = "MIT" diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c17c485..1326365 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.therealaleph.mhrv" minSdk = 24 // Android 7.0 — covers 99%+ of live devices. targetSdk = 34 - versionCode = 156 - versionName = "1.7.11" + versionCode = 157 + versionName = "1.8.0" // Ship all four mainstream Android ABIs: // - arm64-v8a — 95%+ of real-world Android phones since 2019 diff --git a/assets/apps_script/Code.gs b/assets/apps_script/Code.gs index 8c2acec..3eee306 100644 --- a/assets/apps_script/Code.gs +++ b/assets/apps_script/Code.gs @@ -18,6 +18,19 @@ const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET"; +// Active-probing defense. When false (production default), bad AUTH_KEY +// requests get a decoy HTML page that looks like a placeholder Apps +// Script web app instead of the JSON `{"e":"unauthorized"}` body. This +// makes the deployment indistinguishable from a forgotten-but-public +// Apps Script project to active scanners that POST malformed payloads +// looking for proxy endpoints. +// +// Set to `true` during initial setup if a misconfigured client is +// hitting "unauthorized" and you want the explicit JSON error to debug +// — then flip back to false before the deployment is widely shared. +// (Inspired by #365 Section 3, mhrv-rs v1.8.0+.) +const DIAGNOSTIC_MODE = false; + // Keep browser capability headers (sec-ch-ua*, sec-fetch-*) intact. // Some modern apps, notably Google Meet, use them for browser gating. const SKIP_HEADERS = { @@ -26,10 +39,25 @@ const SKIP_HEADERS = { "priority": 1, te: 1, }; +// HTML body for the bad-auth decoy. Mimics a minimal Apps Script-style +// placeholder page — no proxy-shaped JSON, nothing distinctive enough +// for a probe to fingerprint as a tunnel endpoint. +const DECOY_HTML = + '
The script completed but did not return anything.
' + + ''; + +function _decoyOrError(jsonBody) { + if (DIAGNOSTIC_MODE) return _json(jsonBody); + return ContentService + .createTextOutput(DECOY_HTML) + .setMimeType(ContentService.MimeType.HTML); +} + function doPost(e) { try { var req = JSON.parse(e.postData.contents); - if (req.k !== AUTH_KEY) return _json({ e: "unauthorized" }); + if (req.k !== AUTH_KEY) return _decoyOrError({ e: "unauthorized" }); // Batch mode: { k, q: [...] } if (Array.isArray(req.q)) return _doBatch(req.q); @@ -37,10 +65,23 @@ function doPost(e) { // Single mode return _doSingle(req); } catch (err) { - return _json({ e: String(err) }); + // Parse failures of the request body are also probe-shaped — a real + // mhrv-rs client never sends invalid JSON. Decoy for the same reason. + return _decoyOrError({ e: String(err) }); } } +// `doGet` is what active scanners hit first (HTTP GET probes are cheaper +// than POSTs). Apps Script defaults to a "Script function not found" page +// here which is a fine-enough decoy on its own, but explicitly returning +// the same harmless placeholder makes the response identical to the +// bad-auth POST decoy — one less fingerprint vector. +function doGet(e) { + return ContentService + .createTextOutput(DECOY_HTML) + .setMimeType(ContentService.MimeType.HTML); +} + function _doSingle(req) { if (!req.u || typeof req.u !== "string" || !req.u.match(/^https?:\/\//i)) { return _json({ e: "bad url" }); diff --git a/assets/apps_script/CodeFull.gs b/assets/apps_script/CodeFull.gs index 77b2a5e..e116ee7 100644 --- a/assets/apps_script/CodeFull.gs +++ b/assets/apps_script/CodeFull.gs @@ -16,18 +16,46 @@ const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET"; const TUNNEL_SERVER_URL = "https://YOUR_TUNNEL_NODE_URL"; const TUNNEL_AUTH_KEY = "YOUR_TUNNEL_AUTH_KEY"; +// Active-probing defense. When false (production default), bad AUTH_KEY +// requests get a decoy HTML page that looks like a placeholder Apps +// Script web app instead of the JSON `{"e":"unauthorized"}` body. This +// makes the deployment indistinguishable from a forgotten-but-public +// Apps Script project to active scanners that POST malformed payloads +// looking for proxy endpoints. +// +// Set to `true` during initial setup if a misconfigured client is +// hitting "unauthorized" and you want the explicit JSON error to debug +// — then flip back to false before the deployment is widely shared. +// (Inspired by #365 Section 3, mhrv-rs v1.8.0+.) +const DIAGNOSTIC_MODE = false; + const SKIP_HEADERS = { host: 1, connection: 1, "content-length": 1, "transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1, "priority": 1, te: 1, }; +// HTML body for the bad-auth decoy. Mimics a minimal Apps Script-style +// placeholder page — no proxy-shaped JSON, nothing distinctive enough +// for a probe to fingerprint as a tunnel endpoint. +const DECOY_HTML = + 'The script completed but did not return anything.
' + + ''; + +function _decoyOrError(jsonBody) { + if (DIAGNOSTIC_MODE) return _json(jsonBody); + return ContentService + .createTextOutput(DECOY_HTML) + .setMimeType(ContentService.MimeType.HTML); +} + // ========================== Entry point ========================== function doPost(e) { try { var req = JSON.parse(e.postData.contents); - if (req.k !== AUTH_KEY) return _json({ e: "unauthorized" }); + if (req.k !== AUTH_KEY) return _decoyOrError({ e: "unauthorized" }); // Tunnel mode if (req.t) return _doTunnel(req); @@ -38,7 +66,9 @@ function doPost(e) { // Single relay mode return _doSingle(req); } catch (err) { - return _json({ e: String(err) }); + // Parse failures of the request body are also probe-shaped — a real + // mhrv-rs client never sends invalid JSON. Decoy for the same reason. + return _decoyOrError({ e: String(err) }); } } diff --git a/docs/changelog/v1.8.0.md b/docs/changelog/v1.8.0.md new file mode 100644 index 0000000..77a79bd --- /dev/null +++ b/docs/changelog/v1.8.0.md @@ -0,0 +1,12 @@ + +• Padding random برای پایلود Apps Script ([#313](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/313)، [#365](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/365) Section 1): هر request به Apps Script حالا یک فیلد `_pad` با طول uniform-random بین ۰-۱۰۲۴ بایت اضافه میکنه — بهصورت base64 encoded. بدون این، طول request body در هر mode تقریباً ثابت میمونه + DPI ایران میتونه بر اساس distribution طول fingerprint بزنه. حالا packet sizes uniformly distributed هستن + length-clustering match نمیکنه. تأثیر bandwidth: متوسط ۵۱۲ بایت اضافه به batch ~۲KB = +۲۵٪، negligible در برابر floor latency Apps Script. backward-compatible: Code.gs قدیم هم کار میکنه (unknown JSON fields ignore میشن). +• Defense active probing: decoy 200 HTML در Code.gs / CodeFull.gs روی AUTH_KEY بد ([#365](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/365) Section 3): قبلاً request بدون auth `{"e":"unauthorized"}` JSON برمیگردوند — fingerprint مشخص "این یه API endpoint هست". حالا یه HTML benign placeholder برمیگردونه که شبیه یه Apps Script web app forgotten-but-public هست. scanner active که با AUTH_KEY ساختگی POST میکنه categorize میکنه بهعنوان "non-tunnel، nothing interesting". flag `DIAGNOSTIC_MODE` برای setup که response قدیمی JSON رو برمیگردونه — default `false` (production-strong) +• Defense active probing: decoy 404 nginx در tunnel-node روی auth بد: tunnel-node قبلاً `{"e":"unauthorized"}` JSON برمیگردوند. حالا response 404 با body HTML شبیه nginx default error میفرسته (active scanners "static web server هست، tunnel نیست" تشخیص میدن). env var `MHRV_DIAGNOSTIC=1` برای setup behavior قدیمی رو فعال میکنه +• رفع باگ "Usage today (estimated) در Full mode همیشه ۰" ([#230](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/230)، [#362](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/362)): counter `today_calls` و `today_bytes` فقط روی apps_script-mode relay path در `domain_fronter::relay()` افزایش مییافت. Full mode از `tunnel_client::fire_batch` میگذره که کانتر رو زد. حالا fire_batch بعد از batch موفق `record_today(response_bytes)` رو صدا میزنه — bytes از sum طول `d` و `pkts` در BatchTunnelResponse تخمین زده میشه. Full mode users حالا "Usage today" واقعی میبینن +• رفع باگ "quota reset countdown با time UTC بهجای PT نشون داده میشه" ([#230](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/230)، [#362](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/362)): Apps Script's UrlFetchApp quota در 00:00 **Pacific Time** ریست میشه (PST/PDT با DST)، نه UTC. ما UTC midnight رو نشون میدادیم — ۷-۸ ساعت off. fix: helpers جدید `current_pt_day_key()` + `seconds_until_pacific_midnight()` با hand-rolled DST detection (بدون اضافه کردن chrono-tz / 3MB tzdb). UI label "UTC day" → "PT day" تغییر کرد. ۲ test جدید برای DST window boundaries (مارس ۲۰۲۴/۲۰۲۶/۲۰۲۷، نوامبر ۲۰۲۴/۲۰۲۶) + Sakamoto's day-of-week +--- +• Random payload padding for Apps Script requests ([#313](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/313), [#365](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/365) Section 1): every outbound request to Apps Script now carries a `_pad` field of uniform-random length 0–1024 bytes (base64 encoded). Before this, request body sizes within each mode were tightly clustered, giving ISP DPI a clean length-distribution fingerprint to match against. Now packet sizes are spread uniformly across the range so length-clustering DPI heuristics can't match. Bandwidth cost: ~512 bytes added to a typical 2 KB tunnel batch = +25%, negligible against Apps Script's per-call latency floor. Backward-compatible: old Code.gs deployments ignore the unknown field. Applied at all three payload-build sites: single relay, single tunnel op, batch tunnel. +• Active-probing defense: decoy 200 HTML on bad AUTH_KEY in `Code.gs` and `CodeFull.gs` ([#365](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/365) Section 3): previously a request with a missing/wrong AUTH_KEY got `{"e":"unauthorized"}` as a JSON body — a clear "this is some kind of API endpoint" signal that active scanners can fingerprint. Now bad-auth requests get a benign HTML placeholder page that looks like a forgotten-but-public Apps Script web app, indistinguishable from the millions of stale Apps Script projects on Google's infrastructure. New `DIAGNOSTIC_MODE` const (default `false`) restores the old JSON error response for setup/debugging — flip to `true` while configuring a misconfigured client, then back to `false` before sharing the deployment widely. +• Active-probing defense: decoy 404 nginx-style HTML on bad auth in `tunnel-node` ([#365](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/365) Section 3): previously a bad-auth request got `{"e":"unauthorized"}`. Now it gets an HTTP 404 with an `nginx`-style error page body, looking like a vanilla static web server. Active scanners that POST malformed payloads to `/tunnel` to discover proxy endpoints categorize this host as "boring" and move on. New `MHRV_DIAGNOSTIC=1` env var restores the verbose JSON error during setup; default is the production decoy. +• Fix "Usage today (estimated) is always 0 in Full mode" ([#230](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/230), [#362](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/362)): the daily-usage counters (`today_calls` / `today_bytes`) were incremented only on the `apps_script`-mode relay path inside `domain_fronter::relay()`. Full-mode traffic goes through `tunnel_client::fire_batch` which never wired the counter. Now `fire_batch` calls `record_today(response_bytes)` after each successful batch — bytes are estimated from the sum of per-session `d` (TCP payload) and `pkts` (UDP datagrams) lengths in the `BatchTunnelResponse`, which is a stable proxy for "how much did this batch move." Full mode users now see real usage numbers instead of stuck-at-zero. +• Fix "quota reset countdown shown in UTC instead of Pacific Time" ([#230](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/230), [#362](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/362)): Apps Script's `UrlFetchApp` quota actually resets at midnight Pacific Time (PST/PDT — observes DST), not midnight UTC. We were displaying the countdown to UTC midnight, which is 7–8 hours off depending on DST. Fix: new `current_pt_day_key()` + `seconds_until_pacific_midnight()` helpers using a hand-rolled US DST detector (2nd Sunday of March → 1st Sunday of November = PDT, otherwise PST) so we don't pull `chrono-tz` and a ~3 MB IANA tzdb just for one helper. UI label updated from "UTC day" to "PT day". Two new tests pin down the DST window boundaries (March 2024 / 2026 / 2027, November 2024 / 2026) and Sakamoto's day-of-week formula. diff --git a/src/bin/ui.rs b/src/bin/ui.rs index 1f8b282..407d2b7 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -1113,7 +1113,7 @@ impl eframe::App for App { ), ), ("bytes today", fmt_bytes(s.today_bytes)), - ("UTC day", s.today_key.clone()), + ("PT day", s.today_key.clone()), ("resets in", reset_str), ]; egui::Grid::new("usage_today") diff --git a/src/domain_fronter.rs b/src/domain_fronter.rs index b13fd34..cc85a60 100644 --- a/src/domain_fronter.rs +++ b/src/domain_fronter.rs @@ -21,6 +21,7 @@ use std::time::{Duration, Instant}; use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; +use rand::{thread_rng, Rng, RngCore}; use serde::{Deserialize, Serialize}; use serde_json::Value; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -287,15 +288,19 @@ impl DomainFronter { 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()), + today_key: std::sync::Mutex::new(current_pt_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(); + /// 00:00 Pacific Time, matching Apps Script's quota reset cadence + /// (#230, #362). Crate-public so the Full-mode batch path in + /// `tunnel_client::fire_batch` can wire into the same accounting + /// (Apps Script sees Full-mode batches as ordinary `UrlFetchApp` + /// calls and counts them against the same daily quota). + pub(crate) fn record_today(&self, bytes: u64) { + let today = current_pt_day_key(); // Fast path: same day as what we last saw. No lock. let mut guard = self.today_key.lock().unwrap(); if *guard != today { @@ -340,8 +345,8 @@ impl DomainFronter { // 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(); + // midnight PT). + let today_now = current_pt_day_key(); let today_key = { let mut guard = self.today_key.lock().unwrap(); if *guard != today_now { @@ -364,7 +369,7 @@ impl DomainFronter { 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(), + today_reset_secs: seconds_until_pacific_midnight(), } } @@ -1148,7 +1153,16 @@ impl DomainFronter { ct, r: true, }; - Ok(serde_json::to_vec(&req)?) + // Serialize via Value so we can splice in the random `_pad` field + // without changing RelayRequest's wire schema. Apps Script ignores + // unknown JSON fields, so old Code.gs deployments stay compatible + // — the pad is just bytes-on-the-wire that the server sees and + // discards. + let mut v = serde_json::to_value(&req)?; + if let Value::Object(map) = &mut v { + add_random_pad(map); + } + Ok(serde_json::to_vec(&v)?) } // ────── Full-mode tunnel protocol ────────────────────────────────── @@ -1276,6 +1290,7 @@ impl DomainFronter { if let Some(d) = data { map.insert("d".into(), Value::String(d)); } + add_random_pad(&mut map); Ok(serde_json::to_vec(&Value::Object(map))?) } @@ -1303,6 +1318,7 @@ impl DomainFronter { map.insert("k".into(), Value::String(self.auth_key.clone())); map.insert("t".into(), Value::String("batch".into())); map.insert("ops".into(), serde_json::to_value(ops)?); + add_random_pad(&mut map); let payload = serde_json::to_vec(&Value::Object(map))?; let path = format!("/macros/s/{}/exec", script_id); @@ -1613,30 +1629,74 @@ 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 { +/// Maximum bytes of random padding appended to outbound Apps Script +/// JSON request bodies. Picked so the per-request padding distribution +/// (uniformly 0..MAX) shifts the body length enough to defeat naive +/// length-fingerprint DPI without bloating bandwidth — at the average +/// 512-byte add, on a typical 2 KB tunnel batch this is +25%, which is +/// negligible compared to Apps Script's per-call latency floor anyway. +/// (Issue #313, #365 Section 1 — DPI evasion.) +const MAX_RANDOM_PAD_BYTES: usize = 1024; + +/// Insert a `_pad` field of random length (0..MAX_RANDOM_PAD_BYTES) +/// into a request payload before serialization. Server-side ignores +/// unknown JSON fields, so this is fully backward-compatible with old +/// `Code.gs` / `CodeFull.gs` deployments — the pad is just along for +/// the ride. +/// +/// Random bytes are base64-encoded (NO inner JSON-escape worries) and +/// the pad LENGTH itself is uniformly distributed, so packet sizes +/// land all over the place rather than clustering at a few discrete +/// peaks. That's the property DPI's length-distribution clustering +/// fingerprints can't match. +fn add_random_pad(map: &mut serde_json::Map