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:
```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://<name>.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
+24 -11
View File
@@ -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://<name>.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
@@ -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<string, string> {
const out: Record<string, string> = {};
function sanitizeHeaders(h) {
const 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 (STRIP_HEADERS.has(k.toLowerCase())) continue;
out[k] = String(v ?? "");
@@ -44,7 +44,7 @@ function sanitizeHeaders(h: unknown): Record<string, string> {
}
export default {
async fetch(req: Request): Promise<Response> {
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<string, string> = {};
const respHeaders = {};
resp.headers.forEach((value, key) => {
respHeaders[key] = value;
});
+7 -3
View File
@@ -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"
]
}
}
+80 -9
View File
@@ -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)