In `relay_parallel_range`, when a chunk failed validation
(`extract_exact_range_body` returned Err) OR the stitched body length
didn't match the advertised total, the fallback path called
`rewrite_206_to_200(&first)` — which converted the 256 KiB probe
response into HTTP 200 + Content-Length=262144 and returned that as
if it were the full file. Browsers saw a complete-looking 200 and
treated the download as finished at 256 KB.
Common triggers for the chunk-validation failure (per the user
reports):
- Apps Script's UrlFetchApp stripping `Content-Range` from chunk
responses while preserving it on the probe
- Origin returning 200-OK on follow-up Range requests (some servers
flatten ranges after the first one)
- Mismatched `total` field across chunks for paths behind a varying
cache layer
The correct fallback is a single GET without any Range header —
Apps Script fetches the whole URL (up to its 50 MiB cap) and
returns a normal 200 with the complete body. Slower than parallel
for large files but produces a correct response, which is the
minimum bar.
Two independent reports (Ehsan in #162, Recruit1992 confirming).
98 lib tests still pass; existing `validate_probe_range_rejects_*`
and `extract_exact_range_body_*` tests already cover the validation
side, the fallback path is observed integration-testing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five small but real Android-only fixes:
1. Connect/Disconnect button gated on VpnState.isRunning state-flow
with 12s backstop, replacing the fixed 2s transitionCooldown
timer. Closes the race where a tap-after-Stop hit "Address already
in use" because the previous teardown's listener-socket release
wasn't done.
2. Tun2proxy.stop() wrapped in 2s join() — if the native call hangs,
bounded teardown still releases the listener port instead of
holding the teardown thread.
3. fd-leak fixed between parcelFd.detachFd() and Thread.start(): an
OOM-thrown Thread.start used to orphan the detached fd. Now
adopted into a fresh ParcelFileDescriptor purely so we can close()
it.
4. Misleading teardown doc-comment rewritten — the "step 2 closes
the TUN fd to force EBADF on read" claim has been factually
wrong since detachFd landed.
5. Recursive crash trap: Log.e in MhrvApp's uncaught handler now
wrapped in try/catch so a logd failure during exception logging
falls through to the previous handler with the real exception.
No Rust changes; 98 lib + 22 tunnel-node tests still pass.
Local Android build verified, APK installed on mhrv_test emulator,
launches cleanly with v1.6.1 in title.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ships PR #183. SOCKS5 UDP ASSOCIATE → tunnel-mux udp_open/udp_data ops
→ tunnel-node UDP sessions → real UDP egress. QUIC/HTTP3, DNS, and
STUN now traverse the tunnel instead of falling back to TCP or
leaking outside it.
- 256-session-per-associate cap with FIFO eviction
- 9 KB datagram size guard (DNS/STUN tiny, QUIC max ~1452, leaves
IPv6 PMTUD headroom without burning Apps Script quota on rogue
traffic)
- Source-IP pinned to the control TCP peer; port locked to first
parseable datagram (malformed datagrams from the right IP no
longer DoS the legitimate flow)
- Event-driven UDP drain reusing v1.5.0's long-poll knobs
Backward compat: TunnelResponse.pkts is `Option<Vec<String>>` with
serde default; v1.5.0 clients hitting v1.6.0 tunnel-nodes ignore
the new field; v1.6.0 clients hitting v1.5.0 tunnel-nodes get
UNSUPPORTED_OP on udp_open and the existing fallback path takes
over (TCP-only). Apps Script CodeFull.gs is opaque to the new ops
— no redeploy needed; just doc-comment update.
98 lib tests + 22 tunnel-node tests pass (was 92 + 17 before).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds end-to-end UDP support: SOCKS5 client UDP ASSOCIATE → tunnel-mux
udp_open/udp_data ops → tunnel-node UDP sessions → real UDP to upstream.
QUIC/HTTP3, DNS, and STUN now traverse full mode without falling back to
TCP or leaking outside the tunnel.
Apps Script proxies the new ops opaquely through the existing batch
endpoint; CodeFull.gs only gets a doc-comment update.
Highlights:
- proxy_server.rs: SOCKS5 UDP ASSOCIATE handler with per-session task,
bounded uplink mpsc channel, adaptive empty-poll backoff (500 ms → 30 s),
source-IP validation against the control TCP peer, port-locking on
first valid datagram, and self-removal from the dispatch map on eof.
- tunnel_client.rs: UdpOpen / UdpData / close_session mux variants
alongside the existing TCP plumbing; pkts decoder helper.
- tunnel-node: UdpSessionInner with bounded VecDeque queue, drop-oldest
on overflow with queue_drops counter and warn-then-throttled logs,
last_active refreshed only on real activity (uplink send or upstream
recv — empty polls do not refresh), independent TCP/UDP drain in
handle_batch Phase 2, separate active-drain (150 ms) and retry
(250 ms) windows for UDP, idle long-poll (5 s).
- Tests: SOCKS5 UDP packet parser (IPv4/IPv6/DOMAIN round-trips,
truncation rejects, fragmented rejects), UDP queue overflow drop +
counter, regression test that batch with both UDP and TCP-data ops
still runs the TCP retry pass.
Docs: README + android.{md,fa.md} updated to reflect UDP availability
in full mode; tunnel-node/README documents the new ops.
Ships PR #173 (event-driven drain) plus three operational improvements:
PR #173 — long-poll tunnel mode. The tunnel-node's batch drain
switched from a fixed 150 ms sleep to an event-driven Notify wait;
idle sessions long-poll up to 5 s and wake on the first byte from
upstream. Push notifications and chat messages now arrive in roughly
RTT instead of waiting for the next client poll tick. Backward compat
with pre-#173 tunnel-nodes is automatic via a sticky AtomicBool that
detects fast empty replies and reverts to the legacy cadence.
92 client tests + 17 tunnel-node tests pass, including end-to-end
TCP-pair verification of the notify wiring.
Docker image for tunnel-node. Adds a hardened Dockerfile (BuildKit
cache mounts, non-root runtime user, ca-certificates for HTTPS
upstreams) and a .dockerignore to keep build context small. New
`tunnel-docker` job in the release workflow builds + pushes
multi-arch (linux/amd64 + linux/arm64) to
ghcr.io/therealaleph/mhrv-tunnel-node with `:latest`, `:1.5`, and
`:1.5.0` tags on every release. Setting up Full Tunnel mode goes
from "rustup + cargo build on a 1 GB VPS" (which fails on memory
half the time) to a one-liner. tunnel-node/README.md updated with
prebuilt-image + docker-compose recipes.
Brief Persian release note in Telegram caption. The release-post
caption now leads with a `<blockquote>`-wrapped FA bullet headlines
extracted from `docs/changelog/v<ver>.md`, above the existing two
links (repo + release). Markdown links → Telegram HTML <a> for
clickability. Cap-budget-aware truncation at bullet boundaries
keeps total caption under Telegram's 1024-char limit. Headlines-only
rather than full bullets so multiple "what's new" items fit
comfortably (the full bullets remain on the GH release page and as
the optional --with-changelog reply-threaded message).
GitHub Releases page bodies now lead with the changelog content
(Persian section + `---` + English) instead of just a Full Changelog
comparison link. The auto comparison link is appended at the bottom
via `append_body: true` rather than removed.
Workflow changes:
- New `permissions: packages: write` at the workflow level (required
for ghcr push via docker/login-action).
- New `tunnel-docker` job needs `build` (not the full matrix) to
serialize the QEMU buildx layer with the matrix cache.
- Release job composes the body from `docs/changelog/v${VER}.md`
in a pre-step that handles both tag-push and workflow_dispatch
paths (uses inputs.version || github.ref_name like the rest of
the workflow).
Tested locally:
- `cargo test` — 92 lib tests pass
- `cargo test -p mhrv-tunnel-node` — 17 tests pass
- `docker build` of tunnel-node Dockerfile — 32 MB image, runs as
non-root, /health returns "ok", auth rejection works correctly,
legitimate requests open sessions to remote hosts
- Telegram script `--dry-run` mode added; rendered captions for
v1.4.0, v1.4.1, v1.5.0 all fit under 900 chars
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Patch release covering:
- #160 (deniz_us): Test Relay returned Google datacenter IPs even in
Full Tunnel mode, because test_cmd::run unconditionally used
fronter.relay() (apps_script path) regardless of configured mode.
The user's tunnel-node was actually working — whatismyipaddress.com
in their browser showed the correct VPS IP — but Test Relay
contradicted it. Now Test Relay refuses cleanly in Full mode with
a clear message and points users at the right verification path.
A real Full-mode test through the tunnel mux is enhancement-tracked.
- mhrv-rs-openwrt-mipsel-softfloat artifact lands natively (commit
febeeca). v1.4.0 had a build break on the 32-bit MIPS target due to
PR #153's std::sync::atomic::AtomicU64 import — switched to
portable_atomic::AtomicU64 which is already the project's
convention for that reason. The artifact was hot-published to the
v1.4.0 release page via workflow_dispatch yesterday; v1.4.1 ships
it the normal way.
91 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Issue #160 (deniz_us): Test Relay returned a Google-datacenter IP
even when mode=full and the user's tunnel-node was clearly working
in the browser. The reason: test_cmd::run unconditionally calls
fronter.relay() which is the apps_script-mode path; it doesn't go
through the tunnel mux at all. Result was a worst-of-both-worlds
silent fallback — looked like a successful Test, but the IP it
returned was nothing to do with the user's actual data plane.
Same shape of fix as the existing google_only branch: detect
Mode::Full and return a clear error explaining that Test isn't
wired for full mode, plus how to verify the tunnel-node manually
(whatismyipaddress.com via 127.0.0.1:8085) — at least until a real
full-mode test using the tunnel mux gets implemented.
Following up #160 to track the real fix as an enhancement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related fixes for the self-hosted Linux jobs failing on
`actions/checkout@v4` with `EACCES: permission denied unlink
target/.rustc_info.json`:
1. Pre-checkout cleanup. The previous mipsel docker step left root-
owned files in target/ when its `cargo +nightly build` failed —
the post-step `sudo chown -R …` line was AFTER the docker run,
and bash -e short-circuited on docker non-zero, so chown never
executed. Once those root-owned files exist on the runner,
every subsequent self-hosted job's `actions/checkout@v4` clean
step fails because it can't unlink them. Add a guarded
pre-checkout step that uses `sudo rm -rf` to clear root-owned
leftovers from $GITHUB_WORKSPACE. Gated on
`contains(matrix.os, 'self-hosted')` so GitHub-hosted runners
(macOS, Windows) skip it — they get fresh VMs anyway.
2. Always-chown trap. The mipsel docker step now uses
`trap '…chown…' EXIT` so the chown runs whether the inner
`cargo build` succeeded or failed. A transient mipsel compile
regression (tier-3 target, occasional std-build breakage) no
longer poisons the runner's workspace for the next run.
Together: (1) recovers from the existing broken state on next run,
(2) prevents the same poisoned state from recurring.
Triggered for the v1.4.0 redeploy that's trying to publish the
missing mhrv-rs-openwrt-mipsel-softfloat artifact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original `on: push: tags: 'v*'` trigger is the right primary path
— a new tag = a new release = a fresh workflow run. But it has a
failure mode: if one matrix job fails (e.g. mipsel-softfloat is
tier-3 and occasionally regresses) and we push a fix to main, we
can't move the immutable tag (tag protection rule), so the original
artifact stays missing from that release forever.
`workflow_dispatch` with a `version` input lets us re-run the workflow
from main against an existing release tag:
gh workflow run release.yml --ref main -f version=1.4.0
The build matrix runs against the current main commit (which has
the fix), and the release-upload step uploads to the existing
v1.4.0 release page with the same filename pattern, alongside the
artifacts that succeeded on the original push.
Three call sites had to learn the new path: the macOS .app bundle
build, the Android APK rename step, and the Telegram caption builder.
All three previously did `VER="${GITHUB_REF#refs/tags/v}"`, which on
a workflow_dispatch from main produces "heads/main" — the new
two-line `VER="${{ inputs.version || github.ref_name }}"; VER="${VER#v}"`
pattern handles both: tag pushes get the bare version from
`github.ref_name` ("v1.4.0" → "1.4.0"), workflow_dispatch gets it
from the explicit input.
The release job's `softprops/action-gh-release@v2` step also needed an
explicit `tag_name` — without it the action defaults to `github.ref`,
which on workflow_dispatch is the dispatch ref (`refs/heads/main`)
and would try to create a release named "main". Now it's:
tag_name: ${{ inputs.version && format('v{0}', inputs.version) || github.ref_name }}
Pair with `gh variable set TELEGRAM_NOTIFY_ENABLED --body false` before
dispatch when the re-run is for the same version (no new content for
the channel to see) — the Telegram job is already gated on that
variable, no per-run flag needed.
Used right now to publish the mhrv-rs-openwrt-mipsel-softfloat artifact
that failed on v1.4.0's original push, after commit febeeca fixed the
underlying compile error.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #153's connect_data instrumentation imported `AtomicU64` directly
from `std::sync::atomic`, which works on every release target except
the 32-bit MIPS OpenWRT target (`mipsel-unknown-linux-musl`) — that
platform has no hardware-backed 64-bit atomics, and Rust's std type
isn't even defined there. The v1.4.0 release workflow's mipsel build
failed with `error[E0432]: no AtomicU64 in sync::atomic`.
`domain_fronter.rs` already imports `portable_atomic::AtomicU64` for
the same reason — `portable-atomic` (already in Cargo.toml with the
"fallback" feature) provides a software-emulated 64-bit atomic on
targets that need it. Apply the same import here for the new
preread_* counter and connect_data_unsupported flag.
`AtomicBool` stays in std — it works on every target, no polyfill
needed.
Verified locally: cargo build + cargo test --lib (91/91 pass) clean
with the import change.
Same v1.4.0 — no version bump. Re-runs the release workflow to publish
the missing mhrv-rs-openwrt-mipsel-softfloat artifact alongside the
existing v1.4.0 release assets. Telegram notification suppressed for
this re-publish (same version, no point re-posting).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rolls up #150 + #151 + #153 (all merged on main) into a tagged release.
Highlights:
- Full Tunnel mode opens new HTTPS connections ~500-2000 ms faster
(#153). The new connect_data tunnel op bundles the client's first
bytes (typically TLS ClientHello) with the CONNECT call, eliminating
one full Apps Script round trip per new flow. Backward compat is
handled via UNSUPPORTED_OP detection + sticky AtomicBool fallback +
pending_client_data replay so older deployments keep working without
byte loss. New `connect_data preread: X win / Y loss / Z skip`
metric in logs lets us measure the win ratio empirically.
- Android ONLY-mode split fix (#150): when the allow-list contained
only mhrv-rs or stale uninstalled packages, every
addAllowedApplication call silently failed and Android applied the
TUN to every app — looping our own proxy traffic. Now we count
successful adds; if zero, we fall back to ALL-mode self-exclusion.
Complements PR #143 which fixed the empty-list case.
- Memory-safety cap on relay range stitching (#151): a hostile or
buggy origin could advertise an absurd Content-Range total
(e.g. 10 GiB) and force range-parallel to plan millions of chunks
and preallocate a huge stitched buffer. Now capped at 64 MiB; larger
totals fall back to a normal single GET.
Tests: 91 lib tests pass (was 82; +1 from #151, +8 from #153).
Tunnel-node: 6 tests pass (all new from #153).
Local Android build verified — universal + four per-ABI APKs all
produced at expected sizes (universal 53 MB, arm64-v8a 21 MB,
armeabi-v7a 18 MB, x86_64 23 MB, x86 22 MB). Installed on mhrv_test
emulator, app launches and renders correctly with v1.4.0 in title.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Range-parallel relay trusted the origin's Content-Range total before planning remaining ranges and preallocating the stitched response buffer. A bad upstream could advertise an absurd total and force unbounded memory/CPU work. Bound synthetic stitching to 64 MiB and fall back to a normal single GET for larger totals, preserving correctness without returning a fake truncated 200 response.
Rolls up the four post-v1.2.14 commits on main into a single tagged
release. Highlights:
- Per-deployment concurrency (#142): each deployment ID gets its own
30-permit semaphore, so setups with deployments across multiple
Google accounts get a genuine 30×N throughput ceiling. Single-account
setups still cap at Google's per-account 30-simultaneous limit —
docs (EN + FA) updated to call that out.
- Android app-splitting ONLY-mode bug fix (#143): the previous code
called both addAllowedApplication and addDisallowedApplication,
which Android documents as mutually exclusive. ONLY mode was
silently failing establish(). Now fixed.
- Per-ABI Android APKs (#136): ships four split APKs (arm64-v8a ~21 MB,
armeabi-v7a ~18 MB, x86_64 ~23 MB, x86 ~22 MB) alongside the ~53 MB
universal. Huge distribution win for users on unreliable
censorship-tunnel paths — the 21 MB arm64-v8a download succeeds
where the universal doesn't.
- Honest IP-exposure note in Security Posture (#148): clarified that
v1.2.9's forwarded-header stripping only covers the client-side leg;
what Google's own infrastructure may add on the UrlFetchApp.fetch()
second leg is outside this client's control. Full Tunnel mode is
the recommendation for threat models where that matters.
- Telegram release-post format: added Persian preambles above both
links (GitHub repo + full Persian guide; release page + desktop/
router builds) so channel readers see the intent at a glance.
82 tests pass. Desktop + Android builds both verified clean locally
across the v1.2.15+ commit series.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When ONLY mode contains only this app or stale packages, no allowed application is successfully added. Android then treats the VPN as applying to every app, which can route more traffic than requested and can also include our own proxy traffic in the TUN path. Count successful addAllowedApplication calls and fall back to the existing ALL-mode self-exclusion behavior when none are usable.
@creep247 raised a fair concern: v1.2.9's forwarded-header stripping
handles the client-side leg (browser extensions / local proxies
inserting X-Forwarded-For before the request reaches Apps Script),
but it cannot cover whatever Google's infrastructure may add when
the Apps Script runtime's subsequent UrlFetchApp.fetch() hits the
target server — that leg is outside this client's control.
Added a paragraph to both the English and Persian "Security posture"
sections making the model honest:
- what v1.2.9's stripping DOES cover (client-side added headers)
- what it DOES NOT cover (Google's internal header chain on the
fetch from Apps Script runtime → destination)
- recommendation: users whose threat model requires the destination
site cannot under any circumstances learn their IP should use
Full Tunnel mode, which exits via the user's own VPS end-to-end
No code change — the privacy claim is narrower than a naive reading
of "v1.2.9 fixed the IP leak" might suggest, so the docs should say
so explicitly rather than let users over-trust the apps_script mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitHub Releases is filtered from inside IR, and the 50 MB universal APK
is a bottleneck for users on slow/unstable censorship-tunnel paths that
can't reliably pull that much data. Per-ABI APKs are ~18–23 MB each —
small enough to succeed where the universal fails.
Build changes:
- android/app/build.gradle.kts: enabled `splits { abi { ... } }` with
`isUniversalApk = true`, producing five release APKs:
app-universal-release.apk ~53 MB (all 4 ABIs)
app-arm64-v8a-release.apk ~21 MB (95%+ of modern devices)
app-armeabi-v7a-release.apk ~18 MB (older 32-bit ARM)
app-x86_64-release.apk ~23 MB (emulators, Chromebooks)
app-x86-release.apk ~22 MB (legacy 32-bit Intel)
abiFilters is retained for the universal build; splits.abi layers on
per-ABI outputs without removing it.
- .github/workflows/release.yml: rename step now copies all 5 APKs to
dist/ under versioned names (mhrv-rs-android-{abi}-v{VER}.apk),
logs a warning if any per-ABI APK is missing, and hard-fails only if
the universal is missing. Universal keeps its existing download path
and filename so Telegram mirrors / previous-version update prompts
keep working.
The release + telegram aggregation jobs downstream don't need changes —
they already use `files: dist/*` and `mhrv-rs-android-universal` artifact
name respectively.
Local build verified: clean assembleRelease produces all 5 APKs with the
expected size ratios (arm64-v8a is 41% the universal size).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apps Script enforces 30 concurrent executions per account. The old pipeline
used a single global semaphore sized to the number of deployments, meaning
1 in-flight batch per deployment. Now each deployment ID gets its own
semaphore with 30 permits — matching the actual per-account limit.
With N deployments the system can sustain 30×N concurrent batch requests
instead of N.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a Straightforward README that's short, plain-language, and skips
the technical "how it works" diagrams. Covers the same setup flow as
the main README but with friendlier wording, plus a "Common issues"
section that surfaces the most-asked-about gotchas (YouTube SafeSearch
loop #61, Cloudflare Turnstile loop, 504 Relay timeout, daily quota,
"connected but nothing loads"). Both English and Persian.
The main README's index line now offers four links instead of two:
- Quick Start (EN)
- Full English README
- راهنمای خلاصه فارسی
- راهنمای کامل فارسی
So users who don't have a deep networking background can land on a
guide tailored to them, and users who want the full picture still
have a single click.
Closes#135.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a daily-budget visualization for users worried about hitting the
Apps Script free-tier quota (20,000 UrlFetchApp calls/day).
Usage today card (desktop + Android):
- today_calls / today_bytes / today_key / today_reset_secs atomics on
DomainFronter, hooked into the bytes_relayed fetch_add path so we only
count successful relays (matching what Google actually billed)
- Daily rollover at 00:00 UTC, std-only date math (Hinnant's
civil_from_days) — no chrono/time dep pull
- StatsSnapshot extended with the four new fields + to_json() for the
Android JNI bridge
- Desktop UI renders the card right under the existing Traffic stats
with a hyperlink to https://script.google.com/home/usage for the
authoritative Google-side number
- Android UI renders the same card via Compose, polling
Native.statsJson(handle) once a second only while the proxy is up,
with an Intent(ACTION_VIEW, …) opening the dashboard URL
JNI / state plumbing:
- New Java_…_statsJson reads the Arc<DomainFronter> kept in slot_map
- VpnState.proxyHandle StateFlow so HomeScreen knows which handle to
poll without poking into the service's internal state
- MhrvVpnService publishes the handle on start, zeroes on teardown
Persian localization:
- HowToUseBody (5-step guide + Cloudflare Turnstile note) was
hardcoded English even when locale=FA. Ported to a string resource
with a full FA translation in values-fa/strings.xml. Persian users
no longer drop to English at the bottom of the screen.
Also lands the deferred Android ConfigStore.kt wiring for
passthrough_hosts (commit fe9328e shipped the Rust + desktop side).
82 tests pass (added: unix_to_ymd_utc_handles_known_epochs,
seconds_until_utc_midnight_is_bounded). Built and visually verified on
both desktop and Android emulator (mhrv_test AVD).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pipeline depth was artificially capped at 12. Users with 20+
deployments across multiple accounts were wasting pipeline capacity.
Now: pipeline_depth = num_scripts (minimum 2, no upper cap).
The connection pool (80) is the natural ceiling.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The 504 "Relay timeout" message was too terse — users hit it after
their daily UrlFetchApp quota exhausts and had no idea why a setup
that worked yesterday suddenly started failing.
New message explicitly names the most common cause (daily quota
reset at 00:00 UTC) and points at the script's Executions tab for
the real server-side error. Same three-line explanation #99, #111,
#105 each independently got from me in issue comments — now it's in
the browser page body where the user actually sees it.
New config field `passthrough_hosts: Vec<String>` that lists hostnames
which should bypass Apps Script relay entirely and pass through as
plain TCP (via upstream_socks5 if set). Applies across all modes —
apps_script, google_only, and full — because it expresses user intent
("never relay this host") that should win over the default routing.
Matching rules:
- Exact: "example.com" matches only example.com
- Suffix: ".example.com" matches example.com AND any subdomain
- Case-insensitive; trailing dots normalized
- Empty / whitespace-only entries are ignored
Dispatch order is now:
0. passthrough_hosts ← new, highest priority
1. Mode::Full → batch tunnel
2. SNI-rewrite → direct Google edge
3. Mode::GoogleOnly → plain-tcp
4. Mode::AppsScript → peek + MITM/relay/plain-tcp
Wired through:
- src/config.rs: new Config field with serde default
- src/proxy_server.rs: RewriteCtx.passthrough_hosts + matches_passthrough()
helper + dispatch check as step 0 + 5 new unit tests
- src/bin/ui.rs: FormState + ConfigWire round-trip so the desktop UI
preserves user entries across save/load
Android ConfigStore.kt wiring and a UI editor will land in a follow-up.
80 tests pass (75 → 80 with the new passthrough match tests).
Adds:
- Shield badge in the top badge row linking to https://sh1n.org/donate
- Dedicated "Support this project" section in English (above the
ltr/rtl divider) explaining what donations cover
- Dedicated "حمایت از پروژه" section in Persian mirroring the same
content inside the RTL block
Donations cover hosting, self-hosted CI runner costs, and continued
maintenance. Starring the repo remains the free equivalent.
My earlier commit fe84db0 fixed the English quick-start but missed
that vahidlazio's PR #126 also rewrote the Persian FAQ entry
\"چند Deployment ID لازم دارم؟\" with the same correction. Mirroring
that here so both language tracks say the same thing.
Persian wording credit to @vahidlazio from #126.
Vahidlazio flagged that the README's full-mode quick-start read as
"you need 3-12 separate Google accounts," which is wrong — you need
3-12 deployment IDs, which can all live on one Google account (each
"New deployment" produces its own ID). Going multi-account only
buys daily-quota headroom; the pipeline depth itself scales fine on
one account up to Apps Script's simultaneous-execution ceiling.
Also rewrites the accompanying recipe into three concrete tiers
(solo / small group / large group) instead of waving vaguely at a
range.
Closes#126. Also obsoletes my earlier analysis on #61 where I told
@Feiabyte that same-account multi-deployment "does not give
throughput" — that was wrong; it does, because simultaneous Apps
Script executions aren't bottlenecked by a per-deployment limit
until you pile up more than ~30 concurrent executions per account,
which 12 deployments don't come close to. Posting a correction on
#61 separately.
Bug fix release. My v1.2.12 merge of Mode::Full bypassed the
deployment-ID + auth-key check on Android, but Full mode talks to
CodeFull.gs on Apps Script and needs those same credentials.
Users selecting "Full tunnel (no cert)" with empty fields would see
the VPN service bail silently instead of surfacing a clear "config
incomplete" error. Vahidlazio's fix changes the gate from
`mode == APPS_SCRIPT` to `mode != GOOGLE_ONLY` and removes the
Mode.FULL bypass in the Start button's enabled-state.
Also includes a UX refactor of the Deployment IDs editor (per-row
rows with add/remove buttons instead of raw newline-separated text),
making multi-deployment setups easier to manage on Android — useful
now that Full Tunnel Mode users routinely scale to 5+ deployments
per their Google accounts.
Android-only diff; Rust side is byte-identical to v1.2.12.
Real bug I introduced in #94: Full mode was skipping the credential check that apps_script mode enforces, but Full mode does talk to CodeFull.gs on Apps Script and needs the same auth_key + deployment ID. Users flipping to Full mode with empty fields would silently fail.
Two sites fixed:
- MhrvVpnService.kt — changed `mode == APPS_SCRIPT` gate to `mode != GOOGLE_ONLY`
- HomeScreen.kt — removed the `cfg.mode == Mode.FULL` bypass in the Start button's enabled-state
Also includes a UX improvement for the Deployment IDs editor (per-row field with add/remove buttons instead of raw newline-separated text), which makes multi-deployment setups easier to manage on Android.
Rust-side 75 tests still green, Kotlin compiles clean. Android-only diff so no Rust CI impact.
Keep PR scoped to UI checkbox for existing youtube_via_relay field.
Drop behavior-neutral proxy refactor and config whitespace-only diff.
Made-with: Cursor
Keep ads/analytics domains in the core SNI rewrite list so the YouTube skip toggle only affects YouTube paths, and drop machine-specific .cargo cross-compile helpers that break CI portability.
Made-with: Cursor
Rollup of PR #94 — Mode::Full dispatch + batch tunnel client. Ships
the long-awaited no-MITM path that was the motivating fix for half
the open issues this week.
User-facing: add `"mode": "full"` to config.json, deploy CodeFull.gs
as a second Apps Script alongside your existing one, deploy
tunnel-node (tunnel-node/README.md) on a VPS, and traffic is tunneled
end-to-end: client → mhrv-rs → script.google.com → your tunnel node →
destination. Browser speaks TLS directly with the destination; we
never see plaintext. No CA needed on the client device.
Android side gets a "Full tunnel (no cert)" dropdown option; toggling
it writes `"mode": "full"` to config.json.
Safety: Mode::AppsScript and Mode::GoogleOnly dispatch paths are
unchanged — Full mode is an additive branch at the top of
dispatch_tunnel. Existing users on the default apps_script mode see
zero behaviour change.
Testing status: compiles clean on all 10 CI targets; 75 tests pass
(+2 new config-validation tests for Full mode); end-to-end real-VPS
testing will come post-release from @Feiabyte and others who opt in.
Any Full-mode regression gets a fast-follow fix.
Adds a new `mode: full` that tunnels ALL traffic end-to-end through Apps Script → a remote tunnel node. Browser does TLS directly with the destination. No MITM, no CA installation needed on the client device.
Ships as part of the 3-PR series: #93 (tunnel-node service + CodeFull.gs, merged) + this (Rust-side Mode::Full + batch tunnel client) + #95 (Android UI dropdown, now rolled into this PR post-rebase).
### Architecture
- Client → mhrv-rs → script.google.com (Apps Script fetch) → tunnel-node on user's VPS → real destination
- Apps Script is the transport to reach the VPS; works even when the ISP blocks direct VPS IPs
- Batch multiplexer collects data from all active sessions and ships one Apps Script request per tick
### Safety properties of this merge
- AppsScript + GoogleOnly dispatch paths are **unchanged**; Full mode is an additive branch at the top of `dispatch_tunnel`.
- `tunnel_client.rs` is a new isolated module (387 LOC).
- `tunnel_request()` is a new method on `DomainFronter`, no change to `relay()` / `relay_parallel_range()`.
- Config: additive `Mode::Full` variant + validation tests (2 new); existing validation rules untouched.
- Local build: clean compile. `cargo test --quiet`: 75 passed (73 → 75 with 2 new config tests).
### Closes
Unblocks the feature requested in #61, #69, #100, #105, #110, #111, #113, #116.
### Testing
vahidlazio has iterated on prior review feedback. End-to-end testing with a real tunnel-node deployment will follow post-merge from @Feiabyte (volunteered in #61). Post-merge CI will exercise compile + full test matrix across all targets; any regression caught there gets a fast-follow fix.