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

Two changes addressing user-reported issues today:

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

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

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

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

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

169 mhrv-rs lib tests + 33 tunnel-node tests + UI build clean.
This commit is contained in:
therealaleph
2026-05-01 11:52:32 +03:00
parent d65759d8b8
commit 4aac9a793f
12 changed files with 1070 additions and 494 deletions
-392
View File
@@ -1,392 +0,0 @@
#!/usr/bin/env python3
"""
Post a CI-built Android APK to the project Telegram channel on each
release tag, followed by a reply-threaded changelog message with
Persian + English bullets in <blockquote> blocks.
Called from the `telegram:` job in `.github/workflows/release.yml`.
Environment:
BOT_TOKEN Telegram bot token (repo secret TELEGRAM_BOT_TOKEN)
CHAT_ID Numeric chat id, e.g. -1002282061190 (repo secret
TELEGRAM_CHAT_ID)
Arguments:
--apk path to the APK file to upload
--version bare version string, e.g. "1.1.0"
--repo "owner/repo"
--changelog path to docs/changelog/vX.Y.Z.md; split on a line
that is exactly "---" — anything before is Persian,
anything after is English. Missing file = only the
APK is posted (no reply).
Why Python over curl: curl's `-F name=value` multipart spec treats
`<file` as "read from file" and `@file` as "upload file". Our HTML
captions contain literal `<b>` tags, which triggers the file-read
path and exits 26 "Failed to open/read local data". urllib has no
such behavior.
Telegram quirks we deliberately handle:
- Captions max out at 1024 chars, so the APK caption is short
(title + sha256 + repo + release URL) and the real changelog
goes in a reply-threaded message (sendMessage has no practical
length limit).
- sendDocument content-type defaults to application/octet-stream
for unknown extensions — we pass .apk with
application/vnd.android.package-archive so channel previews
label it as an Android package, not a generic file.
"""
import argparse
import hashlib
import http.client
import json
import os
import re
import ssl
import sys
import uuid
from pathlib import Path
def _strip_leading_comments(body: str) -> str:
"""Strip leading HTML comment blocks (single- or multi-line) from `body`.
The changelog template uses `<!-- ... -->` to document the format for
editors; we don't want those echoed to Telegram or the GitHub Release
page. The `(?:...)+` quantifier eats N consecutive comments separated
only by whitespace, so a stub with both a format-docs comment and a
TODO comment is cleaned in one pass. `re.S` makes `.` cross newlines
for multi-line `<!--\\n...\\n-->` blocks.
The matching regex is also used inline by .github/workflows/release.yml
to compose the GitHub Release body — keep them in sync if you change
one. Run `python -m doctest telegram_release_notify.py -v` to check.
>>> _strip_leading_comments("<!-- header -->\\nbody")
'body'
>>> _strip_leading_comments("<!-- a -->\\n<!-- b -->\\nbody")
'body'
>>> _strip_leading_comments("<!--\\nmulti\\nline\\n-->\\nbody")
'body'
>>> _strip_leading_comments("<!-- a -->\\n\\n<!-- b -->\\n\\nbody")
'body'
>>> _strip_leading_comments("body without comments")
'body without comments'
>>> _strip_leading_comments("body\\n<!-- mid-file comment -->\\nmore")
'body\\n<!-- mid-file comment -->\\nmore'
"""
return re.sub(r"^\s*(?:<!--.*?-->\s*)+", "", body, count=1, flags=re.S)
def parse_changelog(path: str) -> tuple[str, str]:
"""Return (persian_body, english_body). Blank strings if file missing."""
p = Path(path)
if not p.is_file():
return "", ""
body = _strip_leading_comments(p.read_text(encoding="utf-8"))
fa, sep, en = body.partition("\n---\n")
if not sep:
# No separator — treat everything as Persian (content-language
# is a project preference rather than a hard rule).
return body.strip(), ""
return fa.strip(), en.strip()
# Telegram caption hard-cap is 1024 chars. The fixed parts of our caption
# (title + SHA hash + two-link footer with their preambles) sum to roughly
# 470 chars on a typical version string. That leaves ~550 chars for the
# release-note section before we'd start losing the trailing release URL.
# Keep the budget conservative so a long version string or a slightly
# longer hash representation doesn't push us over.
CAPTION_FA_NOTE_BUDGET = 500
def _md_links_to_html(text: str) -> str:
"""Convert `[label](url)` markdown links to `<a href="url">label</a>`.
Telegram's HTML parse mode renders `<a>` as clickable but treats
markdown verbatim, so an unconverted `[#160](https://…)` appears as
that literal string in the channel post — both ugly and wasteful of
caption budget. The HTML form is shorter visually (`#160` vs the
full URL), still clickable, and counts the same toward Telegram's
1024-char limit. Inline `code` (`backtick-quoted`) is also
translated to `<code>…</code>` since markdown backticks render
literally too.
"""
text = re.sub(
r"\[([^\]]+)\]\(([^)]+)\)",
lambda m: f'<a href="{m.group(2)}">{m.group(1)}</a>',
text,
)
text = re.sub(r"`([^`\n]+)`", r"<code>\1</code>", text)
# Bold (**…**) is rare in our changelog but happens — convert to <b>.
text = re.sub(r"\*\*([^*\n]+)\*\*", r"<b>\1</b>", text)
return text
def _extract_headlines(fa_section: str) -> str:
"""For each `• …: …` bullet, keep the headline part and drop the
elaboration.
Our changelog convention writes each bullet as one of:
• headline: full explanation
• headline ([#NN](url)): full explanation
• headline (issue ref): full explanation
The headline is everything up to the `: ` (colon + space) that ends
the leading clause. Naively searching for the first `:` lands inside
`https:` URLs of the markdown link form — instead we search from the
end of the parenthesized-issue-ref (if any) for the first `: `, or
fall back to the first `: ` in the line.
Headlines stay on the FA caption; the explanation is preserved in
the docs/changelog/ file and (optionally) the reply-threaded message
posted via --with-changelog.
Returns a newline-joined string of `• <headline>` lines.
"""
headlines: list[str] = []
for line in fa_section.splitlines():
if not line.startswith(""):
continue
body = line[2:] # drop "• "
# Prefer cutting at "): " — the close of the parenthesized ref
# followed by the convention colon + space. That's our actual
# bullet structure and avoids the false-positive `https:` cut.
cut_idx = body.find("): ")
if cut_idx > 0:
headline = body[: cut_idx + 1] # keep the close paren
else:
# Fall back to ": " (colon + space) anywhere in the body.
# Adding the space requirement skips `https:` which is
# always followed by `/`.
cut_idx = body.find(": ")
headline = body[:cut_idx] if cut_idx > 0 else body
headlines.append(f"{headline.rstrip()}")
return "\n".join(headlines)
def build_caption_release_note(changelog_path: str) -> str:
"""Build the Persian "what's new" block for the Telegram caption.
Pulls the FA section of `docs/changelog/v<ver>.md`, extracts just
the bullet headlines (before the first `:` of each bullet) so the
note is compact, converts markdown links/code to Telegram HTML for
clickability, and wraps in a `<blockquote>`. Falls back to the full
FA section if the headlines extraction yields nothing (e.g. a
changelog that doesn't follow our `• headline: details` convention).
If the result still exceeds CAPTION_FA_NOTE_BUDGET, truncate at a
bullet boundary with a trailing `…`. In practice the headlines-only
form fits comfortably for any reasonable release note.
"""
fa, _en = parse_changelog(changelog_path)
if not fa:
return ""
headlines = _extract_headlines(fa)
note = headlines if headlines else fa.strip()
note = _md_links_to_html(note)
if len(note) > CAPTION_FA_NOTE_BUDGET:
truncated = note[:CAPTION_FA_NOTE_BUDGET]
last_bullet = truncated.rfind("\n")
if last_bullet > 0:
note = truncated[:last_bullet].rstrip() + "\n"
else:
note = truncated.rstrip() + ""
return f"<blockquote>{note}</blockquote>"
def sha256_of(path: str) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def tg_request(method: str, token: str, *, body: bytes, content_type: str) -> dict:
"""POST `body` to https://api.telegram.org/bot<token>/<method>."""
conn = http.client.HTTPSConnection(
"api.telegram.org", context=ssl.create_default_context()
)
conn.request(
"POST",
f"/bot{token}/{method}",
body=body,
headers={"Content-Type": content_type, "Content-Length": str(len(body))},
)
resp = conn.getresponse()
raw = resp.read()
try:
data = json.loads(raw)
except json.JSONDecodeError:
raise SystemExit(f"Telegram {method}: non-JSON response ({resp.status}): {raw!r}")
if not data.get("ok"):
raise SystemExit(f"Telegram {method} failed: {data}")
return data["result"]
def send_document(token: str, chat_id: str, apk_path: str, caption: str) -> int:
"""Upload the APK file with a short HTML caption. Returns message_id."""
boundary = "----" + uuid.uuid4().hex
with open(apk_path, "rb") as f:
file_bytes = f.read()
def text_field(name: str, value: str) -> bytes:
return (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
f"{value}\r\n"
).encode("utf-8")
def file_field(name: str, filename: str, content: bytes) -> bytes:
head = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
# Proper MIME type — makes the Telegram client show the APK
# with the Android package icon and honour its size/name.
f"Content-Type: application/vnd.android.package-archive\r\n\r\n"
).encode("utf-8")
return head + content + b"\r\n"
body = (
text_field("chat_id", chat_id)
+ text_field("caption", caption)
+ text_field("parse_mode", "HTML")
+ file_field("document", os.path.basename(apk_path), file_bytes)
+ f"--{boundary}--\r\n".encode("utf-8")
)
result = tg_request(
"sendDocument",
token,
body=body,
content_type=f"multipart/form-data; boundary={boundary}",
)
return int(result["message_id"])
def send_reply(token: str, chat_id: str, text: str, reply_to: int) -> None:
"""Post a text message as a reply to the APK message."""
from urllib.parse import urlencode
body = urlencode(
{
"chat_id": chat_id,
"text": text,
"parse_mode": "HTML",
"reply_to_message_id": str(reply_to),
}
).encode()
tg_request(
"sendMessage",
token,
body=body,
content_type="application/x-www-form-urlencoded",
)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--apk", required=True)
ap.add_argument("--version", required=True)
ap.add_argument("--repo", required=True)
ap.add_argument("--changelog", required=True,
help="Path to docs/changelog/vX.Y.Z.md; only read when --with-changelog is passed.")
# Default: just the APK + short caption (title + SHA-256 + repo URL +
# release URL). The per-release Persian/English blockquote reply is
# opt-in via `--with-changelog` so routine releases don't flood the
# channel with bullet-point bodies. To re-enable for a specific tag:
# set the repo variable TELEGRAM_INCLUDE_CHANGELOG=true before pushing
# the tag (the workflow converts that into --with-changelog).
ap.add_argument("--with-changelog", action="store_true",
help="Include the Persian+English changelog as a reply-threaded message.")
# Dry-run lets you verify the rendered caption locally without hitting
# Telegram. Useful when changing the brief-release-note budget /
# truncation logic — print, eyeball, push.
ap.add_argument("--dry-run", action="store_true",
help="Render the caption and print it instead of posting. "
"Skips token/chat_id checks.")
args = ap.parse_args()
if not args.dry_run:
token = os.environ.get("BOT_TOKEN", "")
chat_id = os.environ.get("CHAT_ID", "")
if not token or not chat_id:
print("TELEGRAM secrets not present, skipping post.")
return 0
else:
token = ""
chat_id = ""
ver = args.version
sha = sha256_of(args.apk)
# Brief Persian release-note above the links. Pulled from the FA
# half of `docs/changelog/v<ver>.md` so each release auto-includes
# what's new without manual edits to this script. Truncated to fit
# Telegram's 1024-char caption budget alongside title + SHA + the
# two-link footer.
fa_note = build_caption_release_note(args.changelog)
# Caption structure requested by the repo owner:
# 1. Title + SHA-256 (as before)
# 2. Brief Persian "what's new" note (extracted from changelog)
# 3. Persian preamble labelling the repo link as
# "GitHub repo + full Persian guide"
# 4. Repo URL
# 5. Persian preamble labelling the release link as
# "this version's release — desktop/router builds live here"
# 6. Release URL
# Keeps total well under Telegram's 1024-char caption limit.
caption_parts = [
f"<b>mhrv-rs Android v{ver}</b>",
"",
f"SHA-256: <code>{sha}</code>",
]
if fa_note:
caption_parts.extend(["", fa_note])
caption_parts.extend([
"",
"مخزن گیتهاب + مطالعه راهنمای کامل فارسی:",
f"https://github.com/{args.repo}",
"",
"لینک به این نسخه جهت دریافت نسخه های مربوط به مودم و کامپیوتر:",
f"https://github.com/{args.repo}/releases/tag/v{ver}",
])
caption = "\n".join(caption_parts)
if args.dry_run:
print(f"--- DRY RUN: caption ({len(caption)} chars) ---")
print(caption)
print(f"--- END DRY RUN ---")
if args.with_changelog:
fa, en = parse_changelog(args.changelog)
print(f"\nWould reply with changelog "
f"(fa: {len(fa) if fa else 0} chars, "
f"en: {len(en) if en else 0} chars)")
return 0
doc_mid = send_document(token, chat_id, args.apk, caption)
print(f"sendDocument OK, message_id={doc_mid}")
if not args.with_changelog:
print("Changelog reply disabled (default). Pass --with-changelog to include.")
return 0
fa, en = parse_changelog(args.changelog)
if not fa and not en:
print(f"No changelog at {args.changelog}, skipping reply.")
return 0
parts = []
if fa:
parts.append(f"<blockquote>{fa}</blockquote>")
if en:
parts.append(f"<blockquote>{en}</blockquote>")
reply = "\n\n".join(parts)
send_reply(token, chat_id, reply, doc_mid)
print("Reply OK")
return 0
if __name__ == "__main__":
sys.exit(main())
+14 -100
View File
@@ -652,11 +652,9 @@ jobs:
{
echo 'body<<__RELEASE_BODY_EOF__'
# Strip leading HTML comment blocks (single-line OR multi-line)
# using the SAME regex as
# .github/scripts/telegram_release_notify.py:parse_changelog,
# so the GitHub Release page and the Telegram post agree on
# exactly what counts as "the leading comment block." Both
# also strip any leading whitespace/blank lines that follow.
# so the GitHub Release page sees only the body content, not
# the file-format header comment that every changelog has.
# Also strips any leading whitespace/blank lines that follow.
#
# Quoted heredoc (`<<'PY'`) so backticks/$ in the python
# snippet aren't shell-interpolated; CHANGELOG is passed in
@@ -828,99 +826,15 @@ jobs:
# isn't gated by the same protection.
git push origin HEAD:main
# ─────────── LEGACY — DORMANT BY DEFAULT ───────────
# The legacy `telegram` job that posted a universal APK + changelog
# bundle to the main Telegram channel was removed in v1.9.4. It was
# superseded by `.github/workflows/telegram-publish-files.yml` (per-
# platform per-file posts to the files channel + a single cross-link
# to the main channel). With both running together, every release
# produced a duplicate APK post on the main channel — the legacy
# bundled post AND the new cross-link.
#
# Posts the universal APK + per-version changelog to the **main**
# Telegram channel as one big sendDocument + sendMessage pair.
#
# Superseded as of v1.8.0+ by `.github/workflows/telegram-publish-files.yml`,
# which posts each platform's artifact individually to the **files**
# channel (with SHA-256 captions) and then a single cross-link
# message to the main channel pointing at the files-channel anchor.
#
# This job stays in the source tree, dormant, in case we ever want
# to revert to the bundled-changelog-on-main-channel pattern (or
# use both at once during a transition). To turn it back on:
#
# gh variable set TELEGRAM_NOTIFY_ENABLED --body true
#
# Note: with the new workflow active too, that produces TWO posts
# to the main channel per release (the legacy APK+changelog *and*
# the new cross-link). Pick one.
#
# Default state is disabled.
telegram:
needs: [android, release]
runs-on: ubuntu-latest
# Gated on the repo variable `TELEGRAM_NOTIFY_ENABLED`. Default is
# off — the job skips silently unless the variable is set to the
# literal string "true".
if: ${{ vars.TELEGRAM_NOTIFY_ENABLED == 'true' && needs.android.result == 'success' }}
steps:
- uses: actions/checkout@v4
# Same retry pattern as the `release` job above — `actions/download-artifact@v4`
# has been flaking on this workflow with 5-retries-exhausted errors.
- name: Download universal APK (with retries)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p apk
for attempt in 1 2 3; do
if gh run download "${GITHUB_RUN_ID}" \
--name mhrv-rs-android-universal \
--dir apk \
--repo "${GITHUB_REPOSITORY}"; then
echo "downloaded universal APK on attempt $attempt"
ls -la apk/
exit 0
fi
echo "download attempt $attempt failed; retrying in 30s..."
sleep 30
done
echo "::error::failed to download universal APK after 3 attempts"
exit 1
- name: Post to Telegram
env:
BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
INCLUDE_CHANGELOG: ${{ vars.TELEGRAM_INCLUDE_CHANGELOG }}
# Python over curl/bash so we don't have to fight curl's -F
# value-interpretation rules. curl treats `-F "caption=<..."`
# as "read the caption from file named ..." when the value
# starts with `<`, which matches our `<b>` HTML-bold tags and
# silently turns the whole job into a "file not found" exit
# 26. Python stdlib has no such wart.
run: |
set -euo pipefail
VER="${{ inputs.version || github.ref_name }}"
VER="${VER#v}"
APK="apk/mhrv-rs-android-universal-v${VER}.apk"
if [ -z "${BOT_TOKEN:-}" ] || [ -z "${CHAT_ID:-}" ]; then
echo "::notice::TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID not set, skipping Telegram post"
exit 0
fi
if [ ! -f "$APK" ]; then
echo "::error::expected $APK to exist; got:"
ls -la apk/
exit 1
fi
# --with-changelog is opt-in. Default post is just the APK
# plus a short caption with the SHA-256, repo URL, and release
# URL — no long body. To include the Persian/English bullets
# for a specific tag, set the repo variable
# TELEGRAM_INCLUDE_CHANGELOG=true before pushing that tag.
INCLUDE_CHANGELOG_FLAG=""
if [ "${INCLUDE_CHANGELOG:-}" = "true" ]; then
INCLUDE_CHANGELOG_FLAG="--with-changelog"
fi
python3 .github/scripts/telegram_release_notify.py \
--apk "$APK" \
--version "$VER" \
--repo "$GITHUB_REPOSITORY" \
--changelog "docs/changelog/v${VER}.md" \
$INCLUDE_CHANGELOG_FLAG
# If you ever need to bring back the bundled-APK-on-main pattern, the
# commit history before v1.9.4 has the full job — `git log -- .github/workflows/release.yml`.
# The `TELEGRAM_NOTIFY_ENABLED` repo variable + `telegram_release_notify.py`
# script that the legacy job called are no longer referenced.
Generated
+1 -1
View File
@@ -2222,7 +2222,7 @@ dependencies = [
[[package]]
name = "mhrv-rs"
version = "1.9.3"
version = "1.9.4"
dependencies = [
"base64 0.22.1",
"bytes",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "mhrv-rs"
version = "1.9.3"
version = "1.9.4"
edition = "2021"
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
license = "MIT"
+174
View File
@@ -0,0 +1,174 @@
# Exit node — دور زدن CF anti-bot برای ChatGPT / Claude / Grok / X
بسیاری از سرویس‌های پشت Cloudflare، traffic از رنج IP datacenter
گوگل را به‌عنوان bot flag می‌کنن + به‌جای صفحه واقعی یک Turnstile /
CAPTCHA / 502 challenge می‌فرستن. `UrlFetchApp.fetch()` در Apps
Script از همان رنج IP datacenter Google خروج می‌کنه، پس برای سایت‌هایی مانند:
- **chatgpt.com / openai.com**
- **claude.ai**
- **grok.com / x.com**
…مسیر apps_script-mode عادی mhrv-rs ارورهایی مثل
`Relay error: json: key must be a string at line 2 column 1` یا
`502 Relay error` می‌ده چون Code.gs در حال wrap کردن صفحه‌ی HTML
challenge CF است که کلاینت نمی‌تونه parse کنه.
**Exit node** یک endpoint کوچک TypeScript HTTP است که روی یک پلتفرم
serverless (val.town، Deno Deploy، fly.io، …) deploy می‌شه + بین Apps
Script و destination قرار می‌گیره. مسیر traffic این می‌شه:
```
Browser ─┐ ┌─→ Destination
│ │ (chatgpt.com)
▼ │
mhrv-rs │
│ │
│ TLS به Google IP، SNI=www.google.com (DPI cover)│
▼ │
Apps Script (Google datacenter) │
│ │
│ UrlFetchApp.fetch(EXIT_NODE_URL) │
▼ │
val.town (non-Google IP) │
│ │
│ fetch(real_url) │
└──────────────────────────────────────────────────┘
```
Destination IP val.town رو می‌بینه، نه Google datacenter. heuristic
anti-bot CF نمی‌سوزه + صفحه واقعی برمی‌گرده.
**نکته مهم:** leg user-side (Iran ISP → Apps Script) **بدون تغییر**
است. ISP فقط TLS به Google IP می‌بینه — second hop کاملاً درون
outbound Apps Script اجرا می‌شه، invisible از شبکه‌ی کاربر. پس DPI
evasion property که mhrv-rs براش ساخته شده، دست نمی‌خوره.
## راه‌اندازی
1. **در [val.town](https://val.town) ثبت‌نام کنید** (free tier کافی
است — bandwidth outbound free tier برای personal use کافی).
2. **یک HTTP val جدید بسازید** (TypeScript). در val.town: New → HTTP.
3. **محتوای `valtown.ts`** از این directory رو paste کنید.
4. **PSK رو در بالای فایل تنظیم کنید**:
```ts
const PSK = "<your-strong-secret>";
```
Strong secret تولید کنید با `openssl rand -hex 32` از terminal.
**placeholder رو در production نگذارید** — کد val.town عمداً
fail-closed است (در هر request 503 برمی‌گردونه) تا placeholder
replace نشده، تا جلوی serve شدن به‌عنوان open relay accidentally
گرفته بشه.
5. **Save** کنید val رو. URL public val رو copy کنید — به این شکل:
`https://your-handle-mhrv.web.val.run`.
6. **در `config.json` mhrv-rs**، block `exit_node` اضافه کنید:
```json
"exit_node": {
"enabled": true,
"relay_url": "https://your-handle-mhrv.web.val.run",
"psk": "<همان PSK که در گام 4 گذاشتید>",
"mode": "selective",
"hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"]
}
```
7. **mhrv-rs رو restart کنید** (Disconnect + Connect، یا `kill` +
restart binary).
8. **تست کنید** — `chatgpt.com` یا `grok.com` رو از browser pointed به
mhrv-rs proxy باز کنید. صفحه login واقعی رو می‌بینید، نه CF challenge.
config مثال کامل در
[`config.exit-node.example.json`](../../config.exit-node.example.json)
در root repo.
## انتخاب `selective` vs `full`
| Mode | چی می‌کنه | کی استفاده کنید |
|---|---|---|
| `selective` (default) | فقط hosts در `hosts` از طریق exit node می‌رن؛ بقیه از مسیر Apps Script عادی | توصیه می‌شه. exit-node hop ~۲۰۰-۵۰۰ms به هر request اضافه می‌کنه — برای سایت‌هایی reserve کنید که نیاز به non-Google IP دارن. |
| `full` | همه‌ی request‌ها از طریق exit node می‌رن | فقط زمانی که کل workload شما CF-anti-bot affected است، یا exit node خود از Apps Script سریع‌تر روی مسیر شبکه شما (rare). budget runtime val.town رو برای سایت‌هایی که نیاز ندارن می‌سوزونه. |
## رفتار در صورت failure
اگر exit node در دسترس نباشه، 5xx برمی‌گردونه، یا response malformed
بفرسته، mhrv-rs **به‌طور خودکار به Apps Script relay عادی fallback
می‌کنه**. در log یک خط `warn: exit node failed for ... — falling back
to direct Apps Script` می‌بینید. سایت‌هایی که نیاز به exit node دارن در آن
case fail می‌گیرن (CF challenge)، ولی سایر سایت‌ها کار می‌کنن — یک
exit node down شما رو fully offline نمی‌کنه.
## Security model
PSK تنها چیز است که مانع می‌شه val.town endpoint یک public open proxy
بشه. مثل password برخورد کنید:
- **commit نکنید** PSK رو به source control. منبع val.town به‌طور
default برای account شما private است؛ همان‌طور نگه دارید.
- **publicly share نکنید** PSK رو. هر کسی که هم URL هم PSK رو داره
می‌تونه quota val.town شما رو به‌عنوان proxy خود استفاده کنه.
- **rotate** اگر leak مشکوک هست. PSK رو در val.town source تغییر بدید،
save کنید، سپس `psk` در `config.json` mhrv-rs رو update + restart.
اسکریپت val.town شامل **loop guard** هم هست (refuse می‌کنه fetch host
خود) + **placeholder check** (در صورت `PSK === "CHANGE_ME_TO_A_STRONG_SECRET"`
return 503 می‌کنه) تا یک fresh deploy بدون setup نتونه به‌طور
accidentally به‌عنوان open relay سرو بشه.
## پلتفرم‌های جایگزین
اسکریپت `valtown.ts` plain TypeScript است که از web-standard APIs
(`Request`، `Response`، `fetch`) استفاده می‌کنه. اجرا می‌شه روی:
- **val.town** — ساده‌ترین، free tier کافی برای personal use
- **Deno Deploy** — API مشابه؛ deploy با `deployctl`
- **fly.io** — نیاز به `Dockerfile` wrapper؛ region geographic ثابت
- **Cloudflare Workers** — کمک نمی‌کنه (CF Workers از IP space خود CF
خروج می‌کنن، که CF anti-bot هنوز به‌عنوان worker-internal flag می‌کنه)
برای اکثر کاربران، val.town انتخاب درست است. Deno Deploy اگر option
non-val.town برای redundancy می‌خواید.
## چرا default-on نیست
- ۲۰۰-۵۰۰ms به هر request اضافه می‌کنه (hop اضافی)
- budget bandwidth free-tier val.town رو می‌سوزونه
- برای سایت‌هایی که CF anti-bot ندارن benefit نداره
- Setup یک account جداگانه روی پلتفرم third-party می‌خواد
پس `enabled: false` default است. کاربرانی که خصوصاً به ChatGPT / Claude /
Grok اهمیت می‌دن opt in؛ همه‌ی دیگران lighter اجرا می‌کنن.
## Troubleshooting
**`exit node refused or errored: unauthorized`** — PSK mismatch.
بررسی کنید `psk` در `config.json` دقیقاً با `PSK` constant در val.town
match هست. whitespace + quoting مهم است.
**`exit node refused or errored: exit_node misconfigured: PSK is still
the placeholder`** — فراموش کردید `CHANGE_ME_TO_A_STRONG_SECRET` رو
در val.town جایگزین کنید. val رو edit + save کنید.
**`exit node failed for ...: connection refused`** — URL val.town
اشتباه است یا val deploy نشده. با hit کردن URL مستقیم از browser
verify کنید — باید `{"e":"method_not_allowed"}` برگردونه (val expects
POST).
**`exit node failed for ...: timeout`** — outbound val.town slow است
یا destination slow. region val.town متفاوت رو امتحان کنید، یا latency
trade-off رو accept کنید.
**سایت همچنان CF challenge نشون می‌ده بعد از enable exit node** — CF
IP val.town رو هم flag می‌کنه. برخی customers CF صراحتاً val.town رو
blocklist کردن. workarounds: Deno Deploy رو امتحان کنید، یا سایت رو
به `passthrough_hosts` اضافه کنید (MITM رو bypass می‌کنه؛ از real
IP ISP شما استفاده می‌کنه).
## همچنین ببینید
- [English version](README.md) of this doc
- [`valtown.ts`](valtown.ts) — منبع val.town (با hardening)
- [`config.exit-node.example.json`](../../config.exit-node.example.json)
— config مثال کامل
- Issue [#382](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/382)
— thread tracking canonical Cloudflare anti-bot
- Issue [#309](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/309)
— roadmap CF WARP integration (approach جایگزین، longer-horizon)
+176
View File
@@ -0,0 +1,176 @@
# Exit node — bypass Cloudflare anti-bot for ChatGPT / Claude / Grok / X
Many Cloudflare-protected services flag traffic from Google datacenter
IP ranges as bots and serve a Turnstile / interactive CAPTCHA / 502
challenge instead of the actual page. Apps Script's `UrlFetchApp.fetch()`
exits from those Google datacenter IPs, so for sites like:
- **chatgpt.com / openai.com** (Cloudflare anti-bot, often blocks GCP IPs)
- **claude.ai** (same)
- **grok.com / x.com** (CF-fronted, returns 502 on Google IPs)
…the regular mhrv-rs apps_script-mode path returns errors like
`Relay error: json: key must be a string at line 2 column 1` or
`502 Relay error` because Code.gs is wrapping a CF challenge HTML
page that the client can't make sense of.
The **exit node** is a small TypeScript HTTP endpoint deployed on a
serverless platform (val.town, Deno Deploy, fly.io, etc.) that sits
between Apps Script and the destination. The traffic chain becomes:
```
Browser ─┐ ┌─→ Destination
│ │ (chatgpt.com)
▼ │
mhrv-rs │
│ │
│ TLS to Google IP, SNI=www.google.com (DPI cover) │
▼ │
Apps Script (Google datacenter) │
│ │
│ UrlFetchApp.fetch(EXIT_NODE_URL) │
▼ │
val.town (non-Google IP) │
│ │
│ fetch(real_url) │
└──────────────────────────────────────────────────────┘
```
The destination sees the val.town IP, not Google datacenter. CF's
anti-bot heuristic doesn't fire, and you get the actual page.
Crucially: **the user-side leg (Iran ISP → Apps Script) is unchanged.**
The ISP still only sees TLS to a Google IP — the second hop happens
entirely inside Apps Script's outbound, invisible from the user's
network. So the DPI evasion property mhrv-rs is built around stays
intact.
## Setup
1. **Sign up at [val.town](https://val.town)** (free tier is fine —
the free tier's outbound bandwidth is enough for personal use).
2. **Create a new HTTP val** (TypeScript). On val.town: New → HTTP.
3. **Paste the contents of `valtown.ts`** from this directory.
4. **Set the PSK** at the top of the file:
```ts
const PSK = "<your-strong-secret>";
```
Generate a strong secret with `openssl rand -hex 32` from a terminal.
**Don't leave the placeholder in production** — the val.town code
intentionally fails closed (returns 503 on every request) until
you replace the placeholder, so you can't accidentally serve as
an open relay.
5. **Save** the val. Copy the val's public URL — it looks like
`https://your-handle-mhrv.web.val.run`.
6. **In your mhrv-rs `config.json`**, add an `exit_node` block:
```json
"exit_node": {
"enabled": true,
"relay_url": "https://your-handle-mhrv.web.val.run",
"psk": "<the same PSK you set in step 4>",
"mode": "selective",
"hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"]
}
```
7. **Restart mhrv-rs** (Disconnect + Connect, or `kill` + restart the
binary).
8. **Test** — visit `chatgpt.com` or `grok.com` from a browser pointed
at the mhrv-rs proxy. You should see the actual login page now,
not a CF challenge.
A complete example config is at
[`config.exit-node.example.json`](../../config.exit-node.example.json)
in the repo root.
## How `selective` vs `full` mode pick
| Mode | What it does | When to use |
|---|---|---|
| `selective` (default) | Only hosts in `hosts` route via exit node; everything else takes the regular Apps Script path | Recommended. The exit-node hop adds ~200-500ms per request, so reserve it for sites that need a non-Google IP. |
| `full` | Every request routes via exit node | Only useful when your entire workload is CF-anti-bot affected, or when the exit node happens to be faster than Apps Script alone for your network path (rare). Burns val.town runtime budget for sites that don't need it. |
## Failure mode
If the exit node is unreachable, returns a 5xx, or returns a malformed
response, mhrv-rs **falls back to the regular Apps Script relay
automatically**. You'll see a `warn: exit node failed for ... — falling
back to direct Apps Script` line in the log. Sites that need the exit
node will fail in that case (CF challenge), but other sites work
normally — a down exit node doesn't take you fully offline.
## Security model
The PSK is the only thing keeping the val.town endpoint from being a
public open proxy. Treat it like a password:
- **Don't commit** the PSK to source control. The val.town source
is private to your account by default; keep it that way.
- **Don't share** the PSK publicly. Anyone who has both the URL and
the PSK can use your val.town quota as their own proxy.
- **Rotate** if you suspect leak. Change the PSK in val.town source,
save, then update `psk` in mhrv-rs `config.json` and restart.
The val.town script also includes a **loop guard** (refuses to fetch
its own host) and **placeholder check** (returns 503 if `PSK ===
"CHANGE_ME_TO_A_STRONG_SECRET"`) so a fresh deploy without setup can't
accidentally serve as an open relay.
## Alternative platforms
The `valtown.ts` script is plain TypeScript using web-standard APIs
(`Request`, `Response`, `fetch`). It runs on:
- **val.town** — easiest, free tier sufficient for personal use
- **Deno Deploy** — similar API; deploy with `deployctl`
- **fly.io** — needs a `Dockerfile` wrapper; gives you a fixed
geographic region
- **Cloudflare Workers** — won't help (CF Workers exit from CF's own
IP space, which CF anti-bot still flags as worker-internal)
For most users, val.town's the right choice. Deno Deploy if you want
a non-val.town option for redundancy.
## Why not always-on by default
- Adds 200-500ms per request (extra hop)
- Burns val.town's free-tier bandwidth budget
- Offers no benefit for sites that don't have CF anti-bot
- Setup requires a separate account on a third-party platform
So `enabled: false` is the default. Users who care about ChatGPT /
Claude / Grok specifically opt in; everyone else runs lighter.
## Troubleshooting
**`exit node refused or errored: unauthorized`** — PSK mismatch.
Check that the `psk` in `config.json` exactly matches the `PSK`
constant in val.town. Whitespace and quoting matter.
**`exit node refused or errored: exit_node misconfigured: PSK is still
the placeholder`** — you forgot to replace `CHANGE_ME_TO_A_STRONG_SECRET`
in val.town. Edit + save the val.
**`exit node failed for ...: connection refused`** — the val.town URL
is wrong or the val isn't deployed. Verify by hitting the URL directly
from a browser — it should return `{"e":"method_not_allowed"}` (val
expects POST).
**`exit node failed for ...: timeout`** — val.town outbound is slow
or the destination is slow. Try a different val.town deployment region,
or accept the latency trade-off.
**Site still shows CF challenge after enabling exit node** — CF is
flagging val.town's IP too. Some CF customers explicitly blocklist
val.town. Workarounds: try Deno Deploy instead, or add the site to
`passthrough_hosts` (bypasses MITM entirely; uses your real ISP IP).
## See also
- [Persian translation](README.fa.md) of this doc
- [`valtown.ts`](valtown.ts) — the val.town source (with hardening)
- [`config.exit-node.example.json`](../../config.exit-node.example.json)
— complete example config
- Issue [#382](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/382)
— canonical Cloudflare anti-bot tracking thread
- Issue [#309](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/309)
— CF WARP integration roadmap (alternative approach, longer-horizon)
+162
View File
@@ -0,0 +1,162 @@
// mhrv-rs exit node — deploy as an HTTP endpoint on val.town (or Deno
// Deploy, fly.io, anywhere with a public residential-adjacent IP).
//
// Purpose: chain client → Apps Script → this exit node → destination.
// Apps Script's UrlFetchApp can't reach Cloudflare-protected sites that
// flag Google datacenter IPs as bots (chatgpt.com, claude.ai, grok.x.ai,
// many other CF-fronted SaaS). This exit node sits between Apps Script
// and the destination; the destination sees the exit node's IP (val.town's
// outbound, generally not flagged as Google datacenter) and accepts the
// request.
//
// Setup:
// 1. Sign in to https://val.town and create a new HTTP val (TypeScript)
// 2. Paste the contents of this file
// 3. Set PSK below to a strong secret (use `openssl rand -hex 32`
// from a terminal — DO NOT leave the placeholder in production)
// 4. Save and copy the val's public URL (looks like
// https://your-handle-mhrv.web.val.run)
// 5. In mhrv-rs config.json, add:
// "exit_node": {
// "enabled": true,
// "relay_url": "https://your-handle-mhrv.web.val.run",
// "psk": "<the same PSK you set above>",
// "mode": "selective",
// "hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com"]
// }
//
// Threat model: PSK is the only thing keeping this from being an open
// proxy on the public internet. Treat it like a password: do not commit
// to source control, do not share publicly, rotate if leaked. The exit
// node refuses all requests that don't carry the matching PSK.
//
// Failure mode: if the exit node is unreachable, mhrv-rs falls back to
// the regular Apps Script relay automatically — the only consequence
// of an offline exit node is that ChatGPT/Claude/Grok stop working;
// other sites are unaffected.
const PSK = "CHANGE_ME_TO_A_STRONG_SECRET";
// Headers the client may send that must NOT be forwarded to the
// destination — they're hop-by-hop or would break re-encoding.
const STRIP_HEADERS = new Set([
"host",
"connection",
"content-length",
"transfer-encoding",
"proxy-connection",
"proxy-authorization",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-proto",
"x-forwarded-port",
"x-real-ip",
"forwarded",
"via",
]);
function decodeBase64ToBytes(input: string): Uint8Array {
const bin = atob(input);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function encodeBytesToBase64(bytes: Uint8Array): string {
let bin = "";
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
function sanitizeHeaders(h: unknown): Record<string, string> {
const out: Record<string, string> = {};
if (!h || typeof h !== "object") return out;
for (const [k, v] of Object.entries(h as Record<string, unknown>)) {
if (!k) continue;
if (STRIP_HEADERS.has(k.toLowerCase())) continue;
out[k] = String(v ?? "");
}
return out;
}
export default async function (req: Request): Promise<Response> {
// Fail closed on the placeholder PSK so a fresh deploy without setup
// can't accidentally serve as an open relay.
if (PSK === "CHANGE_ME_TO_A_STRONG_SECRET") {
return Response.json(
{
e:
"exit_node misconfigured: PSK is still the placeholder. Set " +
"a strong secret in the val.town source before deploying.",
},
{ status: 503 },
);
}
try {
if (req.method !== "POST") {
return Response.json({ e: "method_not_allowed" }, { status: 405 });
}
const body = await req.json();
if (!body || typeof body !== "object") {
return Response.json({ e: "bad_json" }, { status: 400 });
}
const k = String((body as any).k ?? "");
const u = String((body as any).u ?? "");
const m = String((body as any).m ?? "GET").toUpperCase();
const h = sanitizeHeaders((body as any).h);
const b64 = (body as any).b;
if (k !== PSK) {
return Response.json({ e: "unauthorized" }, { status: 401 });
}
if (!/^https?:\/\//i.test(u)) {
return Response.json({ e: "bad url" }, { status: 400 });
}
// Loop guard: if u points at this exit node's own host, refuse.
// Without this, a misconfigured client could chain exit-node →
// exit-node → exit-node → ... and burn the val.town runtime budget.
try {
const reqUrl = new URL(req.url);
const dstUrl = new URL(u);
if (
reqUrl.host === dstUrl.host &&
reqUrl.protocol === dstUrl.protocol
) {
return Response.json({ e: "exit-node loop refused" }, { status: 400 });
}
} catch {
// Malformed URL — let the fetch below 400.
}
let payload: Uint8Array | undefined;
if (typeof b64 === "string" && b64.length > 0) {
payload = decodeBase64ToBytes(b64);
}
const resp = await fetch(u, {
method: m,
headers: h,
body: payload,
redirect: "manual",
});
const data = new Uint8Array(await resp.arrayBuffer());
const respHeaders: Record<string, string> = {};
resp.headers.forEach((value, key) => {
respHeaders[key] = value;
});
return Response.json({
s: resp.status,
h: respHeaders,
b: encodeBytesToBase64(data),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return Response.json({ e: message }, { status: 500 });
}
}
+33
View File
@@ -0,0 +1,33 @@
{
"_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.",
"mode": "apps_script",
"google_ip": "216.239.38.120",
"front_domain": "www.google.com",
"auth_key": "PUT_YOUR_APPS_SCRIPT_AUTH_KEY_HERE",
"script_id": [
"PUT_YOUR_APPS_SCRIPT_DEPLOYMENT_ID_HERE"
],
"listen_host": "0.0.0.0",
"listen_port": 8085,
"socks5_port": 8086,
"log_level": "info",
"verify_ssl": true,
"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_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.",
"hosts": [
"chatgpt.com",
"claude.ai",
"x.com",
"grok.com",
"openai.com"
]
}
}
+26
View File
@@ -0,0 +1,26 @@
<!-- 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 جدید:
```json
"exit_node": {
"enabled": true,
"relay_url": "https://your-handle-mhrv.web.val.run",
"psk": "<openssl rand -hex 32>",
"mode": "selective",
"hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"]
}
```
دو 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:
```json
"exit_node": {
"enabled": true,
"relay_url": "https://your-handle-mhrv.web.val.run",
"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).
• 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.
+25
View File
@@ -280,6 +280,10 @@ struct FormState {
auto_blacklist_window_secs: u64,
auto_blacklist_cooldown_secs: u64,
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.
exit_node: mhrv_rs::config::ExitNodeConfig,
}
#[derive(Clone, Debug)]
@@ -381,6 +385,7 @@ fn load_form() -> (FormState, Option<String>) {
auto_blacklist_window_secs: c.auto_blacklist_window_secs,
auto_blacklist_cooldown_secs: c.auto_blacklist_cooldown_secs,
request_timeout_secs: c.request_timeout_secs,
exit_node: c.exit_node.clone(),
}
} else {
FormState {
@@ -419,6 +424,7 @@ fn load_form() -> (FormState, Option<String>) {
auto_blacklist_window_secs: 30,
auto_blacklist_cooldown_secs: 120,
request_timeout_secs: 30,
exit_node: mhrv_rs::config::ExitNodeConfig::default(),
}
};
(form, load_err)
@@ -593,6 +599,10 @@ impl FormState {
auto_blacklist_window_secs: self.auto_blacklist_window_secs,
auto_blacklist_cooldown_secs: self.auto_blacklist_cooldown_secs,
request_timeout_secs: self.request_timeout_secs,
// Exit-node config (CF-anti-bot bypass for chatgpt.com / claude.ai
// / grok.com / x.com). Round-trip through FormState — config-only
// editing for now, UI editor planned for v1.9.x desktop UI batch.
exit_node: self.exit_node.clone(),
})
}
}
@@ -664,12 +674,26 @@ struct ConfigWire<'a> {
auto_blacklist_cooldown_secs: u64,
#[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
/// 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.
#[serde(skip_serializing_if = "is_default_exit_node")]
exit_node: &'a mhrv_rs::config::ExitNodeConfig,
}
fn is_default_strikes(v: &u32) -> bool { *v == 3 }
fn is_default_window_secs(v: &u64) -> bool { *v == 30 }
fn is_default_cooldown_secs(v: &u64) -> bool { *v == 120 }
fn is_default_timeout_secs(v: &u64) -> bool { *v == 30 }
fn is_default_exit_node(en: &&mhrv_rs::config::ExitNodeConfig) -> bool {
!en.enabled
&& en.relay_url.is_empty()
&& en.psk.is_empty()
&& en.hosts.is_empty()
&& (en.mode.is_empty() || en.mode == "selective")
}
fn is_false(b: &bool) -> bool {
!*b
@@ -724,6 +748,7 @@ impl<'a> From<&'a Config> for ConfigWire<'a> {
auto_blacklist_window_secs: c.auto_blacklist_window_secs,
auto_blacklist_cooldown_secs: c.auto_blacklist_cooldown_secs,
request_timeout_secs: c.request_timeout_secs,
exit_node: &c.exit_node,
}
}
}
+74
View File
@@ -332,6 +332,80 @@ pub struct Config {
/// no benefit).
#[serde(default = "default_request_timeout_secs")]
pub request_timeout_secs: u64,
/// Optional second-hop exit node, for sites that block traffic
/// from Google datacenter IPs (Apps Script's outbound IP space).
/// Most visibly: Cloudflare-fronted services that flag the GCP IP
/// block as bots — ChatGPT (chatgpt.com), Claude (claude.ai),
/// Grok (grok.com / x.com), and a long tail of CF-protected SaaS.
///
/// Architecture: chain becomes
/// `client → SNI rewrite → Apps Script (Google IP) → exit node
/// (val.town / Deno Deploy / 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
/// fire. mhrv-rs's DPI cover (Iran ISP only sees the SNI-rewritten
/// TLS to a Google IP) is unchanged — the second hop happens
/// inside Apps Script, invisible from the user's network.
///
/// Setup walkthrough at `assets/exit_node/README.md`. Default off.
#[serde(default)]
pub exit_node: ExitNodeConfig,
}
/// Configuration for the optional second-hop exit node.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ExitNodeConfig {
/// Master switch. Default false. Even with `relay_url` and `psk`
/// set, nothing routes through the exit node unless this is true.
#[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
/// exit node is what makes the outbound `fetch()` call to the
/// destination, so its IP is what the destination sees.
#[serde(default)]
pub relay_url: String,
/// Pre-shared key — must match the `PSK` constant in the exit-node
/// script. Without a matching PSK the exit node refuses the request
/// (401). The PSK is what keeps the exit node from being usable as
/// an open proxy by anyone who learns its URL. Treat like a
/// password: do not commit, rotate if leaked. Generate with
/// `openssl rand -hex 32`.
#[serde(default)]
pub psk: String,
/// `"selective"` (default): only hosts in `hosts` go through the
/// exit node; everything else takes the regular Apps Script path.
/// Recommended — the exit-node hop adds ~200-500 ms per request,
/// so reserve it for sites that need a non-Google IP.
///
/// `"full"`: every request goes through the exit node. Useful only
/// when the entire workload is CF-anti-bot affected, or when the
/// exit node happens to be faster than Apps Script alone for the
/// user's network path (rare but possible on very slow ISPs).
#[serde(default = "default_exit_node_mode")]
pub mode: String,
/// In `"selective"` mode, the list of destination hostnames that
/// route through the exit node. Matches exactly OR as a
/// dot-anchored suffix, mirroring `passthrough_hosts` semantics:
/// `"chatgpt.com"` covers `chatgpt.com` and `api.chatgpt.com` and
/// `auth.chatgpt.com` etc. Leading dots are stripped at load.
///
/// The recurring CF-anti-bot list from community reports:
/// `chatgpt.com`, `claude.ai`, `x.com`, `grok.com`. Extend for
/// any other CF-blocked sites you need.
#[serde(default)]
pub hosts: Vec<String>,
}
fn default_exit_node_mode() -> String {
"selective".into()
}
/// One multi-edge fronting group. Edge CDNs like Vercel and Fastly
+384
View File
@@ -151,6 +151,21 @@ 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.)
/// 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
/// common state), all relay traffic takes the regular Apps Script
/// path. When true, hosts matching `exit_node_hosts` (or all hosts
/// when `exit_node_full`) route through the exit-node URL inside
/// the Apps Script call.
exit_node_enabled: bool,
exit_node_url: String,
exit_node_psk: String,
exit_node_full: bool,
/// Pre-normalized (lowercased, leading-dot stripped) host list for
/// fast O(N) match in `exit_node_matches`.
exit_node_hosts: Vec<String>,
}
/// Aggregated stats for one remote host.
@@ -311,9 +326,54 @@ impl DomainFronter {
batch_timeout: Duration::from_secs(
config.request_timeout_secs.clamp(5, 300),
),
exit_node_enabled: config.exit_node.enabled
&& !config.exit_node.relay_url.is_empty()
&& !config.exit_node.psk.is_empty(),
exit_node_url: config
.exit_node
.relay_url
.trim_end_matches('/')
.to_string(),
exit_node_psk: config.exit_node.psk.clone(),
exit_node_full: matches!(
config.exit_node.mode.to_ascii_lowercase().as_str(),
"full"
),
exit_node_hosts: config
.exit_node
.hosts
.iter()
.map(|h| h.trim().trim_start_matches('.').to_ascii_lowercase())
.filter(|h| !h.is_empty())
.collect(),
})
}
/// True when the configured exit node should handle this URL.
/// In `selective` mode (default), checks the host against the
/// pre-normalized `exit_node_hosts` list (exact match OR
/// dot-anchored suffix, mirroring `passthrough_hosts` semantics).
/// In `full` mode, every URL routes through the exit node.
pub(crate) fn exit_node_matches(&self, url: &str) -> bool {
if !self.exit_node_enabled {
return false;
}
if self.exit_node_full {
return true;
}
let host = match extract_host(url) {
Some(h) => h,
None => return false,
};
let host_lc = host.to_ascii_lowercase();
for entry in &self.exit_node_hosts {
if host_lc == *entry || host_lc.ends_with(&format!(".{}", entry)) {
return true;
}
}
false
}
/// Per-batch HTTP round-trip timeout. Read by `tunnel_client` so the
/// `BATCH_TIMEOUT` constant doesn't have to be touched on every config
/// change. Clamped to `[5s, 300s]` at construction.
@@ -709,6 +769,40 @@ impl DomainFronter {
url
};
// Exit-node short-circuit: route through the configured second-hop
// relay (val.town / Deno Deploy / 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.)
// 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) {
let t0 = Instant::now();
match self.relay_via_exit_node(method, url, headers, body).await {
Ok(bytes) => {
self.record_site(
url,
false,
bytes.len() as u64,
t0.elapsed().as_nanos() as u64,
);
return bytes;
}
Err(e) => {
tracing::warn!(
"exit node failed for {}: {} — falling back to direct Apps Script",
url,
e
);
// fall through to the regular relay path below
}
}
}
// Range requests are partial-content responses; caching or
// coalescing them against a non-range key would be catastrophic
// (wrong bytes for the wrong consumer). The range-parallel
@@ -1185,6 +1279,185 @@ impl DomainFronter {
}
}
/// Send a request through the configured exit node, chained inside
/// an Apps Script call. Path:
///
/// ```text
/// client → SNI rewrite → Apps Script (Google IP)
/// → UrlFetchApp.fetch(exit_node_url)
/// → exit node (val.town, non-Google IP)
/// → fetch(real_url)
/// → response back through both layers
/// ```
///
/// Apps Script sees the outer call (URL = exit_node_url, method =
/// POST, body = inner relay JSON authenticated with the exit-node
/// PSK). The exit node sees the inner JSON, fetches the real
/// 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
/// 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.
async fn relay_via_exit_node(
&self,
method: &str,
url: &str,
headers: &[(String, String)],
body: &[u8],
) -> Result<Vec<u8>, FronterError> {
let inner_json = self.build_exit_node_inner_payload(method, url, headers, body)?;
// The outer payload is just a normal Apps Script relay request
// pointing at the exit-node URL with POST + the inner JSON as body.
// 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
// unwrap below.
let exit_url = self.exit_node_url.clone();
let outer_headers = vec![(
"Content-Type".to_string(),
"application/json".to_string(),
)];
let outer_payload =
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).
let app_body = self
.send_prebuilt_payload_through_relay(outer_payload)
.await?;
// val.town's JSON envelope: {s: u16, h: {...}, b: "<base64>"} on
// success, {e: "..."} on its own internal error.
parse_exit_node_response(&app_body)
}
/// Build the inner-layer payload that the exit node will execute.
/// Same wire shape as a normal `RelayRequest` (`{k, m, u, h, b, ct, r}`)
/// but `k` is the exit-node PSK rather than the user's Apps Script
/// `auth_key`, and we skip the random-padding field — padding only
/// helps DPI evasion on the Iran-side leg, which the inner payload
/// is invisible to (it's encrypted inside the Apps Script HTTPS
/// connection that the ISP can't inspect).
fn build_exit_node_inner_payload(
&self,
method: &str,
url: &str,
headers: &[(String, String)],
body: &[u8],
) -> Result<Vec<u8>, FronterError> {
let filtered = filter_forwarded_headers(headers);
let hmap = if filtered.is_empty() {
None
} else {
let mut m = serde_json::Map::with_capacity(filtered.len());
for (k, v) in &filtered {
m.insert(k.clone(), Value::String(v.clone()));
}
Some(m)
};
let b_encoded = if body.is_empty() {
None
} else {
Some(B64.encode(body))
};
let ct = if body.is_empty() {
None
} else {
find_header(headers, "content-type")
};
let req = RelayRequest {
k: &self.exit_node_psk,
m: method,
u: url,
h: hmap,
b: b_encoded,
ct,
r: false, // val.town returns its own JSON envelope, not raw HTTP
};
Ok(serde_json::to_vec(&req)?)
}
/// Drive the standard script-id rotation + TLS pool send path with
/// 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
/// has a different shape from Code.gs's raw-HTTP wrapping.
async fn send_prebuilt_payload_through_relay(
&self,
payload: Vec<u8>,
) -> Result<Vec<u8>, FronterError> {
let script_id = self.next_script_id();
let path = format!("/macros/s/{}/exec", script_id);
let mut entry = self.acquire().await?;
let req_head = format!(
"POST {path} HTTP/1.1\r\n\
Host: {host}\r\n\
Content-Type: application/json\r\n\
Content-Length: {len}\r\n\
Accept-Encoding: gzip\r\n\
Connection: keep-alive\r\n\
\r\n",
path = path,
host = self.http_host,
len = payload.len(),
);
entry.stream.write_all(req_head.as_bytes()).await?;
entry.stream.write_all(&payload).await?;
entry.stream.flush().await?;
let (mut status, mut resp_headers, mut resp_body) =
read_http_response(&mut entry.stream).await?;
// Follow Apps Script's /exec → /macros/.../exec redirect chain
// (typical: 1-2 hops to script.googleusercontent.com). Mirrors
// the redirect handling in do_relay_once_with.
for _ in 0..5 {
if !matches!(status, 301 | 302 | 303 | 307 | 308) {
break;
}
let Some(loc) = header_get(&resp_headers, "location") else {
break;
};
let (rpath, rhost) = parse_redirect(&loc);
let rhost = rhost.unwrap_or_else(|| self.http_host.to_string());
let req = format!(
"GET {rpath} HTTP/1.1\r\n\
Host: {rhost}\r\n\
Accept-Encoding: gzip\r\n\
Connection: keep-alive\r\n\
\r\n",
);
entry.stream.write_all(req.as_bytes()).await?;
entry.stream.flush().await?;
let (s, h, b) = read_http_response(&mut entry.stream).await?;
status = s;
resp_headers = h;
resp_body = b;
}
// Don't return to pool — the exit-node path is rare enough that
// the connection-reuse semantics aren't worth replicating here.
drop(entry);
if status != 200 {
let body_txt = String::from_utf8_lossy(&resp_body)
.chars()
.take(200)
.collect::<String>();
return Err(FronterError::Relay(format!(
"Apps Script HTTP {} (exit-node outer call): {}",
status, body_txt
)));
}
Ok(resp_body)
}
fn build_payload_json(
&self,
method: &str,
@@ -1860,6 +2133,117 @@ 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
/// 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
/// layer, not the inner field).
///
/// We synthesize a complete HTTP/1.1 response from these fields so the
/// MITM TLS write-back path sees the same shape it gets from the regular
/// Apps Script relay (status line + headers + body).
fn parse_exit_node_response(body: &[u8]) -> Result<Vec<u8>, FronterError> {
let v: Value = serde_json::from_slice(body).map_err(|e| {
FronterError::Relay(format!(
"exit-node response not valid JSON ({}): {}",
e,
String::from_utf8_lossy(&body[..body.len().min(200)])
))
})?;
// Surface val.town's internal errors clearly rather than as a 502
// from the outer envelope. The `{e: "..."}` shape is what the val.town
// 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!(
"exit node refused or errored: {}",
err_msg
)));
}
let status = v
.get("s")
.and_then(|x| x.as_u64())
.map(|n| n as u16)
.unwrap_or(502);
let body_b64 = v.get("b").and_then(|x| x.as_str()).unwrap_or("");
let body_bytes = if body_b64.is_empty() {
Vec::new()
} else {
B64.decode(body_b64).map_err(|e| {
FronterError::Relay(format!("exit-node body base64 decode failed: {}", e))
})?
};
// Reconstruct headers. Skip hop-by-hop / would-double-up headers
// (Content-Length comes from our own length count below; the outer
// Apps Script transport already handled Transfer-Encoding/chunked).
const SKIP_RESPONSE_HEADERS: &[&str] = &[
"content-length",
"transfer-encoding",
"connection",
"keep-alive",
];
let mut out = Vec::with_capacity(body_bytes.len() + 256);
let _ = std::io::Write::write_fmt(
&mut out,
format_args!("HTTP/1.1 {} {}\r\n", status, status_reason(status)),
);
if let Some(headers_obj) = v.get("h").and_then(|x| x.as_object()) {
for (k, v_val) in headers_obj {
let lc = k.to_ascii_lowercase();
if SKIP_RESPONSE_HEADERS.contains(&lc.as_str()) {
continue;
}
if let Some(val_str) = v_val.as_str() {
let _ = std::io::Write::write_fmt(
&mut out,
format_args!("{}: {}\r\n", k, val_str),
);
}
}
}
let _ = std::io::Write::write_fmt(
&mut out,
format_args!("Content-Length: {}\r\n\r\n", body_bytes.len()),
);
out.extend_from_slice(&body_bytes);
Ok(out)
}
/// Minimal HTTP status reason-phrase table for synthesizing status
/// lines in `parse_exit_node_response`. Browsers don't actually parse
/// the reason phrase (only the status code matters), but a recognizable
/// string makes log lines readable.
fn status_reason(status: u16) -> &'static str {
match status {
200 => "OK",
201 => "Created",
204 => "No Content",
301 => "Moved Permanently",
302 => "Found",
303 => "See Other",
304 => "Not Modified",
307 => "Temporary Redirect",
308 => "Permanent Redirect",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
408 => "Request Timeout",
429 => "Too Many Requests",
500 => "Internal Server Error",
502 => "Bad Gateway",
503 => "Service Unavailable",
504 => "Gateway Timeout",
_ => "Status",
}
}
fn extract_host(url: &str) -> Option<String> {
let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
let authority = after_scheme.split('/').next().unwrap_or("");