diff --git a/README.md b/README.md index 73f44a5..518a475 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ resolution. | | config source | PAC + WPAD | change signal | |---|---|---|---| -| **Windows** | `WinHttpGetIEProxyConfigForCurrentUser` | selected embedded backend + DNS WPAD; WinHTTP fallback when backend-less | registry change notification | +| **Windows** | `WinHttpGetIEProxyConfigForCurrentUser` | selected embedded backend + DHCP/DNS WPAD; WinHTTP fallback when backend-less | registry change notification | | **macOS** | `SCDynamicStoreCopyProxies` | built-in [QuickJS] PAC engine + DNS WPAD | `SCDynamicStore` callback | | **Linux** | GNOME `org.gnome.system.proxy` via `gsettings` | built-in [QuickJS] PAC engine + DNS WPAD | `dconf watch` / `gsettings monitor` | @@ -95,9 +95,9 @@ backend-less Windows build is valid and uses WinHTTP for PAC and WPAD resolution. The PAC helper functions are first-party JavaScript implemented from the public PAC specification. -Non-goals: DHCP-based WPAD (option 252) when using an embedded backend or on -macOS/Linux, KDE proxy settings, proxy authentication credentials. A -backend-less Windows build gets DHCP WPAD through WinHTTP. +Non-goals: DHCP-based WPAD (option 252) on macOS/Linux, KDE proxy settings, +proxy authentication credentials. Windows probes DHCP before DNS for both +embedded and WinHTTP-backed resolution. ## The PAC cage @@ -183,16 +183,18 @@ if let Some(pac) = config.pac { } ``` -The snapshot includes normalized static HTTP/HTTPS/SOCKS rules and, when the +The snapshot includes raw values for configured `http_proxy`, `https_proxy`, +`all_proxy`, and `no_proxy` variables (unset variables are omitted), normalized +static HTTP/HTTPS/SOCKS rules, and, when the native source was available, source-specific settings from WinHTTP, -SystemConfiguration, or GNOME GSettings. Proxy environment variables are not -included. If auto-detection is enabled, the call performs DNS WPAD discovery -first; if no usable `wpad.dat` is found, it loads the configured PAC URL. PAC -loading is best-effort and synchronous, using the timeouts in `ResolverOptions`. -The returned script includes its configured or discovered URL and is never -evaluated. Windows DNS WPAD uses adapter DNS suffixes. DHCP option 252 is only -available for URL resolution in a backend-less Windows build through WinHTTP; -it is not queried by this inspection API. +SystemConfiguration, or GNOME GSettings. DHCP WPAD, DNS WPAD, and the configured +PAC URL are all inspected; +their `disabled`, `unsupported`, `unconfigured`, `not-found`, `available`, +`error-discovery`, or `error-download` status is retained with URL/error +details. PAC loading is best-effort and synchronous, using the timeouts in +`ResolverOptions`. The selected script is the first available by precedence +(DHCP, DNS, configured), reports `WpadDhcp`, `WpadDns`, or `Configured`, and is +never evaluated. Windows DNS WPAD uses adapter DNS suffixes. ## Bad-proxy feedback diff --git a/index.d.ts b/index.d.ts index 7a29f9a..b0efe38 100644 --- a/index.d.ts +++ b/index.d.ts @@ -23,18 +23,60 @@ export interface Proxy { } /** How a PAC script was selected. */ -export type PacScriptSource = 'wpad' | 'configured' | 'unknown'; +export type PacScriptSource = 'wpad-dns' | 'wpad-dhcp' | 'configured' | 'unknown'; -/** A PAC script loaded from an OS setting or DNS WPAD, but not evaluated. */ +/** A PAC script loaded from an OS setting or WPAD, but not evaluated. */ export interface PacScript { /** The configured or discovered URL from which {@link content} was loaded. */ url: string; /** The PAC JavaScript source. */ content: string; - /** Whether the script came from DNS WPAD or an explicit OS setting. */ + /** Whether the script came from DNS/DHCP WPAD or an explicit OS setting. */ source: PacScriptSource; } +/** Result of inspecting one possible PAC source. */ +export type PacSourceState = + | 'disabled' + | 'unsupported' + | 'unconfigured' + | 'not-found' + | 'available' + | 'error-discovery' + | 'error-download' + | 'unknown'; + +/** Diagnostics for one possible PAC source. */ +export interface PacSourceStatus { + state: PacSourceState; + /** Discovered or configured URL, when known. */ + url?: string; + /** Discovery or download error detail. May contain platform/network data. */ + error?: string; +} + +/** Diagnostics for one effective proxy environment variable. */ +export interface EnvironmentVariableStatus { + /** Effective spelling, for example `https_proxy` or `HTTPS_PROXY`. */ + variable: string; + /** Raw environment value. May contain credentials. */ + value: string; + /** Present when the raw value cannot be used as a proxy setting. */ + error?: string; +} + +/** + * Supported proxy environment variables captured when the resolver was + * constructed. Unset variables are omitted. Windows matches names + * case-insensitively; Unix prefers lowercase names over uppercase aliases. + */ +export interface EnvironmentProxyConfig { + httpProxy?: EnvironmentVariableStatus; + httpsProxy?: EnvironmentVariableStatus; + allProxy?: EnvironmentVariableStatus; + noProxy?: EnvironmentVariableStatus; +} + /** Normalized static proxy settings read from the operating system. */ export interface StaticProxyRules { /** Proxy for HTTP and WebSocket requests. */ @@ -76,12 +118,20 @@ export type PlatformProxyConfig = WindowsProxyConfig | MacosProxyConfig | LinuxP /** A snapshot of the current operating-system proxy configuration. */ export interface ProxyConfig { + /** Captured `http_proxy`, `https_proxy`, `all_proxy`, and `no_proxy` settings. */ + environment: EnvironmentProxyConfig; /** Whether the operating system requested automatic proxy discovery. */ autoDetect: boolean; /** The configured PAC URL, even if the script could not be loaded. */ pacUrl?: string; /** The first PAC script available by resolution precedence. */ pac?: PacScript; + /** DHCP WPAD status. Unsupported on non-Windows platforms. */ + wpadDhcp: PacSourceStatus; + /** DNS WPAD status. */ + wpadDns: PacSourceStatus; + /** Explicitly configured PAC status. */ + configuredPac: PacSourceStatus; /** Normalized static proxy settings, if configured. */ staticRules?: StaticProxyRules; /** Raw source-specific settings, if the native source was available. */ @@ -114,8 +164,10 @@ export declare class ProxyResolver { /** * Reads the operating-system proxy configuration without evaluating PAC. * - * Proxy environment variables are not included. If auto-detection is - * enabled, DNS WPAD discovery runs before the configured PAC URL is loaded. + * Includes proxy environment variables captured when this resolver was + * constructed. DHCP WPAD, DNS WPAD, and the configured PAC URL are inspected + * independently. {@link ProxyConfig.pac} contains the first available script + * by precedence (DHCP before DNS on Windows, then configured PAC). * Potentially blocking OS, DNS, and network work runs outside the JavaScript * event loop. */ @@ -167,7 +219,7 @@ export declare class ProxyResolver { export declare function resolveProxy(url: string): Promise; /** - * Reads the operating-system proxy configuration using a process-wide - * {@link ProxyResolver}. PAC scripts are loaded but never evaluated. + * Reads proxy environment and operating-system configuration using a + * process-wide {@link ProxyResolver}. PAC scripts are loaded but never evaluated. */ export declare function readProxyConfig(): Promise; \ No newline at end of file diff --git a/npm/native/src/lib.rs b/npm/native/src/lib.rs index 0aa453c..cdbf4c4 100644 --- a/npm/native/src/lib.rs +++ b/npm/native/src/lib.rs @@ -12,7 +12,8 @@ use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; use napi::{Env, Error, JsFunction, JsUnknown, Result, Status}; use napi_derive::napi; use os_proxy_resolver::{ - PacScriptSource, PlatformProxyConfig, ProxyKind, StaticProxyRules, Subscription, + EnvironmentProxyConfig, EnvironmentVariableStatus, PacScriptSource, PacSourceState, + PacSourceStatus, PlatformProxyConfig, ProxyKind, StaticProxyRules, Subscription, }; #[napi(object)] @@ -71,6 +72,69 @@ pub struct NodePacScript { pub source: String, } +#[napi(object)] +pub struct NodePacSourceStatus { + pub state: String, + pub url: Option, + pub error: Option, +} + +impl From for NodePacSourceStatus { + fn from(status: PacSourceStatus) -> Self { + Self { + state: match status.state { + PacSourceState::Disabled => "disabled", + PacSourceState::Unsupported => "unsupported", + PacSourceState::Unconfigured => "unconfigured", + PacSourceState::NotFound => "not-found", + PacSourceState::Available => "available", + PacSourceState::ErrorDiscovery => "error-discovery", + PacSourceState::ErrorDownload => "error-download", + _ => "unknown", + } + .into(), + url: status.url, + error: status.error, + } + } +} + +#[napi(object)] +pub struct NodeEnvironmentVariableStatus { + pub variable: String, + pub value: String, + pub error: Option, +} + +impl From for NodeEnvironmentVariableStatus { + fn from(status: EnvironmentVariableStatus) -> Self { + Self { + variable: status.variable, + value: status.value, + error: status.error, + } + } +} + +#[napi(object)] +pub struct NodeEnvironmentProxyConfig { + pub http_proxy: Option, + pub https_proxy: Option, + pub all_proxy: Option, + pub no_proxy: Option, +} + +impl From for NodeEnvironmentProxyConfig { + fn from(config: EnvironmentProxyConfig) -> Self { + Self { + http_proxy: config.http_proxy.map(Into::into), + https_proxy: config.https_proxy.map(Into::into), + all_proxy: config.all_proxy.map(Into::into), + no_proxy: config.no_proxy.map(Into::into), + } + } +} + #[napi(object)] pub struct NodeStaticProxyRules { pub http: Option, @@ -144,9 +208,13 @@ impl From for NodePlatformProxyConfig { #[napi(object)] pub struct NodeProxyConfig { + pub environment: NodeEnvironmentProxyConfig, pub auto_detect: bool, pub pac_url: Option, pub pac: Option, + pub wpad_dhcp: NodePacSourceStatus, + pub wpad_dns: NodePacSourceStatus, + pub configured_pac: NodePacSourceStatus, pub static_rules: Option, pub platform: Option, } @@ -154,18 +222,23 @@ pub struct NodeProxyConfig { impl From for NodeProxyConfig { fn from(config: os_proxy_resolver::ProxyConfig) -> Self { Self { + environment: config.environment.into(), auto_detect: config.auto_detect, pac_url: config.pac_url, pac: config.pac.map(|pac| NodePacScript { url: pac.url, content: pac.content, source: match pac.source { - PacScriptSource::Wpad => "wpad", + PacScriptSource::WpadDns => "wpad-dns", + PacScriptSource::WpadDhcp => "wpad-dhcp", PacScriptSource::Configured => "configured", _ => "unknown", } .into(), }), + wpad_dhcp: config.wpad_dhcp.into(), + wpad_dns: config.wpad_dns.into(), + configured_pac: config.configured_pac.into(), static_rules: config.static_rules.map(NodeStaticProxyRules::from), platform: config.platform.map(NodePlatformProxyConfig::from), } diff --git a/npm/test/smoke.js b/npm/test/smoke.js index a8bea7f..1f41b1f 100644 --- a/npm/test/smoke.js +++ b/npm/test/smoke.js @@ -26,11 +26,22 @@ async function main() { assert.strictEqual(typeof resolver.readProxyConfig, 'function'); assert.strictEqual(typeof resolver.configGeneration, 'number'); const config = await resolver.readProxyConfig(); + for (const status of Object.values(config.environment)) { + assert.strictEqual(typeof status.variable, 'string'); + assert.strictEqual(typeof status.value, 'string'); + } assert.strictEqual(typeof config.autoDetect, 'boolean'); + for (const status of [config.wpadDhcp, config.wpadDns, config.configuredPac]) { + assert.strictEqual(typeof status.state, 'string'); + assert.ok([ + 'disabled', 'unsupported', 'unconfigured', 'not-found', 'available', + 'error-discovery', 'error-download', 'unknown', + ].includes(status.state)); + } if (config.pac) { assert.strictEqual(typeof config.pac.url, 'string'); assert.strictEqual(typeof config.pac.content, 'string'); - assert.ok(['wpad', 'configured', 'unknown'].includes(config.pac.source)); + assert.ok(['wpad-dns', 'wpad-dhcp', 'configured', 'unknown'].includes(config.pac.source)); } if (config.platform) { assert.ok(['windows', 'macos', 'linux', 'unknown'].includes(config.platform.kind)); diff --git a/src/env_cfg.rs b/src/env_cfg.rs index 451e782..f3e39b7 100644 --- a/src/env_cfg.rs +++ b/src/env_cfg.rs @@ -10,7 +10,9 @@ //! fall-through to the OS config. use crate::bypass::BypassRules; -use crate::types::{with_default_port, ProxyKind}; +use crate::types::{ + with_default_port, EnvironmentProxyConfig, EnvironmentVariableStatus, ProxyKind, +}; use url::Url; #[derive(Debug, Default, Clone)] @@ -19,29 +21,42 @@ pub(crate) struct EnvConfig { https: Option, all: Option, no_proxy: BypassRules, + diagnostics: EnvironmentProxyConfig, } impl EnvConfig { pub fn from_env() -> Self { - Self::from_lookup(|name| { - std::env::var(name.to_ascii_lowercase()) - .or_else(|_| std::env::var(name.to_ascii_uppercase())) - .ok() - .filter(|v| !v.trim().is_empty()) - }) + Self::from_named_lookup(lookup_environment_variable) } + #[cfg(test)] pub fn from_lookup(get: impl Fn(&str) -> Option) -> Self { + Self::from_named_lookup(|name| get(name).map(|value| (name.to_string(), value))) + } + + fn from_named_lookup(get: impl Fn(&str) -> Option<(String, String)>) -> Self { + let (http, http_status) = inspect_proxy(get("http_proxy")); + let (https, https_status) = inspect_proxy(get("https_proxy")); + let (all, all_status) = inspect_proxy(get("all_proxy")); + let (no_proxy, no_proxy_status) = inspect_no_proxy(get("no_proxy")); EnvConfig { - http: get("http_proxy").as_deref().and_then(parse_proxy_value), - https: get("https_proxy").as_deref().and_then(parse_proxy_value), - all: get("all_proxy").as_deref().and_then(parse_proxy_value), - no_proxy: get("no_proxy") - .map(|v| BypassRules::parse([v.as_str()])) - .unwrap_or_default(), + http, + https, + all, + no_proxy, + diagnostics: EnvironmentProxyConfig { + http_proxy: http_status, + https_proxy: https_status, + all_proxy: all_status, + no_proxy: no_proxy_status, + }, } } + pub fn diagnostics(&self) -> EnvironmentProxyConfig { + self.diagnostics.clone() + } + /// `None` when the environment does not configure a proxy for this URL's /// scheme (fall through to OS config). `Some(vec![Direct])` when a proxy /// is configured but `no_proxy` excludes the host. @@ -60,6 +75,74 @@ impl EnvConfig { } } +#[cfg(windows)] +fn lookup_environment_variable(name: &str) -> Option<(String, String)> { + std::env::vars_os().find_map(|(key, value)| { + let key = key.into_string().ok()?; + if key.eq_ignore_ascii_case(name) { + value.into_string().ok().map(|value| (key, value)) + } else { + None + } + }) +} + +#[cfg(not(windows))] +fn lookup_environment_variable(name: &str) -> Option<(String, String)> { + let lowercase = name.to_ascii_lowercase(); + let uppercase = name.to_ascii_uppercase(); + std::env::var(&lowercase) + .ok() + .map(|value| (lowercase, value)) + .or_else(|| { + std::env::var(&uppercase) + .ok() + .map(|value| (uppercase, value)) + }) +} + +fn inspect_proxy( + setting: Option<(String, String)>, +) -> (Option, Option) { + let Some((variable, value)) = setting else { + return (None, None); + }; + let Some(proxy) = parse_proxy_value(&value) else { + return ( + None, + Some(EnvironmentVariableStatus { + variable, + value, + error: Some("proxy value is empty or has no host".into()), + }), + ); + }; + ( + Some(proxy), + Some(EnvironmentVariableStatus { + variable, + value, + error: None, + }), + ) +} + +fn inspect_no_proxy( + setting: Option<(String, String)>, +) -> (BypassRules, Option) { + let Some((variable, value)) = setting else { + return (BypassRules::default(), None); + }; + ( + BypassRules::parse([value.as_str()]), + Some(EnvironmentVariableStatus { + variable, + value, + error: None, + }), + ) +} + /// Parse an env proxy value: `http://host:port`, `socks5://host:port`, or a /// bare `host:port` (treated as an HTTP proxy). fn parse_proxy_value(value: &str) -> Option { @@ -176,4 +259,28 @@ mod tests { ); assert_eq!(parse_proxy_value(" "), None); } + + #[test] + fn diagnostics_capture_effective_variables_and_raw_values() { + let config = EnvConfig::from_named_lookup(|name| match name { + "http_proxy" => Some(("HTTP_PROXY".into(), "http://user:secret@proxy:8080".into())), + "https_proxy" => Some(("https_proxy".into(), " ".into())), + "no_proxy" => Some(("NO_PROXY".into(), "localhost,.internal".into())), + _ => None, + }); + let diagnostics = config.diagnostics(); + assert_eq!( + diagnostics.http_proxy, + Some(EnvironmentVariableStatus { + variable: "HTTP_PROXY".into(), + value: "http://user:secret@proxy:8080".into(), + error: None, + }) + ); + assert!(diagnostics.https_proxy.as_ref().unwrap().error.is_some()); + let no_proxy = diagnostics.no_proxy.as_ref().unwrap(); + assert_eq!(no_proxy.variable, "NO_PROXY"); + assert_eq!(no_proxy.value, "localhost,.internal"); + assert!(diagnostics.all_proxy.is_none()); + } } diff --git a/src/lib.rs b/src/lib.rs index d7f3bf4..2cb0545 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,16 +39,15 @@ //! //! | | config source | PAC/WPAD engine | change signal | //! |---|---|---|---| -//! | Windows | `WinHttpGetIEProxyConfigForCurrentUser` | selected embedded backend + DNS WPAD; WinHTTP fallback with no backend | registry notification | +//! | Windows | `WinHttpGetIEProxyConfigForCurrentUser` | selected embedded backend + DHCP/DNS WPAD; WinHTTP fallback with no backend | registry notification | //! | macOS | `SCDynamicStoreCopyProxies` | built-in [QuickJS] PAC engine + DNS WPAD | `SCDynamicStore` callback | //! | Linux | GNOME `org.gnome.system.proxy` (gsettings) | built-in [QuickJS] PAC engine + DNS WPAD | `dconf watch` / `gsettings monitor` | //! -//! On Windows, WinHTTP always reads Internet Settings. When an embedded PAC -//! backend is compiled in, normal resolution uses that backend and shared DNS -//! WPAD discovery. A backend-less Windows build instead delegates PAC and WPAD -//! resolution to WinHTTP, including DHCP option 252. DHCP-based WPAD is not -//! available with an embedded backend or on macOS/Linux; DNS-based WPAD walks -//! `wpad.` with tight timeouts. +//! On Windows, WinHTTP always reads Internet Settings. DHCP option 252 is +//! probed before the shared DNS WPAD path; an embedded PAC backend evaluates +//! the discovered script, while a backend-less build delegates PAC evaluation +//! to WinHTTP. DHCP-based WPAD is not available on macOS/Linux; DNS-based WPAD +//! walks `wpad.` with tight timeouts. //! //! # The PAC cage //! @@ -129,7 +128,8 @@ compile_error!( pub use notify::Subscription; pub use resolver::{ProxyResolver, ResolverOptions}; pub use types::{ - Error, LinuxProxyConfig, MacosProxyConfig, PacBackendKind, PacScript, PacScriptSource, + EnvironmentProxyConfig, EnvironmentVariableStatus, Error, LinuxProxyConfig, MacosProxyConfig, + PacBackendKind, PacScript, PacScriptSource, PacSourceState, PacSourceStatus, PlatformProxyConfig, ProxyConfig, ProxyKind, Result, StaticProxyRules, WindowsProxyConfig, }; diff --git a/src/platform/mod.rs b/src/platform/mod.rs index ec698ee..1ce61b8 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -26,6 +26,8 @@ mod imp; #[path = "windows.rs"] mod imp; +#[cfg(windows)] +pub(crate) use imp::detect_dhcp_wpad_url; #[cfg(all( windows, not(any( diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 207d264..2a543f7 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -26,15 +26,8 @@ use crate::types::{with_default_port, PlatformProxyConfig, ProxyKind, WindowsPro use crate::types::{Error, Result}; use std::ffi::c_void; use std::sync::Arc; -#[cfg(not(any( - feature = "pac-engine", - feature = "pac-engine-wasmtime", - feature = "pac-engine-wasmtime-jit", - feature = "pac-engine-wasm2c" -)))] -use windows_sys::Win32::Foundation::GetLastError; use windows_sys::Win32::Foundation::{ - CloseHandle, GlobalFree, ERROR_BUFFER_OVERFLOW, HANDLE, WAIT_OBJECT_0, + CloseHandle, GetLastError, GlobalFree, ERROR_BUFFER_OVERFLOW, HANDLE, WAIT_OBJECT_0, }; use windows_sys::Win32::NetworkManagement::IpHelper::{ GetAdaptersAddresses, GAA_FLAG_SKIP_ANYCAST, GAA_FLAG_SKIP_DNS_SERVER, GAA_FLAG_SKIP_MULTICAST, @@ -49,10 +42,12 @@ use windows_sys::Win32::NetworkManagement::IpHelper::{ use windows_sys::Win32::Networking::WinHttp::{ WinHttpCloseHandle, WinHttpGetProxyForUrl, WinHttpOpen, WINHTTP_ACCESS_TYPE_NO_PROXY, WINHTTP_AUTOPROXY_AUTO_DETECT, WINHTTP_AUTOPROXY_CONFIG_URL, WINHTTP_AUTOPROXY_OPTIONS, - WINHTTP_AUTO_DETECT_TYPE_DHCP, WINHTTP_AUTO_DETECT_TYPE_DNS_A, WINHTTP_PROXY_INFO, + WINHTTP_AUTO_DETECT_TYPE_DNS_A, WINHTTP_PROXY_INFO, }; use windows_sys::Win32::Networking::WinHttp::{ - WinHttpGetIEProxyConfigForCurrentUser, WINHTTP_CURRENT_USER_IE_PROXY_CONFIG, + WinHttpDetectAutoProxyConfigUrl, WinHttpGetIEProxyConfigForCurrentUser, + ERROR_WINHTTP_AUTODETECTION_FAILED, WINHTTP_AUTO_DETECT_TYPE_DHCP, + WINHTTP_CURRENT_USER_IE_PROXY_CONFIG, }; use windows_sys::Win32::Networking::WinSock::AF_UNSPEC; use windows_sys::Win32::System::Registry::{ @@ -124,6 +119,24 @@ pub(crate) fn read_config() -> OsProxyConfig { config } +/// Discover the DHCP option 252 PAC URL. WinHTTP tries DHCP before DNS when +/// both mechanisms are requested; keeping this probe separate lets inspection +/// report which mechanism supplied the script. +pub(crate) fn detect_dhcp_wpad_url() -> std::result::Result, String> { + let mut url = std::ptr::null_mut(); + if unsafe { WinHttpDetectAutoProxyConfigUrl(WINHTTP_AUTO_DETECT_TYPE_DHCP, &mut url) } == 0 { + let error = unsafe { GetLastError() }; + return if error == ERROR_WINHTTP_AUTODETECTION_FAILED { + Ok(None) + } else { + Err(format!( + "WinHttpDetectAutoProxyConfigUrl(DHCP) failed: error {error}" + )) + }; + } + Ok(unsafe { take_wide_string(url) }) +} + /// Connection-specific DNS suffixes used for DNS WPAD candidate generation. pub(crate) fn dns_search_domains() -> Vec { let flags = GAA_FLAG_SKIP_ANYCAST diff --git a/src/resolver.rs b/src/resolver.rs index e1ca654..b661fb5 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -10,8 +10,8 @@ use crate::env_cfg::EnvConfig; use crate::notify::{Notifier, Subscription}; use crate::platform::{self, OsProxyConfig}; use crate::types::{ - Error, PacBackendKind, PacScript, PacScriptSource, ProxyConfig, ProxyKind, Result, - StaticProxyRules, + Error, PacBackendKind, PacScript, PacScriptSource, PacSourceState, PacSourceStatus, + ProxyConfig, ProxyKind, Result, StaticProxyRules, }; use std::collections::HashMap; use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; @@ -194,6 +194,42 @@ struct WpadCache { script: Option>, } +struct PacInspection { + pac: Option, + status: PacSourceStatus, +} + +impl PacInspection { + fn state(state: PacSourceState) -> Self { + Self { + pac: None, + status: PacSourceStatus::new(state), + } + } + + fn available(pac: PacScript) -> Self { + Self { + status: PacSourceStatus { + state: PacSourceState::Available, + url: Some(pac.url.clone()), + error: None, + }, + pac: Some(pac), + } + } + + fn error(state: PacSourceState, url: Option, error: String) -> Self { + Self { + pac: None, + status: PacSourceStatus { + state, + url, + error: Some(error), + }, + } + } +} + #[cfg(feature = "tokio")] #[derive(Clone, PartialEq, Eq, Hash)] struct AsyncResolutionKey { @@ -482,23 +518,16 @@ impl ProxyResolver { /// Read the current proxy configuration from the operating system. /// - /// When automatic discovery is enabled, this performs DNS WPAD discovery - /// and loads the first usable `wpad.dat`. If discovery finds nothing, the - /// explicitly configured PAC URL is loaded instead. The returned PAC - /// source is never evaluated. Proxy environment variables are not part of - /// this operating-system snapshot. + /// Includes captured proxy environment variables and independently inspects + /// DHCP WPAD, DNS WPAD, and the configured PAC, including failures. `pac` + /// is the first available script by precedence (DHCP, DNS, configured) and + /// is never evaluated. pub fn read_proxy_config(&self) -> ProxyConfig { let config = self.os_config(); - self.read_proxy_config_with( - config, - || { - crate::wpad::discover( - self.inner.options.wpad_dns_timeout, - self.inner.options.wpad_fetch_timeout, - ) - }, - |url| crate::fetch::fetch_pac(url, self.inner.options.pac_fetch_timeout), - ) + let wpad_dhcp = self.inspect_dhcp_wpad(config.auto_detect); + let wpad_dns = self.inspect_dns_wpad(config.auto_detect); + let configured_pac = self.inspect_configured_pac(config.pac_url.as_deref()); + self.build_proxy_config(config, wpad_dhcp, wpad_dns, configured_pac) } /// Resolve the ordered proxy list without blocking an async-runtime worker. @@ -723,49 +752,127 @@ impl ProxyResolver { ); } - fn read_proxy_config_with( + fn build_proxy_config( &self, config: OsProxyConfig, - discover_wpad: impl FnOnce() -> Option, - fetch_configured: impl FnOnce(&str) -> Result, + wpad_dhcp: PacInspection, + wpad_dns: PacInspection, + configured_pac: PacInspection, ) -> ProxyConfig { - let pac = if config.auto_detect { - discover_wpad().map(|discovered| PacScript { - url: discovered.url, - content: discovered.content, - source: PacScriptSource::Wpad, - }) - } else { - None - } - .or_else(|| { - let url = config.pac_url.as_deref()?; - match fetch_configured(url) { - Ok(content) => Some(PacScript { - url: url.to_string(), - content, - source: PacScriptSource::Configured, - }), - Err(error) => { - log::warn!("{error}"); - None - } - } - }); + let PacInspection { + pac: dhcp_pac, + status: wpad_dhcp, + } = wpad_dhcp; + let PacInspection { + pac: dns_pac, + status: wpad_dns, + } = wpad_dns; + let PacInspection { + pac: configured, + status: configured_pac, + } = configured_pac; + let pac = dhcp_pac.or(dns_pac).or(configured); let static_rules = config.static_rules.as_ref().map(|rules| StaticProxyRules { http: rules.http.clone(), https: rules.https.clone(), socks: rules.socks.clone(), }); ProxyConfig { + environment: self.inner.env.diagnostics(), auto_detect: config.auto_detect, pac_url: config.pac_url, pac, + wpad_dhcp, + wpad_dns, + configured_pac, static_rules, platform: config.platform, } } + fn inspect_dhcp_wpad(&self, enabled: bool) -> PacInspection { + #[cfg(not(windows))] + { + let _ = enabled; + PacInspection::state(PacSourceState::Unsupported) + } + #[cfg(windows)] + { + if !enabled { + return PacInspection::state(PacSourceState::Disabled); + } + let url = match platform::detect_dhcp_wpad_url() { + Ok(Some(url)) => url, + Ok(None) => return PacInspection::state(PacSourceState::NotFound), + Err(error) => { + return PacInspection::error(PacSourceState::ErrorDiscovery, None, error) + } + }; + match crate::fetch::fetch_pac(&url, self.inner.options.wpad_fetch_timeout) { + Ok(content) if content.contains("FindProxyForURL") => { + PacInspection::available(PacScript { + url, + content, + source: PacScriptSource::WpadDhcp, + }) + } + Ok(_) => PacInspection::error( + PacSourceState::ErrorDownload, + Some(url.clone()), + format!("{url} does not look like a PAC script"), + ), + Err(error) => PacInspection::error( + PacSourceState::ErrorDownload, + Some(url), + error.to_string(), + ), + } + } + } + + fn inspect_dns_wpad(&self, enabled: bool) -> PacInspection { + if !enabled { + return PacInspection::state(PacSourceState::Disabled); + } + let (pac, status) = crate::wpad::inspect( + self.inner.options.wpad_dns_timeout, + self.inner.options.wpad_fetch_timeout, + ); + PacInspection { + pac: pac.map(|pac| PacScript { + url: pac.url, + content: pac.content, + source: pac.source, + }), + status, + } + } + + fn inspect_configured_pac(&self, url: Option<&str>) -> PacInspection { + let Some(url) = url else { + return PacInspection::state(PacSourceState::Unconfigured); + }; + match crate::fetch::fetch_pac(url, self.inner.options.pac_fetch_timeout) { + Ok(content) if content.contains("FindProxyForURL") => { + PacInspection::available(PacScript { + url: url.to_string(), + content, + source: PacScriptSource::Configured, + }) + } + Ok(_) => PacInspection::error( + PacSourceState::ErrorDownload, + Some(url.to_string()), + format!("{url} does not look like a PAC script"), + ), + Err(error) => PacInspection::error( + PacSourceState::ErrorDownload, + Some(url.to_string()), + error.to_string(), + ), + } + } + fn demote_bad(&self, list: Vec) -> Vec { let mut retry = lock(&self.inner.retry); let cooldown = self.inner.options.retry_cooldown; @@ -940,11 +1047,9 @@ impl ProxyResolver { c.generation == generation && c.at.elapsed() < ttl }); if !valid { - let script = crate::wpad::discover( - self.inner.options.wpad_dns_timeout, - self.inner.options.wpad_fetch_timeout, - ) - .map(|pac| Arc::::from(pac.content)); + let script = self + .discover_wpad() + .map(|pac| Arc::::from(pac.content)); *cache = Some(WpadCache { generation, at: Instant::now(), @@ -956,6 +1061,37 @@ impl ProxyResolver { self.eval_for_resolution(&script, url) } + #[cfg(any( + not(windows), + feature = "pac-engine", + feature = "pac-engine-wasmtime", + feature = "pac-engine-wasmtime-jit", + feature = "pac-engine-wasm2c" + ))] + fn discover_wpad(&self) -> Option { + #[cfg(windows)] + if let Ok(Some(url)) = platform::detect_dhcp_wpad_url() { + match crate::fetch::fetch_pac(&url, self.inner.options.wpad_fetch_timeout) { + Ok(content) if content.contains("FindProxyForURL") => { + log::info!("WPAD: using DHCP URL {url}"); + return Some(crate::wpad::DiscoveredPac { + url, + content, + source: PacScriptSource::WpadDhcp, + }); + } + Ok(_) => log::warn!("WPAD: DHCP URL {url} does not look like a PAC script"), + Err(error) => log::debug!("WPAD: {error}"), + } + } + + crate::wpad::inspect( + self.inner.options.wpad_dns_timeout, + self.inner.options.wpad_fetch_timeout, + ) + .0 + } + /// Best-effort local IP for PAC `myIpAddress()`, so the engine doesn't /// fall back to resolving the hostname (slow, often wrong on multi-homed /// machines). A connected UDP socket never sends a packet. @@ -1191,24 +1327,24 @@ mod tests { }), ..Default::default() }; - let snapshot = resolver.read_proxy_config_with( + let dns_pac = PacScript { + url: "http://wpad.example/wpad.dat".into(), + content: "function FindProxyForURL() { return 'DIRECT'; }".into(), + source: PacScriptSource::WpadDns, + }; + let configured_pac = PacScript { + url: "https://configured.example/proxy.pac".into(), + content: "configured script".into(), + source: PacScriptSource::Configured, + }; + let snapshot = resolver.build_proxy_config( config, - || { - Some(crate::wpad::DiscoveredPac { - url: "http://wpad.example/wpad.dat".into(), - content: "function FindProxyForURL() { return 'DIRECT'; }".into(), - }) - }, - |_| panic!("configured PAC must not load when WPAD succeeds"), - ); - assert_eq!( - snapshot.pac, - Some(PacScript { - url: "http://wpad.example/wpad.dat".into(), - content: "function FindProxyForURL() { return 'DIRECT'; }".into(), - source: PacScriptSource::Wpad, - }) + PacInspection::state(PacSourceState::NotFound), + PacInspection::available(dns_pac.clone()), + PacInspection::available(configured_pac), ); + assert_eq!(snapshot.pac, Some(dns_pac)); + assert_eq!(snapshot.configured_pac.state, PacSourceState::Available); assert_eq!( snapshot.static_rules.unwrap().http, Some(ProxyKind::Http("proxy.example:8080".into())) @@ -1223,6 +1359,26 @@ mod tests { assert!(resolver.inner.pac.get().is_none()); } + #[test] + fn proxy_config_preserves_dhcp_wpad_source() { + let resolver = ProxyResolver::with_env(ResolverOptions::default(), env(&[])); + let snapshot = resolver.build_proxy_config( + OsProxyConfig { + auto_detect: true, + ..Default::default() + }, + PacInspection::available(PacScript { + url: "http://dhcp.example/proxy.pac".into(), + content: "function FindProxyForURL() { return 'DIRECT'; }".into(), + source: PacScriptSource::WpadDhcp, + }), + PacInspection::state(PacSourceState::NotFound), + PacInspection::state(PacSourceState::Unconfigured), + ); + + assert_eq!(snapshot.pac.unwrap().source, PacScriptSource::WpadDhcp); + } + #[test] fn proxy_config_loads_configured_pac_after_wpad_miss() { let resolver = ProxyResolver::with_env(ResolverOptions::default(), env(&[])); @@ -1231,22 +1387,50 @@ mod tests { pac_url: Some("https://configured.example/proxy.pac".into()), ..Default::default() }; - let snapshot = resolver.read_proxy_config_with( + let configured_pac = PacScript { + url: "https://configured.example/proxy.pac".into(), + content: "configured script".into(), + source: PacScriptSource::Configured, + }; + let snapshot = resolver.build_proxy_config( config, - || None, - |url| { - assert_eq!(url, "https://configured.example/proxy.pac"); - Ok("configured script".into()) - }, + PacInspection::state(PacSourceState::NotFound), + PacInspection::state(PacSourceState::NotFound), + PacInspection::available(configured_pac.clone()), ); + assert_eq!(snapshot.pac, Some(configured_pac)); + } + + #[test] + fn configured_pac_reports_download_error() { + let resolver = ProxyResolver::with_env(ResolverOptions::default(), env(&[])); + let inspection = resolver.inspect_configured_pac(Some("file:///nonexistent/proxy.pac")); + assert_eq!(inspection.status.state, PacSourceState::ErrorDownload); assert_eq!( - snapshot.pac, - Some(PacScript { - url: "https://configured.example/proxy.pac".into(), - content: "configured script".into(), - source: PacScriptSource::Configured, - }) + inspection.status.url.as_deref(), + Some("file:///nonexistent/proxy.pac") ); + assert!(inspection.status.error.is_some()); + } + + #[test] + fn configured_pac_rejects_non_pac_content() { + let dir = std::env::temp_dir().join("os-proxy-resolver-configured-pac-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("invalid.pac"); + std::fs::write(&path, "not a PAC script").unwrap(); + let url = url::Url::from_file_path(&path).unwrap(); + let resolver = ProxyResolver::with_env(ResolverOptions::default(), env(&[])); + + let inspection = resolver.inspect_configured_pac(Some(url.as_str())); + + assert!(inspection.pac.is_none()); + assert_eq!(inspection.status.state, PacSourceState::ErrorDownload); + assert!(inspection + .status + .error + .as_deref() + .is_some_and(|error| error.contains("does not look like a PAC script"))); } #[cfg(feature = "tokio")] diff --git a/src/types.rs b/src/types.rs index d93b1fd..0e0ae74 100644 --- a/src/types.rs +++ b/src/types.rs @@ -7,13 +7,15 @@ use std::fmt; /// A snapshot of the proxy configuration read from the operating system. /// -/// This API does not consider proxy environment variables and never evaluates -/// a PAC script. When auto-detection is enabled, DNS WPAD discovery is -/// attempted before the explicitly configured PAC URL, matching proxy -/// resolution precedence. +/// This API never evaluates a PAC script. It includes proxy environment +/// variables captured at resolver construction and dynamically reads the OS +/// configuration. When auto-detection is enabled, WPAD discovery is attempted +/// before the explicitly configured PAC URL (DHCP before DNS on Windows). #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub struct ProxyConfig { + /// Proxy environment variables captured when the resolver was constructed. + pub environment: EnvironmentProxyConfig, /// Whether the operating system requested automatic proxy discovery. pub auto_detect: bool, /// The explicit PAC URL configured by the operating system, whether or not @@ -22,13 +24,82 @@ pub struct ProxyConfig { /// The first PAC script available by resolution precedence, if one could /// be discovered or loaded. pub pac: Option, + /// DHCP WPAD inspection result. + pub wpad_dhcp: PacSourceStatus, + /// DNS WPAD inspection result. + pub wpad_dns: PacSourceStatus, + /// Explicitly configured PAC inspection result. + pub configured_pac: PacSourceStatus, /// Normalized static proxy settings, if configured. pub static_rules: Option, /// Source-specific settings retained where the platform exposes them. pub platform: Option, } -/// A PAC script loaded from an operating-system setting or DNS WPAD. +/// Effective proxy environment variables. Unix prefers lowercase names over +/// uppercase aliases; Windows matches names case-insensitively. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[non_exhaustive] +pub struct EnvironmentProxyConfig { + pub http_proxy: Option, + pub https_proxy: Option, + pub all_proxy: Option, + pub no_proxy: Option, +} + +/// Diagnostic status for one supported proxy environment variable. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct EnvironmentVariableStatus { + /// Effective variable spelling, for example `https_proxy` or `HTTPS_PROXY`. + pub variable: String, + /// Raw environment value. Proxy values may contain credentials. + pub value: String, + pub error: Option, +} + +/// Diagnostic status for one possible PAC source. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct PacSourceStatus { + pub state: PacSourceState, + /// Discovered or configured URL, when known. + pub url: Option, + /// Discovery or download error detail. May contain platform/network data. + pub error: Option, +} + +impl PacSourceStatus { + pub(crate) fn new(state: PacSourceState) -> Self { + Self { + state, + url: None, + error: None, + } + } +} + +/// Outcome of inspecting a possible PAC source. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum PacSourceState { + /// The source is supported but disabled by OS configuration. + Disabled, + /// The platform does not support inspecting this source. + Unsupported, + /// No explicit PAC URL is configured. + Unconfigured, + /// Discovery completed without finding a PAC URL. + NotFound, + /// A usable PAC script was loaded. + Available, + /// Discovery failed before a PAC URL was available. + ErrorDiscovery, + /// A known PAC URL could not be downloaded or did not contain a PAC script. + ErrorDownload, +} + +/// A PAC script loaded from an operating-system setting or WPAD. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub struct PacScript { @@ -45,7 +116,9 @@ pub struct PacScript { #[non_exhaustive] pub enum PacScriptSource { /// Found through DNS WPAD (`http://wpad./wpad.dat`). - Wpad, + WpadDns, + /// Found through DHCP WPAD (option 252). + WpadDhcp, /// Loaded from the explicit PAC URL configured by the operating system. Configured, } diff --git a/src/wpad.rs b/src/wpad.rs index 9590b52..1090059 100644 --- a/src/wpad.rs +++ b/src/wpad.rs @@ -3,9 +3,8 @@ * Licensed under the MIT License. See LICENSE.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -//! DNS-based WPAD discovery. Windows uses this path when an embedded PAC -//! backend is compiled in. A backend-less Windows build delegates WPAD, -//! including DHCP option 252, to WinHTTP. DHCP-based discovery is otherwise a +//! DNS-based WPAD discovery. Windows probes DHCP option 252 separately before +//! falling back to this shared DNS path. DHCP-based discovery is otherwise a //! documented non-goal here. //! //! Strategy: take the DNS search domains from the OS resolver configuration @@ -21,6 +20,7 @@ //! fetch gets a slightly longer one, and the caller caches negative results. use crate::fetch::fetch_pac; +use crate::types::{PacScriptSource, PacSourceState, PacSourceStatus}; use std::net::ToSocketAddrs; use std::sync::mpsc; use std::time::Duration; @@ -29,49 +29,78 @@ use std::time::Duration; pub(crate) struct DiscoveredPac { pub url: String, pub content: String, + pub source: PacScriptSource, } -/// Returns the fetched `wpad.dat` PAC script and its discovered URL, or `None` -/// when this network has no usable DNS WPAD. -pub(crate) fn discover(dns_timeout: Duration, fetch_timeout: Duration) -> Option { - discover_with_domains(&search_domains(), dns_timeout, fetch_timeout) -} - -fn discover_with_domains( - domains: &[String], +pub(crate) fn inspect( dns_timeout: Duration, fetch_timeout: Duration, -) -> Option { - discover_with_domains_using( - domains, +) -> (Option, PacSourceStatus) { + inspect_with_domains_using( + &search_domains(), |candidate| resolves(candidate, dns_timeout), |url| fetch_pac(url, fetch_timeout), ) } -fn discover_with_domains_using( +fn inspect_with_domains_using( domains: &[String], - mut resolves: impl FnMut(&str) -> bool, + mut resolves: impl FnMut(&str) -> std::result::Result, mut fetch: impl FnMut(&str) -> crate::Result, -) -> Option { +) -> (Option, PacSourceStatus) { + let mut error = None; for candidate in candidate_hosts(domains) { - if !resolves(&candidate) { - continue; + match resolves(&candidate) { + Ok(true) => {} + Ok(false) => continue, + Err(message) => { + error.get_or_insert((PacSourceState::ErrorDiscovery, None, message)); + continue; + } } let url = format!("http://{candidate}/wpad.dat"); match fetch(&url) { Ok(script) if script.contains("FindProxyForURL") => { log::info!("WPAD: using {url}"); - return Some(DiscoveredPac { - url, - content: script, - }); + return ( + Some(DiscoveredPac { + url: url.clone(), + content: script, + source: PacScriptSource::WpadDns, + }), + PacSourceStatus { + state: PacSourceState::Available, + url: Some(url), + error: None, + }, + ); + } + Ok(_) => { + let message = format!("{url} does not look like a PAC script"); + log::warn!("WPAD: {message}"); + error = Some((PacSourceState::ErrorDownload, Some(url), message)); + } + Err(fetch_error) => { + log::debug!("WPAD: {fetch_error}"); + error = Some(( + PacSourceState::ErrorDownload, + Some(url), + fetch_error.to_string(), + )); } - Ok(_) => log::warn!("WPAD: {url} does not look like a PAC script, skipping"), - Err(e) => log::debug!("WPAD: {e}"), } } - None + match error { + Some((state, url, error)) => ( + None, + PacSourceStatus { + state, + url, + error: Some(error), + }, + ), + None => (None, PacSourceStatus::new(PacSourceState::NotFound)), + } } /// `wpad.` candidates from the search domains, deduplicated, order-preserving. @@ -126,17 +155,21 @@ fn search_domains() -> Vec { /// DNS probe with a hard timeout. `ToSocketAddrs` has no timeout knob, so the /// lookup runs on a throwaway thread and we stop waiting after `timeout` (the /// thread finishes in the background; the result is discarded). -fn resolves(host: &str, timeout: Duration) -> bool { +fn resolves(host: &str, timeout: Duration) -> std::result::Result { let (tx, rx) = mpsc::sync_channel(1); let host_owned = format!("{host}:80"); - std::thread::Builder::new() + let thread_started = std::thread::Builder::new() .name("os-proxy-wpad-dns".into()) .spawn(move || { let ok = host_owned.to_socket_addrs().map(|mut a| a.next().is_some()); let _ = tx.send(ok.unwrap_or(false)); }) - .is_ok() - && rx.recv_timeout(timeout).unwrap_or(false) + .is_ok(); + if !thread_started { + return Err("failed to start DNS probe".into()); + } + rx.recv_timeout(timeout) + .map_err(|error| format!("DNS probe for {host} failed: {error}")) } #[cfg(test)] @@ -203,16 +236,40 @@ mod tests { #[test] fn returns_discovered_url_with_script() { - let pac = discover_with_domains_using( + let (pac, status) = inspect_with_domains_using( &["corp.example.com".into()], - |host| host == "wpad.corp.example.com", + |host| Ok(host == "wpad.corp.example.com"), |url| { assert_eq!(url, "http://wpad.corp.example.com/wpad.dat"); Ok("function FindProxyForURL() { return 'DIRECT'; }".into()) }, - ) - .unwrap(); + ); + let pac = pac.unwrap(); assert_eq!(pac.url, "http://wpad.corp.example.com/wpad.dat"); assert!(pac.content.contains("FindProxyForURL")); + assert_eq!(pac.source, PacScriptSource::WpadDns); + assert_eq!(status.state, PacSourceState::Available); + } + + #[test] + fn distinguishes_discovery_and_download_errors() { + let (_, discovery) = inspect_with_domains_using( + &["corp.example.com".into()], + |_| Err("DNS timed out".into()), + |_| panic!("fetch must not run after discovery failure"), + ); + assert_eq!(discovery.state, PacSourceState::ErrorDiscovery); + assert_eq!(discovery.error.as_deref(), Some("DNS timed out")); + + let (_, download) = inspect_with_domains_using( + &["corp.example.com".into()], + |host| Ok(host == "wpad.corp.example.com"), + |_| Err(crate::Error::PacFetch("connection refused".into())), + ); + assert_eq!(download.state, PacSourceState::ErrorDownload); + assert_eq!( + download.url.as_deref(), + Some("http://wpad.corp.example.com/wpad.dat") + ); } }