net/go.book
All Parts Marketing

Proxy Servers and Clients: Concepts and Go Implementation

"A proxy is like the receptionist at a busy office: visitors don't walk straight to an employee's desk — they talk to the receptionist, who decides who to route them to, checks their badge, and sometimes just answers the question herself from a note on the desk. Neither side needs to know exactly what happens on the other."


What Is a Proxy?

A proxy is an intermediary that sits between a client and a server, forwarding traffic on someone's behalf. You've already built the two halves it needs — a TCP client and a TCP server — in earlier chapters. A proxy is simply a program that is both at once, wired together.

Two shapes come up constantly:

  • Forward proxy: Sits in front of clients, forwarding their requests out to arbitrary destinations. Corporate proxies, VPN-like traffic filters, and anonymizing proxies are forward proxies — the client explicitly configures it as their gateway to the internet.
  • Reverse proxy: Sits in front of servers, accepting inbound requests and forwarding them to one of several backend servers. Load balancers, API gateways, and CDN edge nodes are reverse proxies — clients don't even know the backend exists.

The difference is whose identity the proxy hides: a forward proxy hides the client from the destination; a reverse proxy hides the backend from the client.

A proxy doesn't change what gets said — it changes who's allowed to hear it, and from where.


Real-World Proxy Protocols

