net/go.book
All Parts Marketing

NAT Traversal and P2P Networking: Concepts and Go Implementation

"Imagine two apartment buildings, each with a single front desk that receives all mail for the building. If you want to send a package directly to apartment 4B in the other building, you can't — mail only flows in through the front desk, and only if the desk is expecting it. NAT traversal is the trick of getting both front desks to expect each other's package at the same time, so the packages can meet in the middle."


What NAT Is and Why It Breaks Direct Connections

Every server you've built so far assumed the client could reach it directly at a known IP and port. That assumption falls apart the moment both peers are behind NAT (Network Address Translation) — the norm for home routers and most consumer internet connections, where many private IPs (like 192.168.1.x) share one public IP.

NAT works fine for outbound connections: your laptop opens a connection out, the router remembers the mapping, and return traffic finds its way back. The problem is inbound: nothing arrives at your router unless the router already has a mapping for it. Two peers, each behind their own NAT, cannot simply net.Dial each other directly — there's no public listener on either side.

Types of NAT Behavior

  • Full cone: Once an internal address maps to an external port, any external host can reach it through that port. The most traversal-friendly.
  • Restricted cone: Only external hosts the internal host has already sent traffic to can reply through the mapping.
  • Symmetric: A new external port is assigned for every distinct destination, making the external mapping unpredictable ahead of time. The hardest to traverse.

A NAT mapping is not a connection, it's a fading memory — stop sending, and the router forgets you exist.


Hole Punching

The classic technique for connecting two NATed peers is hole punching: both peers simultaneously send traffic toward each other's public address, which causes each router to open a mapping ("punch a hole") for the other's replies, right at the moment those replies arrive. Neither side is a passive listener waiting for a stranger; both are actively initiating traffic, which is what makes it work with cone NATs.

UDP is far friendlier to this than TCP, since it's connectionless — you don't need a three-way handshake before you can start punching, you just start sending datagrams. This is the same connectionless "send now, ask questions later" model from the UDP chapter.

To punch a hole, both peers first need to learn their own public address and exchange it with the other peer — neither can see its own NAT mapping from the inside. That requires a third party both peers can already reach: a rendezvous server.


Go Implementation: A Rendezvous Server

The rendezvous server has one job: when a peer connects, record the public address the connection appears to come from (its NAT-mapped address, not whatever private IP the peer thinks it has), and hand that address to the other peer that registers under the same session.

An unmatched session is a memory leak
If a peer registers under a session ID and its counterpart never shows up — a typo, a peer that crashed, a client that gave up — the naive version of this server keeps that entry in the peers map forever. On a long-running rendezvous server handling many sessions per day, that's an unbounded, ever-growing map. Any server-side state keyed by client-supplied input needs an eviction policy, not just an insertion path.

package main

import (
	"fmt"
	"net"
	"sync"
	"time"
)

type pendingPeer struct {
	addr   *net.UDPAddr
	joined time.Time
}

type rendezvous struct {
	mu    sync.Mutex
	peers map[string]pendingPeer // session ID -> first peer's address
}

func (r *rendezvous) handle(conn *net.UDPConn, addr *net.UDPAddr, session string) {
	r.mu.Lock()
	defer r.mu.Unlock()

	other, ok := r.peers[session]
	if !ok {
		// First peer for this session: remember its address and wait.
		r.peers[session] = pendingPeer{addr: addr, joined: time.Now()}
		return
	}

	// Second peer arrived: tell each peer the other's public address.
	// Checking the error matters here — a silently dropped reply means
	// one peer waits forever for an address that was actually sent.
	if _, err := conn.WriteToUDP([]byte(other.addr.String()), addr); err != nil {
		fmt.Println("notify second peer failed:", err)
	}
	if _, err := conn.WriteToUDP([]byte(addr.String()), other.addr); err != nil {
		fmt.Println("notify first peer failed:", err)
	}
	delete(r.peers, session)
}

// sweep evicts sessions whose first peer never got a match within
// maxAge, so an abandoned or mistyped session ID doesn't sit in
// memory for the life of the server.
func (r *rendezvous) sweep(maxAge time.Duration) {
	for range time.Tick(30 * time.Second) {
		r.mu.Lock()
		for session, p := range r.peers {
			if time.Since(p.joined) > maxAge {
				delete(r.peers, session)
			}
		}
		r.mu.Unlock()
	}
}

