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 on an emulator and one phone; no
46//! device has run the iOS 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. On a phone with Android 17, a Tauri app read, resolved and received changes for a Wi-Fi proxy with a bypass list, a PAC URL, and a switch to mobile data. The poll fallback has not run, nor a global, VPN or APN proxy |
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//! # Pitfalls
117//!
118//! - [`read`] can block for seconds on macOS and inside a Linux sandbox, while a system
119//! service comes up or times out. Keep it off a UI thread.
120//! - A PAC or WPAD machine makes `resolve()` fail with [`Error::PacNotSupported`] rather
121//! than answering direct, so a caller that treats an error as "go direct" routes traffic
122//! around the administrator's proxy. Handle it, or pass the snapshot to a PAC resolver
123//! with an engine enabled.
124//! - A sandboxed watcher never fires without [`WatchOptions::poll_interval`]: the portal
125//! has no change signal, so the stream stays on its first snapshot.
126//! - A `kioslaverc` that is a symlink into another directory is read but not watched: the
127//! watch is on the config directories, and an edit to the link's target fires nothing
128//! there. Set [`WatchOptions::poll_interval`] if a dotfile manager links it.
129//! - `ProxyStep::to_url()` carries the password in the clear. It exists to hand
130//! `user:password@` to a client; `endpoint()`'s `Display` masks it, so print that one.
131//! - `watch_channel()` (behind `tokio`) publishes configuration only. A failed re-read or a
132//! dead notification route leaves the last good configuration standing with nothing
133//! marking it.
134//!
135//! # Backend limits
136//!
137//! Each backend documents its own limits at the top of its module. Those modules are private,
138//! so the text is in the source. These are the limits to know before relying on a backend.
139//!
140//! - Windows 8 / Windows Server 2012 is the floor, and nothing checks it. The registry watch
141//! arms `RegNotifyChangeKeyValue` with `REG_NOTIFY_THREAD_AGNOSTIC`, which Microsoft
142//! documents as "only supported in Windows 8 and later"; the function itself goes back
143//! to Windows 2000, so the flag sets the floor, not the call.
144//! `pac-windows-native` calls `WinHttpCreateProxyResolver` and `WinHttpGetProxyForUrlEx`,
145//! both of which list Windows 8 and Windows Server 2012 as their minimum supported client
146//! and server. Behaviour on an older release has not been measured.
147//! - Windows reads the settings of the active connection only. `WinHttpGetIEProxyConfigForCurrentUser`
148//! is documented as returning them for the current active connection (LAN, dial-up or VPN
149//! alike) and this crate never enumerates connectoids to find the others. It also reports
150//! one mode: auto-detect wins over a PAC URL, which wins over static servers.
151//! - `kioslaverc` is merged across the whole XDG cascade (`/etc/xdg` through
152//! `$XDG_CONFIG_HOME`), so a system value marked immutable (`[$i]`) is not overridden by
153//! a user file. A `[$e]` flag is not expanded: KDE substitutes `$VAR`/`${VAR}` from the
154//! environment of whichever session read the file. A library that only reports should not
155//! pull process environment in on a config file's say-so. A `[$e]` value that still
156//! contains a `$` is recorded in `ProxyMode::rejected` and not reported as a host, bypass
157//! pattern, or PAC script. The literal text is not the value the desktop is using, and
158//! naming it as a proxy would invent an unconfigured destination. A `[$e]` value with no
159//! `$` in it expands to itself and is read as written.
160//! - A `kioslaverc` watch can go silent if a watched directory is deleted. That surfaces as
161//! `WatchHealth::degraded`.
162// Doc examples compile, but rustdoc discards the warnings of the ones that pass, so a
163// dead `use` in a snippet a reader is shown survives every command CI runs,
164// `RUSTDOCFLAGS=-D warnings` included. Denying it turns that silence into a failing test.
165// Narrow on purpose: an unused *binding* can be a legitimate way to show a type in an
166// example, and its only escape hatch is an underscore the reader can see. An unused
167// import has no legitimate form.
168#![doc(test(attr(deny(unused_imports))))]
169// Paired with `rustdoc-args = ["--cfg", "docsrs"]` in `Cargo.toml`: neither half does
170// anything alone. Together they put an "Available on crate feature `resolve`" banner on
171// every gated item, which the docs.rs page (built with nearly every feature on) otherwise
172// omits, leaving a reader no way to see which items a default dependency line compiles.
173#![cfg_attr(docsrs, feature(doc_cfg))]
174
175mod auth;
176#[cfg(feature = "tokio")]
177mod bridge;
178mod bypass;
179mod config;
180#[cfg(test)]
181mod debug_masking; // hand-written Debug gate
182mod diagnostic;
183mod endpoint;
184mod env;
185mod error;
186mod mode;
187#[cfg(feature = "resolve")]
188mod resolve;
189mod sys;
190mod trace;
191mod util;
192mod watch;
193
194#[cfg(target_os = "android")]
195pub mod android;
196#[cfg(feature = "pac")]
197pub mod pac;
198pub mod parse;
199
200pub use crate::auth::{PasswordState, ProxyAuth};
201pub use crate::bypass::{BypassRules, HostPattern, ImplicitBypass, LOCAL_TOKEN, NO_LOOPBACK_TOKEN};
202pub use crate::config::{EnvPrecedence, ProxyConfig, ProxyConfigSource};
203pub use crate::diagnostic::{RejectedValue, RejectionKind, RejectionSource};
204pub use crate::endpoint::{ProxyEndpoint, ProxyEntry, ProxyScheme, Scheme};
205pub use crate::env::{CGI_MARKER_VAR, ProxyEnv};
206pub use crate::error::Error;
207pub use crate::mode::ProxyMode;
208pub use crate::watch::{
209 CapturedEnv, DEFAULT_DEBOUNCE, ProxyWatcher, WatchEvent, WatchHealth, WatchOptions, WatchState,
210 read, read_in, read_with_options,
211};
212
213/// Routing decisions, behind the `resolve` feature (on by default).
214#[cfg(feature = "resolve")]
215pub use crate::resolve::{ProxyStep, resolve};
216
217/// Routing decisions that may need a PAC script, behind the `pac` feature (off by
218/// default).
219#[cfg(feature = "pac")]
220pub use crate::resolve::resolve_with_pac;
221
222/// The tokio bridge, behind the `tokio` feature (off by default).
223#[cfg(feature = "tokio")]
224pub use crate::bridge::watch_channel;
225
226/// Re-export of [`futures_core::Stream`].
227pub use futures_core::Stream;
228
229/// Re-export of [`url::Host`] (IPv6 brackets already resolved).
230pub use url::Host;
231
232/// Re-export of [`ipnet::IpNet`] for [`HostPattern::Cidr`].
233pub use ipnet::IpNet;
234
235/// Re-export of [`url::Url`] for [`ProxyMode::Pac`].
236pub use url::Url;
237
238// Hands the README to rustdoc so that `cargo test` compiles its examples. They are the
239// first code a reader runs, and the one place a signature change is invisible to `cargo
240// build`. Keep each block whole: rustdoc's `# ` hidden lines render literally on GitHub,
241// so a block only compiles here if it also reads as a complete program there.
242//
243// `resolve` gates the item because one example calls `resolve()`, which the feature-off
244// rows of the matrix do not compile.
245#[cfg(all(doctest, feature = "resolve"))]
246#[doc = include_str!("../README.md")]
247struct ReadmeDoctests;