LearnThe Network StackHow Packets Find Their Way

Packet Filtering

nft - a hands-on Linux lab on a real virtual machine.

A firewall inspects Layer 3 and Layer 4 headers and makes a decision per packet. nftables is how Linux does it.

The machine that cannot say no

The server namespace in this lab accepts every packet that arrives. Ping it, it answers. Request a page, it serves one. Probe a port where nothing listens, and its TCP stack still politely replies that the port is closed.

Nothing is checking, and not because someone configured allow everything. No firewall exists on that machine at all. There is a difference between a door someone propped open and a wall that was never built, and in a few minutes you will be able to see that difference in a single command.

This lesson is a build-test-teardown run. You will build a firewall from nothing, watch it kill ping while HTTP keeps answering on the same wire, then remove every trace. One packet type dies, another lives, and you are the one who decides which.

The lab uses the two-namespace topology you know from this module:

  [client]  10.0.1.10  <-------->  10.0.1.20  [server]

The server runs an HTTP service on port 80. Your firewall goes on the server, because the server is the machine being protected.

The black boxes below are a practice terminal: a safe sandbox that checks the one command each step teaches. The real VM comes at the end. The lab user is student on the host filter-lab, and three helpers wrap the long namespace commands: server-nft runs nft inside the server namespace, send-ping and send-curl send traffic from the client. Your progress in the lab is tracked automatically, so type commands naturally.

Tab completion works on command names: type send- and press Tab twice to see both helpers, or serv and Tab to get server-nft. The nft keywords after it, like ruleset, you type yourself.

Three layers: table, chain, rule

A firewall is code that reads the headers of every packet entering or leaving a machine and delivers a verdict: let it through, silently discard it, or refuse it with a reply. Rules you write drive the verdict, and they match the header fields you already know from this track: source and destination address, protocol, port.

Where does that code run? Inside the Linux kernel, the packet path has fixed checkpoints called netfilter hooks. nftables is the rule engine that plugs into those checkpoints, and nft is the command that talks to it.

nftables organizes everything in three layers:

1. Table - a container that groups related chains. A table has a family (which protocols it sees) and a name. 2. Chain - an ordered list of rules, attached to a hook point such as input. 3. Rule - one match-and-verdict statement. If the packet is an ICMP echo request, drop it.

Think of a filing cabinet. The table is the cabinet, the chain is a folder inside it, and the rules are the pages in the folder, read top to bottom.

One paragraph of history, because it explains the tool you have. The netfilter team has replaced Linux firewalling three times: ipchains arrived with kernel 2.2 in 1999, iptables with kernel 2.4 in 2001, and nftables arrived in kernel 3.13, released January 2014, the same team retiring its own tool. Debian has shipped it as the default since 2019, so nft is what you are expected to speak on a modern box.

Two of those three were the work of one person. Rusty Russell wrote ipchains and then netfilter and iptables, having decided the previous design was worth throwing away rather than patching. Twice. nftables came from the same project years later, under Patrick McHardy and Pablo Neira Ayuso, on the same reasoning: a smaller kernel engine with the cleverness moved out into userspace. The command you are about to run is the third draft of an idea its own authors kept refusing to settle for.

Look before you touch

Every firewall job starts the same way, on a machine you built or a machine you inherited: list what is already there. The subcommand prints every table, chain, and rule in one screen.

server-nft list ruleset

prompt: student@filter-lab:~$ answer: server-nft list ruleset ||| sudo ip netns exec server nft list ruleset output: hint: The helper is server-nft, and the subcommand that prints everything is: server-nft list ruleset

Nothing. Not an empty table, not a default policy. Nothing at all.

That blank screen says more than it looks. nftables only inspects traffic where a chain is attached to a kernel hook, and this kernel has no chains, so no filtering code runs anywhere on the packet path. Zero rules does not mean allow everything as a decision. It means the question is never asked.

There is a performance edge hiding in that: a host with an empty ruleset pays no per-packet filtering cost at all. The moment you attach your first chain, every packet through that hook starts paying the toll, rules or no rules. Keep that in mind for the predict question coming up.

Build the cabinet

Rules need a chain to live in, and chains need a table. Cabinet first.

server-nft add table inet filter

The word after add table is the family: which protocols the table can see. inet means both IPv4 and IPv6 in one table. The narrower families are ip (IPv4 only) and ip6 (IPv6 only), and there are special-purpose ones like arp and bridge.

filter is only a name, and you could call the table anything. nftables has no built-in tables or chains at all: every one on a Linux box was created by someone, which is exactly why your first listing was empty. The name filter is a convention inherited from iptables, which did ship fixed built-in tables with that name.

