mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-18 07:34:36 +03:00
feat(cfw): add Apps Script + Cloudflare Worker alternative backend
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* DomainFront Relay — Apps Script with Cloudflare Worker exit.
|
||||
*
|
||||
* Variant of Code.gs that off-loads the actual outbound HTTP fetch to
|
||||
* a Cloudflare Worker. Apps Script becomes a thin auth-and-forward
|
||||
* relay; Cloudflare does the work and pays the latency.
|
||||
*
|
||||
* mhrv-rs ──► Apps Script (this file) ──► Cloudflare Worker ──► target
|
||||
* ▲ inbound auth & batch ▲ outbound fetch + base64
|
||||
*
|
||||
* Wire protocol with mhrv-rs is identical to Code.gs:
|
||||
* 1. Single: POST { k, m, u, h, b, ct, r } → { s, h, b }
|
||||
* 2. Batch: POST { k, q: [{m,u,h,b,ct,r}, ...] } → { q: [{s,h,b}, ...] }
|
||||
* Both shapes are forwarded to the Worker as one POST per call
|
||||
* from Apps Script: single mode posts {k, u, m, ...} once, batch
|
||||
* mode posts {k, q: [...]} once. The Worker fans out batches
|
||||
* internally via Promise.all. This is the design choice that
|
||||
* makes Code.cfw.gs actually save GAS UrlFetchApp quota — without
|
||||
* it we'd have to fetchAll(N worker calls) and end up at parity
|
||||
* with the standard Code.gs.
|
||||
*
|
||||
* Trade-off summary (read before deploying):
|
||||
* + Per-call latency drops from ~250-500 ms (Apps Script internal
|
||||
* hop) to ~10-50 ms (CF edge). Visibly snappier for chat-style
|
||||
* workloads (Telegram, page navigation).
|
||||
* + Apps Script *runtime* quota (90 min/day on consumer accounts)
|
||||
* stretches significantly because each call now spends almost all
|
||||
* its time in the network leg to the Worker, not in the body
|
||||
* fetch + base64 + header processing.
|
||||
* + Apps Script *UrlFetchApp count* quota stretches roughly Nx for
|
||||
* an N-URL batch because the batch is sent as a small number of
|
||||
* POSTs to the Worker (one per chunk of WORKER_BATCH_CHUNK URLs),
|
||||
* not fanned out per-URL via fetchAll. For mhrv-rs's typical
|
||||
* 5-30 URL batches that's 1 GAS call (vs N under standard
|
||||
* Code.gs). Single non-batched requests still count 1:1.
|
||||
* - YouTube long-form streaming gets WORSE. Apps Script allows
|
||||
* ~6 min wall per execution; CF Workers cap at 30 s wall. The
|
||||
* SABR cliff hits sooner. For YouTube-heavy use, keep the
|
||||
* standard Code.gs (apps_script mode).
|
||||
* - Batch mode now has a per-batch wall, not per-URL: Promise.all
|
||||
* resolves only when every fetch finishes, so the slowest URL
|
||||
* dominates. mhrv-rs already retries failed batch items
|
||||
* individually, so failure modes are graceful, but it's a real
|
||||
* behavioural change vs Code.gs's per-URL fetchAll wall.
|
||||
* - Cloudflare anti-bot challenges on destination sites can be
|
||||
* stricter — exit IP is now in CF's own range, which CF's
|
||||
* anti-bot fingerprints as a worker-internal request. This is
|
||||
* a different problem than DPI bypass; not solved by either
|
||||
* variant.
|
||||
*
|
||||
* Deployment:
|
||||
* 1. Deploy assets/cloudflare/worker.js to Cloudflare Workers first
|
||||
* (set its AUTH_KEY to a strong secret).
|
||||
* 2. Note the *.workers.dev URL of that Worker.
|
||||
* 3. Open https://script.google.com → New project, delete default code.
|
||||
* 4. Paste THIS entire file.
|
||||
* 5. Set AUTH_KEY (must match the Worker's AUTH_KEY and your mhrv-rs
|
||||
* config's auth_key — all three identical).
|
||||
* 6. Set WORKER_URL to your *.workers.dev URL (must include https://).
|
||||
* 7. Deploy → New deployment → Web app
|
||||
* Execute as: Me | Who has access: Anyone
|
||||
* 8. Copy the Deployment ID into mhrv-rs config.json as "script_id".
|
||||
* mhrv-rs does not need to know about Cloudflare; it talks to
|
||||
* Apps Script the same way it always has.
|
||||
*
|
||||
* CHANGE THESE TWO CONSTANTS BELOW.
|
||||
*
|
||||
* Upstream credit for the GAS-→-Worker pattern: github.com/denuitt1/mhr-cfw.
|
||||
* This file inherits the hardening (decoy-on-bad-auth, hop-loop guard)
|
||||
* from the standard Code.gs.
|
||||
*/
|
||||
|
||||
const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET";
|
||||
|
||||
// Full https://… URL of the Cloudflare Worker you deployed using
|
||||
// assets/cloudflare/worker.js. Must include the scheme.
|
||||
const WORKER_URL = "https://CHANGE_ME.workers.dev";
|
||||
|
||||
// ── Sentinels — DO NOT EDIT ─────────────────────────────────
|
||||
// These two constants are NOT configuration. They are the literal
|
||||
// template-default values used by the fail-closed check in doPost so
|
||||
// that a forgotten edit (AUTH_KEY or WORKER_URL still set to the
|
||||
// placeholder) returns a loud error instead of silently accepting the
|
||||
// placeholder secret or POSTing to a bogus URL. Configure AUTH_KEY
|
||||
// and WORKER_URL above; leave these alone.
|
||||
const DEFAULT_AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET";
|
||||
const DEFAULT_WORKER_URL = "https://CHANGE_ME.workers.dev";
|
||||
|
||||
// Must match the Worker's MAX_BATCH_SIZE. Batches larger than this
|
||||
// are split into chunks of this size and dispatched via fetchAll —
|
||||
// each chunk costs 1 GAS UrlFetchApp call, so an N-URL batch costs
|
||||
// ceil(N/CHUNK) calls (still much cheaper than the per-URL cost
|
||||
// under standard Code.gs's fetchAll).
|
||||
const WORKER_BATCH_CHUNK = 40;
|
||||
|
||||
// Active-probing defense — same semantics as Code.gs. Bad-auth and
|
||||
// malformed POST bodies receive a decoy HTML page that looks like a
|
||||
// placeholder Apps Script web app instead of the JSON `{e}` error,
|
||||
// so probes can't fingerprint the deployment as a relay endpoint.
|
||||
// Flip to `true` only during initial setup if you need to debug an
|
||||
// "unauthorized" loop, then flip back before sharing the deployment.
|
||||
const DIAGNOSTIC_MODE = false;
|
||||
|
||||
const SKIP_HEADERS = {
|
||||
host: 1, connection: 1, "content-length": 1,
|
||||
"transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1,
|
||||
"priority": 1, te: 1,
|
||||
};
|
||||
|
||||
const DECOY_HTML =
|
||||
'<!DOCTYPE html><html><head><title>Web App</title></head>' +
|
||||
'<body><p>The script completed but did not return anything.</p>' +
|
||||
'</body></html>';
|
||||
|
||||
// ── Request Handlers ────────────────────────────────────────
|
||||
|
||||
function _decoyOrError(jsonBody) {
|
||||
if (DIAGNOSTIC_MODE) return _json(jsonBody);
|
||||
return ContentService
|
||||
.createTextOutput(DECOY_HTML)
|
||||
.setMimeType(ContentService.MimeType.HTML);
|
||||
}
|
||||
|
||||
function doPost(e) {
|
||||
try {
|
||||
// Fail-closed if either constant is still the template default.
|
||||
// Without this, a forgotten edit would either accept the placeholder
|
||||
// secret as valid auth or POST to a literal "CHANGE_ME" URL — both
|
||||
// are silent failure modes a deploy might miss. Surface them loud.
|
||||
if (AUTH_KEY === DEFAULT_AUTH_KEY) {
|
||||
return _json({ e: "configure AUTH_KEY in Code.cfw.gs" });
|
||||
}
|
||||
if (WORKER_URL === DEFAULT_WORKER_URL) {
|
||||
return _json({ e: "configure WORKER_URL in Code.cfw.gs" });
|
||||
}
|
||||
|
||||
var req = JSON.parse(e.postData.contents);
|
||||
if (req.k !== AUTH_KEY) return _decoyOrError({ e: "unauthorized" });
|
||||
|
||||
if (Array.isArray(req.q)) return _doBatch(req.q);
|
||||
return _doSingle(req);
|
||||
} catch (err) {
|
||||
return _decoyOrError({ e: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
function doGet(e) {
|
||||
return ContentService
|
||||
.createTextOutput(DECOY_HTML)
|
||||
.setMimeType(ContentService.MimeType.HTML);
|
||||
}
|
||||
|
||||
// ── Worker Forwarding ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Strip headers that must not be forwarded (hop-by-hop / Apps-Script-
|
||||
* managed). Returns a fresh header map; the input is never mutated.
|
||||
*/
|
||||
function _scrubHeaders(rawHeaders) {
|
||||
var out = {};
|
||||
if (rawHeaders && typeof rawHeaders === "object") {
|
||||
for (var k in rawHeaders) {
|
||||
if (rawHeaders.hasOwnProperty(k) && !SKIP_HEADERS[k.toLowerCase()]) {
|
||||
out[k] = rawHeaders[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one request item into the shape the Worker expects.
|
||||
* Used for both single and batch paths — single mode wraps this in
|
||||
* `{k, ...item}`; batch mode wraps it in `{k, q: [item, ...]}`.
|
||||
* Auth key is added at envelope level by callers, not per-item.
|
||||
*/
|
||||
function _normalizeItem(item) {
|
||||
return {
|
||||
u: item.u,
|
||||
m: (item.m || "GET").toUpperCase(),
|
||||
h: _scrubHeaders(item.h),
|
||||
b: item.b || null,
|
||||
ct: item.ct || null,
|
||||
r: item.r !== false,
|
||||
};
|
||||
}
|
||||
|
||||
function _workerFetchOptions(payload) {
|
||||
return {
|
||||
url: WORKER_URL,
|
||||
method: "post",
|
||||
contentType: "application/json",
|
||||
payload: JSON.stringify(payload),
|
||||
muteHttpExceptions: true,
|
||||
followRedirects: true,
|
||||
validateHttpsCertificates: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Single Request ─────────────────────────────────────────
|
||||
|
||||
function _doSingle(req) {
|
||||
if (!req.u || typeof req.u !== "string" || !req.u.match(/^https?:\/\//i)) {
|
||||
return _json({ e: "bad url" });
|
||||
}
|
||||
|
||||
var item = _normalizeItem(req);
|
||||
var envelope = {
|
||||
k: AUTH_KEY,
|
||||
u: item.u,
|
||||
m: item.m,
|
||||
h: item.h,
|
||||
b: item.b,
|
||||
ct: item.ct,
|
||||
r: item.r,
|
||||
};
|
||||
var opts = _workerFetchOptions(envelope);
|
||||
// muteHttpExceptions covers HTTP-level errors (4xx/5xx come back as
|
||||
// a normal HTTPResponse). It does NOT cover network-level failures
|
||||
// — DNS resolution failure, TLS handshake failure, connection
|
||||
// timeout to *.workers.dev, etc. — those throw. Catch and surface
|
||||
// them as `{e}` so the operator debugging "why isn't my deployment
|
||||
// responding?" gets a useful signal instead of the doPost outer
|
||||
// catch returning the decoy HTML page (which makes the deployment
|
||||
// look like a bad-auth probe to the client). Auth has already
|
||||
// passed at this point so the probe-defence argument doesn't apply.
|
||||
var resp;
|
||||
try {
|
||||
resp = UrlFetchApp.fetch(opts.url, opts);
|
||||
} catch (err) {
|
||||
return _json({ e: "worker unreachable: " + String(err) });
|
||||
}
|
||||
return _json(_parseWorkerJson(resp));
|
||||
}
|
||||
|
||||
// ── Batch Request ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Forward a batch to the Worker, chunking when needed. Each chunk
|
||||
* becomes ONE POST to the Worker; the Worker fans out across the URLs
|
||||
* in the chunk via Promise.all and returns `{q: [...]}` in the same
|
||||
* order. Multiple chunks fire in parallel via UrlFetchApp.fetchAll.
|
||||
*
|
||||
* Quota cost: ceil(N / WORKER_BATCH_CHUNK) GAS UrlFetchApp calls for
|
||||
* an N-URL batch. For typical mhrv-rs batches of 5-30 URLs this is
|
||||
* exactly 1 call (vs N under standard Code.gs's fetchAll). Larger
|
||||
* batches gracefully degrade to a few calls instead of failing under
|
||||
* the Worker's own MAX_BATCH_SIZE soft cap.
|
||||
*
|
||||
* Bad-URL items are filtered locally so the Worker only sees valid
|
||||
* inputs, then re-interleaved into the result array in original order
|
||||
* so mhrv-rs's batch-index assumptions hold.
|
||||
*/
|
||||
function _doBatch(items) {
|
||||
var validItems = [];
|
||||
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;
|
||||
}
|
||||
validItems.push(_normalizeItem(item));
|
||||
}
|
||||
|
||||
var workerResults = [];
|
||||
if (validItems.length > 0) {
|
||||
// Split into chunks ≤ WORKER_BATCH_CHUNK so each Worker call stays
|
||||
// under the Worker's MAX_BATCH_SIZE cap. Single-chunk fast path
|
||||
// avoids the fetchAll overhead for the common case.
|
||||
var chunks = [];
|
||||
for (var c = 0; c < validItems.length; c += WORKER_BATCH_CHUNK) {
|
||||
chunks.push(validItems.slice(c, c + WORKER_BATCH_CHUNK));
|
||||
}
|
||||
|
||||
var fetchOpts = chunks.map(function(chunk) {
|
||||
return _workerFetchOptions({ k: AUTH_KEY, q: chunk });
|
||||
});
|
||||
|
||||
// muteHttpExceptions covers HTTP-level errors. Network-level
|
||||
// failures (DNS, TLS, connection timeout to *.workers.dev) still
|
||||
// throw — catch and convert to per-chunk `{e}` errors that get
|
||||
// spread across each chunk's slots. mhrv-rs's per-item retry
|
||||
// then handles them individually instead of getting the decoy
|
||||
// HTML page from the doPost outer catch. See _doSingle for why
|
||||
// the probe-defence argument doesn't apply post-auth.
|
||||
var responses;
|
||||
try {
|
||||
if (fetchOpts.length === 1) {
|
||||
responses = [UrlFetchApp.fetch(fetchOpts[0].url, fetchOpts[0])];
|
||||
} else {
|
||||
responses = UrlFetchApp.fetchAll(fetchOpts);
|
||||
}
|
||||
} catch (err) {
|
||||
var unreachable = { e: "worker unreachable: " + String(err) };
|
||||
for (var u = 0; u < validItems.length; u++) workerResults.push(unreachable);
|
||||
// Skip the per-response loop below by returning early through the
|
||||
// reassembly code path.
|
||||
responses = null;
|
||||
}
|
||||
|
||||
for (var r = 0; responses && r < responses.length; r++) {
|
||||
var parsed = _parseWorkerJson(responses[r]);
|
||||
if (parsed && Array.isArray(parsed.q)) {
|
||||
for (var k = 0; k < parsed.q.length; k++) {
|
||||
workerResults.push(parsed.q[k]);
|
||||
}
|
||||
} else {
|
||||
// Per-chunk failure (worker error, parse failure, auth, etc).
|
||||
// Spread the same error to every slot in this chunk so mhrv-rs
|
||||
// retries each item individually rather than masking the
|
||||
// failure. Other chunks are unaffected.
|
||||
var slotErr = (parsed && parsed.e)
|
||||
? { e: parsed.e }
|
||||
: { e: "worker batch failure" };
|
||||
for (var s = 0; s < chunks[r].length; s++) workerResults.push(slotErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reassemble into the original order: validated slots get their
|
||||
// worker result; invalid slots get their pre-flight error.
|
||||
var results = [];
|
||||
var wi = 0;
|
||||
for (var j = 0; j < items.length; j++) {
|
||||
if (errorMap.hasOwnProperty(j)) {
|
||||
results.push({ e: errorMap[j] });
|
||||
} else {
|
||||
results.push(workerResults[wi++] || { e: "missing worker response" });
|
||||
}
|
||||
}
|
||||
return _json({ q: results });
|
||||
}
|
||||
|
||||
// ── Worker response handling ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse the Worker's JSON envelope. Worker errors come back as
|
||||
* `{e: "..."}` — pass them through to the client unchanged so mhrv-rs
|
||||
* sees the same error-shape it would for a direct-fetch failure in
|
||||
* Code.gs. On HTTP errors from the Worker itself (auth failure, 5xx,
|
||||
* etc.), wrap into `{e}` so the client gets a useful message instead
|
||||
* of a parse-failure.
|
||||
*/
|
||||
function _parseWorkerJson(resp) {
|
||||
var code = resp.getResponseCode();
|
||||
var text = resp.getContentText();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (err) {
|
||||
return { e: "worker " + code + ": " + (text.length > 200 ? text.substring(0, 200) + "…" : text) };
|
||||
}
|
||||
}
|
||||
|
||||
function _json(obj) {
|
||||
return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(
|
||||
ContentService.MimeType.JSON
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
# Apps Script source (mirrored)
|
||||
# Apps Script source
|
||||
|
||||
The file `Code.gs` next to this README is a verbatim snapshot of the upstream script you deploy in your own Google Apps Script project:
|
||||
Three deploy-ready Apps Script files live here. They all speak the same `{k, m, u, h, b, ct, r}` wire protocol with `mhrv-rs`, so the client just points its `script_id` at whichever deployment you want — no mode change required.
|
||||
|
||||
- Upstream: <https://github.com/masterking32/MasterHttpRelayVPN/blob/python_testing/apps_script/Code.gs>
|
||||
- Raw link: <https://raw.githubusercontent.com/masterking32/MasterHttpRelayVPN/refs/heads/python_testing/apps_script/Code.gs>
|
||||
## Variants and origins
|
||||
|
||||
This copy lives in our repo for two reasons:
|
||||
- **`Code.gs`** — standard relay. **Verbatim mirror of upstream.** Apps Script does the outbound fetch itself. This is the default choice for most users.
|
||||
- Upstream: <https://github.com/masterking32/MasterHttpRelayVPN/blob/python_testing/apps_script/Code.gs>
|
||||
- Credit: [@masterking32](https://github.com/masterking32). We do not modify this file.
|
||||
- The mirror lives here so that (a) users on networks where `raw.githubusercontent.com` is unreachable can still deploy from a `git clone` / ZIP, and (b) we have a snapshot to diff against if upstream changes silently break the informal relay protocol.
|
||||
|
||||
1. **Survives upstream outages**: if the user is on a network where raw.githubusercontent.com is temporarily unreachable but they can clone or ZIP this repo, they still have the deploy-ready file.
|
||||
2. **Pins what we tested against**: the relay protocol between `mhrv-rs` and the script is informal; upstream changes can silently break us. Keeping a snapshot here lets us diff and see if a spec drift is responsible for any reported breakage.
|
||||
- **`CodeFull.gs`** — superset of `Code.gs` that additionally proxies raw-TCP / UDP via `tunnel-node` (used by `mode: "full"`). **Maintained in this repo** — written for this Rust port and not present upstream. Deploy this if you want full-tunnel mode; details in the file's header comment.
|
||||
|
||||
All credit for `Code.gs` goes to [@masterking32](https://github.com/masterking32) — we do not modify it. If you're using mhrv-rs, follow the upstream deploy instructions in the script's header comment. The only edit **you** must make is the `AUTH_KEY` constant — set it to a strong secret and reuse that exact string in your `mhrv-rs` config.
|
||||
- **`Code.cfw.gs`** — Apps Script becomes a thin auth+forward layer; the actual outbound fetch happens on a Cloudflare Worker you also deploy ([`assets/cloudflare/`](../cloudflare/)). **Derivative work — not unmodified upstream.** The pattern of forwarding through a Cloudflare Worker came from [denuitt1/mhr-cfw](https://github.com/denuitt1/mhr-cfw); this file inherits hardening from `Code.gs` (decoy-on-bad-auth, fail-closed sentinels) and adds chunked batch forwarding (`Promise.all` on the Worker side, `ceil(N/40)` GAS calls per batch) that the upstream `mhr-cfw` does not have. Faster per-call latency, worse YouTube long-form, no fix for Cloudflare anti-bot. Read [`assets/cloudflare/README.md`](../cloudflare/README.md) before choosing this one.
|
||||
|
||||
## What you must edit before deploying
|
||||
|
||||
For every variant: change `AUTH_KEY` from its placeholder to a strong secret, and use that same string in your `mhrv-rs` config's `auth_key`. `Code.cfw.gs` additionally requires setting `WORKER_URL` to your deployed Cloudflare Worker URL; `CodeFull.gs` additionally requires `TUNNEL_SERVER_URL` and `TUNNEL_AUTH_KEY` for the tunnel-node leg.
|
||||
|
||||
Reference in New Issue
Block a user