Not every proxy speaks the same language to its clients:

  • HTTP proxy: The client sends ordinary HTTP requests, but with the full URL (GET http://example.com/page HTTP/1.1) instead of just the path, and points its request at the proxy's address. Works only for plain HTTP; HTTPS needs the CONNECT tunneling method covered later in this chapter.
  • SOCKS5: A lower-level, protocol-agnostic proxy standard (RFC 1928). The client performs a small binary handshake — negotiate an authentication method, then send a connect request naming the destination host and port — after which the proxy relays raw bytes, same as our TCP proxy below. Because SOCKS5 doesn't parse HTTP at all, it works for any TCP (and even UDP, via UDP ASSOCIATE) traffic, which is why it's the protocol behind most VPN-client and Tor configurations.
  • Transparent proxy: Intercepts traffic at the network layer (via firewall rules or routing) without the client configuring anything. The client has no idea a proxy is involved at all.

This chapter builds the two easiest to implement correctly in Go — a byte-level TCP proxy (which is structurally identical to a SOCKS5 relay once the handshake is done) and an HTTP reverse proxy — plus the CONNECT tunnel that makes an HTTP proxy usable for HTTPS.


Why Proxies Exist

  • Caching: Store responses so repeated requests don't hit the origin server.
  • Filtering and security: Block malicious requests, enforce access policies, terminate TLS in one place.
  • Load balancing: Spread requests across multiple backend instances.
  • Anonymity and privacy: Hide the client's real address from the destination.
  • Protocol translation: Accept one protocol and speak another to the backend (e.g., terminate HTTPS and forward plain HTTP internally).

A Byte-Level TCP Proxy

The simplest possible proxy doesn't understand HTTP, DNS, or any application protocol at all — it just shuffles bytes between two connections. This works for any TCP-based protocol, because a proxy at this level never has to parse the payload.

package main

import (
	"context"
	"fmt"
	"io"
	"net"
	"os/signal"
	"syscall"
)

func proxyConn(ctx context.Context, client net.Conn, upstreamAddr string) {
	defer client.Close()

	var dialer net.Dialer
	upstream, err := dialer.DialContext(ctx, "tcp", upstreamAddr)
	if err != nil {
		fmt.Println("failed to reach upstream:", err)
		return
	}
	defer upstream.Close()

	// Copy in both directions concurrently. When either side closes,
	// both io.Copy calls return and the connections are torn down.
	done := make(chan struct{}, 2)
	go func() {
		io.Copy(upstream, client)
		done <- struct{}{}
	}()
	go func() {
		io.Copy(client, upstream)
		done <- struct{}{}
	}()
	<-done
}

func main() {
	ctx, stop := signal.NotifyContext(
		context.Background(), syscall.SIGINT, syscall.SIGTERM,
	)
	defer stop()

	ln, err := net.Listen("tcp", ":9200")
	if err != nil {
		panic(err)
	}
	fmt.Println("TCP proxy listening on :9200, forwarding to :9000")

	for {
		conn, err := ln.Accept()
		if err != nil {
			select {
			case <-ctx.Done():
				return
			default:
				continue
			}
		}
		go proxyConn(ctx, conn, "localhost:9000")
	}
}

Exercise: TCP Byte Proxy

Notice how this reuses the same context.Context cancellation pattern from the context chapter: signal.NotifyContext ties the proxy's shutdown to SIGINT/SIGTERM, and DialContext respects that same cancellation when opening new upstream connections.

No deadlines means a hung peer can pin resources forever
proxyConn never calls SetDeadline, SetReadDeadline, or SetWriteDeadline on either connection. A client that opens a connection and then simply never sends or reads anything — accidentally or as a denial-of-service tactic — keeps both io.Copy calls blocked indefinitely, along with the goroutines, file descriptors, and any upstream connection slots they hold. A production proxy should set a read (and often an overall idle) deadline on both sides and reset it on every successful read, closing the pair if it's ever exceeded.

Extending proxyConn with an idle timeout is a small, realistic addition:

func proxyConn(
	ctx context.Context, client net.Conn, upstreamAddr string,
) {
	defer client.Close()

	var dialer net.Dialer
	upstream, err := dialer.DialContext(ctx, "tcp", upstreamAddr)
	if err != nil {
		fmt.Println("failed to reach upstream:", err)
		return
	}
	defer upstream.Close()

	const idleTimeout = 5 * time.Minute
	client.SetDeadline(time.Now().Add(idleTimeout))
	upstream.SetDeadline(time.Now().Add(idleTimeout))

	done := make(chan struct{}, 2)
	go func() {
		io.Copy(upstream, client)
		done <- struct{}{}
	}()
	go func() {
		io.Copy(client, upstream)
		done <- struct{}{}
	}()
	<-done
}

This sets a single deadline covering the whole relayed session rather than resetting it per read; a proxy that needs to tolerate long idle gaps between bursts of traffic (rather than bounding total session length) would instead reset the deadline inside a custom io.Reader wrapper each time bytes actually arrive.


HTTP Reverse Proxy with net/http/httputil

For HTTP traffic specifically, the standard library already ships a production-grade reverse proxy: httputil.ReverseProxy. It handles header rewriting, streaming request/response bodies, and connection reuse for you.

package main

import (
	"log"
	"net/http"
	"net/http/httputil"
	"net/url"
)

func main() {
	backend, err := url.Parse("http://localhost:8080")
	if err != nil {
		log.Fatal(err)
	}

	proxy := httputil.NewSingleHostReverseProxy(backend)

	// Wrap the proxy to add a custom header on every forwarded request,
	// a common pattern for injecting tracing or identifying the proxy hop.
	handler := func(w http.ResponseWriter, r *http.Request) {
		r.Header.Set("X-Forwarded-By", "go-reverse-proxy")
		proxy.ServeHTTP(w, r)
	}

	log.Println("reverse proxy listening on :9300, forwarding to :8080")
	log.Fatal(http.ListenAndServe(":9300", http.HandlerFunc(handler)))
}

Exercise: HTTP Reverse Proxy

NewSingleHostReverseProxy rewrites the request's scheme, host, and path prefix to point at the backend, then streams the response back to the original client — the same request/response model you saw in the HTTP chapter, just with an extra hop in the middle.


A Minimal Forward Proxy (HTTP CONNECT)

A forward proxy for plain HTTP just needs to read the target host from the request and forward it — similar to the reverse proxy above. Forwarding HTTPS is trickier, because the client wants an encrypted tunnel straight through to the destination, not a proxy that can read the traffic. HTTP defines the CONNECT method exactly for this: the client asks the proxy to open a raw TCP tunnel to host:port, and from that point on the proxy just relays encrypted bytes without ever decrypting them — the same byte-shuffling pattern as the TCP proxy above.

func handleConnect(w http.ResponseWriter, r *http.Request) {
	destConn, err := net.Dial("tcp", r.Host)
	if err != nil {
		http.Error(w, err.Error(), http.StatusServiceUnavailable)
		return
	}
	defer destConn.Close()

	hijacker, ok := w.(http.Hijacker)
	if !ok {
		http.Error(w,
			"hijacking not supported", http.StatusInternalServerError)
		return
	}
	clientConn, bufrw, err := hijacker.Hijack()
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	defer clientConn.Close()

	clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))

	// bufrw may already hold bytes the http package buffered from the
	// client while parsing headers, ahead of the CONNECT request itself.
	// Drain that buffer to destConn before relaying raw bytes directly,
	// or those leading bytes of the tunneled protocol get silently lost.
	if n := bufrw.Reader.Buffered(); n > 0 {
		buffered := make([]byte, n)
		bufrw.Read(buffered)
		destConn.Write(buffered)
	}

	go io.Copy(destConn, clientConn)
	io.Copy(clientConn, destConn)
}

http.Hijacker is the key piece here: it lets a handler take over the raw TCP connection underneath an http.ResponseWriter, stepping outside the request/response model entirely so the proxy can relay opaque bytes for the rest of the connection's life.

The original handler leaked both connections
The first version of this handler above never called destConn.Close() or clientConn.Close() — once hijacked, net/http no longer manages the connection's lifecycle for you, so if you don't close it yourself, nobody does. Every tunneled request would leak one upstream socket and one client socket until the process ran out of file descriptors. Hijacking hands you full ownership, defer included.