prompt: student@filter-lab:~$ answer: server-nft add table inet filter ||| sudo ip netns exec server nft add table inet filter output: hint: The shape is: server-nft add table <family> <name>. Family inet, name filter.

No output. In the Unix tradition, silence is success: the kernel state changed and there was nothing else to report. Every add, flush, and delete in this lesson behaves the same way. Errors, when they come, are loud, and this lesson has a whole step for reading them.

The inet family is worth a sentence of respect. Under iptables this object took two tools: iptables wrote the IPv4 rules and ip6tables wrote a separate IPv6 copy, and on real machines the two copies drifted apart. inet exists so one ruleset can cover both stacks.

Read back what you built

Prove the table exists. There is a listing narrower than the whole ruleset:

server-nft list tables

prompt: student@filter-lab:~$ answer: server-nft list tables ||| sudo ip netns exec server nft list tables output: table inet filter hint: Same helper, and the subcommand is list tables, plural.

One line, two facts: the family and the name. Read the family every time you list tables, not just the name. Two tables can share a name in different families and be complete strangers to each other.

That detail turns into a famous error message later in this lesson. Hold on to it.

Attach the chain, and mind your quotes

Now the folder inside the cabinet: a chain named input, attached to the kernel's input hook. Read the anatomy before you run it:

server-nft add chain inet filter input '{ type filter hook input priority 0 ; }'

The brace block is what makes this a base chain, one wired to a hook so packets flow through it on their own. A chain created without the braces is a regular chain: a container other rules can jump to, which no packet ever visits by itself.

The quotes around the brace block are not decoration. To bash, an unquoted ; ends one command and begins another, which leaves a lone } trying to start a command of its own. Bash reads the whole line before it runs any of it, so it refuses the line entire and nft is never called. You will meet that exact refusal before the lesson ends.

prompt: student@filter-lab:~$ answer: server-nft add chain inet filter input '{ type filter hook input priority 0 ; }' ||| server-nft add chain inet filter input "{ type filter hook input priority 0 ; }" ||| server-nft add chain inet filter input '{ type filter hook input priority 0; }' ||| server-nft add chain inet filter input "{ type filter hook input priority 0; }" output: hint: server-nft add chain inet filter input, then the brace block in single quotes: '{ type filter hook input priority 0 ; }'

Silent again, and this is the moment the machine actually changed. Attaching a base chain is what registers filtering code at the hook. Before you pressed Enter, packets entered the server unexamined. From now on, every packet addressed to it walks your chain.

What the kernel heard

List the whole ruleset again and compare it with what you typed.

server-nft list ruleset

prompt: student@filter-lab:~$ answer: server-nft list ruleset ||| sudo ip netns exec server nft list ruleset output: table inet filter { chain input { type filter hook input priority filter; policy accept; } } hint: The same listing command as the first step: server-nft list ruleset

Look closely at the middle line. You typed priority 0 and the listing says priority filter. nft did not save your text anywhere. It compiled your command, sent it to the kernel over netlink, and this listing is decompiled back from what the kernel actually holds. On the way out, well-known priority numbers get printed as names: 0 is filter, and other named marks on the same line include raw at -300, mangle at -150, and security at 50.

Priorities are signed, and negative runs earlier. The kernel's own machinery sits on this number line too: connection tracking runs at -200, well before your chain, so by the time a packet reaches priority 0 it already carries connection state.

The other new words are policy accept. The policy is the chain's verdict for packets that match none of its rules. It belongs to the chain, not to the system. policy drop would turn this into a default-deny firewall, the standard posture for serious perimeters, and also the fastest way to lock yourself out of a remote machine. This lesson keeps accept.

Commit: what did an empty chain change?

The chain exists, holds zero rules, and its policy is accept. Take a position before moving on.

One rule: drop the pings

A rule is a match plus a verdict. Remember from the ICMP lesson: an Echo Request is ICMP type 8, and the reply is type 0. The rule you are about to write names the request by its field name instead of its number:

server-nft add rule inet filter input icmp type echo-request drop

prompt: student@filter-lab:~$ answer: server-nft add rule inet filter input icmp type echo-request drop ||| sudo ip netns exec server nft add rule inet filter input icmp type echo-request drop output: hint: The shape is: server-nft add rule <family> <table> <chain> <match> <verdict>. The match is icmp type echo-request, the verdict is drop.

Silence, so it landed. add rule appends to the end of the chain. Its sibling insert rule prepends instead, and position matters because evaluation runs top to bottom and the first matching verdict wins.

