nftables from Scratch: Tables, Chains and NAT Examples - 夜莺博客

nftables from Scratch: Tables, Chains and NAT Examples

nftables is the Linux kernel's current packet filtering framework — the default on Debian 12, Ubuntu 22.04+ and RHEL 9 — replacing iptables, ip6tables, arptables and ebtables with one tool, one syntax and atomic rule updates. Unlike iptables there are no predefined tables: you create every table, chain and rule yourself. This guide builds a complete stateful server firewall plus a NAT gateway in /etc/nftables.conf, then shows how to migrate an existing iptables ruleset.

Core Concepts

  • Table — a namespace scoped to an address family: ip, ip6, inet (both, prefer this), arp, bridge, netdev.
  • Base chain — attached to a kernel hook (prerouting, input, forward, output, postrouting) with a type (filter, nat, route) and priority.
  • Policy — the default verdict (accept/drop) when no rule matches.

Why the Kernel Moved to nftables

The old iptables world was four separate binaries — iptables, ip6tables, arptables and ebtables — each with its own rule format, each compiled against a different kernel subsystem, and each one rewritten in full every time you added a single rule. On a host with a few thousand rules, iptables-restore could take seconds and every call held the global xt_table lock, so adding a blocklist entry stuttered packet processing for everyone.

nftables replaces all four with a single userspace tool and a single kernel engine. Rules are compiled into bytecode and evaluated by one virtual machine; the kernel keeps a transaction log, so an update either applies completely or not at all — no more window where packets are half-filtered. Named sets and maps let one rule match a million addresses with a hash lookup instead of a million linear comparisons, which is the single biggest reason to migrate: a blocklist that would have cost you ten thousand rules costs you one.

The trade-off is that nftables does not pre-create anything. There is no implicit filter table, no INPUT chain waiting for you. If you forget to build them, nothing filters anything and the host sits wide open while the service looks healthy. That is the most common way a throwaway "quick" server ends up unprotected.

Install and Enable


sudo apt update && sudo apt install -y nftables
sudo systemctl enable --now nftables

The Configuration File and Persistence

Two things must be true for your firewall to survive a reboot: the ruleset has to live in a file, and the service has to load it. Debian and Ubuntu ship /etc/nftables.conf with a shebang line that lets the file run as an executable and an include directive that pulls in fragments from /etc/nftables.d/:

#!/usr/sbin/nft -f
flush ruleset
include "/etc/nftables.d/*.nft"

table inet filter {
    chain input { type filter hook input priority 0; policy drop; }
}

Splitting the ruleset into /etc/nftables.d/ files is worth the effort on anything non-trivial: one file per purpose (10-baseline.nft, 20-services.nft, 30-blocklist.nft) means you can regenerate one concern without touching the others. On RHEL 9 and derivatives the service is nftables.service reading /etc/sysconfig/nftables.conf, which includes /etc/nftables/ and does not flush by default — a subtle difference that matters when you copy a Debian ruleset onto a RHEL box.

sudo systemctl enable --now nftables
sudo systemctl status nftables
sudo systemctl restart nftables   # re-reads the file, atomic swap

Never edit the file and assume the kernel noticed. The file is only input; the running ruleset lives in the kernel until something loads the new version. Check both with sudo nft list ruleset and sudo nft -f /etc/nftables.conf.

A Stateful Server Firewall

Drop inbound by default, allow established flows, loopback, ICMP and the services you expose:


#!/usr/sbin/nft -f
flush ruleset

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;
        iif lo accept
        ct state invalid drop
        ct state { established, related } accept
        ip protocol icmp icmp type { echo-request } limit rate 10/second accept
        ip6 nexthdr ipv6-icmp icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert } accept
        tcp dport 22 accept
        tcp dport { 80, 443 } accept
        log prefix "nft-drop: " flags all drop
    }
    chain forward { type filter hook forward priority 0; policy drop; }
    chain output  { type filter hook output priority 0; policy accept; }
}

Load and validate atomically — nftables applies a file as one transaction, so a syntax error changes nothing:


sudo nft -c -f /etc/nftables.conf
sudo nft -f /etc/nftables.conf
sudo nft list ruleset

Reading the Ruleset: Handles, Tracing and Debugging

nftables assigns a handle to every rule. Ask for them with -a and you can delete a single rule without rewriting the file:

sudo nft -a list ruleset
sudo nft delete rule inet filter input handle 14
sudo nft insert rule inet filter input handle 4 tcp dport 8443 accept

Handles are not stable across a reload — re-issue the ruleset and every handle is regenerated — so use them for live surgery during an incident, then fold the change back into /etc/nftables.conf and reload.

When packets vanish and you cannot tell which rule ate them, turn on tracing. It is the nftables equivalent of iptables -j LOG everywhere at once, and it works per packet:

# mark a flow for tracing
sudo nft add rule inet filter input ip saddr 198.51.100.7 meta nftrace set 1

# watch the trace in another terminal
sudo nft monitor trace

# stop tracing when done
sudo nft delete rule inet filter input handle <N>

