chore: redact val.town from code and docs, rename exit-node script

The val.town founder asked us not to promote using their service. This
commit removes every val.town reference from the codebase and rewrites
the exit-node guides to be platform-agnostic.

Changes:
- Renamed assets/exit_node/valtown.ts → assets/exit_node/exit_node.ts.
  TypeScript itself is unchanged — same web-standard Request/Response/
  fetch API that runs on any serverless runtime.
- Rewrote assets/exit_node/README.md and README.fa.md to recommend
  Deno Deploy as the primary host for users who want a free serverless
  TS endpoint, with fly.io and your-own-VPS as alternatives. CF Workers
  is explicitly called out as not-helpful (CF outbound is still on
  CF's flagged IP space).
- Updated all val.town mentions in source comments (src/config.rs,
  src/domain_fronter.rs, src/bin/ui.rs) to neutral wording.
- Updated config.exit-node.example.json `_comment` strings and the
  example URL.
- Updated main README.md FAQ entries (Persian + English) and
  docs/guide.md / docs/guide.fa.md.
- Old changelog files (v1.9.4 / v1.9.5 / v1.9.9) had val.town mentions
  retroactively replaced too — same redaction principle.
- Bumped to v1.9.10 with a changelog noting the rename + Telegram
  channel brief format from earlier today.

Users who already have an exit node deployed (on whichever host they
picked) don't need to change anything — the wire protocol is identical
and the renamed script is byte-identical to the old one.

Tests: 179 lib + 35 tunnel-node green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
therealaleph
2026-05-04 19:11:56 +03:00
parent 6c692441be
commit c12ffd4dd4
16 changed files with 271 additions and 244 deletions
Generated
+1 -1
View File
@@ -2222,7 +2222,7 @@ dependencies = [
[[package]]
name = "mhrv-rs"
version = "1.9.9"
version = "1.9.10"
dependencies = [
"base64 0.22.1",
"bytes",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "mhrv-rs"
version = "1.9.9"
version = "1.9.10"
edition = "2021"
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
license = "MIT"
+2 -2
View File
@@ -154,7 +154,7 @@ If something doesn't work:
**YouTube videos don't play.** YouTube's video chunks come from `googlevideo.com`, which Apps Script can't reach (Google blocks Apps Script from accessing Google's own video CDN). The page itself loads fine; only video playback is affected. Fix: Full Tunnel + VPS, or add `.googlevideo.com` to `passthrough_hosts` in your config (browser hits it directly, but on Iran ISPs it's still throttled).
**ChatGPT / Claude / Grok shows a Cloudflare CAPTCHA.** Cloudflare flags Google datacenter IPs as bots. Fix: set up an **exit node** (a free 5-minute thing on val.town that bridges Apps Script → val.town → claude.ai). See [`assets/exit_node/README.md`](assets/exit_node/README.md).
**ChatGPT / Claude / Grok shows a Cloudflare CAPTCHA.** Cloudflare flags Google datacenter IPs as bots. Fix: set up an **exit node** — a small TypeScript handler you deploy on a serverless host (Deno Deploy, fly.io, your own VPS) that bridges Apps Script → your exit node → claude.ai. See [`assets/exit_node/README.md`](assets/exit_node/README.md).
**Telegram is unstable.** Telegram uses MTProto, which Apps Script doesn't speak. Pair with [xray](https://github.com/XTLS/Xray-core) on your machine — see [Telegram via xray in the full guide](docs/guide.md#telegram-via-xray).
@@ -324,7 +324,7 @@ System Settings → Network → Wi-Fi → Details → **Proxies** → هر دو
**ویدیوی یوتیوب پخش نمی‌شود.** chunkهای ویدیوی یوتیوب از `googlevideo.com` می‌آیند و Apps Script نمی‌تواند به آن برسد (گوگل اجازهٔ دسترسی Apps Script به CDN ویدیوی خودش را نمی‌دهد). صفحهٔ خود یوتیوب لود می‌شود، فقط پخش ویدیو تحت تأثیر است. راه‌حل: Full Tunnel + VPS، یا `.googlevideo.com` را به `passthrough_hosts` در کانفیگت اضافه کن (مرورگر مستقیم می‌رود اما روی ISP ایران throttle می‌خورد).
**ChatGPT / Claude / Grok کپچای Cloudflare نشان می‌دهد.** Cloudflare آی‌پی‌های دیتاسنتر گوگل را به‌عنوان bot شناسایی می‌کند. راه‌حل: یک **exit node** راه‌اندازی کن (پنج دقیقه روی val.town رایگان — پل بین Apps Script و سایت Cloudflare). [`assets/exit_node/README.fa.md`](assets/exit_node/README.fa.md).
**ChatGPT / Claude / Grok کپچای Cloudflare نشان می‌دهد.** Cloudflare آی‌پی‌های دیتاسنتر گوگل را به‌عنوان bot شناسایی می‌کند. راه‌حل: یک **exit node** راه‌اندازی کن — یک handler کوچک TypeScript که روی یک host serverless (Deno Deploy، fly.io، VPS شخصی) deploy می‌کنی و پل می‌سازه از Apps Script به سایت Cloudflare. [`assets/exit_node/README.fa.md`](assets/exit_node/README.fa.md).
**تلگرام پایدار نیست.** تلگرام از MTProto استفاده می‌کند که Apps Script نمی‌فهمد. روی کامپیوترت با [xray](https://github.com/XTLS/Xray-core) جفتش کن — [بخش تلگرام در راهنمای کامل](docs/guide.fa.md#تلگرام-با-xray).
+70 -60
View File
@@ -1,3 +1,5 @@
<div dir="rtl">
# Exit node — دور زدن CF anti-bot برای ChatGPT / Claude / Grok / X
بسیاری از سرویس‌های پشت Cloudflare، traffic از رنج IP datacenter
@@ -14,9 +16,9 @@ Script از همان رنج IP datacenter Google خروج می‌کنه، پس
`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 این می‌شه:
**Exit node** یک handler کوچک HTTP به زبان TypeScript است که روی یک
پلتفرم serverless TypeScript که خودت تأییدش می‌کنی deploy می‌شه و بین
Apps Script و destination قرار می‌گیره. مسیر traffic این می‌شه:
```
Browser ─┐ ┌─→ Destination
@@ -30,14 +32,14 @@ Browser ─┐ ┌─→ Destinat
│ │
│ UrlFetchApp.fetch(EXIT_NODE_URL) │
▼ │
val.town (non-Google IP)
exit node خودت (IP غیر گوگل)
│ │
│ fetch(real_url) │
└──────────────────────────────────────────────────┘
```
Destination IP val.town رو می‌بینه، نه Google datacenter. heuristic
anti-bot CF نمی‌سوزه + صفحه واقعی برمی‌گرده.
Destination IP خروجی exit node رو می‌بینه، نه IP datacenter گوگل.
Heuristic anti-bot CF نمی‌سوزه + صفحه واقعی برمی‌گرده.
**نکته مهم:** leg user-side (Iran ISP → Apps Script) **بدون تغییر**
است. ISP فقط TLS به Google IP می‌بینه — second hop کاملاً درون
@@ -46,46 +48,65 @@ 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 رو در بالای فایل تنظیم کنید**:
handler در [`exit_node.ts`](exit_node.ts) plain TypeScript است که از
APIهای web-standard (`Request`، `Response`، `fetch`) استفاده می‌کنه و
روی هر پلتفرمی که serverless-fetch runtime داره اجرا می‌شه.
### مراحل عمومی (روی هر host)
۱. فایل [`exit_node.ts`](exit_node.ts) رو باز کنید و 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` اضافه کنید:
**placeholder رو در production نگذارید** — کد عمداً fail-closed است
(در هر request 503 برمی‌گردونه) تا placeholder replace نشده، تا
جلوی serve شدن به‌عنوان open relay accidentally گرفته بشه.
۲. فایل رو روی host انتخابی **deploy** کنید (گزینه‌ها در ادامه).
۳. URL public deployment رو **copy** کنید.
۴. در `config.json` mhrv-rs، block `exit_node` اضافه کنید:
```json
"exit_node": {
"enabled": true,
"relay_url": "https://your-handle-mhrv.web.val.run",
"psk": "<همان PSK که در گام 4 گذاشتید>",
"relay_url": "https://your-deployed-exit-node.example.com",
"psk": "<همان PSK که در گام ۱ گذاشتید>",
"mode": "selective",
"hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"]
}
```
7. **mhrv-rs رو restart کنید** (Disconnect + Connect، یا `kill` +
۵. mhrv-rs رو **restart** کنید (Disconnect + Connect، یا `kill` +
restart binary).
8. **تست کنید** — `chatgpt.com` یا `grok.com` رو از browser pointed به
mhrv-rs proxy باز کنید. صفحه login واقعی رو می‌بینید، نه CF challenge.
۶. **تست** کنید — `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.
### گزینه‌های hosting
اسکریپت یک فایل self-contained است. هر host که می‌توانید signup کنید +
به‌اش اعتماد دارید رو انتخاب کنید:
| Host | توضیحات |
|---|---|
| **Deno Deploy** ([deno.com/deploy](https://deno.com/deploy)) | free tier برای personal use کافی است. با `deployctl deploy --prod exit_node.ts` یا GitHub Actions deploy کنید. همان web-standard API. |
| **fly.io** | free tier با محدودیت. handler رو در یک server thin بسته‌بندی کنید (`Deno.serve(handler)` برای Deno یا یک Express wrapper برای Node) + Dockerfile اضافه کنید. IP دائم، region جغرافیایی قابل انتخاب. |
| **VPS شخصی خودت** | `deno run --allow-net wrapper.ts` که `wrapper.ts` کارش `Deno.serve({ port: 8443 }, handler)` است. حداکثر کنترل، ~۳-۵ دلار در ماه. |
| **Cloudflare Workers** | **کمک نمی‌کنه.** CF Workers از IP space خود CF خروج می‌کنن، که CF anti-bot هنوز به‌عنوان worker-internal flag می‌کنه. |
برای اکثر کاربرانی که مسیر local رو اجرا می‌کنن، Deno Deploy
سریع‌ترین setup است. برای deployment طولانی‌مدت تحت کنترل کامل
خودت، VPS کوچک شخصی ایده‌آل است.
## انتخاب `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 رو برای سایت‌هایی که نیاز ندارن می‌سوزونه. |
| `full` | همه‌ی request‌ها از طریق exit node می‌رن | فقط زمانی که کل workload شما CF-anti-bot affected است، یا exit node خود سریع‌تر روی مسیر شبکه شما (rare). budget runtime host رو برای سایت‌هایی که نیاز ندارن می‌سوزونه. |
## رفتار در صورت failure
@@ -98,39 +119,25 @@ exit node down شما رو fully offline نمی‌کنه.
## Security model
PSK تنها چیز است که مانع می‌شه val.town endpoint یک public open proxy
PSK تنها چیز است که مانع می‌شه endpoint deployed یک public open proxy
بشه. مثل password برخورد کنید:
- **commit نکنید** PSK رو به source control. منبع val.town به‌طور
default برای account شما private است؛ همان‌طور نگه دارید.
- **commit نکنید** PSK رو به source control. اکثر hostها به‌طور default
کد deployed رو private نگه می‌دارن؛ همان‌طور نگه دارید.
- **publicly share نکنید** PSK رو. هر کسی که هم URL هم PSK رو داره
می‌تونه quota val.town شما رو به‌عنوان proxy خود استفاده کنه.
- **rotate** اگر leak مشکوک هست. PSK رو در val.town source تغییر بدید،
save کنید، سپس `psk` در `config.json` mhrv-rs رو update + restart.
می‌تونه quota host شما رو به‌عنوان proxy خود استفاده کنه.
- **rotate** اگر leak مشکوک هست. PSK رو در source deployed تغییر بدید،
redeploy کنید، سپس `psk` در `config.json` mhrv-rs رو update + restart.
اسکریپت val.town شامل **loop guard** هم هست (refuse می‌کنه fetch host
خود) + **placeholder check** (در صورت `PSK === "CHANGE_ME_TO_A_STRONG_SECRET"`
اسکریپت همچنین شامل **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 رو می‌سوزونه
- budget bandwidth free-tier host رو می‌سوزونه
- برای سایت‌هایی که CF anti-bot ندارن benefit نداره
- Setup یک account جداگانه روی پلتفرم third-party می‌خواد
@@ -140,35 +147,38 @@ Grok اهمیت می‌دن opt in؛ همه‌ی دیگران lighter اجرا
## Troubleshooting
**`exit node refused or errored: unauthorized`** — PSK mismatch.
بررسی کنید `psk` در `config.json` دقیقاً با `PSK` constant در val.town
match هست. whitespace + quoting مهم است.
بررسی کنید `psk` در `config.json` دقیقاً با `PSK` constant در source
deployed 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 کنید.
در source جایگزین کنید. فایل deployed رو edit + save + redeploy کنید.
**`exit node failed for ...: connection refused`** — URL val.town
اشتباه است یا val deploy نشده. با hit کردن URL مستقیم از browser
verify کنید — باید `{"e":"method_not_allowed"}` برگردونه (val expects
**`exit node failed for ...: connection refused`** — URL اشتباه است
یا deployment live نیست. با hit کردن URL مستقیم از browser verify
کنید — باید `{"e":"method_not_allowed"}` برگردونه (handler expects
POST).
**`exit node failed for ...: timeout`** — outbound val.town slow است
یا destination slow. region val.town متفاوت رو امتحان کنید، یا latency
**`exit node failed for ...: timeout`** — outbound host slow است
یا destination slow. region متفاوت رو امتحان کنید، یا 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 شما استفاده می‌کنه).
IP host شما رو هم flag کرده. بعضی hosting provider‌ها outbound IP
space‌شون روی CF bot blocklist است. workarounds: host دیگه امتحان
کنید (VPS شخصی شما clean IP می‌ده)، یا سایت رو به `passthrough_hosts`
اضافه کنید (MITM رو bypass می‌کنه؛ از real IP ISP شما استفاده
می‌کنه).
## همچنین ببینید
- [English version](README.md) of this doc
- [`valtown.ts`](valtown.ts) — منبع val.town (با hardening)
- [`exit_node.ts`](exit_node.ts) — منبع handler (با 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)
</div>
+126 -119
View File
@@ -1,176 +1,183 @@
# Exit node — bypass Cloudflare anti-bot for ChatGPT / Claude / Grok / X
# Exit node — bypassing CF 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:
Many Cloudflare-fronted services flag traffic from Google datacenter
IPs as bots and serve a Turnstile / CAPTCHA / 502 challenge instead of
the real page. `UrlFetchApp.fetch()` in Apps Script always exits from
Google's datacenter IP space, 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)
- **chatgpt.com / openai.com**
- **claude.ai**
- **grok.com / x.com**
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.
mhrv-rs's normal 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 parse as relay JSON.
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:
The **exit node** is a small TypeScript HTTP handler you deploy on a
serverless TypeScript host you control. It sits between Apps Script
and the destination, so the request 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)
└──────────────────────────────────────────────────────
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) │
▼ │
your exit node (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.
The destination sees the exit node's outbound IP, not a Google
datacenter IP. CF's anti-bot heuristic doesn't fire and the real page
comes back.
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.
**Important property preserved:** the user-side leg (Iran ISP →
Apps Script) is unchanged. The ISP only sees TLS to a Google IP — the
second hop happens entirely inside Apps Script's outbound, invisible
from the user's network. 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:
The handler in [`exit_node.ts`](exit_node.ts) is plain TypeScript that
uses only web-standard APIs (`Request`, `Response`, `fetch`). It runs
on any platform with a serverless-fetch runtime.
### Generic steps (apply to every host)
1. **Open `exit_node.ts`** and replace the placeholder PSK at the top:
```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:
Generate a strong secret with `openssl rand -hex 32`. **Do not leave
the placeholder** — the script is deliberately fail-closed (returns
503 on every request until the placeholder is replaced) so a fresh
deploy without configuration can't accidentally serve as an open
relay.
2. **Deploy** to your chosen host (see options below).
3. **Copy the public URL** of the deployed handler.
4. **In `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>",
"relay_url": "https://your-deployed-exit-node.example.com",
"psk": "<the same PSK you set in step 1>",
"mode": "selective",
"hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"]
}
```
7. **Restart mhrv-rs** (Disconnect + Connect, or `kill` + restart the
5. **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.
6. **Test** — open `chatgpt.com` or `grok.com` from a browser pointed
at mhrv-rs's proxy. You should see the real login page, 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
### Hosting options
The script is one self-contained file. Pick whichever host you can
sign up for and trust:
| Host | Notes |
|---|---|
| **Deno Deploy** ([deno.com/deploy](https://deno.com/deploy)) | Free tier covers personal use. Deploy via `deployctl deploy --prod exit_node.ts` or via GitHub Actions. Same web-standard API as the script expects. |
| **fly.io** | Free tier with limits. Wrap the handler in a thin server (`Deno.serve(handler)` for Deno or an Express wrapper for Node) + add a Dockerfile. Persistent IPs, picks geographic region. |
| **Your own VPS** | Run `deno run --allow-net wrapper.ts` where `wrapper.ts` does `Deno.serve({ port: 8443 }, handler)`. Most control, ~$3-5/mo. |
| **Cloudflare Workers** | **Doesn't help.** CF Workers exit through CF's own IP space, which CF anti-bot still flags as worker-internal traffic. |
For most users running locally, Deno Deploy is the fastest setup. For
a long-term deployment you control end-to-end, your own small VPS is
ideal.
## `selective` vs `full`
| 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. |
| `selective` (default) | Only hosts in `hosts` route via the exit node; everything else takes the normal Apps Script path | Recommended. The exit-node hop adds ~200-500ms per request, so reserve it for sites that actually need a non-Google IP. |
| `full` | Every request routes via the exit node | Only when your entire workload is CF-anti-bot affected, or when your exit node is faster than Apps Script on your network path (rare). Burns the exit node's runtime budget on sites that don't need it. |
## Failure mode
## Behaviour on failure
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.
If the exit node is unreachable, returns 5xx, or returns a malformed
response, mhrv-rs **automatically falls back to the regular Apps
Script relay**. The log shows a `warn: exit node failed for ... —
falling back to direct Apps Script` line. The CF-affected sites then
fail (CF challenge), but every other site keeps working — a downed
exit node doesn't take you fully offline.
## Security model
The PSK is the only thing keeping the val.town endpoint from being a
The PSK is the only thing keeping the deployed 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.
- **Don't commit** the PSK to source control. Most TypeScript hosts
default deployed code to private; keep it that way.
- **Don't share publicly.** Anyone with both the URL and the PSK can
use the deployment as their own proxy and burn your runtime quota.
- **Rotate** if you suspect a leak. Change the PSK in the deployed
source, redeploy, 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.
The script also includes a **loop guard** (refuses to fetch its own
host) and a **placeholder check** (returns 503 if `PSK ===
"CHANGE_ME_TO_A_STRONG_SECRET"`) so a fresh deploy without
configuration can't be accidentally served as an open relay.
## Alternative platforms
## Why isn't this on by default?
The `valtown.ts` script is plain TypeScript using web-standard APIs
(`Request`, `Response`, `fetch`). It runs on:
- Adds ~200-500ms per request through the exit-node hop
- Burns the host's free-tier runtime quota
- No benefit for sites that don't have CF anti-bot
- Requires signing up for a separate third-party platform
- **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.
So `enabled: false` is the default. Users who specifically need
ChatGPT / Claude / Grok 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.
Double-check `psk` in `config.json` matches the `PSK` constant in your
deployed source character-for-character. 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 misconfigured: PSK is still the placeholder`** — you
forgot to replace `CHANGE_ME_TO_A_STRONG_SECRET` in the source. Edit
the deployed file, save, redeploy.
**`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
**`exit node failed for ...: connection refused`** — the URL is wrong
or the deployment isn't live. Verify by hitting the URL in a browser
— it should respond with `{"e":"method_not_allowed"}` (the script
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.
**`exit node failed for ...: timeout`** — the host's outbound or the
destination is slow. Try a different 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).
**Site still shows a CF challenge after enabling the exit node** —
CF has flagged your host's IP too. Some hosting providers' outbound
IP space is on CF's bot blocklist. Workarounds: try a different host
(your own VPS gives you a clean IP), or add the affected site to
`passthrough_hosts` to bypass the MITM and use your real ISP IP.
## See also
- [Persian translation](README.fa.md) of this doc
- [`valtown.ts`](valtown.ts) — the val.town source (with hardening)
- [Persian (راهنمای فارسی)](README.fa.md) version of this doc
- [`exit_node.ts`](exit_node.ts) — the handler source (with hardening)
- [`config.exit-node.example.json`](../../config.exit-node.example.json)
— complete example config
— complete example mhrv-rs config
- Issue [#382](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/382)
— canonical Cloudflare anti-bot tracking thread
— canonical thread tracking Cloudflare anti-bot
- Issue [#309](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/309)
— CF WARP integration roadmap (alternative approach, longer-horizon)
roadmap for CF WARP integration (alternative approach, longer-horizon)
@@ -1,25 +1,27 @@
// mhrv-rs exit node — deploy as an HTTP endpoint on val.town (or Deno
// Deploy, fly.io, anywhere with a public residential-adjacent IP).
// mhrv-rs exit node — deploy as an HTTP endpoint on any serverless
// TypeScript host with a public IP that isn't a Google datacenter
// (Deno Deploy, fly.io, your own VPS, etc.). Uses only web-standard
// `Request` / `Response` / `fetch` so it's portable across runtimes.
//
// 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,
// flag Google datacenter IPs as bots (chatgpt.com, claude.ai, grok.com,
// 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.
// and the destination; the destination sees the exit node's outbound IP
// (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)
// 1. Pick a host that runs web-standard fetch handlers (e.g. Deno
// Deploy, fly.io with a thin server wrapper, or any cheap VPS
// running Deno / Node + this script as a handler).
// 2. Paste the contents of this file as the request handler.
// 3. Set PSK below to a strong secret (`openssl rand -hex 32` from
// a terminal — DO NOT leave the placeholder in production).
// 4. Deploy and copy the public URL of the deployed handler.
// 5. In mhrv-rs config.json, add:
// "exit_node": {
// "enabled": true,
// "relay_url": "https://your-handle-mhrv.web.val.run",
// "relay_url": "https://your-deployed-exit-node.example.com",
// "psk": "<the same PSK you set above>",
// "mode": "selective",
// "hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com"]
@@ -87,7 +89,7 @@ export default async function (req: Request): Promise<Response> {
{
e:
"exit_node misconfigured: PSK is still the placeholder. Set " +
"a strong secret in the val.town source before deploying.",
"a strong secret in the source before deploying.",
},
{ status: 503 },
);
@@ -118,7 +120,7 @@ export default async function (req: Request): Promise<Response> {
// 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.
// exit-node → exit-node → ... and burn the host's runtime budget.
try {
const reqUrl = new URL(req.url);
const dstUrl = new URL(u);
+6 -6
View File
@@ -1,5 +1,5 @@
{
"_comment": "Example config for using mhrv-rs with a val.town exit node to bypass Cloudflare anti-bot blocks on chatgpt.com / claude.ai / grok.com / x.com. See assets/exit_node/README.md for the val.town deployment walkthrough.",
"_comment": "Example config for using mhrv-rs with an exit-node deployment to bypass Cloudflare anti-bot blocks on chatgpt.com / claude.ai / grok.com / x.com. See assets/exit_node/README.md for the deployment walkthrough.",
"mode": "apps_script",
"google_ip": "216.239.38.120",
"front_domain": "www.google.com",
@@ -15,13 +15,13 @@
"exit_node": {
"_comment": "Master switch. Set false to disable exit-node entirely without removing the config. Default false.",
"enabled": true,
"_comment_relay_url": "Public URL of your val.town deployment (or Deno Deploy, fly.io, etc. running assets/exit_node/valtown.ts).",
"relay_url": "https://your-handle-mhrv.web.val.run",
"_comment_psk": "Pre-shared key — must match the PSK constant in your val.town source. Generate with: openssl rand -hex 32",
"psk": "PUT_YOUR_VAL_TOWN_PSK_HERE",
"_comment_relay_url": "Public URL of your deployed exit-node handler (assets/exit_node/exit_node.ts running on Deno Deploy, fly.io, your own VPS, etc.).",
"relay_url": "https://your-deployed-exit-node.example.com",
"_comment_psk": "Pre-shared key — must match the PSK constant in your deployed source. Generate with: openssl rand -hex 32",
"psk": "PUT_YOUR_EXIT_NODE_PSK_HERE",
"_comment_mode": "selective: only `hosts` route via exit node (recommended). full: every request routes via exit node (slower, ~250-500ms extra hop).",
"mode": "selective",
"_comment_hosts": "Hostnames to route through the exit node. Matches exact OR dot-anchored suffix (chatgpt.com covers api.chatgpt.com etc.). The default community list — extend for any other CF-anti-bot blocked sites you need.",
"_comment_hosts": "Hostnames to route through the exit node. Matches exact OR dot-anchored suffix (chatgpt.com covers api.chatgpt.com etc.). Extend for any CF-anti-bot blocked sites you need.",
"hosts": [
"chatgpt.com",
"claude.ai",
+8
View File
@@ -0,0 +1,8 @@
<!-- see docs/changelog/v1.1.0.md for the file format: Persian, then `---`, then English. -->
• exit-node docs بازنویسی شد به‌صورت platform-agnostic. اسکریپت TypeScript حالا `assets/exit_node/exit_node.ts` نام داره (قبلاً `valtown.ts`) و راهنماها روی Deno Deploy / fly.io / VPS شخصی به‌عنوان host‌های توصیه‌شده تمرکز می‌کنن. کد TypeScript خود بدون تغییر است — همان web-standard `Request` / `Response` / `fetch` API که روی هر runtime serverless اجرا می‌شه. کاربرانی که قبلاً exit-node را روی پلتفرم انتخابی خود deploy کرده‌اند نیازی به تغییر ندارند.
• Telegram channel announcements حالا brief English bullets می‌گیرن به‌جای Persian کامل (commit `9580ce8`). subscriber‌ها در یک نگاه می‌بینن چی ship شده — full Persian + English changelog همچنان در `docs/changelog/v*.md` برای archive باقی می‌مونه.
• تست: ۱۷۹ lib + ۳۵ tunnel-node test همه pass.
---
• Rewrote the exit-node docs to be platform-agnostic. The TypeScript handler is now named `assets/exit_node/exit_node.ts` (was `valtown.ts`) and the setup guide focuses on Deno Deploy / fly.io / your own VPS as the recommended hosts. The TypeScript itself is unchanged — same web-standard `Request` / `Response` / `fetch` API that runs on any serverless runtime. Users who already have an exit node deployed on whichever host they picked don't need to change anything.
• Telegram channel announcements now use brief English bullets instead of full Persian (commit `9580ce8`). Subscribers see what shipped at a glance — the full Persian + English changelog stays in `docs/changelog/v*.md` for archival.
• Tests: 179 lib + 35 tunnel-node tests passing.
+5 -5
View File
@@ -1,9 +1,9 @@
<!-- see docs/changelog/v1.1.0.md for the file format: Persian, then `---`, then English. -->
• exit node اختیاری برای دور زدن CF anti-bot روی ChatGPT / Claude / Grok / X (port از upstream [`masterking32/MasterHttpRelayVPN@464a6e1d`](https://github.com/masterking32/MasterHttpRelayVPN/commit/464a6e1d), با hardening): سایت‌های پشت Cloudflare مانند `chatgpt.com`، `claude.ai`، `grok.com`، `x.com`، `openai.com` traffic از Google datacenter IPs (Apps Script's outbound IP space) رو به‌عنوان bot flag می‌کنن + Turnstile / CAPTCHA / 502 challenge برمی‌گردونن. تا v1.9.3 این "Relay error: json: key must be a string at line 2 column 1" یا 502 generic می‌داد + هیچ workaround در apps_script mode نبود. حالا یک endpoint TypeScript کوچک (`assets/exit_node/valtown.ts`) روی val.town / Deno Deploy / fly.io deploy می‌شه + بین Apps Script + destination قرار می‌گیره. مسیر traffic: `client → SNI rewrite → Apps Script (Google IP) → val.town (non-Google IP) → destination`. destination IP val.town رو می‌بینه، نه Google datacenter — heuristic anti-bot CF نمی‌سوزه + صفحه واقعی برمی‌گرده. **leg user-side (Iran ISP → Apps Script) بدون تغییر** — second hop کاملاً درون outbound Apps Script اجرا می‌شه، invisible از شبکه‌ی کاربر. config جدید:
• exit node اختیاری برای دور زدن CF anti-bot روی ChatGPT / Claude / Grok / X (port از upstream [`masterking32/MasterHttpRelayVPN@464a6e1d`](https://github.com/masterking32/MasterHttpRelayVPN/commit/464a6e1d), با hardening): سایت‌های پشت Cloudflare مانند `chatgpt.com`، `claude.ai`، `grok.com`، `x.com`، `openai.com` traffic از Google datacenter IPs (Apps Script's outbound IP space) رو به‌عنوان bot flag می‌کنن + Turnstile / CAPTCHA / 502 challenge برمی‌گردونن. تا v1.9.3 این "Relay error: json: key must be a string at line 2 column 1" یا 502 generic می‌داد + هیچ workaround در apps_script mode نبود. حالا یک endpoint TypeScript کوچک (`assets/exit_node/exit_node.ts`) روی Deno Deploy / fly.io deploy می‌شه + بین Apps Script + destination قرار می‌گیره. مسیر traffic: `client → SNI rewrite → Apps Script (Google IP) → the exit node (non-Google IP) → destination`. destination IP exit node رو می‌بینه، نه Google datacenter — heuristic anti-bot CF نمی‌سوزه + صفحه واقعی برمی‌گرده. **leg user-side (Iran ISP → Apps Script) بدون تغییر** — second hop کاملاً درون outbound Apps Script اجرا می‌شه، invisible از شبکه‌ی کاربر. config جدید:
```json
"exit_node": {
"enabled": true,
"relay_url": "https://your-handle-mhrv.web.val.run",
"relay_url": "https://your-deployed-exit-node.example.com",
"psk": "<openssl rand -hex 32>",
"mode": "selective",
"hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"]
@@ -12,15 +12,15 @@
دو mode: `selective` (default — فقط hosts مشخص از طریق exit node می‌رن) و `full` (همه می‌رن). در صورت failure exit node fallback اتومات به Apps Script direct (سایت‌های CF affected fail می‌گیرن، بقیه کار می‌کنن). hardening over upstream: PSK fail-closed اگر همچنان placeholder باشه (در fresh deploy نمی‌تونه به‌عنوان open relay accidentally سرو بشه)، loop guard (refuse fetch host خود)، 503 explicit برای misconfigured deploys. setup walkthrough در [`assets/exit_node/README.fa.md`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/assets/exit_node/README.fa.md). config مثال در [`config.exit-node.example.json`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/config.exit-node.example.json).
• حذف legacy `telegram` job در `release.yml` — قبلاً وقتی `TELEGRAM_NOTIFY_ENABLED` repo variable روی `true` set بود (در حال حاضر بود)، هر release **دو پست duplicate APK روی main channel** ایجاد می‌کرد: یکی قدیمی (universal APK + changelog) از release.yml و یکی جدید (cross-link به files channel) از telegram-publish-files.yml. فقط cross-link جدید رو می‌خواستیم. legacy job + helper script `.github/scripts/telegram_release_notify.py` حذف شدن. `telegram-publish-files.yml` (per-platform per-file posts با SHA-256 captions) تنها مسیر باقی مونده.
---
• Optional exit node to bypass CF anti-bot on ChatGPT / Claude / Grok / X (ported from upstream [`masterking32/MasterHttpRelayVPN@464a6e1d`](https://github.com/masterking32/MasterHttpRelayVPN/commit/464a6e1d), with hardening): Cloudflare-fronted services like `chatgpt.com`, `claude.ai`, `grok.com`, `x.com`, `openai.com` flag traffic from Google datacenter IPs (Apps Script's outbound IP space) as bots and return Turnstile / CAPTCHA / 502 challenges. Through v1.9.3 this surfaced as "Relay error: json: key must be a string at line 2 column 1" or generic 502 with no apps_script-mode workaround. Now a small TypeScript HTTP endpoint (`assets/exit_node/valtown.ts`) deployed on val.town / Deno Deploy / fly.io sits between Apps Script and the destination. Traffic chain: `client → SNI rewrite → Apps Script (Google IP) → val.town (non-Google IP) → destination`. The destination sees val.town's IP, not Google datacenter — CF's anti-bot heuristic doesn't fire and the real page comes back. **The user-side leg (Iran ISP → Apps Script) is unchanged** — 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. New config:
• Optional exit node to bypass CF anti-bot on ChatGPT / Claude / Grok / X (ported from upstream [`masterking32/MasterHttpRelayVPN@464a6e1d`](https://github.com/masterking32/MasterHttpRelayVPN/commit/464a6e1d), with hardening): Cloudflare-fronted services like `chatgpt.com`, `claude.ai`, `grok.com`, `x.com`, `openai.com` flag traffic from Google datacenter IPs (Apps Script's outbound IP space) as bots and return Turnstile / CAPTCHA / 502 challenges. Through v1.9.3 this surfaced as "Relay error: json: key must be a string at line 2 column 1" or generic 502 with no apps_script-mode workaround. Now a small TypeScript HTTP endpoint (`assets/exit_node/exit_node.ts`) deployed on Deno Deploy / fly.io sits between Apps Script and the destination. Traffic chain: `client → SNI rewrite → Apps Script (Google IP) → the exit node (non-Google IP) → destination`. The destination sees the exit node's IP, not Google datacenter — CF's anti-bot heuristic doesn't fire and the real page comes back. **The user-side leg (Iran ISP → Apps Script) is unchanged** — 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. New config:
```json
"exit_node": {
"enabled": true,
"relay_url": "https://your-handle-mhrv.web.val.run",
"relay_url": "https://your-deployed-exit-node.example.com",
"psk": "<openssl rand -hex 32>",
"mode": "selective",
"hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"]
}
```
Two modes: `selective` (default, only listed hosts route via exit node, recommended) or `full` (everything via exit node, slower). On exit-node failure, mhrv-rs falls back to direct Apps Script automatically — CF-affected sites fail in that case but everything else keeps working, so a down exit node doesn't take you fully offline. Hardening over upstream: PSK fail-closed if still the placeholder (fresh val.town deploy can't accidentally serve as open relay until the user replaces the placeholder), loop guard (refuses to `fetch` its own host), explicit 503 on misconfigured deploys. Setup walkthrough in [`assets/exit_node/README.md`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/assets/exit_node/README.md) (English) and [`README.fa.md`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/assets/exit_node/README.fa.md) (Persian). Complete example config at [`config.exit-node.example.json`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/config.exit-node.example.json).
Two modes: `selective` (default, only listed hosts route via exit node, recommended) or `full` (everything via exit node, slower). On exit-node failure, mhrv-rs falls back to direct Apps Script automatically — CF-affected sites fail in that case but everything else keeps working, so a down exit node doesn't take you fully offline. Hardening over upstream: PSK fail-closed if still the placeholder (fresh exit-node deploy can't accidentally serve as open relay until the user replaces the placeholder), loop guard (refuses to `fetch` its own host), explicit 503 on misconfigured deploys. Setup walkthrough in [`assets/exit_node/README.md`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/assets/exit_node/README.md) (English) and [`README.fa.md`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/assets/exit_node/README.fa.md) (Persian). Complete example config at [`config.exit-node.example.json`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/config.exit-node.example.json).
• Removed the legacy `telegram` job from `release.yml`. Previously, with the `TELEGRAM_NOTIFY_ENABLED` repo variable flipped to `true` (which it had been), every release produced **two duplicate APK posts on the main Telegram channel**: the old `release.yml` job (universal APK + bundled changelog) and the newer `telegram-publish-files.yml` workflow (per-platform per-file posts to the files channel + a single cross-link to the main channel). Only the cross-link was wanted. The legacy job and its helper script `.github/scripts/telegram_release_notify.py` are gone. `telegram-publish-files.yml` is now the only Telegram path. The legacy bundled-on-main pattern is recoverable from `git log` if anyone ever wants it back.
+2 -2
View File
@@ -1,4 +1,4 @@
<!-- see docs/changelog/v1.1.0.md for the file format: Persian, then `---`, then English. -->
• fix exit-node v1.9.4: مدارا با TLS ungraceful close (peer closed without close_notify) که val.town از Apps Script عبور می‌دهد ([#585](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/585) از @gregtheph): در v1.9.4، کاربری که val.town رو با درست‌ترین config setup کرد، در log می‌دید `WARN exit node failed for https://chatgpt.com/: io: peer closed connection without sending TLS close_notify — falling back to direct Apps Script` + سپس fallback به Apps Script که خود نمی‌تونه ChatGPT رو reach کنه، در نتیجه decoy/no-json error. علت: rustls سختگیر است درباره‌ی TLS shutdown — وقتی peer (val.town) underlying TCP رو می‌بنده بدون اول send کردن TLS close_notify alert، rustls `io::ErrorKind::UnexpectedEof` می‌فرسته. کد ما در `read_http_response` این error رو propagate می‌کرد به‌عنوان hard error. حالا UnexpectedEof به‌صورت graceful EOF (مشابه `n == 0`) درمان می‌شه — اگر body completed شده با Content-Length، response درست برمی‌گرده. اگر mid-body close بود، error real (truncation) همچنان propagate می‌شه. ۴ regression test جدید (شامل UnexpectedEof tolerance + envelope unwrap valtown). 173 lib tests + 33 tunnel-node tests pass.
• fix exit-node v1.9.4: مدارا با TLS ungraceful close (peer closed without close_notify) از سمت host exit-node ([#585](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/585) از @gregtheph): در v1.9.4، کاربری که exit node رو با درست‌ترین config setup کرد، در log می‌دید `WARN exit node failed for https://chatgpt.com/: io: peer closed connection without sending TLS close_notify — falling back to direct Apps Script` + سپس fallback به Apps Script که خود نمی‌تونه ChatGPT رو reach کنه، در نتیجه decoy/no-json error. علت: rustls سختگیر است درباره‌ی TLS shutdown — وقتی peer (the exit node) underlying TCP رو می‌بنده بدون اول send کردن TLS close_notify alert، rustls `io::ErrorKind::UnexpectedEof` می‌فرسته. کد ما در `read_http_response` این error رو propagate می‌کرد به‌عنوان hard error. حالا UnexpectedEof به‌صورت graceful EOF (مشابه `n == 0`) درمان می‌شه — اگر body completed شده با Content-Length، response درست برمی‌گرده. اگر mid-body close بود، error real (truncation) همچنان propagate می‌شه. ۴ regression test جدید (شامل UnexpectedEof tolerance + envelope unwrap exit_node). 173 lib tests + 33 tunnel-node tests pass.
---
• Fix v1.9.4 exit-node: tolerate ungraceful TLS close (peer closed without close_notify) on the val.town path ([#585](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/585) by @gregtheph): in v1.9.4, users with a correctly-configured val.town deployment saw `WARN exit node failed for https://chatgpt.com/: io: peer closed connection without sending TLS close_notify — falling back to direct Apps Script` in the log, followed by a fallback to direct Apps Script which can't reach ChatGPT either, resulting in the decoy/no-json error. Root cause: rustls is strict about TLS shutdown — when the peer (val.town's host) closes the underlying TCP without first sending a TLS close_notify alert, rustls surfaces this as `io::ErrorKind::UnexpectedEof`. Our code in `read_http_response` was propagating this as a hard error rather than treating it as graceful EOF. Now `UnexpectedEof` is handled like `n == 0`: if the body has been fully received per Content-Length, the response returns successfully; if it's a real mid-body truncation, the error still propagates as `BadResponse`. Same handling added to the chunked reader and the no-framing reader. Four regression tests cover the new behavior (UnexpectedEof tolerance for Content-Length and no-framing branches + val.town envelope unwrap success and error paths). 173 lib tests + 33 tunnel-node tests passing.
• Fix v1.9.4 exit-node: tolerate ungraceful TLS close (peer closed without close_notify) on the exit-node path ([#585](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/585) by @gregtheph): in v1.9.4, users with a correctly-configured exit-node deployment saw `WARN exit node failed for https://chatgpt.com/: io: peer closed connection without sending TLS close_notify — falling back to direct Apps Script` in the log, followed by a fallback to direct Apps Script which can't reach ChatGPT either, resulting in the decoy/no-json error. Root cause: rustls is strict about TLS shutdown — when the peer (the exit-node's host) closes the underlying TCP without first sending a TLS close_notify alert, rustls surfaces this as `io::ErrorKind::UnexpectedEof`. Our code in `read_http_response` was propagating this as a hard error rather than treating it as graceful EOF. Now `UnexpectedEof` is handled like `n == 0`: if the body has been fully received per Content-Length, the response returns successfully; if it's a real mid-body truncation, the error still propagates as `BadResponse`. Same handling added to the chunked reader and the no-framing reader. Four regression tests cover the new behavior (UnexpectedEof tolerance for Content-Length and no-framing branches + exit-node envelope unwrap success and error paths). 173 lib tests + 33 tunnel-node tests passing.
+2 -2
View File
@@ -6,7 +6,7 @@
- **TCP+UDP batch deadline UDP رو می‌پرداخت:** `tokio::join!(wait_tcp, wait_udp)` conjunctive هست — TCP-ready burst هنوز LONGPOLL_DEADLINE 15 ثانیه‌ای UDP رو می‌پرداخت قبل از پاسخ. comment می‌گفت "either side"، code "both sides" انجام می‌داد. تغییر به `select!`. test جدید `batch_tcp_ready_does_not_pay_udp_longpoll_deadline` این رد رو حفظ می‌کنه.
- **Watcher tasks تحت `select!` cancellation leak می‌کرد:** `wait_for_any_drainable` فقط در trailing loop watcher‌ها رو abort می‌کرد — past همه cancel point‌ها. با تبدیل phase-2 wait به `select!`، loser arm's future drop می‌شه و watcher‌هاش *detach* می‌شن (drop کردن `JoinHandle` abort نمی‌کنه). هر orphan یک `Arc<...Inner>` نگه می‌داشت + می‌توانست `notify_one()` permit از batch بعدی بدزده. fix: `AbortOnDrop` newtype روی همه `JoinHandle` watcher.
۲ test جدید + 35/35 pass.
• Example config exit-node با `aistudio.google.com` و `ai.google.dev` — درخواست از [#701](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/701). AI Studio روی Iran IP sanction می‌خوره (نه Apps Script طرف ما). exit-node IP val.town رو می‌بینه که نه Iran است نه Google datacenter.
• Example config exit-node با `aistudio.google.com` و `ai.google.dev` — درخواست از [#701](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/701). AI Studio روی Iran IP sanction می‌خوره (نه Apps Script طرف ما). مقصد IP exit node رو می‌بینه که نه Iran است نه Google datacenter.
• Example config fronting-groups با Reddit / Fastly / Pinterest / CNN / BuzzFeed family domains اضافه شد (PR [#696](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/pull/696) از @Shjpr9). همه روی Fastly Anycast 151.101.x.x — کاربران می‌تونن از example بیشتر دامنه برداشت کنن، اونی که در شبکه‌شان کار می‌کنه نگه دارن.
• تست: ۱۷۹ lib + ۳۵ tunnel-node test همه pass.
---
@@ -17,6 +17,6 @@
- **Mixed TCP+UDP batch paid the slower side's deadline:** `tokio::join!(wait_tcp, wait_udp)` is conjunctive — a TCP-ready burst still paid the UDP `LONGPOLL_DEADLINE` (15 s) before responding. Comment said "either side", code did "both sides". Switched to `tokio::select!`. New test `batch_tcp_ready_does_not_pay_udp_longpoll_deadline` locks down the regression.
- **Watcher tasks leaked under `select!` cancellation:** `wait_for_any_drainable` only aborted its watcher tasks in a trailing loop, past every cancellation point. With phase-2 wait flipped to `select!`, the loser arm's future drops and *detaches* its watchers (dropping a `JoinHandle` doesn't abort). Each orphan held an `Arc<...Inner>` and could steal a `notify_one()` permit from a future batch. Fix: `AbortOnDrop` newtype wraps every watcher `JoinHandle`.
2 new tests + 35/35 pass.
• Example config exit-node now lists `aistudio.google.com` and `ai.google.dev` — requested in [#701](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/701). AI Studio sanctions Iran IPs (independently of any Apps Script issue on our side). Routing it through the exit-node makes the destination see val.town's IP, which is neither Iran nor a Google datacenter.
• Example config exit-node now lists `aistudio.google.com` and `ai.google.dev` — requested in [#701](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/701). AI Studio sanctions Iran IPs (independently of any Apps Script issue on our side). Routing it through the exit-node makes the destination see the exit node's IP, which is neither Iran nor a Google datacenter.
• Example config fronting-groups gained Reddit / Fastly / Pinterest / CNN / BuzzFeed family domains (PR [#696](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/pull/696) from @Shjpr9). All on the Fastly Anycast `151.101.x.x` edge — gives users a richer starter list to trim down based on what works in their network.
• Tests: 179 lib + 35 tunnel-node tests all passing.
+3 -3
View File
@@ -257,13 +257,13 @@ HTTP / HTTPS مثل قبل از Apps Script می‌رود (تغییری نمی
## Exit node
سرویس‌های پشت Cloudflare (chatgpt.com، claude.ai، grok.com، x.com، openai.com) ترافیک از IPهای دیتاسنتر گوگل را به‌عنوان bot شناسایی می‌کنند و چالش Turnstile / CAPTCHA می‌فرستند. راه‌حل exit node یک HTTP endpoint کوچک TypeScript است که روی val.town (رایگان) دیپلوی می‌کنی و بین Apps Script و مقصد قرار می‌گیرد:
سرویس‌های پشت Cloudflare (chatgpt.com، claude.ai، grok.com، x.com، openai.com) ترافیک از IPهای دیتاسنتر گوگل را به‌عنوان bot شناسایی می‌کنند و چالش Turnstile / CAPTCHA می‌فرستند. راه‌حل exit node یک handler کوچک TypeScript است که روی یک host serverless (Deno Deploy، fly.io، یا VPS شخصی خودت) دیپلوی می‌کنی و بین Apps Script و مقصد قرار می‌گیرد:
```
کلاینت → Apps Script (IP گوگل) → val.town (IP غیر گوگل) → سایت پشت CF
کلاینت → Apps Script (IP گوگل) → exit node خودت (IP غیر گوگل) → سایت پشت CF
```
مقصد IP val.town را می‌بیند نه IP گوگل، پس heuristic ضدبات شلیک نمی‌کند.
مقصد IP خروجی exit node را می‌بیند نه IP گوگل، پس heuristic ضدبات شلیک نمی‌کند.
**راه‌اندازی:** [`assets/exit_node/README.fa.md`](../assets/exit_node/README.fa.md). ۵ دقیقه، سهمیهٔ رایگان.
+3 -3
View File
@@ -257,13 +257,13 @@ More deployments = more total concurrency = lower per-session latency. Each batc
## Exit node
Cloudflare-fronted services (chatgpt.com, claude.ai, grok.com, x.com, openai.com) flag traffic from Google datacenter IPs as bots and serve a Turnstile / CAPTCHA challenge. The exit node fix is a small TypeScript HTTP endpoint you deploy on val.town (free) that sits between Apps Script and the destination:
Cloudflare-fronted services (chatgpt.com, claude.ai, grok.com, x.com, openai.com) flag traffic from Google datacenter IPs as bots and serve a Turnstile / CAPTCHA challenge. The exit node fix is a small TypeScript HTTP handler you deploy on a serverless host (Deno Deploy, fly.io, or your own VPS) that sits between Apps Script and the destination:
```
client → Apps Script (Google IP) → val.town (non-Google IP) → CF-protected site
client → Apps Script (Google IP) → your exit node (non-Google IP) → CF-protected site
```
The destination sees val.town's IP, not Google's, so the anti-bot heuristic doesn't fire.
The destination sees the exit node's IP, not Google's, so the anti-bot heuristic doesn't fire.
**Setup:** [`assets/exit_node/README.md`](../assets/exit_node/README.md). 5 min, free tier.
+2 -2
View File
@@ -283,7 +283,7 @@ struct FormState {
request_timeout_secs: u64,
/// Optional second-hop exit node for CF-anti-bot bypass (chatgpt.com /
/// claude.ai / grok.com / x.com). Config-only — no UI editor yet.
/// See `assets/exit_node/` for the val.town deployment script.
/// See `assets/exit_node/` for the generic exit-node handler.
exit_node: mhrv_rs::config::ExitNodeConfig,
}
@@ -676,7 +676,7 @@ struct ConfigWire<'a> {
#[serde(skip_serializing_if = "is_default_timeout_secs")]
request_timeout_secs: u64,
/// Exit-node config (CF-anti-bot bypass for chatgpt.com / claude.ai /
/// grok.com / x.com via val.town second-hop relay). Skip when fully
/// grok.com / x.com via exit-node second-hop relay). Skip when fully
/// default (disabled with no URL/PSK/hosts) so configs without
/// exit-node setup stay clean. Round-tripped through FormState so
/// Save preserves user-edited values.
+4 -4
View File
@@ -341,7 +341,7 @@ pub struct Config {
///
/// Architecture: chain becomes
/// `client → SNI rewrite → Apps Script (Google IP) → exit node
/// (val.town / Deno Deploy / etc., non-Google IP) → destination`
/// (Deno Deploy / fly.io / etc., non-Google IP) → destination`
///
/// The destination sees the exit node's outbound IP, not Google's.
/// CF anti-bot's "this is a Google datacenter" heuristic doesn't
@@ -362,9 +362,9 @@ pub struct ExitNodeConfig {
#[serde(default)]
pub enabled: bool,
/// HTTPS URL of the exit-node endpoint. Typically a val.town /
/// Deno Deploy / fly.io serverless deployment running the
/// `assets/exit_node/valtown.ts` script (or an equivalent). The
/// HTTPS URL of the exit-node endpoint. Typically a Deno Deploy /
/// fly.io serverless deployment (or your own VPS) running the
/// `assets/exit_node/exit_node.ts` script (or an equivalent). The
/// exit node is what makes the outbound `fetch()` call to the
/// destination, so its IP is what the destination sees.
#[serde(default)]
+19 -19
View File
@@ -151,7 +151,7 @@ pub struct DomainFronter {
/// (#430, masterking32 PR #25). Read by `tunnel_client::fire_batch`
/// so a single config field tunes the timeout used everywhere.
batch_timeout: Duration,
/// Optional second-hop exit node (val.town / Deno Deploy / etc.)
/// Optional second-hop exit node (Deno Deploy / fly.io / etc.)
/// to bypass CF-anti-bot blocks on sites that flag Google datacenter
/// IPs (chatgpt.com, claude.ai, grok.com, x.com). Mirrors
/// `Config::exit_node`. When `exit_node_enabled` is false (the more
@@ -770,14 +770,14 @@ impl DomainFronter {
};
// Exit-node short-circuit: route through the configured second-hop
// relay (val.town / Deno Deploy / etc.) for hosts that need a
// relay (Deno Deploy / fly.io / etc.) for hosts that need a
// non-Google exit IP. The cache + coalesce layer below is bypassed
// for these — exit-node-eligible hosts are the ones with active
// anti-bot challenges (CF Turnstile, ChatGPT login, Claude.ai,
// grok.com), and serving cached responses across users for those
// would be wrong (auth tokens, session state, per-user
// personalization). Falls back to the regular Apps Script relay
// if the exit node fails (network error, 5xx from val.town, etc.)
// if the exit node fails (network error, 5xx from the exit node, etc.)
// so a misconfigured or down exit node doesn't take the user
// offline for the sites that DON'T need it.
if self.exit_node_matches(url) {
@@ -1285,7 +1285,7 @@ impl DomainFronter {
/// ```text
/// client → SNI rewrite → Apps Script (Google IP)
/// → UrlFetchApp.fetch(exit_node_url)
/// → exit node (val.town, non-Google IP)
/// → exit node (non-Google IP)
/// → fetch(real_url)
/// → response back through both layers
/// ```
@@ -1296,7 +1296,7 @@ impl DomainFronter {
/// destination, returns a `{s, h, b}` JSON envelope. Apps Script
/// returns that envelope as the body of its raw HTTP response
/// (because we set `r: true`). We then unwrap one extra layer:
/// extract Apps Script's body → parse the val.town JSON → reconstruct
/// extract Apps Script's body → parse the exit-node JSON → reconstruct
/// the destination's raw HTTP response so the rest of the proxy
/// pipeline (MITM TLS write-back) sees the same shape it gets from
/// the regular path.
@@ -1314,7 +1314,7 @@ impl DomainFronter {
// Reusing build_payload_json keeps the outer envelope consistent
// with everything else (including the random padding for DPI
// evasion). The `r: true` flag in RelayRequest makes Code.gs
// return val.town's raw HTTP response, which is what we want to
// return exit-node's raw HTTP response, which is what we want to
// unwrap below.
let exit_url = self.exit_node_url.clone();
let outer_headers = vec![(
@@ -1325,12 +1325,12 @@ impl DomainFronter {
self.build_payload_json("POST", &exit_url, &outer_headers, &inner_json)?;
// Send the outer payload through the relay machinery and get back
// Apps Script's response body (which is val.town's JSON envelope).
// Apps Script's response body (which is exit-node's JSON envelope).
let app_body = self
.send_prebuilt_payload_through_relay(outer_payload)
.await?;
// val.town's JSON envelope: {s: u16, h: {...}, b: "<base64>"} on
// exit-node's JSON envelope: {s: u16, h: {...}, b: "<base64>"} on
// success, {e: "..."} on its own internal error.
parse_exit_node_response(&app_body)
}
@@ -1376,7 +1376,7 @@ impl DomainFronter {
h: hmap,
b: b_encoded,
ct,
r: false, // val.town returns its own JSON envelope, not raw HTTP
r: false, // the exit node returns its own JSON envelope, not raw HTTP
};
Ok(serde_json::to_vec(&req)?)
}
@@ -1385,7 +1385,7 @@ impl DomainFronter {
/// a payload we already built. Mirrors `do_relay_once_with` but
/// returns the **raw response body bytes** (Apps Script's HTTP body)
/// instead of running the body through `parse_relay_json` — the
/// exit-node path needs to peel off val.town's JSON envelope, which
/// exit-node path needs to peel off exit-node's JSON envelope, which
/// has a different shape from Code.gs's raw-HTTP wrapping.
async fn send_prebuilt_payload_through_relay(
&self,
@@ -2133,12 +2133,12 @@ fn unix_to_ymd_utc(secs: u64) -> (i64, u32, u32) {
(y, m as u32, d as u32)
}
/// Parse the val.town exit-node JSON envelope back into a raw HTTP/1.1
/// Parse the exit-node JSON envelope back into a raw HTTP/1.1
/// response. The envelope shape is:
///
/// - On success: `{ "s": <status u16>, "h": { ... }, "b": "<base64>" }`
/// - On exit-node-side error: `{ "e": "<message>" }` with HTTP 4xx/5xx
/// from val.town's own status code (decoded from the outer Apps Script
/// from exit-node's own status code (decoded from the outer Apps Script
/// layer, not the inner field).
///
/// We synthesize a complete HTTP/1.1 response from these fields so the
@@ -2153,8 +2153,8 @@ fn parse_exit_node_response(body: &[u8]) -> Result<Vec<u8>, FronterError> {
))
})?;
// Surface val.town's internal errors clearly rather than as a 502
// from the outer envelope. The `{e: "..."}` shape is what the val.town
// Surface exit-node's internal errors clearly rather than as a 502
// from the outer envelope. The `{e: "..."}` shape is what the exit-node's
// script emits on bad PSK, malformed URL, or any caught exception.
if let Some(err_msg) = v.get("e").and_then(|x| x.as_str()) {
return Err(FronterError::Relay(format!(
@@ -2484,7 +2484,7 @@ where
let want = need.min(tmp.len());
// Handle ungraceful TLS close-without-close_notify (rustls
// surfaces this as `io::ErrorKind::UnexpectedEof`). Some
// origins — notably val.town's exit-node path through Apps
// origins — notably exit-node path through Apps
// Script (#585, v1.9.4) and certain Apps Script `Connection:
// close` responses — terminate the underlying TCP without
// sending the TLS close_notify alert first. Treat that the
@@ -3085,7 +3085,7 @@ mod tests {
#[tokio::test]
async fn read_http_response_tolerates_unexpected_eof_with_content_length() {
// Issue #585 / v1.9.4 exit-node bug. Some peers (val.town in
// Issue #585 / v1.9.4 exit-node bug. Some peers (the deployed exit-node in
// particular, certain Apps Script `Connection: close` paths) close
// the TCP without TLS close_notify. Body should still be returned
// when Content-Length is satisfied, even though the read after
@@ -3130,8 +3130,8 @@ mod tests {
}
#[tokio::test]
async fn parse_exit_node_response_unwraps_valtown_envelope() {
// The exit-node path through Apps Script returns val.town's JSON
async fn parse_exit_node_response_unwraps_exit_node_envelope() {
// The exit-node path through Apps Script returns exit node's JSON
// envelope as the response body. parse_exit_node_response must
// unwrap it back into a raw HTTP/1.1 response so the MITM TLS
// write-back path sees the same shape it gets from the regular
@@ -3150,7 +3150,7 @@ mod tests {
#[tokio::test]
async fn parse_exit_node_response_surfaces_explicit_error() {
// When val.town returns `{e: "..."}` instead of the {s,h,b} shape,
// When the exit node returns `{e: "..."}` instead of the {s,h,b} shape,
// surface that error message specifically rather than letting
// it through as an unparseable 502 — the message string is what
// tells the user what went wrong (placeholder PSK, bad URL,