From 9013eb9ef7d73c494f3b5a75e917bb3c90f5b6b3 Mon Sep 17 00:00:00 2001 From: dazzling-no-more <278675588+dazzling-no-more@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:41:19 +0400 Subject: [PATCH] feat(cfw): add Apps Script + Cloudflare Worker alternative backend --- README.md | 6 + assets/apps_script/Code.cfw.gs | 360 +++++++++++++++++++++++++++++++++ assets/apps_script/README.md | 21 +- assets/cloudflare/README.fa.md | 110 ++++++++++ assets/cloudflare/README.md | 97 +++++++++ assets/cloudflare/worker.js | 302 +++++++++++++++++++++++++++ 6 files changed, 888 insertions(+), 8 deletions(-) create mode 100644 assets/apps_script/Code.cfw.gs create mode 100644 assets/cloudflare/README.fa.md create mode 100644 assets/cloudflare/README.md create mode 100644 assets/cloudflare/worker.js diff --git a/README.md b/README.md index 82fa5b88..b4a9980f 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ This part is unchanged from the original project. Follow @masterking32's guide o - Who has access: **Anyone** 6. Copy the **Deployment ID** (the long random string in the URL). +> **Alternative backend — Apps Script + Cloudflare Worker.** 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. The win on day one is **latency** (~10-50 ms at the CF edge vs ~250-500 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 vs 6 min), no fix for 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`. + #### Can't reach `script.google.com` from your network? If your ISP is already blocking Google Apps Script (or all of Google), you need Step 1's browser connection to succeed *before* you have a relay to use. `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 — the old name is still accepted in config files.) @@ -499,6 +501,10 @@ Donations cover hosting, self-hosted CI runner costs, and continued maintenance. > **نکته:** اگر نمی‌دانید رمز `AUTH_KEY` چه بگذارید، یک رشتهٔ تصادفی ۱۶ تا ۲۴ کاراکتری بسازید. مهم فقط این است که **دقیقاً همان رشته** را در برنامه هم وارد کنید. + + +> **پشتیبان جایگزین — `Apps Script` + `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` همیشه درخواست تک‌آدرسی می‌فرستد؛ مسیر دسته‌ای روی `GAS+Worker` آماده و سیم‌کشی شده (`ceil(N/40)` سهمیه به‌ازای دستهٔ `N` آدرسی) ولی هیچ کلاینتی فعلاً آن را تولید نمی‌کند. مبادلات: ویدیوی طولانی یوتیوب بدتر می‌شود (دیوار ۳۰ ثانیه به جای ۶ دقیقه)، ضدبات `Cloudflare` را حل نمی‌کند، و **با `mode: "full"` سازگار نیست** (پشتیبانی از عملیات تونل ندارد → برای واتس‌اَپ و سایر مسنجرها روی اندرویدِ تونل کامل کمکی نمی‌کند). راهنمای کامل استقرار و جدول مبادلات در [`assets/cloudflare/README.fa.md`](assets/cloudflare/README.fa.md). در `mhrv-rs` هیچ تنظیمی تغییر نمی‌کند — همان `mode: "apps_script"`، همان `script_id`، همان `auth_key`. + #### به `script.google.com` هم دسترسی ندارید؟ اگر `ISP` شما از قبل `Apps Script` (یا کل گوگل) را مسدود کرده، برای مرحلهٔ ۱ باید مرورگرتان **اول** به `script.google.com` برسد — قبل از اینکه رله‌ای داشته باشید. `mhrv-rs` یک حالت `direct` دقیقاً برای همین دارد — فقط تونل بازنویسی `SNI`، بدون نیاز به رلهٔ `Apps Script`. (قبل از v1.9 این حالت `google_only` نام داشت — نام قدیمی همچنان در فایل کانفیگ پذیرفته می‌شود.) diff --git a/assets/apps_script/Code.cfw.gs b/assets/apps_script/Code.cfw.gs new file mode 100644 index 00000000..f455fe20 --- /dev/null +++ b/assets/apps_script/Code.cfw.gs @@ -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 = + 'Web App' + + '

The script completed but did not return anything.

