wiretally 0.1.0

Counts a child process's network bytes per domain via an ephemeral loopback proxy
Documentation

wiretally

Run any command behind an ephemeral counting proxy and get a per-domain byte report when it exits. No root, no certificate to install, no setup.

Counts traffic from cooperative clients — those that honour HTTP_PROXY/HTTPS_PROXY/ ALL_PROXY, which covers curl, git, the AWS SDKs, requests, and most everything else that speaks HTTP or TCP. Traffic a client sends without consulting the proxy is not measured; see cooperative clients only.

wiretally --domain-prefix amazonaws.com -- mcap info s3://my-bucket/dataset.mcap
================================================================================
                          NET-COUNTER TRAFFIC SUMMARY
================================================================================
Command:          curl -s -o /dev/null https://example.com/
Target Filter:    *.example.com (Prefix Match)
Execution Time:   0.09s
Exit Code:        0

DOMAIN / ENDPOINT SUMMARY:
--------------------------------------------------------------------------------
ENDPOINT / DOMAIN                   INGRESS (Rx)      EGRESS (Tx)   CONNS
--------------------------------------------------------------------------------
example.com                              4.71 KB            585 B       1
--------------------------------------------------------------------------------
TOTAL (Matching Filter):                 4.71 KB            585 B       1
TOTAL (All Destinations):                4.71 KB            585 B       1
================================================================================

Usage

wiretally [OPTIONS] -- <COMMAND> [ARGS...]

  -d, --domain-prefix <STRING>  Domain suffix filter, e.g. "amazonaws.com"
  -v, --verbose                 Per-connection wire log on stderr
  -j, --json                    Emit the summary as JSON instead of a table

The child's stdout and stderr are inherited directly, and wiretally exits with the child's exit code (128 + signal if it was killed), so it drops into pipelines and CI without changing behaviour. With --json, the report is a single JSON document on stdout:

wiretally -j -d amazonaws.com -- mcap info s3://bucket/file.mcap \
  | jq '.endpoints[] | select(.matches_filter) | {domain, ingress_bytes}'

What this measures: cooperative clients only

wiretally counts what a client chooses to send through it. It is not a packet capture and not an interception layer — it is a proxy, which means it sits in the path only for clients that read the proxy environment variables and route themselves through it. Proxy env vars are advisory configuration, not enforcement.

counted:     child ──TCP──> wiretally ──TCP──> service     (service's only peer is the proxy)
invisible:   child ──UDP──────────────────────> service     (proxy never involved)

Two consequences worth internalising:

  • Inside a tunnel, accounting is complete. Once a connection goes through wiretally, the service knows nothing but the proxy's address and replies to it, so both directions are seen and counted. A service cannot escape an established tunnel and talk to the child directly — it has no idea the child exists.
  • Outside a tunnel, there is nothing to see. Anything the child sends without asking the proxy first — a raw sendto(), a QUIC handshake, a DNS query, a library that only honours its own config keys, a host matched by NO_PROXY — leaves the machine directly. The service then replies to the child, because the child is the only peer it ever had. Those bytes are not under-counted; they are absent, and wiretally cannot detect that they happened.

So the report is exact for cooperative traffic and silent about the rest. For clients whose transports you know (the AWS SDKs, curl, git, requests, most gRPC-over-TCP stacks) that covers everything. If you are unsure for a given command, verify from outside — lsof -i UDP -p <pid> while it runs, or a one-off sudo tcpdump -i any 'udp and port 443'. Structurally bypass-proof accounting needs OS-level capture (a TUN device, eBPF, or pcap), which costs root or Linux-only support; wiretally trades that for being rootless, cross-platform, and zero-setup.

How it works

  1. A proxy binds an OS-assigned port on 127.0.0.1 and speaks three protocols on it, chosen by the first byte of each connection: SOCKS5 (0x05), HTTP CONNECT, and plain HTTP.
  2. The child's environment — and only the child's — gets HTTP_PROXY/HTTPS_PROXY (plus lowercase) pointing at http://127.0.0.1:PORT, and ALL_PROXY/all_proxy pointing at socks5h://127.0.0.1:PORT. Nothing global is touched.
  3. CONNECT and SOCKS5 connections are spliced as raw TCP. TLS is never terminated: no certificate to install, no crypto on the data path, and the counters see exactly the ciphertext that crossed the wire. Because the splice is protocol-agnostic, anything the client tunnels is measured — HTTP/2, gRPC, WebSockets, Postgres, plain TLS. Plain HTTP is forwarded by hyper, with the counters on the upstream socket so the numbers reflect what left the machine rather than what the child handed to the proxy.
  4. Counting is one relaxed fetch_add per read or write on an otherwise untouched buffer, so the per-chunk overhead is a few nanoseconds and nothing is buffered in memory.
  5. After the child exits, in-flight connections are given up to two seconds to drain, endpoints that were only ever seen as bare IPs get a reverse-DNS name, and the report is printed.

socks5h rather than socks5 matters: it makes the client send hostnames to the proxy instead of resolving them first, so endpoints stay named in the report instead of collapsing to IPs.

What gets counted

Traffic Counted Via
HTTPS, HTTP/2 over TLS yes CONNECT tunnel
Plain HTTP yes forwarded by hyper
gRPC, Postgres, Redis, arbitrary TCP yes, if the client honours ALL_PROXY SOCKS5 CONNECT
UDP a client routes through the proxy yes SOCKS5 UDP ASSOCIATE relay
QUIC, HTTP/3, DNS sent straight to the destination no never reaches the proxy
Anything from a client that ignores the proxy env vars no not cooperative, so not in the path

Endpoint names come from the CONNECT target or request URI when available, because that is what the child asked for; a PTR record for an S3 address (s3-1-w.amazonaws.com) says much less than the regional endpoint the SDK used. Reverse DNS is therefore a fallback, not the default.

--domain-prefix matches on label boundaries: amazonaws.com covers amazonaws.com and s3.us-east-1.amazonaws.com, but not notamazonaws.com. Non-matching endpoints are dropped from the table but still counted in the TOTAL (All Destinations) row, so a filter can never hide traffic completely.

Limitations

All of these follow from being a proxy rather than a packet capture — see cooperative clients only for why:

  • Only cooperative clients are measured. Anything that ignores the proxy environment variables is invisible: raw sockets, hosts matched by an inherited NO_PROXY, and SDKs that only honour their own config keys.
  • UDP is counted only when the client routes it through the relay. QUIC and HTTP/3 normally open a UDP socket straight to the destination, and DNS almost always does. wiretally cannot even detect that this happened: the Alt-Svc: h3=… header announcing the switch arrives inside TLS it deliberately does not decrypt. If an application or server can shift to UDP, treat the totals as a floor rather than a full account.
  • The CONNECT/SOCKS handshake between child and proxy is excluded from the totals — it is local overhead that never reaches the remote host.
  • Per-endpoint totals aggregate by hostname, so two hostnames on the same IP stay separate rows (which is usually what you want) and one hostname across many IPs collapses into one row.

Development

cargo test          # unit, doc, and integration tests
cargo clippy --all-targets

The integration tests put a raw TCP origin server behind the proxy and make that server count what crossed its own socket; the proxy's counters must match those numbers exactly. No network access is required for them.