Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,10 @@ enum class UiLang { AUTO, FA, EN }
* 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).
* - [FULL] — full tunnel mode. ALL traffic is tunneled end-to-end through
* Apps Script + a remote tunnel node. No certificate installation needed.
*/
enum class Mode { APPS_SCRIPT, GOOGLE_ONLY }
enum class Mode { APPS_SCRIPT, GOOGLE_ONLY, FULL }

data class MhrvConfig(
val mode: Mode = Mode.APPS_SCRIPT,
Expand Down Expand Up @@ -147,6 +149,7 @@ data class MhrvConfig(
put("mode", when (mode) {
Mode.APPS_SCRIPT -> "apps_script"
Mode.GOOGLE_ONLY -> "google_only"
Mode.FULL -> "full"
})
put("listen_host", listenHost)
put("listen_port", listenPort)
Expand Down Expand Up @@ -231,6 +234,7 @@ object ConfigStore {
MhrvConfig(
mode = when (obj.optString("mode", "apps_script")) {
"google_only" -> Mode.GOOGLE_ONLY
"full" -> Mode.FULL
else -> Mode.APPS_SCRIPT
},
listenHost = obj.optString("listen_host", "127.0.0.1"),
Expand Down
12 changes: 2 additions & 10 deletions android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -90,19 +90,11 @@ class MhrvVpnService : VpnService() {
// `ForegroundServiceDidNotStartInTimeException`. Every `stopSelf()`
// path below MUST therefore happen after a `startForeground()`
// call — otherwise the user-visible symptom is "the app crashes
// the instant I tap Start". See issue #73: user configured
// google_only mode (no deployment ID needed), which tripped the
// old early-return-before-startForeground branch.
//
// We call startForeground immediately here with the notification
// used by the normal running state; if we bail out below, we
// tear the foreground service down in an orderly way.
// the instant I tap Start". See issue #73.
startForeground(NOTIF_ID, buildNotif(cfg.listenPort))

// Deployment ID + auth key are only required in apps_script mode.
// google_only mode (bootstrap / Telegram-only use cases) runs
// with neither. Closes #73 regression where google_only users
// hit this branch and crashed on startForeground timeout.
// google_only (bootstrap) and full (tunnel) modes run without them.
val needsAppsScriptCreds = cfg.mode == Mode.APPS_SCRIPT
if (needsAppsScriptCreds && (!cfg.hasDeploymentId || cfg.authKey.isBlank())) {
Log.e(TAG, "Config is incomplete — can't start proxy in apps_script mode")
Expand Down
13 changes: 11 additions & 2 deletions android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ fun HomeScreen(
Spacer(Modifier.height(4.dp))
SectionHeader(stringResource(R.string.sec_apps_script_relay))

val appsScriptEnabled = cfg.mode == Mode.APPS_SCRIPT
val appsScriptEnabled = cfg.mode == Mode.APPS_SCRIPT || cfg.mode == Mode.FULL
DeploymentIdsField(
urls = cfg.appsScriptUrls,
onChange = { persist(cfg.copy(appsScriptUrls = it)) },
Expand Down Expand Up @@ -418,6 +418,7 @@ fun HomeScreen(
},
enabled = (isVpnRunning ||
cfg.mode == Mode.GOOGLE_ONLY ||
cfg.mode == Mode.FULL ||
(cfg.hasDeploymentId && cfg.authKey.isNotBlank())) && !transitionCooldown,
colors = ButtonDefaults.buttonColors(
containerColor = if (isVpnRunning) ErrRed else OkGreen,
Expand Down Expand Up @@ -729,11 +730,13 @@ private fun ModeDropdown(
mode: Mode,
onChange: (Mode) -> Unit,
) {
val labelApps = "Apps Script (full)"
val labelApps = "Apps Script (MITM)"
val labelGoogle = "Google-only (bootstrap)"
val labelFull = "Full tunnel (no cert)"
val currentLabel = when (mode) {
Mode.APPS_SCRIPT -> labelApps
Mode.GOOGLE_ONLY -> labelGoogle
Mode.FULL -> labelFull
}
var expanded by remember { mutableStateOf(false) }

Expand Down Expand Up @@ -762,6 +765,10 @@ private fun ModeDropdown(
text = { Text(labelGoogle) },
onClick = { onChange(Mode.GOOGLE_ONLY); expanded = false },
)
DropdownMenuItem(
text = { Text(labelFull) },
onClick = { onChange(Mode.FULL); expanded = false },
)
}
}

Expand All @@ -770,6 +777,8 @@ private fun ModeDropdown(
"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."
Mode.FULL ->
"All traffic tunneled end-to-end through Apps Script + remote tunnel node. No certificate needed."
}
Text(
help,
Expand Down
208 changes: 208 additions & 0 deletions assets/apps_script/CodeFull.gs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* DomainFront Relay + Full Tunnel — Google Apps Script
*
* FOUR modes:
* 1. Single relay: POST { k, m, u, h, b, ct, r } → { s, h, b }
* 2. Batch relay: POST { k, q: [{m,u,h,b,ct,r}, ...] } → { q: [{s,h,b}, ...] }
* 3. Tunnel: POST { k, t, h, p, sid, d } → { sid, d, eof }
* 4. Tunnel batch: POST { k, t:"batch", ops:[...] } → { r: [...] }
*
* CHANGE THESE TO YOUR OWN VALUES!
*/

const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET";
const TUNNEL_SERVER_URL = "https://YOUR_TUNNEL_NODE_URL";
const TUNNEL_AUTH_KEY = "YOUR_TUNNEL_AUTH_KEY";

const SKIP_HEADERS = {
host: 1, connection: 1, "content-length": 1,
"transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1,
"priority": 1, te: 1,
};

// ========================== Entry point ==========================

function doPost(e) {
try {
var req = JSON.parse(e.postData.contents);
if (req.k !== AUTH_KEY) return _json({ e: "unauthorized" });

// Tunnel mode
if (req.t) return _doTunnel(req);

// Batch relay mode
if (Array.isArray(req.q)) return _doBatch(req.q);

// Single relay mode
return _doSingle(req);
} catch (err) {
return _json({ e: String(err) });
}
}

// ========================== Tunnel mode ==========================

function _doTunnel(req) {
// Batch tunnel: { k, t:"batch", ops:[...] }
if (req.t === "batch") {
return _doTunnelBatch(req);
}

// Single tunnel op
var payload = { k: TUNNEL_AUTH_KEY };
switch (req.t) {
case "connect":
payload.op = "connect";
payload.host = req.h;
payload.port = req.p;
break;
case "data":
payload.op = "data";
payload.sid = req.sid;
if (req.d) payload.data = req.d;
break;
case "close":
payload.op = "close";
payload.sid = req.sid;
break;
default:
return _json({ e: "unknown tunnel op: " + req.t });
}

var resp = UrlFetchApp.fetch(TUNNEL_SERVER_URL + "/tunnel", {
method: "post",
contentType: "application/json",
payload: JSON.stringify(payload),
muteHttpExceptions: true,
followRedirects: true,
});

if (resp.getResponseCode() !== 200) {
return _json({ e: "tunnel node HTTP " + resp.getResponseCode() });
}

return ContentService.createTextOutput(resp.getContentText())
.setMimeType(ContentService.MimeType.JSON);
}

// Batch tunnel: forward all ops in one request to /tunnel/batch
function _doTunnelBatch(req) {
var payload = {
k: TUNNEL_AUTH_KEY,
ops: req.ops || [],
};

var resp = UrlFetchApp.fetch(TUNNEL_SERVER_URL + "/tunnel/batch", {
method: "post",
contentType: "application/json",
payload: JSON.stringify(payload),
muteHttpExceptions: true,
followRedirects: true,
});

if (resp.getResponseCode() !== 200) {
return _json({ e: "tunnel batch HTTP " + resp.getResponseCode() });
}

return ContentService.createTextOutput(resp.getContentText())
.setMimeType(ContentService.MimeType.JSON);
}

// ========================== HTTP relay mode ==========================

function _doSingle(req) {
if (!req.u || typeof req.u !== "string" || !req.u.match(/^https?:\/\//i)) {
return _json({ e: "bad url" });
}
var opts = _buildOpts(req);
var resp = UrlFetchApp.fetch(req.u, opts);
return _json({
s: resp.getResponseCode(),
h: _respHeaders(resp),
b: Utilities.base64Encode(resp.getContent()),
});
}

function _doBatch(items) {
var fetchArgs = [];
var errorMap = {};
for (var i = 0; i < items.length; i++) {
var item = items[i];
if (!item.u || typeof item.u !== "string" || !item.u.match(/^https?:\/\//i)) {
errorMap[i] = "bad url";
continue;
}
var opts = _buildOpts(item);
opts.url = item.u;
fetchArgs.push({ _i: i, _o: opts });
}
var responses = [];
if (fetchArgs.length > 0) {
responses = UrlFetchApp.fetchAll(fetchArgs.map(function(x) { return x._o; }));
}
var results = [];
var rIdx = 0;
for (var i = 0; i < items.length; i++) {
if (errorMap.hasOwnProperty(i)) {
results.push({ e: errorMap[i] });
} else {
var resp = responses[rIdx++];
results.push({
s: resp.getResponseCode(),
h: _respHeaders(resp),
b: Utilities.base64Encode(resp.getContent()),
});
}
}
return _json({ q: results });
}

// ========================== Helpers ==========================

function _buildOpts(req) {
var opts = {
method: (req.m || "GET").toLowerCase(),
muteHttpExceptions: true,
followRedirects: req.r !== false,
validateHttpsCertificates: true,
escaping: false,
};
if (req.h && typeof req.h === "object") {
var headers = {};
for (var k in req.h) {
if (req.h.hasOwnProperty(k) && !SKIP_HEADERS[k.toLowerCase()]) {
headers[k] = req.h[k];
}
}
opts.headers = headers;
}
if (req.b) {
opts.payload = Utilities.base64Decode(req.b);
if (req.ct) opts.contentType = req.ct;
}
return opts;
}

function _respHeaders(resp) {
try {
if (typeof resp.getAllHeaders === "function") {
return resp.getAllHeaders();
}
} catch (err) {}
return resp.getHeaders();
}

function doGet(e) {
return HtmlService.createHtmlOutput(
"<!DOCTYPE html><html><head><title>My App</title></head>" +
'<body style="font-family:sans-serif;max-width:600px;margin:40px auto">' +
"<h1>Welcome</h1><p>This application is running normally.</p>" +
"</body></html>"
);
}

function _json(obj) {
return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(
ContentService.MimeType.JSON
);
}
12 changes: 12 additions & 0 deletions config.full.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"mode": "full",
"google_ip": "216.239.38.120",
"front_domain": "www.google.com",
"script_id": "YOUR_APPS_SCRIPT_DEPLOYMENT_ID",
"auth_key": "CHANGE_ME_TO_A_STRONG_SECRET",
"listen_host": "127.0.0.1",
"listen_port": 8085,
"socks5_port": 8086,
"log_level": "info",
"verify_ssl": true
}
26 changes: 20 additions & 6 deletions src/bin/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -684,21 +684,26 @@ impl eframe::App for App {
// 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."
"apps_script: DPI bypass via Apps Script relay (needs cert).\n\
full: tunnel ALL traffic through Apps Script + tunnel node (no cert needed).\n\
google_only: bootstrap — direct SNI-rewrite tunnel to *.google.com only."
), |ui| {
egui::ComboBox::from_id_source("mode")
.selected_text(match self.form.mode.as_str() {
"google_only" => "Google-only (bootstrap)",
_ => "Apps Script (full)",
"full" => "Full tunnel (no cert)",
_ => "Apps Script (MITM)",
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut self.form.mode,
"apps_script".into(),
"Apps Script (full)",
"Apps Script (MITM)",
);
ui.selectable_value(
&mut self.form.mode,
"full".into(),
"Full tunnel (no cert)",
);
ui.selectable_value(
&mut self.form.mode,
Expand All @@ -716,6 +721,15 @@ impl eframe::App for App {
.color(OK_GREEN));
});
}
if self.form.mode == "full" {
ui.horizontal(|ui| {
ui.add_space(120.0 + 8.0);
ui.small(egui::RichText::new(
"Full tunnel — all traffic tunneled end-to-end via Apps Script + remote tunnel node. No certificate needed.",
)
.color(OK_GREEN));
});
}
});

let google_only = self.form.mode == "google_only";
Expand Down
Loading