From b6806cf2064c8148013be45904735128967de0d2 Mon Sep 17 00:00:00 2001 From: May Knott Date: Sat, 23 May 2026 22:21:41 +0330 Subject: [PATCH 1/3] fix(config): reject non-loopback proxy binds The proxy listener is a local ingress point for both HTTP and SOCKS traffic. Binding that listener to a wildcard address or LAN address exposes the relay surface to other devices on the network. Without inbound proxy authentication, that exposure can turn a local client into an unauthenticated shared proxy and allow unrelated devices to consume the operator's Apps Script quota. The default listen_host is now 127.0.0.1, matching the local-only behavior expected for a desktop proxy. Config validation now accepts IPv4 loopback, IPv6 loopback, bracketed IPv6 loopback, and localhost. It rejects wildcard binds, LAN addresses, public hostnames, and other non-loopback values with a hard configuration error before any listener socket is opened. The guard is implemented at configuration validation time rather than at bind time so TOML loading, JSON migration, CLI startup, and UI save paths all observe the same fail-closed rule. Existing explicit loopback profiles continue to load unchanged. Profiles that rely on 0.0.0.0 or a LAN address must wait for an authenticated LAN-sharing mode rather than silently opening an unauthenticated listener. TOML examples now show the loopback listener and call out that non-loopback binds are rejected until inbound proxy authentication exists. The English and Persian guides no longer instruct users to set listen_host to 0.0.0.0 for hotspot or OpenWRT sharing; they describe the current local-only safety behavior instead. Focused config tests cover the repaired default, accepted loopback forms, rejected wildcard and non-loopback forms, TOML network defaults, and JSON-to-TOML migration preserving the loopback listen_host. --- config.direct.example.toml | 1 + config.example.toml | 1 + config.exit-node.example.toml | 5 +- config.fronting-groups.example.toml | 1 + config.full.example.toml | 1 + docs/guide.fa.md | 8 +-- docs/guide.md | 8 +-- src/config.rs | 81 ++++++++++++++++++++++++++++- 8 files changed, 96 insertions(+), 10 deletions(-) diff --git a/config.direct.example.toml b/config.direct.example.toml index ebe2b8ee..463240f7 100644 --- a/config.direct.example.toml +++ b/config.direct.example.toml @@ -4,6 +4,7 @@ mode = "direct" [network] google_ip = "216.239.38.120" front_domain = "www.google.com" +# Non-loopback binds such as 0.0.0.0 are rejected until inbound proxy authentication is implemented. listen_host = "127.0.0.1" listen_port = 8085 socks5_port = 8086 diff --git a/config.example.toml b/config.example.toml index ab233f3f..48d31922 100644 --- a/config.example.toml +++ b/config.example.toml @@ -6,6 +6,7 @@ auth_key = "CHANGE_ME_TO_A_STRONG_SECRET" [network] google_ip = "216.239.38.120" front_domain = "www.google.com" +# Non-loopback binds such as 0.0.0.0 are rejected until inbound proxy authentication is implemented. listen_host = "127.0.0.1" listen_port = 8085 socks5_port = 8086 diff --git a/config.exit-node.example.toml b/config.exit-node.example.toml index 72efeecf..fe634fd7 100644 --- a/config.exit-node.example.toml +++ b/config.exit-node.example.toml @@ -9,7 +9,8 @@ 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" +# Non-loopback binds such as 0.0.0.0 are rejected until inbound proxy authentication is implemented. +listen_host = "127.0.0.1" listen_port = 8085 socks5_port = 8086 verify_ssl = true @@ -44,4 +45,4 @@ hosts = [ "openai.com", "aistudio.google.com", "ai.google.dev", -] \ No newline at end of file +] diff --git a/config.fronting-groups.example.toml b/config.fronting-groups.example.toml index db00d9d9..7f3dcf6e 100644 --- a/config.fronting-groups.example.toml +++ b/config.fronting-groups.example.toml @@ -4,6 +4,7 @@ mode = "direct" [network] google_ip = "216.239.38.120" front_domain = "www.google.com" +# Non-loopback binds such as 0.0.0.0 are rejected until inbound proxy authentication is implemented. listen_host = "127.0.0.1" listen_port = 8085 socks5_port = 8086 diff --git a/config.full.example.toml b/config.full.example.toml index e75f0b42..fd661bae 100644 --- a/config.full.example.toml +++ b/config.full.example.toml @@ -6,6 +6,7 @@ auth_key = "CHANGE_ME_TO_A_STRONG_SECRET" [network] google_ip = "216.239.38.120" front_domain = "www.google.com" +# Non-loopback binds such as 0.0.0.0 are rejected until inbound proxy authentication is implemented. listen_host = "127.0.0.1" listen_port = 8085 socks5_port = 8086 diff --git a/docs/guide.fa.md b/docs/guide.fa.md index d0247453..d1e7b5b3 100644 --- a/docs/guide.fa.md +++ b/docs/guide.fa.md @@ -269,7 +269,9 @@ HTTP / HTTPS مثل قبل از Apps Script می‌رود (تغییری نمی ## اشتراک‌گذاری هات‌اسپات -mhrv-rs به‌طور پیش‌فرض روی `0.0.0.0` گوش می‌دهد، پس هر دستگاه روی همان شبکه می‌تواند ازش استفاده کند. سناریوی رایج: اشتراک تونل از گوشی اندروید به آیفون / آیپد / لپ‌تاپ از هات‌اسپات: +mhrv-rs حالا به‌طور پیش‌فرض فقط روی `127.0.0.1` گوش می‌دهد و تا وقتی احراز هویت HTTP/SOCKS برای ورودی پیاده‌سازی نشده باشد، bind غیر loopback را رد می‌کند. این کار جلوی open proxy ناخواسته و مصرف شدن quota Apps Script توسط دستگاه‌های دیگر شبکه را می‌گیرد. + +اشتراک‌گذاری هات‌اسپات/LAN بعداً به‌صورت یک حالت صریح و دارای احراز هویت برمی‌گردد. در نسخهٔ فعلی `listen_host` را به `0.0.0.0` تغییر نده؛ اعتبارسنجی کانفیگ fail-closed می‌شود. workflow قدیمی این بود: ۱. **اندروید:** هات‌اسپات موبایل را روشن کن + اپ را استارت کن ۲. **دستگاه دیگر:** به Wi-Fi هات‌اسپات اندروید وصل شو @@ -287,7 +289,7 @@ Settings → Wi-Fi → روی (i) شبکهٔ هات‌اسپات بزن → Conf HTTP proxy سیستم را روی `192.168.43.1:8080` بگذار، یا per-app SOCKS5 روی `192.168.43.1:1081`. -> اگر `listen_host` در کانفیگت `127.0.0.1` است، به `0.0.0.0` تغییرش بده تا اتصال از دستگاه‌های دیگر را بپذیرد. +> گیت امنیتی فعلی: مقدارهای غیر loopback مثل `0.0.0.0`، `::` یا IP شبکهٔ LAN تا زمان اضافه شدن احراز هویت proxy رد می‌شوند. ## اجرا روی OpenWRT @@ -306,7 +308,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.toml` مقدار `listen_host` را به `0.0.0.0` بگذار تا روتر اتصال LAN را بپذیرد. +نسخهٔ فعلی فقط روی loopback گوش می‌دهد. اجرای CLI روی OpenWRT برای تست محلی همچنان کار می‌کند، اما استفاده از روتر به‌عنوان proxy برای کل LAN به حالت authenticated LAN-sharing آینده نیاز دارد. در این نسخه `listen_host` را در `/etc/mhrv-rs/config.toml` به `0.0.0.0` تغییر نده؛ اعتبارسنجی کانفیگ این bind ناامن را رد می‌کند. مصرف حافظه ~۱۵–۲۰ مگابایت — روی هر روتری با ۱۲۸ مگابایت RAM به بالا اجرا می‌شود. UI روی musl نیست (روترها headlessاند). diff --git a/docs/guide.md b/docs/guide.md index 679a35d0..2e844298 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -269,7 +269,9 @@ The destination sees the exit node's IP, not Google's, so the anti-bot heuristic ## Sharing via hotspot -mhrv-rs listens on `0.0.0.0` by default, so any device on the same network can use it. Common scenario: share the tunnel from an Android phone to an iPhone, iPad, or laptop over hotspot: +mhrv-rs listens on `127.0.0.1` by default and rejects non-loopback proxy binds until inbound HTTP/SOCKS authentication is implemented. This prevents accidental open-proxy exposure and Apps Script quota theft on shared Wi-Fi or hotspots. + +Hotspot/LAN sharing will return as an explicit authenticated mode in a later release. On current builds, do not change `listen_host` to `0.0.0.0`; startup validation will fail closed. The old sharing workflow was: 1. **Android:** enable mobile hotspot + start the app 2. **Other device:** connect to the Android hotspot Wi-Fi @@ -287,7 +289,7 @@ For full device-wide coverage on iOS, use [Shadowrocket](https://apps.apple.com/ Set system HTTP proxy to `192.168.43.1:8080`, or per-app SOCKS5 to `192.168.43.1:1081`. -> If `listen_host` is `127.0.0.1` in your config, change to `0.0.0.0` to allow other devices. +> Current safety gate: non-loopback values such as `0.0.0.0`, `::`, or a LAN IP are rejected until proxy authentication is available. ## Running on OpenWRT @@ -306,7 +308,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.toml` so the router accepts LAN connections. +Current builds listen on loopback only. Running the CLI on OpenWRT for local diagnostics still works, but using the router as a LAN-wide proxy requires the upcoming authenticated LAN-sharing mode. Do not set `listen_host` to `0.0.0.0` in `/etc/mhrv-rs/config.toml` on this version; config validation will reject the unsafe bind. Memory footprint ~15–20 MB resident — fine on anything ≥128 MB RAM. No UI on musl (routers are headless). diff --git a/src/config.rs b/src/config.rs index cd63b8b8..348e3c3b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -538,7 +538,7 @@ fn default_front_domain() -> String { "www.google.com".into() } fn default_listen_host() -> String { - "0.0.0.0".into() + "127.0.0.1".into() } fn default_listen_port() -> u16 { 8085 @@ -550,6 +550,22 @@ fn default_verify_ssl() -> bool { true } +fn is_loopback_listen_host(host: &str) -> bool { + let host = host.trim(); + if host.eq_ignore_ascii_case("localhost") { + return true; + } + + let host = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); + + host.parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false) +} + impl Config { pub fn load(path: &Path) -> Result<(Self, Option), ConfigError> { let ext = path @@ -654,6 +670,14 @@ impl Config { self.listen_port, self.listen_host ))); } + if !is_loopback_listen_host(&self.listen_host) { + return Err(ConfigError::Invalid(format!( + "listen_host '{}' is not loopback. Non-loopback proxy binds \ + are disabled until inbound HTTP/SOCKS authentication is \ + configured; use 127.0.0.1 or ::1 in config.toml.", + self.listen_host + ))); + } for (i, g) in self.fronting_groups.iter().enumerate() { if g.name.trim().is_empty() { return Err(ConfigError::Invalid(format!( @@ -1184,6 +1208,56 @@ mod tests { let cfg: Config = serde_json::from_str(s).unwrap(); assert!(cfg.validate().is_err()); } + + #[test] + fn defaults_listen_host_to_loopback() { + let s = r#"{ + "mode": "direct" + }"#; + let cfg: Config = serde_json::from_str(s).unwrap(); + assert_eq!(cfg.listen_host, "127.0.0.1"); + cfg.validate().unwrap(); + } + + #[test] + fn validate_accepts_loopback_listen_hosts() { + for host in ["127.0.0.1", "::1", "[::1]", "localhost"] { + let s = format!( + r#"{{ + "mode": "direct", + "listen_host": "{}" + }}"#, + host + ); + let cfg: Config = serde_json::from_str(&s).unwrap(); + cfg.validate() + .unwrap_or_else(|e| panic!("expected loopback host '{}' to validate: {}", host, e)); + } + } + + #[test] + fn validate_rejects_non_loopback_listen_hosts() { + for host in ["0.0.0.0", "::", "[::]", "192.168.1.10", "example.com"] { + let s = format!( + r#"{{ + "mode": "direct", + "listen_host": "{}" + }}"#, + host + ); + let cfg: Config = serde_json::from_str(&s).unwrap(); + let err = cfg + .validate() + .expect_err(&format!("expected non-loopback host '{}' to fail", host)); + let msg = format!("{}", err); + assert!( + msg.contains("not loopback") && msg.contains("inbound HTTP/SOCKS authentication"), + "error should explain unsafe bind gate for '{}': {}", + host, + msg + ); + } + } } #[cfg(test)] @@ -1316,6 +1390,7 @@ 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_host, "127.0.0.1"); assert_eq!(cfg.listen_port, 8085); assert!(cfg.verify_ssl); assert!(cfg.block_doh); @@ -1445,9 +1520,11 @@ script_id = "ABCDEF" 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_host, "127.0.0.1"); + assert_eq!(cfg.listen_host, cfg2.listen_host); 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 +} From 03731e55aea9463e5e6ef4856fb50e97bf4551cc Mon Sep 17 00:00:00 2001 From: May Knott Date: Sun, 24 May 2026 03:16:50 +0330 Subject: [PATCH 2/3] fix(ui): disable LAN sharing while binds require loopback The config validator now rejects non-loopback listen_host values until inbound HTTP/SOCKS authentication exists, but the desktop form still exposed the previous LAN-sharing checkbox that wrote 0.0.0.0. That made the UI capable of saving a configuration the application would intentionally reject on the next start. Align the Network row with the fail-closed bind policy. The share-with-LAN checkbox is displayed disabled, the form keeps loopback as the saved value for normal configurations, and the hover/help text explains that LAN sharing returns only after authenticated inbound proxy mode exists. If an existing form load contains a wildcard or custom non-loopback bind, show it as unsafe and provide a Reset to loopback action instead of silently overwriting it. This keeps the UI, saved TOML behavior, and startup validation consistent while preserving the user's ability to see and repair an old unsafe value. --- src/bin/ui.rs | 112 +++++++++++++++++--------------------------------- 1 file changed, 37 insertions(+), 75 deletions(-) diff --git a/src/bin/ui.rs b/src/bin/ui.rs index c5f9ed63..059f8336 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -13,7 +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::lan_utils::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}; @@ -919,98 +919,60 @@ impl eframe::App for App { .labelled_by(label_id); }); - // 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.toml`; we - // detect that case and show a "Custom bind" badge so - // the checkbox doesn't silently overwrite their setting - // on the next Save. + // Network sharing is disabled until inbound proxy auth + // exists. The config validator rejects non-loopback binds; + // keep the form aligned so Save never writes a value that + // the next startup will fail closed. // // 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. + // closure — we may reset `self.form.listen_host` inside + // the closure, so avoid holding a borrow through it. 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." + "The proxy is reachable only from this computer. Non-loopback \ + binds are rejected until HTTP/SOCKS inbound authentication is \ + implemented, which prevents accidental open-proxy exposure on \ + shared Wi-Fi or hotspots." ), |ui, _label_id| { - 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.toml - // if they want to change it. + if was_share_on_lan || is_custom_bind { ui.vertical(|ui| { ui.label(egui::RichText::new(format!( - "Custom bind: {}", + "Unsafe bind configured: {}", listen_host_snapshot - )).color(egui::Color32::from_rgb(220, 180, 100))); - ui.small("Edit `listen_host` in config.toml to change."); + )).color(ERR_RED)); + ui.small( + "Current builds reject non-loopback binds. Set \ + listen_host to 127.0.0.1 in config.toml." + ); + if ui.small_button("Reset to loopback").clicked() { + self.form.listen_host = "127.0.0.1".to_string(); + } }); } 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))); - } - } - } + self.form.listen_host = "127.0.0.1".to_string(); + let mut share = false; + ui.add_enabled( + false, + egui::Checkbox::new( + &mut share, + "Share with other devices on my Wi-Fi / network", + ), + ) + .on_disabled_hover_text( + "LAN sharing will return after inbound proxy authentication is available.", + ); + ui.small( + egui::RichText::new("Loopback only: HTTP/SOCKS accept local connections.") + .color(egui::Color32::from_rgb(120, 200, 140)), + ); } }); - if let Some(updated) = new_listen_host { - self.form.listen_host = updated; - } ui.horizontal(|ui| { ui.add_sized( From 6185b739b40075e195f9184d73e5f70a9a8e7559 Mon Sep 17 00:00:00 2001 From: May Knott Date: Sun, 24 May 2026 03:47:01 +0330 Subject: [PATCH 3/3] feat(proxy): support explicit LAN sharing modes Add first-class inbound proxy authentication for non-loopback proxy binds while preserving the frictionless trusted-LAN workflow as an explicit persisted opt-in. The configuration model now supports proxy_auth credentials for HTTP Basic and SOCKS5 username/password authentication, plus allow_unauthenticated_lan for private home networks and personal hotspots where the operator intentionally wants open LAN access. Enforce the selected mode at runtime. HTTP proxy requests require Proxy-Authorization when credentials are configured and return 407 when authentication is missing or invalid. SOCKS5 negotiation selects RFC 1929 username/password authentication when configured, rejects clients that do not offer it, and keeps no-auth negotiation available when the operator has opted into unauthenticated LAN sharing. If allow_unauthenticated_lan is true, runtime auth is disabled even if stale credentials remain in a hand-edited config. Extend validation and TOML migration coverage so non-loopback binds fail unless they are protected by complete credentials or explicitly marked as open LAN. Partial credentials are rejected to avoid ambiguous exposure states, and TOML tests cover both authenticated and open LAN configurations. Update the desktop UI to expose LAN sharing as a real workflow instead of disabling it. The UI now lets users toggle sharing, choose authenticated or open trusted-LAN mode, enter proxy credentials, see the detected LAN endpoint, and receive a persistent-mode warning when switching between authenticated and open LAN behavior. Saving the form makes the selected mode the default for future launches, and switching back plus saving restores the safer authenticated posture. Refresh the example TOML files and English/Persian guide sections with concrete LAN-sharing configuration snippets, client setup notes, and warnings about quota exposure on shared networks. --- config.direct.example.toml | 9 +- config.example.toml | 9 +- config.exit-node.example.toml | 9 +- config.fronting-groups.example.toml | 9 +- config.full.example.toml | 9 +- docs/guide.fa.md | 35 +++++- docs/guide.md | 35 +++++- src/bin/ui.rs | 177 +++++++++++++++++++++----- src/config.rs | 146 +++++++++++++++++++++- src/proxy_server.rs | 186 +++++++++++++++++++++++++++- 10 files changed, 568 insertions(+), 56 deletions(-) diff --git a/config.direct.example.toml b/config.direct.example.toml index 463240f7..5c98c5cf 100644 --- a/config.direct.example.toml +++ b/config.direct.example.toml @@ -4,12 +4,19 @@ mode = "direct" [network] google_ip = "216.239.38.120" front_domain = "www.google.com" -# Non-loopback binds such as 0.0.0.0 are rejected until inbound proxy authentication is implemented. +# Local-only by default. For LAN sharing, set listen_host = "0.0.0.0" and +# either configure [network.proxy_auth] or set allow_unauthenticated_lan = true +# for a trusted home LAN/personal hotspot. listen_host = "127.0.0.1" +allow_unauthenticated_lan = false listen_port = 8085 socks5_port = 8086 verify_ssl = true +# [network.proxy_auth] +# username = "mhrv" +# password = "CHANGE_ME" + [network.hosts] [scan] diff --git a/config.example.toml b/config.example.toml index 48d31922..c7305729 100644 --- a/config.example.toml +++ b/config.example.toml @@ -6,12 +6,19 @@ auth_key = "CHANGE_ME_TO_A_STRONG_SECRET" [network] google_ip = "216.239.38.120" front_domain = "www.google.com" -# Non-loopback binds such as 0.0.0.0 are rejected until inbound proxy authentication is implemented. +# Local-only by default. For LAN sharing, set listen_host = "0.0.0.0" and +# either configure [network.proxy_auth] or set allow_unauthenticated_lan = true +# for a trusted home LAN/personal hotspot. listen_host = "127.0.0.1" +allow_unauthenticated_lan = false listen_port = 8085 socks5_port = 8086 verify_ssl = true +# [network.proxy_auth] +# username = "mhrv" +# password = "CHANGE_ME" + [network.hosts] [scan] diff --git a/config.exit-node.example.toml b/config.exit-node.example.toml index fe634fd7..01d64068 100644 --- a/config.exit-node.example.toml +++ b/config.exit-node.example.toml @@ -9,12 +9,19 @@ auth_key = "PUT_YOUR_APPS_SCRIPT_AUTH_KEY_HERE" [network] google_ip = "216.239.38.120" front_domain = "www.google.com" -# Non-loopback binds such as 0.0.0.0 are rejected until inbound proxy authentication is implemented. +# Local-only by default. For LAN sharing, set listen_host = "0.0.0.0" and +# either configure [network.proxy_auth] or set allow_unauthenticated_lan = true +# for a trusted home LAN/personal hotspot. listen_host = "127.0.0.1" +allow_unauthenticated_lan = false listen_port = 8085 socks5_port = 8086 verify_ssl = true +# [network.proxy_auth] +# username = "mhrv" +# password = "CHANGE_ME" + [network.hosts] [scan] diff --git a/config.fronting-groups.example.toml b/config.fronting-groups.example.toml index 7f3dcf6e..711519a8 100644 --- a/config.fronting-groups.example.toml +++ b/config.fronting-groups.example.toml @@ -4,12 +4,19 @@ mode = "direct" [network] google_ip = "216.239.38.120" front_domain = "www.google.com" -# Non-loopback binds such as 0.0.0.0 are rejected until inbound proxy authentication is implemented. +# Local-only by default. For LAN sharing, set listen_host = "0.0.0.0" and +# either configure [network.proxy_auth] or set allow_unauthenticated_lan = true +# for a trusted home LAN/personal hotspot. listen_host = "127.0.0.1" +allow_unauthenticated_lan = false listen_port = 8085 socks5_port = 8086 verify_ssl = true +# [network.proxy_auth] +# username = "mhrv" +# password = "CHANGE_ME" + [network.hosts] [scan] diff --git a/config.full.example.toml b/config.full.example.toml index fd661bae..53a1d2ce 100644 --- a/config.full.example.toml +++ b/config.full.example.toml @@ -6,12 +6,19 @@ auth_key = "CHANGE_ME_TO_A_STRONG_SECRET" [network] google_ip = "216.239.38.120" front_domain = "www.google.com" -# Non-loopback binds such as 0.0.0.0 are rejected until inbound proxy authentication is implemented. +# Local-only by default. For LAN sharing, set listen_host = "0.0.0.0" and +# either configure [network.proxy_auth] or set allow_unauthenticated_lan = true +# for a trusted home LAN/personal hotspot. listen_host = "127.0.0.1" +allow_unauthenticated_lan = false listen_port = 8085 socks5_port = 8086 verify_ssl = true +# [network.proxy_auth] +# username = "mhrv" +# password = "CHANGE_ME" + [network.hosts] [scan] diff --git a/docs/guide.fa.md b/docs/guide.fa.md index d1e7b5b3..a8320537 100644 --- a/docs/guide.fa.md +++ b/docs/guide.fa.md @@ -269,9 +269,36 @@ HTTP / HTTPS مثل قبل از Apps Script می‌رود (تغییری نمی ## اشتراک‌گذاری هات‌اسپات -mhrv-rs حالا به‌طور پیش‌فرض فقط روی `127.0.0.1` گوش می‌دهد و تا وقتی احراز هویت HTTP/SOCKS برای ورودی پیاده‌سازی نشده باشد، bind غیر loopback را رد می‌کند. این کار جلوی open proxy ناخواسته و مصرف شدن quota Apps Script توسط دستگاه‌های دیگر شبکه را می‌گیرد. +mhrv-rs به‌طور پیش‌فرض فقط روی `127.0.0.1` گوش می‌دهد، یعنی فقط برنامه‌های همان دستگاه می‌توانند از proxy استفاده کنند. برای اشتراک‌گذاری با گوشی، تبلت، لپ‌تاپ یا روتر خانه، `listen_host` را به `0.0.0.0` تغییر بده و یکی از دو حالت LAN را انتخاب کن: -اشتراک‌گذاری هات‌اسپات/LAN بعداً به‌صورت یک حالت صریح و دارای احراز هویت برمی‌گردد. در نسخهٔ فعلی `listen_host` را به `0.0.0.0` تغییر نده؛ اعتبارسنجی کانفیگ fail-closed می‌شود. workflow قدیمی این بود: +- **اشتراک‌گذاری LAN با احراز هویت**: `[network.proxy_auth]` را تنظیم کن. کلاینت HTTP باید `Proxy-Authorization: Basic ...` بفرستد و کلاینت SOCKS5 باید username/password داشته باشد. این حالت برای Wi-Fi مشترک، خوابگاه، محل کار، کتابخانه، و هر هات‌اسپاتی که همهٔ دستگاه‌های وصل‌شده‌اش دست خودت نیستند پیشنهاد می‌شود. +- **اشتراک‌گذاری LAN باز و مطمئن**: `allow_unauthenticated_lan = true` را بگذار. این همان workflow راحت قدیمی برای LAN خانه یا هات‌اسپات شخصی است. هر دستگاهی که به پورت proxy برسد می‌تواند از quota Apps Script و تونل تو استفاده کند، پس روی شبکهٔ مشترک روشنش نکن. + +UI هر دو حالت را نشان می‌دهد. بعد از Save، همان حالت برای اجراهای بعدی پیش‌فرض می‌شود؛ هر وقت خواستی به حالت امن‌تر برگردی، گزینه را خاموش کن و دوباره Save بزن. + +نمونهٔ TOML: + +```toml +[network] +listen_host = "0.0.0.0" +listen_port = 8085 +socks5_port = 8086 +allow_unauthenticated_lan = false + +[network.proxy_auth] +username = "home" +password = "CHANGE_ME" +``` + +برای خانه/هات‌اسپات خصوصی بدون credential: + +```toml +[network] +listen_host = "0.0.0.0" +allow_unauthenticated_lan = true +``` + +workflow پایهٔ هات‌اسپات: ۱. **اندروید:** هات‌اسپات موبایل را روشن کن + اپ را استارت کن ۲. **دستگاه دیگر:** به Wi-Fi هات‌اسپات اندروید وصل شو @@ -289,7 +316,7 @@ Settings → Wi-Fi → روی (i) شبکهٔ هات‌اسپات بزن → Conf HTTP proxy سیستم را روی `192.168.43.1:8080` بگذار، یا per-app SOCKS5 روی `192.168.43.1:1081`. -> گیت امنیتی فعلی: مقدارهای غیر loopback مثل `0.0.0.0`، `::` یا IP شبکهٔ LAN تا زمان اضافه شدن احراز هویت proxy رد می‌شوند. +اگر LAN با احراز هویت روشن است، username/password تنظیم‌شده را در تنظیمات proxy کلاینت وارد کن. اگر کلاینت فقط proxy بدون احراز هویت را پشتیبانی می‌کند، حالت LAN باز را فقط روی شبکه‌ای روشن کن که کنترلش دست خودت است. ## اجرا روی OpenWRT @@ -308,7 +335,7 @@ chmod +x /usr/bin/mhrv-rs /etc/init.d/mhrv-rs logread -e mhrv-rs -f # تمام لاگ ``` -نسخهٔ فعلی فقط روی loopback گوش می‌دهد. اجرای CLI روی OpenWRT برای تست محلی همچنان کار می‌کند، اما استفاده از روتر به‌عنوان proxy برای کل LAN به حالت authenticated LAN-sharing آینده نیاز دارد. در این نسخه `listen_host` را در `/etc/mhrv-rs/config.toml` به `0.0.0.0` تغییر نده؛ اعتبارسنجی کانفیگ این bind ناامن را رد می‌کند. +OpenWRT می‌تواند proxy کل LAN باشد: در `/etc/mhrv-rs/config.toml` مقدار `listen_host = "0.0.0.0"` را بگذار. اگر افراد دیگر هم می‌توانند به LAN وصل شوند، `[network.proxy_auth]` را تنظیم کن. برای شبکهٔ خصوصی خانه که عمداً رفتار بدون رمز قدیمی را می‌خواهی، `allow_unauthenticated_lan = true` را بگذار. مصرف حافظه ~۱۵–۲۰ مگابایت — روی هر روتری با ۱۲۸ مگابایت RAM به بالا اجرا می‌شود. UI روی musl نیست (روترها headlessاند). diff --git a/docs/guide.md b/docs/guide.md index 2e844298..671856dc 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -269,9 +269,36 @@ The destination sees the exit node's IP, not Google's, so the anti-bot heuristic ## Sharing via hotspot -mhrv-rs listens on `127.0.0.1` by default and rejects non-loopback proxy binds until inbound HTTP/SOCKS authentication is implemented. This prevents accidental open-proxy exposure and Apps Script quota theft on shared Wi-Fi or hotspots. +mhrv-rs listens on `127.0.0.1` by default, so only apps on the same device can use the HTTP/SOCKS proxy. To share the proxy with another phone, tablet, laptop, or a home router, switch the bind address to `0.0.0.0` and choose one of two persisted LAN modes: -Hotspot/LAN sharing will return as an explicit authenticated mode in a later release. On current builds, do not change `listen_host` to `0.0.0.0`; startup validation will fail closed. The old sharing workflow was: +- **Authenticated LAN sharing**: configure `[network.proxy_auth]`. HTTP clients must send `Proxy-Authorization: Basic ...`; SOCKS5 clients must use username/password authentication. This is the recommended mode on shared Wi-Fi, dorm networks, offices, libraries, and any hotspot where you do not fully control every connected device. +- **Open trusted-LAN sharing**: set `allow_unauthenticated_lan = true`. This keeps the old frictionless hotspot workflow for a trusted home LAN or personal hotspot. Anyone who can reach the proxy port can use your Apps Script quota and tunnel, so leave this off on shared networks. + +The UI exposes both modes. After you save, the selected mode becomes the default for future launches; switch it back and save again whenever you need to return to the safer authenticated mode. + +TOML example: + +```toml +[network] +listen_host = "0.0.0.0" +listen_port = 8085 +socks5_port = 8086 +allow_unauthenticated_lan = false + +[network.proxy_auth] +username = "home" +password = "CHANGE_ME" +``` + +For a private home/hotspot setup without credentials: + +```toml +[network] +listen_host = "0.0.0.0" +allow_unauthenticated_lan = true +``` + +Basic hotspot workflow: 1. **Android:** enable mobile hotspot + start the app 2. **Other device:** connect to the Android hotspot Wi-Fi @@ -289,7 +316,7 @@ For full device-wide coverage on iOS, use [Shadowrocket](https://apps.apple.com/ Set system HTTP proxy to `192.168.43.1:8080`, or per-app SOCKS5 to `192.168.43.1:1081`. -> Current safety gate: non-loopback values such as `0.0.0.0`, `::`, or a LAN IP are rejected until proxy authentication is available. +If authenticated LAN sharing is enabled, enter the configured username/password in the client proxy settings. If the client only supports unauthenticated proxies, use open trusted-LAN mode only on a network you control. ## Running on OpenWRT @@ -308,7 +335,7 @@ chmod +x /usr/bin/mhrv-rs /etc/init.d/mhrv-rs logread -e mhrv-rs -f # tail logs ``` -Current builds listen on loopback only. Running the CLI on OpenWRT for local diagnostics still works, but using the router as a LAN-wide proxy requires the upcoming authenticated LAN-sharing mode. Do not set `listen_host` to `0.0.0.0` in `/etc/mhrv-rs/config.toml` on this version; config validation will reject the unsafe bind. +OpenWRT can act as a LAN-wide proxy by setting `listen_host = "0.0.0.0"` in `/etc/mhrv-rs/config.toml`. Use `[network.proxy_auth]` when other people can join the LAN. For a private home network where you intentionally want the old no-password behavior, set `allow_unauthenticated_lan = true`. Memory footprint ~15–20 MB resident — fine on anything ≥128 MB RAM. No UI on musl (routers are headless). diff --git a/src/bin/ui.rs b/src/bin/ui.rs index 059f8336..36dc35b9 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -10,10 +10,10 @@ use tokio::sync::Mutex as AsyncMutex; use tokio::task::JoinHandle; use mhrv_rs::cert_installer::{install_ca, reconcile_sudo_environment, remove_ca}; -use mhrv_rs::config::{Config, FrontingGroup, ScriptId}; +use mhrv_rs::config::{Config, FrontingGroup, ProxyAuth, ScriptId}; use mhrv_rs::data_dir; use mhrv_rs::domain_fronter::{DomainFronter, DEFAULT_GOOGLE_SNI_POOL}; -use mhrv_rs::lan_utils::is_share_on_lan; +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}; @@ -230,6 +230,10 @@ struct FormState { listen_host: String, listen_port: String, socks5_port: String, + proxy_auth_username: String, + proxy_auth_password: String, + show_proxy_auth_password: bool, + allow_unauthenticated_lan: bool, log_level: String, verify_ssl: bool, upstream_socks5: String, @@ -363,6 +367,10 @@ fn load_form() -> (FormState, Option) { listen_host: c.listen_host, listen_port: c.listen_port.to_string(), socks5_port: c.socks5_port.map(|p| p.to_string()).unwrap_or_default(), + proxy_auth_username: c.proxy_auth.as_ref().map(|a| a.username.clone()).unwrap_or_default(), + proxy_auth_password: c.proxy_auth.as_ref().map(|a| a.password.clone()).unwrap_or_default(), + show_proxy_auth_password: false, + allow_unauthenticated_lan: c.allow_unauthenticated_lan, log_level: c.log_level, verify_ssl: c.verify_ssl, upstream_socks5: c.upstream_socks5.unwrap_or_default(), @@ -403,6 +411,10 @@ fn load_form() -> (FormState, Option) { listen_host: "127.0.0.1".into(), listen_port: "8085".into(), socks5_port: "8086".into(), + proxy_auth_username: String::new(), + proxy_auth_password: String::new(), + show_proxy_auth_password: false, + allow_unauthenticated_lan: false, log_level: "info".into(), verify_ssl: true, upstream_socks5: String::new(), @@ -532,6 +544,16 @@ impl FormState { script_id, script_ids: None, auth_key: self.auth_key.clone(), + proxy_auth: { + let username = self.proxy_auth_username.trim().to_string(); + let password = self.proxy_auth_password.clone(); + if self.allow_unauthenticated_lan || (username.is_empty() && password.is_empty()) { + None + } else { + Some(ProxyAuth { username, password }) + } + }, + allow_unauthenticated_lan: self.allow_unauthenticated_lan, listen_host: self.listen_host.trim().to_string(), listen_port, socks5_port, @@ -919,60 +941,149 @@ impl eframe::App for App { .labelled_by(label_id); }); - // Network sharing is disabled until inbound proxy auth - // exists. The config validator rejects non-loopback binds; - // keep the form aligned so Save never writes a value that - // the next startup will fail closed. + // Network sharing can be authenticated or explicitly open. + // The selected mode is persisted with the rest of the config. // // Snapshot the relevant flags before entering form_row's // closure — we may reset `self.form.listen_host` inside // the closure, so avoid holding a borrow through it. 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( - "The proxy is reachable only from this computer. Non-loopback \ - binds are rejected until HTTP/SOCKS inbound authentication is \ - implemented, which prevents accidental open-proxy exposure on \ - shared Wi-Fi or hotspots." + "Loopback is local-only. LAN sharing binds the proxy to all \ + interfaces. Use proxy auth on shared networks; open LAN mode \ + is for trusted home networks and personal hotspots." ), |ui, _label_id| { - if was_share_on_lan || is_custom_bind { + if is_custom_bind { ui.vertical(|ui| { ui.label(egui::RichText::new(format!( - "Unsafe bind configured: {}", + "Custom bind: {}", listen_host_snapshot - )).color(ERR_RED)); - ui.small( - "Current builds reject non-loopback binds. Set \ - listen_host to 127.0.0.1 in config.toml." - ); + )).color(egui::Color32::from_rgb(220, 180, 100))); + ui.small("Edit `listen_host` in config.toml to change."); if ui.small_button("Reset to loopback").clicked() { self.form.listen_host = "127.0.0.1".to_string(); } }); } else { - self.form.listen_host = "127.0.0.1".to_string(); - let mut share = false; - ui.add_enabled( - false, - egui::Checkbox::new( - &mut share, - "Share with other devices on my Wi-Fi / network", - ), - ) - .on_disabled_hover_text( - "LAN sharing will return after inbound proxy authentication is available.", - ); - ui.small( - egui::RichText::new("Loopback only: HTTP/SOCKS accept local connections.") - .color(egui::Color32::from_rgb(120, 200, 140)), - ); + 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 { + let open_lan_changed = ui.checkbox( + &mut self.form.allow_unauthenticated_lan, + "Allow LAN clients without proxy auth", + ) + .on_hover_text( + "Use only on a trusted home LAN or personal hotspot. \ + Anyone who can reach this port can use your proxy.", + ) + .changed(); + if open_lan_changed && self.form.allow_unauthenticated_lan { + self.status = Some( + "Open LAN mode selected. Save to make this the default for future launches; turn it off and save again to return to authenticated sharing." + .to_string(), + ); + } else if open_lan_changed { + self.status = Some( + "Authenticated LAN mode selected. Save to require proxy credentials on future launches." + .to_string(), + ); + } + let auth_ready = self.form.allow_unauthenticated_lan + || (!self.form.proxy_auth_username.trim().is_empty() + && !self.form.proxy_auth_password.is_empty()); + ui.small( + egui::RichText::new(if self.form.allow_unauthenticated_lan { + "Open LAN sharing is persisted after Save. Anyone on this network can use the proxy until you turn this off and save again." + } else if auth_ready { + "Authenticated LAN sharing is persisted after Save. LAN clients must send HTTP Basic or SOCKS5 username/password credentials." + } else { + "Set proxy username and password before saving LAN sharing." + }) + .color(if auth_ready { + if self.form.allow_unauthenticated_lan { + egui::Color32::from_rgb(220, 180, 100) + } else { + OK_GREEN + } + } else { + ERR_RED + }), + ); + 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, or run `ipconfig` on Windows." + ).color(egui::Color32::from_rgb(220, 180, 100))); + } + } + } else { + self.form.allow_unauthenticated_lan = false; + } } }); + if let Some(updated) = new_listen_host { + self.form.listen_host = updated; + } + + ui.horizontal(|ui| { + ui.add_sized( + [120.0, 20.0], + egui::Label::new(egui::RichText::new("Proxy auth") + .color(egui::Color32::from_gray(200))), + ); + let auth_inputs_enabled = !self.form.allow_unauthenticated_lan; + let user_label = ui.label(egui::RichText::new("User").small()); + ui.add_enabled( + auth_inputs_enabled, + egui::TextEdit::singleline(&mut self.form.proxy_auth_username) + .desired_width(110.0), + ) + .labelled_by(user_label.id); + let pass_label = ui.label(egui::RichText::new("Pass").small()); + ui.add_enabled( + auth_inputs_enabled, + egui::TextEdit::singleline(&mut self.form.proxy_auth_password) + .password(!self.form.show_proxy_auth_password) + .desired_width(130.0), + ) + .labelled_by(pass_label.id); + ui.add_enabled( + auth_inputs_enabled, + egui::Checkbox::new(&mut self.form.show_proxy_auth_password, "Show"), + ); + }); ui.horizontal(|ui| { ui.add_sized( diff --git a/src/config.rs b/src/config.rs index 348e3c3b..f4b58f43 100644 --- a/src/config.rs +++ b/src/config.rs @@ -59,6 +59,20 @@ impl ScriptId { } } +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct ProxyAuth { + #[serde(default)] + pub username: String, + #[serde(default)] + pub password: String, +} + +impl ProxyAuth { + pub fn is_configured(&self) -> bool { + !self.username.trim().is_empty() && !self.password.is_empty() + } +} + #[derive(Debug, Clone, Deserialize)] pub struct Config { pub mode: String, @@ -72,6 +86,10 @@ pub struct Config { pub script_ids: Option, #[serde(default)] pub auth_key: String, + #[serde(default)] + pub proxy_auth: Option, + #[serde(default)] + pub allow_unauthenticated_lan: bool, #[serde(default = "default_listen_host")] pub listen_host: String, #[serde(default = "default_listen_port")] @@ -670,11 +688,22 @@ impl Config { self.listen_port, self.listen_host ))); } - if !is_loopback_listen_host(&self.listen_host) { + if let Some(auth) = &self.proxy_auth { + if auth.username.trim().is_empty() != auth.password.is_empty() { + return Err(ConfigError::Invalid( + "proxy_auth requires both username and password".into(), + )); + } + } + if !is_loopback_listen_host(&self.listen_host) + && !self.allow_unauthenticated_lan + && !self.proxy_auth.as_ref().map_or(false, ProxyAuth::is_configured) + { return Err(ConfigError::Invalid(format!( "listen_host '{}' is not loopback. Non-loopback proxy binds \ - are disabled until inbound HTTP/SOCKS authentication is \ - configured; use 127.0.0.1 or ::1 in config.toml.", + require proxy_auth.username/password or \ + allow_unauthenticated_lan = true; use 127.0.0.1 or ::1 \ + for local-only proxy access.", self.listen_host ))); } @@ -809,6 +838,10 @@ pub struct TomlNetwork { pub verify_ssl: bool, #[serde(default)] pub upstream_socks5: Option, + #[serde(default)] + pub proxy_auth: Option, + #[serde(default)] + pub allow_unauthenticated_lan: bool, #[serde(default = "default_block_quic")] pub block_quic: bool, #[serde(default = "default_block_stun")] @@ -837,6 +870,8 @@ impl Default for TomlNetwork { socks5_port: None, verify_ssl: default_verify_ssl(), upstream_socks5: None, + proxy_auth: None, + allow_unauthenticated_lan: false, block_quic: default_block_quic(), block_stun: default_block_stun(), sni_hosts: None, @@ -912,6 +947,8 @@ impl From for Config { script_id: t.relay.script_id, script_ids: t.relay.script_ids, auth_key: t.relay.auth_key, + proxy_auth: t.network.proxy_auth, + allow_unauthenticated_lan: t.network.allow_unauthenticated_lan, listen_host: t.network.listen_host, listen_port: t.network.listen_port, socks5_port: t.network.socks5_port, @@ -979,6 +1016,8 @@ impl From<&Config> for TomlConfig { socks5_port: c.socks5_port, verify_ssl: c.verify_ssl, upstream_socks5: c.upstream_socks5.clone(), + proxy_auth: c.proxy_auth.clone(), + allow_unauthenticated_lan: c.allow_unauthenticated_lan, block_quic: c.block_quic, block_stun: c.block_stun, sni_hosts: c.sni_hosts.clone(), @@ -1251,13 +1290,68 @@ mod tests { .expect_err(&format!("expected non-loopback host '{}' to fail", host)); let msg = format!("{}", err); assert!( - msg.contains("not loopback") && msg.contains("inbound HTTP/SOCKS authentication"), + msg.contains("not loopback") + && msg.contains("proxy_auth") + && msg.contains("allow_unauthenticated_lan"), "error should explain unsafe bind gate for '{}': {}", host, msg ); } } + + #[test] + fn validate_accepts_non_loopback_with_proxy_auth() { + let s = r#"{ + "mode": "direct", + "listen_host": "0.0.0.0", + "proxy_auth": { + "username": "home", + "password": "lan-secret" + } + }"#; + let cfg: Config = serde_json::from_str(s).unwrap(); + cfg.validate() + .expect("authenticated LAN bind should validate"); + } + + #[test] + fn validate_accepts_explicit_unauthenticated_lan_opt_in() { + let s = r#"{ + "mode": "direct", + "listen_host": "0.0.0.0", + "allow_unauthenticated_lan": true + }"#; + let cfg: Config = serde_json::from_str(s).unwrap(); + cfg.validate() + .expect("explicit open LAN opt-in should validate"); + } + + #[test] + fn validate_rejects_partial_proxy_auth() { + for auth in [ + r#""proxy_auth": { "username": "home", "password": "" }"#, + r#""proxy_auth": { "username": "", "password": "lan-secret" }"#, + ] { + let s = format!( + r#"{{ + "mode": "direct", + "listen_host": "0.0.0.0", + {} + }}"#, + auth + ); + let cfg: Config = serde_json::from_str(&s).unwrap(); + let err = cfg + .validate() + .expect_err("partial proxy_auth must fail validation"); + assert!( + format!("{}", err).contains("proxy_auth requires both username and password"), + "unexpected validation error: {}", + err + ); + } + } } #[cfg(test)] @@ -1463,6 +1557,50 @@ mode = "direct" assert_eq!(cfg.hosts.get("test.example.com"), Some(&"5.6.7.8".to_string())); } + #[test] + fn toml_parses_authenticated_lan_bind() { + let s = r#" +[relay] +mode = "direct" + +[network] +listen_host = "0.0.0.0" +allow_unauthenticated_lan = false + +[network.proxy_auth] +username = "home" +password = "lan-secret" +"#; + let toml_cfg: TomlConfig = toml::from_str(s).unwrap(); + let cfg = Config::from(toml_cfg); + cfg.validate().unwrap(); + assert_eq!(cfg.listen_host, "0.0.0.0"); + assert!(!cfg.allow_unauthenticated_lan); + assert_eq!( + cfg.proxy_auth + .as_ref() + .map(|a| (a.username.as_str(), a.password.as_str())), + Some(("home", "lan-secret")) + ); + } + + #[test] + fn toml_parses_explicit_open_lan_bind() { + let s = r#" +[relay] +mode = "direct" + +[network] +listen_host = "0.0.0.0" +allow_unauthenticated_lan = true +"#; + let toml_cfg: TomlConfig = toml::from_str(s).unwrap(); + let cfg = Config::from(toml_cfg); + cfg.validate().unwrap(); + assert!(cfg.allow_unauthenticated_lan); + assert!(cfg.proxy_auth.is_none()); + } + #[test] fn toml_multi_script_id_array() { let s = r#" diff --git a/src/proxy_server.rs b/src/proxy_server.rs index 209bbc58..ac505f05 100644 --- a/src/proxy_server.rs +++ b/src/proxy_server.rs @@ -3,8 +3,10 @@ use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; use bytes::Bytes; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream, UdpSocket}; use tokio::sync::{mpsc, Mutex}; use tokio::task::JoinSet; @@ -16,7 +18,7 @@ use tokio_rustls::rustls::server::Acceptor; use tokio_rustls::rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme}; use tokio_rustls::{LazyConfigAcceptor, TlsAcceptor, TlsConnector}; -use crate::config::{Config, FrontingGroup, Mode}; +use crate::config::{Config, FrontingGroup, Mode, ProxyAuth}; use crate::domain_fronter::DomainFronter; use crate::mitm::MitmCertManager; use crate::tunnel_client::{decode_udp_packets, TunnelMux}; @@ -262,6 +264,7 @@ pub struct RewriteCtx { /// domains used only for matching). Empty = feature off (only /// the built-in Google edge SNI-rewrite is active). pub fronting_groups: Vec>, + pub inbound_auth: Option, } /// True if `host` matches a known DoH endpoint — either the built-in @@ -513,6 +516,11 @@ impl ProxyServer { block_doh: config.block_doh, bypass_doh_hosts: config.bypass_doh_hosts.clone(), fronting_groups, + inbound_auth: if config.allow_unauthenticated_lan { + None + } else { + config.proxy_auth.clone() + }, }); let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1); @@ -810,9 +818,23 @@ async fn handle_http_client( } }; - let (method, target, _version, _headers) = parse_request_head(&head) + let (method, target, _version, headers) = parse_request_head(&head) .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "bad request"))?; + if !http_proxy_auth_ok(&headers, rewrite_ctx.inbound_auth.as_ref()) { + tracing::warn!("HTTP proxy auth failed"); + let _ = sock + .write_all( + b"HTTP/1.1 407 Proxy Authentication Required\r\n\ + Proxy-Authenticate: Basic realm=\"mhrv-rs\"\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\r\n", + ) + .await; + let _ = sock.flush().await; + return Ok(()); + } + if method.eq_ignore_ascii_case("CONNECT") { let (host, port) = parse_host_port(&target); // Mirror the SOCKS5 short-circuit: if the tunnel-node just failed @@ -856,6 +878,26 @@ async fn handle_http_client( // ---------- SOCKS5 ---------- +fn http_proxy_auth_ok(headers: &[(String, String)], auth: Option<&ProxyAuth>) -> bool { + let Some(auth) = auth else { + return true; + }; + let expected = format!("{}:{}", auth.username, auth.password); + headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("proxy-authorization")) + .and_then(|(_, v)| { + let value = v.trim(); + value + .get(..6) + .filter(|prefix| prefix.eq_ignore_ascii_case("Basic ")) + .and_then(|_| value.get(6..)) + }) + .and_then(|b64| B64.decode(b64.trim()).ok()) + .and_then(|raw| String::from_utf8(raw).ok()) + .map_or(false, |actual| actual == expected) +} + async fn handle_socks5_client( mut sock: TcpStream, fronter: Option>, @@ -872,12 +914,22 @@ async fn handle_socks5_client( let nmethods = hdr[1] as usize; let mut methods = vec![0u8; nmethods]; sock.read_exact(&mut methods).await?; - // Only "no auth" (0x00) is supported. - if !methods.contains(&0x00) { + if let Some(auth) = rewrite_ctx.inbound_auth.as_ref() { + if !methods.contains(&0x02) { + sock.write_all(&[0x05, 0xff]).await?; + return Ok(()); + } + sock.write_all(&[0x05, 0x02]).await?; + if !socks5_username_password_auth(&mut sock, auth).await? { + tracing::warn!("SOCKS5 proxy auth failed"); + return Ok(()); + } + } else if methods.contains(&0x00) { + sock.write_all(&[0x05, 0x00]).await?; + } else { sock.write_all(&[0x05, 0xff]).await?; return Ok(()); } - sock.write_all(&[0x05, 0x00]).await?; // Request: VER=5, CMD, RSV=0, ATYP, DST.ADDR, DST.PORT let mut req = [0u8; 4]; @@ -963,6 +1015,29 @@ async fn handle_socks5_client( dispatch_tunnel(sock, host, port, fronter, mitm, rewrite_ctx, tunnel_mux).await } +async fn socks5_username_password_auth( + sock: &mut (impl AsyncRead + AsyncWrite + Unpin), + auth: &ProxyAuth, +) -> std::io::Result { + let mut head = [0u8; 2]; + sock.read_exact(&mut head).await?; + if head[0] != 0x01 { + sock.write_all(&[0x01, 0x01]).await?; + return Ok(false); + } + let mut uname = vec![0u8; head[1] as usize]; + sock.read_exact(&mut uname).await?; + let mut plen = [0u8; 1]; + sock.read_exact(&mut plen).await?; + let mut passwd = vec![0u8; plen[0] as usize]; + sock.read_exact(&mut passwd).await?; + + let ok = uname == auth.username.as_bytes() && passwd == auth.password.as_bytes(); + sock.write_all(&[0x01, if ok { 0x00 } else { 0x01 }]).await?; + sock.flush().await?; + Ok(ok) +} + #[derive(Clone, Debug, Eq, Hash, PartialEq)] struct SocksUdpTarget { host: String, @@ -3228,6 +3303,105 @@ mod tests { assert_eq!(build_socks5_udp_packet(&target, payload), raw); } + #[test] + fn http_proxy_auth_allows_requests_when_auth_is_not_configured() { + assert!(http_proxy_auth_ok(&headers(&[]), None)); + } + + #[test] + fn http_proxy_auth_accepts_valid_basic_credentials() { + let auth = ProxyAuth { + username: "home".into(), + password: "lan-secret".into(), + }; + let encoded = B64.encode("home:lan-secret"); + assert!(http_proxy_auth_ok( + &headers(&[("Proxy-Authorization", &format!("Basic {}", encoded))]), + Some(&auth), + )); + assert!(http_proxy_auth_ok( + &headers(&[("proxy-authorization", &format!("basic {}", encoded))]), + Some(&auth), + )); + } + + #[test] + fn http_proxy_auth_rejects_missing_bad_or_malformed_credentials() { + let auth = ProxyAuth { + username: "home".into(), + password: "lan-secret".into(), + }; + let wrong = B64.encode("home:wrong"); + assert!(!http_proxy_auth_ok(&headers(&[]), Some(&auth))); + assert!(!http_proxy_auth_ok( + &headers(&[("Proxy-Authorization", &format!("Basic {}", wrong))]), + Some(&auth), + )); + assert!(!http_proxy_auth_ok( + &headers(&[("Proxy-Authorization", "Basic !!!")]), + Some(&auth), + )); + assert!(!http_proxy_auth_ok( + &headers(&[("Authorization", &format!("Basic {}", B64.encode("home:lan-secret")))]), + Some(&auth), + )); + } + + #[tokio::test(flavor = "current_thread")] + async fn socks5_username_password_auth_accepts_matching_credentials() { + let auth = ProxyAuth { + username: "home".into(), + password: "lan-secret".into(), + }; + let (mut client, mut server) = duplex(128); + let client_task = tokio::spawn(async move { + client + .write_all(&[ + 0x01, 0x04, b'h', b'o', b'm', b'e', 0x0a, b'l', b'a', b'n', b'-', b's', + b'e', b'c', b'r', b'e', b't', + ]) + .await + .unwrap(); + let mut status = [0u8; 2]; + client.read_exact(&mut status).await.unwrap(); + status + }); + + assert!( + socks5_username_password_auth(&mut server, &auth) + .await + .unwrap() + ); + assert_eq!(client_task.await.unwrap(), [0x01, 0x00]); + } + + #[tokio::test(flavor = "current_thread")] + async fn socks5_username_password_auth_rejects_wrong_credentials() { + let auth = ProxyAuth { + username: "home".into(), + password: "lan-secret".into(), + }; + let (mut client, mut server) = duplex(128); + let client_task = tokio::spawn(async move { + client + .write_all(&[ + 0x01, 0x04, b'h', b'o', b'm', b'e', 0x05, b'w', b'r', b'o', b'n', b'g', + ]) + .await + .unwrap(); + let mut status = [0u8; 2]; + client.read_exact(&mut status).await.unwrap(); + status + }); + + assert!( + !socks5_username_password_auth(&mut server, &auth) + .await + .unwrap() + ); + assert_eq!(client_task.await.unwrap(), [0x01, 0x01]); + } + #[tokio::test(flavor = "current_thread")] async fn read_body_decodes_chunked_request() { let (mut client, mut server) = duplex(1024);