Commit Graph

33 Commits

Author SHA1 Message Date
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
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.
2026-04-22 22:25:52 +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.
2026-04-22 20:46:00 +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.
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.
2026-04-22 19:59:59 +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.
2026-04-22 19:41:28 +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.
2026-04-22 19:03:14 +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.
2026-04-22 18:30:18 +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.
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.
2026-04-22 16:34:40 +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.
2026-04-22 15:59:28 +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.
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.
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
therealaleph 04661bfdec v0.7.1: Linux GLIBC 2.35 floor + scroll main window on short screens
Two user-reported issues.

=== GLIBC too new (reported via twitter) ===

Our linux-amd64 and linux-arm64 gnu builds were compiled on
ubuntu-latest (24.04, GLIBC 2.39), which means the resulting binaries
refuse to load on anything older:

  ./mhrv-rs: /lib/x86_64-linux-gnu/libc.so.6: version 'GLIBC_2.39'
    not found (required by ./mhrv-rs)

Users on Ubuntu 22.04 / Mint 21 (GLIBC 2.35) — the typical user in Iran
where this project's target audience lives, and where they can't
dist-upgrade because they're behind exactly the kind of network
restriction this tool exists to bypass — could not run the gnu builds
at all.

Fix: pin the linux-gnu matrix entries to ubuntu-22.04 runners. GLIBC
2.35 is now the minimum; binaries load on Ubuntu 22.04, Mint 21,
Debian 12, Fedora 36+, RHEL 9+ and everything newer.

Users on older distros (Ubuntu 20.04, CentOS 7) can still use the
static musl builds (mhrv-rs-linux-musl-amd64.tar.gz et al.) which
have no GLIBC dependency at all.

=== Short-screen laptops — main window content clipped (PR #6) ===

Co-authored fix from @v4g4b0nd-0x76 in PR #6 (manually applied to
avoid pulling in 400 lines of unrelated cargo-fmt churn):

- Wrap the CentralPanel body in ScrollArea::vertical()
  .auto_shrink([false; 2]) so everything stays reachable on short
  screens.
- Lower the min_inner_size from [420, 540] to [420, 400] so laptops
  with ~13" screens at default scaling can shrink the window without
  clipping UI elements.

Closes #6.

Co-authored-by: v4g4b0nd-0x76 <v4g4b0nd-0x76@users.noreply.github.com>
2026-04-22 11:08:57 +03:00
therealaleph bcdb3e7803 v0.7.0: editable SNI rotation pool with reachability probes
New feature — users can now edit exactly which SNI names are rotated
through the outbound Google-edge tunnel, and probe each one's
reachability. Useful when an ISP selectively blocks individual Google
subdomains (e.g. mail.google.com in Iran at various times).

=== Data model ===

Config gains an optional 'sni_hosts' field:
  "sni_hosts": ["www.google.com", "drive.google.com"]

Precedence in domain_fronter::build_sni_pool_for():
  1. If sni_hosts is set & non-empty, use that list verbatim.
  2. Else, if front_domain is one of the default Google-edge names,
     auto-expand to {www, mail, drive, docs, calendar}.google.com.
  3. Else, use just [front_domain].

Empty / all-disabled list saves as None so the backend falls back to
the defaults instead of having zero names to rotate through.

=== New scan_sni module ===

