Skip to content

Releases: therealaleph/MasterHttpRelayVPN-RUST

v1.9.25

13 May 20:07

Choose a tag to compare

نصب MITM CA در LibreWolf (#1145, PR #1159 by @dazzling-no-more). کاربران LibreWolf با خطای MOZILLA_PKIX_ERROR_MITM_DETECTED روی سایت‌های HSTS-protected (bing.com، youtube.com، …) مواجه می‌شدن. علت: cert_installer.rs فقط Firefox profile rootها رو scan می‌کرد. LibreWolf یک Firefox fork است که همون NSS DB layout رو share می‌کنه ولی profile tree خودش رو زیر app dir خودش نگه می‌داره — هیچ‌کدوم از certutil -A per-profile install یا user.js enterprise-roots auto-trust fallback به LibreWolf نمی‌رسیدن. راه‌حل: firefox_profile_dirs()mozilla_family_profile_dirs() که هم Firefox هم LibreWolf paths رو per-OS برمی‌گردونه. هیچ تغییری برای کاربران Firefox. ۲۳۱ → ۲۳۹ lib test (+۸ regression برای LibreWolf path discovery). همان class از bug که قبلاً در #955 و #959 (Firefox-fork) closed شده بود.

رفع باگ Full mode «Google و اکثر سایت‌ها خراب، تلگرام سالم» — udpgw magic IP از داخل virtual-DNS range tun2proxy منتقل شد (#251 by @dazzling-no-more).

در Full mode روی Android، تلگرام کار می‌کرد ولی Google search و اکثر سایت‌ها silently fail می‌شدن — apps_script mode روی همون device سالم بود و VPS هم idle.

علت: آدرس magic مربوط به udpgw (یعنی 198.18.0.1:7300) داخل 198.18.0.0/15 بود، یعنی دقیقاً همون range‌ای که tun2proxy --dns virtual ازش IPهای ساختگی رو برای hostname lookupها اختصاص می‌ده. هر دفعه که virtual DNS اتفاقاً 198.18.0.1 رو به یک hostname مثل www.google.com allocate می‌کرد، traffic اون host به‌عنوان udpgw connection مصادره می‌شد و drop می‌شد. تلگرام immune بود چون native clientش از IPهای عددی hardcoded استفاده می‌کنه؛ همچنین apps_script mode هم immune بود چون اصلاً --udpgw-server ست نمی‌کنه.

راه‌حل: ثابت UDPGW_MAGIC_IP به 192.0.2.1 (RFC 5737 TEST-NET-1) منتقل شد. دو فایل تغییر کرده: یکی tunnel-node/src/udpgw.rs (constant + tests) و دیگری android/.../MhrvVpnService.kt (که حالا از یک companion const به اسم UDPGW_MAGIC_DEST استفاده می‌کنه).

سازگاری با نسخه‌های قدیمی: نسخهٔ جدید tunnel-node همچنان 198.18.0.1:7300 قدیمی رو هم accept می‌کنه برای یک deprecation cycle (حذف در v1.10.0) — یعنی اگه VPS رو زودتر آپدیت کنی، Android قدیمی هنوز کار می‌کنه. ولی اگه Android رو زودتر آپدیت کنی، tunnel-node قدیمی UDP relay رو در Full mode break می‌کنه. توصیه: اول tunnel-node رو آپدیت کن، بعد APK رو.

--- • **Install MITM CA into LibreWolf NSS stores** ([#1145](https://github.com//issues/1145), [PR #1159](https://github.com//pull/1159) by @dazzling-no-more). LibreWolf users were getting `MOZILLA_PKIX_ERROR_MITM_DETECTED` when visiting HSTS-protected sites (bing.com, youtube.com, …) through mhrv-rs's MITM mode. HSTS gives no "Add Exception" affordance, so users were fully locked out despite the OS-level CA install having succeeded.

Root cause: cert_installer.rs only scanned Firefox profile roots (~/.mozilla/firefox, the snap variant, %APPDATA%\Mozilla\Firefox\Profiles, ~/Library/Application Support/Firefox/Profiles). LibreWolf is a Firefox fork that shares Firefox's NSS DB layout and respects the same security.enterprise_roots.enabled pref, but stores its profile tree under its own app dir — neither the per-profile certutil -A install nor the user.js enterprise-roots auto-trust fallback ever touched LibreWolf. Same failure mode as already-closed #955 / #959 (Firefox-fork users).

Fix: extend Mozilla-family profile discovery to cover LibreWolf on every supported platform. firefox_profile_dirs()mozilla_family_profile_dirs() (returns union of Firefox + LibreWolf paths per-OS). Per-OS coverage:

  • Linux: ~/.mozilla/firefox, snap variant, ~/.librewolf, $XDG_CONFIG_HOME/librewolf.
  • macOS: ~/Library/Application Support/Firefox/Profiles, ~/Library/Application Support/LibreWolf/Profiles.
  • Windows: %APPDATA%\Mozilla\Firefox\Profiles, %APPDATA%\LibreWolf\Profiles.

No behavioural change for Firefox installs. 231 → 239 lib tests (+8 regression for LibreWolf path discovery on each OS).


Fix Full mode "Google + most websites broken while Telegram works" — udpgw magic IP moved out of tun2proxy virtual-DNS range (#251 by @dazzling-no-more). Users on Android Full mode reported that Telegram worked fine but Google search and most other websites failed to load — while apps_script mode on the same device + same google_ip worked perfectly and the VPS was sitting idle.

Root cause: the udpgw magic destination address (198.18.0.1:7300) lived inside 198.18.0.0/15 — the exact same range that tun2proxy's --dns virtual allocator uses to synthesise fake IPs for hostname lookups. Whenever virtual DNS happened to assign 198.18.0.1 to a real hostname (e.g. www.google.com), that hostname's connections were intercepted by tun2proxy itself as a udpgw request before they ever reached the SOCKS5 proxy. Result: a random subset of DNS-resolved hosts silently broke per session, depending on which hostname won the 198.18.0.1 allocation. Telegram was unaffected because its native client uses hardcoded numeric IPs (no DNS allocation needed). apps_script mode was unaffected because it doesn't pass --udpgw-server to tun2proxy at all.

Fix: relocate UDPGW_MAGIC_IP from 198.18.0.1 to 192.0.2.1 (RFC 5737 TEST-NET-1). TEST-NET-1 is reserved for documentation, never routed on the public internet, and — critically — outside any virtual-DNS allocation pool. Structurally equivalent to the old address as a "guaranteed-not-real-destination", just no longer colliding with tun2proxy's reserved range.

Coordinated two-side change:

  1. tunnel-node/src/udpgw.rs: UDPGW_MAGIC_IP = [192, 0, 2, 1], doc comment now cites RFC 5737 + explicitly explains why it must stay out of 198.18.0.0/15. Test additions: is_udpgw_dest_works covers both the new IP and the legacy IP (back-compat assertion); new magic_ip_outside_virtual_dns_range enforces the invariant at the 198.18.0.0/15 range level, so any future move to 198.19.x.y would also fail the test rather than re-introducing the same class of bug.
  2. android/.../MhrvVpnService.kt: --udpgw-server $UDPGW_MAGIC_DEST where UDPGW_MAGIC_DEST = "192.0.2.1:7300" is a new companion-object constant, with a docstring pointing back at the Rust constant — gives the next editor a single, labelled place to update if the convention ever changes again.

Back-compatibility — partial, one-way:

The udpgw magic IP is a wire-protocol convention between the Android client and the mhrv-tunnel Docker container. v1.9.25 tunnel-nodes accept both the new 192.0.2.1:7300 and the legacy 198.18.0.1:7300 for one deprecation cycle (slated for removal in v1.10.0). That softens — but does not fully resolve — the asymmetric-upgrade matrix:

Android Tunnel-node Full-mode UDP relay
v1.9.25 v1.9.25 ✅ fully fixed
≤v1.9.24 v1.9.25 ⚠️ udpgw handshake works (legacy IP still recognised by the node), but the old client still asks tun2proxy for --udpgw-server 198.18.0.1:7300 — meaning the underlying #251 virtual-DNS-pool collision is still live on the device. Telegram works; the random Google-search-style breakage persists until the APK is updated.
v1.9.25 ≤v1.9.24 breaks silently — new client sends 192.0.2.1, old node treats it as a real TCP destination and the connect fails
≤v1.9.24 ≤v1.9.24 unchanged from before (still has the original #251 bug)

Recommended upgrade order: update both halves to v1.9.25. The fix is on the client side (which magic IP it asks tun2proxy to reserve) — the tunnel-node back-compat shim only prevents a hard handshake break during the window where the node is upgraded first; it does not fix the original bug. If you can only update one half right now: do the APK first (or both together), since updating just the tunnel-node leaves clients still hitting the virtual-DNS collision. apps_script-only users are unaffected (the udpgw path isn't used in apps_script mode).

Diagnostic note for stuck users: if Telegram works on Full mode but Google search / random websites silently fail on v1.9.24 or earlier, this is your bug. As a workaround pending upgrade, add Google domains to passthrough_hosts to route them through tunnel-node like Telegram does:

{
  "passthrough_hosts": [".google.com", ".gstatic.com", ".googleusercontent.com", ".googleapis.com", ".youtube.com", ".ytimg.com"]
}

Slower per-request (Apps Script overhead) but bypasses the virtual-DNS clash entirely. Remove once both halves are on v1.9.25.

What's Changed

Full Changelog: v1.9.24...v1.9.25

v1.9.24

13 May 11:18

Choose a tag to compare

Fix Full mode timeout cascade — `batch header read honors request_timeout_secs` (#1088, PR #1108 by @dazzling-no-more). در Full mode، یک Apps Script edge کند، تمام تونل sessionهای hot-and-flowing رو cascade-kill می‌کرد. کاربرها روی v1.9.21+ مرتب 10s "batch timeout" می‌دیدن و download progress تلگرام/browser رو از دست می‌دادن. Root cause: `read_http_response` در `domain_fronter.rs` یک hardcoded 10s header-read timeout داشت که داخل `tunnel_batch_request_to` اجرا می‌شد — مستقل از و کوتاه‌تر از outer `tokio::time::timeout(batch_timeout, ...)` در `fire_batch`. Apps Script cold starts معمولاً 8-12s طول می‌کشن (PR #1040's A/B 4/30 H1 batches رو ثبت کرد که دقیقاً 10s timeout می‌شدن)، پس inner cliff به‌عنوان false-positive batch timeout قبل از اینکه `request_timeout_secs` (default 30s) trigger بشه fire می‌شد. Fix: (1) `tunnel_batch_request_to` حالا `batch_timeout` رو به header read pass می‌کنه via new `read_http_response_with_header_timeout` helper. (2) Header read یک absolute deadline استفاده می‌کنه (`timeout_at`) به جای per-read `timeout()` — slow drip-feed peer دیگه نمی‌تونه silently extend بزنه. (3) Bonus: `TunnelMux::reply_timeout` با `batch_timeout` co-vary می‌کنه (`batch_timeout + 5s slack`). ۲۰۹ → ۲۳۱ lib test (+22 regression).
Docker: cargo-chef برای build بدون BuildKit (#620, PR #1117 by @dazzling-no-more). `tunnel-node/Dockerfile` از BuildKit-only `RUN --mount=type=cache` استفاده می‌کرد که روی Cloud Run's `gcloud run deploy --source .` path شکست می‌خورد (underlying `gcr.io/cloud-builders/docker` builder BuildKit رو enable نمی‌کنه). cargo-chef pattern: `recipe.json` planner stage + `cargo chef cook` deps stage + final build with `src/` on top. Docker's regular layer cache حالا dependency reuse رو handle می‌کنه — warm rebuilds تنها `src/` رو compile می‌کنن. Base bump `rust:1.85-slim` → `rust:1.90-slim` (cargo-chef نیاز به rustc 1.86+ داره).

Fix Full mode timeout cascade — batch header read honors request_timeout_secs (#1088, PR #1108 by @dazzling-no-more). Under Full mode, a single slow Apps Script edge cascade-killed every in-flight tunnel session sharing its batch. Users on v1.9.21+ saw frequent 10s "batch timeout" errors and lost download progress on Telegram / browser sessions.

Root cause: read_http_response in domain_fronter.rs had a hardcoded 10s header-read timeout that ran inside tunnel_batch_request_to — independent of and shorter than the outer tokio::time::timeout(batch_timeout, …) in fire_batch. Apps Script cold starts routinely land in the 8-12s range (PR #1040's A/B recorded 4/30 H1 batches timing out at exactly 10s after the H2→H1 switch), so the inner cliff fired as a false-positive batch timeout well before request_timeout_secs (default 30s) could.

Fix (in domain_fronter.rs + tunnel_client.rs):

  1. tunnel_batch_request_to passes batch_timeout to the header read via new read_http_response_with_header_timeout helper. Config::request_timeout_secs is now the only knob controlling how long we wait for an Apps Script edge to start responding. Other callers (relay path, exit-node) keep the historical 10s value.
  2. Header read uses a single absolute deadline (timeout_at) instead of per-read timeout(). Total elapsed across all header reads is bounded regardless of read cadence — a slow drip-feed peer can no longer silently extend.
  3. TunnelMux::reply_timeout co-varies with batch_timeout (computed at construction as fronter.batch_timeout() + 5s slack instead of fixed 35s const). Operators raising request_timeout_secs no longer have sessions abandon reply_rx just before fire_batch's HTTP round-trip would complete.

209 → 231 lib tests (+22 regression covering the deadline/co-variance behavior).

Docker: cargo-chef so tunnel-node builds without BuildKit (#620, PR #1117 by @dazzling-no-more). tunnel-node/Dockerfile used BuildKit-only RUN --mount=type=cache directives, breaking on Cloud Run's gcloud run deploy --source . path (the underlying gcr.io/cloud-builders/docker builder doesn't enable BuildKit, and --set-build-env-vars DOCKER_BUILDKIT=1 doesn't flip it on either).

Reworked to use cargo-chef: a dedicated planner stage emits recipe.json for dependency metadata, a cargo chef cook stage builds just the deps in their own Docker layer, the final build stage adds src/ on top. Docker's regular layer cache handles dependency reuse — warm rebuilds where only src/ changes still skip the slow crate compile.

Base bump rust:1.85-slimrust:1.90-slim (cargo-chef's transitive deps require rustc 1.86+; tunnel-node's Cargo.toml has no rust-version pin so the bump is internal-only).

Action for Cloud Run users blocked on #620: pull v1.9.24 of the tunnel-node Docker image (ghcr.io/therealaleph/mhrv-tunnel-node:v1.9.24 or :latest) — your gcloud run deploy --source . should now succeed without BuildKit.

Followup: issue #1131 (BuffOvrFlw) reports h1 open timed out after 8s — that's the H1_OPEN_TIMEOUT_SECS = 8 from PR #1029 firing on open() (TCP+TLS handshake), separate from the header-read timeout this release fixes. Worth a follow-up PR to make H1_OPEN_TIMEOUT_SECS parameterized via request_timeout_secs too.

What's Changed

Full Changelog: v1.9.23...v1.9.24

v1.9.23

12 May 13:14

Choose a tag to compare

Fix: stream range-parallel downloads larger than Apps Script's 50 MiB cap (#1042 + PR #1085 by @dazzling-no-more). دانلودهای range-capable بزرگ‌تر از ~۵۰ MiB از طریق Apps Script relay با `504 Relay timeout — Apps Script unresponsive` fail می‌شد. v2rayN DMG 104 MiB در reported logs canonical repro بود. روت کاز: `relay_parallel_range` در 64 MiB ceiling داشت و برای بالاتر به single `relay()` fallback می‌کرد که از 50 MiB Apps Script ceiling عبور می‌کرد، Apps Script script رو mid-execution می‌کشت، و 25s timeout. Fix: `relay_parallel_range` به writer-based API تبدیل شد که large files رو chunk-by-chunk (هر chunk ≤256 KiB، خوب زیر 50 MiB cap) به client socket stream می‌کنه. ۴-way dispatch: Buffered (≤40 MiB)، Stream (40 MiB-16 GiB)، FallbackSingleGet (wrapper 40-64 MiB)، RejectTooLarge (>16 GiB، quota guard). Lazy range planning با `saturating_*` — O(1) memory حتی برای `u64::MAX` total (قبل ~6 GB Vec allocation می‌داد). MITM HTTPS + plain HTTP call sites + CORS-aware `transform_head` همه updated. ۲۰۹ → ۲۲۷ lib test (+۱۸ new: dispatch enum، lazy planning، head assembly، head transform، streaming writer، flush behavior، CORS-into-streaming integration).

Fix: stream range-parallel downloads larger than Apps Script's 50 MiB cap (#1042 + PR #1085 by @dazzling-no-more).

Range-capable downloads larger than ~50 MiB through the Apps Script relay returned 504 Relay timeout — Apps Script unresponsive instead of the file. The 104 MiB v2rayN DMG in the reported logs was the canonical repro (also fixes @Paymanonline's #1077 report).

Root cause: relay_parallel_range capped the stitched response at 64 MiB and fell back to a single relay() for anything larger. Single-GET routes through Apps Script's ~50 MiB response ceiling, so Apps Script killed the script mid-execution and we hung for the full 25s relay timeout before returning 504.

Fix: convert relay_parallel_range into a writer-based API that streams large files chunk-by-chunk to the client socket. Each chunk is still one ≤256 KiB Apps Script call (well under the 50 MiB cap); only the host-side buffering changes. Backward-compatible Vec<u8> wrapper preserves the pre-v1.9.23 API surface for external library consumers.

Three-way dispatch via RangeDispatch { Buffered, Stream, FallbackSingleGet, RejectTooLarge } and the pure dispatch_range_response(total, streaming_allowed) predicate:

  • Bufferedtotal ≤ APPS_SCRIPT_BODY_MAX_BYTES (40 MiB) on either surface. Existing stitch + single-GET fallback path; fully recovers on chunk failure.
  • Stream — writer API above 40 MiB. Streams; chunk failure flushes the committed prefix and returns Err so the Content-Length mismatch tells download clients to resume via Range.
  • FallbackSingleGet — wrapper above 64 MiB. Falls back to self.relay(), matching the pre-v1.9.23 cliff for external library consumers stuck on the old API.
  • RejectTooLarge — writer API above 16 GiB. Refuses with 502; bounds worst-case Apps Script quota drain from a hostile origin advertising an absurd Content-Range total.

Memory bounds: Lazy plan_remaining_ranges via std::iter::from_fn + saturating_*. Range planning is O(1) memory regardless of advertised total — even a u64::MAX total no longer drives a ~6 GB Vec<(u64, u64)> allocation.

CORS interaction: MITM HTTPS and plain-HTTP call sites updated to use relay_parallel_range_to with a CORS-aware transform_head closure. New inject_cors_into_head (head-only variant of inject_cors_response_headers) lets the streaming path rewrite ACL headers before the body has been assembled.

209 → 227 lib tests (+18 new: RangeDispatch enum coverage, lazy range planning under u64::MAX, assemble_200_head correctness, transform_head closure invocation, streaming writer chunk-by-chunk semantics, head-then-flush-before-body ordering, CORS-into-streaming cross-module integration).

User impact: GitHub release downloads, large CDN binaries, ROM-hack distributions, anything in the 50 MiB – 16 GiB range now downloads successfully through apps_script mode. Previously these required Full mode, an Iran-mirror proxy (#1077), or a friend-with-VPS workaround.

What's Changed

Full Changelog: v1.9.22...v1.9.23

v1.9.22

11 May 13:24

Choose a tag to compare

Fix: skip H2 برای `tunnel_request` (single ops) — completes #1040 (PR #1041 by @yyoyoian-pixel). v1.9.21's PR #1040 H2 رو از `tunnel_batch_request_to` skip کرد ولی `tunnel_request` (single-op path برای plain `connect` ops) جا موند. کاربرانی که session‌های full-tunnel با single-op path داشتند هنوز ۱۶-۱۷s long-poll stalls می‌گرفتن. این PR fix رو complete می‌کنه — same shape: حذف H2 try/fallback/NonRetryable block، مستقیم H1 pool `acquire()`. همه ۵ تا call site `h2_relay_request` audit شدن (جدول در PR description) — relay-mode paths H2 رو نگه می‌دارن (apps_script users بدون change)، همه full-tunnel paths حالا H1-only. ۲۰۹ lib test still pass.

Fix: skip H2 for tunnel_request (single ops) — completes #1040 (PR #1041 by @yyoyoian-pixel).

v1.9.21's PR #1040 skipped H2 for tunnel_batch_request_to but missed tunnel_request — the single-op path used for plain connect ops. Users on full-tunnel sessions that went through the single-op path still saw 16-17s long-poll stalls. This PR completes the fix: same shape, remove the H2 try/fallback/NonRetryable block from tunnel_request, go straight to H1 pool acquire().

All 5 h2_relay_request call sites audited:

Call site Function Mode H2 skipped?
do_relay_once_with relay Relay No (correct — relay benefits from H2)
relay() exit-node relay Relay No (correct)
tunnel_request tunnel single op Full tunnel Yes (this release)
tunnel_batch_request_to tunnel batch Full tunnel Yes (v1.9.21)
tunnel_batch_request_with_timeout tunnel batch Full tunnel Yes (v1.9.21)

No other full-tunnel paths use H2 after this fix. Relay-mode H2 stays — r0ar's controlled A/B in #962 confirmed h2 is strictly better for apps_script-mode users, and that path is unchanged.

209 lib tests still pass. domain_fronter.rs-only, -41 net lines.

What's Changed

Full Changelog: v1.9.21...v1.9.22

v1.9.21

11 May 00:01

Choose a tag to compare

Perf: skip H2 برای Full-tunnel batch requests (PR #1040 by @yyoyoian-pixel). Full mode tunnel batches قبلاً N op رو در یک HTTP request coalesce می‌کنند — H2 stream multiplexing چیزی برای multiplex کردن نداره. H2 try/fallback path در این مسیر خاص سه regression از v1.9.14 معرفی کرد: (1) long-poll stallها در ۱۶-۱۷s به جای 10s timeout روی H1 — هر poll ~۶۰٪ بیشتر slot Apps Script رو نگه می‌داشت، (2) silent batch drops via `RequestSent::Maybe` بدون retry، (3) pool starvation از `POOL_MIN_H2_FALLBACK = 2` که از 8 → 2 trim می‌کرد. H2 multiplexing برای relay mode (apps_script) فعال می‌مونه — اونجا واقعاً به‌درد می‌خوره (r0ar در #962 confirmed). A/B روی Pixel 6 Pro: 0/30 vs 8-10/30 long-poll stalls. ۲۰۹ lib test still pass. v1.9.14 tunnel performance بازگشت + همه v1.9.15+ improvements حفظ شد (relay mode h2، zero-copy mux، block DoH/QUIC، PR #1029 warm-race fix).

Perf: skip H2 for Full-tunnel batch requests (PR #1040 by @yyoyoian-pixel). Tunnel batches already coalesce N ops into one HTTP request — H2 stream multiplexing has nothing to multiplex on this code path. The H2 try/fallback block introduced three regressions vs v1.9.14:

  1. Long-poll stalls: idle polls completed at 16-17s (LONGPOLL_DEADLINE + network latency) instead of timing out at 10s on H1. Each poll held an Apps Script execution slot ~60% longer.
  2. Silent batch drops: RequestSent::Maybe failures dropped the entire batch with no retry — a failure mode H1 doesn't have.
  3. Pool starvation: POOL_MIN_H2_FALLBACK = 2 trimmed the H1 pool from 8 → 2 once H2 connected, but tunnel batches still used H1 and needed the full pool.

H2 multiplexing stays active for relay mode (non-full) where each browser request is a separate HTTP call that genuinely benefits from stream multiplexing — r0ar's controlled A/B test in #962 confirmed h2 is strictly better than force_http1: true for apps_script-mode users, and that path is unchanged here.

Changes (domain_fronter.rs-only, -54/-12 lines, +12 net)

  • tunnel_batch_request_to: remove H2 try/fallback/NonRetryable block, go straight to H1 pool acquire().
  • run_pool_refill: always maintain POOL_MIN = 8. Remove the POOL_MIN_H2_FALLBACK = 2 trim.

A/B results (Pixel 6 Pro, 30 batch samples each)

Metric H2 (stock v1.9.20) H1 (this release) v1.9.14 (baseline)
16-17s batches 8-10/30 0/30 0/30
10s timeouts 0 4/30 5/30
Active RTTs 1.4-2.4s 1.3-2.2s 1.4-2.3s

Restores v1.9.14 tunnel performance while keeping all v1.9.15+ improvements (H2 for relay, zero-copy mux from PR #881, block DoH/QUIC defaults from v1.9.13/14, PR #1029's warm-race fix from v1.9.20).

Interaction with v1.9.20 (PR #1029)

PR #1029 added H2Cell.dead: Arc<AtomicBool> for synchronous dead-cell detection. With this release removing the H2 path for tunnel batches, the dead-cell flag scopes to relay mode only — that's intentional (the flag was protecting the relay path in practice). No regression.

209 lib tests still pass (no test changes — the affected paths are exercised by integration probes which the PR reporter ran on Pixel 6 Pro).

What's Changed

Full Changelog: v1.9.20...v1.9.21

v1.9.20

10 May 21:56

Choose a tag to compare

Fix Full mode regression از v1.9.15 (#924 — یک ۳-هفته‌ای tracking thread با ۱۸+ duplicate report، fixed by @rezaisrad in PR #1029). علامت: `batch timed out after 30s` در Full mode، در حالی که apps_script mode normal کار می‌کرد. فقط workaround موجود `"force_http1": true` kill switch بود. Bisect دقیق این رو به `0e678630a` (PR #799 که h2 multiplexing رو اضافه کرد) رساند. روت کاز یک‌ line ordering: `warm()` در v1.9.15 h1 prewarm loop رو پشت `ensure_h2().await` گذاشت — وقتی h2 handshake کند بود (تا 8s)، pool h1 خالی می‌موند. اگر در آن window یک request می‌آمد، h1 fallback یک TCP+TLS handshake cold می‌زد که خود stall می‌شد، outside the 30s batch_timeout. Fix: h1 prewarm parallel با h2 handshake (v1.9.14 ordering restored)، plus بستنک‌های پیرامون با `H1_OPEN_TIMEOUT_SECS = 8` و `H2Cell.dead` AtomicBool. ۲۰۸ → ۲۰۹ lib test (+1 regression: `ensure_h2_rejects_dead_cell_within_ttl`). تأیید end-to-end: 5/5 cold restarts pass (9.6-22.5s)، 5/5 concurrent SOCKS5 burst.

Fix Full mode regression since v1.9.15 (#924, PR #1029 by @rezaisrad). #924 was the canonical tracking thread for an 18+ duplicate cluster spanning ~3 weeks; affected users saw batch timed out after 30s on every Full-mode request while apps_script mode kept working. The only available workaround was the "force_http1": true kill switch.

Root cause (rigorously bisected to 0e678630a — PR #799 which added HTTP/2 multiplexing): PR #799 gated the h1 socket-pool prewarm behind ensure_h2().await. ensure_h2() is bounded by H2_OPEN_TIMEOUT_SECS = 8s but can take the full window on a cold first connection. During that window the h1 fallback pool was empty, so any request that arrived would:

  1. Get Err((Relay("h2 unavailable"), No)) immediately → fall back to h1
  2. Empty pool → cold open() → fresh TCP+TLS to connect_host:443
  3. Same network conditions that stalled h2 also stalled h1; cold open exceeded the 30s batch_timeout
  4. User saw batch timed out after 30s that "works on apps_script" couldn't explain

Fix (two commits, domain_fronter.rs-only):

  1. warm h1 pool in parallel with h2: spawn h2 prewarm in a separate task so the h1 prewarm loop runs concurrently. Full n h1 sockets are warm before user traffic, even when h2 stalls. run_pool_refill trims back to POOL_MIN_H2_FALLBACK = 2 within 5s once h2 lands as the fast path.

  2. bound h1 open() + detect dead h2 cells synchronously: H1_OPEN_TIMEOUT_SECS = 8 wraps the TCP+TLS handshake in open() so a stuck handshake doesn't block acquire() until the outer batch budget elapses. H2Cell.dead: Arc<AtomicBool> flipped by the connection driver task when Connection::await ends — known-dead cells are rejected within ≤5s instead of waiting for H2_CONN_TTL_SECS = 540s to expire.

API impact: h2_handshake_post_tls return type changes to (SendRequest, Arc<AtomicBool>). One existing test (h2_handshake_post_tls_returns_alpn_refused_when_peer_picks_h1) tweaks its Ok arm to match — no panic message change.

208 → 209 lib tests (+1 regression: ensure_h2_rejects_dead_cell_within_ttl). Live end-to-end (per PR notes): 5/5 cold restarts pass in 9.6-22.5s, 5/5 concurrent SOCKS5 burst, default full.json baseline 200 OK in 13.3s.

Action for affected users: update to v1.9.20, drop the "force_http1": true workaround from config.json if you had it set. Full mode should work reliably on cold restart again.

What's Changed

New Contributors

Full Changelog: v1.9.19...v1.9.20

v1.9.19

10 May 14:20

Choose a tag to compare

• UI accessibility — screen reader labels for NVDA / Narrator (#1015 by @brightening-eyes, fixes #916). `accesskit` در Cargo.toml از قبل فعال بود ولی هیچ widget label-association نداشت — وقتی focus به یک text input یا combobox می‌رفت، NVDA فقط نوع control رو می‌گفت ("edit", "combobox") نه نام field رو. حالا `form_row` پلامبینگ `egui::Id` رو به widget می‌فرسته و هر widget با `.labelled_by(label_id)` به label visible خود معرفی می‌شه. تست شد توسط کاربر نابینایی که issue رو گزارش داد. ۲۰۸ lib test همه pass. (also includes c437598 fix for exit_node Content-Encoding + Content-Length stripping — ChatGPT / Claude / Reddit through exit-node now work without Content Encoding Error.)

UI accessibility — proper screen-reader labels for NVDA / Narrator (#1015 by @brightening-eyes, fixes #916). The accesskit feature was already enabled in Cargo.toml via eframe, but no widget had an explicit label association — so when focus moved to a text input or combobox, NVDA / Narrator only announced the control type ("edit", "combobox") instead of the field name. The fix plumbs egui::Id through form_row so each widget can call .labelled_by(label_id) to associate with its visible label. Tested by the blind user who originally reported the issue with their actual NVDA setup. 208/208 lib tests still pass.

form_row's signature changes from widget: impl FnOnce(&mut egui::Ui) to widget: impl FnOnce(&mut egui::Ui, egui::Id). Two existing call sites that don't need the label id (the Mode combobox, Share on LAN checkbox) bind it as _label_id — no functional change there.

• Also rolling up the exit_node Content-Encoding fix (#964): fetch() (Deno / Bun / Node) auto-decompresses gzip / br / deflate response bodies, but the destination's Content-Encoding: gzip header was forwarded verbatim — telling the browser the body was gzipped when it was already plain. Browsers raised Content Encoding Error: invalid or unsupported form of compression. Strip both Content-Encoding and Content-Length from the forwarded headers (the Apps Script + Rust transport reframes the wire body anyway, so neither is meaningful end-to-end). Affects every compressed-response destination through exit-node: ChatGPT, Claude, Reddit, X, etc.

Action for exit-node users: pull the latest assets/exit_node/exit_node.ts and redeploy your Deno Deploy / VPS exit-node. The Rust binary side has nothing new for this fix — it's purely on the exit-node script.

What's Changed

Full Changelog: v1.9.18...v1.9.19

v1.9.18

08 May 03:15

Choose a tag to compare

• Performance refactor of full-tunnel mux hot path (#881 by @dazzling-no-more) — zero-copy reads via Bytes/BytesMut و base64 encoding از روی single mux thread برداشته شد. هیچ wire-protocol change نداره — فقط internal data flow. (1) tunnel_loop و SOCKS5 UDP receive loop دیگه per-iteration Vec::to_vec() copy ندارن. MuxMsg::{ConnectData,Data,UdpOpen,UdpData} حالا Bytes (Arc-backed) carry می‌کنن به جای Vec<u8>/Arc<Vec<u8>>. TCP path threshold-based: ≥32 KB → BytesMut::split().freeze() (saves 64 KB memcpy on hot downloads); <32 KB → Bytes::copy_from_slice + buf.clear() (payload-sized retention). UDP path: fixed Vec<u8> recv buffer + size-guarded copy. (2) base64 encoding (تا ~3 MB per batch) از mux thread رفت به spawned task تو fire_batch بعد از per-deployment semaphore — single mux task دیگه serialize نمی‌شه. (3) Code quality: BatchAccum::push_or_fire (۴ match arm به ۱ کلپس)، should_fire() predicate با saturating_add، encode_pending() free function. ۲۰۰ → ۲۰۸ lib test (+۸ regression: encode_pending × ۴، should_fire × ۳، batch_accum_reindexes_after_flush). API change: TunnelMux::udp_open/udp_data حالا impl Into<Bytes> می‌گیرن — existing callers با Vec/Bytes/BytesMut بدون تغییر کار می‌کنن.

• Performance refactor of the full-tunnel mux hot data path (#881 by @dazzling-no-more). No wire-protocol changes — internal data flow only.

1. Zero-copy reads via Bytes/BytesMut. tunnel_loop and the SOCKS5 UDP receive loop drop per-iteration Vec::to_vec() copies. MuxMsg::{ConnectData,Data,UdpOpen,UdpData} now carry Bytes (Arc-backed internally) instead of Vec<u8>/Arc<Vec<u8>>; the Arc::try_unwrap dance for pending_client_data is gone. TCP path is threshold-based to avoid memory regressions:

  • n ≥ 32 KB: BytesMut::split().freeze() — saves the 64 KB memcpy on hot downloads.
  • n < 32 KB: Bytes::copy_from_slice + buf.clear() — payload-sized retention. Without this split, bytes 1.x's whole-allocation refcount would pin a full 64 KB per queued tiny read under semaphore stall (worst case ~96 MB on a backpressured tunnel).

UDP path: fixed Vec<u8> recv buffer + Bytes::copy_from_slice after the 9 KB MAX_UDP_PAYLOAD_BYTES guard. parse_socks5_udp_packet split into _offsets + &[u8] wrapper so callers stay on the reusable buffer.

2. Base64 encoding moved off the single mux thread. New internal PendingOp { data: Option<Bytes>, encode_empty: bool } flows through mux_loop with raw bytes. Actual B64.encode(...) runs in fire_batch's spawned task, after the per-deployment semaphore. Up to ~3 MB of encoding per batch (50 ops × 64 KB) no longer serializes the single mux task.

3. Code quality (drive-bys). BatchAccum::push_or_fire collapses 4× ~25-line match arms into ~10 lines each. should_fire(pending_len, payload_bytes, op_bytes) predicate extracted with saturating_add. encode_pending(p) -> BatchOp extracted as a free function for direct test coverage.

Public API change: TunnelMux::udp_open and udp_data now take data: impl Into<Bytes> instead of Vec<u8> — existing in-tree callers passing Vec<u8>, &'static [u8], Bytes, or BytesMut all keep compiling.

200 → 208 lib tests (+8 regression: encode_pending_* × 4, should_fire_* × 3, batch_accum_reindexes_after_flush).

What's Changed

Full Changelog: v1.9.16...v1.9.18

v1.9.17

07 May 18:15

Choose a tag to compare

• Inject CORS response headers after relay — اضافه شد به‌جای فقط preflight short-circuit. مرورگرها در درخواست‌های cross-origin (مثل YouTube’s youtubei/v1/next / youtubei/v1/comments که از script context fire می‌شه) responseـی نیاز دارن با Access-Control-Allow-Origin که با origin درخواست match کنه + Allow-Credentials: true. Apps Script's UrlFetchApp.fetch() گاهی header‌های ACL مقصد رو preserve نمی‌کنه، یا destination با Allow-Origin: * پاسخ می‌ده که با credentialed request ناسازگاره. mhrv-rs حالا header‌های Access-Control-* پاسخ relay رو strip می‌کنه + permissive set تزریق می‌کنه که با origin درخواست echo می‌شه. علت ریشه‌ای: YouTube comments نمی‌اومدن load بشن + گاهی restricted-mode error به همین دلیل ظاهر می‌شد. ایده credit: ThisIsDara/mhr-cfw-go (Go rewrite of upstream Python). فقط برای درخواست‌هایی با Origin header اعمال می‌شه — non-CORS traffic (curl، apps native) دست‌نخورده می‌مونه. ۱۹۷ → ۲۰۰ lib test (+۳ regression test for CORS injection edge cases).

• Inject CORS response headers after relay (in addition to the existing preflight short-circuit). When browsers issue cross-origin fetches from script contexts — e.g. YouTube's youtubei/v1/next / youtubei/v1/comments calls, which fire from the player JS — they require the response to carry Access-Control-Allow-Origin matching the request's origin AND Allow-Credentials: true. Apps Script's UrlFetchApp.fetch() sometimes doesn't preserve the destination's ACL headers, or the destination returns Allow-Origin: * which is incompatible with credentialed requests. mhrv-rs now strips any Access-Control-* headers from the relay response and injects a permissive set keyed on the request's Origin. Root cause: YouTube comments not loading + the "restricted mode" error sometimes surfacing on cross-origin XHR responses the browser silently dropped. Idea credit: ThisIsDara/mhr-cfw-go (Go rewrite of upstream Python's CFW variant). Only applies when the original request had an Origin header — non-CORS traffic (curl, app-level HTTP clients) passes through byte-for-byte unchanged. 197 → 200 lib tests (+3 regression tests for CORS injection edge cases: wildcard-origin replacement, non-ACL header preservation, malformed-response passthrough).

Full Changelog: v1.9.16...v1.9.17

v1.9.16

07 May 16:47

Choose a tag to compare

• Fix Full mode large-download truncation at exactly 50 MiB (#863). Apps Script's response body cap is ~50 MiB; tunnel-node had a TCP_DRAIN_MAX_BYTES = 16 MiB per-session cap to stay under it, but multiple sessions in the same batch each contributed up to 16 MiB raw, summing past 50 MiB on busy VPS (Steam/CDN downloads with N≥4 concurrent sessions). Symptom: batch JSON parse error: EOF while parsing a string at line 1 column 52428630 (body_len=52428630) followed by session abort + download restart from 0. Fix: new BATCH_RESPONSE_BUDGET = 32 MiB total-batch cap; the drain loop tracks remaining budget across sessions and stops one short of the cliff. Sessions deferred this batch keep their buffered data and drain on the next poll (no data loss). New regression test drain_now_respects_caller_budget_below_per_session_cap. ۳۶ tunnel-node test (was 35) همه pass + ۱۹۷ lib test همه pass.

• Fix Full mode large-download truncation at exactly 50 MiB (#863). Apps Script's response body cap is ~50 MiB; tunnel-node had a TCP_DRAIN_MAX_BYTES = 16 MiB per-session cap to stay under it, but multiple sessions in the same batch each contributed up to 16 MiB raw, summing past 50 MiB on busy VPS (Steam / CDN downloads with N≥4 concurrent sessions). Symptom: batch JSON parse error: EOF while parsing a string at line 1 column 52428630 (body_len=52428630) followed by session abort + download restart from 0. Fix: new BATCH_RESPONSE_BUDGET = 32 MiB total-batch cap; the drain loop tracks remaining budget across sessions and stops one short of the cliff. Sessions deferred this batch keep their buffered data and drain on the next poll (no data loss). New regression test drain_now_respects_caller_budget_below_per_session_cap. 36 tunnel-node tests (was 35) + 197 lib tests all green.

Full Changelog: v1.9.15...v1.9.16