Merge branch 'main' into feat/google-ipv4-fetch

This commit is contained in:
v4g4b0nd_0x76
2026-04-22 13:54:41 +03:30
committed by GitHub
17 changed files with 403 additions and 269 deletions
+8 -2
View File
@@ -14,11 +14,17 @@ jobs:
fail-fast: false
matrix:
include:
# Pin to Ubuntu 22.04 (GLIBC 2.35) so the glibc builds run on any
# distro that's ≥ Ubuntu 22.04 / Debian 12 / Mint 21 / Fedora 36.
# ubuntu-latest points at 24.04 (GLIBC 2.39) which bakes in a
# too-new GLIBC symbol requirement and rejects loading on older
# distros. For users behind tight internet who literally can't
# dist-upgrade, this matters.
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
os: ubuntu-22.04
name: mhrv-rs-linux-amd64
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
os: ubuntu-22.04
name: mhrv-rs-linux-arm64
- target: x86_64-apple-darwin
os: macos-latest
Generated
+1 -1
View File
@@ -1494,7 +1494,7 @@ dependencies = [
[[package]]
name = "mhrv-rs"
version = "0.7.0"
version = "0.7.1"
dependencies = [
"base64 0.22.1",
"bytes",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "mhrv-rs"
version = "0.7.0"
version = "0.7.1"
edition = "2021"
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
license = "MIT"
+155 -130
View File
@@ -351,117 +351,103 @@ Original project: <https://github.com/masterking32/MasterHttpRelayVPN> by [@mast
## راهنمای فارسی
پورت Rust پروژهٔ [MasterHttpRelayVPN](https://github.com/masterking32/MasterHttpRelayVPN) از [@masterking32](https://github.com/masterking32). **تمام اعتبار ایده و پیاده‌سازی اصلی پایتون متعلق به ایشان است.** این نسخه فقط مدل `apps_script` را به‌صورت دو فایل اجرایی کوچک (CLI + رابط گرافیکی) بدون هیچ وابستگی run-time ارائه می‌دهد.
### این ابزار چیست؟
عبور رایگان از DPI با استفاده از Google Apps Script به‌عنوان رله، به‌همراه مخفی‌سازی SNI در TLS. سانسور ISP فکر می‌کند ترافیک شما به سمت `www.google.com` می‌رود؛ در پشت صحنه یک Apps Script که خودتان در اکانت گوگل خودتان دیپلوی کرده‌اید سایت اصلی را برای شما واکشی می‌کند.
یک پروکسی کوچک که روی سیستم خودتان اجرا می‌شود و ترافیک شما را از طریق یک اسکریپت رایگان که در حساب گوگل خودتان می‌سازید، عبور می‌دهد. `ISP` شما فقط یک اتصال `HTTPS` ساده به `www.google.com` می‌بیند و اجازه می‌دهد رد شود؛ در پشت پرده، اسکریپتی که خودتان منتشر می‌کنید سایت مقصد را برای شما می‌خواند و پاسخ را بازمی‌گرداند.
### چرا این نسخه؟
این نسخهٔ `Rust` از پروژهٔ اصلی [MasterHttpRelayVPN](https://github.com/masterking32/MasterHttpRelayVPN) اثر [@masterking32](https://github.com/masterking32) است. **تمام اعتبار ایده و نسخهٔ اصلی پایتون برای ایشان است.** این پورت همان روش را در قالب یک فایل اجرایی تک‌پارچه (~۳ مگابایت) بدون نیاز به نصب پایتون یا هیچ وابستگی دیگری ارائه می‌دهد.
نسخهٔ اصلی پایتون عالی است، اما نیاز به Python + نصب `cryptography` و `h2` و چند وابستگی سیستمی دارد. برای کاربرانی که PyPI فیلتر است یا Python ندارند این فرآیند خودش یک دردسر است. این پورت فقط یک فایل اجرایی ~۲.۵ مگابایتی است که دانلود می‌کنید و اجرا می‌کنید. تمام.
### برای چه کسی مفید است؟
### نحوهٔ کار
- کسانی که در شبکه‌های تحت سانسور قوی (مثل ایران) زندگی می‌کنند
- کسی که می‌خواهد بدون `VPN` تجاری، بدون نصب پایتون، و بدون پرداخت پول عبور کند
- کسی که حتی یک حساب گوگل رایگان دارد
مرورگر/تلگرام/xray شما با این ابزار به‌عنوان HTTP proxy یا SOCKS5 proxy صحبت می‌کند. ابزار ترافیک را از طریق TLS به یک IP گوگل می‌فرستد، اما SNI را `www.google.com` می‌گذارد. داخل TLS رمزگذاری‌شده، header به‌نام `Host: script.google.com` رد می‌شود. DPI فقط `www.google.com` را می‌بیند و اجازه عبور می‌دهد. Apps Script سایت مقصد را واکشی می‌کند و پاسخ را به شما بازمی‌گرداند.
### چه چیز لازم دارید؟
برای چند دامنهٔ متعلق به خود گوگل (`google.com`، `youtube.com`، `fonts.googleapis.com` و …) از همین تونل مستقیم استفاده می‌شود بدون عبور از Apps Script. این کار هم مشکل سهمیهٔ Apps Script را حل می‌کند و هم مشکل «User-Agent همیشه Google-Apps-Script است» را برای این دامنه‌ها از بین می‌برد. می‌توانید دامنه‌های بیشتری را از طریق `hosts` در config اضافه کنید.
۱. یک حساب گوگل (همان `Gmail` رایگان کافیست)
۲. مرورگر (`Firefox`، `Chrome`، `Edge`، …) یا برنامه‌ای که `HTTP proxy` یا `SOCKS5` قبول کند
۳. دسترسی به سیستم خودتان (مک / لینوکس / ویندوز)
### پلتفرم‌ها
### پنج مرحله برای راه‌اندازی
لینوکس (x86_64، aarch64)، مک‌اواس (x86_64، aarch64)، ویندوز (x86_64). فایل‌های آماده در [صفحهٔ releases](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases).
#### مرحلهٔ ۱ — ساخت اسکریپت در گوگل (فقط یک بار)
### محتوای هر release
۱. به <https://script.google.com> بروید و با حساب گوگل خودتان وارد شوید
۲. روی **`New project`** کلیک کنید و کد پیش‌فرض را پاک کنید
۳. محتوای فایل [`Code.gs`](https://github.com/masterking32/MasterHttpRelayVPN/blob/python_testing/Code.gs) را از ریپوی اصلی کپی کنید و داخل ویرایشگر بچسبانید
۴. بالای کد، خط `const AUTH_KEY = "..."` را پیدا کنید و مقدار آن را به یک رمز قوی و خاص خودتان تغییر دهید (یک رشتهٔ تصادفی حداقل ۱۶ کاراکتری کافی است، مثلاً `aK8f3xM9pQ2nL5vR`)
۵. روی دکمهٔ آبی **`Deploy`** در بالا سمت راست کلیک کنید و **`New deployment`** را بزنید
۶. **`Type`** را روی **`Web app`** بگذارید و این تنظیمات را اعمال کنید:
- **`Execute as`**: **`Me`**
- **`Who has access`**: **`Anyone`**
۷. روی **`Deploy`** کلیک کنید. گوگل یک **`Deployment ID`** نشان می‌دهد — رشتهٔ طولانی تصادفی که داخل آدرس `URL` است. کپی‌اش کنید؛ در برنامه لازم دارید
هر آرشیو شامل دو باینری و یک اسکریپت راه‌انداز است:
> **نکته:** اگر نمی‌دانید رمز `AUTH_KEY` چه بگذارید، یک رشتهٔ تصادفی ۱۶ تا ۲۴ کاراکتری بسازید. مهم فقط این است که **دقیقاً همان رشته** را در برنامه هم وارد کنید.
- `mhrv-rs` / `mhrv-rs.exe` — نسخهٔ CLI، برای سرور و استفادهٔ headless.
- `mhrv-rs-ui` / `mhrv-rs-ui.exe` — رابط گرافیکی دسکتاپ (egui). فرم تنظیمات، دکمه‌های Start/Stop/Test، آمار زنده، لاگ.
- `run.sh` / `run.command` / `run.bat` — اسکریپت راه‌انداز مخصوص هر سیستم‌عامل: اول CA را نصب می‌کند (نیاز به sudo/Administrator) بعد UI را اجرا می‌کند. **بار اول حتماً همین را اجرا کنید.**
#### مرحلهٔ ۲ — دانلود برنامه
نسخهٔ مک آرشیو `*-app.zip` هم دارد که داخلش `mhrv-rs.app` است — با دو بار کلیک از Finder اجرا می‌شود. ولی بار اول باید CA را نصب کنید (با `mhrv-rs --install-cert` یا همان `run.command`).
به [صفحهٔ Releases](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases) بروید و آرشیو مناسب سیستم‌عامل خود را دانلود و از حالت فشرده خارج کنید:
### مسیر فایل‌ها
| سیستم‌عامل | فایل مناسب |
|---|---|
| مک اپل‌سیلیکون (`M1` / `M2` / …) | `mhrv-rs-macos-arm64-app.zip` (قابل دوبار کلیک در `Finder`) |
| مک اینتل | `mhrv-rs-macos-amd64-app.zip` |
| ویندوز | `mhrv-rs-windows-amd64.zip` |
| لینوکس معمولی (اوبونتو، مینت، دبیان، فدورا، آرچ، …) | `mhrv-rs-linux-amd64.tar.gz` |
| لینوکس روی روتر (`OpenWRT`) یا `Alpine` | `mhrv-rs-linux-musl-amd64.tar.gz` |
Config و ریشهٔ MITM در پوشهٔ کاربر سیستم‌عامل قرار می‌گیرند:
> اگر نمی‌دانید مک شما `M1/M2` است یا اینتل: منوی اپل → `About This Mac` → در خط **`Chip`** اگر **`Apple`** نوشته شده، `arm64` بگیرید؛ اگر **`Intel`**، `amd64`.
>
> کاربران اوبونتو ۲۰.۰۴ یا سیستم‌های خیلی قدیمی که خطای `GLIBC not found` می‌گیرند: آرشیو `linux-musl-amd64` را دانلود کنید — اجرا می‌شود.
- مک: `~/Library/Application Support/mhrv-rs/`
- لینوکس: `~/.config/mhrv-rs/`
- ویندوز: `%APPDATA%\mhrv-rs\`
#### مرحلهٔ ۳ — اجرای بار اول (نصب گواهی محلی)
داخل این پوشه: `config.json`، `ca/ca.crt` (گواهی عمومی) و `ca/ca.key` (کلید خصوصی — فقط روی سیستم شماست و هرگز جایی ارسال نمی‌شود).
برای اینکه برنامه بتواند ترافیک `HTTPS` مرورگر شما را باز کند و از طریق `Apps Script` رد کند، یک گواهی امنیتی کوچک **روی سیستم خودتان** می‌سازد و به سیستم‌عامل می‌گوید به آن اعتماد کند.
### مراحل راه‌اندازی
**کاری که باید بکنید (خودکار است):**
#### ۱. دیپلوی Apps Script (یک بار)
| سیستم‌عامل | روش |
|---|---|
| مک | روی `run.command` دو بار کلیک کنید |
| ویندوز | روی `run.bat` دو بار کلیک کنید |
| لینوکس | در ترمینال دستور `./run.sh` را اجرا کنید |
این بخش دقیقاً همان نسخهٔ اصلی است:
**فقط یک بار** رمز سیستم (`sudo` در مک/لینوکس یا `UAC` در ویندوز) می‌خواهد تا گواهی را نصب کند. بعد از آن برنامه باز می‌شود و در اجراهای بعدی می‌توانید مستقیماً از فایل اصلی (`mhrv-rs.app` در مک، `mhrv-rs-ui.exe` در ویندوز) استفاده کنید.
۱. به <https://script.google.com> بروید و با اکانت گوگل وارد شوید.
۲. **New project** بزنید و کد پیش‌فرض را پاک کنید.
۳. محتوای [`Code.gs`](https://github.com/masterking32/MasterHttpRelayVPN/blob/python_testing/Code.gs) ([لینک raw](https://raw.githubusercontent.com/masterking32/MasterHttpRelayVPN/refs/heads/python_testing/Code.gs)) را از ریپو اصلی کپی و Paste کنید.
۴. خط `const AUTH_KEY = "..."` را به یک رمز قوی و مختص خودتان تغییر دهید.
۵. **Deploy → New deployment → Web app**
- Execute as: **Me**
- Who has access: **Anyone**
۶. **Deployment ID** را کپی کنید (رشتهٔ تصادفی طولانی داخل URL).
**امنیت این گواهی:**
#### ۲. دانلود
- گواهی **کاملاً روی سیستم شما** ساخته می‌شود. کلید خصوصی هیچ‌وقت از کامپیوترتان خارج نمی‌شود
- هیچ سرور راه دوری — از جمله خود گوگل — نمی‌تواند با این گواهی خودش را جای سایت‌ها جا بزند
- هر وقت خواستید می‌توانید گواهی را حذف کنید (بخش **[حذف گواهی](#سوالات-رایج)** را ببینید)
آرشیو پلتفرم خود را از [صفحهٔ releases](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases) بگیرید و extract کنید.
> **اگر نمی‌خواهید از اسکریپت راه‌انداز استفاده کنید**، می‌توانید مرحلهٔ گواهی را دستی انجام دهید:
>
> - مک/لینوکس: `sudo ./mhrv-rs --install-cert`
> - ویندوز (با `Run as administrator`): `mhrv-rs.exe --install-cert`
#### ۳. اجرای بار اول: نصب گواهی MITM
#### مرحلهٔ ۴ — تنظیمات در برنامه
برای اینکه ترافیک HTTPS مرورگر از طریق Apps Script رد شود، `mhrv-rs` باید TLS را **روی سیستم خودتان** باز کند، درخواست را از رله بفرستد، و پاسخ را با یک گواهی که مرورگر شما trust می‌کند دوباره رمزگذاری کند. این کار یک **Certificate Authority محلی** کوچک نیاز دارد.
پنجرهٔ برنامه باز می‌شود. این فیلدها را پر کنید:
**چه اتفاقی در اجرای بار اول می‌افتد:**
| فیلد | مقدار |
|---|---|
| **`Apps Script ID(s)`** | همان `Deployment ID` مرحلهٔ ۱ را paste کنید |
| **`Auth key`** | همان رمز `AUTH_KEY` که داخل `Code.gs` گذاشتید |
| **`Google IP`** | پیش‌فرض `216.239.38.120` معمولاً خوب است. دکمهٔ `scan` کنارش IPهای دیگر گوگل را تست می‌کند و سریع‌ترین را نشان می‌دهد |
| **`Front domain`** | پیش‌فرض `www.google.com` را نگه دارید |
| **`HTTP port`** / **`SOCKS5 port`** | پیش‌فرض‌های `8085` و `8086` خوب‌اند |
- یک keypair تازهٔ CA (`ca/ca.crt` + `ca/ca.key`) **روی سیستم شما** در پوشهٔ user-data ساخته می‌شود.
- فایل عمومی `ca.crt` به trust store سیستم اضافه می‌شود تا مرورگر گواهی‌های per-site که `mhrv-rs` on-the-fly می‌سازد را بپذیرد. همین مرحله است که sudo / Administrator می‌خواهد.
- کلید خصوصی `ca.key` **هرگز از سیستم شما خارج نمی‌شود**. جایی آپلود نمی‌شود، با هیچ سرور راه دوری تماس گرفته نمی‌شود، و هیچ طرف دیگری — از جمله رلهٔ Apps Script — نمی‌تواند با آن خودش را جای سایت‌ها جا بزند.
- هر وقت خواستید می‌توانید حذفش کنید: keychain مک (Keychain Access → System → `mhrv-rs` را حذف کنید) / cert store ویندوز / `/etc/ca-certificates` در لینوکس، به‌علاوهٔ پاک کردن پوشهٔ `ca/`.
بعد روی **`Save config`** و سپس **`Start`** کلیک کنید. هر وقت خواستید وضعیت را تست کنید، دکمهٔ **`Test`** را بزنید — یک درخواست کامل می‌فرستد و نتیجه را نشان می‌دهد.
اسکریپت راه‌انداز همهٔ این کارها را برایتان انجام می‌دهد و بعد UI را باز می‌کند:
#### مرحلهٔ ۵ — تنظیم مرورگر یا اپلیکیشن
- **مک**: روی `run.command` دو بار کلیک کنید (یا از ترمینال `./run.command`).
- **لینوکس**: در ترمینال `./run.sh`.
- **ویندوز**: روی `run.bat` دو بار کلیک کنید.
برنامه روی دو پورت منتظر است:
اسکریپت **فقط** برای trust کردن CA رمز شما را می‌خواهد (sudo یا UAC). بعد از آن UI هم باز می‌شود، و در اجراهای بعدی دیگر لازم نیست از launcher استفاده کنید — مستقیماً `mhrv-rs.app` یا `mhrv-rs-ui.exe` یا `mhrv-rs-ui` را اجرا کنید.
- **`HTTP proxy`** روی `127.0.0.1:8085` — برای مرورگرها
- **`SOCKS5 proxy`** روی `127.0.0.1:8086` — برای تلگرام / `xray` / بقیهٔ اپلیکیشن‌ها
اگر ترجیح می‌دهید مرحلهٔ CA را دستی انجام دهید:
```bash
# لینوکس/مک
sudo ./mhrv-rs --install-cert
# ویندوز (به‌عنوان Administrator)
mhrv-rs.exe --install-cert
```
Firefox cert store خودش را جدا دارد؛ installer تلاش می‌کند از طریق `certutil` گواهی را داخل NSS فایرفاکس هم بیندازد (best-effort). اگر فایرفاکس هنوز شکایت کرد، خودتان دستی `ca/ca.crt` را از Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import اضافه کنید.
#### ۴. تنظیمات در UI
فرم را پر کنید:
- **Apps Script ID** — همان Deployment ID مرحلهٔ ۱. برای استفاده از چند deployment به‌صورت round-robin، با کاما جدا کنید.
- **Auth key** — همان رمز `AUTH_KEY` داخل `Code.gs`.
- **Google IP** — پیش‌فرض `216.239.38.120` خوب است. دکمهٔ **scan** کنارش IPهای دیگر گوگل را از شبکهٔ شما تست می‌کند و سریع‌ترین را معرفی می‌کند.
- **Front domain** — همان `www.google.com` را نگه دارید.
- **HTTP port** / **SOCKS5 port** — پیش‌فرض‌ها `8085` و `8086`.
**Save** بعد **Start**. دکمهٔ **Test** در هر زمان یک درخواست کامل از طریق رله می‌فرستد و نتیجه را گزارش می‌دهد.
#### ۴ (جایگزین). فقط CLI
هر کاری که UI می‌کند از CLI هم قابل انجام است. `config.example.json` را به `config.json` کپی و مقادیر را پر کنید، بعد:
```bash
./mhrv-rs # اجرای proxy
./mhrv-rs test # تست یک درخواست کامل
./mhrv-rs scan-ips # رتبه‌بندی IPهای گوگل بر اساس تأخیر
./mhrv-rs --install-cert # نصب مجدد CA
./mhrv-rs --help
```
**فایرفاکس (ساده‌ترین):**
#### پیکربندی scan-ips (اختیاری)
@@ -489,33 +475,38 @@ Firefox cert store خودش را جدا دارد؛ installer تلاش می‌ک
با استفاده از این گزینه‌ها ممکن است IPهایی پیدا کنید که سریع‌تر از آرایه ثابت پیش‌فرض هستند اما تضمینی وجود ندارد که این IPها کار کنند.
#### ۵. تنظیم proxy در کلاینت
۱. منوی `Settings` را باز کنید، در خانهٔ جست‌وجو عبارت `proxy` را تایپ کنید
۲. روی **`Network Settings`** کلیک کنید
۳. گزینهٔ **`Manual proxy configuration`** را انتخاب کنید
۴. در فیلد **`HTTP Proxy`** آدرس `127.0.0.1` و پورت `8085` را بگذارید
۵. تیک **`Also use this proxy for HTTPS`** را بزنید
۶. `OK` را بزنید
ابزار روی **دو** پورت گوش می‌دهد:
**کروم یا Edge:** از تنظیمات `proxy` سیستم‌عامل استفاده می‌کنند. ساده‌ترین راه نصب افزونهٔ **`Proxy SwitchyOmega`** و تنظیم آن روی `127.0.0.1:8085` است.
**HTTP proxy** (مرورگرها) — `127.0.0.1:8085`
**تلگرام:**
- **Firefox** — Settings → Network Settings → **Manual proxy**. HTTP برابر `127.0.0.1`، port `8085`، تیک **Also use this proxy for HTTPS**.
- **Chrome / Edge** — از تنظیمات proxy سیستم یا افزونهٔ **Proxy SwitchyOmega** استفاده کنید.
- **مک (system-wide)** — System Settings → Network → Wi-Fi → Details → Proxies → **Web Proxy (HTTP)** و **Secure Web Proxy (HTTPS)** را فعال کنید، هر دو `127.0.0.1:8085`.
- **ویندوز (system-wide)** — Settings → Network & Internet → Proxy → **Manual proxy setup**، address `127.0.0.1`، port `8085`.
۱. `Settings``Advanced``Connection type`
۲. **`Use custom proxy`** → **`SOCKS5`**
۳. هاست `127.0.0.1`، پورت `8086`، نام کاربری و رمز را خالی بگذارید
۴. `Save` بزنید
**SOCKS5 proxy** (تلگرام، xray، کلاینت‌های app-level) — `127.0.0.1:8086`، بدون auth.
> **نکتهٔ مهم دربارهٔ تلگرام:** اگر فقط این ابزار را استفاده کنید، تلگرام ممکن است مرتب قطع و وصل شود، چون `Apps Script` پروتکل `MTProto` تلگرام را نمی‌فهمد. برای پایداری کامل تلگرام، بخش [**تلگرام پایدار با xray**](#تلگرام-و-غیره--جفت-کردن-با-xray) را ببینید.
برای HTTP و HTTPS و **هم** پروتکل‌های غیر-HTTP (MTProto تلگرام، TCP خام) کار می‌کند. ابزار به‌صورت هوشمند تشخیص می‌دهد: HTTP/HTTPS از رلهٔ Apps Script می‌رود، دامنه‌های قابل SNI-rewrite از تونل مستقیم لبهٔ گوگل، و بقیه به TCP خام می‌افتد.
### از کجا بفهمم کار می‌کند؟
### تلگرام، IMAP، SSH — با xray جفت کنید (اختیاری)
۱. در پنجرهٔ برنامه، وضعیت باید **`Status: running`** باشد (سبز رنگ)
۲. دکمهٔ **`Test`** را بزنید — اگر سبز شد، سرویس سالم است
۳. در مرورگر به <https://icanhazip.com> بروید — `IP` نمایش داده‌شده باید متفاوت از `IP` واقعی شما باشد (آی‌پی گوگل)
۴. اگر مشکلی بود، پنل **`Recent log`** پایین برنامه را نگاه کنید
رلهٔ Apps Script فقط HTTP request/response می‌فهمد، پس پروتکل‌های غیر-HTTP (MTProto تلگرام، IMAP، SSH، TCP خام) از داخلش عبور نمی‌کنند. بدون کار اضافه این جور ترافیک به مسیر TCP مستقیم می‌افتد — یعنی واقعاً tunnel نمی‌شود و اگر ISP تلگرام را بلاک کرده باشد، همچنان بلاک است.
### تلگرام و غیره — جفت کردن با xray
راه حل: یک [xray](https://github.com/XTLS/Xray-core) (یا v2ray / sing-box) با outbound VLESS/Trojan/Shadowsocks به یک VPS شخصی خودتان بالا بیاورید، و mhrv-rs را از طریق فیلد **Upstream SOCKS5** در UI (یا کلید `upstream_socks5` در config) به SOCKS5 inbound آن وصل کنید. با این کار ترافیک TCP خامی که از SOCKS5 mhrv-rs رد می‌شود، به‌جای اتصال مستقیم، از xray رد شده و به تونل واقعی می‌رسد.
`Apps Script` فقط `HTTP` می‌فهمد، پس پروتکل‌های دیگر (مثل `MTProto` تلگرام، `IMAP` ایمیل، `SSH`، …) مستقیماً از آن رد نمی‌شوند. نتیجه: اگر `ISP` تلگرام را با `DPI` بلاک کرده باشد، همچنان بلاک است.
```
تلگرام ┐ ┌─ Apps Script ── HTTP/HTTPS
├─ SOCKS5 :8086 ┤ mhrv-rs ├─ SNI rewrite ─── google.com, youtube.com, …
مرورگر ┘ └─ upstream SOCKS5 ─ xray ── VLESS ── VPS شما (تلگرام، IMAP، SSH، TCP خام)
```
**راه‌حل:** یک [`xray`](https://github.com/XTLS/Xray-core) (یا `v2ray` یا `sing-box`) روی سیستم خودتان اجرا کنید که با `VLESS` / `Trojan` / `Shadowsocks` به یک سرور `VPS` شخصی وصل می‌شود. بعد در برنامهٔ `mhrv-rs`، فیلد **`Upstream SOCKS5`** را با آدرس `xray` پر کنید (مثلاً `127.0.0.1:50529`).
قطعه‌ای از config:
بعد از این کار، ترافیکی که `HTTP` نیست (مثل تلگرام) از `xray` عبور می‌کند و به سرور شما می‌رسد. ترافیک `HTTP/HTTPS` مثل قبل از `Apps Script` می‌رود، پس مرورگر شما دست نخورده کار می‌کند.
```json
{
@@ -523,66 +514,100 @@ Firefox cert store خودش را جدا دارد؛ installer تلاش می‌ک
}
```
HTTP/HTTPS هیچ تغییری نمی‌کند (همچنان از Apps Script می‌رود) و تونل SNI-rewrite برای `google.com` / `youtube.com` / … هم سر جای خودش است — پس یوتوب مثل قبل سریع می‌ماند و تلگرام بالاخره یک تونل واقعی می‌گیرد.
### ویرایشگر SNI pool
به‌صورت پیش‌فرض `mhrv-rs` بین `{www, mail, drive, docs, calendar}.google.com` روی اتصال‌های TLS خروجی به IP گوگل می‌چرخد تا یک نام تنها fingerprint نشود. بعضی از این‌ها ممکن است در شبکهٔ شما بلاک باشند — مثلاً `mail.google.com` در ایران چند بار هدف گرفته شده.
به‌صورت پیش‌فرض برنامه بین چند نام گوگل می‌چرخد (`www.google.com`، `mail.google.com`، `drive.google.com`، `docs.google.com`، `calendar.google.com`) تا اثر انگشت ترافیک شما یکنواخت نباشد. اما بعضی از این نام‌ها گاهی در شبکهٔ شما بلاک می‌شوند — مثلاً `mail.google.com` در ایران چند بار هدف قرار گرفته.
یا:
**برای بررسی و ویرایش:**
- UI را باز کنید، روی **SNI pool…** کلیک کنید، **Test all** را بزنید، بعد **Keep ✓ only** برای trim خودکار. از textbox پایین می‌توانید نام‌های دلخواه اضافه کنید. Save بزنید.
- یا مستقیماً در `config.json`:
۱. روی دکمهٔ آبی **`SNI pool…`** در برنامه کلیک کنید
۲. دکمهٔ **`Test all`** را بزنید — هر نام را تست می‌کند و نتیجه را کنارش نشان می‌دهد (`ok` یا `fail`)
۳. دکمهٔ **`Keep working only`** را بزنید — همه نام‌هایی که پاسخ ندادند را غیرفعال می‌کند
۴. اگر نام جدیدی می‌خواهید اضافه کنید، در کادر پایین نام را بنویسید و **`+ Add`** بزنید — خودکار تست می‌شود
۵. با **`Save config`** در پنجرهٔ اصلی ذخیره کنید
```json
{
"sni_hosts": ["www.google.com", "drive.google.com", "docs.google.com"]
}
```
### اجرا روی OpenWRT (روتر)
اگر `sni_hosts` را نگذارید، pool پیش‌فرض اعمال می‌شود. قبل از ذخیره، `mhrv-rs test-sni` را اجرا کنید تا ببینید چه نامی از شبکهٔ شما رد می‌شود.
### اجرا روی OpenWRT (یا هر سیستم musl)
آرشیوهای `*-linux-musl-*` یک CLI کاملاً static می‌دهند که روی OpenWRT، Alpine و هر userland لینوکسی بدون glibc اجرا می‌شود. باینری را روی روتر بگذارید و به‌عنوان سرویس راه بیندازید:
اگر می‌خواهید برنامه را روی روترتان اجرا کنید تا همهٔ دستگاه‌های شبکه از آن استفاده کنند، آرشیو `mhrv-rs-linux-musl-*.tar.gz` را دانلود کنید (این نسخه فایل اجرایی استاتیک دارد و بدون نصب هیچ وابستگی روی روتر کار می‌کند).
```sh
# از یک ماشین که به روترتان می‌رسد:
# از کامپیوتری که به روترتان دسترسی دارد:
scp mhrv-rs root@192.168.1.1:/usr/bin/mhrv-rs
scp mhrv-rs.init root@192.168.1.1:/etc/init.d/mhrv-rs
scp config.json root@192.168.1.1:/etc/mhrv-rs/config.json
# روی خود روتر:
# روی خود روتر (ssh کنید به روتر):
chmod +x /usr/bin/mhrv-rs /etc/init.d/mhrv-rs
/etc/init.d/mhrv-rs enable
/etc/init.d/mhrv-rs start
logread -e mhrv-rs -f
```
بعدش دستگاه‌های LAN، proxy HTTP خودشان را روی IP روتر پورت `8085` (یا SOCKS5 روی `8086`) بگذارند. در `/etc/mhrv-rs/config.json` مقدار `listen_host` را به `0.0.0.0` تغییر دهید تا روتر از LAN هم connection بپذیرد (نه فقط localhost).
در فایل `config.json`، مقدار `listen_host` را به `0.0.0.0` تغییر دهید تا روتر از همهٔ دستگاه‌های `LAN` اتصال بپذیرد. بعد در هر دستگاه، `HTTP proxy` را روی آی‌پی روتر پورت `8085` (یا `SOCKS5` روی `8086`) تنظیم کنید.
مصرف حافظه حدود ۱۵-۲۰ مگابایت است — روی هر روتری با حداقل ۱۲۸ مگابایت RAM اجرا می‌شود. UI برای musl ساخته نمی‌شود (روترها بدون صفحه‌نمایش هستند).
مصرف حافظه حدود ۱۵ تا ۲۰ مگابایت است — روی هر روتری با حداقل ۱۲۸ مگابایت `RAM` اجرا می‌شود.
### سوالات رایج
**چرا باید گواهی نصب کنم؟ امن است؟**
برنامه برای اینکه بتواند ترافیک `HTTPS` شما را باز کند و از طریق `Apps Script` رد کند، به یک گواهی محلی نیاز دارد. این گواهی **فقط روی سیستم خودتان** ساخته می‌شود و کلید خصوصی هیچ‌وقت جایی ارسال نمی‌شود. هیچ کس — حتی خود گوگل — نمی‌تواند با این گواهی به ترافیک شما دسترسی پیدا کند.
**چطور گواهی را بعداً حذف کنم؟**
- **مک:** `Keychain Access` را باز کنید، در بخش `System` دنبال `mhrv-rs` بگردید و حذف کنید. سپس پوشهٔ `~/Library/Application Support/mhrv-rs/ca/` را پاک کنید
- **ویندوز:** `certmgr.msc` را اجرا کنید → `Trusted Root Certification Authorities``Certificates` → دنبال `mhrv-rs` بگردید و حذف کنید
- **لینوکس:** فایل `/usr/local/share/ca-certificates/mhrv-rs.crt` را حذف و `sudo update-ca-certificates` اجرا کنید
**چند `Deployment ID` لازم دارم؟**
یکی برای استفادهٔ عادی کافی است. هر حساب گوگل روزانه حدود ۲ میلیون درخواست سهمیه دارد. اگر مصرف بالا دارید یا سرعت کم شده، در حساب‌های گوگل دیگر `Deployment` بسازید و همهٔ `Deployment ID`ها را در فیلد `Apps Script ID(s)` یک در هر خط وارد کنید — برنامه خودکار بینشان می‌چرخد.
**یوتوب کار می‌کند؟ ویدیو پخش می‌شود؟**
صفحهٔ یوتوب سریع باز می‌شود (چون مستقیم از لبهٔ گوگل می‌آید). اما `chunk`های ویدیوی اصلی از `googlevideo.com` از طریق `Apps Script` می‌آیند و روزانه سهمیه دارند. برای تماشای گاه‌به‌گاه خوب است، برای ۱۰۸۰p پخش طولانی دردناک.
**`ChatGPT` یا `OpenAI` کار می‌کنند؟**
استریم زنده (`streaming`) آن‌ها کار نمی‌کند چون از `WebSocket` استفاده می‌کنند و `Apps Script` آن را پشتیبانی نمی‌کند. تنها راه‌حل: از `xray` استفاده کنید (بخش **تلگرام و غیره** را ببینید).
**خطای `GLIBC_2.39 not found` در لینوکس می‌گیرم. چه کنم؟**
از نسخهٔ `v0.7.1` به بعد این مشکل حل شده. اما اگر روی سیستم خیلی قدیمی هستید، آرشیو `mhrv-rs-linux-musl-amd64.tar.gz` را دانلود کنید — این نسخه بدون نیاز به `glibc` روی هر لینوکسی اجرا می‌شود.
**می‌توانم با `CLI` هم استفاده کنم (بدون رابط گرافیکی)؟**
بله. فایل `config.example.json` را به `config.json` کپی کنید، مقادیر را پر کنید، و این دستورات را بزنید:
```bash
./mhrv-rs # اجرای پروکسی
./mhrv-rs test # تست یک درخواست کامل
./mhrv-rs scan-ips # رتبه‌بندی IPهای گوگل بر اساس سرعت
./mhrv-rs test-sni # تست نام‌های SNI در pool
./mhrv-rs --install-cert # نصب مجدد گواهی
./mhrv-rs --help
```
**چرا گاهی جست‌وجوی گوگل بدون `JavaScript` نشان داده می‌شود؟**
`Apps Script` مجبور است `User-Agent` درخواست‌های خود را روی `Google-Apps-Script` بگذارد. بعضی سایت‌ها این را به عنوان ربات شناسایی می‌کنند و نسخهٔ سادهٔ بدون `JavaScript` برمی‌گردانند. دامنه‌هایی که در لیست `SNI-rewrite` قرار گرفته‌اند (مثل `google.com`، `youtube.com`) از این مشکل در امان هستند چون مستقیماً از لبهٔ گوگل می‌آیند، نه از `Apps Script`.
**ورود به حساب گوگل با این ابزار ایمن است؟**
توصیه می‌شود اولین بار بدون این پروکسی یا با `VPN` واقعی وارد شوید، چون گوگل ممکن است `IP` `Apps Script` را به‌عنوان «دستگاه ناشناس» ببیند و هشدار بدهد. بعد از ورود اولیه، استفاده بی‌مشکل است.
### محدودیت‌های شناخته‌شده
این‌ها محدودیت‌های ذاتی روش Apps Script + SNI هستند، نه باگ در این کلاینت. نسخهٔ اصلی پایتون هم دقیقاً همین‌ها را دارد.
این محدودیت‌ها ذاتی روش `Apps Script` هستند، نه باگ این برنامه. نسخهٔ اصلی پایتون هم دقیقاً همین محدودیت‌ها را دارد.
- **User-Agent همیشه `Google-Apps-Script` است** برای هر چیزی که از رله رد می‌شود. `UrlFetchApp.fetch()` گوگل اجازهٔ تغییر این را نمی‌دهد. نتیجه: سایت‌هایی که ربات را تشخیص می‌دهند (مثل جست‌وجوی `google.com`، بعضی CAPTCHAها) نسخهٔ سادهٔ بدون JS را نشان می‌دهند. راه‌حل: دامنهٔ موردنظر را به `hosts` در `config.json` اضافه کنید تا از تونل SNI-rewrite (با UA واقعی مرورگر) رد شود. `google.com`، `youtube.com`، `fonts.googleapis.com` از قبل در این لیست هستند.
- **پخش ویدیو کند است و سهمیه دارد** برای چیزهایی که از رله رد می‌شوند. HTML یوتوب از تونل می‌آید (سریع)، اما chunkهای ویدیو از `googlevideo.com` از طریق Apps Script می‌آیند. هر اکانت consumer گوگل روزانه ~۲ میلیون `UrlFetchApp` call و سقف ۵۰ مگابایت روی هر fetch دارد. برای مرور متنی عالی است، برای ۱۰۸۰p دردناک. چند `script_id` بگذارید، یا برای ویدیو از VPN واقعی استفاده کنید.
- **Brotli فیلتر می‌شود** از header ارسالی `Accept-Encoding`. Apps Script می‌تواند gzip باز کند اما brotli نه، و اگر `br` را رد کنیم پاسخ خراب می‌شود. gzip فعال است. سربار حجمی جزئی.
- **WebSocket کار نمی‌کند** از طریق رله (تک request/response JSON است). سایت‌هایی که به WS ارتقا می‌دهند fail می‌کنند (streaming ChatGPT، voice دیسکورد و غیره).
- **سایت‌های HSTS-preloaded / pin-شده** گواهی MITM را قبول نمی‌کنند. اکثر سایت‌ها مشکلی ندارند چون CA ما trust شده، ولی چند مورد استثنا هستند.
- **ورود دومرحله‌ای گوگل/یوتوب** ممکن است «دستگاه ناشناس» هشدار بدهد چون درخواست از IP Apps Script می‌آید نه IP شما. یک بار با تونل (`google.com` از قبل در لیست است) لاگین کنید.
- `User-Agent` همهٔ درخواست‌ها ثابت روی `Google-Apps-Script` است (گوگل اجازهٔ تغییر نمی‌دهد). بعضی سایت‌ها به‌خاطر این نسخهٔ ساده‌شدهٔ بدون `JavaScript` نشان می‌دهند
- پخش ویدیو سهمیه دارد و ممکن است کند باشد (هر حساب گوگل روزانه حدود ۲ میلیون درخواست سهمیه دارد)
- فشرده‌سازی `Brotli` پشتیبانی نمی‌شود (فقط `gzip` سربار حجمی جزئی
- `WebSocket` از `Apps Script` عبور نمی‌کند (`ChatGPT` استریم، `Discord voice`، …)
- سایت‌هایی که گواهی خود را `pin` کرده‌اند گواهی `MITM` برنامه را قبول نمی‌کنند (تعداد کمی‌اند)
- ورود دومرحله‌ای گوگل ممکن است هشدار «دستگاه ناشناس» بدهد — اولین ورود را بدون این ابزار انجام دهید
### امنیت
- ریشهٔ MITM **فقط روی سیستم شما می‌ماند**. کلید خصوصی `ca/ca.key` محلی ساخته می‌شود و هرگز از user-data dir خارج نمی‌شود.
- `auth_key` بین کلاینت و Apps Script یک secret مشترک است که خودتان انتخاب می‌کنید. کد سرور (`Code.gs`) هر درخواستی را که این کلید را نداشته باشد رد می‌کند.
- ترافیک بین سیستم شما و لبهٔ گوگل TLS 1.3 استاندارد است.
- آنچه گوگل می‌بیند: URL و headerهای درخواست (چون Apps Script به‌جای شما fetch می‌کند). این دقیقاً همان trust model هر proxy میزبانی‌شده است — اگر قابل قبول نیست از VPN self-hosted استفاده کنید.
- ریشهٔ `MITM` **فقط روی سیستم شما می‌ماند**. کلید خصوصی هیچ‌وقت از سیستمتان خارج نمی‌شود
- `auth_key` یک رمز اختصاصی بین شما و اسکریپت شماست. کد سرور هر درخواستی را که این رمز را نداشته باشد رد می‌کند
- ترافیک بین شما و گوگل، `TLS 1.3` استاندارد است
- آنچه گوگل می‌بیند: آدرس `URL` و هدرهای درخواست شما (چون `Apps Script` به‌جای شما `fetch` می‌کند). این همان سطح اعتماد هر پروکسی میزبانی‌شده است — اگر قابل قبول نیست، از `VPN` روی سرور شخصی خودتان استفاده کنید
### اعتبار
پروژهٔ اصلی: <https://github.com/masterking32/MasterHttpRelayVPN> توسط [@masterking32](https://github.com/masterking32). ایده، پروتکل Apps Script، معماری proxy و نگهداری همه متعلق به ایشان است. این پورت Rust فقط برای ساده‌کردن توزیع سمت کلاینت درست شده.
پروژهٔ اصلی: <https://github.com/masterking32/MasterHttpRelayVPN> توسط [@masterking32](https://github.com/masterking32). ایده، پروتکل `Apps Script`، و معماری پروکسی همه متعلق به ایشان است. این پورت `Rust` فقط برای ساده‌تر کردن توزیع سمت کلاینت درست شده.
</div>
+2 -2
View File
@@ -2,7 +2,7 @@
This folder contains the prebuilt binaries from the latest release, committed directly to the repository for users who cannot reach the GitHub Releases page.
Current version: **v0.7.0**
Current version: **v0.7.1**
| File | Platform | Contents |
|---|---|---|
@@ -49,7 +49,7 @@ See the [main README](../README.md) for full setup (Apps Script deployment, conf
این پوشه شامل فایل‌های آخرین نسخه است و مستقیماً در ریپو قرار گرفته برای کاربرانی که به صفحهٔ GitHub Releases دسترسی ندارند.
نسخهٔ فعلی: **v0.7.0**
نسخهٔ فعلی: **v0.7.1**
### دانلود از طریق ZIP
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+163 -82
View File
@@ -39,7 +39,7 @@ fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([WIN_WIDTH, WIN_HEIGHT])
.with_min_inner_size([420.0, 540.0])
.with_min_inner_size([420.0, 400.0])
.with_title(format!("mhrv-rs {}", VERSION)),
..Default::default()
};
@@ -95,10 +95,16 @@ enum Cmd {
PollStats,
/// Probe a single SNI against the given google_ip. Result is written
/// into UiState::sni_probe keyed by the SNI string.
TestSni { google_ip: String, sni: String },
TestSni {
google_ip: String,
sni: String,
},
/// Probe a batch of SNI names. Results appear in UiState::sni_probe one
/// by one as each probe finishes.
TestAllSni { google_ip: String, snis: Vec<String> },
TestAllSni {
google_ip: String,
snis: Vec<String>,
},
}
struct App {
@@ -225,7 +231,10 @@ fn sni_pool_for_form(user: Option<&[String]>, front_domain: &str) -> Vec<SniRow>
if !user_clean.is_empty() {
return user_clean
.into_iter()
.map(|name| SniRow { name, enabled: true })
.map(|name| SniRow {
name,
enabled: true,
})
.collect();
}
// Default: primary + the other Google-edge subdomains, primary first,
@@ -235,11 +244,17 @@ fn sni_pool_for_form(user: Option<&[String]>, front_domain: &str) -> Vec<SniRow>
let mut out = Vec::new();
if !primary.is_empty() {
seen.insert(primary.clone());
out.push(SniRow { name: primary, enabled: true });
out.push(SniRow {
name: primary,
enabled: true,
});
}
for s in DEFAULT_GOOGLE_SNI_POOL {
if seen.insert(s.to_string()) {
out.push(SniRow { name: (*s).to_string(), enabled: true });
out.push(SniRow {
name: (*s).to_string(),
enabled: true,
});
}
}
out
@@ -293,7 +308,11 @@ impl FormState {
enable_batching: false,
upstream_socks5: {
let v = self.upstream_socks5.trim();
if v.is_empty() { None } else { Some(v.to_string()) }
if v.is_empty() {
None
} else {
Some(v.to_string())
}
},
parallel_relay: self.parallel_relay,
sni_hosts: {
@@ -308,7 +327,11 @@ impl FormState {
// If the user's pool is empty/all-off we still save as None so
// the backend falls back to sensible defaults instead of dying
// on an empty pool.
if active.is_empty() { None } else { Some(active) }
if active.is_empty() {
None
} else {
Some(active)
}
},
fetch_ips_from_api:self.fetch_ips_from_api,
max_ips_to_scan: self.max_ips_to_scan,
@@ -323,8 +346,7 @@ fn save_config(cfg: &Config) -> Result<PathBuf, String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let json = serde_json::to_string_pretty(&ConfigWire::from(cfg))
.map_err(|e| e.to_string())?;
let json = serde_json::to_string_pretty(&ConfigWire::from(cfg)).map_err(|e| e.to_string())?;
std::fs::write(&path, json).map_err(|e| e.to_string())?;
Ok(path)
}
@@ -384,7 +406,10 @@ impl<'a> From<&'a Config> for ConfigWire<'a> {
hosts: &c.hosts,
upstream_socks5: c.upstream_socks5.as_deref(),
parallel_relay: c.parallel_relay,
sni_hosts: c.sni_hosts.as_ref().map(|v| v.iter().map(String::as_str).collect()),
sni_hosts: c
.sni_hosts
.as_ref()
.map(|v| v.iter().map(String::as_str).collect()),
}
}
}
@@ -400,6 +425,13 @@ impl eframe::App for App {
egui::CentralPanel::default().show(ctx, |ui| {
ui.style_mut().spacing.item_spacing = egui::vec2(8.0, 6.0);
// Wrap the whole central panel in a vertical scroll area so the
// form + stats + log panel stay accessible on short screens
// (~13" laptops at default scaling). Nested scroll areas still
// work fine within this outer scroller.
egui::ScrollArea::vertical()
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(format!("mhrv-rs {}", VERSION))
.size(16.0));
@@ -727,6 +759,7 @@ impl eframe::App for App {
self.toast = None;
}
}
}); // end ScrollArea
});
}
}
@@ -782,14 +815,13 @@ impl App {
});
}
}
if ui.button("Keep working only").on_hover_text(
"Uncheck every SNI that didn't pass the last probe."
).clicked() {
if ui
.button("Keep working only")
.on_hover_text("Uncheck every SNI that didn't pass the last probe.")
.clicked()
{
for row in &mut self.form.sni_pool {
let ok = matches!(
probe_map.get(&row.name),
Some(SniProbeState::Ok(_))
);
let ok = matches!(probe_map.get(&row.name), Some(SniProbeState::Ok(_)));
row.enabled = ok;
}
}
@@ -801,13 +833,20 @@ impl App {
if ui.button("Clear status").clicked() {
self.shared.state.lock().unwrap().sni_probe.clear();
}
if ui.button("Reset to defaults").on_hover_text(
"Replace the list with the built-in Google SNI pool. Custom entries \
are dropped."
).clicked() {
if ui
.button("Reset to defaults")
.on_hover_text(
"Replace the list with the built-in Google SNI pool. Custom entries \
are dropped.",
)
.clicked()
{
self.form.sni_pool = DEFAULT_GOOGLE_SNI_POOL
.iter()
.map(|s| SniRow { name: (*s).to_string(), enabled: true })
.map(|s| SniRow {
name: (*s).to_string(),
enabled: true,
})
.collect();
self.shared.state.lock().unwrap().sni_probe.clear();
}
@@ -820,51 +859,55 @@ impl App {
let mut test_name: Option<String> = None;
const STATUS_W: f32 = 150.0;
const NAME_W: f32 = 230.0;
egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| {
for (i, row) in self.form.sni_pool.iter_mut().enumerate() {
ui.horizontal(|ui| {
ui.checkbox(&mut row.enabled, "");
ui.add(
egui::TextEdit::singleline(&mut row.name)
.desired_width(NAME_W)
.font(egui::TextStyle::Monospace),
);
let status_txt = match probe_map.get(&row.name) {
Some(SniProbeState::Ok(ms)) => {
egui::RichText::new(format!("ok {} ms", ms))
.color(egui::Color32::from_rgb(80, 180, 100))
.monospace()
}
Some(SniProbeState::Failed(e)) => {
let short = if e.len() > 22 { &e[..22] } else { e };
egui::RichText::new(format!("fail {}", short))
.color(egui::Color32::from_rgb(220, 110, 110))
.monospace()
}
Some(SniProbeState::InFlight) => {
egui::RichText::new("testing…")
egui::ScrollArea::vertical()
.max_height(280.0)
.show(ui, |ui| {
for (i, row) in self.form.sni_pool.iter_mut().enumerate() {
ui.horizontal(|ui| {
ui.checkbox(&mut row.enabled, "");
ui.add(
egui::TextEdit::singleline(&mut row.name)
.desired_width(NAME_W)
.font(egui::TextStyle::Monospace),
);
let status_txt = match probe_map.get(&row.name) {
Some(SniProbeState::Ok(ms)) => {
egui::RichText::new(format!("ok {} ms", ms))
.color(egui::Color32::from_rgb(80, 180, 100))
.monospace()
}
Some(SniProbeState::Failed(e)) => {
let short = if e.len() > 22 { &e[..22] } else { e };
egui::RichText::new(format!("fail {}", short))
.color(egui::Color32::from_rgb(220, 110, 110))
.monospace()
}
Some(SniProbeState::InFlight) => {
egui::RichText::new("testing…")
.color(egui::Color32::GRAY)
.monospace()
}
None => egui::RichText::new("untested")
.color(egui::Color32::GRAY)
.monospace()
.monospace(),
};
ui.add_sized(
[STATUS_W, 18.0],
egui::Label::new(status_txt).truncate(),
);
if ui.small_button("Test").clicked() {
test_name = Some(row.name.clone());
}
None => {
egui::RichText::new("untested")
.color(egui::Color32::GRAY)
.monospace()
if ui
.small_button("remove")
.on_hover_text("Remove this row")
.clicked()
{
to_remove = Some(i);
}
};
ui.add_sized([STATUS_W, 18.0], egui::Label::new(status_txt).truncate());
if ui.small_button("Test").clicked() {
test_name = Some(row.name.clone());
}
if ui.small_button("remove")
.on_hover_text("Remove this row")
.clicked()
{
to_remove = Some(i);
}
});
}
});
});
}
});
if let Some(name) = test_name {
let name = name.trim().to_string();
@@ -946,12 +989,16 @@ fn fmt_bytes(b: u64) -> String {
fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {
let rt = Runtime::new().expect("failed to create tokio runtime");
let mut active: Option<(JoinHandle<()>, Arc<AsyncMutex<Option<Arc<DomainFronter>>>>)> = None;
let mut active: Option<(
JoinHandle<()>,
Arc<AsyncMutex<Option<Arc<DomainFronter>>>>,
tokio::sync::oneshot::Sender<()>,
)> = None;
loop {
match rx.recv_timeout(Duration::from_millis(250)) {
Ok(Cmd::PollStats) => {
if let Some((_, fronter_slot)) = &active {
if let Some((_, fronter_slot, _)) = &active {
let slot = fronter_slot.clone();
let shared = shared.clone();
rt.spawn(async move {
@@ -966,6 +1013,7 @@ fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {
});
}
}
// In background_thread function, modify the Cmd::Start handler:
Ok(Cmd::Start(cfg)) => {
if active.is_some() {
push_log(&shared, "[ui] already running");
@@ -977,6 +1025,8 @@ fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {
Arc::new(AsyncMutex::new(None));
let fronter_slot2 = fronter_slot.clone();
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
let handle = rt.spawn(async move {
let base = data_dir::data_dir();
let mitm = match MitmCertManager::new_in(&base) {
@@ -1002,27 +1052,49 @@ fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {
s.running = true;
s.started_at = Some(Instant::now());
}
push_log(&shared2, &format!(
"[ui] listening HTTP {}:{} SOCKS5 {}:{}",
cfg.listen_host, cfg.listen_port,
cfg.listen_host, cfg.socks5_port.unwrap_or(cfg.listen_port + 1)
));
let _ = server.run().await;
push_log(
&shared2,
&format!(
"[ui] listening HTTP {}:{} SOCKS5 {}:{}",
cfg.listen_host,
cfg.listen_port,
cfg.listen_host,
cfg.socks5_port.unwrap_or(cfg.listen_port + 1)
),
);
let _ = server.run(shutdown_rx).await;
shared2.state.lock().unwrap().running = false;
push_log(&shared2, "[ui] proxy stopped");
});
active = Some((handle, fronter_slot));
active = Some((handle, fronter_slot, shutdown_tx));
}
Ok(Cmd::Stop) => {
if let Some((handle, _)) = active.take() {
handle.abort();
if let Some((handle, _, shutdown_tx)) = active.take() {
push_log(&shared, "[ui] stop requested");
let _ = shutdown_tx.send(());
// Give the proxy 2 seconds to shut down gracefully
rt.block_on(async {
tokio::select! {
_ = handle => {
push_log(&shared, "[ui] proxy stopped gracefully");
}
_ = tokio::time::sleep(tokio::time::Duration::from_secs(2)) => {
push_log(&shared, "[ui] shutdown timeout, forcing abort");
}
}
});
shared.state.lock().unwrap().running = false;
shared.state.lock().unwrap().started_at = None;
shared.state.lock().unwrap().last_stats = None;
push_log(&shared, "[ui] stop requested");
}
}
Ok(Cmd::Test(cfg)) => {
let shared2 = shared.clone();
push_log(&shared, "[ui] running test...");
@@ -1034,7 +1106,10 @@ fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {
} else {
"Test failed — see terminal for details.".into()
};
push_log(&shared2, &format!("[ui] test result: {}", if ok { "pass" } else { "fail" }));
push_log(
&shared2,
&format!("[ui] test result: {}", if ok { "pass" } else { "fail" }),
);
// Also run ip scan on demand (cheap).
let _ = scan_ips::run(&cfg).await;
});
@@ -1071,7 +1146,9 @@ fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {
let result = scan_sni::probe_one(&google_ip, &sni).await;
let state = match result.latency_ms {
Some(ms) => SniProbeState::Ok(ms),
None => SniProbeState::Failed(result.error.unwrap_or_else(|| "failed".into())),
None => {
SniProbeState::Failed(result.error.unwrap_or_else(|| "failed".into()))
}
};
shared2.state.lock().unwrap().sni_probe.insert(sni, state);
});
@@ -1090,7 +1167,9 @@ fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {
for (sni, r) in results {
let state = match r.latency_ms {
Some(ms) => SniProbeState::Ok(ms),
None => SniProbeState::Failed(r.error.unwrap_or_else(|| "failed".into())),
None => {
SniProbeState::Failed(r.error.unwrap_or_else(|| "failed".into()))
}
};
st.sni_probe.insert(sni, state);
}
@@ -1109,7 +1188,7 @@ fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {
}
// Clean up finished task.
if let Some((handle, _)) = &active {
if let Some((handle, _, _)) = &active {
if handle.is_finished() {
active = None;
shared.state.lock().unwrap().running = false;
@@ -1121,7 +1200,9 @@ fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {
fn push_log(shared: &Shared, msg: &str) {
let line = format!(
"{} {}",
time::OffsetDateTime::now_utc().format(&time::format_description::well_known::Iso8601::DEFAULT).unwrap_or_default(),
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Iso8601::DEFAULT)
.unwrap_or_default(),
msg
);
let mut s = shared.state.lock().unwrap();
+28 -8
View File
@@ -90,7 +90,9 @@ fn parse_args() -> Result<Args, String> {
std::process::exit(0);
}
"-c" | "--config" => {
let v = it.next().ok_or_else(|| "--config needs a path".to_string())?;
let v = it
.next()
.ok_or_else(|| "--config needs a path".to_string())?;
config_path = Some(PathBuf::from(v));
}
"--install-cert" => install_cert = true,
@@ -107,8 +109,7 @@ fn parse_args() -> Result<Args, String> {
}
fn init_logging(level: &str) {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(level));
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level));
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
@@ -168,22 +169,38 @@ async fn main() -> ExitCode {
match args.command {
Command::Test => {
let ok = test_cmd::run(&config).await;
return if ok { ExitCode::SUCCESS } else { ExitCode::FAILURE };
return if ok {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
};
}
Command::ScanIps => {
let ok = scan_ips::run(&config).await;
return if ok { ExitCode::SUCCESS } else { ExitCode::FAILURE };
return if ok {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
};
}
Command::TestSni => {
let ok = mhrv_rs::scan_sni::run(&config).await;
return if ok { ExitCode::SUCCESS } else { ExitCode::FAILURE };
return if ok {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
};
}
Command::Serve => {}
}
let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1);
tracing::warn!("mhrv-rs {} starting (mode: apps_script)", VERSION);
tracing::info!("HTTP proxy : {}:{}", config.listen_host, config.listen_port);
tracing::info!(
"HTTP proxy : {}:{}",
config.listen_host,
config.listen_port
);
tracing::info!("SOCKS5 proxy : {}:{}", config.listen_host, socks5_port);
tracing::info!(
"Apps Script relay: SNI={} -> script.google.com (via {})",
@@ -233,7 +250,9 @@ async fn main() -> ExitCode {
}
};
let run = server.run();
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
let run = server.run(shutdown_rx);
tokio::select! {
r = run => {
if let Err(e) = r {
@@ -243,6 +262,7 @@ async fn main() -> ExitCode {
}
_ = tokio::signal::ctrl_c() => {
tracing::warn!("Ctrl+C — shutting down.");
let _ = shutdown_tx.send(());
}
}
ExitCode::SUCCESS
+45 -43
View File
@@ -54,7 +54,10 @@ fn matches_sni_rewrite(host: &str) -> bool {
.any(|s| h == *s || h.ends_with(&format!(".{}", s)))
}
fn hosts_override<'a>(hosts: &'a std::collections::HashMap<String, String>, host: &str) -> Option<&'a str> {
fn hosts_override<'a>(
hosts: &'a std::collections::HashMap<String, String>,
host: &str,
) -> Option<&'a str> {
let h = host.to_ascii_lowercase();
let h = h.trim_end_matches('.');
if let Some(ip) = hosts.get(h) {
@@ -135,8 +138,10 @@ impl ProxyServer {
pub fn fronter(&self) -> Arc<DomainFronter> {
self.fronter.clone()
}
pub async fn run(self) -> Result<(), ProxyError> {
pub async fn run(
self,
mut shutdown_rx: tokio::sync::oneshot::Receiver<()>,
) -> Result<(), ProxyError> {
let http_addr = format!("{}:{}", self.host, self.port);
let socks_addr = format!("{}:{}", self.host, self.socks5_port);
let http_listener = TcpListener::bind(&http_addr).await?;
@@ -149,7 +154,6 @@ impl ProxyServer {
"Listening SOCKS5 on {} — xray / Telegram / app-level SOCKS5 clients use this.",
socks_addr
);
// Pre-warm the outbound connection pool so the user's first request
// doesn't pay a fresh TLS handshake to Google edge. Best-effort;
// failures are logged and ignored.
@@ -159,7 +163,7 @@ impl ProxyServer {
});
let stats_fronter = self.fronter.clone();
tokio::spawn(async move {
let stats_task = tokio::spawn(async move {
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
ticker.tick().await;
@@ -175,7 +179,7 @@ impl ProxyServer {
let http_fronter = self.fronter.clone();
let http_mitm = self.mitm.clone();
let http_ctx = self.rewrite_ctx.clone();
let http_task = tokio::spawn(async move {
let mut http_task = tokio::spawn(async move {
loop {
let (sock, peer) = match http_listener.accept().await {
Ok(x) => x,
@@ -199,7 +203,7 @@ impl ProxyServer {
let socks_fronter = self.fronter.clone();
let socks_mitm = self.mitm.clone();
let socks_ctx = self.rewrite_ctx.clone();
let socks_task = tokio::spawn(async move {
let mut socks_task = tokio::spawn(async move {
loop {
let (sock, peer) = match socks_listener.accept().await {
Ok(x) => x,
@@ -220,7 +224,18 @@ impl ProxyServer {
}
});
let _ = tokio::join!(http_task, socks_task);
tokio::select! {
biased;
_ = &mut shutdown_rx => {
tracing::info!("Shutdown signal received, stopping listeners");
stats_task.abort();
http_task.abort();
socks_task.abort();
}
_ = &mut http_task => {}
_ = &mut socks_task => {}
}
Ok(())
}
}
@@ -241,7 +256,8 @@ async fn handle_http_client(
if method.eq_ignore_ascii_case("CONNECT") {
let (host, port) = parse_host_port(&target);
sock.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n").await?;
sock.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
.await?;
sock.flush().await?;
dispatch_tunnel(sock, host, port, fronter, mitm, rewrite_ctx).await
} else {
@@ -282,7 +298,8 @@ async fn handle_socks5_client(
let cmd = req[1];
if cmd != 0x01 {
// CONNECT only.
sock.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
sock.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await?;
return Ok(());
}
let atyp = req[3];
@@ -306,7 +323,8 @@ async fn handle_socks5_client(
addr.to_string()
}
_ => {
sock.write_all(&[0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
sock.write_all(&[0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await?;
return Ok(());
}
};
@@ -317,7 +335,8 @@ async fn handle_socks5_client(
tracing::info!("SOCKS5 CONNECT -> {}:{}", host, port);
// Success reply with zeroed BND.
sock.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
sock.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await?;
sock.flush().await?;
dispatch_tunnel(sock, host, port, fronter, mitm, rewrite_ctx).await
@@ -393,7 +412,10 @@ async fn plain_tcp_passthrough(
Err(e) => {
tracing::warn!(
"upstream-socks5 {} -> {}:{} failed: {} (falling back to direct)",
proxy, host, port, e
proxy,
host,
port,
e
);
match tokio::time::timeout(
std::time::Duration::from_secs(10),
@@ -443,19 +465,12 @@ async fn plain_tcp_passthrough(
/// Open a TCP stream to `(host, port)` through an upstream SOCKS5 proxy
/// (no-auth only). Returns the connected stream after SOCKS5 negotiation.
async fn socks5_connect_via(
proxy: &str,
host: &str,
port: u16,
) -> std::io::Result<TcpStream> {
async fn socks5_connect_via(proxy: &str, host: &str, port: u16) -> std::io::Result<TcpStream> {
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
let mut s = tokio::time::timeout(
std::time::Duration::from_secs(5),
TcpStream::connect(proxy),
)
.await
.map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "connect timeout"))??;
let mut s = tokio::time::timeout(std::time::Duration::from_secs(5), TcpStream::connect(proxy))
.await
.map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "connect timeout"))??;
let _ = s.set_nodelay(true);
// Greeting: VER=5, NMETHODS=1, METHOD=no-auth
@@ -531,15 +546,7 @@ async fn socks5_connect_via(
fn looks_like_http(first_bytes: &[u8]) -> bool {
// Cheap sniff: must start with an ASCII HTTP method token followed by a space.
for m in [
"GET ",
"POST ",
"PUT ",
"HEAD ",
"DELETE ",
"PATCH ",
"OPTIONS ",
"CONNECT ",
"TRACE ",
"GET ", "POST ", "PUT ", "HEAD ", "DELETE ", "PATCH ", "OPTIONS ", "CONNECT ", "TRACE ",
] {
if first_bytes.starts_with(m.as_bytes()) {
return true;
@@ -613,15 +620,7 @@ fn parse_request_head(head: &[u8]) -> Option<(String, String, String, Vec<(Strin
fn is_valid_http_method(m: &str) -> bool {
matches!(
m,
"GET"
| "POST"
| "PUT"
| "DELETE"
| "HEAD"
| "OPTIONS"
| "PATCH"
| "TRACE"
| "CONNECT"
"GET" | "POST" | "PUT" | "DELETE" | "HEAD" | "OPTIONS" | "PATCH" | "TRACE" | "CONNECT"
)
}
@@ -704,7 +703,10 @@ async fn do_sni_rewrite_tunnel_from_tcp(
tracing::info!(
"SNI-rewrite tunnel -> {}:{} via {} (outbound SNI={})",
host, port, target_ip, rewrite_ctx.front_domain
host,
port,
target_ip,
rewrite_ctx.front_domain
);
// Accept browser TLS with a cert we sign for `host`.