feat(cfw): add Apps Script + Cloudflare Worker alternative backend

This commit is contained in:
dazzling-no-more
2026-04-30 16:41:19 +04:00
parent 777a28a16b
commit 9013eb9ef7
6 changed files with 888 additions and 8 deletions
+360
View File
@@ -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
);
}
+13 -8
View File
@@ -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.