Thuta Learning
IntermediateDevOps & Toolsbeginner

NAT and Private Address Space

What you'll walk away with

  • Explain the core ideas behind NAT and Private Address Space
  • Read the diagram and trace how data actually moves through it
  • Run the sample code and verify its output

Build the mental model

An IPv4 address is 32 bits, so there are about four billion of them, and by the 1990s that was clearly not enough. Two answers emerged: IPv6 for the long term, NAT for right now. NAT's premise is simple. Most devices inside a network never need to be reachable from outside, so they need no globally unique address — unique within their own network is enough. RFC 1918 reserves three ranges for exactly this: 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16. Public routers never forward these, which is why millions of offices all use 192.168.1.10 without colliding.

What is deployed almost everywhere is not plain address translation but PAT (NAPT), which rewrites the port too. When an inside host sends a packet, the router replaces the source address with its own public one and the source port with a unique port it picks, recording in a translation table that this outside port belongs to that inside host and port. When the reply comes back, the router reads the destination port and knows where to send it. The port is not an optimisation, it is the whole mechanism: once every host shares one address, the port is the only thing left that tells them apart.

That is also where NAT's big limitation comes from: table entries exist only because an outbound packet created them. If someone outside opens a fresh connection to your public address, the router has no entry to consult, and drops it. This is why a home server is unreachable from the internet, and why port forwarding — a table entry added by hand ahead of time, 'anything on 8080 goes to 192.168.1.50:80' — must be configured rather than discovered.

text
NAT TRANSLATION: BEFORE, AFTER, AND THE TABLE
---------------------------------------------
BEFORE (inside the LAN)                AFTER (on the Internet)
src 192.168.1.10:51230  --[NAT]-->     src 198.51.100.7:50000
src 192.168.1.11:51230  --[NAT]-->     src 198.51.100.7:50001
src 192.168.1.10:51231  --[NAT]-->     src 198.51.100.7:50002

