feat: multi-edge fronting_groups + rename google_only to direct (#488)

Generalizes the Google-edge SNI-rewrite trick to any multi-tenant CDN edge (Vercel, Fastly, …). By @dazzling-no-more, with credit to @patterniha for the original technique (MITM-DomainFronting).

New `fronting_groups: [{name, ip, sni, domains}]` config field — matched hosts get MITM-decrypted at the local CA and re-encrypted upstream against `ip` with `sni` as the TLS SNI. Works alongside the built-in Google fronting and `passthrough_hosts`.

Rename: `mode = "google_only"` → `mode = "direct"`. Old name kept as deprecated alias on parse — no existing config / saved settings break. UI dropdown updated, on-disk file migrates on next Save.

Review fixes folded in: SNI validated via rustls at config-load gate, Vec<Arc<>> refcount instead of clone-on-match, byte-level dot-anchored matcher (no per-match format!()), startup warnings for inert combos.

Working example at config.fronting-groups.example.json. Full doc at docs/fronting-groups.md including precedence rules + the cross-tenant Host-header leak warning.

Test plan: cargo build --release clean, cargo test --lib 169/169 passing (+8 new: dispatch matching, config validation, alias back-compat).

Per author's recommendation, this lands as the v1.9.0 headline — new top-level config field + public mode-string rename are minor-bump territory. xmux moves to v1.10.0.
This commit is contained in:
Shin (Former Aleph)
2026-04-29 19:27:17 +03:00
committed by GitHub
17 changed files with 811 additions and 119 deletions
+6 -6
View File
@@ -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? #### 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. 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`. 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. 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. 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.
@@ -501,15 +501,15 @@ Donations cover hosting, self-hosted CI runner costs, and continued maintenance.
#### به `script.google.com` هم دسترسی ندارید؟ #### به `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` تنظیم کنید ۳. برنامه را اجرا کنید و `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` را کپی کنید ۵. حالا مرحلهٔ ۱ را در مرورگر انجام دهید (اتصال به `script.google.com` با `SNI` فرونت می‌شود). `Code.gs` را مستقر کنید و `Deployment ID` را کپی کنید
+2 -2
View File
@@ -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).** **1. Set up the relay in your Google account (one-time).**
Go to <https://script.google.com>, 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**. Go to <https://script.google.com>, 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.** **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. 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
**۱. ساخت ریله در حساب گوگل (فقط یک بار).** **۱. ساخت ریله در حساب گوگل (فقط یک بار).**
به <https://script.google.com> بروید، وارد حساب گوگل شوید و روی **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** شماست. به <https://script.google.com> بروید، وارد حساب گوگل شوید و روی **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.** **۲. نصب و اجرای mhrv-rs.**
بستهٔ مخصوص سیستم خودتان را از [بخش Releases](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases/latest) دانلود کنید و از حالت فشرده در بیاورید. بستهٔ مخصوص سیستم خودتان را از [بخش Releases](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases/latest) دانلود کنید و از حالت فشرده در بیاورید.
@@ -64,14 +64,18 @@ enum class UiLang { AUTO, FA, EN }
* *
* - [APPS_SCRIPT] (default) — full DPI bypass through the user's deployed * - [APPS_SCRIPT] (default) — full DPI bypass through the user's deployed
* Apps Script relay. Requires a Deployment ID + Auth key. * Apps Script relay. Requires a Deployment ID + Auth key.
* - [GOOGLE_ONLY] — bootstrap mode. Only the SNI-rewrite tunnel to the * - [DIRECT] — no Apps Script relay. Only the SNI-rewrite tunnel is
* Google edge is active, so the user can reach `script.google.com` to * active: Google edge by default, plus any user-configured
* deploy Code.gs in the first place. No Deployment ID / Auth key needed. * `fronting_groups` (Vercel, Fastly, …). Useful as a bootstrap to
* Non-Google traffic goes direct (no relay). * 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 * - [FULL] — full tunnel mode. ALL traffic is tunneled end-to-end through
* Apps Script + a remote tunnel node. No certificate installation needed. * 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( data class MhrvConfig(
val mode: Mode = Mode.APPS_SCRIPT, val mode: Mode = Mode.APPS_SCRIPT,
@@ -177,14 +181,14 @@ data class MhrvConfig(
// "missing field `mode`" and startProxy silently returns 0. // "missing field `mode`" and startProxy silently returns 0.
put("mode", when (mode) { put("mode", when (mode) {
Mode.APPS_SCRIPT -> "apps_script" Mode.APPS_SCRIPT -> "apps_script"
Mode.GOOGLE_ONLY -> "google_only" Mode.DIRECT -> "direct"
Mode.FULL -> "full" Mode.FULL -> "full"
}) })
put("listen_host", listenHost) put("listen_host", listenHost)
put("listen_port", listenPort) put("listen_port", listenPort)
socks5Port?.let { put("socks5_port", it) } 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 // still persist whatever the user typed so flipping back to
// apps_script mode doesn't wipe their settings. // apps_script mode doesn't wipe their settings.
put("script_ids", JSONArray().apply { ids.forEach { put(it) } }) put("script_ids", JSONArray().apply { ids.forEach { put(it) } })
@@ -286,7 +290,7 @@ object ConfigStore {
// Always include essential fields. // Always include essential fields.
obj.put("mode", when (cfg.mode) { obj.put("mode", when (cfg.mode) {
Mode.APPS_SCRIPT -> "apps_script" Mode.APPS_SCRIPT -> "apps_script"
Mode.GOOGLE_ONLY -> "google_only" Mode.DIRECT -> "direct"
Mode.FULL -> "full" Mode.FULL -> "full"
}) })
val ids = cfg.appsScriptUrls.mapNotNull { url -> val ids = cfg.appsScriptUrls.mapNotNull { url ->
@@ -391,7 +395,10 @@ object ConfigStore {
return MhrvConfig( return MhrvConfig(
mode = when (obj.optString("mode", "apps_script")) { 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 "full" -> Mode.FULL
else -> Mode.APPS_SCRIPT else -> Mode.APPS_SCRIPT
}, },
@@ -104,10 +104,10 @@ class MhrvVpnService : VpnService() {
startForeground(NOTIF_ID, buildNotif(cfg.listenPort, notifSocks5Port)) startForeground(NOTIF_ID, buildNotif(cfg.listenPort, notifSocks5Port))
// Deployment ID + auth key are required for apps_script and full // Deployment ID + auth key are required for apps_script and full
// modes — both talk to Apps Script. Only google_only (bootstrap) // modes — both talk to Apps Script. Only `direct` mode runs
// runs without them. Closes #73 regression where google_only // without them. Closes #73 regression where direct-mode users
// users hit this branch and crashed on startForeground timeout. // hit this branch and crashed on startForeground timeout.
val needsCreds = cfg.mode != Mode.GOOGLE_ONLY val needsCreds = cfg.mode != Mode.DIRECT
if (needsCreds && (!cfg.hasDeploymentId || cfg.authKey.isBlank())) { if (needsCreds && (!cfg.hasDeploymentId || cfg.authKey.isBlank())) {
Log.e(TAG, "Config is incomplete — deployment ID + auth key required for ${cfg.mode}") Log.e(TAG, "Config is incomplete — deployment ID + auth key required for ${cfg.mode}")
try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (_: Throwable) {} try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (_: Throwable) {}
@@ -83,7 +83,7 @@ object Native {
* Live traffic/usage counters for a running proxy handle. Returns a * Live traffic/usage counters for a running proxy handle. Returns a
* JSON blob with the StatsSnapshot fields — or an empty string if the * 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 * 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): * Schema (all integer fields unless noted):
* relay_calls, relay_failures, coalesced, bytes_relayed, * relay_calls, relay_failures, coalesced, bytes_relayed,
@@ -264,7 +264,7 @@ private fun ImportConfirmDialog(
val preview = ids.take(3).joinToString("\n") { " ${it.take(20)}" } val preview = ids.take(3).joinToString("\n") { " ${it.take(20)}" }
val modeLabel = when (cfg.mode) { val modeLabel = when (cfg.mode) {
com.therealaleph.mhrv.Mode.APPS_SCRIPT -> "apps_script" 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" com.therealaleph.mhrv.Mode.FULL -> "full"
} }
@@ -316,7 +316,7 @@ fun HomeScreen(
} }
}, },
enabled = (isVpnRunning || enabled = (isVpnRunning ||
cfg.mode == Mode.GOOGLE_ONLY || cfg.mode == Mode.DIRECT ||
(cfg.hasDeploymentId && cfg.authKey.isNotBlank())) && !transitioning, (cfg.hasDeploymentId && cfg.authKey.isNotBlank())) && !transitioning,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = if (isVpnRunning) ErrRed else OkGreen, 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) @OptIn(ExperimentalMaterial3Api::class)
@@ -847,11 +847,11 @@ private fun ModeDropdown(
onChange: (Mode) -> Unit, onChange: (Mode) -> Unit,
) { ) {
val labelApps = "Apps Script (MITM)" val labelApps = "Apps Script (MITM)"
val labelGoogle = "Google-only (bootstrap)" val labelDirect = "Direct (no relay)"
val labelFull = "Full tunnel (no cert)" val labelFull = "Full tunnel (no cert)"
val currentLabel = when (mode) { val currentLabel = when (mode) {
Mode.APPS_SCRIPT -> labelApps Mode.APPS_SCRIPT -> labelApps
Mode.GOOGLE_ONLY -> labelGoogle Mode.DIRECT -> labelDirect
Mode.FULL -> labelFull Mode.FULL -> labelFull
} }
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
@@ -878,8 +878,8 @@ private fun ModeDropdown(
onClick = { onChange(Mode.APPS_SCRIPT); expanded = false }, onClick = { onChange(Mode.APPS_SCRIPT); expanded = false },
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text(labelGoogle) }, text = { Text(labelDirect) },
onClick = { onChange(Mode.GOOGLE_ONLY); expanded = false }, onClick = { onChange(Mode.DIRECT); expanded = false },
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text(labelFull) }, text = { Text(labelFull) },
@@ -891,8 +891,8 @@ private fun ModeDropdown(
val help = when (mode) { val help = when (mode) {
Mode.APPS_SCRIPT -> Mode.APPS_SCRIPT ->
"Full DPI bypass through your deployed Apps Script relay." "Full DPI bypass through your deployed Apps Script relay."
Mode.GOOGLE_ONLY -> Mode.DIRECT ->
"Bootstrap: reach *.google.com directly so you can open script.google.com and deploy Code.gs. Non-Google traffic goes 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 -> Mode.FULL ->
"All traffic tunneled end-to-end through Apps Script + remote tunnel node. No certificate needed." "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. * this device relayed.
* *
* Hidden when the handle is 0 (proxy not running) or the JSON comes back * 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). * have nothing to report).
*/ */
@Composable @Composable
@@ -1,5 +1,5 @@
{ {
"mode": "google_only", "mode": "direct",
"google_ip": "216.239.38.120", "google_ip": "216.239.38.120",
"front_domain": "www.google.com", "front_domain": "www.google.com",
"listen_host": "127.0.0.1", "listen_host": "127.0.0.1",
+51
View File
@@ -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"
]
}
]
}
+143
View File
@@ -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:
<your-domain>` 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.
+2 -2
View File
@@ -42,7 +42,7 @@ struct Running {
rt: Option<Runtime>, rt: Option<Runtime>,
/// Keep an Arc to the DomainFronter so `statsJson(handle)` can read the /// Keep an Arc to the DomainFronter so `statsJson(handle)` can read the
/// live stats without going through the async server. `None` for /// 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<Arc<crate::domain_fronter::DomainFronter>>, fronter: Option<Arc<crate::domain_fronter::DomainFronter>>,
} }
@@ -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 /// `Native.statsJson(long handle)` -> String. Returns a JSON blob with the
/// live `StatsSnapshot` for a running proxy, or an empty string if 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 /// Cheap — just reads a handful of atomics. The Kotlin UI polls this on a
/// timer to render the "Usage today (estimated)" card. /// timer to render the "Usage today (estimated)" card.
+48 -20
View File
@@ -10,7 +10,7 @@ use tokio::sync::Mutex as AsyncMutex;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use mhrv_rs::cert_installer::{install_ca, reconcile_sudo_environment, remove_ca}; 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::data_dir;
use mhrv_rs::domain_fronter::{DomainFronter, DEFAULT_GOOGLE_SNI_POOL}; use mhrv_rs::domain_fronter::{DomainFronter, DEFAULT_GOOGLE_SNI_POOL};
use mhrv_rs::mitm::{MitmCertManager, CA_CERT_FILE}; use mhrv_rs::mitm::{MitmCertManager, CA_CERT_FILE};
@@ -216,9 +216,11 @@ struct App {
#[derive(Clone)] #[derive(Clone)]
struct FormState { struct FormState {
/// `"apps_script"` (default) or `"google_only"`. Controls whether the /// `"apps_script"` (default), `"direct"`, or `"full"`. Controls
/// Apps Script relay is wired up at all. In `google_only`, the form /// whether the Apps Script relay is wired up at all. In `direct`,
/// tolerates an empty script_id / auth_key. /// 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, mode: String,
script_id: String, script_id: String,
auth_key: String, auth_key: String,
@@ -265,6 +267,11 @@ struct FormState {
/// User-supplied DoH hostnames added to the built-in default list, /// User-supplied DoH hostnames added to the built-in default list,
/// round-tripped from config.json. See config.rs `bypass_doh_hosts`. /// round-tripped from config.json. See config.rs `bypass_doh_hosts`.
bypass_doh_hosts: Vec<String>, bypass_doh_hosts: Vec<String>,
/// 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<FrontingGroup>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -322,8 +329,18 @@ fn load_form() -> (FormState, Option<String>) {
}, },
}; };
let sni_pool = sni_pool_for_form(c.sni_hosts.as_deref(), &c.front_domain); 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 { FormState {
mode: c.mode.clone(), mode: mode_normalized,
script_id: sid, script_id: sid,
auth_key: c.auth_key, auth_key: c.auth_key,
google_ip: c.google_ip, google_ip: c.google_ip,
@@ -351,6 +368,7 @@ fn load_form() -> (FormState, Option<String>) {
disable_padding: c.disable_padding, disable_padding: c.disable_padding,
tunnel_doh: c.tunnel_doh, tunnel_doh: c.tunnel_doh,
bypass_doh_hosts: c.bypass_doh_hosts.clone(), bypass_doh_hosts: c.bypass_doh_hosts.clone(),
fronting_groups: c.fronting_groups.clone(),
} }
} else { } else {
FormState { FormState {
@@ -382,6 +400,7 @@ fn load_form() -> (FormState, Option<String>) {
disable_padding: false, disable_padding: false,
tunnel_doh: false, tunnel_doh: false,
bypass_doh_hosts: Vec::new(), bypass_doh_hosts: Vec::new(),
fronting_groups: Vec::new(),
} }
}; };
(form, load_err) (form, load_err)
@@ -433,8 +452,10 @@ fn sni_pool_for_form(user: Option<&[String]>, front_domain: &str) -> Vec<SniRow>
impl FormState { impl FormState {
fn to_config(&self) -> Result<Config, String> { fn to_config(&self) -> Result<Config, String> {
let is_google_only = self.mode == "google_only"; // `direct` and the legacy `google_only` alias both run without
if !is_google_only { // 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() { if self.script_id.trim().is_empty() {
return Err("Apps Script ID is required".into()); return Err("Apps Script ID is required".into());
} }
@@ -536,6 +557,9 @@ impl FormState {
// added) so save doesn't drop them. // added) so save doesn't drop them.
tunnel_doh: self.tunnel_doh, tunnel_doh: self.tunnel_doh,
bypass_doh_hosts: self.bypass_doh_hosts.clone(), 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 // PR #448 (Android): adaptive coalesce window. Desktop UI
// doesn't expose sliders for these yet (Android does), so // doesn't expose sliders for these yet (Android does), so
// we pass 0 to keep the compiled defaults (40ms step, // we pass 0 to keep the compiled defaults (40ms step,
@@ -600,6 +624,8 @@ struct ConfigWire<'a> {
tunnel_doh: bool, tunnel_doh: bool,
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
bypass_doh_hosts: &'a Vec<String>, bypass_doh_hosts: &'a Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
fronting_groups: &'a Vec<FrontingGroup>,
} }
fn is_false(b: &bool) -> bool { 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, google_ip_validation: c.google_ip_validation,
tunnel_doh: c.tunnel_doh, tunnel_doh: c.tunnel_doh,
bypass_doh_hosts: &c.bypass_doh_hosts, bypass_doh_hosts: &c.bypass_doh_hosts,
fronting_groups: &c.fronting_groups,
} }
} }
} }
@@ -787,19 +814,20 @@ impl eframe::App for App {
// ── Section: Mode ───────────────────────────────────────────── // ── Section: Mode ─────────────────────────────────────────────
// Surfacing the mode at the top of the form because it changes // Surfacing the mode at the top of the form because it changes
// which of the sections below are actually used. google_only is // which of the sections below are actually used. `direct` runs
// a bootstrap mode for users who don't yet have internet access // without the Apps Script relay (Google edge + any configured
// to deploy Code.gs — once deployed, they switch back to // fronting_groups via the SNI-rewrite tunnel only) — useful as
// apps_script. // 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| { section(ui, "Mode", |ui| {
form_row(ui, "Mode", Some( form_row(ui, "Mode", Some(
"apps_script: DPI bypass via Apps Script relay (needs cert).\n\ "apps_script: DPI bypass via Apps Script relay (needs cert).\n\
full: tunnel ALL traffic through Apps Script + tunnel node (no cert needed).\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| { ), |ui| {
egui::ComboBox::from_id_source("mode") egui::ComboBox::from_id_source("mode")
.selected_text(match self.form.mode.as_str() { .selected_text(match self.form.mode.as_str() {
"google_only" => "Google-only (bootstrap)", "direct" | "google_only" => "Direct (no relay)",
"full" => "Full tunnel (no cert)", "full" => "Full tunnel (no cert)",
_ => "Apps Script (MITM)", _ => "Apps Script (MITM)",
}) })
@@ -816,16 +844,16 @@ impl eframe::App for App {
); );
ui.selectable_value( ui.selectable_value(
&mut self.form.mode, &mut self.form.mode,
"google_only".into(), "direct".into(),
"Google-only (bootstrap)", "Direct (no relay)",
); );
}); });
}); });
if self.form.mode == "google_only" { if self.form.mode == "direct" || self.form.mode == "google_only" {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.add_space(120.0 + 8.0); ui.add_space(120.0 + 8.0);
ui.small(egui::RichText::new( 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)); .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: Apps Script relay ────────────────────────────────
section(ui, "Apps Script relay", |ui| { 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( form_row(ui, "Deployment IDs", Some(
"One deployment ID per line. Proxy round-robins between them and sidelines \ "One deployment ID per line. Proxy round-robins between them and sidelines \
any ID that hits its daily quota for 10 minutes before retrying." any ID that hits its daily quota for 10 minutes before retrying."
@@ -1916,7 +1944,7 @@ fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {
return; 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. // status panel's relay stats simply show no data in that case.
*fronter_slot2.lock().await = server.fronter(); *fronter_slot2.lock().await = server.fronter();
{ {
+202 -17
View File
@@ -1,4 +1,5 @@
use serde::Deserialize; use rustls::pki_types::ServerName;
use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path; use std::path::Path;
@@ -14,14 +15,19 @@ pub enum ConfigError {
/// Operating mode. `AppsScript` is the full client — MITMs TLS locally and /// Operating mode. `AppsScript` is the full client — MITMs TLS locally and
/// relays HTTP/HTTPS through a user-deployed Apps Script endpoint. /// relays HTTP/HTTPS through a user-deployed Apps Script endpoint.
/// `GoogleOnly` is a bootstrap: no relay, no Apps Script config needed, /// `Direct` runs without any Apps Script relay: only the SNI-rewrite tunnel
/// only the SNI-rewrite tunnel to the Google edge is active. Intended for /// is active, targeting the Google edge by default plus any user-configured
/// users who need to reach `script.google.com` to deploy `Code.gs` in the /// `fronting_groups`. Originally introduced as a `script.google.com`
/// first place. /// 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode { pub enum Mode {
AppsScript, 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, Full,
} }
@@ -29,7 +35,7 @@ impl Mode {
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
Mode::AppsScript => "apps_script", Mode::AppsScript => "apps_script",
Mode::GoogleOnly => "google_only", Mode::Direct => "direct",
Mode::Full => "full", Mode::Full => "full",
} }
} }
@@ -252,6 +258,65 @@ pub struct Config {
/// startup if both are set together. /// startup if both are set together.
#[serde(default)] #[serde(default)]
pub bypass_doh_hosts: Vec<String>, pub bypass_doh_hosts: Vec<String>,
/// 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<FrontingGroup>,
}
/// 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<String>,
} }
fn default_fetch_ips_from_api() -> bool { false } fn default_fetch_ips_from_api() -> bool { false }
@@ -321,16 +386,62 @@ impl Config {
self.listen_port, self.listen_host 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(()) Ok(())
} }
pub fn mode_kind(&self) -> Result<Mode, ConfigError> { pub fn mode_kind(&self) -> Result<Mode, ConfigError> {
match self.mode.as_str() { match self.mode.as_str() {
"apps_script" => Ok(Mode::AppsScript), "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), "full" => Ok(Mode::Full),
other => Err(ConfigError::Invalid(format!( other => Err(ConfigError::Invalid(format!(
"unknown mode '{}' (expected 'apps_script', 'google_only', or 'full')", "unknown mode '{}' (expected 'apps_script', 'direct', or 'full')",
other other
))), ))),
} }
@@ -397,24 +508,36 @@ mod tests {
} }
#[test] #[test]
fn parses_google_only_without_script_id() { fn parses_direct_without_script_id() {
// Bootstrap mode: no script_id, no auth_key — both are only meaningful // Direct mode: no script_id, no auth_key — both are only meaningful
// once the Apps Script relay exists. // 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#"{ let s = r#"{
"mode": "google_only" "mode": "google_only"
}"#; }"#;
let cfg: Config = serde_json::from_str(s).unwrap(); let cfg: Config = serde_json::from_str(s).unwrap();
cfg.validate().expect("google_only must validate without script_id / auth_key"); cfg.validate().expect("google_only alias must still validate");
assert_eq!(cfg.mode_kind().unwrap(), Mode::GoogleOnly); assert_eq!(cfg.mode_kind().unwrap(), Mode::Direct);
} }
#[test] #[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, // UI round-trip: user saved config in apps_script with the placeholder,
// then switched mode to google_only. The placeholder should not block // then switched mode to direct. The placeholder should not block
// validation in the bootstrap mode. // validation in the no-relay mode.
let s = r#"{ let s = r#"{
"mode": "google_only", "mode": "direct",
"script_id": "YOUR_APPS_SCRIPT_DEPLOYMENT_ID" "script_id": "YOUR_APPS_SCRIPT_DEPLOYMENT_ID"
}"#; }"#;
let cfg: Config = serde_json::from_str(s).unwrap(); let cfg: Config = serde_json::from_str(s).unwrap();
@@ -466,6 +589,68 @@ mod tests {
assert!(cfg.validate().is_err()); 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] #[test]
fn rejects_same_http_and_socks5_port() { fn rejects_same_http_and_socks5_port() {
let s = r#"{ let s = r#"{
+5 -4
View File
@@ -288,11 +288,12 @@ async fn main() -> ExitCode {
tracing::info!("Script ID: {}", sids[0]); tracing::info!("Script ID: {}", sids[0]);
} }
} }
mhrv_rs::config::Mode::GoogleOnly => { mhrv_rs::config::Mode::Direct => {
tracing::warn!( tracing::warn!(
"google_only bootstrap: direct SNI-rewrite tunnel to {} only. \ "direct mode: SNI-rewrite tunnel only (Google edge {} + any \
Open https://script.google.com in your browser (proxy set to \ configured fronting_groups). Open https://script.google.com \
{}:{}), deploy Code.gs, then switch to apps_script mode.", in your browser (proxy set to {}:{}), deploy Code.gs, then \
switch to apps_script mode for full DPI bypass.",
config.google_ip, config.google_ip,
config.listen_host, config.listen_host,
config.listen_port config.listen_port
+313 -36
View File
@@ -15,7 +15,7 @@ use tokio_rustls::rustls::server::Acceptor;
use tokio_rustls::rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme}; use tokio_rustls::rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
use tokio_rustls::{LazyConfigAcceptor, TlsAcceptor, TlsConnector}; use tokio_rustls::{LazyConfigAcceptor, TlsAcceptor, TlsConnector};
use crate::config::{Config, Mode}; use crate::config::{Config, FrontingGroup, Mode};
use crate::domain_fronter::DomainFronter; use crate::domain_fronter::DomainFronter;
use crate::mitm::MitmCertManager; use crate::mitm::MitmCertManager;
use crate::tunnel_client::{decode_udp_packets, TunnelMux}; use crate::tunnel_client::{decode_udp_packets, TunnelMux};
@@ -210,8 +210,9 @@ pub struct ProxyServer {
host: String, host: String,
port: u16, port: u16,
socks5_port: u16, socks5_port: u16,
/// `None` in `google_only` (bootstrap) mode: no Apps Script relay is /// `None` in `direct` mode: no Apps Script relay is wired up,
/// wired up, only the SNI-rewrite tunnel path is live. /// only the SNI-rewrite tunnel path (Google edge + any configured
/// `fronting_groups`) is live.
fronter: Option<Arc<DomainFronter>>, fronter: Option<Arc<DomainFronter>>,
mitm: Arc<Mutex<MitmCertManager>>, mitm: Arc<Mutex<MitmCertManager>>,
rewrite_ctx: Arc<RewriteCtx>, rewrite_ctx: Arc<RewriteCtx>,
@@ -247,6 +248,14 @@ pub struct RewriteCtx {
/// User-supplied DoH hostnames added to the built-in default list. /// User-supplied DoH hostnames added to the built-in default list.
/// Same matching semantics as `passthrough_hosts`. /// Same matching semantics as `passthrough_hosts`.
pub bypass_doh_hosts: Vec<String>, pub bypass_doh_hosts: Vec<String>,
/// 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<String>` of normalized
/// domains used only for matching). Empty = feature off (only
/// the built-in Google edge SNI-rewrite is active).
pub fronting_groups: Vec<Arc<FrontingGroupResolved>>,
} }
/// True if `host` matches a known DoH endpoint — either the built-in /// 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)) 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<String>,
}
impl FrontingGroupResolved {
fn from_config(g: &FrontingGroup) -> Result<Self, String> {
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<FrontingGroupResolved>],
) -> Option<&'a Arc<FrontingGroupResolved>> {
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. /// True if `host` matches any entry in the user's passthrough list.
/// Match is case-insensitive. Entries match either exactly, or as a /// Match is case-insensitive. Entries match either exactly, or as a
/// suffix if they start with "." (e.g. ".internal.example" matches /// suffix if they start with "." (e.g. ".internal.example" matches
@@ -313,16 +404,16 @@ impl ProxyServer {
.mode_kind() .mode_kind()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?; .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 // 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 { let fronter = match mode {
Mode::AppsScript | Mode::Full => { Mode::AppsScript | Mode::Full => {
let f = DomainFronter::new(config) let f = DomainFronter::new(config)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("{e}")))?; .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("{e}")))?;
Some(Arc::new(f)) Some(Arc::new(f))
} }
Mode::GoogleOnly => None, Mode::Direct => None,
}; };
let tls_config = if config.verify_ssl { 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<Arc<FrontingGroupResolved>> =
Vec::with_capacity(config.fronting_groups.len());
let mut seen_names: std::collections::HashSet<String> = 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 { let rewrite_ctx = Arc::new(RewriteCtx {
google_ip: config.google_ip.clone(), google_ip: config.google_ip.clone(),
front_domain: config.front_domain.clone(), front_domain: config.front_domain.clone(),
@@ -365,6 +504,7 @@ impl ProxyServer {
block_quic: config.block_quic, block_quic: config.block_quic,
bypass_doh: !config.tunnel_doh, bypass_doh: !config.tunnel_doh,
bypass_doh_hosts: config.bypass_doh_hosts.clone(), bypass_doh_hosts: config.bypass_doh_hosts.clone(),
fronting_groups,
}); });
let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1); 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 // Pre-warm the outbound connection pool so the user's first request
// doesn't pay a fresh TLS handshake to Google edge. Best-effort; // doesn't pay a fresh TLS handshake to Google edge. Best-effort;
// failures are logged and ignored. Skipped in `google_only` — there // failures are logged and ignored. Skipped in `direct` mode —
// is no fronter to warm. // there is no fronter to warm.
// //
// Sized to roughly match a browser's parallel-connection burst at // Sized to roughly match a browser's parallel-connection burst at
// startup. The previous fixed `3` was fine for a single deployment // 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 // goes cold after ~5min idle and costs 1-3s to wake. A periodic
// HEAD ping prevents the cold-start lag on the first request // HEAD ping prevents the cold-start lag on the first request
// after a quiet pause (most visible as YouTube player stalls). // 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. // there's no fronter to ping.
// //
// The handle is captured (not fire-and-forget) so the shutdown // 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 // apps_script mode: relay through the Apps Script fronter (which
// is the whole point of the relay). // is the whole point of the relay).
// //
// google_only bootstrap mode: no fronter exists, so passthrough as // direct mode: no fronter exists, so passthrough as raw TCP.
// direct TCP. Same contract as `dispatch_tunnel` honors for CONNECT // Same contract as `dispatch_tunnel` honors for CONNECT in
// in google_only — anything not on the Google edge is forwarded // direct mode — anything not on the Google edge / not in a
// direct (or via `upstream_socks5`) so the user's browser still // configured fronting_group is forwarded direct (or via
// works while they finish setting up Apps Script. Issue: typing a // `upstream_socks5`) so the user's browser still works while
// bare `http://example.com` URL used to return a 502 here even // 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. // though `https://example.com` (CONNECT) worked fine.
match fronter { match fronter {
Some(f) => do_plain_http(sock, &head, &leftover, f).await, Some(f) => do_plain_http(sock, &head, &leftover, f).await,
@@ -1480,6 +1621,40 @@ async fn dispatch_tunnel(
return Ok(()); 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, // 2. Explicit hosts override or SNI-rewrite suffix: for HTTPS targets,
// use the TLS SNI-rewrite tunnel (skipped in full mode above). // use the TLS SNI-rewrite tunnel (skipped in full mode above).
if should_use_sni_rewrite( if should_use_sni_rewrite(
@@ -1493,17 +1668,18 @@ async fn dispatch_tunnel(
host, host,
port 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 // 3. direct mode: no Apps Script relay exists. Anything that isn't
// isn't SNI-rewrite-matched gets direct TCP passthrough so the user's // SNI-rewrite-matched (Google edge or a configured fronting_group)
// browser still works while they're deploying Code.gs. They'd switch // gets raw TCP passthrough so the user's browser still works while
// to apps_script mode for the real DPI bypass. // they're deploying Code.gs. They'd switch to apps_script mode for
if rewrite_ctx.mode == Mode::GoogleOnly { // full DPI bypass.
if rewrite_ctx.mode == Mode::Direct {
let via = rewrite_ctx.upstream_socks5.as_deref(); let via = rewrite_ctx.upstream_socks5.as_deref();
tracing::info!( tracing::info!(
"dispatch {}:{} -> raw-tcp ({}) (google_only: no relay)", "dispatch {}:{} -> raw-tcp ({}) (direct mode: no relay)",
host, host,
port, port,
via.unwrap_or("direct") via.unwrap_or("direct")
@@ -1969,17 +2145,37 @@ async fn do_sni_rewrite_tunnel_from_tcp(
port: u16, port: u16,
mitm: Arc<Mutex<MitmCertManager>>, mitm: Arc<Mutex<MitmCertManager>>,
rewrite_ctx: Arc<RewriteCtx>, rewrite_ctx: Arc<RewriteCtx>,
// 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<Arc<FrontingGroupResolved>>,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
let target_ip = hosts_override(&rewrite_ctx.hosts, host) 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()) .map(|s| s.to_string())
.unwrap_or_else(|| rewrite_ctx.google_ip.clone()); .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!( tracing::info!(
"SNI-rewrite tunnel -> {}:{} via {} (outbound SNI={})", "SNI-rewrite tunnel -> {}:{} via {} (outbound SNI={})",
host, host,
port, port,
target_ip, target_ip,
rewrite_ctx.front_domain outbound_sni
); );
// Accept browser TLS with a cert we sign for `host`. // 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 _ = 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 let outbound = match rewrite_ctx
.tls_connector .tls_connector
.connect(server_name, upstream_tcp) .connect(server_name, upstream_tcp)
@@ -2512,10 +2701,10 @@ async fn do_plain_http(
Ok(()) Ok(())
} }
/// google_only mode plain-HTTP passthrough. The CONNECT path already /// `direct` mode plain-HTTP passthrough. The CONNECT path already
/// falls through to direct TCP for non-Google-edge hosts in google_only; /// falls through to raw TCP for hosts outside the SNI-rewrite set in
/// this is the same idea for the `GET http://…` proxy form so a bare /// `direct`; this is the same idea for the `GET http://…` proxy form
/// `http://example.com` typed in the address bar doesn't 502. /// 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 /// We rewrite the absolute-form request URI (`GET http://host/path`) to
/// origin form (`GET /path`), strip hop-by-hop headers, force /// origin form (`GET /path`), strip hop-by-hop headers, force
@@ -2542,7 +2731,7 @@ async fn do_plain_http_passthrough(
}; };
tracing::info!( tracing::info!(
"dispatch http {}:{} -> raw-tcp ({}) (google_only: no relay)", "dispatch http {}:{} -> raw-tcp ({}) (direct mode: no relay)",
host, host,
port, port,
rewrite_ctx.upstream_socks5.as_deref().unwrap_or("direct"), rewrite_ctx.upstream_socks5.as_deref().unwrap_or("direct"),
@@ -3094,4 +3283,92 @@ mod tests {
// But substring overlap must still be rejected. // But substring overlap must still be rejected.
assert!(!matches_doh_host("xdoh.acme.test", &extra)); assert!(!matches_doh_host("xdoh.acme.test", &extra));
} }
fn fg(name: &str, sni: &str, domains: &[&str]) -> Arc<FrontingGroupResolved> {
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<Arc<FrontingGroupResolved>> = 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());
}
} }
+4 -4
View File
@@ -20,10 +20,10 @@ use crate::domain_fronter::DomainFronter;
const TEST_URL: &str = "https://api.ipify.org/?format=json"; const TEST_URL: &str = "https://api.ipify.org/?format=json";
pub async fn run(config: &Config) -> bool { 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 \ 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 \ wired up in direct mode. Run `mhrv-rs test-sni` to check \
check the direct SNI-rewrite tunnel instead."; the SNI-rewrite tunnel instead.";
println!("{}", msg); println!("{}", msg);
tracing::error!("{}", msg); tracing::error!("{}", msg);
return false; return false;
@@ -35,7 +35,7 @@ pub async fn run(config: &Config) -> bool {
// back as the Apps Script datacenter — confusing because it // back as the Apps Script datacenter — confusing because it
// disagreed with what whatismyipaddress.com showed in the // disagreed with what whatismyipaddress.com showed in the
// browser (which DOES go through the tunnel). Rather than fake // 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. // tell the user how to actually verify Full mode.
let msg = "`mhrv-rs test` is wired only for the apps_script relay \ let msg = "`mhrv-rs test` is wired only for the apps_script relay \
path. In full mode the data plane is the pipelined \ path. In full mode the data plane is the pipelined \
+1 -1
View File
@@ -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 داری. برای **حالت `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‌ای پیشنهاد می‌شه؟ ### چه VPS‌ای پیشنهاد می‌شه؟