mirror of
https://github.com/masterking32/MasterHttpRelayVPN.git
synced 2026-05-17 21:24:37 +03:00
feat: add optional exit node support for full-tunnel relay to bypass IP blocking
This commit is contained in:
@@ -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.
|
||||
- `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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -131,6 +131,43 @@ cp config.example.json config.json
|
||||
- `script_id` : همان Deployment ID مرحله 2
|
||||
- `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: اجرا
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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
@@ -84,5 +84,12 @@
|
||||
"safebrowsing.google.com"
|
||||
],
|
||||
"youtube_via_relay": false,
|
||||
"hosts": {}
|
||||
"hosts": {},
|
||||
"exit_node": {
|
||||
"enabled": false,
|
||||
"relay_url": "",
|
||||
"psk": "",
|
||||
"mode": "selective",
|
||||
"hosts": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +210,24 @@ class DomainFronter:
|
||||
log.info("Fan-out relay: %d parallel Apps Script instances per request",
|
||||
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.
|
||||
log.info("Response codecs: %s", codec.supported_encodings())
|
||||
|
||||
@@ -1087,6 +1105,73 @@ class DomainFronter:
|
||||
def _auth_header(self) -> str:
|
||||
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) ──────────────────────
|
||||
|
||||
async def relay(self, method: str, url: str,
|
||||
@@ -1107,6 +1192,26 @@ class DomainFronter:
|
||||
|
||||
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()
|
||||
errored = False
|
||||
result: bytes = b""
|
||||
|
||||
Reference in New Issue
Block a user