From 8ed8e85687b72d5f2b039e2aa96b20e971d787d1 Mon Sep 17 00:00:00 2001 From: dazzling-no-more <278675588+dazzling-no-more@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:12:15 +0400 Subject: [PATCH 1/2] feat: multi-edge fronting_groups + rename google_only to direct --- README.md | 12 +- SF_README.md | 4 +- .../java/com/therealaleph/mhrv/ConfigStore.kt | 25 +- .../com/therealaleph/mhrv/MhrvVpnService.kt | 8 +- .../main/java/com/therealaleph/mhrv/Native.kt | 2 +- .../com/therealaleph/mhrv/ui/ConfigSharing.kt | 2 +- .../com/therealaleph/mhrv/ui/HomeScreen.kt | 18 +- ...example.json => config.direct.example.json | 2 +- config.fronting-groups.example.json | 42 +++ docs/fronting-groups.md | 141 +++++++ src/android_jni.rs | 4 +- src/bin/ui.rs | 68 +++- src/config.rs | 219 ++++++++++- src/main.rs | 9 +- src/proxy_server.rs | 353 ++++++++++++++++-- src/test_cmd.rs | 8 +- tunnel-node/README.fa.md | 2 +- 17 files changed, 800 insertions(+), 119 deletions(-) rename config.google-only.example.json => config.direct.example.json (88%) create mode 100644 config.fronting-groups.example.json create mode 100644 docs/fronting-groups.md diff --git a/README.md b/README.md index 353bd0f..fb830b3 100644 --- a/README.md +++ b/README.md @@ -104,12 +104,12 @@ This part is unchanged from the original project. Follow @masterking32's guide o #### Can't reach `script.google.com` from your network? -If your ISP is already blocking Google Apps Script (or all of Google), you need Step 1's browser connection to succeed *before* you have a relay to use. `mhrv-rs` ships a small bootstrap mode for exactly this: `google_only`. +If your ISP is already blocking Google Apps Script (or all of Google), you need Step 1's browser connection to succeed *before* you have a relay to use. `mhrv-rs` ships a `direct` mode for exactly this — SNI-rewrite tunnel only, no Apps Script relay required. (Was named `google_only` before v1.9 — the old name is still accepted in config files.) 1. Build / download the binary as in Step 2 below. -2. Copy [`config.google-only.example.json`](config.google-only.example.json) to `config.json` — no `script_id`, no `auth_key` required. +2. Copy [`config.direct.example.json`](config.direct.example.json) to `config.json` — no `script_id`, no `auth_key` required. 3. Run `mhrv-rs serve` and set your browser's HTTP proxy to `127.0.0.1:8085`. -4. In `google_only` mode the proxy only relays `*.google.com`, `*.youtube.com`, and the other Google-edge hosts via the same SNI-rewrite tunnel the full client uses. Other traffic goes direct — no Apps Script relay exists yet. +4. In `direct` mode the proxy only routes `*.google.com`, `*.youtube.com`, and the other Google-edge hosts (plus any [`fronting_groups`](docs/fronting-groups.md) you've configured) via the SNI-rewrite tunnel. Other traffic goes raw — no Apps Script relay exists yet. 5. Do Step 1 in your browser (the connection to `script.google.com` will be SNI-fronted). Deploy Code.gs, copy the Deployment ID. 6. In the desktop UI or the Android app (or by editing `config.json`) switch the mode back to `apps_script`, paste the Deployment ID and your auth key, and restart. @@ -481,15 +481,15 @@ Donations cover hosting, self-hosted CI runner costs, and continued maintenance. #### به `script.google.com` هم دسترسی ندارید؟ -اگر `ISP` شما از قبل `Apps Script` (یا کل گوگل) را مسدود کرده، برای مرحلهٔ ۱ باید مرورگرتان **اول** به `script.google.com` برسد — قبل از اینکه رله‌ای داشته باشید. `mhrv-rs` یک حالت بوت‌استرپ کوچک دقیقاً برای همین دارد: `google_only`. +اگر `ISP` شما از قبل `Apps Script` (یا کل گوگل) را مسدود کرده، برای مرحلهٔ ۱ باید مرورگرتان **اول** به `script.google.com` برسد — قبل از اینکه رله‌ای داشته باشید. `mhrv-rs` یک حالت `direct` دقیقاً برای همین دارد — فقط تونل بازنویسی `SNI`، بدون نیاز به رلهٔ `Apps Script`. (قبل از v1.9 این حالت `google_only` نام داشت — نام قدیمی همچنان در فایل کانفیگ پذیرفته می‌شود.) ۱. برنامه را طبق مرحلهٔ ۲ پایین دانلود کنید -۲. فایل [`config.google-only.example.json`](config.google-only.example.json) را در کنار فایل اجرایی به نام `config.json` کپی کنید — نه `script_id` لازم دارد و نه `auth_key` +۲. فایل [`config.direct.example.json`](config.direct.example.json) را در کنار فایل اجرایی به نام `config.json` کپی کنید — نه `script_id` لازم دارد و نه `auth_key` ۳. برنامه را اجرا کنید و `HTTP proxy` مرورگرتان را روی `127.0.0.1:8085` تنظیم کنید -۴. در حالت `google_only`، پروکسی فقط `*.google.com`، `*.youtube.com` و بقیهٔ میزبان‌های لبهٔ گوگل را از طریق همان تونل بازنویسی `SNI` رد می‌کند. بقیهٔ ترافیک مستقیم می‌رود — هنوز رله‌ای در کار نیست +۴. در حالت `direct`، پروکسی فقط `*.google.com`، `*.youtube.com` و بقیهٔ میزبان‌های لبهٔ گوگل (به علاوهٔ هر [`fronting_groups`](docs/fronting-groups.md) که تنظیم کرده باشید) را از طریق تونل بازنویسی `SNI` رد می‌کند. بقیهٔ ترافیک مستقیم می‌رود — هنوز رله‌ای در کار نیست ۵. حالا مرحلهٔ ۱ را در مرورگر انجام دهید (اتصال به `script.google.com` با `SNI` فرونت می‌شود). `Code.gs` را مستقر کنید و `Deployment ID` را کپی کنید diff --git a/SF_README.md b/SF_README.md index 2648c79..a172fd8 100644 --- a/SF_README.md +++ b/SF_README.md @@ -23,7 +23,7 @@ A free way to bypass internet censorship by routing your traffic through your ow **1. Set up the relay in your Google account (one-time).** Go to , sign in, click **New project**. Delete the sample code, paste in the [Code.gs file from this repo](assets/apps_script/Code.gs), change `AUTH_KEY = "..."` to a password only you know. Click **Deploy → New deployment → Web app**, set "Execute as: Me", "Who has access: Anyone". Copy the long ID from the URL — that's your **Deployment ID**. -> Can't reach `script.google.com` because it's blocked? Run mhrv-rs first in `google_only` mode (use [`config.google-only.example.json`](config.google-only.example.json)). It only relays Google sites and lets you reach the Apps Script editor through the bypass tunnel. Do step 1 in your browser, then switch back to normal mode. +> Can't reach `script.google.com` because it's blocked? Run mhrv-rs first in `direct` mode (use [`config.direct.example.json`](config.direct.example.json)). It only relays Google sites (plus any [fronting_groups](docs/fronting-groups.md) you've configured) and lets you reach the Apps Script editor through the bypass tunnel. Do step 1 in your browser, then switch back to normal mode. (`direct` was named `google_only` before v1.9 — the old name still works.) **2. Install and run mhrv-rs.** Download the package for your system from [Releases](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases/latest) and unzip it. @@ -94,7 +94,7 @@ This project is free and run by volunteers. If it helped you and you can spare a **۱. ساخت ریله در حساب گوگل (فقط یک بار).** به بروید، وارد حساب گوگل شوید و روی **New project** بزنید. کد پیش‌فرض را پاک کنید و محتوای [فایل Code.gs](assets/apps_script/Code.gs) همین مخزن را در آن جای‌گذاری کنید. خط `AUTH_KEY = "..."` را به یک رمز دلخواه که فقط خودتان می‌دانید تغییر دهید. سپس **Deploy → New deployment → Web app** را بزنید، گزینهٔ "Execute as: Me" و "Who has access: Anyone" را انتخاب کنید. آی‌دی طولانی توی URL را کپی کنید — این **Deployment ID** شماست. -> اگر `script.google.com` خودش بسته است، اول mhrv-rs را در حالت `google_only` اجرا کنید (از [`config.google-only.example.json`](config.google-only.example.json) استفاده کنید). این حالت فقط سایت‌های گوگل را تونل می‌کند تا بتوانید به ویرایشگر Apps Script برسید. مرحلهٔ ۱ را در مرورگر انجام دهید و بعد به حالت معمولی برگردید. +> اگر `script.google.com` خودش بسته است، اول mhrv-rs را در حالت `direct` اجرا کنید (از [`config.direct.example.json`](config.direct.example.json) استفاده کنید). این حالت فقط سایت‌های گوگل (به علاوهٔ هر [fronting_groups](docs/fronting-groups.md) که تنظیم کرده باشید) را تونل می‌کند تا بتوانید به ویرایشگر Apps Script برسید. مرحلهٔ ۱ را در مرورگر انجام دهید و بعد به حالت معمولی برگردید. (نام قبلی این حالت `google_only` بود — همچنان پذیرفته می‌شود.) **۲. نصب و اجرای mhrv-rs.** بستهٔ مخصوص سیستم خودتان را از [بخش Releases](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases/latest) دانلود کنید و از حالت فشرده در بیاورید. diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt index 39cba12..36b6031 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt @@ -64,14 +64,18 @@ enum class UiLang { AUTO, FA, EN } * * - [APPS_SCRIPT] (default) — full DPI bypass through the user's deployed * Apps Script relay. Requires a Deployment ID + Auth key. - * - [GOOGLE_ONLY] — bootstrap mode. Only the SNI-rewrite tunnel to the - * Google edge is active, so the user can reach `script.google.com` to - * deploy Code.gs in the first place. No Deployment ID / Auth key needed. - * Non-Google traffic goes direct (no relay). + * - [DIRECT] — no Apps Script relay. Only the SNI-rewrite tunnel is + * active: Google edge by default, plus any user-configured + * `fronting_groups` (Vercel, Fastly, …). Useful as a bootstrap to + * reach `script.google.com` and deploy Code.gs, or as a standalone + * mode for users who only need fronting-group targets. No Deployment + * ID / Auth key needed. Non-matching traffic goes raw (no relay). + * Was named `GOOGLE_ONLY` before fronting_groups was added — the + * string `"google_only"` is still accepted on parse for back-compat. * - [FULL] — full tunnel mode. ALL traffic is tunneled end-to-end through * Apps Script + a remote tunnel node. No certificate installation needed. */ -enum class Mode { APPS_SCRIPT, GOOGLE_ONLY, FULL } +enum class Mode { APPS_SCRIPT, DIRECT, FULL } data class MhrvConfig( val mode: Mode = Mode.APPS_SCRIPT, @@ -177,14 +181,14 @@ data class MhrvConfig( // "missing field `mode`" and startProxy silently returns 0. put("mode", when (mode) { Mode.APPS_SCRIPT -> "apps_script" - Mode.GOOGLE_ONLY -> "google_only" + Mode.DIRECT -> "direct" Mode.FULL -> "full" }) put("listen_host", listenHost) put("listen_port", listenPort) socks5Port?.let { put("socks5_port", it) } - // In google_only mode these are unused by the Rust side, but we + // In direct mode these are unused by the Rust side, but we // still persist whatever the user typed so flipping back to // apps_script mode doesn't wipe their settings. put("script_ids", JSONArray().apply { ids.forEach { put(it) } }) @@ -286,7 +290,7 @@ object ConfigStore { // Always include essential fields. obj.put("mode", when (cfg.mode) { Mode.APPS_SCRIPT -> "apps_script" - Mode.GOOGLE_ONLY -> "google_only" + Mode.DIRECT -> "direct" Mode.FULL -> "full" }) val ids = cfg.appsScriptUrls.mapNotNull { url -> @@ -391,7 +395,10 @@ object ConfigStore { return MhrvConfig( mode = when (obj.optString("mode", "apps_script")) { - "google_only" -> Mode.GOOGLE_ONLY + "direct" -> Mode.DIRECT + // Deprecated alias kept forever for back-compat with + // configs written before the rename. + "google_only" -> Mode.DIRECT "full" -> Mode.FULL else -> Mode.APPS_SCRIPT }, diff --git a/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt b/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt index 58e3dbf..1c948eb 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt @@ -104,10 +104,10 @@ class MhrvVpnService : VpnService() { startForeground(NOTIF_ID, buildNotif(cfg.listenPort, notifSocks5Port)) // Deployment ID + auth key are required for apps_script and full - // modes — both talk to Apps Script. Only google_only (bootstrap) - // runs without them. Closes #73 regression where google_only - // users hit this branch and crashed on startForeground timeout. - val needsCreds = cfg.mode != Mode.GOOGLE_ONLY + // modes — both talk to Apps Script. Only `direct` mode runs + // without them. Closes #73 regression where direct-mode users + // hit this branch and crashed on startForeground timeout. + val needsCreds = cfg.mode != Mode.DIRECT if (needsCreds && (!cfg.hasDeploymentId || cfg.authKey.isBlank())) { Log.e(TAG, "Config is incomplete — deployment ID + auth key required for ${cfg.mode}") try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (_: Throwable) {} diff --git a/android/app/src/main/java/com/therealaleph/mhrv/Native.kt b/android/app/src/main/java/com/therealaleph/mhrv/Native.kt index 517e46d..51b37f8 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/Native.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/Native.kt @@ -83,7 +83,7 @@ object Native { * Live traffic/usage counters for a running proxy handle. Returns a * JSON blob with the StatsSnapshot fields — or an empty string if the * handle is unknown or the proxy isn't using the Apps Script relay - * (google_only / full-only modes). + * (direct / full-only modes). * * Schema (all integer fields unless noted): * relay_calls, relay_failures, coalesced, bytes_relayed, diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt index 6c499e5..966030c 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/ConfigSharing.kt @@ -264,7 +264,7 @@ private fun ImportConfirmDialog( val preview = ids.take(3).joinToString("\n") { " ${it.take(20)}…" } val modeLabel = when (cfg.mode) { com.therealaleph.mhrv.Mode.APPS_SCRIPT -> "apps_script" - com.therealaleph.mhrv.Mode.GOOGLE_ONLY -> "google_only" + com.therealaleph.mhrv.Mode.DIRECT -> "direct" com.therealaleph.mhrv.Mode.FULL -> "full" } diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt index 8608d26..24323b7 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt @@ -316,7 +316,7 @@ fun HomeScreen( } }, enabled = (isVpnRunning || - cfg.mode == Mode.GOOGLE_ONLY || + cfg.mode == Mode.DIRECT || (cfg.hasDeploymentId && cfg.authKey.isNotBlank())) && !transitioning, colors = ButtonDefaults.buttonColors( containerColor = if (isVpnRunning) ErrRed else OkGreen, @@ -837,7 +837,7 @@ private fun DeploymentIdsField( } // ========================================================================= -// Mode dropdown: apps_script (default) vs google_only (bootstrap). +// Mode dropdown: apps_script (default), direct (no relay), or full. // ========================================================================= @OptIn(ExperimentalMaterial3Api::class) @@ -847,11 +847,11 @@ private fun ModeDropdown( onChange: (Mode) -> Unit, ) { val labelApps = "Apps Script (MITM)" - val labelGoogle = "Google-only (bootstrap)" + val labelDirect = "Direct (no relay)" val labelFull = "Full tunnel (no cert)" val currentLabel = when (mode) { Mode.APPS_SCRIPT -> labelApps - Mode.GOOGLE_ONLY -> labelGoogle + Mode.DIRECT -> labelDirect Mode.FULL -> labelFull } var expanded by remember { mutableStateOf(false) } @@ -878,8 +878,8 @@ private fun ModeDropdown( onClick = { onChange(Mode.APPS_SCRIPT); expanded = false }, ) DropdownMenuItem( - text = { Text(labelGoogle) }, - onClick = { onChange(Mode.GOOGLE_ONLY); expanded = false }, + text = { Text(labelDirect) }, + onClick = { onChange(Mode.DIRECT); expanded = false }, ) DropdownMenuItem( text = { Text(labelFull) }, @@ -891,8 +891,8 @@ private fun ModeDropdown( val help = when (mode) { Mode.APPS_SCRIPT -> "Full DPI bypass through your deployed Apps Script relay." - Mode.GOOGLE_ONLY -> - "Bootstrap: reach *.google.com directly so you can open script.google.com and deploy Code.gs. Non-Google traffic goes direct." + Mode.DIRECT -> + "SNI-rewrite tunnel only — no relay. Reach *.google.com (and any configured fronting_groups) directly. Useful as a bootstrap to open script.google.com and deploy Code.gs." Mode.FULL -> "All traffic tunneled end-to-end through Apps Script + remote tunnel node. No certificate needed." } @@ -1430,7 +1430,7 @@ private fun CollapsibleSection( * this device relayed. * * Hidden when the handle is 0 (proxy not running) or the JSON comes back - * empty (google_only / full-only configs don't run a DomainFronter and so + * empty (direct / full-only configs don't run a DomainFronter and so * have nothing to report). */ @Composable diff --git a/config.google-only.example.json b/config.direct.example.json similarity index 88% rename from config.google-only.example.json rename to config.direct.example.json index 890f966..c0a9594 100644 --- a/config.google-only.example.json +++ b/config.direct.example.json @@ -1,5 +1,5 @@ { - "mode": "google_only", + "mode": "direct", "google_ip": "216.239.38.120", "front_domain": "www.google.com", "listen_host": "127.0.0.1", diff --git a/config.fronting-groups.example.json b/config.fronting-groups.example.json new file mode 100644 index 0000000..11e14f8 --- /dev/null +++ b/config.fronting-groups.example.json @@ -0,0 +1,42 @@ +{ + "mode": "direct", + "google_ip": "216.239.38.120", + "front_domain": "www.google.com", + "listen_host": "127.0.0.1", + "listen_port": 8085, + "socks5_port": 8086, + "log_level": "info", + "verify_ssl": true, + "fronting_groups": [ + { + "name": "vercel", + "ip": "76.76.21.21", + "sni": "react.dev", + "domains": [ + "vercel.com", + "vercel.app", + "vercel.dev", + "vercel.live", + "vercel.sh", + "nextjs.org", + "now.sh", + "cursor.com", + "ai-sdk.dev" + ] + }, + { + "name": "fastly", + "ip": "151.101.1.140", + "sni": "www.python.org", + "domains": [ + "reddit.com", + "redditstatic.com", + "redditmedia.com", + "githubassets.com", + "githubusercontent.com", + "pypi.org", + "fastly.com" + ] + } + ] +} diff --git a/docs/fronting-groups.md b/docs/fronting-groups.md new file mode 100644 index 0000000..772ee5a --- /dev/null +++ b/docs/fronting-groups.md @@ -0,0 +1,141 @@ +# Multi-edge fronting groups + +The default mhrv-rs SNI-rewrite path targets Google's edge: TLS goes out +with `SNI=www.google.com` to a Google IP, the inner `Host` header (after +the local MITM CA terminates the browser's TLS) names the real +destination, and Google's frontend routes by `Host`. That's how +`www.youtube.com`, `script.google.com`, and friends reach you despite a +DPI box that drops anything not SNI'd as `www.google.com`. + +The same trick works on any multi-tenant CDN edge that: + +1. serves multiple tenant domains on the same IP pool, and +2. dispatches to the right backend by inner HTTP `Host`, and +3. presents a TLS cert whose name matches the SNI you choose. + +Vercel and Fastly fit the bill. Pick a benign-looking domain hosted on +the same edge, use it as the SNI, and you can route many other domains +on that edge through the same tunnel without burning Apps Script quota. + +## Config shape + +```jsonc +{ + "mode": "direct", // or apps_script / full + "fronting_groups": [ + { + "name": "vercel", // free-form, used in logs + "ip": "76.76.21.21", // a Vercel edge IP + "sni": "react.dev", // a Vercel-hosted domain + "domains": [ // hosts to route via this group + "vercel.com", "vercel.app", + "nextjs.org", "now.sh" + ] + } + ] +} +``` + +`domains` matches case-insensitively, exact OR dot-anchored suffix — +`vercel.com` covers both `vercel.com` and `*.vercel.com`. First group +in the list whose member matches wins. + +A working example is shipped at `config.fronting-groups.example.json`. + +## Picking the (ip, sni) pair + +The SNI must be a real, currently-live domain on the same edge. rustls +validates the upstream cert against the SNI you send; if the edge +returns a cert that doesn't cover that name, the handshake fails. So +the recipe is: + +1. Pick the target edge (Vercel, Fastly, …). +2. Find a neutral, never-blocked domain hosted there. Vercel: `react.dev`, + `nextjs.org`. Fastly: `www.python.org`, `pypi.org`. +3. Resolve that domain (`dig +short react.dev A`) — pick one IP, drop + it in `ip`. +4. List the domains you actually want to reach via this edge in + `domains` — **only domains you've verified are hosted on the same + edge as `sni`** (see warning below). + +Edge IPs rotate. If a group's `ip` stops working, re-resolve the SNI +domain and update the config — IP rotation per-group is on the +roadmap but not implemented yet. + +## ⚠️ Cross-tenant leak: don't list domains that aren't on the edge + +If you put a domain in `domains` that is **not** actually hosted on the +edge you've configured, two things happen, both bad: + +1. **Privacy leak.** The proxy completes a TLS handshake with the edge + (validated against `sni`, which IS on the edge), then sends `Host: + ` inside that encrypted stream. The edge — which is + not your-domain's host — now sees a request labelled with + your-domain's name. From the edge's perspective, *you* deliberately + sent that request to them. Vercel/Fastly logs will show your-domain + in their access logs, attributable to your IP and timestamps. + +2. **UX failure.** The edge has no backend for your-domain, so it + returns its default 404 / wrong-tenant page. The site appears + "broken via mhrv-rs" but works fine over a normal connection, + which is confusing to debug. + +**Verify before listing.** A simple check: if `dig +short your-domain +A` returns an IP that's *also* one of the edge's IPs, you're fine. If +the IPs differ, your-domain is hosted somewhere else and listing it +will leak. This is also why the upstream MITM-DomainFronting Xray +config uses `verifyPeerCertByName` with an explicit SAN allowlist — +it's a second guard against accidentally fronting unrelated domains +through the same edge. mhrv-rs leaves verification to rustls + the +SNI you send; the leak guard is "you, the operator, listing only +domains you've verified." + +Only listed domains are routed to the group. Anything else falls +through to the next dispatch step (Google SNI-rewrite or Apps Script +relay), so unrelated traffic does NOT accidentally hit a group's edge. + +## Routing precedence + +Within a single CONNECT, the dispatch order is: + +1. `passthrough_hosts` — explicit user opt-out. +2. DoH bypass (port 443, known DoH host). +3. `mode = full` — everything via the batch tunnel mux. +4. **`fronting_groups` match (port 443).** — this feature. +5. Built-in Google SNI-rewrite suffix list (port 443). +6. `mode = direct` fallback → raw TCP. +7. `mode = apps_script` peek + relay. + +So fronting groups beat the Google-edge default for hosts they list, +but lose to user-explicit passthrough/DoH choices. Putting `vercel.com` +in a Vercel fronting group will route Vercel traffic through Vercel's +edge directly, not through the Apps Script relay or the Google edge. + +## Limitations / what's not here yet + +- **Single IP per group.** Real edges have many; we'll add a pool with + health-checking when there's a clear need. Workaround: when the + configured IP starts failing, swap it. +- **No bundled domain catalog.** The upstream Xray config uses + `geosite:vercel` / `geosite:fastly` lists from a binary geosite + database — we don't ship that, you list domains explicitly. +- **No UI editor.** Edit `config.json` directly. The UI's Save path + preserves your `fronting_groups` block (round-tripped) — it just + doesn't render an editor for it. +- **Browsers only for Android non-root**, same as the Google path — + third-party apps that don't trust user CAs (Telegram, Instagram, …) + can't be MITM'd, so this trick doesn't help them. +- **Cert verification matches the SNI.** No per-group SAN allowlist + (their `verifyPeerCertByName`); the SNI you send IS what rustls + validates against. If you want stricter pinning, set `verify_ssl: + false` is the wrong answer — instead, pick an SNI whose cert + genuinely covers your targets. + +## Credit + +The technique is the same one [@masterking32]'s original +MasterHttpRelayVPN demonstrated for Google's edge. The Vercel + +Fastly extension and the matching Xray config came from +[@patterniha]'s [MITM-DomainFronting](https://github.com/patterniha/MITM-DomainFronting) +project — this `fronting_groups` field is a Rust port of that idea +into mhrv-rs's existing dispatcher. diff --git a/src/android_jni.rs b/src/android_jni.rs index 6bb5a97..91b4fa5 100644 --- a/src/android_jni.rs +++ b/src/android_jni.rs @@ -42,7 +42,7 @@ struct Running { rt: Option, /// Keep an Arc to the DomainFronter so `statsJson(handle)` can read the /// live stats without going through the async server. `None` for - /// google-only / full-only configs where the fronter isn't used. + /// direct / full-only configs where the fronter isn't used. fronter: Option>, } @@ -457,7 +457,7 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_testSni<'a>( /// `Native.statsJson(long handle)` -> String. Returns a JSON blob with the /// live `StatsSnapshot` for a running proxy, or an empty string if the -/// handle is unknown or the proxy has no fronter (google_only / full modes). +/// handle is unknown or the proxy has no fronter (direct / full modes). /// /// Cheap — just reads a handful of atomics. The Kotlin UI polls this on a /// timer to render the "Usage today (estimated)" card. diff --git a/src/bin/ui.rs b/src/bin/ui.rs index 9f1907e..a7ef62c 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -10,7 +10,7 @@ use tokio::sync::Mutex as AsyncMutex; use tokio::task::JoinHandle; use mhrv_rs::cert_installer::{install_ca, reconcile_sudo_environment, remove_ca}; -use mhrv_rs::config::{Config, ScriptId}; +use mhrv_rs::config::{Config, FrontingGroup, ScriptId}; use mhrv_rs::data_dir; use mhrv_rs::domain_fronter::{DomainFronter, DEFAULT_GOOGLE_SNI_POOL}; use mhrv_rs::mitm::{MitmCertManager, CA_CERT_FILE}; @@ -216,9 +216,11 @@ struct App { #[derive(Clone)] struct FormState { - /// `"apps_script"` (default) or `"google_only"`. Controls whether the - /// Apps Script relay is wired up at all. In `google_only`, the form - /// tolerates an empty script_id / auth_key. + /// `"apps_script"` (default), `"direct"`, or `"full"`. Controls + /// whether the Apps Script relay is wired up at all. In `direct`, + /// the form tolerates an empty script_id / auth_key. + /// On load we normalize the legacy `"google_only"` string to + /// `"direct"` so the next save rewrites the on-disk config. mode: String, script_id: String, auth_key: String, @@ -265,6 +267,11 @@ struct FormState { /// User-supplied DoH hostnames added to the built-in default list, /// round-tripped from config.json. See config.rs `bypass_doh_hosts`. bypass_doh_hosts: Vec, + /// Multi-edge fronting groups. Round-tripped from config.json so + /// the UI's Save doesn't drop the user's hand-edited groups — + /// there is no UI editor for these yet, only file-edited config. + /// See config.rs `fronting_groups`. + fronting_groups: Vec, } #[derive(Clone, Debug)] @@ -322,8 +329,18 @@ fn load_form() -> (FormState, Option) { }, }; let sni_pool = sni_pool_for_form(c.sni_hosts.as_deref(), &c.front_domain); + // Normalize the legacy `google_only` mode string on load. The + // backend's `mode_kind()` accepts the alias forever, but storing + // it as `direct` in the form means the next Save rewrites the + // on-disk config to the new name — one-way migration, no warn + // on every startup. + let mode_normalized = if c.mode == "google_only" { + "direct".to_string() + } else { + c.mode.clone() + }; FormState { - mode: c.mode.clone(), + mode: mode_normalized, script_id: sid, auth_key: c.auth_key, google_ip: c.google_ip, @@ -351,6 +368,7 @@ fn load_form() -> (FormState, Option) { disable_padding: c.disable_padding, tunnel_doh: c.tunnel_doh, bypass_doh_hosts: c.bypass_doh_hosts.clone(), + fronting_groups: c.fronting_groups.clone(), } } else { FormState { @@ -382,6 +400,7 @@ fn load_form() -> (FormState, Option) { disable_padding: false, tunnel_doh: false, bypass_doh_hosts: Vec::new(), + fronting_groups: Vec::new(), } }; (form, load_err) @@ -433,8 +452,10 @@ fn sni_pool_for_form(user: Option<&[String]>, front_domain: &str) -> Vec impl FormState { fn to_config(&self) -> Result { - let is_google_only = self.mode == "google_only"; - if !is_google_only { + // `direct` and the legacy `google_only` alias both run without + // an Apps Script relay, so neither requires a script_id. + let is_direct = self.mode == "direct" || self.mode == "google_only"; + if !is_direct { if self.script_id.trim().is_empty() { return Err("Apps Script ID is required".into()); } @@ -536,6 +557,9 @@ impl FormState { // added) so save doesn't drop them. tunnel_doh: self.tunnel_doh, bypass_doh_hosts: self.bypass_doh_hosts.clone(), + // Multi-edge fronting groups: file-edited only for now, + // round-tripped through the UI so Save doesn't drop them. + fronting_groups: self.fronting_groups.clone(), // PR #448 (Android): adaptive coalesce window. Desktop UI // doesn't expose sliders for these yet (Android does), so // we pass 0 to keep the compiled defaults (40ms step, @@ -600,6 +624,8 @@ struct ConfigWire<'a> { tunnel_doh: bool, #[serde(skip_serializing_if = "Vec::is_empty")] bypass_doh_hosts: &'a Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + fronting_groups: &'a Vec, } fn is_false(b: &bool) -> bool { @@ -650,6 +676,7 @@ impl<'a> From<&'a Config> for ConfigWire<'a> { google_ip_validation: c.google_ip_validation, tunnel_doh: c.tunnel_doh, bypass_doh_hosts: &c.bypass_doh_hosts, + fronting_groups: &c.fronting_groups, } } } @@ -787,19 +814,20 @@ impl eframe::App for App { // ── Section: Mode ───────────────────────────────────────────── // Surfacing the mode at the top of the form because it changes - // which of the sections below are actually used. google_only is - // a bootstrap mode for users who don't yet have internet access - // to deploy Code.gs — once deployed, they switch back to - // apps_script. + // which of the sections below are actually used. `direct` runs + // without the Apps Script relay (Google edge + any configured + // fronting_groups via the SNI-rewrite tunnel only) — useful as + // a bootstrap to deploy Code.gs, or as a standalone mode for + // users who only need access to fronting-group targets. section(ui, "Mode", |ui| { form_row(ui, "Mode", Some( "apps_script: DPI bypass via Apps Script relay (needs cert).\n\ full: tunnel ALL traffic through Apps Script + tunnel node (no cert needed).\n\ - google_only: bootstrap — direct SNI-rewrite tunnel to *.google.com only." + direct: SNI-rewrite tunnel only — no relay (Google edge + any fronting_groups)." ), |ui| { egui::ComboBox::from_id_source("mode") .selected_text(match self.form.mode.as_str() { - "google_only" => "Google-only (bootstrap)", + "direct" | "google_only" => "Direct (no relay)", "full" => "Full tunnel (no cert)", _ => "Apps Script (MITM)", }) @@ -816,16 +844,16 @@ impl eframe::App for App { ); ui.selectable_value( &mut self.form.mode, - "google_only".into(), - "Google-only (bootstrap)", + "direct".into(), + "Direct (no relay)", ); }); }); - if self.form.mode == "google_only" { + if self.form.mode == "direct" || self.form.mode == "google_only" { ui.horizontal(|ui| { ui.add_space(120.0 + 8.0); ui.small(egui::RichText::new( - "Bootstrap mode — reach script.google.com to deploy Code.gs, then switch back to Apps Script.", + "Direct mode — SNI-rewrite tunnel only. Reach the Google edge (and any configured fronting_groups) without an Apps Script relay.", ) .color(OK_GREEN)); }); @@ -841,11 +869,11 @@ impl eframe::App for App { } }); - let google_only = self.form.mode == "google_only"; + let direct_mode = self.form.mode == "direct" || self.form.mode == "google_only"; // ── Section: Apps Script relay ──────────────────────────────── section(ui, "Apps Script relay", |ui| { - ui.add_enabled_ui(!google_only, |ui| { + ui.add_enabled_ui(!direct_mode, |ui| { form_row(ui, "Deployment IDs", Some( "One deployment ID per line. Proxy round-robins between them and sidelines \ any ID that hits its daily quota for 10 minutes before retrying." @@ -1916,7 +1944,7 @@ fn background_thread(shared: Arc, rx: Receiver) { return; } }; - // `fronter()` is `None` in google_only (bootstrap) mode — the + // `fronter()` is `None` in direct mode — the // status panel's relay stats simply show no data in that case. *fronter_slot2.lock().await = server.fronter(); { diff --git a/src/config.rs b/src/config.rs index 824ef76..b2640ca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,5 @@ -use serde::Deserialize; +use rustls::pki_types::ServerName; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; @@ -14,14 +15,19 @@ pub enum ConfigError { /// Operating mode. `AppsScript` is the full client — MITMs TLS locally and /// relays HTTP/HTTPS through a user-deployed Apps Script endpoint. -/// `GoogleOnly` is a bootstrap: no relay, no Apps Script config needed, -/// only the SNI-rewrite tunnel to the Google edge is active. Intended for -/// users who need to reach `script.google.com` to deploy `Code.gs` in the -/// first place. +/// `Direct` runs without any Apps Script relay: only the SNI-rewrite tunnel +/// is active, targeting the Google edge by default plus any user-configured +/// `fronting_groups`. Originally introduced as a `script.google.com` +/// bootstrap (when this mode could only reach Google's edge it was named +/// `google_only`), now generalized to any user-configured CDN edge. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { AppsScript, - GoogleOnly, + /// Was named `GoogleOnly` before v1.9 and the introduction of + /// `fronting_groups`. The string `"google_only"` is still accepted + /// in `mode_kind()` as a deprecated alias so existing configs do + /// not break. + Direct, Full, } @@ -29,7 +35,7 @@ impl Mode { pub fn as_str(self) -> &'static str { match self { Mode::AppsScript => "apps_script", - Mode::GoogleOnly => "google_only", + Mode::Direct => "direct", Mode::Full => "full", } } @@ -252,6 +258,65 @@ pub struct Config { /// startup if both are set together. #[serde(default)] pub bypass_doh_hosts: Vec, + + /// Multi-edge domain-fronting groups. Each group is a triple of + /// (edge IP, front SNI, member domains): when a CONNECT to one of + /// the member domains arrives, the proxy MITMs at the local CA + /// then re-encrypts upstream against `ip` with `sni` as the TLS + /// SNI — same trick we already do for `google_ip` + `front_domain`, + /// but generalised so users can target Vercel's edge (sni=react.dev, + /// fronting vercel.com / vercel.app / nextjs.org / ...) or Fastly's + /// (sni=www.python.org, fronting reddit.com / githubassets.com / ...) + /// directly without burning Apps Script quota or relying on the + /// Google edge for non-Google traffic. + /// + /// The cert returned by the upstream is validated against `sni` by + /// rustls as normal — no custom SAN-allowlist needed, the front SNI + /// must itself be a real domain hosted by the same edge as the + /// targets. Picking the right (ip, sni) pair is on the user; see + /// `docs/fronting-groups.md` for the recipe. + /// + /// Group match wins over the built-in Google SNI-rewrite suffix list + /// but loses to `passthrough_hosts` (explicit user opt-out wins) and + /// to the DoH bypass. Empty / missing = feature off. + #[serde(default)] + pub fronting_groups: Vec, +} + +/// One multi-edge fronting group. Edge CDNs like Vercel and Fastly +/// host hundreds of tenants behind a single set of edge IPs and use +/// the inner HTTP `Host` header (after TLS handshake) to dispatch to +/// the right backend. Pick one neutral domain hosted on the same edge +/// as `sni`; the cert it serves will be valid for that name (rustls +/// validates against `sni`, not against the inner `Host`), and the +/// edge will route based on the `Host` header. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct FrontingGroup { + /// Human-readable name used in log lines. Free-form; uniqueness not + /// enforced but recommended. + pub name: String, + /// Edge IP to dial. A single IP for now — most edges have many but + /// one is enough to validate the technique. IP rotation per-group + /// can come later. + pub ip: String, + /// SNI to send on the outbound TLS handshake. Must be a real domain + /// served by the same edge as `domains`, otherwise the edge will + /// either refuse the handshake or serve a default page that 404s + /// the inner Host. Examples: `react.dev` for Vercel, `www.python.org` + /// for Fastly. + pub sni: String, + /// Member domain list. Matching is case-insensitive: an entry + /// matches the host exactly OR as an unconditional dot-anchored + /// suffix (`vercel.com` matches `app.vercel.com` too). Same shape + /// as the DoH host list. + /// + /// Canonical form for matching is lowercase and trailing-dot + /// trimmed; entries are normalized to that form once at proxy + /// startup. The on-disk representation is preserved as written + /// (we don't mutate the user's config), so `Vercel.com.` and + /// `vercel.com` both work — the matcher is the source of truth + /// for equality. + pub domains: Vec, } fn default_fetch_ips_from_api() -> bool { false } @@ -321,16 +386,62 @@ impl Config { self.listen_port, self.listen_host ))); } + for (i, g) in self.fronting_groups.iter().enumerate() { + if g.name.trim().is_empty() { + return Err(ConfigError::Invalid(format!( + "fronting_groups[{}]: name is empty", i + ))); + } + if g.ip.trim().is_empty() { + return Err(ConfigError::Invalid(format!( + "fronting_groups[{}] ('{}'): ip is empty", i, g.name + ))); + } + if g.sni.trim().is_empty() { + return Err(ConfigError::Invalid(format!( + "fronting_groups[{}] ('{}'): sni is empty", i, g.name + ))); + } + // Parse the SNI here so an invalid hostname fails the same + // load path the UI / `mhrv-rs` CLI both use, rather than + // surfacing later only when ProxyServer::new tries to build + // the TLS server name. Same fail-fast contract as the rest + // of validate(). The parse is cheap; runtime path repeats + // it once at proxy startup, idempotently. + if let Err(e) = ServerName::try_from(g.sni.clone()) { + return Err(ConfigError::Invalid(format!( + "fronting_groups[{}] ('{}'): invalid sni '{}': {}", + i, g.name, g.sni, e + ))); + } + if g.domains.is_empty() { + return Err(ConfigError::Invalid(format!( + "fronting_groups[{}] ('{}'): domains list is empty", i, g.name + ))); + } + for d in &g.domains { + if d.trim().is_empty() { + return Err(ConfigError::Invalid(format!( + "fronting_groups[{}] ('{}'): empty domain entry", i, g.name + ))); + } + } + } Ok(()) } pub fn mode_kind(&self) -> Result { match self.mode.as_str() { "apps_script" => Ok(Mode::AppsScript), - "google_only" => Ok(Mode::GoogleOnly), + "direct" => Ok(Mode::Direct), + // Deprecated alias. `google_only` was the name of `direct` + // before fronting_groups generalized the mode beyond + // Google's edge. Accepted forever so old configs keep + // working — the UI rewrites it on next save. + "google_only" => Ok(Mode::Direct), "full" => Ok(Mode::Full), other => Err(ConfigError::Invalid(format!( - "unknown mode '{}' (expected 'apps_script', 'google_only', or 'full')", + "unknown mode '{}' (expected 'apps_script', 'direct', or 'full')", other ))), } @@ -397,24 +508,36 @@ mod tests { } #[test] - fn parses_google_only_without_script_id() { - // Bootstrap mode: no script_id, no auth_key — both are only meaningful + fn parses_direct_without_script_id() { + // Direct mode: no script_id, no auth_key — both are only meaningful // once the Apps Script relay exists. + let s = r#"{ + "mode": "direct" + }"#; + let cfg: Config = serde_json::from_str(s).unwrap(); + cfg.validate().expect("direct must validate without script_id / auth_key"); + assert_eq!(cfg.mode_kind().unwrap(), Mode::Direct); + } + + #[test] + fn google_only_alias_parses_as_direct() { + // Backwards compat: `direct` was named `google_only` before + // fronting_groups. Existing configs must continue to load. let s = r#"{ "mode": "google_only" }"#; let cfg: Config = serde_json::from_str(s).unwrap(); - cfg.validate().expect("google_only must validate without script_id / auth_key"); - assert_eq!(cfg.mode_kind().unwrap(), Mode::GoogleOnly); + cfg.validate().expect("google_only alias must still validate"); + assert_eq!(cfg.mode_kind().unwrap(), Mode::Direct); } #[test] - fn google_only_ignores_placeholder_script_id() { + fn direct_ignores_placeholder_script_id() { // UI round-trip: user saved config in apps_script with the placeholder, - // then switched mode to google_only. The placeholder should not block - // validation in the bootstrap mode. + // then switched mode to direct. The placeholder should not block + // validation in the no-relay mode. let s = r#"{ - "mode": "google_only", + "mode": "direct", "script_id": "YOUR_APPS_SCRIPT_DEPLOYMENT_ID" }"#; let cfg: Config = serde_json::from_str(s).unwrap(); @@ -466,6 +589,68 @@ mod tests { assert!(cfg.validate().is_err()); } + #[test] + fn fronting_groups_parse_and_validate() { + let s = r#"{ + "mode": "direct", + "fronting_groups": [ + { + "name": "vercel", + "ip": "76.76.21.21", + "sni": "react.dev", + "domains": ["vercel.com", "nextjs.org"] + } + ] + }"#; + let cfg: Config = serde_json::from_str(s).unwrap(); + cfg.validate().unwrap(); + assert_eq!(cfg.fronting_groups.len(), 1); + assert_eq!(cfg.fronting_groups[0].name, "vercel"); + assert_eq!(cfg.fronting_groups[0].domains.len(), 2); + } + + #[test] + fn fronting_group_rejects_invalid_sni_at_validate() { + // SNI must parse as a DNS hostname at the same fail-fast point + // as the rest of validate(), not later at proxy-startup time. + // The CLI and UI both run validate() on Save / before serve. + let s = r#"{ + "mode": "direct", + "fronting_groups": [{ + "name": "bad", + "ip": "1.2.3.4", + "sni": "not a valid hostname", + "domains": ["x.com"] + }] + }"#; + let cfg: Config = serde_json::from_str(s).unwrap(); + let err = cfg.validate().expect_err("invalid sni must fail validate()"); + let msg = format!("{}", err); + assert!(msg.contains("invalid sni"), "error should mention invalid sni: {}", msg); + } + + #[test] + fn fronting_group_rejects_empty_fields() { + for bad in [ + r#"{ "name": "", "ip": "1.2.3.4", "sni": "a.b", "domains": ["x.com"] }"#, + r#"{ "name": "n", "ip": "", "sni": "a.b", "domains": ["x.com"] }"#, + r#"{ "name": "n", "ip": "1.2.3.4","sni": "", "domains": ["x.com"] }"#, + r#"{ "name": "n", "ip": "1.2.3.4","sni": "a.b", "domains": [] }"#, + r#"{ "name": "n", "ip": "1.2.3.4","sni": "a.b", "domains": [" "] }"#, + ] { + let s = format!( + r#"{{ "mode": "direct", "fronting_groups": [{}] }}"#, + bad + ); + let cfg: Config = serde_json::from_str(&s).unwrap(); + assert!( + cfg.validate().is_err(), + "expected validation error for: {}", + bad + ); + } + } + #[test] fn rejects_same_http_and_socks5_port() { let s = r#"{ diff --git a/src/main.rs b/src/main.rs index fe33d16..202c7ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -288,11 +288,12 @@ async fn main() -> ExitCode { tracing::info!("Script ID: {}", sids[0]); } } - mhrv_rs::config::Mode::GoogleOnly => { + mhrv_rs::config::Mode::Direct => { tracing::warn!( - "google_only bootstrap: direct SNI-rewrite tunnel to {} only. \ - Open https://script.google.com in your browser (proxy set to \ - {}:{}), deploy Code.gs, then switch to apps_script mode.", + "direct mode: SNI-rewrite tunnel only (Google edge {} + any \ + configured fronting_groups). Open https://script.google.com \ + in your browser (proxy set to {}:{}), deploy Code.gs, then \ + switch to apps_script mode for full DPI bypass.", config.google_ip, config.listen_host, config.listen_port diff --git a/src/proxy_server.rs b/src/proxy_server.rs index a3a232a..8a3d944 100644 --- a/src/proxy_server.rs +++ b/src/proxy_server.rs @@ -15,7 +15,7 @@ use tokio_rustls::rustls::server::Acceptor; use tokio_rustls::rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme}; use tokio_rustls::{LazyConfigAcceptor, TlsAcceptor, TlsConnector}; -use crate::config::{Config, Mode}; +use crate::config::{Config, FrontingGroup, Mode}; use crate::domain_fronter::DomainFronter; use crate::mitm::MitmCertManager; use crate::tunnel_client::{decode_udp_packets, TunnelMux}; @@ -210,8 +210,9 @@ pub struct ProxyServer { host: String, port: u16, socks5_port: u16, - /// `None` in `google_only` (bootstrap) mode: no Apps Script relay is - /// wired up, only the SNI-rewrite tunnel path is live. + /// `None` in `direct` mode: no Apps Script relay is wired up, + /// only the SNI-rewrite tunnel path (Google edge + any configured + /// `fronting_groups`) is live. fronter: Option>, mitm: Arc>, rewrite_ctx: Arc, @@ -247,6 +248,14 @@ pub struct RewriteCtx { /// User-supplied DoH hostnames added to the built-in default list. /// Same matching semantics as `passthrough_hosts`. pub bypass_doh_hosts: Vec, + /// Multi-edge fronting groups, resolved at startup. Each group's + /// `ServerName` is parsed once so the per-connection dial path + /// is allocation-free. Wrapped in `Arc` so a per-CONNECT match + /// can hand the dispatcher a refcount-clone instead of cloning + /// the whole struct (which holds a `Vec` of normalized + /// domains used only for matching). Empty = feature off (only + /// the built-in Google edge SNI-rewrite is active). + pub fronting_groups: Vec>, } /// True if `host` matches a known DoH endpoint — either the built-in @@ -282,6 +291,88 @@ pub fn matches_doh_host(host: &str, extra: &[String]) -> bool { extra.iter().any(|s| host_matches_doh_entry(h, s)) } +/// A `FrontingGroup` after one-time validation: the group's `sni` is +/// parsed into a `ServerName` so we don't repay that on every dialed +/// connection, and domain entries are pre-lower-cased + dot-trimmed +/// so the per-request match path is just byte comparisons. +#[derive(Debug, Clone)] +pub struct FrontingGroupResolved { + pub name: String, + pub ip: String, + pub sni: String, + pub server_name: ServerName<'static>, + domains_normalized: Vec, +} + +impl FrontingGroupResolved { + fn from_config(g: &FrontingGroup) -> Result { + let server_name = ServerName::try_from(g.sni.clone()) + .map_err(|e| format!("invalid sni '{}': {}", g.sni, e))?; + let domains_normalized = g + .domains + .iter() + .map(|d| d.trim().trim_end_matches('.').to_ascii_lowercase()) + .filter(|d| !d.is_empty()) + .collect(); + Ok(Self { + name: g.name.clone(), + ip: g.ip.clone(), + sni: g.sni.clone(), + server_name, + domains_normalized, + }) + } +} + +/// First fronting group whose domain list contains `host`, if any. +/// Match is case-insensitive and unconditionally suffix-anchored: an +/// entry `vercel.com` matches both `vercel.com` and `*.vercel.com`. +/// This is the right shape for fronting because every legitimate +/// subdomain of a fronted domain is itself fronted by the same edge +/// — requiring users to spell out every subdomain would be a footgun. +/// Same matching shape as the DoH host list. First match wins, so +/// users can put more-specific groups earlier when entries would +/// otherwise overlap. +pub fn match_fronting_group<'a>( + host: &str, + groups: &'a [Arc], +) -> Option<&'a Arc> { + if groups.is_empty() { + return None; + } + let h = host.to_ascii_lowercase(); + let h = h.trim_end_matches('.'); + if h.is_empty() { + return None; + } + for g in groups { + for d in &g.domains_normalized { + if is_dot_anchored_match(h, d) { + return Some(g); + } + } + } + None +} + +/// True if `host` equals `entry` exactly OR is a strict dot-anchored +/// suffix of it (i.e. `entry == "vercel.com"` matches `host == +/// "app.vercel.com"` but not `host == "xvercel.com"`). Both inputs +/// must already be lowercase + trailing-dot trimmed; the function +/// does no allocation, unlike the obvious `format!(".{}", entry)` +/// implementation that allocates per call. +#[inline] +fn is_dot_anchored_match(host: &str, entry: &str) -> bool { + if host == entry { + return true; + } + let hb = host.as_bytes(); + let eb = entry.as_bytes(); + hb.len() > eb.len() + && hb.ends_with(eb) + && hb[hb.len() - eb.len() - 1] == b'.' +} + /// True if `host` matches any entry in the user's passthrough list. /// Match is case-insensitive. Entries match either exactly, or as a /// suffix if they start with "." (e.g. ".internal.example" matches @@ -313,16 +404,16 @@ impl ProxyServer { .mode_kind() .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?; - // `google_only` mode skips the Apps Script relay entirely, so we must + // `direct` mode skips the Apps Script relay entirely, so we must // not try to construct the DomainFronter — it errors on a missing - // `script_id`, which is exactly the state a bootstrapping user is in. + // `script_id`, which is exactly the state a direct-mode user is in. let fronter = match mode { Mode::AppsScript | Mode::Full => { let f = DomainFronter::new(config) .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("{e}")))?; Some(Arc::new(f)) } - Mode::GoogleOnly => None, + Mode::Direct => None, }; let tls_config = if config.verify_ssl { @@ -353,6 +444,54 @@ impl ProxyServer { ); } + // Same-shape warning for fronting_groups in full mode. The dispatch + // short-circuits to the tunnel mux before the fronting_groups check + // (full mode preserves end-to-end TLS, fronting_groups requires + // MITM), so groups configured here will never fire. Surface this + // at startup rather than letting users wonder why their Vercel + // domains never hit the configured edge. + if mode == Mode::Full && !config.fronting_groups.is_empty() { + tracing::warn!( + "config: fronting_groups has {} entries but mode=full — \ + full mode tunnels everything end-to-end through Apps Script \ + (no MITM), so groups never fire. Switch to mode=apps_script \ + or mode=direct to use them, or remove the groups to silence \ + this warning.", + config.fronting_groups.len() + ); + } + + let mut fronting_groups: Vec> = + Vec::with_capacity(config.fronting_groups.len()); + let mut seen_names: std::collections::HashSet = Default::default(); + for g in &config.fronting_groups { + let resolved = FrontingGroupResolved::from_config(g).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("fronting_groups['{}']: {}", g.name, e), + ) + })?; + // Surface duplicate group names at startup. Not a hard + // error — copy-pasted configs can land here legitimately + // — but log lines key on `name` and dedup ambiguity makes + // them unreadable. + if !seen_names.insert(resolved.name.clone()) { + tracing::warn!( + "fronting group name '{}' is used by more than one group; \ + log lines that reference the name will be ambiguous", + resolved.name + ); + } + tracing::info!( + "fronting group '{}': sni={} ip={} domains={}", + resolved.name, + resolved.sni, + resolved.ip, + resolved.domains_normalized.len() + ); + fronting_groups.push(Arc::new(resolved)); + } + let rewrite_ctx = Arc::new(RewriteCtx { google_ip: config.google_ip.clone(), front_domain: config.front_domain.clone(), @@ -365,6 +504,7 @@ impl ProxyServer { block_quic: config.block_quic, bypass_doh: !config.tunnel_doh, bypass_doh_hosts: config.bypass_doh_hosts.clone(), + fronting_groups, }); let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1); @@ -410,8 +550,8 @@ impl ProxyServer { ); // Pre-warm the outbound connection pool so the user's first request // doesn't pay a fresh TLS handshake to Google edge. Best-effort; - // failures are logged and ignored. Skipped in `google_only` — there - // is no fronter to warm. + // failures are logged and ignored. Skipped in `direct` mode — + // there is no fronter to warm. // // Sized to roughly match a browser's parallel-connection burst at // startup. The previous fixed `3` was fine for a single deployment @@ -431,7 +571,7 @@ impl ProxyServer { // goes cold after ~5min idle and costs 1-3s to wake. A periodic // HEAD ping prevents the cold-start lag on the first request // after a quiet pause (most visible as YouTube player stalls). - // Skipped in google_only mode for the same reason as warm — + // Skipped in direct mode for the same reason as warm — // there's no fronter to ping. // // The handle is captured (not fire-and-forget) so the shutdown @@ -680,12 +820,13 @@ async fn handle_http_client( // apps_script mode: relay through the Apps Script fronter (which // is the whole point of the relay). // - // google_only bootstrap mode: no fronter exists, so passthrough as - // direct TCP. Same contract as `dispatch_tunnel` honors for CONNECT - // in google_only — anything not on the Google edge is forwarded - // direct (or via `upstream_socks5`) so the user's browser still - // works while they finish setting up Apps Script. Issue: typing a - // bare `http://example.com` URL used to return a 502 here even + // direct mode: no fronter exists, so passthrough as raw TCP. + // Same contract as `dispatch_tunnel` honors for CONNECT in + // direct mode — anything not on the Google edge / not in a + // configured fronting_group is forwarded direct (or via + // `upstream_socks5`) so the user's browser still works while + // they finish setting up Apps Script. Issue: typing a bare + // `http://example.com` URL used to return a 502 here even // though `https://example.com` (CONNECT) worked fine. match fronter { Some(f) => do_plain_http(sock, &head, &leftover, f).await, @@ -1480,6 +1621,40 @@ async fn dispatch_tunnel( return Ok(()); } + // 2a. User-configured fronting groups (Vercel, Fastly, etc.). Wins + // over the built-in Google SNI-rewrite suffix list — if a user + // adds e.g. `vercel.com` to a Vercel fronting group, we hit + // Vercel's edge with sni=react.dev rather than trying to resolve + // it through Google's. Port-gated to 443: SNI-rewrite needs a + // real ClientHello and a non-TLS CONNECT to the same hostname + // would just hang. Only HTTPS sites are fronted by these CDNs in + // practice, so the gate has no false negatives we care about. + if port == 443 { + // `Arc::clone` here is refcount-only; we hold it across the + // await below without keeping `rewrite_ctx` borrowed. + let group_match = + match_fronting_group(&host, &rewrite_ctx.fronting_groups).map(Arc::clone); + if let Some(group) = group_match { + tracing::info!( + "dispatch {}:{} -> sni-rewrite tunnel (fronting group '{}', edge {} sni={})", + host, + port, + group.name, + group.ip, + group.sni + ); + return do_sni_rewrite_tunnel_from_tcp( + sock, + &host, + port, + mitm, + rewrite_ctx, + Some(group), + ) + .await; + } + } + // 2. Explicit hosts override or SNI-rewrite suffix: for HTTPS targets, // use the TLS SNI-rewrite tunnel (skipped in full mode above). if should_use_sni_rewrite( @@ -1493,17 +1668,18 @@ async fn dispatch_tunnel( host, port ); - return do_sni_rewrite_tunnel_from_tcp(sock, &host, port, mitm, rewrite_ctx).await; + return do_sni_rewrite_tunnel_from_tcp(sock, &host, port, mitm, rewrite_ctx, None).await; } - // 3. google_only bootstrap: no Apps Script relay exists. Anything that - // isn't SNI-rewrite-matched gets direct TCP passthrough so the user's - // browser still works while they're deploying Code.gs. They'd switch - // to apps_script mode for the real DPI bypass. - if rewrite_ctx.mode == Mode::GoogleOnly { + // 3. direct mode: no Apps Script relay exists. Anything that isn't + // SNI-rewrite-matched (Google edge or a configured fronting_group) + // gets raw TCP passthrough so the user's browser still works while + // they're deploying Code.gs. They'd switch to apps_script mode for + // full DPI bypass. + if rewrite_ctx.mode == Mode::Direct { let via = rewrite_ctx.upstream_socks5.as_deref(); tracing::info!( - "dispatch {}:{} -> raw-tcp ({}) (google_only: no relay)", + "dispatch {}:{} -> raw-tcp ({}) (direct mode: no relay)", host, port, via.unwrap_or("direct") @@ -1969,17 +2145,37 @@ async fn do_sni_rewrite_tunnel_from_tcp( port: u16, mitm: Arc>, rewrite_ctx: Arc, + // When Some, overrides the default Google edge target with a + // user-configured fronting group's (ip, sni). `Arc` so the + // dispatcher hands us a refcount-only clone — the resolved + // group also carries the matcher's normalized domain list which + // we don't need here. None = built-in Google edge path. + group: Option>, ) -> std::io::Result<()> { - let target_ip = hosts_override(&rewrite_ctx.hosts, host) - .map(|s| s.to_string()) - .unwrap_or_else(|| rewrite_ctx.google_ip.clone()); + let (target_ip, outbound_sni, server_name) = match &group { + Some(g) => (g.ip.clone(), g.sni.clone(), g.server_name.clone()), + None => { + let ip = hosts_override(&rewrite_ctx.hosts, host) + .map(|s| s.to_string()) + .unwrap_or_else(|| rewrite_ctx.google_ip.clone()); + let sni = rewrite_ctx.front_domain.clone(); + let sn = match ServerName::try_from(sni.clone()) { + Ok(n) => n, + Err(e) => { + tracing::error!("invalid front_domain '{}': {}", sni, e); + return Ok(()); + } + }; + (ip, sni, sn) + } + }; tracing::info!( "SNI-rewrite tunnel -> {}:{} via {} (outbound SNI={})", host, port, target_ip, - rewrite_ctx.front_domain + outbound_sni ); // Accept browser TLS with a cert we sign for `host`. @@ -2023,13 +2219,6 @@ async fn do_sni_rewrite_tunnel_from_tcp( }; let _ = upstream_tcp.set_nodelay(true); - let server_name = match ServerName::try_from(rewrite_ctx.front_domain.clone()) { - Ok(n) => n, - Err(e) => { - tracing::error!("invalid front_domain '{}': {}", rewrite_ctx.front_domain, e); - return Ok(()); - } - }; let outbound = match rewrite_ctx .tls_connector .connect(server_name, upstream_tcp) @@ -2512,10 +2701,10 @@ async fn do_plain_http( Ok(()) } -/// google_only mode plain-HTTP passthrough. The CONNECT path already -/// falls through to direct TCP for non-Google-edge hosts in google_only; -/// this is the same idea for the `GET http://…` proxy form so a bare -/// `http://example.com` typed in the address bar doesn't 502. +/// `direct` mode plain-HTTP passthrough. The CONNECT path already +/// falls through to raw TCP for hosts outside the SNI-rewrite set in +/// `direct`; this is the same idea for the `GET http://…` proxy form +/// so a bare `http://example.com` typed in the address bar doesn't 502. /// /// We rewrite the absolute-form request URI (`GET http://host/path`) to /// origin form (`GET /path`), strip hop-by-hop headers, force @@ -2542,7 +2731,7 @@ async fn do_plain_http_passthrough( }; tracing::info!( - "dispatch http {}:{} -> raw-tcp ({}) (google_only: no relay)", + "dispatch http {}:{} -> raw-tcp ({}) (direct mode: no relay)", host, port, rewrite_ctx.upstream_socks5.as_deref().unwrap_or("direct"), @@ -3094,4 +3283,92 @@ mod tests { // But substring overlap must still be rejected. assert!(!matches_doh_host("xdoh.acme.test", &extra)); } + + fn fg(name: &str, sni: &str, domains: &[&str]) -> Arc { + Arc::new( + FrontingGroupResolved::from_config(&FrontingGroup { + name: name.into(), + ip: "127.0.0.1".into(), + sni: sni.into(), + domains: domains.iter().map(|s| s.to_string()).collect(), + }) + .expect("test fronting group should resolve"), + ) + } + + #[test] + fn fronting_group_match_exact_and_suffix() { + let groups = vec![fg("vercel", "react.dev", &["vercel.com", "nextjs.org"])]; + // Exact. + assert_eq!( + match_fronting_group("vercel.com", &groups).map(|g| g.name.as_str()), + Some("vercel") + ); + // Suffix. + assert_eq!( + match_fronting_group("app.vercel.com", &groups).map(|g| g.name.as_str()), + Some("vercel") + ); + // Different member. + assert_eq!( + match_fronting_group("docs.nextjs.org", &groups).map(|g| g.name.as_str()), + Some("vercel") + ); + // Non-member. + assert!(match_fronting_group("example.com", &groups).is_none()); + // Substring overlap is NOT a match (xvercel.com isn't *.vercel.com). + assert!(match_fronting_group("xvercel.com", &groups).is_none()); + } + + #[test] + fn fronting_group_match_case_and_trailing_dot() { + let groups = vec![fg("fastly", "www.python.org", &["reddit.com"])]; + assert_eq!( + match_fronting_group("Reddit.COM", &groups).map(|g| g.name.as_str()), + Some("fastly") + ); + assert_eq!( + match_fronting_group("reddit.com.", &groups).map(|g| g.name.as_str()), + Some("fastly") + ); + assert_eq!( + match_fronting_group("WWW.Reddit.com.", &groups).map(|g| g.name.as_str()), + Some("fastly") + ); + } + + #[test] + fn fronting_group_match_first_wins() { + // When a host is in two groups, the earlier group is chosen. + // Lets users put more-specific groups first. + let groups = vec![ + fg("specific", "a.example", &["api.example.com"]), + fg("broad", "b.example", &["example.com"]), + ]; + assert_eq!( + match_fronting_group("api.example.com", &groups).map(|g| g.name.as_str()), + Some("specific") + ); + assert_eq!( + match_fronting_group("example.com", &groups).map(|g| g.name.as_str()), + Some("broad") + ); + } + + #[test] + fn fronting_group_match_empty_list() { + let groups: Vec> = Vec::new(); + assert!(match_fronting_group("vercel.com", &groups).is_none()); + } + + #[test] + fn fronting_group_resolve_rejects_invalid_sni() { + let bad = FrontingGroup { + name: "bad".into(), + ip: "127.0.0.1".into(), + sni: "not a valid hostname".into(), + domains: vec!["x.com".into()], + }; + assert!(FrontingGroupResolved::from_config(&bad).is_err()); + } } diff --git a/src/test_cmd.rs b/src/test_cmd.rs index a9007a8..b87c7fd 100644 --- a/src/test_cmd.rs +++ b/src/test_cmd.rs @@ -20,10 +20,10 @@ use crate::domain_fronter::DomainFronter; const TEST_URL: &str = "https://api.ipify.org/?format=json"; pub async fn run(config: &Config) -> bool { - if matches!(config.mode_kind(), Ok(Mode::GoogleOnly)) { + if matches!(config.mode_kind(), Ok(Mode::Direct)) { let msg = "`mhrv-rs test` probes the Apps Script relay, which isn't \ - wired up in google_only mode. Run `mhrv-rs test-sni` to \ - check the direct SNI-rewrite tunnel instead."; + wired up in direct mode. Run `mhrv-rs test-sni` to check \ + the SNI-rewrite tunnel instead."; println!("{}", msg); tracing::error!("{}", msg); return false; @@ -35,7 +35,7 @@ pub async fn run(config: &Config) -> bool { // back as the Apps Script datacenter — confusing because it // disagreed with what whatismyipaddress.com showed in the // browser (which DOES go through the tunnel). Rather than fake - // a passing test, refuse the same way we do for google_only and + // a passing test, refuse the same way we do for direct mode and // tell the user how to actually verify Full mode. let msg = "`mhrv-rs test` is wired only for the apps_script relay \ path. In full mode the data plane is the pipelined \ diff --git a/tunnel-node/README.fa.md b/tunnel-node/README.fa.md index 47f891c..007f717 100644 --- a/tunnel-node/README.fa.md +++ b/tunnel-node/README.fa.md @@ -177,7 +177,7 @@ TUNNEL_AUTH_KEY=your-secret PORT=8080 ./target/release/tunnel-node برای **حالت `apps_script`** (browsing فقط HTTPS): **خیر، نیاز به VPS نیست** — فقط نیاز به Apps Script setup روی Google account داری. -برای **حالت `google_only`** (فقط Google services مثل Search/Gmail/YouTube ساده): **نه VPS لازمه نه Apps Script** — بوت‌استرپ ساده. +برای **حالت `direct`** (Google services مثل Search/Gmail/YouTube، به علاوهٔ هر `fronting_groups` که تنظیم کرده باشید): **نه VPS لازمه نه Apps Script** — فقط تونل بازنویسی `SNI`. (نام قبلی این حالت `google_only` بود.) ### چه VPS‌ای پیشنهاد می‌شه؟ From f32d343260158916d74d6cb387fccb8dd9c233d3 Mon Sep 17 00:00:00 2001 From: dazzling-no-more <278675588+dazzling-no-more@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:28:49 +0400 Subject: [PATCH 2/2] docs(fronting-groups): add netlify (CloudFront) example --- config.fronting-groups.example.json | 9 +++++++++ docs/fronting-groups.md | 6 ++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/config.fronting-groups.example.json b/config.fronting-groups.example.json index 11e14f8..27b2cdc 100644 --- a/config.fronting-groups.example.json +++ b/config.fronting-groups.example.json @@ -37,6 +37,15 @@ "pypi.org", "fastly.com" ] + }, + { + "name": "netlify", + "ip": "35.157.26.135", + "sni": "letsencrypt.org", + "domains": [ + "netlify.app", + "netlify.com" + ] } ] } diff --git a/docs/fronting-groups.md b/docs/fronting-groups.md index 772ee5a..ac57c23 100644 --- a/docs/fronting-groups.md +++ b/docs/fronting-groups.md @@ -13,7 +13,8 @@ The same trick works on any multi-tenant CDN edge that: 2. dispatches to the right backend by inner HTTP `Host`, and 3. presents a TLS cert whose name matches the SNI you choose. -Vercel and Fastly fit the bill. Pick a benign-looking domain hosted on +Vercel, Fastly, and AWS CloudFront (which is what Netlify-hosted sites +sit behind) all fit the bill. Pick a benign-looking domain hosted on the same edge, use it as the SNI, and you can route many other domains on that edge through the same tunnel without burning Apps Script quota. @@ -51,7 +52,8 @@ the recipe is: 1. Pick the target edge (Vercel, Fastly, …). 2. Find a neutral, never-blocked domain hosted there. Vercel: `react.dev`, - `nextjs.org`. Fastly: `www.python.org`, `pypi.org`. + `nextjs.org`. Fastly: `www.python.org`, `pypi.org`. AWS CloudFront + (where Netlify lives): `letsencrypt.org`, `aws.amazon.com`. 3. Resolve that domain (`dig +short react.dev A`) — pick one IP, drop it in `ip`. 4. List the domains you actually want to reach via this edge in