feat: add exit node deployment guide and templates for Val Town, Cloudflare Workers, and Deno Deploy

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Abolfazl
2026-05-01 22:26:44 +03:30
parent 94e88c3805
commit ff419b1338
6 changed files with 307 additions and 49 deletions
+111
View File
@@ -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
+71 -12
View File
@@ -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: To fix that, configure an exit node so traffic path becomes:
```text ```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/): You can deploy any one of these free exit-node templates:
- Create a new val
- Paste file contents 1. Val Town: [`apps_script/valtown.ts`](apps_script/valtown.ts)
- Add HTTP trigger 2. Cloudflare Workers: [`apps_script/cloudflare_worker.js`](apps_script/cloudflare_worker.js)
- Copy your generated URL (`https://<name>.web.val.run`) 3. Deno Deploy: [`apps_script/deno_deploy.ts`](apps_script/deno_deploy.ts)
2. Set `PSK` inside the val code to a strong secret.
3. Add this block to your `config.json`: 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 ```json
"exit_node": { "exit_node": {
"enabled": true, "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", "psk": "CHANGE_ME_TO_A_STRONG_SECRET",
"mode": "full", "mode": "full",
"hosts": [ "hosts": [
@@ -205,9 +211,11 @@ Browser -> Local Proxy -> Apps Script -> val.town -> Target website
``` ```
Notes: 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: "full"` = everything goes through exit node (ignore `hosts`).
- `mode: "selective"` = only domains in `hosts` go through exit node. - `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: Production recommendation:
- Keep `verify_ssl: true` - 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 ## 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. | | `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. | | `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. | | `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 ### 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 ## Architecture
``` ```
@@ -464,7 +520,10 @@ MasterHttpRelayVPN/
├── config.example.json # Copy to config.json and fill in your values ├── config.example.json # Copy to config.json and fill in your values
├── requirements.txt # Python dependencies ├── requirements.txt # Python dependencies
├── apps_script/ ├── 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/ # Generated MITM CA (do NOT share)
│ ├── ca.crt │ ├── ca.crt
│ └── ca.key │ └── ca.key
+24 -11
View File
@@ -137,21 +137,27 @@ cp config.example.json config.json
برای حل این مورد، نود خروجی (exit node) را فعال کنید تا مسیر این‌گونه شود: برای حل این مورد، نود خروجی (exit node) را فعال کنید تا مسیر این‌گونه شود:
```text ```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 کنید: می‌توانید یکی از این template های رایگان را deploy کنید:
- یک val جدید بسازید
- محتوای فایل را paste کنید 1. Val Town: [apps_script/valtown.ts](apps_script/valtown.ts)
- HTTP trigger را فعال کنید 2. Cloudflare Workers: [apps_script/cloudflare_worker.js](apps_script/cloudflare_worker.js)
- آدرس نهایی (`https://<name>.web.val.run`) را کپی کنید 3. Deno Deploy: [apps_script/deno_deploy.ts](apps_script/deno_deploy.ts)
2. مقدار `PSK` داخل فایل val را با یک رمز قوی تغییر دهید.
3. در `config.json` این بخش را اضافه/تکمیل کنید: راهنمای کامل مرحله‌به‌مرحله برای هر provider:
- [EXIT_NODE_DEPLOYMENT.md](EXIT_NODE_DEPLOYMENT.md)
سپس همان secret را هم در کد نود خروجی (`PSK`) و هم در `config.json` یکسان بگذارید.
نمونه کانفیگ برای سوییچ بین provider ها:
```json ```json
"exit_node": { "exit_node": {
"enabled": true, "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", "psk": "CHANGE_ME_TO_A_STRONG_SECRET",
"mode": "full", "mode": "full",
"hosts": [ "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: "full"` یعنی همه ترافیک از exit node عبور می‌کند (`hosts` نادیده گرفته می‌شود).
- `mode: "selective"` یعنی فقط دامنه‌های داخل `hosts` از exit node عبور می‌کنند. - `mode: "selective"` یعنی فقط دامنه‌های داخل `hosts` از exit node عبور می‌کنند.
- مقدار `psk` باید دقیقا با `PSK` در `valtown.ts` یکی باشد. - مقدار `psk` باید دقیقا با secret تنظیم‌شده در runtime برابر باشد.
### مرحله 4: اجرا ### مرحله 4: اجرا
@@ -284,6 +292,8 @@ json
| `bypass_hosts` | `["localhost", ".local", ".lan", ".home.arpa"]` | هاست‌هایی که مستقیم می‌روند (بدون MITM و بدون رله). برای منابع داخلی شبکه یا سایت‌هایی که با MITM مشکل دارند. | | `bypass_hosts` | `["localhost", ".local", ".lan", ".home.arpa"]` | هاست‌هایی که مستقیم می‌روند (بدون MITM و بدون رله). برای منابع داخلی شبکه یا سایت‌هایی که با MITM مشکل دارند. |
| `direct_google_exclude` | مراجعه به [config.example.json](config.example.json) | اپ‌های Google که باید از مسیر MITM برای رله استفاده کنند به‌جای tunnel مستقیم. | | `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 بیشتر و تأخیر اندکی بالاتر می‌رود. | | `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 کپی شود) ├── config.example.json # نمونه کانفیگ (به config.json کپی شود)
├── requirements.txt # وابستگی‌های اختیاری پایتون ├── requirements.txt # وابستگی‌های اختیاری پایتون
├── apps_script/ ├── 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/ # گواهی MITM (هرگز به اشتراک نگذارید)
│ ├── ca.crt │ ├── ca.crt
│ └── ca.key │ └── ca.key
@@ -19,23 +19,23 @@ const STRIP_HEADERS = new Set([
"via", "via",
]); ]);
function decodeBase64ToBytes(input: string): Uint8Array { function decodeBase64ToBytes(input) {
const bin = atob(input); const bin = atob(input);
const out = new Uint8Array(bin.length); const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out; return out;
} }
function encodeBytesToBase64(bytes: Uint8Array): string { function encodeBytesToBase64(bytes) {
let bin = ""; let bin = "";
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin); return btoa(bin);
} }
function sanitizeHeaders(h: unknown): Record<string, string> { function sanitizeHeaders(h) {
const out: Record<string, string> = {}; const out = {};
if (!h || typeof h !== "object") return out; if (!h || typeof h !== "object") return out;
for (const [k, v] of Object.entries(h as Record<string, unknown>)) { for (const [k, v] of Object.entries(h)) {
if (!k) continue; if (!k) continue;
if (STRIP_HEADERS.has(k.toLowerCase())) continue; if (STRIP_HEADERS.has(k.toLowerCase())) continue;
out[k] = String(v ?? ""); out[k] = String(v ?? "");
@@ -44,7 +44,7 @@ function sanitizeHeaders(h: unknown): Record<string, string> {
} }
export default { export default {
async fetch(req: Request): Promise<Response> { async fetch(req) {
try { try {
if (req.method !== "POST") { if (req.method !== "POST") {
return Response.json({ e: "method_not_allowed" }, { status: 405 }); return Response.json({ e: "method_not_allowed" }, { status: 405 });
@@ -59,28 +59,28 @@ export default {
return Response.json({ e: "server_psk_missing" }, { status: 500 }); return Response.json({ e: "server_psk_missing" }, { status: 500 });
} }
const k = String((body as any).k ?? ""); const k = String(body.k ?? "");
const u = String((body as any).u ?? ""); const u = String(body.u ?? "");
const m = String((body as any).m ?? "GET").toUpperCase(); const m = String(body.m ?? "GET").toUpperCase();
const h = sanitizeHeaders((body as any).h); const h = sanitizeHeaders(body.h);
const b64 = (body as any).b; const b64 = body.b;
if (k !== PSK) return Response.json({ e: "unauthorized" }, { status: 401 }); if (k !== PSK) return Response.json({ e: "unauthorized" }, { status: 401 });
if (!/^https?:\/\//i.test(u)) return Response.json({ e: "bad_url" }, { status: 400 }); 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); if (typeof b64 === "string" && b64.length > 0) payload = decodeBase64ToBytes(b64);
const requestBody = payload ? Uint8Array.from(payload) : undefined; const requestBody = payload ? Uint8Array.from(payload) : undefined;
const resp = await fetch(u, { const resp = await fetch(u, {
method: m, method: m,
headers: h, headers: h,
body: requestBody as unknown as BodyInit, body: requestBody,
redirect: "manual", redirect: "manual",
}); });
const data = new Uint8Array(await resp.arrayBuffer()); const data = new Uint8Array(await resp.arrayBuffer());
const respHeaders: Record<string, string> = {}; const respHeaders = {};
resp.headers.forEach((value, key) => { resp.headers.forEach((value, key) => {
respHeaders[key] = value; respHeaders[key] = value;
}); });
+7 -3
View File
@@ -87,9 +87,13 @@
"hosts": {}, "hosts": {},
"exit_node": { "exit_node": {
"enabled": false, "enabled": false,
"relay_url": "", "provider": "cloudflare",
"url": "",
"psk": "", "psk": "",
"mode": "selective", "mode": "full",
"hosts": [] "hosts": [
"example.com",
"example.org"
]
} }
} }
+80 -9
View File
@@ -214,9 +214,17 @@ class DomainFronter:
# Useful for sites that block GCP/Apps Script IPs (e.g. ChatGPT). # Useful for sites that block GCP/Apps Script IPs (e.g. ChatGPT).
en_cfg = config.get("exit_node") or {} en_cfg = config.get("exit_node") or {}
self._exit_node_enabled: bool = bool(en_cfg.get("enabled", False)) 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_psk: str = str(en_cfg.get("psk") or "")
self._exit_node_mode: str = str(en_cfg.get("mode") or "selective").lower() 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( self._exit_node_hosts: frozenset[str] = frozenset(
str(h).lower().strip().lstrip(".") str(h).lower().strip().lstrip(".")
for h in (en_cfg.get("hosts") or []) for h in (en_cfg.get("hosts") or [])
@@ -224,8 +232,15 @@ class DomainFronter:
) )
if self._exit_node_enabled and self._exit_node_url: if self._exit_node_enabled and self._exit_node_url:
log.info( log.info(
"Exit node enabled [mode=%s]: %s", "Exit node enabled [mode=%s, provider=%s]: %s",
self._exit_node_mode, self._exit_node_url, 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. # Capability log for content encodings.
@@ -1107,6 +1122,62 @@ class DomainFronter:
# ── Exit node relay ─────────────────────────────────────────── # ── 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: def _exit_node_matches(self, url: str) -> bool:
"""Return True if this URL should be routed through the exit node.""" """Return True if this URL should be routed through the exit node."""
if not self._exit_node_enabled or not self._exit_node_url: if not self._exit_node_enabled or not self._exit_node_url:
@@ -1123,22 +1194,22 @@ class DomainFronter:
return False return False
async def _relay_via_exit_node(self, payload: dict) -> bytes: 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: Traffic path:
Client → [domain fronting TLS] → Apps Script (Google) 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 → [fetch()] → Destination
This preserves the DPI bypass (Apps Script is always the outbound This preserves the DPI bypass (Apps Script is always the outbound
connection from the client's perspective) while giving the destination connection from the client's perspective) while giving the destination
a non-Google exit IP — fixing Cloudflare Turnstile, ChatGPT, etc. 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 body of the outer Apps Script relay call, so Apps Script POSTs it to
the exit node URL on our behalf. 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 = dict(payload)
inner["k"] = self._exit_node_psk inner["k"] = self._exit_node_psk
inner_json = json.dumps(inner).encode() inner_json = json.dumps(inner).encode()
@@ -1163,9 +1234,9 @@ class DomainFronter:
# Send through the normal Apps Script relay path (H2 or H1 + retry) # Send through the normal Apps Script relay path (H2 or H1 + retry)
raw = await self._relay_with_retry(outer) 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. # _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. # response body (which is itself a relay JSON), so parse twice.
_, _, apps_script_body = self._split_raw_response(raw) _, _, apps_script_body = self._split_raw_response(raw)
result = self._parse_relay_response(apps_script_body) result = self._parse_relay_response(apps_script_body)