func main() {
	conn, err := net.ListenUDP("udp", &net.UDPAddr{Port: 9400})
	if err != nil {
		panic(err)
	}
	defer conn.Close()
	fmt.Println("rendezvous server listening on :9400")

	r := &rendezvous{peers: make(map[string]pendingPeer)}
	go r.sweep(2 * time.Minute)

	buf := make([]byte, 256)
	for {
		n, addr, err := conn.ReadFromUDP(buf)
		if err != nil {
			continue
		}
		session := string(buf[:n])
		r.handle(conn, addr, session)
	}
}

Exercise: Rendezvous Server

Because the server reads the packet's source address itself (addr from ReadFromUDP) rather than trusting anything the client claims, it naturally learns each peer's real public IP and NAT-assigned port — exactly the information neither peer can see on its own.


Go Implementation: The Peer

Each peer registers with the rendezvous server under a shared session name, receives the other peer's public address, and starts sending UDP packets directly to it — punching the hole in its own NAT in the process.

package main

import (
	"context"
	"fmt"
	"net"
	"os"
	"os/signal"
	"time"
)

func main() {
	session := os.Args[1] // both peers must use the same session ID

	rendezvousAddr, err := net.ResolveUDPAddr(
		"udp", "rendezvous.example.com:9400")
	if err != nil {
		panic(err)
	}
	conn, err := net.ListenUDP("udp", &net.UDPAddr{Port: 0})
	if err != nil {
		panic(err)
	}
	defer conn.Close()

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
	defer stop()

	// Register with the rendezvous server.
	if _, err := conn.WriteToUDP([]byte(session), rendezvousAddr); err != nil {
		panic(err)
	}

	conn.SetReadDeadline(time.Now().Add(10 * time.Second))
	buf := make([]byte, 64)
	n, _, err := conn.ReadFromUDP(buf)
	if err != nil {
		panic(fmt.Errorf("no reply from rendezvous server: %w", err))
	}
	peerAddr, err := net.ResolveUDPAddr("udp", string(buf[:n]))
	if err != nil {
		panic(err)
	}
	fmt.Println("peer public address:", peerAddr)

	// connected signals the punch goroutine to stop once we've heard
	// back from the peer at least once — the hole is open, and from
	// here on application traffic itself keeps the NAT mapping alive.
	connected := make(chan struct{})

	// Punch: send directly to the peer's public address. The first few
	// packets may be dropped by the peer's NAT before its own outbound
	// packets have opened a matching hole — that's expected and why
	// both sides keep sending on a short interval.
	go func() {
		ticker := time.NewTicker(500 * time.Millisecond)
		defer ticker.Stop()
		for {
			select {
			case <-ctx.Done():
				return
			case <-connected:
				return
			case <-ticker.C:
				_, err := conn.WriteToUDP([]byte("ping"), peerAddr)
				if err != nil {
					fmt.Println("punch send failed:", err)
				}
			}
		}
	}()

	announced := false
	for {
		conn.SetReadDeadline(time.Now().Add(5 * time.Second))
		n, from, err := conn.ReadFromUDP(buf)
		if err != nil {
			select {
			case <-ctx.Done():
				fmt.Println("shutting down")
				return
			default:
				continue // still waiting for the hole to open
			}
		}
		if !announced {
			close(connected)
			announced = true
		}
		fmt.Printf("received %q from %s\n", buf[:n], from)
	}
}

Exercise: UDP Hole Punching

A retry goroutine needs a way to stop
An earlier, simpler version of the punch loop above ran for { ... time.Sleep(...) } with no exit condition — it would keep firing a UDP packet every 500ms for the entire life of the process, even long after the connection succeeded. That wastes bandwidth and makes the goroutine impossible to stop cleanly on shutdown. Any background retry loop should have an explicit stop signal, whether a context.Context cancellation, a dedicated done channel like connected above, or both.

Once each router sees outbound traffic from its own peer toward the other's address, it opens a mapping that lets the other peer's replies through — the "hole" is punched, and from that point the two peers are talking directly, with no traffic passing through the rendezvous server at all.


