diff --git a/Cargo.lock b/Cargo.lock
index 1ec515c..1ea207b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2222,7 +2222,7 @@ dependencies = [
[[package]]
name = "mhrv-rs"
-version = "1.9.9"
+version = "1.9.10"
dependencies = [
"base64 0.22.1",
"bytes",
diff --git a/Cargo.toml b/Cargo.toml
index c6bcea5..aed0616 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
diff --git a/README.md b/README.md
index f28661d..73154f8 100644
--- a/README.md
+++ b/README.md
@@ -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).
diff --git a/assets/exit_node/README.fa.md b/assets/exit_node/README.fa.md
index ff31f2d..e04d477 100644
--- a/assets/exit_node/README.fa.md
+++ b/assets/exit_node/README.fa.md
@@ -1,3 +1,5 @@
+
+
# 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 = "";
```
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)
+
+
diff --git a/assets/exit_node/README.md b/assets/exit_node/README.md
index 4ac4788..118bc1e 100644
--- a/assets/exit_node/README.md
+++ b/assets/exit_node/README.md
@@ -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 = "";
```
- 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": "",
+ "relay_url": "https://your-deployed-exit-node.example.com",
+ "psk": "",
"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)
diff --git a/assets/exit_node/valtown.ts b/assets/exit_node/exit_node.ts
similarity index 80%
rename from assets/exit_node/valtown.ts
rename to assets/exit_node/exit_node.ts
index 2740074..0e6a32c 100644
--- a/assets/exit_node/valtown.ts
+++ b/assets/exit_node/exit_node.ts
@@ -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": "",
// "mode": "selective",
// "hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com"]
@@ -87,7 +89,7 @@ export default async function (req: Request): Promise {
{
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 {
// 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);
diff --git a/config.exit-node.example.json b/config.exit-node.example.json
index 2b36407..8af5516 100644
--- a/config.exit-node.example.json
+++ b/config.exit-node.example.json
@@ -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",
diff --git a/docs/changelog/v1.9.10.md b/docs/changelog/v1.9.10.md
new file mode 100644
index 0000000..d9d55d6
--- /dev/null
+++ b/docs/changelog/v1.9.10.md
@@ -0,0 +1,8 @@
+
+• 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.
diff --git a/docs/changelog/v1.9.4.md b/docs/changelog/v1.9.4.md
index 80253e7..d746501 100644
--- a/docs/changelog/v1.9.4.md
+++ b/docs/changelog/v1.9.4.md
@@ -1,9 +1,9 @@
-• 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": "",
"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": "",
"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.
diff --git a/docs/changelog/v1.9.5.md b/docs/changelog/v1.9.5.md
index 966237d..2e77c59 100644
--- a/docs/changelog/v1.9.5.md
+++ b/docs/changelog/v1.9.5.md
@@ -1,4 +1,4 @@
-• 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.
diff --git a/docs/changelog/v1.9.9.md b/docs/changelog/v1.9.9.md
index 0c4354c..b2af4d5 100644
--- a/docs/changelog/v1.9.9.md
+++ b/docs/changelog/v1.9.9.md
@@ -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.
diff --git a/docs/guide.fa.md b/docs/guide.fa.md
index 55a1395..3e95ffc 100644
--- a/docs/guide.fa.md
+++ b/docs/guide.fa.md
@@ -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). ۵ دقیقه، سهمیهٔ رایگان.
diff --git a/docs/guide.md b/docs/guide.md
index 9b21a08..55ee955 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -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.
diff --git a/src/bin/ui.rs b/src/bin/ui.rs
index 837d9e7..4042c40 100644
--- a/src/bin/ui.rs
+++ b/src/bin/ui.rs
@@ -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.
diff --git a/src/config.rs b/src/config.rs
index d0281a6..f611e63 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -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)]
diff --git a/src/domain_fronter.rs b/src/domain_fronter.rs
index 38a454b..953cc2e 100644
--- a/src/domain_fronter.rs
+++ b/src/domain_fronter.rs
@@ -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: ""} on
+ // exit-node's JSON envelope: {s: u16, h: {...}, b: ""} 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": , "h": { ... }, "b": "" }`
/// - On exit-node-side error: `{ "e": "" }` 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, 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,