Resolve a system HTTP proxy from a per-interface :proxy configuration
field (optionally combined with DHCP Option 252 / WPAD discovery), and
expose the result via the
VintageNet property table.
Designed to replace polling + file-based IPC + service-restart architectures (PACrunner, D-Bus, etc.) with event-driven property subscriptions.
Add vintage_net_proxy to the deps in your mix.exs. The library is
not yet on Hex, so depend on it via Git:
def deps do
[
{:vintage_net, "~> 0.13"},
{:vintage_net_proxy, github: "DensityCo/vintage_net_proxy"}
]
endRequires Elixir ~> 1.15 and OTP 26 or newer. CI verifies the matrix
1.15-1.19 against OTP 26-28.
Tell the library which interfaces to track, in priority order. The
list usually matches whatever you've passed to VintageNet.configure/3
on each interface:
# config/config.exs
config :vintage_net_proxy, interfaces: ["eth0", "wlan0"]The Application starts a supervision tree (VintageNetProxy.Supervisor)
that subscribes to each listed interface's config, dhcp_options,
and connection properties and publishes the resolved proxy at
["proxy", "config"]. Nothing else is required to bring it up — set
the :proxy field via VintageNet.configure/3 and the library
reacts.
The current proxy model is published at ["proxy", "config"] in the
VintageNet property table as one of:
| Value | Meaning |
|---|---|
:unset |
No proxy intent, or PAC intent without a loaded script yet |
:direct |
Bypass any proxy; connect directly |
proxy_descriptor |
A fixed proxy to use for everything |
:auto |
PAC-managed — call VintageNetProxy.resolve(url) per request |
PAC is inherently per-URL, so for :auto the library does not compress
the script down to a single descriptor. The published value is the
sentinel :auto; consumers route each outbound URL through
resolve/1 for a concrete answer.
The proxy_descriptor map looks like:
%{
scheme: :http, # :http | :https | :socks4 | :socks5
host: "proxy.corp",
port: 8080,
username: "alice", # optional
password: "secret" # optional
}:scheme, :host, :port are always present. :username / :password
are present only for authenticated proxies (typically set via the
:manual mode; the bundled PAC parser does not extract credentials).
For most consumers, the simplest integration is to call
VintageNetProxy.resolve/1 at connect time. It returns :direct or a
descriptor, never :unset — the library collapses "no proxy intent"
into :direct (treat-as-no-proxy is the right default; gating
connections on a non-:unset value would mean a device on a network
with no proxy advertisement never connects).
defp connect(url) do
case VintageNetProxy.resolve(url) do
:direct -> direct_connect(url)
%{} = descriptor -> proxied_connect(url, descriptor)
end
endIf you do subscribe to the published property (e.g. to drop and
reconnect when the proxy changes), treat :unset the same as
:direct:
VintageNet.subscribe(VintageNetProxy.property())
def handle_info({VintageNet, ["proxy", "config"], _, proxy, _}, state) do
case proxy do
:unset -> {:noreply, reconnect(state, :direct)}
:direct -> {:noreply, reconnect(state, :direct)}
:auto -> {:noreply, reconnect(state, :auto)}
%{scheme: _} = descriptor -> {:noreply, reconnect(state, descriptor)}
end
endThe :auto sentinel means "PAC is loaded; call resolve/1 per
URL." It's distinct from :unset (no proxy intent / no PAC loaded
yet) so consumers can opt out of routing through a stale PAC during
the boot window.
All proxy configuration is expressed as a :proxy field inside an
interface configuration. The schema follows GNOME's
org.gnome.system.proxy taxonomy (:direct | :auto | :manual), which is
the de facto Linux desktop convention.
See VintageNetProxy.Config for full schema details.
VintageNet.configure("eth0", %{
type: VintageNetEthernet,
ipv4: %{method: :dhcp},
proxy: %{mode: :direct}
})Use DHCP-supplied WPAD URL (Option 252):
VintageNet.configure("wlan0", %{
type: VintageNetWiFi,
ipv4: %{method: :dhcp},
proxy: %{mode: :auto}
})Or pin an explicit PAC URL:
VintageNet.configure("wlan0", %{
type: VintageNetWiFi,
ipv4: %{method: :dhcp},
proxy: %{mode: :auto, pac_url: "http://wpad.corp/wpad.dat"}
})VintageNet.configure("wlan0", %{
type: VintageNetWiFi,
ipv4: %{method: :dhcp},
proxy: %{
mode: :manual,
scheme: :http, # defaults to :http if omitted
host: "proxy.corp",
port: 8080,
username: "alice", # optional
password: "secret" # optional
}
}):scheme accepts :http, :https, :socks4, or :socks5.
Because intent lives in the interface configuration, each interface can
have its own proxy policy. A roaming device can have a corporate proxy
on wlan0 and go direct on eth0:
VintageNet.configure("wlan0", %{type: VintageNetWiFi, ipv4: %{method: :dhcp},
proxy: %{mode: :auto}})
VintageNet.configure("eth0", %{type: VintageNetEthernet, ipv4: %{method: :dhcp},
proxy: %{mode: :direct}})Tell the library which interfaces to track, in priority order:
config :vintage_net_proxy, interfaces: ["eth0", "wlan0"]At runtime the library walks the list and picks the first interface
that (a) is connected (connection is :internet or :lan) and
(b) has a :proxy intent in its config. When the active interface goes
offline, the next eligible one takes over; when it returns, it
reclaims. Each interface's PAC script is cached only while that
interface is up — disconnecting drops the script so a reconnect
re-fetches against the (possibly new) network.
VintageNetProxy.Supervisor (rest_for_one)
├── VintageNetProxy.InterfaceRegistry (Registry: iface name → pid)
├── VintageNetProxy.Selector (GenServer: snapshot aggregator)
└── VintageNetProxy.InterfaceSupervisor (one_for_one)
├── VintageNetProxy.Interface (eth0) (GenServer: one per iface)
├── VintageNetProxy.Interface (wlan0) (GenServer: one per iface)
└── ...
PAC discovery requires fetching a script over HTTP, which is blocking
and can be slow (5-second timeout if a WPAD URL is unreachable). The
property changes that trigger a fetch — connection flipping up, a
new DHCP wpad, a config edit — flow in continuously, and consumers of
resolve/1 and status/0 need answers in microseconds, not seconds.
A single-GenServer design forces a tradeoff: either block the mailbox
on the fetch (so resolve/1 waits up to 5 seconds during an in-flight
PAC load) or move the fetch to a side Task (which then needs URL
tagging, stale-result rejection, and a coordination handshake to keep
the cached script consistent).
Per-interface GenServers split the problem geographically:
-
Each
Interfaceowns one network interface end-to-end — subscribes to its three PropertyTable keys (config,dhcp_options,connection), holds the per-interface state (intent, connection, DHCP wpad, cachedpac_script), and runsFetcher.get/1synchronously inside its own mailbox. The blocking is real but localized: it only stalls that interface's own event processing, not the Selector or other interfaces. -
The
Selectorshrinks to a snapshot aggregator. Each Interface pushes its full state to the Selector via{:interface_changed, iface, state}after every change. The Selector keeps the latest snapshot per interface in aRoster, picks the highest-priority eligible interface, and publishes the resulting proxy value.resolve/1andstatus/0are served from cached snapshots and never block on a fetch. -
Stale-script handling falls out for free. Because each Interface's mailbox is single-threaded, a fetch runs against whatever URL was effective when the fetch started. Subsequent property changes queue up and are processed after the fetch completes. No URL tagging or "is this result still valid?" check is needed in the code path.
Interface.init/1 reads PropertyTable values (fast) and returns
immediately with {:ok, state, {:continue, :startup}}. The
handle_continue(:startup, ...) callback does the blocking PAC fetch
after init returns. Effects:
Supervisor.start_linkreturns in ~5ms regardless of whether PAC URLs are reachable (verified: 5057ms → 5ms with an unreachable PAC URL pre-populated in the PropertyTable).- Multiple interfaces fetch their PAC scripts in parallel — each
handle_continueruns in its own process. - Application boot doesn't stall on the network coming up.
Top-level :rest_for_one ensures the Selector and the
InterfaceSupervisor restart together when the Selector dies — fresh
Interfaces re-push their initial snapshots to the fresh Selector and
the system recovers. The inner InterfaceSupervisor is :one_for_one,
so a crash in one Interface doesn't disturb its siblings: only that
interface restarts, re-reads its state, and re-fetches its PAC.
Interfaces are registered via the
VintageNetProxy.InterfaceRegistry ({:via, Registry, ...}), so they're
discoverable by interface name —
VintageNetProxy.Interface.get(iface) returns the live state for
debugging or external inspection.
-
VintageNetProxy.Interface— both the per-interface struct (the shape of a snapshot) and the GenServer that maintains it. Pure helpers (eligible?,value,resolve,effective_pac_url,snapshot) operate on the struct and are tested without a process. -
VintageNetProxy.Selector— a thin GenServer (~35 lines). One handle_info clause for{:interface_changed, ...}, two handle_calls forstatusandresolve. It owns no fetch logic and no PropertyTable subscriptions. -
VintageNetProxy.Roster— a pure module: priority list of interfaces plus%{iface => Interface.t}. Knows how to find the active interface and to compute the publishedvalue, theresolveresult, and thestatusmap. -
VintageNetProxy.Publisher— owns the single public PropertyTable key this library writes (["proxy", "config"]). Three calls:put/1,get/0,property/0. Selector is the only caller. -
VintageNetProxy.Fetcher— synchronousFetcher.get(url)using:httpc. Has a 5-second timeout and a 256 KiB body cap. -
VintageNetProxy.PAC,PAC.Predicate,PAC.IP— the PAC script evaluator (see "PAC subset" below).
There is no separate persistence layer. VintageNet already persists
interface configurations (encrypted, with the same machinery that hides
WiFi passphrases), so the :proxy field gets persisted alongside the
rest of the interface config and is restored on boot automatically.
PAC scripts are a function from URL → proxy decision, so for :auto
mode the published property is the sentinel :auto, not a descriptor.
Consumers call resolve/1 per request:
VintageNetProxy.resolve("https://api.example.com/")
#=> %{scheme: :http, host: "corp-proxy", port: 8080}
VintageNetProxy.resolve("http://intranet/")
#=> :directFor :manual and :direct modes the answer is the same regardless of
URL, so subscribing to ["proxy", "config"] is enough. Embedded devices
that talk to a single known upstream can also just call resolve/1
once with that URL and use the result.
For :auto proxy intent with no explicit :pac_url, the library tries
two discovery paths in order, both driven off the
["interface", iface, "dhcp_options"] property that VintageNet's
udhcpc handler populates from each lease:
- DHCP Option 252 (
wpad) — if the lease included a WPAD URL directly, that's what gets fetched. This is the modern path and what most corporate WPAD-aware DHCP servers advertise. - DNS-WPAD fallback — if option 252 wasn't present but DHCP option
15 (
domain) was, the library constructshttp://wpad.<domain>/wpad.datand fetches that. This is the classic WPAD discovery path used by networks that publish PAC via DNS only.
Either signal triggers a PAC fetch and re-publish, provided the
interface's connection is :internet or :lan. An explicit
pac_url in the proxy config wins over both DHCP-derived paths.
The DNS-WPAD step deliberately does not walk up the DNS hierarchy
(wpad.eng.corp.example → wpad.corp.example → ...). It constructs
exactly one URL from the exact DHCP-supplied domain. Walking up is a
known WPAD spoofing vector and is not implemented; if a deployment
needs multiple-domain discovery, set :pac_url explicitly.
The bundled PAC evaluator handles the patterns found in typical corporate WPAD scripts.
Predicate atoms:
shExpMatch(host, "<glob>")—*and?wildcardsdnsDomainIs(host, ".<suffix>")— case-insensitive suffix matchisPlainHostName(host)isInNet(host, "<net>", "<mask>")— IPv4 literal hosts only (no DNS)host == "<literal>"/host === "<literal>"
Boolean composition: ||, &&, !, and parentheses. Standard
precedence (! > && > ||); left-associative.
Directives:
"DIRECT"→:direct"PROXY host:port"→%{scheme: :http, ...}"HTTPS host:port"→%{scheme: :https, ...}"SOCKS host:port"/"SOCKS4 host:port"→%{scheme: :socks4, ...}"SOCKS5 host:port"→%{scheme: :socks5, ...}- Fallback lists (
"PROXY a:1; PROXY b:2; DIRECT") — only the first recognized entry is returned
Anything outside this subset (unsupported atom, malformed predicate, parse
error) evaluates to false and the rule falls through. Malformed scripts
return :direct.
isInNet deliberately matches only when host is already an IPv4
literal — embedding DNS resolution inside PAC evaluation would make proxy
lookup network-dependent. Real-world WPADs typically gate the IP arm with
isPlainHostName(host) || isInNet(host, ...), which works correctly under
this rule.
If real-world PAC files need more (DNS-resolving isInNet, myIpAddress,
weekdayRange, credential parsing, etc.), extend
VintageNetProxy.PAC.Predicate.
A full JavaScript engine is the correct general solution but a poor fit for embedded Nerves devices: ~1MB of binary, a C dependency, and a sandbox we'd have to reason about for security. The simple subset evaluator fits in ~150 lines of Elixir and covers the cases real corporate networks actually deploy. Revisit if a customer ships a PAC file that needs the full grammar.
Unit and Selector/Interface tests run against an in-process :gen_tcp
HTTP fixture and execute under mix test. The integration suite
exercises the library against a real nginx (serving the PAC) and a
real tinyproxy (the proxy the WPAD points to); see
dev/README.md:
docker compose -f dev/docker-compose.yml up -d
mix test --include integration
docker compose -f dev/docker-compose.yml downCI runs both suites on every push and PR across an Elixir 1.15 → 1.19 matrix paired with OTP 26 → 28.
VintageNetEthernet.normalize/1andVintageNetWiFi.normalize/1preserve the:proxyfield for all four shapes (:direct,:manualwith credentials,:autowith explicitpac_url,:autofor DHCP-discovered WPAD).- A real
VintageNet.OSEventDispatcher.dispatch(["bound"], env)with a realistic udhcpc env hash (including"wpad" => ...from DHCP option 252) flows through the udhcpc-env parser, lands as:wpadindhcp_options, and triggers a PAC fetch that publishes:auto. - An actual HTTP
GETissued to the descriptorresolve/1returns reaches the upstream — observable in tinyproxy's access log.
The remaining gap is a deployment on real Nerves hardware against a network that advertises WPAD via DHCP, which is the only thing the host-side suite can't reproduce.
Production-shaped, not production-deployed. The PAC parser handles the
patterns found in typical corporate WPAD files; real-world PAC files
may exercise predicates this library doesn't handle (DNS-resolving
isInNet, myIpAddress, weekdayRange, etc.) — extend
VintageNetProxy.PAC.Predicate when a new pattern shows up.