diff --git a/Cargo.lock b/Cargo.lock index 5fe874d..9080361 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2222,7 +2222,7 @@ dependencies = [ [[package]] name = "mhrv-rs" -version = "1.9.6" +version = "1.9.7" dependencies = [ "base64 0.22.1", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 16f673f..df24697 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mhrv-rs" -version = "1.9.6" +version = "1.9.7" edition = "2021" description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting" license = "MIT" diff --git a/docs/changelog/v1.9.7.md b/docs/changelog/v1.9.7.md new file mode 100644 index 0000000..54c4a96 --- /dev/null +++ b/docs/changelog/v1.9.7.md @@ -0,0 +1,30 @@ + +• چک‌باکس **«Share with other devices on my Wi-Fi / network»** به UI دسکتاپ اضافه شد. به‌جای اینکه کاربر `listen_host` را به‌صورت دستی روی `0.0.0.0` تنظیم کند (که اکثر کاربران نمی‌دانستند)، حالا فقط یک چک‌باکس ساده روی فرم اصلی است. وقتی روشن می‌شود: + - Bind به‌طور خودکار به `0.0.0.0` تغییر می‌کند (تمام interfaceها) + - IP محلی شبکه‌ات با `detect_lan_ip()` تشخیص داده می‌شود (یک trick UDP `connect` که از kernel می‌پرسد source-IP outbound کدام است — هیچ ترافیک شبکه‌ای واقعی فرستاده نمی‌شود) و در زیر چک‌باکس همراه با پورت‌ها نمایش داده می‌شود تا بتوانی مستقیم به گوشی / لپ‌تاپ مهمان بدهی: `Other devices: HTTP 192.168.x.y:8085 · SOCKS5 192.168.x.y:8086` + - tooltip توضیح می‌دهد macOS اولین بار prompt firewall می‌اندازد + - اگر کاربر از قبل یک bind IP خاص (مثلاً `192.168.1.50` یک NIC مشخص) در `config.json` نوشته باشد، چک‌باکس قفل می‌شود + برچسب «Custom bind: 192.168.1.50» نشان می‌دهد تا تنظیم دستی توسط Save بعدی پاک نشود. + ماژول جدید `src/lan_utils.rs` با ۳ تست (تشخیص wildcard، تشخیص loopback، تست detect واقعی). +• Code.gs / CodeFull.gs hardening + باگ‌فیکس (هیچ تغییری در کانفیگ کاربر لازم نیست — فقط Code.gs خودتان را با [`assets/apps_script/Code.gs`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/assets/apps_script/Code.gs) (یا `CodeFull.gs` برای حالت full) جایگزین کنید + در Apps Script editor: `Manage deployments → ✏️ → Version: New version → Deploy`. Deployment ID همان قبلی می‌ماند): + - **`Code.gs` doGet تکراری حذف شد**: نسخه‌ای که با `HtmlService.createHtmlOutput` تعریف شده بود به‌خاطر hoisting جاوااسکریپت روی نسخهٔ صحیح `ContentService` overwrite می‌کرد. در نتیجه هر GET به URL deployment پاسخ سندباکس `goog.script.init` iframe برمی‌گرداند به‌جای HTML پلیس‌هولدر ساده. + - **`CodeFull.gs` `doGet` به `ContentService` تغییر کرد** (قبلاً `HtmlService` بود) — به همان دلیل بالا. + - **هدرهای IP-leak در `SKIP_HEADERS` اضافه شد** (`X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Forwarded-Port`, `X-Real-IP`, `Forwarded`, `Via`) — لایهٔ دفاع دوم به stripping سمت کلاینت v1.2.9 (#104). + - **`_doBatch` دارای fallback شد**: اگر `UrlFetchApp.fetchAll()` به‌عنوان یک کل throw کند، حالا برای متدهای امن (GET / HEAD / OPTIONS) per-item fetch می‌کند به‌جای صفر کردن کل پاسخ batch. port از `masterking32/MasterHttpRelayVPN@3094288`. +• `parse_relay_json` (سمت Rust): unwrapper برای `goog.script.init("...userHtml...")` اضافه شد — اگر هر deployment‌ای پاسخ HtmlService-wrapped برگرداند (legacy Code.gs قبل از v1.9.6، یا redirect که doGet را GET بزند)، client حالا JSON داخلی را استخراج می‌کند به‌جای fail کردن با `key must be a string at line 2 column 1`. +• README بازنویسی شد: نسخهٔ کوتاه دوزبانه (انگلیسی + فارسی RTL) برای کاربر معمولی + راهنمای کامل پیشرفته در [`docs/guide.md`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/docs/guide.md) و [`docs/guide.fa.md`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/docs/guide.fa.md). جدا کردن "راه‌اندازی ۵ دقیقه‌ای" از "همهٔ گزینه‌ها و troubleshooting" راهنما را خیلی قابل‌فهم‌تر کرد. در guide.fa.md task list با `[x]` با جدول جایگزین شد چون رندر RTL در GitHub با چک‌باکس مارک‌داون خراب می‌شد. +• تست: ۶ regression test جدید (۳ برای unwrap goog.script.init + ۳ برای lan_utils). **۱۷۹ lib test + ۳۳ tunnel-node test همه pass.** +--- +• Added a **"Share with other devices on my Wi-Fi / network"** checkbox to the desktop UI. Instead of asking users to know they can set `listen_host` to `0.0.0.0` (which almost no one did), it's now a single checkbox on the main form. When enabled: + - Bind address auto-flips to `0.0.0.0` (all interfaces) + - Your LAN IP is detected via `detect_lan_ip()` (UDP `connect` trick — asks the kernel which source IP it would use for an outbound packet, no actual network traffic sent) and shown alongside the proxy ports so you can hand them to the guest device directly: `Other devices: HTTP 192.168.x.y:8085 · SOCKS5 192.168.x.y:8086` + - Tooltip explains macOS will pop a Firewall prompt the first time + - If you've already written a specific bind IP (e.g. `192.168.1.50` for one NIC) into `config.json`, the checkbox locks itself and shows a "Custom bind: 192.168.1.50" badge so the next Save can't clobber your manual setting. + New `src/lan_utils.rs` module with 3 unit tests (wildcard detection, loopback detection, live detect smoke). +• Code.gs / CodeFull.gs hardening + bug fixes (no client config change needed — just replace your own Code.gs with [`assets/apps_script/Code.gs`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/assets/apps_script/Code.gs) (or `CodeFull.gs` for full mode) and in the Apps Script editor: `Manage deployments → ✏️ → Version: New version → Deploy`. Your Deployment ID stays the same): + - **Removed duplicate `doGet` in `Code.gs`**: a second copy declared with `HtmlService.createHtmlOutput` was silently overriding the correct `ContentService` one due to JS function hoisting. Result: every GET to the deployment URL was returning the `goog.script.init` sandbox iframe instead of the simple placeholder HTML. + - **`CodeFull.gs` `doGet` switched to `ContentService`** (was `HtmlService`) — same reason as above. + - **Added IP-leak headers to `SKIP_HEADERS`** (`X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Forwarded-Port`, `X-Real-IP`, `Forwarded`, `Via`) — second line of defense to v1.2.9's client-side stripping (#104). + - **`_doBatch` got a fallback path**: if `UrlFetchApp.fetchAll()` throws as a whole, it now per-item-fetches safe methods (GET / HEAD / OPTIONS) instead of zeroing the entire batch's responses. Ported from `masterking32/MasterHttpRelayVPN@3094288`. +• `parse_relay_json` (Rust client): added unwrapper for `goog.script.init("...userHtml...")` iframe — if any deployment ever returns an HtmlService-wrapped response (legacy Code.gs, or a redirect that GET-hits doGet), the client now extracts the inner JSON instead of failing with `key must be a string at line 2 column 1`. +• Rewrote the README: short bilingual landing page (English + Persian RTL) for normal users, with the full advanced reference moved to [`docs/guide.md`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/docs/guide.md) and [`docs/guide.fa.md`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/docs/guide.fa.md). Splitting "5-minute quick start" from "every option + troubleshooting" makes the docs much more approachable. In guide.fa.md the `[x]` task list was replaced with a table because GitHub's RTL renderer mangled the checkbox positions inside `
`. +• Tests: 6 new regression tests (3 for goog.script.init unwrap + 3 for lan_utils). **179 lib tests + 33 tunnel-node tests all passing.** diff --git a/docs/guide.fa.md b/docs/guide.fa.md index 637fb76..55a1395 100644 --- a/docs/guide.fa.md +++ b/docs/guide.fa.md @@ -336,53 +336,80 @@ logread -e mhrv-rs -f # تمام لاگ ## چه چیز پیاده شده و چه چیز نه -این پورت روی **حالت `apps_script`** تمرکز دارد — تنها حالتی که در سال ۲۰۲۶ مقابل سانسورگر مدرن قابل اتکاست. پیاده‌شده: +این پورت روی **حالت `apps_script`** تمرکز دارد — تنها حالتی که در سال ۲۰۲۶ مقابل سانسورگر مدرن قابل اتکاست. -- [x] HTTP proxy محلی (CONNECT برای HTTPS، forwarding ساده برای HTTP) -- [x] SOCKS5 محلی با dispatch هوشمند TLS / HTTP / TCP خام (تلگرام، xray، …) -- [x] MITM با تولید گواهی per-domain روی پرواز با `rcgen` -- [x] تولید CA + نصب خودکار روی مک / لینوکس / ویندوز -- [x] نصب گواهی NSS فایرفاکس (best-effort با `certutil`) -- [x] رلهٔ JSON Apps Script سازگار با پروتکل `Code.gs` -- [x] connection pool (TTL ۴۵ ثانیه، حداکثر ۲۰ idle) -- [x] رمزگشایی gzip -- [x] چرخش بین چند اسکریپت (round-robin) -- [x] blacklist خودکار اسکریپت‌های ناموفق روی خطای 429 / quota (cooldown ۱۰ دقیقه) -- [x] کش پاسخ (۵۰ مگابایت، FIFO + TTL، آگاه از `Cache-Control: max-age`، heuristics برای static asset) -- [x] coalescing درخواست‌ها: GETهای یکسان همزمان یک fetch upstream را به اشتراک می‌گذارند -- [x] تونل‌های بازنویسی SNI (مستقیم به لبهٔ گوگل، دور زدن رله) برای `google.com`، `youtube.com`، `youtu.be`، `youtube-nocookie.com`، `fonts.googleapis.com`. دامنه‌های اضافی از فیلد `hosts` قابل تنظیم. -- [x] هندل خودکار ریدایرکت روی رله (`/exec` → `googleusercontent.com`) -- [x] فیلتر هدر (حذف connection-specific، brotli) -- [x] subcommandهای `test` و `scan-ips` -- [x] Script IDها در لاگ ماسک می‌شوند (`prefix…suffix`) تا لاگ Deployment IDها را افشا نکند -- [x] UI دسکتاپ (egui) — کراس‌پلتفرم، بدون bundler -- [x] چِین کردن SOCKS5 upstream (اختیاری) برای ترافیک غیر-HTTP (MTProto تلگرام، IMAP، SSH…) -- [x] pre-warm connection pool در شروع (اولین درخواست handshake TLS به لبهٔ گوگل را skip می‌کند) -- [x] چرخش SNI per-connection بین `{www, mail, drive, docs, calendar}.google.com` -- [x] dispatch موازی script-ID اختیاری (`parallel_relay`): fan-out به N اسکریپت همزمان، اولین موفقیت برمی‌گردد -- [x] drill-down آمار per-site در UI (درخواست‌ها، نرخ کش، بایت، تأخیر متوسط per host) -- [x] pool چرخش SNI قابل ویرایش (UI + فیلد `sni_hosts`) با probe دسترسی -- [x] بیلدهای OpenWRT / Alpine / musl — باینری استاتیک، با اسکریپت init procd -- [x] **Exit node** برای سایت‌های پشت Cloudflare (v1.9.4+) -- [x] **Unwrap iframe goog.script.init** — دفاع‌در‌عمق در مقابل Deploymentهایی که پاسخ HtmlService-wrapped برمی‌گردانند (v1.9.6+) +### پیاده‌شده -عمداً پیاده **نشده**: +| ویژگی | توضیح | +|---|---| +| HTTP proxy محلی | CONNECT برای HTTPS، forwarding ساده برای HTTP | +| SOCKS5 محلی | dispatch هوشمند TLS / HTTP / TCP خام (تلگرام، xray، …) | +| MITM | تولید گواهی per-domain روی پرواز با `rcgen` | +| نصب CA | تولید + نصب خودکار روی مک / لینوکس / ویندوز | +| پشتیبانی فایرفاکس | نصب گواهی NSS با `certutil` (best-effort) | +| رلهٔ JSON | پروتکل سازگار با `Code.gs` | +| Connection pool | TTL ۴۵ ثانیه، حداکثر ۲۰ idle | +| رمزگشایی gzip | اتوماتیک | +| چند اسکریپت | چرخش round-robin | +| Blacklist خودکار | روی خطای 429 / quota، با cooldown ۱۰ دقیقه | +| کش پاسخ | ۵۰ مگابایت، FIFO + TTL، آگاه از `Cache-Control: max-age`، heuristic برای static asset | +| Coalescing | GETهای یکسان همزمان یک fetch upstream را به اشتراک می‌گذارند | +| تونل بازنویسی SNI | مستقیم به لبهٔ گوگل (بدون رله) برای `google.com`، `youtube.com`، `youtu.be`، `youtube-nocookie.com`، `fonts.googleapis.com` — دامنه‌های اضافی از فیلد `hosts` | +| هندل ریدایرکت | اتوماتیک: `/exec` → `googleusercontent.com` | +| فیلتر هدر | حذف connection-specific و brotli | +| Subcommand‌ها | `test` و `scan-ips` و `test-sni` | +| ماسک Script ID | به‌صورت `prefix…suffix` در لاگ، تا Deployment ID افشا نشود | +| UI دسکتاپ | egui — کراس‌پلتفرم، بدون bundler | +| چِین SOCKS5 upstream | اختیاری برای ترافیک غیر-HTTP (MTProto تلگرام، IMAP، SSH …) | +| Pre-warm pool | اولین درخواست TLS handshake به لبهٔ گوگل را skip می‌کند | +| چرخش SNI per-connection | بین `{www, mail, drive, docs, calendar}.google.com` | +| Parallel relay | اختیاری: fan-out به N اسکریپت همزمان، اولین موفقیت برمی‌گردد | +| Drill-down آمار per-site | در UI: درخواست‌ها، نرخ کش، بایت، تأخیر متوسط هر host | +| ویرایشگر pool SNI | UI + فیلد `sni_hosts` با probe دسترسی | +| بیلد musl | OpenWRT / Alpine / محیط‌های بدون libc — باینری استاتیک، با procd init | +| **Exit node** | برای سایت‌های پشت Cloudflare (v1.9.4+) | +| **Unwrap goog.script.init** | دفاع‌در‌عمق در مقابل Deploymentهایی که پاسخ HtmlService-wrapped می‌فرستند (v1.9.6+) | -- **HTTP/2 multiplexing** — state machine کریت `h2` (stream IDs، flow control، GOAWAY) موارد hang ظریف زیادی دارد؛ coalescing + pool ۲۰-conn بیشتر فایده را می‌گیرد. -- **batch درخواست (`q:[...]` در حالت apps_script)** — connection pool + tokio async از قبل خوب موازی‌سازی می‌کند؛ batch ~۲۰۰ خط مدیریت state اضافه می‌کند با سود نامشخص. -- **دانلود موازی Range-based** — edge case‌های واقعی (سرورهای بدون Range، chunked وسط stream)؛ ویدیوی یوتیوب از قبل با تونل بازنویسی SNI Apps Script را دور می‌زند. -- **حالت‌های دیگر** (`domain_fronting`، `google_fronting`، `custom_domain`) — Cloudflare در ۲۰۲۴ domain fronting عمومی را کشت؛ Cloud Run پلن پولی می‌خواهد. +### عمداً پیاده نشده + +| ویژگی | چرا نه | +|---|---| +| HTTP/2 multiplexing | state machine کریت `h2` (stream IDs، flow control، GOAWAY) موارد hang ظریف زیادی دارد؛ coalescing + pool ۲۰-conn بیشتر فایده را می‌گیرد | +| Batch (`q:[...]` در apps_script) | connection pool + tokio async از قبل خوب موازی‌سازی می‌کند؛ batch ~۲۰۰ خط مدیریت state اضافه می‌کند با سود نامشخص | +| Range-based parallel download | edge case‌های واقعی (سرورهای بدون Range، chunked وسط stream)؛ ویدیوی یوتیوب از قبل با تونل بازنویسی SNI، Apps Script را دور می‌زند | +| حالت‌های `domain_fronting` / `google_fronting` / `custom_domain` | Cloudflare در ۲۰۲۴ domain fronting عمومی را کشت؛ Cloud Run پلن پولی می‌خواهد | ## محدودیت‌های شناخته‌شده این محدودیت‌ها ذاتی روش Apps Script + domain fronting هستند، نه باگ این کلاینت. نسخهٔ پایتون اصلی هم همین مشکلات را دارد. -- **User-Agent ثابت روی `Google-Apps-Script`** برای ترافیک از رله. `UrlFetchApp.fetch()` اجازهٔ override نمی‌دهد. سایت‌هایی که bot detect می‌کنند (جست‌وجوی گوگل، بعضی CAPTCHAها) نسخهٔ no-JS برمی‌گردانند. راه‌حل: دامنه را به `hosts` اضافه کن تا از تونل بازنویسی SNI با UA واقعی مرورگرت برود. `google.com`، `youtube.com`، `fonts.googleapis.com` پیش‌فرض داخل‌اند. -- **پخش ویدیو کند و quota-محدود** برای هرچیزی که از رله رد می‌شود. HTML یوتیوب سریع می‌آید (تونل بازنویسی SNI)، اما chunkهای `googlevideo.com` از Apps Script. سهمیهٔ رایگان: ~۲۰ هزار `UrlFetchApp` در روز، سقف بدنهٔ ۵۰ مگابایت per fetch. برای مرور متنی خوب، برای ۱۰۸۰p دردناک. چند `script_id` بچرخان برای هد رومبیش‌تر، یا VPN واقعی برای ویدیو. -- **Brotli حذف می‌شود** از `Accept-Encoding` هدر. Apps Script gzip را decompress می‌کند ولی `br` نه؛ forward کردن `br` پاسخ را خراب می‌کند. سربار حجمی جزئی. -- **WebSocket کار نمی‌کند** از رله — این رله request/response JSON است. سایت‌هایی که به WS upgrade می‌کنند fail می‌شوند (streaming ChatGPT، صدای Discord، …). -- **سایت‌های HSTS-preloaded / hard-pinned** گواهی MITM را قبول نمی‌کنند. اکثر سایت‌ها مشکل ندارند؛ تعداد کمی دارند. -- **2FA و ورود حساس گوگل / یوتیوب** ممکن است هشدار «دستگاه ناشناس» بدهد چون درخواست‌ها از IPهای Apps Script گوگل می‌آیند نه IP تو. یک‌بار از تونل وارد شو (`google.com` در لیست بازنویسی است) تا این مشکل برطرف شود. +### User-Agent ثابت روی `Google-Apps-Script` + +برای ترافیکی که از رله رد می‌شود، `UrlFetchApp.fetch()` اجازهٔ override کردن User-Agent را نمی‌دهد. سایت‌هایی که bot detect می‌کنند (جست‌وجوی گوگل، بعضی CAPTCHAها) نسخهٔ no-JS برمی‌گردانند. + +**راه‌حل:** دامنه را به فیلد `hosts` اضافه کن تا از تونل بازنویسی SNI با User-Agent واقعی مرورگرت برود. این دامنه‌ها پیش‌فرض داخل‌اند: `google.com`، `youtube.com`، `fonts.googleapis.com`. + +### پخش ویدیو کند و quota-محدود + +HTML یوتیوب سریع می‌آید (از تونل بازنویسی SNI)، اما chunkهای ویدیو از `googlevideo.com` از Apps Script رد می‌شوند. سهمیهٔ رایگان: ~۲۰٬۰۰۰ `UrlFetchApp` در روز، سقف بدنهٔ ۵۰ مگابایت per fetch. + +برای مرور متنی خوب است، برای ۱۰۸۰p دردناک. چند `script_id` بچرخان برای هد روم بیشتر، یا VPN واقعی برای ویدیو. + +### Brotli حذف می‌شود + +از هدر `Accept-Encoding` ‏`br` حذف می‌شود. Apps Script gzip را decompress می‌کند ولی Brotli نه؛ forward کردن `br` پاسخ را خراب می‌کند. سربار حجمی جزئی. + +### WebSocket کار نمی‌کند + +این رله request/response JSON است. سایت‌هایی که به WebSocket upgrade می‌کنند fail می‌شوند (streaming ChatGPT، صدای Discord، …). + +### سایت‌های HSTS-preloaded / hard-pinned + +گواهی MITM را قبول نمی‌کنند. اکثر سایت‌ها مشکل ندارند؛ تعداد کمی هستند. + +### هشدار «دستگاه ناشناس» در ورود حساس گوگل + +2FA و ورودهای حساس گوگل / یوتیوب ممکن است هشدار «دستگاه ناشناس» بدهند، چون درخواست‌ها از IPهای Apps Script گوگل می‌آیند نه IP تو. یک‌بار از تونل وارد شو تا این مشکل برطرف شود (دامنهٔ `google.com` در لیست بازنویسی SNI است، پس از همان IP که قبلاً ورود کرده‌ای می‌رود). ## امنیت diff --git a/src/bin/ui.rs b/src/bin/ui.rs index cd89083..587b7b1 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -13,6 +13,7 @@ use mhrv_rs::cert_installer::{install_ca, reconcile_sudo_environment, remove_ca} use mhrv_rs::config::{Config, FrontingGroup, ScriptId}; use mhrv_rs::data_dir; use mhrv_rs::domain_fronter::{DomainFronter, DEFAULT_GOOGLE_SNI_POOL}; +use mhrv_rs::lan_utils::{detect_lan_ip, is_share_on_lan}; use mhrv_rs::mitm::{MitmCertManager, CA_CERT_FILE}; use mhrv_rs::proxy_server::ProxyServer; use mhrv_rs::{scan_ips, scan_sni, test_cmd}; @@ -1030,10 +1031,98 @@ impl eframe::App for App { .desired_width(f32::INFINITY)); }); - form_row(ui, "Listen host", None, |ui| { - ui.add(egui::TextEdit::singleline(&mut self.form.listen_host) - .desired_width(f32::INFINITY)); + // Network sharing: phones, tablets, other laptops on the + // same Wi-Fi can use this proxy when the bind address is + // 0.0.0.0 instead of 127.0.0.1. We expose this as a + // single-checkbox UI rather than the raw `listen_host` + // 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 + // detect that case and show a "Custom bind" badge so + // the checkbox doesn't silently overwrite their setting + // on the next Save. + // + // Snapshot the relevant flags before entering form_row's + // closure — we need to mutate `self.form.listen_host` + // inside the closure when the checkbox toggles, so we + // can't hold a borrow on it through the closure. + let listen_host_snapshot = self.form.listen_host.trim().to_string(); + let listen_port_snapshot = self.form.listen_port.trim().to_string(); + let socks5_port_snapshot = self.form.socks5_port.trim().to_string(); + let was_share_on_lan = is_share_on_lan(&listen_host_snapshot); + let lower_snapshot = listen_host_snapshot.to_ascii_lowercase(); + let is_custom_bind = !listen_host_snapshot.is_empty() + && !was_share_on_lan + && lower_snapshot != "127.0.0.1" + && lower_snapshot != "localhost"; + let mut new_listen_host: Option = None; + form_row(ui, "Network", Some( + "By default the proxy is reachable only from this computer. \ + Turn this on to let phones, tablets, and other laptops on the \ + same Wi-Fi (or a hotspot you're sharing) use it too. The \ + other devices then point their HTTP / SOCKS5 proxy at the \ + LAN IP shown below. Make sure your firewall lets in the proxy \ + port — macOS pops up a Firewall prompt the first time." + ), |ui| { + 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 + // 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."); + }); + } else { + let mut share = was_share_on_lan; + if ui.checkbox(&mut share, "Share with other devices on my Wi-Fi / network").changed() { + new_listen_host = Some(if share { + "0.0.0.0".to_string() + } else { + "127.0.0.1".to_string() + }); + } + if share { + // detect_lan_ip() opens a UDP socket and + // asks the kernel which interface a packet + // to a public IP would use. Cheap (no + // syscall does network I/O) and accurate + // (it's the same selection any outbound + // connection would make). + match detect_lan_ip() { + Some(ip) => { + let port = if listen_port_snapshot.is_empty() { + "8085" + } else { + listen_port_snapshot.as_str() + }; + let socks_port = if socks5_port_snapshot.is_empty() { + "8086" + } else { + socks5_port_snapshot.as_str() + }; + ui.small(egui::RichText::new(format!( + "Other devices: HTTP {}:{} · SOCKS5 {}:{}", + ip, port, ip, socks_port, + )).color(egui::Color32::from_rgb(120, 200, 140))); + } + None => { + ui.small(egui::RichText::new( + "Couldn't detect your LAN IP. Find it in System Settings \ + → Network → Wi-Fi → Details (macOS) or `ipconfig` (Windows)." + ).color(egui::Color32::from_rgb(220, 180, 100))); + } + } + } + } }); + if let Some(updated) = new_listen_host { + self.form.listen_host = updated; + } ui.horizontal(|ui| { ui.add_sized( diff --git a/src/lan_utils.rs b/src/lan_utils.rs new file mode 100644 index 0000000..bbf329f --- /dev/null +++ b/src/lan_utils.rs @@ -0,0 +1,100 @@ +//! Helpers for the "Share with other devices on my Wi-Fi / network" toggle in +//! the desktop UI and the Android share-LAN config. +//! +//! `detect_lan_ip()` returns the IPv4 address that the OS would use as the +//! source for outbound traffic (i.e. the LAN-reachable address on the +//! interface that has the default route). The trick is to open a UDP socket, +//! `connect()` it to a public address (no packets are actually sent during +//! the syscall), then read the socket's bound `local_addr()` — that's the +//! IP a peer on the LAN would use to reach this machine. +//! +//! Returns `None` if the host has no usable IPv4 (no network at all, or +//! IPv6-only). Callers fall back to telling the user to figure it out +//! themselves in that case. +//! +//! This is the same pattern used by `gethostbyname` callers and by every +//! other "what's my LAN IP" helper across the ecosystem — no +//! getifaddrs / `if_nameindex` boilerplate, no platform-specific code, +//! works on every target the rest of mhrv-rs builds on. + +use std::net::{IpAddr, UdpSocket}; + +/// Try to figure out the LAN-reachable IPv4 of the current host. See module +/// docs for the trick. Returns `None` on any failure (no IPv4 stack, no +/// route, etc.) — callers should treat that as "ask the user to find it +/// themselves" rather than as an error. +pub fn detect_lan_ip() -> Option { + // Bind to all interfaces on a kernel-picked port. We never read or + // write — the socket is just a vehicle for asking the routing table + // which interface would carry traffic to a public IP. + let sock = UdpSocket::bind(("0.0.0.0", 0)).ok()?; + // Public IP outside any RFC-1918 range. UDP "connect" doesn't actually + // send anything; it just records the peer for later sendto/recv calls + // and tells the kernel to commit a source-address selection. + sock.connect(("1.1.1.1", 80)).ok()?; + let local = sock.local_addr().ok()?.ip(); + // The socket's local_addr is `0.0.0.0` only when the OS hasn't + // committed a source address yet (rare — connect() forces commit on + // every modern kernel). Treat that case as "no LAN IP available." + match local { + IpAddr::V4(v4) if v4.is_unspecified() => None, + ip => Some(ip), + } +} + +/// Returns `true` if the bind host string represents "all interfaces" +/// (`0.0.0.0`, `[::]`, or an empty / whitespace-only value — empty defaults +/// to `0.0.0.0` in the underlying socket bind on most platforms). +/// +/// Used by the UI to decide whether the "share on LAN" checkbox should +/// appear checked. +pub fn is_share_on_lan(listen_host: &str) -> bool { + let trimmed = listen_host.trim(); + matches!(trimmed, "0.0.0.0" | "[::]" | "::") +} + +/// Returns `true` if the bind host string is loopback-only +/// (`127.0.0.1`, `localhost`, `::1`, `[::1]`). +pub fn is_loopback_only(listen_host: &str) -> bool { + let trimmed = listen_host.trim().to_ascii_lowercase(); + matches!(trimmed.as_str(), "127.0.0.1" | "localhost" | "::1" | "[::1]") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn share_on_lan_recognizes_wildcards() { + assert!(is_share_on_lan("0.0.0.0")); + assert!(is_share_on_lan(" 0.0.0.0 ")); + assert!(is_share_on_lan("[::]")); + assert!(is_share_on_lan("::")); + assert!(!is_share_on_lan("127.0.0.1")); + assert!(!is_share_on_lan("192.168.1.42")); + assert!(!is_share_on_lan("")); + } + + #[test] + fn loopback_only_recognizes_local_names() { + assert!(is_loopback_only("127.0.0.1")); + assert!(is_loopback_only("localhost")); + assert!(is_loopback_only("LocalHost")); + assert!(is_loopback_only("::1")); + assert!(is_loopback_only("[::1]")); + assert!(!is_loopback_only("0.0.0.0")); + assert!(!is_loopback_only("192.168.1.42")); + } + + #[test] + fn detect_lan_ip_returns_non_unspecified_when_online() { + // This test makes a UDP `connect()` to 1.1.1.1 to ask the OS what + // IP it would use. On a CI box with no network the connect can + // fail and we'd get None; on a typical dev machine we get a real + // address. Either result is allowed — we just verify the unwrapped + // value is never `0.0.0.0` (the contract). + if let Some(ip) = detect_lan_ip() { + assert!(!ip.is_unspecified(), "got unspecified address: {}", ip); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 1c62a5b..6b53a32 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod cert_installer; pub mod config; pub mod data_dir; pub mod domain_fronter; +pub mod lan_utils; pub mod mitm; pub mod proxy_server; pub mod rlimit;