Commit Graph

353 Commits

Author SHA1 Message Date
freeinternet865 4738a5768a domain_fronter: reject truncated Content-Length relay responses (#48)
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>
2026-04-23 14:36:27 +03:00
acdasdcasdcasdcasdc 053a22e858 Merge branch 'main' into feature/youtube-sni-relay 2026-04-23 14:12:00 +03:30
Shin (Former Aleph) 8d2f90b0a7 v1.1.4: YouTube video streaming — expanded SNI-rewrite list + parallel Range fetcher (#56)
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.4
2026-04-23 13:37:58 +03:00
root cee9e075d3 youtube sni rewrite 2026-04-23 10:07:42 +00:00
Shin (Former Aleph) 5a5139f6ea v1.1.3: portable-atomic polyfill so mipsel-softfloat compiles (#46)
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.3
2026-04-23 11:23:40 +03:00
Shin (Former Aleph) 383bea008e v1.1.2: actually-green mipsel-softfloat (YAML comment-fold bug) (#44)
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.
v1.1.2
2026-04-23 11:06:37 +03:00
therealaleph 54b3956e23 ci: gate Telegram auto-notify behind repo variable (default off)
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.
2026-04-23 10:52:44 +03:00
Shin (Former Aleph) a7b63ee53a v1.1.1: accounts.googl.com in SNI pool + mipsel-softfloat lands green (#43)
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).
v1.1.1
2026-04-23 10:45:00 +03:00
therealaleph a4e378d88c ci: mipsel-softfloat — fix rustup upgrade-in-place error
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.
2026-04-23 10:18:04 +03:00
therealaleph 6541eb013f ci: mipsel-softfloat — use mipsel-musl image + RUSTFLAGS=+soft-float
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.
v1.1.0
2026-04-23 10:14:11 +03:00
therealaleph cbfdab582a ci(telegram): move notify step to Python, fix curl < parse bug
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.
2026-04-23 10:09:51 +03:00
therealaleph d51719b7b5 ci: auto-post CI-built Android APK to Telegram on every release tag
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.
2026-04-23 09:52:41 +03:00
therealaleph a25ce563ab ci: skip host rustup for mipsel-softfloat (docker handles its toolchain)
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.
2026-04-23 09:49:06 +03:00
Shin (Former Aleph) 28be8f67d5 v1.1.0: unified Connect button, proxy mode, app splitting, Persian UI, MIPS build (#41)
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>
2026-04-23 09:38:10 +03:00
Shin (Former Aleph) be698f4928 docs(android, fa): Persian translation of Android guide + README section (#35)
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>
2026-04-23 04:42:33 +03:00
Shin (Former Aleph) 63a397cca8 docs(android): rewrite install guide for clarity + v1.0.2 accuracy (#34)
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>
2026-04-23 04:28:28 +03:00
Shin (Former Aleph) 64409f6b41 v1.0.2: stable release signature, idempotent Stop, top-level Settings for CA install (#33)
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>
v1.0.2
2026-04-23 04:19:52 +03:00
Shin (Former Aleph) b734f41faa v1.0.1: auto-resolve google_ip, robust Stop, Check-for-updates, front_domain repair (#31)
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>
v1.0.1
2026-04-23 03:45:08 +03:00
Shin (Former Aleph) 91015b0594 v1.0.0: multi-arch Android APK + GitHub Actions release job + install docs (#30)
Version bump reflects the scope — a unified Rust core that now ships
for desktop (Linux/macOS/Windows) AND Android from the same crate.

Android changes:
- build.gradle.kts: ABI filters expanded to arm64-v8a + armeabi-v7a
  + x86_64 + x86. cargoBuild{Debug,Release} pass all four ABIs to
  cargo-ndk in a single invocation. normalizeTun2proxySo() walks every
  ABI dir now (was arm64-only).
- Release buildType signs with the debug keystore — no Play Store
  target, so signature identity doesn't matter, installability does.
  Gradle auto-provisions ~/.android/debug.keystore if absent, so CI
  runners inherit this without extra setup.
- versionName 1.0.0, versionCode 100 (room to bump monotonically).

CI:
- release.yml gets a dedicated `android:` job that sets up JDK 17,
  Android SDK/NDK 26, all four rust-android targets, installs
  cargo-ndk, runs assembleRelease, and uploads a single universal APK
  named `mhrv-rs-android-universal-v<version>.apk` into the same
  `dist/` collected by the release job downstream.
- `release:` job now gates on `needs: [build, android]` so tagging
  v1.0.0 triggers both build matrices before cutting the GitHub
  release.

Docs:
- docs/android.md — full 10-step install walk-through: APK sideload,
  Apps Script deployment (with "Advanced → Go to (unsafe) → Allow"
  reality check), config paste, SNI reachability test, MITM CA
  install with OEM-specific nav paths (Pixel / Samsung / Xiaomi),
  Start, troubleshooting common failure modes. Also documents the
  known limitations — Cloudflare Turnstile loops (inherent to the
  Apps Script egress IP pool), UDP/QUIC not tunnelled, IPv6 leaks,
  Apps Script daily quota — so users know what to expect before
  trying it on a site that won't work.
- releases/README.md — APK row added to the English and Persian
  tables, version bumped everywhere to v1.0.0.
- Top-level README — Android listed under Platforms with a link
  to docs/android.md.

Release artifact:
- releases/mhrv-rs-android-universal-v1.0.0.apk — 38 MB universal
  APK built locally from this tree. Installs + launches on API 24+.
  The CI job will regenerate it on tag push; this is the copy
  committed for users who can't reach GitHub Releases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.0.0
2026-04-23 02:56:39 +03:00
Shin (Former Aleph) 96d1352728 Add Android app with full TUN bridge + two proxy fixes the desktop also wants (#29)
The app is a Kotlin/Compose front-end that reuses the mhrv-rs crate
via JNI. It speaks VpnService to get a TUN fd, hands that to tun2proxy,
and funnels every app's traffic through the in-process SOCKS5 listener —
no per-app proxy setup on the device.

Two fixes in `src/proxy_server.rs` apply to desktop builds too:

* SNI peek via `LazyConfigAcceptor`. When a browser uses DoH (Chrome's
  default), tun2proxy hands us a raw IP in the SOCKS5 CONNECT. Minting
  a MITM cert for the IP produced `ERR_CERT_COMMON_NAME_INVALID` on
  Cloudflare-fronted sites. We now read the ClientHello's SNI first
  and use that both as the cert subject and as the upstream host for
  the Apps Script relay (fetching `https://<IP>/...` with an IP in the
  Host header gets rejected by CF anyway).
* Short-circuit CORS preflight at the MITM boundary. `UrlFetchApp.fetch()`
  rejects `OPTIONS` with a Swedish "Ett attribut med ogiltigt värde
  har angetts: method" error, which silently broke every fetch()/XHR
  preflight and was the root cause of "JS doesn't load" on Discord,
  Yahoo, and similar. Since we already terminate the TLS the browser
  talks to, answering the preflight with a permissive 204 is safe —
  the real request still goes through the relay.

Android-side capabilities (feature-parity with `mhrv-rs-ui` where it
fits on a phone):

* multi-deployment ID editor
* SNI rotation pool + per-SNI "Test" + "Test all" (JNI into scan_sni)
* live logs panel (JNI ring buffer drained on a 500 ms poll)
* Advanced section: verify_ssl, parallel_relay, log_level, upstream_socks5
* CA install flow that matches modern Android's reality: saves
  `Downloads/mhrv-ca.crt` via MediaStore, deep-links Security settings,
  then verifies post-hoc by fingerprint lookup in AndroidCAStore (the
  KeyChain intent dead-ends with a Close-only dialog on Android 11+)
* Start/Stop debounced to dodge an emulator EGL renderer crash on
  rapid taps

Theme matches the desktop palette exactly — always-dark, accent
`#4678B4`, card fill `#1C1E22`, 4dp button / 6dp card radii.
No dynamic color, no light scheme: the desktop is always dark and
we follow.

Build wiring:

* `Cargo.toml`: `cdylib` crate-type added; `jni` + `tun2proxy`
  scoped to `cfg(target_os = "android")` so desktop builds pay
  nothing.
* `src/data_dir.rs`: `set_data_dir()` override so the Android app's
  private filesDir replaces the `directories` crate's desktop default.
* `src/android_jni.rs`: JNI entry points for start/stop/exportCa plus
  a ring buffer draining to `Native.drainLogs()` and `testSni()` that
  wraps `scan_sni::probe_one`.
* Gradle task chain runs `cargo ndk` before each assemble; post-step
  normalizes tun2proxy's hash-suffixed cdylib to a stable filename
  so `System.loadLibrary("tun2proxy")` works.

Verified end-to-end on an API 34 emulator: ipleak, yahoo, discord,
cloudflare.com all render; TLS is MITM-ed under our user-installed
CA; service survives rapid Stop/Start cycles.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:44:17 +03:00
freeinternet865 4cfd9d9652 proxy_server: support chunked request bodies (#21)
Teach the incoming HTTP request parser to handle Transfer-Encoding:
chunked instead of only Content-Length-framed bodies.

Also reply with 100 Continue when a client sends Expect:
100-continue before waiting for the request body.

This keeps request framing correct for POST/PUT-style clients and
adds focused tests for chunked decoding and 100-continue handling.

Co-authored-by: freeinternet865 <free@internet865.com>
2026-04-23 02:05:22 +03:00
freeinternet865 520ac46de7 domain_fronter: drain chunked response trailers (#22)
Consume the final CRLF and any trailer lines after a zero-length
chunk before returning a pooled upstream connection.

Without this, leftover bytes from a chunked response could remain on
the socket and corrupt the next response read on the reused connection.

Add a regression test that reads a chunked response with a trailer and
then successfully parses a second response from the same stream.

Co-authored-by: freeinternet865 <free@internet865.com>
2026-04-23 02:04:51 +03:00
freeinternet865 4fd0bc6646 ui: handle proxy task shutdown more reliably (#25)
Log server.run() errors in the UI background task instead of
silently discarding them.

Also abort the task if graceful shutdown times out, and make sure a
finished or stopped proxy task clears the running flag consistently
without wiping the last visible stats snapshot.

Co-authored-by: freeinternet865 <free@internet865.com>
2026-04-23 02:04:18 +03:00
freeinternet865 819bbb72fa domain_fronter: reject invalid relay body base64 (#24)
Treat malformed base64 in Apps Script relay responses as a bad
upstream response instead of silently turning it into an empty body.

Add a focused regression test for invalid response body encoding.

Co-authored-by: freeinternet865 <free@internet865.com>
2026-04-23 02:03:47 +03:00
freeinternet865 ba672adfa3 config: reject zero scan batch size (#23)
Validate scan_batch_size at config load time so scan-ips cannot
reach candidate_ips.chunks(0) and panic.

Add a focused regression test for scan_batch_size = 0.

Co-authored-by: freeinternet865 <free@internet865.com>
2026-04-23 02:03:11 +03:00
therealaleph 83fac52690 releases: refresh in-repo archives to v0.9.4
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:33:47 +03:00
therealaleph a9ad697b6a v0.9.4: actionable diagnostic when outbound TLS to Google edge fails (#18 follow-up)
@Behzad9 reports: after the EMFILE fix in v0.9.3 landed cleanly, the
relay now fails with a different error:

    ERROR Relay failed: io: invalid peer certificate: UnknownIssuer

repeated on every request. This is rustls (via domain_fronter) rejecting
the server cert that whatever sits on our TLS connection to google_ip
presents. In practice this means one of three things, in decreasing
order of likelihood for an Iranian OpenWRT user:

  1. The ISP / a middlebox is intercepting outbound TLS to Google IPs
     and presenting its own cert. webpki-roots (Mozilla trust store,
     baked in) correctly rejects it.
  2. The user's google_ip setting points at a non-Google host.
  3. Router clock is wildly off (NTP not synced), certs look not-yet-valid.

Before this change: one identical ERROR per failed relay, no guidance.
Log filled with the same line.

Now:
  - New DomainFronter::log_relay_failure() detects cert-related error
    strings (UnknownIssuer, CertificateExpired, CertNotValidYet,
    NotValidForName, 'invalid peer certificate').
  - First occurrence logs an ERROR with the three root causes and three
    concrete fixes: run  to find a working Google IP,
    check the system clock, or as a LAST RESORT set verify_ssl=false
    (with the explicit warning that traffic is then only protected by
    the Apps Script auth_key, not outer TLS).
  - Subsequent occurrences drop to debug so the log stays readable —
    an AtomicBool gate on the DomainFronter instance tracks whether
    the hint was shown. Resets on proxy restart.
  - Non-cert errors still log at error level unchanged.

49 tests pass, no code-path regressions (log line content changed, not
behavior). Shipping so users hit this get actionable output.
v0.9.4
2026-04-22 22:25:52 +03:00
therealaleph 0ad206f05e releases: refresh in-repo archives to v0.9.3 2026-04-22 20:54:56 +03:00
therealaleph 93fac57f5f v0.9.3: accept-loop backoff on EMFILE + louder rlimit diagnostics (issue #18)
@Behzad9 on #18: the OpenWRT 'No file descriptors available' errors
are back in v0.8.0+, this time logged as a wall of thousands of
identical ERRORs within seconds of activating the proxy. Two real
bugs, now fixed:

=== 1. accept() loop had no backoff ===

Previous code:
    loop {
        match listener.accept().await {
            Ok(x) => ...,
            Err(e) => { tracing::error!(...); continue; }  // tight loop
        }
    }

On EMFILE (RLIMIT_NOFILE exhausted), accept() returns synchronously,
the match re-runs instantly, accept() EMFILEs again, forever. The tight
loop ALSO starves the tokio runtime of CPU that existing connections
need to finish and close their fds — so the problem never clears on its
own. It's a self-sustaining meltdown.

New accept_backoff() helper (in proxy_server.rs) wraps both the HTTP
and SOCKS5 accept loops:
  - Detects EMFILE/ENFILE via raw_os_error (24 or 23).
  - Sleeps proportional to how long the pressure has lasted (50 ms
    first hit, ramping to a 2 s cap around hit #40). Gives existing
    connections a chance to finish and free fds.
  - Rate-limits the log line: one WARN on the first EMFILE with fix
    instructions, then one every 100 retries. No more walls of
    identical errors.
  - Resets the counter on the next successful accept.
  - Non-EMFILE errors (ECONNABORTED from clients that went away during
    handshake, etc.) get a plain single-line error + 5 ms sleep so we
    still don't tight-loop on any unexpected error.

End-to-end verified: ran mhrv-rs under , flooded the
SOCKS5 port with 247 concurrent connections to trip EMFILE. Before:
log would have been 1000s of identical lines. After: exactly 1 warning,
listener stayed quiet, fds drained, accept resumed.

=== 2. RLIMIT_NOFILE bump was too conservative + silent ===

Previous behavior: target 16384 soft, cap to existing hard limit,
no log. On constrained systems where hard is already tiny, we'd
stay at the tiny limit silently.

rlimit.rs now:
  - Targets 65536 soft.
  - ALSO tries to raise the hard limit up to /proc/sys/fs/nr_open
    on Linux (Linux allows a non-privileged process to bump its own
    hard limit up to the kernel ceiling, usually 1048576 on modern
    kernels). On macOS/BSD we skip this — only bump soft.
  - Logs WARN on startup if soft ends up <4096 with the exact fix
    ('ulimit -n 65536' or use the procd init). No more silent
    failure.
  - Logs INFO with the before/after limits otherwise, so field bug
    reports tell us immediately whether the kernel cap is the real
    bottleneck.

Moved the rlimit call from main() pre-logging to post-init_logging so
its tracing output actually lands in the log panel + stderr. Small
reorganization only.

49 tests pass, musl x86_64 cross-compile verified locally.
v0.9.3
2026-04-22 20:46:00 +03:00
therealaleph ca947d7755 releases: refresh in-repo archives to v0.9.2 2026-04-22 20:19:28 +03:00
therealaleph 9768cd9edb v0.9.2: update check tunnels through proxy + one-click asset download (#15 follow-up)
@zula-editor reported on issue #15 that the Check-for-updates button
was returning HTTP 403 on their ISP — classic GitHub
unauthenticated-API rate limit (60/hour per IP) on a shared NAT IP.
They also asked for the update to actually be downloadable from the
app, not just a page link.

Both addressed:

=== Route update check through our own proxy when running ===

New mhrv_rs::update_check::Route enum:
  - Direct: straight rustls to api.github.com (existing behavior)
  - Proxy { host, port }: HTTP CONNECT through our local HTTP proxy
    listener → MITM → Apps Script → api.github.com.

When the proxy is running, the UI automatically picks Proxy. From
GitHub's POV the request now comes from Apps Script's IP range (a
Google datacenter) — completely different rate-limit bucket from the
user's ISP IP, AND works even if GitHub is blocked on their network.

Routing over proxy means the MITM leaf for api.github.com has to be
trusted in the update_check's TLS config. build_root_store() now
conditionally adds our own CA cert from data_dir::ca_cert_path() to
the webpki roots when Route::Proxy is in use. Direct path is
unchanged.

=== Download button ===

The UpdateCheck::UpdateAvailable variant now carries an optional
ReleaseAsset { name, download_url, size_bytes } picked by
pick_asset_for_platform() from the GitHub API's assets[] array.
Preference list per (OS, arch):
  - macOS arm64 → mhrv-rs-macos-arm64-app.zip, else tar.gz
  - macOS amd64 → mhrv-rs-macos-amd64-app.zip, else tar.gz
  - Windows → mhrv-rs-windows-amd64.zip
  - Linux aarch64 → mhrv-rs-linux-arm64.tar.gz
  - Linux armv7 → mhrv-rs-raspbian-armhf.tar.gz
  - Linux x86_64 → mhrv-rs-linux-amd64.tar.gz

UI: when an update is available AND we have an asset, the transient
status line grows an accent-blue 'Download X.Y MB' button. Clicking
fires Cmd::DownloadUpdate, which pipes the asset through the same
Route (proxy if running, direct otherwise), writes it to
UserDirs::download_dir() (~/Downloads on most systems), and shows a
'show in folder' button that opens Finder / Explorer / xdg-open on
the containing directory.

Three new unit tests for asset-picking. The gated live test now
takes a Route argument (Direct) so it keeps working across the API
shape change. 49 tests pass.

Also refreshed in-repo releases/ archives to v0.9.1 alongside.
v0.9.2
2026-04-22 20:11:35 +03:00
therealaleph 0beec6a277 v0.9.1: normalize X/Twitter GraphQL URLs for cache hit rate (issue #16)
User @barzamini pointed out an optimization from the Python community
(originally from seramo_ir): X/Twitter GraphQL URLs look like

  https://x.com/i/api/graphql/{hash}/{op}?variables=...&features=...&fieldToggles=...

The features and fieldToggles params change across sessions and even
within a session, busting our 50 MB response cache on every request to
the same logical query. Stripping everything after 'variables=' lets
identical logical queries collapse into one cache entry, dramatically
reducing quota usage when browsing Twitter through the relay.

Implementation:
  - src/domain_fronter.rs: new normalize_x_graphql_url() helper. Matches
    exactly the Python patch's pattern (host == 'x.com', path starts
    with /i/api/graphql/, query starts with variables=). Truncates at
    the first '&' past the '?'. Applied at the top of relay() so the
    normalized URL feeds BOTH the cache key AND the request sent to
    Apps Script — so we save on Apps Script quota too, not just on
    return-trip bytes.
  - src/config.rs: new opt-in normalize_x_graphql bool (default false).
    Off by default because strict X endpoints may reject trimmed requests;
    user should flip it on and watch for regressions.
  - src/bin/ui.rs: checkbox in the Advanced section,
    'Normalize X/Twitter GraphQL URLs', with tooltip explaining the
    trade-off and crediting the source.
  - Four new unit tests in domain_fronter::tests covering: the happy
    path trim, non-x.com hosts pass through unchanged, non-graphql x.com
    paths pass through unchanged, and idempotency. 48 tests total, all
    green.

Credit: idea by seramo_ir, Python patch at
https://gist.github.com/seramo/0ae9e5d30ac23a73d5eb3bd2710fcd67,
implementation request by @barzamini in issue #16.
v0.9.1
2026-04-22 19:59:59 +03:00
therealaleph 346daaad01 releases: refresh in-repo archives to v0.9.0 2026-04-22 19:47:51 +03:00
therealaleph 3387d94ed9 v0.9.0: UI redesign + stricter end-to-end test verification
=== UI redesign (zero new deps, same binary size) ===

Entire App::update() rewritten around three ideas:

1. Section cards. Form rows are grouped inside rounded frames with
   faint fills and small-caps headings:
     - 'Apps Script relay'  — Deployment IDs (textarea) + Auth key
     - 'Network'            — Google IP (+inline scan button), Front
                              domain, Listen host, HTTP+SOCKS5 ports
                              on one row, SNI pool button
     - Collapsing 'Advanced' — upstream SOCKS5, parallel dispatch,
                              log level, verify SSL, show auth key.
                              Closed by default — most users never
                              touch these.

2. Clearer action hierarchy. Primary buttons are accent-filled and
   larger:
     - Start  (green filled,  ▶ glyph, 120x32)
     - Stop   (red filled,    ■ glyph, 120x32)
     - Save config (blue accent filled, path shown inline after →)
     - SNI pool (blue accent filled, inside Network section)
     - Test relay (neutral, tall)
   Secondary actions (Install CA / Check CA / Check for updates)
   moved to their own compact row below, no longer competing.

3. Status + log clarity.
   - Header version links to GitHub:  → repo,  →
     the release tag page.
   - Running/stopped status is now a pill-shaped colored chip at the
     right end of the header (green fill + green dot when running,
     red when stopped).
   - Traffic stats in a 2-column layout inside the Traffic card —
     7 metrics fit in 4 rows instead of a 7-row vertical strip.
   - One compact transient status line above the log that auto-hides
     after 10 seconds — replaces the previous stack of permanent
     ca_trusted / test_msg / update_check labels that were pushing
     the log panel off-screen.
   - Log panel now has its own bordered frame (darker fill), a
     '[x] show' checkbox that hides it entirely when off, a 'save…'
     button that writes the current log buffer to a timestamped
     log-YYYYMMDD-HHMMSS.txt in the user-data dir, and a 'clear'
     button. Empty state shows a muted placeholder instead of
     silent void.

All helper functions (section, primary_button, form_row) live at the
top of ui.rs as small local helpers — no new modules, no new
dependencies.

=== Stricter end-to-end test (test_cmd.rs) ===

Previous test passed on any HTTP 200 status regardless of body.
After a user pointed out that the test reported PASS even after
they deleted their Apps Script deployment, updated the pass criteria:

  1. Status must contain '200 OK'.
  2. Body must parse as JSON.
  3. JSON must have an 'ip' field with a valid IPv4 or IPv6.

Anything else → SUSPECT (returns false), with a specific log message
like 'HTML returned instead of JSON. The Apps Script deployment may
be deleted, not published to Anyone, or requires sign-in.'

Also now emits tracing::info!/warn!/error! alongside println!, so
the verdict + detail show up in the UI's Recent log panel instead
of disappearing to a stdout nobody sees.

One new unit test: looks_like_ip() accepts v4+v6, rejects empty,
rejects malformed, rejects overflowed octets. 44 tests total, all
green.

Verified locally end-to-end — UI launches clean, form loads config
cleanly, Start/Stop/Save all fire correctly, Test relay produces
the new PASS/SUSPECT verdict with the tracing detail visible in
the log panel, Check-for-updates hits GitHub and resolves with the
compact auto-hiding status line.
v0.9.0
2026-04-22 19:41:28 +03:00
therealaleph 52d52b8239 releases: refresh in-repo archives to v0.8.6 (+ raspbian armhf) 2026-04-22 19:13:48 +03:00
therealaleph 3534747e68 v0.8.6: armhf glibc pin + Check-for-updates button (#15)
=== PR #14 follow-up: armhf build runs on Pi Bookworm/Bullseye ===

PR #14 (merged earlier) added arm-unknown-linux-gnueabihf to the
release matrix but pinned os=ubuntu-latest, which is 24.04 with GLIBC
2.39. Target armhf sysroot on 24.04 is Debian Trixie (GLIBC 2.39),
far too new for a Raspberry Pi 2/3 on Bookworm (2.36) or Bullseye
(2.31) — users would get 'GLIBC_2.39 not found' the same way the
Linux-amd64 issue #2 folks did before we pinned them to 22.04.

Fix: pin the armhf matrix entry to ubuntu-22.04, matching our other
linux-gnu targets. Binary will link against GLIBC 2.35 max, which
loads on Pi Bookworm and Bullseye. Also trimmed two trailing spaces.

Locally verified the cross-compile: rust:latest + gcc-arm-linux-
gnueabihf + proper CARGO_HOME config.toml produces a valid ARM 32-bit
ELF (2.9 MB, armhf EABI5).

=== Issue #15: 'Check for updates' button in the UI ===

New src/update_check.rs module. On the user's click (no polling):

  1. Tcp-probes github.com:443 with a 5s budget. If unreachable, we
     return Offline(reason) instead of a confusing 'update check
     failed' — distinguishes 'you're offline' from 'GitHub API
     misbehaved'.

  2. HTTPS GET api.github.com/repos/.../releases/latest via the
     tokio + rustls stack (same hand-rolled HTTP pattern as
     domain_fronter — no new crate deps). Parses tag_name, strips
     the v-prefix, loose-semver-compares to env!(CARGO_PKG_VERSION).

  3. Returns one of four UpdateCheck variants: Offline / Error /
     UpToDate / UpdateAvailable { release_url }.

New UI wiring (src/bin/ui.rs):
  - Cmd::CheckUpdate enqueue variant
  - UiState::last_update_check { InFlight, Done(result) }
  - 'Check for updates' button next to the CA buttons
  - Result displayed as a colored small-text line under the CA info:
    green 'up to date', amber 'update available v0.8.5 → v0.8.6' with
    a clickable release-page hyperlink, red for offline/error.

Verified end-to-end with a live github.com fetch (got a rate-limit
HTTP 403 from my IP because I've been hitting the API a lot, but
that's the expected Error() state — response classification was
correct). Three unit tests for is_newer() and a gated live test for
the full round-trip.

43 tests pass.
v0.8.6
2026-04-22 19:03:14 +03:00
Shin (Former Aleph) 5606cb3f63 Merge pull request #14 from hamed256/main
Thanks @hamed256 — armhf cross-compile verified locally, produces a valid ARM 32-bit ELF. Merging with a follow-up commit on main to pin the runner to ubuntu-22.04 (GLIBC 2.36 floor, same policy as our other linux-gnu targets) so it runs on Raspberry Pi users on Bookworm / Bullseye.
2026-04-22 18:57:01 +03:00
therealaleph c2597cfa2b releases: refresh in-repo archives to v0.8.5 2026-04-22 18:38:27 +03:00
therealaleph 62a75093ec docs: mirror upstream Code.gs locally (assets/apps_script/)
Two reasons to pin a copy in the repo:

1. Users on networks where raw.githubusercontent.com is intermittent
   can still get the deploy-ready file via a repo ZIP / clone.
2. The Apps Script relay protocol between mhrv-rs and Code.gs is
   informal — upstream changes can silently break us. Keeping a
   snapshot lets future-us diff against what we tested against
   when diagnosing protocol-drift bugs.

Fetched verbatim from:
  https://raw.githubusercontent.com/masterking32/MasterHttpRelayVPN/refs/heads/python_testing/apps_script/Code.gs

Credit stays with @masterking32. The assets/apps_script/README.md
next to it calls out that we don't modify this file — users deploy
it as-is into their own Google Apps Script project.

Updated the Setup Guide link in both the English and Persian
sections so offline / restricted-network users have a fallback path.
2026-04-22 18:37:01 +03:00
therealaleph 014c2a8cd1 v0.8.5: Check-CA actually checks Windows now (follow-up to #13)
User on issue #13 reported that even after installing the CA (and
seeing it in the Windows cert manager UI), our 'Check CA' button still
said 'NOT trusted'. Root cause: is_ca_trusted() on Windows was just
returning false unconditionally — Check-CA has never worked on Windows.

Fix: is_trusted_windows() now shells out to certutil:
  certutil -user -store Root 'MasterHttpRelayVPN'
  certutil -store Root 'MasterHttpRelayVPN'

Checks both the user store (where our install_windows puts it by
default) and the machine store (fallback path when user-store install
is blocked). Requires certutil to print the cert name in stdout AND
exit 0 — belt-and-suspenders against locales where certutil exits 0
even on an empty match.

Also made the Check-CA UI message point users at the CA file path
for cross-device install — the same user reported their Android
V2rayNG client getting cert errors on our MITM-signed TLS leaves,
which is the expected 'the phone doesn't trust our CA' scenario. The
message now calls out the ca.crt path explicitly, and notes the
Android 7+ user-CA restriction (Firefox Android works, Chrome and
most apps don't trust user-installed CAs regardless).

Not addressed (by design):
- Replacing our CA keypair with Python-generated PEM fails to parse
  via rcgen. User tried this as a workaround before reporting. rcgen
  expects PKCS#8 PEM; Python's cryptography commonly emits PKCS#1
  ('BEGIN RSA PRIVATE KEY'). Even if parsing worked, mixing an
  external CA with our leaf-issuing code would break the key match.
  Users should stick with our generated CA — that's the supported
  flow. The Python cross-contamination experiment is expected to
  fail; we don't document it as supported.
v0.8.5
2026-04-22 18:30:18 +03:00
Hamed Soltani b617f67c6f add armhf arch to build targets 2026-04-22 18:18:44 +03:30
therealaleph d49c494b20 releases: refresh in-repo archives to v0.8.4 2026-04-22 17:27:34 +03:00
therealaleph 790212cf51 v0.8.4: config load failures are no longer silent (diagnoses the reset-on-reopen bug)
A user reported that after Save-config, closing the UI, and reopening,
every form field was blank — but the config.json on disk still had all
the right values.

The culprit in the UI was load_form()'s swallow-errors pattern:

  let existing = if path.exists() {
      Config::load(&path).ok()   // .ok() threw away the error
  } else { ... };
  if let Some(c) = existing { /* populate form */ } else { /* defaults */ }

When Config::load returned an Err, .ok() silently converted to None,
the form went back to defaults, and the user had no signal at all
that the load had failed or WHY. On every platform I could test
(macOS / Linux) the round-trip works fine with a real round-trip test
added in config.rs (config::rt_tests::round_trip_all_current_fields
and round_trip_minimal_fields_only — both green). So whatever's
failing for this specific reporter is environment-specific (weird
filesystem encoding, partial write, different field shape from an
older version, … TBD). Without visibility we can't diagnose it.

Changes:

1. load_form() now returns (FormState, Option<String>). The String
   is a user-facing error message (with the full path + the
   underlying parse/validate reason) when Config::load fails on an
   existing file.
2. main() plumbs that error into App's initial toast, which sticks
   for 30 seconds (vs the normal 5 for regular toasts) so users who
   only open the UI briefly still see it.
3. Added tracing::info! in load_form for the success path too —
   the Recent log panel now always shows either 'config: loaded OK
   from <path>' or 'Config at <path> failed to load: <reason>' on
   startup, regardless of toast timing.
4. Added two regression-guard tests in config.rs covering the
   full-fields and minimal-fields save shapes the UI emits.

Next time a user reports this: they'll have the exact error in the
toast + the Recent log panel, and we can fix the actual bug instead
of shooting blind.
v0.8.4
2026-04-22 17:19:31 +03:00
therealaleph 5371bfc7d5 v0.8.3: UI log panel now captures tracing events + dispatch routing visibility (issue #12)
Two reported issues:

1. Log level in the form had no visible effect — trace produced the
   same panel output as warn.
2. upstream_socks5 was reported as never being attempted.

(1) was because the UI binary never installed a tracing subscriber.
Every tracing::info!/debug!/trace! from the proxy was discarded; only
the handful of manual push_log() calls for start/stop/test reached
the 'Recent log' panel. Swapping the log level in the combo-box just
rewrote the config field — nothing consumed it.

Fix: install_ui_tracing() at startup registers a tracing_subscriber
fmt layer with a custom MakeWriter that mirrors each formatted event
line into shared.state.log. Respects RUST_LOG, defaults to 'info'
with hyper pinned to warn so the panel isn't swamped by low-level
HTTP chatter. Now the log level switch actually filters panel
output, and routing decisions show up live.

(2) is a documentation / visibility issue more than a bug. Our
upstream_socks5 routing is intentionally scoped to raw-TCP traffic
(non-HTTP, non-TLS) — HTTPS goes through the Apps Script relay,
which is the whole reason mhrv-rs exists. But without working logs,
it looks like upstream_socks5 is dead code.

Fix: every branch of dispatch_tunnel now emits a tracing::info! that
says exactly which path the connection took and, where applicable,
whether upstream_socks5 was used:

    dispatch api.telegram.org:443 -> raw-tcp (127.0.0.1:50529)
    dispatch www.google.com:443   -> sni-rewrite tunnel (Google edge direct)
    dispatch httpbin.org:443      -> MITM + Apps Script relay (TLS detected)

Combined with (1), users can now see in real time whether their
traffic is hitting upstream_socks5. If it says 'raw-tcp (direct)'
after they set the field, that's evidence of a real bug; if it
never reaches the raw-tcp branch at all, that's the documented
design (HTTPS → Apps Script).

Also per user request, updated README:
- Shields.io badges up top: latest release, total downloads, CI
  status, license, stars.
- Short 'Heads up on authorship' note crediting Anthropic's Claude
  for the bulk of the Rust port (with the human-on-every-commit
  caveat). English and Persian mirrors both have it.

All 37 unit tests pass.
v0.8.3
2026-04-22 16:34:40 +03:00
therealaleph 293e5714bb releases: refresh in-repo archives to v0.8.2 2026-04-22 16:07:20 +03:00
therealaleph e9a973fc0f v0.8.2: CA trust — Firefox enterprise_roots + Chrome ~/.pki/nssdb (follow-up to #11)
After v0.8.1 fixed the leaf cert extensions, users reported "still
broken" — specifically Firefox showing:
  "Software is Preventing Firefox From Safely Connecting to This Site.
   drive.google.com ... This issue is caused by MasterHttpRelayVPN"
for HSTS-preloaded sites. That error is Firefox's "MITM detected AND
issuing CA isn't in my trust store" path combined with HSTS blocking
the normal override button — so users were stuck with no workaround.

Real root cause of the "still broken" reports: the CA was making it
into the OS trust store (Windows cert store / update-ca-certificates
on Linux) but NOT into the browser-specific trust stores that
Firefox and Chrome use on every OS.

Three additions:

1. Firefox: .
   For every Firefox profile we find, we now write this pref to the
   profile's user.js. It tells Firefox to trust the OS CA store, so
   our already-successful system-level install automatically covers
   Firefox on next startup. Critical on Windows (NSS certutil isn't
   on PATH there, so the certutil-based Firefox install never
   worked). Idempotent — checks for existing pref before writing
   and leaves a non-matching user value alone.

2. Chrome/Chromium on Linux: install into ~/.pki/nssdb.
   Linux Chrome uses its own shared NSS DB, independent of both the
   OS store (populated by update-ca-certificates) AND Firefox's
   per-profile NSS. Without this, users installed the CA via
   run.sh, Chrome still refused every HTTPS site, and they spiraled
   trying to re-install the CA. We now also initialize that DB
   with  if it doesn't exist yet.

3. Refactored the NSS-install path so Firefox and Chrome share a
   single install_nss_in_dir() helper. Renamed the top-level entry
   from install_firefox_nss to install_nss_stores to match scope.

Locally verified the cert itself is fine — openssl x509 -text shows
Version 3, SAN, KeyUsage (critical), ExtendedKeyUsage, and
 passes. So the leaf is correct;
what was failing was the trust-chain validation inside the specific
browser because our CA wasn't in THAT browser's trust DB.

Upgrade path: download v0.8.2 and run the launcher or
`./mhrv-rs --install-cert`. Restart Firefox/Chrome after install —
Firefox needs the restart to re-read user.js.
v0.8.2
2026-04-22 15:59:28 +03:00
therealaleph 69838d19a0 releases: refresh in-repo archives to v0.8.1 2026-04-22 14:47:00 +03:00
therealaleph 27cda07f8d v0.8.1: fix MITM leaf cert extensions — HTTPS through the proxy now works (closes #11)
Multiple users reported the same thing (issue #11): they trusted the
CA, then re-installed it, then deleted and re-generated it, and still
every HTTPS site through the proxy failed in the browser. The python
version of the same project doesn't have the issue.

Root cause: rcgen's CertificateParams::default() produces a
minimum-viable x509 cert that does NOT carry:

  - ExtendedKeyUsage extension with id-kp-serverAuth
  - KeyUsage extension with digitalSignature + keyEncipherment

Modern Chrome / Firefox / Edge / Safari all reject TLS server leaves
without those. The CA trust bit didn't matter — the browser's chain
validator rejected the leaf itself with NET::ERR_CERT_INVALID before
ever consulting the trust store. So 'reinstall the CA' was powerless
to help.

Fix in src/mitm.rs::issue_leaf:
  - Set params.extended_key_usages = [ServerAuth].
  - Set params.key_usages = [DigitalSignature, KeyEncipherment].
  - Backdate not_before by 5 min to absorb clock skew between the
    MITM process and a slightly-fast client clock. Same fix in the
    CA's own not_before.

Also added src/mitm.rs::tests::leaf_has_serverauth_eku_and_key_usage
as a permanent regression guard — it parses the DER with x509-parser
and asserts the three extensions are present. Added x509-parser to
dev-dependencies (already in the tree transitively via rcgen).

Upgrade path for users affected by #11: download v0.8.1, run it. No
CA reinstall required — the CA cert itself was fine, only the per-
site leaves were broken.

Verified end-to-end locally:
  curl --cacert <ca.crt> -x http://127.0.0.1:... https://httpbin.org/ip
  curl --cacert <ca.crt> -x socks5h://127.0.0.1:... https://httpbin.org/ip
Both return JSON without cert errors, through the Apps Script relay
path. 37 unit tests pass.
v0.8.1
2026-04-22 14:37:20 +03:00
therealaleph 501c03a873 ci fix: move [target.'cfg(unix)'.dependencies] after main [dependencies]
Placing a new table header before eframe silently scoped it into the
unix-only target table, so Windows builds lost the dependency entirely:

  error[E0432]: unresolved import `eframe`
  use of unresolved module or unlinked crate `eframe`

(Builds green on Mac/Linux because those hit cfg(unix) == true. Windows
was the only casualty.)

Moved the [target.'cfg(unix)'.dependencies] block to the end of
Cargo.toml, after the optional eframe line, so the main [dependencies]
table stays intact for all targets. Added a comment so this foot-gun
can't return.
v0.8.0
2026-04-22 14:08:16 +03:00
therealaleph 5101a06a5d v0.8.0: dynamic IP discovery (from PR #9), OpenWRT fd fix (#8), Windows UI diagnostics (#7)
Three user-reported fixes / features in one release.

=== PR #9 — dynamic Google IP discovery (@v4g4b0nd-0x76) ===

Already merged in the previous commit. Opt-in via 'fetch_ips_from_api'
in config. Pulls goog.json from www.gstatic.com, maps it against
resolved IPs of well-known Google domains, samples from matching
CIDRs, and validates each candidate with gws / x-google / alt-svc
response-header checks. Graceful fallback to the static list if the
fetch fails or nothing passes validation. Default is off so existing
users are unaffected. Closes #10.

=== Issue #8 — OpenWRT: 'accept: No file descriptors available' ===

OpenWRT routers ship a very low RLIMIT_NOFILE (often 1024, sometimes
256 on constrained devices). A browser's burst of ~30 parallel sub-
resource requests can fill the limit within seconds, after which
accept(2) returns EMFILE and the proxy is effectively dead.

Two-fold fix:

1. New assets/openwrt/mhrv-rs.init now sets procd limits nofile=
   "16384 16384" on the service. procd raises the per-process fd
   limit before the binary even starts.
2. New src/rlimit.rs best-effort-raises RLIMIT_NOFILE in the binary
   itself (Unix only, no new runtime deps — libc is already
   transitively present via tokio). Targets 16384 soft, capped to
   whatever hard limit the kernel already allows the user (so it
   doesn't need root).

Both layers mean the fix applies whether the user runs via
  /etc/init.d/mhrv-rs start    (procd limits kick in)
or
  ./mhrv-rs --config ...       (in-binary bump kicks in)
or any other invocation path.

Closes #8.

=== Issue #7 — Windows UI crashes silently ===

User report: on Win 11, run.bat prints 'Starting mhrv-rs UI...' and
exits clean, but no UI window ever appears. Root cause: the old
run.bat used 'start "" "mhrv-rs-ui.exe"' which returns
immediately — if the UI binary dies at launch time (missing GPU
driver, RDP without GL accel, AV blocking, …), the crash is invisible
because start already disowned the child.

Fix: run the UI in-place (not via 'start'), so its stderr and exit
code land in the run.bat cmd window. On non-zero exit print a helpful
checklist of common Windows launch failures and pause so the user can
screenshot the output for an issue report.

This doesn't fix the underlying crash for affected users, but it
turns a ghost-crash bug into a self-diagnosing one so the next report
includes actionable info. Closes-via-diag #7.

=== Fixes folded into the PR #9 merge ===

- src/scan_ips.rs: rand::thread_rng() held across an .await tripped
  the Send bound on the async fn. Scoped the rng in a block so it
  drops before the subsequent awaits.
- src/scan_ips.rs: defend /0 and /32 CIDRs in cidr_to_ips and
  ip_in_cidr against 1u32 << 32 shift panic.

All 36 unit tests pass.
2026-04-22 14:01:56 +03:00