A forward HTTP/HTTPS proxy that enforces per-subnet destination rules. The YAML config reloads live when you edit it. Everything is denied unless a rule explicitly allows it.
It is built on the standard library alone (plus YAML parsing, logging and file watching): the proxy itself is a few hundred lines, so everything the binary does with untrusted input is in this repository.
- HTTP: every plain proxy request (
GET http://…) is checked separately, including each request on a keep-alive connection. - HTTPS:
CONNECT host:porttunnels are checked by hostname and port. TLS stays end to end (no MITM), so rules work per host, not per URL path.
- The client IP must fall in a configured subnet. The most specific CIDR
wins, so a
/32can override its/24. - The host must be a valid hostname or standard-notation IP, and the port a
valid number. Anything else (
127.1,2130706433, Unicode lookalikes, zone IDs, …) is rejected. - Deny rules are checked first and always win.
- An allow rule must match. Hostname rules (including
*) match hostnames only; IP/CIDR rules match IP addresses. The exception isdeny: ["*"], which denies everything, IP addresses included. - The port must be in the subnet's
portslist (default: 80 and 443). - The hostname is resolved, and only now: names that fail the rules never
cause a DNS lookup. Every resolved address is checked. One matching a
deny CIDR denies the request. A non-public one (LAN, loopback, link-local,
CGNAT, …) needs an IP/CIDR allow rule. This stops DNS rebinding and
*.nip.io-style tricks from turning the proxy into a bridge between your VLANs. - The proxy connects only to the addresses it checked. It never resolves the name again. Plain-HTTP upstream connections are kept alive and reused, but only between requests whose checks approved exactly the same destination (host, port and addresses).
See config.example.yaml for the full rule syntax.
Network requirement: clients are identified only by source IP. That's
only meaningful if a device can't give itself an address from another
subnet's range, so each subnet should be its own VLAN (not just a different
IP range on a shared network or Wi-Fi SSID). On the proxy host, set strict
reverse-path filtering on the VLAN interfaces (net.ipv4.conf.<if>.rp_filter=1,
see host settings) so packets can't claim a source address
from another interface's subnet. Also firewall the proxy port so only your
internal VLANs can reach it.
All subnets share one process, so per-client limits keep one runaway device
from degrading the proxy for the others. The defaults are generous and meant
to contain a misbehaving device, not to throttle normal use. They can be
changed in the config's limits block and per subnet, live.
| limit | default | when exceeded |
|---|---|---|
total_connections |
20000 | new connections are closed |
client_connections (per client IP, incl. tunnels) |
512 | new connections are closed |
client_requests_per_second / client_request_burst |
200/s, burst 1000 | 429 Too Many Requests |
tunnel_idle_timeout (no data either way) |
1h | tunnel is closed |
The proxy also:
- drops connections from clients outside every subnet as soon as they're accepted, without reading or answering anything;
- closes the client connection after a denied request;
- answers with a generic
502when an allowed destination can't be reached, and logs the actual error (which contains resolved addresses) instead; - strips the hop-by-hop headers (
Connectionand everything it names,Upgrade,Keep-Alive,Proxy-Authorization, …) from what it forwards, so protocol upgrades go throughCONNECTor not at all. A destination that answers a plain request with101 Switching Protocolsanyway gets a502: handing the client connection over would put the stream outside the ACL's tunnel accounting and its idle timeout; - caps request headers at 64 KB and upstream response headers at 1 MB;
- dials at most 3 addresses per IP family, IPv4 and IPv6 in parallel ("happy eyeballs"), within 30 s in total;
- caches DNS answers for 30 s (5 s for names that don't exist, 3 s for timeouts and server failures) and merges concurrent lookups of the same name. At most 256 lookups run at once, and at most 32 per client, so a device retrying names whose DNS never answers can't stall everyone else's lookups. Resolved addresses are still checked on every request.
Download a binary for linux amd64, arm64 or armv7 from the releases (see verifying a download), or build it:
go build -o proxy-acl ./cmd/proxy-acl
./proxy-acl -config config.yaml -listen :3128 -log-format console| flag | default | |
|---|---|---|
-config |
config.yaml |
ACL file; reloaded on change and on SIGHUP |
-listen |
:3128 |
listen address (:3128 = all interfaces) |
-log-format |
json |
json or console |
-version |
print the version and exit |
If the config file is invalid at startup, the proxy won't start. If a later edit is invalid (bad YAML, unknown key, bad pattern…), the error is logged and the previous config stays active.
Images for linux/amd64, linux/arm64 and linux/arm/v7 are published to
ghcr.io/etsubu/proxy-acl: :1.2.3, :1.2, :1 and :latest for
releases, :main for the latest commit on main. The image is distroless
and runs as a non-root user. The proxy needs no capabilities and writes no
files:
docker run -d --name proxy-acl --restart unless-stopped \
--network host \
--read-only --cap-drop ALL --security-opt no-new-privileges \
--memory 1g --pids-limit 512 --ulimit nofile=262144:262144 \
-v /etc/proxy-acl:/etc/proxy-acl:ro \
ghcr.io/etsubu/proxy-acl:1To build it yourself: docker build -t proxy-acl .
- Use
--network host. The rules depend on the real client IP. With Docker's port publishing, clients can show up as the bridge gateway (e.g.172.17.0.1), which matches no subnet, so everything is denied. - Mount the directory, not the file. Most editors save by replacing the file, and a single-file bind mount would keep pointing at the old one, so hot reload wouldn't see the change.
deploy/proxy-acl.service runs the proxy as a
throwaway user with no capabilities, a read-only view of the system, a
system-call filter and memory, task and file-descriptor limits, and
restarts it if it ever exits:
install -m 755 proxy-acl /usr/local/bin/
install -D -m 644 config.yaml /etc/proxy-acl/config.yaml
cp deploy/proxy-acl.service /etc/systemd/system/
systemctl enable --now proxy-acl
systemctl reload proxy-acl # same as editing the config: reloads itThe memory limit (1 GB) is a backstop behind the proxy's own limits; lower it on a small VM or container if you like.
deploy/90-proxy-acl.conf (copy to
/etc/sysctl.d/, apply with sysctl --system) sets:
net.ipv4.tcp_tw_reuse = 1and a widerip_local_port_range: after each tunnel the proxy's side of the upstream connection waits a minute inTIME_WAIT, which otherwise limits it to about 470 new connections per second to any one destination;- strict reverse-path filtering (
rp_filter = 1), see the network requirement above; - optionally,
fs.pipe-user-pages-soft = 0, which only matters with 10 GbE and many tunnels open at once.
Allowed requests are logged at info and denied ones at warn, with
client, subnet, host, port, reason and the matching rule. Set
log_level: warn to see only blocks. Failed connections to allowed
destinations are logged at warn as upstream connection failed.
Each client may write about 20 access log lines per second (burst 200). A device over that is summarized every 10 s instead, with counts per host, so a flood can't push everyone else's log lines out of the journal. Clients outside every subnet share one such budget, summarized by client address. Client-supplied fields are clipped (hosts to 253 bytes), so a request can't produce huge log lines:
{"level":"warn","client":"10.0.20.7","subnet":"iot","suppressed":8412,"deny":{"telemetry.vendor.example":8400,"(connection)":12},"message":"access log lines suppressed"}journalctl -u proxy-acl -o cat | jq -c 'select(.action=="deny") | {subnet, client, host, port, reason}'
# most-blocked hosts per subnet
journalctl -u proxy-acl -o cat | jq -r 'select(.action=="deny") | "\(.subnet) \(.host):\(.port)"' | sort | uniq -c | sort -rn
# devices being summarized
journalctl -u proxy-acl -o cat | jq -c 'select(.message=="access log lines suppressed")'make build # bin/proxy-acl
make run # dev mode, see below
make test # tests with the race detector
make fuzz # fuzz the ACL (FUZZTIME=60s)
make lint # the golangci-lint quality gate (.golangci.yml)
make lint-fix # apply the fixes the linters can make themselves
make vulncheck # known vulnerabilities in dependencies
make check # everything CI checks: formatting, vet, lint, vulncheck, tests
make release # release binaries and archives into dist/, as CI builds them
make # list all targetsLocal builds take their version from git tags: 1.2.3 on a release tag,
1.2.3-4-gabc1234 four commits later, -dirty with uncommitted changes
(scripts/version.sh).
make run starts the proxy on 127.0.0.1:3128 with the race detector,
console logs and config.dev.yaml, which lets this
machine through to anything and logs every decision at debug level. Edits
to the config apply live. Point a client at it with
curl -x http://127.0.0.1:3128 https://example.com, or override
CONFIG=... and LISTEN=....
internal/aclhas the security tests: lookalike hosts for each pattern type, deny precedence, ports, subnet selection, DNS results pointing at private/loopback/deny-listed addresses, and making sure denied names never reach DNS. It also has a fuzz target that re-checks every allowed decision against a simple, independent model of the policy.internal/proxytests the proxy end to end with raw requests: Host header and userinfo tricks, unsupported schemes, missing ports, alternative IP notations, and keep-alive reuse. It also checks that the dialer refuses anything the ACL didn't approve.
internal/proxy also covers the limits end to end (unknown clients, slot
release on every close path including refused protocol upgrades, rate
limiting, tunnel idle timeout and half-close, dial fallback and budget,
generic upstream errors, hop-by-hop header stripping, log sampling).
make lint downloads the golangci-lint version pinned as
GOLANGCI_VERSION in the Makefile, which CI reads from there too, so local
runs and CI can't drift apart. .golangci.yml keeps the
set small: every linter in it either found a real problem here or guards a
mistake that is expensive in a proxy (leaked connections, unchecked errors,
requests without a context). Tests are exempt from the resource-handling
linters only. Suppressions have to name the linter and say why, which
nolintlint enforces — there is one, for the config file the operator
names being read by path.
CI runs formatting, vet, the lint gate, govulncheck, race tests and a fuzz pass, builds
the release binaries for every platform and the Docker image, and
smoke-tests the image (checks.yml). Pushes to main also publish the
:main image (ci.yml, image.yml).