From ea62c967ca93b5754c5c4a6aa4e086212ba3e583 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Fri, 17 Jul 2026 10:08:54 +0200 Subject: [PATCH 1/3] Distinguish WPAD source --- README.md | 21 ++++++------ index.d.ts | 9 ++--- npm/native/src/lib.rs | 3 +- npm/test/smoke.js | 2 +- src/lib.rs | 13 ++++---- src/platform/mod.rs | 2 ++ src/platform/windows.rs | 29 ++++++++++------ src/resolver.rs | 74 +++++++++++++++++++++++++++++++---------- src/types.rs | 12 ++++--- src/wpad.rs | 9 +++-- 10 files changed, 114 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 73f44a5..e7ce4e0 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 @@ -186,13 +186,12 @@ if let Some(pac) = config.pac { The snapshot includes 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. +included. If auto-detection is enabled, the call performs WPAD discovery first +(DHCP before DNS on Windows); 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, reports `WpadDhcp` or `WpadDns`, 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..2f1967b 100644 --- a/index.d.ts +++ b/index.d.ts @@ -23,15 +23,15 @@ 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; } @@ -115,7 +115,8 @@ 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. + * enabled, WPAD discovery runs before the configured PAC URL is loaded + * (DHCP before DNS on Windows). * Potentially blocking OS, DNS, and network work runs outside the JavaScript * event loop. */ diff --git a/npm/native/src/lib.rs b/npm/native/src/lib.rs index 0aa453c..3238c96 100644 --- a/npm/native/src/lib.rs +++ b/npm/native/src/lib.rs @@ -160,7 +160,8 @@ impl From for NodeProxyConfig { 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", } diff --git a/npm/test/smoke.js b/npm/test/smoke.js index a8bea7f..8c9cac9 100644 --- a/npm/test/smoke.js +++ b/npm/test/smoke.js @@ -30,7 +30,7 @@ async function main() { 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/lib.rs b/src/lib.rs index d7f3bf4..547e552 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 //! 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..eeee0ec 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,11 @@ 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, + 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 +118,21 @@ 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() -> Option { + let mut url = std::ptr::null_mut(); + if unsafe { WinHttpDetectAutoProxyConfigUrl(WINHTTP_AUTO_DETECT_TYPE_DHCP, &mut url) } == 0 { + log::debug!( + "WinHttpDetectAutoProxyConfigUrl(DHCP) failed: error {}", + unsafe { GetLastError() } + ); + return None; + } + 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..b221e69 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -482,21 +482,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. + /// When automatic discovery is enabled, this performs WPAD discovery and + /// loads the first usable `wpad.dat` (DHCP before DNS on Windows). 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. 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, - ) - }, + || self.discover_wpad(), |url| crate::fetch::fetch_pac(url, self.inner.options.pac_fetch_timeout), ) } @@ -733,7 +728,7 @@ impl ProxyResolver { discover_wpad().map(|discovered| PacScript { url: discovered.url, content: discovered.content, - source: PacScriptSource::Wpad, + source: discovered.source, }) } else { None @@ -940,11 +935,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 +949,29 @@ impl ProxyResolver { self.eval_for_resolution(&script, url) } + fn discover_wpad(&self) -> Option { + #[cfg(windows)] + if let 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::discover( + self.inner.options.wpad_dns_timeout, + self.inner.options.wpad_fetch_timeout, + ) + } + /// 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. @@ -1197,6 +1213,7 @@ mod tests { Some(crate::wpad::DiscoveredPac { url: "http://wpad.example/wpad.dat".into(), content: "function FindProxyForURL() { return 'DIRECT'; }".into(), + source: PacScriptSource::WpadDns, }) }, |_| panic!("configured PAC must not load when WPAD succeeds"), @@ -1206,7 +1223,7 @@ mod tests { Some(PacScript { url: "http://wpad.example/wpad.dat".into(), content: "function FindProxyForURL() { return 'DIRECT'; }".into(), - source: PacScriptSource::Wpad, + source: PacScriptSource::WpadDns, }) ); assert_eq!( @@ -1223,6 +1240,27 @@ 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.read_proxy_config_with( + OsProxyConfig { + auto_detect: true, + ..Default::default() + }, + || { + Some(crate::wpad::DiscoveredPac { + url: "http://dhcp.example/proxy.pac".into(), + content: "function FindProxyForURL() { return 'DIRECT'; }".into(), + source: PacScriptSource::WpadDhcp, + }) + }, + |_| panic!("configured PAC must not load when DHCP WPAD succeeds"), + ); + + 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(&[])); diff --git a/src/types.rs b/src/types.rs index d93b1fd..197a0f9 100644 --- a/src/types.rs +++ b/src/types.rs @@ -8,9 +8,9 @@ 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. +/// a PAC script. When auto-detection is enabled, WPAD discovery is attempted +/// before the explicitly configured PAC URL (DHCP before DNS on Windows), +/// matching proxy resolution precedence. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub struct ProxyConfig { @@ -28,7 +28,7 @@ pub struct ProxyConfig { pub platform: Option, } -/// A PAC script loaded from an operating-system setting or DNS WPAD. +/// A PAC script loaded from an operating-system setting or WPAD. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub struct PacScript { @@ -45,7 +45,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..1ec4d4d 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; use std::net::ToSocketAddrs; use std::sync::mpsc; use std::time::Duration; @@ -29,6 +29,7 @@ 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` @@ -65,6 +66,7 @@ fn discover_with_domains_using( return Some(DiscoveredPac { url, content: script, + source: PacScriptSource::WpadDns, }); } Ok(_) => log::warn!("WPAD: {url} does not look like a PAC script, skipping"), @@ -214,5 +216,6 @@ mod tests { .unwrap(); assert_eq!(pac.url, "http://wpad.corp.example.com/wpad.dat"); assert!(pac.content.contains("FindProxyForURL")); + assert_eq!(pac.source, PacScriptSource::WpadDns); } } From 082d9477a66a5a0b4d07c42a45e491d0d56638b6 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Fri, 17 Jul 2026 10:58:22 +0200 Subject: [PATCH 2/3] Include other PAC source details --- README.md | 13 +- index.d.ts | 33 ++++- npm/native/src/lib.rs | 36 +++++- npm/test/smoke.js | 7 ++ src/lib.rs | 3 +- src/platform/windows.rs | 20 +-- src/resolver.rs | 272 ++++++++++++++++++++++++++++------------ src/types.rs | 47 +++++++ src/wpad.rs | 120 +++++++++++++----- 9 files changed, 422 insertions(+), 129 deletions(-) diff --git a/README.md b/README.md index e7ce4e0..b77edca 100644 --- a/README.md +++ b/README.md @@ -186,12 +186,13 @@ if let Some(pac) = config.pac { The snapshot includes 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 WPAD discovery first -(DHCP before DNS on Windows); 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, reports `WpadDhcp` or `WpadDns`, and is never evaluated. Windows -DNS WPAD uses adapter DNS suffixes. +included. DHCP WPAD, DNS WPAD, and the configured PAC URL are all inspected; +their `available`, `not-found`, `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 2f1967b..4637201 100644 --- a/index.d.ts +++ b/index.d.ts @@ -35,6 +35,26 @@ export interface PacScript { 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; +} + /** Normalized static proxy settings read from the operating system. */ export interface StaticProxyRules { /** Proxy for HTTP and WebSocket requests. */ @@ -82,6 +102,12 @@ export interface ProxyConfig { 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,9 +140,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, WPAD discovery runs before the configured PAC URL is loaded - * (DHCP before DNS on Windows). + * Proxy environment variables are not included. 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. */ diff --git a/npm/native/src/lib.rs b/npm/native/src/lib.rs index 3238c96..46e6f97 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, + PacScriptSource, PacSourceState, PacSourceStatus, PlatformProxyConfig, ProxyKind, + StaticProxyRules, Subscription, }; #[napi(object)] @@ -71,6 +72,33 @@ 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 NodeStaticProxyRules { pub http: Option, @@ -147,6 +175,9 @@ pub struct NodeProxyConfig { 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, } @@ -167,6 +198,9 @@ impl From for NodeProxyConfig { } .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 8c9cac9..0dd5bec 100644 --- a/npm/test/smoke.js +++ b/npm/test/smoke.js @@ -27,6 +27,13 @@ async function main() { assert.strictEqual(typeof resolver.configGeneration, 'number'); const config = await resolver.readProxyConfig(); 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'); diff --git a/src/lib.rs b/src/lib.rs index 547e552..9bf1d11 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -129,7 +129,8 @@ pub use notify::Subscription; pub use resolver::{ProxyResolver, ResolverOptions}; pub use types::{ Error, LinuxProxyConfig, MacosProxyConfig, PacBackendKind, PacScript, PacScriptSource, - PlatformProxyConfig, ProxyConfig, ProxyKind, Result, StaticProxyRules, WindowsProxyConfig, + PacSourceState, PacSourceStatus, PlatformProxyConfig, ProxyConfig, ProxyKind, Result, + StaticProxyRules, WindowsProxyConfig, }; /// Size in bytes of the embedded ahead-of-time-compiled PAC guest module — diff --git a/src/platform/windows.rs b/src/platform/windows.rs index eeee0ec..2a543f7 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -46,7 +46,8 @@ use windows_sys::Win32::Networking::WinHttp::{ }; use windows_sys::Win32::Networking::WinHttp::{ WinHttpDetectAutoProxyConfigUrl, WinHttpGetIEProxyConfigForCurrentUser, - WINHTTP_AUTO_DETECT_TYPE_DHCP, WINHTTP_CURRENT_USER_IE_PROXY_CONFIG, + 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::{ @@ -121,16 +122,19 @@ pub(crate) fn read_config() -> OsProxyConfig { /// 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() -> Option { +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 { - log::debug!( - "WinHttpDetectAutoProxyConfigUrl(DHCP) failed: error {}", - unsafe { GetLastError() } - ); - return None; + let error = unsafe { GetLastError() }; + return if error == ERROR_WINHTTP_AUTODETECTION_FAILED { + Ok(None) + } else { + Err(format!( + "WinHttpDetectAutoProxyConfigUrl(DHCP) failed: error {error}" + )) + }; } - unsafe { take_wide_string(url) } + Ok(unsafe { take_wide_string(url) }) } /// Connection-specific DNS suffixes used for DNS WPAD candidate generation. diff --git a/src/resolver.rs b/src/resolver.rs index b221e69..42b0fd8 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,18 +518,16 @@ impl ProxyResolver { /// Read the current proxy configuration from the operating system. /// - /// When automatic discovery is enabled, this performs WPAD discovery and - /// loads the first usable `wpad.dat` (DHCP before DNS on Windows). If - /// discovery finds nothing, the explicitly configured PAC URL is loaded - /// instead. The returned PAC source is never evaluated. Proxy environment + /// Inspects DHCP WPAD, DNS WPAD, and the configured PAC independently, + /// including failures. `pac` is the first available script by precedence + /// (DHCP, DNS, configured) and is never evaluated. Proxy environment /// variables are not part of this operating-system snapshot. pub fn read_proxy_config(&self) -> ProxyConfig { let config = self.os_config(); - self.read_proxy_config_with( - config, - || self.discover_wpad(), - |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. @@ -718,35 +752,26 @@ 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: discovered.source, - }) - } 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(), @@ -756,11 +781,90 @@ impl ProxyResolver { 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) => PacInspection::available(PacScript { + url: url.to_string(), + content, + source: PacScriptSource::Configured, + }), + 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; @@ -949,9 +1053,16 @@ 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 Some(url) = platform::detect_dhcp_wpad_url() { + 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}"); @@ -966,10 +1077,11 @@ impl ProxyResolver { } } - crate::wpad::discover( + 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 @@ -1207,25 +1319,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(), - source: PacScriptSource::WpadDns, - }) - }, - |_| 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::WpadDns, - }) + 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())) @@ -1243,19 +1354,18 @@ mod tests { #[test] fn proxy_config_preserves_dhcp_wpad_source() { let resolver = ProxyResolver::with_env(ResolverOptions::default(), env(&[])); - let snapshot = resolver.read_proxy_config_with( + let snapshot = resolver.build_proxy_config( OsProxyConfig { auto_detect: true, ..Default::default() }, - || { - Some(crate::wpad::DiscoveredPac { - url: "http://dhcp.example/proxy.pac".into(), - content: "function FindProxyForURL() { return 'DIRECT'; }".into(), - source: PacScriptSource::WpadDhcp, - }) - }, - |_| panic!("configured PAC must not load when DHCP WPAD succeeds"), + 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); @@ -1269,22 +1379,30 @@ 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()); } #[cfg(feature = "tokio")] diff --git a/src/types.rs b/src/types.rs index 197a0f9..33dcaf0 100644 --- a/src/types.rs +++ b/src/types.rs @@ -22,12 +22,59 @@ 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, } +/// 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] diff --git a/src/wpad.rs b/src/wpad.rs index 1ec4d4d..1090059 100644 --- a/src/wpad.rs +++ b/src/wpad.rs @@ -20,7 +20,7 @@ //! fetch gets a slightly longer one, and the caller caches negative results. use crate::fetch::fetch_pac; -use crate::types::PacScriptSource; +use crate::types::{PacScriptSource, PacSourceState, PacSourceStatus}; use std::net::ToSocketAddrs; use std::sync::mpsc; use std::time::Duration; @@ -32,48 +32,75 @@ pub(crate) struct DiscoveredPac { 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, - source: PacScriptSource::WpadDns, - }); + 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. @@ -128,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)] @@ -205,17 +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") + ); } } From 4890d80714bb2f9f5097d62189f1c5a7edcb151a Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Fri, 17 Jul 2026 11:48:59 +0200 Subject: [PATCH 3/3] Include env variables --- README.md | 20 ++++--- index.d.ts | 36 ++++++++++-- npm/native/src/lib.rs | 42 ++++++++++++- npm/test/smoke.js | 4 ++ src/env_cfg.rs | 133 +++++++++++++++++++++++++++++++++++++----- src/lib.rs | 6 +- src/resolver.rs | 46 ++++++++++++--- src/types.rs | 32 ++++++++-- 8 files changed, 273 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index b77edca..518a475 100644 --- a/README.md +++ b/README.md @@ -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. DHCP WPAD, DNS WPAD, and the configured PAC URL are all inspected; -their `available`, `not-found`, `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. +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 4637201..b0efe38 100644 --- a/index.d.ts +++ b/index.d.ts @@ -55,6 +55,28 @@ export interface PacSourceStatus { 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. */ @@ -96,6 +118,8 @@ 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. */ @@ -140,10 +164,10 @@ export declare class ProxyResolver { /** * Reads the operating-system proxy configuration without evaluating PAC. * - * Proxy environment variables are not included. 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). + * 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. */ @@ -195,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 46e6f97..cdbf4c4 100644 --- a/npm/native/src/lib.rs +++ b/npm/native/src/lib.rs @@ -12,8 +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, PacSourceState, PacSourceStatus, PlatformProxyConfig, ProxyKind, - StaticProxyRules, Subscription, + EnvironmentProxyConfig, EnvironmentVariableStatus, PacScriptSource, PacSourceState, + PacSourceStatus, PlatformProxyConfig, ProxyKind, StaticProxyRules, Subscription, }; #[napi(object)] @@ -99,6 +99,42 @@ impl From for NodePacSourceStatus { } } +#[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, @@ -172,6 +208,7 @@ impl From for NodePlatformProxyConfig { #[napi(object)] pub struct NodeProxyConfig { + pub environment: NodeEnvironmentProxyConfig, pub auto_detect: bool, pub pac_url: Option, pub pac: Option, @@ -185,6 +222,7 @@ 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 { diff --git a/npm/test/smoke.js b/npm/test/smoke.js index 0dd5bec..1f41b1f 100644 --- a/npm/test/smoke.js +++ b/npm/test/smoke.js @@ -26,6 +26,10 @@ 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'); 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 9bf1d11..2cb0545 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -128,9 +128,9 @@ compile_error!( pub use notify::Subscription; pub use resolver::{ProxyResolver, ResolverOptions}; pub use types::{ - Error, LinuxProxyConfig, MacosProxyConfig, PacBackendKind, PacScript, PacScriptSource, - PacSourceState, PacSourceStatus, PlatformProxyConfig, ProxyConfig, ProxyKind, Result, - StaticProxyRules, WindowsProxyConfig, + EnvironmentProxyConfig, EnvironmentVariableStatus, Error, LinuxProxyConfig, MacosProxyConfig, + PacBackendKind, PacScript, PacScriptSource, PacSourceState, PacSourceStatus, + PlatformProxyConfig, ProxyConfig, ProxyKind, Result, StaticProxyRules, WindowsProxyConfig, }; /// Size in bytes of the embedded ahead-of-time-compiled PAC guest module — diff --git a/src/resolver.rs b/src/resolver.rs index 42b0fd8..b661fb5 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -518,10 +518,10 @@ impl ProxyResolver { /// Read the current proxy configuration from the operating system. /// - /// Inspects DHCP WPAD, DNS WPAD, and the configured PAC independently, - /// including failures. `pac` is the first available script by precedence - /// (DHCP, DNS, configured) and 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(); let wpad_dhcp = self.inspect_dhcp_wpad(config.auto_detect); @@ -778,6 +778,7 @@ impl ProxyResolver { socks: rules.socks.clone(), }); ProxyConfig { + environment: self.inner.env.diagnostics(), auto_detect: config.auto_detect, pac_url: config.pac_url, pac, @@ -852,11 +853,18 @@ impl ProxyResolver { return PacInspection::state(PacSourceState::Unconfigured); }; match crate::fetch::fetch_pac(url, self.inner.options.pac_fetch_timeout) { - Ok(content) => PacInspection::available(PacScript { - url: url.to_string(), - content, - source: PacScriptSource::Configured, - }), + 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()), @@ -1405,6 +1413,26 @@ mod tests { 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")] #[tokio::test(flavor = "current_thread")] async fn async_resolution_coalesces_identical_concurrent_calls() { diff --git a/src/types.rs b/src/types.rs index 33dcaf0..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, WPAD discovery is attempted -/// before the explicitly configured PAC URL (DHCP before DNS on Windows), -/// 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 @@ -34,6 +36,28 @@ pub struct ProxyConfig { pub platform: Option, } +/// 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]