mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-17 21:24: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]]
|
||||
name = "mhrv-rs"
|
||||
version = "1.1.5"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mhrv-rs"
|
||||
version = "1.1.5"
|
||||
version = "1.2.0"
|
||||
edition = "2021"
|
||||
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
|
||||
license = "MIT"
|
||||
|
||||
@@ -97,6 +97,19 @@ This part is unchanged from the original project. Follow @masterking32's guide o
|
||||
- Who has access: **Anyone**
|
||||
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
|
||||
|
||||
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` چه بگذارید، یک رشتهٔ تصادفی ۱۶ تا ۲۴ کاراکتری بسازید. مهم فقط این است که **دقیقاً همان رشته** را در برنامه هم وارد کنید.
|
||||
|
||||
#### به `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) بروید و آرشیو مناسب سیستمعامل خود را دانلود و از حالت فشرده خارج کنید:
|
||||
|
||||
@@ -14,8 +14,8 @@ android {
|
||||
applicationId = "com.therealaleph.mhrv"
|
||||
minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
|
||||
targetSdk = 34
|
||||
versionCode = 115
|
||||
versionName = "1.1.5"
|
||||
versionCode = 120
|
||||
versionName = "1.2.0"
|
||||
|
||||
// Ship all four mainstream Android ABIs:
|
||||
// - 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 }
|
||||
|
||||
/**
|
||||
* 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(
|
||||
val mode: Mode = Mode.APPS_SCRIPT,
|
||||
|
||||
val listenHost: String = "127.0.0.1",
|
||||
val listenPort: Int = 8080,
|
||||
val socks5Port: Int? = 1081,
|
||||
@@ -130,11 +144,17 @@ data class MhrvConfig(
|
||||
val obj = JSONObject().apply {
|
||||
// `mode` is required — without it serde errors with
|
||||
// "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_port", listenPort)
|
||||
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("auth_key", authKey)
|
||||
|
||||
@@ -209,6 +229,10 @@ object ConfigStore {
|
||||
}?.filter { it.isNotBlank() }.orEmpty()
|
||||
|
||||
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"),
|
||||
listenPort = obj.optInt("listen_port", 8080),
|
||||
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.DEFAULT_SNI_POOL
|
||||
import com.therealaleph.mhrv.MhrvConfig
|
||||
import com.therealaleph.mhrv.Mode
|
||||
import com.therealaleph.mhrv.Native
|
||||
import com.therealaleph.mhrv.ConnectionMode
|
||||
import com.therealaleph.mhrv.NetworkDetect
|
||||
@@ -228,11 +229,20 @@ fun HomeScreen(
|
||||
.padding(16.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))
|
||||
|
||||
val appsScriptEnabled = cfg.mode == Mode.APPS_SCRIPT
|
||||
DeploymentIdsField(
|
||||
urls = cfg.appsScriptUrls,
|
||||
onChange = { persist(cfg.copy(appsScriptUrls = it)) },
|
||||
enabled = appsScriptEnabled,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
@@ -240,6 +250,7 @@ fun HomeScreen(
|
||||
onValueChange = { persist(cfg.copy(authKey = it)) },
|
||||
label = { Text(stringResource(R.string.field_auth_key)) },
|
||||
singleLine = true,
|
||||
enabled = appsScriptEnabled,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
supportingText = {
|
||||
@@ -392,6 +403,7 @@ fun HomeScreen(
|
||||
}
|
||||
},
|
||||
enabled = (isVpnRunning ||
|
||||
cfg.mode == Mode.GOOGLE_ONLY ||
|
||||
(cfg.hasDeploymentId && cfg.authKey.isNotBlank())) && !transitionCooldown,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (isVpnRunning) ErrRed else OkGreen,
|
||||
@@ -669,6 +681,7 @@ private fun ConnectionModeDropdown(
|
||||
private fun DeploymentIdsField(
|
||||
urls: List<String>,
|
||||
onChange: (List<String>) -> Unit,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
// Treat the list as newline-joined text. Keep trailing newlines so the
|
||||
// cursor behaves naturally while the user is adding a new entry.
|
||||
@@ -682,6 +695,7 @@ private fun DeploymentIdsField(
|
||||
onChange(parsed)
|
||||
},
|
||||
label = { Text(stringResource(R.string.field_deployment_urls)) },
|
||||
enabled = enabled,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
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.
|
||||
// =========================================================================
|
||||
|
||||
@@ -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)]
|
||||
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,
|
||||
auth_key: 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);
|
||||
FormState {
|
||||
mode: c.mode.clone(),
|
||||
script_id: sid,
|
||||
auth_key: c.auth_key,
|
||||
google_ip: c.google_ip,
|
||||
@@ -287,6 +292,7 @@ fn load_form() -> (FormState, Option<String>) {
|
||||
}
|
||||
} else {
|
||||
FormState {
|
||||
mode: "apps_script".into(),
|
||||
script_id: String::new(),
|
||||
auth_key: String::new(),
|
||||
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 {
|
||||
fn to_config(&self) -> Result<Config, String> {
|
||||
if self.script_id.trim().is_empty() {
|
||||
return Err("Apps Script ID is required".into());
|
||||
}
|
||||
if self.auth_key.trim().is_empty() {
|
||||
return Err("Auth key is required".into());
|
||||
let is_google_only = self.mode == "google_only";
|
||||
if !is_google_only {
|
||||
if self.script_id.trim().is_empty() {
|
||||
return Err("Apps Script ID is required".into());
|
||||
}
|
||||
if self.auth_key.trim().is_empty() {
|
||||
return Err("Auth key is required".into());
|
||||
}
|
||||
}
|
||||
let listen_port: u16 = self
|
||||
.listen_port
|
||||
@@ -384,13 +393,15 @@ impl FormState {
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.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()))
|
||||
} else {
|
||||
Some(ScriptId::Many(ids))
|
||||
};
|
||||
Ok(Config {
|
||||
mode: "apps_script".into(),
|
||||
mode: self.mode.clone(),
|
||||
google_ip: self.google_ip.trim().to_string(),
|
||||
front_domain: self.front_domain.trim().to_string(),
|
||||
script_id,
|
||||
@@ -662,41 +673,87 @@ impl eframe::App for App {
|
||||
|
||||
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(ui, "Apps Script relay", |ui| {
|
||||
form_row(ui, "Deployment IDs", Some(
|
||||
"One deployment ID per line. Proxy round-robins between them and sidelines \
|
||||
any ID that hits its daily quota for 10 minutes before retrying."
|
||||
), |ui| {
|
||||
ui.add(egui::TextEdit::multiline(&mut self.form.script_id)
|
||||
.hint_text("one deployment ID per line")
|
||||
.desired_width(f32::INFINITY)
|
||||
.desired_rows(3));
|
||||
});
|
||||
ui.add_enabled_ui(!google_only, |ui| {
|
||||
form_row(ui, "Deployment IDs", Some(
|
||||
"One deployment ID per line. Proxy round-robins between them and sidelines \
|
||||
any ID that hits its daily quota for 10 minutes before retrying."
|
||||
), |ui| {
|
||||
ui.add(egui::TextEdit::multiline(&mut self.form.script_id)
|
||||
.hint_text("one deployment ID per line")
|
||||
.desired_width(f32::INFINITY)
|
||||
.desired_rows(3));
|
||||
});
|
||||
|
||||
let id_count = self.form.script_id
|
||||
.split(|c: char| c == '\n' || c == ',')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.count();
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(120.0 + 8.0);
|
||||
if id_count <= 1 {
|
||||
ui.small(egui::RichText::new("Tip: add more IDs for round-robin with auto-failover.")
|
||||
.color(egui::Color32::from_gray(140)));
|
||||
} else {
|
||||
ui.small(egui::RichText::new(format!(
|
||||
"{} IDs — round-robin with auto-failover on quota.", id_count
|
||||
)).color(OK_GREEN));
|
||||
}
|
||||
});
|
||||
let id_count = self.form.script_id
|
||||
.split(|c: char| c == '\n' || c == ',')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.count();
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(120.0 + 8.0);
|
||||
if id_count <= 1 {
|
||||
ui.small(egui::RichText::new("Tip: add more IDs for round-robin with auto-failover.")
|
||||
.color(egui::Color32::from_gray(140)));
|
||||
} else {
|
||||
ui.small(egui::RichText::new(format!(
|
||||
"{} IDs — round-robin with auto-failover on quota.", id_count
|
||||
)).color(OK_GREEN));
|
||||
}
|
||||
});
|
||||
|
||||
form_row(ui, "Auth key", Some(
|
||||
"Same value as AUTH_KEY inside your Code.gs."
|
||||
), |ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut self.form.auth_key)
|
||||
.password(!self.form.show_auth_key)
|
||||
.desired_width(f32::INFINITY));
|
||||
form_row(ui, "Auth key", Some(
|
||||
"Same value as AUTH_KEY inside your Code.gs."
|
||||
), |ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut self.form.auth_key)
|
||||
.password(!self.form.show_auth_key)
|
||||
.desired_width(f32::INFINITY));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1584,7 +1641,9 @@ fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {
|
||||
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();
|
||||
s.running = true;
|
||||
|
||||
+86
-20
@@ -12,6 +12,27 @@ pub enum ConfigError {
|
||||
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)]
|
||||
#[serde(untagged)]
|
||||
pub enum ScriptId {
|
||||
@@ -39,6 +60,7 @@ pub struct Config {
|
||||
pub script_id: Option<ScriptId>,
|
||||
#[serde(default)]
|
||||
pub script_ids: Option<ScriptId>,
|
||||
#[serde(default)]
|
||||
pub auth_key: String,
|
||||
#[serde(default = "default_listen_host")]
|
||||
pub listen_host: String,
|
||||
@@ -141,29 +163,26 @@ impl Config {
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), ConfigError> {
|
||||
if self.mode != "apps_script" {
|
||||
return Err(ConfigError::Invalid(format!(
|
||||
"only 'apps_script' mode is supported in this build (got '{}')",
|
||||
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" {
|
||||
let mode = self.mode_kind()?;
|
||||
if mode == Mode::AppsScript {
|
||||
if self.auth_key.trim().is_empty() || self.auth_key == "CHANGE_ME_TO_A_STRONG_SECRET" {
|
||||
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 {
|
||||
return Err(ConfigError::Invalid(
|
||||
@@ -173,6 +192,17 @@ impl Config {
|
||||
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> {
|
||||
if let Some(s) = &self.script_ids {
|
||||
return s.clone().into_vec();
|
||||
@@ -233,6 +263,42 @@ mod tests {
|
||||
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]
|
||||
fn rejects_zero_scan_batch_size() {
|
||||
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);
|
||||
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!(
|
||||
"HTTP proxy : {}:{}",
|
||||
config.listen_host,
|
||||
config.listen_port
|
||||
);
|
||||
tracing::info!("SOCKS5 proxy : {}:{}", config.listen_host, socks5_port);
|
||||
tracing::info!(
|
||||
"Apps Script relay: SNI={} -> script.google.com (via {})",
|
||||
config.front_domain,
|
||||
config.google_ip
|
||||
);
|
||||
let sids = config.script_ids_resolved();
|
||||
if sids.len() > 1 {
|
||||
tracing::info!("Script IDs: {} (round-robin)", sids.len());
|
||||
} else {
|
||||
tracing::info!("Script ID: {}", sids[0]);
|
||||
match mode {
|
||||
mhrv_rs::config::Mode::AppsScript => {
|
||||
tracing::info!(
|
||||
"Apps Script relay: SNI={} -> script.google.com (via {})",
|
||||
config.front_domain,
|
||||
config.google_ip
|
||||
);
|
||||
let sids = config.script_ids_resolved();
|
||||
if sids.len() > 1 {
|
||||
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).
|
||||
|
||||
+102
-28
@@ -11,7 +11,7 @@ use tokio_rustls::rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme}
|
||||
use tokio_rustls::rustls::server::Acceptor;
|
||||
use tokio_rustls::{LazyConfigAcceptor, TlsAcceptor, TlsConnector};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config::{Config, Mode};
|
||||
use crate::domain_fronter::DomainFronter;
|
||||
use crate::mitm::MitmCertManager;
|
||||
|
||||
@@ -104,7 +104,9 @@ pub struct ProxyServer {
|
||||
host: String,
|
||||
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>>,
|
||||
rewrite_ctx: Arc<RewriteCtx>,
|
||||
}
|
||||
@@ -115,12 +117,27 @@ pub struct RewriteCtx {
|
||||
pub hosts: std::collections::HashMap<String, String>,
|
||||
pub tls_connector: TlsConnector,
|
||||
pub upstream_socks5: Option<String>,
|
||||
pub mode: Mode,
|
||||
}
|
||||
|
||||
impl ProxyServer {
|
||||
pub fn new(config: &Config, mitm: Arc<Mutex<MitmCertManager>>) -> Result<Self, ProxyError> {
|
||||
let fronter = DomainFronter::new(config)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("{e}")))?;
|
||||
let mode = config
|
||||
.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 mut roots = tokio_rustls::rustls::RootCertStore::empty();
|
||||
@@ -142,6 +159,7 @@ impl ProxyServer {
|
||||
hosts: config.hosts.clone(),
|
||||
tls_connector,
|
||||
upstream_socks5: config.upstream_socks5.clone(),
|
||||
mode,
|
||||
});
|
||||
|
||||
let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1);
|
||||
@@ -150,13 +168,13 @@ impl ProxyServer {
|
||||
host: config.listen_host.clone(),
|
||||
port: config.listen_port,
|
||||
socks5_port,
|
||||
fronter: Arc::new(fronter),
|
||||
fronter,
|
||||
mitm,
|
||||
rewrite_ctx,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn fronter(&self) -> Arc<DomainFronter> {
|
||||
pub fn fronter(&self) -> Option<Arc<DomainFronter>> {
|
||||
self.fronter.clone()
|
||||
}
|
||||
pub async fn run(
|
||||
@@ -177,25 +195,30 @@ impl ProxyServer {
|
||||
);
|
||||
// Pre-warm the outbound connection pool so the user's first request
|
||||
// doesn't pay a fresh TLS handshake to Google edge. Best-effort;
|
||||
// failures are logged and ignored.
|
||||
let warm_fronter = self.fronter.clone();
|
||||
tokio::spawn(async move {
|
||||
warm_fronter.warm(3).await;
|
||||
});
|
||||
// failures are logged and ignored. Skipped in `google_only` — there
|
||||
// is no fronter to warm.
|
||||
if let Some(warm_fronter) = self.fronter.clone() {
|
||||
tokio::spawn(async move {
|
||||
warm_fronter.warm(3).await;
|
||||
});
|
||||
}
|
||||
|
||||
let stats_fronter = self.fronter.clone();
|
||||
let stats_task = tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
ticker.tick().await;
|
||||
loop {
|
||||
let stats_task = if let Some(stats_fronter) = self.fronter.clone() {
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
ticker.tick().await;
|
||||
let s = stats_fronter.snapshot_stats();
|
||||
if s.relay_calls > 0 || s.cache_hits > 0 {
|
||||
tracing::info!("{}", s.fmt_line());
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
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_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(
|
||||
mut sock: TcpStream,
|
||||
fronter: Arc<DomainFronter>,
|
||||
fronter: Option<Arc<DomainFronter>>,
|
||||
mitm: Arc<Mutex<MitmCertManager>>,
|
||||
rewrite_ctx: Arc<RewriteCtx>,
|
||||
) -> std::io::Result<()> {
|
||||
@@ -344,7 +367,26 @@ async fn handle_http_client(
|
||||
sock.flush().await?;
|
||||
dispatch_tunnel(sock, host, port, fronter, mitm, rewrite_ctx).await
|
||||
} 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(
|
||||
mut sock: TcpStream,
|
||||
fronter: Arc<DomainFronter>,
|
||||
fronter: Option<Arc<DomainFronter>>,
|
||||
mitm: Arc<Mutex<MitmCertManager>>,
|
||||
rewrite_ctx: Arc<RewriteCtx>,
|
||||
) -> std::io::Result<()> {
|
||||
@@ -444,7 +486,7 @@ async fn dispatch_tunnel(
|
||||
sock: TcpStream,
|
||||
host: String,
|
||||
port: u16,
|
||||
fronter: Arc<DomainFronter>,
|
||||
fronter: Option<Arc<DomainFronter>>,
|
||||
mitm: Arc<Mutex<MitmCertManager>>,
|
||||
rewrite_ctx: Arc<RewriteCtx>,
|
||||
) -> 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;
|
||||
}
|
||||
|
||||
// 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
|
||||
// protocol (SMTP, POP3, FTP banner) and jump straight to plain TCP.
|
||||
let mut peek_buf = [0u8; 8];
|
||||
@@ -496,7 +570,7 @@ async fn dispatch_tunnel(
|
||||
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.
|
||||
if peek_n > 0 && looks_like_http(&peek_buf[..peek_n]) {
|
||||
let scheme = if port == 443 { "https" } else { "http" };
|
||||
|
||||
+9
-1
@@ -14,12 +14,20 @@
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config::{Config, Mode};
|
||||
use crate::domain_fronter::DomainFronter;
|
||||
|
||||
const TEST_URL: &str = "https://api.ipify.org/?format=json";
|
||||
|
||||
pub async fn run(config: &Config) -> bool {
|
||||
if matches!(config.mode_kind(), Ok(Mode::GoogleOnly)) {
|
||||
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) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
|
||||
Reference in New Issue
Block a user