proxy-watch
Read the operating system's proxy configuration on Windows, macOS, Linux, Android and
iOS, and get a Stream item every time it changes. On a host set to PAC, a URL's route
comes from the OS's own PAC engine or a bundled one.
use StreamExt;
use ;
A one-shot read at process start goes stale: code that read once and cached the answer
never sees a PAC URL pushed by policy, or a proxy switched out from under a long-running
program. This crate delivers that change as a Stream item.
Which part you need
- The settings once, for a program that starts, reads and exits:
read(). - The settings as they change, for a long-running program:
ProxyWatcher, aStreamthat opens with a snapshot. - The route for one URL, bypass rules applied:
resolve(), and a PAC engine from the feature table below when the host uses PAC or WPAD. - An HTTP client that follows the OS:
reqwest'sProxy::customfed by a watcher. - Only
http_proxy/no_proxy:ProxyEnv::from_env(), on every platform.
Each has a section under Usage.
Install
GNOME support, on by default, opens GLib at run time: a build needs no C library or headers, and a machine without GLib reads the other stores.
Usage
examples/ has a runnable file per section (current, watch, resolve,
reqwest_client), plus env, resolve_os_and_env and pac
(cargo run --example <name>; pac needs --features pac-quickjs). The API reference, every feature's limits and each
platform's details are on docs.rs.
Read it once
read() reports the OS settings only. http_proxy / no_proxy come from
ProxyEnv::from_env(), and you choose which one wins. This puts the environment first:
use ;
On a Linux host with neither desktop's store (no GNOME proxy schema and no kioslaverc,
as on most servers, containers and CI hosts), read() answers Error::Unsupported. Treat
that as "use ProxyEnv alone", not as fatal. With the schema installed and nothing set,
it answers Direct instead.
Watch it
ProxyWatcher runs on OS threads of its own, so it needs no async runtime. This example
drives it with futures-executor; add futures-util and futures-executor yourself, or
.await next() from the runtime you have.
use StreamExt;
use ;
The first item is the current configuration. After that, a snapshot means the
configuration changed or a notification route was lost, which state.health shows.
Changes are coalesced over 200 ms (WatchOptions::debounce). After a
WatchEvent::Error, a recovery that changes nothing publishes nothing; call current() to
check.
With the tokio feature, watch_channel() publishes to a tokio::sync::watch::Receiver,
which clones for more than one consumer. Call it inside a runtime.
Decide how to reach a URL
resolve() turns a snapshot into the ordered ProxySteps to try, bypass rules applied:
use ;
On a machine configured with a PAC script or WPAD it fails, with
Error::PacNotSupported, instead of answering direct. To get an answer there, pass the
snapshot to pac::PacResolver:
let resolver = new.with_system_native?;
let steps = resolver.resolve_config?;
- Let the OS evaluate it: enable
pac-native. On Windows, macOS, iOS and Android the OS downloads and runs the script; elsewherewith_system_native()does nothing. - Evaluate a script you have: enable
pac-quickjs. It runs a body carried in the settings, or one you fetched and pass as the last argument; it never downloads (examples/pac.rs). Android and iOS builds leave the engine out and answer as if none were enabled. - Keep the script out of your process: enable
pac-subprocessand attach aSubprocessEvaluatorwithwith_evaluator(). It runsproxy-watch-pac-worker, whichcargo install proxy-watch --features pac-subprocess,pac-quickjsbuilds. The worker sandboxes itself only on Linux (x86-64, AArch64); elsewhere the evaluator refuses it until you callallow_unsandboxed().
WPAD on Windows and Apple platforms stays PacNotSupported until you build the native
resolver with_wpad(true) and attach it with with_native().
With reqwest
reqwest fixes its proxy when the Client is built, but Proxy::custom asks on every new
connection, so a config kept fresh by a watcher makes one Client follow the OS
(examples/reqwest_client.rs):
let client = builder
.proxy
.build?;
A pooled connection keeps its old route until pool_idle_timeout (90 s by default); set
pool_max_idle_per_host(0) to apply a change at once. .ok()? sends the request direct on
any resolve error, PacNotSupported included. Proxy::custom cannot fail a request, so
decide PAC hosts before the closure runs; the full example says how.
Pitfalls
read()can block for seconds on macOS and inside a Linux sandbox, while a system service comes up or times out. 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 pass the snapshot toPacResolverwith a PAC engine enabled. - Windows reports one connection and one mode: the active connection's settings only, and when more than one is enabled, auto-detect wins over a PAC URL, which wins over static servers.
- A sandboxed watcher never fires without
WatchOptions::poll_interval: the portal has no change signal, so the stream stays on its first snapshot. - A
kioslavercthat is a symlink into another directory is read but not watched: the watch is on the config directories, and an edit to the link's target fires nothing there. SetWatchOptions::poll_intervalif a dotfile manager links it. 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.
Feature flags
| Feature | Default | What it adds |
|---|---|---|
resolve |
on | resolve(): the proxy, or direct, for one URL |
linux-gnome |
on | GNOME settings; opens GLib at run time |
linux-kde |
on | KDE settings (kioslaverc) |
tokio |
off | watch_channel(), a tokio::sync::watch::Receiver |
tracing |
off | Logs naming which source changed; written to keep credentials out of them |
pac-native |
off | PAC through the OS's own resolver on Windows, macOS, iOS and Android |
pac-quickjs |
off | A bundled QuickJS engine for a PAC body you supply; needs a C compiler |
pac-subprocess |
off | The same engine in a worker process of its own; the worker is built with pac-quickjs too |
pac-windows-native |
off | The Windows part of pac-native (WinHTTP) |
pac-macos-native |
off | The macOS part of pac-native (CFNetwork) |
pac-ios-native |
off | The iOS part of pac-native (CFNetwork) |
pac-android-native |
off | The Android part of pac-native (ProxySelector) |
pac |
off | PAC parsing and policy with no engine; every pac-* feature turns it on |
Platform support
| Platform | Status |
|---|---|
| Windows | Tested in CI |
| macOS | Tested in CI without a GUI. GUI changes, network-location switches and MDM GlobalHTTPProxy are unverified on real hardware |
| Linux (GNOME, KDE) | Tested in CI; behind linux-gnome and linux-kde |
| Linux (Flatpak/Snap) | Read through the desktop portal (needs linux-gnome); watching needs WatchOptions::poll_interval. Unverified |
| Android | The crate needs the app's JavaVM and a Context: android-activity, and tao from 0.36 (Tauri 2.12), register them with ndk-context, which the crate reads. A host that registers neither, tao before 0.36 among them, calls proxy_watch::android::init before the first read; a read before it fails, and under panic = "abort" aborts the process. Reading needs API 23+; watching needs API 26+ and in-memory code loading, or else WatchOptions::poll_interval. Partly verified on an emulator, registered through ndk-context, through android::init from a Tauri 2.11 app, and through what tao registers in a Tauri 2.12 app; no device has run it |
| iOS | Watching needs WatchOptions::poll_interval: iOS gives an app no proxy change notification. Partly verified on a simulator |
| Environment variables | ProxyEnv::from_env() on every platform; a snapshot, never a Stream |
Everywhere else the constructors compile and return Error::Unsupported at runtime.
Where the table says unverified, expect this failure mode: no proxy reported while the host has one, and no error. If you see it, the form asks for what the host is set to and what the crate answered; a report needs both.
Minimum supported Rust version
1.88, with every feature. CI checks it on Linux x86-64
(cargo check --all-targets --all-features); the other targets' dependencies are not
checked against it.
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, the iOS backend and the Core Foundation conversion it shares with macOS, the Android backend's JNI entry, the WinHTTP and CFNetwork PAC engines, the limitspac-subprocessputs on its worker (a memory cap throughsetrlimiton Linux and a job object on Windows, and the Linux seccomp filter), and the table through whichlinux-gnomecalls GLib; the OS-independent core and the rest of the Linux backend contain none of their own.- Credentials stay masked in
Debug;ProxyAuthhas noDisplay. That is theuser:password@of a URL; its query prints as it is, a PAC URL's?key=included, because nothing about a query parameter says it is a secret and the PAC URL is what tells two configurations apart.
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.