Two machines that cannot be reached
Last lesson ended with a wall. A NAT router creates a table row only when a packet goes out. A packet coming in with no row is dropped. So a machine behind a NAT cannot accept a connection from the internet.
Now put a NAT on both sides. Peer A at 10.0.0.2 behind one router,
peer B at 192.168.1.7 behind another. A wants to send a datagram to B.
A does not know B's public address. Even if it did, B's router would drop the packet,
because B never sent anything to A. Symmetrically for B. Neither side can go first.
Both sides are waiting for the other to open a door that only opens from the inside.
Yet this is solved every day. Video calls, multiplayer games, and mesh VPNs all move packets directly between two machines that are both behind NATs. The solution is not a hack around the NAT. It is a precise use of the rules from the previous lesson.
Problem one: learning your own public endpoint
From inside, A cannot see its own outside address. getsockname returns
10.0.0.2:5000, the inside half of the row. The outside half,
whatever port the router allocated on whatever public IP, lives only in the router's memory.
But anyone on the public internet can see it. When A sends a datagram to a public server,
the server's recvfrom fills in the source address of the packet as it arrived:
after the NAT rewrote it. The server is looking at the outside half. All it has to do is
send those bytes back.
Notice what the server does not do. It never forwards a byte of the peers' data. It observes two source addresses and reports them. The replies get back through both NATs because each peer's outbound datagram created a row for the server's address. This is a rendezvous server. The standardized version of the "what is my public endpoint?" exchange is called STUN, and it is exactly this with a defined message format.
Problem two: getting a row into both tables
Now A knows B's public endpoint, 203.0.113.50:41200, and B knows A's,
198.51.100.4:40001. A sends to B. The datagram leaves A, and
NAT-A adds a row: inside 10.0.0.2:5000, remote
203.0.113.50:41200. Then it arrives at NAT-B. NAT-B checks its table: has
192.168.1.7:5000 ever sent to 198.51.100.4:40001? No. Dropped.
That first packet died, but it did its job on the way out. NAT-A is now willing to accept a packet from B's public endpoint. So B sends to A. On the way out, NAT-B adds its own row. On arrival, NAT-A finds the row from a moment ago and delivers the packet to A. From this point, both tables have a row, and datagrams pass in both directions. The server is no longer involved.
This is UDP hole punching. Both sides send to each other at roughly the same time. Each side's first packet is probably dropped by the far NAT, but it punches a hole in the near one. Step through the visualization and watch the two tables fill in.
The punch loop
Timing is not something you can coordinate precisely across the internet, so the client does not try. It sends to the peer's public endpoint every 200 ms and waits for anything to arrive from that endpoint. Whichever side's packet gets through first, both sides notice within a round trip. Once a packet has arrived, the hole is open; the loop switches to keepalives so the rows do not expire.
Three details matter. The 6 bytes the server sent are copied straight into
sin_addr and sin_port with no htons, because they
were never converted out of network order; they came from a sockaddr_in and go
back into one. The punch checks that the arriving datagram's source matches the peer,
because a stray packet from the server or anyone else must not count as success. And the
keepalive interval is 25 seconds, under the common 30-second idle timeout from the previous
lesson, with a margin for jitter.
ip:port. If you open a second socket to
talk to the peer, the kernel gives it a new ephemeral port, the router allocates a new
outside port, and the endpoint the server reported describes a socket you are no longer
using. One fd, start to finish.
When the hole cannot be punched
Hole punching relies on one property from the NAT table in the last lesson: the same
inside ip:port maps to the same outside port no matter who it talks to.
Full-cone, address-restricted, and port-restricted NATs all have it. A
symmetric NAT does not. It allocates a new outside port for each remote.
The server saw B on port 41200. When B sends to A, NAT-B uses 41201. A is sending to
41200, which has no row for A, and every packet dies. The step in the visualization
labeled "symmetric" shows the mismatch.
There are tricks, such as guessing that the next port is sequential and sending to a range. They work sometimes. When they do not, the fallback is a relay: a public server both peers send to, which copies datagrams from one to the other. Each NAT sees only ordinary outbound traffic to the relay, so nothing is dropped. The cost is an extra hop and the relay's bandwidth. The standardized relay protocol is TURN. Tailscale runs its own fleet of relays called DERP and uses them as a first connection while the punch is attempted, then switches to the direct path the moment it opens.
Why this matters for what comes next
Every device in a mesh VPN is a laptop or phone behind someone's NAT. For them to send packets to each other without routing everything through a central server, each pair must do exactly what you just did: ask a coordination server for the other side's public endpoint, punch, keep alive, and fall back to a relay when the NAT is symmetric. This lesson is the connection layer of a Tailscale-style network. What travels through the hole once it is open is the subject of the next two lessons.
A public server reads each peer's public endpoint off its packets, then both peers send to each other at once: each first packet dies at the far NAT but opens a row in the near one, and after that the datagrams flow directly.