diff --git a/Cargo.lock b/Cargo.lock index 198b948..c5d13f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3638,6 +3638,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "softbuffer" version = "0.4.8" @@ -4667,6 +4678,7 @@ dependencies = [ "rustls", "rustls-pki-types", "rustls-platform-verifier", + "socks", "ureq-proto", "utf8-zero", "webpki-roots", diff --git a/Cargo.toml b/Cargo.toml index d42d093..3dec60c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,8 @@ path = "src/main.rs" tauri = { version = "2", features = ["tray-icon", "macos-private-api"] } tauri-plugin-single-instance = "2" # rustls with the OS verifier keeps corporate roots trusted; gzip is a default. -ureq = { version = "3", features = ["platform-verifier"] } +# socks-proxy serves SOCKS environment proxies such as ALL_PROXY=socks5h://. +ureq = { version = "3", features = ["platform-verifier", "socks-proxy"] } serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "6" diff --git a/README.md b/README.md index 57ed205..fdf11ea 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,8 @@ same; these differences are visible: no longer use a warning or info icon. - 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. + environment variables still are, including a SOCKS `ALL_PROXY` such as + `socks5h://127.0.0.1:7890`. - Only one copy runs per signed-in user session, instead of one per machine. ## Diagnostics diff --git a/plans/261021-1028-tauri-v2-port/phase-03-data-layer-ureq.md b/plans/261021-1028-tauri-v2-port/phase-03-data-layer-ureq.md index 4798b11..5e7c8fc 100644 --- a/plans/261021-1028-tauri-v2-port/phase-03-data-layer-ureq.md +++ b/plans/261021-1028-tauri-v2-port/phase-03-data-layer-ureq.md @@ -30,7 +30,7 @@ only two Win32 couplings: `src/net/winhttp.rs`, and the `CommandExt` import in - User-Agent `claude-code-usage-bubble/`. - A per-request User-Agent replaces the agent's, as the Codex provider requires. - Proxy: - - Env proxies: `net::env_proxy()` walks the variables ureq reads (`ALL_PROXY`, `HTTPS_PROXY`, `HTTP_PROXY`, either case) and keeps the first HTTP/HTTPS one, skipping SOCKS entries with a warning because the `socks-proxy` feature is off. `NO_PROXY` applies to the chosen proxy. + - Env proxies: `net::env_proxy()` walks the variables ureq reads (`ALL_PROXY`, `HTTPS_PROXY`, `HTTP_PROXY`, either case) and keeps the first valid one, in ureq's order. ureq's `socks-proxy` feature is on, so a SOCKS entry such as Clash's `ALL_PROXY=socks5h://…` is used, not skipped. `NO_PROXY` applies to the chosen proxy. - `cfg(windows)`, only when no env proxy is set: `platform::windows::system_proxy()` reads `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings` (`ProxyEnable`, `ProxyServer`, `ProxyOverride`). Nothing when `ProxyEnable` is not 1. - Parsing, a pure function in `core` with tests: a `ProxyServer` of the form `http=h:p;https=h:p;…` uses the `https=` entry, else the `http=` entry; a bare `h:p` applies to all schemes. `ProxyOverride` is split on `;`; each entry is a case-insensitive glob (`*` wildcard) against the host, and `` matches dotless hosts. - `net::Client` holds a proxied agent and a direct agent and picks per request: direct when the request host matches a `ProxyOverride` entry. The parsed proxy URL is also handed to the updater builder in Phase 09. @@ -83,10 +83,10 @@ only two Win32 couplings: `src/net/winhttp.rs`, and the `CommandExt` import in - `net::Error::Status` is kept so the providers compile unchanged; the client never builds it, because every status comes back as a `Response`. - `net::Client::new` consults `net::system_proxy()` only when - `net::env_proxy()` finds no usable proxy. A SOCKS env entry is skipped - rather than passed to ureq, which without `socks-proxy` fails every request - for `socks5h` and silently connects directly for `socks5`; skipping lets an - HTTP env proxy or the system proxy take over. `Client::system_proxy()` + `net::env_proxy()` finds no usable proxy. SOCKS env proxies go through + ureq's `socks-proxy` connector; without that feature ureq fails every + request for `socks5h` and silently connects directly for `socks5`. The + system proxy stays HTTP/HTTPS only. `Client::system_proxy()` exposes the system proxy in use for the updater builder in Phase 09. Proxy URLs are never logged (they may hold credentials). diff --git a/src/net/mod.rs b/src/net/mod.rs index 6fe82ae..e54a67a 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -58,14 +58,12 @@ const ENV_PROXY_VARS: &[&str] = &[ "http_proxy", ]; -/// The environment proxy, if one is usable. +/// The environment proxy, if one is set. /// -/// Walks the variables `ureq` reads, but skips SOCKS entries: this build has no -/// `socks-proxy` support, so `ureq` would either fail every request (`socks5h`) -/// or silently connect directly (`socks5`). Proxy tools often set -/// `ALL_PROXY=socks5h://…` next to `HTTPS_PROXY=http://…`, so skipping lets the -/// HTTP entry, or failing that the system proxy, take over. `NO_PROXY` applies -/// to the chosen proxy. +/// Walks the variables `ureq` reads and keeps the first valid one, whatever its +/// scheme: HTTP and HTTPS proxies are reached with CONNECT, SOCKS4/4a/5/5h +/// proxies (`ALL_PROXY=socks5h://…`, as Clash and v2ray set) through ureq's +/// `socks-proxy` support. `NO_PROXY` applies to the chosen proxy. pub fn env_proxy() -> Option { env_proxy_from(|name| std::env::var(name).ok()) } @@ -79,10 +77,6 @@ fn env_proxy_from(var: impl Fn(&str) -> Option) -> Option { let Ok(parsed) = Proxy::new(&value) else { continue; }; - if !is_connect_proxy(&parsed) { - log::warn!("{name} ignored, only http and https proxies are supported"); - continue; - } // Rebuild so the proxy carries the `NO_PROXY` bypass list, which only // the builder accepts. let mut builder = Proxy::builder(parsed.protocol()) @@ -108,7 +102,8 @@ fn env_proxy_from(var: impl Fn(&str) -> Option) -> Option { None } -/// Whether `proxy` is an HTTP CONNECT proxy, the only kind this build supports. +/// Whether `proxy` is an HTTP CONNECT proxy, the only kind the system proxy +/// may be. fn is_connect_proxy(proxy: &Proxy) -> bool { matches!(proxy.protocol(), ProxyProtocol::Http | ProxyProtocol::Https) } @@ -160,9 +155,8 @@ impl Client { let direct = make_agent(user_agent, env_proxy); let proxied = match system { Some(proxy) => match Proxy::new(&proxy.url) { - // Without ureq's `socks-proxy` feature a manually configured - // SOCKS proxy panics at connect time, so only CONNECT proxies - // are accepted here. + // The system proxy keeps the old WinHTTP client's scope: + // CONNECT proxies only, as `core::proxy` already parses it. Ok(p) if !is_connect_proxy(&p) => { log::warn!("system proxy ignored, only http and https proxies are supported"); None @@ -502,31 +496,120 @@ mod tests { move |name| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone()) } - #[test] - fn socks_env_proxy_is_skipped_for_the_http_one() { - let proxy = serve_once("200 OK", &[], Vec::new()); - let origin = serve_once("200 OK", &[], Vec::new()); - let chosen = env_proxy_from(env(&[ - ("all_proxy", "socks5h://127.0.0.1:9"), - ("https_proxy", &proxy.base), - ])) - .expect("http env proxy"); - assert_eq!(chosen.protocol(), ProxyProtocol::Http); - - let client = Client::build(USER_AGENT, Some(chosen), None).expect("client"); - // The fake proxy does not tunnel; the CONNECT reaching it is the proof. - let _ = client.get(&format!("{}/x", origin.base)).send(); - let request = received(&proxy); - let authority = origin.base.trim_start_matches("http://"); - assert!( - request.starts_with(&format!("CONNECT {authority} HTTP/1.1")), - "{request}" - ); + /// 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 socks_only_env_proxy_yields_none() { - assert!(env_proxy_from(env(&[("ALL_PROXY", "socks5://127.0.0.1:1080")])).is_none()); + fn socks5h_env_proxy_tunnels_with_remote_resolution() { + let (socks_port, seen) = serve_socks5_once(b"via socks"); + let http_proxy = serve_once("200 OK", &[], Vec::new()); + // Clash and v2ray set both; ureq's order puts ALL_PROXY first. + let chosen = env_proxy_from(env(&[ + ("all_proxy", &format!("socks5h://127.0.0.1:{socks_port}")), + ("https_proxy", &http_proxy.base), + ])) + .expect("socks env proxy"); + assert_eq!(chosen.protocol(), ProxyProtocol::Socks5h); + + let client = Client::build(USER_AGENT, Some(chosen), None).expect("client"); + // `socks5h` hands the host name to the proxy instead of resolving it, + // so the name need not resolve to the fake proxy's own address. + let resp = client + .get("http://example.invalid:8080/x") + .send() + .expect("send through socks"); + assert_eq!(resp.body(), b"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 /x HTTP/1.1\r\n"), + "{request}" + ); + assert!(http_proxy + .request + .recv_timeout(Duration::from_millis(200)) + .is_err()); + } + + #[test] + fn env_proxy_follows_ureq_order_and_skips_invalid_values() { + let socks = env_proxy_from(env(&[("ALL_PROXY", "socks5://127.0.0.1:1080")])) + .expect("socks env proxy"); + assert_eq!(socks.protocol(), ProxyProtocol::Socks5); + let http = env_proxy_from(env(&[ + ("ALL_PROXY", "bad host:x"), + ("http_proxy", "http://proxy.corp:3128"), + ])) + .expect("http env proxy"); + assert_eq!(http.protocol(), ProxyProtocol::Http); + assert_eq!(http.host(), "proxy.corp"); assert!(env_proxy_from(env(&[("HTTPS_PROXY", "bad host:x")])).is_none()); assert!(env_proxy_from(env(&[])).is_none()); }