' + + ''; + +// ── 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 + ); +} diff --git a/assets/apps_script/README.md b/assets/apps_script/README.md index 1cf339a2..6af81d64 100644 --- a/assets/apps_script/README.md +++ b/assets/apps_script/README.md @@ -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: -- Raw link: +## 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: + - 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. diff --git a/assets/cloudflare/README.fa.md b/assets/cloudflare/README.fa.md new file mode 100644 index 00000000..4b183940 --- /dev/null +++ b/assets/cloudflare/README.fa.md @@ -0,0 +1,110 @@ +
+ +# خروجی Cloudflare Worker (پشتیبان جایگزین برای Apps Script) + +> *English: [README.md](README.md)* + +این پوشه یک **Cloudflare Worker** ارائه می‌کند که همراه با [`assets/apps_script/Code.cfw.gs`](../apps_script/Code.cfw.gs) شکل متفاوتی از حالت `apps_script` به شما می‌دهد: + +``` +mhrv-rs ──► Apps Script (Code.cfw.gs) ──► Cloudflare Worker ──► مقصد + ▲ فقط احراز هویت و فوروارد ▲ گرفتن داده + base64 +``` + +پشتیبان استاندارد ([`assets/apps_script/Code.gs`](../apps_script/Code.gs)) خودِ `Apps Script` کار `fetch` به مقصد را انجام می‌دهد. این نسخه‌ٔ جایگزین، `Apps Script` را به یک رلهٔ نازک تبدیل می‌کند و کارِ اصلی را به لبهٔ `Cloudflare` می‌سپارد. **خود `mhrv-rs` تغییر نمی‌کند** — همان پاکت `JSON` روی سیم، همان `mode: "apps_script"` در `config.json`، همان `script_id`. تنها تفاوت این است که `Apps Script` مستقر شدهٔ شما بعد از احراز هویت چه می‌کند. + +ایدهٔ اصلی: . این کپی یک بررسی `AUTH_KEY` روی خود `Worker` اضافه می‌کند، رفتار «صفحهٔ تقلبی برای کلید نامعتبر» را از `Code.gs` به ارث می‌برد، و یک محافظ در برابر حلقه‌شدن دارد. + +## چه‌وقت ارزش راه‌اندازی دارد؟ + +✅ مرور وب، باز کردن صفحات جدید، ترافیک گفتگومحور — به‌طور محسوسی سریع‌تر می‌شود. تأخیر هر تماس از کف ۲۵۰ تا ۵۰۰ میلی‌ثانیه‌ٔ `Apps Script` به ۱۰ تا ۵۰ میلی‌ثانیه‌ٔ لبهٔ `Cloudflare` کاهش می‌یابد. + +✅ تلگرام بلادرنگ — پیام‌های کوتاه و مکرر بیشترین سود را می‌برند. + +✅ شبکه‌هایی که در آن‌ها ابتدا سهمیهٔ **زمان اجرای `Apps Script`** (۹۰ دقیقه در روز برای حساب‌های مصرفی گوگل) تمام می‌شود، نه شمارش `URL fetch`. در این حالت `GAS` تقریباً هیچ زمانی صرف هر تماس نمی‌کند. + +❌ **امروز هیچ کاهشی در شمارش روزانهٔ `UrlFetchApp` به دست نمی‌آورید.** مسیر رلهٔ `HTTP` در `mhrv-rs` همیشه فقط یک پاکت تک‌آدرسی می‌فرستد و هیچ‌گاه شکل دسته‌ای `q: [...]` را تولید نمی‌کند، پس هر درخواست کاربر همچنان یک `UrlFetchApp` در `GAS` مصرف می‌کند — مستقل از اینکه کدام نسخهٔ `Code.gs` را مستقر کرده باشید. مسیر `Code.cfw.gs` به سمت `Worker` *قابلیت* پشتیبانی از دسته را دارد (قطعه‌بندی ۴۰‌تایی، پخش‌سازی روی `Worker` با `Promise.all`، هزینهٔ `ceil(N / 40)` به جای `N`)، ولی این شاخه از هیچ کلاینت موجودی فراخوانی نمی‌شود. **تا زمانی که `mhrv-rs` خودش `HTTP relay` را دسته‌بندی نکند، سقف روزانهٔ ~۲۰٬۰۰۰ مصرف نسبت به `Code.gs` تغییر نمی‌کند.** این پشتیبانی برای سازگاری آینده در کد نگه داشته شده — هزینه‌ای ندارد و روزی که کلاینتِ دسته‌بندی‌کننده برسد، خود به خود فعال می‌شود. + +❌ ویدیوهای طولانی یوتیوب — **بدتر** می‌شود، نه بهتر. `Apps Script` تا حدود ۶ دقیقه دیوار اجرا (`wall`) به ازای هر فراخوانی می‌دهد؛ `Cloudflare Workers` در ۳۰ ثانیه قطع می‌کنند. صخرهٔ `SABR` زودتر فرا می‌رسد. برای استفادهٔ یوتیوب‌محور، روی `Code.gs` بمانید. + +❌ سایت‌هایی که پشت ضدبات `Cloudflare` هستند (توییتر/`X`، `OpenAI`، …) — `IP` خروجی حالا داخل خود `Cloudflare` است، که ضدبات `Cloudflare` آن را به‌عنوان «درخواست داخلی `Worker`» انگشت‌نگاری می‌کند. اغلب **سختگیرانه‌تر** از `IP` گوگل برخورد می‌شود. این مشکلی جدا از عبور از `DPI` است و هیچ‌کدام از این دو نسخه آن را حل نمی‌کنند. + +❌ اگر/زمانی که `HTTP relay` دسته‌ای فعال شود، سقف ۳۰ ثانیه‌ٔ `Cloudflare` روی **کندترین آدرس در هر قطعه** اعمال خواهد شد، نه به‌ازای هر `URL` — یک مقصد قفل‌شده می‌تواند کل قطعهٔ ۴۰ آدرسی را به `timeout` بکشاند. تلاش مجدد تک‌به‌تک در `mhrv-rs` این را پوشش می‌دهد، اما تفاوت رفتاری نسبت به دیوار `per-URL` در `fetchAll` استانداردِ `Code.gs` است. (امروز بی‌اثر است چون کلاینت دسته نمی‌فرستد.) + +## راه‌اندازی + +سه رشتهٔ هم‌خوان نیاز دارید: یک `AUTH_KEY` که بین `worker.js`، `Code.cfw.gs` و `config.json` خود `mhrv-rs` مشترک است. یک رمز تصادفی قوی انتخاب کنید و در هر سه جا paste کنید. + +### ۱. استقرار `Worker` + +۱. وارد شوید → **`Workers & Pages`** → **`Create`** → **`Hello World`** → **`Deploy`**. +۲. روی **`Edit code`** بزنید، کد پیش‌فرض را پاک کنید و محتوای [`worker.js`](worker.js) را paste کنید. +۳. ثابت `AUTH_KEY` در بالای فایل را به رمز قوی خودتان تغییر دهید. +۴. روی **`Deploy`** بزنید. آدرس `*.workers.dev` را کپی کنید — در مرحلهٔ بعد لازم است. + +### ۲. استقرار `Apps Script` + +۱. وارد با حساب گوگلتان شوید → **`New project`** → کد پیش‌فرض را پاک کنید. + +۲. محتوای [`../apps_script/Code.cfw.gs`](../apps_script/Code.cfw.gs) را paste کنید. + +۳. هر دو ثابت بالای فایل را تنظیم کنید: + - مقدار `AUTH_KEY` را همان رمزی بگذارید که در `worker.js` گذاشتید. + - مقدار `WORKER_URL` را آدرس کامل `https://…workers.dev` همان `Worker` که الان مستقر کردید بگذارید (حتماً با پیشوند `https://`). + +۴. از مسیر **`Deploy → New deployment → Web app`** استقرار را شروع کنید: مقدار `Execute as` را روی **`Me`** و `Who has access` را روی **`Anyone`** بگذارید. + +۵. سپس **`Deployment ID`** را کپی کنید. + +### ۳. اشاره دادن `mhrv-rs` به این `Apps Script` + +در `config.json` (یا از طریق فرم `UI`): + +```json +{ + "mode": "apps_script", + "script_id": "PASTE_DEPLOYMENT_ID_HERE", + "auth_key": "SAME_SECRET_AS_BOTH_FILES_ABOVE" +} +``` + +تمام. `mhrv-rs` لازم نیست بداند `Cloudflare` در کار است؛ از نگاه او این `script_id` مثل هر `Deployment` دیگری رفتار می‌کند. اگر چند `Deployment` دارید (بعضی استاندارد، بعضی `CFW`)، می‌توانید همه را در `script_ids: [...]` بگذارید — `round-robin` و `parallel-relay` همچنان روی همه‌شان کار می‌کند. + +## چرا هر سه `AUTH_KEY` باید یکی باشند؟ + +- **بین `mhrv-rs` و `Apps Script`**: جلوی این را می‌گیرد که هر `POST` تصادفی روی آدرس `*.googleusercontent.com` شما رله شود. درخواست‌هایی که این کلید را نداشته باشند، یک صفحهٔ `HTML` تقلبی می‌گیرند (به‌خاطر `DIAGNOSTIC_MODE = false` در `Code.cfw.gs`) و `Deployment` شما به‌جای یک تونل، شبیه یک پروژهٔ فراموش‌شده دیده می‌شود. +- **بین `Apps Script` و `Worker`**: اگر آدرس `Worker` لو برود، جلوی این را می‌گیرد که به یک رلهٔ `HTTP` باز برای مهاجم تبدیل شود. بدون این بررسی، `Worker` شما برای هر کسی که `URL` را پیدا کند، قابل سوءاستفاده است. نسخهٔ بالادست `mhr-cfw` این بررسی را ندارد؛ این کپی آن را اضافه می‌کند. + +اگر می‌خواهید برای امنیت بیشتر روی هر بخش رمز جدا داشته باشید، `Code.cfw.gs` را ویرایش کنید تا یک `k` متفاوت از آن چیزی که از `mhrv-rs` می‌گیرد به `Worker` بفرستد. تنظیم تک‌رمز ساده‌ترین حالتِ درست است. + +## بررسی اینکه کار می‌کند + +همان روش پشتیبان استاندارد: را از طریق پروکسی باز کنید. باید یک `IP` متعلق به `Cloudflare` ببینید (چون `fetch` واقعی حالا از شبکهٔ `Cloudflare` خارج می‌شود)، نه یک `IP` متعلق به گوگل که با `Code.gs` می‌دیدید. اگر `IP` واقعی خودتان را ببینید، پروکسی استفاده نمی‌شود؛ اگر `IP` گوگل ببینید، اشتباهاً `Code.gs` را به‌جای `Code.cfw.gs` مستقر کرده‌اید. + +دکمهٔ **`Test`** در `UI` دسکتاپ همچنان کار می‌کند — یک درخواست `HEAD` از طریق هر `Apps Script Deployment` که تنظیم کرده‌اید رله می‌کند. + +## جدول مقایسه در یک نگاه + +| محور | `Code.gs` (استاندارد) | `Code.cfw.gs` (این نسخه) | +|---|---|---| +| کف تأخیر هر تماس | ۲۵۰–۵۰۰ میلی‌ثانیه (هاپ داخلی `GAS`) | ۱۰–۵۰ میلی‌ثانیه (لبهٔ `CF`) | +| سهمیهٔ `UrlFetchApp` در روز، **آنچه `mhrv-rs` امروز می‌فرستد** | ۱ سهمیه به‌ازای هر درخواست | ۱ سهمیه به‌ازای هر درخواست — یکسان (`mhrv-rs` فقط پاکت تک‌آدرسی تولید می‌کند) | +| سهمیهٔ `UrlFetchApp` در روز، **اگر کلاینتی در آینده دسته بفرستد** | تعداد `N` سهمیه (یکی برای هر آدرس از طریق `fetchAll`) | تعداد `ceil(N / 40)` سهمیه (قطعه‌بندی ۴۰‌تایی؛ پخش‌سازی روی `Worker` با `Promise.all`) | +| سقف درخواست `Cloudflare Workers` در روز (پلن رایگان) | ندارد | ۱۰۰٬۰۰۰ — بسیار بالاتر از چیزی که `GAS` می‌تواند تغذیه‌اش کند؛ گلوگاه نیست | +| سهمیهٔ زمان اجرای `Apps Script` در روز | ۹۰ دقیقه، اغلب گلوگاه | ۹۰ دقیقه، به‌ندرت گلوگاه | +| دیوار اجرای هر فراخوانی | ~۶ دقیقه، به‌ازای هر آدرس | ۳۰ ثانیه، به‌ازای هر تماس (اگر دسته‌بندی فعال شود، به‌ازای هر قطعه) | +| سقف اندازهٔ پاسخ | ~۵۰ مگابایت (مستندات `Apps Script`) | محدود به حافظهٔ `Worker` (۱۲۸ مگابایت در پلن رایگان)؛ در عمل با تبدیل `base64` چند ده مگابایت | +| حروف بزرگ/کوچک هدرهای پاسخ | همان‌طور که مبدأ فرستاده | کاملاً کوچک می‌شود (`Headers.forEach` در `Workers` نرمال می‌کند). فقط برای ابزارهای پایین‌دستی که نام هدر را حساس به حروف مقایسه می‌کنند مهم است؛ `mhrv-rs` خود حساس به حروف نیست. | +| پخش ویدیوی طولانی یوتیوب | قابل قبول (صخرهٔ ۶ دقیقه) | بدتر (صخرهٔ ۳۰ ثانیه) | +| سرعت تلگرام / گفتگو | پایه | محسوساً بهتر | +| ضدبات `Cloudflare` روی مقصد | یک `IP` دیتاسنتر | یک `IP` داخلی `Worker` (اغلب سخت‌گیرانه‌تر) | +| کش پاسخ روی `Spreadsheet` | موجود (اختیاری) | در این نسخه نیست | +| پیچیدگی استقرار | ۱ چیز برای نگه‌داری | ۲ چیز که باید همگام بمانند | + +اگر این مبادلات به نفع شماست، این نسخه را مستقر کنید. اگر نیست — یا حساب `Cloudflare` ندارید — روی `Code.gs` بمانید. + +## محدودیت مهم: این نسخه با `mode: "full"` کار نمی‌کند + +این فایل فقط مسیر **رلهٔ `HTTP`** (حالت‌های ۱ و ۲ در `CodeFull.gs`) را پورت می‌کند. عملیات تونل `TCP/UDP` خام (حالت‌های ۳ و ۴ در `CodeFull.gs` که برای `mode: "full"` و کاربری اپلیکیشن‌های موبایل مثل واتس‌اَپ روی اندروید لازم‌اند) در `Code.cfw.gs` پشتیبانی نمی‌شوند. اگر در حالت `full` هستید و `WhatsApp` کند است، این تغییر کمکی نمی‌کند — این مسئلهٔ متفاوتی است که نیاز به طراحی جداگانه دارد. + +
diff --git a/assets/cloudflare/README.md b/assets/cloudflare/README.md new file mode 100644 index 00000000..403fe81b --- /dev/null +++ b/assets/cloudflare/README.md @@ -0,0 +1,97 @@ +# Cloudflare Worker exit (alternative Apps Script backend) + +> *فارسی: [README.fa.md](README.fa.md)* + +This directory ships a **Cloudflare Worker** that pairs with [`assets/apps_script/Code.cfw.gs`](../apps_script/Code.cfw.gs) to give you a different shape of `apps_script` mode: + +``` +mhrv-rs ──► Apps Script (Code.cfw.gs) ──► Cloudflare Worker ──► target + ▲ thin auth + forward ▲ outbound fetch + base64 +``` + +The standard backend (`assets/apps_script/Code.gs`) does the outbound fetch from inside Apps Script directly. This variant makes Apps Script a thin relay and pushes the actual fetch to Cloudflare's edge. **mhrv-rs itself is unchanged** — same JSON envelope on the wire, same `mode: "apps_script"` in `config.json`, same `script_id`. The only thing that's different is what your deployed Apps Script does after it authenticates the request. + +Original idea: . This copy adds an `AUTH_KEY` check on the Worker, the decoy-on-bad-auth treatment from `Code.gs`, and a hop-loop guard. + +## When this is worth it + +✅ Browsing, page navigation, chat-style traffic — visibly snappier. Per-call latency drops from the ~250-500 ms Apps Script floor to ~10-50 ms at the CF edge. +✅ Telegram realtime — small frequent messages benefit most. +✅ Networks where the Apps Script *runtime* quota (90 min/day on consumer Google accounts) is what you hit before the URL-fetch count cap. GAS spends almost no time per call here. + +❌ **No `UrlFetchApp` daily-count relief today.** mhrv-rs's HTTP relay path emits a single-URL envelope per request, never the `q: [...]` batch shape, so each user request still consumes one GAS UrlFetchApp call regardless of which `Code.gs` variant is deployed. The `Code.cfw.gs` ↔ Worker path *is* batch-aware (chunks at 40, Worker fans out via `Promise.all`, costs `ceil(N / 40)` per batch instead of N), but that branch is unreachable from any shipping client. **Until/unless mhrv-rs grows HTTP-relay batching, the daily 20k-fetch ceiling is unchanged from `Code.gs`.** The ready batching support is left in place for forward compatibility — it costs nothing and goes live the day a batching client lands. +❌ YouTube long-form video — gets **worse**, not better. Apps Script allows ~6 min wall per execution; CF Workers cap at 30 s. The SABR cliff arrives sooner. Stay on `Code.gs` for YouTube-heavy use. +❌ Sites behind Cloudflare anti-bot (Twitter/X, OpenAI, etc.) — exit IP becomes a Workers IP, which CF's own anti-bot fingerprints as a worker-internal request. Often *stricter* than a Google IP. This is a separate problem from DPI bypass and neither variant fixes it. +❌ When/if HTTP-relay batching ships, the 30 s wall would apply to **the slowest URL in each chunk**, not per-URL — a single hung target could drag a 40-URL chunk to timeout. mhrv-rs's existing per-item retry would absorb this, but it's a behavioral change vs the per-URL `fetchAll` wall under `Code.gs`. (Inert today since no batching client exists.) + +## Setup + +You need three matching strings: an `AUTH_KEY` shared between `worker.js`, `Code.cfw.gs`, and your `mhrv-rs` `config.json`. Pick a strong random secret once and paste it into all three. + +### 1. Deploy the Worker + +1. Open → **Workers & Pages** → **Create** → **Hello World** → **Deploy**. +2. Click **Edit code**, delete the template, and paste the contents of [`worker.js`](worker.js). +3. Change the `AUTH_KEY` constant near the top of the file to your strong secret. +4. **Deploy**. Copy the `*.workers.dev` URL — you'll need it next. + +### 2. Deploy the Apps Script + +1. Open while signed into your Google account → **New project** → delete the default code. +2. Paste the contents of [`../apps_script/Code.cfw.gs`](../apps_script/Code.cfw.gs). +3. Set both constants at the top: + - `AUTH_KEY` — the same secret you set in `worker.js`. + - `WORKER_URL` — the full `https://…workers.dev` URL of the Worker you just deployed (must include the scheme). +4. **Deploy → New deployment → Web app**: *Execute as* = **Me**, *Who has access* = **Anyone**. +5. Copy the **Deployment ID**. + +### 3. Point mhrv-rs at the Apps Script + +In `config.json` (or via the UI's config form): + +```json +{ + "mode": "apps_script", + "script_id": "PASTE_DEPLOYMENT_ID_HERE", + "auth_key": "SAME_SECRET_AS_BOTH_FILES_ABOVE" +} +``` + +That's it. mhrv-rs doesn't need to know Cloudflare exists; from its perspective, the `script_id` deployment behaves like any other. If you have multiple deployments (some plain, some CFW), `script_ids: [...]` round-robins across all of them and the parallel-relay fan-out still works. + +## Why three matching `AUTH_KEY`s + +- **mhrv-rs ↔ Apps Script**: prevents random POSTs to your `*.googleusercontent.com` deployment from being relayed. Probes that don't carry the key get the decoy HTML page (`DIAGNOSTIC_MODE = false` in `Code.cfw.gs`), so the deployment looks like a forgotten placeholder rather than a tunnel. +- **Apps Script ↔ Worker**: prevents random POSTs to your `*.workers.dev` Worker from being relayed if the Worker URL ever leaks. Without this check the Worker becomes an open HTTP-relay for arbitrary attackers. The upstream `mhr-cfw` Worker omits it; this copy adds it back. + +If you want compartmentalization (different secret on each leg), edit `Code.cfw.gs` to send a different `k` to the Worker than the one it accepts from mhrv-rs. The single-secret setup is the simplest correct configuration. + +## Verifying it works + +Same procedure as the standard backend: open through the proxy. You should see a Cloudflare-owned IP (since the actual fetch now exits Cloudflare's network), not a Google-owned one as you would with `Code.gs`. If you see your real IP, the proxy isn't being used; if you see a Google IP, you deployed `Code.gs` instead of `Code.cfw.gs`. + +The `Test` button in the desktop UI still works — it does a HEAD relay through whichever Apps Script deployment you configured. + +## Trade-off table at a glance + +| Axis | `Code.gs` (standard) | `Code.cfw.gs` (this variant) | +|---|---|---| +| Per-call latency floor | ~250-500 ms (GAS internal hop) | ~10-50 ms (CF edge) | +| Apps Script `UrlFetchApp`/day, **what mhrv-rs sends today** | 1 quota / request | 1 quota / request — same (mhrv-rs only emits single-URL envelopes) | +| Apps Script `UrlFetchApp`/day, **if a future client batches** | N quota (one per URL via `fetchAll`) | `ceil(N / 40)` quota (chunks at 40, Worker fans out via `Promise.all`) | +| CF Workers requests/day (free tier) | n/a | 100 000 — far above what GAS can feed it; not the binding ceiling | +| Apps Script runtime/day | 90 min, often binding | 90 min, rarely binding | +| Per-execution wall budget | ~6 min, per-URL | 30 s, per-call (would become per-chunk if batching ships) | +| Per-response size cap | ~50 MB (Apps Script doc'd) | bounded by Worker memory (128 MB free tier); ~tens of MB in practice with the base64 conversion | +| Response header casing | preserved as origin sent it | lowercased (Workers' `Headers.forEach` normalises). Matters only for downstream tools that compare header names case-sensitively; mhrv-rs is case-insensitive. | +| YouTube long-form playback | OK (6-min cliff) | WORSE (30-s cliff) | +| Telegram / chat snappiness | baseline | noticeably better | +| Cloudflare anti-bot on target | datacenter IP | worker-internal IP (often stricter) | +| Spreadsheet response cache | available (opt-in) | not in this variant | +| Deployment complexity | 1 thing to maintain | 2 things to keep in sync | + +If those trade-offs land on the right side for you, deploy this variant. If not — or if you don't have a Cloudflare account — stay on `Code.gs`. + +## Important limitation: not compatible with `mode: "full"` + +`Code.cfw.gs` only ports the HTTP-relay path (modes 1 + 2 in `CodeFull.gs`). The raw-TCP/UDP tunnel ops that `mode: "full"` depends on (modes 3 + 4 in `CodeFull.gs` — required for Android full-mode coverage of WhatsApp / Telegram / messengers / any non-HTTPS-MITM-able app) are **not** ported. If you're on full mode and looking for messenger speed-ups, this variant won't help — that's a different design that would need to ride on top of Cloudflare's TCP Sockets API + Durable Objects, with no equivalent for UDP. See the discussion in [issue #380](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/380) for context. diff --git a/assets/cloudflare/worker.js b/assets/cloudflare/worker.js new file mode 100644 index 00000000..f672194b --- /dev/null +++ b/assets/cloudflare/worker.js @@ -0,0 +1,302 @@ +/** + * MHR-CFW Exit Worker — Cloudflare Workers companion to Code.cfw.gs. + * + * Architecture (alternative backend, opt-in): + * mhrv-rs → Apps Script (Code.cfw.gs) → THIS Worker → target site + * + * Apps Script in this configuration is a thin relay: it authenticates + * the inbound request from mhrv-rs, then forwards to this Worker. The + * Worker does the actual outbound fetch(es), base64-encodes the body, + * and returns the same JSON envelope shape the standard Code.gs would + * have returned. The mhrv-rs client is unaware that the work happened + * on Cloudflare — same `{u, m, h, b, ct, r}` request, same `{s, h, b}` + * response. + * + * Two request shapes are accepted: + * 1. Single: { k, u, m, h, b, ct, r } → { s, h, b } + * 2. Batch: { k, q: [{u,m,h,b,ct,r}, ...] } → { q: [{s,h,b} | {e}, ...] } + * + * The batch shape is what makes this design actually save Apps Script + * UrlFetchApp quota. Without it, Code.cfw.gs would have to do + * `UrlFetchApp.fetchAll(N worker calls)` to fan out an N-URL batch, + * which costs N quota — same as the standard Code.gs. With it, + * Code.cfw.gs does ONE fetch to this Worker (1 quota) and we fan out + * inside the Worker via Promise.all. For a typical mhrv-rs batch of + * 5-30 URLs that's a 5-30x reduction in GAS daily quota. + * + * Why bother: + * - Faster per-call latency (~10-50 ms at CF edge vs ~250-500 ms in + * Apps Script), which matters most for many small requests + * (Telegram realtime, page navigation chatter). + * - Apps Script *runtime* quota (90 min/day on consumer accounts) + * stretches further because GAS spends each call almost entirely + * on its single forward to the Worker rather than on body fetch + * + base64 + header munging. + * - With the batch shape (above), Apps Script *UrlFetchApp count* + * quota also stretches roughly Nx for an N-URL batch — typically + * 5-30x for mhrv-rs. + * + * What this does NOT change: + * - Cloudflare anti-bot challenges on the destination. The exit IP + * becomes a Workers IP (inside Cloudflare's network), which CF's + * own anti-bot can fingerprint as a worker-internal request — + * often *stricter* than a Google IP. This is a different problem + * than DPI bypass; see docs. + * - YouTube long-form streaming gets WORSE, not better. Apps Script + * allows ~6 min wall per execution; CF Workers cap at 30s wall. + * The SABR cliff arrives sooner. Keep the standard `apps_script` + * mode (Code.gs) for YouTube-heavy use. + * - The 30s wall now applies to the *slowest URL in the batch* + * because Promise.all only resolves once every fetch finishes. + * mhrv-rs already retries failed batch items individually, so a + * single slow target degrades to a per-item timeout rather than + * a hard failure — but it's a real behavioural difference vs the + * per-URL wall under the standard Code.gs path. + * + * Deployment: + * 1. Cloudflare dashboard → Workers & Pages → Create → Hello World + * 2. Edit code → delete the template, paste this entire file + * 3. Change AUTH_KEY below to the same value you set in Code.cfw.gs + * AND in your mhrv-rs config.json (auth_key). All three must match. + * 4. Deploy. Note the *.workers.dev URL; paste it into Code.cfw.gs as + * WORKER_URL. + * + * SECURITY NOTE: this Worker accepts unauthenticated POSTs from anyone + * who knows the URL unless AUTH_KEY is changed. The check below is + * cheap; do not skip it. The point of the AUTH_KEY is to keep the + * Worker from becoming an open HTTP-relay for arbitrary attackers if + * its URL leaks. Same secret as Code.cfw.gs by convention — if you + * want compartmentalisation, use a different one and have Code.cfw.gs + * forward both keys. + * + * Hardened over the upstream mhr-cfw worker.js by adding the AUTH_KEY + * check and batch handling. Upstream credit: github.com/denuitt1/mhr-cfw. + */ + +const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET"; +const DEFAULT_AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET"; + +// Loop-prevention tag. The Worker tags its OUTBOUND request to the +// target with `x-relay-hop: 1` (see processOne). If a subsequent +// request comes back into the Worker with that header set, the Worker +// has been chained back to itself somehow — most likely the user's +// `item.u` resolved to this Worker's own URL. Bail out instead of +// fetching to avoid a stack-overflow loop. +// +// Note: Code.cfw.gs does NOT set this header on its GAS→Worker call +// (and could not check for it on inbound anyway — Apps Script's +// doPost event doesn't expose request headers). So this guard +// catches Worker-↔-Worker cycles, not GAS-↔-Worker cycles. The +// `targetUrl.hostname === selfHost` check in processOne is the +// primary defence for the common misconfiguration. +const RELAY_HOP_HEADER = "x-relay-hop"; + +// Soft cap on batch size. Cloudflare Workers allow up to 50 +// subrequests per invocation on the free tier (1000 on paid). We +// keep a margin for retries and internal CF traffic. mhrv-rs's +// typical batches are 5-30 URLs so this is rarely the binding limit. +// +// **Must match `WORKER_BATCH_CHUNK` in Code.cfw.gs.** If the GAS side +// chunks at a different size, oversized chunks here return a top-level +// error and the entire chunk's slots fail. Tune both together. +const MAX_BATCH_SIZE = 40; + +// Hop-by-hop headers and headers Cloudflare manages itself. Stripped +// before forwarding so the inbound request doesn't poison the outbound. +// Kept in sync with Code.cfw.gs / Code.gs SKIP_HEADERS so the Worker +// is correct as a defence-in-depth even when called directly (the +// AUTH_KEY check is the primary gate, but GAS scrubs first in the +// normal flow). +const SKIP_HEADERS = new Set([ + "host", + "connection", + "content-length", + "transfer-encoding", + "proxy-connection", + "proxy-authorization", + "priority", + "te", +]); + +export default { + async fetch(request) { + // Fail-closed if the deployer forgot to change AUTH_KEY from the + // template default. Without this guard a forgotten edit would + // accept any client that also happens to send the placeholder — + // effectively running as an open relay. Prefer a loud 500 over + // a silent open door. + if (AUTH_KEY === DEFAULT_AUTH_KEY) { + return json({ e: "configure AUTH_KEY in worker.js" }, 500); + } + + if (request.method !== "POST") { + return json({ e: "method not allowed" }, 405); + } + + if (request.headers.get(RELAY_HOP_HEADER) === "1") { + return json({ e: "loop detected" }, 508); + } + + let req; + try { + req = await request.json(); + } catch (_err) { + return json({ e: "bad json" }, 400); + } + + if (!req || req.k !== AUTH_KEY) { + // Same shape as Code.cfw.gs unauthorized so downstream errors are + // uniform. The Worker URL is generally not user-discoverable; the + // GAS in front of it is the public surface, and probes hit GAS + // first. We don't bother with the decoy-HTML treatment here. + return json({ e: "unauthorized" }, 401); + } + + const selfHost = new URL(request.url).hostname; + + // Batch mode: { k, q: [{u,m,h,b,ct,r}, ...] }. Process all items in + // parallel via Promise.all. Per-item failures are per-item `{e}`s in + // the response array; the envelope itself stays 200 unless the batch + // is malformed at the top level. + if (Array.isArray(req.q)) { + if (req.q.length === 0) return json({ q: [] }); + if (req.q.length > MAX_BATCH_SIZE) { + return json({ + e: "batch too large (" + req.q.length + " > " + MAX_BATCH_SIZE + ")", + }, 400); + } + const results = await Promise.all( + req.q.map((item) => processOne(item, selfHost).catch((err) => ({ + e: "fetch failed: " + String(err), + }))) + ); + return json({ q: results }); + } + + // Single mode: { k, u, m, h, b, ct, r } + let result; + try { + result = await processOne(req, selfHost); + } catch (err) { + return json({ e: "fetch failed: " + String(err) }, 502); + } + if (result.e) { + // Per-item validation errors get HTTP 400 in single mode so + // mhrv-rs sees the same shape as in standard Code.gs ("bad url" + // etc are already client-error-coded there). + return json(result, 400); + } + return json(result); + }, +}; + +/** + * Process one item, whether it came in as the top-level single + * request or as one slot of a batch. Returns a plain object — never + * throws to the caller; Promise.all's .catch above only triggers on + * exceptions from this function's own internals (programmer error). + * + * Result shape mirrors what Code.gs would return for the same item: + * - Success: { s: status, h: {...}, b: base64Body } + * - Validation / fetch failure: { e: "..." } + */ +async function processOne(item, selfHost) { + if (!item || typeof item !== "object") { + return { e: "bad item" }; + } + if (!item.u || typeof item.u !== "string" || !/^https?:\/\//i.test(item.u)) { + return { e: "bad url" }; + } + + let targetUrl; + try { + targetUrl = new URL(item.u); + } catch (_err) { + return { e: "bad url" }; + } + if (targetUrl.hostname === selfHost) { + return { e: "self-fetch blocked" }; + } + + const headers = new Headers(); + if (item.h && typeof item.h === "object") { + for (const [k, v] of Object.entries(item.h)) { + if (SKIP_HEADERS.has(k.toLowerCase())) continue; + try { + headers.set(k, v); + } catch (_err) { + // Worker rejects some headers (e.g. forbidden ones); skip + // rather than fail the whole item. + } + } + } + headers.set(RELAY_HOP_HEADER, "1"); + + const method = (item.m || "GET").toUpperCase(); + const fetchOptions = { + method, + headers, + redirect: item.r === false ? "manual" : "follow", + }; + + // Code.gs/UrlFetchApp tolerates a body on GET/HEAD (browsers don't + // do this, but custom clients sometimes do); Workers' native fetch + // throws TypeError if you set a body on a body-prohibited method. + // To match Code.gs's permissiveness, silently drop the body for + // those methods rather than failing the whole item. + const bodyAllowed = method !== "GET" && method !== "HEAD"; + if (item.b && bodyAllowed) { + try { + const binary = Uint8Array.from(atob(item.b), (c) => c.charCodeAt(0)); + fetchOptions.body = binary; + if (item.ct && !headers.has("content-type")) { + headers.set("content-type", item.ct); + } + } catch (_err) { + return { e: "bad body base64" }; + } + } + + let resp; + try { + resp = await fetch(targetUrl.toString(), fetchOptions); + } catch (err) { + return { e: "fetch failed: " + String(err) }; + } + + const buffer = await resp.arrayBuffer(); + const uint8 = new Uint8Array(buffer); + + // Avoid call-stack overflow from String.fromCharCode.apply on big + // bodies — chunk the conversion. + let binary = ""; + const chunkSize = 0x8000; + for (let i = 0; i < uint8.length; i += chunkSize) { + binary += String.fromCharCode.apply(null, uint8.subarray(i, i + chunkSize)); + } + const base64 = btoa(binary); + + // Note: Headers.forEach delivers keys lowercased per the Fetch + // spec, whereas Code.gs's getAllHeaders preserves the origin's + // casing. mhrv-rs treats headers case-insensitively, but anything + // downstream that does a case-sensitive string compare will see + // a backend-dependent difference. There is no Workers API to + // recover the origin casing, so we accept the divergence. + const responseHeaders = {}; + resp.headers.forEach((v, k) => { + responseHeaders[k] = v; + }); + + return { + s: resp.status, + h: responseHeaders, + b: base64, + }; +} + +function json(obj, status = 200) { + return new Response(JSON.stringify(obj), { + status, + headers: { "content-type": "application/json" }, + }); +}