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
+72 -13
View File
@@ -535,6 +535,16 @@ struct AppState {
sessions: Arc<Mutex<HashMap<String, ManagedSession>>>,
udp_sessions: Arc<Mutex<HashMap<String, ManagedUdpSession>>>,
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<AppState>,
Json(req): Json<TunnelRequest>,
) -> Json<TunnelResponse> {
) -> 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 = "<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 {
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 = "<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.
@@ -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,
}
}