mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-19 08:04:39 +03:00
feat: multi-edge fronting_groups + rename google_only to direct
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
@@ -481,15 +481,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
@@ -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",
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"mode": "direct",
|
||||||
|
"google_ip": "216.239.38.120",
|
||||||
|
"front_domain": "www.google.com",
|
||||||
|
"listen_host": "127.0.0.1",
|
||||||
|
"listen_port": 8085,
|
||||||
|
"socks5_port": 8086,
|
||||||
|
"log_level": "info",
|
||||||
|
"verify_ssl": true,
|
||||||
|
"fronting_groups": [
|
||||||
|
{
|
||||||
|
"name": "vercel",
|
||||||
|
"ip": "76.76.21.21",
|
||||||
|
"sni": "react.dev",
|
||||||
|
"domains": [
|
||||||
|
"vercel.com",
|
||||||
|
"vercel.app",
|
||||||
|
"vercel.dev",
|
||||||
|
"vercel.live",
|
||||||
|
"vercel.sh",
|
||||||
|
"nextjs.org",
|
||||||
|
"now.sh",
|
||||||
|
"cursor.com",
|
||||||
|
"ai-sdk.dev"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fastly",
|
||||||
|
"ip": "151.101.1.140",
|
||||||
|
"sni": "www.python.org",
|
||||||
|
"domains": [
|
||||||
|
"reddit.com",
|
||||||
|
"redditstatic.com",
|
||||||
|
"redditmedia.com",
|
||||||
|
"githubassets.com",
|
||||||
|
"githubusercontent.com",
|
||||||
|
"pypi.org",
|
||||||
|
"fastly.com"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Multi-edge fronting groups
|
||||||
|
|
||||||
|
The default mhrv-rs SNI-rewrite path targets Google's edge: TLS goes out
|
||||||
|
with `SNI=www.google.com` to a Google IP, the inner `Host` header (after
|
||||||
|
the local MITM CA terminates the browser's TLS) names the real
|
||||||
|
destination, and Google's frontend routes by `Host`. That's how
|
||||||
|
`www.youtube.com`, `script.google.com`, and friends reach you despite a
|
||||||
|
DPI box that drops anything not SNI'd as `www.google.com`.
|
||||||
|
|
||||||
|
The same trick works on any multi-tenant CDN edge that:
|
||||||
|
|
||||||
|
1. serves multiple tenant domains on the same IP pool, and
|
||||||
|
2. dispatches to the right backend by inner HTTP `Host`, and
|
||||||
|
3. presents a TLS cert whose name matches the SNI you choose.
|
||||||
|
|
||||||
|
Vercel and Fastly fit the bill. Pick a benign-looking domain hosted on
|
||||||
|
the same edge, use it as the SNI, and you can route many other domains
|
||||||
|
on that edge through the same tunnel without burning Apps Script quota.
|
||||||
|
|
||||||
|
## Config shape
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"mode": "direct", // or apps_script / full
|
||||||
|
"fronting_groups": [
|
||||||
|
{
|
||||||
|
"name": "vercel", // free-form, used in logs
|
||||||
|
"ip": "76.76.21.21", // a Vercel edge IP
|
||||||
|
"sni": "react.dev", // a Vercel-hosted domain
|
||||||
|
"domains": [ // hosts to route via this group
|
||||||
|
"vercel.com", "vercel.app",
|
||||||
|
"nextjs.org", "now.sh"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`domains` matches case-insensitively, exact OR dot-anchored suffix —
|
||||||
|
`vercel.com` covers both `vercel.com` and `*.vercel.com`. First group
|
||||||
|
in the list whose member matches wins.
|
||||||
|
|
||||||
|
A working example is shipped at `config.fronting-groups.example.json`.
|
||||||
|
|
||||||
|
## Picking the (ip, sni) pair
|
||||||
|
|
||||||
|
The SNI must be a real, currently-live domain on the same edge. rustls
|
||||||
|
validates the upstream cert against the SNI you send; if the edge
|
||||||
|
returns a cert that doesn't cover that name, the handshake fails. So
|
||||||
|
the recipe is:
|
||||||
|
|
||||||
|
1. Pick the target edge (Vercel, Fastly, …).
|
||||||
|
2. Find a neutral, never-blocked domain hosted there. Vercel: `react.dev`,
|
||||||
|
`nextjs.org`. Fastly: `www.python.org`, `pypi.org`.
|
||||||
|
3. Resolve that domain (`dig +short react.dev A`) — pick one IP, drop
|
||||||
|
it in `ip`.
|
||||||
|
4. List the domains you actually want to reach via this edge in
|
||||||
|
`domains` — **only domains you've verified are hosted on the same
|
||||||
|
edge as `sni`** (see warning below).
|
||||||
|
|
||||||
|
Edge IPs rotate. If a group's `ip` stops working, re-resolve the SNI
|
||||||
|
domain and update the config — IP rotation per-group is on the
|
||||||
|
roadmap but not implemented yet.
|
||||||
|
|
||||||
|
## ⚠️ Cross-tenant leak: don't list domains that aren't on the edge
|
||||||
|
|
||||||
|
If you put a domain in `domains` that is **not** actually hosted on the
|
||||||
|
edge you've configured, two things happen, both bad:
|
||||||
|
|
||||||
|
1. **Privacy leak.** The proxy completes a TLS handshake with the edge
|
||||||
|
(validated against `sni`, which IS on the edge), then sends `Host:
|
||||||
|
<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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
+315
-38
@@ -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 {
|
||||||
.map(|s| s.to_string())
|
Some(g) => (g.ip.clone(), g.sni.clone(), g.server_name.clone()),
|
||||||
.unwrap_or_else(|| rewrite_ctx.google_ip.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!(
|
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
@@ -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 \
|
||||||
|
|||||||
@@ -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ای پیشنهاد میشه؟
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user