* Update config.fronting-groups.example.json
Added more Fastly fronting groups
* Update config.fronting-groups.example.json
Added `github.io` which is one of the most important domains on the github website
TLS pool improvements:
- Increase POOL_TTL from 45s to 60s so connections live longer
- Add POOL_MIN (8): background refill loop keeps at least 8 ready
TLS connections so acquire() never pays a cold handshake
- Refill checks every 5s, only counts connections with ≥20s
remaining as "healthy" — nearly-expired entries don't count
- warm() now opens sequentially (500ms gaps) with 8s expiry
offset per connection so they roll off gradually instead of
all expiring together after a cliff
- acquire() picks the freshest connection (most remaining TTL)
instead of popping whatever is on top
Coalesce step increase:
- DEFAULT_COALESCE_STEP_MS: 10 → 200. The dominant bottleneck is
the Apps Script round-trip (~1.5s), so the extra 200ms wait is
negligible to the user but lets significantly more ops land in
each batch — measured 3–5 ops/batch vs 1 op/batch at 10ms
during page loads, cutting round-trips roughly in half.
Tested on Android (Pixel 6 Pro) with full-mode tunnel. Pool
hit rate went from 96% (POOL_MIN=4) to 100% (POOL_MIN=8) —
zero cold TLS handshakes during requests.
Co-authored-by: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Problem:
PR #468 changed `tunnel_doh` default to `true` (tunnel DoH through
Apps Script) to avoid ISP-blocked DoH on censored networks. But this
added ~1.5s of Apps Script round-trip per DNS lookup — every page
load got noticeably slower because Chrome's DoH connections had to
traverse the full tunnel path before the page could even start
connecting.
The Android side had a separate bug: `tunnelDoh` defaulted to
`false` but only emitted `tunnel_doh` to JSON when `true`. Since
the Rust default is `true`, omitting the field meant Rust always
tunneled DoH regardless of the Android UI setting — bypass_doh was
silently broken on Android.
Fix:
- Add `block_doh` config option: immediately reject (RST) connections
to known DoH endpoints. Browsers fall back to system DNS, which
tun2proxy handles via virtual DNS (instant, zero tunnel cost).
Eliminates the DoH round-trip without exposing DoH connections to
the ISP (unlike bypass_doh which sends DoH direct).
- Default `block_doh: true` on Android — tested on Chrome/Brave,
falls back to virtual DNS correctly.
- Fix Android `tunnelDoh` default to `true` (matches Rust).
- Always emit `tunnel_doh` and `block_doh` explicitly in Android
JSON serialization — no more default-mismatch bugs.
- Add Block DoH and Bypass DoH toggles in Android Advanced UI.
Block DoH takes priority; Bypass DoH is disabled when Block is on.
Tested on Pixel 6 Pro: zero chrome.cloudflare-dns.com tunnel
sessions with block_doh=true. All DNS resolves instantly via
tun2proxy virtual DNS.
Co-authored-by: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (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).
The val.town founder asked us not to promote using their service. This
commit removes every val.town reference from the codebase and rewrites
the exit-node guides to be platform-agnostic.
Changes:
- Renamed assets/exit_node/valtown.ts → assets/exit_node/exit_node.ts.
TypeScript itself is unchanged — same web-standard Request/Response/
fetch API that runs on any serverless runtime.
- Rewrote assets/exit_node/README.md and README.fa.md to recommend
Deno Deploy as the primary host for users who want a free serverless
TS endpoint, with fly.io and your-own-VPS as alternatives. CF Workers
is explicitly called out as not-helpful (CF outbound is still on
CF's flagged IP space).
- Updated all val.town mentions in source comments (src/config.rs,
src/domain_fronter.rs, src/bin/ui.rs) to neutral wording.
- Updated config.exit-node.example.json `_comment` strings and the
example URL.
- Updated main README.md FAQ entries (Persian + English) and
docs/guide.md / docs/guide.fa.md.
- Old changelog files (v1.9.4 / v1.9.5 / v1.9.9) had val.town mentions
retroactively replaced too — same redaction principle.
- Bumped to v1.9.10 with a changelog noting the rename + Telegram
channel brief format from earlier today.
Users who already have an exit node deployed (on whichever host they
picked) don't need to change anything — the wire protocol is identical
and the renamed script is byte-identical to the old one.
Tests: 179 lib + 35 tunnel-node green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Telegram channel posts up through v1.9.9 inlined the full Persian half of `docs/changelog/v{version}.md` (often >2000 chars), with sub-bullets, contributor mentions, and architectural prose. In a chat-client viewport the result was an unreadable wall of mixed RTL Persian + LTR `<code>` / `<b>` spans + nested bullets that scrolled past most readers.
Switched to brief-extracted English instead:
- Added `brief_changelog(text)` — keeps only top-level `• ` bullets (drops sub-bullets), strips "by @user with full root cause + fix" / "from @user" prefatory phrases, replaces `[#nnn](url)` with `#nnn` for inline issue refs, cuts each bullet at the first natural sentence boundary (`:` after pos 30, `. `, ` — `), hard-caps at 200 chars per bullet, and trims any dangling unbalanced `(` or `[` left by the truncation.
- Both posts (files-channel announcement + main-channel cross-link) now use `english_brief = brief_changelog(english_notes)` instead of the full Persian.
- Title and footer chrome of both posts switched to English ("released" / "Files (Android, Windows, ...)" / "Channel:" / "or:").
The full Persian + full English text stays in `docs/changelog/v*.md` for archival; only the channel post becomes brief.
Verified locally on v1.9.7 / v1.9.8 / v1.9.9 — produces 246–458 char briefs with clean bullet structure, no dangling parens, no contributor noise.
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).
Android (#700 from @ilok67):
- Reordered MhrvVpnService.teardown() to call Native.stopProxy() FIRST. The previous order (tun2proxy.stop → tun.close → join → stopProxy) crashed SIGSEGV ~2s after Disconnect: tun2proxy's worker thread was blocked in native code on a SOCKS5 socket read; after the 2s+4s timeouts expired with the worker still alive, Native.stopProxy freed the runtime including that socket, and the worker hit use-after-free in the next read. The old comment claimed "runtime shutdown will knock the rest of the world over" — wrong, Native.stopProxy can't forcibly terminate a separate native thread, it just frees memory the other thread is still using. New order closes the socket first, the worker's blocking read returns with EOF, the worker exits cleanly through its error path, and the join is then near-instant.
tunnel-node (PR #695 from @dazzling-no-more, merged):
- Cleanup now tracks eof'd sids from drain_now's return value, not the raw atomic — was silently dropping the tail on >16 MiB buffers when EOF arrived between polls.
- Phase-1 `data` op no longer holds the sessions map across upstream write/flush — was head-of-line-blocking every other batch op.
- Mixed TCP+UDP batch wait switched from tokio::join! to tokio::select! — was paying the UDP LONGPOLL_DEADLINE (15 s) on TCP-ready bursts.
- Watcher tasks now wrapped in AbortOnDrop newtype — was leaking Arc<Inner> permits when select!'s loser arm dropped its future.
- 2 new regression tests, 35/35 pass.
Example configs:
- config.exit-node.example.json: added aistudio.google.com + ai.google.dev to default hosts (#701 — AI Studio sanctions Iran IPs).
- config.fronting-groups.example.json: PR #696 from @Shjpr9 added Reddit/Fastly/Pinterest/CNN/BuzzFeed family domains on the Fastly 151.101.x.x edge.
Tests: 179 lib + 35 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).
Android (#666 from @ilok67 with full root cause):
- MainActivity.onStop was sending ACTION_STOP via startService() AND immediately calling stopService() on the same service. ACTION_STOP runs teardown() on a background thread that stopSelf()s at the end; the redundant stopService() triggered onDestroy() in parallel, racing the lifecycle and crashing on every Disconnect tap. Removed the stopService() — ACTION_STOP alone is sufficient for both the live-service and the zombie-after-process-death cases. The tornDown AtomicBoolean already guards against double-teardown of native state but couldn't protect against OS-level stopSelf vs stopService race.
UI (#665 from @cmptrnb):
- Test Relay button was showing red "test result: fail" status when used in full or direct mode. The underlying test_cmd::run deliberately refuses in those modes because probing Apps Script directly while the data plane goes via tunnel-node would give a misleading result, but the refuse path was getting translated to generic "test failed". UI now checks mode before running and shows a mode-specific explainer for full/direct (point users at https://whatismyipaddress.com in the browser via the proxy as the right way to verify).
Includes already-merged PR #674 from @yyoyoian-pixel: drop client coalesce_step + tunnel-node straggler settle_step from 40 ms → 10 ms, raise tunnel-node settle max from 500 ms → 1000 ms. Asymmetric tuning: fast-fire when nothing else is queued, but adaptive coalesce on bursts. Backwards compatible — existing configs with explicit `coalesce_step_ms: 40` keep old behavior.
Tests: 179 lib + 33 tunnel-node green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The batch coalesce step controls how long the client (and the
tunnel-node's straggler settle) waits between checking for more ops
to pack into the same batch. At 40 ms the wait was conservative —
good for packing uploads but needlessly slow on the download path
where the tunnel-node round-trip, not coalescing, is the bottleneck.
Lowering the step to 10 ms means we fire batches almost immediately
when there's nothing else queued, cutting ~30 ms of dead air on
every download-dominated round-trip. When both sides DO have data
in flight (uploads, bursty page loads), the adaptive reset still
works: each arriving op resets the 10 ms step timer, so a rapid
burst naturally coalesces up to the 1 s hard cap without wasting
quota on many small batches.
In short: don't wait when there's nothing to wait for; batch
aggressively when there is.
Client side:
- DEFAULT_COALESCE_STEP_MS 40 → 10 ms
- DEFAULT_COALESCE_MAX_MS unchanged at 1000 ms
Tunnel-node side:
- STRAGGLER_SETTLE_STEP 40 → 10 ms (matches client step)
- STRAGGLER_SETTLE_MAX 500 → 1000 ms (more room to pack
straggler responses when upstream targets reply at different
speeds — saves Apps Script quota on the return leg)
Users who prefer the old behaviour can set "coalesce_step_ms": 40
in config.json.
Co-authored-by: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the YouTube thumbnail + caption + separate text-guide line with a single centered, RTL-directed paragraph containing two Persian-numerated items: ۱ for the video (YouTube) and ۲ for Kian Irani's text guide. Cleaner, less vertical space, and the (YouTube) suffix on item ۱ tells the reader where the link points without needing the big thumbnail to imply it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symmetric with the text-guide caption right below it ("راهنمای جامع متنی…") — تصویری/متنی parallel makes the video-vs-text distinction obvious at a glance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two links on one line: "راهنمای جامع متنی راه اندازی به زبان فارسی" → his guide, "Kian Irani" → his GitHub. Plain "با تشکر از" between them. Cleaner than the previous two-line + sub-text version.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a centered link below the video pointing to https://kian-irani.github.io/mhrv-setup-full-tunell/, with credit to @KIAN-IRANi (https://github.com/KIAN-IRANi). Both links open in a new tab. Persian-speaking users now have three escalating depths of guide right at the top of the README: the 5-min Quick Start, the YouTube walk-through, and Kian Irani's full long-form guide.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds target="_blank" + rel="noopener noreferrer" so the click doesn't navigate the README away from the repo. The `rel` value is the conventional safety pair: `noopener` blocks the new tab from accessing `window.opener`, `noreferrer` strips the Referer header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
YouTube iframe embeds get sanitized by GitHub's README renderer, so we use the standard "thumbnail-image-linking-to-youtube.com" pattern instead — visually identical (Play overlay + auto-loaded by YouTube's CDN), survives the sanitizer, and one click opens the video on YouTube.
Caption: "راهنمای راه اندازی به فارسی:".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Up through v1.9.7 the Telegram posts said only "📦 mhrv-rs vX.Y.Z منتشر شد" + a hashtag and a link to the files channel — subscribers had to click through to GitHub to see what actually changed. Now the announcement post in the files channel and the cross-link post in the main channel both inline the Persian half of `docs/changelog/v{version}.md`.
How it works:
- New `load_changelog(repo_root, version)` reads `docs/changelog/v{version}.md`, strips the leading `<!-- ... -->` editor comment, splits on the lone `---` line that separates Persian from English. Returns (None, None) if the file doesn't exist (lets out-of-band re-publishes for old tags whose changelog file was never landed work without crashing).
- New `md_to_tg_html(md, max_len)` does a minimal markdown → Telegram-flavoured-HTML conversion: `**bold**`, `[text](url)`, `` `code` `` are translated; nested patterns (e.g. `[`code`](url)`, `**[`code`](url)**`) work via a placeholder/unwind pass that loops until stable. Truncates at the 4096-char sendMessage limit, snapping to a newline boundary so a span isn't cut in half, with a "see full notes on GitHub" tail.
- Falls back gracefully if the changelog file is missing — uses the old skeleton message.
Verified locally on docs/changelog/v1.9.7.md: 3039 chars after conversion, well under the 4096 limit, all bold / code / link spans render correctly including nested ones (`[`assets/apps_script/Code.gs`](url)` becomes `<a href="url"><code>assets/apps_script/Code.gs</code></a>`).
This change takes effect on the next release (v1.9.8+); v1.9.7 is already published with the old skeleton.
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).
The desktop UI now has a single "Share with other devices on my Wi-Fi / network" checkbox in place of the cryptic `listen_host: 0.0.0.0` text field. When enabled:
- Bind auto-flips to 0.0.0.0
- LAN IP is detected via the standard UDP-connect trick (no actual traffic) and shown alongside the proxy ports for handing to the guest device
- Tooltip explains macOS Firewall prompt behavior
- A pre-existing custom bind IP in config.json is preserved with a "Custom bind: ..." badge so the next Save can't clobber it
New `src/lan_utils.rs` module with detect_lan_ip / is_share_on_lan / is_loopback_only helpers (3 unit tests).
Also rolls in the v1.9.6 changes (release was cancelled before binaries shipped):
- Code.gs / CodeFull.gs: removed duplicate doGet, switched HtmlService -> ContentService, stripped X-Forwarded-* family in SKIP_HEADERS, added SAFE_REPLAY_METHODS fallback when fetchAll throws as a whole.
- Rust client: parse_relay_json now unwraps goog.script.init iframe wrappers (defense-in-depth for legacy deployments or redirect-induced GET-on-doGet).
- README rewritten as a short bilingual landing page; advanced reference moved to docs/guide.md + docs/guide.fa.md. Persian guide's `[x]` task list replaced with a table because GitHub's RTL renderer mangles checkbox positions inside `<div dir="rtl">`.
Tests: 6 new regression tests (3 goog.script.init unwrap + 3 lan_utils). 179 lib + 33 tunnel-node tests all passing. Bind on 0.0.0.0 smoke-tested via lsof.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server-side (Apps Script) fixes — users replace their Code.gs with assets/apps_script/Code.gs (or CodeFull.gs for full mode) and Manage deployments → ✏️ → New version → Deploy:
- Removed duplicate doGet in Code.gs (HtmlService one was overriding ContentService one due to JS hoisting → every GET to /exec returned a goog.script.init iframe instead of the placeholder HTML)
- CodeFull.gs doGet switched from HtmlService to ContentService (same reason)
- SKIP_HEADERS now strips X-Forwarded-* / Forwarded / Via family — second line of defense to v1.2.9's client-side stripping (#104), in case a misconfigured upstream proxy adds these
- _doBatch fallback when UrlFetchApp.fetchAll() throws as a whole — per-item fetch on safe methods so one bad URL no longer poisons the entire batch (port from masterking32@3094288)
Client-side (Rust) defense-in-depth:
- parse_relay_json now unwraps goog.script.init("...userHtml...") if any deployment returns the iframe-wrapped form (legacy Code.gs, or a redirect that GETs doGet). New extract_apps_script_user_html + decode_js_string_escapes helpers. Tested against a real deployment's doGet response.
Docs:
- README rewritten as short bilingual landing page (English + Persian RTL) targeting normal users; advanced reference moved to docs/guide.md + docs/guide.fa.md.
Tests: 3 new regression tests. 176 lib + 33 tunnel-node tests passing.
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).
Issue #585 from @gregtheph: v1.9.4's exit-node feature failed for every
ChatGPT/Claude/Grok request with `io: peer closed connection without
sending TLS close_notify` and fell back to direct Apps Script (which
can't reach those sites either, producing the no-json error chain).
Root cause: rustls is strict about TLS shutdown — when the peer (val.town's
host) closes the underlying TCP without first sending a TLS close_notify
alert, rustls surfaces this as `io::ErrorKind::UnexpectedEof`. Our
read_http_response propagated this as a hard error, even when the body
was already complete per Content-Length.
Fix: treat UnexpectedEof the same as `n == 0` (graceful EOF). If
Content-Length is satisfied, return the response; if mid-body truncation,
still error as BadResponse. Same handling added to the chunked reader
and the no-framing reader.
4 new regression tests:
- read_http_response_tolerates_unexpected_eof_with_content_length
- read_http_response_tolerates_unexpected_eof_no_framing
- parse_exit_node_response_unwraps_valtown_envelope
- parse_exit_node_response_surfaces_explicit_error
173 lib tests + 33 tunnel-node tests + both release builds passing.
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).
Two changes addressing user-reported issues today:
1. Exit-node feature ported from upstream masterking32@464a6e1d, with
hardening. Cloudflare-protected sites (chatgpt.com, claude.ai,
grok.com, x.com, openai.com) flag Google datacenter IPs as bots and
return Turnstile / CAPTCHA / 502 challenges. Apps Script's UrlFetchApp
exits from those IPs, so v1.9.3 surfaces these as "Relay error: json:
key must be a string..." with no apps_script-mode workaround.
Now a small TypeScript HTTP endpoint (assets/exit_node/valtown.ts)
deployed on val.town / Deno Deploy sits between Apps Script and the
destination. Chain: client → Apps Script (Google IP) → val.town
(non-Google IP) → destination. Destination sees val.town's IP, no
CF challenge.
Config:
"exit_node": {
"enabled": true,
"relay_url": "https://...web.val.run",
"psk": "<openssl rand -hex 32>",
"mode": "selective",
"hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"]
}
Hardening over upstream: PSK fail-closed if still placeholder (fresh
deploy can't be open relay), loop guard (refuses fetch of own host),
explicit 503 on misconfigured. Fallback to direct Apps Script on exit
node failure (CF-affected sites fail, others keep working). Setup
docs in English + Persian at assets/exit_node/README*.md. Example
config at config.exit-node.example.json.
2. Removed the legacy `telegram` job from release.yml. With
TELEGRAM_NOTIFY_ENABLED repo var set to true, every release was
producing two duplicate APK posts on the main Telegram channel: the
old bundled-APK-on-main job AND the newer per-file files-channel
posts (telegram-publish-files.yml). Only the per-file flow is wanted.
Legacy job and its helper telegram_release_notify.py are gone.
Recoverable from git log if anyone needs the bundled pattern back.
169 mhrv-rs lib tests + 33 tunnel-node tests + UI build clean.
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).
- PR #535 by @yyoyoian-pixel: Switch toggle for youtube_via_relay in
Android Advanced settings, matching the desktop UI checkbox. Closes
the parity gap that forced Android users to hand-edit config.json.
Closes#520.
- ci(telegram-publish): --clobber on gh release download so retries
survive partial downloads (caused the v1.9.2 telegram publish to
fail; manually re-triggered).
Parity fix: the desktop UI already has a youtube_via_relay checkbox in src/bin/ui.rs, but the Android UI was missing it — users had to hand-edit config.json on Android. By @yyoyoian-pixel.
Adds youtubeViaRelay field to MhrvConfig with JSON serialization, deserialization, and config-sharing encode. New Switch toggle in Advanced settings section matching the desktop UI checkbox. EN + FA string resources for label and helper text.
Closes#520 (vampire137 Android youtube_via_relay request).
Local verification: cargo test --lib 169/169 passing, both release builds clean. Pure Kotlin/Android change; no Rust impact.
The v1.9.2 telegram publish failed because attempt 1 hit a transient
HTTP 500 from GitHub's release-asset CDN mid-download, leaving partial
files in assets/. Attempts 2 and 3 then errored on "already exists"
because gh release download refuses to overwrite without --clobber.
With --clobber, retries can complete cleanly even when an earlier
attempt left files behind. No change to the success path.
Expose the `youtube_via_relay` config flag in the Android UI, matching
the desktop checkbox. Adds the field to MhrvConfig with serialization
round-trip (toJson / loadFromJson / encode), a Switch in the Advanced
section, and EN + FA string resources.
Co-Authored-By: Claude Opus 4.6 (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).
Pure docs + GAS/Worker addition shipped via PR #533 (#380 / #393 audit
task). No Rust client changes. Bumping to v1.9.2 so users get the new
deploy assets in the next release tarball + Telegram channel binaries
include the updated docs.
Adds opt-in alternative backend for mode: "apps_script". Deploy Code.cfw.gs (new GAS variant) + worker.js (Cloudflare Worker), and Apps Script becomes a thin auth+forward layer that pushes the outbound fetch to CF's edge. By @dazzling-no-more.
Closes the audit task on the v1.9.x roadmap (#380, #393).
Pure docs + GAS/Worker addition; mhrv-rs Rust client unchanged. Same JSON envelope on the wire, same mode/script_id/auth_key — only difference is what the deployed Apps Script does after authentication.
Hardened over upstream denuitt1/mhr-cfw: per-request AUTH_KEY check, fail-closed on placeholder secret, x-relay-hop loop guard + self-host fetch block, SKIP_HEADERS parity with Code.gs, batch handler with Promise.all + soft cap MAX_BATCH_SIZE = 40 paired with WORKER_BATCH_CHUNK on GAS side.
Honest limitations called out in docs:
- Not compatible with mode: "full" (raw-TCP/UDP tunnel ops not ported)
- YouTube long-form gets worse (30s CF Worker wall vs Apps Script 6min — SABR cliff arrives sooner)
- Cloudflare anti-bot unaffected (Worker IP often stricter than Google IP)
- No day-one UrlFetchApp quota relief (batch path unreachable from current single-shape client)
English + Persian docs (assets/cloudflare/README.md + README.fa.md) covering setup, three-matching-AUTH_KEY security model, trade-off table, full-mode incompatibility section.
Local verification: cargo test --lib 169/169 passing, both release builds clean.
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).
Four small fixes that address recurring user-issue patterns:
- src/config.rs / src/domain_fronter.rs: auto_blacklist_strikes,
auto_blacklist_window_secs, auto_blacklist_cooldown_secs config fields
(#391, #444). Previously 3 strikes / 30s window / 120s cooldown were
hard-coded. Single-deployment users on flaky networks hit this too
aggressively; multi-deployment users want tighter fail-fast. Defaults
preserve historical behavior. Power-user file edit only — no UI
control yet. Clamps to [1, 86400] for durations.
- src/config.rs / src/domain_fronter.rs / src/tunnel_client.rs:
request_timeout_secs config field (#430, masterking32 PR #25).
Replaces hard-coded BATCH_TIMEOUT 30s. DomainFronter::batch_timeout()
exposes the value, fire_batch reads it. Clamped to [5s, 300s].
- tunnel-node/src/main.rs: detect MHRV_AUTH_KEY env var being set
while TUNNEL_AUTH_KEY is unset, and emit a specific warning pointing
at the right env var name. Catches the recurring #391/#444 docker
run typo that made users chase phantom AUTH_KEY-mismatch decoys.
- assets/launchers/run.bat: when both UI renderers (glow + wgpu)
fail on older Windows / RDP / VM-without-GPU, fall back to launching
mhrv-rs.exe (CLI) instead of just printing "open an issue".
Addresses #417 / #426 / #487. CLI has the same proxy functionality
on 127.0.0.1:8085 (HTTP) / :8086 (SOCKS5).
169 mhrv-rs lib tests + 33 tunnel-node tests still passing. UI build
clean. ConfigWire round-trips the new fields with skip-default-on-write
so unchanged configs stay clean.
Reddit serves images from redd.it (their CDN-style image host) which is
also on Fastly. Without this entry, the example config matches reddit.com
but image loads still fall back to direct, which is unreliable from Iran
ISPs. Suggested by @Shjpr9 in #502.
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).
Three substantive PRs landed for this release plus an Iran-safe DoH default:
- #488 by @dazzling-no-more (with credit to @patterniha): fronting_groups
config field generalizes the Google-edge SNI-rewrite trick to any
multi-tenant CDN edge (Vercel, Fastly, etc.). Renames `mode = "google_only"`
→ `mode = "direct"` with a deprecated alias keeping existing configs working.
This is the v1.9.0 headline — new top-level config field + public mode-string
rename are minor-bump territory. xmux moves to v1.10.0.
- #494 by @dazzling-no-more: edge-cache DNS at Apps Script (CodeFull.gs)
using CacheService. udp_open / port=53 ops served from cache or DoH
fallback chain (Cloudflare → Google → Quad9). Cache hits drop typical
first-hop DNS latency from 600-1200ms to 200-400ms. Default-on, opt-out
via ENABLE_EDGE_DNS_CACHE; every failure mode falls through to existing
tunnel-node forward path (zero regression).
- #483 by @yyoyoian-pixel: default listen_host from 127.0.0.1 to 0.0.0.0
so an Android phone running the tunnel + an iPhone/laptop on the same
hotspot can use it as proxy. Old configs with explicit 127.0.0.1 are
honored (not overwritten).
Plus: default `tunnel_doh: true` (flipped from false in v1.8.x) per #468
— Iran ISPs filter direct connections to dns.google, chrome.cloudflare-dns.com,
and other pinned DoH hosts. The bypass-on default silently broke DNS for
the dominant Iranian userbase. The safe default keeps DoH inside the
tunnel; non-Iran users can opt back into the bypass for the latency win.
Backwards-compatible — any config with explicit tunnel_doh keeps its setting.
169 mhrv-rs lib tests + 33 tunnel-node tests + 11 edge-DNS JS tests all
passing. Clean release + UI builds.
Generalizes the Google-edge SNI-rewrite trick to any multi-tenant CDN edge (Vercel, Fastly, …). By @dazzling-no-more, with credit to @patterniha for the original technique (MITM-DomainFronting).
New `fronting_groups: [{name, ip, sni, domains}]` config field — matched hosts get MITM-decrypted at the local CA and re-encrypted upstream against `ip` with `sni` as the TLS SNI. Works alongside the built-in Google fronting and `passthrough_hosts`.
Rename: `mode = "google_only"` → `mode = "direct"`. Old name kept as deprecated alias on parse — no existing config / saved settings break. UI dropdown updated, on-disk file migrates on next Save.
Review fixes folded in: SNI validated via rustls at config-load gate, Vec<Arc<>> refcount instead of clone-on-match, byte-level dot-anchored matcher (no per-match format!()), startup warnings for inert combos.
Working example at config.fronting-groups.example.json. Full doc at docs/fronting-groups.md including precedence rules + the cross-tenant Host-header leak warning.
Test plan: cargo build --release clean, cargo test --lib 169/169 passing (+8 new: dispatch matching, config validation, alias back-compat).
Per author's recommendation, this lands as the v1.9.0 headline — new top-level config field + public mode-string rename are minor-bump territory. xmux moves to v1.10.0.
Edge DNS caching at the Apps Script layer using CacheService. By @dazzling-no-more.
Intercepts `udp_open`/port=53 ops in `_doTunnelBatch` and serves them from CacheService (cache hit) or DoH (cache miss). Cache hits drop typical first-hop DNS latency from ~600-1200ms to ~200-400ms.
- DoH fallback chain: Cloudflare → Google → Quad9 over RFC 8484 GET
- Per-qtype cache key keeps A and AAAA from colliding
- Min RR TTL clamped to [30s, 6h]; NXDOMAIN/SERVFAIL get 45s negative cache; NODATA-with-SOA honors SOA TTL per RFC 2308 §5
- Splice helper preserves op-index ordering across mixed TCP+DNS batches
- Default-on, opt-out via `ENABLE_EDGE_DNS_CACHE`; every failure mode falls through to existing tunnel-node forward path (zero regression)
- Privacy-aware: CacheService is volatile + has no on-disk artifact (vs Sheets which would persist a Drive-listed log of every domain users resolve)
11 pure-JS tests covering parsers, txid non-mutation, TTL high-bit clamp, NXDOMAIN-with-SOA TTL extraction, malformed/truncated input rejection, splice correctness for mixed batches. All 160 Rust lib tests still passing.
Closes#481. Default `listen_host` from 127.0.0.1 to 0.0.0.0 so Android-phone-as-tunnel + iPhone/laptop on same hotspot can use it as proxy. Old configs with explicit `127.0.0.1` honored (not overwritten). By @yyoyoian-pixel.
Local verification: cargo test --lib 160/160 passing, both release builds clean.
Change default `listen_host` from `127.0.0.1` to `0.0.0.0` so the
proxy is reachable from other devices on the same network. This
enables hotspot sharing: run the app on Android with hotspot enabled,
and an iPhone/iPad/laptop on the same WiFi can use the proxy by
pointing at the Android's hotspot IP (192.168.43.1:8080 for HTTP,
:1081 for SOCKS5).
On iOS, apps like Shadowrocket or Potatso can create a local VPN
that routes all device traffic through the SOCKS5 proxy — giving
full tunnel coverage without installing the app on the iOS device.
Added a "Sharing via hotspot" section to README with setup steps
for iOS, macOS, and Windows.
Co-Authored-By: Claude Opus 4.6 (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).
@bankbunk reported (#460) that on a 1 Gbps VPS, raw MP4 streams in Full
mode died with `batch JSON parse error: EOF while parsing a string at
line 1 column 52428685` minutes into playback. Root cause: drain_now
took the entire per-session read buffer in one shot. On high-bandwidth
VPS the reader task fills the buffer with tens of MiB between polls;
the resulting batch response (raw + base64 1.33× + JSON envelope)
exceeded Apps Script's ~50 MiB hard cap; Apps Script truncated mid-base64;
the client's serde_json parse hit EOF and the stream tore.
Fix: drain_now now returns at most TCP_DRAIN_MAX_BYTES (16 MiB) per call
and leaves the tail in the buffer for the next poll. EOF is held back
until the buffer is fully drained so partial drains don't tear the
session prematurely. Three regression tests cover the cap, the under-cap
pass-through, and the EOF-holdback case (33 tunnel-node tests passing).
@bankbunk's wondershaper rate-limit workaround (40 Mbps cap on the VPS
interface) is no longer necessary — high-bandwidth VPS users can run at
line rate again.