diff --git a/config.direct.example.toml b/config.direct.example.toml index ebe2b8ee..5c98c5cf 100644 --- a/config.direct.example.toml +++ b/config.direct.example.toml @@ -4,11 +4,19 @@ mode = "direct" [network] google_ip = "216.239.38.120" front_domain = "www.google.com" +# 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 ab233f3f..c7305729 100644 --- a/config.example.toml +++ b/config.example.toml @@ -6,11 +6,19 @@ auth_key = "CHANGE_ME_TO_A_STRONG_SECRET" [network] google_ip = "216.239.38.120" front_domain = "www.google.com" +# 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 72efeecf..01d64068 100644 --- a/config.exit-node.example.toml +++ b/config.exit-node.example.toml @@ -9,11 +9,19 @@ 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" +# 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] @@ -44,4 +52,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..711519a8 100644 --- a/config.fronting-groups.example.toml +++ b/config.fronting-groups.example.toml @@ -4,11 +4,19 @@ mode = "direct" [network] google_ip = "216.239.38.120" front_domain = "www.google.com" +# 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 e75f0b42..53a1d2ce 100644 --- a/config.full.example.toml +++ b/config.full.example.toml @@ -6,11 +6,19 @@ auth_key = "CHANGE_ME_TO_A_STRONG_SECRET" [network] google_ip = "216.239.38.120" front_domain = "www.google.com" +# 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 d0247453..a8320537 100644 --- a/docs/guide.fa.md +++ b/docs/guide.fa.md @@ -269,7 +269,36 @@ HTTP / HTTPS مثل قبل از Apps Script می‌رود (تغییری نمی ## اشتراک‌گذاری هات‌اسپات -mhrv-rs به‌طور پیش‌فرض روی `0.0.0.0` گوش می‌دهد، پس هر دستگاه روی همان شبکه می‌تواند ازش استفاده کند. سناریوی رایج: اشتراک تونل از گوشی اندروید به آیفون / آیپد / لپ‌تاپ از هات‌اسپات: +mhrv-rs به‌طور پیش‌فرض فقط روی `127.0.0.1` گوش می‌دهد، یعنی فقط برنامه‌های همان دستگاه می‌توانند از proxy استفاده کنند. برای اشتراک‌گذاری با گوشی، تبلت، لپ‌تاپ یا روتر خانه، `listen_host` را به `0.0.0.0` تغییر بده و یکی از دو حالت LAN را انتخاب کن: + +- **اشتراک‌گذاری 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 هات‌اسپات اندروید وصل شو @@ -287,7 +316,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` تغییرش بده تا اتصال از دستگاه‌های دیگر را بپذیرد. +اگر LAN با احراز هویت روشن است، username/password تنظیم‌شده را در تنظیمات proxy کلاینت وارد کن. اگر کلاینت فقط proxy بدون احراز هویت را پشتیبانی می‌کند، حالت LAN باز را فقط روی شبکه‌ای روشن کن که کنترلش دست خودت است. ## اجرا روی OpenWRT @@ -306,7 +335,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 را بپذیرد. +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 679a35d0..671856dc 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -269,7 +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 `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, 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: + +- **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 @@ -287,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`. -> If `listen_host` is `127.0.0.1` in your config, change to `0.0.0.0` to allow other devices. +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 @@ -306,7 +335,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. +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 c5f9ed63..36dc35b9 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -10,7 +10,7 @@ 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::{detect_lan_ip, is_share_on_lan}; @@ -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,22 +941,12 @@ 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 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 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(); @@ -946,24 +958,20 @@ impl eframe::App for App { && 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." + "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 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. 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.toml to change."); + 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; @@ -975,12 +983,47 @@ impl eframe::App for App { }); } 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). + 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() { @@ -1001,10 +1044,12 @@ impl eframe::App for App { 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)." + -> Network, or run `ipconfig` on Windows." ).color(egui::Color32::from_rgb(220, 180, 100))); } } + } else { + self.form.allow_unauthenticated_lan = false; } } }); @@ -1012,6 +1057,34 @@ impl eframe::App for App { 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( [120.0, 20.0], diff --git a/src/config.rs b/src/config.rs index cd63b8b8..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")] @@ -538,7 +556,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 +568,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 +688,25 @@ impl Config { self.listen_port, 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 \ + 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 + ))); + } for (i, g) in self.fronting_groups.iter().enumerate() { if g.name.trim().is_empty() { return Err(ConfigError::Invalid(format!( @@ -785,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")] @@ -813,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, @@ -888,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, @@ -955,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(), @@ -1184,6 +1247,111 @@ 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("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)] @@ -1316,6 +1484,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); @@ -1388,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#" @@ -1445,9 +1658,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 +} 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);