feat: HTTP/2 multiplexing on relay leg with idempotency-safe h1 fallback (#799)

This commit is contained in:
dazzling-no-more
2026-05-07 01:43:10 +04:00
committed by GitHub
parent 5a07709be8
commit 0e678630a8
9 changed files with 2050 additions and 82 deletions
@@ -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>
+4 -1
View File
@@ -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>
+15
View File
@@ -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,
} }
} }
+45
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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 })