Firewalls and NAT on Linux
Routing, NAT and an allow-list firewall across three VMs.
Contents
I’ve used NAT every day of my life without ever watching it work. Every home router does it, every cloud VPC does it and it’s always hidden behind a checkbox. So I built the smallest network where I could do it myself: three Linux VMs in a line, the middle one acting as a router, iptables doing the translation and tcpdump running on all three so I could see each packet’s addresses change.
This post starts with how Linux decides what to do with a packet, since everything else depends on that, then builds the lab one step at a time: routing, NAT in both directions and finally turning the router into a firewall that only lets through what it’s told to.
Background: how Linux handles a packet#
Netfilter, tables and chains#
Everything that filters or rewrites packets in the Linux kernel goes through netfilter. iptables (and its successor nftables) is the tool for writing rules into it.
Rules live in chains and each chain is a checkpoint that packets pass through at a specific moment:
| Chain | When a packet hits it |
|---|---|
PREROUTING |
Right after it arrives, before the kernel decides where it’s going |
INPUT |
It’s addressed to this machine |
FORWARD |
It’s passing through this machine to somewhere else |
OUTPUT |
This machine created it |
POSTROUTING |
Just before it leaves, after the route is chosen |
Chains are grouped into tables by purpose. The two that matter here:
- The
filtertable decides whether a packet is allowed. It usesINPUT,FORWARDandOUTPUT. - The
nattable rewrites addresses. It usesPREROUTING(to change where a packet is going) andPOSTROUTING(to change where it appears to come from).
For a router, the path is: arrive → PREROUTING → routing decision → FORWARD → POSTROUTING → leave.
The kinds of NAT#
- SNAT (source NAT) rewrites the source address, usually to the router’s own address. This is what lets a whole private network share one public IP.
- MASQUERADE is SNAT that uses whatever address the outgoing interface currently has, which is handy when that address can change.
- DNAT (destination NAT) rewrites the destination address. This is port forwarding: “anything arriving on port 80, send it to that machine instead”.
Connection tracking#
If you rewrite a packet on the way out, the reply has to be rewritten back on the way in, or it goes to the wrong place. The kernel handles this with connection tracking (conntrack). When the first packet of a connection passes through a NAT rule, conntrack remembers the translation and applies it (and its reverse) to every later packet in that connection automatically.
That means NAT rules only ever see the first packet of each connection. You never write a rule for the replies.
The lab#
Three bare console VMs with minimal RAM, wired in a line. The middle VM is the only one with an interface on both subnets, so any traffic between the two ends has to go through it.
Each pair of neighbours shares a private subnet: VM1 and VM2 on one, VM2 and VM3 on the other. VM1 and VM3 have no direct link.
Step 1: make the middle VM route#
By default Linux won’t forward packets between its interfaces. A packet arriving on one interface addressed to some other machine is simply dropped. Forwarding has to be switched on:
echo 1 > /proc/sys/net/ipv4/ip_forward
This only lasts until the next reboot. To make it permanent, set net.ipv4.ip_forward = 1 in a file under /etc/sysctl.d/.
Each end VM also needs to know that the far subnet is reached through VM2, either as a default route or a specific one. With that in place, traceroute from VM1 to VM3 shows VM2 as the hop in between. That’s the proof the traffic is being routed through the middle VM and not delivered some other way.
Step 2: NAT in both directions#
This was the part I actually wanted to see. The goal is that neither end ever learns the other’s real address:
- When the client connects, the web server should see the connection coming from the router.
- The client should also only ever see the router as the other end.
That takes two rules on the router’s nat table, one for each direction:
# Outbound: rewrite the source of anything leaving toward VM3 to the router's own address
iptables -t nat -A POSTROUTING -o <out-iface> -j MASQUERADE
# Inbound: web traffic that arrives at the router gets sent on to the real server
iptables -t nat -A PREROUTING -p tcp --dport 80 -j DNAT --to-destination <vm3-ip>:80
Walk one request through it. The client connects to the router’s address on port 80. In PREROUTING, the DNAT rule changes the destination to VM3. The routing decision then sends the packet out toward VM3 and in POSTROUTING the MASQUERADE rule changes the source to the router’s address on that side. VM3 receives a packet from the router, to itself. Its reply goes back to the router and conntrack reverses both rewrites, so the client receives a reply from the router, to itself.
Then I ran tcpdump on all three machines at once:
- On VM3, every request appears to come from the router.
- On VM1, every response appears to come from the router.
- On VM2, you can see both versions of each packet: the real addresses on one interface and the translated ones on the other.
Seeing the same packet carry two different source addresses on two different machines is what made NAT concrete for me. It’s easy to read about. It’s much more convincing to watch the address change between two captures.
Step 3: turn the router into a firewall#
A firewall is really an access-control list for a network: a list of who may talk to what. So the last step was to make the router behave like one, using the filter table’s FORWARD chain, since that’s where traffic passing through the router is checked.
The policy I wanted: allow the web server’s ports, 80 and 443 and drop everything else. In iptables terms, that means a default policy of DROP and a short list of exceptions:
# Anything not explicitly allowed is dropped
iptables -P FORWARD DROP
# Let replies to already-allowed connections back through
iptables -A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# Allow new web connections to VM3
iptables -A FORWARD -p tcp -d <vm3-ip> -m multiport --dports 80,443 -j ACCEPT
The ESTABLISHED,RELATED line is easy to forget and important. Without it, the request would get through but the server’s reply would hit the DROP policy on the way back.
To test it I ran an SSH server on VM3 as well. Through the router, port 22 became unreachable while the web server kept working. That’s the firewall doing its one job: allowing the services you named and refusing everything else.
Filtering by source as well#
I then tightened it so that only the client VM could reach ports 80 and 443 and traffic from anywhere else, including the router itself, was refused. For forwarded traffic, that’s a matter of adding a source to the allow rule:
iptables -A FORWARD -p tcp -s <vm1-ip> -d <vm3-ip> -m multiport --dports 80,443 -j ACCEPT
The router is a special case. Packets it creates itself never pass through FORWARD. They go through OUTPUT, which is where blocking them has to happen:
iptables -A OUTPUT -p tcp -d <vm3-ip> -m multiport --dports 80,443 -j REJECT
It’s a good illustration that “the firewall” is really several separate checkpoints and a rule only applies to packets that actually pass through its chain.
Rules shaped like “these hosts, to these ports and nothing else” are how you express a policy at the network layer. It’s the same shape of policy I use at home to keep services boxed in.
To see what the rules are doing, list them with packet counters:
iptables -L FORWARD -v -n --line-numbers
iptables -t nat -L -v -n
The counters go up as matching packets pass, which is the quickest way to find out which rule a packet is actually hitting.
Summary#
| Piece | What it does |
|---|---|
ip_forward = 1 |
Lets the router pass packets between its interfaces at all |
nat / PREROUTING / DNAT |
Changes where an incoming packet is going (port forwarding) |
nat / POSTROUTING / MASQUERADE |
Changes where an outgoing packet appears to come from |
| Connection tracking | Remembers each translation and reverses it for replies |
filter / FORWARD with policy DROP |
Turns the router into an allow-list firewall |
ESTABLISHED,RELATED |
Lets replies back through without opening anything new |
Every home router and cloud gateway does all of this for you, silently. Doing it by hand on three throwaway VMs and watching the source address change between captures, turned NAT and firewalling from things I trusted into things I’d actually seen happen.
Further reading#
- netfilter.org, the project behind iptables and nftables
iptables-extensions(8), forconntrack,multiportand the other match modules- nftables wiki, for the modern replacement