diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..dfe07704 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/Cargo.lock b/Cargo.lock index fd1c494d..cb9df5e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2654,6 +2654,7 @@ dependencies = [ "tun2proxy", "url", "webpki-roots 0.26.11", + "winreg", "x509-parser", "zstd", ] diff --git a/Cargo.toml b/Cargo.toml index 13ea4b4e..88960f0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,7 @@ eframe = { version = "0.28", default-features = false, features = [ "accesskit", ], optional = true } url = "2.5.8" +winreg = "0.55" # Unix-only deps. Must come after `[dependencies]` because starting a new # table here otherwise ends the main one — anything below it (incl. eframe) diff --git a/src/bin/ui.rs b/src/bin/ui.rs index 9c6799b7..40ef13d7 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -24,11 +24,20 @@ const WIN_HEIGHT: f32 = 680.0; const LOG_MAX: usize = 200; fn main() -> eframe::Result<()> { + // Install default rustls crypto provider (ring). let _ = rustls::crypto::ring::default_provider().install_default(); + // Re-point HOME at the invoking user if this binary was launched // under sudo (see cert_installer::reconcile_sudo_environment). Must // run before any data_dir / firefox_profile_dirs call. reconcile_sudo_environment(); + + #[cfg(target_os = "windows")] + { + // Boot-up Initialization Proxy State Flush + sync_wininet_proxy(false, 0); + } + mhrv_rs::rlimit::raise_nofile_limit_best_effort(); let shared = Arc::new(Shared::default()); @@ -95,7 +104,17 @@ fn main() -> eframe::Result<()> { "mhrv-rs", options, Box::new(move |cc| { - cc.egui_ctx.set_visuals(egui::Visuals::dark()); + let mut premium_visuals = egui::Visuals::dark(); + premium_visuals.panel_fill = egui::Color32::from_rgb(18, 20, 24); // Deep Obsidian Canvas + premium_visuals.window_fill = egui::Color32::from_rgb(26, 29, 36); // Slate Card Surface + premium_visuals.widgets.active.bg_fill = egui::Color32::from_rgb(59, 130, 246); // Accent Cobalt + premium_visuals.widgets.hovered.bg_fill = egui::Color32::from_rgb(37, 99, 235); + premium_visuals.widgets.inactive.bg_fill = egui::Color32::from_rgb(31, 41, 55); + premium_visuals.widgets.active.rounding = egui::Rounding::same(10.0); + premium_visuals.widgets.hovered.rounding = egui::Rounding::same(10.0); + premium_visuals.widgets.inactive.rounding = egui::Rounding::same(10.0); + cc.egui_ctx.set_visuals(premium_visuals); + Ok(Box::new(App { shared, cmd_tx, @@ -154,6 +173,8 @@ struct UiState { /// One-line status of the most recent download (Ok(path) or Err(msg)). last_download: Option>, last_download_at: Option, + /// Stashed configuration used to start the current proxy session. + last_config: Option, } #[derive(Clone, Debug)] @@ -232,6 +253,7 @@ struct FormState { socks5_port: String, log_level: String, verify_ssl: bool, + auto_system_proxy: bool, upstream_socks5: String, parallel_relay: u8, show_auth_key: bool, @@ -252,6 +274,7 @@ struct FormState { normalize_x_graphql: bool, youtube_via_relay: bool, passthrough_hosts: Vec, + block_hosts: Vec, /// Round-tripped from config.json so the UI's save path doesn't /// drop the user's setting. Not currently exposed as a UI control; /// users edit `block_quic` directly in `config.json` (Issue #213). @@ -298,6 +321,9 @@ struct FormState { /// claude.ai / grok.com / x.com). Config-only — no UI editor yet. /// See `assets/exit_node/` for the generic exit-node handler. exit_node: mhrv_rs::config::ExitNodeConfig, + inbound_username: String, + inbound_password: String, + show_inbound_password: bool, } #[derive(Clone, Debug)] @@ -403,6 +429,9 @@ fn load_form() -> (FormState, Option) { auto_blacklist_cooldown_secs: c.auto_blacklist_cooldown_secs, request_timeout_secs: c.request_timeout_secs, exit_node: c.exit_node.clone(), + inbound_username: c.inbound_username, + inbound_password: c.inbound_password, + show_inbound_password: false, } } else { FormState { @@ -445,6 +474,9 @@ fn load_form() -> (FormState, Option) { auto_blacklist_cooldown_secs: 120, request_timeout_secs: 30, exit_node: mhrv_rs::config::ExitNodeConfig::default(), + inbound_username: String::new(), + inbound_password: String::new(), + show_inbound_password: false, } }; (form, load_err) @@ -548,6 +580,7 @@ impl FormState { socks5_port, log_level: self.log_level.trim().to_string(), verify_ssl: self.verify_ssl, + auto_system_proxy: self.auto_system_proxy, hosts: std::collections::HashMap::new(), enable_batching: false, upstream_socks5: { @@ -589,6 +622,7 @@ impl FormState { // Similarly config-only for now; round-trips through the // file so the UI doesn't drop the user's entries on save. passthrough_hosts: self.passthrough_hosts.clone(), + block_hosts: self.block_hosts.clone(), // Issue #213: block_quic is config-only for now (no UI // control yet). Round-trip through the file so save // doesn't drop a user-set true. @@ -633,7 +667,11 @@ impl FormState { // / grok.com / x.com). Round-trip through FormState — config-only // editing for now, UI editor planned for v1.9.x desktop UI batch. exit_node: self.exit_node.clone(), - }) + inbound_username: self.inbound_username.trim().to_string(), + inbound_password: self.inbound_password.trim().to_string(), + }; + cfg.validate().map_err(|e| e.to_string())?; + Ok(cfg) } } @@ -724,6 +762,10 @@ struct ConfigWire<'a> { /// Save preserves user-edited values. #[serde(skip_serializing_if = "is_default_exit_node")] exit_node: &'a mhrv_rs::config::ExitNodeConfig, + #[serde(skip_serializing_if = "is_empty_str")] + inbound_username: &'a str, + #[serde(skip_serializing_if = "is_empty_str")] + inbound_password: &'a str, } fn is_default_strikes(v: &u32) -> bool { *v == 3 } @@ -738,6 +780,10 @@ fn is_default_exit_node(en: &&mhrv_rs::config::ExitNodeConfig) -> bool { && (en.mode.is_empty() || en.mode == "selective") } +fn is_empty_str(s: &&str) -> bool { + s.is_empty() +} + fn is_false(b: &bool) -> bool { !*b } @@ -799,6 +845,8 @@ impl<'a> From<&'a Config> for ConfigWire<'a> { request_timeout_secs: c.request_timeout_secs, force_http1: c.force_http1, exit_node: &c.exit_node, + inbound_username: c.inbound_username.as_str(), + inbound_password: c.inbound_password.as_str(), } } } @@ -814,17 +862,17 @@ const ERR_RED: egui::Color32 = egui::Color32::from_rgb(220, 110, 110); fn section(ui: &mut egui::Ui, title: &str, body: impl FnOnce(&mut egui::Ui)) { ui.add_space(6.0); ui.label( - egui::RichText::new(title) - .size(12.0) - .color(egui::Color32::from_gray(180)) + egui::RichText::new(title.to_ascii_uppercase()) + .size(11.0) + .color(egui::Color32::from_rgb(59, 130, 246)) // Cobalt Section Typography Header .strong(), ); ui.add_space(2.0); let frame = egui::Frame::none() - .fill(egui::Color32::from_rgb(28, 30, 34)) + .fill(egui::Color32::from_rgb(26, 29, 36)) .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(50, 54, 60))) - .rounding(6.0) - .inner_margin(egui::Margin::same(10.0)); + .rounding(10.0) // Softened Layout Corner Context + .inner_margin(egui::Margin::same(12.0)); frame.show(ui, body); } @@ -899,14 +947,19 @@ impl eframe::App for App { ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let (fill, dot, label) = if running { + // Request immediate repaint on next loop pass to ensure uniform pulsing animation execution + ui.ctx().request_repaint(); + + let time = ui.ctx().input(|i| i.time); + let alpha = ((time * 3.0).sin() * 0.3 + 0.7) as f32; // Organic Status Shimmer loop ( - egui::Color32::from_rgb(30, 60, 40), - OK_GREEN, - "running", + egui::Color32::from_rgb(20, 35, 25), + egui::Color32::from_rgba_unmultiplied(80, 180, 100, (alpha * 255.0) as u8), + "connected", ) } else { ( - egui::Color32::from_rgb(60, 35, 35), + egui::Color32::from_rgb(45, 25, 25), ERR_RED, "stopped", ) @@ -924,7 +977,7 @@ impl eframe::App for App { ui.painter().circle_filled(rect.center(), 4.0, dot); ui.label( egui::RichText::new(label) - .color(dot) + .color(egui::Color32::from_rgb(80, 180, 100)) .monospace() .strong(), ); @@ -1196,6 +1249,142 @@ impl eframe::App for App { }); }); + // ── Section: Inbound Access Control ─────────────────────────── + section(ui, "Inbound Access Control", |ui| { + // Binding Status & Badges + ui.horizontal(|ui| { + ui.add_sized( + [120.0, 20.0], + egui::Label::new(egui::RichText::new("Binding Security").color(egui::Color32::from_gray(200))), + ); + + let listen_host_snapshot = self.form.listen_host.trim(); + let is_loopback = mhrv_rs::lan_utils::is_loopback_only(listen_host_snapshot) + || listen_host_snapshot.parse::().map(|ip| ip.is_loopback()).unwrap_or(false) + || (listen_host_snapshot.starts_with('[') && listen_host_snapshot.ends_with(']') + && listen_host_snapshot[1..listen_host_snapshot.len()-1].parse::().map(|ip| ip.is_loopback()).unwrap_or(false)); + + if is_loopback { + // Green Local Only badge + egui::Frame::none() + .fill(OK_GREEN) + .rounding(4.0) + .inner_margin(egui::Margin { + left: 6.0, + right: 6.0, + top: 2.0, + bottom: 2.0, + }) + .show(ui, |ui| { + ui.label(egui::RichText::new("Local Only").color(egui::Color32::BLACK).strong().size(10.0)); + }); + } else { + // Orange LAN Exposed badge + egui::Frame::none() + .fill(egui::Color32::from_rgb(230, 160, 50)) + .rounding(4.0) + .inner_margin(egui::Margin { + left: 6.0, + right: 6.0, + top: 2.0, + bottom: 2.0, + }) + .show(ui, |ui| { + ui.label(egui::RichText::new("LAN Exposed").color(egui::Color32::BLACK).strong().size(10.0)); + }); + } + }); + + // Display warning if LAN Exposed + let listen_host_snapshot = self.form.listen_host.trim(); + let is_loopback = mhrv_rs::lan_utils::is_loopback_only(listen_host_snapshot) + || listen_host_snapshot.parse::().map(|ip| ip.is_loopback()).unwrap_or(false) + || (listen_host_snapshot.starts_with('[') && listen_host_snapshot.ends_with(']') + && listen_host_snapshot[1..listen_host_snapshot.len()-1].parse::().map(|ip| ip.is_loopback()).unwrap_or(false)); + + if !is_loopback { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.add_space(120.0 + 8.0); + ui.vertical(|ui| { + ui.colored_label( + egui::Color32::from_rgb(230, 160, 50), + "⚠ WARNING: Binding to a non-loopback address exposes this proxy on your network. \ + Anyone on your LAN can connect, consume your Apps Script execution quota, and access local network resources. \ + Secure inbound credentials are required to start the server.", + ); + }); + }); + } else { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.add_space(120.0 + 8.0); + ui.vertical(|ui| { + ui.colored_label( + egui::Color32::from_gray(140), + "Proxy is bound to loopback. Secure from external network access.", + ); + }); + }); + } + + ui.add_space(6.0); + + // Username input + form_row(ui, "Inbound User", Some("Username required for client authentication when LAN sharing is enabled."), |ui, label_id| { + ui.add(egui::TextEdit::singleline(&mut self.form.inbound_username) + .hint_text("Optional on loopback; required on LAN") + .desired_width(f32::INFINITY)) + .labelled_by(label_id); + }); + + ui.add_space(4.0); + + // Password input + form_row(ui, "Inbound Pass", Some("Password required for client authentication when LAN sharing is enabled."), |ui, label_id| { + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut self.form.inbound_password) + .password(!self.form.show_inbound_password) + .desired_width(ui.available_width() - 80.0)) + .labelled_by(label_id); + + if ui.button(if self.form.show_inbound_password { "Hide" } else { "Show" }).clicked() { + self.form.show_inbound_password = !self.form.show_inbound_password; + } + }); + }); + + ui.add_space(6.0); + + // Random credentials generator button + ui.horizontal(|ui| { + ui.add_space(120.0 + 8.0); + let gen_btn = egui::Button::new( + egui::RichText::new("🎲 Generate Random Credentials") + .color(egui::Color32::WHITE), + ) + .fill(egui::Color32::from_rgb(50, 54, 60)) + .rounding(4.0); + + if ui.add(gen_btn).on_hover_text("Generate a strong secure username and password automatically.").clicked() { + let (uname, passwd) = { + use rand::Rng; + let mut rng = rand::thread_rng(); + let u: String = (0..8) + .map(|_| rng.sample(rand::distributions::Alphanumeric) as char) + .collect(); + let p: String = (0..16) + .map(|_| rng.sample(rand::distributions::Alphanumeric) as char) + .collect(); + (u.to_ascii_lowercase(), p) + }; + self.form.inbound_username = uname; + self.form.inbound_password = passwd; + self.toast = Some(("Generated secure credentials. Don't forget to save config!".into(), Instant::now())); + } + }); + }); + // ── Section: Advanced (collapsed by default) ────────────────── ui.add_space(6.0); egui::CollapsingHeader::new( @@ -1224,6 +1413,15 @@ impl eframe::App for App { .labelled_by(label_id); }); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.checkbox(&mut self.form.auto_system_proxy, "Auto-toggle system proxy (Windows)"); + if self.form.auto_system_proxy { + ui.label(egui::RichText::new("⚠ Automated WinINet Integration Active").color(egui::Color32::from_rgb(59, 130, 246)).size(10.0)); + } + }); + ui.add_space(4.0); + form_row(ui, "Parallel dispatch", Some( "Fire N Apps Script IDs in parallel per request and take the first \ response. 0/1 = off. 2-3 kills long-tail latency at N× quota cost. \ @@ -1342,8 +1540,13 @@ impl eframe::App for App { }; section(ui, &status_title, |ui| { if let Some(s) = &stats { - // Compact two-column layout so 7 metrics fit in ~4 rows + // Compact two-column layout so metrics fit in rows // instead of a tall vertical strip. + let large_upload_policy = match self.form.mode.as_str() { + "full" => "Full mode (Tunnel)", + "apps_script" => "Reject > 5MiB", + _ => "Unknown", + }; let rows: Vec<(&str, String)> = vec![ ("relay calls", s.relay_calls.to_string()), ("failures", s.relay_failures.to_string()), @@ -1367,6 +1570,9 @@ impl eframe::App for App { s.total_scripts ), ), + ("large upload policy", large_upload_policy.to_string()), + ("uploads routed", s.large_upload_full_route.to_string()), + ("uploads rejected", s.large_upload_rejected_413.to_string()), ]; egui::Grid::new("stats") .num_columns(4) @@ -1415,12 +1621,18 @@ impl eframe::App for App { if let Some(s) = &stats { ui.add_space(2.0); section(ui, "Usage today (estimated)", |ui| { - // Free-tier Apps Script UrlFetchApp quota. Workspace / - // paid accounts get 100k but most users are on free. const FREE_QUOTA_PER_DAY: u64 = 20_000; let pct = if FREE_QUOTA_PER_DAY > 0 { (s.today_calls as f64 / FREE_QUOTA_PER_DAY as f64) * 100.0 } else { 0.0 }; + + ui.add_space(4.0); + let progress_ratio = (s.today_calls as f32 / FREE_QUOTA_PER_DAY as f32).min(1.0); + ui.add(egui::ProgressBar::new(progress_ratio) + .text(format!("{:.1}% pool quota consumed", pct)) + .animate(running)); + ui.add_space(6.0); + let reset = s.today_reset_secs; let reset_str = format!( "{}h {}m", @@ -1762,6 +1974,9 @@ impl eframe::App for App { egui::RichText::new("CA appears trusted on this machine.") .color(OK_GREEN), ); + ui.collapsing("🛈 Local Trust Isolation Details", |ui| { + ui.small("Your intercept certificate is securely generated locally and mapped unique to this runtime build. It decodes TLS metadata elements entirely inside your machine's loopback memory spaces before proxying payload packets over remote channels."); + }); } Some(false) => { ui.small( @@ -2130,6 +2345,40 @@ fn fmt_bytes(b: u64) -> String { } } +#[cfg(target_os = "windows")] +fn sync_wininet_proxy(enabled: bool, port: u16) { + use winreg::enums::*; + use winreg::RegKey; + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + if let Ok(sub_key) = hkcu.open_subkey_with_flags( + r"Software\Microsoft\Windows\CurrentVersion\Internet Settings", + KEY_WRITE, + ) { + if enabled { + let proxy_str = format!("http=127.0.0.1:{};https=127.0.0.1:{}", port, port); + let _ = sub_key.set_value("ProxyEnable", &1u32); + let _ = sub_key.set_value("ProxyServer", &proxy_str); + } else { + let _ = sub_key.set_value("ProxyEnable", &0u32); + let _ = sub_key.set_value("ProxyServer", &""); + } + } + + // Broadcast system update instantly via native InternetSetOptionW Win32 calls + unsafe { + extern "system" { + fn InternetSetOptionW( + h: *mut std::ffi::c_void, + o: u32, + b: *mut std::ffi::c_void, + bl: u32, + ) -> i32; + } + InternetSetOptionW(std::ptr::null_mut(), 39, std::ptr::null_mut(), 0); // INTERNET_OPTION_SETTINGS_CHANGED + InternetSetOptionW(std::ptr::null_mut(), 37, std::ptr::null_mut(), 0); // INTERNET_OPTION_REFRESH + } +} + // ---------- Background thread: owns the tokio runtime + proxy lifecycle ---------- fn background_thread(shared: Arc, rx: Receiver) { @@ -2141,8 +2390,35 @@ fn background_thread(shared: Arc, rx: Receiver) { tokio::sync::oneshot::Sender<()>, )> = None; + let mut last_wininet_state: Option<(bool, u16)> = None; + loop { match rx.recv_timeout(Duration::from_millis(250)) { + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + // Periodic health check / state sync loop + #[cfg(target_os = "windows")] + { + let (running, auto_proxy, port) = { + let st = shared.state.lock().unwrap(); + (st.proxy_active, st.last_config.as_ref().map(|c| c.auto_system_proxy).unwrap_or(false), st.last_config.as_ref().map(|c| c.listen_port).unwrap_or(8085)) + }; + + let desired_state = if running && auto_proxy { + Some((true, port)) + } else if auto_proxy { + Some((false, port)) + } else { + None + }; + + if desired_state != last_wininet_state { + if let Some((enabled, p)) = desired_state { + sync_wininet_proxy(enabled, p); + } + last_wininet_state = desired_state; + } + } + } Ok(Cmd::PollStats) => { if let Some((_, fronter_slot, _)) = &active { let slot = fronter_slot.clone(); @@ -2168,7 +2444,11 @@ fn background_thread(shared: Arc, rx: Receiver) { // Flip proxy_active synchronously so a `Remove CA` click // queued in the same frame as Start is rejected before // the MITM manager begins loading. - shared.state.lock().unwrap().proxy_active = true; + { + let mut st = shared.state.lock().unwrap(); + st.proxy_active = true; + st.last_config = Some(cfg.clone()); + } let shared2 = shared.clone(); let fronter_slot: Arc>>> = Arc::new(AsyncMutex::new(None)); @@ -2260,6 +2540,7 @@ fn background_thread(shared: Arc, rx: Receiver) { st.running = false; st.started_at = None; st.proxy_active = false; + st.last_config = None; } } diff --git a/src/config.rs b/src/config.rs index 132b73b0..ecaebef3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -57,7 +57,7 @@ impl ScriptId { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Clone, Deserialize)] pub struct Config { pub mode: String, #[serde(default = "default_google_ip")] @@ -81,8 +81,14 @@ pub struct Config { #[serde(default = "default_verify_ssl")] pub verify_ssl: bool, #[serde(default)] + pub auto_system_proxy: bool, + #[serde(default)] pub hosts: HashMap, #[serde(default)] + pub inbound_username: String, + #[serde(default)] + pub inbound_password: String, + #[serde(default)] pub enable_batching: bool, /// Optional upstream SOCKS5 proxy for non-HTTP / raw-TCP traffic /// (e.g. `"127.0.0.1:50529"` pointing at a local xray / v2ray instance). @@ -178,6 +184,11 @@ pub struct Config { #[serde(default)] pub passthrough_hosts: Vec, + /// Dynamic local block list. Hosts matching any entry are intercepted + /// and short-circuited immediately at the proxy edge boundary. + #[serde(default)] + pub block_hosts: Vec, + /// Block outbound QUIC (UDP/443) at the SOCKS5 listener. /// /// QUIC is HTTP/3-over-UDP. In `apps_script` mode it's hopeless — @@ -398,6 +409,55 @@ pub struct Config { pub exit_node: ExitNodeConfig, } +impl std::fmt::Debug for Config { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Config") + .field("mode", &self.mode) + .field("google_ip", &self.google_ip) + .field("front_domain", &self.front_domain) + .field("script_id", &self.script_id) + .field("script_ids", &self.script_ids) + .field("auth_key", &self.auth_key) + .field("listen_host", &self.listen_host) + .field("listen_port", &self.listen_port) + .field("socks5_port", &self.socks5_port) + .field("log_level", &self.log_level) + .field("verify_ssl", &self.verify_ssl) + .field("auto_system_proxy", &self.auto_system_proxy) + .field("hosts", &self.hosts) + .field("enable_batching", &self.enable_batching) + .field("upstream_socks5", &self.upstream_socks5) + .field("parallel_relay", &self.parallel_relay) + .field("coalesce_step_ms", &self.coalesce_step_ms) + .field("coalesce_max_ms", &self.coalesce_max_ms) + .field("sni_hosts", &self.sni_hosts) + .field("fetch_ips_from_api", &self.fetch_ips_from_api) + .field("max_ips_to_scan", &self.max_ips_to_scan) + .field("scan_batch_size", &self.scan_batch_size) + .field("google_ip_validation", &self.google_ip_validation) + .field("normalize_x_graphql", &self.normalize_x_graphql) + .field("youtube_via_relay", &self.youtube_via_relay) + .field("passthrough_hosts", &self.passthrough_hosts) + .field("block_hosts", &self.block_hosts) + .field("block_stun", &self.block_stun) + .field("block_quic", &self.block_quic) + .field("disable_padding", &self.disable_padding) + .field("force_http1", &self.force_http1) + .field("tunnel_doh", &self.tunnel_doh) + .field("bypass_doh_hosts", &self.bypass_doh_hosts) + .field("block_doh", &self.block_doh) + .field("fronting_groups", &self.fronting_groups) + .field("auto_blacklist_strikes", &self.auto_blacklist_strikes) + .field("auto_blacklist_window_secs", &self.auto_blacklist_window_secs) + .field("auto_blacklist_cooldown_secs", &self.auto_blacklist_cooldown_secs) + .field("request_timeout_secs", &self.request_timeout_secs) + .field("exit_node", &self.exit_node) + .field("inbound_username", &self.inbound_username) + .field("inbound_password", &if self.inbound_password.is_empty() { "" } else { "[REDACTED]" }) + .finish() + } +} + /// Configuration for the optional second-hop exit node. #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct ExitNodeConfig { @@ -504,7 +564,7 @@ fn default_tunnel_doh() -> bool { true } /// Default for `block_quic`: `true`. QUIC over the TCP-based tunnel /// causes TCP-over-TCP meltdown (<1 Mbps). Browsers fall back to /// HTTPS/TCP within seconds of the silent UDP drop. Issue #793. -fn default_block_stun() -> bool { false } +fn default_block_stun() -> bool { true } fn default_block_quic() -> bool { true } /// Default for `block_doh`: `true` (browser DoH is rejected so the @@ -536,7 +596,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 @@ -557,7 +617,24 @@ impl Config { Ok(cfg) } - fn validate(&self) -> Result<(), ConfigError> { + pub fn validate(&self) -> Result<(), ConfigError> { + // Safety guard: non-loopback bind requires active inbound credentials + let is_loopback = crate::lan_utils::is_loopback_only(&self.listen_host) + || self.listen_host.trim().parse::().map(|ip| ip.is_loopback()).unwrap_or(false) + || (self.listen_host.trim().starts_with('[') && self.listen_host.trim().ends_with(']') + && self.listen_host.trim()[1..self.listen_host.trim().len()-1].parse::().map(|ip| ip.is_loopback()).unwrap_or(false)); + + if !is_loopback { + if self.inbound_username.trim().is_empty() || self.inbound_password.trim().is_empty() { + return Err(ConfigError::Invalid( + "Non-loopback bind exposes the proxy to the local network (LAN) or public internet. \ + For security, this setup is blocked unless you configure 'inbound_username' and 'inbound_password' \ + in your settings to prevent unauthorized usage and quota theft. Alternatively, bind to loopback (127.0.0.1)." + .into(), + )); + } + } + let mode = self.mode_kind()?; if mode == Mode::AppsScript || mode == Mode::Full { if self.auth_key.trim().is_empty() || self.auth_key == "CHANGE_ME_TO_A_STRONG_SECRET" { @@ -794,6 +871,51 @@ mod tests { assert!(cfg.validate().is_err()); } + #[test] + fn test_non_loopback_bind_requires_credentials() { + // 1. Loopback bind works fine without credentials + let s1 = r#"{ + "mode": "direct", + "listen_host": "127.0.0.1" + }"#; + let cfg1: Config = serde_json::from_str(s1).unwrap(); + cfg1.validate().expect("loopback 127.0.0.1 should validate without inbound credentials"); + + // IPv6 loopback + let s2 = r#"{ + "mode": "direct", + "listen_host": "::1" + }"#; + let cfg2: Config = serde_json::from_str(s2).unwrap(); + cfg2.validate().expect("loopback ::1 should validate without inbound credentials"); + + let s2_bracket = r#"{ + "mode": "direct", + "listen_host": "[::1]" + }"#; + let cfg2_b: Config = serde_json::from_str(s2_bracket).unwrap(); + cfg2_b.validate().expect("loopback [::1] should validate without inbound credentials"); + + // 2. Non-loopback wildcard 0.0.0.0 fails validation without credentials + let s3 = r#"{ + "mode": "direct", + "listen_host": "0.0.0.0" + }"#; + let cfg3: Config = serde_json::from_str(s3).unwrap(); + assert!(cfg3.validate().is_err(), "wildcard 0.0.0.0 bind should fail without inbound credentials"); + + // 3. Non-loopback wildcard 0.0.0.0 succeeds validation with credentials + let s4 = r#"{ + "mode": "direct", + "listen_host": "0.0.0.0", + "inbound_username": "admin", + "inbound_password": "password123" + }"#; + let cfg4: Config = serde_json::from_str(s4).unwrap(); + cfg4.validate().expect("wildcard 0.0.0.0 bind should succeed with inbound credentials"); + } + + #[test] fn fronting_groups_parse_and_validate() { let s = r#"{ diff --git a/src/domain_fronter.rs b/src/domain_fronter.rs index 3fcfee5f..20e4cdcd 100644 --- a/src/domain_fronter.rs +++ b/src/domain_fronter.rs @@ -439,6 +439,13 @@ pub struct DomainFronter { /// Pre-normalized (lowercased, leading-dot stripped) host list for /// fast O(N) match in `exit_node_matches`. exit_node_hosts: Vec, + /// Thread-safe dynamic sliding queue tracking transaction history timestamps per deployment node. + script_ledger: Arc>>>, + /// User-configured block list. Any host matching an entry in this list + /// is rejected immediately at the relay entrypoint. + block_hosts: Vec, + pub large_upload_full_route: AtomicU64, + pub large_upload_rejected_413: AtomicU64, } /// Aggregated stats for one remote host. @@ -662,6 +669,10 @@ impl DomainFronter { .map(|h| h.trim().trim_start_matches('.').to_ascii_lowercase()) .filter(|h| !h.is_empty()) .collect(), + script_ledger: Arc::new(std::sync::Mutex::new(HashMap::new())), + block_hosts: config.block_hosts.clone(), + large_upload_full_route: AtomicU64::new(0), + large_upload_rejected_413: AtomicU64::new(0), }) } @@ -713,6 +724,9 @@ impl DomainFronter { *guard = today; self.today_calls.store(0, Ordering::Relaxed); self.today_bytes.store(0, Ordering::Relaxed); + if let Ok(mut ledger) = self.script_ledger.lock() { + ledger.clear(); + } } drop(guard); self.today_calls.fetch_add(1, Ordering::Relaxed); @@ -778,6 +792,8 @@ impl DomainFronter { h2_calls: self.h2_calls.load(Ordering::Relaxed), h2_fallbacks: self.h2_fallbacks.load(Ordering::Relaxed), h2_disabled: self.h2_disabled.load(Ordering::Relaxed), + large_upload_full_route: self.large_upload_full_route.load(Ordering::Relaxed), + large_upload_rejected_413: self.large_upload_rejected_413.load(Ordering::Relaxed), } } @@ -798,25 +814,59 @@ impl DomainFronter { } pub fn next_script_id(&self) -> String { - let n = self.script_ids.len(); let mut bl = self.blacklist.lock().unwrap(); - let now = Instant::now(); - bl.retain(|_, until| *until > now); - - for _ in 0..n { - let idx = self.script_idx.fetch_add(1, Ordering::Relaxed); - let sid = &self.script_ids[idx % n]; - if !bl.contains_key(sid) { - return sid.clone(); + let now_instant = std::time::Instant::now(); + bl.retain(|_, until| *until > now_instant); + + let mut chosen_sid = None; + let mut min_calls = usize::MAX; + let sliding_window = std::time::Duration::from_secs(86400); // 24-Hour Rolling Horizon Window + + if let Ok(mut ledger) = self.script_ledger.lock() { + for sid in &self.script_ids { + if !bl.contains_key(sid) { + // Evict expired historical entry counters relative to the current rolling window frame + let entry = ledger.entry(sid.clone()).or_insert_with(Vec::new); + entry.retain(|timestamp| now_instant.duration_since(*timestamp) < sliding_window); + + let active_calls = entry.len(); + if active_calls < min_calls { + min_calls = active_calls; + chosen_sid = Some(sid.clone()); + } + } + } + + if let Some(ref sid) = chosen_sid { + if let Some(entry) = ledger.get_mut(sid) { + entry.push(now_instant); + } } } + + if let Some(sid) = chosen_sid { + return sid; + } + // All blacklisted: pick whichever comes off cooldown soonest. if let Some((sid, _)) = bl.iter().min_by_key(|(_, t)| **t) { let sid = sid.clone(); bl.remove(&sid); + + if let Ok(mut ledger) = self.script_ledger.lock() { + let entry = ledger.entry(sid.clone()).or_insert_with(Vec::new); + entry.push(now_instant); + } + return sid; } - self.script_ids[0].clone() + + let sid = self.script_ids[0].clone(); + if let Ok(mut ledger) = self.script_ledger.lock() { + let entry = ledger.entry(sid.clone()).or_insert_with(Vec::new); + entry.push(now_instant); + } + sid } /// Pick `want` distinct non-blacklisted script IDs for a parallel fan-out @@ -1747,6 +1797,29 @@ impl DomainFronter { url: &str, headers: &[(String, String)], body: &[u8], + ) -> Vec { + // Dynamic Quota Conservation Check via Relay Gate + if let Some(host) = extract_host(url) { + let host_lower = host.to_ascii_lowercase(); + // Validated cleanly with zero inner closure string allocation overhead + if self.block_hosts.iter().any(|h| { + let h_lower = h.to_ascii_lowercase(); + host_lower == h_lower || host_lower.ends_with(&format!(".{}", h_lower)) + }) { + tracing::info!("Quota Conservation: Short-circuited tracking endpoint: {}", host); + return b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec(); + } + } + + self.relay_processed(method, url, headers, body).await + } + + pub async fn relay_processed( + &self, + method: &str, + url: &str, + headers: &[(String, String)], + body: &[u8], ) -> Vec { // Optional URL rewrite for X/Twitter GraphQL (issue #16). Applied // here, at the top of relay(), so it affects BOTH the cache key @@ -2503,9 +2576,17 @@ impl DomainFronter { .chars() .take(200) .collect::(); - if should_blacklist(status, &body_txt) { + + if status == 429 { + // Critical Quota Overflow: Trigger aggressive cooling to allow Google side token bucket refill + self.blacklist_script_for(&script_id, Duration::from_secs(3600), "Critical Quota Overflow (429)"); + } else if status == 401 || status == 403 { + // Auth Failure: Deployment likely deleted or PSK changed. Mark for long-term quarantine. + self.blacklist_script_for(&script_id, Duration::from_secs(14400), "Auth/Deployment Error (401/403)"); + } else if should_blacklist(status, &body_txt) { self.blacklist_script(&script_id, &format!("HTTP {}", status)); } + return Err(FronterError::Relay(format!( "Apps Script HTTP {}: {}", status, body_txt @@ -2514,7 +2595,8 @@ impl DomainFronter { return parse_relay_json(&resp_body).map_err(|e| { if let FronterError::Relay(ref msg) = e { if looks_like_quota_error(msg) { - self.blacklist_script(&script_id, msg); + // User-perceived quota overflow in JSON body: medium cooldown + self.blacklist_script_for(&script_id, Duration::from_secs(1800), msg); } } e @@ -2612,9 +2694,15 @@ impl DomainFronter { .chars() .take(200) .collect::(); - if should_blacklist(status, &body_txt) { + + if status == 429 { + self.blacklist_script_for(&script_id, Duration::from_secs(3600), "Critical Quota Overflow (429)"); + } else if status == 401 || status == 403 { + self.blacklist_script_for(&script_id, Duration::from_secs(14400), "Auth/Deployment Error (401/403)"); + } else if should_blacklist(status, &body_txt) { self.blacklist_script(&script_id, &format!("HTTP {}", status)); } + return Err(FronterError::Relay(format!( "Apps Script HTTP {}: {}", status, body_txt @@ -3022,7 +3110,12 @@ impl DomainFronter { .chars() .take(200) .collect::(); - if should_blacklist(status, &body_txt) { + + if status == 429 { + self.blacklist_script_for(script_id, Duration::from_secs(3600), "Critical Quota Overflow (429)"); + } else if status == 401 || status == 403 { + self.blacklist_script_for(script_id, Duration::from_secs(14400), "Auth/Deployment Error (401/403)"); + } else if should_blacklist(status, &body_txt) { self.blacklist_script(script_id, &format!("HTTP {}", status)); } return Err(FronterError::Relay(format!( @@ -3212,7 +3305,12 @@ impl DomainFronter { .chars() .take(200) .collect::(); - if should_blacklist(status, &body_txt) { + + if status == 429 { + self.blacklist_script_for(script_id, Duration::from_secs(3600), "Critical Quota Overflow (429)"); + } else if status == 401 || status == 403 { + self.blacklist_script_for(script_id, Duration::from_secs(14400), "Auth/Deployment Error (401/403)"); + } else if should_blacklist(status, &body_txt) { self.blacklist_script(script_id, &format!("HTTP {}", status)); } return Err(FronterError::Relay(format!( @@ -4831,6 +4929,8 @@ pub struct StatsSnapshot { /// switch set, or peer refused h2 during ALPN). All traffic on the /// h1 path. pub h2_disabled: bool, + pub large_upload_full_route: u64, + pub large_upload_rejected_413: u64, } impl StatsSnapshot { @@ -4888,7 +4988,7 @@ impl StatsSnapshot { s.replace('\\', "\\\\").replace('"', "\\\"") } format!( - r#"{{"relay_calls":{},"relay_failures":{},"coalesced":{},"bytes_relayed":{},"cache_hits":{},"cache_misses":{},"cache_bytes":{},"blacklisted_scripts":{},"total_scripts":{},"today_calls":{},"today_bytes":{},"today_key":"{}","today_reset_secs":{},"h2_calls":{},"h2_fallbacks":{},"h2_disabled":{}}}"#, + r#"{{"relay_calls":{},"relay_failures":{},"coalesced":{},"bytes_relayed":{},"cache_hits":{},"cache_misses":{},"cache_bytes":{},"blacklisted_scripts":{},"total_scripts":{},"today_calls":{},"today_bytes":{},"today_key":"{}","today_reset_secs":{},"h2_calls":{},"h2_fallbacks":{},"h2_disabled":{},"large_upload_full_route":{},"large_upload_rejected_413":{}}}"#, self.relay_calls, self.relay_failures, self.coalesced, @@ -4905,6 +5005,8 @@ impl StatsSnapshot { self.h2_calls, self.h2_fallbacks, self.h2_disabled, + self.large_upload_full_route, + self.large_upload_rejected_413, ) } } @@ -7278,4 +7380,21 @@ hello"; } server.await.unwrap(); } + + #[tokio::test] + async fn test_large_upload_policy_no_unsafe_headers() { + let config_json = r#"{"mode":"apps_script","script_ids":["fake_id"],"auth_key":"fake_key"}"#; + let config: Config = serde_json::from_str(config_json).unwrap(); + let fronter = DomainFronter::new(&config).unwrap(); + + // Ensure counters are zero initialized + let stats = fronter.snapshot_stats(); + assert_eq!(stats.large_upload_full_route, 0); + assert_eq!(stats.large_upload_rejected_413, 0); + + // Assert serialization includes our fields + let json_str = stats.to_json(); + assert!(json_str.contains("\"large_upload_full_route\":0")); + assert!(json_str.contains("\"large_upload_rejected_413\":0")); + } } diff --git a/src/main.rs b/src/main.rs index 202c7ec5..9c06bad8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,34 @@ use mhrv_rs::{scan_ips, scan_sni, test_cmd}; const VERSION: &str = env!("CARGO_PKG_VERSION"); +#[cfg(target_os = "windows")] +fn flush_windows_system_proxy() { + use winreg::enums::*; + use winreg::RegKey; + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + if let Ok(sub_key) = hkcu.open_subkey_with_flags( + r"Software\Microsoft\Windows\CurrentVersion\Internet Settings", + KEY_WRITE, + ) { + let _ = sub_key.set_value("ProxyEnable", &0u32); + let _ = sub_key.set_value("ProxyServer", &""); + } + + // Broadcast system update to ensure settings apply instantly + unsafe { + extern "system" { + fn InternetSetOptionW( + h: *mut std::ffi::c_void, + o: u32, + b: *mut std::ffi::c_void, + bl: u32, + ) -> i32; + } + InternetSetOptionW(std::ptr::null_mut(), 39, std::ptr::null_mut(), 0); // INTERNET_OPTION_SETTINGS_CHANGED + InternetSetOptionW(std::ptr::null_mut(), 37, std::ptr::null_mut(), 0); // INTERNET_OPTION_REFRESH + } +} + struct Args { config_path: Option, install_cert: bool, @@ -147,6 +175,18 @@ async fn main() -> ExitCode { // invocations. reconcile_sudo_environment(); + #[cfg(target_os = "windows")] + { + // Register Root Panic Watchdog Recovery Hook + std::panic::set_hook(Box::new(|panic_info| { + eprintln!("Critical Exception Caught: {}", panic_info); + flush_windows_system_proxy(); + })); + + // Boot-up Initialization Proxy State Flush + flush_windows_system_proxy(); + } + let args = match parse_args() { Ok(a) => a, Err(e) => { diff --git a/src/proxy_server.rs b/src/proxy_server.rs index 209bbc58..500a4822 100644 --- a/src/proxy_server.rs +++ b/src/proxy_server.rs @@ -19,7 +19,9 @@ use tokio_rustls::{LazyConfigAcceptor, TlsAcceptor, TlsConnector}; use crate::config::{Config, FrontingGroup, Mode}; use crate::domain_fronter::DomainFronter; use crate::mitm::MitmCertManager; -use crate::tunnel_client::{decode_udp_packets, TunnelMux}; +use crate::tunnel_client::{decode_udp_packets, TunnelMux, tunnel_connection_with_prefix}; + +pub const APPS_SCRIPT_UPLOAD_MAX_BYTES: usize = 5 * 1024 * 1024; // Domains that are served from Google's core frontend IP pool and therefore // respond correctly when we connect to `google_ip` with SNI=`front_domain` @@ -237,6 +239,7 @@ pub struct RewriteCtx { /// and pass through as plain TCP (optionally via upstream_socks5). /// See config.rs `passthrough_hosts` for matching rules. Issues #39, #127. pub passthrough_hosts: Vec, + pub block_hosts: Vec, /// If true, drop SOCKS5 UDP datagrams destined for port 443 so /// callers fall back to TCP/HTTPS. See config.rs `block_quic` for /// the trade-off. Issue #213. @@ -262,6 +265,8 @@ 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_username: String, + pub inbound_password: String, } /// True if `host` matches a known DoH endpoint — either the built-in @@ -507,12 +512,15 @@ impl ProxyServer { mode, youtube_via_relay: config.youtube_via_relay, passthrough_hosts: config.passthrough_hosts.clone(), + block_hosts: config.block_hosts.clone(), block_quic: config.block_quic, block_stun: config.block_stun, bypass_doh: !config.tunnel_doh, block_doh: config.block_doh, bypass_doh_hosts: config.bypass_doh_hosts.clone(), fronting_groups, + inbound_username: config.inbound_username.clone(), + inbound_password: config.inbound_password.clone(), }); let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1); @@ -810,9 +818,43 @@ 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 !rewrite_ctx.inbound_username.is_empty() && !rewrite_ctx.inbound_password.is_empty() { + use base64::engine::general_purpose::STANDARD as B64; + use base64::Engine; + + let mut authenticated = false; + if let Some((_, auth_value)) = headers.iter().find(|(k, _)| k.eq_ignore_ascii_case("proxy-authorization")) { + let val_trimmed = auth_value.trim(); + if val_trimmed.len() > 6 && val_trimmed[..6].eq_ignore_ascii_case("basic ") { + let encoded = &val_trimmed[6..]; + if let Ok(decoded_bytes) = B64.decode(encoded.trim()) { + if let Ok(decoded_str) = String::from_utf8(decoded_bytes) { + if let Some((uname, passwd)) = decoded_str.split_once(':') { + if uname == rewrite_ctx.inbound_username && passwd == rewrite_ctx.inbound_password { + authenticated = true; + } + } + } + } + } + } + + if !authenticated { + tracing::warn!("HTTP inbound proxy authentication failed/missing for target {}", target); + sock.write_all( + b"HTTP/1.1 407 Proxy Authentication Required\r\n\ + Proxy-Authenticate: Basic realm=\"mhrv-rs\"\r\n\ + Connection: close\r\n\ + Content-Length: 0\r\n\r\n" + ).await?; + 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 @@ -848,7 +890,7 @@ async fn handle_http_client( // `http://example.com` URL used to return a 502 here even // though `https://example.com` (CONNECT) worked fine. match fronter { - Some(f) => do_plain_http(sock, &head, &leftover, f).await, + Some(f) => do_plain_http(sock, &head, &leftover, f, rewrite_ctx.clone(), tunnel_mux.clone()).await, None => do_plain_http_passthrough(sock, &head, &leftover, &rewrite_ctx).await, } } @@ -872,12 +914,52 @@ 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) { - sock.write_all(&[0x05, 0xff]).await?; - return Ok(()); + + let has_auth = !rewrite_ctx.inbound_username.is_empty() && !rewrite_ctx.inbound_password.is_empty(); + if has_auth { + if !methods.contains(&0x02) { + sock.write_all(&[0x05, 0xff]).await?; + return Ok(()); + } + sock.write_all(&[0x05, 0x02]).await?; + + // Perform RFC 1929 subnegotiation: + // Read subnegotiation VER and ULEN + let mut sub_hdr = [0u8; 2]; + sock.read_exact(&mut sub_hdr).await?; + let sub_ver = sub_hdr[0]; + if sub_ver != 0x01 { + sock.write_all(&[0x01, 0x01]).await?; + return Ok(()); + } + let ulen = sub_hdr[1] as usize; + let mut uname_bytes = vec![0u8; ulen]; + sock.read_exact(&mut uname_bytes).await?; + + let mut plen_byte = [0u8; 1]; + sock.read_exact(&mut plen_byte).await?; + let plen = plen_byte[0] as usize; + let mut passwd_bytes = vec![0u8; plen]; + sock.read_exact(&mut passwd_bytes).await?; + + let client_username = String::from_utf8_lossy(&uname_bytes); + let client_password = String::from_utf8_lossy(&passwd_bytes); + + if client_username == rewrite_ctx.inbound_username && client_password == rewrite_ctx.inbound_password { + // Success + sock.write_all(&[0x01, 0x00]).await?; + } else { + // Failure + sock.write_all(&[0x01, 0x01]).await?; + return Ok(()); + } + } else { + if !methods.contains(&0x00) { + sock.write_all(&[0x05, 0xff]).await?; + return Ok(()); + } + sock.write_all(&[0x05, 0x00]).await?; } - sock.write_all(&[0x05, 0x00]).await?; // Request: VER=5, CMD, RSV=0, ATYP, DST.ADDR, DST.PORT let mut req = [0u8; 4]; @@ -1627,6 +1709,13 @@ async fn dispatch_tunnel( rewrite_ctx: Arc, tunnel_mux: Option>, ) -> std::io::Result<()> { + // 0. Early Quota Conservation Gate: Short-circuit blacklisted hosts before remote socket allocation + if matches_passthrough(&host, &rewrite_ctx.block_hosts) { + tracing::info!("Quota Conservation: Intercepted and terminated connection to blocked host: {}:{}", host, port); + drop(sock); + return Ok(()); + } + // 0. User-configured passthrough list wins over every other path. // If the host matches `passthrough_hosts`, we raw-TCP it (through // upstream_socks5 if set) and never touch Apps Script, SNI-rewrite, @@ -2420,6 +2509,43 @@ where None => return Ok(false), }; + let is_mutating = method.eq_ignore_ascii_case("POST") + || method.eq_ignore_ascii_case("PUT") + || method.eq_ignore_ascii_case("PATCH"); + + if is_mutating { + let mut is_chunked = false; + let mut content_length = None; + for (k, v) in headers.iter() { + if k.eq_ignore_ascii_case("transfer-encoding") && v.eq_ignore_ascii_case("chunked") { + is_chunked = true; + } + if k.eq_ignore_ascii_case("content-length") { + if let Ok(len) = v.parse::() { + content_length = Some(len); + } + } + } + if is_chunked || content_length.map_or(true, |len| len > APPS_SCRIPT_UPLOAD_MAX_BYTES) { + tracing::warn!( + "Mutating large/chunked upload in AppsScript MITM mode. Rejecting locally with 413. (is_chunked={}, content_length={:?})", + is_chunked, + content_length + ); + fronter.large_upload_rejected_413.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let _ = stream + .write_all( + b"HTTP/1.1 413 Payload Too Large\r\n\ + Connection: close\r\n\ + Content-Length: 47\r\n\r\n\ + Payload Too Large: Upload limit is 5 MiB.\n", + ) + .await; + let _ = stream.flush().await; + return Ok(false); + } + } + let body = read_body(stream, &leftover, &headers).await?; // ── Per-host URL fix-ups ────────────────────────────────────────── @@ -2870,10 +2996,78 @@ async fn do_plain_http( head: &[u8], leftover: &[u8], fronter: Arc, + rewrite_ctx: Arc, + tunnel_mux: Option>, ) -> std::io::Result<()> { let (method, target, _version, headers) = parse_request_head(head) .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "bad request"))?; + let is_mutating = method.eq_ignore_ascii_case("POST") + || method.eq_ignore_ascii_case("PUT") + || method.eq_ignore_ascii_case("PATCH"); + + if is_mutating { + let mut is_chunked = false; + let mut content_length = None; + for (k, v) in headers.iter() { + if k.eq_ignore_ascii_case("transfer-encoding") && v.eq_ignore_ascii_case("chunked") { + is_chunked = true; + } + if k.eq_ignore_ascii_case("content-length") { + if let Ok(len) = v.parse::() { + content_length = Some(len); + } + } + } + if is_chunked || content_length.map_or(true, |len| len > APPS_SCRIPT_UPLOAD_MAX_BYTES) { + if rewrite_ctx.mode == Mode::Full { + if let Some(ref mux) = tunnel_mux { + let host_hdr = headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("host")) + .map(|(_, v)| v.clone()) + .unwrap_or_default(); + let (target_host, target_port) = parse_host_port(&host_hdr); + let target_port = if host_hdr.contains(':') { target_port } else { 80 }; + + tracing::info!( + "Mutating large/chunked upload on plain HTTP in Full mode. Routing via Tunnel. (Host: {}:{})", + target_host, + target_port + ); + + fronter.large_upload_full_route.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + + let mut prefix_vec = head.to_vec(); + prefix_vec.extend_from_slice(leftover); + let prefix_bytes = Bytes::from(prefix_vec); + + if let Err(e) = tunnel_connection_with_prefix(sock, &target_host, target_port, mux, prefix_bytes).await { + tracing::error!("Failed to route plain-HTTP large upload through tunnel: {}", e); + } + return Ok(()); + } + } + + tracing::warn!( + "Mutating large/chunked upload on plain HTTP. Rejecting locally with 413. (is_chunked={}, content_length={:?})", + is_chunked, + content_length + ); + fronter.large_upload_rejected_413.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let _ = sock + .write_all( + b"HTTP/1.1 413 Payload Too Large\r\n\ + Connection: close\r\n\ + Content-Length: 47\r\n\r\n\ + Payload Too Large: Upload limit is 5 MiB.\n", + ) + .await; + let _ = sock.flush().await; + return Ok(()); + } + } + let body = read_body(&mut sock, leftover, &headers).await?; // Browser sends `GET http://example.com/path HTTP/1.1` on plain proxy. @@ -3648,4 +3842,222 @@ mod tests { }; assert!(FrontingGroupResolved::from_config(&bad).is_err()); } + + #[tokio::test] + async fn test_handle_mitm_request_rejects_large_mutating_requests() { + let (mut client, mut server) = duplex(1024); + + let config_json = r#"{"mode":"apps_script","script_ids":["fake_id"],"auth_key":"fake_key"}"#; + let config: Config = serde_json::from_str(config_json).unwrap(); + let fronter = std::sync::Arc::new(DomainFronter::new(&config).unwrap()); + + // Write a mutating HTTP POST request that exceeds the 5 MiB ceiling + // Note: Content-Length: 6000000 (approx 5.7 MiB) + let request_bytes = b"POST /upload HTTP/1.1\r\n\ + Host: example.com\r\n\ + Content-Length: 6000000\r\n\ + Connection: keep-alive\r\n\r\n"; + client.write_all(request_bytes).await.unwrap(); + + let fronter_clone = fronter.clone(); + let handle_task = tokio::spawn(async move { + let res = handle_mitm_request(&mut server, "example.com", 443, &fronter_clone, "https").await; + res + }); + + // Read the response from the server on the client side + let mut response_buf = vec![0u8; 1024]; + let n = client.read(&mut response_buf).await.unwrap(); + let response_str = String::from_utf8_lossy(&response_buf[..n]); + + // It should return 413 Payload Too Large locally + assert!(response_str.contains("HTTP/1.1 413 Payload Too Large")); + assert!(response_str.contains("Upload limit is 5 MiB")); + + let handle_res = handle_task.await.unwrap(); + assert_eq!(handle_res.unwrap(), false); // Connection should be terminated + + // Verify rejected counter was incremented + assert_eq!(fronter.snapshot_stats().large_upload_rejected_413, 1); + } + + fn tempdir() -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + let n: u64 = rand::random(); + p.push(format!("mhrv-test-ps-{:x}", n)); + std::fs::create_dir_all(&p).unwrap(); + p + } + + #[tokio::test] + async fn test_handle_http_client_auth() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let tmp = tempdir(); + let mitm = Arc::new(tokio::sync::Mutex::new(MitmCertManager::new_in(&tmp).unwrap())); + + let mut config: Config = serde_json::from_str(r#"{"mode":"direct"}"#).unwrap(); + config.inbound_username = "user".to_string(); + config.inbound_password = "pass".to_string(); + + let proxy_server = ProxyServer::new(&config, mitm.clone()).unwrap(); + let rewrite_ctx = proxy_server.rewrite_ctx.clone(); + + let rewrite_ctx_clone = rewrite_ctx.clone(); + let mitm_clone = mitm.clone(); + tokio::spawn(async move { + if let Ok((sock, _)) = listener.accept().await { + let _ = handle_http_client(sock, None, mitm_clone, rewrite_ctx_clone, None).await; + } + }); + + // 1. Client connects and sends request with no auth + let mut client = tokio::net::TcpStream::connect(addr).await.unwrap(); + client.write_all(b"GET / HTTP/1.1\r\nHost: google.com\r\n\r\n").await.unwrap(); + let mut resp = vec![0u8; 1024]; + let n = client.read(&mut resp).await.unwrap(); + let resp_str = String::from_utf8_lossy(&resp[..n]); + assert!(resp_str.contains("HTTP/1.1 407 Proxy Authentication Required")); + + // 2. Client connects and sends request with wrong auth + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let rewrite_ctx_clone = rewrite_ctx.clone(); + let mitm_clone = mitm.clone(); + tokio::spawn(async move { + if let Ok((sock, _)) = listener.accept().await { + let _ = handle_http_client(sock, None, mitm_clone, rewrite_ctx_clone, None).await; + } + }); + + let mut client = tokio::net::TcpStream::connect(addr).await.unwrap(); + // Send wrong auth ("user:wrong" -> "dXNlcjp3cm9uZw==") + client.write_all(b"GET / HTTP/1.1\r\nHost: google.com\r\nProxy-Authorization: Basic dXNlcjp3cm9uZw==\r\n\r\n").await.unwrap(); + let mut resp = vec![0u8; 1024]; + let n = client.read(&mut resp).await.unwrap(); + let resp_str = String::from_utf8_lossy(&resp[..n]); + assert!(resp_str.contains("HTTP/1.1 407 Proxy Authentication Required")); + + // 3. Client connects and sends request with correct auth + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let rewrite_ctx_clone = rewrite_ctx.clone(); + let mitm_clone = mitm.clone(); + tokio::spawn(async move { + if let Ok((sock, _)) = listener.accept().await { + let _ = handle_http_client(sock, None, mitm_clone, rewrite_ctx_clone, None).await; + } + }); + + let mut client = tokio::net::TcpStream::connect(addr).await.unwrap(); + // Send correct auth ("user:pass" -> "dXNlcjpwYXNz") + client.write_all(b"GET / HTTP/1.1\r\nHost: google.com\r\nProxy-Authorization: Basic dXNlcjpwYXNz\r\n\r\n").await.unwrap(); + let mut resp = vec![0u8; 1024]; + let n = client.read(&mut resp).await.unwrap(); + let resp_str = String::from_utf8_lossy(&resp[..n]); + assert!(!resp_str.contains("407 Proxy Authentication Required")); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[tokio::test] + async fn test_handle_socks5_client_auth() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let tmp = tempdir(); + let mitm = Arc::new(tokio::sync::Mutex::new(MitmCertManager::new_in(&tmp).unwrap())); + + let mut config: Config = serde_json::from_str(r#"{"mode":"direct"}"#).unwrap(); + config.inbound_username = "socksuser".to_string(); + config.inbound_password = "sockspassword".to_string(); + + let proxy_server = ProxyServer::new(&config, mitm.clone()).unwrap(); + let rewrite_ctx = proxy_server.rewrite_ctx.clone(); + + // Test case 1: Client does not support auth methods we require + let rewrite_ctx_clone = rewrite_ctx.clone(); + let mitm_clone = mitm.clone(); + let listener_clone = listener; + let server_task = tokio::spawn(async move { + if let Ok((sock, _)) = listener_clone.accept().await { + let _ = handle_socks5_client(sock, None, mitm_clone, rewrite_ctx_clone, None).await; + } + }); + + let mut client = tokio::net::TcpStream::connect(addr).await.unwrap(); + // Client sends VER=5, NMETHODS=1, METHODS=[0x00] (No authentication) + client.write_all(&[0x05, 0x01, 0x00]).await.unwrap(); + let mut resp = [0u8; 2]; + client.read_exact(&mut resp).await.unwrap(); + // Server must reply with NO ACCEPTABLE METHODS (0xff) + assert_eq!(resp, [0x05, 0xff]); + server_task.await.unwrap(); + + // Test case 2: Client sends wrong credentials + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let rewrite_ctx_clone = rewrite_ctx.clone(); + let mitm_clone = mitm.clone(); + let server_task = tokio::spawn(async move { + if let Ok((sock, _)) = listener.accept().await { + let _ = handle_socks5_client(sock, None, mitm_clone, rewrite_ctx_clone, None).await; + } + }); + + let mut client = tokio::net::TcpStream::connect(addr).await.unwrap(); + // Client sends VER=5, NMETHODS=1, METHODS=[0x02] (Username/Password) + client.write_all(&[0x05, 0x01, 0x02]).await.unwrap(); + let mut resp = [0u8; 2]; + client.read_exact(&mut resp).await.unwrap(); + assert_eq!(resp, [0x05, 0x02]); + + // Client sends RFC 1929 subnegotiation: VER=1, ULEN=5, UNAME="wrong", PLEN=5, PASSWD="wrong" + client.write_all(&[0x01, 0x05]).await.unwrap(); + client.write_all(b"wrong").await.unwrap(); + client.write_all(&[0x05]).await.unwrap(); + client.write_all(b"wrong").await.unwrap(); + + let mut sub_resp = [0u8; 2]; + client.read_exact(&mut sub_resp).await.unwrap(); + // Server replies subnegotiation status VER=1, STATUS=0x01 (Failure) + assert_eq!(sub_resp, [0x01, 0x01]); + server_task.await.unwrap(); + + // Test case 3: Client sends correct credentials + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let rewrite_ctx_clone = rewrite_ctx.clone(); + let mitm_clone = mitm.clone(); + let server_task = tokio::spawn(async move { + if let Ok((sock, _)) = listener.accept().await { + let _ = handle_socks5_client(sock, None, mitm_clone, rewrite_ctx_clone, None).await; + } + }); + + let mut client = tokio::net::TcpStream::connect(addr).await.unwrap(); + // Client sends VER=5, NMETHODS=1, METHODS=[0x02] (Username/Password) + client.write_all(&[0x05, 0x01, 0x02]).await.unwrap(); + let mut resp = [0u8; 2]; + client.read_exact(&mut resp).await.unwrap(); + assert_eq!(resp, [0x05, 0x02]); + + // Client sends correct: UNAME="socksuser", PASSWD="sockspassword" + client.write_all(&[0x01, 0x09]).await.unwrap(); + client.write_all(b"socksuser").await.unwrap(); + client.write_all(&[13]).await.unwrap(); + client.write_all(b"sockspassword").await.unwrap(); + + let mut sub_resp = [0u8; 2]; + client.read_exact(&mut sub_resp).await.unwrap(); + // Server replies VER=1, STATUS=0x00 (Success) + assert_eq!(sub_resp, [0x01, 0x00]); + + // Clean up + server_task.abort(); + let _ = std::fs::remove_dir_all(&tmp); + } + } + diff --git a/src/tunnel_client.rs b/src/tunnel_client.rs index be671b4e..c5df5fcd 100644 --- a/src/tunnel_client.rs +++ b/src/tunnel_client.rs @@ -1259,6 +1259,56 @@ pub async fn tunnel_connection( result } +pub async fn tunnel_connection_with_prefix( + mut sock: TcpStream, + host: &str, + port: u16, + mux: &Arc, + prefix: Bytes, +) -> std::io::Result<()> { + let (sid, first_resp, pending_client_data) = if mux.connect_data_unsupported() { + let sid = connect_plain(host, port, mux).await?; + (sid, None, Some(prefix)) + } else { + match connect_with_initial_data(host, port, prefix.clone(), mux).await? { + ConnectDataOutcome::Opened { sid, response } => (sid, Some(response), None), + ConnectDataOutcome::Unsupported => { + mux.mark_connect_data_unsupported(); + let sid = connect_plain(host, port, mux).await?; + (sid, None, Some(prefix)) + } + } + }; + + tracing::info!("tunnel session {} opened for {}:{} (with prefix)", sid, host, port); + pipeline_debug::session_start(&sid); + + let result = async { + if let Some(resp) = first_resp { + match write_tunnel_response(&mut sock, &resp).await? { + WriteOutcome::Wrote | WriteOutcome::NoData => {} + WriteOutcome::BadBase64 => { + tracing::error!( + "tunnel session {}: bad base64 in connect_data response", + sid + ); + return Ok(()); + } + } + if resp.eof.unwrap_or(false) { + return Ok(()); + } + } + tunnel_loop(&mut sock, &sid, mux, pending_client_data).await + } + .await; + + mux.send(MuxMsg::Close { sid: sid.clone() }).await; + pipeline_debug::session_end(&sid); + tracing::info!("tunnel session {} closed for {}:{} (with prefix)", sid, host, port); + result +} + enum ConnectDataOutcome { Opened { sid: String,