feat: add optional exit node support for full-tunnel relay to bypass IP blocking

This commit is contained in:
Abolfazl
2026-05-01 07:17:38 +03:30
parent 4ae0d115c5
commit 464a6e1dd0
5 changed files with 284 additions and 1 deletions
+43
View File
@@ -172,6 +172,49 @@ It'll prompt for your Deployment ID, generate a random `auth_key`, and write
- `script_id` → Paste the Deployment ID from Step 2. - `script_id` → Paste the Deployment ID from Step 2.
- `auth_key` → The **same password** you set in `Code.gs`. - `auth_key` → The **same password** you set in `Code.gs`.
### Step 3.5: Optional Exit Node for Full-Tunnel (ChatGPT/Turnstile Friendly)
Some websites block Google datacenter IPs when traffic exits directly from Apps Script.
To fix that, configure an exit node so traffic path becomes:
```text
Browser -> Local Proxy -> Apps Script -> val.town -> 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`:
```json
"exit_node": {
"enabled": true,
"relay_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"
]
}
```
Notes:
- `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`.
Production recommendation:
- Keep `verify_ssl: true`
- Keep `listen_host: 127.0.0.1` unless LAN sharing is explicitly needed
- Rotate both secrets periodically
- Never publish your live val URL with valid PSK
### Step 4: Run ### Step 4: Run
```bash ```bash
+37
View File
@@ -131,6 +131,43 @@ cp config.example.json config.json
- `script_id` : همان Deployment ID مرحله 2 - `script_id` : همان Deployment ID مرحله 2
- `auth_key` : همان رمزی که در `Code.gs` گذاشته‌اید - `auth_key` : همان رمزی که در `Code.gs` گذاشته‌اید
### مرحله 3.5: نود خروجی اختیاری برای Full Tunnel
برخی سایت‌ها (مثل ChatGPT) خروجی مستقیم از IPهای دیتاسنتر Google را مسدود می‌کنند.
برای حل این مورد، نود خروجی (exit node) را فعال کنید تا مسیر این‌گونه شود:
```text
مرورگر -> پراکسی محلی -> Apps Script -> val.town -> سایت مقصد
```
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` این بخش را اضافه/تکمیل کنید:
```json
"exit_node": {
"enabled": true,
"relay_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"
]
}
```
نکات:
- `mode: "full"` یعنی همه ترافیک از exit node عبور می‌کند (`hosts` نادیده گرفته می‌شود).
- `mode: "selective"` یعنی فقط دامنه‌های داخل `hosts` از exit node عبور می‌کنند.
- مقدار `psk` باید دقیقا با `PSK` در `valtown.ts` یکی باشد.
### مرحله 4: اجرا ### مرحله 4: اجرا
```bash ```bash
+91
View File
@@ -0,0 +1,91 @@
// MasterHttpRelay exit node for val.town
// Deploy as HTTP endpoint in val.town and set PSK to a strong secret.
const PSK = "CHANGE_ME_TO_A_STRONG_SECRET";
const STRIP_HEADERS = new Set([
"host",
"connection",
"content-length",
"transfer-encoding",
"proxy-connection",
"proxy-authorization",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-proto",
"x-forwarded-port",
"x-real-ip",
"forwarded",
"via",
]);
function decodeBase64ToBytes(input: string): Uint8Array {
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 {
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> = {};
if (!h || typeof h !== "object") return out;
for (const [k, v] of Object.entries(h as Record<string, unknown>)) {
if (!k) continue;
if (STRIP_HEADERS.has(k.toLowerCase())) continue;
out[k] = String(v ?? "");
}
return out;
}
export default async function(req: Request): Promise<Response> {
try {
if (req.method !== "POST") {
return Response.json({ e: "method_not_allowed" }, { status: 405 });
}
const body = await req.json();
if (!body || typeof body !== "object") {
return Response.json({ e: "bad_json" }, { status: 400 });
}
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;
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;
if (typeof b64 === "string" && b64.length > 0) payload = decodeBase64ToBytes(b64);
const resp = await fetch(u, {
method: m,
headers: h,
body: payload,
redirect: "manual",
});
const data = new Uint8Array(await resp.arrayBuffer());
const respHeaders: Record<string, string> = {};
resp.headers.forEach((value, key) => {
respHeaders[key] = value;
});
return Response.json({
s: resp.status,
h: respHeaders,
b: encodeBytesToBase64(data),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return Response.json({ e: message }, { status: 500 });
}
}
+8 -1
View File
@@ -84,5 +84,12 @@
"safebrowsing.google.com" "safebrowsing.google.com"
], ],
"youtube_via_relay": false, "youtube_via_relay": false,
"hosts": {} "hosts": {},
"exit_node": {
"enabled": false,
"relay_url": "",
"psk": "",
"mode": "selective",
"hosts": []
}
} }
+105
View File
@@ -210,6 +210,24 @@ class DomainFronter:
log.info("Fan-out relay: %d parallel Apps Script instances per request", log.info("Fan-out relay: %d parallel Apps Script instances per request",
self._parallel_relay) self._parallel_relay)
# Exit node — optional second-hop relay with a non-Google exit IP.
# 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_psk: str = str(en_cfg.get("psk") or "")
self._exit_node_mode: str = str(en_cfg.get("mode") or "selective").lower()
self._exit_node_hosts: frozenset[str] = frozenset(
str(h).lower().strip().lstrip(".")
for h in (en_cfg.get("hosts") or [])
if h
)
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,
)
# Capability log for content encodings. # Capability log for content encodings.
log.info("Response codecs: %s", codec.supported_encodings()) log.info("Response codecs: %s", codec.supported_encodings())
@@ -1087,6 +1105,73 @@ class DomainFronter:
def _auth_header(self) -> str: def _auth_header(self) -> str:
return f"X-Auth-Key: {self.auth_key}\r\n" if self.auth_key else "" return f"X-Auth-Key: {self.auth_key}\r\n" if self.auth_key else ""
# ── Exit node relay ───────────────────────────────────────────
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:
return False
if self._exit_node_mode == "full":
return True
# selective: check if destination hostname matches configured list
host = self._host_key(url)
if not host:
return False
for pattern in self._exit_node_hosts:
if host == pattern or host.endswith("." + pattern):
return True
return False
async def _relay_via_exit_node(self, payload: dict) -> bytes:
"""Chain: Apps Script → exit node (val.town) → Destination.
Traffic path:
Client → [domain fronting TLS] → Apps Script (Google)
→ [UrlFetchApp.fetch] → exit node (val.town / 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
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
inner = dict(payload)
inner["k"] = self._exit_node_psk
inner_json = json.dumps(inner).encode()
# Build outer payload: what Apps Script will fetch
# Apps Script does: UrlFetchApp.fetch(exit_node_url, { method: "POST", payload: inner_json })
outer = self._build_payload(
"POST",
self._exit_node_url,
{"Content-Type": "application/json"},
inner_json,
)
# Override content-type explicitly so Apps Script sets it correctly
outer["ct"] = "application/json"
log.debug(
"Exit node chain: Apps Script → %s%s",
self._exit_node_url.split("//", 1)[-1][:50],
payload.get("u", "")[:60],
)
# 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)
# _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
# 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)
log.debug("Exit node relay OK: %s", payload.get("u", "")[:80])
return result
# ── Apps Script relay (apps_script mode) ────────────────────── # ── Apps Script relay (apps_script mode) ──────────────────────
async def relay(self, method: str, url: str, async def relay(self, method: str, url: str,
@@ -1107,6 +1192,26 @@ class DomainFronter:
payload = self._build_payload(method, url, headers, body) payload = self._build_payload(method, url, headers, body)
# Exit node short-circuit: route to non-Google IP before Apps Script
if self._exit_node_matches(url):
t0 = time.perf_counter()
errored = False
try:
return await asyncio.wait_for(
self._relay_via_exit_node(payload),
timeout=self._relay_timeout + self._tls_connect_timeout,
)
except Exception as exc:
errored = True
log.warning(
"Exit node failed for %s (%s: %s), falling back to Apps Script",
url[:60], type(exc).__name__, exc,
)
finally:
latency_ns = int((time.perf_counter() - t0) * 1e9)
self._record_site(url, 0, latency_ns, errored)
# fall through to normal Apps Script relay on failure
t0 = time.perf_counter() t0 = time.perf_counter()
errored = False errored = False
result: bytes = b"" result: bytes = b""