Networking · lesson 10

NAT

Your laptop says it is 192.168.1.7. The server you just talked to says you are 203.0.113.9. Both are telling the truth. A router in between is rewriting your packets, and it keeps a table to remember how.

in progress
12 min interactive

Two answers to "what is my address?"

Run ip addr on your laptop. It prints something like 192.168.1.7. Now send a UDP datagram to a server you control and have the server print the address recvfrom fills in. It prints 203.0.113.9, and a port you never bound. Your program wrote 192.168.1.7 into the IP header. Something changed it on the way.

That something is your home router. It is doing network address translation: rewriting the source address and port of every packet that leaves your network, and rewriting the destination of every reply that comes back. It has to remember every rewrite it makes, and the place it remembers them is the subject of this lesson.

Private addresses

IPv4 has 232 addresses, about 4.3 billion. There are far more devices than that. The fix, from 1996, was to carve out three ranges that are never routed on the public internet. Anyone may use them inside their own network. Billions of machines share them.

range (RFC 1918) prefix addresses where you see it
10.0.0.0 – 10.255.255.255 10.0.0.0/8 16,777,216 offices, cloud VPCs
172.16.0.0 – 172.31.255.255 172.16.0.0/12 1,048,576 Docker's default bridge
192.168.0.0 – 192.168.255.255 192.168.0.0/16 65,536 home routers

A packet with a source of 192.168.1.7 cannot cross the internet. No router out there has a route back to it, because millions of networks contain a 192.168.1.7. So the router at the edge of your network has to replace it with an address that is routable: its own public one.

The rewrite

Your laptop sends a datagram from 192.168.1.7:51234 to 203.0.113.9:443. It reaches the router first, because of the default route from the routing lesson. The router does three things before forwarding it:

  1. Overwrites the source IP in the IP header with its public address, 198.51.100.4.
  2. Overwrites the source port in the UDP header with a port it picks, say 40001.
  3. Recomputes the IP and UDP checksums, because the bytes they cover have changed.

The server receives a datagram from 198.51.100.4:40001. That is the address its recvfrom reports. It replies to it, because it is the only address it has.

Now the reply arrives at the router, addressed to 198.51.100.4:40001. The router owns that address. But port 40001 belongs to no process on the router. The packet is for the laptop, and the router has to know that. It knows because it wrote it down.

The translation table

Every NAT router keeps a table in memory. One row per active mapping:

inside src outside port remote proto idle
192.168.1.7:51234 40001 203.0.113.9:443 UDP 0s

Outbound: the router looks up the inside source and remote. If there is no row, it allocates an outside port and inserts one. Inbound: the router looks up the outside port (and, usually, the remote) and finds the inside address to rewrite the destination to. Both lookups must be fast, because every packet does one. This is a hash table keyed on the 5-tuple from the addresses lesson, protocol, source, destination, exactly the structure you built in the algorithms track. A home router holds a few thousand rows. A carrier's NAT holds millions.

💡
NAT is a hash table that rewrites packets. Every outbound packet is an insert-or-lookup. Every inbound packet is a lookup. The router has no other knowledge about your connections. Its memory of your traffic is that table.

Step through the visualization. Watch the row appear on the way out and get used on the way back, and watch what happens to a packet that has no row.

nat — one mapping, one table row
Click next to start the walkthrough.

Why nobody can reach you

Look at the step where an unsolicited datagram arrives for port 40002. The router searches the table for outside port 40002. No row. Which inside machine should get it? The router cannot know, so it drops the packet. It is not making a security decision. It is failing a lookup.

This is why "just run a server at home on port 8080" does not work. Your program calls bind and listen, and the kernel is ready. But the packets from the outside world arrive at the router, find no row, and die there. A row is created only by outbound traffic. Rows are never created by packets arriving from the internet. The side behind the NAT must always speak first.

⚠️
"Connection refused" versus silence. A machine with no listener answers a UDP datagram with an ICMP "port unreachable", and a TCP SYN with a RST. A NAT with no row says nothing at all. If your client hangs with no error, you are probably talking to a NAT that dropped you.

Rows expire

For TCP, the router can watch the handshake and the FINs and knows when a connection ends. UDP has no handshake and no close. The router cannot tell a finished exchange from a pause. So it uses a timer: every row records when it last carried a packet, and a row that stays idle past a timeout is deleted. The timeout varies by router. Thirty seconds is common. Some allow a few minutes. Some drop to as little as ten seconds under load.

The consequence for your code: if two programs exchange UDP datagrams through a NAT and then go quiet for a minute, the next datagram from the outside is dropped. The row is gone. The fix is a keepalive: send a small datagram often enough to reset the timer. Twenty-five seconds is the conventional interval, chosen to beat the common thirty-second timeout.

✅
Any long-lived UDP session through a NAT needs keepalives. Send a one-byte datagram every 25 seconds while the session is idle. The content does not matter; only the fact that a packet crossed the router does.

Seeing your half of the mapping

You cannot see the router's table from your laptop. But you can see the inside half of the row: the ephemeral port your kernel picked. getsockname fills in the local address of a socket. Before the first sendto, the port is 0, meaning not yet assigned. After it, the kernel has chosen one.

c
#include <arpa/inet.h> #include <stdio.h> #include <string.h> #include <sys/socket.h> int main(void) { int fd = socket(AF_INET, SOCK_DGRAM, 0); if (fd == -1) { perror("socket"); return 1; } struct sockaddr_in server = { 0 }; server.sin_family = AF_INET; server.sin_port = htons(443); inet_pton(AF_INET, "203.0.113.9", &server.sin_addr); // no bind() — the kernel picks an ephemeral port on first send sendto(fd, "hi", 2, 0, (struct sockaddr *)&server, sizeof server); struct sockaddr_in local; socklen_t len = sizeof local; getsockname(fd, (struct sockaddr *)&local, &len); char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &local.sin_addr, ip, sizeof ip); printf("inside half: %s:%u\n", ip, ntohs(local.sin_port)); // prints e.g. 192.168.1.7:51234 — the router's table has a row // 192.168.1.7:51234 -> 198.51.100.4:40001, invisible from here return 0; }

The outside half, 198.51.100.4:40001, exists only in the router. The only way to learn it is to ask someone on the other side what they saw. That is the next lesson.

Not all NATs behave the same

The table above keyed rows on the inside source and the remote. Real routers differ in how strict that key is, and the difference decides whether two machines behind NATs can ever talk directly.

kind one line
full-cone Once inside A:p has a row, anyone may send to its outside port and reach it.
address-restricted Inbound is allowed only from an IP that A:p has already sent to; any port on that IP.
port-restricted Inbound is allowed only from the exact IP:port that A:p has sent to. The most common home router.
symmetric A different outside port for every remote. The port a third party sees is useless for anyone else.

The first three share one property: the same inside A:p gets the same outside port no matter who it talks to. That single property is what makes the next lesson possible.

one-line takeaway

A NAT router is a hash table of rewrites: rows are created only by outbound packets, inbound packets without a row are dropped, and idle UDP rows expire unless you keep them alive.