Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ data class MhrvConfig(
val verifySsl: Boolean = true,
val logLevel: String = "info",
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 coalesceMaxMs: Int = 1000,
val upstreamSocks5: String = "",
Expand Down Expand Up @@ -217,6 +225,7 @@ data class MhrvConfig(
put("verify_ssl", verifySsl)
put("log_level", logLevel)
put("parallel_relay", parallelRelay)
if (forceHttp1) put("force_http1", true)
if (coalesceStepMs != 10) put("coalesce_step_ms", coalesceStepMs)
if (coalesceMaxMs != 1000) put("coalesce_max_ms", coalesceMaxMs)
if (upstreamSocks5.isNotBlank()) {
Expand Down Expand Up @@ -328,6 +337,7 @@ object ConfigStore {
if (cfg.verifySsl != defaults.verifySsl) obj.put("verify_ssl", cfg.verifySsl)
if (cfg.logLevel != defaults.logLevel) obj.put("log_level", cfg.logLevel)
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.coalesceMaxMs != defaults.coalesceMaxMs) obj.put("coalesce_max_ms", cfg.coalesceMaxMs)
if (cfg.upstreamSocks5.isNotBlank()) obj.put("upstream_socks5", cfg.upstreamSocks5)
Expand Down Expand Up @@ -431,6 +441,7 @@ object ConfigStore {
verifySsl = obj.optBoolean("verify_ssl", true),
logLevel = obj.optString("log_level", "info"),
parallelRelay = obj.optInt("parallel_relay", 1),
forceHttp1 = obj.optBoolean("force_http1", false),
coalesceStepMs = obj.optInt("coalesce_step_ms", 10),
coalesceMaxMs = obj.optInt("coalesce_max_ms", 1000),
upstreamSocks5 = obj.optString("upstream_socks5", ""),
Expand Down
18 changes: 16 additions & 2 deletions android/app/src/main/java/com/therealaleph/mhrv/Native.kt
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,22 @@ object Native {
* relay_calls, relay_failures, coalesced, bytes_relayed,
* cache_hits, cache_misses, cache_bytes,
* blacklisted_scripts, total_scripts,
* today_calls, today_bytes, today_key (string "YYYY-MM-DD"),
* today_reset_secs (seconds until 00:00 UTC rollover)
* today_calls, today_bytes, today_key (string "YYYY-MM-DD" in
* 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.
*/
Expand Down
15 changes: 9 additions & 6 deletions android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1487,11 +1487,14 @@ private fun CollapsibleSection(
/**
* "Usage today (estimated)" card. Polls `Native.statsJson(handle)` every
* 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,
* and a countdown to the 00:00 UTC reset. 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.
* Apps Script free-tier quota (20,000/day), today's bytes, the Pacific
* Time day key, and a countdown to the 00:00 PT reset. Pacific Time
* matches Apps Script's actual quota reset cadence — UTC would have
* the counter resetting ~7-8 h before the user actually got a fresh
* 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
* empty (direct / full-only configs don't run a DomainFronter and so
Expand Down Expand Up @@ -1563,7 +1566,7 @@ private fun UsageTodayCard() {
value = fmtBytes(todayBytes),
)
UsageRow(
label = stringResource(R.string.label_utc_day),
label = stringResource(R.string.label_pt_day),
value = todayKey,
)
UsageRow(
Expand Down
2 changes: 1 addition & 1 deletion android/app/src/main/res/values-fa/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
<string name="sec_usage_today">مصرف امروز (تخمینی)</string>
<string name="label_calls_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="usage_calls_of_quota">%1$d / %2$d (%3$.1f%%)</string>
<string name="usage_resets_hm">%1$d ساعت و %2$d دقیقه</string>
Expand Down
5 changes: 4 additions & 1 deletion android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,10 @@
<string name="sec_usage_today">Usage today (estimated)</string>
<string name="label_calls_today">calls 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="usage_calls_of_quota">%1$d / %2$d (%3$.1f%%)</string>
<string name="usage_resets_hm">%1$dh %2$dm</string>
Expand Down
15 changes: 15 additions & 0 deletions src/bin/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@ struct FormState {
/// users edit `disable_padding` directly when needed (Issue #391).
/// Default false (padding active).
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 —
/// the bypass-DoH default is the right answer for almost everyone
/// (DoH already encrypts, the tunnel was just adding latency), so
Expand Down Expand Up @@ -384,6 +388,7 @@ fn load_form() -> (FormState, Option<String>) {
passthrough_hosts: c.passthrough_hosts.clone(),
block_quic: c.block_quic,
disable_padding: c.disable_padding,
force_http1: c.force_http1,
tunnel_doh: c.tunnel_doh,
bypass_doh_hosts: c.bypass_doh_hosts.clone(),
block_doh: c.block_doh,
Expand Down Expand Up @@ -422,6 +427,7 @@ fn load_form() -> (FormState, Option<String>) {
passthrough_hosts: Vec::new(),
block_quic: false,
disable_padding: false,
force_http1: false,
tunnel_doh: true,
bypass_doh_hosts: Vec::new(),
block_doh: true,
Expand Down Expand Up @@ -584,6 +590,9 @@ impl FormState {
// Issue #391: disable_padding is config-only for now.
// Round-trip preserves the user's choice.
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`.
// Round-trip the user's choice (and any extra hostnames they
// added) so save doesn't drop them.
Expand Down Expand Up @@ -693,6 +702,11 @@ struct ConfigWire<'a> {
auto_blacklist_cooldown_secs: u64,
#[serde(skip_serializing_if = "is_default_timeout_secs")]
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 /
/// grok.com / x.com via exit-node second-hop relay). Skip when fully
/// default (disabled with no URL/PSK/hosts) so configs without
Expand Down Expand Up @@ -772,6 +786,7 @@ impl<'a> From<&'a Config> for ConfigWire<'a> {
auto_blacklist_window_secs: c.auto_blacklist_window_secs,
auto_blacklist_cooldown_secs: c.auto_blacklist_cooldown_secs,
request_timeout_secs: c.request_timeout_secs,
force_http1: c.force_http1,
exit_node: &c.exit_node,
}
}
Expand Down
45 changes: 45 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,19 @@ pub struct Config {
#[serde(default)]
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):
/// CONNECTs to well-known DoH hostnames (Cloudflare, Google, Quad9,
/// AdGuard, NextDNS, OpenDNS, browser-pinned variants like
Expand Down Expand Up @@ -867,6 +880,38 @@ mod rt_tests {
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]
fn round_trip_minimal_fields_only() {
// User saves with defaults for everything optional. This is what the
Expand Down
Loading
Loading