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