mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-18 06:24:35 +03:00
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:
Generated
+1
-1
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 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.
|
||||||
+1
-1
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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())));
|
||||||
|
|||||||
+72
-13
@@ -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,10 +689,20 @@ async fn handle_batch(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if req.k != state.auth_key {
|
if req.k != state.auth_key {
|
||||||
let resp = serde_json::to_vec(&BatchResponse {
|
if state.diagnostic_mode {
|
||||||
r: vec![TunnelResponse::error("unauthorized")],
|
let resp = serde_json::to_vec(&BatchResponse {
|
||||||
}).unwrap_or_default();
|
r: vec![TunnelResponse::error("unauthorized")],
|
||||||
return (StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], resp);
|
}).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 = "<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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user