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.
|
- `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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
"safebrowsing.google.com"
|
||||||
],
|
],
|
||||||
"youtube_via_relay": false,
|
"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",
|
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""
|
||||||
|
|||||||
Reference in New Issue
Block a user