mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-23 16:16:39 +00:00
fix: send update checks and downloads through a SOCKS environment proxy
The updater left an environment proxy to the plugin's reqwest, which was built without SOCKS support, so a SOCKS ALL_PROXY broke updates and, on Windows, displaced a working system proxy. Enable reqwest's socks feature and pass the chosen environment or system proxy to the plugin explicitly.
This commit is contained in:
1 parent
2e13618837
commit
747de03eb2
6 files changed
+241
-86
No files matched your search
Generated
+2
@@ -524,6 +524,8 @@ dependencies = [
|
||||
"libc",
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"self-replace",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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 = [] }
|
||||
|
||||
@@ -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
|
||||
|
||||
+69
-70
@@ -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<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 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<String>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+1
-5
@@ -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)
|
||||
|
||||
+161
-10
@@ -164,11 +164,67 @@ pub fn apply(mut update: Update, channel: Channel) -> Result<Version, Error> {
|
||||
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<Url> {
|
||||
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<Url> {
|
||||
match env {
|
||||
Some(env) => env_proxy_url(env),
|
||||
None => system_proxy_url(system?),
|
||||
}
|
||||
}
|
||||
|
||||
fn env_proxy_url(proxy: &ureq::Proxy) -> Option<Url> {
|
||||
use ureq::ProxyProtocol as P;
|
||||
let bypassed = UPDATE_HOSTS.iter().any(|host| {
|
||||
format!("https://{host}/")
|
||||
.parse::<ureq::http::Uri>()
|
||||
.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<Url> {
|
||||
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]
|
||||
|
||||
Reference in new issue
Block a user