probe_one(ip, sni) / probe_all(ip, snis) does, for each candidate:
  1. DNS lookup on the SNI (catches typos / non-existent names — Google
     GFE returns a valid wildcard cert for ANY *.google.com, so the
     TLS handshake alone can't tell apart a real name from gibberish).
  2. TCP connect to google_ip:443 (3s timeout).
  3. TLS handshake with the candidate SNI (3s timeout). RST mid-
     handshake signals DPI block.
  4. Small HTTP HEAD over the tunnel to confirm it's still speaking
     HTTP (catches weird misroutes).

Returns ProbeResult { latency_ms, error } per candidate.

=== New 'test-sni' CLI subcommand ===

  $ mhrv-rs test-sni
  Probing 5 SNI candidates against google_ip=216.239.38.120 ...
      SNI                  LATENCY  STATUS
      www.google.com        142 ms  ok
      drive.google.com      138 ms  ok
      mail.google.com            -  handshake RST (SNI may be blocked)
      ...
  Working: 3 / 5

Exit 0 if >=1 passed, non-zero otherwise. Uses the same probe logic
the UI uses.

=== UI editor ===

New 'SNI pool... (active/total)' button in the main form, styled with
a solid blue fill + white text so it's clearly actionable. Opens a
floating egui::Window (resizable, movable, closable) with:

  - Action bar: 'Test all' | 'Keep working only' | 'Enable all' |
    'Clear status' | 'Reset to defaults'
  - Scrollable list of rows, each: checkbox, monospaced name editor
    (230px), status cell (150px, 'ok 142 ms' green / 'fail <reason>'
    red / 'testing...' gray / 'untested' gray), per-row 'Test' and
    'remove' buttons
  - Bottom: text input + '+ Add' that auto-probes the newly added name
    immediately (instead of leaving it silently 'untested')

All rendered with ASCII status text instead of unicode check/cross
glyphs, since egui's default font doesn't ship them on some hosts
and they rendered as a missing-glyph box.

Changes only commit when the user hits Save config in the main window;
probe state is held in UiState::sni_probe so it survives opening and
closing the editor.

=== README ===

English + Persian 'SNI pool editor' sections with the two workflows
(UI button + 'sni_hosts' config field), plus a 'test-sni' line added
to the Diagnostics section. Feature list updated.
2026-04-22 03:25:28 +03:00
therealaleph 0072b3aed9 v0.6.1: fix OpenWRT CA install + replace --user-less CI + perf pack artifacts
v0.6.0's release CI was cancelled before it could produce artifacts.
This is a clean re-cut that also fixes a user-reported bug on OpenWRT.

=== OpenWRT CA install fix ===

User on issue #2 reported that --install-cert fails on an OpenWRT
router with 'install failed on this platform'. Two problems:

1. Misclassification. The old distro detector did a substring-match
   over all of /etc/os-release, and OpenWRT's file contains lines
   like OPENWRT_DEVICE_ARCH=x86_64 and OPENWRT_ARCH=x86_64 — which
   contain the substring 'arch' — so we classified OpenWRT as Arch
   Linux and tried to install into /etc/ca-certificates/trust-source/
   anchors/ (which doesn't exist there) and then run 'trust' (also
   missing). Predictable failure.

2. Even with correct classification, OpenWRT doesn't need the CA on
   the router itself. LAN clients are the ones terminating TLS through
   mhrv-rs's MITM; they're the ones that need to trust our root. The
   router is just forwarding packets. So emitting an error for the
   no-op case is misleading.

Fixes:

- Detect OpenWRT explicitly (/etc/openwrt_release marker file +
  ID=openwrt in os-release).
- Rewrite the fallback os-release parser to look at ID / ID_LIKE
  token-wise instead of substring-matching the whole file. Added
  support for raspbian / rocky / almalinux / endeavouros while we're
  there.
- For OpenWRT: install_linux returns Ok() with a clear message
  explaining that the CA needs to be installed on LAN clients, not
  on the router. No-op success instead of confusing error.
- For unknown distros: the error message now points at the CA file
  path and the two most common anchor dirs so the user can install
  manually.
- Extracted classify_os_release(&str) as a pure function and added
  8 unit tests, including a regression guard with a real OpenWRT 23.05
  os-release file so this specific substring-match bug can't return.

=== v0.6.0 perf pack (same as what cancelled CI was meant to ship) ===

- Connection pool pre-warm on startup (skip handshake on first request)
- Per-connection SNI rotation across known Google-edge subdomains
- Expanded SNI-rewrite suffix list (gstatic, googleusercontent,
  googleapis, ggpht, ytimg, blogspot, blogger)
- Per-site stats tracker + UI drill-down table
- Optional parallel script-ID dispatch (config field parallel_relay)
- TCP_NODELAY audit + fix on SNI-rewrite outbound

All 36 unit tests pass.

Closes-via-fix #2 follow-up.
2026-04-22 02:57:16 +03:00
therealaleph 3f0bbfdab0 v0.6.0: performance pack — pool prewarm, SNI rotation, per-site stats, parallel dispatch
Tier-1 perf changes from the brainstorm, all on by default except where
they change semantics (parallel_relay is opt-in).

Connection pool pre-warm (domain_fronter.rs):
  On startup, open 3 TLS connections to Google edge in parallel and
  park them in the pool. First user request skips the ~300-500 ms
  handshake cost. Best-effort: warm failures are logged at debug and
  ignored. Triggered from ProxyServer::run() in a fire-and-forget
  tokio spawn.

SNI rotation (domain_fronter.rs):
  Replace the single sni_host String with a Vec<String> plus an atomic
  round-robin index. When front_domain is one of the known Google-edge
  subdomains, build_sni_pool() expands it to include the other four
  (www/mail/drive/docs/calendar.google.com), so outbound TLS connection
  counts get spread across names instead of concentrating on one. Custom
  front_domain values are preserved as the single entry (we can't verify
  siblings of a non-Google edge).

Expanded SNI-rewrite suffix list (proxy_server.rs):
  Added gstatic.com, googleusercontent.com, googleapis.com, ggpht.com,
  ytimg.com, blogspot.com, blogger.com to the list of domains routed
  directly via the Google-edge tunnel instead of through the Apps Script
  relay. Bigger bypass = less UA-locking, less quota burn on static CDN
  content.

Per-site stats (domain_fronter.rs + ui.rs):
  New HostStat struct {requests, cache_hits, bytes, total_latency_ns}
  tracked per URL host. Records on both cache hits and relay calls, not
  on SNI-rewrite bypasses (those never touch the fronter). UI renders
  a collapsible table under the existing stats grid with the top 60
  hosts sorted by request count, showing req count, cache hit %, bytes,
  avg latency ms.

Parallel script-ID dispatch (config.rs, domain_fronter.rs, ui.rs):
  New config field parallel_relay: u8 (default 0 = off). When >= 2 and
  there are enough non-blacklisted IDs, do_relay_with_retry fans out
  the request to N script instances concurrently via futures_util's
  select_ok, returns first success, cancels the rest. Kills long-tail
  latency when one Apps Script instance happens to be slow, at the
  cost of N× quota per request. UI exposes it as a DragValue 0-8.

TCP_NODELAY audit (proxy_server.rs):
  Added the missing set_nodelay(true) call on the SNI-rewrite outbound
  TCP stream. All six TcpStream::connect sites in the user traffic path
  now disable Nagle.

Expanded feature list in README, added futures-util dep, added unit
tests for extract_host and build_sni_pool.

Verified end-to-end locally:
- Pool pre-warm log line appears on startup: 'pool pre-warmed with 3
  connection(s)'.
- Static asset hit 3x: first = 2.2s (Apps Script), 2-3 = 6ms (cache).
- youtube.com / google.com: SNI-rewrite tunnel (unchanged).
- All 28 unit tests pass.

Deferred (not in this release, each needs its own cycle):
- uTLS / Chrome fingerprint mimicry (TLS stack swap)
- QUIC/HTTP3 transport (new transport)
- ETag / If-None-Match revalidation (needs cache schema change)
- JSON envelope gzip on request (needs Code.gs change)
- Firebase Cloud Functions as alt backend (new architecture)
- MSS clamp / TCP Fast Open (platform-specific, marginal)
2026-04-22 02:52:36 +03:00
therealaleph 6c5b62e5e6 v0.5.1: static musl builds for OpenWRT (amd64 + arm64)
A user on OpenWRT x86_64 reported the linux release doesn't run there —
root cause was glibc vs musl mismatch (our gnu binary was looking for a
dynamic linker that doesn't exist on router userlands). Add two musl
targets that produce fully static PIE binaries:

- x86_64-unknown-linux-musl  -> mhrv-rs-linux-musl-amd64.tar.gz
- aarch64-unknown-linux-musl -> mhrv-rs-linux-musl-arm64.tar.gz

CI uses the messense/rust-musl-cross docker images (better-maintained
than cargo-zigbuild with a pinned zig, which has version regressions
on the ar wrapper between 0.13 and 0.16).

Locally verified:
- both archs cross-compile green in docker
- resulting x86_64 binary (3.3 MB) runs in an alpine:latest container,
  --version / --help work, no dynamic lib requirements

The musl archive skips the UI (routers are headless) and swaps run.sh
for a procd init script (assets/openwrt/mhrv-rs.init) expecting the
binary at /usr/bin/mhrv-rs and config at /etc/mhrv-rs/config.json.

Side effect: switched tokio-rustls to default-features=false + ring
(was pulling aws-lc-rs transitively, which can't easily cross-compile
for musl). The main crate already uses ring explicitly, so no runtime
behavior change.

README gets a 'Running on OpenWRT (or any musl distro)' section in
both English and Persian with scp + procd enable/start recipe.
Closes #2.
2026-04-22 02:29:26 +03:00
therealaleph e575bf6bf4 v0.5.0: optional upstream SOCKS5 for non-HTTP traffic (Telegram et al.)
The Apps Script relay is HTTP-only, and the SNI-rewrite tunnel only
works for Google-hosted domains — so MTProto / IMAP / SSH / anything
else used to drop to a direct-TCP passthrough, which provides zero
circumvention. Users behind a DPI that blocks Telegram saw constant
disconnect/reconnect loops because the raw TCP ran right into the
block.

Fix: add an optional 'upstream_socks5' config field. When set, the
raw-TCP fallback chains the flow into that SOCKS5 proxy (typically a
local xray / v2ray / sing-box with a VLESS / Trojan / Shadowsocks
outbound to your own VPS) instead of connecting directly. The whole
rest of the pipeline is unchanged:

- HTTP / HTTPS still MITMs and relays via Apps Script
- SNI-rewrite suffixes (google.com, youtube.com, …) still hit the
  direct Google-edge tunnel (so YouTube stays fast)
- Only the raw-TCP bucket (Telegram MTProto, SSH, IMAP, …) gets the
  new upstream chain

Changes:
- config.rs: add Option<String> upstream_socks5 field
- proxy_server.rs: thread it through RewriteCtx; rewrite
  plain_tcp_passthrough to call a new socks5_connect_via() helper
  when configured, with graceful fallback to direct
- ui.rs: new 'Upstream SOCKS5' input with tooltip + placeholder,
  ConfigWire round-trip
- README.md: new 'Pair with xray for Telegram' section (EN + FA)
  with the architecture diagram and example config

Verified end-to-end in Docker: xray with the user's working VLESS
Reality config, mhrv-rs with upstream_socks5 pointing at it.
- HTTPS via mhrv-rs SOCKS5: origin = Google IP (Apps Script path) ✓
- Raw TCP to 3 Telegram DCs + api.telegram.org: all SOCKS5 rep=0, log
  shows 'tcp via upstream-socks5 127.0.0.1:50529 -> …' ✓
- youtube.com / google.com: 'SNI-rewrite tunnel' (unchanged) ✓
- Real Telegram Desktop stayed connected cleanly (user-confirmed).
2026-04-22 01:49:21 +03:00
therealaleph 68532344d6 v0.4.4: multi-line Apps Script ID input (one per line)
Follow-up to v0.4.3 — users asked for one-ID-per-line instead of
comma-separated, which is easier to edit in the UI. Field is now a
3-row textarea with a rolling hint underneath:

- 0 or 1 IDs: 'Tip: add more IDs for round-robin with auto-failover'
- 2+ IDs:    'N IDs — round-robin with auto-failover on quota'

The parser accepts both newlines and commas as separators, so existing
configs keep loading cleanly. Closes #1.
2026-04-22 00:03:42 +03:00
therealaleph d32c857502 v0.4.3: expose multi-script-ID support in the UI
The backend already supported a comma-separated list of Apps Script IDs
with round-robin dispatch and automatic 10-minute sidelining on quota
errors (429 / 403 / 'quota' in body). But the UI label just said 'Apps
Script ID' (singular), so users didn't realize multiple IDs are
allowed.

- Rename the label to 'Apps Script ID(s)'
- Hover tooltip explaining comma-separated input and quota auto-failover
- Placeholder hint 'id1, id2, id3 …'
- Live count line under the field when 2+ IDs are entered

Requested by a user in Farsi: 'add the ability to set multiple script
IDs; switch to the next when one hits its limit'. The feature existed,
this just surfaces it.
2026-04-21 23:47:23 +03:00
therealaleph 70d60f1951 v0.4.2: UI reads stats from the running proxy's fronter
The UI was creating its own DomainFronter instance and polling stats
from it, while traffic actually went through the ProxyServer's own
internal fronter. Result: stats grid stuck at zero even with traffic
flowing.

Fix: expose ProxyServer::fronter() and have the UI pick up that handle
once the server is built, instead of constructing a parallel fronter.
2026-04-21 22:35:59 +03:00
therealaleph 899ef06f4a v0.4.1: launcher scripts (run.sh / run.command / run.bat)
First run needs the CLI to install the MITM CA into the system trust
store (sudo/admin prompt), which the UI alone can't do reliably from a
double-click. Add a small launcher for each platform that runs the CLI
with --install-cert once, then starts the UI. Each release archive now
contains a run.* script alongside the binaries.
2026-04-21 22:17:25 +03:00
therealaleph e4fe2b5939 v0.4.0: add cross-platform desktop UI (egui)
New bin 'mhrv-rs-ui' behind the 'ui' feature flag. CLI users pay
zero egui compile cost; UI users get a single static binary.

UI features:
- Config form (Apps Script ID, auth key, Google IP, front domain,
  ports, log level, verify_ssl)
- Start/Stop buttons that spawn the proxy on a dedicated tokio thread
- Live stats (relay calls, failures, cache hit rate, bytes relayed,
  blacklisted scripts) polled every ~700ms
- Test button (end-to-end relay probe)
- Install CA / Check CA buttons
- Recent log panel (last 200 lines)
- Dense, dark, utility-look: no emojis, no cards, no gradients

Architecture:
- Refactored crate into lib + two bins (mhrv-rs, mhrv-rs-ui).
  src/lib.rs exposes all modules, main.rs uses them via 'use mhrv_rs::...'
- New src/data_dir.rs: platform-appropriate user data dir
  (~/Library/Application Support/mhrv-rs on macOS,
   ~/.config/mhrv-rs on Linux, %APPDATA%\mhrv-rs on Windows).
  CLI falls back to ./config.json for backward compat.
- CA moves to {data_dir}/ca/ca.crt (was ./ca/ca.crt).
- UI background thread owns the tokio runtime and proxy handle;
  communicates with UI via std::mpsc commands + Arc<Mutex<UiState>>.
- macOS .app bundle: assets/macos/Info.plist template + build-app.sh
  that assembles .app from the binary. Bundled into release zips.
- CI: Linux system libs (libxkbcommon, libwayland, libxcb*, libx11,
  libgl, libgtk-3) installed on Ubuntu runners for eframe. aarch64
  Linux UI is best-effort cross-compile. Windows MinGW, macOS native.

25 lib tests still pass. 5MB release UI binary on macOS.
2026-04-21 21:36:52 +03:00
therealaleph c694073da8 Revert "v0.3.1: IP-literal destinations -> plain TCP passthrough (always)"
This reverts commit eed64caf87.
2026-04-21 21:15:07 +03:00
therealaleph eed64caf87 v0.3.1: IP-literal destinations -> plain TCP passthrough (always)
User reported log spam on Windows with many 'relay failed: خطای SSL'
errors for IP-literal targets like 172.105.237.214:443. Root cause:
xray/VLESS, torrent, SSH, and other app-level clients use raw IPs in
CONNECT/SOCKS5 targets. Our previous logic would MITM these, see
'POST /' inside the xhttp wrapping, forward to Apps Script, which
would then fail SSL-verifying the app's self-signed backend.

New heuristic: if the CONNECT target is an IP literal, skip MITM
entirely and do plain TCP passthrough. Reasoning: browsers never
use raw IPs in CONNECT -- they always have a domain. Any client
using an IP literal is using a custom protocol that we have no
business MITMing.

Effect: xray/VLESS tunnels now work through mhrv-rs SOCKS5 (the
app's own TLS wrap passes through untouched). Browser HTTPS still
MITM'd + relayed as before (domain CONNECTs).

Also downgraded 'relay failed' logs from error to warn so they don't
spam the ERROR channel on misrouted traffic.
2026-04-21 20:58:48 +03:00
therealaleph f5397bef43 v0.3.0: SOCKS5 listener + smart TLS/HTTP/plain-TCP dispatch
Ports the SOCKS5 + fallback-chain design from @masterking32's
MasterHTTP-WithSOCKS branch so xray / Telegram / app-level TCP
clients work through this proxy.

Changes:
- New SOCKS5 listener on listen_port+1 (configurable via socks5_port)
  - RFC 1928 CONNECT handshake (v5, no-auth, ATYP IPv4/domain/IPv6)
  - Shared smart dispatch with the HTTP-CONNECT path
- Unified dispatch_tunnel() used by both CONNECT entry points:
  1. If host matches SNI-rewrite suffix or hosts override: go direct
     to google_ip via the MITM+TLS tunnel (fast path for google.com,
     youtube, etc.)
  2. Peek the first byte (300ms timeout for server-first protocols):
     - 0x16: TLS client hello -> MITM + relay via Apps Script (scheme=https)
     - HTTP method signature: HTTP relay via Apps Script (scheme=http)
     - Anything else or timeout: plain TCP passthrough to the target
- handle_mitm_request() now takes a scheme arg (http/https) so the
  same code path handles both MITM'd HTTPS and port-80 plain HTTP
- New plain_tcp_passthrough helper: bidirectional TCP bridge used as
  the final fallback (covers MTProto / raw TCP / server-first protos)

Config:
- Added optional socks5_port field; defaults to listen_port+1

README:
- Added browser vs xray/Telegram instructions under 'Step 6'

Live-tested: HTTP proxy, HTTP proxy -> HTTPS, SOCKS5 -> HTTP,
SOCKS5 -> HTTPS, Google search via SNI-tunnel (now returns full
JS page) all pass.
2026-04-21 20:29:24 +03:00
therealaleph 343def4c88 v0.2.2: route google.com via SNI-tunnel to avoid bot UA
Context: user reported Google search showing no-JS fallback page
('JS is off apparently'). Root cause is Apps Script's fixed
'Google-Apps-Script; beanserver' User-Agent that UrlFetchApp.fetch
does not let you override. Google detects the bot UA and serves
the degraded HTML.

Fix: add google.com to SNI_REWRITE_SUFFIXES so google.com requests
bypass Apps Script entirely and go direct to Google's edge via the
MITM+TLS tunnel. Real browser UA is sent; full JS version is served.

Also documented this and other inherent limitations (WebSockets,
2FA 'unknown device', video chunk slowness, brotli stripping) in
the README under 'Known limitations' in English + Persian so users
aren't surprised. These are platform limits of Apps Script, not
bugs -- same issues exist in the original Python project.
2026-04-21 19:58:06 +03:00
therealaleph 33bba7a0f7 v0.2.1: fix PRI/HTTP2-preface leak + shrink SNI-rewrite list
Two bug fixes surfaced in user testing:

1. Invalid HTTP methods forwarded to Apps Script
   - Browser/xray sent HTTP/2 PRI preface through our MITM despite ALPN
     being set to http/1.1 only (some clients ignore ALPN).
   - Our parser accepted 'PRI' as a method and forwarded to Apps Script,
     which rejected it: 'Exception: parameter provided with invalid value: method'.
   - Fix: validate method against the standard list (GET/POST/PUT/DELETE/
     HEAD/OPTIONS/PATCH/TRACE/CONNECT) at parse time. Non-matching requests
     close the connection cleanly instead of forwarding garbage.

2. YouTube video playback broken by over-broad SNI-rewrite list
   - Previous list included googlevideo.com, ytimg.com, doubleclick.net,
     etc. -- but these are served from SEPARATE CDN pools, NOT from
     Google's 216.239.38.120 frontend. Rewriting sent traffic to the
     wrong backend, which Google dropped.
   - Shrunk to a conservative list that's actually served from the
     main Google frontend: youtube.com, youtu.be, youtube-nocookie.com,
     fonts.googleapis.com. Everything else falls through to MITM+relay
     (slower but actually works).
   - YouTube video chunks now route through Apps Script which is slow
     and quota-limited. This is a known limitation inherent to the
     approach; same issue exists in the original Python version.
2026-04-21 19:34:02 +03:00
therealaleph ea5c6ca9a4 bump to v0.2.0 + update README
Features added since v0.1.0 (all live-tested against real Apps Script):
- Response cache (FIFO+TTL, Cache-Control aware, 50MB cap)
- Request coalescing for concurrent identical GETs
- Auto-blacklist failing scripts on 429/quota (10m cooldown)
- SNI-rewrite MITM tunnels for YouTube/googlevideo/doubleclick/etc.
- Gzip response decoding (was breaking all requests in v0.1.0)
- Firefox NSS cert install (best effort via certutil)
- Periodic stats log (60s)
- 'mhrv-rs test' subcommand (end-to-end relay probe)
- 'mhrv-rs scan-ips' subcommand (28 Google IPs, sorted by latency)
- Script IDs masked in logs

Intentionally skipped with rationale (documented in README):
- HTTP/2 multiplexing: coalesce+pool already parallelizes enough
- Request batching: marginal gain over current async pool
- Range-based parallel download: video bypasses via SNI-rewrite

25 tests pass. 2.5 MB stripped release binary.
2026-04-21 18:37:30 +03:00
therealaleph f3e0d929fd add SNI-rewrite MITM tunnels for YouTube/googlevideo + fix gzip decode
SNI-rewrite tunnels (src/proxy_server.rs):
- CONNECT to youtube.com / googlevideo.com / doubleclick / etc. now bypasses
  the Apps Script relay entirely and goes direct to the Google edge IP
  with SNI=front_domain.
- Accepts browser TLS with our MITM cert, opens outbound TLS to
  config.google_ip with SNI=config.front_domain, bridges decrypted bytes.
- Matches Python's _do_sni_rewrite_tunnel behavior. Faster than relay for
  large streams (video).
- Also respects config.hosts override map (custom IP per suffix).

gzip decode fix (src/domain_fronter.rs):
- Apps Script outer response is gzipped. Previous stub always failed,
  causing 'non-utf8 json' errors. Swapped in flate2::GzDecoder.
- Verified end-to-end: HTTP and HTTPS requests through apps_script
  relay succeed and return real Google IPs.
2026-04-21 18:27:49 +03:00
therealaleph 2dd8be72ca initial release: Rust port of MasterHttpRelayVPN apps_script mode
Faithful port of @masterking32's MasterHttpRelayVPN. All credit for
the original idea, protocol, and Python implementation goes to him.

Implemented:
- Local HTTP proxy (CONNECT + plain HTTP)
- MITM with on-the-fly per-domain cert generation via rcgen
- CA auto-install for macOS / Linux / Windows
- Apps Script JSON relay, protocol-compatible with Code.gs
- TLS client with SNI spoofing (connect to Google IP, SNI=www.google.com,
  inner HTTP Host=script.google.com)
- Connection pooling (45s TTL, max 20 idle)
- Multi-script round-robin for higher quota
- Header filtering (strips connection-specific + brotli)
- Config-driven, JSON schema matches Python version

Deferred (TODOs in code):
- HTTP/2 multiplexing
- Request batching / coalescing / response cache
- Range-based parallel download
- SNI-rewrite tunnels for YouTube/googlevideo
- Firefox NSS cert install
- domain_fronting / google_fronting / custom_domain modes
  (mostly broken post-Cloudflare 2024, not a priority)

13 unit tests pass, 2.4MB stripped release binary.
2026-04-21 18:03:03 +03:00