Hop-by-Hop Headers and Timeouts

Two details matter for any real proxy:

  • Hop-by-hop headers (Connection, Keep-Alive, Transfer-Encoding, Proxy-Authenticate) describe the connection between one pair of endpoints and should be stripped before forwarding — passing them through unmodified can confuse the next hop.
  • Timeouts prevent one slow or hung backend from exhausting the proxy's connections. Use http.Transport's ResponseHeaderTimeout and DialContext with a bounded context, or set deadlines with conn.SetDeadline on raw TCP proxies.

An open proxy is a liability
A forward proxy that accepts connections from anyone and forwards them anywhere can be abused to launder malicious traffic or scan other networks. Always authenticate clients and restrict destinations in anything beyond a local development tool.

Every hop you add to a request is another place trust has to be re-established from scratch.


Try It Yourself: Adding Proxy Authentication

Both the TCP proxy and the CONNECT handler in this chapter accept every connection unconditionally — fine for local development, unacceptable for anything reachable from outside a trusted network. Extend the CONNECT handler to require a shared secret:

  1. Require the client to send a Proxy-Authorization: Basic <base64> header on the initial CONNECT request, the same header format ordinary HTTP Authorization uses.
  2. In handleConnect, check r.Header.Get("Proxy-Authorization") before dialing the destination. If it's missing or doesn't match the expected credentials, respond with 407 Proxy Authentication Required and a Proxy-Authenticate: Basic realm="proxy" header instead of hijacking the connection.
  3. Add a small allowlist of destination hosts (a map[string]bool) and reject CONNECT requests for anything not on it — this alone turns the open proxy from the warning above into a proxy scoped to a known set of destinations.

This mirrors how real forward proxies authenticate: the credentials travel on the one HTTP request that establishes the tunnel, and every byte after that is opaque to the proxy, so there's no way (and no need) to re-authenticate mid-stream.


Frequently Asked Questions

What's the actual difference between a forward proxy and a reverse proxy, since both just relay traffic? It comes down to whose identity gets hidden. A forward proxy sits in front of clients and hides the client from whatever destination it's reaching — the client explicitly configures it as a gateway. A reverse proxy sits in front of servers and hides the backend from the client, who often has no idea multiple servers even exist back there. The wiring underneath — two connections joined together — is the same either way; only the direction of trust differs.

Why does proxyConn use two goroutines and two io.Copy calls instead of one loop? Because TCP is full-duplex — a client can be sending bytes to the server at the same instant the server is sending bytes back. A single goroutine reading and writing in a loop can only push data in one direction at a time, so it would stall whichever direction it wasn't currently servicing. Running one io.Copy per direction lets both flows proceed independently, which is exactly how the underlying network conversation actually behaves.

My proxy connection never times out and just hangs forever if one side goes quiet — why? Because the basic proxyConn example never calls SetDeadline on either connection, so a peer that opens a connection and simply stops sending or reading, whether by accident or as a denial-of-service tactic, keeps both io.Copy calls blocked indefinitely along with their goroutines and file descriptors. The chapter's fix is to set a deadline (an idle timeout, for example) on both connections and either bound the whole session or reset the deadline on every successful read if you need to tolerate long idle gaps.

Why does the CONNECT handler have to read from bufrw before relaying raw bytes from the hijacked connection? Because net/http already buffered some of what the client sent while parsing the CONNECT request's headers, and that buffer — not the raw socket — is where any leading bytes of the tunneled protocol are sitting. Reading straight from the hijacked net.Conn after calling hijacker.Hijack() would skip right past whatever was left in that bufio.ReadWriter, silently losing the first bytes of the tunneled session — a subtle bug this chapter calls out explicitly as a reason to drain the buffer first.

Do I need to close the connections myself after hijacking, or does net/http still handle that? You must close them yourself. The moment a handler calls hijacker.Hijack(), net/http stops managing that connection's lifecycle entirely — an earlier version of the handleConnect example in this chapter never called destConn.Close() or clientConn.Close(), and every tunneled request leaked one upstream and one client socket until the process ran out of file descriptors. Hijacking hands you full ownership, defer included, exactly the same discipline this book has stressed since the TCP chapters.

Key Takeaways

  • Forward proxies represent clients; reverse proxies represent servers. Same mechanics, opposite direction of trust.
  • At the TCP level, a proxy is just two io.Copy calls wired back to back — the same primitive you used for file transfer.
  • httputil.ReverseProxy gives you a correct, header-aware HTTP reverse proxy without reimplementing HTTP semantics.
  • http.Hijacker and the CONNECT method let a proxy tunnel encrypted traffic (like HTTPS) without ever decrypting it.