mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-17 21:24:48 +03:00
feat: HTTP/2 multiplexing on relay leg with idempotency-safe h1 fallback (#799)
This commit is contained in:
@@ -96,6 +96,14 @@ data class MhrvConfig(
|
|||||||
val verifySsl: Boolean = true,
|
val verifySsl: Boolean = true,
|
||||||
val logLevel: String = "info",
|
val logLevel: String = "info",
|
||||||
val parallelRelay: Int = 1,
|
val parallelRelay: Int = 1,
|
||||||
|
/**
|
||||||
|
* Disable the HTTP/2 multiplexing on the Apps Script relay leg.
|
||||||
|
* Default false (h2 active); flip to true to force the legacy
|
||||||
|
* HTTP/1.1 keep-alive pool. Round-tripped from config.json so a
|
||||||
|
* hand-edited kill switch survives a save round trip from the
|
||||||
|
* Android UI. See `src/config.rs` `force_http1`.
|
||||||
|
*/
|
||||||
|
val forceHttp1: Boolean = false,
|
||||||
val coalesceStepMs: Int = 10,
|
val coalesceStepMs: Int = 10,
|
||||||
val coalesceMaxMs: Int = 1000,
|
val coalesceMaxMs: Int = 1000,
|
||||||
/** Block QUIC (UDP/443). QUIC over TCP tunnel causes meltdown. */
|
/** Block QUIC (UDP/443). QUIC over TCP tunnel causes meltdown. */
|
||||||
@@ -219,6 +227,7 @@ data class MhrvConfig(
|
|||||||
put("verify_ssl", verifySsl)
|
put("verify_ssl", verifySsl)
|
||||||
put("log_level", logLevel)
|
put("log_level", logLevel)
|
||||||
put("parallel_relay", parallelRelay)
|
put("parallel_relay", parallelRelay)
|
||||||
|
if (forceHttp1) put("force_http1", true)
|
||||||
if (coalesceStepMs != 10) put("coalesce_step_ms", coalesceStepMs)
|
if (coalesceStepMs != 10) put("coalesce_step_ms", coalesceStepMs)
|
||||||
if (coalesceMaxMs != 1000) put("coalesce_max_ms", coalesceMaxMs)
|
if (coalesceMaxMs != 1000) put("coalesce_max_ms", coalesceMaxMs)
|
||||||
put("block_quic", blockQuic)
|
put("block_quic", blockQuic)
|
||||||
@@ -331,6 +340,7 @@ object ConfigStore {
|
|||||||
if (cfg.verifySsl != defaults.verifySsl) obj.put("verify_ssl", cfg.verifySsl)
|
if (cfg.verifySsl != defaults.verifySsl) obj.put("verify_ssl", cfg.verifySsl)
|
||||||
if (cfg.logLevel != defaults.logLevel) obj.put("log_level", cfg.logLevel)
|
if (cfg.logLevel != defaults.logLevel) obj.put("log_level", cfg.logLevel)
|
||||||
if (cfg.parallelRelay != defaults.parallelRelay) obj.put("parallel_relay", cfg.parallelRelay)
|
if (cfg.parallelRelay != defaults.parallelRelay) obj.put("parallel_relay", cfg.parallelRelay)
|
||||||
|
if (cfg.forceHttp1 != defaults.forceHttp1) obj.put("force_http1", cfg.forceHttp1)
|
||||||
if (cfg.coalesceStepMs != defaults.coalesceStepMs) obj.put("coalesce_step_ms", cfg.coalesceStepMs)
|
if (cfg.coalesceStepMs != defaults.coalesceStepMs) obj.put("coalesce_step_ms", cfg.coalesceStepMs)
|
||||||
if (cfg.coalesceMaxMs != defaults.coalesceMaxMs) obj.put("coalesce_max_ms", cfg.coalesceMaxMs)
|
if (cfg.coalesceMaxMs != defaults.coalesceMaxMs) obj.put("coalesce_max_ms", cfg.coalesceMaxMs)
|
||||||
if (cfg.blockQuic != defaults.blockQuic) obj.put("block_quic", cfg.blockQuic)
|
if (cfg.blockQuic != defaults.blockQuic) obj.put("block_quic", cfg.blockQuic)
|
||||||
@@ -435,6 +445,7 @@ object ConfigStore {
|
|||||||
verifySsl = obj.optBoolean("verify_ssl", true),
|
verifySsl = obj.optBoolean("verify_ssl", true),
|
||||||
logLevel = obj.optString("log_level", "info"),
|
logLevel = obj.optString("log_level", "info"),
|
||||||
parallelRelay = obj.optInt("parallel_relay", 1),
|
parallelRelay = obj.optInt("parallel_relay", 1),
|
||||||
|
forceHttp1 = obj.optBoolean("force_http1", false),
|
||||||
coalesceStepMs = obj.optInt("coalesce_step_ms", 10),
|
coalesceStepMs = obj.optInt("coalesce_step_ms", 10),
|
||||||
coalesceMaxMs = obj.optInt("coalesce_max_ms", 1000),
|
coalesceMaxMs = obj.optInt("coalesce_max_ms", 1000),
|
||||||
blockQuic = obj.optBoolean("block_quic", true),
|
blockQuic = obj.optBoolean("block_quic", true),
|
||||||
|
|||||||
@@ -89,8 +89,22 @@ object Native {
|
|||||||
* relay_calls, relay_failures, coalesced, bytes_relayed,
|
* relay_calls, relay_failures, coalesced, bytes_relayed,
|
||||||
* cache_hits, cache_misses, cache_bytes,
|
* cache_hits, cache_misses, cache_bytes,
|
||||||
* blacklisted_scripts, total_scripts,
|
* blacklisted_scripts, total_scripts,
|
||||||
* today_calls, today_bytes, today_key (string "YYYY-MM-DD"),
|
* today_calls, today_bytes, today_key (string "YYYY-MM-DD" in
|
||||||
* today_reset_secs (seconds until 00:00 UTC rollover)
|
* Pacific Time — matches Apps Script's actual quota reset),
|
||||||
|
* today_reset_secs (seconds until the next 00:00 Pacific Time
|
||||||
|
* rollover; ~7-8 h offset from UTC depending on DST),
|
||||||
|
* h2_calls (calls served by the HTTP/2 multiplexed transport,
|
||||||
|
* across all entry points — Apps-Script direct, exit-node
|
||||||
|
* outer call, full-mode tunnel single op, full-mode tunnel
|
||||||
|
* batch. NOT comparable to relay_calls, which only sees the
|
||||||
|
* Apps-Script-direct path),
|
||||||
|
* h2_fallbacks (calls that attempted h2 but had to fall back
|
||||||
|
* to h1 — handshake failure, open backoff, sticky ALPN
|
||||||
|
* refusal, post-send error retried on h1; same all-entry-
|
||||||
|
* points scope as h2_calls. Compute h2 health as
|
||||||
|
* h2_calls / (h2_calls + h2_fallbacks)),
|
||||||
|
* h2_disabled (boolean: true when h2 fast path is permanently
|
||||||
|
* off — config force_http1 set, or peer refused h2 via ALPN)
|
||||||
*
|
*
|
||||||
* Cheap — just reads atomics. Safe to poll on a second-scale timer.
|
* Cheap — just reads atomics. Safe to poll on a second-scale timer.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1509,11 +1509,14 @@ private fun CollapsibleSection(
|
|||||||
/**
|
/**
|
||||||
* "Usage today (estimated)" card. Polls `Native.statsJson(handle)` every
|
* "Usage today (estimated)" card. Polls `Native.statsJson(handle)` every
|
||||||
* second while the proxy is up and renders today's relay calls vs. the
|
* second while the proxy is up and renders today's relay calls vs. the
|
||||||
* Apps Script free-tier quota (20,000/day), today's bytes, UTC day key,
|
* Apps Script free-tier quota (20,000/day), today's bytes, the Pacific
|
||||||
* and a countdown to the 00:00 UTC reset. Also shows a "View quota on
|
* Time day key, and a countdown to the 00:00 PT reset. Pacific Time
|
||||||
* Google" button that opens Google's Apps Script dashboard — the
|
* matches Apps Script's actual quota reset cadence — UTC would have
|
||||||
* authoritative number, since the client-side estimate only sees what
|
* the counter resetting ~7-8 h before the user actually got a fresh
|
||||||
* this device relayed.
|
* quota allotment from Google. Also shows a "View quota on Google"
|
||||||
|
* button that opens Google's Apps Script dashboard — the authoritative
|
||||||
|
* number, since the client-side estimate only sees what this device
|
||||||
|
* relayed.
|
||||||
*
|
*
|
||||||
* Hidden when the handle is 0 (proxy not running) or the JSON comes back
|
* Hidden when the handle is 0 (proxy not running) or the JSON comes back
|
||||||
* empty (direct / full-only configs don't run a DomainFronter and so
|
* empty (direct / full-only configs don't run a DomainFronter and so
|
||||||
@@ -1585,7 +1588,7 @@ private fun UsageTodayCard() {
|
|||||||
value = fmtBytes(todayBytes),
|
value = fmtBytes(todayBytes),
|
||||||
)
|
)
|
||||||
UsageRow(
|
UsageRow(
|
||||||
label = stringResource(R.string.label_utc_day),
|
label = stringResource(R.string.label_pt_day),
|
||||||
value = todayKey,
|
value = todayKey,
|
||||||
)
|
)
|
||||||
UsageRow(
|
UsageRow(
|
||||||
|
|||||||
@@ -88,7 +88,7 @@
|
|||||||
<string name="sec_usage_today">مصرف امروز (تخمینی)</string>
|
<string name="sec_usage_today">مصرف امروز (تخمینی)</string>
|
||||||
<string name="label_calls_today">درخواستهای امروز</string>
|
<string name="label_calls_today">درخواستهای امروز</string>
|
||||||
<string name="label_bytes_today">بایت امروز</string>
|
<string name="label_bytes_today">بایت امروز</string>
|
||||||
<string name="label_utc_day">روز (UTC)</string>
|
<string name="label_pt_day">روز (PT)</string>
|
||||||
<string name="label_resets_in">ریست تا</string>
|
<string name="label_resets_in">ریست تا</string>
|
||||||
<string name="usage_calls_of_quota">%1$d / %2$d (%3$.1f%%)</string>
|
<string name="usage_calls_of_quota">%1$d / %2$d (%3$.1f%%)</string>
|
||||||
<string name="usage_resets_hm">%1$d ساعت و %2$d دقیقه</string>
|
<string name="usage_resets_hm">%1$d ساعت و %2$d دقیقه</string>
|
||||||
|
|||||||
@@ -103,7 +103,10 @@
|
|||||||
<string name="sec_usage_today">Usage today (estimated)</string>
|
<string name="sec_usage_today">Usage today (estimated)</string>
|
||||||
<string name="label_calls_today">calls today</string>
|
<string name="label_calls_today">calls today</string>
|
||||||
<string name="label_bytes_today">bytes today</string>
|
<string name="label_bytes_today">bytes today</string>
|
||||||
<string name="label_utc_day">UTC day</string>
|
<!-- Pacific Time day key — Apps Script's UrlFetchApp quota
|
||||||
|
resets at midnight Pacific, not midnight UTC, so the day
|
||||||
|
label and the reset countdown both use PT. -->
|
||||||
|
<string name="label_pt_day">PT day</string>
|
||||||
<string name="label_resets_in">resets in</string>
|
<string name="label_resets_in">resets in</string>
|
||||||
<string name="usage_calls_of_quota">%1$d / %2$d (%3$.1f%%)</string>
|
<string name="usage_calls_of_quota">%1$d / %2$d (%3$.1f%%)</string>
|
||||||
<string name="usage_resets_hm">%1$dh %2$dm</string>
|
<string name="usage_resets_hm">%1$dh %2$dm</string>
|
||||||
|
|||||||
@@ -260,6 +260,10 @@ struct FormState {
|
|||||||
/// users edit `disable_padding` directly when needed (Issue #391).
|
/// users edit `disable_padding` directly when needed (Issue #391).
|
||||||
/// Default false (padding active).
|
/// Default false (padding active).
|
||||||
disable_padding: bool,
|
disable_padding: bool,
|
||||||
|
/// Round-tripped from config.json. Not exposed as a UI control —
|
||||||
|
/// users edit `force_http1` directly when needed. Default false
|
||||||
|
/// (HTTP/2 multiplexing on the relay leg active).
|
||||||
|
force_http1: bool,
|
||||||
/// Round-tripped from config.json. Not exposed in the UI form yet —
|
/// Round-tripped from config.json. Not exposed in the UI form yet —
|
||||||
/// the bypass-DoH default is the right answer for almost everyone
|
/// the bypass-DoH default is the right answer for almost everyone
|
||||||
/// (DoH already encrypts, the tunnel was just adding latency), so
|
/// (DoH already encrypts, the tunnel was just adding latency), so
|
||||||
@@ -384,6 +388,7 @@ fn load_form() -> (FormState, Option<String>) {
|
|||||||
passthrough_hosts: c.passthrough_hosts.clone(),
|
passthrough_hosts: c.passthrough_hosts.clone(),
|
||||||
block_quic: c.block_quic,
|
block_quic: c.block_quic,
|
||||||
disable_padding: c.disable_padding,
|
disable_padding: c.disable_padding,
|
||||||
|
force_http1: c.force_http1,
|
||||||
tunnel_doh: c.tunnel_doh,
|
tunnel_doh: c.tunnel_doh,
|
||||||
bypass_doh_hosts: c.bypass_doh_hosts.clone(),
|
bypass_doh_hosts: c.bypass_doh_hosts.clone(),
|
||||||
block_doh: c.block_doh,
|
block_doh: c.block_doh,
|
||||||
@@ -422,6 +427,7 @@ fn load_form() -> (FormState, Option<String>) {
|
|||||||
passthrough_hosts: Vec::new(),
|
passthrough_hosts: Vec::new(),
|
||||||
block_quic: true,
|
block_quic: true,
|
||||||
disable_padding: false,
|
disable_padding: false,
|
||||||
|
force_http1: false,
|
||||||
tunnel_doh: true,
|
tunnel_doh: true,
|
||||||
bypass_doh_hosts: Vec::new(),
|
bypass_doh_hosts: Vec::new(),
|
||||||
block_doh: true,
|
block_doh: true,
|
||||||
@@ -584,6 +590,9 @@ impl FormState {
|
|||||||
// Issue #391: disable_padding is config-only for now.
|
// Issue #391: disable_padding is config-only for now.
|
||||||
// Round-trip preserves the user's choice.
|
// Round-trip preserves the user's choice.
|
||||||
disable_padding: self.disable_padding,
|
disable_padding: self.disable_padding,
|
||||||
|
// HTTP/2 multiplexing kill switch. Config-only for now;
|
||||||
|
// round-trip preserves the user's choice across Save.
|
||||||
|
force_http1: self.force_http1,
|
||||||
// DoH bypass is enabled-by-default with `tunnel_doh = false`.
|
// DoH bypass is enabled-by-default with `tunnel_doh = false`.
|
||||||
// Round-trip the user's choice (and any extra hostnames they
|
// Round-trip the user's choice (and any extra hostnames they
|
||||||
// added) so save doesn't drop them.
|
// added) so save doesn't drop them.
|
||||||
@@ -693,6 +702,11 @@ struct ConfigWire<'a> {
|
|||||||
auto_blacklist_cooldown_secs: u64,
|
auto_blacklist_cooldown_secs: u64,
|
||||||
#[serde(skip_serializing_if = "is_default_timeout_secs")]
|
#[serde(skip_serializing_if = "is_default_timeout_secs")]
|
||||||
request_timeout_secs: u64,
|
request_timeout_secs: u64,
|
||||||
|
/// HTTP/2 multiplexing kill switch. Default false (h2 active); only
|
||||||
|
/// emitted on save when the user has explicitly disabled h2, so
|
||||||
|
/// unchanged configs stay clean.
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
|
force_http1: bool,
|
||||||
/// Exit-node config (CF-anti-bot bypass for chatgpt.com / claude.ai /
|
/// Exit-node config (CF-anti-bot bypass for chatgpt.com / claude.ai /
|
||||||
/// grok.com / x.com via exit-node second-hop relay). Skip when fully
|
/// grok.com / x.com via exit-node second-hop relay). Skip when fully
|
||||||
/// default (disabled with no URL/PSK/hosts) so configs without
|
/// default (disabled with no URL/PSK/hosts) so configs without
|
||||||
@@ -772,6 +786,7 @@ impl<'a> From<&'a Config> for ConfigWire<'a> {
|
|||||||
auto_blacklist_window_secs: c.auto_blacklist_window_secs,
|
auto_blacklist_window_secs: c.auto_blacklist_window_secs,
|
||||||
auto_blacklist_cooldown_secs: c.auto_blacklist_cooldown_secs,
|
auto_blacklist_cooldown_secs: c.auto_blacklist_cooldown_secs,
|
||||||
request_timeout_secs: c.request_timeout_secs,
|
request_timeout_secs: c.request_timeout_secs,
|
||||||
|
force_http1: c.force_http1,
|
||||||
exit_node: &c.exit_node,
|
exit_node: &c.exit_node,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,6 +220,19 @@ pub struct Config {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub disable_padding: bool,
|
pub disable_padding: bool,
|
||||||
|
|
||||||
|
/// Disable HTTP/2 multiplexing on the Apps Script relay leg.
|
||||||
|
/// Default `false` (= h2 enabled): the TLS handshake to the Google
|
||||||
|
/// edge advertises ALPN `["h2", "http/1.1"]`; if the server picks
|
||||||
|
/// h2 we route all relay traffic over a single multiplexed
|
||||||
|
/// connection (~100 concurrent streams) instead of the legacy
|
||||||
|
/// per-request TLS pool of 8-80 sockets. Kills head-of-line
|
||||||
|
/// blocking on slow Apps Script responses (one stalled call no
|
||||||
|
/// longer pins a whole socket). Set to `true` to force the
|
||||||
|
/// pre-v1.9.x HTTP/1.1 path — useful as a kill switch if a specific
|
||||||
|
/// deployment, fronting domain, or middlebox refuses h2.
|
||||||
|
#[serde(default)]
|
||||||
|
pub force_http1: bool,
|
||||||
|
|
||||||
/// Opt-out for the DoH bypass. Default `false` (= bypass active):
|
/// Opt-out for the DoH bypass. Default `false` (= bypass active):
|
||||||
/// CONNECTs to well-known DoH hostnames (Cloudflare, Google, Quad9,
|
/// CONNECTs to well-known DoH hostnames (Cloudflare, Google, Quad9,
|
||||||
/// AdGuard, NextDNS, OpenDNS, browser-pinned variants like
|
/// AdGuard, NextDNS, OpenDNS, browser-pinned variants like
|
||||||
@@ -892,6 +905,38 @@ mod rt_tests {
|
|||||||
let _ = std::fs::remove_file(&tmp);
|
let _ = std::fs::remove_file(&tmp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn force_http1_round_trips_through_config() {
|
||||||
|
let json = r#"{
|
||||||
|
"mode": "apps_script",
|
||||||
|
"google_ip": "216.239.38.120",
|
||||||
|
"front_domain": "www.google.com",
|
||||||
|
"script_id": "X",
|
||||||
|
"auth_key": "secretkey123",
|
||||||
|
"listen_host": "127.0.0.1",
|
||||||
|
"listen_port": 8085,
|
||||||
|
"log_level": "info",
|
||||||
|
"verify_ssl": true,
|
||||||
|
"force_http1": true
|
||||||
|
}"#;
|
||||||
|
let cfg: Config = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(cfg.force_http1, "force_http1=true must round-trip");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn force_http1_defaults_false_when_omitted() {
|
||||||
|
// Existing configs from before v1.9.13 don't have the field.
|
||||||
|
// serde(default) must give false (h2 active) so older configs
|
||||||
|
// continue to work and unchanged users get the optimization.
|
||||||
|
let json = r#"{
|
||||||
|
"mode": "apps_script",
|
||||||
|
"auth_key": "secretkey123",
|
||||||
|
"script_id": "X"
|
||||||
|
}"#;
|
||||||
|
let cfg: Config = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(!cfg.force_http1, "default must be false (h2 enabled)");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn round_trip_minimal_fields_only() {
|
fn round_trip_minimal_fields_only() {
|
||||||
// User saves with defaults for everything optional. This is what the
|
// User saves with defaults for everything optional. This is what the
|
||||||
|
|||||||
+1937
-60
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -587,7 +587,7 @@ impl ProxyServer {
|
|||||||
// accept loops.
|
// accept loops.
|
||||||
let keepalive_task = if let Some(keepalive_fronter) = self.fronter.clone() {
|
let keepalive_task = if let Some(keepalive_fronter) = self.fronter.clone() {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
keepalive_fronter.run_h1_keepalive().await;
|
keepalive_fronter.run_keepalive().await;
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
tokio::spawn(async move { std::future::pending::<()>().await })
|
tokio::spawn(async move { std::future::pending::<()>().await })
|
||||||
|
|||||||
Reference in New Issue
Block a user