fix: v1.9.6 — Code.gs/CodeFull.gs hardening, goog.script.init unwrap, README rewrite

Server-side (Apps Script) fixes — users replace their Code.gs with assets/apps_script/Code.gs (or CodeFull.gs for full mode) and Manage deployments → ✏️ → New version → Deploy:
- Removed duplicate doGet in Code.gs (HtmlService one was overriding ContentService one due to JS hoisting → every GET to /exec returned a goog.script.init iframe instead of the placeholder HTML)
- CodeFull.gs doGet switched from HtmlService to ContentService (same reason)
- SKIP_HEADERS now strips X-Forwarded-* / Forwarded / Via family — second line of defense to v1.2.9's client-side stripping (#104), in case a misconfigured upstream proxy adds these
- _doBatch fallback when UrlFetchApp.fetchAll() throws as a whole — per-item fetch on safe methods so one bad URL no longer poisons the entire batch (port from masterking32@3094288)

Client-side (Rust) defense-in-depth:
- parse_relay_json now unwraps goog.script.init("...userHtml...") if any deployment returns the iframe-wrapped form (legacy Code.gs, or a redirect that GETs doGet). New extract_apps_script_user_html + decode_js_string_escapes helpers. Tested against a real deployment's doGet response.

Docs:
- README rewritten as short bilingual landing page (English + Persian RTL) targeting normal users; advanced reference moved to docs/guide.md + docs/guide.fa.md.

