* ci(release): pin i686-pc-windows-msvc to Rust 1.77.2 for Win7 compat
Fixes#318. Rust 1.78 (May 2024) raised the std MSRV for Windows from
Win7 to Win10 by switching std::time to GetSystemTimePreciseAsFileTime,
a kernel32 export that doesn't exist on Win7 SP1. Building the i686
binary with stable Rust (currently 1.86+) produces an exe that fails
to load on Win7 with "the procedure entry point
GetSystemTimePreciseAsFile could not be located in the dynamic link
library kernel32.dll" — making the whole reason we ship i686 (legacy
Win7 32-bit boxes per #272) moot.
Add a per-matrix `rust_toolchain` knob; only i686-pc-windows-msvc uses
it, pinning to 1.77.2 (last stable that supports Win7). Other targets
remain on @stable and pick up regular Rust updates.
dtolnay/rust-toolchain switches from `@stable` to `@master` because
the per-tag aliases (`@stable`, `@1.77.2`) can't be selected via a
matrix variable — `@master` accepts the toolchain string as input.
Cache key gains a toolchain suffix so the 1.77.2 cache doesn't collide
with the stable cache for the same target, and a future toolchain bump
invalidates only the affected slot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(release): make i686-pc-windows-msvc continue-on-error
Companion to the Rust 1.77.2 pin: if the deps' MSRV ever moves above
1.77, the i686 target will fail to build, but we don't want it to
block the rest of the release. Mirror the mipsel-softfloat approach.
If/when this triggers, options are dropping i686 entirely or moving
to the tier-3 i686-win7-windows-msvc target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
Patch release for the auto-blacklist of timeout-saturated deployments
shipped via #319. No new features; bugfix only — cargo, gradle, and
changelog bumps in lockstep so the release workflow can ship matching
artifacts.
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 v1.7.7 tag commit (6885800) only updated the changelog; the
version field edits failed earlier due to file-state-changed-mid-edit
race. Fixing forward — Cargo.toml + build.gradle.kts now show 1.7.7
properly.
Workflow will build from main HEAD on workflow_dispatch, so the
v1.7.7 release-page artifacts will have the correct internal version
even though the tag commit itself doesn't include the version bump.
- #288 (@amiralishoja): adds i686-pc-windows-msvc to the release
matrix. 32-bit Windows users get mhrv-rs-windows-i686.zip on
every release.
- #290 (@dazzling-no-more): per-deployment longpoll fallback state
with TTL-based auto-recovery. Replaces a global AtomicBool that
one degraded deployment could permanently flip. Now the aggregate
legacy gate only fires when every configured deployment is marked,
and self-corrects on TTL expiry — upgraded tunnel-nodes rejoin
the fast path automatically. 4 new tokio::test virtual-time tests.
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 commit-releases job's `actions/download-artifact@v4` step has
failed twice in a row (v1.7.5 retrigger, v1.7.6) with the same
shape: ~10 artifacts download successfully, then "Unable to
download artifact(s): Artifact download failed after 5 retries" on
the 11th-13th. The 10 that complete print their SHA256 digests
cleanly; the failure is unambiguously inside actions/download-
artifact, not on our side.
Workaround: pull from `gh release download` instead. The `release`
job populated the GitHub Release page a few seconds earlier with
the same artifacts; pulling from there reads from a different
CDN (Release-page blob store) with different retry / rate-limit
characteristics. Empirically more reliable for our 13-artifact
release size.
Filtered to *.tar.gz / *.zip / *.apk so we only fetch the user-
facing artifacts (skipping anything like checksum sidecars that
softprops/action-gh-release@v2 might add later).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.7.4 added googlevideo.com to SNI_REWRITE_SUFFIXES on the theory
that video chunks should bypass the Apps Script relay. Multiple
users (#275 amirabbas117, #281 mrerf) reported total YouTube
breakage on v1.7.4: SNI-rewriting googlevideo.com:443 to a GFE IP
returned TLS handshake failure / wrong-cert error.
Root cause: googlevideo.com is served by Google's separate "EVA"
edge IPs, not the regular GFE IPs that the user's `google_ip`
typically points at. The SNI-rewrite tunnel TLS handshake against
a GFE IP for googlevideo.com SNI fails because the GFE IP doesn't
hold a googlevideo.com cert.
Pre-v1.7.4 behaviour restored: video chunks fall through to the
Apps Script relay path. Slower but reliable on every GFE IP.
The other v1.7.4 youtube_via_relay carve-out fixes (ytimg.com
correctly stays on SNI rewrite, youtubei.googleapis.com correctly
goes through relay) remain intact — those were a separate
improvement and still correct.
Future: if we want direct googlevideo.com routing, it needs a
separate `eva_edge_ip` config knob — users can populate from their
own EVA scan, defaulting to "use relay" if not configured.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.7.5's block_quic config field broke the UI binary build because
src/bin/ui.rs constructs Config{} explicitly and I forgot to add the
new field there. CLI binary loads from JSON via serde so it didn't
trip — only the 4 UI-building targets failed (linux-amd64-gnu,
windows-amd64, macos-amd64, macos-arm64).
block_quic is round-tripped through the form (config-only for now,
no UI control) so save doesn't drop a user-set true.
- Adds `block_quic = true` config flag for client-side QUIC drop.
SOCKS5 UDP relay refuses UDP/443 datagrams; browsers fall back to
TCP/HTTPS through the relay. Opt-in. Thanks @w0l4i
- Workflow now auto-refreshes the in-repo releases/ folder on each
release tag, so Iranian users behind GitHub-Releases-page filtering
can download via Code → Download ZIP. Practice was started before
v1.1.0 then dropped; resumed at user request.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resume the practice (dropped after v1.1.0) of committing prebuilt
binaries to the repo's releases/ folder. Iranian users behind state
network filtering frequently can't reach the GitHub Releases page
(/releases/tag/...) but CAN reach the static source tree via
Code → Download ZIP — that pulls the in-repo releases/ folder along
with the source. Telegram channel feedback explicitly requested
this be resumed.
The new commit-releases job:
1. Runs after release+build+android succeed.
2. Wipes existing binary artifacts from releases/ (.apk, .tar.gz,
.zip) but preserves README.md and .gitattributes.
3. Copies all desktop archives (which already have stable
platform-suffixed names like mhrv-rs-linux-amd64.tar.gz).
4. Copies all per-ABI Android APKs (so users on slow connections
can grab the ~37 MB arm64-v8a APK instead of the ~110 MB
universal).
5. sed-updates the "Current version" line and APK filename refs
in releases/README.md (both English and Persian copies).
6. Commits as github-actions[bot] and pushes to main.
The GitHub Release page itself keeps the canonical versioned
artifacts as before — this in-repo folder is the fallback for
users who can't reach that URL.
Tag protection rules don't apply to refs/heads/main so the push
isn't gated. release-drafter.yml triggers on push-to-main but only
updates the next-release draft, no cycle risk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
w0l4i has been asking for client-side QUIC block since #213. Now
implemented as a small config flag.
When `block_quic = true`, the SOCKS5 UDP relay drops any datagram
destined for port 443 — that's HTTP/3-over-UDP. The client's QUIC
stack retries a couple of times and then falls back to TCP/HTTPS
through the regular CONNECT path (which goes through the relay
normally).
Why client-side rather than server-side udpgw block: the udpgw
block in #222 is bound to Full mode + Android tun2proxy. This
covers everyone — apps_script users, desktop, Full mode, all the
same path. Skipping at the SOCKS5 layer rather than the tunnel-node
layer also avoids paying 200–500 ms tunnel-node round-trip per
QUIC datagram drop, which compounds during browser retries.
Silent drop is the contractually correct shape: SOCKS5 UDP wire
has no `host unreachable` reply (RFC 1928 §6 only defines that for
TCP CONNECT). Browsers' QUIC stacks have a "no response → fall
back" timeout, so silent drop matches what the protocol expects.
Default false (opt-in) — udpgw mitigates QUIC partly via persistent
sockets, and a tiny minority of sites only support HTTP/3.
Will ship in v1.7.5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#275: youtube_via_relay no longer routes video/image CDNs through
Apps Script. The flag now correctly carves out only the API/HTML
hosts where Restricted Mode is enforced; video chunks come direct
from googlevideo.com (which was missing from the SNI rewrite list
entirely — fixed). Long videos no longer hit Apps Script's 6-min
execution cap, and single-chunk timeouts no longer abort playback.
#280: TunnelMux now caches "destination unreachable" responses from
the tunnel-node (Network is unreachable / No route to host) for 30
seconds, short-circuiting subsequent CONNECTs to that destination
with 502 (HTTP) or 0x04 (SOCKS5). Saves ~5 batches/second on
IPv6-only host probes. Startup pre-warm pool grew 12→24.
143/143 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The tunnel-docker job in v1.7.3 release failed with:
error: failed to unpack package `serde_json v1.0.149`
Caused by: failed to open `/usr/local/cargo/registry/src/.../serde_json-1.0.149/.cargo-ok`
Caused by: File exists (os error 17)
Root cause: BuildKit's default cache-mount sharing is "shared" — both
linux/amd64 and linux/arm64 build stages mount the SAME on-disk cache
dir. Cargo's registry source extraction is non-atomic; both arches
race on `tar -xzf serde_json-1.0.149.crate` into the same destination,
and the loser hits EEXIST mid-unpack.
Fix: scope each cache mount with `id=cargo-registry-${TARGETPLATFORM}`
(and matching for cargo-git + target). BuildKit then keeps separate
on-disk caches per architecture — no race. Per-arch warm-build speedup
is preserved (each cache fills with that arch's pre-built deps); the
only loss is one cache miss per arch on the first build after this
change, which we already paid in v1.7.3.
The target/ mount is also platform-scoped since target/ holds compiled
object files for a single ABI; sharing across arches would either miss
or, worse, link wrong-ABI objects together.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-#275, youtube_via_relay=true routed every YouTube-related host
through Apps Script — including ytimg.com (thumbnails) and any
googlevideo.com chunk request the player issued. Two problems:
1. ytimg.com via Apps Script is wasted quota — image CDN, no
Restricted Mode logic to bypass.
2. googlevideo.com wasn't even in SNI_REWRITE_SUFFIXES, so video
chunks hit the relay regardless of the flag. A single chunk
timeout aborted the whole video on Firefox; long videos risked
the Apps Script 6-min execution cap mid-playback.
Fix: split YouTube into "API/HTML hosts" (where Restricted Mode
lives, gated by the flag) and "asset CDNs" (always direct). The
new YOUTUBE_RELAY_HOSTS list is youtube.com, youtu.be,
youtube-nocookie.com, youtubei.googleapis.com — those go through
relay when the flag is on. ytimg.com, googlevideo.com (added),
ggpht.com all stay on SNI rewrite.
The matches_sni_rewrite logic was also restructured: the carve-out
now runs FIRST before the SNI suffix match, so the broad
googleapis.com entry can't override the narrower
youtubei.googleapis.com decision.
Reported with detailed analysis by @amirabbas117. Will ship in v1.7.4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move from yyoyoian-pixel/tun2proxy fork (with patched JNI signature)
to canonical tun2proxy 0.7.21 from crates.io with feature flag
"udpgw". Cargo.toml [patch.crates-io] section removed entirely.
The Android side now resolves tun2proxy_run_with_cli_args at runtime
via dlsym from libtun2proxy.so, which is the upstream maintainer's
recommended path for callers that need full CLI flexibility.
mhrv-rs builds the CLI string in MhrvVpnService and passes it through
Native.runTun2proxy → src/android_jni.rs → dlsym → tun2proxy.
Future tun2proxy upgrades are now a single Cargo version bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Uses tun2proxy_run_with_cli_args (the C API) via dlsym instead of
modifying the JNI run() signature. The upstream tun2proxy maintainer
recommended this path — the CLI API accepts --udpgw-server natively.
- Cargo.toml: enable udpgw feature, remove [patch.crates-io]
- MhrvVpnService.kt: build CLI args with --udpgw-server in full mode
- Native.kt + android_jni.rs: dlsym wrapper for the C API
- Tun2proxy.kt: reverted to upstream signature
No fork, no patch, no submodule.
Co-authored-by: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- mhrv-rs:// deep links, QR scanner, clipboard banner, share sheet
- DEFLATE-compressed base64 encoding (~200 chars vs ~800 raw)
- Every import path requires explicit user confirmation; the dialog
shows the new deployment IDs and a trust warning so an attacker
posting a malicious mhrv-rs:// link in a public channel can't
silently overwrite a user's auth_key + script_ids
- ZXing for QR generation/scanning (no Google Play Services)
Closes#266. Thanks @yyoyoian-pixel — the rebase from auto-import
to confirmation-gated import is exactly the right shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(android): config import/export via clipboard, QR code, deep link, and share sheet
- Clipboard paste: banner auto-detects mhrv:// or raw JSON in clipboard,
one tap to import. Clipboard cleared after successful import.
- Export dialog: QR code + compressed hash + copy button + Android share
sheet (sends QR image + text together).
- QR scanner: ZXing embedded scanner in portrait orientation.
- Deep link: mhrv:// URIs auto-open the app and import the config.
- Compact encoding: only non-default fields included, DEFLATE compressed
before base64. Accepts both compressed and raw JSON on import.
- ConfigStore.loadFromJson() deduplicated — shared by file load + import.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: deep link requires confirmation, trust warning on import, mhrv-rs:// scheme
Security fix: deep link (mhrv-rs://) no longer auto-imports config.
Stashes decoded config for UI confirmation dialog — same flow as
clipboard paste and QR scan.
Import confirmation dialog now shows:
- Trust warning: "Importing routes your traffic through the deployment
IDs in this config. Only import from trusted sources."
- Mode and deployment ID count with first 3 IDs previewed
- Explicit Import / Cancel buttons
Also:
- Renamed scheme from mhrv:// to mhrv-rs:// (less collision risk)
- Deduplicated import dialog into shared ImportConfirmDialog composable
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Telegram release notifier used to post just the universal APK with
a single-document caption. This change ships the per-platform binaries
for macOS (amd64+arm64 CLI), Linux (amd64+arm64 CLI), Windows
(amd64 UI), and Android (universal APK) as a single Telegram media
group with one caption listing every filename + SHA-256.
Workflow side (.github/workflows/release.yml):
- The telegram job now downloads ALL artifacts (was: APK only).
- New `Prepare files for Telegram media group` step extracts the raw
binaries out of each per-platform .tar.gz / .zip (no archive
wrappers in the channel) and renames them with version suffixes
(mhrv-rs-linux-amd64-v1.7.2, mhrv-rs-windows-amd64-ui-v1.7.2.exe,
etc.). Per-platform extraction is best-effort: a missing artifact
emits a `::warning::` and skips that platform rather than failing
the whole post.
- The post step builds a `--files <path>` arg list from tg-files/,
sorted for deterministic order across runs, and invokes the
notifier without --with-changelog (the script auto-replies with
changelog whenever --files is used).
Script side (.github/scripts/telegram_release_notify.py):
- New --files arg (repeatable). 2..=10 files → sendMediaGroup; 1 file
→ sendDocument with the same caption shape; 0 → error. Telegram's
sendMediaGroup rejects single-item groups, so the 1-file fallback
isn't optional.
- New build_media_group_caption() composes title + per-file
filename+SHA list + repo/release URLs. Fits ~860 chars for a 6-file
release; fallback to filename-only-list if a future swell pushes
past Telegram's 1024-char caption cap.
- send_media_group() handles the multipart/form-data shape with each
file referenced as `attach://fileN` from the media JSON. Caption is
attached to file 0 only (Telegram clients render per-item captions
inconsistently for media groups; first-item-only is the safe
pattern).
- Legacy --apk path kept for any caller that hasn't migrated; either
--apk or --files must be present (validated at startup).
- _content_type_for() picks application/vnd.android.package-archive
for .apk and application/octet-stream for everything else, so
Telegram clients label the APK with the Android icon and label
desktop binaries by filename without a misleading icon.
Behavioural change for users:
- The Telegram channel now sees one grouped post per release with all
primary platform binaries inline, instead of just the APK. macOS
users wanting the gatekeeper-friendly .app.zip still grab it from
the GitHub Releases page; the Telegram drop is for the "give me
the binary, I'll run it" path.
- The Persian/English changelog reply that used to be opt-in (via
TELEGRAM_INCLUDE_CHANGELOG=true) is now automatic in the --files
path because the per-file SHA list eats the caption budget that
previously held the FA brief-note.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mhrv-rs --remove-cert (CLI) and Remove CA button (UI) for verified
clean-slate revocation. Clears OS trust store, NSS browser stores
(Linux Firefox/Chrome), and the on-disk ca/ directory. config.json
and the Apps Script deployment are untouched.
By-name trust verification runs before browser-state mutation; OS
removal failures return RemovalIncomplete with browser state intact
so retries are idempotent. Sudo-aware on Unix (re-roots HOME to the
real user). 29 new unit tests on the pure logic (Firefox user.js
marker handling, getent passwd parsing, NSS stderr classification,
NssReport state rules).
Tested end-to-end on Windows by the contributor; macOS verified at
merge time on real hardware (login keychain delete + NSS-missing
fallback). Linux paths await user testing.
Closes#121.
Thanks @dazzling-no-more.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Highlights:
- Native udpgw protocol in Full mode (#222) — Telegram voice/video
calls and Google Meet now work in Full mode on Android. UDP flows
through one persistent TCP tunnel (instead of session-per-destination)
so STUN/RTP flow counts no longer stall. Requires redeploying the
tunnel-node Docker image (ghcr.io/therealaleph/mhrv-tunnel-node:1.7.0).
- Android home screen restructure (#258, closes#246) — Connect button
now pinned under Mode field, App picker shows pre-selected apps at
top. With long deployment-ID lists, Connect no longer scrolls
off-screen.
- release-drafter + prepare-release tooling (#260) — incrementally
drafts release notes from merged PR titles; manual workflow_dispatch
prepares version bumps + changelog stubs.
No protocol breaking changes; existing apps_script-mode and Full-mode
deployments work unchanged. Full-mode users get udpgw automatically
once the tunnel-node Docker image is updated.
Thanks to @yyoyoian-pixel and @dazzling-no-more.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: native udpgw protocol alongside existing UDP associate
Why udpgw is needed even with UDP associate:
UDP associate (udp_open/udp_data) creates one tunnel session per UDP
destination and polls each independently. On high-latency or shaky
networks this compounds — N simultaneous UDP flows need N separate
polling loops, each paying its own batch round-trip overhead. Google
Meet calls, which fire dozens of concurrent STUN + RTP flows, stall
or fail entirely because the per-destination polling can't keep up.
udpgw multiplexes ALL UDP over one persistent TCP-like session using
conn_id framing. One batch op carries frames for many destinations.
Persistent sockets per (conn_id, dest) with continuous reader tasks
keep source ports stable — critical for protocols like Telegram VoIP
and STUN that expect replies on the same port.
Both paths coexist — they serve different traffic:
- UDP associate (SOCKS5): apps that negotiate SOCKS5 UDP relay
- udpgw (198.18.0.1:7300): TUN-captured UDP (DNS, QUIC, Meet, etc.)
tun2proxy vendored as git submodule at v0.7.20 with one transparent
commit adding udpgw_server to the Android JNI run() function.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: block QUIC (UDP 443) and DNS (UDP 53) from udpgw
QUIC through udpgw is slower than TCP/HTTP2 through the batch pipeline
— blocking it forces browsers to fall back to TCP, improving YouTube
and general browsing speed.
DNS is better handled by tun2proxy's virtual DNS / SOCKS5 UDP associate
path which is more reliable for single request-response exchanges.
VoIP (Telegram, Meet) still flows through udpgw normally.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: replace submodule with [patch.crates-io] for tun2proxy udpgw
Use the idiomatic Rust [patch.crates-io] mechanism instead of a git
submodule. Points to yyoyoian-pixel/tun2proxy fork with the udpgw
JNI parameter patch (upstream PR: tun2proxy/tun2proxy#247).
Will be removed once upstream ships the change in tun2proxy >= 0.8.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: pin tun2proxy patch SHA in Cargo.lock
Locks tun2proxy at dfc24ed1 so the patch resolution is recorded and
any branch rewrite is visible in the lockfile diff.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use AbortHandle for ConnSocket readers to prevent FD leaks
JoinHandle::drop detaches the task without aborting it. When
udpgw_server_task is cancelled (session close), the post-loop
cleanup never runs and per-(conn_id, dest) reader tasks become
zombies holding Arc<UdpSocket> file descriptors.
AbortHandle::drop aborts the task automatically, so cleanup is
correct by construction regardless of how the parent task exits.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- #245 (@Parsa307): match twitter.com in X.com URL normalization
- #255 (@dazzling-no-more): copy-logs button + selectable log lines on Android
- #257 (@dazzling-no-more): bulk paste of multiple deployment IDs on Android
- #256 (@dazzling-no-more): plain HTTP proxy passthrough in google_only mode
(used to return 502; now falls through to direct TCP / upstream_socks5,
matching the existing CONNECT behavior)
No protocol or wire-format changes; existing config and Apps Script
deployments work unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The batch-build loop blocked on a 30 ms timeout for the first message,
then drained whatever else was in the channel via try_recv() and fired
the batch. Under any non-bursty workload, the channel queue was always
empty by the time the first op woke us up — so every "batch" had
exactly one op, defeating the entire batching premise. Reporter (w0l4i)
saw `batch: 1 ops → ..., rtt=6.3 s` repeating in logs even under high
concurrency.
Fix: after the first op lands, hold the buffer open for an 8 ms
coalescing window. Concurrent ops (parallel fetches, HTTP/2 stream
openings, etc.) now accumulate into the same batch. 8 ms is rounding
error against the 2–7 s Apps Script RTT we're amortizing, and restores
the multi-op-per-batch behavior the rest of the code already supports
(MAX_BATCH_OPS=50, MAX_BATCH_PAYLOAD_BYTES=4 MiB).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
buildNotif() hardcoded `proxyPort + 1` for the SOCKS5 line, ignoring
cfg.socks5Port entirely. With the default Android config
(listenPort=8080, socks5Port=1081) the foreground notification read
"Routing via SOCKS5 127.0.0.1:8081" but the real listener was on 1081 —
so users configuring per-app SOCKS5 (Telegram, etc.) against the
notification value silently failed.
Use the same `cfg.socks5Port ?: (cfg.listenPort + 1)` elvis fallback the
real listener uses, and surface both ports in the notification:
HTTP 127.0.0.1:8080 · SOCKS5 127.0.0.1:1081
Reported by vpnineh and l3est (with netstat screenshots showing the
exact mismatch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>