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:
dazzling-no-more
2026-04-23 16:28:47 +04:00
committed by GitHub
parent 5a108f73cb
commit b90b003cbc
13 changed files with 499 additions and 104 deletions
Generated
+1 -1
View File
@@ -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
View File
@@ -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"
+31
View File
@@ -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) بروید و آرشیو مناسب سیستم‌عامل خود را دانلود و از حالت فشرده خارج کنید:
+2 -2
View File
@@ -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.
// =========================================================================
+10
View File
@@ -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
}
+28
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) => {