a Postgres aware reverse proxy / protocol sniffer
This workshop explores the PostgreSQL wire protocol as a living example of binary communication systems while also showcasing Go’s elegance and power in building low-level network tools.
We’ll study, design, and implement a PostgreSQL reverse proxy and sniffer from scratch, using only Go’s standard library. This exercise demonstrates why Go is a first-class citizen in network programming: simple primitives, predictable performance, and a design that encourages understanding of what’s truly happening on the wire.
A protocol sniffer is a program that captures, inspects, and sometimes routes messages exchanged between a client and a server. It’s like watching two computers talk — byte by byte — and understanding their language.
Examples:
- Wireshark – a universal packet analyzer.
- tcpdump – the classic CLI packet capture tool.
- PgBouncer / Envoy – specialized proxies that can also inspect protocol metadata.
- PG-proxy – lightweight, concurrent, and fully under your control.
A tiny Go example (3 lines):
// snippets/simple-proxy
ln, _ := net.Listen("tcp", ":5433")
for { conn, _ := ln.Accept(); go io.Copy(os.Stdout, conn) }That’s a sniffer. A full TCP listener and reader in one line. No frameworks, no external dependencies — just the Go standard library.
| Feature | Text Protocols | Binary Protocols |
|---|---|---|
| Human-readable | ✅ | ❌ |
| Message Size | Larger (delimiters, quotes) | Smaller (fixed-size fields) |
| Examples | HTTP, SMTP | PostgreSQL, MySQL, Protobuf |
Big Endian → Most Significant Byte first.
Little Endian → Least Significant Byte first.
PostgreSQL uses Big Endian, and Go makes this explicit with binary.BigEndian util in the binary/encoding package.
n := binary.BigEndian.Uint32([]byte{0x04, 0xD2, 0x16, 0x2F})
fmt.Println(n) // 80877103 (PostgreSQL SSLRequest code)Readable, direct, and type-safe — this is Go’s charm: clarity over magic.
The The net and io Packages is arguably the most beautiful std lib in the entire world — Go’s Superpowers
The net and io packages are what make Go the language for building protocol-aware systems.
Unlike many languages that abstract away the network layer behind complex APIs, Go exposes sockets as first-class citizens — but with safety and simplicity built-in.
Every network connection in Go implements net.Conn, giving you a simple, consistent API:
type Conn interface {
Read(b []byte) (n int, err error)
Write(b []byte) (n int, err error)
Close() error
LocalAddr() Addr
RemoteAddr() Addr
}Whether it’s TCP, TLS, or Unix sockets — same interface, same code.
Combine net.Conn with the io package's Writer and Reader interfaces, and you have a universal pipeline!
ln, _ := net.Listen("tcp", ":5432")
for {
client, _ := ln.Accept()
backend, _ := net.Dial("tcp", "localhost:5433")
go io.Copy(backend, client)
go io.Copy(client, backend)
}In six lines, you’ve built a full TCP proxy — no dependencies, no event loops. This composability is where Go shines: you write plumbing, not boilerplate.
- Each client connection runs in its own goroutine:
- No manual thread management.
- No shared-state headaches.
- No deadlocks caused by I/O blocking.
Go’s scheduler multiplexes goroutines across OS threads efficiently, making network-heavy programs scale effortlessly.
Need to upgrade to TLS? Add tls.Server() or tls.Client() on top of any net.Conn.
Need timeouts, cancellation, or deadlines? Add a context.Context.
Go gives you full control — without ceremony.
-
Predictable resource usage: Goroutines scale linearly, not exponentially.
-
Minimalism: The net package encourages understanding of TCP and sockets.
-
Stability: The Go standard library rarely breaks — perfect for long-lived systems.
-
Portability: Cross-compile once, run anywhere.
-
Transparency: You see every byte that crosses the wire.
In Go, you don’t hide the complexity of networks — you embrace it, safely.
PostgreSQL’s binary wire protocol is our playground — a clean, structured system that maps beautifully onto Go’s binary APIs.
| Subprotocol | Description |
|---|---|
| Start-up | Connection setup, SSL negotiation, authentication |
| Query | Executing SQL statements |
| Function Call | Stored procedure invocation |
| Copy | Bulk data transfer |
| Termination | Connection teardown |
- Client sends SSLRequest.
- Server replies 'S' or 'N'.
- If
S, perform TLS handshake. (we'd impersonate the Postmaster here and reply with S) - Client sends StartupMessage (protocol version, user, db).
- Server authenticates → Query phase begins.
At step (3), we can intercept SNI — that’s where routing decisions are made. The beauty is that Go’s crypto/tls lets us peek at the handshake metadata before finishing the handshake — perfect for SNI-based routing.
Challenge Go’s Limitation Why It’s Still Beautiful
| Challenge | Go’s Limitation | Why It’s Still Beautiful |
|---|---|---|
| No async I/O | Uses goroutines instead of event loops | Easier to reason about concurrency |
| Verbose error handling | Explicit if err != nil blocks |
Encourages discipline and clarity |
| Limited binary parsing DSLs | Manual byte reads | Forces understanding of the protocol |
| Verbose TLS APIs | Manual wrapping | Full control and transparency |
Every "limitation" in Go forces explicitness — a virtue in protocol-level work.