This lab configures a Linux host as a NAT gateway for a small internal network. By the end you will have SNAT for outbound and DNAT for inbound port forwarding, validated end-to-end.
Objective
By the end of this lab, you can:
- Configure a host as a NAT gateway.
- Configure source NAT for outbound traffic.
- Configure destination NAT for inbound port forwarding.
- Validate the topology from internal and external hosts.
Architecture
You need three hosts (or VMs):
- Gateway (10.0.0.1, eth0; 192.0.2.10, eth1): the NAT gateway. Two interfaces.
- Internal (10.0.0.5, eth0): an internal host behind the gateway.
- External: any host outside the gateway (use the host running this lab).
The gateway’s eth0 faces the internal network; eth1 faces the external network.
Tasks
Task 1: Record the pre-lab state, then configure IP forwarding
Capture what the gateway had before you change it. The cleanup section restores from these two files; without them the only cleanup available is zeroing state that may not have been zero.
# The existing ruleset - Docker, libvirt or firewalld may own tables here
sudo nft list ruleset | sudo tee /root/nft-rollback.conf >/dev/null
# The existing forwarding setting, which may already be 1
sysctl -n net.ipv4.ip_forward | sudo tee /root/ip_forward.orig
Then enable forwarding:
# sudo tee, not a redirect: in `sudo echo 1 > /proc/...` the shell opens the
# file as your unprivileged user and the write fails with Permission denied.
echo 1 | sudo tee /proc/sys/net/ipv4/ip_forward
# Persist
echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-nat.conf
sudo sysctl -p /etc/sysctl.d/99-nat.conf
Task 2: Configure the NAT ruleset
Create /etc/nftables.d/nat.conf:
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept
iif lo accept
ip protocol icmp accept
tcp dport 22 accept # SSH for management
}
chain forward {
type filter hook forward priority 0; policy drop;
ct state established,related accept
# Allow internal hosts to reach external
iifname "eth0" oifname "eth1" accept
# Allow external to reach internal port 22 (forwarded)
iifname "eth1" oifname "eth0" ip daddr 10.0.0.5 tcp dport 22 accept
}
chain output {
type filter hook output priority 0; policy accept;
}
}
table inet nat {
chain postrouting {
type nat hook postrouting priority 100;
# Source NAT for outbound traffic from internal
oifname "eth1" ip saddr 10.0.0.0/24 snat to 192.0.2.10
}
chain prerouting {
type nat hook prerouting priority -100;
# DNAT for inbound port forwarding
# The `ip` keyword is mandatory here - see the note below.
iifname "eth1" tcp dport 2222 dnat ip to 10.0.0.5:22
}
}
Task 3: Apply and validate
sudo nft -c -f /etc/nftables.d/nat.conf
sudo nft -f /etc/nftables.d/nat.conf
sudo nft list ruleset
Task 4: Test outbound NAT
On the internal host:
# Default route via the gateway
sudo ip route add default via 10.0.0.1
# Test external connectivity
ping -c 3 8.8.8.8
curl -I https://example.com
These should work. From the gateway:
sudo conntrack -L -s 10.0.0.5
You should see a NAT entry for the internal host’s connection, with the source rewritten to 192.0.2.10.
Task 5: Test inbound DNAT
From an external host (or the same host):
ssh -p 2222 user@192.0.2.10
The connection should land on the internal host (10.0.0.5). On the gateway:
sudo conntrack -L -d 192.0.2.10
You should see a DNAT entry showing the destination rewritten from 192.0.2.10:2222 to 10.0.0.5:22.
Task 6: Test denial
Pick a port that is neither forwarded nor served by the gateway. 8080 has no DNAT rule and nothing is listening on it, so it is the honest negative test:
nc -vz -w 3 192.0.2.10 8080
With policy drop on the input chain and no rule matching, the
packet is dropped silently and nc times out after 3 seconds.
A Connection refused here would mean a RST came back, which is
what you would see if the chain policy were reject or if
something on the gateway were listening — either is a finding
worth chasing.
Task 7: Test SSH access to the gateway itself
ssh user@192.0.2.10
This should work (the gateway’s own SSH is allowed by the input chain). DNAT is for traffic forwarded through the gateway, not to the gateway.
Task 8: Document the topology
# NAT topology
## Gateway (192.0.2.10)
- eth0: 10.0.0.1 (internal)
- eth1: 192.0.2.10 (external)
- Forwards internal -> external (SNAT)
- Forwards external:2222 -> internal:22 (DNAT)
## Internal host (10.0.0.5)
- Default route via 10.0.0.1
- Outbound appears as 192.0.2.10
## External clients
- ssh -p 2222 user@192.0.2.10 reaches 10.0.0.5
## Validation
- Internal host: ping 8.8.8.8 (via gateway, SNAT)
- External: ssh -p 2222 192.0.2.10 (DNAT to 10.0.0.5:22)
- conntrack shows both NAT entries
Validation
- Outbound traffic from internal works.
- Inbound port forwarding works.
- Direct access to the gateway’s ports works (input chain).
- Unforwarded ports are blocked.
Cleanup
Remove only what this lab created, and restore the state you captured in Task 1.
# Delete the lab's own tables. Never `nft flush ruleset` in a cleanup:
# it deletes every table on the host, including Docker's, libvirt's,
# Kubernetes' and firewalld's.
sudo nft delete table inet nat 2>/dev/null || true
sudo nft delete table inet filter 2>/dev/null || true
# Restore the ruleset that was loaded before the lab
if [ -s /root/nft-rollback.conf ]; then
sudo nft -c -f /root/nft-rollback.conf && sudo nft -f /root/nft-rollback.conf
fi
sudo rm -f /etc/nftables.d/nat.conf /etc/sysctl.d/99-nat.conf
# Restore forwarding to what it was - not to 0. A gateway, a Docker host
# or any box running libvirt NAT needs ip_forward=1, and zeroing it breaks
# container and VM networking with no error message.
sudo sysctl -w net.ipv4.ip_forward="$(cat /root/ip_forward.orig)"
# Confirm
sudo nft list ruleset
sysctl -n net.ipv4.ip_forward
On the internal host:
sudo ip route del default via 10.0.0.1
What you learned
- A NAT gateway requires IP forwarding enabled and the correct firewall rules.
- SNAT rewrites outbound traffic; DNAT rewrites inbound.
- The conntrack table confirms what the firewall actually did.
- The difference between traffic to the gateway itself (input chain) and traffic through the gateway (forward chain).
- In an
inettable,dnat/snatwith a literal address need an explicitiporip6keyword unless an earlier match in the same rule has already pinned the family. nft -fis atomic: one bad rule rejects the entire file, sonft -c -fis a mandatory pre-flight and not an optional courtesy.