The trace output names each rule the packet traversed and the verdict it received, which resolves the classic "the connection times out but the rule looks right" problem in seconds. Remember to remove the trace rule: every matching packet now generates console output, and on a busy host that is a self-inflicted denial of service.

Counters are the cheaper option for the same question. Attach counter to a rule and read it back later to see whether traffic ever matched:

sudo nft add rule inet filter input tcp dport 5432 counter accept
sudo nft list ruleset | grep -A1 5432

Named Sets: Blocklists without Rule Sprawl

A set matches hundreds of addresses with one rule and hash-based lookups. Entries can be added at runtime without reloading the ruleset:


table inet filter {
    set blocklist { type ipv4_addr; flags timeout; timeout 1h; }
    chain input {
        ip saddr @blocklist drop
    }
}

sudo nft add element inet filter blocklist { 203.0.113.66 }

Concatenated Sets, Verdict Maps and Intervals

Sets are not limited to one field. A concatenated set keys on a tuple — address plus port, or interface plus address — which collapses an entire allow-list into a single rule:

table inet filter {
    set mgmt_pairs {
        type ipv4_addr . inet_service
        elements = { 10.20.0.0/24 . 22, 10.20.0.0/24 . 161, 192.0.2.5 . 443 }
    }
    chain input {
        ip saddr . tcp dport @mgmt_pairs accept
    }
}

For CIDR-heavy lists, mark a set as storing intervals so the kernel stores contiguous ranges rather than one entry per address:

set trusted_nets { type ipv4_addr; flags interval; auto-merge;
    elements = { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } }

A verdict map goes further and returns an action instead of a boolean. Load balancers, redirection targets and per-source policies all become table lookups with no rule duplication:

table inet filter {
    map redirect_to {
        type ipv4_addr : ipv4_addr
        elements = { 203.0.113.10 : 192.168.1.10, 203.0.113.11 : 192.168.1.11 }
    }
    chain prerouting {
        type nat hook prerouting priority dstnat;
        ip daddr vmap @redirect_to dnat
    }
}

Timeouts matter operationally. A set declared with flags timeout and an element timeout lets a fail2ban-style script add an address that expires on its own, so a brute-force source is blocked for an hour and then forgotten instead of accumulating forever:

sudo nft add element inet filter blocklist { 203.0.113.66 timeout 1h }
sudo nft list set inet filter blocklist

NAT Gateway: Masquerade and DNAT

Forwarding needs the kernel sysctl plus explicit rules; masquerade rewrites the source of LAN egress, and DNAT exposes internal services:


sudo sysctl -w net.ipv4.ip_forward=1

table ip nat {
    chain postrouting {
        type nat hook postrouting priority srcnat;
        oifname "eth0" masquerade
    }
    chain prerouting {
        type nat hook prerouting priority dstnat;
        tcp dport 2222 dnat to 192.168.1.20:22
    }
}

Two gotchas from production: never run flush ruleset on a host with Docker, firewalld or libvirt — it deletes their tables too (scope flushes to your own table); and never run iptables and nftables side by side on the same hooks, since both program the same kernel subsystem.

Coexisting with Docker, firewalld and libvirt

Nothing in the previous sections told you to be careful about other tools that also program netfilter, and that omission is the classic production outage. Docker inserts its own chains in the ip nat and ip filter tables, firewalld owns a table and a daemon that rewrites it, and libvirt adds bridge-filter rules. A stock flush ruleset at the top of your file deletes every one of them. Container networking stops, published ports stop, and the symptom appears minutes later as "nothing can reach my app" — with no obvious link back to your firewall edit.

Scope the flush to your own table instead of the whole ruleset:

table inet filter {
    delete table inet filter   # replaces rules in your namespace only
}
table inet filter {
    chain input { type filter hook input priority 0; policy drop; }
}

Better still, pick a priority that runs before or after the tools you are cooperating with. Base chains have a numeric priority and lower values are evaluated first, so priority -200 on your input chain gets you ahead of Docker's chains and lets you apply a default-deny policy that container rules cannot silently bypass. Do not attempt to run iptables and nftables against the same hooks in parallel "for compatibility" — they program the same kernel path, ordering between them is unspecified, and you get a firewall whose behaviour depends on which tool ran last.

If firewalld is installed and you want to keep it, add your rules through firewall-cmd rather than writing your own table; mixed ownership of the ruleset is the one design that reliably produces unexplainable drops. On a Kubernetes node, the same caution applies to kube-proxy and to the CNI — see Kubernetes NetworkPolicy default-deny patterns for how policy there interacts with host rules.

Safe Rollout: Not Locking Yourself Out

A default-deny rule with a bad syntax can remove your SSH access before you can fix it. Build the habit of a three-step rollout: check, schedule, and arm a rollback.

# 1. syntax check - parses and resolves, changes nothing
sudo nft -c -f /etc/nftables.conf

# 2. load, then arm an automatic rollback in 5 minutes
sudo nft -f /etc/nftables.conf
sudo sh -c 'sleep 300 && nft -f /etc/nftables.known-good' &

# 3. still connected? cancel the rollback and make it permanent
kill %1
sudo cp /etc/nftables.conf /etc/nftables.known-good

