feat: v1.9.4 — exit node for ChatGPT/Claude/Grok + drop duplicate Telegram post

Two changes addressing user-reported issues today:

1. Exit-node feature ported from upstream masterking32@464a6e1d, with
   hardening. Cloudflare-protected sites (chatgpt.com, claude.ai,
   grok.com, x.com, openai.com) flag Google datacenter IPs as bots and
   return Turnstile / CAPTCHA / 502 challenges. Apps Script's UrlFetchApp
   exits from those IPs, so v1.9.3 surfaces these as "Relay error: json:
   key must be a string..." with no apps_script-mode workaround.

   Now a small TypeScript HTTP endpoint (assets/exit_node/valtown.ts)
   deployed on val.town / Deno Deploy sits between Apps Script and the
   destination. Chain: client → Apps Script (Google IP) → val.town
   (non-Google IP) → destination. Destination sees val.town's IP, no
   CF challenge.

   Config:
     "exit_node": {
       "enabled": true,
       "relay_url": "https://...web.val.run",
       "psk": "<openssl rand -hex 32>",
       "mode": "selective",
       "hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"]
     }

   Hardening over upstream: PSK fail-closed if still placeholder (fresh
   deploy can't be open relay), loop guard (refuses fetch of own host),
   explicit 503 on misconfigured. Fallback to direct Apps Script on exit
   node failure (CF-affected sites fail, others keep working). Setup
   docs in English + Persian at assets/exit_node/README*.md. Example
   config at config.exit-node.example.json.

2. Removed the legacy `telegram` job from release.yml. With
   TELEGRAM_NOTIFY_ENABLED repo var set to true, every release was
   producing two duplicate APK posts on the main Telegram channel: the
   old bundled-APK-on-main job AND the newer per-file files-channel
   posts (telegram-publish-files.yml). Only the per-file flow is wanted.
   Legacy job and its helper telegram_release_notify.py are gone.
   Recoverable from git log if anyone needs the bundled pattern back.

169 mhrv-rs lib tests + 33 tunnel-node tests + UI build clean.
This commit is contained in:
therealaleph
2026-05-01 11:52:32 +03:00
parent d65759d8b8
commit 4aac9a793f
12 changed files with 1070 additions and 494 deletions
+174
View File
@@ -0,0 +1,174 @@
# Exit node — دور زدن CF anti-bot برای ChatGPT / Claude / Grok / X
بسیاری از سرویس‌های پشت Cloudflare، traffic از رنج IP datacenter
گوگل را به‌عنوان bot flag می‌کنن + به‌جای صفحه واقعی یک Turnstile /
CAPTCHA / 502 challenge می‌فرستن. `UrlFetchApp.fetch()` در Apps
Script از همان رنج IP datacenter Google خروج می‌کنه، پس برای سایت‌هایی مانند:
- **chatgpt.com / openai.com**
- **claude.ai**
- **grok.com / x.com**
…مسیر apps_script-mode عادی mhrv-rs ارورهایی مثل
`Relay error: json: key must be a string at line 2 column 1` یا
`502 Relay error` می‌ده چون Code.gs در حال wrap کردن صفحه‌ی HTML
challenge CF است که کلاینت نمی‌تونه parse کنه.
**Exit node** یک endpoint کوچک TypeScript HTTP است که روی یک پلتفرم
serverless (val.town، Deno Deploy، fly.io، …) deploy می‌شه + بین Apps
Script و destination قرار می‌گیره. مسیر traffic این می‌شه:
```
Browser ─┐ ┌─→ Destination
│ │ (chatgpt.com)
▼ │
mhrv-rs │
│ │
│ TLS به Google IP، SNI=www.google.com (DPI cover)│
▼ │
Apps Script (Google datacenter) │
│ │
│ UrlFetchApp.fetch(EXIT_NODE_URL) │
▼ │
val.town (non-Google IP) │
│ │
│ fetch(real_url) │
└──────────────────────────────────────────────────┘
```
Destination IP val.town رو می‌بینه، نه Google datacenter. heuristic
anti-bot CF نمی‌سوزه + صفحه واقعی برمی‌گرده.
**نکته مهم:** leg user-side (Iran ISP → Apps Script) **بدون تغییر**
است. ISP فقط TLS به Google IP می‌بینه — second hop کاملاً درون
outbound Apps Script اجرا می‌شه، invisible از شبکه‌ی کاربر. پس DPI
evasion property که mhrv-rs براش ساخته شده، دست نمی‌خوره.
## راه‌اندازی
1. **در [val.town](https://val.town) ثبت‌نام کنید** (free tier کافی
است — bandwidth outbound free tier برای personal use کافی).
2. **یک HTTP val جدید بسازید** (TypeScript). در val.town: New → HTTP.
3. **محتوای `valtown.ts`** از این directory رو paste کنید.
4. **PSK رو در بالای فایل تنظیم کنید**:
```ts
const PSK = "<your-strong-secret>";
```
Strong secret تولید کنید با `openssl rand -hex 32` از terminal.
**placeholder رو در production نگذارید** — کد val.town عمداً
fail-closed است (در هر request 503 برمی‌گردونه) تا placeholder
replace نشده، تا جلوی serve شدن به‌عنوان open relay accidentally
گرفته بشه.
5. **Save** کنید val رو. URL public val رو copy کنید — به این شکل:
`https://your-handle-mhrv.web.val.run`.
6. **در `config.json` mhrv-rs**، block `exit_node` اضافه کنید:
```json
"exit_node": {
"enabled": true,
"relay_url": "https://your-handle-mhrv.web.val.run",
"psk": "<همان PSK که در گام 4 گذاشتید>",
"mode": "selective",
"hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"]
}
```
7. **mhrv-rs رو restart کنید** (Disconnect + Connect، یا `kill` +
restart binary).
8. **تست کنید** — `chatgpt.com` یا `grok.com` رو از browser pointed به
mhrv-rs proxy باز کنید. صفحه login واقعی رو می‌بینید، نه CF challenge.
config مثال کامل در
[`config.exit-node.example.json`](../../config.exit-node.example.json)
در root repo.
## انتخاب `selective` vs `full`
| Mode | چی می‌کنه | کی استفاده کنید |
|---|---|---|
| `selective` (default) | فقط hosts در `hosts` از طریق exit node می‌رن؛ بقیه از مسیر Apps Script عادی | توصیه می‌شه. exit-node hop ~۲۰۰-۵۰۰ms به هر request اضافه می‌کنه — برای سایت‌هایی reserve کنید که نیاز به non-Google IP دارن. |
| `full` | همه‌ی request‌ها از طریق exit node می‌رن | فقط زمانی که کل workload شما CF-anti-bot affected است، یا exit node خود از Apps Script سریع‌تر روی مسیر شبکه شما (rare). budget runtime val.town رو برای سایت‌هایی که نیاز ندارن می‌سوزونه. |
## رفتار در صورت failure
اگر exit node در دسترس نباشه، 5xx برمی‌گردونه، یا response malformed
بفرسته، mhrv-rs **به‌طور خودکار به Apps Script relay عادی fallback
می‌کنه**. در log یک خط `warn: exit node failed for ... — falling back
to direct Apps Script` می‌بینید. سایت‌هایی که نیاز به exit node دارن در آن
case fail می‌گیرن (CF challenge)، ولی سایر سایت‌ها کار می‌کنن — یک
exit node down شما رو fully offline نمی‌کنه.
## Security model
PSK تنها چیز است که مانع می‌شه val.town endpoint یک public open proxy
بشه. مثل password برخورد کنید:
- **commit نکنید** PSK رو به source control. منبع val.town به‌طور
default برای account شما private است؛ همان‌طور نگه دارید.
- **publicly share نکنید** PSK رو. هر کسی که هم URL هم PSK رو داره
می‌تونه quota val.town شما رو به‌عنوان proxy خود استفاده کنه.
- **rotate** اگر leak مشکوک هست. PSK رو در val.town source تغییر بدید،
save کنید، سپس `psk` در `config.json` mhrv-rs رو update + restart.
اسکریپت val.town شامل **loop guard** هم هست (refuse می‌کنه fetch host
خود) + **placeholder check** (در صورت `PSK === "CHANGE_ME_TO_A_STRONG_SECRET"`
return 503 می‌کنه) تا یک fresh deploy بدون setup نتونه به‌طور
accidentally به‌عنوان open relay سرو بشه.
## پلتفرم‌های جایگزین
اسکریپت `valtown.ts` plain TypeScript است که از web-standard APIs
(`Request`، `Response`، `fetch`) استفاده می‌کنه. اجرا می‌شه روی:
- **val.town** — ساده‌ترین، free tier کافی برای personal use
- **Deno Deploy** — API مشابه؛ deploy با `deployctl`
- **fly.io** — نیاز به `Dockerfile` wrapper؛ region geographic ثابت
- **Cloudflare Workers** — کمک نمی‌کنه (CF Workers از IP space خود CF
خروج می‌کنن، که CF anti-bot هنوز به‌عنوان worker-internal flag می‌کنه)
برای اکثر کاربران، val.town انتخاب درست است. Deno Deploy اگر option
non-val.town برای redundancy می‌خواید.
## چرا default-on نیست
- ۲۰۰-۵۰۰ms به هر request اضافه می‌کنه (hop اضافی)
- budget bandwidth free-tier val.town رو می‌سوزونه
- برای سایت‌هایی که CF anti-bot ندارن benefit نداره
- Setup یک account جداگانه روی پلتفرم third-party می‌خواد
پس `enabled: false` default است. کاربرانی که خصوصاً به ChatGPT / Claude /
Grok اهمیت می‌دن opt in؛ همه‌ی دیگران lighter اجرا می‌کنن.
## Troubleshooting
**`exit node refused or errored: unauthorized`** — PSK mismatch.
بررسی کنید `psk` در `config.json` دقیقاً با `PSK` constant در val.town
match هست. whitespace + quoting مهم است.
**`exit node refused or errored: exit_node misconfigured: PSK is still
the placeholder`** — فراموش کردید `CHANGE_ME_TO_A_STRONG_SECRET` رو
در val.town جایگزین کنید. val رو edit + save کنید.
**`exit node failed for ...: connection refused`** — URL val.town
اشتباه است یا val deploy نشده. با hit کردن URL مستقیم از browser
verify کنید — باید `{"e":"method_not_allowed"}` برگردونه (val expects
POST).
**`exit node failed for ...: timeout`** — outbound val.town slow است
یا destination slow. region val.town متفاوت رو امتحان کنید، یا latency
trade-off رو accept کنید.
**سایت همچنان CF challenge نشون می‌ده بعد از enable exit node** — CF
IP val.town رو هم flag می‌کنه. برخی customers CF صراحتاً val.town رو
blocklist کردن. workarounds: Deno Deploy رو امتحان کنید، یا سایت رو
به `passthrough_hosts` اضافه کنید (MITM رو bypass می‌کنه؛ از real
IP ISP شما استفاده می‌کنه).
## همچنین ببینید
- [English version](README.md) of this doc
- [`valtown.ts`](valtown.ts) — منبع val.town (با hardening)
- [`config.exit-node.example.json`](../../config.exit-node.example.json)
— config مثال کامل
- Issue [#382](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/382)
— thread tracking canonical Cloudflare anti-bot
- Issue [#309](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/309)
— roadmap CF WARP integration (approach جایگزین، longer-horizon)
+176
View File
@@ -0,0 +1,176 @@
# Exit node — bypass Cloudflare anti-bot for ChatGPT / Claude / Grok / X
Many Cloudflare-protected services flag traffic from Google datacenter
IP ranges as bots and serve a Turnstile / interactive CAPTCHA / 502
challenge instead of the actual page. Apps Script's `UrlFetchApp.fetch()`
exits from those Google datacenter IPs, so for sites like:
- **chatgpt.com / openai.com** (Cloudflare anti-bot, often blocks GCP IPs)
- **claude.ai** (same)
- **grok.com / x.com** (CF-fronted, returns 502 on Google IPs)
…the regular mhrv-rs apps_script-mode path returns errors like
`Relay error: json: key must be a string at line 2 column 1` or
`502 Relay error` because Code.gs is wrapping a CF challenge HTML
page that the client can't make sense of.
The **exit node** is a small TypeScript HTTP endpoint deployed on a
serverless platform (val.town, Deno Deploy, fly.io, etc.) that sits
between Apps Script and the destination. The traffic chain becomes:
```
Browser ─┐ ┌─→ Destination
│ │ (chatgpt.com)
▼ │
mhrv-rs │
│ │
│ TLS to Google IP, SNI=www.google.com (DPI cover) │
▼ │
Apps Script (Google datacenter) │
│ │
│ UrlFetchApp.fetch(EXIT_NODE_URL) │
▼ │
val.town (non-Google IP) │
│ │
│ fetch(real_url) │
└──────────────────────────────────────────────────────┘
```
The destination sees the val.town IP, not Google datacenter. CF's
anti-bot heuristic doesn't fire, and you get the actual page.
Crucially: **the user-side leg (Iran ISP → Apps Script) is unchanged.**
The ISP still only sees TLS to a Google IP — the second hop happens
entirely inside Apps Script's outbound, invisible from the user's
network. So the DPI evasion property mhrv-rs is built around stays
intact.
## Setup
1. **Sign up at [val.town](https://val.town)** (free tier is fine —
the free tier's outbound bandwidth is enough for personal use).
2. **Create a new HTTP val** (TypeScript). On val.town: New → HTTP.
3. **Paste the contents of `valtown.ts`** from this directory.
4. **Set the PSK** at the top of the file:
```ts
const PSK = "<your-strong-secret>";
```
Generate a strong secret with `openssl rand -hex 32` from a terminal.
**Don't leave the placeholder in production** — the val.town code
intentionally fails closed (returns 503 on every request) until
you replace the placeholder, so you can't accidentally serve as
an open relay.
5. **Save** the val. Copy the val's public URL — it looks like
`https://your-handle-mhrv.web.val.run`.
6. **In your mhrv-rs `config.json`**, add an `exit_node` block:
```json
"exit_node": {
"enabled": true,
"relay_url": "https://your-handle-mhrv.web.val.run",
"psk": "<the same PSK you set in step 4>",
"mode": "selective",
"hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"]
}
```
7. **Restart mhrv-rs** (Disconnect + Connect, or `kill` + restart the
binary).
8. **Test** — visit `chatgpt.com` or `grok.com` from a browser pointed
at the mhrv-rs proxy. You should see the actual login page now,
not a CF challenge.
A complete example config is at
[`config.exit-node.example.json`](../../config.exit-node.example.json)
in the repo root.
## How `selective` vs `full` mode pick
| Mode | What it does | When to use |
|---|---|---|
| `selective` (default) | Only hosts in `hosts` route via exit node; everything else takes the regular Apps Script path | Recommended. The exit-node hop adds ~200-500ms per request, so reserve it for sites that need a non-Google IP. |
| `full` | Every request routes via exit node | Only useful when your entire workload is CF-anti-bot affected, or when the exit node happens to be faster than Apps Script alone for your network path (rare). Burns val.town runtime budget for sites that don't need it. |
## Failure mode
If the exit node is unreachable, returns a 5xx, or returns a malformed
response, mhrv-rs **falls back to the regular Apps Script relay
automatically**. You'll see a `warn: exit node failed for ... — falling
back to direct Apps Script` line in the log. Sites that need the exit
node will fail in that case (CF challenge), but other sites work
normally — a down exit node doesn't take you fully offline.
## Security model
The PSK is the only thing keeping the val.town endpoint from being a
public open proxy. Treat it like a password:
- **Don't commit** the PSK to source control. The val.town source
is private to your account by default; keep it that way.
- **Don't share** the PSK publicly. Anyone who has both the URL and
the PSK can use your val.town quota as their own proxy.
- **Rotate** if you suspect leak. Change the PSK in val.town source,
save, then update `psk` in mhrv-rs `config.json` and restart.
The val.town script also includes a **loop guard** (refuses to fetch
its own host) and **placeholder check** (returns 503 if `PSK ===
"CHANGE_ME_TO_A_STRONG_SECRET"`) so a fresh deploy without setup can't
accidentally serve as an open relay.
## Alternative platforms
The `valtown.ts` script is plain TypeScript using web-standard APIs
(`Request`, `Response`, `fetch`). It runs on:
- **val.town** — easiest, free tier sufficient for personal use
- **Deno Deploy** — similar API; deploy with `deployctl`
- **fly.io** — needs a `Dockerfile` wrapper; gives you a fixed
geographic region
- **Cloudflare Workers** — won't help (CF Workers exit from CF's own
IP space, which CF anti-bot still flags as worker-internal)
For most users, val.town's the right choice. Deno Deploy if you want
a non-val.town option for redundancy.
## Why not always-on by default
- Adds 200-500ms per request (extra hop)
- Burns val.town's free-tier bandwidth budget
- Offers no benefit for sites that don't have CF anti-bot
- Setup requires a separate account on a third-party platform
So `enabled: false` is the default. Users who care about ChatGPT /
Claude / Grok specifically opt in; everyone else runs lighter.
## Troubleshooting
**`exit node refused or errored: unauthorized`** — PSK mismatch.
Check that the `psk` in `config.json` exactly matches the `PSK`
constant in val.town. Whitespace and quoting matter.
**`exit node refused or errored: exit_node misconfigured: PSK is still
the placeholder`** — you forgot to replace `CHANGE_ME_TO_A_STRONG_SECRET`
in val.town. Edit + save the val.
**`exit node failed for ...: connection refused`** — the val.town URL
is wrong or the val isn't deployed. Verify by hitting the URL directly
from a browser — it should return `{"e":"method_not_allowed"}` (val
expects POST).
**`exit node failed for ...: timeout`** — val.town outbound is slow
or the destination is slow. Try a different val.town deployment region,
or accept the latency trade-off.
**Site still shows CF challenge after enabling exit node** — CF is
flagging val.town's IP too. Some CF customers explicitly blocklist
val.town. Workarounds: try Deno Deploy instead, or add the site to
`passthrough_hosts` (bypasses MITM entirely; uses your real ISP IP).
## See also
- [Persian translation](README.fa.md) of this doc
- [`valtown.ts`](valtown.ts) — the val.town source (with hardening)
- [`config.exit-node.example.json`](../../config.exit-node.example.json)
— complete example config
- Issue [#382](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/382)
— canonical Cloudflare anti-bot tracking thread
- Issue [#309](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/309)
— CF WARP integration roadmap (alternative approach, longer-horizon)
+162
View File
@@ -0,0 +1,162 @@
// mhrv-rs exit node — deploy as an HTTP endpoint on val.town (or Deno
// Deploy, fly.io, anywhere with a public residential-adjacent IP).
//
// Purpose: chain client → Apps Script → this exit node → destination.
// Apps Script's UrlFetchApp can't reach Cloudflare-protected sites that
// flag Google datacenter IPs as bots (chatgpt.com, claude.ai, grok.x.ai,
// many other CF-fronted SaaS). This exit node sits between Apps Script
// and the destination; the destination sees the exit node's IP (val.town's
// outbound, generally not flagged as Google datacenter) and accepts the
// request.
//
// Setup:
// 1. Sign in to https://val.town and create a new HTTP val (TypeScript)
// 2. Paste the contents of this file
// 3. Set PSK below to a strong secret (use `openssl rand -hex 32`
// from a terminal — DO NOT leave the placeholder in production)
// 4. Save and copy the val's public URL (looks like
// https://your-handle-mhrv.web.val.run)
// 5. In mhrv-rs config.json, add:
// "exit_node": {
// "enabled": true,
// "relay_url": "https://your-handle-mhrv.web.val.run",
// "psk": "<the same PSK you set above>",
// "mode": "selective",
// "hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com"]
// }
//
// Threat model: PSK is the only thing keeping this from being an open
// proxy on the public internet. Treat it like a password: do not commit
// to source control, do not share publicly, rotate if leaked. The exit
// node refuses all requests that don't carry the matching PSK.
//
// Failure mode: if the exit node is unreachable, mhrv-rs falls back to
// the regular Apps Script relay automatically — the only consequence
// of an offline exit node is that ChatGPT/Claude/Grok stop working;
// other sites are unaffected.
const PSK = "CHANGE_ME_TO_A_STRONG_SECRET";
// Headers the client may send that must NOT be forwarded to the
// destination — they're hop-by-hop or would break re-encoding.
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> {
// Fail closed on the placeholder PSK so a fresh deploy without setup
// can't accidentally serve as an open relay.
if (PSK === "CHANGE_ME_TO_A_STRONG_SECRET") {
return Response.json(
{
e:
"exit_node misconfigured: PSK is still the placeholder. Set " +
"a strong secret in the val.town source before deploying.",
},
{ status: 503 },
);
}
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 });
}
// Loop guard: if u points at this exit node's own host, refuse.
// Without this, a misconfigured client could chain exit-node →
// exit-node → exit-node → ... and burn the val.town runtime budget.
try {
const reqUrl = new URL(req.url);
const dstUrl = new URL(u);
if (
reqUrl.host === dstUrl.host &&
reqUrl.protocol === dstUrl.protocol
) {
return Response.json({ e: "exit-node loop refused" }, { status: 400 });
}
} catch {
// Malformed URL — let the fetch below 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 });
}
}