Skip to content

Repository files navigation

AProxy

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: CONNECT and UDP ASSOCIATE, with optional username/password
  • HTTP: CONNECT tunnels and absolute-URI forwarding, with Proxy-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

Build

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 -j

macOS:

brew install boost openssl@3 cmake

Debian/Ubuntu:

sudo apt-get install clang-19 cmake ninja-build libboost-all-dev libssl-dev

Windows, 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 Release

Bazel

There 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).

Build options

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

Run

./build/aproxy --config config/aproxy.yaml

With 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/

Configuration

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.

Authentication

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-password

UDP behind NAT

The 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.

Ad blocking

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 UI

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.

Deployment

  • 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 via docker-compose.yml.

    /etc/aproxy is where the container looks for aproxy.yaml (mount your own directory there to edit the config from the host); /var/cache/aproxy is adblock.cache_dir and only matters if ad blocking is enabled. 1080 is SOCKS5/HTTP; 40000-41000 is udp.port_range, published as a full range because UDP ASSOCIATE hands 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 since admin.enabled is false in the sample config; add -p 8080:8080 once you turn it on. The image runs as a non-root user; no capability is needed since none of these ports are privileged.

Testing

cmake -S . -B build -DAPROXY_BUILD_TESTS=ON
cmake --build build -j
cd build && ctest --output-on-failure

That 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 on SIGHUP.
  • tests/integration_udp.pyUDP ASSOCIATE end to end: a relayed datagram round trip, a datagram from an unknown source being dropped, FRAG != 0 being 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/aproxy

Substitute -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 10000

Fuzzing

tests/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.

Tracing

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/SIGTERM

The 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.

Performance

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.

Design

                    ┌──────────────┐
   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.

Deviations from the plan

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 keeps Worker free 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.bazel sketch 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 with http_archive. rules_shell and rules_python had to be added because Bazel 9 removed the native sh_test and py_test.
  • splice and recvmmsg are 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; CONNECT tunnels are unaffected, and they are what carries essentially all real traffic.
  • BIND (CMD 0x02) is parsed and answered with 0x07 (command not supported), as the plan specified.

Licence

CC0 1.0 Universal (public domain dedication). See LICENSE.

Used by

Contributors

Languages