Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
Fix the v1.9.28 Code.gs JSON parse regression by keeping normal relay responses wrapped and using req.r only for redirect handling.\n\nTests:\n- node --check /tmp/Code-1265-fix.js\n- cargo test --lib
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
Ship PR #1115 from @yyoyoian-pixel: adaptive pipelined Full-mode polls, wseq-ordered tunnel-node writes, default STUN/TURN UDP blocking for faster WebRTC TCP fallback, and Android/desktop config support for the new block_stun path.
Local release gates passed on macOS: cargo test --lib, tunnel-node tests, cargo build --release, tunnel-node release build, desktop UI release build, and Android compileDebugKotlin with Android Studio JBR and the local SDK.
feat(tunnel): pipelined full-tunnel polls, ordered writes, and STUN blocking
Merged trusted PR #1115 by @yyoyoian-pixel after local verification and a small maintainer fix on the PR branch.
---
Answered via LLM, Supervised @therealaleph
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
Ship PR #958 by @dazzling-no-more.
CodeFull.gs now resolves DNS candidates in two passes, using one CacheService.getAll(keys) lookup per tunnel batch and reusing successful DoH answers inside the same batch. Long qnames now get SHA-256 cache keys instead of skipping cache, while parse/DoH failures still fall back to the tunnel-node path.
Verification:
- node assets/apps_script/tests/edge_dns_batch_test.js
- node assets/apps_script/tests/edge_dns_test.js
- cargo test --lib
- cargo build --release
- cargo build --bin mhrv-rs-ui --release --features ui
Curated fronting-group additions from @Shjpr9 (continuing the empirical IP/SNI resolution work from [#923](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/923)).
Changes to `config.fronting-groups.example.json`:
- **`vercel`**: switched fronting IP/SNI from `76.76.21.21` / `react.dev` to `216.230.84.193` / `nextjs.org`. Expanded covered-domain list with vercel infra subdomains (`ai-sdk.dev`, `now.sh`, `turborepo.org`, `vercel-dns.com`, `vercel.events`, etc.).
- **`fastly`**: switched IP/SNI from `151.101.1.140` / `www.python.org` to `151.101.128.223` / `pypi.org`. Removed `pypi.org` from the covered-domain list (it's now the SNI). Tightened CNN/etc. routing.
- **Renamed `netlify` → `amazon-cloudfront`**: more accurate label since the Netlify edge actually lives on CloudFront. New SNI `kubernetes.io` for the `3.33.186.135` IP.
- **Added 4 new groups**: `github-central` (api.githubcopilot.com routing), `github-alive` (alive.github.com / live.github.com), `github` (gist), `pubmed` (PubMed Central PMC).
This is the `*.example.json` template file — users copy it as a starting point for their `config.fronting-groups.json`. Not consumed in the binary by default, so no runtime risk.
Reviewed via Anthropic Claude. Thanks for the continued IP/SNI research, @Shjpr9 — this expands the working-out-of-the-box surface meaningfully.
Co-Authored-By: Shjpr9 <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
v1.9.25 ships two bug fixes from @dazzling-no-more:
- #1143 (#251): Android Full-mode `udpgw magic IP` moved from
198.18.0.1 → 192.0.2.1 to avoid clash with tun2proxy's virtual-DNS
allocator range. Resolves "Google + most websites silently broken
while Telegram works" on Android Full mode. Back-compat: legacy IP
still recognised by tunnel-node for one deprecation cycle.
- #1159 (#1145): MITM CA now installs into LibreWolf NSS stores
alongside Firefox. Closes `MOZILLA_PKIX_ERROR_MITM_DETECTED` HSTS
lockout on LibreWolf. Same class as already-closed #955/#959.
Cargo.toml bump (1.9.24 → 1.9.25) came in via #1143. This commit
amends the pre-baked v1.9.25 changelog to include #1159 and refreshes
Cargo.lock.
239 lib tests + 38 tunnel-node tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#1145. LibreWolf users were getting `MOZILLA_PKIX_ERROR_MITM_DETECTED` when visiting HSTS-protected sites (bing.com, youtube.com, …) through MasterHttpRelayVPN's MITM mode. HSTS gives no "Add Exception" affordance, so users were fully locked out of those sites 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 with strict privacy defaults; it 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, so the browser never trusted our CA.
Same failure mode behind already-closed #955 and #959 (Firefox-fork users reporting the identical "secure connection could not be established" symptom).
**Fix**: extend Mozilla-family profile discovery to cover LibreWolf on every supported platform. No behavioural change for Firefox installs.
## Changes (`src/cert_installer.rs`-only)
- Renamed `firefox_profile_dirs()` → `mozilla_family_profile_dirs()`. Same flat-vec return type so all five call sites read identically; the rename is signposting only.
- Extracted `mozilla_family_profile_roots(os, home, appdata, xdg_config_home)`: returns the union of Firefox + LibreWolf profile root directories, per-OS:
- **Linux**: `~/.mozilla/firefox`, snap variant, `~/.librewolf`, `$XDG_CONFIG_HOME/librewolf` (LibreWolf respects XDG by default).
- **macOS**: `~/Library/Application Support/Firefox/Profiles`, `~/Library/Application Support/LibreWolf/Profiles`.
- **Windows**: `%APPDATA%\Mozilla\Firefox\Profiles`, `%APPDATA%\LibreWolf\Profiles`.
- All five existing call sites (per-profile install, enterprise-roots fallback, uninstall, dry-run reporter, test-mode reporter) read from the renamed function without further changes.
## Verified locally (on top of v1.9.24)
- `cargo test --lib --release`: **239/239** ✅ (was 231; this PR adds 8 new tests covering LibreWolf-path discovery on each OS).
- `cargo build --release --features ui --bin mhrv-rs-ui`: clean ✅
## Will combine with #1143
PR #1143 already pre-baked the v1.9.25 release files (Cargo.toml + changelog). This PR doesn't touch either, so the squash-merge will land cleanly alongside #1143's changes. Will edit v1.9.25's changelog to include #1159 as a second bullet before tagging.
Reviewed via Anthropic Claude.
Co-Authored-By: dazzling-no-more <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#251. In Android Full mode, Telegram worked but Google search and most other websites failed silently. `apps_script` mode on the same setup was unaffected.
**Root cause**: the udpgw magic destination (`198.18.0.1:7300`) was inside `198.18.0.0/15` — the exact range tun2proxy's `--dns virtual` allocator uses to synthesise fake IPs for hostname lookups. Whenever virtual DNS assigned `198.18.0.1` to a real hostname, that hostname's traffic was intercepted by tun2proxy *itself* as a udpgw connection and dropped. Telegram was immune because it uses hardcoded numeric IPs; `apps_script` mode was immune because it never sets `--udpgw-server`.
**Fix**: move `UDPGW_MAGIC_IP` to `192.0.2.1` (RFC 5737 TEST-NET-1) — outside any virtual-DNS allocation pool. Coordinated change across the tunnel-node constant and the Android `--udpgw-server` flag.
## Back-compat
v1.9.25 tunnel-nodes still recognise the legacy `198.18.0.1:7300` for one deprecation cycle (removal in v1.10.0).
| Android | Tunnel-node | Full-mode UDP |
|---|---|---|
| v1.9.25 | v1.9.25 | ✅ fully fixed |
| ≤v1.9.24 | v1.9.25 | ⚠️ handshake works (legacy IP still recognised), but the old client still asks tun2proxy for `198.18.0.1`, so the #251 virtual-DNS collision is still live on-device |
| v1.9.25 | ≤v1.9.24 | ❌ breaks silently (old node rejects `192.0.2.1`) |
The fix lives on the client side (which magic IP it asks tun2proxy to reserve). The back-compat is on the tunnel-node side (accepting both during the deprecation window).
## Verified locally
- `cargo test --lib --release`: 231/231 ✅
- `cargo build --release --features ui --bin mhrv-rs-ui`: clean ✅
- `(cd tunnel-node && cargo test --release)`: 38/38 ✅ (+2 new tests for the IP change)
## Version bump
Cargo.toml already bumped to 1.9.25 in this PR; `docs/changelog/v1.9.25.md` pre-baked. Will combine with any other PRs landing into v1.9.25 before tagging.
Reviewed via Anthropic Claude.
Co-Authored-By: dazzling-no-more <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
Fixes#1088 — under Full mode, a single slow Apps Script edge cascade-killed every in-flight tunnel session sharing its batch. Users on 1.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.
Secondary: even with a parameterized timeout, the per-read `timeout(d, stream.read(...))` form would silently extend its budget if a peer drip-fed bytes just under `d` each — a slow edge could keep the loop alive past the outer `batch_timeout` and defeat the whole wiring.
## Fix (two changes in `domain_fronter.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** (`tokio::time::timeout_at(deadline, …)`) instead of per-read `timeout()`. Total elapsed across all header reads is bounded by `header_read_timeout`, regardless of read cadence.
## Bonus (in `tunnel_client.rs`)
3. **`TunnelMux::reply_timeout` co-varies with `batch_timeout`**: computed at construction as `fronter.batch_timeout() + 5s slack` instead of the 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.
## Verified locally (on top of v1.9.23 / main after #1117 merge)
- `cargo test --lib --release`: **231/231** ✅ (was 209 in v1.9.23 baseline; this PR adds 22 new tests covering the deadline/co-variance behavior)
- `cargo build --release --features ui --bin mhrv-rs-ui`: clean ✅
## Interaction with v1.9.20 (PR #1029)
PR #1029 added `H1_OPEN_TIMEOUT_SECS = 8` to bound the TCP+TLS handshake in `open()`. That bound is **separate** from the header-read timeout this PR addresses — both bounds exist in the same call chain. Issue #1131 (BuffOvrFlw, just opened) reports `h1 open timed out after 8s` errors which are the `open()` bound firing, not the header-read bound. Worth a follow-up to make `H1_OPEN_TIMEOUT_SECS` parameterized too, but that's a separate change.
Reviewed via Anthropic Claude.
Co-Authored-By: dazzling-no-more <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes#620 — `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.
## Changes (`tunnel-node/Dockerfile`-only)
- Dropped `# syntax=docker/dockerfile:1` parser directive and all `RUN --mount=type=cache,...` blocks
- Added cargo-chef multi-stage build (`chef` → `planner` → `builder`)
- Pinned `cargo-chef` to exact `0.1.77` with `--locked` for reproducible installs
- Bumped base from `rust:1.85-slim` → `rust: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)
- Removed `ARG TARGETPLATFORM` per-platform cache-id workaround — Docker's regular layer cache is already arch-scoped
## Non-changes (deliberate)
- `tunnel-node/Cargo.toml` left alone — the old Dockerfile comment claimed "matches MSRV in Cargo.toml" but no `rust-version` field actually exists. The Docker base bump is internal build-env, not a declared MSRV.
- Base image digest pinning left on tag refs — without Renovate/Dependabot to keep digests fresh, pinning trades automatic glibc/openssl/ca-certificates CVE patching for a reproducibility property this repo doesn't currently need.
## Verified locally
- `cd tunnel-node && cargo build --release`: clean (binary side unchanged)
- `cd tunnel-node && cargo test --release`: 36/36
- Local `docker build` couldn't run (daemon not started on the dev machine); the PR author's test plan documents successful build under classic Docker daemon.
Reviewed via Anthropic Claude.
Co-Authored-By: dazzling-no-more <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
Bumps Cargo.toml v1.9.22 → v1.9.23. Ships @dazzling-no-more's PR #1085
which converts relay_parallel_range into a writer-based API that streams
files >50 MiB chunk-by-chunk instead of trying to buffer the whole
response and hitting Apps Script's body ceiling. Four-way dispatch
(Buffered / Stream / FallbackSingleGet / RejectTooLarge) with O(1)
memory range planning + a 16 GiB hostile-origin guard. 209 → 227 lib
tests (+18 new). Unblocks GitHub releases / large CDN binaries through
apps_script mode without needing Full mode or external mirrors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes#1042 — 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 matches @Paymanonline's report in #1077, closed
prior as "architectural limit, use mirrors" — this PR makes it actually
work via streaming).
## 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-1.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:
- **`Buffered`** — `total ≤ 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. Matches pre-1.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. Extracted `inject_cors_into_head`
(head-only variant of `inject_cors_response_headers`) so the streaming
path can rewrite ACL headers before the body has been assembled.
## Verified locally on top of v1.9.22
- `cargo test --lib --release`: 227/227 ✅ (was 209; +18 new — 15 stated
in PR body + 3 incidental from the helper extractions)
- `cargo build --release --features ui --bin mhrv-rs-ui`: clean ✅
Manual repro of the 104 MiB v2rayN DMG download is unchecked in the PR
test plan — the unit tests cover the dispatch + streaming + flush
contracts thoroughly. The architectural reasoning is sound and the new
test count (+18) is concrete.
Reviewed via Anthropic Claude.
Co-Authored-By: dazzling-no-more <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes#1047. `_doSingle`'s normal-relay path (cache disabled or cache miss on
non-cachable request) ran `UrlFetchApp.fetch` → `getContent` → `base64Encode`
with no error wrapper. Any throw — most commonly when the response body
approaches Apps Script's ~50 MB ceiling and `base64Encode` blows the V8 heap,
also URL-too-long / payload-too-large / quota exhaustion / 6-minute execution
timeout — propagated unhandled, and Apps Script served its default
`<title>Web App</title>` HTML error page in place of the JSON envelope.
The Rust client (`parse_relay_json` in `domain_fronter.rs`) then failed to
find JSON and surfaced the cryptic `bad response: no json in: <!DOCTYPE html>...`
with no signal as to the actual cause.
The reporter's symptom — a single failing host (`shc-dist.lostsig.co`,
sonichacking.org) serving large ROM-hack binaries — matches this exactly.
Every other download worked because they were all under the body-size
ceiling.
## Fix
Wrap the normal-relay block in `_doSingle` with
`try { ... } catch (err) { return _json({ e: "fetch failed: " + String(err) }); }`.
Mirrors the per-item try/catch already present in `_doBatch`. Turns the
silent HTML crash into a structured `FronterError::Relay("fetch failed: …")`
on the client side that pinpoints the real underlying error.
Cache path intentionally untouched:
- `_fetchAndCache` already wraps its own fetch in try/catch and returns
`null` on any failure (so `_doSingle` falls through cleanly to the
normal relay).
- The cached-read path is bounded to ≤ `CACHE_MAX_BODY_BYTES` (35 KB)
so it cannot trip the size limits that caused this bug.
## Verified locally on top of v1.9.22
- `node --check assets/apps_script/Code.gs`: clean ✅
- `cargo test --lib --release`: 209/209 ✅ (sanity — no Rust change)
Reviewed via Anthropic Claude.
Co-Authored-By: dazzling-no-more <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
Completes [#1040](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/pull/1040) (v1.9.21). #1040 skipped H2 for `tunnel_batch_request_to` but missed `tunnel_request` — the single-op path used for plain `connect` ops. The 16-17s long-poll stalls persisted on full-tunnel sessions that go through the single-op path; this PR closes that gap.
Same fix shape: remove the H2 try/fallback/NonRetryable block from `tunnel_request`, go straight to H1 pool `acquire()`. H2 remains active for relay-mode paths (`do_relay_once_with`, exit-node `relay()`).
## All 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 PR** |
| `tunnel_batch_request_to` | tunnel batch | Full tunnel | Yes (PR #1040) |
| `tunnel_batch_request_with_timeout` | tunnel batch | Full tunnel | Yes (PR #1040) |
No other full-tunnel paths use H2 after this fix.
## Verified locally on top of v1.9.21
- `cargo test --lib --release`: 209/209 ✅
- `cargo build --release --features ui --bin mhrv-rs-ui`: clean ✅
Reviewed via Anthropic Claude.
Co-Authored-By: yyoyoian-pixel <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
Bumps Cargo.toml v1.9.20 → v1.9.21 and ships the changelog. Headline:
PR #1040 (@yyoyoian-pixel) skips H2 multiplexing for tunnel_batch_request_to
(Full-mode batch path). Tunnel batches already coalesce N ops into one
HTTP request, so H2 stream multiplexing has nothing to multiplex there;
the H2 path was actively hurting via long-poll stalls + silent batch
drops + pool starvation. H2 remains active for relay mode (apps_script),
where r0ar's #962 A/B data confirmed it's strictly better. A/B on Pixel
6 Pro: 0/30 vs 8-10/30 long-poll stalls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Skip H2 for `tunnel_batch_request_to` (the Full-mode batch path).
Tunnel batches already coalesce N ops into one HTTP request — H2 stream
multiplexing has nothing to multiplex. The H2 try/fallback path on this
specific code path 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 is **kept 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
- `tunnel_batch_request_to`: remove H2 try/fallback/NonRetryable block,
go straight to H1 pool `acquire()` (-54 lines).
- `run_pool_refill`: always maintain `POOL_MIN = 8`. Remove the
`POOL_MIN_H2_FALLBACK = 2` trim that was starving tunnel batches
(-12 lines).
## A/B results (Pixel 6 Pro, 30 batch samples each)
| Metric | H2 (stock v1.9.20) | H1 (this PR) | 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, block DoH/QUIC, TLS pool
tuning, PR #1029's warm-race fix).
## Verified locally on top of v1.9.20
- `cargo test --lib --release`: 209/209 ✅ (matches v1.9.20 baseline)
- `cargo build --release --features ui --bin mhrv-rs-ui`: clean ✅
## Interaction with PR #1029 (just shipped in v1.9.20)
PR #1029 added `H2Cell.dead: Arc<AtomicBool>` for synchronous dead-cell
detection. With this PR removing the H2 path for tunnel batches, the
dead-cell flag is no longer consulted on the tunnel batch path — that's
intentional (the flag now scopes to relay mode, which is the path it
was protecting in practice).
Reviewed via Anthropic Claude.
Co-Authored-By: yyoyoian-pixel <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
Bumps Cargo.toml v1.9.19 → v1.9.20 and ships the changelog. Headline
fix: the v1.9.15 Full-mode regression that's been tracking in #924 for
~3 weeks is resolved by @rezaisrad's PR #1029. Bisect-quality root
cause (h1 prewarm gated behind h2 handshake, both stall on cold start
under the same network conditions). Affected users can drop the
`force_http1: true` workaround now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes#924 — the canonical tracking thread for the v1.9.15 Full-mode
regression cluster that spanned ~3 weeks and 18+ duplicate reports.
**Root cause** (rigorously bisected to PR #799's `warm()`):
PR #799 added HTTP/2 multiplexing on the relay leg, and 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
connection. During that window the h1 fallback pool is empty, so any
request that arrives gets:
1. `Err((Relay("h2 unavailable"), No))` immediately → falls back to h1
2. h1 path calls `acquire()` → empty pool → cold `open()` → fresh
TCP+TLS handshake to `connect_host:443`
3. Same network conditions that stalled h2 also stall h1; cold open
exceeds the 30s `batch_timeout` enforced in `dispatch_full_tunnel`
4. User sees `batch timed out after 30s` while apps_script mode keeps
working
**Fix** (two commits, both `domain_fronter.rs`-only):
Commit 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 the h2 handshake stalls or hits its 8s timeout. `run_pool_refill`
trims the pool back to `POOL_MIN_H2_FALLBACK = 2` within 5s once h2
lands as the fast path.
Commit 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 to a blackholed `connect_host:443` doesn't
block `acquire()` until the outer 30s batch budget elapses (same
symptom #924 hits during the warm-race window).
- `H2Cell.dead: Arc<AtomicBool>` flipped by the connection driver task
when `Connection::await` ends (GOAWAY, network error, normal close).
`ensure_h2`'s fast path and `run_pool_refill`'s pool-target check
both consult the flag — known-dead cells are rejected within ≤5s
instead of waiting for `H2_CONN_TTL_SECS = 540s` to expire or for a
request to discover the breakage via `ready()` failure.
**API impact**:
`h2_handshake_post_tls`'s 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 — panic message unchanged.
**Verified locally on top of v1.9.19**:
- `cargo test --lib --release`: 209/209 (was 208; +1 new test
`ensure_h2_rejects_dead_cell_within_ttl`)
- `cargo build --release --features ui --bin mhrv-rs-ui`: clean
- `(cd tunnel-node && cargo test --release)`: 36/36
**Live end-to-end** (from PR description):
- 5 cold restarts (warm-up race window): 5/5 pass, 9.6-22.5s
- Concurrent burst (5 simultaneous SOCKS5 streams): 5/5
- Default full.json baseline: 200 OK in 13.3s
- `force_http1: true` sanity: 200 OK in 17.7s
**A/B vs PR #903** (per-session pipelining): commits land in disjoint
functions, cherry-picked clean on top. If #903 lands first, this needs
a mechanical rebase only.
Exemplary debugging work — bisect with concrete probe data, root cause
identification down to specific commit + line, working fix with bounded
timeouts on the two adjacent paths the same stall pattern could recur
through, +1 regression test. The kind of PR that lands fast.
Closes#924.
Reviewed via Anthropic Claude.
Co-Authored-By: rezaisrad <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
Bumps Cargo.toml v1.9.18 → v1.9.19 and ships the changelog. User-visible
binary changes since v1.9.18:
- UI a11y: widgets now associate with visible labels via
`.labelled_by(label_id)`, so NVDA / Narrator read the field name
instead of just the control type. Fixes#916. Verified by
@brightening-eyes (the blind user who reported the issue).
Also rolling up the exit_node Content-Encoding fix (c437598 / #964) in
the changelog — that's an asset-only commit (exit_node.ts), no Rust
binary change for it, but worth flagging in the release notes so
existing exit-node users know to redeploy their .ts script.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `egui::Id` plumbing through `form_row` so each widget can call
`.labelled_by(label_id)` to associate with its visible label. NVDA /
Narrator now reads the field name when focus moves to a control,
instead of just announcing the control type. AccessKit was already
enabled in `Cargo.toml` (`eframe` features), but without the explicit
`labelled_by` association, the screen reader had no way to map the
text input or combobox to its preceding label.
Verified locally on top of v1.9.18 / main:
- `cargo build --release --features ui --bin mhrv-rs-ui`: clean
- `cargo test --lib --release`: 208/208
Tested by @brightening-eyes (the blind user who originally reported
#916) with NVDA — confirmed working.
`form_row`'s signature now takes `widget: impl FnOnce(&mut egui::Ui,
egui::Id)`. Two existing callers that don't need the label_id (the
`Mode` combobox, `Share on LAN` checkbox) ignore it via `_label_id`
binding — no functional change there.
Closes#916.
Reviewed via Anthropic Claude.
Co-Authored-By: brightening-eyes <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multiple Iran-ISP users (#924 Recruit1992, #913 ehsan272727) report that
ngrok's free tier now exclusively hands out *.ngrok-free.dev domains for
new accounts, with no path to claim the older *.ngrok-free.app TLD. Some
Iran ISPs (TCI, Irancell, IRMCI confirmed) block *.ngrok-free.dev at DNS
or TCP. Symptom: curl from Iran network to ngrok URL times out, but works
from non-Iran.
Updates README.md and ngrok.md to:
1. Note the ngrok TLD shift (.app grandfathered, .dev for new accounts).
2. List ISPs confirmed to block *.ngrok-free.dev.
3. Add an "Alternative hosts" section recommending HuggingFace Spaces
(Docker SDK) as the most Iran-friendly option in 2026 — permanent
*.hf.space URL with no tunnel layer.
4. Update the URL behavior column for Method 2 since ngrok now gives a
permanent dev domain by default (not "new URL each session").
No code changes — docs only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mehdimalekidev reported `Content Encoding Error` when ChatGPT was routed
through Apps Script + exit-node. Root cause:
1. Browser → Apps Script with `Accept-Encoding: gzip, br` (default).
2. Apps Script forwards header to exit-node.
3. exit-node calls `fetch(destination)` which **auto-decompresses** the
response body (Deno / Bun / Node `fetch()` does this by default).
4. `resp.arrayBuffer()` returns **plain decompressed bytes** but
`resp.headers` still has `Content-Encoding: gzip` from the destination.
5. exit-node forwards both — Apps Script + Rust client pass them through —
browser sees `Content-Encoding: gzip` on plain bytes → "Content Encoding
Error: invalid or unsupported form of compression".
Fix: strip `Content-Encoding` and `Content-Length` from the response
headers before returning to the relay. The Apps Script + Rust transport
layer reframes the wire body anyway, so neither header is meaningful to
forward end-to-end.
Affects ChatGPT (gzip), Claude (br), Reddit (gzip), and any other
compressed exit-node-routed destination — the fix makes them all work.
No new test (this fixes a regression that would only show in a real
fetch path; mocking auto-decompression behavior is fragile). Manual
verification: tested ChatGPT through exit-node, response renders normally
in Firefox.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three orthogonal cache-correctness wins on `Code.gs`'s spreadsheet response
cache. All compound: more URLs become cacheable, the cache stops being
poisoned by transient outages, and 4xx storms cost zero quota.
**1. Gzip body before base64 storage** (new `Z` column, base64(gzip(rawBytes))
when worthwhile, skipped when already encoded or under 256 bytes). 3-5×
compression on text bodies — many ~100-150 KB responses now fit under the
35 KB cell ceiling that previously rejected them. `_getFromCache`
decompresses on hit and re-encodes for the wire — relay protocol's `b`
field stays `base64(rawBytes)`, no client change.
**2. 5xx never enters the cache.** A flapping upstream that returned 503
once was previously pinned for 24h, breaking the URL for every subsequent
client. `status >= 500` now early-returns the live response without
writing.
**3. Negative caching for persistent 4xx** (404/410/451 → 5-minute TTL when
upstream is silent on Cache-Control). Long enough to absorb favicon /
telemetry / dev-tools-probe storms; short enough that transient 404s
self-heal. Origin-stated max-age still wins when present.
**Schema migration**: cache sheet grows 7 → 8 columns (added `Z` flag).
Existing 7-column rows read back with `Z = undefined` (falsy → falls
through the not-gzipped branch) — fully backward-compatible, no user
action required.
**Verified locally** on top of v1.9.18 / main:
- `node --check Code.gs` clean
- `cargo build --bins --lib` clean
- `cargo test --lib --release`: 208/208 (no client-side change so this is
a sanity check only)
Behavior changes flagged in the PR body for users relying on the old
"everything cached for 24h" default — the 5xx-never-cached fix is
intentional, the 4xx 5-min default is overrideable via origin
Cache-Control.
Squash-merging.
Reviewed via Anthropic Claude.
Co-Authored-By: dazzling-no-more <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@BehroozAmoozad reported the docs reference a wrapper.ts file that
doesn't exist in the repo — the README.md / README.fa.md "Your own VPS"
row pointed at a wrapper.ts the user was supposed to write themselves.
For non-JavaScript users, this was a hard blocker.
Adds assets/exit_node/wrapper.ts that:
- Imports the handler from exit_node.ts directly (so editing the PSK in
exit_node.ts is the only setup step).
- Auto-detects Deno, Bun, or Node 22+ at runtime and uses the matching
HTTP server primitive (Deno.serve / Bun.serve / node:http).
- Reads PORT, HOST, CERT_FILE, KEY_FILE from env.
- Defaults to plain HTTP on port 8443 and recommends a reverse proxy
(Caddy / nginx / Cloudflare Tunnel) for TLS termination — the path
most VPS setups already have.
- Optional standalone TLS via CERT_FILE + KEY_FILE for users without
a reverse proxy.
README.md + README.fa.md updated to link the file directly and adjust
the deno run command to include the new --allow-env / --allow-read
permissions the wrapper needs.
No code changes — assets-only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
Bumps Cargo.toml v1.9.17 → v1.9.18 and ships the changelog for the
zero-copy mux refactor merged in 54552bb. No user-visible behavior
change; perf-focused release.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Performance refactor of full-tunnel mode hot path. No wire-protocol or
behavior changes — internal data flow only.
**1. Zero-copy reads via `Bytes`/`BytesMut`**
`tunnel_loop` and the SOCKS5 UDP receive loop drop their per-iteration
`Vec::to_vec()` copies. `MuxMsg::{ConnectData,Data,UdpOpen,UdpData}` now
carry `Bytes` instead of `Vec<u8>`/`Arc<Vec<u8>>`; the `Arc::try_unwrap`
dance for `pending_client_data` is gone (Bytes is already Arc-backed).
TCP path is threshold-based to avoid the obvious memory regression:
- 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, a queued tiny TLS record would refcount-
pin the full 64 KB recv buffer (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**
- `BatchAccum::push_or_fire` collapses 4× ~25-line match arms → ~10 each.
- `should_fire(pending_len, payload_bytes, op_bytes)` extracted with
`saturating_add` for a self-contained contract.
- `encode_pending(p) -> BatchOp` extracted as a free function so the
encoding contract is directly testable.
**Tests:** 208/208 (was 200, +8 new):
- `encode_pending_*` × 4 — base64-encode contract per MuxMsg variant
- `should_fire_*` × 3 — first-op, MAX_BATCH_OPS boundary, payload cap
- `batch_accum_reindexes_after_flush` — regression test for post-flush
reply index lookup in `fire_batch`
**Public API:** `TunnelMux::udp_open` and `udp_data` now take
`data: impl Into<Bytes>` instead of `Vec<u8>`. Existing call sites
keep compiling.
Reviewed via Anthropic Claude.
Co-Authored-By: dazzling-no-more <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Montazeran8 noticed two stale doc claims in the ngrok tunnel guide:
1. ngrok.md Step 8 told users to run `mhrv-rs test` to verify a Full-mode
tunnel — but `mhrv-rs test` is wired for the apps_script relay path only
and refuses to run in Full mode. Fixed to direct users to ipleak.net /
whatismyipaddress.com instead.
2. ngrok.md "Renewing the Tunnel" + "Limitations" sections claimed the
*.ngrok-free.app URL changes every run. ngrok's free tier now ships with
a default static domain per account, so the URL stays the same across
runs once assigned. Updated both sections to distinguish static-domain
accounts (no CodeFull.gs redeploy needed) from older accounts that opted
out.
3. README.md "Limitations" + "After Starting the Tunnel" sections updated
to reflect that only Method 1 (cloudflared Quick) has truly volatile URLs.
Method 2 (ngrok) keeps the same URL on accounts with a static domain.
No code changes — doc-only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
mhrv-rs already short-circuits CORS preflight (OPTIONS → 204 with permissive ACL headers, no relay round-trip). What was missing: the actual cross-origin fetch that follows the preflight also needs CORS-compliant headers on the response, or the browser drops the response and the JS layer sees a CORS failure even though the relay succeeded.
Apps Script's UrlFetchApp.fetch() preserves the destination's response headers inconsistently — sometimes the origin returns `Access-Control-Allow-Origin: *` (which is incompatible with `Allow-Credentials: true`), sometimes drops ACL headers entirely. The visible symptom is YouTube comments not loading + the "restricted mode" error surfacing on responses the browser silently rejected before the JS handler could read them.
Fix: after the relay returns, if the original request had an `Origin` header, we strip any `Access-Control-*` headers the destination emitted and inject a fresh permissive set echoing the request's origin (required for credentialed fetches; `*` is invalid alongside Allow-Credentials).
The body is preserved byte-for-byte; only the header block before the first \r\n\r\n is rewritten. Malformed responses (no header/body separator) round-trip unchanged so we never corrupt non-HTTP/1.x bytes.
Idea credit: ThisIsDara/mhr-cfw-go — Go rewrite of upstream Python's CFW variant added the same fix; reviewing their code surfaced the gap in mhrv-rs. Their other claimed improvements (HTTP/2, connection pooling, request coalescing, response caching, range-parallel) are already in mhrv-rs.
Tests: 200 lib (was 197, +3 covering wildcard-origin replacement, non-ACL header preservation, malformed-response passthrough) + 36 tunnel-node green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).