NAT TRANSLATION TABLE (state held only in the router's memory)
+--------------+-------------+--------------+----------+
| inside addr  | inside port | outside port | protocol |
+--------------+-------------+--------------+----------+
| 192.168.1.10 |       51230 |        50000 | TCP      |
| 192.168.1.11 |       51230 |        50001 | TCP      |
| 192.168.1.10 |       51231 |        50002 | TCP      |
+--------------+-------------+--------------+----------+

reply arriving for  198.51.100.7:50001
   -> matches row 2 -> rewritten to 192.168.1.11:51230  DELIVERED

fresh inbound SYN to 198.51.100.7:8080
   -> no row exists -> nothing to rewrite to     DROPPED
   -> port forwarding = a row you add by hand, ahead of time

Connect it to a real scenario

Say you want to reach a dashboard running on a Raspberry Pi in the office from home. The Pi serves on 192.168.1.50:80 and every laptop in the office can open it, but from home, hitting the office's public address 198.51.100.7 gets no response at all. The reason is not a firewall on the Pi; it is that the router's NAT table has no entry for this connection, because nothing inside ever sent a packet that would have created one.

There are three ways forward. The simplest is a port-forwarding rule on the router mapping outside port 8080 to 192.168.1.50:80, which is literally a permanent NAT entry installed by hand. The second is a reverse tunnel: the Pi opens an outbound connection to a server you control and traffic rides back over it. Because it is outbound, the entry is created automatically and the router needs no configuration at all. The third is dynamic DNS, but be clear what it does — it solves the separate problem of your ISP changing the public address, and does nothing about NAT itself. And if your ISP uses carrier-grade NAT, your router does not hold a public address in the first place, so port forwarding cannot work at any level and a reverse tunnel is the only option left.

Try the working example

python
import ipaddress

PUBLIC_IP = ipaddress.ip_address("198.51.100.7")   # the router's WAN address
RFC1918 = [ipaddress.ip_network(n) for n in
           ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")]


def is_private(addr):
    ip = ipaddress.ip_address(addr)
    return any(ip in net for net in RFC1918)


class NAT:
    """Port Address Translation: many inside hosts, one outside address.
    The key of the table is the OUTSIDE port, because that is the only
    thing in the reply packet that can tell the hosts apart."""

    def __init__(self, first_port=50000):
        self.table = {}          # outside port -> (inside ip, inside port)
        self.next_port = first_port

    def outbound(self, in_ip, in_port, dst_ip, dst_port):
        for out_port, entry in self.table.items():
            if entry == (in_ip, in_port):
                break
        else:
            out_port = self.next_port
            self.next_port += 1
            self.table[out_port] = (in_ip, in_port)
        return (str(PUBLIC_IP), out_port)

    def inbound(self, out_port):
        return self.table.get(out_port)


nat = NAT()
FLOWS = [
    ("192.168.1.10", 51230, "203.0.113.5", 443),
    ("192.168.1.11", 51230, "203.0.113.5", 443),   # same inside port!
    ("192.168.1.10", 51231, "203.0.113.9", 80),
]

print("OUTBOUND: source address and port are rewritten")
print("inside socket           becomes                 destination")
print("----------------------  ----------------------  ---------------")
for ip, port, dip, dport in FLOWS:
    out_ip, out_port = nat.outbound(ip, port, dip, dport)
    print("%-22s  %-22s  %s:%d"
          % ("%s:%d" % (ip, port), "%s:%d" % (out_ip, out_port), dip, dport))

print()
print("NAT TRANSLATION TABLE")
print("outside port  ->  inside socket")
for out_port in sorted(nat.table):
    ip, port = nat.table[out_port]
    print("%12d  ->  %s:%d" % (out_port, ip, port))

print()
print("INBOUND: replies are matched by outside port")
for probe in (50001, 50099):
    entry = nat.inbound(probe)
    if entry:
        print("reply to %s:%d -> forwarded to %s:%d"
              % (PUBLIC_IP, probe, entry[0], entry[1]))
    else:
        print("reply to %s:%d -> NO TABLE ENTRY, dropped"
              % (PUBLIC_IP, probe))

print()
for addr in ("192.168.1.10", "10.20.30.5", "172.20.0.1",
             "172.32.0.1", "198.51.100.7"):
    print("%-14s private(RFC1918)=%s" % (addr, is_private(addr)))
You should see
OUTBOUND: source address and port are rewritten
inside socket           becomes                 destination
----------------------  ----------------------  ---------------
192.168.1.10:51230      198.51.100.7:50000      203.0.113.5:443
192.168.1.11:51230      198.51.100.7:50001      203.0.113.5:443
192.168.1.10:51231      198.51.100.7:50002      203.0.113.9:80

NAT TRANSLATION TABLE
outside port  ->  inside socket
       50000  ->  192.168.1.10:51230
       50001  ->  192.168.1.11:51230
       50002  ->  192.168.1.10:51231

INBOUND: replies are matched by outside port
reply to 198.51.100.7:50001 -> forwarded to 192.168.1.11:51230
reply to 198.51.100.7:50099 -> NO TABLE ENTRY, dropped

192.168.1.10   private(RFC1918)=True
10.20.30.5     private(RFC1918)=True
172.20.0.1     private(RFC1918)=True
172.32.0.1     private(RFC1918)=False
198.51.100.7   private(RFC1918)=False

5-minute try-it

Add a duplicate flow from 192.168.1.10:51230 to FLOWS: does a new row appear, and why not? Then simulate port forwarding by pre-seeding the table with a fixed entry before any outbound traffic, and confirm that the inbound probe now succeeds — that single line is what a port-forwarding rule really is.

One important caution

Treating NAT as a security feature. Blocking inbound is a side effect, and UPnP or a single compromised inside host punches straight through it. You still need a real firewall.

Assuming 172.16.0.0/12 means only 172.16.x.x. It actually spans 172.16.0.0 to 172.31.255.255, while 172.32.0.0 is public address space — a classic subnet-design mistake.

RFC 1918 - Address Allocation for Private InternetsComputer Networking

Easy traps

  • Treating NAT as a security feature. Blocking inbound is a side effect, and UPnP or a single compromised inside host punches straight through it. You still need a real firewall.
  • Assuming 172.16.0.0/12 means only 172.16.x.x. It actually spans 172.16.0.0 to 172.31.255.255, while 172.32.0.0 is public address space — a classic subnet-design mistake.
  • Validate sample code in a local or test environment before applying it to a production network.

Exercise

Add a duplicate flow from 192.168.1.10:51230 to FLOWS: does a new row appear, and why not? Then simulate port forwarding by pre-seeding the table with a fixed entry before any outbound traffic, and confirm that the inbound probe now succeeds — that single line is what a port-forwarding rule really is.

You'll know it worked when: OUTBOUND: source address and port are rewritten inside socket becomes destination ---------------------- ---------------------- --------------- 192.168.1.10:51230 198.51.100.7:50000 203.0.113.5:443 192.168.1.11:51230 198.51.100.7:50001 203.0.113.5:443 192.168.1.10:51231 198.51.100.7:50002 203.0.113.9:80 NAT TRANSLATION TABLE outside port -> inside socket 50000 -> 192.168.1.10:51230 50001 -> 192.168.1.11:51230 50002 -> 192.168.1.10:51231 INBOUND: replies are matched by outside port reply to 198.51.100.7:50001 -> forwarded to 192.168.1.11:51230 reply to 198.51.100.7:50099 -> NO TABLE ENTRY, dropped 192.168.1.10 private(RFC1918)=True 10.20.30.5 private(RFC1918)=True 172.20.0.1 private(RFC1918)=True 172.32.0.1 private(RFC1918)=False 198.51.100.7 private(RFC1918)=False

NAT and Private Address Space | Thuta Learning