From ff419b133895d513e99c5bcd443c97c0c87c27fa Mon Sep 17 00:00:00 2001 From: Abolfazl Date: Fri, 1 May 2026 22:26:44 +0330 Subject: [PATCH] feat: add exit node deployment guide and templates for Val Town, Cloudflare Workers, and Deno Deploy Co-authored-by: Copilot --- EXIT_NODE_DEPLOYMENT.md | 111 ++++++++++++++++++ README.md | 83 +++++++++++-- README_FA.md | 35 ++++-- ...udflare_worker.ts => cloudflare_worker.js} | 28 ++--- config.example.json | 10 +- src/domain_fronter.py | 89 ++++++++++++-- 6 files changed, 307 insertions(+), 49 deletions(-) create mode 100644 EXIT_NODE_DEPLOYMENT.md rename apps_script/{cloudflare_worker.ts => cloudflare_worker.js} (74%) diff --git a/EXIT_NODE_DEPLOYMENT.md b/EXIT_NODE_DEPLOYMENT.md new file mode 100644 index 0000000..26b39d7 --- /dev/null +++ b/EXIT_NODE_DEPLOYMENT.md @@ -0,0 +1,111 @@ +# Exit Node Deployment Guide (Val Town / Cloudflare / Deno) + +This guide explains how to deploy an exit node for MasterHttpRelayVPN on free platforms. + +Traffic path: + +Browser -> Local Proxy -> Apps Script -> Exit Node -> Target Website + +Use this when destinations block Google datacenter egress. + +## 1) Choose One Provider + +- Val Town +- Cloudflare Workers +- Deno Deploy + +You only need one provider. + +## 2) Set PSK In Code + +Each template includes: + +const PSK = "CHANGE_ME_TO_A_STRONG_SECRET"; + +Replace that value with a long random secret. + +Important: +- Use the same PSK in your local config under exit_node.psk. +- Never share your deployed URL together with a valid PSK. + +## 3) Deploy On Val Town + +Source file: apps_script/valtown.ts + +Steps: +1. Sign in at https://www.val.town +2. Create a new Val (TypeScript HTTP endpoint). +3. Paste content from apps_script/valtown.ts. +4. Set the PSK constant in the code. +5. Save and deploy. +6. Copy your public URL, usually like https://YOUR-NAME.web.val.run + +## 4) Deploy On Cloudflare Workers + +Source file: apps_script/cloudflare_worker.js + +Steps: +1. Sign in at https://dash.cloudflare.com +2. Go to Compute -> Workers & Pages. +3. Create Application -> Start with Hello World -> Deploy -> Edit Code. +4. Replace code with apps_script/cloudflare_worker.js content. +5. Set PSK constant in code. +6. Deploy. +7. Copy URL, usually like https://YOUR-WORKER.YOUR-SUBDOMAIN.workers.dev + +## 5) Deploy On Deno Deploy + +Source file: apps_script/deno_deploy.ts + +Steps: +1. Sign in at https://dash.deno.com +2. Create new project. +3. Upload or paste apps_script/deno_deploy.ts. +4. Set PSK constant in code. +5. Deploy. +6. Copy URL, usually like https://YOUR-PROJECT.deno.dev + +## 6) Configure MasterHttpRelayVPN + +Update config.json: + +{ + "exit_node": { + "enabled": true, + "provider": "valtown", + "url": "https://YOUR-NAME.web.val.run", + "psk": "CHANGE_ME_TO_A_STRONG_SECRET", + "mode": "full", + "hosts": [ + "chatgpt.com", + "openai.com", + "claude.ai", + "anthropic.com" + ] + } +} + +Provider values: +- valtown +- cloudflare +- deno + +If mode is selective, only hosts listed in hosts use the exit node. +If mode is full, all relayed traffic uses the exit node. + +## 7) Quick Test + +1. Start app: python main.py +2. Ensure proxy is set in browser. +3. Open a site known to require non-Google egress. +4. If it fails, check: + - provider and url are correct + - psk matches exactly between config and deployed code + - exit_node.enabled is true + +## Troubleshooting + +- unauthorized: PSK mismatch +- method_not_allowed: endpoint got non-POST request directly (normal when opened in browser) +- bad_url: malformed target URL from relay payload +- timeout or 5xx: temporary provider issue, redeploy and retry diff --git a/README.md b/README.md index b34b1fb..414beda 100644 --- a/README.md +++ b/README.md @@ -178,21 +178,27 @@ Some websites block Google datacenter IPs when traffic exits directly from Apps To fix that, configure an exit node so traffic path becomes: ```text -Browser -> Local Proxy -> Apps Script -> val.town -> Target website +Browser -> Local Proxy -> Apps Script -> Exit Node (Val Town / Cloudflare / Deno) -> Target website ``` -1. Open [`apps_script/valtown.ts`](apps_script/valtown.ts) and deploy it on [val.town](https://www.val.town/): - - Create a new val - - Paste file contents - - Add HTTP trigger - - Copy your generated URL (`https://.web.val.run`) -2. Set `PSK` inside the val code to a strong secret. -3. Add this block to your `config.json`: +You can deploy any one of these free exit-node templates: + +1. Val Town: [`apps_script/valtown.ts`](apps_script/valtown.ts) +2. Cloudflare Workers: [`apps_script/cloudflare_worker.js`](apps_script/cloudflare_worker.js) +3. Deno Deploy: [`apps_script/deno_deploy.ts`](apps_script/deno_deploy.ts) + +Full step-by-step deployment guide (all providers): +- [EXIT_NODE_DEPLOYMENT.md](EXIT_NODE_DEPLOYMENT.md) + +Set the same PSK secret inside the exit-node code (`PSK` constant) and in `config.json`. + +Then configure provider switching like this: ```json "exit_node": { "enabled": true, - "relay_url": "https://YOUR-NAME.web.val.run", + "provider": "valtown", + "url": "https://YOUR-NAME.web.val.run", "psk": "CHANGE_ME_TO_A_STRONG_SECRET", "mode": "full", "hosts": [ @@ -205,9 +211,11 @@ Browser -> Local Proxy -> Apps Script -> val.town -> Target website ``` Notes: +- For noob setup, only fill `provider`, `url`, and `psk`. +- Switch provider by changing `exit_node.provider` and `exit_node.url`. - `mode: "full"` = everything goes through exit node (ignore `hosts`). - `mode: "selective"` = only domains in `hosts` go through exit node. -- `psk` must be exactly the same as `PSK` in `valtown.ts`. +- `psk` must exactly match your deployed exit node secret. Production recommendation: - Keep `verify_ssl: true` @@ -306,7 +314,7 @@ By default, the proxy only listens on `127.0.0.1` (localhost), meaning only your ## Modes Overview -This project focuses entirely on the **Apps Script** relay — a free Google account is all you need, no server, no VPS, no Cloudflare setup. Everything is configured out of the box for this mode. +This project is centered on the **Apps Script** relay (free, no VPS needed). For destinations that block Google egress, you can optionally chain a free edge exit node (Val Town, Cloudflare Workers, or Deno Deploy). --- @@ -345,6 +353,8 @@ This project focuses entirely on the **Apps Script** relay — a free Google acc | `direct_google_exclude` | see [config.example.json](config.example.json) | Google apps that must use the MITM relay path instead of the fast direct tunnel. | | `hosts` | `{}` | Manual DNS override: map a hostname to a specific IP. | | `youtube_via_relay` | `false` | Route YouTube (`youtube.com`, `youtu.be`, `youtube-nocookie.com`) through the Apps Script relay instead of the SNI-rewrite path. The SNI-rewrite path uses Google's frontend IP which enforces SafeSearch and can cause **"Video Unavailable"** errors. Setting this to `true` fixes playback at the cost of using more Apps Script executions and slightly higher latency. | +| `exit_node.provider` | `valtown` | Selected exit-node backend: `valtown`, `cloudflare`, `deno`, or `custom`. | +| `exit_node.url` | `""` | Beginner-friendly single URL for the selected provider. | ### Optional Dependencies @@ -441,6 +451,52 @@ After scanning, update your `config.json` with the recommended IP and restart th --- +## CI/CD Releases (Hidden First Release) + +This repository includes a release workflow at `.github/workflows/release.yml`. + +Default behavior is **hidden for users**: +- Tag push (`v*`) creates a GitHub Release as **draft** + **prerelease** +- It is not marked as **Latest** + +That means you can run the first release via CI/CD, verify assets, and publish later. + +### Create first hidden release from GitHub Actions + +1. Push a tag: + ```bash + git tag v1.1.0 + git push origin v1.1.0 + ``` +2. Wait for the **Release** workflow to finish. +3. Open GitHub Releases and review the draft release artifacts. + +### Make it public later + +Run **Actions → Release → Run workflow** with: +- `publish = true` +- `release_tag = v1.1.0` +- `make_public = true` + +This will publish a non-draft, non-prerelease release and mark it as latest. + +### Extra targets (optional, non-blocking) + +`macos-13` runners can be heavily queued, which may leave `macos-x64` waiting for a long time. +To keep normal releases fast and reliable, default tag releases now build: +- `windows-x64` +- `linux-x64` +- `macos-arm64` + +If you want extra targets, run **Actions -> Release -> Run workflow** and enable one or more: +- `build_macos_x64 = true` (intel macOS) +- `build_linux_arm64 = true` (native ARM64 Linux runner) +- `build_termux_bundle = true` (Termux source package for arm64/armv7/x86_64 on-device install) + +These extra jobs are optional and non-blocking, so even if one is delayed or unavailable, your main release still completes. + +--- + ## Architecture ``` @@ -464,7 +520,10 @@ MasterHttpRelayVPN/ ├── config.example.json # Copy to config.json and fill in your values ├── requirements.txt # Python dependencies ├── apps_script/ -│ └── Code.gs # The relay script you deploy to Google Apps Script +│ ├── Code.gs # The relay script you deploy to Google Apps Script +│ ├── valtown.ts # Exit node template for val.town +│ ├── cloudflare_worker.js # Exit node template for Cloudflare Workers +│ └── deno_deploy.ts # Exit node template for Deno Deploy ├── ca/ # Generated MITM CA (do NOT share) │ ├── ca.crt │ └── ca.key diff --git a/README_FA.md b/README_FA.md index deaa4ea..f3461d4 100644 --- a/README_FA.md +++ b/README_FA.md @@ -137,21 +137,27 @@ cp config.example.json config.json برای حل این مورد، نود خروجی (exit node) را فعال کنید تا مسیر این‌گونه شود: ```text -مرورگر -> پراکسی محلی -> Apps Script -> val.town -> سایت مقصد +مرورگر -> پراکسی محلی -> Apps Script -> Exit Node (Val Town / Cloudflare / Deno) -> سایت مقصد ``` -1. فایل [apps_script/valtown.ts](apps_script/valtown.ts) را در val.town deploy کنید: - - یک val جدید بسازید - - محتوای فایل را paste کنید - - HTTP trigger را فعال کنید - - آدرس نهایی (`https://.web.val.run`) را کپی کنید -2. مقدار `PSK` داخل فایل val را با یک رمز قوی تغییر دهید. -3. در `config.json` این بخش را اضافه/تکمیل کنید: +می‌توانید یکی از این template های رایگان را deploy کنید: + +1. Val Town: [apps_script/valtown.ts](apps_script/valtown.ts) +2. Cloudflare Workers: [apps_script/cloudflare_worker.js](apps_script/cloudflare_worker.js) +3. Deno Deploy: [apps_script/deno_deploy.ts](apps_script/deno_deploy.ts) + +راهنمای کامل مرحله‌به‌مرحله برای هر provider: +- [EXIT_NODE_DEPLOYMENT.md](EXIT_NODE_DEPLOYMENT.md) + +سپس همان secret را هم در کد نود خروجی (`PSK`) و هم در `config.json` یکسان بگذارید. + +نمونه کانفیگ برای سوییچ بین provider ها: ```json "exit_node": { "enabled": true, - "relay_url": "https://YOUR-NAME.web.val.run", + "provider": "valtown", + "url": "https://YOUR-NAME.web.val.run", "psk": "CHANGE_ME_TO_A_STRONG_SECRET", "mode": "full", "hosts": [ @@ -164,9 +170,11 @@ cp config.example.json config.json ``` نکات: +- برای تنظیم ساده، فقط `provider`، `url` و `psk` را پر کنید. +- برای تغییر backend مقدار `exit_node.provider` و `exit_node.url` را عوض کنید. - `mode: "full"` یعنی همه ترافیک از exit node عبور می‌کند (`hosts` نادیده گرفته می‌شود). - `mode: "selective"` یعنی فقط دامنه‌های داخل `hosts` از exit node عبور می‌کنند. -- مقدار `psk` باید دقیقا با `PSK` در `valtown.ts` یکی باشد. +- مقدار `psk` باید دقیقا با secret تنظیم‌شده در runtime برابر باشد. ### مرحله 4: اجرا @@ -284,6 +292,8 @@ json | `bypass_hosts` | `["localhost", ".local", ".lan", ".home.arpa"]` | هاست‌هایی که مستقیم می‌روند (بدون MITM و بدون رله). برای منابع داخلی شبکه یا سایت‌هایی که با MITM مشکل دارند. | | `direct_google_exclude` | مراجعه به [config.example.json](config.example.json) | اپ‌های Google که باید از مسیر MITM برای رله استفاده کنند به‌جای tunnel مستقیم. | | `youtube_via_relay` | `false` | مسیردهی YouTube (`youtube.com`، `youtu.be`، `youtube-nocookie.com`) از طریق رله Apps Script به‌جای مسیر SNI-rewrite. مسیر SNI-rewrite از IP فرانت‌اند Google عبور می‌کند که SafeSearch را اجباری می‌کند و می‌تواند باعث خطای **«ویدیو در دسترس نیست»** شود. با فعال کردن این گزینه، پخش ویدیو درست می‌شود اما تعداد اجراهای Apps Script بیشتر و تأخیر اندکی بالاتر می‌رود. | +| `exit_node.provider` | `valtown` | backend انتخاب‌شده برای exit node: `valtown`، `cloudflare`، `deno` یا `custom`. | +| `exit_node.url` | `""` | آدرس ساده و اصلی برای provider انتخاب‌شده. | ### وابستگی‌های اختیاری @@ -398,7 +408,10 @@ MasterHttpRelayVPN/ ├── config.example.json # نمونه کانفیگ (به config.json کپی شود) ├── requirements.txt # وابستگی‌های اختیاری پایتون ├── apps_script/ -│ └── Code.gs # اسکریپت رله روی Google Apps Script +│ ├── Code.gs # اسکریپت رله روی Google Apps Script +│ ├── valtown.ts # template نود خروجی برای val.town +│ ├── cloudflare_worker.js # template نود خروجی برای Cloudflare Workers +│ └── deno_deploy.ts # template نود خروجی برای Deno Deploy ├── ca/ # گواهی MITM (هرگز به اشتراک نگذارید) │ ├── ca.crt │ └── ca.key diff --git a/apps_script/cloudflare_worker.ts b/apps_script/cloudflare_worker.js similarity index 74% rename from apps_script/cloudflare_worker.ts rename to apps_script/cloudflare_worker.js index 3549d10..27374dd 100644 --- a/apps_script/cloudflare_worker.ts +++ b/apps_script/cloudflare_worker.js @@ -19,23 +19,23 @@ const STRIP_HEADERS = new Set([ "via", ]); -function decodeBase64ToBytes(input: string): Uint8Array { +function decodeBase64ToBytes(input) { const bin = atob(input); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } -function encodeBytesToBase64(bytes: Uint8Array): string { +function encodeBytesToBase64(bytes) { let bin = ""; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin); } -function sanitizeHeaders(h: unknown): Record { - const out: Record = {}; +function sanitizeHeaders(h) { + const out = {}; if (!h || typeof h !== "object") return out; - for (const [k, v] of Object.entries(h as Record)) { + for (const [k, v] of Object.entries(h)) { if (!k) continue; if (STRIP_HEADERS.has(k.toLowerCase())) continue; out[k] = String(v ?? ""); @@ -44,7 +44,7 @@ function sanitizeHeaders(h: unknown): Record { } export default { - async fetch(req: Request): Promise { + async fetch(req) { try { if (req.method !== "POST") { return Response.json({ e: "method_not_allowed" }, { status: 405 }); @@ -59,28 +59,28 @@ export default { return Response.json({ e: "server_psk_missing" }, { status: 500 }); } - const k = String((body as any).k ?? ""); - const u = String((body as any).u ?? ""); - const m = String((body as any).m ?? "GET").toUpperCase(); - const h = sanitizeHeaders((body as any).h); - const b64 = (body as any).b; + const k = String(body.k ?? ""); + const u = String(body.u ?? ""); + const m = String(body.m ?? "GET").toUpperCase(); + const h = sanitizeHeaders(body.h); + const b64 = body.b; if (k !== PSK) return Response.json({ e: "unauthorized" }, { status: 401 }); if (!/^https?:\/\//i.test(u)) return Response.json({ e: "bad_url" }, { status: 400 }); - let payload: Uint8Array | undefined; + let payload; if (typeof b64 === "string" && b64.length > 0) payload = decodeBase64ToBytes(b64); const requestBody = payload ? Uint8Array.from(payload) : undefined; const resp = await fetch(u, { method: m, headers: h, - body: requestBody as unknown as BodyInit, + body: requestBody, redirect: "manual", }); const data = new Uint8Array(await resp.arrayBuffer()); - const respHeaders: Record = {}; + const respHeaders = {}; resp.headers.forEach((value, key) => { respHeaders[key] = value; }); diff --git a/config.example.json b/config.example.json index fd2acd6..3539dec 100644 --- a/config.example.json +++ b/config.example.json @@ -87,9 +87,13 @@ "hosts": {}, "exit_node": { "enabled": false, - "relay_url": "", + "provider": "cloudflare", + "url": "", "psk": "", - "mode": "selective", - "hosts": [] + "mode": "full", + "hosts": [ + "example.com", + "example.org" + ] } } diff --git a/src/domain_fronter.py b/src/domain_fronter.py index ff9e5b2..22ac09f 100644 --- a/src/domain_fronter.py +++ b/src/domain_fronter.py @@ -214,9 +214,17 @@ class DomainFronter: # Useful for sites that block GCP/Apps Script IPs (e.g. ChatGPT). en_cfg = config.get("exit_node") or {} self._exit_node_enabled: bool = bool(en_cfg.get("enabled", False)) - self._exit_node_url: str = str(en_cfg.get("relay_url") or "").rstrip("/") + self._exit_node_provider: str = self._normalize_exit_node_provider( + en_cfg.get("provider"), + ) + self._exit_node_url: str = self._resolve_exit_node_url( + self._exit_node_provider, + en_cfg, + ) self._exit_node_psk: str = str(en_cfg.get("psk") or "") self._exit_node_mode: str = str(en_cfg.get("mode") or "selective").lower() + if self._exit_node_mode not in ("full", "selective"): + self._exit_node_mode = "selective" self._exit_node_hosts: frozenset[str] = frozenset( str(h).lower().strip().lstrip(".") for h in (en_cfg.get("hosts") or []) @@ -224,8 +232,15 @@ class DomainFronter: ) if self._exit_node_enabled and self._exit_node_url: log.info( - "Exit node enabled [mode=%s]: %s", - self._exit_node_mode, self._exit_node_url, + "Exit node enabled [mode=%s, provider=%s]: %s", + self._exit_node_mode, + self._exit_node_provider, + self._exit_node_url, + ) + elif self._exit_node_enabled: + log.warning( + "Exit node is enabled but no URL is configured for provider '%s'", + self._exit_node_provider, ) # Capability log for content encodings. @@ -1107,6 +1122,62 @@ class DomainFronter: # ── Exit node relay ─────────────────────────────────────────── + @staticmethod + def _normalize_exit_node_provider(raw: object) -> str: + provider = str(raw or "custom").strip().lower() + aliases = { + "val": "valtown", + "val-town": "valtown", + "cloudflare_worker": "cloudflare", + "worker": "cloudflare", + "cf": "cloudflare", + "deno_deploy": "deno", + } + return aliases.get(provider, provider or "custom") + + @classmethod + def _resolve_exit_node_url(cls, provider: str, + en_cfg: dict[str, object]) -> str: + providers = en_cfg.get("providers") + if not isinstance(providers, dict): + providers = {} + + def _pick_from(mapping: dict[str, object], *keys: str) -> str: + for key in keys: + value = mapping.get(key) + if isinstance(value, str): + value = value.strip() + if value: + return value.rstrip("/") + return "" + + # Beginner-first: one URL field is enough for all providers. + direct = _pick_from(en_cfg, "url") + if direct: + return direct + + if provider == "valtown": + selected = _pick_from(en_cfg, "valtown_url", "val_url") or _pick_from( + providers, "valtown", "val_town", "val", + ) + elif provider == "cloudflare": + selected = _pick_from( + en_cfg, "cloudflare_url", "worker_url", "cf_url", + ) or _pick_from( + providers, "cloudflare", "cloudflare_worker", "worker", "cf", + ) + elif provider == "deno": + selected = _pick_from(en_cfg, "deno_url") or _pick_from( + providers, "deno", "deno_deploy", + ) + else: + selected = "" + + if selected: + return selected + # Backward compatibility for older config format. + return _pick_from(en_cfg, "relay_url") + def _exit_node_matches(self, url: str) -> bool: """Return True if this URL should be routed through the exit node.""" if not self._exit_node_enabled or not self._exit_node_url: @@ -1123,22 +1194,22 @@ class DomainFronter: return False async def _relay_via_exit_node(self, payload: dict) -> bytes: - """Chain: Apps Script → exit node (val.town) → Destination. + """Chain: Apps Script → edge relay (exit node) → Destination. Traffic path: Client → [domain fronting TLS] → Apps Script (Google) - → [UrlFetchApp.fetch] → exit node (val.town / non-Google IP) + → [UrlFetchApp.fetch] → exit node (non-Google IP) → [fetch()] → Destination This preserves the DPI bypass (Apps Script is always the outbound connection from the client's perspective) while giving the destination a non-Google exit IP — fixing Cloudflare Turnstile, ChatGPT, etc. - The inner payload going to val.town is base64-encoded and sent as the + The inner payload going to the exit node is base64-encoded and sent as the body of the outer Apps Script relay call, so Apps Script POSTs it to the exit node URL on our behalf. """ - # Build inner payload: what val.town will execute + # Build inner payload: what the exit node will execute inner = dict(payload) inner["k"] = self._exit_node_psk inner_json = json.dumps(inner).encode() @@ -1163,9 +1234,9 @@ class DomainFronter: # Send through the normal Apps Script relay path (H2 or H1 + retry) raw = await self._relay_with_retry(outer) - # raw is now the response from val.town (which is the inner relay JSON) + # raw is now the response from the exit node (inner relay JSON) # _parse_relay_response will decode it into the final HTTP response. - # But we need to unwrap one level: Apps Script gives us val.town's HTTP + # But we need to unwrap one level: Apps Script gives us exit node HTTP # response body (which is itself a relay JSON), so parse twice. _, _, apps_script_body = self._split_raw_response(raw) result = self._parse_relay_response(apps_script_body)