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.
================================================================================
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:
|
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 byNO_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
- A proxy binds an OS-assigned port on
127.0.0.1and speaks three protocols on it, chosen by the first byte of each connection: SOCKS5 (0x05), HTTPCONNECT, and plain HTTP. - The child's environment — and only the child's — gets
HTTP_PROXY/HTTPS_PROXY(plus lowercase) pointing athttp://127.0.0.1:PORT, andALL_PROXY/all_proxypointing atsocks5h://127.0.0.1:PORT. Nothing global is touched. CONNECTand 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.- Counting is one relaxed
fetch_addper read or write on an otherwise untouched buffer, so the per-chunk overhead is a few nanoseconds and nothing is buffered in memory. - 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
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.