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 = + 'Web App' + + '

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 = + 'Web App' + + '

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) { + 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() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .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) } -/// 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 { +/// Seconds until the next 00:00 Pacific Time. Used by the UI to render +/// a "resets in Xh Ym" countdown matching Apps Script's actual quota +/// reset cadence (#230, #362). Conservative: if the system clock is +/// broken we return 0 instead of a huge negative-looking number. +fn seconds_until_pacific_midnight() -> u64 { let secs = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); + let pt_secs = unix_to_pt_seconds(secs); let day = 86_400u64; - let rem = secs % day; + let rem = pt_secs % day; if rem == 0 { day } 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 /// (year, month, day) tuple, UTC. Standalone so we can stay /// std-only — no chrono/time/jiff dependency pulled for one caller. @@ -2116,15 +2235,18 @@ 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. + /// Relay calls attributed to the current Pacific Time day. Resets + /// 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, - /// 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, - /// "YYYY-MM-DD" of the day `today_calls` / `today_bytes` refer to. - /// Useful for cross-referencing against Google's dashboard. + /// "YYYY-MM-DD" of the PT day `today_calls` / `today_bytes` refer + /// to. Useful for cross-referencing against Google's dashboard, + /// which is also PT-aligned. 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. pub today_reset_secs: u64, } @@ -2336,12 +2458,47 @@ mod tests { } #[test] - fn seconds_until_utc_midnight_is_bounded() { - let n = seconds_until_utc_midnight(); + fn seconds_until_pacific_midnight_is_bounded() { + let n = seconds_until_pacific_midnight(); // Must be in (0, 86400] for any valid system clock. 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] fn filter_forwarded_headers_strips_identity_revealing_headers() { // Issue #104: any proxy/extension that inserts these must not diff --git a/src/tunnel_client.rs b/src/tunnel_client.rs index 57f1c78..7d56cf9 100644 --- a/src/tunnel_client.rs +++ b/src/tunnel_client.rs @@ -828,6 +828,35 @@ async fn fire_batch( match result { Ok(Ok(batch_resp)) => { 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::()) + .unwrap_or(0); + d + pkts + }) + .sum(); + f.record_today(response_bytes); for (idx, reply) in data_replies { if let Some(resp) = batch_resp.r.get(idx) { let _ = reply.send(Ok((resp.clone(), script_id.clone()))); diff --git a/tunnel-node/src/main.rs b/tunnel-node/src/main.rs index 2200624..9ad9d7f 100644 --- a/tunnel-node/src/main.rs +++ b/tunnel-node/src/main.rs @@ -535,6 +535,16 @@ struct AppState { sessions: Arc>>, udp_sessions: Arc>>, 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( State(state): State, Json(req): Json, -) -> Json { +) -> axum::response::Response { if req.k != state.auth_key { - return Json(TunnelResponse::error("unauthorized")); + return decoy_or_unauthorized(state.diagnostic_mode); } - match req.op.as_str() { - "connect" => Json(handle_connect(&state, req.host, req.port).await), + let resp: TunnelResponse = match req.op.as_str() { + "connect" => handle_connect(&state, req.host, req.port).await, "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), - "close" => Json(handle_close(&state, req.sid).await), - other => Json(TunnelResponse::unsupported_op(other)), + "data" => handle_data_single(&state, req.sid, req.data).await, + "close" => handle_close(&state, req.sid).await, + 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 = "\r\n404 Not Found\r\n\ + \r\n

404 Not Found

\r\n\ +
nginx
\r\n\r\n\r\n"; + ( + StatusCode::NOT_FOUND, + [(header::CONTENT_TYPE, "text/html")], + body, + ) + .into_response() } // --------------------------------------------------------------------------- @@ -657,10 +689,20 @@ async fn handle_batch( }; if req.k != state.auth_key { - let resp = serde_json::to_vec(&BatchResponse { - r: vec![TunnelResponse::error("unauthorized")], - }).unwrap_or_default(); - return (StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], resp); + if state.diagnostic_mode { + let resp = serde_json::to_vec(&BatchResponse { + r: vec![TunnelResponse::error("unauthorized")], + }).unwrap_or_default(); + 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 = "\r\n404 Not Found\r\n\ + \r\n

404 Not Found

\r\n\ +
nginx
\r\n\r\n\r\n" + .as_bytes() + .to_vec(); + return (StatusCode::NOT_FOUND, [(header::CONTENT_TYPE, "text/html")], body); } // Process all ops in two phases. @@ -1311,7 +1353,20 @@ async fn main() { Arc::new(Mutex::new(HashMap::new())); 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() .route("/tunnel", post(handle_tunnel)) @@ -1346,6 +1401,10 @@ mod tests { sessions: Arc::new(Mutex::new(HashMap::new())), udp_sessions: Arc::new(Mutex::new(HashMap::new())), 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, } }