proxy-watch
Read the operating system's proxy configuration on Windows, macOS and Linux — and get a
Stream item every time it changes.
A one-shot read at process start goes stale: a PAC URL pushed by policy, or a proxy
switched out from under a long-running program, never reaches code that read once and
cached the answer. This crate exists so that change arrives as a Stream item — within
what each backend can see. Windows reads whichever connection is currently active, not
every connection configured; Platform support below says what else each one leaves out.
Backends prefer the OS notification API: RegNotifyChangeKeyValue on Windows,
SCDynamicStore on macOS, GSettings signals or a kioslaverc file watch on Linux.
Sandboxed Linux (Flatpak/Snap portal) has no change signal — set
WatchOptions::poll_interval there, or the stream stays on the first snapshot.
Install
The default features include GNOME support, which on Linux needs GLib's development files
(libglib2.0-dev, glib2-devel) at build time; Windows and macOS builds never reach for
them. A Linux build with no C dependency turns the defaults off and asks for the KDE half
instead:
That command is also the answer to the licence question linux-gnome raises. The Rust
bindings it pulls in — gio, glib and their -sys crates — are MIT, and this crate
carries none of GLib's own code; GLib itself is LGPL-2.1-or-later, reached at run time as
a shared library through pkg-config. Turning the feature off drops the C library from
the picture entirely.
Usage
examples/ carries a runnable file per section — current, watch,
resolve, reqwest_client — plus env, resolve_os_and_env and pac
(cargo run --example <name>). The full API reference is on
docs.rs.
Read it once
read() starts no watcher: no thread, and no change-notification route to register. That
last part is the reason it exists rather than being ProxyWatcher::new()?.current() —
arming notifications can fail on a machine whose settings still read fine, and only the
watcher has to care.
If configuration and watcher liveness must describe the same instant, use the atomic
ProxyWatcher::state() observation instead of separate current() and health() calls.
read() reports the OS stores alone. http_proxy / no_proxy are a second snapshot,
ProxyEnv::from_env(), and which one wins is a policy you name rather than one this crate
picks — most command-line tools want the environment first, the way curl reads it:
use ;
On a Linux host with no desktop store at all — a server, a container, CI — read() itself
answers Error::Unsupported, and the environment is the only configuration there. Treat
that error as "use ProxyEnv alone", not as fatal.
Watch it
ProxyWatcher is a futures_core::Stream on OS threads of its own, so it needs no async
runtime: StreamExt::next() gives one future per item, and anything that blocks on a
future drives it. Neither futures-util nor futures-executor is a dependency of this
crate — add them yourself, or .await the same next() from whatever runtime you have.
use StreamExt;
use ;
The first item is always a snapshot of the current configuration, emitted at subscription
time. After that a snapshot means the configuration really changed, or that a
notification route was lost — the health rides the same stream, so a watcher that has gone
deaf says so instead of only falling quiet. Snapshots equal to the previous one are
dropped, and notifications are coalesced over a 200 ms debounce window
(WatchOptions::debounce).
Nothing is promised after a WatchEvent::Error. If the next successful read returns the
same configuration and no route changed, there is nothing to publish and the stream stays
silent; confirm recovery with current() or state() rather than waiting for an item. A
snapshot does follow whenever the configuration or the health actually moved.
With the tokio feature, watch_channel() moves the watcher into a background task and
publishes to a tokio::sync::watch::Receiver, which clones freely — that is how you get
more than one consumer. It has to be called from inside a runtime, and it is configuration
only: it drops both the errors and the health, so a failed re-read or a dead route leaves
the last good configuration published with nothing to mark it. Poll the Stream yourself,
or read ProxyWatcher::state(), if you need either.
Decide how to reach a URL
resolve() turns a snapshot into an ordered list of ProxySteps, applying the bypass
rules, the per-scheme precedence and the <local> / CIDR / wildcard patterns:
use ;
endpoint() and not to_url(), for two reasons. ProxyEndpoint's Display masks
credentials and a Url's does not — to_url() exists to hand user:password@ to a
client, so printing one prints the proxy password. And to_url() also answers None for
an endpoint whose host cannot be written into a URL, which would print DIRECT for a
machine that has a proxy. Use to_url() where the URL is sent, as the reqwest snippet
below does.
On a machine configured with a PAC script or WPAD it fails, with
Error::PacNotSupported. Falling back to a direct connection would route traffic around a
proxy the administrator configured, so the choice is the caller's.
To evaluate a script instead, enable pac — plus pac-boa for a pure-Rust engine — and
call resolve_with_pac() with a body you already have; that path never downloads. On
Windows, pac-windows-native hands discovery, download and evaluation to WinHTTP
instead. PacPolicy's rustdoc covers the safety envelope and the two CVEs behind its
defaults.
With reqwest
reqwest fixes its proxy at Client build time
(#2674), but Proxy::custom runs a
closure each time a connection is opened, so a config kept fresh by a watcher makes an
existing Client follow the OS for every destination it is not already connected to
(examples/reqwest_client.rs):
let client = builder
.proxy
.build?;
A connection already in the pool keeps its old route, because hyper-util keys the pool on
the destination alone: expect the change after pool_idle_timeout (90 s by default), or
set pool_max_idle_per_host(0), or rebuild the Client on each event.
Turning a resolve error into None via .ok()? is fail-open (direct), and two errors
reach it: PacNotSupported on a PAC/WPAD machine, and ProxyEntryUnusable where the
configured proxy for that scheme could not be read. Fail the request, or call
resolve_with_pac() / WinHTTP for the first — the full example spells that out.
Pitfalls
read()blocks, and not always briefly. Both non-Windows backends wait on a system service that can be absent: aconfigdstill coming up is retried for five seconds, and inside a Linux sandbox each portalLookupis bounded at five seconds — per call, and a sandboxed read asks five of them. Keep it off a UI thread.- A PAC or WPAD machine makes
resolve()fail withError::PacNotSupportedrather than answeringDIRECT, so a caller that treats an error as "go direct" routes traffic around the administrator's proxy. Handle it, or enablepac. - Windows reports one connection and one step: the active connection's settings only, and when auto-detect, a PAC URL and static servers are all enabled, only the first.
- A sandboxed watcher never fires without
WatchOptions::poll_interval— the portal has no change signal, so the stream stays on its first snapshot. ProxyStep::to_url()carries the password in the clear. It exists to handuser:password@to a client;endpoint()'sDisplaymasks it, so print that one.watch_channel()publishes configuration only. A failed re-read or a dead notification route leaves the last good configuration standing with nothing marking it.
Where a host's settings live
Reproducing a report, or writing a setting to test against, means knowing which store is being read — which is not always the one the GUI writes.
| Platform | Store | Where it comes from |
|---|---|---|
| Windows | Per-user WinINet through WinHttpGetIEProxyConfigForCurrentUser, falling back to HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings (ProxyServer, ProxyOverride, AutoConfigURL) |
Settings → Network & Internet → Proxy |
| Windows | The WinHTTP machine default, WinHttpGetDefaultProxyConfiguration |
netsh winhttp set proxy. Reported, but never effective — the per-user store outranks it |
| Windows | HKLM\Software\Policies\Microsoft\Windows\CurrentVersion\Internet Settings |
Group Policy. Also reported, also behind the per-user store |
| 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 |
| 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' |
| Linux (KDE) | [Proxy Settings] in kioslaverc, merged across the whole XDG cascade |
System Settings → Network → Proxy, which writes ~/.config/kioslaverc |
| 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 |
Each of those spells its bypass list differently, and the differences decide which hosts go
direct. The parse module's docs have the table, entry shape by entry shape.
Feature flags
| Feature | Default | What it adds |
|---|---|---|
resolve |
on | resolve() and ProxyStep. No extra dependency |
linux-gnome |
on | GNOME half of the Linux backend (GSettings, XDG portal fallback). Pulls in gio/glib, so GLib is a build-time C dependency |
linux-kde |
on | KDE half (kioslaverc and its file watch). Pure Rust |
tokio |
off | watch_channel(); pulls in tokio with only rt and sync |
pac |
off | The pac module and resolve_with_pac(). Parsing and policy only — no JavaScript engine; you supply the script body. Implies resolve |
pac-boa |
off | The engine for pac: pure-Rust boa_engine. Opt-in on purpose, since a PAC script is code from a network-controlled location. Implies pac |
pac-windows-native |
off | Windows only: pac::WinHttpPacResolver — WPAD discovery, download, and evaluation via WinHTTP. No PacInline; PacPolicy does not apply on this path. Implies pac |
tracing |
off | Log lines naming which source changed the configuration. Credentials, PAC bodies and echoed parse inputs are never logged |
Platform backends are not feature-selected — they are chosen by target cfg, because
a feature enabled anywhere in a dependency graph can never be turned off again.
linux-gnome and linux-kde are the deliberate exception: they pick between stores
within Linux. Turning linux-gnome off drops the C dependency; with both off, Linux
behaves like an unsupported target — except inside a sandbox, where the portal fallback
needs linux-gnome to be compiled in at all, so the answer is Error::Sandboxed rather
than Error::Unsupported. Both are errors; a caller matching only on Unsupported
misses that one.
Platform support
| Platform | Status |
|---|---|
| Windows | Implemented, tested in CI (RegNotifyChangeKeyValue over Internet Settings, read through WinHttpGetIEProxyConfigForCurrentUser) |
| macOS | Implemented, tested in CI headless (SCDynamicStore plus a CFRunLoop). GUI changes, network-location switching and MDM GlobalHTTPProxy are unverified on real hardware |
| Linux (GNOME) | Implemented, tested in CI (gio changed signal), behind linux-gnome |
| Linux (KDE) | Implemented, tested in CI (kioslaverc file watch, no desktop environment needed), behind linux-kde |
| 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 |
| 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 |
Everywhere else the constructors compile and return Error::Unsupported at runtime, so
downstream code compiles on any target.
Where the table says unverified, the failure to expect is the quiet one: the crate reports no proxy while the host has one, and publishes no error. The crate docs open with what to look for on each. An issue carrying that output is worth more than any review — these are the surfaces this crate cannot verify on its own, and the form asks for the pair that settles one: what the host is set to, and what the crate answered.
Each backend documents its own limits at the top of its module. Those modules are private, so the text is in the source rather than on docs.rs; the limits worth knowing before relying on a backend are:
- Windows 8 / Windows Server 2012 is the floor, and nothing checks it. The registry watch
arms
RegNotifyChangeKeyValuewithREG_NOTIFY_THREAD_AGNOSTIC, which Microsoft documents as "only supported in Windows 8 and later" — the function itself goes back to Windows 2000, so it is the flag and not the call that sets the floor — andpac-windows-nativecallsWinHttpCreateProxyResolverandWinHttpGetProxyForUrlEx, both of which list Windows 8 and Windows Server 2012 as their minimum supported client and server. Behaviour on an older release has not been measured. - Windows reads the settings of the active connection only.
WinHttpGetIEProxyConfigForCurrentUseris documented as returning them for the current active connection — LAN, dial-up or VPN alike — and this crate never enumerates connectoids to find the others. It also reports only the first enabled step when auto-detect, a PAC URL and static servers are all on. kioslavercis merged across the whole XDG cascade (/etc/xdgthrough$XDG_CONFIG_HOME), so a system value marked immutable ([$i]) is not overridden by a user file. But a[$e]flag is not expanded: KDE substitutes$VAR/${VAR}from the environment of whichever session read the file, and pulling process environment in on a config file's say-so is not something a library that only reports should do. A[$e]value that still contains a$is therefore recorded inProxyMode::rejectedrather than reported as a host, a bypass pattern, or a PAC script — the literal text is not the value the desktop is using, and naming it as a proxy would invent a destination nobody configured. A[$e]value with no$in it expands to itself and is read as written.- A
kioslavercwatch can go silent if a watched directory is deleted. That surfaces asWatchHealth::degraded.
Minimum supported Rust version
1.88, and 1.92 with linux-gnome (its gio/glib declare that themselves).
linux-gnome is a default feature, so a default build on Linux needs 1.92; 1.88 is the
floor everywhere else. Both are checked in CI (cargo check --all-targets) rather than
taken on trust.
Design
- Async-runtime agnostic:
futures_core::Streamon dedicated OS threads (tokiooptional). unsafeis confined to the code that calls the OS directly. Windows and macOS backends and the WinHTTP PAC engine; the OS-independent core and the Linux backend contain none of their own.- Credentials stay masked in
Debug;ProxyAuthhas noDisplay.
Contributing
CONTRIBUTING.md has the checks CI applies and how to run them, what
#[ignore] means here — it marks the tests that rewrite your machine's real proxy settings
— and what the prose gates in xtask/ require of a comment.
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.