mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-17 21:24:48 +03:00
fix: v1.9.12 — gate parallel_relay fan-out to idempotent methods only (#743)
Reported in #743: with `parallel_relay > 1`, a single POST (e.g. submitting a comment) reached the destination as N concurrent requests, so the comment got posted twice. Root cause is unfixable from the Rust side: `select_ok` cancels only OUR futures, but Apps Script has no way to learn the cancellation, so every fan-out call still runs to completion and each `UrlFetchApp.fetch()` still hits the destination. Fan-out now only triggers for idempotent methods (GET / HEAD / OPTIONS); POST / PUT / PATCH / DELETE always go sequential. Same pattern as `SAFE_REPLAY_METHODS` in Code.gs `_doBatch` fallback — safe methods are idempotent so re-firing is at worst wasteful, unsafe methods can have side effects so re-firing is incorrect. New regression test locks down `is_method_safe_for_fanout` predicate. Tests: 180 lib + 35 tunnel-node green. 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.9.11"
|
version = "1.9.12"
|
||||||
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.9.11"
|
version = "1.9.12"
|
||||||
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"
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<!-- see docs/changelog/v1.1.0.md for the file format: Persian, then `---`, then English. -->
|
||||||
|
• Fix `parallel_relay` causing duplicate POSTs ([#743](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/743)): وقتی `parallel_relay > 1` تنظیم بود، تکPOST کاربر (مثل ارسال کامنت در GitHub) بهعنوان دو/چند درخواست همزمان به سایت مقصد میرسید + کامنت دو بار ثبت میشد. علت: `select_ok` فقط futureهای Rust سمت ما را cancel میکنه، ولی Apps Script سرورسایده هیچ خبری از cancel ندارد — هر فراخوانی fan-out روی Apps Script کامل میشه و `UrlFetchApp.fetch()` هر کدام به مقصد میرسه. حالا fan-out فقط برای متدهای **idempotent** (GET / HEAD / OPTIONS) اجرا میشه؛ POST / PUT / PATCH / DELETE همیشه sequential میرن — کاربر روی browse کاهش tail latency رو نگه میداره و روی form submit از duplicate side-effect ایمن میمونه. الگوی همان `SAFE_REPLAY_METHODS` که در `Code.gs` `_doBatch` fallback داریم. تست regression جدید locks down predicate. **۱۸۰ lib + ۳۵ tunnel-node test** همه pass.
|
||||||
|
---
|
||||||
|
• Fix `parallel_relay` causing duplicate POSTs ([#743](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/743)): with `parallel_relay > 1` set, a single user POST (e.g. submitting a comment on GitHub) was reaching the destination as two or more concurrent requests, so the comment got posted twice. Root cause: `select_ok` only cancels the loser futures on our side, but Apps Script has no way to learn about that cancellation server-side, so every fan-out call still runs to completion and each `UrlFetchApp.fetch()` to the destination still fires. Fan-out now only triggers for **idempotent** methods (GET / HEAD / OPTIONS); POST / PUT / PATCH / DELETE always go sequential — users keep the p95 tail-latency win on browsing without losing correctness on form submits. Same pattern as the `SAFE_REPLAY_METHODS` guard in `Code.gs` `_doBatch` fallback. New regression test locks down the predicate. **180 lib + 35 tunnel-node tests passing.**
|
||||||
+66
-1
@@ -1169,8 +1169,29 @@ impl DomainFronter {
|
|||||||
// Fan-out path: fire N instances in parallel, return first Ok, cancel
|
// Fan-out path: fire N instances in parallel, return first Ok, cancel
|
||||||
// the rest. Clamps to number of available script IDs so the single-ID
|
// the rest. Clamps to number of available script IDs so the single-ID
|
||||||
// case is a no-op even if parallel_relay>1 was configured.
|
// case is a no-op even if parallel_relay>1 was configured.
|
||||||
|
//
|
||||||
|
// `select_ok` cancels the loser futures, but those futures only own
|
||||||
|
// the OUR-side I/O (TLS write, response read) — the Apps Script
|
||||||
|
// server has no idea the racing Rust task is gone, so every fan-out
|
||||||
|
// call still completes server-side and Apps Script's
|
||||||
|
// `UrlFetchApp.fetch()` to the destination still fires. For
|
||||||
|
// **non-idempotent** methods (POST / PUT / PATCH / DELETE) this
|
||||||
|
// surfaces as duplicate writes at the destination — a comment
|
||||||
|
// posted twice, a vote double-counted, a payment double-charged.
|
||||||
|
//
|
||||||
|
// Reported in #743: parallel_relay=2 + a POST to GitHub created
|
||||||
|
// two issue comments per submission. Same root cause as the
|
||||||
|
// SAFE_REPLAY_METHODS guard in Code.gs's `_doBatch` fallback —
|
||||||
|
// safe methods are idempotent, so re-firing is at worst wasteful;
|
||||||
|
// unsafe methods can have side effects, so re-firing is incorrect.
|
||||||
|
//
|
||||||
|
// Drop to sequential for non-idempotent methods regardless of
|
||||||
|
// `parallel_relay` setting. Users keep p95 wins on browsing /
|
||||||
|
// GET-heavy traffic (the common case) and don't lose correctness
|
||||||
|
// on form submits.
|
||||||
|
let method_safe_for_fanout = is_method_safe_for_fanout(method);
|
||||||
let fan = self.parallel_relay.min(self.script_ids.len()).max(1);
|
let fan = self.parallel_relay.min(self.script_ids.len()).max(1);
|
||||||
if fan >= 2 {
|
if fan >= 2 && method_safe_for_fanout {
|
||||||
return self.do_relay_parallel(method, url, headers, body, fan).await;
|
return self.do_relay_parallel(method, url, headers, body, fan).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2697,6 +2718,18 @@ fn parse_status_line(line: &str) -> Result<u16, FronterError> {
|
|||||||
code.parse::<u16>().map_err(|_| FronterError::BadResponse(format!("bad status code: {}", code)))
|
code.parse::<u16>().map_err(|_| FronterError::BadResponse(format!("bad status code: {}", code)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the HTTP method is safe to fan-out across multiple
|
||||||
|
/// Apps Script deployments (i.e. idempotent per RFC 9110 §9.2.2). Used
|
||||||
|
/// by `do_relay_with_retry` to gate the `parallel_relay` fan-out so that
|
||||||
|
/// non-idempotent operations (POST / PUT / PATCH / DELETE) don't double-
|
||||||
|
/// fire at the destination — Apps Script `UrlFetchApp.fetch()` can't be
|
||||||
|
/// cancelled mid-request from our side, so every parallel attempt
|
||||||
|
/// completes server-side even when our `select_ok` already returned a
|
||||||
|
/// winner. See #743 for the user-visible bug (duplicate POSTs).
|
||||||
|
fn is_method_safe_for_fanout(method: &str) -> bool {
|
||||||
|
matches!(method.to_ascii_uppercase().as_str(), "GET" | "HEAD" | "OPTIONS")
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse the JSON envelope from Apps Script and build a raw HTTP response.
|
/// Parse the JSON envelope from Apps Script and build a raw HTTP response.
|
||||||
fn parse_relay_json(body: &[u8]) -> Result<Vec<u8>, FronterError> {
|
fn parse_relay_json(body: &[u8]) -> Result<Vec<u8>, FronterError> {
|
||||||
let text = std::str::from_utf8(body)
|
let text = std::str::from_utf8(body)
|
||||||
@@ -3591,6 +3624,38 @@ hello";
|
|||||||
assert_eq!(mask_script_id("AKfycbx1234567890abcdef"), "AKfy...cdef");
|
assert_eq!(mask_script_id("AKfycbx1234567890abcdef"), "AKfy...cdef");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parallel_relay_only_safe_for_idempotent_methods() {
|
||||||
|
// Locks down #743: parallel_relay must never fan-out non-idempotent
|
||||||
|
// methods because Apps Script can't be cancelled mid-request, so
|
||||||
|
// every concurrent attempt completes server-side and side-effects
|
||||||
|
// duplicate at the destination (comment posted twice, etc.).
|
||||||
|
for safe in ["GET", "HEAD", "OPTIONS", "get", "head", "options"] {
|
||||||
|
assert!(
|
||||||
|
is_method_safe_for_fanout(safe),
|
||||||
|
"{} should be safe for fan-out (idempotent per RFC 9110)",
|
||||||
|
safe,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for unsafe_m in ["POST", "PUT", "PATCH", "DELETE", "post", "put", "patch", "delete"] {
|
||||||
|
assert!(
|
||||||
|
!is_method_safe_for_fanout(unsafe_m),
|
||||||
|
"{} must NOT be safe for fan-out (non-idempotent — duplicate side-effects)",
|
||||||
|
unsafe_m,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Unknown methods (CONNECT, TRACE, custom verbs) default to NOT
|
||||||
|
// safe — conservative call, matches the upstream `UrlFetchApp`
|
||||||
|
// lookup behavior.
|
||||||
|
for unknown in ["CONNECT", "TRACE", "PROPFIND", ""] {
|
||||||
|
assert!(
|
||||||
|
!is_method_safe_for_fanout(unknown),
|
||||||
|
"{} must default to NOT safe for fan-out when unrecognised",
|
||||||
|
unknown,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_relay_array_set_cookie() {
|
fn parse_relay_array_set_cookie() {
|
||||||
let body = r#"{"s":200,"h":{"Set-Cookie":["a=1","b=2"]},"b":""}"#;
|
let body = r#"{"s":200,"h":{"Set-Cookie":["a=1","b=2"]},"b":""}"#;
|
||||||
|
|||||||
Reference in New Issue
Block a user