From 1b0934cefabe8a822c5c90545149d7334c9a4e2e Mon Sep 17 00:00:00 2001 From: "Amagi:DDmxh" Date: Tue, 11 Aug 2026 22:29:32 +0800 Subject: [PATCH] fix(ws): honor macOS proxy exclusions Signed-off-by: Amagi:DDmxh --- Cargo.lock | 2 + crates/buzz-ws-client/Cargo.toml | 4 + crates/buzz-ws-client/src/proxy/macos.rs | 218 +++++++++++++++++++++++ crates/buzz-ws-client/src/proxy/mod.rs | 72 +++++++- desktop/src-tauri/Cargo.lock | 2 + 5 files changed, 289 insertions(+), 9 deletions(-) create mode 100644 crates/buzz-ws-client/src/proxy/macos.rs diff --git a/Cargo.lock b/Cargo.lock index f30266bd6bb..a67c081aae3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1294,7 +1294,9 @@ dependencies = [ "futures-util", "hyper-util", "nostr", + "plist", "serde_json", + "system-configuration", "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.29.0", diff --git a/crates/buzz-ws-client/Cargo.toml b/crates/buzz-ws-client/Cargo.toml index 2bb835edc80..680c7fc36ce 100644 --- a/crates/buzz-ws-client/Cargo.toml +++ b/crates/buzz-ws-client/Cargo.toml @@ -18,5 +18,9 @@ thiserror = { workspace = true } url = { workspace = true } tracing = { workspace = true } +[target.'cfg(target_os = "macos")'.dependencies] +plist = "1" +system-configuration = "0.7" + [target.'cfg(windows)'.dependencies] windows-registry = "0.6" diff --git a/crates/buzz-ws-client/src/proxy/macos.rs b/crates/buzz-ws-client/src/proxy/macos.rs new file mode 100644 index 00000000000..ebd7743b2df --- /dev/null +++ b/crates/buzz-ws-client/src/proxy/macos.rs @@ -0,0 +1,218 @@ +//! macOS static proxy discovery. +//! +//! Hyper-util reads the configured HTTP and HTTPS proxies on macOS, but does +//! not currently include `ExceptionsList` or `ExcludeSimpleHostnames`. This +//! module preserves its environment-variable precedence while adding those +//! system bypass settings to Buzz's shared matcher. + +use std::io::Cursor; + +use hyper_util::client::proxy::matcher::Matcher; +use plist::{Dictionary, Value}; +use system_configuration::{ + core_foundation::{ + base::{CFType, TCFType}, + dictionary::CFDictionary, + propertylist::{create_data, kCFPropertyListBinaryFormat_v1_0}, + string::CFString, + }, + dynamic_store::SCDynamicStoreBuilder, +}; + +use super::SystemProxySettings; + +pub(super) fn system_proxy_settings() -> SystemProxySettings { + if std::env::var_os("REQUEST_METHOD").is_some() { + return SystemProxySettings::new(Matcher::from_env()); + } + + let mut http = first_env(&["HTTP_PROXY", "http_proxy"]); + let mut https = first_env(&["HTTPS_PROXY", "https_proxy"]); + let all = first_env(&["ALL_PROXY", "all_proxy"]); + let mut no = first_env(&["NO_PROXY", "no_proxy"]); + let mut exclude_simple_hostnames = false; + + if let Some(system) = read_system_proxy() { + if http.is_empty() && all.is_empty() { + http = system.http.unwrap_or_default(); + } + if https.is_empty() && all.is_empty() { + https = system.https.unwrap_or_default(); + } + if no.is_empty() { + no = system.no; + exclude_simple_hostnames = system.exclude_simple_hostnames; + } + } + + SystemProxySettings { + matcher: Matcher::builder() + .all(all) + .http(http) + .https(https) + .no(no) + .build(), + exclude_simple_hostnames, + } +} + +fn first_env(names: &[&str]) -> String { + names + .iter() + .find_map(|name| std::env::var(name).ok()) + .unwrap_or_default() +} + +fn read_system_proxy() -> Option { + let store = SCDynamicStoreBuilder::new("buzz-ws-client").build()?; + let proxies = store.get_proxies()?; + let dictionary = proxy_dictionary(&proxies)?; + Some(parse_system_proxy(&dictionary)) +} + +fn proxy_dictionary(proxies: &CFDictionary) -> Option { + let data = create_data( + proxies.as_CFTypeRef().cast(), + kCFPropertyListBinaryFormat_v1_0, + ) + .ok()?; + Value::from_reader(Cursor::new(data.bytes())) + .ok()? + .into_dictionary() +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct SystemProxy { + http: Option, + https: Option, + no: String, + exclude_simple_hostnames: bool, +} + +fn parse_system_proxy(proxies: &Dictionary) -> SystemProxy { + SystemProxy { + http: proxy_endpoint(proxies, "HTTPEnable", "HTTPProxy", "HTTPPort"), + https: proxy_endpoint(proxies, "HTTPSEnable", "HTTPSProxy", "HTTPSPort"), + no: proxy_exceptions(proxies), + exclude_simple_hostnames: setting_enabled(proxies.get("ExcludeSimpleHostnames")), + } +} + +fn proxy_endpoint( + proxies: &Dictionary, + enabled_key: &str, + host_key: &str, + port_key: &str, +) -> Option { + if !setting_enabled(proxies.get(enabled_key)) { + return None; + } + + let host = proxies.get(host_key)?.as_string()?.trim(); + if host.is_empty() { + return None; + } + + let port = proxies + .get(port_key) + .and_then(integer_value) + .and_then(|value| u16::try_from(value).ok()); + Some(match port { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }) +} + +fn proxy_exceptions(proxies: &Dictionary) -> String { + proxies + .get("ExceptionsList") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_string) + .filter_map(normalize_exception) + .collect::>() + .join(",") +} + +fn normalize_exception(value: &str) -> Option<&str> { + let value = value.trim(); + let value = value.strip_prefix("*.").unwrap_or(value); + (!value.is_empty()).then_some(value) +} + +fn setting_enabled(value: Option<&Value>) -> bool { + value.is_some_and(|value| { + value.as_boolean() == Some(true) + || value.as_signed_integer() == Some(1) + || value.as_unsigned_integer() == Some(1) + }) +} + +fn integer_value(value: &Value) -> Option { + value.as_signed_integer().or_else(|| { + value + .as_unsigned_integer() + .and_then(|value| i64::try_from(value).ok()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_enabled_http_and_https_proxies() { + let mut proxies = Dictionary::new(); + proxies.insert("HTTPEnable".into(), Value::Integer(1.into())); + proxies.insert("HTTPProxy".into(), Value::String("proxy.local".into())); + proxies.insert("HTTPPort".into(), Value::Integer(8080.into())); + proxies.insert("HTTPSEnable".into(), Value::Boolean(true)); + proxies.insert( + "HTTPSProxy".into(), + Value::String("secure-proxy.local".into()), + ); + proxies.insert("HTTPSPort".into(), Value::Integer(8443.into())); + + let proxy = parse_system_proxy(&proxies); + + assert_eq!(proxy.http.as_deref(), Some("proxy.local:8080")); + assert_eq!(proxy.https.as_deref(), Some("secure-proxy.local:8443")); + } + + #[test] + fn normalizes_macos_proxy_exceptions_for_hyper_util() { + let mut proxies = Dictionary::new(); + proxies.insert( + "ExceptionsList".into(), + Value::Array(vec![ + Value::String(" *.example.com ".into()), + Value::String("10.0.0.0/8".into()), + Value::String("localhost".into()), + Value::String(String::new()), + ]), + ); + + let exceptions = proxy_exceptions(&proxies); + assert_eq!(exceptions, "example.com,10.0.0.0/8,localhost"); + + let matcher = Matcher::builder() + .all("http://proxy.local:8080") + .no(exceptions) + .build(); + let excluded = "https://relay.example.com".parse().unwrap(); + let proxied = "https://notexample.com".parse().unwrap(); + assert!(matcher.intercept(&excluded).is_none()); + assert!(matcher.intercept(&proxied).is_some()); + } + + #[test] + fn reads_exclude_simple_hostnames_boolean_and_integer_values() { + let mut proxies = Dictionary::new(); + proxies.insert("ExcludeSimpleHostnames".into(), Value::Integer(1.into())); + assert!(parse_system_proxy(&proxies).exclude_simple_hostnames); + + proxies.insert("ExcludeSimpleHostnames".into(), Value::Boolean(false)); + assert!(!parse_system_proxy(&proxies).exclude_simple_hostnames); + } +} diff --git a/crates/buzz-ws-client/src/proxy/mod.rs b/crates/buzz-ws-client/src/proxy/mod.rs index e3eff06f761..3e6c9b58b60 100644 --- a/crates/buzz-ws-client/src/proxy/mod.rs +++ b/crates/buzz-ws-client/src/proxy/mod.rs @@ -15,6 +15,8 @@ use tokio_tungstenite::tungstenite::Error; use tokio_tungstenite::{client_async_tls, MaybeTlsStream, WebSocketStream}; use tower_service::Service; +#[cfg(target_os = "macos")] +mod macos; #[cfg(any(windows, test))] mod windows; @@ -35,34 +37,73 @@ where R: IntoClientRequest + Unpin, { let request = request.into_client_request()?; - let matcher = system_proxy_matcher(); - connect_websocket_with(&matcher, request).await + let settings = system_proxy_settings(); + connect_websocket_with_options( + &settings.matcher, + request, + settings.exclude_simple_hostnames, + ) + .await } -fn system_proxy_matcher() -> Matcher { +struct SystemProxySettings { + matcher: Matcher, + exclude_simple_hostnames: bool, +} + +impl SystemProxySettings { + fn new(matcher: Matcher) -> Self { + Self { + matcher, + exclude_simple_hostnames: false, + } + } +} + +fn system_proxy_settings() -> SystemProxySettings { + #[cfg(target_os = "macos")] + { + macos::system_proxy_settings() + } + #[cfg(windows)] { - windows::system_proxy_matcher() + SystemProxySettings::new(windows::system_proxy_matcher()) } - #[cfg(not(windows))] + #[cfg(not(any(target_os = "macos", windows)))] { - Matcher::from_system() + SystemProxySettings::new(Matcher::from_system()) } } +#[cfg(test)] async fn connect_websocket_with( matcher: &Matcher, request: Request, +) -> Result<(ProxyWebSocketStream, Response), Error> { + connect_websocket_with_options(matcher, request, false).await +} + +async fn connect_websocket_with_options( + matcher: &Matcher, + request: Request, + exclude_simple_hostnames: bool, ) -> Result<(ProxyWebSocketStream, Response), Error> { let target = proxy_target_uri(request.uri())?; - let stream = connect_tcp(matcher, &target).await?; + let stream = connect_tcp(matcher, &target, exclude_simple_hostnames).await?; client_async_tls(request, stream).await } -async fn connect_tcp(matcher: &Matcher, target: &Uri) -> Result { - if target.host().is_some_and(is_loopback_host) { +async fn connect_tcp( + matcher: &Matcher, + target: &Uri, + exclude_simple_hostnames: bool, +) -> Result { + if target.host().is_some_and(|host| { + is_loopback_host(host) || (exclude_simple_hostnames && is_simple_hostname(host)) + }) { return connect_direct(target).await; } @@ -223,6 +264,11 @@ fn is_loopback_host(host: &str) -> bool { .is_ok_and(|address| address.is_loopback()) } +fn is_simple_hostname(host: &str) -> bool { + let host = host.trim_matches(['[', ']']); + !host.contains('.') && host.parse::().is_err() +} + fn transport_error( operation: &'static str, source: impl StdError + Send + Sync + 'static, @@ -272,6 +318,14 @@ mod tests { assert!(!is_loopback_host("127.0.0.2.example.com")); } + #[test] + fn simple_hostnames_exclude_ip_addresses() { + assert!(is_simple_hostname("relay")); + assert!(!is_simple_hostname("relay.example.com")); + assert!(!is_simple_hostname("192.0.2.1")); + assert!(!is_simple_hostname("[2001:db8::1]")); + } + #[tokio::test] async fn loopback_destination_connects_directly() { let target_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 7018cf565c5..2a8f336b4e0 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1151,7 +1151,9 @@ dependencies = [ "futures-util", "hyper-util", "nostr", + "plist", "serde_json", + "system-configuration", "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.29.0",