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:
tiennm99 committed 2026-10-21 19:22:02 +07:00
1 parent 2e13618837
commit 747de03eb2
6 files changed
+241 -86

No files matched your search

Generated
+2
View File
@@ -524,6 +524,8 @@ dependencies = [
"libc",
"libloading 0.9.0",
"log",
"reqwest",
"rustls",
"self-replace",
"serde",
"serde_json",
+7
View File
@@ -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 = [] }
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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]