Skip to content

Commit 36232f9

Browse files
authored
feat: Mode::Full + batch tunnel client (#94)
Adds a new `mode: full` that tunnels ALL traffic end-to-end through Apps Script → a remote tunnel node. Browser does TLS directly with the destination. No MITM, no CA installation needed on the client device. Ships as part of the 3-PR series: #93 (tunnel-node service + CodeFull.gs, merged) + this (Rust-side Mode::Full + batch tunnel client) + #95 (Android UI dropdown, now rolled into this PR post-rebase). ### Architecture - Client → mhrv-rs → script.google.com (Apps Script fetch) → tunnel-node on user's VPS → real destination - Apps Script is the transport to reach the VPS; works even when the ISP blocks direct VPS IPs - Batch multiplexer collects data from all active sessions and ships one Apps Script request per tick ### Safety properties of this merge - AppsScript + GoogleOnly dispatch paths are **unchanged**; Full mode is an additive branch at the top of `dispatch_tunnel`. - `tunnel_client.rs` is a new isolated module (387 LOC). - `tunnel_request()` is a new method on `DomainFronter`, no change to `relay()` / `relay_parallel_range()`. - Config: additive `Mode::Full` variant + validation tests (2 new); existing validation rules untouched. - Local build: clean compile. `cargo test --quiet`: 75 passed (73 → 75 with 2 new config tests). ### Closes Unblocks the feature requested in #61, #69, #100, #105, #110, #111, #113, #116. ### Testing vahidlazio has iterated on prior review feedback. End-to-end testing with a real tunnel-node deployment will follow post-merge from @Feiabyte (volunteered in #61). Post-merge CI will exercise compile + full test matrix across all targets; any regression caught there gets a fast-follow fix.
1 parent 9447400 commit 36232f9

13 files changed

Lines changed: 847 additions & 40 deletions

File tree

README.md

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ Firefox keeps its own cert store; the installer also drops the CA into Firefox's
160160

161161
Open the UI and fill in the form:
162162

163-
- **Apps Script ID** — the Deployment ID from Step 1. Comma-separate multiple IDs for round-robin rotation across several deployments (higher quota, more throughput).
163+
- **Apps Script ID** — the Deployment ID from Step 1. Add multiple IDs (one per line in the UI, or a JSON array in `config.json`) for higher quota **and** lower latency. In `apps_script` mode, IDs are round-robined. In `full` mode, more IDs directly increase the pipeline depth (see [Full tunnel mode](#full-tunnel-mode) below).
164164
- **Auth key** — the same secret you set in `Code.gs`.
165165
- **Google IP**`216.239.38.120` is a solid default. Use the **scan** button to probe for a faster one from your network.
166166
- **Front domain** — keep `www.google.com`.
@@ -262,6 +262,46 @@ Example config fragment (both UI and JSON):
262262

263263
HTTP/HTTPS continues to route through the Apps Script relay (no change), and the SNI-rewrite tunnel for `google.com` / `youtube.com` / etc. keeps bypassing both — so YouTube stays as fast as before while Telegram gets a real tunnel.
264264

265+
## Full tunnel mode
266+
267+
Full tunnel mode (`"mode": "full"`) routes **all** traffic end-to-end through Apps Script and a remote [tunnel-node](tunnel-node/) — no MITM certificate needed. The trade-off is higher latency per request (every byte goes Apps Script → tunnel-node → destination), but it works for every protocol and every app without CA installation.
268+
269+
### How deployment IDs affect performance
270+
271+
Each Apps Script batch request takes ~2 seconds round-trip. In full mode, `mhrv-rs` runs a **pipelined batch multiplexer** that fires multiple batch requests concurrently without waiting for the previous one to return. The number of in-flight batches (the *pipeline depth*) scales directly with the number of deployment IDs you configure:
272+
273+
```
274+
pipeline_depth = number_of_script_ids (clamped to 2..12)
275+
```
276+
277+
| Deployments | Pipeline depth | Effective batch interval | Notes |
278+
|-------------|---------------|------------------------|-------|
279+
| 1 | 2 | ~1.0s | Minimum — still pipelines 2 batches |
280+
| 3 | 3 | ~0.7s | Good for light browsing |
281+
| 6 | 6 | ~0.3s | Recommended for daily use |
282+
| 12 | 12 | ~0.17s | Maximum — diminishing returns past this |
283+
284+
More deployments = more concurrent batches = lower per-session latency. Each batch round-robins across your deployment IDs, so the load is spread evenly and you're less likely to hit a single deployment's quota ceiling.
285+
286+
**Resource guards** keep things safe:
287+
- **50 ops max** per batch — if more sessions are active, the mux splits into multiple batches
288+
- **4 MB payload cap** per batch — well under Apps Script's 50 MB limit
289+
- **30 s timeout** per batch — a slow/dead target can't block other sessions forever
290+
291+
### Quick start
292+
293+
1. Deploy [`CodeFull.gs`](assets/apps_script/CodeFull.gs) to 3–12 Google accounts (same steps as `Code.gs`, but use the full-mode script that forwards to your tunnel-node)
294+
2. Deploy the [tunnel-node](tunnel-node/) on a VPS
295+
3. Set `"mode": "full"` in your config with all deployment IDs:
296+
297+
```json
298+
{
299+
"mode": "full",
300+
"script_id": ["id1", "id2", "id3", "id4", "id5", "id6"],
301+
"auth_key": "your-secret"
302+
}
303+
```
304+
265305
## Running on OpenWRT (or any musl distro)
266306

267307
The `*-linux-musl-*` archives ship a fully static CLI that runs on OpenWRT, Alpine, and any libc-less Linux userland. Put the binary on the router and start it as a service:
@@ -569,6 +609,27 @@ Original project: <https://github.com/masterking32/MasterHttpRelayVPN> by [@mast
569609
۴. اگر نام جدیدی می‌خواهید اضافه کنید، در کادر پایین نام را بنویسید و **`+ Add`** بزنید — خودکار تست می‌شود
570610
۵. با **`Save config`** در پنجرهٔ اصلی ذخیره کنید
571611

612+
### حالت تونل کامل (Full tunnel mode)
613+
614+
حالت `"mode": "full"` **تمام** ترافیک را سرتاسر از طریق `Apps Script` و یک [tunnel-node](tunnel-node/) روی سرور شما عبور می‌دهد — **بدون نیاز به نصب گواهی `MITM`**. تنها هزینه‌اش تأخیر بیشتر است (هر بایت از مسیر `Apps Script → tunnel-node → مقصد` می‌رود)، اما برای هر پروتکل و هر برنامه بدون نصب `CA` کار می‌کند.
615+
616+
#### چرا تعداد `Deployment ID` مهم است؟
617+
618+
هر درخواست دسته‌ای (`batch`) به `Apps Script` حدود ۲ ثانیه طول می‌کشد. در حالت `full`، برنامه یک **لولهٔ موازی** (`pipeline`) اجرا می‌کند که چند درخواست دسته‌ای را همزمان می‌فرستد بدون اینکه منتظر پاسخ قبلی بماند. تعداد درخواست‌های همزمان مستقیماً با تعداد `Deployment ID`ها رابطه دارد:
619+
620+
```
621+
عمق لوله = تعداد Deployment IDها (حداقل ۲، حداکثر ۱۲)
622+
```
623+
624+
| تعداد Deployment | عمق لوله | فاصلهٔ مؤثر بین دسته‌ها | |
625+
|-----------------|----------|------------------------|---|
626+
| ۱ | ۲ | ~۱ ثانیه | حداقل |
627+
| ۳ | ۳ | ~۰.۷ ثانیه | مناسب مرور سبک |
628+
| ۶ | ۶ | ~۰.۳ ثانیه | توصیه‌شده برای استفادهٔ روزانه |
629+
| ۱۲ | ۱۲ | ~۰.۱۷ ثانیه | حداکثر |
630+
631+
بیشتر `Deployment` = بیشتر درخواست همزمان = تأخیر کمتر برای هر نشست. هر دسته بین `ID`ها چرخش می‌کند (`round-robin`)، پس بار به‌طور یکنواخت توزیع می‌شود.
632+
572633
### اجرا روی OpenWRT (روتر)
573634

574635
اگر می‌خواهید برنامه را روی روترتان اجرا کنید تا همهٔ دستگاه‌های شبکه از آن استفاده کنند، آرشیو `mhrv-rs-linux-musl-*.tar.gz` را دانلود کنید (این نسخه فایل اجرایی استاتیک دارد و بدون نصب هیچ وابستگی روی روتر کار می‌کند).

android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,10 @@ enum class UiLang { AUTO, FA, EN }
6868
* Google edge is active, so the user can reach `script.google.com` to
6969
* deploy Code.gs in the first place. No Deployment ID / Auth key needed.
7070
* Non-Google traffic goes direct (no relay).
71+
* - [FULL] — full tunnel mode. ALL traffic is tunneled end-to-end through
72+
* Apps Script + a remote tunnel node. No certificate installation needed.
7173
*/
72-
enum class Mode { APPS_SCRIPT, GOOGLE_ONLY }
74+
enum class Mode { APPS_SCRIPT, GOOGLE_ONLY, FULL }
7375

7476
data class MhrvConfig(
7577
val mode: Mode = Mode.APPS_SCRIPT,
@@ -147,6 +149,7 @@ data class MhrvConfig(
147149
put("mode", when (mode) {
148150
Mode.APPS_SCRIPT -> "apps_script"
149151
Mode.GOOGLE_ONLY -> "google_only"
152+
Mode.FULL -> "full"
150153
})
151154
put("listen_host", listenHost)
152155
put("listen_port", listenPort)
@@ -231,6 +234,7 @@ object ConfigStore {
231234
MhrvConfig(
232235
mode = when (obj.optString("mode", "apps_script")) {
233236
"google_only" -> Mode.GOOGLE_ONLY
237+
"full" -> Mode.FULL
234238
else -> Mode.APPS_SCRIPT
235239
},
236240
listenHost = obj.optString("listen_host", "127.0.0.1"),

android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -90,19 +90,11 @@ class MhrvVpnService : VpnService() {
9090
// `ForegroundServiceDidNotStartInTimeException`. Every `stopSelf()`
9191
// path below MUST therefore happen after a `startForeground()`
9292
// call — otherwise the user-visible symptom is "the app crashes
93-
// the instant I tap Start". See issue #73: user configured
94-
// google_only mode (no deployment ID needed), which tripped the
95-
// old early-return-before-startForeground branch.
96-
//
97-
// We call startForeground immediately here with the notification
98-
// used by the normal running state; if we bail out below, we
99-
// tear the foreground service down in an orderly way.
93+
// the instant I tap Start". See issue #73.
10094
startForeground(NOTIF_ID, buildNotif(cfg.listenPort))
10195

10296
// Deployment ID + auth key are only required in apps_script mode.
103-
// google_only mode (bootstrap / Telegram-only use cases) runs
104-
// with neither. Closes #73 regression where google_only users
105-
// hit this branch and crashed on startForeground timeout.
97+
// google_only (bootstrap) and full (tunnel) modes run without them.
10698
val needsAppsScriptCreds = cfg.mode == Mode.APPS_SCRIPT
10799
if (needsAppsScriptCreds && (!cfg.hasDeploymentId || cfg.authKey.isBlank())) {
108100
Log.e(TAG, "Config is incomplete — can't start proxy in apps_script mode")

android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ fun HomeScreen(
238238
Spacer(Modifier.height(4.dp))
239239
SectionHeader(stringResource(R.string.sec_apps_script_relay))
240240

241-
val appsScriptEnabled = cfg.mode == Mode.APPS_SCRIPT
241+
val appsScriptEnabled = cfg.mode == Mode.APPS_SCRIPT || cfg.mode == Mode.FULL
242242
DeploymentIdsField(
243243
urls = cfg.appsScriptUrls,
244244
onChange = { persist(cfg.copy(appsScriptUrls = it)) },
@@ -418,6 +418,7 @@ fun HomeScreen(
418418
},
419419
enabled = (isVpnRunning ||
420420
cfg.mode == Mode.GOOGLE_ONLY ||
421+
cfg.mode == Mode.FULL ||
421422
(cfg.hasDeploymentId && cfg.authKey.isNotBlank())) && !transitionCooldown,
422423
colors = ButtonDefaults.buttonColors(
423424
containerColor = if (isVpnRunning) ErrRed else OkGreen,
@@ -729,11 +730,13 @@ private fun ModeDropdown(
729730
mode: Mode,
730731
onChange: (Mode) -> Unit,
731732
) {
732-
val labelApps = "Apps Script (full)"
733+
val labelApps = "Apps Script (MITM)"
733734
val labelGoogle = "Google-only (bootstrap)"
735+
val labelFull = "Full tunnel (no cert)"
734736
val currentLabel = when (mode) {
735737
Mode.APPS_SCRIPT -> labelApps
736738
Mode.GOOGLE_ONLY -> labelGoogle
739+
Mode.FULL -> labelFull
737740
}
738741
var expanded by remember { mutableStateOf(false) }
739742

@@ -762,6 +765,10 @@ private fun ModeDropdown(
762765
text = { Text(labelGoogle) },
763766
onClick = { onChange(Mode.GOOGLE_ONLY); expanded = false },
764767
)
768+
DropdownMenuItem(
769+
text = { Text(labelFull) },
770+
onClick = { onChange(Mode.FULL); expanded = false },
771+
)
765772
}
766773
}
767774

@@ -770,6 +777,8 @@ private fun ModeDropdown(
770777
"Full DPI bypass through your deployed Apps Script relay."
771778
Mode.GOOGLE_ONLY ->
772779
"Bootstrap: reach *.google.com directly so you can open script.google.com and deploy Code.gs. Non-Google traffic goes direct."
780+
Mode.FULL ->
781+
"All traffic tunneled end-to-end through Apps Script + remote tunnel node. No certificate needed."
773782
}
774783
Text(
775784
help,

src/bin/ui.rs

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -694,21 +694,26 @@ impl eframe::App for App {
694694
// apps_script.
695695
section(ui, "Mode", |ui| {
696696
form_row(ui, "Mode", Some(
697-
"apps_script: full DPI bypass via your Apps Script relay.\n\
698-
google_only: bootstrap — direct SNI-rewrite tunnel to *.google.com \
699-
only (no relay, no script_id needed). Use this just long enough to \
700-
open https://script.google.com and deploy Code.gs."
697+
"apps_script: DPI bypass via Apps Script relay (needs cert).\n\
698+
full: tunnel ALL traffic through Apps Script + tunnel node (no cert needed).\n\
699+
google_only: bootstrap — direct SNI-rewrite tunnel to *.google.com only."
701700
), |ui| {
702701
egui::ComboBox::from_id_source("mode")
703702
.selected_text(match self.form.mode.as_str() {
704703
"google_only" => "Google-only (bootstrap)",
705-
_ => "Apps Script (full)",
704+
"full" => "Full tunnel (no cert)",
705+
_ => "Apps Script (MITM)",
706706
})
707707
.show_ui(ui, |ui| {
708708
ui.selectable_value(
709709
&mut self.form.mode,
710710
"apps_script".into(),
711-
"Apps Script (full)",
711+
"Apps Script (MITM)",
712+
);
713+
ui.selectable_value(
714+
&mut self.form.mode,
715+
"full".into(),
716+
"Full tunnel (no cert)",
712717
);
713718
ui.selectable_value(
714719
&mut self.form.mode,
@@ -726,6 +731,15 @@ impl eframe::App for App {
726731
.color(OK_GREEN));
727732
});
728733
}
734+
if self.form.mode == "full" {
735+
ui.horizontal(|ui| {
736+
ui.add_space(120.0 + 8.0);
737+
ui.small(egui::RichText::new(
738+
"Full tunnel — all traffic tunneled end-to-end via Apps Script + remote tunnel node. No certificate needed.",
739+
)
740+
.color(OK_GREEN));
741+
});
742+
}
729743
});
730744

731745
let google_only = self.form.mode == "google_only";

src/config.rs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ pub enum ConfigError {
2222
pub enum Mode {
2323
AppsScript,
2424
GoogleOnly,
25+
Full,
2526
}
2627

2728
impl Mode {
2829
pub fn as_str(self) -> &'static str {
2930
match self {
3031
Mode::AppsScript => "apps_script",
3132
Mode::GoogleOnly => "google_only",
33+
Mode::Full => "full",
3234
}
3335
}
3436
}
@@ -181,7 +183,7 @@ impl Config {
181183

182184
fn validate(&self) -> Result<(), ConfigError> {
183185
let mode = self.mode_kind()?;
184-
if mode == Mode::AppsScript {
186+
if mode == Mode::AppsScript || mode == Mode::Full {
185187
if self.auth_key.trim().is_empty() || self.auth_key == "CHANGE_ME_TO_A_STRONG_SECRET" {
186188
return Err(ConfigError::Invalid(
187189
"auth_key must be set to a strong secret".into(),
@@ -218,8 +220,9 @@ impl Config {
218220
match self.mode.as_str() {
219221
"apps_script" => Ok(Mode::AppsScript),
220222
"google_only" => Ok(Mode::GoogleOnly),
223+
"full" => Ok(Mode::Full),
221224
other => Err(ConfigError::Invalid(format!(
222-
"unknown mode '{}' (expected 'apps_script' or 'google_only')",
225+
"unknown mode '{}' (expected 'apps_script', 'google_only', or 'full')",
223226
other
224227
))),
225228
}
@@ -310,6 +313,28 @@ mod tests {
310313
cfg.validate().unwrap();
311314
}
312315

316+
#[test]
317+
fn parses_full_mode() {
318+
let s = r#"{
319+
"mode": "full",
320+
"auth_key": "MY_SECRET_KEY_123",
321+
"script_id": "ABCDEF"
322+
}"#;
323+
let cfg: Config = serde_json::from_str(s).unwrap();
324+
cfg.validate().unwrap();
325+
assert_eq!(cfg.mode_kind().unwrap(), Mode::Full);
326+
}
327+
328+
#[test]
329+
fn full_mode_requires_script_id() {
330+
let s = r#"{
331+
"mode": "full",
332+
"auth_key": "SECRET"
333+
}"#;
334+
let cfg: Config = serde_json::from_str(s).unwrap();
335+
assert!(cfg.validate().is_err());
336+
}
337+
313338
#[test]
314339
fn rejects_unknown_mode_value() {
315340
let s = r#"{

0 commit comments

Comments
 (0)