One nuance separates the two verdicts you will use most, and it is worth carrying for life. A drop is final for the packet, everywhere. An accept is only final for this chain: if another base chain sits on the same hook at a later priority, an accepted packet still has to survive that one too. Accept means I have no objection. It does not mean the packet is through.

The rule in its chain

Read the ruleset one more time and find your rule in it.

server-nft list ruleset

prompt: student@filter-lab:~$ answer: server-nft list ruleset ||| sudo ip netns exec server nft list ruleset output: table inet filter { chain input { type filter hook input priority filter; policy accept; icmp type echo-request drop } } hint: The listing command you have used twice already: server-nft list ruleset

There it sits, one page inside the folder inside the cabinet. Every ICMP echo request that reaches the input hook will now match it and die.

Notice what the listing does not show: whether the rule has ever matched anything. nftables counts nothing by default. Add the counter keyword to a rule and its listing grows a live packets and bytes readout, which is how engineers answer the question is this rule even firing. A listed rule proves presence, never traffic.

Commit: what will the client see?

From the client you are about to send three pings to 10.0.1.20, and the server will drop each request at its input hook. Commit to the client's experience before you look.

Test one: ping the wall

Run the test you just predicted.

send-ping

prompt: student@filter-lab:~$ answer: send-ping ||| send-ping 3 ||| sudo ip netns exec client ping -c 3 -W 2 10.0.1.20 output: PING 10.0.1.20 (10.0.1.20) 56(84) bytes of data.

--- 10.0.1.20 ping statistics --- 3 packets transmitted, 0 received, 100% packet loss, time 4005ms hint: The helper that pings the server from the client is send-ping.

Read the summary line like an engineer. 3 packets transmitted, 0 received, 100% packet loss, and the elapsed time is longer than the sending took: three requests a second apart is two seconds, then ping sits out the -W 2 timeout waiting on a reply that never comes. The successful run earlier finished the moment the last reply landed. Silence costs you the full timeout, every time.

Now the part most people get wrong. The server received all three packets. They crossed the wire perfectly, entered the server's stack, and were executed at your chain, after delivery. A dead link and a drop rule print identical ping statistics, and the way to tell them apart is the capture skill from the watching-traffic lesson: capture at the receiver, and a drop shows requests arriving with no replies leaving, while a dead link shows nothing arriving at all.

Test two: HTTP through the same wall

Same wire, same server, different protocol.

send-curl

prompt: student@filter-lab:~$ answer: send-curl ||| sudo ip netns exec client curl -s --max-time 3 http://10.0.1.20/ output: Hello from the server namespace hint: The helper that sends an HTTP request from the client is send-curl.

HTTP lives. The request is TCP to port 80, matches nothing in your chain, and falls through to policy accept. One protocol dead, one alive, on the same interface, because one rule names one match. That precision is the entire craft of firewalling: the rules say exactly what they say, nothing more.

One honest caveat about that precision. In an inet table, icmp matches IPv4's ICMP, IP protocol number 1. IPv6 ping is a different protocol, ICMPv6, number 58, matched with icmpv6 type echo-request. A dual-stack host with this exact ruleset still answers every IPv6 ping. The inet family lets one table see both stacks; it does not make one rule mean both.

Milestone: you have a working firewall

Place what you built on the map this module has been drawing.

A packet from the client arrives at the server's interface and passes the prerouting hook. Then comes the routing decision you studied in the routing lesson: this packet is addressed to me. That hands it to the input hook, where your chain waits. Match means drop. No match means the policy accepts it, and only then does the payload reach a socket.

Now recall the forwarding lesson, where a namespace routed packets between two others. Traffic passing through a router takes a different path: prerouting, then the forward hook, then postrouting. It never visits input. Rules in an input chain are invisible to transit traffic, which is why an engineer who firewalls a router with input rules alone protects the router itself and leaves the network behind it wide open.

drop and reject are both refusals with different manners. Drop discards silently, and the sender burns its full timeout learning nothing. Reject answers immediately with an ICMP error, by default destination unreachable, and reject with tcp reset fakes the reset a closed port would send. The working rule of thumb: drop toward the hostile internet, where silence wastes a scanner's time and confirms nothing exists. Reject inside networks you own, so a misconfigured client fails in milliseconds instead of hanging while someone stares at a spinner.

Commit: the trap in the family

Your table was created as family inet, name filter. Suppose you now ask for it without naming the family: server-nft list table filter. Take a position.

Practice Packet Filtering in a real Linux terminal at The Linux Camp. Progress is verified automatically as you type commands on the machine.

More lessons in How Packets Find Their Way