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..27b2cdc --- /dev/null +++ b/config.fronting-groups.example.json @@ -0,0 +1,51 @@ +{ + "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" + ] + }, + { + "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 new file mode 100644 index 0000000..ac57c23 --- /dev/null +++ b/docs/fronting-groups.md @@ -0,0 +1,143 @@ +# 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, 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. + +## 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`. 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 + `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‌ای پیشنهاد می‌شه؟