diff --git a/.github/workflows/chrome-extension.yml b/.github/workflows/chrome-extension.yml new file mode 100644 index 00000000..f3b79537 --- /dev/null +++ b/.github/workflows/chrome-extension.yml @@ -0,0 +1,61 @@ +name: chrome-extension + +on: + pull_request: + paths: + - 'assets/apps_script/Code.gs' + - 'chrome-extension/**' + - '.github/workflows/chrome-extension.yml' + push: + branches: + - main + paths: + - 'assets/apps_script/Code.gs' + - 'chrome-extension/**' + - '.github/workflows/chrome-extension.yml' + +permissions: + contents: read + +jobs: + bundled-code-sync: + name: bundled Code.gs stays in sync + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Compare Apps Script source with extension fallback + run: | + if ! cmp -s assets/apps_script/Code.gs chrome-extension/Code.gs; then + echo "::error file=chrome-extension/Code.gs::chrome-extension/Code.gs must match assets/apps_script/Code.gs. Update the bundled fallback whenever the canonical Apps Script changes." + diff -u assets/apps_script/Code.gs chrome-extension/Code.gs || true + exit 1 + fi + + auth-key-smoke-test: + name: auth key smoke test + runs-on: ubuntu-latest + needs: bundled-code-sync + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install test dependencies + working-directory: chrome-extension + run: npm install + + - name: Install Playwright browser + working-directory: chrome-extension + run: npx playwright install --with-deps chromium + + - name: Run extension smoke tests + working-directory: chrome-extension + run: xvfb-run npm run test:smoke diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51630c81..815ff156 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,23 @@ permissions: # and more importantly keeps target/ warm via the rust-cache action. jobs: + verify-chrome-extension-codegs-sync: + name: verify chrome-extension Code.gs sync + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Compare Apps Script source with extension fallback + run: | + if ! cmp -s assets/apps_script/Code.gs chrome-extension/Code.gs; then + echo "::error file=chrome-extension/Code.gs::chrome-extension/Code.gs must match assets/apps_script/Code.gs. Update the bundled fallback whenever the canonical Apps Script changes." + diff -u assets/apps_script/Code.gs chrome-extension/Code.gs || true + exit 1 + fi + build: + needs: verify-chrome-extension-codegs-sync strategy: fail-fast: false matrix: diff --git a/README.md b/README.md index cd065e04..8c500f81 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ - 🖥️ **Works on** Mac, Windows, Linux, Android, OpenWRT routers - 🦊 **Any browser or app** that supports HTTP proxy or SOCKS5 +## Community helpers + +- **[mhrv-helper-extension](https://github.com/ardalan-ab/mhrv-helper-extension)** (Chrome, maintained by [@ardalan-ab](https://github.com/ardalan-ab)) — optional helper to generate an `AUTH_KEY`, copy `Code.gs`, and a `config.json` snippet for Apps Script mode. [How to use (English)](https://github.com/ardalan-ab/mhrv-helper-extension/blob/main/HOW_TO_USE.md) · [راهنمای فارسی](https://github.com/ardalan-ab/mhrv-helper-extension/blob/main/HOW_TO_USE.fa.md). + ## How it works (the simple picture) ``` @@ -74,6 +78,8 @@ ISPs can't read inside encrypted HTTPS. They only see the address — `www.googl 11. Google shows a **Deployment ID** (a long random string). **Copy it** — you'll need it in Step 3. > **Tip:** if you ever update `Code.gs` later, don't make a new deployment. Edit the code, then go to **Deploy → Manage deployments → ✏️ → Version: New version → Deploy**. The Deployment ID stays the same. +> +> **Optional:** use the community [Chrome extension](https://github.com/ardalan-ab/mhrv-helper-extension) — see [How to use](https://github.com/ardalan-ab/mhrv-helper-extension/blob/main/HOW_TO_USE.md) (English) or [راهنمای فارسی](https://github.com/ardalan-ab/mhrv-helper-extension/blob/main/HOW_TO_USE.fa.md). ### Step 2 — Download mhrv-rs @@ -198,6 +204,10 @@ Most of the Rust code in this port was written with [Anthropic's Claude](https:/ - 🖥️ **روی** مک، ویندوز، لینوکس، اندروید، روتر OpenWRT کار می‌کند - 🦊 **هر مرورگر یا برنامه‌ای** که از HTTP proxy یا SOCKS5 پشتیبانی کند +## ابزارهای جامعه + +- **[mhrv-helper-extension](https://github.com/ardalan-ab/mhrv-helper-extension)** (کروم، نگهداری [@ardalan-ab](https://github.com/ardalan-ab)) — افزونهٔ اختیاری برای تولید `AUTH_KEY`، کپی `Code.gs` و قطعهٔ `config.json` در حالت Apps Script. [راهنمای فارسی](https://github.com/ardalan-ab/mhrv-helper-extension/blob/main/HOW_TO_USE.fa.md) · [How to use (English)](https://github.com/ardalan-ab/mhrv-helper-extension/blob/main/HOW_TO_USE.md). + ## چطور کار می‌کند (تصویر ساده) ``` @@ -244,6 +254,8 @@ ISP داخل HTTPS رمزشده را نمی‌تواند بخواند. فقط آ ۱۱. گوگل یک **Deployment ID** نشانت می‌دهد (یک رشتهٔ تصادفی طولانی). **کپی‌اش کن** — در مرحلهٔ ۳ لازم داری. > **نکته:** اگر بعداً `Code.gs` را به‌روزرسانی کنی، Deployment جدید نساز. کد را ویرایش کن، بعد **Deploy → Manage deployments → ✏️ → Version: New version → Deploy**. Deployment ID همان قبلی می‌ماند. +> +> **اختیاری:** افزونهٔ اختیاری کروم [mhrv-helper-extension](https://github.com/ardalan-ab/mhrv-helper-extension) — [راهنمای فارسی](https://github.com/ardalan-ab/mhrv-helper-extension/blob/main/HOW_TO_USE.fa.md) · [How to use (English)](https://github.com/ardalan-ab/mhrv-helper-extension/blob/main/HOW_TO_USE.md). ### مرحلهٔ ۲ — دانلود mhrv-rs diff --git a/chrome-extension/.github/workflows/sync-codegs.yml b/chrome-extension/.github/workflows/sync-codegs.yml new file mode 100644 index 00000000..c62b7978 --- /dev/null +++ b/chrome-extension/.github/workflows/sync-codegs.yml @@ -0,0 +1,36 @@ +# Runs when this folder is the Git repository root (e.g. ardalan-ab/mhrv-helper-extension). +# In the main monorepo, use .github/workflows/chrome-extension.yml instead (same cmp logic). + +name: Code.gs sync with upstream + +on: + push: + branches: [main, master] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + bundled-matches-upstream-main: + name: bundled Code.gs matches upstream main + runs-on: ubuntu-latest + steps: + - name: Check out extension repository + uses: actions/checkout@v4 + + - name: Fetch canonical Code.gs from main project (main branch) + run: | + curl -fsSL \ + 'https://raw.githubusercontent.com/therealaleph/MasterHttpRelayVPN-RUST/main/assets/apps_script/Code.gs' \ + -o /tmp/upstream-Code.gs + + - name: Compare with bundled Code.gs + run: | + if ! cmp -s Code.gs /tmp/upstream-Code.gs; then + echo "::error::Bundled Code.gs differs from therealaleph/MasterHttpRelayVPN-RUST main (assets/apps_script/Code.gs). Copy the upstream file into this repo and commit." + diff -u /tmp/upstream-Code.gs Code.gs || true + exit 1 + fi + echo "OK: bundled Code.gs matches upstream main." diff --git a/chrome-extension/.gitignore b/chrome-extension/.gitignore new file mode 100644 index 00000000..3658295b --- /dev/null +++ b/chrome-extension/.gitignore @@ -0,0 +1,29 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +*.codex +node_modules +test-results +playwright-report +dist +dist-ssr +*.local +*.env +*.env.*.local +!.env.example +!.env.*.example +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/chrome-extension/Code.gs b/chrome-extension/Code.gs new file mode 100644 index 00000000..13922255 --- /dev/null +++ b/chrome-extension/Code.gs @@ -0,0 +1,797 @@ +/** + * DomainFront Relay — Google Apps Script + * + * TWO modes: + * 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}, ...] } + * Uses UrlFetchApp.fetchAll() — all URLs fetched IN PARALLEL. + * + * OPTIONAL SPREADSHEET-BACKED RESPONSE CACHE: + * Set CACHE_SPREADSHEET_ID to a valid Google Sheet ID (must be owned by + * the same account). When enabled, public GET requests are stored in the + * sheet and served from there on repeat visits, reducing UrlFetchApp + * quota consumption. Bodies are gzipped before base64 storage so larger + * responses fit under the per-cell character limit, and persistent + * 4xx (404/410/451) get a short negative-cache TTL so buggy clients + * that hammer dead URLs cost zero quota; 5xx is never cached so a + * flapping upstream cannot poison a 24h slot with a transient outage. + * The cache is Vary-aware (Accept-Encoding and Accept-Language are + * hashed into the compound cache key). Leave CACHE_SPREADSHEET_ID as-is + * to disable caching entirely — zero overhead. + * + * DEPLOYMENT: + * 1. Go to https://script.google.com → New project + * 2. Delete the default code, paste THIS entire file + * 3. Change AUTH_KEY below to your own secret + * 4. (Optional) Set CACHE_SPREADSHEET_ID to enable caching + * 5. Click Deploy → New deployment + * 6. Type: Web app | Execute as: Me | Who has access: Anyone + * 7. Copy the Deployment ID into config.json as "script_id" + * + * CHANGE THE AUTH KEY BELOW TO YOUR OWN SECRET! + */ + +const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET"; + +// Active-probing defense. When false (production default), bad AUTH_KEY +// requests get a decoy HTML page that looks like a placeholder Apps +// Script web app instead of the JSON `{"e":"unauthorized"}` body. This +// makes the deployment indistinguishable from a forgotten-but-public +// Apps Script project to active scanners that POST malformed payloads +// looking for proxy endpoints. +// +// Set to `true` during initial setup if a misconfigured client is +// hitting "unauthorized" and you want the explicit JSON error to debug +// — then flip back to false before the deployment is widely shared. +// (Inspired by #365 Section 3, mhrv-rs v1.8.0+.) +const DIAGNOSTIC_MODE = false; + +// ── Optional Spreadsheet Cache ────────────────────────────── +// Set to a valid Spreadsheet ID to enable response caching. +// Leave as-is to disable caching entirely (zero overhead). +const CACHE_SPREADSHEET_ID = "CHANGE_ME_TO_CACHE_SPREADSHEET_ID"; +const CACHE_SHEET_NAME = "RelayCache"; +const CACHE_META_SHEET_NAME = "RelayMeta"; +const CACHE_META_CURSOR_CELL = "A1"; + +// ── Cache Tuning ──────────────────────────────────────────── +const CACHE_MAX_ROWS = 5000; // circular buffer capacity +const CACHE_MAX_BODY_BYTES = 35000; // skip responses larger than ~35 KB +const CACHE_DEFAULT_TTL_SECONDS = 86400; // 24-hour fallback when no Cache-Control + +// ── Negative Caching ──────────────────────────────────────── +// Persistent 4xx errors get a short TTL when the upstream is silent on +// Cache-Control. Buggy clients hammer dead URLs (favicons, telemetry +// pixels, dev-tools probes); a 5-minute floor absorbs the storm at +// zero quota cost while letting transient 404s self-heal quickly. +// 5xx is never cached — see _fetchAndCache. +const NEGATIVE_CACHE_STATUSES = { 404: 1, 410: 1, 451: 1 }; +const NEGATIVE_CACHE_TTL_SECONDS = 300; + +// ── Body Compression ──────────────────────────────────────── +// Bodies are gzipped before base64 storage when worthwhile. Gzip has +// ~20 bytes of header overhead, so very small payloads can bloat; +// skip below this threshold. Already-encoded responses (gzip/br/etc.) +// are stored as-is to avoid double-compression. +const GZIP_MIN_BYTES = 256; + +// ── Vary-Aware Cache Key ──────────────────────────────────── +// These request headers are hashed into the compound cache key +// alongside the URL so that responses with different encodings +// or languages never collide in the cache. Covers ~95 % of +// real-world Vary usage without inspecting the response. +const VARY_KEY_HEADERS = ["accept-encoding", "accept-language"]; + +// Connection-level + IP-leak request headers we strip before forwarding +// 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 = { + host: 1, connection: 1, "content-length": 1, + "transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 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. +const CACHE_BUSTING_HEADERS = { + authorization: 1, cookie: 1, "x-api-key": 1, + "proxy-authorization": 1, "set-cookie": 1, +}; + +// HTML body for the bad-auth decoy. Mimics a minimal Apps Script-style +// placeholder page — no proxy-shaped JSON, nothing distinctive enough +// for a probe to fingerprint as a tunnel endpoint. +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 { + var req = JSON.parse(e.postData.contents); + if (req.k !== AUTH_KEY) return _decoyOrError({ e: "unauthorized" }); + + // Batch mode: { k, q: [...] } + if (Array.isArray(req.q)) return _doBatch(req.q); + + // Single mode + return _doSingle(req); + } catch (err) { + // Parse failures of the request body are also probe-shaped — a real + // mhrv-rs client never sends invalid JSON. Decoy for the same reason. + return _decoyOrError({ e: String(err) }); + } +} + +// `doGet` is what active scanners hit first (HTTP GET probes are cheaper +// than POSTs). Apps Script defaults to a "Script function not found" page +// here which is a fine-enough decoy on its own, but explicitly returning +// the same harmless placeholder makes the response identical to the +// bad-auth POST decoy — one less fingerprint vector. +function doGet(e) { + return ContentService + .createTextOutput(DECOY_HTML) + .setMimeType(ContentService.MimeType.HTML); +} + +// ── Single Request ───────────────────────────────────────── + +function _doSingle(req) { + if (!req.u || typeof req.u !== "string" || !req.u.match(/^https?:\/\//i)) { + return _json({ e: "bad url" }); + } + + // ── Optional cache path ──────────────────────────────── + // Only entered when CACHE_SPREADSHEET_ID is configured and + // the request qualifies as a public, cachable GET. + if (_canUseCache(req)) { + var cached = _getFromCache(req.u, req.h); + if (cached) { + return _json({ + s: cached.status, + h: JSON.parse(cached.headers), + b: cached.body, + cached: true, + }); + } + + var fetchResult = _fetchAndCache(req.u, req.h); + if (fetchResult) { + return _json({ + s: fetchResult.status, + h: JSON.parse(fetchResult.headers), + b: fetchResult.body, + cached: false, + }); + } + // If _fetchAndCache returns null (spreadsheet unavailable), + // fall through to the normal relay path below. + } + + // ── Normal relay (cache disabled or unavailable) ──────── + // Wrap the fetch + body encode in try/catch so any failure surfaces as + // a JSON error envelope the Rust client can parse. Without this, throws + // from UrlFetchApp.fetch (URL too long, payload too large, quota + // exhausted, 6-minute execution timeout) or from base64Encode (response + // body near Apps Script's ~50 MB ceiling can blow the V8 heap during + // encode) propagate unhandled, and Apps Script serves its default + // `Web App` HTML error page — which the client then + // reports as "Relay failed: bad response: no json in: Web App>..." + // and the user has no signal as to the actual cause. Mirrors the + // per-item try/catch in _doBatch below. + try { + var opts = _buildOpts(req); + var resp = UrlFetchApp.fetch(req.u, opts); + return _json({ + s: resp.getResponseCode(), + h: _respHeaders(resp), + b: Utilities.base64Encode(resp.getContent()), + }); + } catch (err) { + return _json({ e: "fetch failed: " + String(err) }); + } +} + +// ── Batch Request ────────────────────────────────────────── + +function _doBatch(items) { + var fetchArgs = []; + var fetchIndex = []; + var fetchMethods = []; + var errorMap = {}; + + for (var i = 0; i < items.length; 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)) { + errorMap[i] = "bad url"; + continue; + } + try { + var opts = _buildOpts(item); + 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. 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 = []; + if (fetchArgs.length > 0) { + 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 rIdx = 0; + for (var i = 0; i < items.length; i++) { + if (Object.prototype.hasOwnProperty.call(errorMap, i)) { + results.push({ e: errorMap[i] }); + } else { + var resp = responses[rIdx++]; + if (!resp) { + results.push({ e: "fetch failed" }); + } else { + results.push({ + s: resp.getResponseCode(), + h: _respHeaders(resp), + b: Utilities.base64Encode(resp.getContent()), + }); + } + } + } + return _json({ q: results }); +} + +// ── Request Building ─────────────────────────────────────── + +function _buildOpts(req) { + var opts = { + method: (req.m || "GET").toLowerCase(), + muteHttpExceptions: true, + followRedirects: req.r !== false, + validateHttpsCertificates: true, + escaping: false, + }; + if (req.h && typeof req.h === "object") { + var headers = {}; + for (var k in req.h) { + if (req.h.hasOwnProperty(k) && !SKIP_HEADERS[k.toLowerCase()]) { + headers[k] = req.h[k]; + } + } + opts.headers = headers; + } + if (req.b) { + opts.payload = Utilities.base64Decode(req.b); + if (req.ct) opts.contentType = req.ct; + } + return opts; +} + +function _respHeaders(resp) { + try { + if (typeof resp.getAllHeaders === "function") { + return resp.getAllHeaders(); + } + } catch (err) {} + return resp.getHeaders(); +} + +function _json(obj) { + return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType( + ContentService.MimeType.JSON + ); +} + +// ═══════════════════════════════════════════════════════════ +// SPREADSHEET CACHE — SHEET MANAGEMENT +// ═══════════════════════════════════════════════════════════ + +function _initCacheSheet() { + if (CACHE_SPREADSHEET_ID === "CHANGE_ME_TO_CACHE_SPREADSHEET_ID") { + return null; + } + try { + var ss = SpreadsheetApp.openById(CACHE_SPREADSHEET_ID); + var sheet = ss.getSheetByName(CACHE_SHEET_NAME); + if (!sheet) { + sheet = ss.insertSheet(CACHE_SHEET_NAME); + // Schema: URL_Hash | URL | Status | Headers | Body | Timestamp | Expires_At | Z + // Z is 1 when Body is base64(gzip(rawBytes)), 0/empty when base64(rawBytes). + // Legacy 7-column rows from older deployments read back as Z=undefined, + // which the cache hit path treats as "not gzipped" — fully compatible. + sheet.getRange(1, 1, 1, 8).setValues([[ + "URL_Hash", "URL", "Status", "Headers", "Body", "Timestamp", "Expires_At", "Z" + ]]); + } + return sheet; + } catch (e) { + return null; + } +} + +function _getMetaSheet() { + if (CACHE_SPREADSHEET_ID === "CHANGE_ME_TO_CACHE_SPREADSHEET_ID") { + return null; + } + try { + var ss = SpreadsheetApp.openById(CACHE_SPREADSHEET_ID); + var sheet = ss.getSheetByName(CACHE_META_SHEET_NAME); + if (!sheet) { + sheet = ss.insertSheet(CACHE_META_SHEET_NAME); + sheet.getRange(CACHE_META_CURSOR_CELL).setValue(2); + sheet.hideSheet(); + } + return sheet; + } catch (e) { + return null; + } +} + +function _getNextCursor(sheet, metaSheet) { + var cursorRange = metaSheet.getRange(CACHE_META_CURSOR_CELL); + var cursor = cursorRange.getValue(); + if (typeof cursor !== "number" || cursor < 2) cursor = 2; + + var totalRows = sheet.getDataRange().getNumRows(); + + if (totalRows < CACHE_MAX_ROWS + 1) { + return totalRows + 1; + } + + return cursor; +} + +function _advanceCursor(metaSheet, currentRow) { + var nextRow = currentRow + 1; + if (nextRow > CACHE_MAX_ROWS + 1) nextRow = 2; + metaSheet.getRange(CACHE_META_CURSOR_CELL).setValue(nextRow); +} + +function _ensureRowsAllocated(sheet) { + var totalRows = sheet.getDataRange().getNumRows(); + if (totalRows < CACHE_MAX_ROWS + 1) { + var needed = CACHE_MAX_ROWS + 1 - totalRows; + sheet.insertRowsAfter(totalRows, needed); + } +} + +// ═══════════════════════════════════════════════════════════ +// SPREADSHEET CACHE — VARY-AWARE COMPOUND KEY +// ═══════════════════════════════════════════════════════════ + +/** + * Case-insensitive header lookup. + * HTTP header names are case-insensitive per RFC 7230 § 3.2. + */ +function _getHeaderCaseInsensitive(headers, targetKey) { + var target = targetKey.toLowerCase(); + for (var k in headers) { + if (headers.hasOwnProperty(k) && k.toLowerCase() === target) { + return headers[k]; + } + } + return null; +} + +/** + * Compute a compound cache key: + * MD5(URL | header1:value1 | header2:value2 | ...) + * + * Instead of reading the response Vary header (which would require + * fetching first — circular), we preemptively include the request + * headers that are known to cause response variation. This handles + * Vary: Accept-Encoding and Vary: Accept-Language without ever + * inspecting the response. + * + * Values are lowercased and whitespace-stripped so semantically + * identical requests from different clients produce the same hash. + * Missing and empty headers both map to "<none>" (same semantic). + */ +function _getCacheKey(url, reqHeaders) { + var parts = [url]; + + if (reqHeaders && typeof reqHeaders === "object") { + for (var i = 0; i < VARY_KEY_HEADERS.length; i++) { + var headerName = VARY_KEY_HEADERS[i]; + var rawValue = _getHeaderCaseInsensitive(reqHeaders, headerName); + + if (rawValue && String(rawValue).trim() !== "") { + parts.push(headerName + ":" + rawValue.toLowerCase().replace(/\s/g, "")); + } else { + parts.push(headerName + ":<none>"); + } + } + } else { + for (var j = 0; j < VARY_KEY_HEADERS.length; j++) { + parts.push(VARY_KEY_HEADERS[j] + ":<none>"); + } + } + + var compoundKey = parts.join("|"); + return _md5Hex(compoundKey); +} + +function _md5Hex(input) { + var rawHash = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, input); + return rawHash + .map(function (byte) { + var v = (byte < 0) ? 256 + byte : byte; + return ("0" + v.toString(16)).slice(-2); + }) + .join(""); +} + +// ═══════════════════════════════════════════════════════════ +// SPREADSHEET CACHE — CORE LOGIC +// ═══════════════════════════════════════════════════════════ + +/** + * Returns true if the request is eligible for the cache path: + * public GET, no body, no auth/cookie headers, cache configured. + */ +function _canUseCache(req) { + if ((req.m || "GET") !== "GET") return false; + if (req.b) return false; + if (!req.u || !req.u.match(/^https?:\/\//i)) return false; + if (CACHE_SPREADSHEET_ID === "CHANGE_ME_TO_CACHE_SPREADSHEET_ID") return false; + + if (req.h && typeof req.h === "object") { + for (var k in req.h) { + if (req.h.hasOwnProperty(k) && CACHE_BUSTING_HEADERS[k.toLowerCase()]) { + return false; + } + } + } + + return true; +} + +/** + * Extract max-age (seconds) from a Cache-Control header value. + * Returns 0 if the directive forbids caching (no-cache / no-store / + * private). Falls back to CACHE_DEFAULT_TTL_SECONDS when no header + * is present. Clamped to [60, 2592000] (1 min – 30 days). + */ +function _parseMaxAge(cacheControlHeader) { + if (!cacheControlHeader) return CACHE_DEFAULT_TTL_SECONDS; + + var lower = cacheControlHeader.toLowerCase(); + + if ( + lower.indexOf("no-cache") !== -1 || + lower.indexOf("no-store") !== -1 || + lower.indexOf("private") !== -1 + ) { + return 0; + } + + var match = lower.match(/max-age=(\d+)/); + if (match) { + var ttl = parseInt(match[1], 10); + return Math.max(60, Math.min(ttl, 2592000)); + } + + return CACHE_DEFAULT_TTL_SECONDS; +} + +/** + * Rewrite time-sensitive headers so the client sees accurate + * Date, Age, and Cache-Control values reflecting cache age. + */ +function _refreshCachedHeaders(headersJson, timestamp) { + var headers = JSON.parse(headersJson); + var cachedAt = new Date(timestamp); + var now = new Date(); + var ageSeconds = Math.floor((now.getTime() - cachedAt.getTime()) / 1000); + + if (ageSeconds < 0) ageSeconds = 0; + + headers["Date"] = now.toUTCString(); + headers["Age"] = String(ageSeconds); + + var originalCc = headers["Cache-Control"] || headers["cache-control"]; + if (originalCc) { + headers["X-Original-Cache-Control"] = originalCc; + } + + var remainingMaxAge = Math.max(0, _parseMaxAge(originalCc) - ageSeconds); + headers["Cache-Control"] = "public, max-age=" + remainingMaxAge; + + headers["X-Cache"] = "HIT from relay-spreadsheet"; + headers["X-Cached-At"] = cachedAt.toUTCString(); + + return JSON.stringify(headers); +} + +/** + * Retrieve a cached response by compound cache key. + * Uses TextFinder for O(log n) lookup. Skips expired entries. + * Returns null on miss, expired entry, or unavailable sheet. + */ +function _getFromCache(url, reqHeaders) { + var sheet = _initCacheSheet(); + if (!sheet) return null; + + var hash = _getCacheKey(url, reqHeaders); + var finder = sheet.createTextFinder(hash).matchEntireCell(true); + var found = finder.findNext(); + + if (found) { + // 8-column read. Legacy 7-column rows return undefined for the Z slot, + // which is falsy and falls through the not-gzipped branch below — fully + // compatible with caches written before the gzip-storage change. + var row = sheet.getRange(found.getRow(), 1, 1, 8).getValues()[0]; + + var expiresAt = row[6]; + if (expiresAt && expiresAt instanceof Date && expiresAt < new Date()) { + return null; + } + + var storedBody = row[4]; + var body; + if (row[7]) { + // Stored as base64(gzip(rawBytes)). The relay protocol's `b` field + // is base64(rawBytes), so decompress and re-encode for the wire. + var gzipped = Utilities.base64Decode(storedBody); + var raw = Utilities + .ungzip(Utilities.newBlob(gzipped, "application/x-gzip")) + .getBytes(); + body = Utilities.base64Encode(raw); + } else { + body = storedBody; + } + + return { + status: row[2], + headers: _refreshCachedHeaders(row[3], row[5]), + body: body, + }; + } + return null; +} + +/** + * Fetch a URL and store the response in the spreadsheet cache + * using a circular buffer (O(1) writes). Skips storage on 5xx + * (transient outages must not poison a 24h slot), when Cache-Control + * forbids caching, or when the post-compression body exceeds + * CACHE_MAX_BODY_BYTES. Always returns the fetch result so the caller + * can serve the live response even when the cache write is skipped. + */ +function _fetchAndCache(url, reqHeaders) { + var sheet = _initCacheSheet(); + if (!sheet) return null; + + try { + var response = UrlFetchApp.fetch(url, { muteHttpExceptions: true }); + var status = response.getResponseCode(); + var headers = _respHeaders(response); + var bodyBytes = response.getContent(); + var rawB64 = Utilities.base64Encode(bodyBytes); + var headersJson = JSON.stringify(headers); + var liveResult = { status: status, headers: headersJson, body: rawB64 }; + + // 5xx never enters the cache. A flapping upstream returning 503 once + // would otherwise pin that response for 24h and break the URL for + // every subsequent client until expiry. + if (status >= 500) return liveResult; + + var cacheControl = + headers["Cache-Control"] || headers["cache-control"] || null; + var ttlSeconds = _parseMaxAge(cacheControl); + + if (ttlSeconds === 0) return liveResult; + + // Negative caching: cap TTL on persistent 4xx when upstream is silent. + // If they explicitly stated a max-age for the 404, we honor it instead + // — the origin knows best when it spoke up. + if (NEGATIVE_CACHE_STATUSES[status] && !cacheControl) { + ttlSeconds = NEGATIVE_CACHE_TTL_SECONDS; + } + + // Decide whether to gzip-store. Skip when upstream is already encoded + // (avoids double-compressing gzip/br/zstd payloads) and when the body + // is too small to overcome gzip's header overhead. + var contentEncoding = String( + headers["Content-Encoding"] || headers["content-encoding"] || "" + ).toLowerCase(); + var alreadyEncoded = contentEncoding && contentEncoding !== "identity"; + var storedBody; + var storedZ; + if (alreadyEncoded || bodyBytes.length < GZIP_MIN_BYTES) { + storedBody = rawB64; + storedZ = 0; + } else { + storedBody = Utilities.base64Encode( + Utilities.gzip(Utilities.newBlob(bodyBytes)).getBytes() + ); + storedZ = 1; + } + + // Cell-size safety gate, applied after compression so that a 100 KB + // text body that gzips to ~15 KB now fits where it previously bailed. + if (storedBody.length > CACHE_MAX_BODY_BYTES) return liveResult; + + var hash = _getCacheKey(url, reqHeaders); + var timestamp = new Date(); + var expiresAt = new Date(timestamp.getTime() + ttlSeconds * 1000); + + // Safety: fallback if Date math produces invalid result + if (isNaN(expiresAt.getTime())) { + expiresAt = new Date(timestamp.getTime() + CACHE_DEFAULT_TTL_SECONDS * 1000); + } + + var rowData = [ + hash, + url, + status, + headersJson, + storedBody, + timestamp.toISOString(), + expiresAt, + storedZ, + ]; + + // Circular buffer write (O(1)) + var metaSheet = _getMetaSheet(); + if (metaSheet) { + _ensureRowsAllocated(sheet); + var writeRow = _getNextCursor(sheet, metaSheet); + sheet.getRange(writeRow, 1, 1, 8).setValues([rowData]); + _advanceCursor(metaSheet, writeRow); + } else { + // Fallback: simple append if meta sheet is unavailable + sheet.appendRow(rowData); + } + + return liveResult; + } catch (e) { + return null; + } +} + +// ═══════════════════════════════════════════════════════════ +// SPREADSHEET CACHE — DIAGNOSTICS +// ═══════════════════════════════════════════════════════════ + +function getCacheStats() { + var sheet = _initCacheSheet(); + if (!sheet) { + console.log("Cache is not enabled or spreadsheet unavailable."); + return; + } + + var data = sheet.getDataRange().getValues(); + var totalEntries = data.length - 1; + var now = new Date(); + var expiredCount = 0; + + for (var i = 1; i < data.length; i++) { + var expiresAt = data[i][6]; + if (expiresAt && expiresAt instanceof Date && expiresAt < now) { + expiredCount++; + } + } + + var metaSheet = _getMetaSheet(); + var cursorInfo = "N/A"; + if (metaSheet) { + cursorInfo = String(metaSheet.getRange(CACHE_META_CURSOR_CELL).getValue()); + } + + console.log("=== CACHE STATS ==="); + console.log("Total rows used: " + totalEntries + " / " + CACHE_MAX_ROWS); + console.log("Active entries: " + (totalEntries - expiredCount)); + console.log("Expired entries: " + expiredCount); + console.log("Cursor position: " + cursorInfo); + console.log("Max body size: " + CACHE_MAX_BODY_BYTES + " chars"); + console.log("Default TTL: " + CACHE_DEFAULT_TTL_SECONDS + " sec"); + console.log("Vary key headers: " + VARY_KEY_HEADERS.join(", ")); + if (totalEntries > 0) { + console.log("Oldest entry: " + data[1][5]); + console.log("Newest entry: " + data[data.length - 1][5]); + } +} + +function clearExpiredCache() { + var sheet = _initCacheSheet(); + if (!sheet) { + console.log("Cache is not enabled."); + return; + } + + var data = sheet.getDataRange().getValues(); + var now = new Date(); + var rowsToClear = []; + + for (var i = 1; i < data.length; i++) { + var expiresAt = data[i][6]; + if (expiresAt && expiresAt instanceof Date && expiresAt < now) { + rowsToClear.push(i + 1); + } + } + + for (var j = 0; j < rowsToClear.length; j++) { + sheet.getRange(rowsToClear[j], 1, 1, 8).clearContent(); + } + + console.log("Cleared " + rowsToClear.length + " expired entries (" + + (data.length - 1 - rowsToClear.length) + " remaining)."); +} + +function clearEntireCache() { + var sheet = _initCacheSheet(); + if (sheet) { + var totalRows = sheet.getDataRange().getNumRows(); + if (totalRows > 1) { + sheet.getRange(2, 1, totalRows - 1, 8).clearContent(); + } + } + + var metaSheet = _getMetaSheet(); + if (metaSheet) { + metaSheet.getRange(CACHE_META_CURSOR_CELL).setValue(2); + } + + console.log("Cache wiped. Cursor reset to row 2."); +} diff --git a/chrome-extension/HOW_TO_USE.fa.md b/chrome-extension/HOW_TO_USE.fa.md new file mode 100644 index 00000000..0a19c738 --- /dev/null +++ b/chrome-extension/HOW_TO_USE.fa.md @@ -0,0 +1,111 @@ +# راهنمای استفاده از افزونهٔ کمک‌کنندهٔ Apps Script برای mhrv-rs (کروم) + +[English](HOW_TO_USE.md) + +این راهنما برای **کاربر نهایی** است که می‌خواهد با این افزونه، راه‌اندازی Apps Script برای [mhrv-rs](https://github.com/therealaleph/MasterHttpRelayVPN-RUST) را ساده‌تر کند. اسکریپت را هنوز باید در **حساب گوگل خودت** دیپلوی کنی و **mhrv-rs** را روی کامپیوترت اجرا کنی؛ افزونه فقط متن آماده می‌کند و لینک باز می‌کند. + +--- + +## ۱. نصب افزونه (حالت unpacked) + +1. پوشهٔ افزونه را بگیر (پوشه‌ای که `manifest.json` داخلش است): + - **پیشنهادی:** مخزن [ardalan-ab/mhrv-helper-extension](https://github.com/ardalan-ab/mhrv-helper-extension) را کلون کن و **ریشهٔ مخزن** را انتخاب کن. + - **جایگزین:** در مخزن اصلی [MasterHttpRelayVPN-RUST](https://github.com/therealaleph/MasterHttpRelayVPN-RUST) از پوشهٔ **`chrome-extension/`** استفاده کن. +2. کروم را باز کن و به `chrome://extensions` برو. +3. **Developer mode** را روشن کن (بالا سمت راست). +4. **Load unpacked** را بزن و همان پوشه را انتخاب کن. +5. (اختیاری) از آیکن پازل، افزونه را **Pin** کن تا همیشه در نوار ابزار باشد. + +--- + +## ۲. باز کردن پاپ‌آپ و انتخاب زبان + +1. روی آیکن افزونه کلیک کن. +2. در بالای پاپ‌آپ، **English** یا **فارسی** را انتخاب کن. برای فارسی، چیدمان راست‌به‌چپ است. + +--- + +## ۳. دانلود mhrv-rs (اختیاری ولی مفید) + +در **مرحلهٔ ۰**: + +- **دانلود mhrv-rs** را بزن — کروم فایل ریلیز مناسب سیستم‌عاملت را از گیت‌هاب باز می‌کند. +- یا **مشاهدهٔ همهٔ انتشارها** را بزن اگر می‌خواهی خودت بیلد دیگری (اندروید، musl و غیره) را انتخاب کنی. + +فایل را از حالت فشرده خارج کن و جایی نگه دار که بعداً برنامه را از همانجا اجرا کنی (طبق README اصلی پروژه). + +--- + +## ۴. تولید کلید احراز هویت + +در **مرحلهٔ ۱**: + +1. **تولید کلید احراز هویت** را بزن. یک رشتهٔ hex به طول ۶۴ کاراکتر در کادر ظاهر می‌شود. +2. در صورت نیاز **کپی کلید** را بزن. + +این مقدار را در **دو جا** یکسان استفاده کن: + +- داخل **Apps Script** به‌عنوان `AUTH_KEY` در `Code.gs` (وقتی **کپی Code.gs** می‌زنی، افزونه آن را در کد قرار می‌دهد). +- در **mhrv-rs** در فیلد auth key هنگام ذخیرهٔ کانفیگ. + +مثل پسورد نگه‌اش دار. افزونه آن را در مرورگر می‌سازد و به سروری ارسال نمی‌کند. + +--- + +## ۵. بردن Code.gs به Google Apps Script + +در **مرحلهٔ ۲**: + +1. تا زمانی که اسپینر تمام شود و اسکریپت بارگذاری شود صبر کن (از گیت‌هاب، یا نسخهٔ داخل بسته اگر raw مسدود باشد). +2. (اختیاری) **بررسی آخرین نسخه Code.gs** را بزن تا ببینی نسخهٔ بسته با upstream یکی است یا نه. +3. در صورت نیاز **باز کردن Apps Script** را بزن تا تب پروژهٔ جدید باز شود. +4. در Apps Script: **New project** (یا پروژهٔ موجود)، محتوای پیش‌فرض `Code.gs` را پاک کن. +5. در افزونه، **کپی Code.gs** (یا **دانلود Code.gs** و باز کردن فایل) را بزن. +6. در ویرایشگر Apps Script پیست کن و **Save** بزن. + +به‌صورت **Web app** مستقر کن (مثل README سریع اصلی): معمولاً **Execute as: Me** و **Who has access: Anyone** (مگر راهنما چیز دیگری بگوید). اگر گوگل دسترسی خواست، تأیید کن. + +**Deployment ID** که گوگل نشان می‌دهد را کپی کن (در مرحلهٔ ۳ افزونه می‌چسبانی). + +--- + +## ۶. ساخت قطعهٔ کانفیگ محلی + +در **مرحلهٔ ۳**: + +1. **شناسهٔ استقرار** را در فیلد مربوطه پیست کن. +2. **کپی قطعهٔ پیکربندی** را بزن. +3. JSON را در **`config.json`** مربوط به `mhrv-rs` ادغام کن (یا در UI گرافیکی برنامه اگر از آن استفاده می‌کنی). + +شکل معمول: + +```json +{ + "mode": "apps_script", + "script_id": "شناسه_استقرار_تو", + "auth_key": "کلید_تو", + "listen_port": 8085 +} +``` + +افزونه از همان چیزی که تولید کرده‌ای و Deployment ID که دادی پرش می‌کند. + +--- + +## ۷. اجرای mhrv-rs و تنظیم پروکسی مرورگر + +از بخش «اجرای اول» به بعد **README اصلی پروژه** را دنبال کن: ذخیرهٔ کانفیگ، استارت پروکسی، تنظیم فایرفاکس یا کروم روی `127.0.0.1:8085` (یا SwitchyOmega طبق همان README). + +در پاپ‌آپ از **مشاهدهٔ مستندات** و **راهنمای راه‌اندازی** برای لینک به README و راهنمای رسمی استفاده کن. + +--- + +## عیب‌یابی کوتاه + +| مشکل | کار پیشنهادی | +|--------|----------------| +| اسکریپت بارگذاری نمی‌شود | ممکن است raw گیت‌هاب مسدود باشد؛ افزونه به `Code.gs` داخل بسته برمی‌گردد. بعد از بهبود شبکه **بررسی آخرین نسخه Code.gs** را بزن. | +| دکمه‌های کپی کار نمی‌کنند | بعضی محیط‌ها کلیپ‌بورد را مسدود می‌کنند؛ پاپ‌آپ را فوکوس کن یا از **دانلود Code.gs** استفاده کن. | +| باینری اشتباه دانلود شد | با **مشاهدهٔ همهٔ انتشارها** دستی فایل مناسب سیستم‌عاملت را انتخاب کن. | + +برای موضوعات پیشرفته‌تر (CAPTCHA، تلگرام، Full Tunnel) از [راهنمای کامل](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/docs/guide.fa.md) در مخزن اصلی استفاده کن. diff --git a/chrome-extension/HOW_TO_USE.md b/chrome-extension/HOW_TO_USE.md new file mode 100644 index 00000000..35d431b9 --- /dev/null +++ b/chrome-extension/HOW_TO_USE.md @@ -0,0 +1,111 @@ +# How to use the mhrv-rs Apps Script Helper (Chrome) + +[فارسی](HOW_TO_USE.fa.md) + +This guide is for **end users** who want the extension to help with Apps Script setup for [mhrv-rs](https://github.com/therealaleph/MasterHttpRelayVPN-RUST). You still deploy the script in **your** Google account and run **mhrv-rs** on your computer; the extension only prepares text and opens links. + +--- + +## 1. Install the extension (unpacked) + +1. Get the extension folder (the directory that contains `manifest.json`): + - **Recommended:** clone [ardalan-ab/mhrv-helper-extension](https://github.com/ardalan-ab/mhrv-helper-extension) and use the **repo root**. + +2. Go to ( chrome://extensions/ ) +3. Click on **Devloper mode** +4. Click **Load unpacked** and select that folder. +5. (Optional) Pin the extension from the puzzle icon so it stays in the toolbar. + +--- + +## 2. Open the popup and pick a language + +1. Click the extension icon. +2. In the header, choose **English** or **فارسی**. Persian uses RTL layout. + +--- + +## 3. Download mhrv-rs (optional but useful) + +In **Step 0**: + +- Click **Download mhrv-rs** — Chrome opens the release asset the extension picks for your OS (from GitHub). +- Or use **View all releases** if you want to choose another build (Android, musl, etc.). + +Unzip the archive and keep the folder somewhere you will run the app from (see the main project README). + +--- + +## 4. Generate the auth key + +In **Step 1**: + +1. Click **Generate auth key**. A 64-character hex string appears in the box. +2. Click **Copy key** if you want it on the clipboard. + +Use this same value in two places: + +- Inside **Apps Script** as `AUTH_KEY` in `Code.gs` (the extension’s **Copy Code.gs** step will embed it for you when you copy). +- In **mhrv-rs** as the auth key field when you save config. + +Treat it like a password. The extension generates it in the browser; it is not sent to a server by the extension. + +--- + +## 5. Get Code.gs into Google Apps Script + +In **Step 2**: + +1. Wait until the spinner disappears and the script has loaded (from GitHub, or bundled fallback if GitHub is blocked). +2. (Optional) Click **Check latest Code.gs** to see if the bundled copy matches the latest file on GitHub. +3. Click **Open Apps Script** if you need a new project tab. +4. In Apps Script: **New project** (or open an existing project), delete the default `Code.gs` content. +5. Back in the extension, click **Copy Code.gs** (or **Download Code.gs** and open the file). +6. Paste into the Apps Script editor and **Save**. + +Deploy as a **Web app** (see the main README Quick Start): **Execute as: Me**, **Who has access: Anyone** (unless your setup requires something else). Finish authorization if Google asks. + +Copy the **Deployment ID** Google shows you (you will paste it in Step 3 of the extension). + +--- + +## 6. Build your local config snippet + +In **Step 3**: + +1. Paste the **Deployment ID** into the **Deployment ID** field. +2. Click **Copy config snippet**. +3. Merge the JSON into your **`config.json`** for `mhrv-rs` (or paste into the app’s config UI if you use the graphical mode). + +Typical shape: + +```json +{ + "mode": "apps_script", + "script_id": "YOUR_DEPLOYMENT_ID", + "auth_key": "YOUR_AUTH_KEY", + "listen_port": 8085 +} +``` + +The extension fills `script_id` and `auth_key` from what you entered and generated. + +--- + +## 7. Run mhrv-rs and point the browser at the proxy + +Follow the **main project README** from “First run” onward: save config, start the proxy, set Firefox or Chrome to `127.0.0.1:8085` (or use SwitchyOmega as documented there). + +Use **View app docs** / **Open setup guide** in the extension footer for links to the official README and guide. + +--- + +## Troubleshooting (short) + +| Problem | What to try | +|--------|-------------| +| Script never loads | GitHub raw may be blocked; the extension falls back to bundled `Code.gs`. Use **Check latest Code.gs** after the network improves. | +| Copy buttons do nothing | Some contexts block clipboard; try focusing the popup first or use **Download Code.gs** instead. | +| Wrong binary downloaded | Use **View all releases** and pick the archive for your OS manually. | + +For deeper issues (CAPTCHA, Telegram, full tunnel), use [docs/guide.md](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/docs/guide.md) in the main repo. \ No newline at end of file diff --git a/chrome-extension/README.fa.md b/chrome-extension/README.fa.md new file mode 100644 index 00000000..8c7001b8 --- /dev/null +++ b/chrome-extension/README.fa.md @@ -0,0 +1,88 @@ +# کمک‌کنندهٔ Apps Script برای mhrv-rs (افزونهٔ کروم) + +[English](README.md) | فارسی + +این یک **افزونهٔ سبک کروم (Manifest V3)** است که راه‌اندازی اولیهٔ **Google Apps Script** را برای استفاده از **[mhrv-rs](https://github.com/therealaleph/MasterHttpRelayVPN-RUST)** در حالت **رلهٔ Apps Script** ساده‌تر می‌کند. خود پروکسی یا تونل نیست؛ فقط در تولید کلید، گرفتن `Code.gs` و ساختن قطعهٔ `config.json` کمک می‌کند. + +**نسخهٔ پیشنهادی برای کلون و توسعه:** [ardalan-ab/mhrv-helper-extension](https://github.com/ardalan-ab/mhrv-helper-extension) +**پروژهٔ اصلی:** [therealaleph/MasterHttpRelayVPN-RUST](https://github.com/therealaleph/MasterHttpRelayVPN-RUST) + +**راهنمای گام‌به‌گام برای کاربر:** [HOW_TO_USE.fa.md](HOW_TO_USE.fa.md) · [English](HOW_TO_USE.md) + +--- + +## این افزونه چه کار می‌کند؟ + +| مرحله | خروجی | +| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ۰ | باز کردن آخرین نسخهٔ **mhrv-rs** متناسب با سیستم‌عامل شما (از طریق API گیت‌هاب). | +| ۱ | تولید **AUTH_KEY** تصادفی و قوی داخل مرورگر (`crypto.getRandomValues`). | +| ۲ | بارگذاری **Code.gs** از `raw.githubusercontent.com` (مسیر رسمی در مخزن اصلی)، با **نسخهٔ پشتیبان داخل بسته** اگر گیت‌هاب در دسترس نباشد. دکمهٔ **بررسی آخرین نسخه Code.gs** وضعیت هم‌خوانی با upstream را نشان می‌دهد. | +| ۳ | ساخت **قطعهٔ JSON پیکربندی** (حالت `apps_script`) پس از وارد کردن **شناسهٔ استقرار (Deployment ID)**. | + +رابط کاربری به **انگلیسی** و **فارسی** است و برای فارسی چیدمان **راست‌به‌چپ (RTL)** درست شده است. + +--- + +## مجوزها (چرا لازم‌اند) + +- **`storage`**: برای تنظیمات احتمالی آینده؛ در جریان فعلی اصلی افزونه الزامی نیست. +- **`clipboardWrite`**: کپی کردن کلید، `Code.gs` و قطعهٔ config با کلیک روی دکمه‌های کپی. +- **دسترسی به میزبان‌ها:** `script.google.com` (باز کردن Apps Script)، `raw.githubusercontent.com` (گرفتن `Code.gs`)، `api.github.com` (پیدا کردن آخرین ریلیز برای دانلود). + +**AUTH_KEY** فقط روی دستگاه شما ساخته می‌شود و توسط این افزونه به هیچ سروری ارسال نمی‌شود؛ تست‌های خودکار (Playwright) بررسی می‌کنند که در ترافیک شبکهٔ ثبت‌شده و storage افزونه دیده نشود. + +--- + +## نصب (بارگذاری unpacked) + +[اینجا کلیک کنید](HOW_TO_USE.fa.md) + +## توسعه و تست + +### بررسی سریع syntax + +```bash +python3 -m json.tool manifest.json +python3 -m json.tool messages.json +node --check popup.js +``` + +### تست دستی UI (بدون APIهای کروم) + +از همین پوشه: + +```bash +python3 -m http.server 8000 +``` + +در مرورگر `http://localhost:8000/test.html` را باز کنید (رفتار کلیپ‌بورد و `chrome.*` با افزونهٔ واقعی فرق دارد). + +### تست خودکار smoke (Playwright) + +```bash +npm install +npx playwright install chromium +npm run test:smoke +``` + +روی لینوکس بدون نمایشگر: `xvfb-run npm run test:smoke` (مثل CI). + +### CI — هم‌خوانی `Code.gs` با upstream + +- **مخزن فقط افزونه** (ریشهٔ git همین پوشه است): workflow [`.github/workflows/sync-codegs.yml`](.github/workflows/sync-codegs.yml) فایل canonical را از `main` پروژهٔ `therealaleph/MasterHttpRelayVPN-RUST` می‌گیرد و با `Code.gs` داخل بسته مقایسه می‌کند. +- **داخل مونوریپوی کامل**: workflow ریشهٔ مخزن [`.github/workflows/chrome-extension.yml`](../../.github/workflows/chrome-extension.yml) `chrome-extension/Code.gs` را با `assets/apps_script/Code.gs` همان commit مقایسه می‌کند. + +دکمهٔ **بررسی آخرین نسخه Code.gs** از **GitHub Contents API** برای همان مسیر استفاده می‌کند، بعد محتوا را با بسته مقایسه می‌کند و نتیجهٔ API (blob کوتاه، اندازه، یا متن خطای API) را نشان می‌دهد. + +--- + +## نسخه + +مقدار `version` در **`manifest.json`** (مثلاً **0.2.0**). + +--- + +## مجوز upstream + +رفتار و محتوای `Code.gs` توسط پروژهٔ **MasterHttpRelayVPN-RUST** تعیین می‌شود. انتشار در **Chrome Web Store** باید با قوانین گوگل و مجوزهای مخزن اصلی سازگار باشد. diff --git a/chrome-extension/README.md b/chrome-extension/README.md new file mode 100644 index 00000000..da6f96ef --- /dev/null +++ b/chrome-extension/README.md @@ -0,0 +1,94 @@ +# mhrv-rs Apps Script Helper (Chrome extension) + +English | [فارسی](README.fa.md) + +A small **Chrome extension (Manifest V3)** that speeds up **Google Apps Script** setup for **[mhrv-rs](https://github.com/therealaleph/MasterHttpRelayVPN-RUST)** when you use **Apps Script relay mode**. It does not replace the proxy or tunnel; it only helps you generate secrets, pull the canonical `Code.gs`, and build a local `config.json` snippet. + +**Maintainer / standalone source:** [ardalan-ab/mhrv-helper-extension](https://github.com/ardalan-ab/mhrv-helper-extension) (recommended). +**Upstream project:** [therealaleph/MasterHttpRelayVPN-RUST](https://github.com/therealaleph/MasterHttpRelayVPN-RUST). + +**Step-by-step for users:** [HOW_TO_USE.md](HOW_TO_USE.md) · [راهنمای فارسی](HOW_TO_USE.fa.md) + +--- + +## What it does + +| Step | What you get | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 | Opens the latest **mhrv-rs** release asset for your OS (via GitHub API). | +| 1 | Generates a strong random **AUTH_KEY** in the browser (`crypto.getRandomValues`). | +| 2 | Loads **Code.gs** from `raw.githubusercontent.com` (canonical path in the main repo), with a **bundled fallback** if the network blocks GitHub. Optional **Check latest Code.gs** compares upstream vs bundled. | +| 3 | Builds a **JSON config snippet** (`apps_script` mode) once you paste your **Deployment ID**. | + +The popup is available in **English** and **Persian (فارسی)** with **RTL** layout for Persian. + +--- + +## Permissions (why they exist) + +- **`storage`**: reserved for future settings; the extension does not need it for core flow today. +- **`clipboardWrite`**: copy auth key, `Code.gs`, and config snippet to the clipboard when you click the copy buttons. +- **Host access**: `script.google.com` (open Apps Script), `raw.githubusercontent.com` (fetch `Code.gs`), `api.github.com` (resolve latest release for downloads). + +The **AUTH_KEY** is generated locally. It is **not** sent to any server by this extension; automated smoke tests assert it does not appear in captured network traffic or extension storage during the test run. + +--- + +## Install (load unpacked) + +[click here](HOW_TO_USE.md) + +Use **View app docs** / **Open setup guide** in the popup for links to the main repository README and guide. + +--- + +## Development and testing + +### JSON and script sanity (local) + +```bash +python3 -m json.tool manifest.json +python3 -m json.tool messages.json +node --check popup.js +``` + +### Manual UI (no Chrome APIs) + +From this folder: + +```bash +python3 -m http.server 8000 +``` + +Open `http://localhost:8000/test.html` in a normal tab (clipboard and `chrome.*` APIs differ from the real extension). + +### Automated smoke test (Playwright) + +Requires Node.js and a one-time browser install for Playwright: + +```bash +npm install +npx playwright install chromium +npm run test:smoke +``` + +On Linux without a display, use `xvfb-run npm run test:smoke` (as in CI). + +### CI — bundled `Code.gs` matches upstream main + +- **Standalone extension repo** (this folder is the git root): workflow [`.github/workflows/sync-codegs.yml`](.github/workflows/sync-codegs.yml) downloads `assets/apps_script/Code.gs` from `therealaleph/MasterHttpRelayVPN-RUST` `main` and `cmp`s it to the bundled `Code.gs`. +- **Inside the full monorepo**: the root workflow [`.github/workflows/chrome-extension.yml`](../../.github/workflows/chrome-extension.yml) compares `chrome-extension/Code.gs` with `assets/apps_script/Code.gs` on the same commit. + +The popup **Check latest Code.gs** button calls the **GitHub Contents API** for that file, then compares bytes to your bundle and shows the API result (blob short SHA, size, or the API error `message`). + +--- + +## Version + +See **`manifest.json`** → `version` (currently aligned with extension releases, e.g. **0.2.0**). + +--- + +## License and upstream + +Behavior and `Code.gs` content are defined by the **MasterHttpRelayVPN-RUST** project. Use and distribute this helper in line with the licenses and policies of the upstream repository and the Chrome Web Store if you publish there. diff --git a/chrome-extension/_locales/en/messages.json b/chrome-extension/_locales/en/messages.json new file mode 100644 index 00000000..0b3c14ea --- /dev/null +++ b/chrome-extension/_locales/en/messages.json @@ -0,0 +1,8 @@ +{ + "extName": { + "message": "mhrv-rs Apps Script Helper" + }, + "extDescription": { + "message": "Generate Google Apps Script deployment code and configuration for mhrv-rs." + } +} diff --git a/chrome-extension/_locales/fa/messages.json b/chrome-extension/_locales/fa/messages.json new file mode 100644 index 00000000..65847b53 --- /dev/null +++ b/chrome-extension/_locales/fa/messages.json @@ -0,0 +1,8 @@ +{ + "extName": { + "message": "کمک‌کننده اسکریپت اپس mhrv-rs" + }, + "extDescription": { + "message": "تولید کد استقرار Google Apps Script و پیکربندی برای mhrv-rs." + } +} diff --git a/chrome-extension/assets/logo.webp b/chrome-extension/assets/logo.webp new file mode 100644 index 00000000..75e3ceab Binary files /dev/null and b/chrome-extension/assets/logo.webp differ diff --git a/chrome-extension/background.js b/chrome-extension/background.js new file mode 100644 index 00000000..aea59063 --- /dev/null +++ b/chrome-extension/background.js @@ -0,0 +1,2 @@ +// Intentionally empty service worker. +// Keeps extension runtime discoverable for automated smoke tests. diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json new file mode 100644 index 00000000..07ebb20b --- /dev/null +++ b/chrome-extension/manifest.json @@ -0,0 +1,16 @@ +{ + "manifest_version": 3, + "name": "__MSG_extName__", + "description": "__MSG_extDescription__", + "version": "0.2.0", + "default_locale": "en", + "action": { + "default_title": "__MSG_extName__", + "default_popup": "popup.html" + }, + "background": { + "service_worker": "background.js" + }, + "permissions": ["storage", "clipboardWrite"], + "host_permissions": ["https://script.google.com/*", "https://raw.githubusercontent.com/*", "https://api.github.com/*"] +} diff --git a/chrome-extension/messages.json b/chrome-extension/messages.json new file mode 100644 index 00000000..962cc337 --- /dev/null +++ b/chrome-extension/messages.json @@ -0,0 +1,92 @@ +{ + "en": { + "appName": "mhrv-rs Apps Script Helper", + "appDesc": "Generate Google Apps Script deployment code and configuration for mhrv-rs.", + "step0Title": "Step 0: Download mhrv-rs", + "step0Desc": "Get the latest Rust binary for your platform.", + "downloadRust": "Download mhrv-rs", + "viewReleases": "View all releases", + "step1Title": "Step 1: Generate a strong auth key", + "step1Desc": "This secret is used by the Apps Script and your local config.", + "generateKey": "Generate auth key", + "copyKey": "Copy key", + "step2Title": "Step 2: Build your Apps Script", + "step2Desc": "Open Apps Script, paste the generated code, and deploy as a Web app.", + "copyScript": "Copy Code.gs", + "downloadScript": "Download Code.gs", + "openScript": "Open Apps Script", + "scriptNotice": "The script is loaded from the extension package and includes the same relay protocol used by mhrv-rs.", + "checkScriptVersion": "Check latest Code.gs", + "step3Title": "Step 3: Create config", + "step3Desc": "Paste your Deployment ID and the auth key into your local config.", + "deploymentIdLabel": "Deployment ID", + "deploymentIdPlaceholder": "YOUR_APPS_SCRIPT_DEPLOYMENT_ID", + "copyConfig": "Copy config snippet", + "linksTitle": "Quick links", + "viewDocs": "View app docs", + "setupGuide": "Open setup guide", + "proxyNote": "Use a local browser proxy such as Proxy SwitchyOmega for Chrome/Edge.", + "keyGenerated": "Auth key generated. Paste it into Apps Script and config.", + "copied": "Copied {item}.", + "scriptDownloaded": "Downloaded Code.gs for Apps Script deployment.", + "codeLoaded": "Code.gs loaded from repository.", + "codeLoadedFallback": "Failed to load Code.gs from repository. Using local fallback.", + "downloadError": "Failed to fetch latest release. Opening releases page.", + "downloadSuccess": "Opening download page for latest mhrv-rs binary.", + "scriptNotLoaded": "Script template not loaded yet.", + "generateKeyFirst": "Generate an auth key first.", + "copyError": "Could not copy {item}.", + "fetchError": "Failed to load Code.gs at all." + , + "scriptUpToDateDetail": "Bundled Code.gs matches main. GitHub API: blob {sha}… ({size} bytes).", + "scriptOutdatedDetail": "Bundled Code.gs differs from main. Upstream blob {sha}… ({size} bytes). Update bundled Code.gs from the main repo.", + "scriptCheckApiError": "GitHub API ({status}): {detail}", + "scriptCheckRawFailed": "GitHub API OK but file body missing: {detail}", + "scriptCheckFailed": "Could not verify Code.gs version." + }, + "fa": { + "appName": "کمک‌کننده اسکریپت اپس mhrv-rs", + "appDesc": "تولید کد استقرار Google Apps Script و پیکربندی برای mhrv-rs.", + "step0Title": "مرحله ۰: دانلود mhrv-rs", + "step0Desc": "دریافت آخرین باینری Rust برای پلتفرم شما.", + "downloadRust": "دانلود mhrv-rs", + "viewReleases": "مشاهده همه انتشارها", + "step1Title": "مرحله ۱: تولید کلید احراز هویت قوی", + "step1Desc": "این راز توسط Apps Script و پیکربندی محلی شما استفاده می‌شود.", + "generateKey": "تولید کلید احراز هویت", + "copyKey": "کپی کلید", + "step2Title": "مرحله ۲: ساخت Apps Script شما", + "step2Desc": "Apps Script را باز کنید، کد تولید شده را جای‌گذاری کنید و به عنوان Web app مستقر کنید.", + "copyScript": "کپی Code.gs", + "downloadScript": "دانلود Code.gs", + "openScript": "باز کردن Apps Script", + "scriptNotice": "اسکریپت از بسته افزونه بارگذاری می‌شود و شامل همان پروتکل رله استفاده شده توسط mhrv-rs است.", + "checkScriptVersion": "بررسی آخرین نسخه Code.gs", + "step3Title": "مرحله ۳: ایجاد پیکربندی", + "step3Desc": "شناسه استقرار و کلید احراز هویت را در پیکربندی محلی خود جای‌گذاری کنید.", + "deploymentIdLabel": "شناسه استقرار", + "deploymentIdPlaceholder": "شناسه_استقرار_اپس_اسکریپت_شما", + "copyConfig": "کپی قطعه پیکربندی", + "linksTitle": "پیوندهای سریع", + "viewDocs": "مشاهده مستندات برنامه", + "setupGuide": "باز کردن راهنمای راه‌اندازی", + "proxyNote": "از یک پروکسی مرورگر محلی مانند Proxy SwitchyOmega برای Chrome/Edge استفاده کنید.", + "keyGenerated": "کلید احراز هویت تولید شد. آن را در Apps Script و پیکربندی جای‌گذاری کنید.", + "copied": "{item} کپی شد.", + "scriptDownloaded": "Code.gs برای استقرار Apps Script دانلود شد.", + "codeLoaded": "Code.gs از مخزن بارگذاری شد.", + "codeLoadedFallback": "بارگذاری Code.gs از مخزن ناموفق بود. از نسخه محلی استفاده می‌شود.", + "downloadError": "دریافت آخرین انتشار ناموفق بود. صفحه انتشارها باز می‌شود.", + "downloadSuccess": "صفحه دانلود برای آخرین باینری mhrv-rs باز می‌شود.", + "scriptNotLoaded": "الگوی اسکریپت هنوز بارگذاری نشده است.", + "generateKeyFirst": "ابتدا یک کلید احراز هویت تولید کنید.", + "copyError": "کپی {item} امکان‌پذیر نبود.", + "fetchError": "بارگذاری Code.gs به طور کامل ناموفق بود." + , + "scriptUpToDateDetail": "Code.gs داخل افزونه با main یکی است. API گیت‌هاب: blob {sha}… ({size} بایت).", + "scriptOutdatedDetail": "Code.gs داخل افزونه با main فرق دارد. blob upstream: {sha}… ({size} بایت). فایل را از مخزن اصلی به‌روز کن.", + "scriptCheckApiError": "خطای API گیت‌هاب ({status}): {detail}", + "scriptCheckRawFailed": "API گیت‌هاب OK بود اما بدنهٔ فایل نیامد: {detail}", + "scriptCheckFailed": "امکان بررسی نسخه Code.gs وجود ندارد." + } +} \ No newline at end of file diff --git a/chrome-extension/package-lock.json b/chrome-extension/package-lock.json new file mode 100644 index 00000000..429311db --- /dev/null +++ b/chrome-extension/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "mhrv-helper-extension-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mhrv-helper-extension-tests", + "devDependencies": { + "@playwright/test": "latest" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/chrome-extension/package.json b/chrome-extension/package.json new file mode 100644 index 00000000..aba0ca44 --- /dev/null +++ b/chrome-extension/package.json @@ -0,0 +1,10 @@ +{ + "name": "mhrv-helper-extension-tests", + "private": true, + "scripts": { + "test:smoke": "playwright test --config=playwright.config.js" + }, + "devDependencies": { + "@playwright/test": "latest" + } +} diff --git a/chrome-extension/playwright.config.js b/chrome-extension/playwright.config.js new file mode 100644 index 00000000..710942a8 --- /dev/null +++ b/chrome-extension/playwright.config.js @@ -0,0 +1,27 @@ +const { defineConfig } = require('@playwright/test'); +const path = require('path'); + +module.exports = defineConfig({ + testDir: './tests', + timeout: 60_000, + fullyParallel: false, + retries: 0, + reporter: 'list', + use: { + headless: false, + }, + projects: [ + { + name: 'chromium-extension', + use: { + channel: 'chromium', + launchOptions: { + args: [ + `--disable-extensions-except=${path.resolve(__dirname)}`, + `--load-extension=${path.resolve(__dirname)}`, + ], + }, + }, + }, + ], +}); diff --git a/chrome-extension/popup.css b/chrome-extension/popup.css new file mode 100644 index 00000000..4c5dbb2a --- /dev/null +++ b/chrome-extension/popup.css @@ -0,0 +1,288 @@ +:root { + color-scheme: light; + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 13px; + color: #111; + background: #f7fafc; + --primary: #2563eb; + --primary-hover: #1d4ed8; + --secondary: #64748b; + --secondary-hover: #475569; + --success: #059669; + --warning: #d97706; + --error: #dc2626; + --border: #e2e8f0; + --bg: #ffffff; + --bg-secondary: #f8fafc; +} + +body { + margin: 0; + min-width: 380px; + max-width: 420px; +} + +body[dir="rtl"] { + direction: rtl; +} + +.container { + padding: 20px; +} + +header { + margin-bottom: 24px; + text-align: center; +} + +.header-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +header h1 { + margin: 0; + font-size: 1.1rem; + font-weight: 700; + color: var(--primary); +} + +header p { + margin: 0; + color: #64748b; + font-size: 0.9rem; +} + +#language-select { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 6px 10px; + font-size: 0.85rem; + color: var(--secondary); + cursor: pointer; + transition: border-color 0.2s ease; +} + +#language-select:focus { + outline: none; + border-color: var(--primary); +} + +.step { + margin-bottom: 20px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 16px; + padding: 18px; + position: relative; + transition: box-shadow 0.2s ease; +} + +.step:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.step h2 { + margin: 0 0 10px; + font-size: 1rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.step h2::before { + content: ''; + width: 6px; + height: 6px; + background: var(--primary); + border-radius: 50%; + flex-shrink: 0; +} + +.step.note h2::before { + background: var(--secondary); +} + +.step p { + margin: 0 0 14px; + line-height: 1.5; + color: #64748b; +} + +.row { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 14px; +} + +.row.small { + gap: 6px; +} + +button { + border: none; + border-radius: 12px; + padding: 12px 16px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + background: var(--primary); + color: #fff; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 6px; + justify-content: center; +} + +button::before { + content: ''; + width: 16px; + height: 16px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + flex-shrink: 0; +} + +button#generate-key::before { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z'/%3E%3C/svg%3E"); +} + +button#copy-key::before, +button#copy-script::before, +button#copy-config::before { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z'/%3E%3C/svg%3E"); +} + +button#download-script::before, +button#download-rust::before { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'/%3E%3C/svg%3E"); +} + +button#open-script::before, +button#open-readme::before, +button#open-guide::before, +button#open-releases::before { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14'/%3E%3C/svg%3E"); +} + +button#check-script-version::before { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'/%3E%3C/svg%3E"); +} + +button.secondary { + background: var(--bg-secondary); + color: var(--secondary); + border: 1px solid var(--border); +} + +button.secondary::before { + filter: invert(0.5); +} + +button:hover { + background: var(--primary-hover); + transform: translateY(-1px); +} + +button.secondary:hover { + background: var(--secondary-hover); + color: white; +} + +button:active { + transform: translateY(0); +} + +label { + display: block; + margin-bottom: 12px; + color: #374151; + font-weight: 500; +} + +input, +textarea { + width: 100%; + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px 14px; + font-family: inherit; + font-size: 0.95rem; + background: var(--bg-secondary); + color: #0f172a; + resize: vertical; + transition: border-color 0.2s ease; +} + +input:focus, +textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +textarea { + min-height: 100px; + max-height: 240px; + overflow: auto; +} + +.notice { + background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); + border-left: 4px solid var(--primary); + padding: 12px 16px; + color: #0c4a6e; + font-size: 0.9rem; + border-radius: 0 12px 12px 12px; + margin-top: 12px; +} + +footer { + margin-top: 16px; + padding-top: 8px; + text-align: center; +} + +#message { + color: var(--success); + font-size: 0.9rem; + font-weight: 500; + padding: 8px 12px; + border-radius: 8px; + background: rgba(5, 150, 105, 0.1); + display: inline-block; + max-width: 100%; + box-sizing: border-box; + word-break: break-word; + text-align: start; +} + +#message.error { + color: var(--error); + background: rgba(220, 38, 38, 0.1); +} + +.progress { + position: absolute; + top: 18px; + right: 18px; + width: 20px; + height: 20px; + border: 2px solid var(--border); + border-top: 2px solid var(--primary); + border-radius: 50%; + animation: spin 1s linear infinite; + display: none; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} diff --git a/chrome-extension/popup.html b/chrome-extension/popup.html new file mode 100644 index 00000000..3ecb9419 --- /dev/null +++ b/chrome-extension/popup.html @@ -0,0 +1,80 @@ +<!DOCTYPE html> +<html lang="en" dir="ltr"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>mhrv-rs Apps Script Helper + + + +
+
+
+

