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.
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.
Single-bug release. Unblocks x.com browsing for users whose browsers
resolve to www.x.com rather than bare x.com — i.e. essentially
everyone using Firefox / Chrome / Safari.
Previous releases still advertised the URL-truncation fix as working
but it only matched exact Host: x.com, which never happens in real
traffic. v1.2.11 widens the matcher to x.com + *.x.com so www.x.com,
api.x.com, and any future x.com subdomain all get the shortened URL
through Apps Script's URL length cap.
The x.com GraphQL URL-length fix added in v1.2.1 (08fe691) only
matched exact host "x.com". But browsers actually navigate to
www.x.com, and api.x.com serves GraphQL endpoints too — the original
fix never fired for real traffic.
@pourya-p's log in #64 made this unambiguous:
relay GET https://www.x.com/i/api/graphql/<hash>/HomeTimeline?variables=...&features=...
...
ERROR Relay failed: relay error: Exception: بیش از حد مجاز: طول نشانی وب URLFetch.
(That Persian text is Apps Script's "URLFetch URL length exceeded"
error, which is exactly what the truncation was supposed to prevent.)
Widened the host matcher to `host == "x.com" || host ends with
".x.com"` so www.x.com / api.x.com / any future x.com subdomain all
hit the rewrite. The path-pattern constraint
(`/i/api/graphql/... ?variables=`) already filters to the right
endpoints.
73 tests still pass.
Single-focus release. The Stop button in the UI previously only
stopped new connections from being accepted — in-flight clients kept
running on the old DomainFronter, which meant:
- Pages kept loading after Stop (users thought they'd stopped)
- Auth-key changes didn't take effect for domains with a live
keep-alive to the proxy
- Apps Script quota could still be consumed post-Stop
Fix (7338e76): wrap per-client spawns in a tokio::task::JoinSet
inside each accept loop. On shutdown, aborting the accept task drops
the JoinSet, which aborts every in-flight client. Sockets close,
the old fronter's TLS pool drops, and a subsequent Start builds a
clean new state.
Finding 1 of #99 (quota-exceeded → "timeout" instead of the real
502 body) is a separate pool-staleness issue and is NOT addressed
in this release.
Before: `ProxyServer::run()` aborted only the two accept tasks on
shutdown (`http_task`, `socks_task`), but every per-client task was
spawned as a bare `tokio::spawn(...)` whose JoinHandle was discarded.
Aborting the accept loop stopped taking new connections, but in-flight
clients kept running on the runtime with their captured (stale)
`Arc<DomainFronter>`.
User-visible symptoms reported by @r-safavi in #99:
1. Hitting Stop in the UI didn't actually stop serving: Firefox still
reached x.com through the proxy even though the user expected a
"connection refused."
2. Starting again with a changed auth_key worked for NEW domains
(yahoo.com) but not for domains with a live keep-alive (x.com) —
because the old child task was still using the old fronter with the
old key.
3. Apps Script quota could be consumed after the user thought they'd
stopped. Arguably the worst of the three.
Fix: wrap per-client spawns in a `tokio::task::JoinSet<()>` scoped
inside each accept task. When the accept task is aborted on shutdown,
the JoinSet is dropped, and `JoinSet::drop` aborts every still-running
child — closing their sockets and dropping their Arc clones of the
fronter, which in turn drops the pool.
Also added an opportunistic `try_join_next()` drain before each
accept() so the JoinSet doesn't grow unbounded with completed-task
handles on long-running proxies.
Covers Finding 2 of #99. Finding 1 (quota-exceeded → timeout instead
of surfacing Apps Script's 502) is a separate pool-staleness issue and
stays open for now.
v1.2.8 tagged cleanly but CI failed compiling mhrv-rs-ui with:
error[E0063]: missing field `youtube_via_relay` in initializer of
`mhrv_rs::config::Config`
When I added the youtube_via_relay field to the main Config struct
in 09f1f5f, I missed the struct-literal construction in src/bin/ui.rs
(FormState::save_to_config) and the ConfigWire serializer.
Fixed here:
- Added youtube_via_relay field to FormState (line 214), read path
(line 291), default path (line 316), and the save path (line 451)
- Added youtube_via_relay field to ConfigWire (line 493) with
skip_serializing_if on false, plus its From impl (line 544)
UI still doesn't expose a checkbox for the toggle — it's config-only
for now, same treatment as normalize_x_graphql. A future PR can add
the checkbox to the Advanced pane.
v1.2.8 tag exists but has no GitHub Release (release job skipped
on failure); v1.2.9 is the clean cut. Same payload as v1.2.8 plus
this fix.
Rollup of four merged fixes since v1.2.7:
- security: strip identity-revealing forwarding headers in the Apps
Script relay path. Closes the XFF leak vector from issue #104 —
users chained behind xray/v2rayNG or running browser extensions
that inject X-Forwarded-For / Forwarded / Via / CF-Connecting-IP
etc. would previously have those forwarded to the origin via the
relay. Now stripped to 16 header variants with a regression test.
- proxy: new `youtube_via_relay` config toggle (#102). Routes
YouTube family suffixes through Apps Script instead of the
SNI-rewrite tunnel. Trades SafeSearch-on-SNI for Apps Script's
fixed User-Agent + quota cost. Off by default.
- scan_sni: decode chunked dns.google DoH responses (#97, from
@freeinternet865). Without this, PTR lookups always failed and
scan-sni discovered zero domains.
- scan_sni: verify dns.google TLS with webpki roots (#98, from
@freeinternet865). The DoH request is a normal public HTTPS call
— an on-path MITM should not be able to forge PTR answers and
poison the suggested SNI pool.
73 tests pass (up from 67 — three new chunked-decode tests + one
XFF-filter + two youtube_via_relay branches).
Ports the upstream Python `youtube_via_relay` flag (commit a0fd8a0 in
masterking32/MasterHttpRelayVPN). When enabled, YouTube-family
suffixes (youtube.com, youtu.be, youtube-nocookie.com, ytimg.com)
opt out of the SNI-rewrite tunnel and fall through to the Apps Script
relay path.
Why it helps some users: when YouTube is reached via SNI-rewrite to
google_ip with SNI=www.google.com, Google's frontend can enforce
SafeSearch / Restricted Mode based on the SNI name, causing "video
restricted" errors on some regular videos. Routing through Apps
Script bypasses that specific filter at the cost of (a) UrlFetchApp's
fixed `User-Agent: Google-Apps-Script`, and (b) counting YouTube
traffic against the script's daily quota.
Off by default so existing behaviour is unchanged. Users who hit the
SafeSearch-on-SNI issue can set `"youtube_via_relay": true` in their
config.json and observe.
Explicit `hosts` overrides always beat the toggle — that's a user
choice and should win over the default policy. Added tests for all
three branches (youtube_via_relay off, on, and with hosts override).
Matching Android-side UI toggle deferred — `normalize_x_graphql` is
also config-only on Android today; users can edit config.json
directly if needed.
filter_forwarded_headers was stripping hop-by-hop headers (Host,
Connection, Content-Length, etc.) but not identity-revealing
forwarding headers. If a user sat behind another proxy or ran a
browser extension that inserts any of:
X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto,
X-Forwarded-Port, X-Forwarded-Server, X-Forwarded-Ssl,
Forwarded, Via, X-Real-IP, X-Client-IP, X-Originating-IP,
True-Client-IP, CF-Connecting-IP, Fastly-Client-IP,
X-Cluster-Client-IP, Client-IP
those would carry the client's real IP all the way through the Apps
Script relay to the origin server. Stripping them so the origin only
ever sees whatever source IP the Apps Script / GFE path terminates on.
This covers the Apps Script relay path (the main leak vector). The
SNI-rewrite tunnel path is a raw TLS byte bridge — it doesn't parse
HTTP at all — so any headers the client emits there pass through as
opaque bytes to the Google edge that terminates TLS. In practice
that's narrower (origin sees GFE) but documenting the caveat on the
issue thread.
Adds a focused regression test that locks in every stripped header.
Reported in #104.
The scan-sni DoH client to dns.google was using NoVerify — an on-path MITM could forge PTR answers and poison the discovered SNI pool. This is a public HTTPS request, not a fronted probe, so certificate validation belongs ON. Switched to the normal webpki root store.
dns.google replies with Transfer-Encoding: chunked; the raw payload was being handed to serde_json with chunk framing still embedded, so every PTR parse failed and scan-sni discovered nothing. Parses the HTTP response (chunked + Content-Length) before JSON decode. Includes 3 new unit tests.
- Android DEFAULT_SNI_POOL: mirror the Rust-side fix from #92 —
accounts.googl.com replaced by accounts.google.com. Same cert-SAN
mismatch that was failing every Nth rotation in the Rust client
affected the Android user's sniHosts population; both pools need
to stay in sync by design.
- Release rolls up PR #92 (cert fix) and PR #93 (tunnel-node +
CodeFull.gs scaffolding). PR #93 adds a standalone binary under
tunnel-node/ plus an Apps Script companion; no main-crate changes,
so this is a zero-risk merge. Users who want to deploy a tunnel
node can start today. The dispatch that activates `mode: full` is
still in review in PR #94.
Standalone Rust/axum HTTP server + Apps Script-side CodeFull.gs for users who want to deploy a remote tunnel node. All new files; no changes to the main Rust crate. This is part 1 of 3 of the full-tunnel feature — it adds scaffolding that users can opt into once the Rust-side Mode::Full lands in #94.
The googl.com shortener domain is NOT in Google's GFE certificate SAN list — verified via `openssl s_client -verify_hostname accounts.googl.com` returning hostname mismatch. Every Nth connection where the rotation landed on this entry was failing cert validation with `verify_ssl=true`. Replaced with accounts.google.com which is covered by *.google.com wildcard.
v1.2.4 and v1.2.5 both cut clean tags but CI failed downstream for
different self-hosted reasons:
- v1.2.4 failed on parallel apt-lock race (fixed)
- v1.2.5 failed with "TOML parse error at line 5 column 9" because
rust-cache v2's default cache-bin=true prunes $CARGO_HOME/bin of
any binary not installed via `cargo install`. `rustup` itself is
installed by rustup-init, not cargo install, so it got flagged as
"unknown" and deleted on cache save. Next job hits the cargo
symlink that points at a missing rustup, which resolves somehow
to a very old cargo that can't parse our Cargo.toml.
Fix:
- Set `cache-bin: "false"` on every Swatinem/rust-cache@v2 call.
We still cache target/ + registry (the big win), just not bin/.
Binaries are stable across runs on our self-hosted box anyway.
- Reinstalled rustup inside each per-runner CARGO_HOME on the server
to recover from the broken state.
Also in this release:
- PR #83: new `mhrv-rs scan-sni` subcommand. Pulls Google's
published IP ranges, does PTR lookups via dns.google on each IP,
filters to Google-related hostnames, then TLS-probes each
discovered SNI against the configured google_ip to see which ones
bypass DPI. Useful for rebuilding a working SNI pool on a new ISP.
Adds the `url` crate dep.
Same user-facing code as v1.2.4/v1.2.5 (PRs #78, #79, README Android
note) plus PR #83 and the CI fixes on top.
New `mhrv-rs scan-sni` subcommand: pulls Google's published IP ranges, issues PTR lookups via dns.google, filters results to Google-related hostnames, then TLS-probes each discovered SNI against the user's configured `google_ip`. Prints the SNIs that pass DPI for the user to paste into `sni_hosts`. Also expands the hardcoded FAMOUS_GOOGLE_DOMAINS list the existing scan-ips command already used.
Adds `url` crate for URL parsing in the DNS-over-HTTPS client. No other behavioural changes.
v1.2.4 tagged cleanly but its CI failed — parallel Linux matrix jobs
on the self-hosted runners all raced on `/var/lib/apt/lists/lock` and
failed the `sudo apt-get install` step within ~20s. v1.2.4's release
job therefore skipped and no assets were published.
Fix:
- Pre-installed every apt dependency the workflow needs on both
self-hosted runners (eframe system libs, gcc-aarch64-linux-gnu,
gcc-arm-linux-gnueabihf).
- Seeded per-runner cargo linker configs at
/home/ghrunner/cargo-{01,02}/config.toml so the "echo
[target.xxx] linker = ..." workflow step is also unnecessary.
- Gated the "Install Linux eframe system deps" and the two cross-
compile-toolchain steps on `runner.environment == 'github-hosted'`
so only hosted runners call apt-get; self-hosted runners skip the
whole thing and use pre-installed tooling.
Re-tagging as v1.2.5 since v1.2.4 is an abandoned tag (git tag exists
but no GitHub Release was cut for it).
Same code changes as what v1.2.4 was meant to ship: PR #78 range-
parallel validation, PR #79 port-collision rejection, README note
on Android 7+ user-CA trust.
- PR #78: validate Content-Range on 206 responses in the range-parallel
path before stitching. Prevents malformed partials from being combined
into a fake 200 OK. Invalid probe falls back to a normal single GET;
invalid later chunks fall back to the validated probe response
instead of shipping truncated/wrong data.
- PR #79: reject configs with listen_port == socks5_port at validation
time (both config-load and UI form) instead of letting the second
bind fail at runtime with a less clear error.
- README: add an explicit note about the Android 7+ user-CA trust
limitation so future reporters (#74, #81, and the next dozen) find
the answer in the docs instead of in a support thread. The previous
"every app routes through the proxy" line was misleading — TUN
captures all IP traffic but HTTPS still needs app-level trust of
our MITM CA, which most non-browser apps don't grant.
Running through the new self-hosted CI pipeline. Warm rust-cache should
bring the full matrix in under ~7 minutes.
Reject configs that set HTTP and SOCKS5 listeners to the same port. Enforced both at config-load and in the UI form so users get a clear error before bind-time failure. Adds a focused regression test.
Validate Content-Range in the range-parallel path before stitching. Malformed 206s are no longer combined into a fake 200 OK; invalid probes fall back to a normal single GET, invalid later chunks fall back to the validated probe response.
Linux / Android / mipsel build jobs now run on two self-hosted runners
on a Hetzner 8-core / 31 GB Ubuntu 24.04 box with Rust, Android SDK+NDK
r26c, all cross-compile toolchains and Docker pre-installed. macOS and
Windows still run on GitHub-hosted — we don't self-host those OSes and
the free minutes on a public repo are plenty.
Adds Swatinem/rust-cache@v2 to every cargo-using job so target/ + cargo
registry survive between runs. With warm caches the Linux jobs take
~1min each and the Android job ~3-4min; cold runs are ~9min for
Android and ~2min for everything else. Release wall time before this
change was ~13m consistently; it should now sit around 6-7m.
No new user-facing code in this release — primarily an infra change
exercised by an actual tag-push so we verify the full pipeline works
end-to-end from the new runners.
Three user-facing fixes:
- Android Start crash in google_only mode (#73): every early-return
path in startEverything now satisfies Android 8+'s foreground-service
contract by calling startForeground before stopSelf. Previously if
you opened the app, selected google_only mode, and tapped Connect
without filling deployment ID + auth key (which google_only doesn't
need anyway), the service crashed with
ForegroundServiceDidNotStartInTimeException. Also gated the
deployment-ID requirement on mode == APPS_SCRIPT.
- google_ip auto-overwrite on Start (#71): some carriers serve poisoned
DNS for www.google.com that resolves but refuses TLS, clobbering
working IPs users had manually set. DNS lookup now only fires when
the field is blank — manual configs are preserved across Connect.
Explicit "Auto-detect" button still refreshes on demand.
- chromewebstore.google.com added to DEFAULT_GOOGLE_SNI_POOL and
DEFAULT_SNI_POOL (#75). Same family as the rest of the pool —
wildcard cert, GFE-hosted.
Rollup of the three upstream-Python ports plus an Android UX polish:
- plain_tcp_passthrough: 4s connect timeout for IP literals (10s for
hostnames). Halves Telegram DC-rotation latency when the current DC
is DPI-dropped.
- DEFAULT_GOOGLE_SNI_POOL / DEFAULT_SNI_POOL: +maps, chat, translate,
play, lens.google.com. More fingerprint spread, and maps/play pass
DPI on some carriers where shorter *.google.com names don't.
- handle_mitm_request: x.com GraphQL URL truncation — strip everything
after the first & when the path matches /i/api/graphql/.../?variables=.
x.com's variables+features+fieldToggles blob overflows Apps Script's
URL cap; `variables=` alone renders the timeline.
- Android SNI editor: paste-and-add now accepts a full list separated
by whitespace / commas / newlines, dedupes, and merges with existing
selection. Closes the "add them all at once" ask from #47.
- rlimit.rs: fence the example error log in a `text` code block so
rustdoc stops trying to compile it.
Three ports from the upstream Python repo — all straightforward wins for
user-facing connection reliability:
- plain-tcp: 4s connect timeout for IP literals (10s for hostnames).
Ported from upstream 7b1812c. When Telegram MTProto (or any protocol
that CONNECTs to a raw IP) hits a DPI-dropped DC, failing fast lets
the client rotate to the next DC roughly twice as quickly. Users
previously sat on "connecting..." for nearly a minute walking DC1→DC3.
- SNI rotation pool: add maps/chat/translate/play/lens.google.com to
both the Rust DEFAULT_GOOGLE_SNI_POOL and the Android DEFAULT_SNI_POOL.
Ported from upstream 57738ec. Extra fingerprint spread plus a couple
of SNIs (maps, play) that reliably pass DPI where shorter
*.google.com names don't.
- x.com GraphQL URL truncation: when the path matches
/i/api/graphql/<hash>/<op>?variables=..., drop everything from the
first `&` onward. The combined variables+features+fieldToggles
query string regularly exceeds Apps Script's URL length cap and
returns a generic relay error; `variables=` alone is enough for
x.com's timeline to render. Ported from upstream 2d959d4.
No version bump — these are low-risk infrastructure patches; they'll
fold into the next release.
- README: Persian FAQ was claiming ~2 million UrlFetchApp calls/day.
Real free-tier quota is 20,000/day (100,000 on paid Workspace) per
https://developers.google.com/apps-script/guides/services/quotas.
Closes#63.
- DEFAULT_GOOGLE_SNI_POOL (Rust) + DEFAULT_SNI_POOL (Android): add
scholar.google.com. Reported in #47 as another SNI that reliably
passes DPI on MCI / Samantel where plain *.google.com subdomains are
selectively blocked. Same mechanism as accounts.googl.com.
Second operating mode for users whose network already blocks
script.google.com and therefore cannot reach it to deploy Code.gs
in the first place. In google_only, the client runs only the
SNI-rewrite tunnel to *.google.com and the other Google-edge
suffixes that are already allowlisted; non-Google traffic falls
through to direct TCP. No script_id or auth_key is required. Once
Code.gs is deployed, the user switches to apps_script mode and
pastes the Deployment ID.
- config: Mode enum, relaxed validation when mode is google_only
- proxy_server: mode check in dispatch_tunnel; DomainFronter is now
Option<Arc<_>> so it is not constructed in google_only
- desktop UI and Android app: Mode dropdown, Apps Script fields
disable in google_only
- README: bootstrap subsection in English and Persian
- config.google-only.example.json
- version bump to 1.2.0 + changelog entry
Backward compatible with existing apps_script configs.
Contains the three safety fixes from PRs #48/#49/#50 and the Persian
README RTL polishing from #58, all squashed into main. Merge details
already in their individual PR comments; summary:
#48: reject truncated Content-Length relay responses (previously
silently accepted whatever bytes arrived before EOF)
#49: reject truncated or malformed (missing CRLF) chunked-encoding
relay responses (same class of silent-acceptance bug)
#50: restrict the SNI-rewrite tunnel dispatch to port 443. Plain
HTTP (:80) targets that happened to match google.com / hosts
override were being steered into the TLS tunnel and blocking
waiting for a ClientHello that would never arrive.
#58: trailing-whitespace line-breaks on Persian bullet lists in
README so the RTL rendering doesn't collapse consecutive
items into a single paragraph.
Test suite grew from 54 to 58 passing (three new negative tests for
the relay-reader correctness fixes + one SNI-rewrite port filter).
Telegram CI notify default switched to file-plus-link:
- script gains a `--with-changelog` flag; default OFF
- workflow only passes it when `vars.TELEGRAM_INCLUDE_CHANGELOG=true`
- every routine release now posts just the APK + short caption
(title + SHA-256 + repo URL + release URL) with no long body
To include bullets for a given release again:
gh variable set TELEGRAM_INCLUDE_CHANGELOG --body true
The existing `vars.TELEGRAM_NOTIFY_ENABLED` job-level gate remains —
changelog toggle is orthogonal to enable/disable.
Also closes PR #55 without merging; ads/analytics domains were being
lumped under a YouTube-specific toggle, and the PR committed per-
machine \`.cargo/config.toml\` + zig-cc cross-compile helpers that
would have broken CI on actual Windows / macOS runners.
dispatch_tunnel() is only used by the HTTP CONNECT and SOCKS5 listeners.
It previously forced hosts matched by matches_sni_rewrite() or the hosts
override map into do_sni_rewrite_tunnel_from_tcp() regardless of port.
That tunnel is TLS-specific: it accepts inbound TLS from the client and
opens a second TLS connection to the Google edge. For non-HTTPS targets
such as :80, selecting that path makes the proxy wait for a ClientHello
that will never arrive.
Introduce should_use_sni_rewrite() and require port 443 before forcing the
rewrite tunnel from dispatch_tunnel(). Non-HTTPS targets now remain on the
normal dispatch path.
The tests now cover both suffix-based and hosts-map matches on ports 443
and 80.
Co-authored-by: freeinternet865 <free@internet865.com>
read_chunked() previously accepted incomplete chunked bodies when the
upstream stream closed before a full chunk payload and delimiter had been
read. It also drained size + 2 bytes without verifying that the delimiter
after each chunk was "\r\n".
Return FronterError::BadResponse("connection closed mid-chunked response")
when EOF occurs before a complete chunk and delimiter are available, and
return FronterError::BadResponse("chunk missing trailing CRLF") when the
chunk delimiter is invalid.
The test suite now covers both truncated-chunk and missing-CRLF inputs.
Co-authored-by: freeinternet865 <free@internet865.com>
Co-authored-by: therealaleph <therealaleph@gmail.com>
read_http_response() handled Content-Length bodies by breaking out of the
read loop on EOF and returning whatever bytes had been collected so far.
That allowed a truncated upstream relay response to be treated as a complete
HTTP response.
Return FronterError::BadResponse("connection closed before full response body")
when the stream closes before the declared Content-Length has been read.
The test suite now covers a response that declares Content-Length: 5 but
only sends 3 body bytes.
Co-authored-by: freeinternet865 <free@internet865.com>
Users of the upstream Python port
(github.com/masterking32/MasterHttpRelayVPN) reported that YouTube
videos render fine through theirs while the Rust port stalls. Diff
against the Python source exposed two substantive gaps we were
missing:
1. SNI-rewrite list was much shorter than upstream. Added:
gvt1.com, gvt2.com — Google Video Transport CDN (YouTube
video chunks + Chrome auto-updates +
Play Store downloads)
doubleclick.net — ads
googlesyndication.com
googleadservices.com
google-analytics.com
googletagmanager.com
googletagservices.com
fonts.googleapis.com — already covered by the googleapis.com
suffix but mirrored explicitly for clarity
These are all on Google's GFE IP pool, so they route over the
existing SNI-rewrite tunnel (direct to `google_ip` with SNI
rewritten) instead of the quota-limited Apps Script relay.
2. No range-parallel download path. Apps Script's per-call latency
is ~flat (~1-2s regardless of payload), so a 10 MB single GET
takes ~10s round-trip; the player times out or stutters. Upstream
Python's `relay_parallel` probes with Range: bytes=0-262143, and
if the origin supports ranges, fetches the rest in parallel
256 KB chunks (up to 16 concurrent). Ported that logic as a new
`DomainFronter::relay_parallel_range` method, called from both
MITM-HTTPS and plain-HTTP handlers for GETs without a body. Rust
implementation uses `futures::stream::buffered` for ordered
bounded-concurrency fan-out; cache layer already skips Range
requests (added defensive check in relay() too).
The existing single-script fan-out (`parallel_relay` config) is
complementary — it races N script IDs for each individual chunk,
where the range-parallel path slices the overall download. Both are
active simultaneously when both are configured.
Helper functions for HTTP parsing (split_response,
parse_content_range_total, rewrite_206_to_200, assemble_full_200)
mirror the Python equivalents.
No behaviour change for non-GET requests; no cache-correctness
changes for GETs that don't return 206.
v1.1.2 reached cargo build inside the mipsel docker this time
(YAML-fold bug finally out of the way) and surfaced the real
underlying problem: MIPS32 has no native 64-bit atomic instructions,
so std::sync::atomic::AtomicU64 doesn't exist on
mipsel-unknown-linux-musl. Three call sites (DomainFronter stats
counters + the request-cache) failed to resolve the import.
Fix: depend on `portable-atomic` with the `fallback` feature and
import AtomicU64 from there instead of std. The API is identical
(same associated methods, same Ordering accepted), so the two
touched files change only the `use` line. On 64-bit targets
portable-atomic compiles down to the native 64-bit atomic insns
with no overhead; on MIPS32 it uses a global spinlock, which is
fine for counter increments that happen a few times per relay.
Cache.rs and domain_fronter.rs both updated. No other callers of
AtomicU64 in non-cfg-gated code (android_jni.rs has it but is
gated `#![cfg(target_os = "android")]`, so mipsel-linux-musl
never sees it).
`cargo test --lib` / `cargo build` still pass on host.
v1.1.1 still failed the mipsel CI matrix for a non-obvious reason.
The `Build CLI (mipsel-softfloat via docker)` step passed a
multi-line argument to `sh -c "..."` with `\` line continuations
and inline `#` comments:
sh -c "set -eux; \
# The image ships with a pre-installed nightly ... \
rustup toolchain uninstall ... \
..."
YAML's `run: |` block-scalar folds that into a single line on the
shell side — backslash-newline collapses become spaces. The
payload handed to `sh -c` becomes one long line in which the
first `#` comments out everything that follows on that line, so
the only command that actually ran inside the container was
`set -eux;`. Everything after it was a comment. The container
exited successfully (set -eux + empty; is a zero-exit no-op),
the `target/` directory never got created, and the post-docker
`sudo chown -R "$(id -u):$(id -g)" target` failed with
chown: cannot access 'target': No such file or directory
Process completed with exit code 1.
which fooled me into thinking the toolchain logic failed, when
actually NO toolchain logic ran at all.
Fix: use bash with a single-quoted multi-line script. Single
quotes preserve newlines literally, so `#` stays a
line-terminating comment rather than collapsing. Heredoc-style
formatting; same commands as before.
No other changes. Version bumps only (Cargo + Android versionCode/
versionName). Telegram notify stays off via the repo-variable
gate we added yesterday.
The `telegram:` job now runs only when the repo-level variable
`TELEGRAM_NOTIFY_ENABLED` is set to the literal string \"true\".
Default (unset) = job skips silently.
Turn on: gh variable set TELEGRAM_NOTIFY_ENABLED --body true
Turn off: gh variable set TELEGRAM_NOTIFY_ENABLED --body false
(or `gh variable delete TELEGRAM_NOTIFY_ENABLED`)
The Python script, secrets, and changelog format all stay in place
so re-enabling is a one-liner, not a workflow edit. Forks that don't
need Telegram don't have to touch anything — they get the clean
no-notify behaviour out of the box.
SNI rotation pool gains `accounts.googl.com` (issue #42). Reporter
confirmed it passes DPI on Samantel and MCI — Iranian carriers that
selectively block some of the longer google.com subdomain SNIs.
`googl.com` is a Google-owned redirect alias served off the same GFE
pool, so the TLS handshake works against `google_ip:443` without
extra plumbing; we just present the name in the ClientHello for
fingerprint diversity. Mirrored into the Android default pool too.
The mipsel-softfloat target finally builds green in CI — two earlier
bugs that compounded: messense doesn't publish a `:mipsel-musl-softfloat`
image tag (fixed in main earlier by using `mipsel-musl` +
`RUSTFLAGS=-C target-feature=+soft-float` + `-Z build-std`), and the
pre-installed nightly in that image has a broken component state
that rustup can't upgrade in place (fixed by uninstalling nightly
first). Both fixes are in the tagged commit this time. Closes
issue #26.
Previous issues addressed in v1.1.0 that this release documents the
closing of:
- issue #28: "egui_glow requires opengl 2.0+" on old Windows /
RDP / VMs — fixed via dual glow+wgpu compile + MHRV_RENDERER
env var + run.bat auto-retry.
- issue #37: connection-mode picker (VPN/TUN vs Proxy-only) so
users who already run another VPN can still use mhrv-rs as a
per-app HTTP/SOCKS5 proxy.
Version bump: 1.1.0 → 1.1.1 (versionCode 110 → 111).
The messense/rust-musl-cross:mipsel-musl image ships a pre-installed
nightly Rust in a state rustup can't cleanly upgrade over — the
in-place upgrade errors with
error: failure removing component 'clippy-preview-x86_64-unknown-linux-gnu',
directory does not exist: 'share/doc/clippy/README.md'
because the prior install is missing files rustup expects to delete.
Workaround: uninstall nightly first, then install fresh with the
minimal profile, then add rust-src as a separate step.
continue-on-error keeps this experimental target from blocking the
release — landing the fix on main so the NEXT tag attempt gets a
working mipsel artifact, without spinning up yet another retag dance
for v1.1.0.
The image tag I assumed existed (`mipsel-musl-softfloat`) isn't
published by messense; docker-pull errored with "manifest unknown".
Available mipsel tags are just `mipsel-musl` (hardfloat) and the
regional mirrors of same.
Fix: use `messense/rust-musl-cross:mipsel-musl` (the standard
hardfloat image) and force soft-float code generation via
`RUSTFLAGS=-C target-feature=+soft-float` on top of the nightly
`-Z build-std=std,panic_abort` we were already using. build-std
recompiles libstd with the same RUSTFLAGS, so libstd itself comes
out soft-float even though the image's gcc/musl is hardfloat. We
don't link anything beyond libc for mhrv-rs (ring is pure-asm for
the crypto hot paths), so the fact that musl libm isn't soft-float
doesn't bite us.
Net result: the binary emits no hardware FP instructions, which is
what MT7621 actually needs.
The v1.1.0 CI telegram job failed with curl exit 26 "Failed to
open/read local data from file/application" because:
-F "caption=<b>mhrv-rs Android v1.1.0</b>..."
curl's -F treats a value starting with `<` as "read from file
named ..." (the canonical way to put file CONTENTS into a text
form field). Our HTML captions start with `<b>`, so curl tried
to open a file named `b>mhrv-rs Android v1.1.0</b>...`, failed,
and the whole job went red.
Rewrote the step in Python (`.github/scripts/telegram_release_notify.py`).
stdlib urllib + http.client have no such value-interpretation
wart. Also:
- uses `application/vnd.android.package-archive` content-type
so Telegram shows the APK with an Android-package label, not
generic octet-stream
- proper sha256 hash (streaming, not shell-piped)
- consolidated the two shell-script HEREDOCs that were parsing
the changelog into one place
- clean exit codes: "no changelog file" and "no secrets" both
exit 0, a broken Telegram response exits non-zero
No behaviour change for callers — the workflow just calls the
script with the same four inputs.
New `telegram:` job in release.yml downloads the Android artifact
uploaded by the `android:` job, posts the APK with a short caption
(Telegram caps captions at 1024 chars, we blow past that), then
replies with the full changelog in two quote blocks — Persian first,
English second — matching the format the user wants.
Changelog content lives in `docs/changelog/v<tag>.md`. The file has
a comment header explaining the format, then:
- Persian bullets
- a bare `---` separator line
- English bullets
The workflow splits on that separator. No emojis. Missing changelog
file = the reply is skipped (doc post still lands).
Telegram credentials come from repo secrets:
TELEGRAM_BOT_TOKEN (set)
TELEGRAM_CHAT_ID (set)
Missing either = job logs a notice and returns 0. A forker who hasn't
set up Telegram gets a clean release with no notify attempt.
Also includes v1.1.0's changelog file so the first run of this job
has something to post.
v1.1.0 CI run (24820831905) failed at the dtolnay/rust-toolchain@stable
step for the mipsel-unknown-linux-musl matrix entry: that target is
tier-3 in stable Rust and `rust-std` isn't available via rustup, so
the `targets:` parameter errors out with
error: component 'rust-std' for target 'mipsel-unknown-linux-musl'
is unavailable for download
before the docker step that actually builds it ever runs. The docker
image we use (`messense/rust-musl-cross:mipsel-musl-softfloat`) bundles
its own Rust + std, so the host-level rustup isn't needed here at all.
Gated by `matrix.mipsel_softfloat != true` so the other 8 targets still
install their toolchain the normal way. `continue-on-error: true` on
the job level already prevented this from blocking the release, but
the failure was noisy and confusing — fix makes the whole matrix green.
Pushed as a small follow-up to v1.1.0 so the re-tag picks it up.
Major feature release across Android + desktop. Six items the user
asked for, verified end-to-end on the emulator.
Android
-------
* Unified Connect/Disconnect button. Single large button swaps
between green "Connect" (when the service is down) and red
"Disconnect" (when it's up). Tracks the real service state via a
new process-wide `VpnState` singleton flipped from the service's
startEverything() / teardown() — not optimistic, the button only
reports what the service actually did.
* Connection mode dropdown (issue #37). Two options: VPN (TUN) —
routes every app — and Proxy only — user configures per-app via
Wi-Fi proxy to 127.0.0.1:8080 (HTTP) / :1081 (SOCKS5). PROXY_ONLY
skips VpnService.prepare() entirely (no OS VPN grant prompt) and
the service just keeps the foreground listeners up. Default is
VPN_TUN so existing behaviour is preserved for users who upgrade
without looking at the dropdown.
* App splitting. In VPN_TUN mode you can pick All / Only selected /
All except selected, with a picker dialog that lists installed
user-visible apps (LazyColumn with search, "show system apps"
toggle, multi-select checkboxes). ONLY calls
`Builder.addAllowedApplication()` for each chosen package;
EXCEPT calls `addDisallowedApplication()` additive to the
mandatory self-exclude. Requires QUERY_ALL_PACKAGES — added to
the manifest along with a `<queries>` launcher-intent filter so
the picker rows can render app labels, not just package strings.
* Persian/English UI toggle with RTL. Top-bar TextButton cycles
AUTO → FA → EN → AUTO. Persian strings live in
`res/values-fa/strings.xml`; English in `res/values/strings.xml`.
`AppCompatDelegate.setApplicationLocales()` is used as the
persistence layer (plus `AppLocalesMetadataHolderService` meta
and `locales_config.xml` for the per-app-language OS entry on
API 33+). MainActivity overrides `attachBaseContext` to wrap the
context with the right locale at the earliest possible moment —
otherwise a saved preference wouldn't apply until the SECOND
process after toggling. RTL swaps automatically because Persian
is script="Arab" in Android's locale database.
* Collapsible How-to-use card. The big instruction block that used
to dominate the bottom of the screen now lives inside a
CollapsibleSection that starts expanded for a fresh install
(empty deployment URLs / auth_key) and collapsed otherwise.
* Update check auto-fires on first composition, silent-on-up-to-date,
snackbar-only-if-available. Still surfaces via the version badge
tap for manual checks.
* MhrvVpnService teardown guard was kept from v1.0.2 —
`AtomicBoolean` makes the second caller a no-op, which is the
SIGSEGV fix for "tap Stop, app closes" from before. Stress-tested
under rapid Connect/Disconnect cycles.
Desktop
-------
* Fix: Advanced section silently resetting on every Save. `ConfigWire`
was missing `fetch_ips_from_api` / `max_ips_to_scan` /
`scan_batch_size` / `google_ip_validation` — every persist dropped
them, every reload fell back to the serde defaults, user saw their
Advanced toggles reset. Added the fields to the wire struct (issue
surfaced by the user as "Advanced resets after reopening the app").
* Windows renderer fallback (issue #28). `eframe` is now built with
BOTH `glow` (OpenGL 2+) and `wgpu` (DX12/Vulkan/Metal); runtime
defaults to glow for compat but honours `MHRV_RENDERER=wgpu` for
boxes that crash with "egui_glow requires opengl 2.0+" — old
Windows hardware, RDP sessions, VMs without GPU acceleration.
`run.bat` auto-retries the UI with `MHRV_RENDERER=wgpu` if the
first launch exits non-zero, so users don't need to know about
the flag.
CI
--
* Added OpenWRT mipsel-softfloat build target (issue #26). MT7621
routers specifically need soft-float because the CPU has no FPU;
a hard-float binary segfaults on first fp op. Built via
`messense/rust-musl-cross:mipsel-musl-softfloat` docker image +
nightly Rust with `-Z build-std` (mipsel is Rust tier 3 since
1.72, no pre-built std). Marked `continue-on-error: true` — the
tier-3 target occasionally regresses and we'd rather ship the
rest of the release than block on MT7621 support.
Signature / versioning
----------------------
* versionCode 110, versionName 1.1.0; Cargo bumped to 1.1.0.
* Release APK signed with the committed `release.jks` (same as
v1.0.2), so v1.0.2 → v1.1.0 upgrades install in-place without
the uninstall-first dance.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors docs/android.md for Persian-speaking users who land on the
Persian half of the README. Same structure — TOC, requirements table,
six-step setup, UI reference, known limitations, troubleshooting
table, log-collection snippet — rewritten in Persian RTL.
README's Persian section gets a new "اجرا روی اندروید" subsection
right before the FAQ, with a five-step quickstart and links to the
full Persian and English Android docs. The English preamble
(above the fold) also gains a فارسی link next to the English
Android-doc reference so bilingual readers see both options
immediately.
No code touched. Cross-references only.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old version landed each release cycle with a different "step 5
says to open Security settings but the code now opens top-level
Settings" kind of drift. Swept the whole page and rebuilt it around
what the app actually does on v1.0.2:
* adds a table of contents at the top — the guide is now scan-first
* requirements moved into a table so the phone/SDK/quota constraints
are all visible at once
* Apps Script deploy step uses a table for the New-deployment form
fields (less prose to read)
* step 4 (SNI tester) explains each possible row outcome in a table,
with the concrete "tap Auto-detect" action for the common failure
* step 5 (MITM CA) now matches the v1.0.2 flow: top-level Settings
app + search "CA certificate", not a Security-settings deep-link.
Search is more portable across Pixel/Samsung/Xiaomi than naming
the menu path
* new "UI quick reference" table mapping each control to what it does
— helps users who skipped the setup prose
* Known limitations tightened: Cloudflare Turnstile loop explained
with the (IP, UA, JA3) binding table; IPv6 leak, UDP/QUIC,
per-script quota, and the Android-7+ user-CA opt-out all kept
* Troubleshooting is now a single table with symptom → cause → fix
columns, including the INSTALL_FAILED_UPDATE_INCOMPATIBLE one-time
note for the v1.0.1 → v1.0.2 upgrade path
* new "Collecting a useful log" section: one copy-pasteable
adb logcat command that captures the tags that matter
(MhrvVpnService, mhrv_rs, mhrv-crash, tun2proxy)
* removed the stub Persian section at the bottom — it said
"file an issue if you want a translation" which is noise;
re-adding only if someone actually asks
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three fixes + one behaviour change from v1.0.1 reports.
APK signature is now stable (release.jks committed)
----------------------------------------------------
v1.0.0 and v1.0.1 signed release APKs with Gradle's
auto-generated debug keystore, which is randomly generated per
machine and per CI runner. Result: every upgrade failed with
INSTALL_FAILED_UPDATE_INCOMPATIBLE and users had to uninstall
first. Unfixable without a stable key.
android/app/release.jks now holds that key, committed to the
repo with the password in plaintext in build.gradle.kts. This
is fine for a FOSS sideload project without a Play Store
identity — the trust model is "trust the source tree you
pulled from," not "trust the key we hold." Anyone forking and
shipping a rebranded build should generate their own key.
One-time cost: v1.0.1 → v1.0.2 STILL requires uninstall,
because we're switching signature keys. Every upgrade from
v1.0.2 onward is clean.
Stop no longer (sometimes) closes the app
-----------------------------------------
teardown() is reachable from three paths on two threads:
1. ACTION_STOP onStartCommand branch (mhrv-teardown worker)
2. onDestroy after stopSelf (main thread)
3. VpnService revocation out-of-band (main thread)
Running the full native cleanup sequence twice races the two
threads through Tun2proxy.stop() → fd.close() →
Native.stopProxy(handle) on state that's already been
nullified — SIGSEGV source, user-visible as "tap Stop, app
disappears."
New AtomicBoolean `tornDown` gates entry: first caller wins,
every subsequent caller logs "teardown: already done" and
returns. onDestroy also wraps the call in try/catch — crashing
out of onDestroy takes the whole process with it, which is
exactly the bug we're trying to fix. Smoke-tested on emulator:
teardown now logs
teardown: begin caller=mhrv-teardown
... clean sequence ...
teardown: done
onDestroy entered
teardown: already done, skipping (caller=main)
onDestroy done
with PID unchanged throughout.
CA install now routes to the Settings search
--------------------------------------------
Old flow: `Settings.ACTION_SECURITY_SETTINGS` deep-link, then
walk "Encryption & credentials → Install a certificate →
CA certificate". That path varies wildly between OEMs (Samsung
buries it under "Biometrics and security → Other security
settings"; Xiaomi under "Passwords & Security → Privacy"; Pixel
splits it between "More security settings" and "Privacy
controls" depending on Android version). Users got lost.
New flow: open the top-level Settings app
(`Settings.ACTION_SETTINGS`) and instruct the user to use the
Settings search bar to find "CA certificate". Search is
consistent across OEMs and Android versions; the menu paths
are not. Dialog, snackbar, and `docs/android.md` copy all
updated to match.
Version bump: 1.0.1 → 1.0.2 (versionCode 101 → 102).
releases/mhrv-rs-android-universal-v1.0.1.apk replaced with
the v1.0.2 build.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three reported issues from v1.0.0 — one real bug, two UX gaps.
google_ip auto-resolve (THE FIX)
--------------------------------
Google rotates the A record for www.google.com across their anycast
pool. A hardcoded default IP breaks new installs on any network that
isn't geo-homed to the same edge — symptom is "all SNIs time out"
even with a fresh deployment. On Start and via a new "Auto-detect"
button, we now do a JVM-side InetAddress lookup BEFORE establishing
the VPN (so the resolver uses the underlying network, not our own
Virtual-DNS TUN — avoids a loop), update the config, and continue.
The auto-resolve lives in the HomeScreen click handler (not
MainActivity) so it goes through the same `persist(cfg)` the text
fields use. Previous iteration did `ConfigStore.load → modify → save`
directly to disk, which left Compose's in-memory cfg stale and a
subsequent field edit would overwrite the fresh IP. One source of
truth now.
Also defensively repairs front_domain: if it's been corrupted into
an IP literal (bad paste, whatever) we restore "www.google.com" —
the TLS SNI on the outbound leg has to be a hostname or the
handshake lands on the wrong vhost.
Robust Stop
-----------
The Stop button now dispatches both ACTION_STOP (graceful: runs
teardown, stops tun2proxy, closes TUN fd, shuts down Rust runtime)
AND stopService() (defensive: covers force-closed-then-reopened
zombie state where Android auto-restarted our START_STICKY service
in a fresh process and the in-memory TUN reference is gone).
Check-for-updates
-----------------
Tapping the version badge in the top bar now runs the same
update_check that the desktop UI uses, via a new
`Native.checkUpdate()` JNI entry point. Returns a JSON blob the
Kotlin side parses into an "Up to date", "Update available: v→v
<url>", "Offline: ...", or "Check failed: ..." snackbar. Mirrors
the desktop's behavior so a user doesn't have to manually poll
GitHub for new builds.
Crash visibility
----------------
New MhrvApp.kt registers a process-wide uncaught exception handler.
Crashes are now stamped into logcat under the `mhrv-crash` tag with
the thread name before the default handler kills the process —
previously the JVM crash in coroutines / the log drain / the
tun2proxy worker was invisible unless you caught the dropoff in
real time.
Version bump: 1.0.0 → 1.0.1 (versionCode 100 → 101). Release APK
rebuilt and replaces the 1.0.0 copy in releases/; CI will regenerate
on the v1.0.1 tag push.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>