From b90b003cbc7fe5794ea9d7b4cefa00500ed15c67 Mon Sep 17 00:00:00 2001 From: dazzling-no-more <278675588+dazzling-no-more@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:28:47 +0400 Subject: [PATCH] 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> 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. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 31 ++++ android/app/build.gradle.kts | 4 +- .../java/com/therealaleph/mhrv/ConfigStore.kt | 26 +++- .../com/therealaleph/mhrv/ui/HomeScreen.kt | 74 ++++++++++ config.google-only.example.json | 10 ++ docs/changelog/v1.2.0.md | 28 ++++ src/bin/ui.rs | 137 +++++++++++++----- src/config.rs | 106 +++++++++++--- src/main.rs | 43 ++++-- src/proxy_server.rs | 130 +++++++++++++---- src/test_cmd.rs | 10 +- 13 files changed, 499 insertions(+), 104 deletions(-) create mode 100644 config.google-only.example.json create mode 100644 docs/changelog/v1.2.0.md diff --git a/Cargo.lock b/Cargo.lock index 5cd0fdb..00d12fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2186,7 +2186,7 @@ dependencies = [ [[package]] name = "mhrv-rs" -version = "1.1.5" +version = "1.2.0" dependencies = [ "base64 0.22.1", "bytes", diff --git a/Cargo.toml b/Cargo.toml index f4625c1..8b9ce60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 5e8c219..b04ea78 100644 --- a/README.md +++ b/README.md @@ -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: 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) بروید و آرشیو مناسب سیستم‌عامل خود را دانلود و از حالت فشرده خارج کنید: diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 89dc907..93c61e9 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt index a7d0b62..8c7ccdf 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt @@ -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 }, diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt index 9d80960..b0f6ca2 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt @@ -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, onChange: (List) -> 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. // ========================================================================= diff --git a/config.google-only.example.json b/config.google-only.example.json new file mode 100644 index 0000000..890f966 --- /dev/null +++ b/config.google-only.example.json @@ -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 +} diff --git a/docs/changelog/v1.2.0.md b/docs/changelog/v1.2.0.md new file mode 100644 index 0000000..5086c46 --- /dev/null +++ b/docs/changelog/v1.2.0.md @@ -0,0 +1,28 @@ + + +• حالت جدید «فقط گوگل» (بوت‌استرپ): دسترسی مستقیم به *.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. diff --git a/src/bin/ui.rs b/src/bin/ui.rs index 3d5389f..5daf4c4 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -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) { }; 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) { } } 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 impl FormState { fn to_config(&self) -> Result { - 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, rx: Receiver) { 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; diff --git a/src/config.rs b/src/config.rs index 3900bb6..2024135 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, #[serde(default)] pub script_ids: Option, + #[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 { + 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 { 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#"{ diff --git a/src/main.rs b/src/main.rs index df2e9eb..a3dd0c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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). diff --git a/src/proxy_server.rs b/src/proxy_server.rs index b564f7a..7ca7192 100644 --- a/src/proxy_server.rs +++ b/src/proxy_server.rs @@ -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, + /// `None` in `google_only` (bootstrap) mode: no Apps Script relay is + /// wired up, only the SNI-rewrite tunnel path is live. + fronter: Option>, mitm: Arc>, rewrite_ctx: Arc, } @@ -115,12 +117,27 @@ pub struct RewriteCtx { pub hosts: std::collections::HashMap, pub tls_connector: TlsConnector, pub upstream_socks5: Option, + pub mode: Mode, } impl ProxyServer { pub fn new(config: &Config, mitm: Arc>) -> Result { - 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 { + pub fn fronter(&self) -> Option> { 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, + fronter: Option>, mitm: Arc>, rewrite_ctx: Arc, ) -> 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, + fronter: Option>, mitm: Arc>, rewrite_ctx: Arc, ) -> std::io::Result<()> { @@ -444,7 +486,7 @@ async fn dispatch_tunnel( sock: TcpStream, host: String, port: u16, - fronter: Arc, + fronter: Option>, mitm: Arc>, rewrite_ctx: Arc, ) -> 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" }; diff --git a/src/test_cmd.rs b/src/test_cmd.rs index d084f2d..38f0fac 100644 --- a/src/test_cmd.rs +++ b/src/test_cmd.rs @@ -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) => {