mhrv-rs Apps Script Helper

+ +
+

Generate Google Apps Script deployment code and configuration for mhrv-rs.

+
+ +
+

Step 0: Download mhrv-rs

+

Get the latest Rust binary for your platform.

+
+ + +
+
+ +
+

Step 1: Generate a strong auth key

+

This secret is used by the Apps Script and your local config.

+
+ + +
+ +
+ +
+

Step 2: Build your Apps Script

+

Open Apps Script, paste the generated code, and deploy as a Web app.

+
+ + + + +
+
+
The script is loaded from the extension package and includes the same relay protocol used by mhrv-rs.
+
+ +
+

Step 3: Create config

+

Paste your Deployment ID and the auth key into your local config.

+ + +
+ +
+ +
+ +
+

Quick links

+
+ + +
+

Use a local browser proxy such as Proxy SwitchyOmega for Chrome/Edge.

+
+ + +
+ + + diff --git a/chrome-extension/popup.js b/chrome-extension/popup.js new file mode 100644 index 00000000..3a5c0539 --- /dev/null +++ b/chrome-extension/popup.js @@ -0,0 +1,453 @@ +const AUTH_KEY_PLACEHOLDER = 'CHANGE_ME_TO_A_STRONG_SECRET'; +const CODE_FILE = 'Code.gs'; +const CODE_FILE_URL = 'https://raw.githubusercontent.com/therealaleph/MasterHttpRelayVPN-RUST/main/assets/apps_script/Code.gs'; +const CODE_GS_API_URL = + 'https://api.github.com/repos/therealaleph/MasterHttpRelayVPN-RUST/contents/assets/apps_script/Code.gs?ref=main'; + +const GITHUB_API_HEADERS = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'mhrv-helper-extension', +}; +let codeTemplate = ''; +let messages = {}; + +const elements = { + languageSelect: document.getElementById('language-select'), + authKey: document.getElementById('auth-key'), + deploymentId: document.getElementById('deployment-id'), + configJson: document.getElementById('config-json'), + message: document.getElementById('message'), + generateKey: document.getElementById('generate-key'), + copyKey: document.getElementById('copy-key'), + copyScript: document.getElementById('copy-script'), + downloadScript: document.getElementById('download-script'), + checkScriptVersion: document.getElementById('check-script-version'), + openScript: document.getElementById('open-script'), + copyConfig: document.getElementById('copy-config'), + openReadme: document.getElementById('open-readme'), + openGuide: document.getElementById('open-guide'), + downloadRust: document.getElementById('download-rust'), + openReleases: document.getElementById('open-releases'), + scriptProgress: document.getElementById('script-progress'), +}; + +function toHex(bytes) { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +async function sha256(text) { + const data = new TextEncoder().encode(text); + const digest = await crypto.subtle.digest('SHA-256', data); + return toHex(new Uint8Array(digest)); +} + +async function loadMessages() { + try { + const response = await fetch(chrome.runtime.getURL('messages.json')); + messages = await response.json(); + } catch (err) { + console.error('Failed to load messages:', err); + // Fallback messages + messages = { + en: { + keyGenerated: "Auth key generated. Paste it into Apps Script and config.", + copied: "Copied {item}.", + scriptDownloaded: "Downloaded Code.gs for Apps Script deployment.", + codeLoaded: "Code.gs loaded from repository.", + codeLoadedFallback: "Failed to load Code.gs from repository. Using local fallback.", + downloadError: "Failed to fetch latest release. Opening releases page.", + downloadSuccess: "Opening download page for latest mhrv-rs binary.", + scriptNotLoaded: "Script template not loaded yet.", + generateKeyFirst: "Generate an auth key first.", + copyError: "Could not copy {item}.", + fetchError: "Failed to load Code.gs at all.", + scriptUpToDateDetail: "Bundled Code.gs matches main. GitHub API: blob {sha}… ({size} bytes).", + scriptOutdatedDetail: "Bundled Code.gs differs from main. Upstream blob {sha}… ({size} bytes). Update bundled Code.gs from the main repo.", + scriptCheckApiError: "GitHub API ({status}): {detail}", + scriptCheckRawFailed: "GitHub API OK but file body missing: {detail}", + scriptCheckFailed: "Could not verify Code.gs version.", + } + }; + } +} + +function getMessage(key, params = {}) { + const lang = elements.languageSelect.value; + let message = messages[lang]?.[key] || messages.en?.[key] || key; + + // Replace placeholders + Object.keys(params).forEach(param => { + message = message.replace(`{${param}}`, params[param]); + }); + + return message; +} + +function updateUILanguage() { + const lang = elements.languageSelect.value; + document.documentElement.lang = lang; + document.documentElement.dir = lang === 'fa' ? 'rtl' : 'ltr'; + + // Update all elements with data-i18n attributes + document.querySelectorAll('[data-i18n]').forEach(el => { + const key = el.getAttribute('data-i18n'); + el.textContent = getMessage(key); + }); + + // Update placeholders + document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { + const key = el.getAttribute('data-i18n-placeholder'); + el.placeholder = getMessage(key); + }); + + // Update title + document.title = getMessage('appName'); + + // Re-render config to update any language-specific text + renderConfig(); +} + +function randomHex(length = 32) { + const array = new Uint8Array(length); + crypto.getRandomValues(array); + return Array.from(array) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +} + +function showMessage(text, isError = false) { + elements.message.textContent = text; + elements.message.className = isError ? 'error' : ''; +} + +function renderConfig() { + const authKey = elements.authKey.value.trim(); + const deploymentId = elements.deploymentId.value.trim(); + const config = { + mode: 'apps_script', + script_id: deploymentId || 'YOUR_APPS_SCRIPT_DEPLOYMENT_ID', + auth_key: authKey || 'PASTE_YOUR_AUTH_KEY_HERE', + listen_port: 8085, + }; + elements.configJson.value = JSON.stringify(config, null, 2); +} + +function renderScript() { + if (!codeTemplate) { + return; + } + const authKey = elements.authKey.value.trim() || AUTH_KEY_PLACEHOLDER; + const replaced = codeTemplate.replace( + /const\s+AUTH_KEY\s*=\s*"[^"]*";/, + `const AUTH_KEY = "${authKey}";` + ); + document.getElementById('code-preview')?.remove(); + const preview = document.createElement('textarea'); + preview.id = 'code-preview'; + preview.readOnly = true; + preview.value = replaced; + preview.style.marginTop = '8px'; + preview.style.height = '220px'; + preview.style.fontFamily = 'monospace'; + preview.style.whiteSpace = 'pre'; + preview.style.overflow = 'auto'; + preview.style.width = '100%'; + preview.style.border = '1px solid #cbd5e1'; + preview.style.borderRadius = '10px'; + const step = elements.copyScript.closest('.step'); + const existing = step.querySelector('#code-preview'); + if (existing) existing.remove(); + step.appendChild(preview); +} + +function setAuthKey(key) { + elements.authKey.value = key; + renderConfig(); + renderScript(); +} + +async function loadTemplate() { + elements.scriptProgress.style.display = 'block'; + try { + const response = await fetch(CODE_FILE_URL); + if (!response.ok) throw new Error('Failed to fetch Code.gs'); + codeTemplate = await response.text(); + renderScript(); + showMessage(getMessage('codeLoaded')); + } catch (err) { + showMessage(getMessage('codeLoadedFallback'), true); + console.error(err); + // Fallback to local if fetch fails + try { + const localResponse = await fetch(chrome.runtime.getURL('Code.gs')); + codeTemplate = await localResponse.text(); + renderScript(); + } catch (localErr) { + showMessage(getMessage('fetchError'), true); + } + } finally { + elements.scriptProgress.style.display = 'none'; + } +} + +function copyText(text, label) { + return navigator.clipboard.writeText(text).then( + () => showMessage(getMessage('copied', { item: label })), + (err) => { + console.error(err); + showMessage(getMessage('copyError', { item: label }), true); + } + ); +} + +async function fetchGithubCodeGsMetadata() { + const response = await fetch(CODE_GS_API_URL, { + cache: 'no-store', + headers: GITHUB_API_HEADERS, + }); + const text = await response.text(); + let json = null; + try { + json = JSON.parse(text); + } catch { + // non-JSON body + } + return { ok: response.ok, status: response.status, json, text }; +} + +function decodeGithubFileContent(json) { + if (json.encoding === 'base64' && typeof json.content === 'string') { + const b64 = json.content.replace(/\n/g, ''); + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return new TextDecoder('utf-8').decode(bytes); + } + return null; +} + +async function getUpstreamCodeGsTextFromApi(json) { + const inline = decodeGithubFileContent(json); + if (inline != null) { + return inline; + } + if (json.download_url) { + const r = await fetch(json.download_url, { cache: 'no-store' }); + if (!r.ok) { + throw new Error(`download_url HTTP ${r.status}`); + } + return r.text(); + } + throw new Error('API response has no file body (content / download_url).'); +} + +function formatApiErrorDetail(status, json, rawText) { + if (json && typeof json.message === 'string') { + return json.message; + } + if (json && typeof json.error === 'string') { + return json.error; + } + const trimmed = (rawText || '').trim(); + if (trimmed) { + return trimmed.slice(0, 240); + } + return `HTTP ${status}`; +} + +async function checkScriptVersion() { + elements.scriptProgress.style.display = 'block'; + try { + const [apiResult, localResp] = await Promise.all([ + fetchGithubCodeGsMetadata(), + fetch(chrome.runtime.getURL(CODE_FILE)), + ]); + + if (!localResp.ok) { + showMessage(getMessage('scriptCheckFailed'), true); + return; + } + + const localText = await localResp.text(); + const localHash = await sha256(localText); + + if (!apiResult.ok) { + const detail = formatApiErrorDetail(apiResult.status, apiResult.json, apiResult.text); + showMessage( + getMessage('scriptCheckApiError', { + status: String(apiResult.status), + detail, + }), + true + ); + return; + } + + const meta = apiResult.json; + const upstreamSha = typeof meta.sha === 'string' ? meta.sha : ''; + const shortSha = upstreamSha.length >= 7 ? upstreamSha.slice(0, 7) : upstreamSha || '—'; + const upstreamSize = + meta.size != null && Number.isFinite(Number(meta.size)) ? String(meta.size) : '—'; + + let upstreamText; + try { + upstreamText = await getUpstreamCodeGsTextFromApi(meta); + } catch (err) { + console.error(err); + showMessage( + getMessage('scriptCheckRawFailed', { detail: err.message || String(err) }), + true + ); + return; + } + + const remoteHash = await sha256(upstreamText); + + if (remoteHash === localHash) { + showMessage( + getMessage('scriptUpToDateDetail', { + sha: shortSha, + size: upstreamSize, + }) + ); + return; + } + + showMessage( + getMessage('scriptOutdatedDetail', { + sha: shortSha, + size: upstreamSize, + }), + true + ); + } catch (err) { + console.error(err); + showMessage( + getMessage('scriptCheckApiError', { + status: '—', + detail: err.message || String(err), + }), + true + ); + } finally { + elements.scriptProgress.style.display = 'none'; + } +} + +async function downloadLatestRust() { + try { + const response = await fetch('https://api.github.com/repos/therealaleph/MasterHttpRelayVPN-RUST/releases/latest'); + if (!response.ok) throw new Error('Failed to fetch releases'); + const release = await response.json(); + const assets = release.assets; + // Detect platform + const platform = navigator.platform.toLowerCase(); + let assetName; + if (platform.includes('win')) { + assetName = assets.find(a => a.name.includes('windows') && a.name.endsWith('.exe')); + } else if (platform.includes('mac')) { + assetName = assets.find(a => a.name.includes('macos') || a.name.includes('darwin')); + } else { + assetName = assets.find(a => a.name.includes('linux')); + } + if (!assetName) { + showMessage('No suitable binary found for your platform.', true); + return; + } + window.open(assetName.browser_download_url, '_blank'); + showMessage(getMessage('downloadSuccess')); + } catch (err) { + console.error(err); + showMessage(getMessage('downloadError'), true); + window.open('https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases', '_blank'); + } +} + +function initListeners() { + elements.languageSelect.addEventListener('change', updateUILanguage); + + elements.generateKey.addEventListener('click', () => { + setAuthKey(randomHex(32)); + showMessage(getMessage('keyGenerated')); + }); + + elements.copyKey.addEventListener('click', () => { + const key = elements.authKey.value.trim(); + if (!key) { + showMessage(getMessage('generateKeyFirst'), true); + return; + } + copyText(key, 'auth key'); + }); + + elements.copyScript.addEventListener('click', () => { + if (!codeTemplate) { + showMessage(getMessage('scriptNotLoaded'), true); + return; + } + const authKey = elements.authKey.value.trim() || AUTH_KEY_PLACEHOLDER; + const scriptText = codeTemplate.replace( + /const\s+AUTH_KEY\s*=\s*"[^"]*";/, + `const AUTH_KEY = "${authKey}";` + ); + copyText(scriptText, 'Apps Script'); + }); + + elements.downloadScript.addEventListener('click', () => { + if (!codeTemplate) { + showMessage(getMessage('scriptNotLoaded'), true); + return; + } + const authKey = elements.authKey.value.trim() || AUTH_KEY_PLACEHOLDER; + const scriptText = codeTemplate.replace( + /const\s+AUTH_KEY\s*=\s*"[^"]*";/, + `const AUTH_KEY = "${authKey}";` + ); + const blob = new Blob([scriptText], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'Code.gs'; + anchor.click(); + URL.revokeObjectURL(url); + showMessage(getMessage('scriptDownloaded')); + }); + + elements.openScript.addEventListener('click', () => { + window.open('https://script.google.com/home/projects', '_blank'); + }); + + elements.copyConfig.addEventListener('click', () => { + copyText(elements.configJson.value, 'config snippet'); + }); + + elements.openReadme.addEventListener('click', () => { + window.open('https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/README.md', '_blank'); + }); + + elements.openGuide.addEventListener('click', () => { + window.open('https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/docs/guide.md', '_blank'); + }); + + elements.checkScriptVersion.addEventListener('click', () => checkScriptVersion()); + + elements.downloadRust.addEventListener('click', () => downloadLatestRust()); + elements.openReleases.addEventListener('click', () => window.open('https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases', '_blank')); + + elements.deploymentId.addEventListener('input', renderConfig); +} + +async function init() { + await loadMessages(); + loadTemplate(); + initListeners(); + renderConfig(); + updateUILanguage(); +} + +init(); diff --git a/chrome-extension/test.html b/chrome-extension/test.html new file mode 100644 index 00000000..cc16db75 --- /dev/null +++ b/chrome-extension/test.html @@ -0,0 +1,99 @@ + + + + + + Extension Test + + + +
+
+
+

mhrv-rs Apps Script Helper

+ +
+

Generate Google Apps Script deployment code and configuration for mhrv-rs.

+
+ +
+

Step 0: Download mhrv-rs

+

Get the latest Rust binary for your platform.

+
+ + +
+
+ +
+

Step 1: Generate a strong auth key

+

This secret is used by the Apps Script and your local config.

+
+ + +
+ +
+ +
+

Step 2: Build your Apps Script

+

Open Apps Script, paste the generated code, and deploy as a Web app.

+
+ + + +
+
+
The script is loaded from the extension package and includes the same relay protocol used by mhrv-rs.
+
+ +
+

Step 3: Create config

+

Paste your Deployment ID and the auth key into your local config.

+ + +
+ +
+ +
+ +
+

Quick links

+
+ + +
+

Use a local browser proxy such as Proxy SwitchyOmega for Chrome/Edge.

+
+ + +
+ + + + + \ No newline at end of file diff --git a/chrome-extension/tests/auth-key-smoke.spec.js b/chrome-extension/tests/auth-key-smoke.spec.js new file mode 100644 index 00000000..d5fdc85c --- /dev/null +++ b/chrome-extension/tests/auth-key-smoke.spec.js @@ -0,0 +1,74 @@ +const { test, expect, chromium } = require('@playwright/test'); +const path = require('path'); + +function containsSecret(value, secret) { + return typeof value === 'string' && value.includes(secret); +} + +test('AUTH_KEY stays local to popup scope', async () => { + const extensionPath = path.resolve(__dirname, '..'); + const context = await chromium.launchPersistentContext('', { + channel: 'chromium', + headless: false, + args: [ + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + ], + }); + + const requestSnapshots = []; + const consoleMessages = []; + let generatedKey = ''; + + context.on('request', (req) => { + requestSnapshots.push({ + url: req.url(), + postData: req.postData() || '', + headers: JSON.stringify(req.headers()), + }); + }); + + try { + let [worker] = context.serviceWorkers(); + if (!worker) { + worker = await context.waitForEvent('serviceworker'); + } + + const extensionId = worker.url().split('/')[2]; + const page = await context.newPage(); + + page.on('console', (msg) => { + consoleMessages.push(msg.text()); + }); + + await page.goto(`chrome-extension://${extensionId}/popup.html`); + + await page.getByRole('button', { name: /Generate auth key|تولید کلید/ }).click(); + generatedKey = await page.locator('#auth-key').inputValue(); + expect(generatedKey).toMatch(/^[a-f0-9]{64}$/); + + await page.getByRole('button', { name: /Copy key|کپی کلید/ }).click(); + await page.getByRole('button', { name: /Copy Code\.gs|کپی Code\.gs/ }).click(); + await page.getByRole('button', { name: /Copy config snippet|کپی قطعه پیکربندی/ }).click(); + + const [localStorageData, syncStorageData] = await Promise.all([ + page.evaluate(() => new Promise((resolve) => chrome.storage.local.get(null, resolve))), + page.evaluate(() => new Promise((resolve) => chrome.storage.sync.get(null, resolve))), + ]); + + expect(JSON.stringify(localStorageData)).not.toContain(generatedKey); + expect(JSON.stringify(syncStorageData)).not.toContain(generatedKey); + + for (const req of requestSnapshots) { + expect(containsSecret(req.url, generatedKey)).toBeFalsy(); + expect(containsSecret(req.postData, generatedKey)).toBeFalsy(); + expect(containsSecret(req.headers, generatedKey)).toBeFalsy(); + } + + for (const line of consoleMessages) { + expect(containsSecret(line, generatedKey)).toBeFalsy(); + } + } finally { + await context.close(); + } +});