mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-18 23:54:48 +03:00
feat: add google_only bootstrap mode (#62)
Second operating mode for users whose network already blocks script.google.com and therefore cannot reach it to deploy Code.gs in the first place. In google_only, the client runs only the SNI-rewrite tunnel to *.google.com and the other Google-edge suffixes that are already allowlisted; non-Google traffic falls through to direct TCP. No script_id or auth_key is required. Once Code.gs is deployed, the user switches to apps_script mode and pastes the Deployment ID. - config: Mode enum, relaxed validation when mode is google_only - proxy_server: mode check in dispatch_tunnel; DomainFronter is now Option<Arc<_>> so it is not constructed in google_only - desktop UI and Android app: Mode dropdown, Apps Script fields disable in google_only - README: bootstrap subsection in English and Persian - config.google-only.example.json - version bump to 1.2.0 + changelog entry Backward compatible with existing apps_script configs.
This commit is contained in:
Generated
+1
-1
@@ -2186,7 +2186,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mhrv-rs"
|
name = "mhrv-rs"
|
||||||
version = "1.1.5"
|
version = "1.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mhrv-rs"
|
name = "mhrv-rs"
|
||||||
version = "1.1.5"
|
version = "1.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
|
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -97,6 +97,19 @@ This part is unchanged from the original project. Follow @masterking32's guide o
|
|||||||
- Who has access: **Anyone**
|
- Who has access: **Anyone**
|
||||||
6. Copy the **Deployment ID** (the long random string in the URL).
|
6. Copy the **Deployment ID** (the long random string in the URL).
|
||||||
|
|
||||||
|
#### 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`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
|
||||||
|
You can also verify reachability before even starting the proxy: `mhrv-rs test-sni` probes `*.google.com` directly and works without any config beyond `google_ip` + `front_domain`.
|
||||||
|
|
||||||
### Step 2 — Download
|
### Step 2 — Download
|
||||||
|
|
||||||
Grab the archive for your platform from the [releases page](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases) and extract it.
|
Grab the archive for your platform from the [releases page](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases) and extract it.
|
||||||
@@ -398,6 +411,24 @@ Original project: <https://github.com/masterking32/MasterHttpRelayVPN> by [@mast
|
|||||||
|
|
||||||
> **نکته:** اگر نمیدانید رمز `AUTH_KEY` چه بگذارید، یک رشتهٔ تصادفی ۱۶ تا ۲۴ کاراکتری بسازید. مهم فقط این است که **دقیقاً همان رشته** را در برنامه هم وارد کنید.
|
> **نکته:** اگر نمیدانید رمز `AUTH_KEY` چه بگذارید، یک رشتهٔ تصادفی ۱۶ تا ۲۴ کاراکتری بسازید. مهم فقط این است که **دقیقاً همان رشته** را در برنامه هم وارد کنید.
|
||||||
|
|
||||||
|
#### به `script.google.com` هم دسترسی ندارید؟
|
||||||
|
|
||||||
|
اگر `ISP` شما از قبل `Apps Script` (یا کل گوگل) را مسدود کرده، برای مرحلهٔ ۱ باید مرورگرتان **اول** به `script.google.com` برسد — قبل از اینکه رلهای داشته باشید. `mhrv-rs` یک حالت بوتاسترپ کوچک دقیقاً برای همین دارد: `google_only`.
|
||||||
|
|
||||||
|
۱. برنامه را طبق مرحلهٔ ۲ پایین دانلود کنید
|
||||||
|
|
||||||
|
۲. فایل [`config.google-only.example.json`](config.google-only.example.json) را در کنار فایل اجرایی به نام `config.json` کپی کنید — نه `script_id` لازم دارد و نه `auth_key`
|
||||||
|
|
||||||
|
۳. برنامه را اجرا کنید و `HTTP proxy` مرورگرتان را روی `127.0.0.1:8085` تنظیم کنید
|
||||||
|
|
||||||
|
۴. در حالت `google_only`، پروکسی فقط `*.google.com`، `*.youtube.com` و بقیهٔ میزبانهای لبهٔ گوگل را از طریق همان تونل بازنویسی `SNI` رد میکند. بقیهٔ ترافیک مستقیم میرود — هنوز رلهای در کار نیست
|
||||||
|
|
||||||
|
۵. حالا مرحلهٔ ۱ را در مرورگر انجام دهید (اتصال به `script.google.com` با `SNI` فرونت میشود). `Code.gs` را مستقر کنید و `Deployment ID` را کپی کنید
|
||||||
|
|
||||||
|
۶. در `UI` دسکتاپ یا اندروید (یا با ویرایش `config.json`) حالت را به `apps_script` برگردانید، `Deployment ID` و `auth_key` را بچسبانید و برنامه را دوباره راهاندازی کنید
|
||||||
|
|
||||||
|
برای بررسی قابلیت دسترسی قبل از راهاندازی پروکسی: دستور `mhrv-rs test-sni` دامنههای `*.google.com` را مستقیماً تست میکند و فقط به `google_ip` و `front_domain` نیاز دارد.
|
||||||
|
|
||||||
#### مرحلهٔ ۲ — دانلود برنامه
|
#### مرحلهٔ ۲ — دانلود برنامه
|
||||||
|
|
||||||
به [صفحهٔ Releases](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases) بروید و آرشیو مناسب سیستمعامل خود را دانلود و از حالت فشرده خارج کنید:
|
به [صفحهٔ Releases](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases) بروید و آرشیو مناسب سیستمعامل خود را دانلود و از حالت فشرده خارج کنید:
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ android {
|
|||||||
applicationId = "com.therealaleph.mhrv"
|
applicationId = "com.therealaleph.mhrv"
|
||||||
minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
|
minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 115
|
versionCode = 120
|
||||||
versionName = "1.1.5"
|
versionName = "1.2.0"
|
||||||
|
|
||||||
// Ship all four mainstream Android ABIs:
|
// Ship all four mainstream Android ABIs:
|
||||||
// - arm64-v8a — 95%+ of real-world Android phones since 2019
|
// - arm64-v8a — 95%+ of real-world Android phones since 2019
|
||||||
|
|||||||
@@ -59,7 +59,21 @@ enum class SplitMode { ALL, ONLY, EXCEPT }
|
|||||||
*/
|
*/
|
||||||
enum class UiLang { AUTO, FA, EN }
|
enum class UiLang { AUTO, FA, EN }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operating mode. Mirrors the Rust-side `Mode` enum.
|
||||||
|
*
|
||||||
|
* - [APPS_SCRIPT] (default) — full DPI bypass through the user's deployed
|
||||||
|
* Apps Script relay. Requires a Deployment ID + Auth key.
|
||||||
|
* - [GOOGLE_ONLY] — bootstrap mode. Only the SNI-rewrite tunnel to the
|
||||||
|
* Google edge is active, so the user can reach `script.google.com` to
|
||||||
|
* deploy Code.gs in the first place. No Deployment ID / Auth key needed.
|
||||||
|
* Non-Google traffic goes direct (no relay).
|
||||||
|
*/
|
||||||
|
enum class Mode { APPS_SCRIPT, GOOGLE_ONLY }
|
||||||
|
|
||||||
data class MhrvConfig(
|
data class MhrvConfig(
|
||||||
|
val mode: Mode = Mode.APPS_SCRIPT,
|
||||||
|
|
||||||
val listenHost: String = "127.0.0.1",
|
val listenHost: String = "127.0.0.1",
|
||||||
val listenPort: Int = 8080,
|
val listenPort: Int = 8080,
|
||||||
val socks5Port: Int? = 1081,
|
val socks5Port: Int? = 1081,
|
||||||
@@ -130,11 +144,17 @@ data class MhrvConfig(
|
|||||||
val obj = JSONObject().apply {
|
val obj = JSONObject().apply {
|
||||||
// `mode` is required — without it serde errors with
|
// `mode` is required — without it serde errors with
|
||||||
// "missing field `mode`" and startProxy silently returns 0.
|
// "missing field `mode`" and startProxy silently returns 0.
|
||||||
put("mode", "apps_script")
|
put("mode", when (mode) {
|
||||||
|
Mode.APPS_SCRIPT -> "apps_script"
|
||||||
|
Mode.GOOGLE_ONLY -> "google_only"
|
||||||
|
})
|
||||||
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
|
||||||
|
// still persist whatever the user typed so flipping back to
|
||||||
|
// apps_script mode doesn't wipe their settings.
|
||||||
put("script_ids", JSONArray().apply { ids.forEach { put(it) } })
|
put("script_ids", JSONArray().apply { ids.forEach { put(it) } })
|
||||||
put("auth_key", authKey)
|
put("auth_key", authKey)
|
||||||
|
|
||||||
@@ -209,6 +229,10 @@ object ConfigStore {
|
|||||||
}?.filter { it.isNotBlank() }.orEmpty()
|
}?.filter { it.isNotBlank() }.orEmpty()
|
||||||
|
|
||||||
MhrvConfig(
|
MhrvConfig(
|
||||||
|
mode = when (obj.optString("mode", "apps_script")) {
|
||||||
|
"google_only" -> Mode.GOOGLE_ONLY
|
||||||
|
else -> Mode.APPS_SCRIPT
|
||||||
|
},
|
||||||
listenHost = obj.optString("listen_host", "127.0.0.1"),
|
listenHost = obj.optString("listen_host", "127.0.0.1"),
|
||||||
listenPort = obj.optInt("listen_port", 8080),
|
listenPort = obj.optInt("listen_port", 8080),
|
||||||
socks5Port = obj.optInt("socks5_port", 1081).takeIf { it > 0 },
|
socks5Port = obj.optInt("socks5_port", 1081).takeIf { it > 0 },
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import com.therealaleph.mhrv.CaInstall
|
|||||||
import com.therealaleph.mhrv.ConfigStore
|
import com.therealaleph.mhrv.ConfigStore
|
||||||
import com.therealaleph.mhrv.DEFAULT_SNI_POOL
|
import com.therealaleph.mhrv.DEFAULT_SNI_POOL
|
||||||
import com.therealaleph.mhrv.MhrvConfig
|
import com.therealaleph.mhrv.MhrvConfig
|
||||||
|
import com.therealaleph.mhrv.Mode
|
||||||
import com.therealaleph.mhrv.Native
|
import com.therealaleph.mhrv.Native
|
||||||
import com.therealaleph.mhrv.ConnectionMode
|
import com.therealaleph.mhrv.ConnectionMode
|
||||||
import com.therealaleph.mhrv.NetworkDetect
|
import com.therealaleph.mhrv.NetworkDetect
|
||||||
@@ -228,11 +229,20 @@ fun HomeScreen(
|
|||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
|
SectionHeader("Mode")
|
||||||
|
ModeDropdown(
|
||||||
|
mode = cfg.mode,
|
||||||
|
onChange = { persist(cfg.copy(mode = it)) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
SectionHeader(stringResource(R.string.sec_apps_script_relay))
|
SectionHeader(stringResource(R.string.sec_apps_script_relay))
|
||||||
|
|
||||||
|
val appsScriptEnabled = cfg.mode == Mode.APPS_SCRIPT
|
||||||
DeploymentIdsField(
|
DeploymentIdsField(
|
||||||
urls = cfg.appsScriptUrls,
|
urls = cfg.appsScriptUrls,
|
||||||
onChange = { persist(cfg.copy(appsScriptUrls = it)) },
|
onChange = { persist(cfg.copy(appsScriptUrls = it)) },
|
||||||
|
enabled = appsScriptEnabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -240,6 +250,7 @@ fun HomeScreen(
|
|||||||
onValueChange = { persist(cfg.copy(authKey = it)) },
|
onValueChange = { persist(cfg.copy(authKey = it)) },
|
||||||
label = { Text(stringResource(R.string.field_auth_key)) },
|
label = { Text(stringResource(R.string.field_auth_key)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
enabled = appsScriptEnabled,
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
supportingText = {
|
supportingText = {
|
||||||
@@ -392,6 +403,7 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = (isVpnRunning ||
|
enabled = (isVpnRunning ||
|
||||||
|
cfg.mode == Mode.GOOGLE_ONLY ||
|
||||||
(cfg.hasDeploymentId && cfg.authKey.isNotBlank())) && !transitionCooldown,
|
(cfg.hasDeploymentId && cfg.authKey.isNotBlank())) && !transitionCooldown,
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = if (isVpnRunning) ErrRed else OkGreen,
|
containerColor = if (isVpnRunning) ErrRed else OkGreen,
|
||||||
@@ -669,6 +681,7 @@ private fun ConnectionModeDropdown(
|
|||||||
private fun DeploymentIdsField(
|
private fun DeploymentIdsField(
|
||||||
urls: List<String>,
|
urls: List<String>,
|
||||||
onChange: (List<String>) -> Unit,
|
onChange: (List<String>) -> Unit,
|
||||||
|
enabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
// Treat the list as newline-joined text. Keep trailing newlines so the
|
// Treat the list as newline-joined text. Keep trailing newlines so the
|
||||||
// cursor behaves naturally while the user is adding a new entry.
|
// cursor behaves naturally while the user is adding a new entry.
|
||||||
@@ -682,6 +695,7 @@ private fun DeploymentIdsField(
|
|||||||
onChange(parsed)
|
onChange(parsed)
|
||||||
},
|
},
|
||||||
label = { Text(stringResource(R.string.field_deployment_urls)) },
|
label = { Text(stringResource(R.string.field_deployment_urls)) },
|
||||||
|
enabled = enabled,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
minLines = 2,
|
minLines = 2,
|
||||||
maxLines = 6,
|
maxLines = 6,
|
||||||
@@ -691,6 +705,66 @@ private fun DeploymentIdsField(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Mode dropdown: apps_script (default) vs google_only (bootstrap).
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ModeDropdown(
|
||||||
|
mode: Mode,
|
||||||
|
onChange: (Mode) -> Unit,
|
||||||
|
) {
|
||||||
|
val labelApps = "Apps Script (full)"
|
||||||
|
val labelGoogle = "Google-only (bootstrap)"
|
||||||
|
val currentLabel = when (mode) {
|
||||||
|
Mode.APPS_SCRIPT -> labelApps
|
||||||
|
Mode.GOOGLE_ONLY -> labelGoogle
|
||||||
|
}
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = !expanded },
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = currentLabel,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("Mode") },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
|
modifier = Modifier.fillMaxWidth().menuAnchor(),
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(labelApps) },
|
||||||
|
onClick = { onChange(Mode.APPS_SCRIPT); expanded = false },
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(labelGoogle) },
|
||||||
|
onClick = { onChange(Mode.GOOGLE_ONLY); expanded = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val help = when (mode) {
|
||||||
|
Mode.APPS_SCRIPT ->
|
||||||
|
"Full DPI bypass through your deployed Apps Script relay."
|
||||||
|
Mode.GOOGLE_ONLY ->
|
||||||
|
"Bootstrap: reach *.google.com directly so you can open script.google.com and deploy Code.gs. Non-Google traffic goes direct."
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
help,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// SNI pool editor + per-SNI probe.
|
// SNI pool editor + per-SNI probe.
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"mode": "google_only",
|
||||||
|
"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
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!--
|
||||||
|
Telegram changelog posted automatically by .github/workflows/release.yml
|
||||||
|
on every tag push. Format:
|
||||||
|
- Persian changelog first (goes in an HTML <blockquote>)
|
||||||
|
- A separator line of just `---`
|
||||||
|
- English changelog second (also in a <blockquote>)
|
||||||
|
- Leave ASCII `-`, digits, and "issue #NN" refs as-is
|
||||||
|
|
||||||
|
The workflow splits on the `---` separator. Bullets can be whatever
|
||||||
|
you like; the bot forwards verbatim.
|
||||||
|
-->
|
||||||
|
|
||||||
|
• حالت جدید «فقط گوگل» (بوتاسترپ): دسترسی مستقیم به *.google.com برای استقرار Code.gs وقتی هنوز به script.google.com دسترسی ندارید — بدون نیاز به Deployment ID یا Auth key
|
||||||
|
|
||||||
|
• انتخابگر حالت در UI دسکتاپ و اندروید؛ CLI از طریق فیلد `mode` در config
|
||||||
|
|
||||||
|
• سازگاری کامل با حالت apps_script؛ کانفیگهای موجود بدون تغییر بارگذاری میشوند
|
||||||
|
|
||||||
|
• نمونه کانفیگ آمادهٔ google_only در ریشهٔ پروژه (`config.google-only.example.json`)
|
||||||
|
|
||||||
|
---
|
||||||
|
• New "Google-only" bootstrap mode: direct SNI-rewrite tunnel to *.google.com so users blocked from script.google.com can still reach it to deploy Code.gs. No Deployment ID or Auth key needed. Non-Google traffic goes direct.
|
||||||
|
|
||||||
|
• Mode selector added to the desktop UI and the Android app; CLI picks it up from the `mode` field in config.
|
||||||
|
|
||||||
|
• Fully backward compatible with apps_script mode — existing configs load unchanged.
|
||||||
|
|
||||||
|
• New ready-to-use `config.google-only.example.json` at the repo root.
|
||||||
+98
-39
@@ -181,6 +181,10 @@ struct App {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct FormState {
|
struct FormState {
|
||||||
|
/// `"apps_script"` (default) or `"google_only"`. Controls whether the
|
||||||
|
/// Apps Script relay is wired up at all. In `google_only`, the form
|
||||||
|
/// tolerates an empty script_id / auth_key.
|
||||||
|
mode: String,
|
||||||
script_id: String,
|
script_id: String,
|
||||||
auth_key: String,
|
auth_key: String,
|
||||||
google_ip: String,
|
google_ip: String,
|
||||||
@@ -263,6 +267,7 @@ 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);
|
||||||
FormState {
|
FormState {
|
||||||
|
mode: c.mode.clone(),
|
||||||
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,
|
||||||
@@ -287,6 +292,7 @@ fn load_form() -> (FormState, Option<String>) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
FormState {
|
FormState {
|
||||||
|
mode: "apps_script".into(),
|
||||||
script_id: String::new(),
|
script_id: String::new(),
|
||||||
auth_key: String::new(),
|
auth_key: String::new(),
|
||||||
google_ip: "216.239.38.120".into(),
|
google_ip: "216.239.38.120".into(),
|
||||||
@@ -359,11 +365,14 @@ 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> {
|
||||||
if self.script_id.trim().is_empty() {
|
let is_google_only = self.mode == "google_only";
|
||||||
return Err("Apps Script ID is required".into());
|
if !is_google_only {
|
||||||
}
|
if self.script_id.trim().is_empty() {
|
||||||
if self.auth_key.trim().is_empty() {
|
return Err("Apps Script ID is required".into());
|
||||||
return Err("Auth key is required".into());
|
}
|
||||||
|
if self.auth_key.trim().is_empty() {
|
||||||
|
return Err("Auth key is required".into());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let listen_port: u16 = self
|
let listen_port: u16 = self
|
||||||
.listen_port
|
.listen_port
|
||||||
@@ -384,13 +393,15 @@ impl FormState {
|
|||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
let script_id = if ids.len() == 1 {
|
let script_id = if ids.is_empty() {
|
||||||
|
None
|
||||||
|
} else if ids.len() == 1 {
|
||||||
Some(ScriptId::One(ids[0].clone()))
|
Some(ScriptId::One(ids[0].clone()))
|
||||||
} else {
|
} else {
|
||||||
Some(ScriptId::Many(ids))
|
Some(ScriptId::Many(ids))
|
||||||
};
|
};
|
||||||
Ok(Config {
|
Ok(Config {
|
||||||
mode: "apps_script".into(),
|
mode: self.mode.clone(),
|
||||||
google_ip: self.google_ip.trim().to_string(),
|
google_ip: self.google_ip.trim().to_string(),
|
||||||
front_domain: self.front_domain.trim().to_string(),
|
front_domain: self.front_domain.trim().to_string(),
|
||||||
script_id,
|
script_id,
|
||||||
@@ -662,41 +673,87 @@ impl eframe::App for App {
|
|||||||
|
|
||||||
ui.add_space(2.0);
|
ui.add_space(2.0);
|
||||||
|
|
||||||
|
// ── Section: Mode ─────────────────────────────────────────────
|
||||||
|
// Surfacing the mode at the top of the form because it changes
|
||||||
|
// which of the sections below are actually used. google_only is
|
||||||
|
// a bootstrap mode for users who don't yet have internet access
|
||||||
|
// to deploy Code.gs — once deployed, they switch back to
|
||||||
|
// apps_script.
|
||||||
|
section(ui, "Mode", |ui| {
|
||||||
|
form_row(ui, "Mode", Some(
|
||||||
|
"apps_script: full DPI bypass via your Apps Script relay.\n\
|
||||||
|
google_only: bootstrap — direct SNI-rewrite tunnel to *.google.com \
|
||||||
|
only (no relay, no script_id needed). Use this just long enough to \
|
||||||
|
open https://script.google.com and deploy Code.gs."
|
||||||
|
), |ui| {
|
||||||
|
egui::ComboBox::from_id_source("mode")
|
||||||
|
.selected_text(match self.form.mode.as_str() {
|
||||||
|
"google_only" => "Google-only (bootstrap)",
|
||||||
|
_ => "Apps Script (full)",
|
||||||
|
})
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut self.form.mode,
|
||||||
|
"apps_script".into(),
|
||||||
|
"Apps Script (full)",
|
||||||
|
);
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut self.form.mode,
|
||||||
|
"google_only".into(),
|
||||||
|
"Google-only (bootstrap)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if self.form.mode == "google_only" {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add_space(120.0 + 8.0);
|
||||||
|
ui.small(egui::RichText::new(
|
||||||
|
"Bootstrap mode — reach script.google.com to deploy Code.gs, then switch back to Apps Script.",
|
||||||
|
)
|
||||||
|
.color(OK_GREEN));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let google_only = 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| {
|
||||||
form_row(ui, "Deployment IDs", Some(
|
ui.add_enabled_ui(!google_only, |ui| {
|
||||||
"One deployment ID per line. Proxy round-robins between them and sidelines \
|
form_row(ui, "Deployment IDs", Some(
|
||||||
any ID that hits its daily quota for 10 minutes before retrying."
|
"One deployment ID per line. Proxy round-robins between them and sidelines \
|
||||||
), |ui| {
|
any ID that hits its daily quota for 10 minutes before retrying."
|
||||||
ui.add(egui::TextEdit::multiline(&mut self.form.script_id)
|
), |ui| {
|
||||||
.hint_text("one deployment ID per line")
|
ui.add(egui::TextEdit::multiline(&mut self.form.script_id)
|
||||||
.desired_width(f32::INFINITY)
|
.hint_text("one deployment ID per line")
|
||||||
.desired_rows(3));
|
.desired_width(f32::INFINITY)
|
||||||
});
|
.desired_rows(3));
|
||||||
|
});
|
||||||
|
|
||||||
let id_count = self.form.script_id
|
let id_count = self.form.script_id
|
||||||
.split(|c: char| c == '\n' || c == ',')
|
.split(|c: char| c == '\n' || c == ',')
|
||||||
.map(|s| s.trim())
|
.map(|s| s.trim())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.count();
|
.count();
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.add_space(120.0 + 8.0);
|
ui.add_space(120.0 + 8.0);
|
||||||
if id_count <= 1 {
|
if id_count <= 1 {
|
||||||
ui.small(egui::RichText::new("Tip: add more IDs for round-robin with auto-failover.")
|
ui.small(egui::RichText::new("Tip: add more IDs for round-robin with auto-failover.")
|
||||||
.color(egui::Color32::from_gray(140)));
|
.color(egui::Color32::from_gray(140)));
|
||||||
} else {
|
} else {
|
||||||
ui.small(egui::RichText::new(format!(
|
ui.small(egui::RichText::new(format!(
|
||||||
"{} IDs — round-robin with auto-failover on quota.", id_count
|
"{} IDs — round-robin with auto-failover on quota.", id_count
|
||||||
)).color(OK_GREEN));
|
)).color(OK_GREEN));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
form_row(ui, "Auth key", Some(
|
form_row(ui, "Auth key", Some(
|
||||||
"Same value as AUTH_KEY inside your Code.gs."
|
"Same value as AUTH_KEY inside your Code.gs."
|
||||||
), |ui| {
|
), |ui| {
|
||||||
ui.add(egui::TextEdit::singleline(&mut self.form.auth_key)
|
ui.add(egui::TextEdit::singleline(&mut self.form.auth_key)
|
||||||
.password(!self.form.show_auth_key)
|
.password(!self.form.show_auth_key)
|
||||||
.desired_width(f32::INFINITY));
|
.desired_width(f32::INFINITY));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1584,7 +1641,9 @@ fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
*fronter_slot2.lock().await = Some(server.fronter());
|
// `fronter()` is `None` in google_only (bootstrap) mode — the
|
||||||
|
// status panel's relay stats simply show no data in that case.
|
||||||
|
*fronter_slot2.lock().await = server.fronter();
|
||||||
{
|
{
|
||||||
let mut s = shared2.state.lock().unwrap();
|
let mut s = shared2.state.lock().unwrap();
|
||||||
s.running = true;
|
s.running = true;
|
||||||
|
|||||||
+86
-20
@@ -12,6 +12,27 @@ pub enum ConfigError {
|
|||||||
Invalid(String),
|
Invalid(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Operating mode. `AppsScript` is the full client — MITMs TLS locally and
|
||||||
|
/// relays HTTP/HTTPS through a user-deployed Apps Script endpoint.
|
||||||
|
/// `GoogleOnly` is a bootstrap: no relay, no Apps Script config needed,
|
||||||
|
/// only the SNI-rewrite tunnel to the Google edge is active. Intended for
|
||||||
|
/// users who need to reach `script.google.com` to deploy `Code.gs` in the
|
||||||
|
/// first place.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Mode {
|
||||||
|
AppsScript,
|
||||||
|
GoogleOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mode {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Mode::AppsScript => "apps_script",
|
||||||
|
Mode::GoogleOnly => "google_only",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub enum ScriptId {
|
pub enum ScriptId {
|
||||||
@@ -39,6 +60,7 @@ pub struct Config {
|
|||||||
pub script_id: Option<ScriptId>,
|
pub script_id: Option<ScriptId>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub script_ids: Option<ScriptId>,
|
pub script_ids: Option<ScriptId>,
|
||||||
|
#[serde(default)]
|
||||||
pub auth_key: String,
|
pub auth_key: String,
|
||||||
#[serde(default = "default_listen_host")]
|
#[serde(default = "default_listen_host")]
|
||||||
pub listen_host: String,
|
pub listen_host: String,
|
||||||
@@ -141,29 +163,26 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), ConfigError> {
|
fn validate(&self) -> Result<(), ConfigError> {
|
||||||
if self.mode != "apps_script" {
|
let mode = self.mode_kind()?;
|
||||||
return Err(ConfigError::Invalid(format!(
|
if mode == Mode::AppsScript {
|
||||||
"only 'apps_script' mode is supported in this build (got '{}')",
|
if self.auth_key.trim().is_empty() || self.auth_key == "CHANGE_ME_TO_A_STRONG_SECRET" {
|
||||||
self.mode
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if self.auth_key.trim().is_empty() || self.auth_key == "CHANGE_ME_TO_A_STRONG_SECRET" {
|
|
||||||
return Err(ConfigError::Invalid(
|
|
||||||
"auth_key must be set to a strong secret".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let ids = self.script_ids_resolved();
|
|
||||||
if ids.is_empty() {
|
|
||||||
return Err(ConfigError::Invalid(
|
|
||||||
"script_id (or script_ids) is required".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
for id in &ids {
|
|
||||||
if id.is_empty() || id == "YOUR_APPS_SCRIPT_DEPLOYMENT_ID" {
|
|
||||||
return Err(ConfigError::Invalid(
|
return Err(ConfigError::Invalid(
|
||||||
"script_id is not set — deploy Code.gs and paste its Deployment ID".into(),
|
"auth_key must be set to a strong secret".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let ids = self.script_ids_resolved();
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
"script_id (or script_ids) is required".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for id in &ids {
|
||||||
|
if id.is_empty() || id == "YOUR_APPS_SCRIPT_DEPLOYMENT_ID" {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
"script_id is not set — deploy Code.gs and paste its Deployment ID".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if self.scan_batch_size == 0 {
|
if self.scan_batch_size == 0 {
|
||||||
return Err(ConfigError::Invalid(
|
return Err(ConfigError::Invalid(
|
||||||
@@ -173,6 +192,17 @@ impl Config {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn mode_kind(&self) -> Result<Mode, ConfigError> {
|
||||||
|
match self.mode.as_str() {
|
||||||
|
"apps_script" => Ok(Mode::AppsScript),
|
||||||
|
"google_only" => Ok(Mode::GoogleOnly),
|
||||||
|
other => Err(ConfigError::Invalid(format!(
|
||||||
|
"unknown mode '{}' (expected 'apps_script' or 'google_only')",
|
||||||
|
other
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn script_ids_resolved(&self) -> Vec<String> {
|
pub fn script_ids_resolved(&self) -> Vec<String> {
|
||||||
if let Some(s) = &self.script_ids {
|
if let Some(s) = &self.script_ids {
|
||||||
return s.clone().into_vec();
|
return s.clone().into_vec();
|
||||||
@@ -233,6 +263,42 @@ mod tests {
|
|||||||
assert!(cfg.validate().is_err());
|
assert!(cfg.validate().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_google_only_without_script_id() {
|
||||||
|
// Bootstrap mode: no script_id, no auth_key — both are only meaningful
|
||||||
|
// once the Apps Script relay exists.
|
||||||
|
let s = r#"{
|
||||||
|
"mode": "google_only"
|
||||||
|
}"#;
|
||||||
|
let cfg: Config = serde_json::from_str(s).unwrap();
|
||||||
|
cfg.validate().expect("google_only must validate without script_id / auth_key");
|
||||||
|
assert_eq!(cfg.mode_kind().unwrap(), Mode::GoogleOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn google_only_ignores_placeholder_script_id() {
|
||||||
|
// UI round-trip: user saved config in apps_script with the placeholder,
|
||||||
|
// then switched mode to google_only. The placeholder should not block
|
||||||
|
// validation in the bootstrap mode.
|
||||||
|
let s = r#"{
|
||||||
|
"mode": "google_only",
|
||||||
|
"script_id": "YOUR_APPS_SCRIPT_DEPLOYMENT_ID"
|
||||||
|
}"#;
|
||||||
|
let cfg: Config = serde_json::from_str(s).unwrap();
|
||||||
|
cfg.validate().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unknown_mode_value() {
|
||||||
|
let s = r#"{
|
||||||
|
"mode": "hybrid",
|
||||||
|
"auth_key": "X",
|
||||||
|
"script_id": "X"
|
||||||
|
}"#;
|
||||||
|
let cfg: Config = serde_json::from_str(s).unwrap();
|
||||||
|
assert!(cfg.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_zero_scan_batch_size() {
|
fn rejects_zero_scan_batch_size() {
|
||||||
let s = r#"{
|
let s = r#"{
|
||||||
|
|||||||
+32
-11
@@ -202,23 +202,44 @@ async fn main() -> ExitCode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1);
|
let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1);
|
||||||
tracing::warn!("mhrv-rs {} starting (mode: apps_script)", VERSION);
|
let mode = match config.mode_kind() {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("config: {}", e);
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tracing::warn!("mhrv-rs {} starting (mode: {})", VERSION, mode.as_str());
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"HTTP proxy : {}:{}",
|
"HTTP proxy : {}:{}",
|
||||||
config.listen_host,
|
config.listen_host,
|
||||||
config.listen_port
|
config.listen_port
|
||||||
);
|
);
|
||||||
tracing::info!("SOCKS5 proxy : {}:{}", config.listen_host, socks5_port);
|
tracing::info!("SOCKS5 proxy : {}:{}", config.listen_host, socks5_port);
|
||||||
tracing::info!(
|
match mode {
|
||||||
"Apps Script relay: SNI={} -> script.google.com (via {})",
|
mhrv_rs::config::Mode::AppsScript => {
|
||||||
config.front_domain,
|
tracing::info!(
|
||||||
config.google_ip
|
"Apps Script relay: SNI={} -> script.google.com (via {})",
|
||||||
);
|
config.front_domain,
|
||||||
let sids = config.script_ids_resolved();
|
config.google_ip
|
||||||
if sids.len() > 1 {
|
);
|
||||||
tracing::info!("Script IDs: {} (round-robin)", sids.len());
|
let sids = config.script_ids_resolved();
|
||||||
} else {
|
if sids.len() > 1 {
|
||||||
tracing::info!("Script ID: {}", sids[0]);
|
tracing::info!("Script IDs: {} (round-robin)", sids.len());
|
||||||
|
} else {
|
||||||
|
tracing::info!("Script ID: {}", sids[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mhrv_rs::config::Mode::GoogleOnly => {
|
||||||
|
tracing::warn!(
|
||||||
|
"google_only bootstrap: direct SNI-rewrite tunnel to {} only. \
|
||||||
|
Open https://script.google.com in your browser (proxy set to \
|
||||||
|
{}:{}), deploy Code.gs, then switch to apps_script mode.",
|
||||||
|
config.google_ip,
|
||||||
|
config.listen_host,
|
||||||
|
config.listen_port
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize MITM manager (generates CA on first run).
|
// Initialize MITM manager (generates CA on first run).
|
||||||
|
|||||||
+102
-28
@@ -11,7 +11,7 @@ use tokio_rustls::rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme}
|
|||||||
use tokio_rustls::rustls::server::Acceptor;
|
use tokio_rustls::rustls::server::Acceptor;
|
||||||
use tokio_rustls::{LazyConfigAcceptor, TlsAcceptor, TlsConnector};
|
use tokio_rustls::{LazyConfigAcceptor, TlsAcceptor, TlsConnector};
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::{Config, Mode};
|
||||||
use crate::domain_fronter::DomainFronter;
|
use crate::domain_fronter::DomainFronter;
|
||||||
use crate::mitm::MitmCertManager;
|
use crate::mitm::MitmCertManager;
|
||||||
|
|
||||||
@@ -104,7 +104,9 @@ pub struct ProxyServer {
|
|||||||
host: String,
|
host: String,
|
||||||
port: u16,
|
port: u16,
|
||||||
socks5_port: u16,
|
socks5_port: u16,
|
||||||
fronter: Arc<DomainFronter>,
|
/// `None` in `google_only` (bootstrap) mode: no Apps Script relay is
|
||||||
|
/// wired up, only the SNI-rewrite tunnel path is live.
|
||||||
|
fronter: Option<Arc<DomainFronter>>,
|
||||||
mitm: Arc<Mutex<MitmCertManager>>,
|
mitm: Arc<Mutex<MitmCertManager>>,
|
||||||
rewrite_ctx: Arc<RewriteCtx>,
|
rewrite_ctx: Arc<RewriteCtx>,
|
||||||
}
|
}
|
||||||
@@ -115,12 +117,27 @@ pub struct RewriteCtx {
|
|||||||
pub hosts: std::collections::HashMap<String, String>,
|
pub hosts: std::collections::HashMap<String, String>,
|
||||||
pub tls_connector: TlsConnector,
|
pub tls_connector: TlsConnector,
|
||||||
pub upstream_socks5: Option<String>,
|
pub upstream_socks5: Option<String>,
|
||||||
|
pub mode: Mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProxyServer {
|
impl ProxyServer {
|
||||||
pub fn new(config: &Config, mitm: Arc<Mutex<MitmCertManager>>) -> Result<Self, ProxyError> {
|
pub fn new(config: &Config, mitm: Arc<Mutex<MitmCertManager>>) -> Result<Self, ProxyError> {
|
||||||
let fronter = DomainFronter::new(config)
|
let mode = config
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("{e}")))?;
|
.mode_kind()
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?;
|
||||||
|
|
||||||
|
// `google_only` mode skips the Apps Script relay entirely, so we must
|
||||||
|
// not try to construct the DomainFronter — it errors on a missing
|
||||||
|
// `script_id`, which is exactly the state a bootstrapping user is in.
|
||||||
|
let fronter = match mode {
|
||||||
|
Mode::AppsScript => {
|
||||||
|
let f = DomainFronter::new(config).map_err(|e| {
|
||||||
|
std::io::Error::new(std::io::ErrorKind::Other, format!("{e}"))
|
||||||
|
})?;
|
||||||
|
Some(Arc::new(f))
|
||||||
|
}
|
||||||
|
Mode::GoogleOnly => None,
|
||||||
|
};
|
||||||
|
|
||||||
let tls_config = if config.verify_ssl {
|
let tls_config = if config.verify_ssl {
|
||||||
let mut roots = tokio_rustls::rustls::RootCertStore::empty();
|
let mut roots = tokio_rustls::rustls::RootCertStore::empty();
|
||||||
@@ -142,6 +159,7 @@ impl ProxyServer {
|
|||||||
hosts: config.hosts.clone(),
|
hosts: config.hosts.clone(),
|
||||||
tls_connector,
|
tls_connector,
|
||||||
upstream_socks5: config.upstream_socks5.clone(),
|
upstream_socks5: config.upstream_socks5.clone(),
|
||||||
|
mode,
|
||||||
});
|
});
|
||||||
|
|
||||||
let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1);
|
let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1);
|
||||||
@@ -150,13 +168,13 @@ impl ProxyServer {
|
|||||||
host: config.listen_host.clone(),
|
host: config.listen_host.clone(),
|
||||||
port: config.listen_port,
|
port: config.listen_port,
|
||||||
socks5_port,
|
socks5_port,
|
||||||
fronter: Arc::new(fronter),
|
fronter,
|
||||||
mitm,
|
mitm,
|
||||||
rewrite_ctx,
|
rewrite_ctx,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn fronter(&self) -> Arc<DomainFronter> {
|
pub fn fronter(&self) -> Option<Arc<DomainFronter>> {
|
||||||
self.fronter.clone()
|
self.fronter.clone()
|
||||||
}
|
}
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
@@ -177,25 +195,30 @@ 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.
|
// failures are logged and ignored. Skipped in `google_only` — there
|
||||||
let warm_fronter = self.fronter.clone();
|
// is no fronter to warm.
|
||||||
tokio::spawn(async move {
|
if let Some(warm_fronter) = self.fronter.clone() {
|
||||||
warm_fronter.warm(3).await;
|
tokio::spawn(async move {
|
||||||
});
|
warm_fronter.warm(3).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let stats_fronter = self.fronter.clone();
|
let stats_task = if let Some(stats_fronter) = self.fronter.clone() {
|
||||||
let stats_task = tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
|
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
ticker.tick().await;
|
|
||||||
loop {
|
|
||||||
ticker.tick().await;
|
ticker.tick().await;
|
||||||
let s = stats_fronter.snapshot_stats();
|
loop {
|
||||||
if s.relay_calls > 0 || s.cache_hits > 0 {
|
ticker.tick().await;
|
||||||
tracing::info!("{}", s.fmt_line());
|
let s = stats_fronter.snapshot_stats();
|
||||||
|
if s.relay_calls > 0 || s.cache_hits > 0 {
|
||||||
|
tracing::info!("{}", s.fmt_line());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
});
|
} else {
|
||||||
|
tokio::spawn(async move { std::future::pending::<()>().await })
|
||||||
|
};
|
||||||
|
|
||||||
let http_fronter = self.fronter.clone();
|
let http_fronter = self.fronter.clone();
|
||||||
let http_mitm = self.mitm.clone();
|
let http_mitm = self.mitm.clone();
|
||||||
@@ -325,7 +348,7 @@ async fn accept_backoff(kind: &str, err: &std::io::Error, count: &mut u64) {
|
|||||||
|
|
||||||
async fn handle_http_client(
|
async fn handle_http_client(
|
||||||
mut sock: TcpStream,
|
mut sock: TcpStream,
|
||||||
fronter: Arc<DomainFronter>,
|
fronter: Option<Arc<DomainFronter>>,
|
||||||
mitm: Arc<Mutex<MitmCertManager>>,
|
mitm: Arc<Mutex<MitmCertManager>>,
|
||||||
rewrite_ctx: Arc<RewriteCtx>,
|
rewrite_ctx: Arc<RewriteCtx>,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
@@ -344,7 +367,26 @@ async fn handle_http_client(
|
|||||||
sock.flush().await?;
|
sock.flush().await?;
|
||||||
dispatch_tunnel(sock, host, port, fronter, mitm, rewrite_ctx).await
|
dispatch_tunnel(sock, host, port, fronter, mitm, rewrite_ctx).await
|
||||||
} else {
|
} else {
|
||||||
do_plain_http(sock, &head, &leftover, fronter).await
|
// Plain HTTP proxy request (e.g. `GET http://…`). The Apps Script
|
||||||
|
// relay is the only code path that can fulfil this, so in google_only
|
||||||
|
// bootstrap mode we return a clear 502 instead.
|
||||||
|
match fronter {
|
||||||
|
Some(f) => do_plain_http(sock, &head, &leftover, f).await,
|
||||||
|
None => {
|
||||||
|
let _ = sock
|
||||||
|
.write_all(
|
||||||
|
b"HTTP/1.1 502 Bad Gateway\r\n\
|
||||||
|
Content-Type: text/plain; charset=utf-8\r\n\
|
||||||
|
Content-Length: 128\r\n\
|
||||||
|
Connection: close\r\n\r\n\
|
||||||
|
google_only mode: plain HTTP proxy requests are not supported. \
|
||||||
|
Browse https over CONNECT, or switch to apps_script mode.",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _ = sock.flush().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +394,7 @@ async fn handle_http_client(
|
|||||||
|
|
||||||
async fn handle_socks5_client(
|
async fn handle_socks5_client(
|
||||||
mut sock: TcpStream,
|
mut sock: TcpStream,
|
||||||
fronter: Arc<DomainFronter>,
|
fronter: Option<Arc<DomainFronter>>,
|
||||||
mitm: Arc<Mutex<MitmCertManager>>,
|
mitm: Arc<Mutex<MitmCertManager>>,
|
||||||
rewrite_ctx: Arc<RewriteCtx>,
|
rewrite_ctx: Arc<RewriteCtx>,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
@@ -444,7 +486,7 @@ async fn dispatch_tunnel(
|
|||||||
sock: TcpStream,
|
sock: TcpStream,
|
||||||
host: String,
|
host: String,
|
||||||
port: u16,
|
port: u16,
|
||||||
fronter: Arc<DomainFronter>,
|
fronter: Option<Arc<DomainFronter>>,
|
||||||
mitm: Arc<Mutex<MitmCertManager>>,
|
mitm: Arc<Mutex<MitmCertManager>>,
|
||||||
rewrite_ctx: Arc<RewriteCtx>,
|
rewrite_ctx: Arc<RewriteCtx>,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
@@ -455,7 +497,39 @@ async fn dispatch_tunnel(
|
|||||||
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).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Peek at the first byte to detect TLS vs plain. Time-bounded — if the
|
// 2. google_only bootstrap: no Apps Script relay exists. Anything that
|
||||||
|
// isn't SNI-rewrite-matched gets direct TCP passthrough so the user's
|
||||||
|
// browser still works while they're deploying Code.gs. They'd switch
|
||||||
|
// to apps_script mode for the real DPI bypass.
|
||||||
|
if rewrite_ctx.mode == Mode::GoogleOnly {
|
||||||
|
let via = rewrite_ctx.upstream_socks5.as_deref();
|
||||||
|
tracing::info!(
|
||||||
|
"dispatch {}:{} -> raw-tcp ({}) (google_only: no relay)",
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
via.unwrap_or("direct")
|
||||||
|
);
|
||||||
|
plain_tcp_passthrough(sock, &host, port, via).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// From here on we know mode == AppsScript, so `fronter` is Some.
|
||||||
|
let fronter = match fronter {
|
||||||
|
Some(f) => f,
|
||||||
|
None => {
|
||||||
|
// Defensive: mode says apps_script but the fronter is missing.
|
||||||
|
// Fall back to raw TCP rather than panicking.
|
||||||
|
tracing::error!(
|
||||||
|
"dispatch {}:{} -> raw-tcp (unexpected: apps_script mode with no fronter)",
|
||||||
|
host,
|
||||||
|
port
|
||||||
|
);
|
||||||
|
plain_tcp_passthrough(sock, &host, port, rewrite_ctx.upstream_socks5.as_deref()).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Peek at the first byte to detect TLS vs plain. Time-bounded — if the
|
||||||
// client doesn't send anything within 300ms, assume server-first
|
// client doesn't send anything within 300ms, assume server-first
|
||||||
// protocol (SMTP, POP3, FTP banner) and jump straight to plain TCP.
|
// protocol (SMTP, POP3, FTP banner) and jump straight to plain TCP.
|
||||||
let mut peek_buf = [0u8; 8];
|
let mut peek_buf = [0u8; 8];
|
||||||
@@ -496,7 +570,7 @@ async fn dispatch_tunnel(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Not TLS. If bytes look like HTTP, relay on scheme=http. Otherwise
|
// 4. Not TLS. If bytes look like HTTP, relay on scheme=http. Otherwise
|
||||||
// fall back to plain TCP passthrough.
|
// fall back to plain TCP passthrough.
|
||||||
if peek_n > 0 && looks_like_http(&peek_buf[..peek_n]) {
|
if peek_n > 0 && looks_like_http(&peek_buf[..peek_n]) {
|
||||||
let scheme = if port == 443 { "https" } else { "http" };
|
let scheme = if port == 443 { "https" } else { "http" };
|
||||||
|
|||||||
+9
-1
@@ -14,12 +14,20 @@
|
|||||||
|
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::{Config, Mode};
|
||||||
use crate::domain_fronter::DomainFronter;
|
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)) {
|
||||||
|
let msg = "`mhrv-rs test` probes the Apps Script relay, which isn't \
|
||||||
|
wired up in google_only mode. Run `mhrv-rs test-sni` to \
|
||||||
|
check the direct SNI-rewrite tunnel instead.";
|
||||||
|
println!("{}", msg);
|
||||||
|
tracing::error!("{}", msg);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
let fronter = match DomainFronter::new(config) {
|
let fronter = match DomainFronter::new(config) {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user