Tests: 3 new regression tests. 176 lib + 33 tunnel-node tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
therealaleph
2026-05-01 19:00:13 +03:00
parent d336bd39e5
commit cbb08468bc
9 changed files with 1430 additions and 739 deletions
Generated
+1 -1
View File
@@ -2222,7 +2222,7 @@ dependencies = [
[[package]] [[package]]
name = "mhrv-rs" name = "mhrv-rs"
version = "1.9.5" version = "1.9.6"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "mhrv-rs" name = "mhrv-rs"
version = "1.9.5" version = "1.9.6"
edition = "2021" edition = "2021"
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting" description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
license = "MIT" license = "MIT"
+220 -691
View File
File diff suppressed because it is too large Load Diff
+75 -22
View File
@@ -61,14 +61,27 @@ const CACHE_DEFAULT_TTL_SECONDS = 86400; // 24-hour fallback when no Cache-Contr
// real-world Vary usage without inspecting the response. // real-world Vary usage without inspecting the response.
const VARY_KEY_HEADERS = ["accept-encoding", "accept-language"]; const VARY_KEY_HEADERS = ["accept-encoding", "accept-language"];
// Keep browser capability headers (sec-ch-ua*, sec-fetch-*) intact. // Connection-level + IP-leak request headers we strip before forwarding
// Some modern apps, notably Google Meet, use them for browser gating. // to the destination. Browser capability headers (sec-ch-ua*, sec-fetch-*)
// stay intact — modern apps like Google Meet use them for browser gating.
// We also drop the `X-Forwarded-*` / `Forwarded` / `Via` family so a
// misconfigured upstream proxy on the user side can't leak the user's
// real IP through the relay path. Mirrors upstream
// `masterking32/MasterHttpRelayVPN@3094288`.
const SKIP_HEADERS = { const SKIP_HEADERS = {
host: 1, connection: 1, "content-length": 1, host: 1, connection: 1, "content-length": 1,
"transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1, "transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1,
"priority": 1, te: 1, "priority": 1, te: 1,
"x-forwarded-for": 1, "x-forwarded-host": 1, "x-forwarded-proto": 1,
"x-forwarded-port": 1, "x-real-ip": 1, "forwarded": 1, "via": 1,
}; };
// Methods we consider safe to replay if `UrlFetchApp.fetchAll()` raises.
// GET/HEAD/OPTIONS are idempotent per RFC 9110; POST/PUT/PATCH/DELETE
// can have side-effects so we surface the error instead of silently
// re-firing them.
const SAFE_REPLAY_METHODS = { GET: 1, HEAD: 1, OPTIONS: 1 };
// Headers that disqualify a request from the cache path. // Headers that disqualify a request from the cache path.
const CACHE_BUSTING_HEADERS = { const CACHE_BUSTING_HEADERS = {
authorization: 1, cookie: 1, "x-api-key": 1, authorization: 1, cookie: 1, "x-api-key": 1,
@@ -168,37 +181,86 @@ function _doSingle(req) {
function _doBatch(items) { function _doBatch(items) {
var fetchArgs = []; var fetchArgs = [];
var fetchIndex = [];
var fetchMethods = [];
var errorMap = {}; var errorMap = {};
for (var i = 0; i < items.length; i++) { for (var i = 0; i < items.length; i++) {
var item = items[i]; var item = items[i];
if (!item || typeof item !== "object") {
errorMap[i] = "bad item";
continue;
}
if (!item.u || typeof item.u !== "string" || !item.u.match(/^https?:\/\//i)) { if (!item.u || typeof item.u !== "string" || !item.u.match(/^https?:\/\//i)) {
errorMap[i] = "bad url"; errorMap[i] = "bad url";
continue; continue;
} }
var opts = _buildOpts(item); try {
opts.url = item.u; var opts = _buildOpts(item);
fetchArgs.push({ _i: i, _o: opts }); opts.url = item.u;
fetchArgs.push(opts);
fetchIndex.push(i);
fetchMethods.push(String(item.m || "GET").toUpperCase());
} catch (buildErr) {
errorMap[i] = String(buildErr);
}
} }
// fetchAll() processes all requests in parallel inside Google // fetchAll() processes all requests in parallel inside Google. If it
// throws as a whole (e.g. one URL violates UrlFetchApp limits and
// poisons the whole batch), degrade to per-item fetch on safe methods
// so a single bad request does not zero out every response in the
// batch. Mirrors upstream `masterking32/MasterHttpRelayVPN@3094288`.
var responses = []; var responses = [];
if (fetchArgs.length > 0) { if (fetchArgs.length > 0) {
responses = UrlFetchApp.fetchAll(fetchArgs.map(function(x) { return x._o; })); try {
responses = UrlFetchApp.fetchAll(fetchArgs);
} catch (fetchAllErr) {
responses = [];
for (var j = 0; j < fetchArgs.length; j++) {
try {
if (!SAFE_REPLAY_METHODS[fetchMethods[j]]) {
errorMap[fetchIndex[j]] =
"batch fetchAll failed; unsafe method not replayed";
responses[j] = null;
continue;
}
var fallbackReq = fetchArgs[j];
var fallbackUrl = fallbackReq.url;
var fallbackOpts = {};
for (var key in fallbackReq) {
if (
Object.prototype.hasOwnProperty.call(fallbackReq, key) &&
key !== "url"
) {
fallbackOpts[key] = fallbackReq[key];
}
}
responses[j] = UrlFetchApp.fetch(fallbackUrl, fallbackOpts);
} catch (singleErr) {
errorMap[fetchIndex[j]] = String(singleErr);
responses[j] = null;
}
}
}
} }
var results = []; var results = [];
var rIdx = 0; var rIdx = 0;
for (var i = 0; i < items.length; i++) { for (var i = 0; i < items.length; i++) {
if (errorMap.hasOwnProperty(i)) { if (Object.prototype.hasOwnProperty.call(errorMap, i)) {
results.push({ e: errorMap[i] }); results.push({ e: errorMap[i] });
} else { } else {
var resp = responses[rIdx++]; var resp = responses[rIdx++];
results.push({ if (!resp) {
s: resp.getResponseCode(), results.push({ e: "fetch failed" });
h: _respHeaders(resp), } else {
b: Utilities.base64Encode(resp.getContent()), results.push({
}); s: resp.getResponseCode(),
h: _respHeaders(resp),
b: Utilities.base64Encode(resp.getContent()),
});
}
} }
} }
return _json({ q: results }); return _json({ q: results });
@@ -239,15 +301,6 @@ function _respHeaders(resp) {
return resp.getHeaders(); 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) { function _json(obj) {
return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType( return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(
ContentService.MimeType.JSON ContentService.MimeType.JSON
+86 -16
View File
@@ -29,12 +29,26 @@ const TUNNEL_AUTH_KEY = "YOUR_TUNNEL_AUTH_KEY";
// (Inspired by #365 Section 3, mhrv-rs v1.8.0+.) // (Inspired by #365 Section 3, mhrv-rs v1.8.0+.)
const DIAGNOSTIC_MODE = false; const DIAGNOSTIC_MODE = false;
// Connection-level + IP-leak request headers we strip before forwarding
// to the destination. UrlFetchApp rejects most of the connection-level
// names anyway, but we also drop the `X-Forwarded-*` / `Forwarded` /
// `Via` family so that a misconfigured upstream proxy on the user side
// can't leak the user's real IP through the relay path. Mirrors
// upstream `masterking32/MasterHttpRelayVPN@3094288`.
const SKIP_HEADERS = { const SKIP_HEADERS = {
host: 1, connection: 1, "content-length": 1, host: 1, connection: 1, "content-length": 1,
"transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1, "transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1,
"priority": 1, te: 1, "priority": 1, te: 1,
"x-forwarded-for": 1, "x-forwarded-host": 1, "x-forwarded-proto": 1,
"x-forwarded-port": 1, "x-real-ip": 1, "forwarded": 1, "via": 1,
}; };
// Methods we consider safe to replay if `UrlFetchApp.fetchAll()` raises.
// GET/HEAD/OPTIONS are idempotent per RFC 9110; POST/PUT/PATCH/DELETE
// can have side-effects so we surface the error instead of silently
// re-firing them.
const SAFE_REPLAY_METHODS = { GET: 1, HEAD: 1, OPTIONS: 1 };
// HTML body for the bad-auth decoy. Mimics a minimal Apps Script-style // HTML body for the bad-auth decoy. Mimics a minimal Apps Script-style
// placeholder page — no proxy-shaped JSON, nothing distinctive enough // placeholder page — no proxy-shaped JSON, nothing distinctive enough
// for a probe to fingerprint as a tunnel endpoint. // for a probe to fingerprint as a tunnel endpoint.
@@ -279,33 +293,85 @@ function _doSingle(req) {
function _doBatch(items) { function _doBatch(items) {
var fetchArgs = []; var fetchArgs = [];
var fetchIndex = [];
var fetchMethods = [];
var errorMap = {}; var errorMap = {};
for (var i = 0; i < items.length; i++) { for (var i = 0; i < items.length; i++) {
var item = items[i]; var item = items[i];
if (!item || typeof item !== "object") {
errorMap[i] = "bad item";
continue;
}
if (!item.u || typeof item.u !== "string" || !item.u.match(/^https?:\/\//i)) { if (!item.u || typeof item.u !== "string" || !item.u.match(/^https?:\/\//i)) {
errorMap[i] = "bad url"; errorMap[i] = "bad url";
continue; continue;
} }
var opts = _buildOpts(item); try {
opts.url = item.u; var opts = _buildOpts(item);
fetchArgs.push({ _i: i, _o: opts }); opts.url = item.u;
fetchArgs.push(opts);
fetchIndex.push(i);
fetchMethods.push(String(item.m || "GET").toUpperCase());
} catch (buildErr) {
errorMap[i] = String(buildErr);
}
} }
// fetchAll() runs all requests in parallel inside Google. If it
// throws as a whole (e.g. one URL violates UrlFetchApp limits and
// poisons the whole batch), degrade to per-item fetch so a single
// bad request does not zero out the entire batch's responses.
// Mirrors upstream `masterking32/MasterHttpRelayVPN@3094288`.
var responses = []; var responses = [];
if (fetchArgs.length > 0) { if (fetchArgs.length > 0) {
responses = UrlFetchApp.fetchAll(fetchArgs.map(function(x) { return x._o; })); try {
responses = UrlFetchApp.fetchAll(fetchArgs);
} catch (fetchAllErr) {
responses = [];
for (var j = 0; j < fetchArgs.length; j++) {
try {
if (!SAFE_REPLAY_METHODS[fetchMethods[j]]) {
errorMap[fetchIndex[j]] =
"batch fetchAll failed; unsafe method not replayed";
responses[j] = null;
continue;
}
var fallbackReq = fetchArgs[j];
var fallbackUrl = fallbackReq.url;
var fallbackOpts = {};
for (var key in fallbackReq) {
if (
Object.prototype.hasOwnProperty.call(fallbackReq, key) &&
key !== "url"
) {
fallbackOpts[key] = fallbackReq[key];
}
}
responses[j] = UrlFetchApp.fetch(fallbackUrl, fallbackOpts);
} catch (singleErr) {
errorMap[fetchIndex[j]] = String(singleErr);
responses[j] = null;
}
}
}
} }
var results = []; var results = [];
var rIdx = 0; var rIdx = 0;
for (var i = 0; i < items.length; i++) { for (var i = 0; i < items.length; i++) {
if (errorMap.hasOwnProperty(i)) { if (Object.prototype.hasOwnProperty.call(errorMap, i)) {
results.push({ e: errorMap[i] }); results.push({ e: errorMap[i] });
} else { } else {
var resp = responses[rIdx++]; var resp = responses[rIdx++];
results.push({ if (!resp) {
s: resp.getResponseCode(), results.push({ e: "fetch failed" });
h: _respHeaders(resp), } else {
b: Utilities.base64Encode(resp.getContent()), results.push({
}); s: resp.getResponseCode(),
h: _respHeaders(resp),
b: Utilities.base64Encode(resp.getContent()),
});
}
} }
} }
return _json({ q: results }); return _json({ q: results });
@@ -346,13 +412,17 @@ function _respHeaders(resp) {
return resp.getHeaders(); return resp.getHeaders();
} }
// `doGet` is what active scanners hit first (HTTP GET probes are cheaper
// than POSTs). We use ContentService here so the response body is the
// raw HTML we wrote — `HtmlService.createHtmlOutput` would wrap it in
// a `goog.script.init` sandbox iframe, which the Rust client would then
// see if it ever GET-followed a redirect back onto /macros/.../exec
// (decoy/no-json error path). ContentService keeps the doGet response
// indistinguishable from a forgotten static-HTML web app.
function doGet(e) { function doGet(e) {
return HtmlService.createHtmlOutput( return ContentService
"<!DOCTYPE html><html><head><title>My App</title></head>" + .createTextOutput(DECOY_HTML)
'<body style="font-family:sans-serif;max-width:600px;margin:40px auto">' + .setMimeType(ContentService.MimeType.HTML);
"<h1>Welcome</h1><p>This application is running normally.</p>" +
"</body></html>"
);
} }
function _json(obj) { function _json(obj) {
+18
View File
@@ -0,0 +1,18 @@
<!-- see docs/changelog/v1.1.0.md for the file format: Persian, then `---`, then English. -->
• Code.gs / CodeFull.gs hardening + باگ‌فیکس (هیچ تغییری در کانفیگ کاربر لازم نیست — فقط Code.gs خودتان را با [`assets/apps_script/Code.gs`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/assets/apps_script/Code.gs) (یا `CodeFull.gs` برای حالت full) جایگزین کنید + در Apps Script editor: `Manage deployments → ✏️ → Version: New version → Deploy`. Deployment ID همان قبلی می‌ماند):
- **`Code.gs` doGet تکراری حذف شد**: نسخه‌ای که با `HtmlService.createHtmlOutput` تعریف شده بود به‌خاطر hoisting جاوااسکریپت روی نسخهٔ صحیح `ContentService` overwrite می‌کرد. در نتیجه هر GET به URL deployment پاسخ سندباکس `goog.script.init` iframe برمی‌گرداند به‌جای HTML پلیس‌هولدر ساده. این برای ترافیک معمولی POST تأثیری نداشت ولی در زنجیرهٔ redirect که با GET پی می‌گیریم می‌توانست باگ ظاهر شود.
- **`CodeFull.gs` `doGet` به `ContentService` تغییر کرد** (قبلاً `HtmlService` بود) — به همان دلیل بالا.
- **هدرهای IP-leak در `SKIP_HEADERS` اضافه شد** (`X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Forwarded-Port`, `X-Real-IP`, `Forwarded`, `Via`) — در صورت misconfigured بودن یک پروکسی upstream سمت کاربر، IP واقعی کاربر دیگر در leg دوم سرور به مقصد نشت نمی‌کند. لایهٔ دفاع دوم به stripping سمت کلاینت v1.2.9 (#104).
- **`_doBatch` دارای fallback شد**: اگر `UrlFetchApp.fetchAll()` به‌عنوان یک کل throw کند (مثلاً یک URL بد همه را poison کند)، حالا برای متدهای امن (GET / HEAD / OPTIONS) per-item fetch می‌کند به‌جای صفر کردن کل پاسخ batch. port از `masterking32/MasterHttpRelayVPN@3094288`.
`parse_relay_json` (سمت Rust): unwrapper برای `goog.script.init("...userHtml...")` اضافه شد — اگر هر deployment‌ای پاسخ HtmlService-wrapped برگرداند (legacy Code.gs قبل از v1.9.6، یا redirect که doGet را GET بزند)، client حالا JSON داخلی را استخراج می‌کند به‌جای `key must be a string at line 2 column 1` fail کردن. در مقابل پاسخ doGet واقعی deployment کاربر تست شده — UTF-8 با `\xNN` byte-escape را درست decode می‌کند.
• README بازنویسی شد: نسخهٔ کوتاه دوزبانه (انگلیسی + فارسی RTL) برای کاربر معمولی + راهنمای کامل پیشرفته در [`docs/guide.md`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/docs/guide.md) و [`docs/guide.fa.md`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/docs/guide.fa.md). جدا کردن "راه‌اندازی ۵ دقیقه‌ای" از "همهٔ گزینه‌ها و troubleshooting" راهنما را خیلی قابل‌فهم‌تر کرد، خصوصاً برای کاربرانی که می‌خواهند فقط شروع کنند.
• تست: ۳ regression test جدید برای `extract_apps_script_user_html` + `decode_js_string_escapes` + `parse_relay_json` end-to-end. **۱۷۶ lib test + ۳۳ tunnel-node test همه pass.**
---
• Code.gs / CodeFull.gs hardening + bug fixes (no client config change needed — just replace your own Code.gs with [`assets/apps_script/Code.gs`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/assets/apps_script/Code.gs) (or `CodeFull.gs` for full mode) and in the Apps Script editor: `Manage deployments → ✏️ → Version: New version → Deploy`. Your Deployment ID stays the same):
- **Removed duplicate `doGet` in `Code.gs`**: a second copy declared with `HtmlService.createHtmlOutput` was silently overriding the correct `ContentService` one due to JS function hoisting. Result: every GET to the deployment URL was returning the `goog.script.init` sandbox iframe instead of the simple placeholder HTML. Did not affect normal POST traffic, but could surface during redirect chains we GET-follow.
- **`CodeFull.gs` `doGet` switched to `ContentService`** (was `HtmlService`) — same reason as above.
- **Added IP-leak headers to `SKIP_HEADERS`** (`X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Forwarded-Port`, `X-Real-IP`, `Forwarded`, `Via`) — if a misconfigured upstream proxy on the user side adds these, the user's real IP no longer leaks to the destination on the server-side leg. Second line of defense to v1.2.9's client-side stripping (#104).
- **`_doBatch` got a fallback path**: if `UrlFetchApp.fetchAll()` throws as a whole (e.g. one bad URL poisons the batch), it now per-item-fetches safe methods (GET / HEAD / OPTIONS) instead of zeroing the entire batch's responses. Ported from `masterking32/MasterHttpRelayVPN@3094288`.
`parse_relay_json` (Rust client): added unwrapper for `goog.script.init("...userHtml...")` iframe — if any deployment ever returns an HtmlService-wrapped response (legacy Code.gs prior to v1.9.6, or a redirect that GET-hits doGet), the client now extracts the inner JSON instead of failing with `key must be a string at line 2 column 1`. Tested against a real user deployment's actual doGet output — correctly decodes UTF-8 with `\xNN` byte-escapes.
• Rewrote the README: short bilingual landing page (English + Persian RTL) for normal users, with the full advanced reference moved to [`docs/guide.md`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/docs/guide.md) and [`docs/guide.fa.md`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/docs/guide.fa.md). Splitting "5-minute quick start" from "every option + troubleshooting" makes the docs much more approachable, especially for users who just want to get running.
• Tests: 3 new regression tests for `extract_apps_script_user_html` + `decode_js_string_escapes` + `parse_relay_json` end-to-end. **176 lib tests + 33 tunnel-node tests all passing.**
+420
View File
@@ -0,0 +1,420 @@
<div dir="rtl">
# mhrv-rs — راهنمای کامل
این نسخهٔ کامل و فنی است — همهٔ گزینه‌های کانفیگ، همهٔ حالت‌های پیشرفته، همهٔ راه‌های رفع اشکال. برای راه‌اندازی ۵ دقیقه‌ای، [README اصلی](../README.md) را ببین.
[English version](guide.md)
## فهرست
- [نگاه دقیق به نحوهٔ کارکرد](#نگاه-دقیق-به-نحوهٔ-کارکرد)
- [پلتفرم‌ها و فایل‌های اجرایی](#پلتفرم‌ها-و-فایل‌های-اجرایی)
- [محل ذخیرهٔ فایل‌ها](#محل-ذخیرهٔ-فایل‌ها)
- [دیپلوی Apps Script](#دیپلوی-apps-script)
- [نسخهٔ Cloudflare Worker (سریع‌تر)](#نسخهٔ-cloudflare-worker)
- [حالت direct (وقتی ISP خود `script.google.com` را بسته)](#حالت-direct)
- [مرجع CLI](#مرجع-cli)
- [حالت scan-ips با API](#حالت-scan-ips-با-api)
- [تلگرام با xray](#تلگرام-با-xray)
- [حالت تونل کامل](#حالت-تونل-کامل)
- [تأثیر تعداد Deployment](#تأثیر-تعداد-deployment)
- [راه‌اندازی سریع](#راه‌اندازی-سریع-حالت-full)
- [Exit node — برای ChatGPT / Claude / Grok](#exit-node)
- [اشتراک‌گذاری از طریق هات‌اسپات](#اشتراک‌گذاری-هات‌اسپات)
- [اجرا روی OpenWRT](#اجرا-روی-openwrt)
- [ابزارهای تشخیص](#ابزارهای-تشخیص)
- [ویرایشگر SNI pool](#ویرایشگر-sni-pool)
- [چه چیز پیاده شده و چه چیز نه](#چه-چیز-پیاده-شده-و-چه-چیز-نه)
- [محدودیت‌های شناخته‌شده](#محدودیت‌های-شناخته‌شده)
- [امنیت](#امنیت)
- [سؤالات رایج](#سؤالات-رایج)
## نگاه دقیق به نحوهٔ کارکرد
```
مرورگر / تلگرام / xray
|
| HTTP proxy (8085) یا SOCKS5 (8086)
v
mhrv-rs (محلی)
|
| TLS به IP گوگل، SNI = www.google.com
v ^
DPI می‌بیند: www.google.com |
| | Host: script.google.com (داخل TLS)
v |
لبهٔ گوگل ----------------------+
|
v
رلهٔ Apps Script (حساب رایگان شما)
|
v
مقصد واقعی
```
DPI سانسورگر فقط SNI داخل TLS را می‌بیند و اجازه می‌دهد `www.google.com` رد شود. لبهٔ گوگل هم `www.google.com` و هم `script.google.com` را روی یک IP سرو می‌کند و بر اساس هدر HTTP `Host` داخل تونل رمزشده آن‌ها را تفکیک می‌کند.
برای دامنه‌های متعلق به گوگل (`google.com`, `youtube.com`, `fonts.googleapis.com`, …) همان تونل مستقیم استفاده می‌شود — بدون رلهٔ Apps Script. این کار سهمیهٔ هر-fetch را دور می‌زند و مشکل قفل‌بودنِ User-Agent روی `Google-Apps-Script` را برای آن سایت‌ها برطرف می‌کند. برای اضافه کردن دامنه‌های دیگر از فیلد `hosts` در `config.json` استفاده کن.
## پلتفرم‌ها و فایل‌های اجرایی
لینوکس (x86_64، aarch64)، مک (x86_64، aarch64)، ویندوز (x86_64)، **اندروید ۷.۰ به بالا** (APK جهانی شامل arm64، armv7، x86_64، x86). فایل‌های آماده در [صفحهٔ releases](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases).
**اندروید:** فایل `mhrv-rs-android-universal-v*.apk` را دانلود کن. راهنمای کامل در [docs/android.fa.md](android.fa.md) (فارسی) یا [docs/android.md](android.md) (انگلیسی). نسخهٔ اندروید همان `mhrv-rs` Rust دسکتاپ را اجرا می‌کند (از طریق JNI) به‌علاوهٔ پل TUN با `tun2proxy` تا تمام برنامه‌های دستگاه بدون نیاز به تنظیم per-app از پروکسی رد شوند.
> **نکتهٔ مهم اندروید (issueهای [#74](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/74) و [#81](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/81)):** TUN تمام ترافیک IP را می‌گیرد، اما HTTPS از برنامه‌های third-party فقط برای برنامه‌هایی کار می‌کند که به CAهای نصب‌شدهٔ کاربر اعتماد می‌کنند. از اندروید ۷ به بعد، برنامه‌ها باید با `networkSecurityConfig` صراحتاً اعلام کنند. **کروم و فایرفاکس می‌کنند**؛ **تلگرام، واتس‌اَپ، اینستاگرام، یوتیوب، برنامه‌های بانکی، بازی‌ها** نمی‌کنند. برای آن‌ها: حالت `PROXY_ONLY` و در داخل برنامه `127.0.0.1:1081` (SOCKS5)، یا حالت `google_only` (بدون CA، فقط سرویس‌های گوگل)، یا `upstream_socks5` به یک VPS خارجی. این طراحی امنیتی اندروید است نه باگ این برنامه.
### محتوای هر release
هر آرشیو شامل:
| فایل | کاربرد |
|---|---|
| `mhrv-rs` / `mhrv-rs.exe` | CLI. استفادهٔ headless، سرور، اتوماسیون. روی مک / ویندوز بدون وابستگی سیستمی. |
| `mhrv-rs-ui` / `mhrv-rs-ui.exe` | UI دسکتاپ (egui). فرم کانفیگ، دکمه‌های Start / Stop / Test، آمار زنده، پنل log. |
| `run.sh` / `run.command` / `run.bat` | راه‌انداز پلتفرم: گواهی MITM را نصب می‌کند (نیاز به sudo / admin) و UI را باز می‌کند. در اولین اجرا از این استفاده کن. |
آرشیوهای مک شامل `mhrv-rs.app` (در `*-app.zip`) هم هستند — در Finder دو بار کلیک کن. یک‌بار `mhrv-rs --install-cert` یا `run.command` را اجرا کن تا CA نصب شود.
<p align="center"><img src="ui-screenshot.png" alt="UI دسکتاپ mhrv-rs شامل فرم کانفیگ، آمار ترافیک زنده، دکمه‌های Start/Stop/Test و پنل log" width="420"></p>
UI لینوکس به این کتابخانه‌ها نیاز دارد: `libxkbcommon`, `libwayland-client`, `libxcb`, `libgl`, `libx11`, `libgtk-3`. روی اکثر توزیع‌های دسکتاپی از قبل نصب‌اند؛ روی سیستم headless یا با package manager نصب کن یا از CLI استفاده کن.
## محل ذخیرهٔ فایل‌ها
کانفیگ و گواهی MITM در دایرکتوری user-data سیستم‌عامل قرار می‌گیرند:
- مک: `~/Library/Application Support/mhrv-rs/`
- لینوکس: `~/.config/mhrv-rs/`
- ویندوز: `%APPDATA%\mhrv-rs\`
داخل آن دایرکتوری:
- `config.json` — تنظیمات تو (با دکمهٔ Save در UI نوشته می‌شود یا دستی)
- `ca/ca.crt`, `ca/ca.key` — گواهی root MITM. کلید خصوصی فقط در دست توست.
CLI همچنین برای سازگاری با راه‌اندازی‌های قدیمی، روی `./config.json` در دایرکتوری جاری هم fallback دارد.
## دیپلوی Apps Script
نسخهٔ ۵ دقیقه‌ای در [README اصلی](../README.md#مرحلهٔ-۱--ساخت-اسکریپت-گوگل-یک‌بار) است. این بخش به نسخه‌های جایگزین می‌پردازد.
### نسخهٔ Cloudflare Worker
یک نسخهٔ جایگزین در [`assets/apps_script/Code.cfw.gs`](../assets/apps_script/Code.cfw.gs) به‌همراه [`assets/cloudflare/worker.js`](../assets/cloudflare/worker.js) وجود دارد که Apps Script را به یک رلهٔ نازک تبدیل می‌کند و کار `fetch` واقعی را به یک Cloudflare Worker که خودت دیپلوی می‌کنی می‌سپارد. **سود روز اول:** کاهش تأخیر (~۱۰ تا ۵۰ میلی‌ثانیه روی لبهٔ CF در مقابل ۲۵۰ تا ۵۰۰ میلی‌ثانیه Apps Script — برای مرور وب و تلگرام محسوس).
سهمیهٔ روزانهٔ ۲۰٬۰۰۰ `UrlFetchApp` را کاهش **نمی‌دهد**، چون امروز mhrv-rs همیشه درخواست تک‌URL می‌فرستد؛ مسیر دسته‌ای روی GAS+Worker سیم‌کشی شده (`ceil(N/40)` سهمیه به‌ازای دستهٔ N) ولی هیچ کلاینتی فعلاً تولیدش نمی‌کند.
**مبادلات:**
- ویدیوی طولانی یوتیوب بدتر است (دیوار ۳۰ ثانیه به جای ۶ دقیقه)
- ضدبات Cloudflare را حل نمی‌کند
- **با `mode: "full"` سازگار نیست** (پشتیبانی tunnel-ops ندارد → برای واتس‌اَپ / مسنجرها روی اندروید Full mode کمک نمی‌کند)
راهنمای کامل و جدول مبادلات در [`assets/cloudflare/README.fa.md`](../assets/cloudflare/README.fa.md). در mhrv-rs هیچ تنظیمی تغییر نمی‌کند — همان `mode: "apps_script"`، همان `script_id`، همان `auth_key`.
### حالت direct
اگر ISP تو از قبل Apps Script (یا کل گوگل) را مسدود کرده، باید مرحلهٔ ۱ **اول** موفق شود — قبل از این‌که رله‌ای داشته باشی. mhrv-rs یک حالت `direct` دقیقاً برای این دارد — فقط تونل بازنویسی SNI، بدون رلهٔ Apps Script. (قبل از v1.9 نام `google_only` داشت — نام قدیمی هم پذیرفته می‌شود.)
۱. فایل اجرایی را دانلود کن (طبق [مرحلهٔ ۲ در README](../README.md#مرحلهٔ-۲--دانلود-mhrv-rs))
۲. فایل [`config.direct.example.json`](../config.direct.example.json) را در کنار فایل اجرا با نام `config.json` کپی کن — نه `script_id` نیاز است نه `auth_key`
۳. `mhrv-rs serve` را اجرا کن و HTTP proxy مرورگرت را روی `127.0.0.1:8085` بگذار
۴. در حالت `direct`، پروکسی فقط `*.google.com`، `*.youtube.com` و سایر میزبان‌های لبهٔ گوگل (به‌علاوهٔ هر [`fronting_groups`](fronting-groups.md) که تنظیم کرده باشی) را از تونل بازنویسی 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` نیاز دارد.
## مرجع CLI
تمام کاری که UI می‌کند را CLI هم می‌کند. `config.example.json` را به `config.json` کپی کن:
```json
{
"mode": "apps_script",
"google_ip": "216.239.38.120",
"front_domain": "www.google.com",
"script_id": "PASTE_YOUR_DEPLOYMENT_ID_HERE",
"auth_key": "same-secret-as-in-code-gs",
"listen_host": "127.0.0.1",
"listen_port": 8085,
"socks5_port": 8086,
"log_level": "info",
"verify_ssl": true
}
```
سپس:
```bash
./mhrv-rs # اجرای پروکسی (پیش‌فرض)
./mhrv-rs test # تست یک درخواست کامل
./mhrv-rs scan-ips # رتبه‌بندی IPهای گوگل بر اساس سرعت
./mhrv-rs test-sni # تست نام‌های SNI روی google_ip
./mhrv-rs --install-cert # نصب مجدد گواهی
./mhrv-rs --remove-cert # حذف کامل: trust store + پوشهٔ ca/
./mhrv-rs --help
```
`--remove-cert` گواهی را از trust store سیستم پاک می‌کند، با بررسی نام تأیید می‌کند که حذف انجام شد، و پوشهٔ `ca/` روی دیسک را حذف می‌کند. پاک‌سازی NSS (فایرفاکس و کروم لینوکس) best-effort است: اگر `certutil` نباشد یا یکی از مرورگرها پایگاه داده NSS را قفل کرده باشد، ابزار راهنمای پاک‌سازی دستی نشان می‌دهد. `config.json` و دیپلوی Apps Script دست‌نخورده می‌مانند، پس CA تازه نیازی به دیپلوی مجدد `Code.gs` ندارد.
`script_id` می‌تواند JSON array باشد: `["id1", "id2", "id3"]`.
### حالت scan-ips با API
به‌طور پیش‌فرض، `scan-ips` از یک لیست ثابت استفاده می‌کند. کشف پویای IP را در `config.json` فعال کن:
```json
{
"fetch_ips_from_api": true,
"max_ips_to_scan": 100,
"scan_batch_size": 100,
"google_ip_validation": true
}
```
وقتی فعال است:
- فایل `goog.json` را از API محدوده‌های IP عمومی گوگل می‌گیرد
- CIDRها را به IP تک‌تک گسترش می‌دهد
- به IPهای دامنه‌های معروف گوگل اولویت می‌دهد (google.com، youtube.com، …)
- به‌طور تصادفی تا `max_ips_to_scan` کاندید انتخاب می‌کند (اولویت‌داران اول)
- فقط کاندیدها را برای اتصال و اعتبارسنجی frontend تست می‌کند
ممکن است IPهایی پیدا کنی که سریع‌تر از لیست ثابت‌اند، اما تضمینی نیست همه کار کنند.
## تلگرام با xray
رلهٔ Apps Script فقط HTTP request/response می‌فهمد، پس پروتکل‌های غیر-HTTP (MTProto تلگرام، IMAP، SSH، TCP خام) نمی‌توانند از آن رد شوند. بدون چیز دیگری، این جریان‌ها به fallback مستقیم TCP می‌خورند — یعنی واقعاً tunnel نشده‌اند، و ISP که تلگرام را بسته همچنان می‌بندد.
**راه‌حل:** یک [xray](https://github.com/XTLS/Xray-core) (یا v2ray / sing-box) محلی با outbound VLESS / Trojan / Shadowsocks به VPS شخصی خودت اجرا کن، و mhrv-rs را با فیلد **Upstream SOCKS5** (یا کلید `upstream_socks5`) به SOCKS5 inbound آن xray وصل کن. وقتی تنظیم شد، جریان‌های TCP خام که از SOCKS5 listener mhrv-rs می‌آیند به xray → تونل واقعی زنجیر می‌شوند.
```
تلگرام ┐ ┌─ Apps Script ── HTTP/HTTPS
├─ SOCKS5 :8086 ─┤ mhrv-rs ├─ بازنویسی SNI ───────── google.com, youtube.com, …
مرورگر ┘ └─ upstream SOCKS5 ─ xray ── VLESS ── VPS تو (تلگرام، IMAP، SSH، TCP خام)
```
قطعهٔ کانفیگ:
```json
{
"upstream_socks5": "127.0.0.1:50529"
}
```
HTTP / HTTPS مثل قبل از Apps Script می‌رود (تغییری نمی‌کند)، تونل بازنویسی SNI برای `google.com` / `youtube.com` همچنان از هر دو دور می‌زند — یوتیوب به سرعت قبل می‌ماند و تلگرام هم تونل واقعی پیدا می‌کند.
## حالت تونل کامل
`"mode": "full"` **تمام** ترافیک را end-to-end از Apps Script و یک [tunnel-node](../tunnel-node/) راه دور رد می‌کند — بدون نیاز به نصب گواهی MITM. TCP به‌صورت سشن‌های پایدار تونل، و UDP از کلاینت‌های اندروید / TUN از طریق SOCKS5 `UDP ASSOCIATE` به tunnel-node که UDP واقعی را از سمت سرور منتشر می‌کند. مبادله: تأخیر بیشتر هر درخواست (هر بایت Apps Script → tunnel-node → مقصد می‌رود)، اما برای هر پروتکل و هر برنامه‌ای بدون نصب CA کار می‌کند.
### تأثیر تعداد Deployment
هر دور بَچ Apps Script حدود ۲ ثانیه طول می‌کشد. در Full mode، mhrv-rs یک **مالتی‌پلکسر بَچ پیپلاین‌شده** اجرا می‌کند که چند بَچ همزمان می‌فرستد بدون اینکه منتظر پاسخ قبلی بماند. هر Deployment ID (= یک حساب گوگل) حوضچهٔ همزمانی مخصوص خودش با **۳۰ درخواست فعال** دارد — مطابق سقف اجرای همزمان Apps Script per-account.
```
حداکثر همزمانی = ۳۰ × تعداد Deployment IDها
```
| Deployment | همزمانی | |
|---|---|---|
| ۱ | ۳۰ | یک حساب — برای مرور سبک کافی |
| ۳ | ۹۰ | مناسب استفادهٔ روزانه |
| ۶ | ۱۸۰ | توصیه‌شده برای استفادهٔ سنگین |
| ۱۲ | ۳۶۰ | چند حساب — حداکثر توان |
بیشتر Deployment = همزمانی بیشتر = تأخیر کمتر هر سشن. هر بَچ بین IDها چرخش می‌کند و بار به‌طور یکنواخت توزیع می‌شود، احتمال رسیدن به سقف سهمیهٔ یک Deployment کاهش می‌یابد.
**محافظ‌های منابع:**
- **حداکثر ۵۰ op** در هر بَچ — اگر سشن‌های فعال بیشتر باشند، مالتی‌پلکسر چند بَچ می‌فرستد
- **سقف payload ۴ مگابایت** در هر بَچ — خیلی کمتر از ۵۰ مگابایت Apps Script
- **timeout ۳۰ ثانیه** هر بَچ — مقصد کند / مرده نمی‌تواند سایر سشن‌ها را گیر بیاندازد
### راه‌اندازی سریع حالت full
۱. [`CodeFull.gs`](../assets/apps_script/CodeFull.gs) را به‌عنوان Web App روی **هر حساب گوگل** دیپلوی کن (همان مراحل `Code.gs`، اما با اسکریپت full-mode که به tunnel-node تو forward می‌کند). یک Deployment per account — سقف ۳۰ همزمان per account است، چند Deployment روی یک حساب سهمیه را زیاد نمی‌کند. برای مقیاس، حساب‌های بیشتر:
- **استفادهٔ تنها** → ۱-۲ حساب
- **اشتراک با ~۳ نفر** → ۳ حساب
- **اشتراک با گروه** → یک حساب per کاربر سنگین
۲. [tunnel-node](../tunnel-node/) را روی VPS دیپلوی کن. سریع‌ترین راه ایمیج Docker آماده:
```bash
docker run -d --name mhrv-tunnel --restart unless-stopped \
-p 8080:8080 -e TUNNEL_AUTH_KEY=رمز_قوی_تو \
ghcr.io/therealaleph/mhrv-tunnel-node:latest
```
Multi-arch (linux/amd64 + linux/arm64)، اجرا با کاربر غیر root، حدود ۳۲ مگابایت فشرده. برای production نسخهٔ مشخص (`:1.5.0`) را pin کن. راهنمای کامل (شامل Cloud Run، docker-compose، بیلد از سورس) در [tunnel-node/README.fa.md](../tunnel-node/README.fa.md).
۳. در کانفیگت `"mode": "full"` با همهٔ Deployment IDها بگذار:
```json
{
"mode": "full",
"script_id": ["id1", "id2", "id3", "id4", "id5", "id6"],
"auth_key": "secret-تو"
}
```
## Exit node
سرویس‌های پشت Cloudflare (chatgpt.com، claude.ai، grok.com، x.com، openai.com) ترافیک از IPهای دیتاسنتر گوگل را به‌عنوان bot شناسایی می‌کنند و چالش Turnstile / CAPTCHA می‌فرستند. راه‌حل exit node یک HTTP endpoint کوچک TypeScript است که روی val.town (رایگان) دیپلوی می‌کنی و بین Apps Script و مقصد قرار می‌گیرد:
```
کلاینت → Apps Script (IP گوگل) → val.town (IP غیر گوگل) → سایت پشت CF
```
مقصد IP val.town را می‌بیند نه IP گوگل، پس heuristic ضدبات شلیک نمی‌کند.
**راه‌اندازی:** [`assets/exit_node/README.fa.md`](../assets/exit_node/README.fa.md). ۵ دقیقه، سهمیهٔ رایگان.
## اشتراک‌گذاری هات‌اسپات
mhrv-rs به‌طور پیش‌فرض روی `0.0.0.0` گوش می‌دهد، پس هر دستگاه روی همان شبکه می‌تواند ازش استفاده کند. سناریوی رایج: اشتراک تونل از گوشی اندروید به آیفون / آیپد / لپ‌تاپ از هات‌اسپات:
۱. **اندروید:** هات‌اسپات موبایل را روشن کن + اپ را استارت کن
۲. **دستگاه دیگر:** به Wi-Fi هات‌اسپات اندروید وصل شو
۳. **پروکسی** را روی دستگاه دیگر تنظیم کن:
- سرور: `192.168.43.1` (IP پیش‌فرض هات‌اسپات اندروید)
- پورت: `8080` (HTTP) یا `1081` (SOCKS5)
### iOS
Settings → Wi-Fi → روی (i) شبکهٔ هات‌اسپات بزن → Configure Proxy → Manual → سرور `192.168.43.1`، پورت `8080`.
برای پوشش سراسری در iOS، از [Shadowrocket](https://apps.apple.com/app/shadowrocket/id932747118) یا [Potatso](https://apps.apple.com/app/potatso/id1239860606) استفاده کن — به SOCKS5 (`192.168.43.1:1081`) وصلش کن، تمام ترافیک از تونل می‌رود.
### مک / ویندوز
HTTP proxy سیستم را روی `192.168.43.1:8080` بگذار، یا per-app SOCKS5 روی `192.168.43.1:1081`.
> اگر `listen_host` در کانفیگت `127.0.0.1` است، به `0.0.0.0` تغییرش بده تا اتصال از دستگاه‌های دیگر را بپذیرد.
## اجرا روی OpenWRT
آرشیوهای `*-linux-musl-*` یک CLI کاملاً استاتیک می‌فرستند که روی OpenWRT، Alpine، و هر لینوکس بدون libc اجرا می‌شود. فایل را روی روتر بگذار و به‌صورت سرویس استارت کن:
```sh
# از کامپیوتری که به روترت دسترسی دارد:
scp mhrv-rs root@192.168.1.1:/usr/bin/mhrv-rs
scp mhrv-rs.init root@192.168.1.1:/etc/init.d/mhrv-rs
scp config.json root@192.168.1.1:/etc/mhrv-rs/config.json
# روی روتر (ssh):
chmod +x /usr/bin/mhrv-rs /etc/init.d/mhrv-rs
/etc/init.d/mhrv-rs enable
/etc/init.d/mhrv-rs start
logread -e mhrv-rs -f # تمام لاگ
```
دستگاه‌های LAN HTTP proxy را روی IP روتر (پورت پیش‌فرض `8085`) یا SOCKS5 روی `<router-ip>:8086` تنظیم می‌کنند. در `/etc/mhrv-rs/config.json` مقدار `listen_host` را به `0.0.0.0` بگذار تا روتر اتصال LAN را بپذیرد.
مصرف حافظه ~۱۵–۲۰ مگابایت — روی هر روتری با ۱۲۸ مگابایت RAM به بالا اجرا می‌شود. UI روی musl نیست (روترها headlessاند).
## ابزارهای تشخیص
- **`mhrv-rs test`** — یک درخواست از طریق رله می‌فرستد، موفقیت / تأخیر گزارش می‌دهد. اولین کاری که باید بکنی وقتی چیزی خراب است — جدا می‌کند "رله سالم است" از "کانفیگ کلاینت غلط است".
- **`mhrv-rs scan-ips`** — تست TLS موازی روی ۲۸ IP frontend شناخته‌شدهٔ گوگل، مرتب‌شده بر اساس تأخیر. بهترین را در `google_ip` بگذار. UI همان را پشت دکمهٔ **scan** دارد.
- **`mhrv-rs test-sni`** — تست TLS موازی هر نام SNI در pool روی `google_ip`. می‌گوید کدام نام‌ها از DPI ISP رد می‌شوند. UI در پنجرهٔ **SNI pool…** همان را با چک‌باکس، دکمهٔ **Test** هر ردیف، و **Keep ✓ only** برای trim خودکار دارد.
- **آمار دوره‌ای** هر ۶۰ ثانیه در سطح `info` لاگ می‌شود (تماس‌های رله، نرخ hit کش، بایت رله شده، اسکریپت‌های فعال در مقابل blacklisted). UI آن را زنده نشان می‌دهد.
### ویرایشگر SNI pool
به‌طور پیش‌فرض mhrv-rs بین `{www, mail, drive, docs, calendar}.google.com` روی TLS خروجی به `google_ip` می‌چرخد، تا اثر انگشت ترافیک یکنواخت نباشد. بعضی‌ها ممکن است محلی مسدود شوند (مثلاً `mail.google.com` در ایران چند بار هدف بوده).
یا:
- UI → **SNI pool…** → **Test all** → **Keep ✓ only** برای trim خودکار. نام جدید را در فیلد پایین اضافه کن. Save.
- یا `config.json` را مستقیم ویرایش کن:
```json
{
"sni_hosts": ["www.google.com", "drive.google.com", "docs.google.com"]
}
```
اگر `sni_hosts` تنظیم نشود، pool خودکار پیش‌فرض استفاده می‌شود. `mhrv-rs test-sni` را اجرا کن تا قبل از ذخیره ببینی چه چیزی از شبکه‌ات کار می‌کند.
## چه چیز پیاده شده و چه چیز نه
این پورت روی **حالت `apps_script`** تمرکز دارد — تنها حالتی که در سال ۲۰۲۶ مقابل سانسورگر مدرن قابل اتکاست. پیاده‌شده:
- [x] HTTP proxy محلی (CONNECT برای HTTPS، forwarding ساده برای HTTP)
- [x] SOCKS5 محلی با dispatch هوشمند TLS / HTTP / TCP خام (تلگرام، xray، …)
- [x] MITM با تولید گواهی per-domain روی پرواز با `rcgen`
- [x] تولید CA + نصب خودکار روی مک / لینوکس / ویندوز
- [x] نصب گواهی NSS فایرفاکس (best-effort با `certutil`)
- [x] رلهٔ JSON Apps Script سازگار با پروتکل `Code.gs`
- [x] connection pool (TTL ۴۵ ثانیه، حداکثر ۲۰ idle)
- [x] رمزگشایی gzip
- [x] چرخش بین چند اسکریپت (round-robin)
- [x] blacklist خودکار اسکریپت‌های ناموفق روی خطای 429 / quota (cooldown ۱۰ دقیقه)
- [x] کش پاسخ (۵۰ مگابایت، FIFO + TTL، آگاه از `Cache-Control: max-age`، heuristics برای static asset)
- [x] coalescing درخواست‌ها: GETهای یکسان همزمان یک fetch upstream را به اشتراک می‌گذارند
- [x] تونل‌های بازنویسی SNI (مستقیم به لبهٔ گوگل، دور زدن رله) برای `google.com`، `youtube.com`، `youtu.be`، `youtube-nocookie.com`، `fonts.googleapis.com`. دامنه‌های اضافی از فیلد `hosts` قابل تنظیم.
- [x] هندل خودکار ریدایرکت روی رله (`/exec` → `googleusercontent.com`)
- [x] فیلتر هدر (حذف connection-specific، brotli)
- [x] subcommandهای `test` و `scan-ips`
- [x] Script IDها در لاگ ماسک می‌شوند (`prefix…suffix`) تا لاگ Deployment IDها را افشا نکند
- [x] UI دسکتاپ (egui) — کراس‌پلتفرم، بدون bundler
- [x] چِین کردن SOCKS5 upstream (اختیاری) برای ترافیک غیر-HTTP (MTProto تلگرام، IMAP، SSH…)
- [x] pre-warm connection pool در شروع (اولین درخواست handshake TLS به لبهٔ گوگل را skip می‌کند)
- [x] چرخش SNI per-connection بین `{www, mail, drive, docs, calendar}.google.com`
- [x] dispatch موازی script-ID اختیاری (`parallel_relay`): fan-out به N اسکریپت همزمان، اولین موفقیت برمی‌گردد
- [x] drill-down آمار per-site در UI (درخواست‌ها، نرخ کش، بایت، تأخیر متوسط per host)
- [x] pool چرخش SNI قابل ویرایش (UI + فیلد `sni_hosts`) با probe دسترسی
- [x] بیلدهای OpenWRT / Alpine / musl — باینری استاتیک، با اسکریپت init procd
- [x] **Exit node** برای سایت‌های پشت Cloudflare (v1.9.4+)
- [x] **Unwrap iframe goog.script.init** — دفاع‌در‌عمق در مقابل Deploymentهایی که پاسخ HtmlService-wrapped برمی‌گردانند (v1.9.6+)
عمداً پیاده **نشده**:
- **HTTP/2 multiplexing** — state machine کریت `h2` (stream IDs، flow control، GOAWAY) موارد hang ظریف زیادی دارد؛ coalescing + pool ۲۰-conn بیشتر فایده را می‌گیرد.
- **batch درخواست (`q:[...]` در حالت apps_script)** — connection pool + tokio async از قبل خوب موازی‌سازی می‌کند؛ batch ~۲۰۰ خط مدیریت state اضافه می‌کند با سود نامشخص.
- **دانلود موازی Range-based** — edge case‌های واقعی (سرورهای بدون Range، chunked وسط stream)؛ ویدیوی یوتیوب از قبل با تونل بازنویسی SNI Apps Script را دور می‌زند.
- **حالت‌های دیگر** (`domain_fronting`، `google_fronting`، `custom_domain`) — Cloudflare در ۲۰۲۴ domain fronting عمومی را کشت؛ Cloud Run پلن پولی می‌خواهد.
## محدودیت‌های شناخته‌شده
این محدودیت‌ها ذاتی روش Apps Script + domain fronting هستند، نه باگ این کلاینت. نسخهٔ پایتون اصلی هم همین مشکلات را دارد.
- **User-Agent ثابت روی `Google-Apps-Script`** برای ترافیک از رله. `UrlFetchApp.fetch()` اجازهٔ override نمی‌دهد. سایت‌هایی که bot detect می‌کنند (جست‌وجوی گوگل، بعضی CAPTCHAها) نسخهٔ no-JS برمی‌گردانند. راه‌حل: دامنه را به `hosts` اضافه کن تا از تونل بازنویسی SNI با UA واقعی مرورگرت برود. `google.com`، `youtube.com`، `fonts.googleapis.com` پیش‌فرض داخل‌اند.
- **پخش ویدیو کند و quota-محدود** برای هرچیزی که از رله رد می‌شود. HTML یوتیوب سریع می‌آید (تونل بازنویسی SNI)، اما chunkهای `googlevideo.com` از Apps Script. سهمیهٔ رایگان: ~۲۰ هزار `UrlFetchApp` در روز، سقف بدنهٔ ۵۰ مگابایت per fetch. برای مرور متنی خوب، برای ۱۰۸۰p دردناک. چند `script_id` بچرخان برای هد رومبیش‌تر، یا VPN واقعی برای ویدیو.
- **Brotli حذف می‌شود** از `Accept-Encoding` هدر. Apps Script gzip را decompress می‌کند ولی `br` نه؛ forward کردن `br` پاسخ را خراب می‌کند. سربار حجمی جزئی.
- **WebSocket کار نمی‌کند** از رله — این رله request/response JSON است. سایت‌هایی که به WS upgrade می‌کنند fail می‌شوند (streaming ChatGPT، صدای Discord، …).
- **سایت‌های HSTS-preloaded / hard-pinned** گواهی MITM را قبول نمی‌کنند. اکثر سایت‌ها مشکل ندارند؛ تعداد کمی دارند.
- **2FA و ورود حساس گوگل / یوتیوب** ممکن است هشدار «دستگاه ناشناس» بدهد چون درخواست‌ها از IPهای Apps Script گوگل می‌آیند نه IP تو. یک‌بار از تونل وارد شو (`google.com` در لیست بازنویسی است) تا این مشکل برطرف شود.
## امنیت
- root MITM **فقط روی سیستم تو می‌ماند**. کلید خصوصی `ca/ca.key` محلی تولید می‌شود و هیچ‌وقت از دایرکتوری user-data خارج نمی‌شود.
- `auth_key` رمز اشتراکی است که خودت انتخاب می‌کنی. `Code.gs` سرور هر درخواست بدون این کلید را رد می‌کند.
- ترافیک بین سیستم تو و لبهٔ گوگل TLS 1.3 استاندارد است.
- آنچه گوگل می‌بیند: URL مقصد و هدرهای هر درخواست (چون Apps Script به‌جای تو fetch می‌کند). همان مدل اعتماد هر پروکسی هاست‌شده — اگر قابل قبول نیست، VPN خودمیزبانی استفاده کن.
- **هشدار افشای IP در حالت `apps_script`:** v1.2.9 همهٔ هدرهای `X-Forwarded-For` / `X-Real-IP` / `Forwarded` / `Via` / `CF-Connecting-IP` / `True-Client-IP` / `Fastly-Client-IP` و ~۱۰ هدر مشابه را قبل از رسیدن به Apps Script از خروجی حذف می‌کند ([#104](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/104)). آنچه پوشش **نمی‌دهد**: هر هدری که زیرساخت گوگل ممکن است وقتی Apps Script `UrlFetchApp.fetch()` بعدی را به مقصد می‌فرستد اضافه کند. آن leg دوم سمت سرور است، خارج از کنترل این کلاینت. مقصد IP دیتاسنتر گوگل را می‌بیند، اما تعهد عمومی از گوگل وجود ندارد که IP اصلی کاربر را در زنجیرهٔ هدرهای داخلی منتشر نمی‌کند. اگر مدل تهدیدت اینه که مقصد تحت هیچ شرایطی نباید IP تو را بفهمد، **از Full Tunnel استفاده کن** (ترافیک از VPS شخصی تو خارج می‌شود، فقط IP آن VPS end-to-end دیده می‌شود). حالت `apps_script` برای دور زدن DPI / دسترسی به سایت‌های فیلتر کاملاً مناسب است، اما فرض می‌کند «دیده‌شدن توسط گوگل» قابل قبول است. در [#148](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/148) مطرح شده.
- در v1.9.6+ `Code.gs` و `CodeFull.gs` هم هدرهای `X-Forwarded-*` / `Forwarded` / `Via` را در سمت سرور به‌عنوان لایهٔ دفاع دوم حذف می‌کنند.
## سؤالات رایج
**چند Deployment ID نیاز دارم؟** یکی برای استفادهٔ معمول کافی است. سهمیهٔ رایگان `UrlFetchApp` هر حساب ۲۰٬۰۰۰ fetch در روز است (Workspace پولی ۱۰۰٬۰۰۰)، با سقف بدنهٔ ۵۰ مگابایت per fetch. **یک Deployment per Google account** بساز — سقف ۳۰ همزمان per account است، چند Deployment روی یک حساب همزمانی اضافه نمی‌کند. برای مقیاس، در حساب‌های گوگل دیگر دیپلوی کن. مرجع: <https://developers.google.com/apps-script/guides/services/quotas>
**چرا گاهی جست‌وجوی گوگلم بدون JavaScript نشان داده می‌شود؟** Apps Script مجبور است `User-Agent` را روی `Google-Apps-Script` بگذارد. بعضی سایت‌ها این را به‌عنوان bot شناسایی کرده و نسخهٔ no-JS برمی‌گردانند. دامنه‌هایی که در لیست SNI-rewrite هستند (`google.com`، `youtube.com`، …) از این مشکل امان‌اند چون مستقیم از لبهٔ گوگل می‌آیند، نه از Apps Script.
**ورود به حساب گوگل با این ابزار ایمن است؟** توصیه: یک‌بار **بدون** پروکسی یا با VPN واقعی وارد شو. گوگل ممکن است IP Apps Script را به‌عنوان "دستگاه ناشناس" ببیند و هشدار بدهد. بعد از ورود اولیه، استفاده بی‌مشکل است.
**چطور گواهی را بعداً حذف کنم؟**
- **ساده‌ترین (هر OS):** در UI **Remove CA** را بزن، یا:
- مک / لینوکس: `sudo ./mhrv-rs --remove-cert`
- ویندوز (با Run as administrator): `mhrv-rs.exe --remove-cert`
- از trust store سیستم، NSS (فایرفاکس / کروم لینوکس) حذف می‌کند، و `ca/ca.crt` + `ca/ca.key` روی دیسک پاک می‌کند. `config.json` و دیپلوی Apps Script دست‌نخورده.
- **به‌صورت دستی:** نام گواهی (Common Name) همه‌جا `MasterHttpRelayVPN` است (نه `mhrv-rs` — این نام برنامه است نه نام گواهی).
- **مک:** Keychain Access → System → دنبال `MasterHttpRelayVPN` بگرد → حذف کن. سپس `rm -rf ~/Library/Application\ Support/mhrv-rs/ca/`
- **ویندوز:** `certmgr.msc` → Trusted Root Certification Authorities → دنبال `MasterHttpRelayVPN` → حذف
- **لینوکس:** `/usr/local/share/ca-certificates/MasterHttpRelayVPN.crt` را حذف کن، بعد `sudo update-ca-certificates`
**خطای `GLIBC_2.39 not found` روی لینوکس؟** از `mhrv-rs-linux-musl-amd64.tar.gz` استفاده کن — کاملاً استاتیک، روی هر لینوکس بدون `glibc` کار می‌کند.
## لایسنس
MIT. [LICENSE](../LICENSE) را ببین.
</div>
+418
View File
@@ -0,0 +1,418 @@
# mhrv-rs — Full guide
This is the long version — every config option, every advanced mode, every troubleshooting tip. For the 5-minute quick start, see the [main README](../README.md).
[Persian version (راهنمای فارسی)](guide.fa.md)
## Contents
- [How it works in detail](#how-it-works-in-detail)
- [Platforms and binaries](#platforms-and-binaries)
- [Where files live on disk](#where-files-live-on-disk)
- [Apps Script deployment](#apps-script-deployment)
- [Cloudflare Worker variant (faster)](#cloudflare-worker-variant)
- [Direct mode (when ISP blocks `script.google.com`)](#direct-mode)
- [CLI reference](#cli-reference)
- [scan-ips API mode](#scan-ips-api-mode)
- [Telegram via xray](#telegram-via-xray)
- [Full Tunnel mode](#full-tunnel-mode)
- [How deployment IDs affect performance](#how-deployment-ids-affect-performance)
- [Quick start](#full-mode-quick-start)
- [Exit node — for ChatGPT / Claude / Grok](#exit-node)
- [Sharing via hotspot](#sharing-via-hotspot)
- [Running on OpenWRT or any musl distro](#running-on-openwrt)
- [Diagnostics](#diagnostics)
- [SNI pool editor](#sni-pool-editor)
- [What's implemented and what isn't](#whats-implemented-and-what-isnt)
- [Known limitations](#known-limitations)
- [Security posture](#security-posture)
- [FAQ](#faq)
## How it works in detail
```
Browser / Telegram / xray
|
| HTTP proxy (8085) or SOCKS5 (8086)
v
mhrv-rs (local)
|
| TLS to Google IP, SNI = www.google.com
v ^
DPI sees www.google.com |
| | Host: script.google.com (inside TLS)
v |
Google edge frontend ---------+
|
v
Apps Script relay (your free Google account)
|
v
Real destination
```
The censor's DPI inspects the TLS SNI and lets `www.google.com` through. Google's edge serves both `www.google.com` and `script.google.com` from the same IP and routes by the HTTP `Host` header inside the encrypted stream.
For Google-owned domains (`google.com`, `youtube.com`, `fonts.googleapis.com`, …) the same tunnel is used directly — no Apps Script relay. This bypasses the per-fetch quota and avoids the locked-in `Google-Apps-Script` User-Agent for those sites. Add more domains via the `hosts` map in `config.json`.
## Platforms and binaries
Linux (x86_64, aarch64), macOS (x86_64, aarch64), Windows (x86_64), **Android 7.0+** (universal APK covering arm64, armv7, x86_64, x86). Prebuilt binaries on the [releases page](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases).
**Android:** download `mhrv-rs-android-universal-v*.apk`. Full walk-through in [docs/android.md](android.md) (English) or [docs/android.fa.md](android.fa.md) (Persian). The Android build runs the same `mhrv-rs` Rust crate as desktop (via JNI) plus a TUN bridge via `tun2proxy` so every app on the device routes its IP traffic through the proxy without per-app config.
> **Important Android caveat (issues [#74](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/74) / [#81](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/81)):** TUN captures all IP traffic, but HTTPS from third-party apps only works for apps that trust user-installed CAs. From Android 7+ apps must opt in via `networkSecurityConfig`. **Chrome and Firefox do**; **Telegram, WhatsApp, Instagram, YouTube, banking apps, games** do not. For those: use `PROXY_ONLY` mode and point in-app proxy at `127.0.0.1:1081` (SOCKS5), or use `google_only` mode (no CA, Google services only), or set `upstream_socks5` to an external VPS. This is an Android security design, not a bug.
### What's in a release
Each archive contains:
| file | purpose |
|---|---|
| `mhrv-rs` / `mhrv-rs.exe` | CLI. Headless use, servers, automation. No system deps on macOS / Windows. |
| `mhrv-rs-ui` / `mhrv-rs-ui.exe` | Desktop UI (egui). Config form, Start / Stop / Test buttons, live stats, log panel. |
| `run.sh` / `run.command` / `run.bat` | Platform launcher: installs the MITM CA (needs sudo / admin) then starts the UI. Use this on first run. |
macOS archives also ship `mhrv-rs.app` (in `*-app.zip`) — double-click in Finder. Run `mhrv-rs --install-cert` or `run.command` once first to install the CA.
<p align="center"><img src="ui-screenshot.png" alt="mhrv-rs desktop UI showing config form, live traffic stats, Start/Stop/Test buttons, and log panel" width="420"></p>
Linux UI also needs `libxkbcommon`, `libwayland-client`, `libxcb`, `libgl`, `libx11`, `libgtk-3`. On most desktop distros these are already there; on a headless box install them via your package manager, or just use the CLI.
## Where files live on disk
Config and the MITM CA live in the OS user-data dir:
- macOS: `~/Library/Application Support/mhrv-rs/`
- Linux: `~/.config/mhrv-rs/`
- Windows: `%APPDATA%\mhrv-rs\`
Inside that dir:
- `config.json` — your settings (written by the UI's **Save** button or hand-edited)
- `ca/ca.crt`, `ca/ca.key` — the MITM root certificate. Only you have the private key.
The CLI also falls back to `./config.json` in the current working directory for backward compatibility.
## Apps Script deployment
The 5-minute version is in the [main README](../README.md#step-1--make-the-google-apps-script-one-time). This section covers the variants.
### Cloudflare Worker variant
A variant in [`assets/apps_script/Code.cfw.gs`](../assets/apps_script/Code.cfw.gs) + [`assets/cloudflare/worker.js`](../assets/cloudflare/worker.js) turns Apps Script into a thin forwarder and offloads the actual `fetch` to a Cloudflare Worker you deploy. **Day-one win:** latency (~1050 ms at the CF edge vs ~250500 ms in Apps Script — visibly snappier for browsing and Telegram).
It does **not** reduce your daily 20k Apps Script `UrlFetchApp` count, because today's mhrv-rs always sends single-URL relay requests; the batch path on the GAS+Worker side is wired and ready (`ceil(N/40)` quota per N-URL batch) but no shipping client emits it.
**Trade-offs:**
- Worse for YouTube long-form (30 s wall clock vs 6 min Apps Script)
- Doesn't fix Cloudflare anti-bot
- **Not compatible with `mode: "full"`** (no tunnel-ops support → won't help WhatsApp / messengers on Android Full mode)
Full setup and trade-off table in [`assets/cloudflare/README.md`](../assets/cloudflare/README.md). mhrv-rs needs no config changes — same `mode: "apps_script"`, same `script_id`, same `auth_key`.
### Direct mode
If your ISP is already blocking Google Apps Script (or all of Google), you need Step 1 to succeed *before* you have a relay. mhrv-rs ships a `direct` mode for exactly this — SNI-rewrite tunnel only, no Apps Script relay required. (Was named `google_only` before v1.9 — old name still accepted.)
1. Download the binary (see [main README → Step 2](../README.md#step-2--download-mhrv-rs))
2. Copy [`config.direct.example.json`](../config.direct.example.json) to `config.json` — no `script_id`, no `auth_key` required
3. Run `mhrv-rs serve` and set browser HTTP proxy to `127.0.0.1:8085`
4. In `direct` mode, the proxy only routes `*.google.com`, `*.youtube.com`, and other Google-edge hosts (plus any [`fronting_groups`](fronting-groups.md) you've configured) via the SNI-rewrite tunnel. Other traffic goes raw — no Apps Script relay exists yet.
5. Now 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 UI / Android app / by editing `config.json`, switch mode to `apps_script`, paste the Deployment ID and your auth key, and restart.
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`.
## CLI reference
Everything the UI does is also in the CLI. Copy `config.example.json` to `config.json` (next to the binary, or in the user-data dir):
```json
{
"mode": "apps_script",
"google_ip": "216.239.38.120",
"front_domain": "www.google.com",
"script_id": "PASTE_YOUR_DEPLOYMENT_ID_HERE",
"auth_key": "same-secret-as-in-code-gs",
"listen_host": "127.0.0.1",
"listen_port": 8085,
"socks5_port": 8086,
"log_level": "info",
"verify_ssl": true
}
```
Then:
```bash
./mhrv-rs # serve (default)
./mhrv-rs test # one-shot end-to-end probe
./mhrv-rs scan-ips # rank Google frontend IPs by latency
./mhrv-rs test-sni # probe SNI names against your google_ip
./mhrv-rs --install-cert # reinstall the MITM CA
./mhrv-rs --remove-cert # uninstall + delete the whole ca/ dir
./mhrv-rs --help
```
`--remove-cert` deletes the CA from the OS trust store, deletes the on-disk `ca/` directory, and verifies the revocation by name. NSS cleanup (Firefox, Chrome on Linux) is best-effort: if `certutil` isn't on PATH or a browser holds the NSS DB open, the tool logs a manual-cleanup hint. Your `config.json` and the Apps Script deployment are untouched, so a fresh CA does not require redeploying `Code.gs`.
> **Upgrading from pre-v1.2.11?** Earlier versions wrote a bare `user_pref("security.enterprise_roots.enabled", true);` into each Firefox profile's `user.js` without a marker. `--remove-cert` does not strip that line — it's indistinguishable from one a user or corp policy wrote. Firefox falls back to its built-in Mozilla root store the moment the MITM CA leaves the OS trust store, so this has no functional effect. Delete by hand if it bothers you.
`script_id` can also be a JSON array: `["id1", "id2", "id3"]`.
### scan-ips API mode
By default, `scan-ips` uses a static list. Enable dynamic IP discovery in `config.json`:
```json
{
"fetch_ips_from_api": true,
"max_ips_to_scan": 100,
"scan_batch_size": 100,
"google_ip_validation": true
}
```
When enabled:
- Fetches `goog.json` from Google's public IP ranges API
- Extracts CIDRs and expands them to individual IPs
- Prioritizes IPs from famous Google domains (google.com, youtube.com, etc.)
- Randomly selects up to `max_ips_to_scan` candidates (prioritized first)
- Tests only those candidates for connectivity and frontend validation
You may find IPs faster than the static array, but no guarantee they all work.
## Telegram via xray
The Apps Script relay only speaks HTTP request / response, so non-HTTP protocols (Telegram MTProto, IMAP, SSH, raw TCP) can't travel through it. Without anything else, those flows hit the direct-TCP fallback — which means they're not actually tunneled, and an ISP that blocks Telegram still blocks them.
**Fix:** run a local [xray](https://github.com/XTLS/Xray-core) (or v2ray / sing-box) with a VLESS / Trojan / Shadowsocks outbound to your own VPS, and point mhrv-rs at xray's SOCKS5 inbound via the **Upstream SOCKS5** field (or the `upstream_socks5` config key). When set, raw-TCP flows through mhrv-rs's SOCKS5 listener get chained into xray → the real tunnel.
```
Telegram ┐ ┌─ Apps Script ── HTTP/HTTPS
├─ SOCKS5 :8086 ─┤ mhrv-rs ├─ SNI rewrite ──────── google.com, youtube.com, …
Browser ┘ └─ upstream SOCKS5 ─ xray ── VLESS ── your VPS (Telegram, IMAP, SSH, raw TCP)
```
Config fragment:
```json
{
"upstream_socks5": "127.0.0.1:50529"
}
```
HTTP / HTTPS keeps going through Apps Script (no change), and the SNI-rewrite tunnel for `google.com` / `youtube.com` keeps bypassing both — YouTube stays as fast as before while Telegram gets a real tunnel.
## 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. TCP carried as persistent tunnel sessions, UDP from Android / TUN clients via SOCKS5 `UDP ASSOCIATE` to the tunnel-node which emits real UDP server-side. Trade-off: higher per-request latency (every byte goes Apps Script → tunnel-node → destination), but works for any protocol and any app, no CA install required.
### How deployment IDs affect performance
Each Apps Script batch round-trip takes ~2 s. In Full mode, mhrv-rs runs a **pipelined batch multiplexer** that fires multiple batches concurrently without waiting on the previous one. Each Deployment ID (= one Google account) gets its own concurrency pool of **30 in-flight requests** — matching the per-account Apps Script execution limit.
```
max_concurrent = 30 × number_of_deployment_ids
```
| Deployments | Concurrent | Notes |
|---|---|---|
| 1 | 30 | Single account — fine for light browsing |
| 3 | 90 | Good for daily use |
| 6 | 180 | Recommended for heavy use |
| 12 | 360 | Multi-account power setup |
More deployments = more total concurrency = lower per-session latency. Each batch round-robins across your IDs, spreading load and reducing the chance of hitting any single deployment's quota ceiling.
**Resource guards:**
- **50 ops max** per batch — if more sessions are active, the mux splits into multiple batches
- **4 MB payload cap** per batch — well under Apps Script's 50 MB limit
- **30 s timeout** per batch — slow / dead targets can't block other sessions forever
### Full mode quick start
1. Deploy [`CodeFull.gs`](../assets/apps_script/CodeFull.gs) as a Web App on **each Google account** (same steps as `Code.gs`, but use the full-mode script that forwards to your tunnel-node). One deployment per account — the 30-concurrent limit is per account, so multiple deployments on one account share the pool. To scale, use more accounts:
- **Solo use** → 12 accounts
- **Shared with ~3 people** → 3 accounts
- **Shared with a group** → one account per heavy user
2. Deploy [tunnel-node](../tunnel-node/) on a VPS. Fastest is the prebuilt Docker image:
```bash
docker run -d --name mhrv-tunnel --restart unless-stopped \
-p 8080:8080 -e TUNNEL_AUTH_KEY=your-strong-secret \
ghcr.io/therealaleph/mhrv-tunnel-node:latest
```
Multi-arch (linux/amd64 + linux/arm64), runs as non-root, ~32 MB compressed. Pin a version tag (`:1.5.0`) for production. See [tunnel-node/README.md](../tunnel-node/README.md) for Cloud Run, docker-compose, and source-build alternatives.
3. Set `"mode": "full"` in your config with all deployment IDs:
```json
{
"mode": "full",
"script_id": ["id1", "id2", "id3", "id4", "id5", "id6"],
"auth_key": "your-secret"
}
```
## Exit node
Cloudflare-fronted services (chatgpt.com, claude.ai, grok.com, x.com, openai.com) flag traffic from Google datacenter IPs as bots and serve a Turnstile / CAPTCHA challenge. The exit node fix is a small TypeScript HTTP endpoint you deploy on val.town (free) that sits between Apps Script and the destination:
```
client → Apps Script (Google IP) → val.town (non-Google IP) → CF-protected site
```
The destination sees val.town's IP, not Google's, so the anti-bot heuristic doesn't fire.
**Setup:** [`assets/exit_node/README.md`](../assets/exit_node/README.md). 5 min, free tier.
## Sharing via hotspot
mhrv-rs listens on `0.0.0.0` by default, so any device on the same network can use it. Common scenario: share the tunnel from an Android phone to an iPhone, iPad, or laptop over hotspot:
1. **Android:** enable mobile hotspot + start the app
2. **Other device:** connect to the Android hotspot Wi-Fi
3. **Configure proxy** on the other device:
- Server: `192.168.43.1` (Android's default hotspot IP)
- Port: `8080` (HTTP) or `1081` (SOCKS5)
### iOS
Settings → Wi-Fi → tap (i) on the hotspot network → Configure Proxy → Manual → Server `192.168.43.1`, Port `8080`.
For full device-wide coverage on iOS, use [Shadowrocket](https://apps.apple.com/app/shadowrocket/id932747118) or [Potatso](https://apps.apple.com/app/potatso/id1239860606) — point at SOCKS5 (`192.168.43.1:1081`) and it routes all traffic through the tunnel.
### macOS / Windows
Set system HTTP proxy to `192.168.43.1:8080`, or per-app SOCKS5 to `192.168.43.1:1081`.
> If `listen_host` is `127.0.0.1` in your config, change to `0.0.0.0` to allow other devices.
## Running on OpenWRT
The `*-linux-musl-*` archives ship a fully static CLI that runs on OpenWRT, Alpine, and any libc-less Linux. Put the binary on the router and start as a service:
```sh
# From a machine that can reach your router:
scp mhrv-rs root@192.168.1.1:/usr/bin/mhrv-rs
scp mhrv-rs.init root@192.168.1.1:/etc/init.d/mhrv-rs
scp config.json root@192.168.1.1:/etc/mhrv-rs/config.json
# On the router:
chmod +x /usr/bin/mhrv-rs /etc/init.d/mhrv-rs
/etc/init.d/mhrv-rs enable
/etc/init.d/mhrv-rs start
logread -e mhrv-rs -f # tail logs
```
LAN devices then point HTTP proxy at the router's LAN IP (default port `8085`) or SOCKS5 at `<router-ip>:8086`. Set `listen_host` to `0.0.0.0` in `/etc/mhrv-rs/config.json` so the router accepts LAN connections.
Memory footprint ~1520 MB resident — fine on anything ≥128 MB RAM. No UI on musl (routers are headless).
## Diagnostics
- **`mhrv-rs test`** — sends one request through the relay, reports success / latency. First thing to try when something breaks — separates "relay is up" from "client config is wrong".
- **`mhrv-rs scan-ips`** — parallel TLS probe of 28 known Google frontend IPs, sorted by latency. Take the winner, put it in `google_ip`. UI has same thing behind **scan** button.
- **`mhrv-rs test-sni`** — parallel TLS probe of every SNI name in your rotation pool against `google_ip`. Tells you which front-domain names pass through your ISP's DPI. UI has same thing in **SNI pool…** window with checkboxes, per-row **Test** buttons, and **Keep ✓ only** to auto-trim.
- **Periodic stats** logged every 60 s at `info` level (relay calls, cache hit rate, bytes relayed, active vs blacklisted scripts). UI shows live.
### SNI pool editor
By default, mhrv-rs rotates through `{www, mail, drive, docs, calendar}.google.com` on outbound TLS to your `google_ip`, to avoid fingerprinting one name too heavily. Some may be locally blocked (e.g. `mail.google.com` has been targeted in Iran at various times).
Either:
- UI → **SNI pool…** → **Test all** → **Keep ✓ only** to auto-trim. Add custom names via the text field at the bottom. Save.
- Or edit `config.json`:
```json
{
"sni_hosts": ["www.google.com", "drive.google.com", "docs.google.com"]
}
```
Leaving `sni_hosts` unset gives you the default auto-pool. Run `mhrv-rs test-sni` to verify what works from your network.
## What's implemented and what isn't
This port focuses on the **`apps_script` mode** — the only one that reliably works against a modern censor in 2026. Implemented:
- [x] Local HTTP proxy (CONNECT for HTTPS, plain forwarding for HTTP)
- [x] Local SOCKS5 with smart TLS / HTTP / raw-TCP dispatch (Telegram, xray, etc.)
- [x] MITM with on-the-fly per-domain certs via `rcgen`
- [x] CA generation + auto-install on macOS / Linux / Windows
- [x] Firefox NSS cert install (best-effort via `certutil`)
- [x] Apps Script JSON relay protocol-compatible with `Code.gs`
- [x] Connection pooling (45 s TTL, max 20 idle)
- [x] Gzip response decoding
- [x] Multi-script round-robin
- [x] Auto-blacklist failing scripts on 429 / quota errors (10 min cooldown)
- [x] Response cache (50 MB, FIFO + TTL, `Cache-Control: max-age` aware, heuristics for static assets)
- [x] Request coalescing: concurrent identical GETs share one upstream fetch
- [x] SNI-rewrite tunnels for `google.com`, `youtube.com`, `youtu.be`, `youtube-nocookie.com`, `fonts.googleapis.com`, configurable via `hosts` map
- [x] Automatic redirect handling on the relay (`/exec` → `googleusercontent.com`)
- [x] Header filtering (strip connection-specific, brotli)
- [x] `test` and `scan-ips` subcommands
- [x] Script IDs masked in logs (`prefix…suffix`) so logs don't leak deployment IDs
- [x] Desktop UI (egui) — cross-platform, no bundler needed
- [x] Optional upstream SOCKS5 chaining for non-HTTP traffic (Telegram MTProto, IMAP, SSH…)
- [x] Connection pool pre-warm on startup
- [x] Per-connection SNI rotation across `{www, mail, drive, docs, calendar}.google.com`
- [x] Optional parallel script-ID dispatch (`parallel_relay`): fan-out to N script instances, return first success
- [x] Per-site stats drill-down in the UI (requests, cache hit %, bytes, avg latency per host)
- [x] Editable SNI rotation pool (UI window + `sni_hosts` config field) with reachability probes
- [x] OpenWRT / Alpine / musl builds — static binaries, procd init script included
- [x] **Exit node** support for Cloudflare-fronted sites (v1.9.4+)
- [x] **Goog.script.init iframe unwrap** — defense-in-depth against deployments that return HtmlService-wrapped responses (v1.9.6+)
Intentionally **not** implemented:
- **HTTP/2 multiplexing** — `h2` crate state machine has too many subtle hang cases; coalescing + 20-conn pool gets most of the benefit
- **Request batching (`q:[...]` mode in apps_script mode)** — connection pool + tokio async already parallelizes well; batching adds ~200 lines of state for unclear gain
- **Range-based parallel download** — edge cases real (non-Range servers, chunked mid-stream); YouTube already bypasses Apps Script via SNI-rewrite tunnel
- **Other modes** (`domain_fronting`, `google_fronting`, `custom_domain`) — Cloudflare killed generic domain fronting in 2024; Cloud Run needs a paid plan
## Known limitations
These are inherent to the Apps Script + domain-fronting approach, not bugs in this client. The original Python version has the same issues.
- **User-Agent fixed to `Google-Apps-Script`** for traffic through the relay. `UrlFetchApp.fetch()` doesn't allow override. Sites that detect bots (Google search, some CAPTCHAs) serve degraded / no-JS pages. Workaround: add the affected domain to the `hosts` map so it's routed through the SNI-rewrite tunnel with your real browser's UA. `google.com`, `youtube.com`, `fonts.googleapis.com` are already there.
- **Video playback slow and quota-limited** for anything through the relay. YouTube HTML loads fast (SNI-rewrite tunnel), but `googlevideo.com` chunks go through Apps Script. Free tier: ~20k `UrlFetchApp` calls / day, 50 MB body cap per fetch. Fine for text browsing, painful for 1080p. Rotate multiple `script_id`s for headroom, or use a real VPN for video.
- **Brotli stripped** from forwarded `Accept-Encoding`. Apps Script can decompress gzip but not `br`; forwarding `br` would garble responses. Minor size overhead.
- **WebSockets don't work** through the relay — it's request / response JSON. Sites that upgrade to WS fail (ChatGPT streaming, Discord voice, etc.).
- **HSTS-preloaded / hard-pinned sites** reject the MITM cert. Most sites are fine; a handful aren't.
- **Google / YouTube 2FA and sensitive logins** may trigger "unrecognized device" warnings because requests originate from Google's Apps Script IPs, not yours. Log in once via the tunnel (`google.com` is in the rewrite list) to avoid this.
## Security posture
- The MITM root **stays on your machine only**. `ca/ca.key` private key is generated locally and never leaves the user-data dir.
- `auth_key` is a shared secret you pick. Server-side `Code.gs` rejects requests without a matching key.
- Traffic between your machine and Google's edge is standard TLS 1.3.
- What Google can see: the destination URL and headers of each request (Apps Script fetches on your behalf). Same trust model as any hosted proxy — if not acceptable, use a self-hosted VPN instead.
- **IP exposure caveat (`apps_script` mode):** v1.2.9 strips every `X-Forwarded-For` / `X-Real-IP` / `Forwarded` / `Via` / `CF-Connecting-IP` / `True-Client-IP` / `Fastly-Client-IP` and ~10 related identity-revealing headers from outbound before reaching Apps Script ([#104](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/104)). What it does **not** cover: whatever Google's own infrastructure may add when its Apps Script runtime makes the subsequent `UrlFetchApp.fetch()` to the target. That second leg is server-side, outside this client's control. Destination sees a Google datacenter IP, but no public guarantee Google never propagates the original caller's IP in some internal header chain. If your threat model requires the destination cannot under any circumstances learn your IP, **use Full Tunnel mode** (traffic exits from your own VPS, only the VPS IP is exposed end-to-end). `apps_script` mode is fine for bypassing DPI / reaching blocked sites where "seen by Google" is acceptable. Raised in [#148](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/148).
- v1.9.6+ Code.gs / CodeFull.gs also strip `X-Forwarded-*` / `Forwarded` / `Via` server-side as a second line of defense.
## FAQ
**How many Deployment IDs do I need?** One is fine for normal use. The free `UrlFetchApp` quota is 20,000 fetches / day per account (100,000 for paid Workspace), with a 50 MB body cap per fetch. Use **one deployment per Google account** — the 30-concurrent limit is per account, so multiple deployments on the same account don't add concurrency. To scale, deploy in different Google accounts. Reference: <https://developers.google.com/apps-script/guides/services/quotas>
**Why does Google search show without JavaScript sometimes?** Apps Script is forced to set `User-Agent: Google-Apps-Script`. Some sites detect that and serve no-JS fallback. Domains in the SNI-rewrite list (`google.com`, `youtube.com`, etc.) are immune because they go directly to Google's edge, not through Apps Script.
**Is logging into a Google account through this safe?** Recommended: log in once **without** the proxy, or with a real VPN, the first time. Google may flag the Apps Script IP as an "unknown device" and warn. After the initial login, use is fine.
**How do I remove the certificate later?**
- **Easiest (any OS):** click **Remove CA** in the UI, or:
- macOS / Linux: `sudo ./mhrv-rs --remove-cert`
- Windows (run as administrator): `mhrv-rs.exe --remove-cert`
- Removes from system trust store, NSS (Firefox / Chrome on Linux), and deletes `ca/ca.crt` + `ca/ca.key` on disk. Your `config.json` and Apps Script deployment are not touched.
- **Manually:** the cert's Common Name is `MasterHttpRelayVPN` (not `mhrv-rs` — that's the app name).
- **macOS:** Keychain Access → System → search `MasterHttpRelayVPN` → delete. Then `rm -rf ~/Library/Application\ Support/mhrv-rs/ca/`
- **Windows:** `certmgr.msc` → Trusted Root Certification Authorities → search `MasterHttpRelayVPN` → delete
- **Linux:** delete `/usr/local/share/ca-certificates/MasterHttpRelayVPN.crt` then `sudo update-ca-certificates`
**`GLIBC_2.39 not found` error on Linux?** Use `mhrv-rs-linux-musl-amd64.tar.gz` — fully static, runs on any Linux without `glibc`.
## License
MIT. See [LICENSE](../LICENSE).
+191 -8
View File
@@ -2654,14 +2654,38 @@ fn parse_relay_json(body: &[u8]) -> Result<Vec<u8>, FronterError> {
let data: RelayResponse = match serde_json::from_str(text) { let data: RelayResponse = match serde_json::from_str(text) {
Ok(v) => v, Ok(v) => v,
Err(_) => { Err(_) => {
// Apps Script may prepend HTML fallback; try to extract first {...} // Some deployments (legacy Code.gs that used HtmlService for
let start = text.find('{').ok_or_else(|| { // _json, or our own doGet hit accidentally via a redirect
FronterError::BadResponse(format!("no json in: {}", &text[..text.len().min(200)])) // chain) wrap the JSON inside the goog.script sandbox iframe
})?; // as `goog.script.init("\x7b...userHtml...\x7d", "", undefined)`.
let end = text.rfind('}').ok_or_else(|| { // Try that unwrap first — if it succeeds, the inner userHtml
FronterError::BadResponse(format!("no json end in: {}", &text[..text.len().min(200)])) // *is* our JSON. Mirrors upstream's Python client extractor.
})?; if let Some(unwrapped) = extract_apps_script_user_html(text) {
serde_json::from_str(&text[start..=end])? if let Ok(v) = serde_json::from_str(&unwrapped) {
v
} else {
return Err(FronterError::BadResponse(format!(
"no json in apps_script user_html: {}",
&unwrapped[..unwrapped.len().min(200)]
)));
}
} else {
// Last resort: extract first { ... last }, in case Apps
// Script prepended HTML preamble before the raw JSON.
let start = text.find('{').ok_or_else(|| {
FronterError::BadResponse(format!(
"no json in: {}",
&text[..text.len().min(200)]
))
})?;
let end = text.rfind('}').ok_or_else(|| {
FronterError::BadResponse(format!(
"no json end in: {}",
&text[..text.len().min(200)]
))
})?;
serde_json::from_str(&text[start..=end])?
}
} }
}; };
@@ -2717,6 +2741,98 @@ fn parse_relay_json(body: &[u8]) -> Result<Vec<u8>, FronterError> {
Ok(out) Ok(out)
} }
/// Unwrap the `goog.script.init` sandbox iframe that wraps every
/// HtmlService web-app response. The wrapper text looks roughly like:
///
/// ```text
/// <html>...
/// goog.script.init("\x7b\x22userHtml\x22:\x22{...}\x22,...\x7d", "", undefined);
/// ...
/// ```
///
/// where the first parameter is a JSON string (with `\xNN` byte-escapes
/// for `{`, `"`, etc.) whose `userHtml` field carries our actual JSON
/// body. We find the marker, decode the byte-escapes, parse the outer
/// JSON, and return `userHtml`. Returns `None` if any step doesn't
/// match — the caller falls back to the brace-scan path.
///
/// Mirrors `_extract_apps_script_user_html` in upstream Python client.
fn extract_apps_script_user_html(text: &str) -> Option<String> {
let marker = "goog.script.init(\"";
let start_idx = text.find(marker)? + marker.len();
// The marker is closed by `", "", undefined` (Apps Script always
// emits this exact literal — there are two more positional args after
// the JSON string, both empty / undefined).
let end_marker = "\", \"\", undefined";
let end_idx = text[start_idx..].find(end_marker)? + start_idx;
let encoded = &text[start_idx..end_idx];
// Decode `\xNN` and `\u00NN` byte-escapes that Apps Script uses to
// protect `{`, `"`, `\`, etc. inside the JS string literal.
let decoded = decode_js_string_escapes(encoded)?;
// Outer JSON — typically `{"userHtml":"<our JSON>", ...}`.
let outer: Value = serde_json::from_str(&decoded).ok()?;
let user_html = outer.get("userHtml")?.as_str()?;
Some(user_html.to_string())
}
/// Minimal JS string-literal escape decoder for `\xNN`, `\uNNNN`, and
/// the standard backslash forms (`\\`, `\"`, `\n`, `\r`, `\t`, `\/`).
/// Used to unwrap the `goog.script.init("...")` parameter — Apps Script
/// emits ASCII-only `\xNN` for every non-alphanumeric byte, so the
/// decoder doesn't need to handle full Unicode surrogates.
fn decode_js_string_escapes(s: &str) -> Option<String> {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c != b'\\' {
// Fast path: copy ASCII / valid UTF-8 byte through.
out.push(c as char);
i += 1;
continue;
}
if i + 1 >= bytes.len() {
return None;
}
let esc = bytes[i + 1];
match esc {
b'x' => {
if i + 3 >= bytes.len() {
return None;
}
let hex = std::str::from_utf8(&bytes[i + 2..i + 4]).ok()?;
let v = u8::from_str_radix(hex, 16).ok()?;
out.push(v as char);
i += 4;
}
b'u' => {
if i + 5 >= bytes.len() {
return None;
}
let hex = std::str::from_utf8(&bytes[i + 2..i + 6]).ok()?;
let v = u32::from_str_radix(hex, 16).ok()?;
let ch = char::from_u32(v)?;
out.push(ch);
i += 6;
}
b'\\' => { out.push('\\'); i += 2; }
b'"' => { out.push('"'); i += 2; }
b'\'' => { out.push('\''); i += 2; }
b'/' => { out.push('/'); i += 2; }
b'n' => { out.push('\n'); i += 2; }
b'r' => { out.push('\r'); i += 2; }
b't' => { out.push('\t'); i += 2; }
b'b' => { out.push('\x08'); i += 2; }
b'f' => { out.push('\x0c'); i += 2; }
_ => return None,
}
}
Some(out)
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct StatsSnapshot { pub struct StatsSnapshot {
pub relay_calls: u64, pub relay_calls: u64,
@@ -3429,6 +3545,73 @@ hello";
assert!(s.contains("Set-Cookie: b=2\r\n")); assert!(s.contains("Set-Cookie: b=2\r\n"));
} }
#[test]
fn decode_js_string_escapes_xnn_and_unicode() {
// \x7b = '{', \x22 = '"', \x7d = '}', \x5b = '[', \x5d = ']'
let inner = r#"\x7b\x22s\x22:200,\x22b\x22:\x22\x22\x7d"#;
let out = decode_js_string_escapes(inner).unwrap();
assert_eq!(out, r#"{"s":200,"b":""}"#);
// A = 'A', mixed with literal
assert_eq!(decode_js_string_escapes(r"ABC").unwrap(), "ABC");
// standard escapes
assert_eq!(decode_js_string_escapes(r#"a\nb\t\\\"c"#).unwrap(), "a\nb\t\\\"c");
// truncated escape returns None instead of panicking
assert!(decode_js_string_escapes(r"\x7").is_none());
assert!(decode_js_string_escapes(r"\u00").is_none());
}
/// Hand-build the `goog.script.init("...", "", undefined)` wrapper for
/// a given inner relay JSON, matching the form Apps Script HtmlService
/// emits when the deployment uses HtmlService for its response. Every
/// `{`/`}` becomes `\x7b`/`\x7d`, every `"` becomes `\"`, every `:`
/// stays — that's the realistic subset our unwrapper has to cope with.
fn build_goog_script_init_wrapper(inner_relay_json: &str) -> String {
// Step 1: build the outer JSON object {"userHtml": "<inner>", ...}
// using serde so the inner JSON is properly JSON-escaped (including
// each `"` → `\"`).
let outer = serde_json::json!({ "userHtml": inner_relay_json });
let outer_str = serde_json::to_string(&outer).unwrap();
// Step 2: re-escape `{`/`}` → `\xNN` and `"` → `\"` to match the
// form Apps Script wraps inside the `goog.script.init("…")`
// JS string literal.
let mut wire = String::with_capacity(outer_str.len() * 2);
for ch in outer_str.chars() {
match ch {
'{' => wire.push_str(r"\x7b"),
'}' => wire.push_str(r"\x7d"),
'"' => wire.push_str(r#"\""#),
other => wire.push(other),
}
}
format!(
"<html><body><script>goog.script.init(\"{}\", \"\", undefined);</script></body></html>",
wire
)
}
#[test]
fn extract_apps_script_user_html_unwraps_goog_init() {
let inner_json = r#"{"s":200,"h":{},"b":"aGk="}"#;
let wrapped = build_goog_script_init_wrapper(inner_json);
let extracted = extract_apps_script_user_html(&wrapped).unwrap();
assert_eq!(extracted, inner_json);
}
#[test]
fn parse_relay_json_unwraps_goog_script_init() {
// End-to-end: an iframe-wrapped body should still parse correctly
// through parse_relay_json. Without the unwrap helper this used
// to fail with `key must be a string at line 2`.
let inner_json = r#"{"s":200,"h":{},"b":""}"#;
let wrapped = build_goog_script_init_wrapper(inner_json);
let raw = parse_relay_json(wrapped.as_bytes()).unwrap();
let s = String::from_utf8_lossy(&raw);
assert!(s.starts_with("HTTP/1.1 200 "), "got: {}", s);
}
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
async fn chunked_reader_consumes_final_crlf_and_trailers() { async fn chunked_reader_consumes_final_crlf_and_trailers() {
let (mut client, mut server) = duplex(1024); let (mut client, mut server) = duplex(1024);