The trick is not clever, but it converts a lockout from a data-centre trip into a five-minute wait. Keep a known-good copy on disk at all times, and keep a second management path — an out-of-band console, a serial port or a cloud provider's web console — so a firewall mistake is never unrecoverable. When you must add a rule to a remote host, add it as an accept for your own source address first, verify that it is present in nft list ruleset with a counter attached, and only then tighten the policy.

Also consider the ordering trap: rules are evaluated top to bottom, and a broad ct state established accept placed above your service rules will make those rules look ineffective in the counter output while everything still works. If the counters on a rule never move, the packet is matching something earlier.

IPv6 in the inet Family

Using the inet family means one table serves both protocols, which is the reason to prefer it over separate ip and ip6 tables: you cannot forget to write the v6 half. The compromise is that a bare tcp dport 22 accept matches both families, which is usually what you want, while address matches must be protocol-specific — ip saddr never matches a v6 packet and ip6 saddr never matches a v4 one.

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;

        # both families
        tcp dport { 22, 443 } accept

        # v6-specific: neighbour discovery must be allowed or the link dies
        icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept
        icmpv6 type { packet-too-big, destination-unreachable } accept

        # v4-specific
        icmp type { echo-request, destination-unreachable } accept
    }
}

Dropping ICMPv6 is the single most common IPv6 firewall bug: without neighbour solicitation and advertisement, a host cannot resolve its next hop and appears to have no connectivity at all, even though the interface has an address and a default route. The same applies to packet-too-big, which IPv6 uses for path-MTU discovery — block it and large transfers hang while pings succeed. If you have been treating "ping works, ssh hangs" as a mystery, check whether ICMP type 3 or ICMPv6 type 2 is being dropped.

Flowtables and Forwarding Performance

For a router or a NAT gateway, the fast path is the flowtable. Instead of every packet walking the chain, the first packet of a flow establishes an entry and subsequent packets are forwarded in the kernel's forwarding fast path, bypassing the ruleset entirely. On 10 Gbps or faster links this is the difference between a firewall that keeps up and one that becomes the bottleneck:

table inet filter {
    flowtable ft {
        hook ingress priority 0
        devices = { eth0, eth1 }
        flags offload
    }
    chain forward {
        type filter hook forward priority filter;
        ip protocol { tcp, udp } flow add @ft
    }
}

Two conditions apply. Only forwarded traffic benefits — locally generated and locally terminated traffic still traverses the chains, so this does nothing for a host that is only running services. And flags offload requires NIC support; the flowtable still works without it but the packets stay in software. If you enable offload on a NIC that does not implement it, you will see the flowtable entries created and no traffic offloaded, which looks like the rule is broken when it is merely ineffective.

Before adding a flowtable, measure. A firewall that is not the bottleneck needs tuning even less than it needs offload, and a flowtable that hides packets from your counters and logs can make the next troubleshooting session significantly harder. The tc netem link emulation techniques and ethtool NIC diagnostics referenced below are the right first stops when you are trying to work out whether the delay is filtering at all.

Migrating from iptables


iptables-translate -A INPUT -p tcp --dport 443 -j ACCEPT
sudo iptables-save | sudo iptables-restore-translate -f /etc/nftables.conf

Review the translation before loading; complex rules may need cleanup, and translated rules default to the ip family so consolidate into inet if you also had ip6tables rules. Pair the firewall with tc netem for link testing and ethtool when symptoms look like drops at the NIC rather than the filter.

Common Mistakes and Troubleshooting

Most nftables problems fall into a short list of recognisable failures. Working through them in order resolves the overwhelming majority of tickets:

Symptom Likely cause Check
Ruleset loads, nothing is filtered No base chain, or hooks on the wrong family nft list ruleset — look for type filter hook input
Rule present, counter stays at zero An earlier rule matched first Reorder, or add counter to the earlier rule and watch it climb
Rules revert after reboot Service not enabled, or file never edited systemctl is-enabled nftables
Docker containers unreachable flush ruleset deleted Docker's tables Restart docker to rebuild its chains, then scope your flush
Established sessions break on reload ct state established missing Add the conntrack rule above the service rules
IPv6 host unreachable but v4 fine ICMPv6 dropped Allow neighbour discovery and packet-too-big
NAT works outbound, returns fail Missing net.ipv4.ip_forward=1, or forward policy drop sysctl net.ipv4.ip_forward, and add an explicit forward rule

Set net.ipv4.ip_forward persistently rather than with sysctl -w, which is lost at reboot. Drop it into /etc/sysctl.d/99-forwarding.conf and run sysctl --system. For a routing-heavy design where the firewall decides the outbound interface, policy routing is the complementary tool — ip rule and policy routing tables covers the source-based selections that nftables alone cannot express.

Two more habits pay off. First, comment your rules: a comment "allow nagios polling" clause travels with the rule into nft list ruleset output and is worth more than any wiki page six months later. Second, keep a written baseline of what the host is supposed to serve. A firewall is only testable against a specification, and "whatever was open when it last worked" is not one.

原文链接:https://linuxjunkies.org/guides/configure-nftables-from-scratch