proxy-watch 0.2.0

Detect and watch operating system proxy configuration changes
Documentation

proxy-watch

crates.io docs.rs CI Audit

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 futures_util::StreamExt;
use proxy_watch::{ProxyWatcher, WatchEvent};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut watcher = ProxyWatcher::new()?;
    while let Some(event) = futures_executor::block_on(watcher.next()) {
        if let WatchEvent::Snapshot { state, .. } = event {
            println!("proxy: {:?}", state.config.effective);
        }
    }
    Ok(())
}

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, a Stream that 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's Proxy::custom fed by a watcher.
  • Only http_proxy / no_proxy: ProxyEnv::from_env(), on every platform.

Each has a section under Usage.

Install

cargo add proxy-watch

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

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = proxy_watch::read()?;
    println!("effective: {:?}", config.effective);
    Ok(())
}

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 proxy_watch::{read, EnvPrecedence, ProxyEnv};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = read()?.with_env(&ProxyEnv::from_env()?, EnvPrecedence::BeforeSystem);
    println!("effective: {:?}", config.effective);
    Ok(())
}

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 futures_util::StreamExt;
use proxy_watch::{ProxyWatcher, WatchEvent};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut watcher = ProxyWatcher::new()?;
    while let Some(event) = futures_executor::block_on(watcher.next()) {
        match event {
            WatchEvent::Snapshot { state, .. } => {
                let live = state.health.is_fully_live();
                println!("-> {:?} (live: {live})", state.config.effective);
            }
            WatchEvent::Error { error, .. } => eprintln!("-> error: {error}"),
            _ => {}
        }
    }
    Ok(())
}

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 proxy_watch::{read, resolve, Url};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = read()?;
    for step in resolve(&config, &Url::parse("https://example.com/")?)? {
        match step.endpoint() {
            Some(endpoint) => println!("via {endpoint}"),
            None => println!("DIRECT"),
        }
    }
    Ok(())
}

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 = PacResolver::new(PacPolicy::new()).with_system_native()?;
let steps = resolver.resolve_config(&config, &url, None)?;
  • Let the OS evaluate it: enable pac-native. On Windows, macOS, iOS and Android the OS downloads and runs the script; elsewhere with_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-subprocess and attach a SubprocessEvaluator with with_evaluator(). It runs proxy-watch-pac-worker, which cargo install proxy-watch --features pac-subprocess,pac-quickjs builds. The worker sandboxes itself only on Linux (x86-64, AArch64); elsewhere the evaluator refuses it until you call allow_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 = reqwest::Client::builder()
    .proxy(reqwest::Proxy::custom(move |url| {
        let config = for_proxy.read().ok()?;          // Arc<RwLock<ProxyConfig>>
        resolve(&config, url).ok()?.first().and_then(ProxyStep::to_url)
    }))
    .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 with Error::PacNotSupported rather than answering DIRECT, so a caller that treats an error as "go direct" routes traffic around the administrator's proxy. Handle it, or pass the snapshot to PacResolver with 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 kioslaverc that 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. Set WatchOptions::poll_interval if a dotfile manager links it.
  • ProxyStep::to_url() carries the password in the clear. It exists to hand user:password@ to a client; endpoint()'s Display masks 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::Stream on dedicated OS threads (tokio optional).
  • unsafe is 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 limits pac-subprocess puts on its worker (a memory cap through setrlimit on Linux and a job object on Windows, and the Linux seccomp filter), and the table through which linux-gnome calls GLib; the OS-independent core and the rest of the Linux backend contain none of their own.
  • Credentials stay masked in Debug; ProxyAuth has no Display. That is the user: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.