A self-contained local HTTP routing proxy for macOS, Linux, and Windows. It serves a smart Proxy Auto-Config (PAC) file that routes Russian-IP traffic directly and sends everything else through a configurable upstream proxy. The config file is hot-reloaded — edit it and the next request reflects the change, no restart needed.
┌────────────────────────────────────────────┐
│ your machine (localhost) │
browser ──PAC──────▶│ http-proxy-local │
▲ │ ├─ GET /proxy.pac → smart PAC │
│ │ └─ everything else → upstream proxy │
│ └─────────────────┬──────────────────────────┘
macOS Network Settings │
Proxies → Automatic ▼
Configuration URL configured upstream proxy
= http://127.0.0.1:8080/proxy.pac
The PAC performs all routing decisions client-side (in the browser), so the proxy itself stays simple and HTTPS works without certificate shenanigans.
Pre-built binaries for Linux, macOS, and Windows (amd64 + arm64 each) are published on the Releases page. Download the archive for your platform, extract it, and run:
| Platform | Archive |
|---|---|
| Linux amd64 | http-proxy-local_<version>_Linux_amd64.tar.gz |
| Linux arm64 | http-proxy-local_<version>_Linux_arm64.tar.gz |
| macOS amd64 | http-proxy-local_<version>_Darwin_amd64.tar.gz |
| macOS arm64 (Apple Silicon) | http-proxy-local_<version>_Darwin_arm64.tar.gz |
| Windows amd64 | http-proxy-local_<version>_Windows_amd64.zip |
| Windows arm64 | http-proxy-local_<version>_Windows_arm64.zip |
# Example (macOS arm64):
curl -L https://github.com/Djarvur/http-proxy-local/releases/latest/download/http-proxy-local_<version>_Darwin_arm64.tar.gz | tar xz
./http-proxy-local --config config.yamlmacOS binaries are unsigned. On first run Gatekeeper will block them —
right-click → Open (or xattr -dr com.apple.quarantine /path/to/binary) to
allow execution. See Apple's docs on opening apps from unidentified developers.
Once built or downloaded, point your OS/browser at the PAC URL
(http://127.0.0.1:8080/proxy.pac by default) — see Quick start
for OS-specific configuration steps.
Requires Go 1.26+ (or use mise — see Quick start):
go install github.com/Djarvur/http-proxy-local/cmd/http-proxy-local@latestor clone and build:
git clone https://github.com/Djarvur/http-proxy-local.git
cd http-proxy-local
make build # produces ./bin/http-proxy-localThe PAC evaluates rules top-down; first match wins:
| # | Rule | Result | Notes |
|---|---|---|---|
| 1 | blacklist match |
DIRECT |
Absolute veto; overrides everything |
| 2 | whitelist match |
PROXY |
Forces upstream even for local addresses |
| 3 | local/private network | DIRECT |
RFC1918, loopback, link-local, plain hostnames |
| 4 | Russian IPv4 | DIRECT |
Binary search over merged RIPE delegation ranges |
| 5 | default | PROXY |
Everything else goes through the upstream |
Both whitelist and blacklist accept suffix patterns (*.example.com)
and CIDR ranges (10.0.0.0/8).
The same precedence is applied server-side by the optional smart HTTP listener (below) for clients that ignore the PAC — so browsers and PAC-blind clients reach the same DIRECT/Upstream decisions.
- Go 1.26+ (or just use mise — see below)
- macOS (the PAC URL is pointed at via System Settings → Network)
mise pins the exact Go and golangci-lint versions
so the build is reproducible:
mise install # reads mise.toml, installs go 1.26.5 + golangci-lint 2.12.2mise run build # → bin/http-proxy-local
# or, without mise:
go build -o bin/http-proxy-local ./cmd/http-proxy-localCopy the example config and edit:
mkdir -p ~/.config/http-proxy-local
cp config.example.yaml ~/.config/http-proxy-local/config.yaml
$EDITOR ~/.config/http-proxy-local/config.yamlSee config.example.yaml for every field and its
default.
mise run run # builds + runs with the default config path
# or:
./bin/http-proxy-local [-config /path/to/config.yaml]On first run, the binary blocks for a few seconds while it fetches the RIPE
delegation list (one-time, cached afterward). The PAC is served at
http://127.0.0.1:8080/proxy.pac by default.
- System Settings → Network → (your interface) → Details → Proxies
- Enable Automatic Proxy Configuration
- URL:
http://127.0.0.1:8080/proxy.pac - Apply. Browsers pick up the PAC within seconds.
To verify, visit a RU site (e.g. https://ya.ru) — it should load normally
via direct connection. A non-RU site (e.g. https://example.com) routes
through your configured upstream.
Some clients — most notably the Telegram desktop and mobile apps — cannot use an HTTP/HTTPS proxy and only speak SOCKS5 (or MTProto, which this proxy does not implement). For those, enable the optional SOCKS5 listener.
The SOCKS5 listener is opt-in. It only starts when socks5.listen is
non-empty in your config — there is no default port. Add the block and
restart:
socks5:
listen: 127.0.0.1:1080Then point the client at it:
- Telegram desktop: Settings → Advanced → Connection type → Use custom
proxy → Add proxy → SOCKS5, host
127.0.0.1, port1080, no auth. - Telegram Android/iOS: Settings → Data and Storage → Proxy → Add Proxy → SOCKS5, same host/port.
Traffic entering the SOCKS5 listener is tunneled through the same upstream cascade as browser traffic, so to DPI it looks identical to your other proxied traffic. No PAC, RU-geo, or whitelist/blacklist rules are applied to SOCKS5 connections — the client decides its own destinations (Telegram picks its DCs), and the proxy just forwards.
| Feature | Status |
|---|---|
| Messages, media, channels | ✅ Works (TCP) |
| Voice/video calls | |
| P2P voice/video (direct UDP) | ❌ Not supported — the upstream cascade is TCP-only (HTTP CONNECT), so UDP has no transport through it |
If you need true UDP voice/video, that's an upstream infrastructure change (e.g. a WireGuard tunnel or SOCKS5-upstream-with-UDP-support to your server), not something this local proxy can provide — the limitation is the channel to the upstream, not the local listener.
MTProto is also TCP-only (no help for UDP), its obfuscation is pointless
inside a loopback + TLS-upstream channel (DPI never sees the localhost hop),
and it cannot be cascaded through an HTTP-CONNECT upstream. SOCKS5
CONNECT-only reuses the existing cascade directly. See openspec/changes/ add-socks5-listener/design.md for the full alternatives analysis.
Some clients — notably the zcode CLI — honour HTTP_PROXY/HTTPS_PROXY
but ignore the system PAC and expose no way to inject one. Without help,
every request they make is forced through the upstream, even to hosts that
are reachable directly (z.ai, RU-domestic endpoints, RFC1918 services).
The smart HTTP listener solves this: a second HTTP listener on a separate port that applies the same routing precedence as the PAC (blacklist → whitelist → local/private → RU geo → default) but evaluates it server-side, so PAC-blind clients get the same DIRECT/Upstream decisions a browser would make.
The smart listener is opt-in. It only starts when smart.listen is
non-empty:
smart:
listen: 127.0.0.1:8081
# Optional tuning (defaults shown):
dial_timeout: 3s # per-attempt timeout for direct dials
dial_attempts: 2 # direct retries before falling back to upstream
negative_cache_ttl: 60s # how long to remember a direct failurePoint the PAC-blind client at it:
export HTTP_PROXY=http://127.0.0.1:8081
export HTTPS_PROXY=http://127.0.0.1:8081The precedence is identical (parity is enforced by a property test that runs
both the PAC and the server-side decision over the same corpus). Execution
differs in one place: the blacklist rule emits [Direct, Upstream] and
dials them in order — if direct fails (e.g. z.ai is briefly unreachable
directly), it falls back to upstream. A failed direct attempt is remembered
for negative_cache_ttl, so subsequent requests skip the doomed direct dial
and go straight to upstream. RU-geo Direct has no fallback — move an
unreliable RU host to the whitelist if you want it routed via upstream.
The smart listener does not serve /proxy.pac (PAC-blind clients don't
fetch it, and the PAC's PROXY directive would point at the dumb listener
anyway). It resolves hostnames via the system resolver (no application DNS
cache — the system resolver already caches correctly). See the
add-smart-http-listener change archive for the full design.
The config file is watched with fsnotify. Edit config.yaml, save, and
the next /proxy.pac fetch reflects the new rules. No restart needed.
- Changes to
upstream.*,routing.*,pac.cache_seconds,geo.refresh_interval,geo.retry_intervalapply immediately to subsequent requests. - Changes to
listen(the bind address) require a restart — the proxy logs arestart requiredwarning when this happens, since you almost certainly want to know your listen address moved. - Editor safe-save is supported: vim, emacs, VS Code, and most modern
editors write to a temp file and atomically rename over the original
(rather than truncating + writing in place). The watcher follows the
rename and reloads from the new file. Tested in
TestRuntime_HotReload_EditorSafeSave.
Two latencies compound:
- Config save → PAC swap on disk: ~400ms (fsnotify + debounce + render). This is the proxy's internal hot-reload, always fast.
- PAC swap → browser re-fetches it: bounded by the PAC's
Cache-Control: max-age. macOS does not re-read the PAC on a fixed timer — it honors HTTP cache headers.
The PAC cache TTL is configurable via pac.cache_seconds (default 15).
With the default, the browser revalidates within ~15s of an edit. Set
pac.cache_seconds: 0 to disable caching entirely (the browser
revalidates on every request — useful while iterating on config). See
config.example.yaml for the full semantics.
If you ever edit the config and the browser doesn't pick it up within the expected window, toggle System Settings → Network → Proxies → Automatic Proxy Configuration off and on, or run:
networksetup -setautoproxyurl "Wi-Fi" "http://127.0.0.1:8080/proxy.pac"The binary is self-contained: it fetches the RIPE delegation list from
https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-latest on a
configurable interval (default: every 24h), parses + merges the ~6000 RU
IPv4 prefixes into ~3000-4000 disjoint ranges, and caches them to
~/.config/http-proxy-local/ru-cidrs.cache.
- Successful refresh → wait
geo.refresh_interval(default24h). - Failed refresh → wait
geo.retry_interval(default5m), retry until success, then revert to the success interval. - Fresh cache on startup → no fetch, instant serve.
- Stale cache on startup → blocking fetch, falls back to stale on failure.
- No cache + fetch fails on first run → serves an empty-RU PAC (all non-listed hosts route to upstream) and keeps retrying. Never crashes.
mise run test # full unit + integration suite
mise run lint # strict golangci-lint (zero findings required)
mise run test-race # with the race detectorThe PAC routing rules are verified end-to-end by a node-based execution
harness (internal/app/pacjs_test.go) that loads the rendered PAC and
asserts on FindProxyForURL for every precedence scenario.
Tests against a real upstream proxy are gated behind a build tag and excluded from the default suite (they need network + credentials):
ZCODE_HTTP_PROXY=https://user:pass@host:port \
go test -tags realproxy -run TestRealProxy ./internal/in/http/Credentials are read from the environment, never hardcoded. See
CONTRIBUTING.md for details.
See docs/architecture.md for the design
rationale, package layout, and the hot-reload contract. Short version:
- PAC-decides-routing: the browser evaluates the PAC and decides DIRECT vs PROXY per URL. The Go binary is a thin PAC generator + cascading forwarder.
- Hexagonal layout (per
go-ultimate): ports and adapters; the app layer is pure logic with no I/O. - Atomic snapshots: live config and PAC bytes are read via
atomic.Pointeron every request, lock-free on the hot path.
See CONTRIBUTING.md. The short version: TDD is
mandatory, the strict lint gate must pass with zero findings, and Go
architecture questions route through the go-ultimate skill.
http-proxy-local/
├── go.mod module github.com/Djarvur/http-proxy-local
├── .golangci.yml strict lint config (17 linters)
├── mise.toml pins go + golangci-lint versions
├── Makefile build/test/lint/run targets
├── cmd/http-proxy-local/ package main: entrypoint + wire.go
├── internal/
│ ├── port/ boundary types & Out-port interfaces
│ ├── app/ pure logic: PAC render, RIPE parse, fetcher, orchestrator
│ ├── in/http/ inbound adapter: PAC handler + forwarder + CONNECT
│ └── out/{fscache,fsnotify,ripe,yamlconfig}/ outbound adapters
├── .github/workflows/ pr.yml (gate) + periodic.yml (weekly health)
├── docs/architecture.md design rationale
└── openspec/ change history (proposal/specs/design/tasks)
MIT — see LICENSE.