A small logging TCP proxy in Rust. Listens on one address, forwards to another, and prints every chunk that passes through as hex + ASCII — with a per-connection session ID.
This is the thing you want in a tmux pane when you're debugging a protocol over TCP (Postgres, Redis, SMTP, a custom RPC) and "is the client even sending what I think it's sending?" is the question.
listening on 127.0.0.1:5432, forwarding to 127.0.0.1:5433
[sid=0001] accepted from 127.0.0.1:55318
[sid=0001] C->S (13 bytes)
00000000 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 21 |Hello, world! |
[sid=0001] S->C (13 bytes)
00000000 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 21 |Hello, world! |
[sid=0001] closed: eof
tcpdumpshows bytes, but it's packet-level: you'll see retransmits, keepalives, and fragmentation boundaries that have nothing to do with your application framing. Reading a session back out of pcap is a chore.socatforwards TCP just fine, but it doesn't log bytes. You still have to run tcpdump next to it.mitmproxyis wonderful if you're debugging HTTP(S). It is not useful if you're debugging Postgres wire protocol or a custom framing.
tcp-proxy sits in the gap: application-level session framing, raw bytes
visible, any TCP protocol, ~400 lines of tokio.
# Build from source (requires Rust 1.90+).
cargo build --release
# Or with Docker (~10 MB alpine image).
docker build -t tcp-proxy .
docker run --rm --network host tcp-proxy \
--listen 127.0.0.1:6380 --forward 127.0.0.1:6379tcp-proxy --listen <ADDR> --forward <ADDR> [OPTIONS]
# Sniff Postgres traffic. Point psql at :5432, real server is :5433.
tcp-proxy --listen 127.0.0.1:5432 --forward 127.0.0.1:5433
# Redis with smaller dumps (lots of INFO noise otherwise).
tcp-proxy --listen 127.0.0.1:6380 --forward redis.internal:6379 --max-dump 128
# Only log what the client sends (great for request shape debugging).
tcp-proxy --listen 127.0.0.1:8080 --forward api:8080 --direction c2s
# High-throughput connection: keep counts, drop hex dumps.
tcp-proxy --listen 127.0.0.1:9000 --forward upstream:9000 --no-dump
# Machine-readable log for ingestion into your log pipeline.
tcp-proxy --listen 127.0.0.1:5000 --forward upstream:5000 --format json| Flag | Default | What it does |
|---|---|---|
--listen ADDR |
required | Address to bind, e.g. 127.0.0.1:5432 |
--forward ADDR |
required | Upstream address |
--max-dump N |
256 |
Max bytes of hex dump per chunk. 0 = unlimited |
--no-dump |
off | Only log byte counts, no hex body |
--timeout SECONDS |
30 |
Per-connection idle timeout |
--direction c2s|s2c|both |
both |
Which direction to log |
--format text|json |
text |
Log format |
Each connection opens with an accepted line and closes with a closed
line. Between those, every chunk in either direction is logged with:
[sid=0001] C->S (13 bytes)
00000000 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 21 |Hello, world! |
sidis a zero-padded hex counter (0001, 0002, ...) that lets you grep out one connection's traffic from an interleaved log.C->S/S->Cis the direction.(13 bytes)is the chunk size — useful when--no-dumpis on.- The hex+ASCII body follows the classic
hexdump -Clayout, capped at--max-dumpwith a... N more bytes ...marker.
{"kind":"accepted","sid":"0001","peer":"127.0.0.1:55318"}
{"kind":"chunk","sid":"0001","dir":"c2s","bytes":13,"dump":"00000000 48 ..."}
{"kind":"closed","sid":"0001","reason":"eof"}One object per line. dump is omitted if --no-dump is on or the chunk
is empty. Close reasons: eof, idle_timeout, shutdown, error,
upstream_unreachable.
0— clean shutdown (SIGINT / SIGTERM received, in-flight connections drained)1— failed to bind the listen address2— invalid arguments
- Manual tee loop, not
io::copy_bidirectional. The builtin copies bytes directly between halves without giving us a hook to log them. We split the downstream and upstream sockets withTcpStream::into_splitand spawn two hand-rolledread → log → writeloops, one per direction. - Pure formatter, impure sink.
format_chunkandhexdump::format_chunkare pure(&[u8], usize) -> Stringfunctions. The session handler builds one full record (header + hex body) and hands it to a sharedLogSinkbehind a mutex in a single write — so a hex dump from one connection cannot interleave with a line from another. - Graceful shutdown with a deadline. SIGINT/SIGTERM flips an
AtomicBool. The accept loop exits. Running session handlers finish their currentread()(or time out), shut down the opposite write half, and exit. A 5-second deadline caps the total drain time sodocker stopdoesn't hang on a stuck client. - No TLS termination.
tcp-proxysees the ciphertext, same astcpdumpwould. If you need to peek inside TLS, use a TLS-aware tool (mitmproxy,SSLKEYLOGFILE+ Wireshark).
cargo testUnit tests cover the hex dump formatter and log record shapes. Integration
tests stand up real 127.0.0.1:0 echo / sink servers, run a client through
session::handle directly, and assert against captured log output — so
tests exercise the real tokio read/write split with no external daemons.
MIT. See LICENSE.