mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-21 22:16:30 +00:00
fix: route SOCKS environment proxies through ureq's socks-proxy support
Clash and v2ray set ALL_PROXY=socks5h://..., which ureq reads before HTTPS_PROXY. Enable the socks-proxy feature so that entry is used instead of skipped, and cover a socks5h tunnel with a fake SOCKS5 server.
This commit is contained in:
1 parent
6407e49499
commit
2c1d10c47f
5 files changed
+141
-44
No files matched your search
Generated
+12
@@ -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",
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,7 +30,7 @@ only two Win32 couplings: `src/net/winhttp.rs`, and the `CommandExt` import in
|
||||
- User-Agent `claude-code-usage-bubble/<version>`.
|
||||
- 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 `<local>` 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).
|
||||
|
||||
+120
-37
@@ -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<Proxy> {
|
||||
env_proxy_from(|name| std::env::var(name).ok())
|
||||
}
|
||||
@@ -79,10 +77,6 @@ fn env_proxy_from(var: impl Fn(&str) -> Option<String>) -> Option<Proxy> {
|
||||
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<String>) -> Option<Proxy> {
|
||||
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<String>) {
|
||||
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());
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user