Limitations

Hole punching reliably works against full-cone and restricted-cone NATs. Against symmetric NAT, the external port assigned to a peer changes per destination, so the address learned via the rendezvous server (which the peer contacted from a different destination than the eventual peer-to-peer target) may already be stale by the time the other peer tries it. In that case, a relay — a server both peers can reach that simply forwards traffic between them, the same byte-shuffling proxy pattern from the previous chapter — becomes the only reliable fallback, which is exactly the role TURN servers play in production ICE implementations.

A relay is not a free fallback
Falling back to a TURN-style relay isn't just a config flag — it changes the cost and performance profile of the whole system. Every byte of every message now transits a server you operate and pay bandwidth for, and every round trip gains the relay's own latency on top of the direct path. Production ICE stacks treat the relay strictly as a last resort precisely because of this: they try direct candidates first (including hole-punched ones) and only commit to relaying once negotiation proves nothing better is reachable.


Try It Yourself: Making the Punch Observable

The rendezvous and peer programs above are enough to punch a hole, but they don't show what's happening at the network layer. Extend the exercise code:

  • Count dropped punches. Increment a counter each time the punch goroutine sends a ping, and print it once the first reply arrives — a quick way to compare how "aggressive" different router models are about opening a mapping.
  • Add a keepalive after the hole opens. Once connected closes, start a slower ticker (every 15 seconds) sending a tiny keepalive — this is what stops the conntrack row from expiring, tying back to the OS-level deep dive earlier in this chapter.
  • Simulate a lost rendezvous reply. Comment out one conn.WriteToUDP call in handle and see whether the peer that never gets a reply hangs, or whether the SetReadDeadline you added actually saves it.
  • Swap the transport. Try a direct net.Dial("tcp", ...) to the reported address instead of UDP — it fails far more often, a concrete demonstration of why hole punching is a UDP technique first.

Frequently Asked Questions

Why can't two peers behind NAT just net.Dial each other directly? Because NAT only remembers mappings it created for outbound traffic — nothing arrives at your router unless the router already expects it. Without a rendezvous step, neither peer even knows the other's public address, let alone has a mapping open to receive traffic on it, so a plain net.Dial from either side has nowhere real to land.

Why does hole punching use UDP instead of TCP? Because UDP is connectionless: a peer can start firing packets toward the other's public address immediately, with no three-way handshake required before the router opens a mapping. TCP's handshake expects a listener actively accepting a SYN, which is exactly what neither NATed peer has, so the "Swap the transport" exercise in this chapter has both peers try a raw TCP dial specifically to watch it fail more often.

If hole punching succeeds, why do the peers keep sending pings every few seconds? Because a NAT mapping is not a connection, it's a fading memory, as the chapter's opening axiom puts it — routers evict a UDP conntrack row after roughly 30 seconds of silence. The keepalive traffic in the "Try It Yourself" exercise exists purely to keep that row alive, not because the application has anything to say.

Why does the peer program reuse one socket for both the rendezvous exchange and the punch traffic? Because the NAT mapping a router creates is keyed to a specific local source port. Opening a second net.UDPConn on a different port would get a brand-new external mapping that the rendezvous server never observed and the other peer never learned, so reusing the same socket is what guarantees the address exchanged during rendezvous is the one punch traffic actually comes from.

What happens when one peer is behind a symmetric NAT? Hole punching usually fails, because symmetric NAT assigns a fresh external port per destination — the address the rendezvous server observed was learned from a different destination (the rendezvous server itself) than the eventual peer-to-peer target, so it can already be stale. That is the scenario the Limitations section falls back to a TURN-style relay for, the same role production ICE implementations reserve strictly as a last resort.


Key Takeaways

  • NAT breaks unsolicited inbound connections; hole punching works around it by having both peers punch outbound at the same time.
  • A rendezvous server's core value is observing each peer's real public address — something neither peer can determine from the inside.
  • UDP's connectionless model makes it far more forgiving for hole punching than TCP.
  • Symmetric NAT often defeats hole punching outright, which is why production systems keep a relay (TURN-style) as a fallback.