diff --git a/Cargo.lock b/Cargo.lock index 4e2ca756..0c008ecb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2649,6 +2649,7 @@ dependencies = [ "time", "tokio", "tokio-rustls", + "toml", "tracing", "tracing-subscriber", "tun2proxy", @@ -3868,6 +3869,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -4339,11 +4349,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + [[package]] name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] [[package]] name = "toml_datetime" @@ -4365,6 +4390,20 @@ dependencies = [ "winnow 0.5.40", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.11+spec-1.1.0" @@ -4386,6 +4425,12 @@ dependencies = [ "winnow 1.0.2", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tproxy-config" version = "7.0.7" @@ -5135,7 +5180,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -5620,6 +5665,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index 8d070e2c..3432ad9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ base64 = "0.22" bytes = "1" httparse = "1" rand = "0.8" +toml = "0.8" h2 = "0.4" http = "1" flate2 = "1" diff --git a/SF_README.md b/SF_README.md index a172fd8e..0195d972 100644 --- a/SF_README.md +++ b/SF_README.md @@ -49,7 +49,7 @@ Click **Connect** (or **Start** on desktop). Done. Your browser, Telegram, etc. ### Common issues (most people hit at least one) **YouTube videos look "restricted" or comments are missing? ([#61](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/61))** -Turn on **"Send YouTube through relay (no SNI rewrite)"** in the desktop UI's Advanced section, or set `youtube_via_relay: true` in `config.json`. YouTube then goes through the Apps Script relay instead of the direct Google tunnel, which avoids YouTube's SafeSearch-on-SNI behaviour. Trade-off: slightly slower video, and it counts against your daily quota. +Turn on **"Send YouTube through relay (no SNI rewrite)"** in the desktop UI's Advanced section, or set `youtube_via_relay = true` in `config.toml`. YouTube then goes through the Apps Script relay instead of the direct Google tunnel, which avoids YouTube's SafeSearch-on-SNI behaviour. Trade-off: slightly slower video, and it counts against your daily quota. **"Verify you are human" loop on Cloudflare-protected sites?** This can't be fixed in this app. Every Apps Script request comes from a different Google datacenter IP, and Cloudflare's challenge cookie is locked to one IP — so the next request fails the check and re-challenges you. Sites that only check once per session work fine. Sites that check every page won't. @@ -58,7 +58,7 @@ This can't be fixed in this app. Every Apps Script request comes from a differen Your Apps Script deployment isn't responding. Go back to , **Deploy → Manage deployments → Edit (pencil)**, change "Version" to **New version**, click Deploy. Copy the **new** Deployment ID and paste it into the app. **Hit your daily limit?** -Free Google accounts get **20,000 relay requests per day**. The desktop and Android apps show a "Usage today" card with how many you've used. Add multiple Deployment IDs (one per line in the UI, or a JSON array in `config.json`) — each ID has its own quota and they're rotated automatically. You can also click "View quota on Google" to see the official number on Google's dashboard. +Free Google accounts get **20,000 relay requests per day**. The desktop and Android apps show a "Usage today" card with how many you've used. Add multiple Deployment IDs (one per line in the UI, or an array in `config.toml`) — each ID has its own quota and they're rotated automatically. You can also click "View quota on Google" to see the official number on Google's dashboard. **App says it's connected but websites don't load?** - Open the **SNI pool** section and click **Test all**. If everything fails, your `google_ip` value is unreachable from your network — click **Auto-detect google_ip** to fix. @@ -120,7 +120,7 @@ This project is free and run by volunteers. If it helped you and you can spare a ### مشکلات رایج (اکثر کاربران حداقل یکی از این‌ها را می‌بینند) **ویدیوهای یوتیوب «محدود» نشان داده می‌شوند یا کامنت‌ها دیده نمی‌شوند؟ ([#61](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/61))** -در بخش Advanced دسکتاپ گزینهٔ **«Send YouTube through relay (no SNI rewrite)»** را روشن کنید، یا در `config.json` مقدار `youtube_via_relay: true` بگذارید. در این حالت یوتیوب از مسیر ریلهٔ Apps Script رد می‌شود و فیلتر SafeSearch-on-SNI گوگل دور می‌خورد. تریدآف: ویدیو کمی کندتر و مصرف از سهمیهٔ روزانه. +در بخش Advanced دسکتاپ گزینهٔ **«Send YouTube through relay (no SNI rewrite)»** را روشن کنید، یا در `config.toml` مقدار `youtube_via_relay = true` بگذارید. در این حالت یوتیوب از مسیر ریلهٔ Apps Script رد می‌شود و فیلتر SafeSearch-on-SNI گوگل دور می‌خورد. تریدآف: ویدیو کمی کندتر و مصرف از سهمیهٔ روزانه. **روی سایت‌های پشت Cloudflare loop «Verify you are human» می‌خورد؟** این مشکل در این ابزار قابل حل نیست. هر درخواست Apps Script از یک IP متفاوت دیتاسنتر گوگل خارج می‌شود و کوکی challenge کلودفلر به یک IP خاص قفل است — درخواست بعدی از IP دیگر دوباره چالش می‌خورد. سایت‌هایی که فقط یک‌بار در ابتدای session چک می‌کنند درست کار می‌کنند. سایت‌هایی که هر صفحه چک می‌کنند، نه. @@ -129,7 +129,7 @@ This project is free and run by volunteers. If it helped you and you can spare a Apps Script شما پاسخ نمی‌دهد. به برگردید، **Deploy → Manage deployments → Edit (آیکن مداد)** را بزنید، گزینهٔ "Version" را روی **New version** بگذارید و Deploy کنید. **آی‌دی جدید** Deployment را کپی کنید و در برنامه جای‌گذاری کنید. **سهمیهٔ روزانه تمام شده؟** -هر حساب گوگل رایگان روزانه **۲۰٬۰۰۰ درخواست ریله** دارد. کارت «مصرف امروز» در دسکتاپ و اندروید مقدار مصرف فعلی را نشان می‌دهد. می‌توانید چند Deployment ID (هر کدام در یک خط، یا به‌صورت JSON array در `config.json`) اضافه کنید — هر آی‌دی سهمیهٔ خودش را دارد و به‌صورت چرخشی استفاده می‌شوند. دکمهٔ «مشاهدهٔ سهمیه در گوگل» شما را به داشبورد رسمی گوگل می‌برد. +هر حساب گوگل رایگان روزانه **۲۰٬۰۰۰ درخواست ریله** دارد. کارت «مصرف امروز» در دسکتاپ و اندروید مقدار مصرف فعلی را نشان می‌دهد. می‌توانید چند Deployment ID (هر کدام در یک خط، یا در `config.toml`) اضافه کنید — هر آی‌دی سهمیهٔ خودش را دارد و به‌صورت چرخشی استفاده می‌شوند. دکمهٔ «مشاهدهٔ سهمیه در گوگل» شما را به داشبورد رسمی گوگل می‌برد. **برنامه می‌گوید وصل است ولی سایت‌ها باز نمی‌شوند؟** - بخش **SNI pool** را باز کنید و **Test all** بزنید. اگر همه fail شدند، یعنی `google_ip` فعلی از شبکهٔ شما در دسترس نیست — روی **Auto-detect google_ip** بزنید تا اصلاح شود. diff --git a/assets/apps_script/Code.cfw.gs b/assets/apps_script/Code.cfw.gs index f455fe20..d9ca0776 100644 --- a/assets/apps_script/Code.cfw.gs +++ b/assets/apps_script/Code.cfw.gs @@ -59,7 +59,7 @@ * 6. Set WORKER_URL to your *.workers.dev URL (must include https://). * 7. Deploy → New deployment → Web app * Execute as: Me | Who has access: Anyone - * 8. Copy the Deployment ID into mhrv-rs config.json as "script_id". + * 8. Copy the Deployment ID into mhrv-rs config.toml as "script_id". * mhrv-rs does not need to know about Cloudflare; it talks to * Apps Script the same way it always has. * diff --git a/assets/apps_script/Code.gs b/assets/apps_script/Code.gs index 9403bbac..f9d03755 100644 --- a/assets/apps_script/Code.gs +++ b/assets/apps_script/Code.gs @@ -26,7 +26,7 @@ * 4. (Optional) Set CACHE_SPREADSHEET_ID to enable caching * 5. Click Deploy → New deployment * 6. Type: Web app | Execute as: Me | Who has access: Anyone - * 7. Copy the Deployment ID into config.json as "script_id" + * 7. Copy the Deployment ID into config.toml as "script_id" * * CHANGE THE AUTH KEY BELOW TO YOUR OWN SECRET! */ diff --git a/assets/cloudflare/README.fa.md b/assets/cloudflare/README.fa.md index 4b183940..491066c5 100644 --- a/assets/cloudflare/README.fa.md +++ b/assets/cloudflare/README.fa.md @@ -11,7 +11,7 @@ mhrv-rs ──► Apps Script (Code.cfw.gs) ──► Cloudflare Worker ── ▲ فقط احراز هویت و فوروارد ▲ گرفتن داده + base64 ``` -پشتیبان استاندارد ([`assets/apps_script/Code.gs`](../apps_script/Code.gs)) خودِ `Apps Script` کار `fetch` به مقصد را انجام می‌دهد. این نسخه‌ٔ جایگزین، `Apps Script` را به یک رلهٔ نازک تبدیل می‌کند و کارِ اصلی را به لبهٔ `Cloudflare` می‌سپارد. **خود `mhrv-rs` تغییر نمی‌کند** — همان پاکت `JSON` روی سیم، همان `mode: "apps_script"` در `config.json`، همان `script_id`. تنها تفاوت این است که `Apps Script` مستقر شدهٔ شما بعد از احراز هویت چه می‌کند. +پشتیبان استاندارد ([`assets/apps_script/Code.gs`](../apps_script/Code.gs)) خودِ `Apps Script` کار `fetch` به مقصد را انجام می‌دهد. این نسخه‌ٔ جایگزین، `Apps Script` را به یک رلهٔ نازک تبدیل می‌کند و کارِ اصلی را به لبهٔ `Cloudflare` می‌سپارد. **خود `mhrv-rs` تغییر نمی‌کند** — همان پاکت `JSON` روی سیم، همان `mode = "apps_script"` در `config.toml`، همان `script_id`. تنها تفاوت این است که `Apps Script` مستقر شدهٔ شما بعد از احراز هویت چه می‌کند. ایدهٔ اصلی: . این کپی یک بررسی `AUTH_KEY` روی خود `Worker` اضافه می‌کند، رفتار «صفحهٔ تقلبی برای کلید نامعتبر» را از `Code.gs` به ارث می‌برد، و یک محافظ در برابر حلقه‌شدن دارد. @@ -33,7 +33,7 @@ mhrv-rs ──► Apps Script (Code.cfw.gs) ──► Cloudflare Worker ── ## راه‌اندازی -سه رشتهٔ هم‌خوان نیاز دارید: یک `AUTH_KEY` که بین `worker.js`، `Code.cfw.gs` و `config.json` خود `mhrv-rs` مشترک است. یک رمز تصادفی قوی انتخاب کنید و در هر سه جا paste کنید. +سه رشتهٔ هم‌خوان نیاز دارید: یک `AUTH_KEY` که بین `worker.js`، `Code.cfw.gs` و `config.toml` خود mhrv-rs مشترک است. یک رمز تصادفی قوی انتخاب کنید و در هر سه جا paste کنید. ### ۱. استقرار `Worker` @@ -58,14 +58,13 @@ mhrv-rs ──► Apps Script (Code.cfw.gs) ──► Cloudflare Worker ── ### ۳. اشاره دادن `mhrv-rs` به این `Apps Script` -در `config.json` (یا از طریق فرم `UI`): +در `config.toml` (یا از طریق فرم `UI`): -```json -{ - "mode": "apps_script", - "script_id": "PASTE_DEPLOYMENT_ID_HERE", - "auth_key": "SAME_SECRET_AS_BOTH_FILES_ABOVE" -} +```toml +[relay] +mode = "apps_script" +auth_key = "YOUR_SHARED_SECRET" +script_ids = ["YOUR_DEPLOYMENT_ID"] ``` تمام. `mhrv-rs` لازم نیست بداند `Cloudflare` در کار است؛ از نگاه او این `script_id` مثل هر `Deployment` دیگری رفتار می‌کند. اگر چند `Deployment` دارید (بعضی استاندارد، بعضی `CFW`)، می‌توانید همه را در `script_ids: [...]` بگذارید — `round-robin` و `parallel-relay` همچنان روی همه‌شان کار می‌کند. diff --git a/assets/cloudflare/README.md b/assets/cloudflare/README.md index 403fe81b..efc017a5 100644 --- a/assets/cloudflare/README.md +++ b/assets/cloudflare/README.md @@ -9,7 +9,7 @@ mhrv-rs ──► Apps Script (Code.cfw.gs) ──► Cloudflare Worker ── ▲ thin auth + forward ▲ outbound fetch + base64 ``` -The standard backend (`assets/apps_script/Code.gs`) does the outbound fetch from inside Apps Script directly. This variant makes Apps Script a thin relay and pushes the actual fetch to Cloudflare's edge. **mhrv-rs itself is unchanged** — same JSON envelope on the wire, same `mode: "apps_script"` in `config.json`, same `script_id`. The only thing that's different is what your deployed Apps Script does after it authenticates the request. +The standard backend (`assets/apps_script/Code.gs`) does the outbound fetch from inside Apps Script directly. This variant makes Apps Script a thin relay and pushes the actual fetch to Cloudflare's edge. **mhrv-rs itself is unchanged** — same JSON envelope on the wire, same `mode = "apps_script"` in `config.toml`, same `script_id`. The only thing that's different is what your deployed Apps Script does after it authenticates the request. Original idea: . This copy adds an `AUTH_KEY` check on the Worker, the decoy-on-bad-auth treatment from `Code.gs`, and a hop-loop guard. @@ -26,7 +26,7 @@ Original idea: . This copy adds an `AUTH_KE ## Setup -You need three matching strings: an `AUTH_KEY` shared between `worker.js`, `Code.cfw.gs`, and your `mhrv-rs` `config.json`. Pick a strong random secret once and paste it into all three. +You need three matching strings: an `AUTH_KEY` shared between `worker.js`, `Code.cfw.gs`, and your mhrv-rs `config.toml`. Pick a strong random secret once and paste it into all three. ### 1. Deploy the Worker @@ -47,14 +47,13 @@ You need three matching strings: an `AUTH_KEY` shared between `worker.js`, `Code ### 3. Point mhrv-rs at the Apps Script -In `config.json` (or via the UI's config form): +In `config.toml` (or via the UI's config form): -```json -{ - "mode": "apps_script", - "script_id": "PASTE_DEPLOYMENT_ID_HERE", - "auth_key": "SAME_SECRET_AS_BOTH_FILES_ABOVE" -} +```toml +[relay] +mode = "apps_script" +auth_key = "YOUR_SHARED_SECRET" +script_ids = ["YOUR_DEPLOYMENT_ID"] ``` That's it. mhrv-rs doesn't need to know Cloudflare exists; from its perspective, the `script_id` deployment behaves like any other. If you have multiple deployments (some plain, some CFW), `script_ids: [...]` round-robins across all of them and the parallel-relay fan-out still works. diff --git a/assets/cloudflare/worker.js b/assets/cloudflare/worker.js index f672194b..0901fc00 100644 --- a/assets/cloudflare/worker.js +++ b/assets/cloudflare/worker.js @@ -57,7 +57,7 @@ * 1. Cloudflare dashboard → Workers & Pages → Create → Hello World * 2. Edit code → delete the template, paste this entire file * 3. Change AUTH_KEY below to the same value you set in Code.cfw.gs - * AND in your mhrv-rs config.json (auth_key). All three must match. + * AND in your mhrv-rs config.toml (auth_key). All three must match. * 4. Deploy. Note the *.workers.dev URL; paste it into Code.cfw.gs as * WORKER_URL. * diff --git a/assets/exit_node/README.fa.md b/assets/exit_node/README.fa.md index e497adcd..21bb15fe 100644 --- a/assets/exit_node/README.fa.md +++ b/assets/exit_node/README.fa.md @@ -65,15 +65,14 @@ APIهای web-standard (`Request`، `Response`، `fetch`) استفاده می‌ جلوی serve شدن به‌عنوان open relay accidentally گرفته بشه. ۲. فایل رو روی host انتخابی **deploy** کنید (گزینه‌ها در ادامه). ۳. URL public deployment رو **copy** کنید. -۴. در `config.json` mhrv-rs، block `exit_node` اضافه کنید: - ```json - "exit_node": { - "enabled": true, - "relay_url": "https://your-deployed-exit-node.example.com", - "psk": "<همان PSK که در گام ۱ گذاشتید>", - "mode": "selective", - "hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"] - } +۴. در `config.toml` mhrv-rs، section `[exit_node]` اضافه کنید: + ```toml + [exit_node] + enabled = true + relay_url = "https://your-deployed-exit-node.example.com" + psk = "<همان PSK که در گام ۱ گذاشتید>" + mode = "selective" + hosts = ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"] ``` ۵. mhrv-rs رو **restart** کنید (Disconnect + Connect، یا `kill` + restart binary). @@ -127,7 +126,7 @@ PSK تنها چیز است که مانع می‌شه endpoint deployed یک publ - **publicly share نکنید** PSK رو. هر کسی که هم URL هم PSK رو داره می‌تونه quota host شما رو به‌عنوان proxy خود استفاده کنه. - **rotate** اگر leak مشکوک هست. PSK رو در source deployed تغییر بدید، - redeploy کنید، سپس `psk` در `config.json` mhrv-rs رو update + restart. + redeploy کنید، سپس `psk` در `config.toml` mhrv-rs رو update + restart. اسکریپت همچنین شامل **loop guard** هست (refuse می‌کنه fetch host خود) + **placeholder check** (در صورت `PSK === "CHANGE_ME_TO_A_STRONG_SECRET"` @@ -147,7 +146,7 @@ Grok اهمیت می‌دن opt in؛ همه‌ی دیگران lighter اجرا ## Troubleshooting **`exit node refused or errored: unauthorized`** — PSK mismatch. -بررسی کنید `psk` در `config.json` دقیقاً با `PSK` constant در source +بررسی کنید `psk` در `config.toml` دقیقاً با `PSK` constant در source deployed match هست. whitespace + quoting مهم است. **`exit node refused or errored: exit_node misconfigured: PSK is still diff --git a/assets/exit_node/README.md b/assets/exit_node/README.md index be84bcda..00b17a23 100644 --- a/assets/exit_node/README.md +++ b/assets/exit_node/README.md @@ -65,15 +65,14 @@ on any platform with a serverless-fetch runtime. relay. 2. **Deploy** to your chosen host (see options below). 3. **Copy the public URL** of the deployed handler. -4. **In `mhrv-rs` config.json**, add an `exit_node` block: - ```json - "exit_node": { - "enabled": true, - "relay_url": "https://your-deployed-exit-node.example.com", - "psk": "", - "mode": "selective", - "hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"] - } +4. **In `mhrv-rs` config.toml**, add an `[exit_node]` section: + ```toml + [exit_node] + enabled = true + relay_url = "https://your-deployed-exit-node.example.com" + psk = "" + mode = "selective" + hosts = ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"] ``` 5. **Restart mhrv-rs** (Disconnect + Connect, or kill + restart the binary). @@ -127,7 +126,7 @@ public open proxy. Treat it like a password: - **Don't share publicly.** Anyone with both the URL and the PSK can use the deployment as their own proxy and burn your runtime quota. - **Rotate** if you suspect a leak. Change the PSK in the deployed - source, redeploy, then update `psk` in `mhrv-rs` config.json and + source, redeploy, then update `psk` in mhrv-rs `config.toml` and restart. The script also includes a **loop guard** (refuses to fetch its own @@ -148,7 +147,7 @@ ChatGPT / Claude / Grok opt in; everyone else runs lighter. ## Troubleshooting **`exit node refused or errored: unauthorized`** — PSK mismatch. -Double-check `psk` in `config.json` matches the `PSK` constant in your +Double-check `psk` in `config.toml` matches the `PSK` constant in your deployed source character-for-character. Whitespace and quoting matter. diff --git a/assets/exit_node/exit_node.ts b/assets/exit_node/exit_node.ts index c7c242b8..e536f113 100644 --- a/assets/exit_node/exit_node.ts +++ b/assets/exit_node/exit_node.ts @@ -18,14 +18,13 @@ // 3. Set PSK below to a strong secret (`openssl rand -hex 32` from // a terminal — DO NOT leave the placeholder in production). // 4. Deploy and copy the public URL of the deployed handler. -// 5. In mhrv-rs config.json, add: -// "exit_node": { -// "enabled": true, -// "relay_url": "https://your-deployed-exit-node.example.com", -// "psk": "", -// "mode": "selective", -// "hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com"] -// } +// 5. In mhrv-rs config.toml, add: +// [exit_node]; +// enabled = true; +// relay_url = "https://your-deployed-exit-node.example.com"; +// psk = ""; +// mode = "selective"; +// hosts = ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.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 diff --git a/assets/launchers/run.bat b/assets/launchers/run.bat index bf5939c3..aa9199e6 100644 --- a/assets/launchers/run.bat +++ b/assets/launchers/run.bat @@ -64,8 +64,8 @@ if not "%UI_EXIT%"=="0" ( echo. echo mhrv-rs.exe echo. - echo Set your config in %%APPDATA%%\mhrv-rs\config\config.json (or - echo place a config.json next to mhrv-rs.exe in this folder), then + echo Set your config in %%APPDATA%%\mhrv-rs\config\config.toml (or + echo place a config.toml next to mhrv-rs.exe in this folder), then echo point your browser proxy at 127.0.0.1:8085 (HTTP) or echo 127.0.0.1:8086 (SOCKS5). The CLI is the same proxy without echo the UI shell, so all functionality is available. diff --git a/assets/openwrt/mhrv-rs.init b/assets/openwrt/mhrv-rs.init index 729d9907..982b4d30 100644 --- a/assets/openwrt/mhrv-rs.init +++ b/assets/openwrt/mhrv-rs.init @@ -6,13 +6,13 @@ # # Expects: # /usr/bin/mhrv-rs (the static musl binary from the release) -# /etc/mhrv-rs/config.json (your config) +# /etc/mhrv-rs/config.toml (your config) START=99 USE_PROCD=1 BIN=/usr/bin/mhrv-rs -CONFIG=/etc/mhrv-rs/config.json +CONFIG=/etc/mhrv-rs/config.toml start_service() { [ -x "$BIN" ] || return 1 diff --git a/config.direct.example.json b/config.direct.example.json deleted file mode 100644 index c0a95948..00000000 --- a/config.direct.example.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "mode": "direct", - "google_ip": "216.239.38.120", - "front_domain": "www.google.com", - "listen_host": "127.0.0.1", - "listen_port": 8085, - "socks5_port": 8086, - "log_level": "info", - "verify_ssl": true -} diff --git a/config.direct.example.toml b/config.direct.example.toml new file mode 100644 index 00000000..ebe2b8ee --- /dev/null +++ b/config.direct.example.toml @@ -0,0 +1,20 @@ +[relay] +mode = "direct" + +[network] +google_ip = "216.239.38.120" +front_domain = "www.google.com" +listen_host = "127.0.0.1" +listen_port = 8085 +socks5_port = 8086 +verify_ssl = true + +[network.hosts] + +[scan] + +[logging] +log_level = "info" + +[exit_node] +enabled = false diff --git a/config.example.json b/config.example.json deleted file mode 100644 index fbd6acbb..00000000 --- a/config.example.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "mode": "apps_script", - "google_ip": "216.239.38.120", - "front_domain": "www.google.com", - "script_id": "YOUR_APPS_SCRIPT_DEPLOYMENT_ID", - "auth_key": "CHANGE_ME_TO_A_STRONG_SECRET", - "listen_host": "127.0.0.1", - "listen_port": 8085, - "socks5_port": 8086, - "log_level": "info", - "verify_ssl": true, - "hosts": {} -} diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 00000000..ab233f3f --- /dev/null +++ b/config.example.toml @@ -0,0 +1,22 @@ +[relay] +mode = "apps_script" +script_id = "YOUR_APPS_SCRIPT_DEPLOYMENT_ID" +auth_key = "CHANGE_ME_TO_A_STRONG_SECRET" + +[network] +google_ip = "216.239.38.120" +front_domain = "www.google.com" +listen_host = "127.0.0.1" +listen_port = 8085 +socks5_port = 8086 +verify_ssl = true + +[network.hosts] + +[scan] + +[logging] +log_level = "info" + +[exit_node] +enabled = false diff --git a/config.exit-node.example.json b/config.exit-node.example.json deleted file mode 100644 index 8af55161..00000000 --- a/config.exit-node.example.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "_comment": "Example config for using mhrv-rs with an exit-node deployment to bypass Cloudflare anti-bot blocks on chatgpt.com / claude.ai / grok.com / x.com. See assets/exit_node/README.md for the deployment walkthrough.", - "mode": "apps_script", - "google_ip": "216.239.38.120", - "front_domain": "www.google.com", - "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 deployed exit-node handler (assets/exit_node/exit_node.ts running on Deno Deploy, fly.io, your own VPS, etc.).", - "relay_url": "https://your-deployed-exit-node.example.com", - "_comment_psk": "Pre-shared key — must match the PSK constant in your deployed source. Generate with: openssl rand -hex 32", - "psk": "PUT_YOUR_EXIT_NODE_PSK_HERE", - "_comment_mode": "selective: only `hosts` route via exit node (recommended). full: every request routes via exit node (slower, ~250-500ms extra hop).", - "mode": "selective", - "_comment_hosts": "Hostnames to route through the exit node. Matches exact OR dot-anchored suffix (chatgpt.com covers api.chatgpt.com etc.). Extend for any CF-anti-bot blocked sites you need.", - "hosts": [ - "chatgpt.com", - "claude.ai", - "x.com", - "grok.com", - "openai.com", - "aistudio.google.com", - "ai.google.dev" - ] - } -} diff --git a/config.exit-node.example.toml b/config.exit-node.example.toml new file mode 100644 index 00000000..72efeecf --- /dev/null +++ b/config.exit-node.example.toml @@ -0,0 +1,47 @@ +# Example config for using mhrv-rs with an exit-node deployment. +# See assets/exit_node/README.md for the full deployment walkthrough. + +[relay] +mode = "apps_script" +script_id = ["PUT_YOUR_APPS_SCRIPT_DEPLOYMENT_ID_HERE"] +auth_key = "PUT_YOUR_APPS_SCRIPT_AUTH_KEY_HERE" + +[network] +google_ip = "216.239.38.120" +front_domain = "www.google.com" +listen_host = "0.0.0.0" +listen_port = 8085 +socks5_port = 8086 +verify_ssl = true + +[network.hosts] + +[scan] + +[logging] +log_level = "info" + +[exit_node] +# Master switch. Set false to disable exit-node entirely without removing +# the config. +enabled = true +# Public URL of your deployed exit-node handler (assets/exit_node/exit_node.ts +# running on Deno Deploy, fly.io, your own VPS, etc.). +relay_url = "https://your-deployed-exit-node.example.com" +# Pre-shared key — must match the PSK constant in your deployed source. +# Generate with: openssl rand -hex 32 +psk = "PUT_YOUR_EXIT_NODE_PSK_HERE" +# selective: only `hosts` route via exit node (recommended). +# full: every request routes via exit node (slower, ~250-500ms extra hop). +mode = "selective" +# Hostnames to route through the exit node. Matches exact OR dot-anchored +# suffix (chatgpt.com covers api.chatgpt.com etc.). +hosts = [ + "chatgpt.com", + "claude.ai", + "x.com", + "grok.com", + "openai.com", + "aistudio.google.com", + "ai.google.dev", +] \ No newline at end of file diff --git a/config.fronting-groups.example.json b/config.fronting-groups.example.json deleted file mode 100644 index c54756d9..00000000 --- a/config.fronting-groups.example.json +++ /dev/null @@ -1,135 +0,0 @@ -{ - "mode": "direct", - "google_ip": "216.239.38.120", - "front_domain": "www.google.com", - "listen_host": "127.0.0.1", - "listen_port": 8085, - "socks5_port": 8086, - "log_level": "info", - "verify_ssl": true, - "fronting_groups": [ - { - "name": "vercel", - "ip": "216.230.84.193", - "sni": "nextjs.org", - "domains": [ - "ai-sdk.dev", - "err.sh", - "hyper.is", - "nextjs.org", - "now.sh", - "skills.sh", - "static.fun", - "title.sh", - "turborepo.org", - "vercel-dns.com", - "vercel-status.com", - "vercel.app", - "vercel.blog", - "vercel.com", - "vercel.dev", - "vercel.events", - "vercel.live", - "vercel.pub", - "vercel.sh", - "vercel.store", - "zeit-world.co.uk", - "zeit-world.com", - "zeit-world.net", - "zeit-world.org", - "zeit.co", - "zeit.sh", - "zeitworld.com" - ] - }, - { - "name": "fastly", - "ip": "151.101.128.223", - "sni": "pypi.org", - "domains": [ - "redd.it", - "reddit.com", - "redditstatic.com", - "redditmedia.com", - "reddit.app.link", - "redditblog.com", - "reddithelp.com", - "redditinc.com", - "redditmail.com", - "redditspace.com", - "redditstatus.com", - "reddit.map.fastly.net", - - "githubassets.com", - "githubusercontent.com", - "github.io", - - "fastly.com", - "fastly-edge.com", - "fastly-terrarium.com", - "fastly.io", - "fastly.net", - "fastlylabs.com", - "fastlylb.net", - - "www.pinterest.com", - "pinimg.com", - - "cnn.com", - "cnn.io", - "cnn.it", - "cnnarabic.com", - "cnnlabs.com", - "cnnmoney.ch", - "cnnmoney.com", - "cnnmoneystream.com", - "cnnpolitics.com", - - "buzzfeed.com" - ] - }, - { - "name": "amazon-cloudfront", - "ip": "3.33.186.135", - "sni": "kubernetes.io", - "domains": [ - "bitballoon.com", - "netlify.app", - "netlify.com", - "netlifystatus.com" - ] - }, - { - "name": "github-central", - "ip": "140.82.113.21", - "sni": "central.github.com", - "domains": [ - "objects-origin.githubusercontent.com", - "api.individual.githubcopilot.com", - "glb-db52c2cf8be544.github.com", - "api.githubcopilot.com" - ] - }, - { - "name": "github-alive", - "ip": "140.82.112.26", - "sni": "alive.github.com", - "domains": [ - "alive.github.com", - "live.github.com" - ] - }, - { - "name": "github", - "ip": "140.82.121.3", - "sni": "github.com", - "domains": ["gist.github.com"] - }, - { - "name": "pubmed", - "ip": "34.107.134.59", - "sni": "pubmed.ncbi.nlm.nih.gov", - "domains": ["pmc.ncbi.nlm.nih.gov"] - } - ] -} diff --git a/config.fronting-groups.example.toml b/config.fronting-groups.example.toml new file mode 100644 index 00000000..db00d9d9 --- /dev/null +++ b/config.fronting-groups.example.toml @@ -0,0 +1,87 @@ +[relay] +mode = "direct" + +[network] +google_ip = "216.239.38.120" +front_domain = "www.google.com" +listen_host = "127.0.0.1" +listen_port = 8085 +socks5_port = 8086 +verify_ssl = true + +[network.hosts] + +[scan] + +[logging] +log_level = "info" + +[exit_node] +enabled = false + +[[fronting_groups]] +name = "vercel" +ip = "216.230.84.193" +sni = "nextjs.org" +domains = [ + "ai-sdk.dev", "err.sh", "hyper.is", "nextjs.org", "now.sh", + "skills.sh", "static.fun", "title.sh", "turborepo.org", + "vercel-dns.com", "vercel-status.com", "vercel.app", "vercel.blog", + "vercel.com", "vercel.dev", "vercel.events", "vercel.live", + "vercel.pub", "vercel.sh", "vercel.store", "zeit-world.co.uk", + "zeit-world.com", "zeit-world.net", "zeit-world.org", "zeit.co", + "zeit.sh", "zeitworld.com", +] + +[[fronting_groups]] +name = "fastly" +ip = "151.101.128.223" +sni = "pypi.org" +domains = [ + "redd.it", "reddit.com", "redditstatic.com", "redditmedia.com", + "reddit.app.link", "redditblog.com", "reddithelp.com", "redditinc.com", + "redditmail.com", "redditspace.com", "redditstatus.com", + "reddit.map.fastly.net", "githubassets.com", "githubusercontent.com", + "github.io", "fastly.com", "fastly-edge.com", "fastly-terrarium.com", + "fastly.io", "fastly.net", "fastlylabs.com", "fastlylb.net", + "www.pinterest.com", "pinimg.com", "cnn.com", "cnn.io", "cnn.it", + "cnnarabic.com", "cnnlabs.com", "cnnmoney.ch", "cnnmoney.com", + "cnnmoneystream.com", "cnnpolitics.com", "buzzfeed.com", +] + +[[fronting_groups]] +name = "amazon-cloudfront" +ip = "3.33.186.135" +sni = "kubernetes.io" +domains = [ + "bitballoon.com", "netlify.app", "netlify.com", "netlifystatus.com", +] + +[[fronting_groups]] +name = "github-central" +ip = "140.82.113.21" +sni = "central.github.com" +domains = [ + "objects-origin.githubusercontent.com", + "api.individual.githubcopilot.com", + "glb-db52c2cf8be544.github.com", + "api.githubcopilot.com", +] + +[[fronting_groups]] +name = "github-alive" +ip = "140.82.112.26" +sni = "alive.github.com" +domains = ["alive.github.com", "live.github.com"] + +[[fronting_groups]] +name = "github" +ip = "140.82.121.3" +sni = "github.com" +domains = ["gist.github.com"] + +[[fronting_groups]] +name = "pubmed" +ip = "34.107.134.59" +sni = "pubmed.ncbi.nlm.nih.gov" +domains = ["pmc.ncbi.nlm.nih.gov"] diff --git a/config.full.example.json b/config.full.example.json deleted file mode 100644 index 106112eb..00000000 --- a/config.full.example.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "mode": "full", - "google_ip": "216.239.38.120", - "front_domain": "www.google.com", - "script_id": "YOUR_APPS_SCRIPT_DEPLOYMENT_ID", - "auth_key": "CHANGE_ME_TO_A_STRONG_SECRET", - "listen_host": "127.0.0.1", - "listen_port": 8085, - "socks5_port": 8086, - "log_level": "info", - "verify_ssl": true -} diff --git a/config.full.example.toml b/config.full.example.toml new file mode 100644 index 00000000..e75f0b42 --- /dev/null +++ b/config.full.example.toml @@ -0,0 +1,22 @@ +[relay] +mode = "full" +script_id = "YOUR_APPS_SCRIPT_DEPLOYMENT_ID" +auth_key = "CHANGE_ME_TO_A_STRONG_SECRET" + +[network] +google_ip = "216.239.38.120" +front_domain = "www.google.com" +listen_host = "127.0.0.1" +listen_port = 8085 +socks5_port = 8086 +verify_ssl = true + +[network.hosts] + +[scan] + +[logging] +log_level = "info" + +[exit_node] +enabled = false diff --git a/docs/fronting-groups.md b/docs/fronting-groups.md index ac57c230..9c4704a7 100644 --- a/docs/fronting-groups.md +++ b/docs/fronting-groups.md @@ -20,28 +20,25 @@ on that edge through the same tunnel without burning Apps Script quota. ## Config shape -```jsonc -{ - "mode": "direct", // or apps_script / full - "fronting_groups": [ - { - "name": "vercel", // free-form, used in logs - "ip": "76.76.21.21", // a Vercel edge IP - "sni": "react.dev", // a Vercel-hosted domain - "domains": [ // hosts to route via this group - "vercel.com", "vercel.app", - "nextjs.org", "now.sh" - ] - } - ] -} +```toml +[relay] +mode = "direct" # or apps_script / full + +[[fronting_groups]] +name = "vercel" # free-form, used in logs +ip = "76.76.21.21" # a Vercel edge IP +sni = "react.dev" # a Vercel-hosted domain +domains = [ # hosts to route via this group + "vercel.com", "vercel.app", + "nextjs.org", "now.sh", +] ``` `domains` matches case-insensitively, exact OR dot-anchored suffix — `vercel.com` covers both `vercel.com` and `*.vercel.com`. First group in the list whose member matches wins. -A working example is shipped at `config.fronting-groups.example.json`. +A working example is shipped at `config.fronting-groups.example.toml`. ## Picking the (ip, sni) pair @@ -121,7 +118,7 @@ edge directly, not through the Apps Script relay or the Google edge. - **No bundled domain catalog.** The upstream Xray config uses `geosite:vercel` / `geosite:fastly` lists from a binary geosite database — we don't ship that, you list domains explicitly. -- **No UI editor.** Edit `config.json` directly. The UI's Save path +- **No UI editor.** Edit `config.toml` directly. The UI's Save path preserves your `fronting_groups` block (round-tripped) — it just doesn't render an editor for it. - **Browsers only for Android non-root**, same as the Google path — diff --git a/docs/guide.fa.md b/docs/guide.fa.md index 3e95ffc5..d0247453 100644 --- a/docs/guide.fa.md +++ b/docs/guide.fa.md @@ -55,7 +55,7 @@ mhrv-rs (محلی) DPI سانسورگر فقط SNI داخل TLS را می‌بیند و اجازه می‌دهد `www.google.com` رد شود. لبهٔ گوگل هم `www.google.com` و هم `script.google.com` را روی یک IP سرو می‌کند و بر اساس هدر HTTP `Host` داخل تونل رمزشده آن‌ها را تفکیک می‌کند. -برای دامنه‌های متعلق به گوگل (`google.com`, `youtube.com`, `fonts.googleapis.com`, …) همان تونل مستقیم استفاده می‌شود — بدون رلهٔ Apps Script. این کار سهمیهٔ هر-fetch را دور می‌زند و مشکل قفل‌بودنِ User-Agent روی `Google-Apps-Script` را برای آن سایت‌ها برطرف می‌کند. برای اضافه کردن دامنه‌های دیگر از فیلد `hosts` در `config.json` استفاده کن. +برای دامنه‌های متعلق به گوگل (`google.com`, `youtube.com`, `fonts.googleapis.com`, …) همان تونل مستقیم استفاده می‌شود — بدون رلهٔ Apps Script. این کار سهمیهٔ هر-fetch را دور می‌زند و مشکل قفل‌بودنِ User-Agent روی `Google-Apps-Script` را برای آن سایت‌ها برطرف می‌کند. برای اضافه کردن دامنه‌های دیگر از فیلد `hosts` در `config.toml` استفاده کن. ## پلتفرم‌ها و فایل‌های اجرایی @@ -91,10 +91,10 @@ UI لینوکس به این کتابخانه‌ها نیاز دارد: `libxkbco داخل آن دایرکتوری: -- `config.json` — تنظیمات تو (با دکمهٔ Save در UI نوشته می‌شود یا دستی) +- `config.toml` — تنظیمات تو (با دکمهٔ Save در UI نوشته می‌شود یا دستی) - `ca/ca.crt`, `ca/ca.key` — گواهی root MITM. کلید خصوصی فقط در دست توست. -CLI همچنین برای سازگاری با راه‌اندازی‌های قدیمی، روی `./config.json` در دایرکتوری جاری هم fallback دارد. +CLI همچنین برای سازگاری با راه‌اندازی‌های قدیمی، روی `./config.toml` در دایرکتوری جاری هم fallback دارد. ## دیپلوی Apps Script @@ -118,31 +118,34 @@ CLI همچنین برای سازگاری با راه‌اندازی‌های ق اگر ISP تو از قبل Apps Script (یا کل گوگل) را مسدود کرده، باید مرحلهٔ ۱ **اول** موفق شود — قبل از این‌که رله‌ای داشته باشی. mhrv-rs یک حالت `direct` دقیقاً برای این دارد — فقط تونل بازنویسی SNI، بدون رلهٔ Apps Script. (قبل از v1.9 نام `google_only` داشت — نام قدیمی هم پذیرفته می‌شود.) ۱. فایل اجرایی را دانلود کن (طبق [مرحلهٔ ۲ در README](../README.md#مرحلهٔ-۲--دانلود-mhrv-rs)) -۲. فایل [`config.direct.example.json`](../config.direct.example.json) را در کنار فایل اجرا با نام `config.json` کپی کن — نه `script_id` نیاز است نه `auth_key` +۲. فایل [`config.direct.example.toml`](../config.direct.example.toml) را در کنار فایل اجرا با نام `config.toml` کپی کن — نه `script_id` نیاز است نه `auth_key` ۳. `mhrv-rs serve` را اجرا کن و HTTP proxy مرورگرت را روی `127.0.0.1:8085` بگذار ۴. در حالت `direct`، پروکسی فقط `*.google.com`، `*.youtube.com` و سایر میزبان‌های لبهٔ گوگل (به‌علاوهٔ هر [`fronting_groups`](fronting-groups.md) که تنظیم کرده باشی) را از تونل بازنویسی SNI رد می‌کند. بقیه راو می‌رود — هنوز رله‌ای در کار نیست. ۵. حالا مرحلهٔ ۱ را در مرورگر انجام بده (اتصال به `script.google.com` با SNI فرونت می‌شود). `Code.gs` را دیپلوی کن، Deployment ID را کپی کن. -۶. در UI / اپ اندروید / یا با ویرایش `config.json`، حالت را به `apps_script` برگردان، Deployment ID و auth key را پیست کن، و دوباره استارت کن. +۶. در UI / اپ اندروید / یا با ویرایش `config.toml`، حالت را به `apps_script` برگردان، Deployment ID و auth key را پیست کن، و دوباره استارت کن. برای بررسی دسترسی قبل از استارت پروکسی: `mhrv-rs test-sni` دامنه‌های `*.google.com` را مستقیم تست می‌کند و فقط به `google_ip` و `front_domain` نیاز دارد. ## مرجع CLI -تمام کاری که UI می‌کند را CLI هم می‌کند. `config.example.json` را به `config.json` کپی کن: - -```json -{ - "mode": "apps_script", - "google_ip": "216.239.38.120", - "front_domain": "www.google.com", - "script_id": "PASTE_YOUR_DEPLOYMENT_ID_HERE", - "auth_key": "same-secret-as-in-code-gs", - "listen_host": "127.0.0.1", - "listen_port": 8085, - "socks5_port": 8086, - "log_level": "info", - "verify_ssl": true -} +تمام کاری که UI می‌کند را CLI هم می‌کند. `config.example.toml` را به `config.toml` کپی کن: + +```toml +[relay] +mode = "apps_script" +script_id = "PASTE_YOUR_DEPLOYMENT_ID_HERE" +auth_key = "same-secret-as-in-code-gs" + +[network] +google_ip = "216.239.38.120" +front_domain = "www.google.com" +listen_host = "127.0.0.1" +listen_port = 8085 +socks5_port = 8086 +verify_ssl = true + +[logging] +log_level = "info" ``` سپس: @@ -157,21 +160,20 @@ CLI همچنین برای سازگاری با راه‌اندازی‌های ق ./mhrv-rs --help ``` -`--remove-cert` گواهی را از trust store سیستم پاک می‌کند، با بررسی نام تأیید می‌کند که حذف انجام شد، و پوشهٔ `ca/` روی دیسک را حذف می‌کند. پاک‌سازی NSS (فایرفاکس و کروم لینوکس) best-effort است: اگر `certutil` نباشد یا یکی از مرورگرها پایگاه داده NSS را قفل کرده باشد، ابزار راهنمای پاک‌سازی دستی نشان می‌دهد. `config.json` و دیپلوی Apps Script دست‌نخورده می‌مانند، پس CA تازه نیازی به دیپلوی مجدد `Code.gs` ندارد. +`--remove-cert` گواهی را از trust store سیستم پاک می‌کند، با بررسی نام تأیید می‌کند که حذف انجام شد، و پوشهٔ `ca/` روی دیسک را حذف می‌کند. پاک‌سازی NSS (فایرفاکس و کروم لینوکس) best-effort است: اگر `certutil` نباشد یا یکی از مرورگرها پایگاه داده NSS را قفل کرده باشد، ابزار راهنمای پاک‌سازی دستی نشان می‌دهد. `config.toml` و دیپلوی Apps Script دست‌نخورده می‌مانند، پس CA تازه نیازی به دیپلوی مجدد `Code.gs` ندارد. `script_id` می‌تواند JSON array باشد: `["id1", "id2", "id3"]`. ### حالت scan-ips با API -به‌طور پیش‌فرض، `scan-ips` از یک لیست ثابت استفاده می‌کند. کشف پویای IP را در `config.json` فعال کن: +به‌طور پیش‌فرض، `scan-ips` از یک لیست ثابت استفاده می‌کند. کشف پویای IP را در `config.toml` فعال کن: -```json -{ - "fetch_ips_from_api": true, - "max_ips_to_scan": 100, - "scan_batch_size": 100, - "google_ip_validation": true -} +```toml +[scan] +fetch_ips_from_api = true +max_ips_to_scan = 100 +scan_batch_size = 100 +google_ip_validation = true ``` وقتی فعال است: @@ -197,10 +199,9 @@ CLI همچنین برای سازگاری با راه‌اندازی‌های ق قطعهٔ کانفیگ: -```json -{ - "upstream_socks5": "127.0.0.1:50529" -} +```toml +[network] +upstream_socks5 = "127.0.0.1:50529" ``` HTTP / HTTPS مثل قبل از Apps Script می‌رود (تغییری نمی‌کند)، تونل بازنویسی SNI برای `google.com` / `youtube.com` همچنان از هر دو دور می‌زند — یوتیوب به سرعت قبل می‌ماند و تلگرام هم تونل واقعی پیدا می‌کند. @@ -247,12 +248,11 @@ HTTP / HTTPS مثل قبل از Apps Script می‌رود (تغییری نمی Multi-arch (linux/amd64 + linux/arm64)، اجرا با کاربر غیر root، حدود ۳۲ مگابایت فشرده. برای production نسخهٔ مشخص (`:1.5.0`) را pin کن. راهنمای کامل (شامل Cloud Run، docker-compose، بیلد از سورس) در [tunnel-node/README.fa.md](../tunnel-node/README.fa.md). ۳. در کانفیگت `"mode": "full"` با همهٔ Deployment IDها بگذار: - ```json - { - "mode": "full", - "script_id": ["id1", "id2", "id3", "id4", "id5", "id6"], - "auth_key": "secret-تو" - } + ```toml + [relay] + mode = "full" + script_id = ["id1", "id2", "id3", "id4", "id5", "id6"] + auth_key = "your-secret" ``` ## Exit node @@ -297,7 +297,7 @@ HTTP proxy سیستم را روی `192.168.43.1:8080` بگذار، یا per-app # از کامپیوتری که به روترت دسترسی دارد: 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 +scp config.toml root@192.168.1.1:/etc/mhrv-rs/config.toml # روی روتر (ssh): chmod +x /usr/bin/mhrv-rs /etc/init.d/mhrv-rs @@ -306,7 +306,7 @@ chmod +x /usr/bin/mhrv-rs /etc/init.d/mhrv-rs logread -e mhrv-rs -f # تمام لاگ ``` -دستگاه‌های LAN HTTP proxy را روی IP روتر (پورت پیش‌فرض `8085`) یا SOCKS5 روی `:8086` تنظیم می‌کنند. در `/etc/mhrv-rs/config.json` مقدار `listen_host` را به `0.0.0.0` بگذار تا روتر اتصال LAN را بپذیرد. +دستگاه‌های LAN HTTP proxy را روی IP روتر (پورت پیش‌فرض `8085`) یا SOCKS5 روی `:8086` تنظیم می‌کنند. در `/etc/mhrv-rs/config.toml` مقدار `listen_host` را به `0.0.0.0` بگذار تا روتر اتصال LAN را بپذیرد. مصرف حافظه ~۱۵–۲۰ مگابایت — روی هر روتری با ۱۲۸ مگابایت RAM به بالا اجرا می‌شود. UI روی musl نیست (روترها headlessاند). @@ -324,12 +324,11 @@ logread -e mhrv-rs -f # تمام لاگ یا: - UI → **SNI pool…** → **Test all** → **Keep ✓ only** برای trim خودکار. نام جدید را در فیلد پایین اضافه کن. Save. -- یا `config.json` را مستقیم ویرایش کن: +- یا `config.toml` را مستقیم ویرایش کن: -```json -{ - "sni_hosts": ["www.google.com", "drive.google.com", "docs.google.com"] -} +```toml +[relay] +sni_hosts = ["www.google.com", "drive.google.com", "docs.google.com"] ``` اگر `sni_hosts` تنظیم نشود، pool خودکار پیش‌فرض استفاده می‌شود. `mhrv-rs test-sni` را اجرا کن تا قبل از ذخیره ببینی چه چیزی از شبکه‌ات کار می‌کند. @@ -432,7 +431,7 @@ HTML یوتیوب سریع می‌آید (از تونل بازنویسی SNI)، - **ساده‌ترین (هر OS):** در UI **Remove CA** را بزن، یا: - مک / لینوکس: `sudo ./mhrv-rs --remove-cert` - ویندوز (با Run as administrator): `mhrv-rs.exe --remove-cert` - - از trust store سیستم، NSS (فایرفاکس / کروم لینوکس) حذف می‌کند، و `ca/ca.crt` + `ca/ca.key` روی دیسک پاک می‌کند. `config.json` و دیپلوی Apps Script دست‌نخورده. + - از trust store سیستم، NSS (فایرفاکس / کروم لینوکس) حذف می‌کند، و `ca/ca.crt` + `ca/ca.key` روی دیسک پاک می‌کند. `config.toml` و دیپلوی Apps Script دست‌نخورده. - **به‌صورت دستی:** نام گواهی (Common Name) همه‌جا `MasterHttpRelayVPN` است (نه `mhrv-rs` — این نام برنامه است نه نام گواهی). - **مک:** Keychain Access → System → دنبال `MasterHttpRelayVPN` بگرد → حذف کن. سپس `rm -rf ~/Library/Application\ Support/mhrv-rs/ca/` - **ویندوز:** `certmgr.msc` → Trusted Root Certification Authorities → دنبال `MasterHttpRelayVPN` → حذف diff --git a/docs/guide.md b/docs/guide.md index 55ee955e..679a35d0 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -53,7 +53,7 @@ mhrv-rs (local) The censor's DPI inspects the TLS SNI and lets `www.google.com` through. Google's edge serves both `www.google.com` and `script.google.com` from the same IP and routes by the HTTP `Host` header inside the encrypted stream. -For Google-owned domains (`google.com`, `youtube.com`, `fonts.googleapis.com`, …) the same tunnel is used directly — no Apps Script relay. This bypasses the per-fetch quota and avoids the locked-in `Google-Apps-Script` User-Agent for those sites. Add more domains via the `hosts` map in `config.json`. +For Google-owned domains (`google.com`, `youtube.com`, `fonts.googleapis.com`, …) the same tunnel is used directly — no Apps Script relay. This bypasses the per-fetch quota and avoids the locked-in `Google-Apps-Script` User-Agent for those sites. Add more domains via the `hosts` map in `config.toml`. ## Platforms and binaries @@ -89,10 +89,10 @@ Config and the MITM CA live in the OS user-data dir: Inside that dir: -- `config.json` — your settings (written by the UI's **Save** button or hand-edited) +- `config.toml` — your settings (written by the UI's **Save** button or hand-edited) - `ca/ca.crt`, `ca/ca.key` — the MITM root certificate. Only you have the private key. -The CLI also falls back to `./config.json` in the current working directory for backward compatibility. +The CLI also falls back to `./config.toml` in the current working directory for backward compatibility. ## Apps Script deployment @@ -116,31 +116,34 @@ Full setup and trade-off table in [`assets/cloudflare/README.md`](../assets/clou If your ISP is already blocking Google Apps Script (or all of Google), you need Step 1 to succeed *before* you have a relay. mhrv-rs ships a `direct` mode for exactly this — SNI-rewrite tunnel only, no Apps Script relay required. (Was named `google_only` before v1.9 — old name still accepted.) 1. Download the binary (see [main README → Step 2](../README.md#step-2--download-mhrv-rs)) -2. Copy [`config.direct.example.json`](../config.direct.example.json) to `config.json` — no `script_id`, no `auth_key` required +2. Copy [`config.direct.example.toml`](../config.direct.example.toml) to `config.toml` — no `script_id`, no `auth_key` required 3. Run `mhrv-rs serve` and set browser HTTP proxy to `127.0.0.1:8085` 4. In `direct` mode, the proxy only routes `*.google.com`, `*.youtube.com`, and other Google-edge hosts (plus any [`fronting_groups`](fronting-groups.md) you've configured) via the SNI-rewrite tunnel. Other traffic goes raw — no Apps Script relay exists yet. 5. Now do Step 1 in your browser (the connection to `script.google.com` will be SNI-fronted). Deploy `Code.gs`, copy the Deployment ID. -6. In the UI / Android app / by editing `config.json`, switch mode to `apps_script`, paste the Deployment ID and your auth key, and restart. +6. In the UI / Android app / by editing `config.toml`, switch mode to `apps_script`, paste the Deployment ID and your auth key, and restart. Verify reachability before even starting the proxy: `mhrv-rs test-sni` probes `*.google.com` directly and works without any config beyond `google_ip` + `front_domain`. ## CLI reference -Everything the UI does is also in the CLI. Copy `config.example.json` to `config.json` (next to the binary, or in the user-data dir): - -```json -{ - "mode": "apps_script", - "google_ip": "216.239.38.120", - "front_domain": "www.google.com", - "script_id": "PASTE_YOUR_DEPLOYMENT_ID_HERE", - "auth_key": "same-secret-as-in-code-gs", - "listen_host": "127.0.0.1", - "listen_port": 8085, - "socks5_port": 8086, - "log_level": "info", - "verify_ssl": true -} +Everything the UI does is also in the CLI. Copy `config.example.toml` to `config.toml` (next to the binary, or in the user-data dir): + +```toml +[relay] +mode = "apps_script" +script_id = "PASTE_YOUR_DEPLOYMENT_ID_HERE" +auth_key = "same-secret-as-in-code-gs" + +[network] +google_ip = "216.239.38.120" +front_domain = "www.google.com" +listen_host = "127.0.0.1" +listen_port = 8085 +socks5_port = 8086 +verify_ssl = true + +[logging] +log_level = "info" ``` Then: @@ -155,7 +158,7 @@ Then: ./mhrv-rs --help ``` -`--remove-cert` deletes the CA from the OS trust store, deletes the on-disk `ca/` directory, and verifies the revocation by name. NSS cleanup (Firefox, Chrome on Linux) is best-effort: if `certutil` isn't on PATH or a browser holds the NSS DB open, the tool logs a manual-cleanup hint. Your `config.json` and the Apps Script deployment are untouched, so a fresh CA does not require redeploying `Code.gs`. +`--remove-cert` deletes the CA from the OS trust store, deletes the on-disk `ca/` directory, and verifies the revocation by name. NSS cleanup (Firefox, Chrome on Linux) is best-effort: if `certutil` isn't on PATH or a browser holds the NSS DB open, the tool logs a manual-cleanup hint. Your `config.toml` and the Apps Script deployment are untouched, so a fresh CA does not require redeploying `Code.gs`. > **Upgrading from pre-v1.2.11?** Earlier versions wrote a bare `user_pref("security.enterprise_roots.enabled", true);` into each Firefox profile's `user.js` without a marker. `--remove-cert` does not strip that line — it's indistinguishable from one a user or corp policy wrote. Firefox falls back to its built-in Mozilla root store the moment the MITM CA leaves the OS trust store, so this has no functional effect. Delete by hand if it bothers you. @@ -163,15 +166,14 @@ Then: ### scan-ips API mode -By default, `scan-ips` uses a static list. Enable dynamic IP discovery in `config.json`: +By default, `scan-ips` uses a static list. Enable dynamic IP discovery in `config.toml`: -```json -{ - "fetch_ips_from_api": true, - "max_ips_to_scan": 100, - "scan_batch_size": 100, - "google_ip_validation": true -} +```toml +[scan] +fetch_ips_from_api = true +max_ips_to_scan = 100 +scan_batch_size = 100 +google_ip_validation = true ``` When enabled: @@ -197,10 +199,9 @@ Browser ┘ └─ upstream Config fragment: -```json -{ - "upstream_socks5": "127.0.0.1:50529" -} +```toml +[network] +upstream_socks5 = "127.0.0.1:50529" ``` HTTP / HTTPS keeps going through Apps Script (no change), and the SNI-rewrite tunnel for `google.com` / `youtube.com` keeps bypassing both — YouTube stays as fast as before while Telegram gets a real tunnel. @@ -247,12 +248,11 @@ More deployments = more total concurrency = lower per-session latency. Each batc Multi-arch (linux/amd64 + linux/arm64), runs as non-root, ~32 MB compressed. Pin a version tag (`:1.5.0`) for production. See [tunnel-node/README.md](../tunnel-node/README.md) for Cloud Run, docker-compose, and source-build alternatives. 3. Set `"mode": "full"` in your config with all deployment IDs: - ```json - { - "mode": "full", - "script_id": ["id1", "id2", "id3", "id4", "id5", "id6"], - "auth_key": "your-secret" - } + ```toml + [relay] + mode = "full" + script_id = ["id1", "id2", "id3", "id4", "id5", "id6"] + auth_key = "your-secret" ``` ## Exit node @@ -297,7 +297,7 @@ The `*-linux-musl-*` archives ship a fully static CLI that runs on OpenWRT, Alpi # From a machine that can reach your router: 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 +scp config.toml root@192.168.1.1:/etc/mhrv-rs/config.toml # On the router: chmod +x /usr/bin/mhrv-rs /etc/init.d/mhrv-rs @@ -306,7 +306,7 @@ chmod +x /usr/bin/mhrv-rs /etc/init.d/mhrv-rs logread -e mhrv-rs -f # tail logs ``` -LAN devices then point HTTP proxy at the router's LAN IP (default port `8085`) or SOCKS5 at `:8086`. Set `listen_host` to `0.0.0.0` in `/etc/mhrv-rs/config.json` so the router accepts LAN connections. +LAN devices then point HTTP proxy at the router's LAN IP (default port `8085`) or SOCKS5 at `:8086`. Set `listen_host` to `0.0.0.0` in `/etc/mhrv-rs/config.toml` so the router accepts LAN connections. Memory footprint ~15–20 MB resident — fine on anything ≥128 MB RAM. No UI on musl (routers are headless). @@ -324,12 +324,11 @@ By default, mhrv-rs rotates through `{www, mail, drive, docs, calendar}.google.c Either: - UI → **SNI pool…** → **Test all** → **Keep ✓ only** to auto-trim. Add custom names via the text field at the bottom. Save. -- Or edit `config.json`: +- Or edit `config.toml`: -```json -{ - "sni_hosts": ["www.google.com", "drive.google.com", "docs.google.com"] -} +```toml +[relay] +sni_hosts = ["www.google.com", "drive.google.com", "docs.google.com"] ``` Leaving `sni_hosts` unset gives you the default auto-pool. Run `mhrv-rs test-sni` to verify what works from your network. @@ -405,7 +404,7 @@ These are inherent to the Apps Script + domain-fronting approach, not bugs in th - **Easiest (any OS):** click **Remove CA** in the UI, or: - macOS / Linux: `sudo ./mhrv-rs --remove-cert` - Windows (run as administrator): `mhrv-rs.exe --remove-cert` - - Removes from system trust store, NSS (Firefox / Chrome on Linux), and deletes `ca/ca.crt` + `ca/ca.key` on disk. Your `config.json` and Apps Script deployment are not touched. + - Removes from system trust store, NSS (Firefox / Chrome on Linux), and deletes `ca/ca.crt` + `ca/ca.key` on disk. Your `config.toml` and Apps Script deployment are not touched. - **Manually:** the cert's Common Name is `MasterHttpRelayVPN` (not `mhrv-rs` — that's the app name). - **macOS:** Keychain Access → System → search `MasterHttpRelayVPN` → delete. Then `rm -rf ~/Library/Application\ Support/mhrv-rs/ca/` - **Windows:** `certmgr.msc` → Trusted Root Certification Authorities → search `MasterHttpRelayVPN` → delete diff --git a/docs/maintainer/references/architecture.md b/docs/maintainer/references/architecture.md index 476b01df..edbe2380 100644 --- a/docs/maintainer/references/architecture.md +++ b/docs/maintainer/references/architecture.md @@ -47,7 +47,7 @@ These are the constant source of user confusion. Get the names right: | Secret | Lives where | Must match | Notes | |--------|-------------|------------|-------| -| `AUTH_KEY` (or `auth_key` in mhrv-rs config.json) | mhrv-rs `config.json` ↔ `Code.gs`/`CodeFull.gs` | Both ends | Per-deployment user secret; protects against random people hitting the user's deployment URL. Editing it in Code.gs without **redeploying as a new version** in Apps Script is the single most common user mistake. | +| `AUTH_KEY` (or `auth_key` in mhrv-rs config.toml) | mhrv-rs `config.toml` ↔ `Code.gs`/`CodeFull.gs` | Both ends | Per-deployment user secret; protects against random people hitting the user's deployment URL. Editing it in Code.gs without **redeploying as a new version** in Apps Script is the single most common user mistake. | | `TUNNEL_AUTH_KEY` | `CodeFull.gs` ↔ tunnel-node container env var | Both ends | Full mode only. Env var name is **literally `TUNNEL_AUTH_KEY`** — uppercase, with underscores, exact string. Several users have written `MHRV_AUTH_KEY` (wrong) or `Tunnel` (wrong); the env var is case-sensitive in Linux/Docker and any deviation falls back to the default `changeme`. | | `DIAGNOSTIC_MODE` | `Code.gs` and `CodeFull.gs` (constant at top) | n/a — local toggle | When `false` (default), the script returns a benign HTML decoy (`"The script completed but did not return anything"`) for bad-auth requests, mimicking Apps Script's own placeholder. When `true`, returns explicit JSON `{"e":"unauthorized"}`. The decoy mode is anti-active-probing defense (#357 pattern); diagnostic mode is for setup. | @@ -86,7 +86,7 @@ The TLS handshake between mhrv-rs and Apps Script does: Iran ISPs occasionally filter specific Google IPs (#313 pattern). When this happens, the user can rotate `google_ip` to another IP from `DEFAULT_GOOGLE_SNI_POOL` (the 12-entry list in `src/domain_fronter.rs`). `mhrv-rs scan-ips` is a diagnostic command that probes Google IPs from the user's network and reports which ones complete TLS handshakes. -`scan_config.json` (separate from main `config.json`) is the input for `mhrv-rs scan-ips` — users sometimes confuse the two and put the scan config where the main config should be. See `issue-patterns.md`. +`scan_config.toml` (separate from main `config.toml`) is the input for `mhrv-rs scan-ips` — users sometimes confuse the two and put the scan config where the main config should be. See `issue-patterns.md`. ## v1.8.0 anti-fingerprinting features @@ -112,7 +112,7 @@ Iran ISPs occasionally filter specific Google IPs (#313 pattern). When this happ - `src/tunnel_client.rs` — Full mode batch client. Decoy detection + script_id-in-logs added v1.8.1; softer 6-cause message v1.8.3. - `src/mitm/` — MITM cert manager. - `src/cert_installer/` — per-OS trust store installation logic. -- `src/config.rs` — `Config` struct + JSON serde. Default values, validation. +- `src/config.rs` — `Config` struct + TOML/JSON serde. Default values, validation. - `assets/apps_script/Code.gs` and `CodeFull.gs` — server-side scripts. Edit these and tell users to redeploy as new version in Apps Script. - `tunnel-node/` — separate Rust crate for the Full-mode VPS container. README + README.fa.md (Persian translation). - `android/app/src/main/java/com/therealaleph/mhrv/` — Android Kotlin glue. `MhrvVpnService.kt` is the VPNService that calls into Rust via JNI. `ConfigStore.kt` is the form/preferences round-trip. diff --git a/docs/maintainer/references/diagnostic-taxonomy.md b/docs/maintainer/references/diagnostic-taxonomy.md index 35b8b6f8..aae27bac 100644 --- a/docs/maintainer/references/diagnostic-taxonomy.md +++ b/docs/maintainer/references/diagnostic-taxonomy.md @@ -21,7 +21,7 @@ This taxonomy is the post-mortem evolution of v1.8.0 → v1.8.1 → v1.8.2 → v **Source**: Our `Code.gs` / `CodeFull.gs` returns this when `request.k !== AUTH_KEY` and `DIAGNOSTIC_MODE = false`. It mimics Apps Script's stock placeholder for empty-return scripts. -**Trigger**: User edited AUTH_KEY in Apps Script editor but didn't redeploy as new version, OR user has different AUTH_KEY in `config.json` than in `Code.gs`, OR user is using Code.gs deployment ID with `mode: full` (which expects CodeFull.gs). +**Trigger**: User edited AUTH_KEY in Apps Script editor but didn't redeploy as new version, OR user has different AUTH_KEY in `config.toml` than in `Code.gs`, OR user is using Code.gs deployment ID with `mode: full` (which expects CodeFull.gs). **Disambiguator**: Set `DIAGNOSTIC_MODE = true` in Code.gs / CodeFull.gs + redeploy as new version. Then this case returns `{"e":"unauthorized"}` (explicit JSON) instead of the HTML. The other 5 cases are independent of DIAGNOSTIC_MODE and still return their natural body. @@ -37,7 +37,7 @@ This taxonomy is the post-mortem evolution of v1.8.0 → v1.8.1 → v1.8.2 → v **Disambiguator**: With `DIAGNOSTIC_MODE = true`, AUTH_KEY mismatch (cause 1) goes away; if the placeholder body still appears for some batches, it's likely cause 2/3/4/5/6. -**Fix**: Lower `parallel_concurrency` in `config.json`, retry, accept some intermittent failures. +**Fix**: Lower `parallel_concurrency` in `config.toml`, retry, accept some intermittent failures. ### 3. Apps Script soft-quota tear diff --git a/docs/maintainer/references/issue-patterns.md b/docs/maintainer/references/issue-patterns.md index 87dd77b5..31761cd1 100644 --- a/docs/maintainer/references/issue-patterns.md +++ b/docs/maintainer/references/issue-patterns.md @@ -10,7 +10,7 @@ The repo gets the same ~15 issues over and over with different wrappers. Recogni - Issue title often "502 error", "خطای 502", "ارور relay", or "no json in batch response" - Often combined with: "MITM mode works but Full mode doesn't" (CodeFull.gs has different AUTH_KEY than Code.gs) -**Root cause**: The `AUTH_KEY` constant in `Code.gs` (or `CodeFull.gs`) on Apps Script doesn't match the `auth_key` field in mhrv-rs `config.json`. Apps Script returns the v1.8.0 decoy HTML. +**Root cause**: The `AUTH_KEY` constant in `Code.gs` (or `CodeFull.gs`) on Apps Script doesn't match the `auth_key` field in mhrv-rs `config.toml`. Apps Script returns the v1.8.0 decoy HTML. **The hidden killer**: Apps Script does NOT auto-pickup edits to deployed scripts. Editing `const AUTH_KEY = "..."` in the Apps Script editor and clicking Save does nothing for the deployed version. The user must: @@ -215,16 +215,16 @@ No confirmed cases of full Google account ban (Gmail deletion, Drive loss). Susp Architectural ceiling — can't be fixed in mhrv-rs core. -## Pattern 10: Config file confusion (config.json vs scan_config.json) +## Pattern 10: Config file confusion (config.toml vs scan_config.json) **Symptoms**: - "I followed instructions but it doesn't import the config" - User pastes a config that has `google_ips`, `max_ips_to_scan`, `scan_batch_size`, `google_ip_validation` fields - Says "the program doesn't pick up my config" -**Root cause**: User confused `config.json` (main runtime config — `script_ids`, `auth_key`, `google_ip`, `mode`, etc.) with `scan_config.json` (input for `mhrv-rs scan-ips` diagnostic command — Google IP discovery). +**Root cause**: User confused `config.toml` (main runtime config — `script_ids`, `auth_key`, `google_ip`, `mode`, etc.) with `scan_config.json` (input for `mhrv-rs scan-ips` diagnostic command — Google IP discovery). -**Fix**: explain the two files, point at `config.example.json` in repo root for the right template. +**Fix**: explain the two files, point at `config.example.toml` in repo root for the right template. Common related typos: - `script_id` (singular) instead of `script_ids` (plural array) — mhrv-rs parses as 0 deployments and falls back @@ -239,7 +239,7 @@ Common related typos: **Root cause**: User's Windows lacks OpenGL 2.0+ AND lacks DX12/Vulkan-compatible GPU. Causes: old GPU (Intel HD 2500/3000-era), running in VM without GPU acceleration, RDP session, missing/corrupt graphics drivers. -**Workaround**: use the CLI binary `mhrv-rs.exe` directly. Put `config.json` in the same folder, double-click `mhrv-rs.exe`, set browser proxy to `127.0.0.1:8086`. Same functionality, no UI. +**Workaround**: use the CLI binary `mhrv-rs.exe` directly. Put `config.toml` in the same folder, double-click `mhrv-rs.exe`, set browser proxy to `127.0.0.1:8086`. Same functionality, no UI. v1.8.x roadmap: improve `run.bat` to auto-fallback to CLI when both UI renderers fail. diff --git a/docs/maintainer/references/persian-templates.md b/docs/maintainer/references/persian-templates.md index 932dcd76..0a00a8aa 100644 --- a/docs/maintainer/references/persian-templates.md +++ b/docs/maintainer/references/persian-templates.md @@ -43,7 +43,7 @@ URL deployment همون می‌مونه ولی الان Apps Script کد جدی سپس **redeploy as new version** کنید (مثل بالا). سپس test: - اگر **هنوز decoy body همون** برمی‌گرده → علت **NOT** AUTH_KEY mismatch است (یکی از سایر ۵ علت) -- اگر **JSON `{"e":"unauthorized"}` صریح** برمی‌گرده → بله، AUTH_KEY mismatch — fix رو با aligning AUTH_KEY در config.json با Code.gs انجام دهید + redeploy as new version +- اگر **JSON `{"e":"unauthorized"}` صریح** برمی‌گرده → بله، AUTH_KEY mismatch — fix رو با aligning AUTH_KEY در config.toml با Code.gs انجام دهید + redeploy as new version بعد از debug کامل، DIAGNOSTIC_MODE رو به `false` برگردونید + redeploy. در production این flag رو false نگه می‌داریم چون decoy body anti-fingerprinting protection محسوب می‌شه. @@ -142,11 +142,9 @@ curl -L -X POST 'https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec' \ ۲. **`disable_padding: true` در config:** -```json -{ - "disable_padding": true, - ... -} +```toml +[relay] +disable_padding = true ``` ~۲۵٪ bandwidth کم‌تر، در شبکه‌های throttle شده compounds رو کم می‌کنه. @@ -264,12 +262,11 @@ const TUNNEL_AUTH_KEY = "your-tunnel-secret-here"; // match با docker run -e **۷. mhrv-rs config:** -```json -{ - "mode": "full", - "auth_key": "your-mhrv-auth-key", - "script_ids": ["YOUR_DEPLOYMENT_ID"] -} +```toml +[relay] +mode = "full" +auth_key = "your-mhrv-auth-key" +script_ids = ["YOUR_DEPLOYMENT_ID"] ``` **`script_ids` plural با s** — این یک typo رایجه که config رو 0-deployment می‌کنه. diff --git a/docs/maintainer/references/workflow-conventions.md b/docs/maintainer/references/workflow-conventions.md index f3c3ba89..9c2669cc 100644 --- a/docs/maintainer/references/workflow-conventions.md +++ b/docs/maintainer/references/workflow-conventions.md @@ -32,7 +32,7 @@ The repo's userbase is majority Persian-speaking. Writing in their language matt - Code blocks (Rust, JSON, bash, JS — all stay as-is) - Command-line examples (`gh issue close N`, `cargo build`, `docker run ...`) - Technical identifiers: `AUTH_KEY`, `TUNNEL_AUTH_KEY`, `script_id`, `parallel_concurrency`, `disable_padding`, `tunnel_doh`, `bypass_doh_hosts`, `DIAGNOSTIC_MODE`, `passthrough_hosts`, `google_ip`, `mode: "full"` / `mode: "apps_script"` -- Filename references: `Code.gs`, `CodeFull.gs`, `config.json`, `tunnel-node`, `mhrv-rs.exe`, `MhrvVpnService.kt`, `domain_fronter.rs` +- Filename references: `Code.gs`, `CodeFull.gs`, `config.toml`, `tunnel-node`, `mhrv-rs.exe`, `MhrvVpnService.kt`, `domain_fronter.rs` - URLs and links - The reply marker - Issue references like `#404`, `#313` diff --git a/scripts/bench-pipeline.sh b/scripts/bench-pipeline.sh index 65fd2aba..200a5533 100755 --- a/scripts/bench-pipeline.sh +++ b/scripts/bench-pipeline.sh @@ -8,11 +8,11 @@ # Usage: # ./scripts/bench-pipeline.sh [CONFIG_FILE] # -# Default: config.json +# Default: config.toml set -euo pipefail -CONFIG="${1:-config.json}" +CONFIG="${1:-config.toml}" RUNS=3 SOCKS_PORT=18088 HTTP_PORT=18087 @@ -43,16 +43,40 @@ echo "╚═══════════════════════ echo "" # Write a temp config with our ports -TEMP_CONFIG="$TMPDIR_BENCH/config.json" +TEMP_CONFIG="$TMPDIR_BENCH/bench.json" python3 -c " -import json -with open('$CONFIG') as f: - c = json.load(f) -c['listen_port'] = $HTTP_PORT -c['socks5_port'] = $SOCKS_PORT +import sys, os, json + +config_path = '${CONFIG}' +ext = os.path.splitext(config_path)[1].lower() + +if ext == '.toml': + if sys.version_info >= (3, 11): + import tomllib + else: + try: + import tomli as tomllib + except ImportError: + sys.exit('ERROR: Python 3.11+ or pip install tomli required to read TOML config') + with open(config_path, 'rb') as f: + t = tomllib.load(f) + c = {} + for section in ('relay', 'network', 'scan', 'logging'): + c.update(t.get(section, {})) + if 'exit_node' in t: + c['exit_node'] = t['exit_node'] + if 'fronting_groups' in t: + c['fronting_groups'] = t['fronting_groups'] +else: + with open(config_path) as f: + c = json.load(f) + +c['listen_port'] = ${HTTP_PORT} +c['socks5_port'] = ${SOCKS_PORT} c['log_level'] = 'warn' -with open('$TEMP_CONFIG', 'w') as f: - json.dump(c, f) + +with open('${TEMP_CONFIG}', 'w') as f: + json.dump(c, f, indent=2) " run_test() { diff --git a/src/android_jni.rs b/src/android_jni.rs index a551e83e..7bccfdd2 100644 --- a/src/android_jni.rs +++ b/src/android_jni.rs @@ -1,7 +1,7 @@ //! JNI entry points for the Android app. //! //! The app (Kotlin) calls `Native.setDataDir()` once, then `Native.startProxy()` -//! with the full config.json payload and gets back a handle (u64). Later the +//! with the full config payload (JSON or TOML string) and gets back a handle (u64). Later the //! app calls `stopProxy(handle)` to stop, `statsJson(handle)` to poll, or //! `exportCa(dest)` to copy the MITM CA cert to a path the app can hand to //! Android's system "install certificate" dialog. @@ -26,7 +26,7 @@ use jni::JNIEnv; use tokio::runtime::Runtime; use tokio::sync::{oneshot, Mutex as AsyncMutex}; -use crate::config::Config; +use crate::config::{Config, TomlConfig}; use crate::mitm::{MitmCertManager, CA_CERT_FILE}; use crate::proxy_server::ProxyServer; @@ -188,12 +188,15 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_startProxy( install_logging_once(); let json = jstring_to_string(&mut env, &config_json); - let config: Config = match serde_json::from_str(&json) { + let config: Config = match serde_json::from_str::(&json) { Ok(c) => c, - Err(e) => { - tracing::error!("android: invalid config json: {}", e); - return 0i64; - } + Err(json_err) => match toml::from_str::(&json) { + Ok(tc) => Config::from(tc), + Err(_) => { + tracing::error!("android: invalid config: {}", json_err); + return 0; + } + }, }; // Try to build the runtime first — if allocation fails we want to diff --git a/src/bin/ui.rs b/src/bin/ui.rs index 9c6799b7..8db9fd0f 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -38,7 +38,7 @@ fn main() -> eframe::Result<()> { // with their saved log level. Otherwise the form's log-level combobox // would only ever take effect via env var or after Save → restart, and // users on the UI binary (issue #401) reasonably expect the saved - // config.json `log_level` to apply at boot like it does for the CLI. + // config.toml `log_level` to apply at boot like it does for the CLI. let (form, load_err) = load_form(); let initial_toast = load_err.map(|e| (e, Instant::now())); @@ -252,36 +252,36 @@ struct FormState { normalize_x_graphql: bool, youtube_via_relay: bool, passthrough_hosts: Vec, - /// Round-tripped from config.json so the UI's save path doesn't + /// Round-tripped from config.toml so the UI's save path doesn't /// drop the user's setting. Not currently exposed as a UI control; - /// users edit `block_quic` directly in `config.json` (Issue #213). + /// users edit `block_quic` directly in `config.toml` (Issue #213). block_quic: bool, - /// Round-tripped from config.json and exposed beside QUIC blocking. + /// Round-tripped from config.toml and exposed beside QUIC blocking. /// Default true to push WebRTC apps toward TCP TURN instead of slow /// UDP ICE retries. block_stun: bool, - /// Round-tripped from config.json. Not exposed as a UI control — + /// Round-tripped from config.toml. Not exposed as a UI control — /// users edit `disable_padding` directly when needed (Issue #391). /// Default false (padding active). disable_padding: bool, - /// Round-tripped from config.json. Not exposed as a UI control — + /// Round-tripped from config.toml. Not exposed as a UI control — /// users edit `force_http1` directly when needed. Default false /// (HTTP/2 multiplexing on the relay leg active). force_http1: bool, - /// Round-tripped from config.json. Not exposed in the UI form yet — + /// Round-tripped from config.toml. Not exposed in the UI form yet — /// the bypass-DoH default is the right answer for almost everyone /// (DoH already encrypts, the tunnel was just adding latency), so /// this is a config-only opt-out. See config.rs `tunnel_doh`. tunnel_doh: bool, /// User-supplied DoH hostnames added to the built-in default list, - /// round-tripped from config.json. See config.rs `bypass_doh_hosts`. + /// round-tripped from config.toml. See config.rs `bypass_doh_hosts`. bypass_doh_hosts: Vec, /// PR #763: when true, immediately reject browser DoH CONNECTs so the /// browser falls back to system DNS (tun2proxy virtual DNS — instant). - /// Round-tripped from config.json. Desktop UI doesn't expose a toggle + /// Round-tripped from config.toml. Desktop UI doesn't expose a toggle /// yet — Android does. See config.rs `block_doh`. block_doh: bool, - /// Multi-edge fronting groups. Round-tripped from config.json so + /// Multi-edge fronting groups. Round-tripped from config.toml so /// the UI's Save doesn't drop the user's hand-edited groups — /// there is no UI editor for these yet, only file-edited config. /// See config.rs `fronting_groups`. @@ -311,13 +311,12 @@ fn load_form() -> (FormState, Option) { // fails so the user isn't silently shown a blank form (issue: user reports // 'settings saved to file but not loaded back'). Without this signal the // failure is invisible — `.ok()` swallows it and the form looks fresh. - let path = data_dir::config_path(); - let cwd = PathBuf::from("config.json"); + let path = data_dir::resolve_config_path(None); let (existing, load_err): (Option, Option) = if path.exists() { tracing::info!("config: attempting load from {}", path.display()); match Config::load(&path) { - Ok(c) => { + Ok((c, _)) => { tracing::info!("config: loaded OK from {}", path.display()); (Some(c), None) } @@ -327,16 +326,6 @@ fn load_form() -> (FormState, Option) { (None, Some(msg)) } } - } else if cwd.exists() { - tracing::info!("config: attempting fallback load from {}", cwd.display()); - match Config::load(&cwd) { - Ok(c) => (Some(c), None), - Err(e) => { - let msg = format!("Config at {} failed to load: {}", cwd.display(), e); - tracing::warn!("{}", msg); - (None, Some(msg)) - } - } } else { tracing::info!( "config: no config found at {} — starting with defaults", @@ -609,7 +598,7 @@ impl FormState { // tun2proxy's virtual DNS handles name lookups, saving the // ~1.5s tunnel round-trip per DNS query). Desktop UI doesn't // expose a toggle yet (Android does), so this is a config-only - // round-trip — we keep whatever the user has in config.json. + // round-trip — we keep whatever the user has in config.toml. block_doh: self.block_doh, // Multi-edge fronting groups: file-edited only for now, // round-tripped through the UI so Save doesn't drop them. @@ -642,8 +631,9 @@ fn save_config(cfg: &Config) -> Result { 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())?; - std::fs::write(&path, json).map_err(|e| e.to_string())?; + let toml_str = toml::to_string_pretty(&mhrv_rs::config::TomlConfig::from(cfg)) + .map_err(|e| e.to_string())?; + std::fs::write(&path, toml_str).map_err(|e| e.to_string())?; Ok(path) } @@ -1092,7 +1082,7 @@ impl eframe::App for App { // text field — typing `0.0.0.0` from memory is enough of // a friction point that almost no one does it. Power // users with a custom bind IP (specific NIC) can still - // edit `listen_host` directly in `config.json`; we + // edit `listen_host` directly in `config.toml`; we // detect that case and show a "Custom bind" badge so // the checkbox doesn't silently overwrite their setting // on the next Save. @@ -1122,14 +1112,14 @@ impl eframe::App for App { if is_custom_bind { // The user manually wrote a specific bind IP — // don't let the checkbox stomp on it. Show what - // they have and tell them to edit config.json + // they have and tell them to edit config.toml // if they want to change it. ui.vertical(|ui| { ui.label(egui::RichText::new(format!( "Custom bind: {}", listen_host_snapshot )).color(egui::Color32::from_rgb(220, 180, 100))); - ui.small("Edit `listen_host` in config.json to change."); + ui.small("Edit `listen_host` in config.toml to change."); }); } else { let mut share = was_share_on_lan; @@ -1608,7 +1598,7 @@ impl eframe::App for App { and delete the on-disk ca/ directory. NSS cleanup (Firefox/Chrome) \ is best-effort and logs a hint if certutil is missing or a browser \ has the DB locked. A fresh CA is generated the next time you start \ - the proxy. Your config.json and the Apps Script deployment are NOT \ + the proxy. Your config.toml and the Apps Script deployment are NOT \ touched — no need to redeploy Code.gs." }; ui.add_enabled_ui(!proxy_active && !running && !cert_op_in_flight, |ui| { @@ -2412,7 +2402,7 @@ fn background_thread(shared: Arc, rx: Receiver) { push_log(&shared2, &format!("[ui] {}", outcome.summary())); push_log( &shared2, - "[ui] config.json and Apps Script deployment untouched", + "[ui] config.toml and Apps Script deployment untouched", ); } Err(e) => { diff --git a/src/cert_installer.rs b/src/cert_installer.rs index 3e0884dd..2ec89c34 100644 --- a/src/cert_installer.rs +++ b/src/cert_installer.rs @@ -224,7 +224,7 @@ pub fn install_ca(path: &Path) -> Result<(), InstallError> { /// profiles + Chrome/Chromium on Linux), and delete the on-disk /// `ca/ca.crt` + `ca/ca.key`. A fresh CA will be regenerated the next /// time the proxy starts — and since the Apps Script deployment lives on -/// Google's side and `config.json` is never touched here, the user does +/// Google's side and `config.toml` is never touched here, the user does /// not have to redeploy `Code.gs` or re-enter their deployment ID. /// Platform-specific — may require admin/sudo for system stores. /// diff --git a/src/config.rs b/src/config.rs index 132b73b0..cd63b8b8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,8 @@ pub enum ConfigError { Read(String, #[source] std::io::Error), #[error("failed to parse config json: {0}")] Parse(#[from] serde_json::Error), + #[error("failed to parse config toml: {0}")] + ParseToml(#[from] toml::de::Error), #[error("invalid config: {0}")] Invalid(String), } @@ -41,7 +43,7 @@ impl Mode { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum ScriptId { One(String), @@ -549,14 +551,75 @@ fn default_verify_ssl() -> bool { } impl Config { - pub fn load(path: &Path) -> Result { + pub fn load(path: &Path) -> Result<(Self, Option), ConfigError> { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + + match ext.as_str() { + "toml" => Self::load_toml(path).map(|c| (c, None)), + "json" => Self::load_json_and_migrate(path), + _ => { + // No extension or unrecognised: try TOML first, then JSON. + // JSON success also triggers migration. On double failure, + // surface the TOML error (the format new configs expect). + let toml_err = match Self::load_toml(path) { + Ok(cfg) => return Ok((cfg, None)), + Err(e) => e, + }; + match Self::load_json_and_migrate(path) { + Ok((cfg, msg)) => Ok((cfg, msg)), + Err(_) => Err(toml_err), + } + } + } + } + + pub fn load_toml(path: &Path) -> Result { let data = std::fs::read_to_string(path) .map_err(|e| ConfigError::Read(path.display().to_string(), e))?; - let cfg: Config = serde_json::from_str(&data)?; + let toml_cfg: TomlConfig = toml::from_str(&data) + .map_err(ConfigError::ParseToml)?; + let cfg = Config::from(toml_cfg); cfg.validate()?; Ok(cfg) } + fn load_json_and_migrate(path: &Path) -> Result<(Self, Option), ConfigError> { + let data = std::fs::read_to_string(path) + .map_err(|e| ConfigError::Read(path.display().to_string(), e))?; + let cfg: Config = serde_json::from_str(&data)?; + cfg.validate()?; + + // Write a .toml equivalent alongside the .json file. Failure is + // non-fatal: the in-memory Config is still valid and returned. + let toml_path = path.with_extension("toml"); + let msg = match toml::to_string_pretty(&TomlConfig::from(&cfg)) { + Ok(toml_str) => match std::fs::write(&toml_path, &toml_str) { + Ok(()) => Some(format!( + "Found legacy config.json. Translated to {} automatically. \ + config.json has been left in place but will no longer be read. \ + You can delete it.", + toml_path.display() + )), + Err(e) => Some(format!( + "Found legacy config.json but could not write {}: {}. \ + Continuing from the JSON config.", + toml_path.display(), e + )), + }, + Err(e) => Some(format!( + "Found legacy config.json but could not serialize to TOML: {}. \ + Continuing from the JSON config.", + e + )), + }; + + Ok((cfg, msg)) + } + fn validate(&self) -> Result<(), ConfigError> { let mode = self.mode_kind()?; if mode == Mode::AppsScript || mode == Mode::Full { @@ -587,7 +650,7 @@ impl Config { if self.socks5_port == Some(self.listen_port) { return Err(ConfigError::Invalid(format!( "listen_port and socks5_port must differ on the same host \ - (both set to {} on {}). Change one of them in config.json.", + (both set to {} on {}). Change one of them in config.toml.", self.listen_port, self.listen_host ))); } @@ -663,6 +726,259 @@ impl Config { } } +// TOML intermediate structs +// +// The flat `Config` struct and all its callers are unchanged. These structs +// only exist inside Config::load_toml and the JSON->TOML migration writer. +// Both paths produce a flat Config in the end via From. + +/// [relay] section of config.toml. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TomlRelay { + pub mode: String, + #[serde(default)] + pub script_id: Option, + #[serde(default)] + pub script_ids: Option, + #[serde(default)] + pub auth_key: String, + #[serde(default)] + pub parallel_relay: u8, + #[serde(default)] + pub enable_batching: bool, + #[serde(default)] + pub coalesce_step_ms: u16, + #[serde(default)] + pub coalesce_max_ms: u16, + #[serde(default)] + pub youtube_via_relay: bool, + #[serde(default)] + pub normalize_x_graphql: bool, + #[serde(default)] + pub disable_padding: bool, + #[serde(default)] + pub force_http1: bool, + #[serde(default = "default_auto_blacklist_strikes")] + pub auto_blacklist_strikes: u32, + #[serde(default = "default_auto_blacklist_window_secs")] + pub auto_blacklist_window_secs: u64, + #[serde(default = "default_auto_blacklist_cooldown_secs")] + pub auto_blacklist_cooldown_secs: u64, + #[serde(default = "default_request_timeout_secs")] + pub request_timeout_secs: u64, +} + +/// [network] section of config.toml. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TomlNetwork { + #[serde(default = "default_google_ip")] + pub google_ip: String, + #[serde(default = "default_front_domain")] + pub front_domain: String, + #[serde(default = "default_listen_host")] + pub listen_host: String, + #[serde(default = "default_listen_port")] + pub listen_port: u16, + #[serde(default)] + pub socks5_port: Option, + #[serde(default = "default_verify_ssl")] + pub verify_ssl: bool, + #[serde(default)] + pub upstream_socks5: Option, + #[serde(default = "default_block_quic")] + pub block_quic: bool, + #[serde(default = "default_block_stun")] + pub block_stun: bool, + #[serde(default)] + pub sni_hosts: Option>, + #[serde(default)] + pub passthrough_hosts: Vec, + #[serde(default = "default_tunnel_doh")] + pub tunnel_doh: bool, + #[serde(default = "default_block_doh")] + pub block_doh: bool, + #[serde(default)] + pub bypass_doh_hosts: Vec, + #[serde(default)] + pub hosts: HashMap, +} + +impl Default for TomlNetwork { + fn default() -> Self { + Self { + google_ip: default_google_ip(), + front_domain: default_front_domain(), + listen_host: default_listen_host(), + listen_port: default_listen_port(), + socks5_port: None, + verify_ssl: default_verify_ssl(), + upstream_socks5: None, + block_quic: default_block_quic(), + block_stun: default_block_stun(), + sni_hosts: None, + passthrough_hosts: Vec::new(), + tunnel_doh: default_tunnel_doh(), + block_doh: default_block_doh(), + bypass_doh_hosts: Vec::new(), + hosts: HashMap::new(), + } + } +} + +/// [scan] section of config.toml. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TomlScan { + #[serde(default = "default_fetch_ips_from_api")] + pub fetch_ips_from_api: bool, + #[serde(default = "default_max_ips_to_scan")] + pub max_ips_to_scan: usize, + #[serde(default = "default_scan_batch_size")] + pub scan_batch_size: usize, + #[serde(default = "default_google_ip_validation")] + pub google_ip_validation: bool, +} + +impl Default for TomlScan { + fn default() -> Self { + Self { + fetch_ips_from_api: default_fetch_ips_from_api(), + max_ips_to_scan: default_max_ips_to_scan(), + scan_batch_size: default_scan_batch_size(), + google_ip_validation: default_google_ip_validation(), + } + } +} + +/// [logging] section of config.toml. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TomlLogging { + #[serde(default = "default_log_level")] + pub log_level: String, +} + +impl Default for TomlLogging { + fn default() -> Self { + Self { log_level: default_log_level() } + } +} + +/// Root config.toml document. Deserialized first, then flattened into +/// `Config` via `From` so the rest of the codebase is untouched. +#[derive(Debug, Deserialize, Serialize)] +pub struct TomlConfig { + pub relay: TomlRelay, + #[serde(default)] + pub network: TomlNetwork, + #[serde(default)] + pub scan: TomlScan, + #[serde(default)] + pub logging: TomlLogging, + #[serde(default)] + pub exit_node: ExitNodeConfig, + #[serde(default)] + pub fronting_groups: Vec, +} + +impl From for Config { + fn from(t: TomlConfig) -> Self { + Config { + mode: t.relay.mode, + google_ip: t.network.google_ip, + front_domain: t.network.front_domain, + script_id: t.relay.script_id, + script_ids: t.relay.script_ids, + auth_key: t.relay.auth_key, + listen_host: t.network.listen_host, + listen_port: t.network.listen_port, + socks5_port: t.network.socks5_port, + log_level: t.logging.log_level, + verify_ssl: t.network.verify_ssl, + hosts: t.network.hosts, + enable_batching: t.relay.enable_batching, + upstream_socks5: t.network.upstream_socks5, + parallel_relay: t.relay.parallel_relay, + coalesce_step_ms: t.relay.coalesce_step_ms, + coalesce_max_ms: t.relay.coalesce_max_ms, + sni_hosts: t.network.sni_hosts, + fetch_ips_from_api: t.scan.fetch_ips_from_api, + max_ips_to_scan: t.scan.max_ips_to_scan, + scan_batch_size: t.scan.scan_batch_size, + google_ip_validation: t.scan.google_ip_validation, + normalize_x_graphql: t.relay.normalize_x_graphql, + youtube_via_relay: t.relay.youtube_via_relay, + passthrough_hosts: t.network.passthrough_hosts, + block_stun: t.network.block_stun, + block_quic: t.network.block_quic, + disable_padding: t.relay.disable_padding, + force_http1: t.relay.force_http1, + tunnel_doh: t.network.tunnel_doh, + bypass_doh_hosts: t.network.bypass_doh_hosts, + block_doh: t.network.block_doh, + fronting_groups: t.fronting_groups, + auto_blacklist_strikes: t.relay.auto_blacklist_strikes, + auto_blacklist_window_secs: t.relay.auto_blacklist_window_secs, + auto_blacklist_cooldown_secs: t.relay.auto_blacklist_cooldown_secs, + request_timeout_secs: t.relay.request_timeout_secs, + exit_node: t.exit_node, + } + } +} + +/// Used by the JSON->TOML migration write path: takes a reference so the +/// flat Config can still be returned as Ok(config) after the TOML is written. +impl From<&Config> for TomlConfig { + fn from(c: &Config) -> Self { + TomlConfig { + relay: TomlRelay { + mode: c.mode.clone(), + script_id: c.script_id.clone(), + script_ids: c.script_ids.clone(), + auth_key: c.auth_key.clone(), + parallel_relay: c.parallel_relay, + enable_batching: c.enable_batching, + coalesce_step_ms: c.coalesce_step_ms, + coalesce_max_ms: c.coalesce_max_ms, + youtube_via_relay: c.youtube_via_relay, + normalize_x_graphql: c.normalize_x_graphql, + disable_padding: c.disable_padding, + force_http1: c.force_http1, + auto_blacklist_strikes: c.auto_blacklist_strikes, + 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, + }, + network: TomlNetwork { + google_ip: c.google_ip.clone(), + front_domain: c.front_domain.clone(), + listen_host: c.listen_host.clone(), + listen_port: c.listen_port, + socks5_port: c.socks5_port, + verify_ssl: c.verify_ssl, + upstream_socks5: c.upstream_socks5.clone(), + block_quic: c.block_quic, + block_stun: c.block_stun, + sni_hosts: c.sni_hosts.clone(), + passthrough_hosts: c.passthrough_hosts.clone(), + tunnel_doh: c.tunnel_doh, + block_doh: c.block_doh, + bypass_doh_hosts: c.bypass_doh_hosts.clone(), + hosts: c.hosts.clone(), + }, + scan: TomlScan { + fetch_ips_from_api: c.fetch_ips_from_api, + max_ips_to_scan: c.max_ips_to_scan, + scan_batch_size: c.scan_batch_size, + google_ip_validation: c.google_ip_validation, + }, + logging: TomlLogging { + log_level: c.log_level.clone(), + }, + exit_node: c.exit_node.clone(), + fronting_groups: c.fronting_groups.clone(), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -899,7 +1215,7 @@ mod rt_tests { }"#; let tmp = std::env::temp_dir().join("mhrv-rt-test.json"); std::fs::write(&tmp, json).unwrap(); - let cfg = Config::load(&tmp).expect("config should load"); + let cfg = Config::load(&tmp).expect("config should load").0; assert_eq!(cfg.mode, "apps_script"); assert_eq!(cfg.auth_key, "testtesttest"); assert_eq!(cfg.listen_port, 8085); @@ -910,6 +1226,7 @@ mod rt_tests { &vec!["www.google.com".to_string(), "drive.google.com".to_string()] ); assert_eq!(cfg.fetch_ips_from_api, true); + let _ = std::fs::remove_file(tmp.with_extension("toml")); let _ = std::fs::remove_file(&tmp); } @@ -962,8 +1279,175 @@ mod rt_tests { }"#; let tmp = std::env::temp_dir().join("mhrv-rt-min.json"); std::fs::write(&tmp, json).unwrap(); - let cfg = Config::load(&tmp).expect("minimal config should load"); + let cfg = Config::load(&tmp).expect("minimal config should load").0; assert_eq!(cfg.mode, "apps_script"); + let _ = std::fs::remove_file(tmp.with_extension("toml")); let _ = std::fs::remove_file(&tmp); } } + +#[cfg(test)] +mod toml_tests { + use super::*; + + #[test] + fn toml_parses_minimal_relay_section() { + let s = r#" +[relay] +mode = "apps_script" +auth_key = "MY_SECRET_KEY_123" +script_id = "ABCDEF" +"#; + let toml_cfg: TomlConfig = toml::from_str(s).unwrap(); + let cfg = Config::from(toml_cfg); + assert_eq!(cfg.mode, "apps_script"); + assert_eq!(cfg.auth_key, "MY_SECRET_KEY_123"); + assert_eq!(cfg.script_ids_resolved(), vec!["ABCDEF".to_string()]); + cfg.validate().unwrap(); + } + + #[test] + fn toml_network_defaults_apply_when_section_omitted() { + // [network] section is entirely optional — all fields have defaults. + let s = r#" +[relay] +mode = "direct" +"#; + let toml_cfg: TomlConfig = toml::from_str(s).unwrap(); + let cfg = Config::from(toml_cfg); + assert_eq!(cfg.google_ip, "216.239.38.120"); + assert_eq!(cfg.listen_port, 8085); + assert!(cfg.verify_ssl); + assert!(cfg.block_doh); + assert!(cfg.tunnel_doh); + cfg.validate().unwrap(); + } + + #[test] + fn toml_parses_exit_node_section() { + let s = r#" +[relay] +mode = "apps_script" +auth_key = "SECRET" +script_id = "X" + +[exit_node] +enabled = true +relay_url = "https://example.com" +psk = "mypsk" +mode = "selective" +hosts = ["claude.ai", "chatgpt.com"] +"#; + let toml_cfg: TomlConfig = toml::from_str(s).unwrap(); + let cfg = Config::from(toml_cfg); + cfg.validate().unwrap(); + assert!(cfg.exit_node.enabled); + assert_eq!(cfg.exit_node.relay_url, "https://example.com"); + assert_eq!(cfg.exit_node.hosts, vec!["claude.ai", "chatgpt.com"]); + } + + #[test] + fn toml_parses_fronting_groups_array_of_tables() { + let s = r#" +[relay] +mode = "direct" + +[[fronting_groups]] +name = "vercel" +ip = "76.76.21.21" +sni = "react.dev" +domains = ["vercel.com", "nextjs.org"] + +[[fronting_groups]] +name = "fastly" +ip = "151.101.128.223" +sni = "pypi.org" +domains = ["reddit.com"] +"#; + let toml_cfg: TomlConfig = toml::from_str(s).unwrap(); + let cfg = Config::from(toml_cfg); + cfg.validate().unwrap(); + assert_eq!(cfg.fronting_groups.len(), 2); + assert_eq!(cfg.fronting_groups[0].name, "vercel"); + assert_eq!(cfg.fronting_groups[1].name, "fastly"); + } + + #[test] + fn toml_parses_network_hosts_subtable() { + let s = r#" +[relay] +mode = "direct" + +[network.hosts] +"example.com" = "1.2.3.4" +"test.example.com" = "5.6.7.8" +"#; + let toml_cfg: TomlConfig = toml::from_str(s).unwrap(); + let cfg = Config::from(toml_cfg); + assert_eq!(cfg.hosts.get("example.com"), Some(&"1.2.3.4".to_string())); + assert_eq!(cfg.hosts.get("test.example.com"), Some(&"5.6.7.8".to_string())); + } + + #[test] + fn toml_multi_script_id_array() { + let s = r#" +[relay] +mode = "apps_script" +auth_key = "SECRET" +script_id = ["A", "B", "C"] +"#; + let toml_cfg: TomlConfig = toml::from_str(s).unwrap(); + let cfg = Config::from(toml_cfg); + assert_eq!(cfg.script_ids_resolved(), vec!["A", "B", "C"]); + } + + #[test] + fn config_load_reads_toml_file_directly() { + let s = r#" +[relay] +mode = "apps_script" +auth_key = "MY_SECRET_KEY_123" +script_id = "ABCDEF" +"#; + let tmp = std::env::temp_dir().join("mhrv-load-toml-test.toml"); + std::fs::write(&tmp, s).unwrap(); + let cfg = Config::load(&tmp).expect("Config::load must handle .toml extension").0; + assert_eq!(cfg.mode, "apps_script"); + assert_eq!(cfg.script_ids_resolved(), vec!["ABCDEF".to_string()]); + let _ = std::fs::remove_file(&tmp); + } + + #[test] + fn json_migration_writes_toml_alongside_and_result_roundtrips() { + let json = r#"{ + "mode": "apps_script", + "auth_key": "MY_SECRET_KEY_123", + "script_id": "ABCDEF", + "listen_port": 8085 +}"#; + let dir = std::env::temp_dir(); + let json_path = dir.join("mhrv-migration-test.json"); + let toml_path = dir.join("mhrv-migration-test.toml"); + let _ = std::fs::remove_file(&json_path); + let _ = std::fs::remove_file(&toml_path); + + std::fs::write(&json_path, json).unwrap(); + let cfg = Config::load(&json_path) + .expect("JSON config must load and trigger migration").0; + + assert!(toml_path.exists(), "migration must write config.toml alongside config.json"); + + // The written TOML must parse back to an equivalent Config. + let toml_str = std::fs::read_to_string(&toml_path).unwrap(); + let toml_cfg: TomlConfig = toml::from_str(&toml_str) + .expect("migrated TOML must be valid TOML"); + let cfg2 = Config::from(toml_cfg); + assert_eq!(cfg.mode, cfg2.mode); + assert_eq!(cfg.auth_key, cfg2.auth_key); + assert_eq!(cfg.script_ids_resolved(), cfg2.script_ids_resolved()); + assert_eq!(cfg.listen_port, cfg2.listen_port); + + let _ = std::fs::remove_file(&json_path); + let _ = std::fs::remove_file(&toml_path); + } +} \ No newline at end of file diff --git a/src/data_dir.rs b/src/data_dir.rs index 3051a810..9cf8a880 100644 --- a/src/data_dir.rs +++ b/src/data_dir.rs @@ -37,8 +37,15 @@ pub fn data_dir() -> PathBuf { dir } -/// Path to the config.json for this platform's data dir. +/// Path to config.toml in the platform data dir (the canonical location +/// for new users and post-migration installs). pub fn config_path() -> PathBuf { + data_dir().join("config.toml") +} + +/// Path to the legacy config.json. Used only by resolve_config_path to +/// detect a JSON config that needs auto-migration to TOML. +pub fn json_config_path() -> PathBuf { data_dir().join("config.json") } @@ -53,22 +60,33 @@ pub fn ca_key_path() -> PathBuf { } /// Resolve a config path: if the user supplied an explicit path, use it. -/// Otherwise look in the user-data dir first, fall back to `./config.json` -/// in the current working directory (for backward compatibility with the -/// original CLI behavior). +/// +/// Otherwise search in preference order, TOML before JSON in both the +/// user-data dir and the current working directory. JSON hits trigger the +/// auto-migration in Config::load so the user is upgraded transparently. +/// +/// Falls back to data_dir/config.toml (non-existent) so new-user error +/// messages and Save-config operations point to the right place. pub fn resolve_config_path(cli_arg: Option<&Path>) -> PathBuf { if let Some(p) = cli_arg { return p.to_path_buf(); } - let user = config_path(); - if user.exists() { - return user; + let user_toml = config_path(); + if user_toml.exists() { + return user_toml; + } + let user_json = json_config_path(); + if user_json.exists() { + return user_json; + } + let cwd_toml = PathBuf::from("config.toml"); + if cwd_toml.exists() { + return cwd_toml; } - let cwd = PathBuf::from("config.json"); - if cwd.exists() { - return cwd; + let cwd_json = PathBuf::from("config.json"); + if cwd_json.exists() { + return cwd_json; } - // Neither exists: return the user-data path so errors point to the - // blessed location and commands like "Save config" write there. - user + // No config found anywhere - return the canonical new-user location. + user_toml } diff --git a/src/domain_fronter.rs b/src/domain_fronter.rs index bfcebe50..1fce6802 100644 --- a/src/domain_fronter.rs +++ b/src/domain_fronter.rs @@ -930,7 +930,7 @@ impl DomainFronter { (3) your system clock is way off (NTP not synced).\n\ Fixes (try in order): run `mhrv-rs scan-ips` to find a different Google \ frontend IP that isn't being MITM'd; check `date` on your host; as a \ - LAST RESORT set `\"verify_ssl\": false` in config.json — this lets the \ + LAST RESORT set `verify_ssl = false` in config.toml — this lets the \ relay work even through a middlebox, but your traffic is then only \ protected by the Apps Script relay's secret `auth_key`, not by outer TLS.", e diff --git a/src/main.rs b/src/main.rs index 202c7ec5..72e5aefb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,12 +43,12 @@ USAGE: mhrv-rs test-sni [OPTIONS] Probe each SNI name in the rotation pool against google_ip OPTIONS: - -c, --config PATH Path to config.json (default: ./config.json) + -c, --config PATH Path to config.toml file (default: /config.toml) --install-cert Install the MITM CA certificate and exit --remove-cert Remove the MITM CA from the OS trust store (verified by name), then delete the on-disk ca/ directory and exit. NSS cleanup (Firefox/Chrome) is best-effort. A fresh CA - is generated on next run. config.json and your Apps + is generated on next run. config.toml and your Apps Script deployment are untouched. --no-cert-check Skip the auto-install-if-untrusted check on startup -h, --help Show this message @@ -157,7 +157,7 @@ async fn main() -> ExitCode { }; // --remove-cert runs without a valid config — the CA files may be - // the only thing present in the data dir. `config.json` and the + // the only thing present in the data dir. `config.toml` and the // Apps Script deployment are intentionally untouched: the user does // not have to redeploy Code.gs after regenerating the CA. if args.remove_cert { @@ -201,12 +201,12 @@ async fn main() -> ExitCode { } let config_path = mhrv_rs::data_dir::resolve_config_path(args.config_path.as_deref()); - let config = match Config::load(&config_path) { + let (config, migration_warn) = match Config::load(&config_path) { Ok(c) => c, Err(e) => { eprintln!("{}", e); eprintln!( - "No valid config found. Copy config.example.json to either:\n {}\nor run with --config .", + "No valid config found. Copy config.example.toml to either:\n {}\nor run with --config .", config_path.display() ); return ExitCode::FAILURE; @@ -214,6 +214,9 @@ async fn main() -> ExitCode { }; init_logging(&config.log_level); + if let Some(msg) = migration_warn { + tracing::warn!("{}", msg); + } // Bump RLIMIT_NOFILE now that tracing is live — OpenWRT/Alpine hosts // often ship a default so low (issue #8, issue #18) that we run out diff --git a/src/scan_ips.rs b/src/scan_ips.rs index deecd29a..908d7621 100644 --- a/src/scan_ips.rs +++ b/src/scan_ips.rs @@ -231,7 +231,7 @@ pub async fn run(config: &Config) -> bool { println!("No Google IPs reachable from this network."); false } else { - println!("To use the fastest, set \"google_ip\" in config.json to the top result above."); + println!("To use the fastest, set \"google_ip\" in config.toml to the top result above."); true } }