A multiplatform HTTP and SOCKS5 proxy in C++23, built on Boost.Asio with C++20
coroutines. One io_context per core, one thread per io_context, and a
session — TCP or UDP — that lives on a single thread from accept to close.
- SOCKS5:
CONNECTandUDP ASSOCIATE, with optional username/password - HTTP:
CONNECTtunnels and absolute-URI forwarding, withProxy-Authorization - UDP: one socket per association, source-locked, rate-limited, with a destination cap
- Ad blocking: EasyList, hosts-file and plain-domain lists, applied to
CONNECT, HTTP forwarding and (optionally) DNS over UDP - Admin UI: live traffic graph, session list, whitelist and user editing, Prometheus metrics
Requirements: a C++23 compiler (clang 17+, GCC 13+, or clang-cl), CMake 3.24+,
Boost 1.81+ (headers only), OpenSSL 3. rapidyaml is vendored in
third_party/. On macOS the floor is 13.4, because std::format needs
libc++'s floating-point formatter, which Apple marks unavailable below that.
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -jmacOS:
brew install boost openssl@3 cmakeDebian/Ubuntu:
sudo apt-get install clang-19 cmake ninja-build libboost-all-dev libssl-devWindows, via vcpkg:
vcpkg install boost-asio:x64-windows boost-beast:x64-windows `
boost-json:x64-windows boost-unordered:x64-windows openssl:x64-windows
cmake -S . -B build -T ClangCL -A x64 `
-DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake"
cmake --build build --config ReleaseThere is a second, fully hermetic build. It needs nothing installed but Bazel itself — Boost, BoringSSL, googletest and the rest come from the Bazel Central Registry.
bazel build //:aproxy # binary at .bazel/bin/aproxy
bazel test //...Bazel's convenience symlinks are pointed at a hidden .bazel/ directory
(--symlink_prefix in .bazelrc) instead of littering the project root with
bazel-bin, bazel-out and friends. They are symlinks into the Bazel cache,
so nothing of substance lives there.
| Config | Effect |
|---|---|
--config=release |
opt build with LTO |
--config=asan-ubsan / --config=tsan |
sanitizers |
--config=trace |
compile the Perfetto trace points |
--config=hermetic |
build with the pinned clang from toolchains_llvm |
Note that the CMake build trees are listed in .bazelignore; that file does not
support wildcards, so a build directory outside the four documented names has to
be added there (or kept out of the source tree).
| Option | Default | Meaning |
|---|---|---|
APROXY_BUILD_TESTS |
ON |
gtest unit tests, fetched via FetchContent |
APROXY_BUILD_BENCH |
OFF |
Google Benchmark microbenchmarks |
APROXY_SANITIZE |
(empty) | address, thread, undefined, or address+undefined |
APROXY_TRACING |
OFF |
compile the Perfetto trace points |
./build/aproxy --config config/aproxy.yamlWith no --config, AProxy runs on defaults: SOCKS5 and HTTP on 0.0.0.0:1080,
no whitelist, no password. It will warn that it is open to every source address.
Usage:
aproxy [--config FILE] [--log-level LEVEL]
aproxy --check FILE validate a configuration and exit
aproxy --hash-password [PW] print a pbkdf2 record for a config file
aproxy --version
aproxy --help
Signals:
SIGHUP reload the configuration file
SIGINT graceful shutdown
SIGTERM graceful shutdown
Try it:
curl --proxy socks5h://127.0.0.1:1080 https://example.com/
curl --proxy http://127.0.0.1:1080 https://example.com/See config/aproxy.yaml for a commented sample. The
shape:
workers: 0 # 0 = one per core
log_level: info
listen:
- { addr: "0.0.0.0", port: 1080, protocols: [socks5, http] }
- { addr: "::", port: 1080, protocols: [socks5, http] }
udp:
enabled: true
public_address: "" # empty = local address of the control connection
port_range: [40000, 41000]
idle_timeout_s: 120
max_destinations: 64
max_pps: 5000
allow_client_addr_mismatch: false
resolve_domain: false
auth:
whitelist: ["127.0.0.1/32", "::1/128", "192.168.0.0/16"]
require_password: false
users:
- { name: "alice", pbkdf2: "sha256$120000$<salt>$<hash>" }
limits:
max_sessions_per_ip: 200
handshake_timeout_s: 10
idle_timeout_s: 300
tcp_pool_blocks: 4096 # per worker; x 32 KiB = memory ceiling
udp_pool_blocks: 512 # per worker; x 64 KiB
admin:
enabled: false
listen: "127.0.0.1:8080"
password_pbkdf2: ""
tls: { cert: "", key: "" }
adblock:
enabled: true
dns_filter: false
update_hours: 24
cache_dir: "/var/cache/aproxy"
lists: ["https://easylist.to/easylist/easylist.txt"]Validate before restarting anything:
aproxy --check /etc/aproxy/aproxy.yaml--check reports every problem it finds, not just the first, and warns
about configurations that are valid but probably not what you meant — an open
proxy, or UDP on a public interface with no public_address.
Checks run in this order: IP whitelist → password → command. A source address that is not on the whitelist is dropped before a single byte is read.
An empty whitelist means "accept every source". If you set neither a whitelist
nor require_password, AProxy starts and warns loudly.
Generate a password record:
aproxy --hash-passwordThe UDP ASSOCIATE reply tells the client which address to send datagrams to.
If AProxy is behind NAT, that has to be the public address, and port_range
has to be forwarded:
udp:
public_address: "203.0.113.5"
port_range: [40000, 41000]Leaving public_address empty on a non-loopback listener produces a startup
warning, because the client will be handed an address it cannot reach.
adblock:
enabled: true
dns_filter: true
update_hours: 24
cache_dir: "/var/cache/aproxy"
lists:
- "https://easylist.to/easylist/easylist.txt"
- "/etc/aproxy/local-blocklist.txt"Lists may be URLs or file paths. Downloads are verified against the system
certificate store and cached in cache_dir, so a restart without network
falls back to the last good copy rather than to no filtering.
Recognised rule forms:
| Form | Example | Effect |
|---|---|---|
| EasyList network rule | ||ads.example.com^ |
block the domain and its subdomains |
| EasyList exception | @@||safe.example.com^ |
allow, wins over any block |
| Hosts file | 0.0.0.0 ads.example.com |
block the domain |
| Plain domain | ads.example.com |
block the domain |
| Address or CIDR | 198.51.100.0/24 |
block the address range |
Cosmetic rules (##), URL-path rules and rules carrying resource-type options
($image, $script) are counted and skipped: a connection-level proxy cannot
evaluate them, and applying them to the whole host would over-block.
With dns_filter: true, a UDP datagram addressed to port 53 has its question
checked against the domain rules. A blocked name is answered directly with
0.0.0.0/:: (or NXDOMAIN for record types with no address) and never
forwarded, which is what makes domain lists effective for UDP traffic.
admin:
enabled: true
listen: "127.0.0.1:8080"
password_pbkdf2: "sha256$120000$..."
tls: { cert: "/etc/aproxy/admin.crt", key: "/etc/aproxy/admin.key" }Open http://127.0.0.1:8080/. Login is a PBKDF2 password, the session is an
HttpOnly SameSite=Strict cookie, every state-changing call needs a matching
X-AProxy-CSRF header, and logins are rate-limited per source address. Binding
to a non-loopback address without TLS logs a warning.
The page shows the proxy's own CPU use, not the machine's: it comes from
getrusage(RUSAGE_SELF) (GetProcessTimes on Windows), sampled over a window
of at least a second. The figure is a share of one core, so it can pass 100%
when several workers are busy; the card label carries the core count so the
ceiling is visible. The same numbers are on /api/stats as cpu_percent,
cpu_seconds_total and cpu_cores, and on /metrics as
aproxy_process_cpu_seconds_total and aproxy_process_cpu_percent.
Sessions are grouped by destination domain, each group showing its connection count, sorted newest first. The groups covering the ten most recent sessions are expanded; the rest are one row per domain and expand on click. The whole list scrolls inside a fixed-height block, so the page keeps the same height whether there are five sessions or five thousand. Clicking a group header toggles it, and that choice survives the background refresh.
| Endpoint | Method | Purpose |
|---|---|---|
/api/login, /api/logout |
POST | session cookie + CSRF token |
/api/stats |
GET | summed counters |
/api/sessions |
GET | live sessions across all workers |
/api/sessions/{id} |
DELETE | close one session |
/api/config/whitelist |
GET, PUT | read/replace the whitelist |
/api/config/users |
GET, PUT | list users; add or remove them |
/api/config/save |
POST | write the running config back to disk |
/api/reload |
POST | re-read the config file |
/api/adblock/update |
POST | refresh the filter lists now |
/metrics |
GET | Prometheus text format |
/ws |
WebSocket | live traffic frames every 250 ms |
PUT applies in memory immediately. POST /api/config/save persists the
running configuration to the file given by --config, keeping the previous one
as <name>.bak. The rewrite is generated, so comments in the original file are
lost — that is why it is a separate, explicit call.
- systemd:
sudo packaging/systemd/install.sh build/aproxy - launchd:
sudo packaging/launchd/install.sh build/aproxy - Windows:
packaging/windows/install-service.ps1(elevated)
The systemd unit uses AmbientCapabilities=CAP_NET_BIND_SERVICE so the process
can bind a privileged port without running as root. AProxy can also drop
privileges itself after binding, via user:/group: in the config; either is
sufficient.
-
Docker:
docker build -t aproxy . docker run --rm \ -p 1080:1080 -p 1080:1080/udp \ -p 40000-41000:40000-41000/udp \ -v "$(pwd)/config:/etc/aproxy:ro" \ aproxy
Or
docker compose up, which does the same viadocker-compose.yml./etc/aproxyis where the container looks foraproxy.yaml(mount your own directory there to edit the config from the host);/var/cache/aproxyisadblock.cache_dirand only matters if ad blocking is enabled.1080is SOCKS5/HTTP;40000-41000isudp.port_range, published as a full range becauseUDP ASSOCIATEhands out one port per session from it — shrink the range in your config and here together if you don't need that many.8080(admin UI) is exposed but not published by default sinceadmin.enabledisfalsein the sample config; add-p 8080:8080once you turn it on. The image runs as a non-root user; no capability is needed since none of these ports are privileged.
cmake -S . -B build -DAPROXY_BUILD_TESTS=ON
cmake --build build -j
cd build && ctest --output-on-failureThat runs 115 unit tests plus two integration suites:
tests/integration_proxy.sh— 33 end-to-end checks: SOCKS5 and HTTP with and without credentials, a 1 MiB payload checksummed through the relay, ad blocking over both protocols, malformed input, the whole admin API, and reload onSIGHUP.tests/integration_udp.py—UDP ASSOCIATEend to end: a relayed datagram round trip, a datagram from an unknown source being dropped,FRAG != 0being dropped, the destination cap, and the association ending when the control connection closes.
Both are hermetic — the origin server and the UDP destinations are local, so neither needs network access.
Under sanitizers:
cmake -S . -B build-asan -DCMAKE_BUILD_TYPE=Debug -DAPROXY_SANITIZE=address+undefined
cmake --build build-asan -j && ./build-asan/aproxy_tests
./tests/integration_proxy.sh ./build-asan/aproxy
python3 tests/integration_udp.py ./build-asan/aproxySubstitute -DAPROXY_SANITIZE=thread for TSan. Both are clean across the unit
tests and both integration suites. A TSan report in this codebase would mean a
design error, since nothing is shared between workers.
Load tests:
python3 tools/throughput.py ./build/aproxy --seconds 5 --streams 4
python3 tools/idle_sessions.py ./build/aproxy --sessions 10000tests/fuzz_targets.cc wires up
FuzzTest — real coverage-guided
fuzzing, not the std::mt19937 corpora that stand in for it elsewhere (see
the comment above Socks5Fuzz in tests/proto_test.cc) — over every parser
that reads bytes an attacker controls: SOCKS5 (greeting, user/pass, request,
the whole handshake, the UDP header), HTTP (the request line and headers,
Basic auth decoding), DNS questions, the YAML config, and adblock filter
lists (both parse_line and building a full Engine). FuzzTest needs Clang's
sanitizer-coverage instrumentation, which only the Bazel build wires up, so
this lives there rather than in the CMake build:
bazel test //:fuzz_test # regression: seed corpus only, any toolchain
bazel run --config=fuzztest //:fuzz_test -- \
--fuzz=Socks5.RequestNeverReadsPastInput --fuzz_for=1m # real fuzzing, one target--config=fuzztest comes from fuzztest.bazelrc, generated by
tools/regen_fuzztest_bazelrc.sh (see that
script and the comments around fuzztest/rules_go/riegeli/highwayhash
in MODULE.bazel for why it's a local script and a handful of version
overrides rather than bazel run @fuzztest//bazel:setup_configs — in short,
Bazel 9 dropped several native rules that FuzzTest's own transitive Bazel
Central Registry dependencies, at their pinned versions, still use). Plain
bazel test //... builds and regression-tests fuzz_test too, no extra
toolchain required, but that binary isn't sanitizer-coverage instrumented;
CI's bazel job separately runs bazel test --config=fuzztest //:fuzz_test
so the actual instrumented build — the one --fuzz_for fuzzing runs against
— is exercised on every push, not just when someone happens to fuzz locally.
Each of the 11 properties above ran coverage-guided for one minute
(--fuzz_for=1m) with zero crashes, ASSERT/EXPECT failures, or timeouts:
| Property | Runs | Edges covered | Corpus |
|---|---|---|---|
Socks5.GreetingNeverMisbehaves |
54,200 | 163 | 45 |
Socks5.UserpassNeverMisbehaves |
39,000 | 168 | 29 |
Socks5.RequestNeverReadsPastInput |
40,700 | 171 | 27 |
Socks5.HandshakeNeverMisbehaves |
9,700 | 191 | 46 |
Socks5.UdpHeaderNeverReadsPastInput |
43,900 | 237 | 45 |
Http.ParseNeverMisbehaves |
36,300 | 343 | 149 |
Http.DecodeBasicNeverMisbehaves |
57,000 | 262 | 115 |
Dns.ParseQuestionNeverReadsPastInput |
46,700 | 241 | 114 |
Config.ParseNeverCrashes |
14,300 | 8,231 | 652 |
Adblock.ParseLineNeverMisbehaves |
31,600 | 970 | 316 |
Adblock.EngineBuildAndLookupNeverMisbehaves |
11,400 | 2,564 | 427 |
("Edges" and "Corpus" are FuzzTest's own coverage-edge and corpus-entry counts for that run, not a fraction of some fixed total.) Config and adblock cover far more edges per run because parsing a whole YAML document or filter list touches much more code per input than one fixed-shape wire message.
The trace points from Phase 7 are compiled out by default and cost nothing. To turn them on:
cmake -S . -B build-trace -DCMAKE_BUILD_TYPE=RelWithDebInfo -DAPROXY_TRACING=ON
cmake --build build-trace -j
APROXY_TRACE_FILE=aproxy.perfetto-trace ./build-trace/aproxy --config config/aproxy.yaml
# ...run traffic through it, then stop the proxy with SIGINT/SIGTERMThe trace is written on shutdown; open it at
ui.perfetto.dev. Under Bazel, bazel build --config=trace //:aproxy does the same. The Perfetto SDK is fetched on demand
by either build, so nothing is downloaded unless tracing is enabled.
Categories and what they carry:
| Category | Events |
|---|---|
accept |
one instant per accepted connection, tagged with the worker |
handshake |
socks5.parse / http.parse slices, socks5.request / http.request, and done when the relay starts |
relay |
read.client, write.upstream, read.upstream, write.client, each with a byte count |
udp |
assoc.open / assoc.close, recv, parse.header, send.upstream, send.client |
session |
tcp_active and udp_active counters, plus a close instant |
Everything on a suspending path is an instant event or a counter, never a
scoped slice. Perfetto keeps the stack of open slices in thread-local state,
so a slice spanning a co_await would interleave with the other coroutines on
that worker and corrupt the nesting. Scoped slices are used only around code
that cannot suspend — the parsers.
Full report with methodology in docs/PERFORMANCE.md. Headline numbers on a 10-core Apple M-series, Release build:
| TCP relay, 4 streams | 19.3 Gbit/s (2.25 GiB/s) |
| UDP outbound relay, one association | ~25k pps (512-byte payloads) |
| 8,148 idle sessions | 0.00% CPU |
| SOCKS5 request parse | 5.3 ns |
| HTTP head parse | 276 ns |
| Whitelist lookup, 16k CIDRs | 9.5 ns |
| Ad-block lookup, 100k rules | 75 ns |
| Ad-block compile, 100k rules | 23.7 ms |
| Block pool acquire/release | 4.8 ns vs 41.3 ns for new/delete |
The two design decisions the measurements settled: recvmmsg/sendmmsg
batching is the top remaining optimisation (the UDP number is syscall-bound, not
harness-bound), and splice stays unimplemented until there is a Linux
measurement to justify it.
┌──────────────┐
accept ─────────►│ Worker 0 │ io_context, 1 thread, no locks
│ ┌────────┐ │
│ │ pools │ │ 32 KiB TCP blocks, 64 KiB UDP blocks
│ │ stats │ │ per-thread relaxed atomics
│ │ sweeper│ │ one 30 s timer, runs only when non-empty
│ │sessions│ │ TcpSession | UdpAssociation
│ └────────┘ │
└──────────────┘
┌──────────────┐
│ Worker N │ …one per core
└──────────────┘
┌──────────────┐
│ Admin thread │ own io_context; reaches workers only by
└──────────────┘ asio::post and per-worker SPSC rings
Session affinity. A session lives on one worker for its whole life. On Linux
each worker binds its own acceptor with SO_REUSEPORT and the kernel spreads
connections. Elsewhere worker 0 accepts, but the peer socket is created directly
on the target worker's io_context, so no native handle changes hands.
One socket per UDP association. The same socket talks to the client and to every destination, which removes the global session table entirely. Each association owns its socket, its 64 KiB receive block and its destination set. A datagram from a source that is neither the client nor a destination the client sent to is dropped — that is what stops reflection into the client.
No allocation on the relay path. Blocks come from a per-worker free list
with a hard cap from the config. When the pool is empty the session is refused;
nothing on the relay path ever calls new. The UDP receive block reserves 22
bytes at the front so a reply header can be written in front of the payload
and sent as one contiguous buffer, with no copy.
Immutable configuration. Config is parsed into plain structs and published
as a shared_ptr<const Config>. Each worker holds its own snapshot, refreshed
by an asio::post on reload, so the hot path never synchronises to read it. The
same applies to the ad-block engine.
Timeouts. One timer per session guards the handshake and then, reused, the
upstream connect — they are never in flight together. Idle sessions are found by
one sweeper timer per worker that compares a relaxed last_activity tick,
rather than a timer per session, and it only runs while that worker has
sessions.
Documented rather than silent:
- rapidyaml is vendored as the upstream single-source amalgamation
(
third_party/rapidyaml/) rather than fetched, so both builds compile byte-identical parser sources without network access. One upstream line is patched; see the README there. - PBKDF2 uses OpenSSL/BoringSSL, not Boost.Hash2. It is already a dependency
for admin TLS and list downloads, and its KDF and constant-time compare are
the vetted ones. Verification also runs on a small thread pool with a
per-worker positive cache, because 19 ms on a worker thread per authenticated
connection would stall every session on that
io_context. - Sessions use a two-method virtual interface, not
std::variant. The virtual calls happen only on a sweeper tick and on an admin request, never on the relay path, and it keepsWorkerfree of circular includes. - FuzzTest is not wired up. The fuzz properties the plan asks for
(
FUZZ_TEST(Socks5, Parse),FUZZ_TEST(Http, Parse),FUZZ_TEST(Udp, ParseHeader), and the YAML→struct path) exist as deterministic randomized-corpus gtest cases, run under ASan in CI. They check the same property — never read past the buffer, never hang, never crash — but they do not do coverage-guided mutation. - Module versions differ from the plan's. Every version named in the plan's
MODULE.bazelsketch turned out not to exist in the Bazel Central Registry; the file now pins what the registry actually carries. Perfetto has no registry module at all, so its SDK is fetched withhttp_archive.rules_shellandrules_pythonhad to be added because Bazel 9 removed the nativesh_testandpy_test. spliceandrecvmmsgare not implemented. See docs/PERFORMANCE.md for the measurements behind that.- HTTP forward proxying rewrites the request head, appends
Connection: close, and then relays the remaining bytes verbatim. Body framing headers are preserved. This means one request per upstream connection on the forward path;CONNECTtunnels are unaffected, and they are what carries essentially all real traffic. - BIND (
CMD 0x02) is parsed and answered with0x07(command not supported), as the plan specified.
CC0 1.0 Universal (public domain dedication). See LICENSE.