feat: v1.8.0 — DPI evasion, active-probing defense, full-mode usage counters

Five user-visible changes shipping together. Each is independently
useful + bounded; bundled because they're all "small architectural
hardening" that benefits from one release announcement.

1. Random payload padding (#313, #365 §1)

   Every outbound Apps Script JSON request now carries a `_pad` field
   of uniform-random length 0..1024 bytes (base64). Defeats DPI that
   fingerprints on the tight length distribution of mhrv-rs's previous
   per-mode-bound packet sizes. ~25% bandwidth on a typical 2 KB batch,
   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.

2. Active-probing decoy: GAS bad-auth → 200 HTML (#365 §3)

   `Code.gs` and `CodeFull.gs` now return a benign Apps-Script-style
   placeholder HTML page on bad/missing AUTH_KEY instead of the JSON
   `{"e":"unauthorized"}`. To an active scanner the deployment looks
   like one of the millions of forgotten public Apps Script projects
   rather than an obvious API endpoint. New `DIAGNOSTIC_MODE` const
   restores JSON errors during setup; default false (production-strong).

3. Active-probing decoy: tunnel-node bad-auth → 404 nginx (#365 §3)

   `tunnel-node` returns an HTTP 404 with an nginx-style HTML body on
   bad auth instead of `{"e":"unauthorized"}`. Active scanners cataloging
   the host see "static web server, nothing tunnel-shaped here." New
   `MHRV_DIAGNOSTIC=1` env var restores verbose JSON during setup.

4. Fix: Full-mode usage counter stuck at zero (#230, #362)

   `today_calls` / `today_bytes` were only being incremented on the
   apps_script-mode relay path. Full-mode batches go through
   `tunnel_client::fire_batch` which never wired into the counter.
   Now `fire_batch` calls `record_today(response_bytes)` after each
   successful batch — bytes estimated from the `d` (TCP payload) and
   `pkts` (UDP datagrams) sizes in the BatchTunnelResponse. Full-mode
   users now see real usage numbers.

5. Fix: quota reset countdown was UTC, should be PT (#230, #362)

   Apps Script's UrlFetchApp daily quota resets at midnight Pacific
   Time, not UTC. We were displaying the countdown to UTC midnight,
   off by 7-8h depending on DST. New `current_pt_day_key()` and
   `seconds_until_pacific_midnight()` helpers with hand-rolled US DST
   detection (2nd Sunday March → 1st Sunday November = PDT, else PST)
   so we don't pull `chrono-tz` and a ~3 MB IANA tzdb just for one
   helper. UI label "UTC day" → "PT day". Tests pin DST window
   boundaries against March/November of 2024, 2026, 2027 to catch
   regressions in the day-of-week math.

Tested:
- cargo test --lib: 154 passed (was 152, +2 for DST window + day-of-week)
- cargo build --release: clean
- cargo build --release --bin mhrv-rs-ui --features ui: clean (macOS arm64)
- tunnel-node cargo test: 30 passed
- Android: ./gradlew assembleDebug succeeds; APK installs + launches
  on mhrv_test emulator (arm64-v8a), no UnsatisfiedLink, no crash

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
therealaleph
2026-04-28 01:39:33 +03:00
parent f7da4f01cc
commit cb3732f920
10 changed files with 378 additions and 50 deletions
Generated
+1 -1
View File
@@ -2222,7 +2222,7 @@ dependencies = [
[[package]] [[package]]
name = "mhrv-rs" name = "mhrv-rs"
version = "1.7.11" version = "1.8.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "mhrv-rs" name = "mhrv-rs"
version = "1.7.11" version = "1.8.0"
edition = "2021" edition = "2021"
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting" description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
license = "MIT" license = "MIT"
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.therealaleph.mhrv" applicationId = "com.therealaleph.mhrv"
minSdk = 24 // Android 7.0 — covers 99%+ of live devices. minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
targetSdk = 34 targetSdk = 34
versionCode = 156 versionCode = 157
versionName = "1.7.11" versionName = "1.8.0"
// Ship all four mainstream Android ABIs: // Ship all four mainstream Android ABIs:
// - arm64-v8a — 95%+ of real-world Android phones since 2019 // - arm64-v8a — 95%+ of real-world Android phones since 2019
+43 -2
View File
@@ -18,6 +18,19 @@
const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET"; 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. // Keep browser capability headers (sec-ch-ua*, sec-fetch-*) intact.
// Some modern apps, notably Google Meet, use them for browser gating. // Some modern apps, notably Google Meet, use them for browser gating.
const SKIP_HEADERS = { const SKIP_HEADERS = {
@@ -26,10 +39,25 @@ const SKIP_HEADERS = {
"priority": 1, te: 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 =
'<!DOCTYPE html><html><head><title>Web App</title></head>' +
'<body><p>The script completed but did not return anything.</p>' +
'</body></html>';
function _decoyOrError(jsonBody) {
if (DIAGNOSTIC_MODE) return _json(jsonBody);
return ContentService
.createTextOutput(DECOY_HTML)
.setMimeType(ContentService.MimeType.HTML);
}
function doPost(e) { function doPost(e) {
try { try {
var req = JSON.parse(e.postData.contents); 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: [...] } // Batch mode: { k, q: [...] }
if (Array.isArray(req.q)) return _doBatch(req.q); if (Array.isArray(req.q)) return _doBatch(req.q);
@@ -37,10 +65,23 @@ function doPost(e) {
// Single mode // Single mode
return _doSingle(req); return _doSingle(req);
} catch (err) { } 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) { function _doSingle(req) {
if (!req.u || typeof req.u !== "string" || !req.u.match(/^https?:\/\//i)) { if (!req.u || typeof req.u !== "string" || !req.u.match(/^https?:\/\//i)) {
return _json({ e: "bad url" }); return _json({ e: "bad url" });
+32 -2
View File
@@ -16,18 +16,46 @@ const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET";
const TUNNEL_SERVER_URL = "https://YOUR_TUNNEL_NODE_URL"; const TUNNEL_SERVER_URL = "https://YOUR_TUNNEL_NODE_URL";
const TUNNEL_AUTH_KEY = "YOUR_TUNNEL_AUTH_KEY"; 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 = { const SKIP_HEADERS = {
host: 1, connection: 1, "content-length": 1, host: 1, connection: 1, "content-length": 1,
"transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1, "transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1,
"priority": 1, te: 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 =
'<!DOCTYPE html><html><head><title>Web App</title></head>' +
'<body><p>The script completed but did not return anything.</p>' +
'</body></html>';
function _decoyOrError(jsonBody) {
if (DIAGNOSTIC_MODE) return _json(jsonBody);
return ContentService
.createTextOutput(DECOY_HTML)
.setMimeType(ContentService.MimeType.HTML);
}
// ========================== Entry point ========================== // ========================== Entry point ==========================
function doPost(e) { function doPost(e) {
try { try {
var req = JSON.parse(e.postData.contents); 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 // Tunnel mode
if (req.t) return _doTunnel(req); if (req.t) return _doTunnel(req);
@@ -38,7 +66,9 @@ function doPost(e) {
// Single relay mode // Single relay mode
return _doSingle(req); return _doSingle(req);
} catch (err) { } 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) });
} }
} }
+12
View File
@@ -0,0 +1,12 @@
<!-- see docs/changelog/v1.1.0.md for the file format: Persian, then `---`, then English. -->
• 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 01024 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 78 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.
+1 -1
View File
@@ -1113,7 +1113,7 @@ impl eframe::App for App {
), ),
), ),
("bytes today", fmt_bytes(s.today_bytes)), ("bytes today", fmt_bytes(s.today_bytes)),
("UTC day", s.today_key.clone()), ("PT day", s.today_key.clone()),
("resets in", reset_str), ("resets in", reset_str),
]; ];
egui::Grid::new("usage_today") egui::Grid::new("usage_today")
+185 -28
View File
@@ -21,6 +21,7 @@ use std::time::{Duration, Instant};
use base64::engine::general_purpose::STANDARD as B64; use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine; use base64::Engine;
use rand::{thread_rng, Rng, RngCore};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -287,15 +288,19 @@ impl DomainFronter {
per_site: Arc::new(std::sync::Mutex::new(HashMap::new())), per_site: Arc::new(std::sync::Mutex::new(HashMap::new())),
today_calls: AtomicU64::new(0), today_calls: AtomicU64::new(0),
today_bytes: 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 /// Record one relay call toward the daily budget. Called once per
/// outbound Apps Script fetch. Rolls over both daily counters at /// outbound Apps Script fetch. Rolls over both daily counters at
/// 00:00 UTC. /// 00:00 Pacific Time, matching Apps Script's quota reset cadence
fn record_today(&self, bytes: u64) { /// (#230, #362). Crate-public so the Full-mode batch path in
let today = current_utc_day_key(); /// `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. // Fast path: same day as what we last saw. No lock.
let mut guard = self.today_key.lock().unwrap(); let mut guard = self.today_key.lock().unwrap();
if *guard != today { if *guard != today {
@@ -340,8 +345,8 @@ impl DomainFronter {
// Read today_key under lock and cheaply check rollover so the // Read today_key under lock and cheaply check rollover so the
// UI never sees stale "today_calls=1847" on a day where no // UI never sees stale "today_calls=1847" on a day where no
// traffic has flowed yet (e.g. user left the app open past // traffic has flowed yet (e.g. user left the app open past
// midnight UTC). // midnight PT).
let today_now = current_utc_day_key(); let today_now = current_pt_day_key();
let today_key = { let today_key = {
let mut guard = self.today_key.lock().unwrap(); let mut guard = self.today_key.lock().unwrap();
if *guard != today_now { if *guard != today_now {
@@ -364,7 +369,7 @@ impl DomainFronter {
today_calls: self.today_calls.load(Ordering::Relaxed), today_calls: self.today_calls.load(Ordering::Relaxed),
today_bytes: self.today_bytes.load(Ordering::Relaxed), today_bytes: self.today_bytes.load(Ordering::Relaxed),
today_key, today_key,
today_reset_secs: seconds_until_utc_midnight(), today_reset_secs: seconds_until_pacific_midnight(),
} }
} }
@@ -1148,7 +1153,16 @@ impl DomainFronter {
ct, ct,
r: true, 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 ────────────────────────────────── // ────── Full-mode tunnel protocol ──────────────────────────────────
@@ -1276,6 +1290,7 @@ impl DomainFronter {
if let Some(d) = data { if let Some(d) = data {
map.insert("d".into(), Value::String(d)); map.insert("d".into(), Value::String(d));
} }
add_random_pad(&mut map);
Ok(serde_json::to_vec(&Value::Object(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("k".into(), Value::String(self.auth_key.clone()));
map.insert("t".into(), Value::String("batch".into())); map.insert("t".into(), Value::String("batch".into()));
map.insert("ops".into(), serde_json::to_value(ops)?); map.insert("ops".into(), serde_json::to_value(ops)?);
add_random_pad(&mut map);
let payload = serde_json::to_vec(&Value::Object(map))?; let payload = serde_json::to_vec(&Value::Object(map))?;
let path = format!("/macros/s/{}/exec", script_id); 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) format!("{}{}{}?{}", scheme, host, path, new_query)
} }
/// "YYYY-MM-DD" of the current UTC date. Used as the daily-reset /// Maximum bytes of random padding appended to outbound Apps Script
/// boundary for `today_calls` / `today_bytes`. We format manually so /// JSON request bodies. Picked so the per-request padding distribution
/// this stays std-only and doesn't pull `time` or `chrono` for a /// (uniformly 0..MAX) shifts the body length enough to defeat naive
/// ~20-line helper. /// length-fingerprint DPI without bloating bandwidth — at the average
fn current_utc_day_key() -> String { /// 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<String, Value>) {
let mut rng = thread_rng();
let len = rng.gen_range(0..=MAX_RANDOM_PAD_BYTES);
if len == 0 {
// Skip the field entirely sometimes — adds another bit of
// distribution variance (presence-vs-absence of `_pad` itself).
return;
}
let mut buf = vec![0u8; len];
rng.fill_bytes(&mut buf);
map.insert("_pad".into(), Value::String(B64.encode(&buf)));
}
/// "YYYY-MM-DD" of the current Pacific Time date. Used as the daily-reset
/// boundary for `today_calls` / `today_bytes` because **Apps Script's
/// quota counter resets at midnight Pacific Time, not UTC** — that's
/// where Google's quota bookkeeping lives. We format manually so this
/// stays std-only and doesn't pull `time-tz` or `chrono` plus a ~3 MB
/// IANA tzdb just for one ~50-line helper. (Issue #230, #362.)
///
/// PT offset depends on DST: PST = UTC-8, PDT = UTC-7. We use the
/// stable US DST rule (2nd Sunday of March 02:00 → 1st Sunday of
/// November 02:00 = PDT, otherwise PST). The hour-of-day boundary on
/// transition days is approximated; this drifts by up to 1h for at
/// most 2h/year on the spring-forward / fall-back transitions, which
/// is fine for a daily countdown.
fn current_pt_day_key() -> String {
let secs = std::time::SystemTime::now() let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0); .unwrap_or(0);
let (y, m, d) = unix_to_ymd_utc(secs); let pt_secs = unix_to_pt_seconds(secs);
let (y, m, d) = unix_to_ymd_utc(pt_secs);
format!("{:04}-{:02}-{:02}", y, m, d) format!("{:04}-{:02}-{:02}", y, m, d)
} }
/// Seconds until the next 00:00 UTC. Used by the UI to render a /// Seconds until the next 00:00 Pacific Time. Used by the UI to render
/// "resets in Xh Ym" countdown without the UI having to import time /// a "resets in Xh Ym" countdown matching Apps Script's actual quota
/// libraries. Conservative: if the system clock is broken we return /// reset cadence (#230, #362). Conservative: if the system clock is
/// 0 instead of a huge negative-looking number. /// broken we return 0 instead of a huge negative-looking number.
fn seconds_until_utc_midnight() -> u64 { fn seconds_until_pacific_midnight() -> u64 {
let secs = std::time::SystemTime::now() let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0); .unwrap_or(0);
let pt_secs = unix_to_pt_seconds(secs);
let day = 86_400u64; let day = 86_400u64;
let rem = secs % day; let rem = pt_secs % day;
if rem == 0 { if rem == 0 {
day day
} else { } else {
@@ -1644,6 +1704,65 @@ fn seconds_until_utc_midnight() -> u64 {
} }
} }
/// Convert Unix UTC seconds to "Pacific Time as if it were UTC" seconds,
/// i.e. add the PT-from-UTC offset (negative for the western hemisphere
/// becomes a subtraction). Result is suitable for feeding into
/// `unix_to_ymd_utc` to extract the PT calendar date, or for `% 86_400`
/// to find PT seconds-into-day.
fn unix_to_pt_seconds(utc_secs: u64) -> u64 {
// First-pass guess at PT date using PST (-8) — used to determine
// whether DST is currently in effect, which then settles the actual
// offset. The two-pass approach avoids the chicken-and-egg of
// "I need the PT date to know if it's DST, but I need the offset
// to compute the PT date." A 1-hour fudge in the guess is harmless
// because DST never starts within the first hour after midnight
// PST or ends within the first hour after midnight PDT.
let pst_guess = utc_secs.saturating_sub(8 * 3600);
let (y, m, d) = unix_to_ymd_utc(pst_guess);
let offset_secs = if pacific_is_dst(y, m, d) {
7 * 3600
} else {
8 * 3600
};
utc_secs.saturating_sub(offset_secs)
}
/// Whether Pacific Time is observing daylight saving on the given
/// calendar date (year, month=1..12, day=1..31). US DST window:
/// 2nd Sunday of March through 1st Sunday of November. The transition
/// hour itself (02:00 local) is approximated to whole-day boundaries —
/// good enough for a daily-quota countdown.
fn pacific_is_dst(year: i64, month: u32, day: u32) -> bool {
if month < 3 || month > 11 {
return false;
}
if month > 3 && month < 11 {
return true;
}
if month == 3 {
let dst_start = nth_sunday_of_month(year, 3, 2);
day >= dst_start
} else {
// month == 11
let dst_end = nth_sunday_of_month(year, 11, 1);
day < dst_end
}
}
/// Day-of-month for the Nth Sunday (1-indexed) of (year, month). Uses
/// Sakamoto's method for the month's-1st day-of-week, then offsets to
/// the desired Sunday. Pure arithmetic, no calendar tables.
fn nth_sunday_of_month(year: i64, month: u32, nth: u32) -> u32 {
// Sakamoto's day-of-week. 0 = Sunday.
static T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
let y = if month < 3 { year - 1 } else { year };
let m = month as i64;
let dow_of_1st =
((y + y / 4 - y / 100 + y / 400 + T[(m - 1) as usize] + 1).rem_euclid(7)) as u32;
let first_sunday = if dow_of_1st == 0 { 1 } else { 8 - dow_of_1st };
first_sunday + (nth - 1) * 7
}
/// Convert a Unix timestamp (seconds since 1970-01-01 UTC) to a /// Convert a Unix timestamp (seconds since 1970-01-01 UTC) to a
/// (year, month, day) tuple, UTC. Standalone so we can stay /// (year, month, day) tuple, UTC. Standalone so we can stay
/// std-only — no chrono/time/jiff dependency pulled for one caller. /// std-only — no chrono/time/jiff dependency pulled for one caller.
@@ -2116,15 +2235,18 @@ pub struct StatsSnapshot {
pub cache_bytes: usize, pub cache_bytes: usize,
pub blacklisted_scripts: usize, pub blacklisted_scripts: usize,
pub total_scripts: usize, pub total_scripts: usize,
/// Relay calls attributed to the current UTC day. Resets at 00:00 UTC. /// Relay calls attributed to the current Pacific Time day. Resets
/// This is what-this-process-has-done today, not the Google-side bucket. /// at 00:00 PT (midnight Pacific) — matches Apps Script's actual
/// quota reset cadence (#230, #362). This is what-this-process-
/// has-done today, not the Google-side bucket.
pub today_calls: u64, pub today_calls: u64,
/// Response bytes from relay calls attributed to the current UTC day. /// Response bytes from relay calls attributed to the current PT day.
pub today_bytes: u64, pub today_bytes: u64,
/// "YYYY-MM-DD" of the day `today_calls` / `today_bytes` refer to. /// "YYYY-MM-DD" of the PT day `today_calls` / `today_bytes` refer
/// Useful for cross-referencing against Google's dashboard. /// to. Useful for cross-referencing against Google's dashboard,
/// which is also PT-aligned.
pub today_key: String, pub today_key: String,
/// Seconds until the next 00:00 UTC rollover. Convenient for the UI /// Seconds until the next 00:00 PT rollover. Convenient for the UI
/// to render "Resets in Xh Ym" without importing time libraries. /// to render "Resets in Xh Ym" without importing time libraries.
pub today_reset_secs: u64, pub today_reset_secs: u64,
} }
@@ -2336,12 +2458,47 @@ mod tests {
} }
#[test] #[test]
fn seconds_until_utc_midnight_is_bounded() { fn seconds_until_pacific_midnight_is_bounded() {
let n = seconds_until_utc_midnight(); let n = seconds_until_pacific_midnight();
// Must be in (0, 86400] for any valid system clock. // Must be in (0, 86400] for any valid system clock.
assert!(n > 0 && n <= 86_400); assert!(n > 0 && n <= 86_400);
} }
#[test]
fn nth_sunday_of_month_anchors() {
// Spot-check Sakamoto's day-of-week + offset arithmetic against
// a few known Sundays. Mistakes here would silently shift the
// DST transition by ±1 week.
// March 2026: 2nd Sunday is March 8 (Sun Mar 1, Sun Mar 8).
assert_eq!(nth_sunday_of_month(2026, 3, 2), 8);
// November 2026: 1st Sunday is November 1 (Sun Nov 1).
assert_eq!(nth_sunday_of_month(2026, 11, 1), 1);
// March 2024: 2nd Sunday is March 10 (Sun Mar 3, Sun Mar 10).
assert_eq!(nth_sunday_of_month(2024, 3, 2), 10);
// November 2024: 1st Sunday is November 3.
assert_eq!(nth_sunday_of_month(2024, 11, 1), 3);
// March 2027: 2nd Sunday is March 14.
assert_eq!(nth_sunday_of_month(2027, 3, 2), 14);
}
#[test]
fn pacific_dst_window_anchors() {
// Outside the DST window: PST.
assert!(!pacific_is_dst(2026, 1, 15));
assert!(!pacific_is_dst(2026, 12, 25));
assert!(!pacific_is_dst(2026, 2, 28));
assert!(!pacific_is_dst(2026, 11, 5)); // first Sun of Nov 2026 = Nov 1; Nov 5 is past
// Inside: PDT.
assert!(pacific_is_dst(2026, 6, 1));
assert!(pacific_is_dst(2026, 9, 30));
// Boundary: March 8, 2026 (DST start day) and after = PDT.
assert!(!pacific_is_dst(2026, 3, 7));
assert!(pacific_is_dst(2026, 3, 8));
// Boundary: Oct 31 = PDT, Nov 1 = first Sunday = PST flips on.
assert!(pacific_is_dst(2026, 10, 31));
assert!(!pacific_is_dst(2026, 11, 1));
}
#[test] #[test]
fn filter_forwarded_headers_strips_identity_revealing_headers() { fn filter_forwarded_headers_strips_identity_revealing_headers() {
// Issue #104: any proxy/extension that inserts these must not // Issue #104: any proxy/extension that inserts these must not
+29
View File
@@ -828,6 +828,35 @@ async fn fire_batch(
match result { match result {
Ok(Ok(batch_resp)) => { Ok(Ok(batch_resp)) => {
f.record_batch_success(&script_id); f.record_batch_success(&script_id);
// Wire the Full-mode usage counter that #230 / #362 flagged
// as stuck-at-zero. Each successful batch is one
// `UrlFetchApp.fetch()` call against the deploying Google
// account's daily quota — bytes-counted is the inbound JSON
// response which is the closest analogue to the apps_script
// path's `record_today(bytes_received)` (we don't have the
// exact response byte count post-deserialize, so we use a
// proxy: sum of per-session response payload bytes the
// batch carried back). Underestimates by JSON envelope
// overhead but is in the right order of magnitude.
let response_bytes: u64 = batch_resp
.r
.iter()
.map(|r| {
// `d` carries TCP payload (base64 string len ≈
// 4/3 of decoded bytes; close enough); `pkts`
// carries UDP datagrams (each base64); plus any
// error string. Sum gives a stable proxy for
// "how much did this batch move."
let d = r.d.as_ref().map(|s| s.len() as u64).unwrap_or(0);
let pkts = r
.pkts
.as_ref()
.map(|v| v.iter().map(|p| p.len() as u64).sum::<u64>())
.unwrap_or(0);
d + pkts
})
.sum();
f.record_today(response_bytes);
for (idx, reply) in data_replies { for (idx, reply) in data_replies {
if let Some(resp) = batch_resp.r.get(idx) { if let Some(resp) = batch_resp.r.get(idx) {
let _ = reply.send(Ok((resp.clone(), script_id.clone()))); let _ = reply.send(Ok((resp.clone(), script_id.clone())));
+68 -9
View File
@@ -535,6 +535,16 @@ struct AppState {
sessions: Arc<Mutex<HashMap<String, ManagedSession>>>, sessions: Arc<Mutex<HashMap<String, ManagedSession>>>,
udp_sessions: Arc<Mutex<HashMap<String, ManagedUdpSession>>>, udp_sessions: Arc<Mutex<HashMap<String, ManagedUdpSession>>>,
auth_key: String, auth_key: String,
/// Active probing defense: when false (default, production), bad
/// AUTH_KEY responses are a generic-looking 404 with no JSON-shaped
/// "unauthorized" body — same as a static nginx 404. Active scanners
/// that POST malformed payloads to `/tunnel` to discover proxy
/// endpoints categorize this as a non-tunnel host and move on.
/// Enable via `MHRV_DIAGNOSTIC=1` for setup/debugging — restores the
/// previous JSON `{"e":"unauthorized"}` body so it's clear *which*
/// of "wrong key", "wrong URL path", or "wrong tunnel-node" you've
/// hit. (Inspired by #365 Section 3.)
diagnostic_mode: bool,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -608,19 +618,41 @@ struct BatchResponse {
async fn handle_tunnel( async fn handle_tunnel(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<TunnelRequest>, Json(req): Json<TunnelRequest>,
) -> Json<TunnelResponse> { ) -> axum::response::Response {
if req.k != state.auth_key { if req.k != state.auth_key {
return Json(TunnelResponse::error("unauthorized")); return decoy_or_unauthorized(state.diagnostic_mode);
} }
match req.op.as_str() { let resp: TunnelResponse = match req.op.as_str() {
"connect" => Json(handle_connect(&state, req.host, req.port).await), "connect" => handle_connect(&state, req.host, req.port).await,
"connect_data" => { "connect_data" => {
Json(handle_connect_data_single(&state, req.host, req.port, req.data).await) handle_connect_data_single(&state, req.host, req.port, req.data).await
} }
"data" => Json(handle_data_single(&state, req.sid, req.data).await), "data" => handle_data_single(&state, req.sid, req.data).await,
"close" => Json(handle_close(&state, req.sid).await), "close" => handle_close(&state, req.sid).await,
other => Json(TunnelResponse::unsupported_op(other)), other => TunnelResponse::unsupported_op(other),
};
Json(resp).into_response()
} }
/// Active-probing defense for the bad-auth path. Production default is
/// a 404 with a generic "Not Found" HTML body that mimics a vanilla
/// nginx/apache static error page — active scanners categorize this
/// as a regular web server with nothing interesting and move on.
/// `MHRV_DIAGNOSTIC=1` restores the previous JSON `{"e":"unauthorized"}`
/// body so misconfigured clients get a clear error during setup.
fn decoy_or_unauthorized(diagnostic_mode: bool) -> axum::response::Response {
if diagnostic_mode {
return Json(TunnelResponse::error("unauthorized")).into_response();
}
let body = "<html>\r\n<head><title>404 Not Found</title></head>\r\n\
<body>\r\n<center><h1>404 Not Found</h1></center>\r\n\
<hr><center>nginx</center>\r\n</body>\r\n</html>\r\n";
(
StatusCode::NOT_FOUND,
[(header::CONTENT_TYPE, "text/html")],
body,
)
.into_response()
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -657,11 +689,21 @@ async fn handle_batch(
}; };
if req.k != state.auth_key { if req.k != state.auth_key {
if state.diagnostic_mode {
let resp = serde_json::to_vec(&BatchResponse { let resp = serde_json::to_vec(&BatchResponse {
r: vec![TunnelResponse::error("unauthorized")], r: vec![TunnelResponse::error("unauthorized")],
}).unwrap_or_default(); }).unwrap_or_default();
return (StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], resp); return (StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], resp);
} }
// Production: same nginx-404 decoy as the single-op path. See
// `decoy_or_unauthorized` for rationale.
let body = "<html>\r\n<head><title>404 Not Found</title></head>\r\n\
<body>\r\n<center><h1>404 Not Found</h1></center>\r\n\
<hr><center>nginx</center>\r\n</body>\r\n</html>\r\n"
.as_bytes()
.to_vec();
return (StatusCode::NOT_FOUND, [(header::CONTENT_TYPE, "text/html")], body);
}
// Process all ops in two phases. // Process all ops in two phases.
// //
@@ -1311,7 +1353,20 @@ async fn main() {
Arc::new(Mutex::new(HashMap::new())); Arc::new(Mutex::new(HashMap::new()));
tokio::spawn(cleanup_task(sessions.clone(), udp_sessions.clone())); tokio::spawn(cleanup_task(sessions.clone(), udp_sessions.clone()));
let state = AppState { sessions, udp_sessions, auth_key }; // MHRV_DIAGNOSTIC=1 in env restores verbose JSON error responses on
// bad auth (instead of the nginx-404 decoy). Use during setup so
// misconfigured clients see "unauthorized"; flip back off in prod.
let diagnostic_mode = std::env::var("MHRV_DIAGNOSTIC")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
if diagnostic_mode {
tracing::warn!(
"MHRV_DIAGNOSTIC=1 — bad-auth responses are verbose JSON \
errors instead of the production nginx-404 decoy. Disable \
before exposing this tunnel-node to the public internet."
);
}
let state = AppState { sessions, udp_sessions, auth_key, diagnostic_mode };
let app = Router::new() let app = Router::new()
.route("/tunnel", post(handle_tunnel)) .route("/tunnel", post(handle_tunnel))
@@ -1346,6 +1401,10 @@ mod tests {
sessions: Arc::new(Mutex::new(HashMap::new())), sessions: Arc::new(Mutex::new(HashMap::new())),
udp_sessions: Arc::new(Mutex::new(HashMap::new())), udp_sessions: Arc::new(Mutex::new(HashMap::new())),
auth_key: "test-key".into(), auth_key: "test-key".into(),
// Tests assert against the JSON `unauthorized` body shape
// (see e.g. `bad_auth_returns_unauthorized`), so they need
// diagnostic_mode enabled. Production default is false.
diagnostic_mode: true,
} }
} }