Skip to main content

proxy_watch/
lib.rs

1//! Detect and *watch* the operating system's proxy settings on Windows, macOS, Linux,
2//! Android and iOS.
3//!
4//! [`read`] answers once, starting no watcher and arming no notification route:
5//!
6//! ```no_run
7//! use proxy_watch::{Scheme, read};
8//!
9//! let config = read()?;
10//! match config.effective.endpoint_for(Scheme::Https) {
11//!     Some(endpoint) => println!("https goes through {endpoint}"),
12//!     None => println!("https is direct"),
13//! }
14//! # Ok::<(), proxy_watch::Error>(())
15//! ```
16//!
17//! [`ProxyWatcher`] follows the settings instead: a [`Stream`] of [`WatchEvent`] that
18//! opens with a snapshot, then publishes changes coalesced over
19//! [`WatchOptions::debounce`] (200 ms). It runs its own OS threads, so it needs no async
20//! runtime. Where there is no store to read (any other platform, or a Linux host with no
21//! desktop), the answer is [`Error::Unsupported`]. Treat this as falling back to
22//! `ProxyEnv::from_env()`.
23//!
24//! `resolve()` (behind the `resolve` feature, so unlinked here) turns a snapshot and a URL
25//! into the ordered endpoints to try, bypass rules applied; a host on PAC or WPAD stops it
26//! with [`Error::PacNotSupported`] until `pac` and an engine (`pac-quickjs`,
27//! `pac-windows-native` on Windows, `pac-macos-native` on macOS, `pac-ios-native` on iOS,
28//! or `pac-android-native` on Android, all four at once as `pac-native`) are on;
29//! `pac-subprocess` runs the QuickJS engine in a worker process instead. The remaining flags are `linux-gnome` and
30//! `linux-kde` (the Linux stores, on by default), `tokio` for a
31//! `tokio::sync::watch::Receiver`, and `tracing` for lifecycle logs.
32//!
33//! The README carries a runnable example for each of the above. The sections below are the
34//! reference it leaves out: every feature with its limits, what each platform reads and
35//! what writes it, and each backend's limits.
36//!
37//! # Platform coverage
38//!
39//! Windows, GNOME and KDE are exercised on real machines. Under Flatpak or snap without
40//! dconf, and under a KDE Flatpak with it, the Linux backend falls back to the desktop
41//! portal (which has no change notification of its own: set
42//! [`WatchOptions::poll_interval`] to poll) or reports [`Error::Sandboxed`].
43//!
44//! **Unverified:** the macOS backend and that sandbox path have run only in CI, the iOS
45//! backend only on a simulator and the Android backend only on an emulator; no device has
46//! run either mobile backend. The table below says what each run covered.
47//! **Risk:** macOS reads a missing or unexpected answer as "nothing is configured"
48//! instead of reporting an error, so a host that does have a proxy is reported as having
49//! none. iOS reads the same dictionary the same way. The sandbox path fails loudly, so
50//! the open question is whether the detection fires at all, not what it does once it has
51//! fired. On Android below API 26 the watcher runs on `poll_interval` alone, and that
52//! timer has not re-read on any device.
53//! **Symptom:** on a machine whose settings show a proxy, the stream yields
54//! [`ProxyMode::Direct`] and never publishes an error; on an Android device below API 26
55//! the stream keeps its first snapshot after the settings change. `src/sys/mac/mod.rs`,
56//! `src/sys/linux/sandbox.rs` and `src/sys/linux/portal.rs` carry the per-backend form.
57//!
58//! | Platform | Status |
59//! |---|---|
60//! | Windows | Implemented, tested in CI (`RegNotifyChangeKeyValue` over `Internet Settings`, read through `WinHttpGetIEProxyConfigForCurrentUser`) |
61//! | macOS | Implemented, tested in CI headless (`SCDynamicStore` plus a `CFRunLoop`). GUI changes, network-location switching and MDM `GlobalHTTPProxy` are **unverified** on real hardware |
62//! | Linux (GNOME) | Implemented, tested in CI (GSettings `changed` signal), behind `linux-gnome` |
63//! | Linux (KDE) | Implemented, tested in CI (`kioslaverc` file watch, no desktop environment needed), behind `linux-kde` |
64//! | Linux (Flatpak/Snap) | Sandbox detected from `/.flatpak-info`, or (as GLib's `is_snap` does it) from `$SNAP/meta/snap.yaml` declaring anything but `confinement: classic`; read via the `ProxyResolver` portal (needs `linux-gnome`) or `Error::Sandboxed`. Watch needs `WatchOptions::poll_interval` (portal has no change signal). **Unverified** |
65//! | Android | Read through JNI, API 23+; the crate needs the app's `JavaVM` and a `Context`: `android-activity` registers them with `ndk-context`, which the crate reads, and so does `tao` from 0.36 (Tauri 2.12); a host built on an earlier `tao` calls `android::init` instead. The bypass list is read with `java.net.ProxySelector`'s rules, so loopback goes direct only when the list is non-empty. Watch receives the `PROXY_CHANGE_ACTION` broadcast through a small receiver class the crate loads from memory (API 26+); below API 26, or where in-memory code loading is refused, it needs `WatchOptions::poll_interval`. **Partly verified**. `read()` (direct, manual, PAC), `pac-android-native` and the watcher ran on an API 36 x86-64 emulator: a settings change, a switch of the default network between a Wi-Fi with its own proxy and bypass list and mobile data, and unregistering on drop. The host registered through `ndk-context` there, through `android::init` from a Tauri 2.11 app, and through what `tao` registers in a Tauri 2.12 app built for release; the poll fallback has not run, and no device has run the backend |
66//! | iOS | Read through `CFNetworkCopySystemProxySettings()` and interpreted as macOS's dictionary is. Watch needs `WatchOptions::poll_interval`: iOS gives an app no proxy change notification. The app becoming active and a network path change (`nw_path_monitor`, iOS 12+) re-read before the interval is up. **Partly verified**: on an iOS simulator in CI, watchers read, take their first network path update and drop; what they read is not checked against a configured proxy, and no device has run it |
67//! | Environment variables | `ProxyEnv::from_env()` on every platform: a snapshot, never a `Stream`. Nothing outside the process can change them, and a process changing its own is signalled nowhere. Re-read to pick that up; `ProxyEnv::captured_at()` says when the snapshot was taken |
68//!
69//! # Feature flags
70//!
71//! | Feature | Default | What it adds |
72//! |---|---|---|
73//! | `resolve` | **on** | `resolve()` and `ProxyStep`. No extra dependency |
74//! | `linux-gnome` | **on** | GNOME half of the Linux backend (GSettings, XDG portal fallback). Opens GLib at run time; nothing to install at build time |
75//! | `linux-kde` | **on** | KDE half (`kioslaverc` and its file watch). Pure Rust |
76//! | `tokio` | off | `watch_channel()`; pulls in `tokio` with only `rt` and `sync` |
77//! | `tracing` | off | Log lines giving the effective mode before and after each change, and the sources read. They are written to leave out credentials, PAC bodies and echoed parse inputs |
78//! | `pac-native` | off | Every native resolver below; each builds only on its own OS, so one feature list serves every target. `pac::NativePacResolver` names this target's, and `PacResolver::with_system_native()` attaches it with its defaults. Implies `pac` |
79//! | `pac-quickjs` | off | An engine for `pac`: QuickJS through `rquickjs`. Stops a script at its timeout and bounds its heap and stack; compiles C, so it needs a C compiler for the target, and is left out of the docs.rs build for that reason: run `cargo doc --features pac-quickjs` to see `QuickJsEvaluator`. Implies `pac` |
80//! | `pac-subprocess` | off | `pac::SubprocessEvaluator`: each evaluation runs in a `proxy-watch-pac-worker` process at a path you give, killed at the timeout, with name lookups answered by your process under its `PacPolicy`. Needs no engine itself; the worker binary is built with `pac-subprocess` and `pac-quickjs` (`cargo install proxy-watch --features pac-subprocess,pac-quickjs`). Windows and Linux also cap the worker's memory. On Linux (x86-64, AArch64) the worker runs under a seccomp filter that leaves it no file, socket or process calls; elsewhere it has no sandbox and is refused unless you call `allow_unsandboxed()`. Implies `pac` |
81//! | `pac-windows-native` | off | Windows only. `pac::WinHttpPacResolver`: download and evaluation via WinHTTP, and WPAD discovery after `with_wpad(true)`. No `PacInline`; `PacPolicy` does not apply on this path. Implies `pac` |
82//! | `pac-macos-native` | off | macOS only: `pac::CfNetworkPacResolver` downloads and evaluates a PAC URL through CFNetwork, and `pac::CfNetworkPacEvaluator` evaluates a body there as a `PacEvaluator`. WPAD after `with_wpad(true)`, through the PAC URL the system settings name; `PacPolicy` does not apply on this path; CFNetwork drops `HTTPS` and `SOCKS5` entries from a script's answer, cuts an IPv6 literal proxy at its first colon (dropped here), and the script sees the target URL cut to its scheme and host. Implies `pac` |
83//! | `pac-ios-native` | off | iOS only: the same `pac::CfNetworkPacResolver` and `pac::CfNetworkPacEvaluator` as `pac-macos-native`, with the same limits; WPAD resolves from the `CfNetworkSystemSettings` scope. Not yet run on a device or simulator. Implies `pac` |
84//! | `pac-android-native` | off | Android only: `pac::AndroidPacResolver` asks `ProxySelector.getDefault()`, which the system PAC service answers, for a `Pac` configuration `ConnectivityManager` reports, and errors once the system settings name a different PAC URL. No download or WPAD of its own; `PacPolicy` does not apply on this path. Implies `pac` |
85//! | `pac` | off | The `pac` module and `resolve_with_pac()`. Parsing and policy only: no JavaScript engine; you supply the script body. Implies `resolve` |
86//!
87//! Platform backends are **not** feature-selected: they are chosen by target `cfg`, because
88//! a feature enabled anywhere in a dependency graph can never be turned off again.
89//! `linux-gnome` and `linux-kde` are the exception: they pick between stores *within*
90//! Linux. Turning `linux-gnome` off means GLib is never opened; with both off, Linux
91//! behaves like an unsupported target, except inside a sandbox, where the portal fallback
92//! needs `linux-gnome` to be compiled in at all, so the answer is `Error::Sandboxed` rather
93//! than `Error::Unsupported`. Both are errors; a caller matching only on `Unsupported`
94//! misses that one.
95//!
96//! # Where a host's settings live
97//!
98//! Reproducing a report, or writing a setting to test against, means knowing which store is
99//! being read, which is not always the one the GUI writes.
100//!
101//! | Platform | Store | Where it comes from |
102//! |---|---|---|
103//! | Windows | Per-user WinINet through `WinHttpGetIEProxyConfigForCurrentUser`, falling back to `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings` (`ProxyServer`, `ProxyOverride`, `AutoConfigURL`) | Settings → Network & Internet → Proxy |
104//! | Windows | The WinHTTP machine default, `WinHttpGetDefaultProxyConfiguration` | `netsh winhttp set proxy`. Reported, but never `effective`: the per-user store outranks it |
105//! | Windows | `HKLM\Software\Policies\Microsoft\Windows\CurrentVersion\Internet Settings` | Group Policy. Also reported, also behind the per-user store |
106//! | macOS | The `SCDynamicStore` proxy dictionary (`HTTPProxy`, `ExceptionsList`, `ProxyAutoConfigURLString`, …) | System Settings → Network → *service* → Details → Proxies, or `sudo networksetup -setwebproxy <service> <host> <port>`. Read it back with `scutil --proxy` |
107//! | Linux (GNOME) | GSettings `org.gnome.system.proxy` and its `.http` / `.https` / `.ftp` / `.socks` children | Settings → Network → Network Proxy, or `gsettings set org.gnome.system.proxy mode 'manual'` |
108//! | Linux (KDE) | `[Proxy Settings]` in `kioslaverc`, merged across the whole XDG cascade | System Settings → Network → Proxy, which writes `~/.config/kioslaverc` |
109//! | Android | `ConnectivityManager.getDefaultProxy()` through JNI: host and port for `http` and `https`, the exclusion list, the PAC URL | Wi-Fi → *network* → Proxy, a VPN's proxy, or `adb shell settings put global http_proxy <host>:<port>` |
110//! | iOS | `CFNetworkCopySystemProxySettings()`, the same dictionary keys as macOS | Settings → Wi-Fi → *network* → Configure Proxy, a VPN's proxy, or an MDM profile |
111//! | Any | `http_proxy`, `https_proxy`, `ftp_proxy`, `all_proxy`, `no_proxy` | The process environment, read by `ProxyEnv::from_env()`. Lowercase beats uppercase; on Windows any other casing is read after both. An `http_proxy` next to a non-empty `REQUEST_METHOD` is refused with `Error::CgiHttpProxy`, because a request header sets that variable |
112//!
113//! Each of those spells its bypass list differently, and the differences decide which hosts go
114//! direct. The [`parse`] module's docs have the table, entry shape by entry shape.
115//!
116//! # Backend limits
117//!
118//! Each backend documents its own limits at the top of its module. Those modules are private,
119//! so the text is in the source. These are the limits to know before relying on a backend.
120//!
121//! - Windows 8 / Windows Server 2012 is the floor, and nothing checks it. The registry watch
122//!   arms `RegNotifyChangeKeyValue` with `REG_NOTIFY_THREAD_AGNOSTIC`, which Microsoft
123//!   documents as "only supported in Windows 8 and later"; the function itself goes back
124//!   to Windows 2000, so the flag sets the floor, not the call.
125//!   `pac-windows-native` calls `WinHttpCreateProxyResolver` and `WinHttpGetProxyForUrlEx`,
126//!   both of which list Windows 8 and Windows Server 2012 as their minimum supported client
127//!   and server. Behaviour on an older release has not been measured.
128//! - Windows reads the settings of the active connection only. `WinHttpGetIEProxyConfigForCurrentUser`
129//!   is documented as returning them for the current active connection (LAN, dial-up or VPN
130//!   alike) and this crate never enumerates connectoids to find the others. It also reports
131//!   one mode: auto-detect wins over a PAC URL, which wins over static servers.
132//! - `kioslaverc` is merged across the whole XDG cascade (`/etc/xdg` through
133//!   `$XDG_CONFIG_HOME`), so a system value marked immutable (`[$i]`) is not overridden by
134//!   a user file. A `[$e]` flag is not expanded: KDE substitutes `$VAR`/`${VAR}` from the
135//!   environment of whichever session read the file. A library that only reports should not
136//!   pull process environment in on a config file's say-so. A `[$e]` value that still
137//!   contains a `$` is recorded in `ProxyMode::rejected` and not reported as a host, bypass
138//!   pattern, or PAC script. The literal text is not the value the desktop is using, and
139//!   naming it as a proxy would invent an unconfigured destination. A `[$e]` value with no
140//!   `$` in it expands to itself and is read as written.
141//! - A `kioslaverc` watch can go silent if a watched directory is deleted. That surfaces as
142//!   `WatchHealth::degraded`.
143// Doc examples compile, but rustdoc discards the warnings of the ones that pass, so a
144// dead `use` in a snippet a reader is shown survives every command CI runs,
145// `RUSTDOCFLAGS=-D warnings` included. Denying it turns that silence into a failing test.
146// Narrow on purpose: an unused *binding* can be a legitimate way to show a type in an
147// example, and its only escape hatch is an underscore the reader can see. An unused
148// import has no legitimate form.
149#![doc(test(attr(deny(unused_imports))))]
150// Paired with `rustdoc-args = ["--cfg", "docsrs"]` in `Cargo.toml`: neither half does
151// anything alone. Together they put an "Available on crate feature `resolve`" banner on
152// every gated item, which the docs.rs page (built with nearly every feature on) otherwise
153// omits, leaving a reader no way to see which items a default dependency line compiles.
154#![cfg_attr(docsrs, feature(doc_cfg))]
155
156mod auth;
157#[cfg(feature = "tokio")]
158mod bridge;
159mod bypass;
160mod config;
161#[cfg(test)]
162mod debug_masking; // hand-written Debug gate
163mod diagnostic;
164mod endpoint;
165mod env;
166mod error;
167mod mode;
168#[cfg(feature = "resolve")]
169mod resolve;
170mod sys;
171mod trace;
172mod util;
173mod watch;
174
175#[cfg(target_os = "android")]
176pub mod android;
177#[cfg(feature = "pac")]
178pub mod pac;
179pub mod parse;
180
181pub use crate::auth::{PasswordState, ProxyAuth};
182pub use crate::bypass::{BypassRules, HostPattern, ImplicitBypass, LOCAL_TOKEN, NO_LOOPBACK_TOKEN};
183pub use crate::config::{EnvPrecedence, ProxyConfig, ProxyConfigSource};
184pub use crate::diagnostic::{RejectedValue, RejectionKind, RejectionSource};
185pub use crate::endpoint::{ProxyEndpoint, ProxyEntry, ProxyScheme, Scheme};
186pub use crate::env::{CGI_MARKER_VAR, ProxyEnv};
187pub use crate::error::Error;
188pub use crate::mode::ProxyMode;
189pub use crate::watch::{
190    CapturedEnv, DEFAULT_DEBOUNCE, ProxyWatcher, WatchEvent, WatchHealth, WatchOptions, WatchState,
191    read, read_in, read_with_options,
192};
193
194/// Routing decisions, behind the `resolve` feature (on by default).
195#[cfg(feature = "resolve")]
196pub use crate::resolve::{ProxyStep, resolve};
197
198/// Routing decisions that may need a PAC script, behind the `pac` feature (off by
199/// default).
200#[cfg(feature = "pac")]
201pub use crate::resolve::resolve_with_pac;
202
203/// The tokio bridge, behind the `tokio` feature (off by default).
204#[cfg(feature = "tokio")]
205pub use crate::bridge::watch_channel;
206
207/// Re-export of [`futures_core::Stream`].
208pub use futures_core::Stream;
209
210/// Re-export of [`url::Host`] (IPv6 brackets already resolved).
211pub use url::Host;
212
213/// Re-export of [`ipnet::IpNet`] for [`HostPattern::Cidr`].
214pub use ipnet::IpNet;
215
216/// Re-export of [`url::Url`] for [`ProxyMode::Pac`].
217pub use url::Url;
218
219// Hands the README to rustdoc so that `cargo test` compiles its examples. They are the
220// first code a reader runs, and the one place a signature change is invisible to `cargo
221// build`. Keep each block whole: rustdoc's `# ` hidden lines render literally on GitHub,
222// so a block only compiles here if it also reads as a complete program there.
223//
224// `resolve` gates the item because one example calls `resolve()`, which the feature-off
225// rows of the matrix do not compile.
226#[cfg(all(doctest, feature = "resolve"))]
227#[doc = include_str!("../README.md")]
228struct ReadmeDoctests;