diff --git a/Cargo.lock b/Cargo.lock index c5d13f5..b556d6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -524,6 +524,8 @@ dependencies = [ "libc", "libloading 0.9.0", "log", + "reqwest", + "rustls", "self-replace", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 3dec60c..335d1af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,10 @@ tiny-skia = "0.11" self-replace = "1.5" tauri-plugin-notification = "2" tauri-plugin-updater = "2" +# Not used directly: Cargo's feature unification turns `socks` on for the +# updater plugin's reqwest, so update requests can use a SOCKS env proxy. +# Keep the major in step with the plugin's (`cargo tree -i reqwest`). +reqwest = { version = "0.13", default-features = false, features = ["socks"] } [target.'cfg(not(windows))'.dependencies] tauri-plugin-autostart = "2" @@ -66,6 +70,9 @@ auto-launch = "0.6" [dev-dependencies] flate2 = "1" +# The crypto provider the updater plugin installs before its reqwest client +# is built; the SOCKS update test builds such a client itself. +rustls = { version = "0.23", default-features = false, features = ["ring"] } [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/README.md b/README.md index fdf11ea..9472fb7 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ same; these differences are visible: - Proxy auto-configuration (PAC scripts and WPAD) is no longer used. A static system proxy, its bypass list and the `HTTPS_PROXY`-style environment variables still are, including a SOCKS `ALL_PROXY` such as - `socks5h://127.0.0.1:7890`. + `socks5h://127.0.0.1:7890`, for usage requests and updates alike. - Only one copy runs per signed-in user session, instead of one per machine. ## Diagnostics diff --git a/src/net/mod.rs b/src/net/mod.rs index e54a67a..2f3bfba 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -334,7 +334,7 @@ mod tests { use std::io::Write; use std::time::Duration; - use super::test_server::serve_once; + use super::test_server::{serve_once, serve_socks5_once}; use super::*; fn direct_client() -> Client { @@ -496,75 +496,6 @@ mod tests { move |name| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone()) } - /// A one-shot SOCKS5 proxy that accepts the handshake, reports the CONNECT - /// target it was asked for, then answers the tunnelled request itself with - /// `200 OK` and `body`. - fn serve_socks5_once(body: &'static [u8]) -> (u16, std::sync::mpsc::Receiver) { - use std::io::Read; - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind loopback"); - let port = listener.local_addr().expect("local addr").port(); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let Ok((mut stream, _)) = listener.accept() else { - return; - }; - // A broken handshake must fail the test, not hang it. - let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(10))); - let byte = |stream: &mut std::net::TcpStream, n: usize| { - let mut buf = vec![0u8; n]; - stream.read_exact(&mut buf).map(|_| buf) - }; - // Greeting: version 5, then the offered auth methods. - let Ok(head) = byte(&mut stream, 2) else { - return; - }; - let _ = byte(&mut stream, usize::from(head[1])); - if stream.write_all(&[5, 0]).is_err() { - return; - } - // Request: version, CONNECT, reserved, address type. - let Ok(req) = byte(&mut stream, 4) else { - return; - }; - let target = match req[3] { - 3 => { - let len = byte(&mut stream, 1).map(|b| usize::from(b[0])).unwrap_or(0); - String::from_utf8_lossy(&byte(&mut stream, len).unwrap_or_default()) - .into_owned() - } - 1 => byte(&mut stream, 4) - .map(|a| std::net::Ipv4Addr::new(a[0], a[1], a[2], a[3]).to_string()) - .unwrap_or_default(), - other => format!("address type {other}"), - }; - let port = byte(&mut stream, 2) - .map(|p| u16::from_be_bytes([p[0], p[1]])) - .unwrap_or(0); - let _ = stream.write_all(&[5, 0, 0, 1, 0, 0, 0, 0, 0, 0]); - // Read the tunnelled request head, then answer it. - let mut seen = Vec::new(); - let mut buf = [0u8; 1024]; - while !seen.windows(4).any(|w| w == b"\r\n\r\n") { - match stream.read(&mut buf) { - Ok(0) | Err(_) => break, - Ok(n) => seen.extend_from_slice(&buf[..n]), - } - } - let reply = format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - body.len() - ); - let _ = stream.write_all(reply.as_bytes()); - let _ = stream.write_all(body); - let _ = tx.send(format!( - "{} {target}:{port}\n{}", - req[0], - String::from_utf8_lossy(&seen) - )); - }); - (port, rx) - } - #[test] fn socks5h_env_proxy_tunnels_with_remote_resolution() { let (socks_port, seen) = serve_socks5_once(b"via socks"); @@ -708,4 +639,72 @@ pub(crate) mod test_server { request: rx, } } + + /// A one-shot SOCKS5 proxy that accepts the handshake, reports the CONNECT + /// target it was asked for, then answers the tunnelled request itself with + /// `200 OK` and `body`. + pub fn serve_socks5_once(body: &'static [u8]) -> (u16, mpsc::Receiver) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback"); + let port = listener.local_addr().expect("local addr").port(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + // A broken handshake must fail the test, not hang it. + let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(10))); + let byte = |stream: &mut std::net::TcpStream, n: usize| { + let mut buf = vec![0u8; n]; + stream.read_exact(&mut buf).map(|_| buf) + }; + // Greeting: version 5, then the offered auth methods. + let Ok(head) = byte(&mut stream, 2) else { + return; + }; + let _ = byte(&mut stream, usize::from(head[1])); + if stream.write_all(&[5, 0]).is_err() { + return; + } + // Request: version, CONNECT, reserved, address type. + let Ok(req) = byte(&mut stream, 4) else { + return; + }; + let target = match req[3] { + 3 => { + let len = byte(&mut stream, 1).map(|b| usize::from(b[0])).unwrap_or(0); + String::from_utf8_lossy(&byte(&mut stream, len).unwrap_or_default()) + .into_owned() + } + 1 => byte(&mut stream, 4) + .map(|a| std::net::Ipv4Addr::new(a[0], a[1], a[2], a[3]).to_string()) + .unwrap_or_default(), + other => format!("address type {other}"), + }; + let port = byte(&mut stream, 2) + .map(|p| u16::from_be_bytes([p[0], p[1]])) + .unwrap_or(0); + let _ = stream.write_all(&[5, 0, 0, 1, 0, 0, 0, 0, 0, 0]); + // Read the tunnelled request head, then answer it. + let mut seen = Vec::new(); + let mut buf = [0u8; 1024]; + while !seen.windows(4).any(|w| w == b"\r\n\r\n") { + match stream.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => seen.extend_from_slice(&buf[..n]), + } + } + let reply = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(reply.as_bytes()); + let _ = stream.write_all(body); + let _ = tx.send(format!( + "{} {target}:{port}\n{}", + req[0], + String::from_utf8_lossy(&seen) + )); + }); + (port, rx) + } } diff --git a/src/runtime.rs b/src/runtime.rs index 352cd50..6e88b97 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -591,11 +591,7 @@ impl Runtime { /// Blocking body of [`Self::check_for_updates`]. fn run_update_check(&self) { - let proxy = if net::env_proxy().is_some() { - None - } else { - update::proxy_url(self.http.system_proxy()) - }; + let proxy = update::proxy_url(net::env_proxy().as_ref(), self.http.system_proxy()); let outcome = update::check(&self.ui.app, proxy); let now_unix = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) diff --git a/src/update/mod.rs b/src/update/mod.rs index c8cc0b2..d15528c 100644 --- a/src/update/mod.rs +++ b/src/update/mod.rs @@ -164,11 +164,67 @@ pub fn apply(mut update: Update, channel: Channel) -> Result { Ok(Version::parse(&update.version)) } -/// The proxy for the update requests: the system proxy, unless an update -/// host bypasses it. Pass `None` when an environment proxy is set, which the -/// plugin reads itself. -pub fn proxy_url(system: Option<&crate::core::proxy::SystemProxy>) -> Option { - let system = system?; +/// The proxy for the update requests, passed to the plugin explicitly so it +/// matches the one the usage requests use. +/// +/// An environment proxy (`net::env_proxy`, SOCKS included) wins over the +/// system proxy. `None`, when an update host is on the `NO_PROXY` or system +/// bypass list or no proxy is set, leaves the plugin to its own discovery, +/// which honours those lists per host. +pub fn proxy_url( + env: Option<&ureq::Proxy>, + system: Option<&crate::core::proxy::SystemProxy>, +) -> Option { + match env { + Some(env) => env_proxy_url(env), + None => system_proxy_url(system?), + } +} + +fn env_proxy_url(proxy: &ureq::Proxy) -> Option { + use ureq::ProxyProtocol as P; + let bypassed = UPDATE_HOSTS.iter().any(|host| { + format!("https://{host}/") + .parse::() + .is_ok_and(|uri| proxy.is_no_proxy(&uri)) + }); + if bypassed { + return None; + } + let scheme = match proxy.protocol() { + P::Http => "http", + P::Https => "https", + P::Socks4 => "socks4", + P::Socks4A => "socks4a", + P::Socks5 => "socks5", + P::Socks5h => "socks5h", + _ => { + log::warn!("environment proxy ignored for updates, unsupported protocol"); + return None; + } + }; + // The credentials stay percent-encoded, as they were in the variable. + let userinfo = match (proxy.username(), proxy.password()) { + (Some(user), Some(password)) => format!("{user}:{password}@"), + (Some(user), None) => format!("{user}@"), + _ => String::new(), + }; + let built = Url::parse(&format!( + "{scheme}://{userinfo}{}:{}", + proxy.host(), + proxy.port() + )); + match built { + Ok(url) => Some(url), + Err(err) => { + // The proxy URL is not logged: it may carry credentials. + log::warn!("environment proxy ignored for updates, not a valid URL: {err}"); + None + } + } +} + +fn system_proxy_url(system: &crate::core::proxy::SystemProxy) -> Option { if UPDATE_HOSTS.iter().any(|host| system.bypasses(host)) { return None; } @@ -271,14 +327,109 @@ mod tests { #[test] fn update_proxy_honours_the_bypass_list() { - assert_eq!(proxy_url(None), None); + assert_eq!(proxy_url(None, None), None); assert_eq!( - proxy_url(Some(&proxy(&[]))).map(|u| u.to_string()), + proxy_url(None, Some(&proxy(&[]))).map(|u| u.to_string()), Some("http://proxy.corp:8080/".into()) ); - assert!(proxy_url(Some(&proxy(&["*.example.com"]))).is_some()); - assert_eq!(proxy_url(Some(&proxy(&["github.com"]))), None); - assert_eq!(proxy_url(Some(&proxy(&["*.githubusercontent.com"]))), None); + assert!(proxy_url(None, Some(&proxy(&["*.example.com"]))).is_some()); + assert_eq!(proxy_url(None, Some(&proxy(&["github.com"]))), None); + assert_eq!( + proxy_url(None, Some(&proxy(&["*.githubusercontent.com"]))), + None + ); + } + + fn env(uri: &str) -> ureq::Proxy { + ureq::Proxy::new(uri).expect("valid proxy") + } + + #[test] + fn update_proxy_passes_the_env_proxy_whatever_its_scheme() { + let url = |env_uri: &str| proxy_url(Some(&env(env_uri)), None).map(|u| u.to_string()); + assert_eq!( + url("http://proxy.corp:3128"), + Some("http://proxy.corp:3128/".into()) + ); + assert_eq!( + url("socks5h://127.0.0.1:7890"), + Some("socks5h://127.0.0.1:7890".into()) + ); + assert_eq!(url("socks5://[::1]"), Some("socks5://[::1]:1080".into())); + assert_eq!( + url("socks4a://10.0.0.1:1081"), + Some("socks4a://10.0.0.1:1081".into()) + ); + assert_eq!( + url("socks5h://us%40r:p%3Ass@127.0.0.1:7890"), + Some("socks5h://us%40r:p%3Ass@127.0.0.1:7890".into()) + ); + } + + #[test] + fn update_proxy_prefers_the_env_proxy_over_the_system_proxy() { + assert_eq!( + proxy_url(Some(&env("socks5h://127.0.0.1:7890")), Some(&proxy(&[]))) + .map(|u| u.to_string()), + Some("socks5h://127.0.0.1:7890".into()) + ); + assert_eq!( + proxy_url(Some(&env("http://proxy.env:3128")), Some(&proxy(&[]))) + .map(|u| u.to_string()), + Some("http://proxy.env:3128/".into()) + ); + } + + #[test] + fn update_proxy_leaves_no_proxy_hosts_to_the_plugin() { + let with_no_proxy = |expr: &str| { + ureq::Proxy::builder(ureq::ProxyProtocol::Socks5h) + .host("127.0.0.1") + .port(7890) + .no_proxy(expr) + .build() + .expect("valid proxy") + }; + assert_eq!(proxy_url(Some(&with_no_proxy("github.com")), None), None); + assert_eq!( + proxy_url(Some(&with_no_proxy(".githubusercontent.com")), None), + None + ); + assert!(proxy_url(Some(&with_no_proxy("example.com")), None).is_some()); + } + + #[test] + fn updater_client_reaches_the_network_through_a_socks_env_proxy() { + // Guards the `socks` feature the direct reqwest dependency turns on + // for the plugin: without it reqwest rejects the SOCKS proxy. + let _ = rustls::crypto::ring::default_provider().install_default(); + let (socks_port, seen) = crate::net::test_server::serve_socks5_once(b"via socks"); + let url = proxy_url( + Some(&env(&format!("socks5h://127.0.0.1:{socks_port}"))), + None, + ) + .expect("socks proxy url"); + let client = reqwest::Client::builder() + .proxy(reqwest::Proxy::all(url.as_str()).expect("reqwest proxy")) + .build() + .expect("reqwest client"); + let body = tauri::async_runtime::block_on(async { + client + .get("http://example.invalid:8080/latest.json") + .send() + .await? + .text() + .await + }) + .expect("request through socks"); + assert_eq!(body, "via socks"); + let request = seen + .recv_timeout(Duration::from_secs(10)) + .expect("socks proxy saw the request"); + assert!( + request.starts_with("5 example.invalid:8080\nGET /latest.json HTTP/1.1\r\n"), + "{request}" + ); } #[test]