Octet

Build a NAT router with a port forward in iptables, then in nftables

LinuxWorking

Tasks

  1. Enable IPv4 forwarding persistently.
  2. Set the FORWARD policy to DROP and allow LAN to internet plus return traffic.
  3. MASQUERADE the LAN out of ens192.
  4. DNAT TCP 8443 on the outside to 10.20.0.50:443 and allow it through FORWARD.
  5. Save the rules so they load at boot.
  6. Write the same policy as an nftables ruleset.

Solution

  1. 1ForwardingR1
    R1 on Linux
    echo 'net.ipv4.ip_forward = 1' > /etc/sysctl.d/90-router.conf
    sysctl --system
  2. 2Stateful FORWARD policyR1
    R1 on Linux
    iptables -P FORWARD DROP
    iptables -A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
    iptables -A FORWARD -m conntrack --ctstate INVALID -j DROP
    iptables -A FORWARD -i ens224 -o ens192 -s 10.20.0.0/24 -j ACCEPT

    The established rule goes first because almost every packet belongs to an existing flow.

  3. 3NAT and the port forwardR1
    R1 on Linux
    iptables -t nat -A POSTROUTING -s 10.20.0.0/24 -o ens192 -j MASQUERADE
    iptables -t nat -A PREROUTING -i ens192 -p tcp --dport 8443 -j DNAT --to-destination 10.20.0.50:443
    iptables -A FORWARD -i ens192 -o ens224 -p tcp -d 10.20.0.50 --dport 443 -m conntrack --ctstate NEW -j ACCEPT

    The FORWARD rule uses the inside address and port 443, because DNAT already happened in PREROUTING. With a static public address, -j SNAT --to-source 198.51.100.10 does the same job as MASQUERADE.

  4. 4Save for bootR1
    R1 on Linux
    apt install iptables-persistent
    netfilter-persistent save

    Rules are written to /etc/iptables/rules.v4 and restored at boot. Do not do this on a Docker host; see the mistakes.

  5. 5The same policy in nftablesR1
    R1 on Linux
    #!/usr/sbin/nft -f
    # /etc/nftables.conf
    flush ruleset
    
    table inet filter {
        chain forward {
            type filter hook forward priority filter; policy drop;
            ct state established,related accept
            ct state invalid drop
            iifname "ens224" oifname "ens192" ip saddr 10.20.0.0/24 accept
            iifname "ens192" oifname "ens224" ip daddr 10.20.0.50 tcp dport 443 ct state new accept
        }
    }
    
    table ip nat {
        chain prerouting {
            type nat hook prerouting priority dstnat; policy accept;
            iifname "ens192" tcp dport 8443 dnat to 10.20.0.50:443
        }
        chain postrouting {
            type nat hook postrouting priority srcnat; policy accept;
            oifname "ens192" ip saddr 10.20.0.0/24 masquerade
        }
    }

    Load with nft -f /etc/nftables.conf and enable nftables.service. Use either this file or the iptables rules, not both. The NAT table is family ip so the dnat to address needs no family keyword.

Verify

R1 output
R1# iptables -t nat -S
-P PREROUTING ACCEPT
-P INPUT ACCEPT
-P OUTPUT ACCEPT
-P POSTROUTING ACCEPT
-A PREROUTING -i ens192 -p tcp -m tcp --dport 8443 -j DNAT --to-destination 10.20.0.50:443
-A POSTROUTING -s 10.20.0.0/24 -o ens192 -j MASQUERADE

One DNAT rule in PREROUTING and one MASQUERADE in POSTROUTING. iptables prints the implicit -m tcp match.

R1 output
R1# iptables -S FORWARD
-P FORWARD DROP
-A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -m conntrack --ctstate INVALID -j DROP
-A FORWARD -s 10.20.0.0/24 -i ens224 -o ens192 -j ACCEPT
-A FORWARD -d 10.20.0.50/32 -i ens192 -o ens224 -p tcp -m tcp --dport 443 -m conntrack --ctstate NEW -j ACCEPT

Policy DROP, the established rule first, and the port-forward rule matching the inside address and port.

R1 output
R1# conntrack -L -p tcp
tcp      6 431996 ESTABLISHED src=10.20.0.15 dst=203.0.113.80 sport=40112 dport=443 src=203.0.113.80 dst=198.51.100.10 sport=443 dport=40112 [ASSURED] mark=0 use=1
tcp      6 431999 ESTABLISHED src=192.0.2.77 dst=198.51.100.10 sport=50612 dport=8443 src=10.20.0.50 dst=192.0.2.77 sport=443 dport=50612 [ASSURED] mark=0 use=1
conntrack v1.4.7 (conntrack-tools): 2 flow entries have been shown.

First line: PC1's web session, source NATed (the reply goes to 198.51.100.10). Second line: the outside client hitting 8443, DNATed (the reply comes from 10.20.0.50 port 443).

R1 output
R1# nft list chain ip nat postrouting
table ip nat {
	chain postrouting {
		type nat hook postrouting priority srcnat; policy accept;
		oifname "ens192" ip saddr 10.20.0.0/24 masquerade
	}
}

After loading the nftables file instead, the NAT rule is listed in its own table and chain.

Mistakes people make

  • Writing the port-forward FORWARD rule with the public address or port 8443. FORWARD sees 10.20.0.50 and 443.
  • Forgetting that SRV1's default gateway must be R1. Otherwise its replies bypass the NAT and the client drops them.
  • Testing the port forward from PC1 on the LAN. Without hairpin NAT that fails even when the config is right.
  • Setting iptables -P FORWARD DROP before the allow rules on a router people are using.
  • Running netfilter-persistent save or an nftables file with flush ruleset on a host where Docker also manages rules.

Say it in the interview

"Three pieces: forwarding on, a stateful FORWARD chain with default drop, and NAT. MASQUERADE in POSTROUTING for the LAN, DNAT in PREROUTING for the published port, and a FORWARD rule written for the inside address, because DNAT happens before filtering. I check it with conntrack, where both translations are visible in the reply tuples, and I keep it in one nftables file so it loads atomically."