Skip to content

sen-ltd/tcp-proxy

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

tcp-proxy

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

Why not tcpdump / socat / mitmproxy?

  • tcpdump shows 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.
  • socat forwards TCP just fine, but it doesn't log bytes. You still have to run tcpdump next to it.
  • mitmproxy is 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.

Install / run

# 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:6379

Usage

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

Flags

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

Output format

Text

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!   |
  • sid is a zero-padded hex counter (0001, 0002, ...) that lets you grep out one connection's traffic from an interleaved log.
  • C->S / S->C is the direction.
  • (13 bytes) is the chunk size — useful when --no-dump is on.
  • The hex+ASCII body follows the classic hexdump -C layout, capped at --max-dump with a ... N more bytes ... marker.

JSON

{"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.

Exit codes

  • 0 — clean shutdown (SIGINT / SIGTERM received, in-flight connections drained)
  • 1 — failed to bind the listen address
  • 2 — invalid arguments

Design notes

  • 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 with TcpStream::into_split and spawn two hand-rolled read → log → write loops, one per direction.
  • Pure formatter, impure sink. format_chunk and hexdump::format_chunk are pure (&[u8], usize) -> String functions. The session handler builds one full record (header + hex body) and hands it to a shared LogSink behind 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 current read() (or time out), shut down the opposite write half, and exit. A 5-second deadline caps the total drain time so docker stop doesn't hang on a stuck client.
  • No TLS termination. tcp-proxy sees the ciphertext, same as tcpdump would. If you need to peek inside TLS, use a TLS-aware tool (mitmproxy, SSLKEYLOGFILE + Wireshark).

Tests

cargo test

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

License

MIT. See LICENSE.

Links

About

A logging TCP proxy CLI in Rust. Listens on one address, forwards to another, and prints every chunk that passes through as classic hex+ASCII dumps tagged with per-connection session IDs — the tool you want in a tmux pane when debugging Postgres, Redis, or a custom RPC over raw TCP.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors