<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>linux on allddd</title><link>https://www.allddd.onl/tags/linux/</link><description>Recent content in linux on allddd</description><generator>Hugo</generator><language>en</language><lastBuildDate>Tue, 20 Oct 2026 13:59:11 +0000</lastBuildDate><atom:link href="https://www.allddd.onl/tags/linux/index.xml" rel="self" type="application/rss+xml"/><item><title>Do not iptables-translate</title><link>https://www.allddd.onl/blog/do-not-iptables-translate/</link><pubDate>Tue, 20 Oct 2026 13:59:11 +0000</pubDate><guid>https://www.allddd.onl/blog/do-not-iptables-translate/</guid><description>&lt;p&gt;Many users, including &lt;a href="https://github.com/tailscale/tailscale/issues/16286"&gt;&lt;em&gt;some&lt;/em&gt;&lt;/a&gt; large projects, migrate to nftables by running their existing iptables rules through &lt;code&gt;iptables-translate&lt;/code&gt; and leaving it at that. It works, sure, but it only changes the syntax, and the syntax was never the point. nftables can keep data in the ruleset, and rules can look things up in it and change it as needed, so a lot of what used to need a daemon/script next to the firewall can now be done by the firewall itself.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Many users, including <a href="https://github.com/tailscale/tailscale/issues/16286"><em>some</em></a> large projects, migrate to nftables by running their existing iptables rules through <code>iptables-translate</code> and leaving it at that. It works, sure, but it only changes the syntax, and the syntax was never the point. nftables can keep data in the ruleset, and rules can look things up in it and change it as needed, so a lot of what used to need a daemon/script next to the firewall can now be done by the firewall itself.</p>
<p>All examples were run on Linux 7.2 with nftables 1.1.7, so older versions may print stuff differently or not support every feature.</p>
<h2 id="sets">Sets</h2>
<p>Sets group multiple elements under a single identifier, so rules can match against them without listing every element separately. So far that’s just <code>ipset</code>, but in nftables each element can have its own state, like a rate limit, a quota or when it last matched.</p>
<p>Give a set <code>flags dynamic</code> and rules can add elements to it using <code>add @s { key }</code>, remove them using <code>delete @s { key }</code>, or use <code>update @s { key }</code>, which works like <code>add</code> but also resets the timeout of an element that’s already there. The key can be a single field, a concatenation of several fields like <code>ip saddr . tcp dport</code>, or a masked value like <code>ip saddr &amp; 255.255.255.0</code>, which turns a whole /24 into a single element (e.g., to ban the entire subnet at once).</p>
<h3 id="banning">Banning</h3>
<p>Sets can be used to build something like Fail2ban directly in the firewall, except it looks at traffic instead of log files.</p>
<pre tabindex="0"><code>$ nft -s list table ip f2b
table ip f2b {
	set jail {
		typeof ip saddr
		size 65535
		flags dynamic,timeout
		timeout 10m
	}

	set syn_rate {
		typeof ip saddr
		size 65535
		flags dynamic,timeout
		timeout 1m
	}

	chain input {
		type filter hook input priority filter; policy accept;
		ip saddr @jail counter drop
		tcp dport 80 ct state new update @syn_rate { ip saddr limit rate over 10/second burst 5 packets } add @jail { ip saddr } counter drop
	}
}
</code></pre><p>Each <code>ip saddr</code> gets its own rate limiter in <code>@syn_rate</code>. The moment one goes over, the same rule adds it to <code>@jail</code>, and the rule above drops its traffic until the timeout removes it from the set. <code>nft</code> shows how many elements the set holds and how long each one has left.</p>
<pre tabindex="0"><code>$ nft list set ip f2b jail
table ip f2b {
	set jail {
		typeof ip saddr
		size 65535	# count 1
		flags dynamic,timeout
		timeout 10m
		elements = { 10.0.0.2 expires 8m32s967ms }
	}
}
</code></pre><h3 id="quotas">Quotas</h3>
<p>Each element can also have a quota, so every client gets its own data cap (iptables needed one rule per address). Here, every client can download 1 GiB a day from the web server on port 80.</p>
<pre tabindex="0"><code>$ nft -s list table ip cap
table ip cap {
	set usage {
		typeof ip daddr
		size 65535
		flags dynamic,timeout
		timeout 1d
	}

	chain output {
		type filter hook output priority filter; policy accept;
		tcp sport 80 add @usage { ip daddr quota over 1024 mbytes } drop
	}
}
</code></pre><p>Since <code>add</code> never resets the timeout of an element that’s already there, each one expires a day after it was added, and the client starts over with a fresh quota. <code>nft</code> shows how much of its quota each client has used.</p>
<pre tabindex="0"><code>$ nft list set ip cap usage
table ip cap {
	set usage {
		typeof ip daddr
		size 65535	# count 1
		flags dynamic,timeout
		timeout 1d
		elements = { 10.0.0.2 quota over 1024 mbytes used 108266055 bytes expires 23h56m41s865ms }
	}
}
</code></pre><h3 id="tracking">Tracking</h3>
<p><code>last</code> records when something last matched. If added to a rule, it makes <code>nft</code> show how long ago the rule last matched (or <code>never</code> if it hasn’t yet).</p>
<pre tabindex="0"><code>$ nft list table inet track
table inet track {
	chain input {
		type filter hook input priority filter; policy accept;
		tcp dport 22 last used 5ms accept
		tcp dport 80 last used never accept
	}
}
</code></pre><p>If added to a set, it timestamps each element separately. Combined with <code>update</code> in an output chain, it gives a set that fills itself with every IPv4 TCP destination and port the host connects to, along with how long ago each one was last used and when it will expire. <code>ct direction original</code> leaves out replies to incoming connections, so the set doesn’t fill up with the ephemeral ports of every client.</p>
<pre tabindex="0"><code>$ nft list table ip track
table ip track {
	set seen {
		typeof ip daddr . tcp dport
		size 65535	# count 1
		flags dynamic,timeout
		last
		timeout 1h
		elements = { 10.0.1.1 . 80 last used 51ms expires 59m59s949ms }
	}

	chain output {
		type filter hook output priority filter; policy accept;
		ct direction original update @seen { ip daddr . tcp dport }
	}
}
</code></pre><h3 id="port-knocking">Port knocking</h3>
<p>Sets can also track progress, with one set per step. <code>add</code>/<code>delete</code> move a client to the next set, and the timeouts decide how long it has to get there.</p>
<pre tabindex="0"><code>$ nft -s list table ip knock
table ip knock {
	set stage1 {
		typeof ip saddr
		size 65535
		flags dynamic,timeout
		timeout 5s
	}

	set stage2 {
		typeof ip saddr
		size 65535
		flags dynamic,timeout
		timeout 5s
	}

	set opened {
		typeof ip saddr
		size 65535
		flags dynamic,timeout
		timeout 30s
	}

	chain input {
		type filter hook input priority filter; policy accept;
		ct state established,related accept
		tcp dport 7003 add @stage1 { ip saddr } drop
		tcp dport 7001 ip saddr @stage1 add @stage2 { ip saddr } delete @stage1 { ip saddr } drop
		tcp dport 7002 ip saddr @stage2 add @opened { ip saddr } delete @stage2 { ip saddr } drop
		tcp dport 22 ip saddr @opened accept
		tcp dport 22 drop
	}
}
</code></pre><p>Knocking on TCP ports 7003, 7001 and 7002 in that order, each within 5 seconds of the last, opens port 22 for 30 seconds, for that source address only. The first rule lets SSH sessions started in that time keep working after the port closes again.</p>
<h3 id="per-service-rules">Per-service rules</h3>
<p>Sets don’t have to be filled by rules. With <code>NFTSet=</code>, systemd puts the cgroup of a unit into a set when it starts and takes it out when it stops. <code>socket cgroupv2</code> then matches packets by the cgroup of their socket.</p>
<pre tabindex="0"><code>$ systemctl cat app
# /etc/systemd/system/app.service
[Unit]
Description=Demo web app

[Service]
ExecStart=/usr/bin/python3 -m http.server 9000 --directory /srv/app
NFTSet=cgroup:inet:svc:allowed
</code></pre><p>systemd only adds/removes elements, so the table has to exist before the unit starts.</p>
<pre tabindex="0"><code>$ nft list table inet svc
table inet svc {
	set allowed {
		type cgroupsv2
		elements = { &#34;system.slice/app.service&#34; }
	}

	chain input {
		type filter hook input priority filter; policy accept;
		ct state established,related accept
		tcp dport 9000 socket cgroupv2 level 2 @allowed accept
		tcp dport 9000 drop
	}
}
</code></pre><p>Only <code>app.service</code> gets traffic on port 9000, even if something else listens there while it’s stopped. After the ruleset is reloaded, <code>systemctl daemon-reload</code> fills the set again.</p>
<h2 id="maps">Maps</h2>
<p>Maps are like <a href="#sets">sets</a>, but each element also has a value, so instead of only checking whether a key is there, a rule looks up its value and uses it, e.g., as a verdict or as data for another statement.</p>
<h3 id="acls">ACLs</h3>
<p>A verdict map can replace a chain of N rules with one rule and one lookup. Large Kubernetes clusters ran into latency issues with kube-proxy’s iptables mode, where every new connection was checked against one rule per service, so its <a href="https://kubernetes.io/blog/2025/02/28/nftables-kube-proxy/">nftables mode</a> uses one instead.</p>
<p>With <code>flags interval</code>, each part of a concatenated key can also be a prefix/range (<code>ipset</code> only had fixed combinations like <code>hash:net,port</code>), so a gateway between the clients in 10.0.0.0/24 and the servers in 10.0.1.0/24 can keep its whole ACL in one map.</p>
<pre tabindex="0"><code>$ nft -s list table ip fw
table ip fw {
	map acl {
		typeof ip saddr . ip daddr . tcp dport : verdict
		flags interval
		elements = { 10.0.0.0/24 . 10.0.1.1 . 80 : accept,
			     10.0.0.3 . 10.0.1.0/24 . 22 : jump log_ssh,
			     10.0.0.2 . 10.0.1.2 . 8000-8999 : accept }
	}

	chain log_ssh {
		log prefix &#34;ssh: &#34; accept
	}

	chain forward {
		type filter hook forward priority filter; policy drop;
		ct state vmap { invalid : drop, established : accept, related : accept }
		ip saddr . ip daddr . tcp dport vmap @acl
	}
}
</code></pre><p>The <code>ct state vmap</code> rule replaces three separate rules for established, related and invalid packets, so only new connections get to the ACL. Every client can reach the web server on 10.0.1.1, 10.0.0.3 can SSH into every server (through <code>log_ssh</code>), and 10.0.0.2 can reach ports 8000 to 8999 on 10.0.1.2. Anything else (e.g., 10.0.0.2 to 10.0.1.2 port 22) has no element and ends up at the <code>drop</code> policy. <code>nft get element</code> shows which element covers a given connection.</p>
<pre tabindex="0"><code>$ nft get element ip fw acl &#39;{ 10.0.0.2 . 10.0.1.2 . 8443 }&#39;
table ip fw {
	map acl {
		type ipv4_addr . ipv4_addr . inet_service : verdict
		flags interval
		elements = { 10.0.0.2 . 10.0.1.2 . 8000-8999 : accept }
	}
}
</code></pre><h3 id="port-forwarding">Port forwarding</h3>
<p>Keys and values can both be concatenations, so a single <code>dnat</code> rule can forward any number of ports, each to its own address/port (iptables needed one rule per forwarded port).</p>
<pre tabindex="0"><code>$ nft -s list table ip nat
table ip nat {
	chain prerouting {
		type nat hook prerouting priority dstnat; policy accept;
		dnat ip to ip daddr . tcp dport map { 10.9.9.9 . 2201 : 10.0.1.1 . 22, 10.9.9.9 . 2202 : 10.0.1.2 . 22 }
	}
}
</code></pre><p>Port 2201 on the gateway (10.9.9.9) leads to SSH on 10.0.1.1, and port 2202 to SSH on 10.0.1.2.</p>
<h3 id="load-balancing">Load balancing</h3>
<p>Like sets, maps can be filled by rules, so a map can remember which backend each client got and keep sending it to the same one.</p>
<pre tabindex="0"><code>$ nft -s list table ip lb
table ip lb {
	map sticky {
		typeof ip saddr : ip daddr
		size 65535
		flags dynamic,timeout
		timeout 30m
	}

	chain pick {
		type nat hook prerouting priority dstnat; policy accept;
		ip daddr 10.9.9.9 tcp dport 80 dnat to ip saddr map @sticky
		ip daddr 10.9.9.9 tcp dport 80 dnat to numgen inc mod 2 map { 0 : 10.0.1.1, 1 : 10.0.1.2 }
	}

	chain learn {
		type filter hook prerouting priority filter; policy accept;
		ct direction original ct original ip daddr 10.9.9.9 update @sticky { ip saddr : ip daddr }
	}
}
</code></pre><p>The first rule in <code>pick</code> looks up the client’s address in <code>@sticky</code> and, if it’s there, sends the connection to the stored backend. If not, the rule doesn’t match, and the second rule takes turns between the backends. <code>numgen inc mod 2</code> counts 0, 1, 0, 1 and so on, and the map in the same rule turns that number into an address. Since <code>nat</code> rules only run on the first packet of a connection, this choice is made once per connection.</p>
<p><code>learn</code> runs after <code>pick</code> (priority <code>filter</code> comes after <code>dstnat</code>), so <code>ip daddr</code> is already the chosen backend there, and <code>update</code> saves it for the client. <code>ct direction original</code> makes sure only packets from the client count, since replies are part of the same connection and would otherwise store each backend as a key pointing to the client.</p>
<h2 id="netdev-hooks">Netdev hooks</h2>
<p>Chains in <code>netdev</code> tables can attach to network devices instead of the IP stack, so they run before the IP stack sees a packet (ingress) and again right before it’s sent (egress). iptables had no hooks there.</p>
<h3 id="early-drops">Early drops</h3>
<p>Ingress runs before conntrack and routing, so dropping a packet there is as cheap as it gets in nftables, which is handy for floods or blocklists.</p>
<pre tabindex="0"><code>$ nft -s list table netdev edge
table netdev edge {
	set blocklist {
		typeof ip saddr
		flags interval
		elements = { 10.66.0.0/16, 10.77.0.0/16 }
	}

	chain ingress {
		type filter hook ingress device &#34;wan0&#34; priority filter; policy accept;
		ip saddr @blocklist counter drop
	}
}
</code></pre><p>Everything from the listed networks is dropped as soon as it arrives on <code>wan0</code>. An <code>inet</code> table can have an ingress chain too, so the same set can also be used in its input or forward chains.</p>
<h3 id="packet-sockets">Packet sockets</h3>
<p>LLDP daemons and DHCP clients send their frames through packet sockets (<code>AF_PACKET</code>), which skip the IP stack, so the iptables OUTPUT chain and the nftables output hook never see them. An egress chain does, so here it stops LLDP from announcing the host on <code>wan0</code>.</p>
<pre tabindex="0"><code>$ nft list ruleset
table inet host {
	chain output {
		type filter hook output priority filter; policy accept;
		oifname &#34;wan0&#34; counter packets 0 bytes 0
	}
}
table netdev edge {
	chain egress {
		type filter hook egress device &#34;wan0&#34; priority filter; policy accept;
		ether type 0x88cc counter packets 5 bytes 300 drop
	}
}
</code></pre><p>Five LLDP frames were sent, and only the egress chain saw them.</p>
<h3 id="direct-forwarding">Direct forwarding</h3>
<p><code>fwd</code> sends a packet straight from ingress out of another device, skipping routing, conntrack and the prerouting/forward/postrouting hooks. In the example, all traffic between the clients and the servers is forwarded like that, and the kernel still looks up the MAC address of the next hop.</p>
<pre tabindex="0"><code>$ nft -s list table netdev wire
table netdev wire {
	chain from_clients {
		type filter hook ingress device &#34;lan0&#34; priority filter; policy accept;
		ip daddr 10.0.1.0/24 fwd ip to ip daddr device &#34;dmz0&#34;
	}

	chain from_servers {
		type filter hook ingress device &#34;dmz0&#34; priority filter; policy accept;
		ip daddr 10.0.0.0/24 fwd ip to ip daddr device &#34;lan0&#34;
	}
}
</code></pre><p>This works with IP forwarding turned off too. <code>dup to</code> does the same but sends a copy, e.g., to an IDS.</p>
<h2 id="bridges">Bridges</h2>
<p>A bridge passes traffic between its ports without going through the IP stack, so iptables didn’t see it at all, unless <code>br_netfilter</code> was loaded to push it through the IP hooks (for every bridge on the host). The <code>bridge</code> family sees that traffic directly and has its own conntrack, so <code>ct state</code> works there too.</p>
<pre tabindex="0"><code>$ nft -s list table bridge vmhost
table bridge vmhost {
	chain forward {
		type filter hook forward priority filter; policy accept;
		ct state established,related accept
		oifname { &#34;vm1&#34;, &#34;vm2&#34; } ct state new drop
	}
}
</code></pre><p>Guests on <code>vm1</code> and <code>vm2</code> can connect anywhere, but nothing can connect to them.</p>
<h2 id="flowtables">Flowtables</h2>
<p>Normally, every forwarded packet goes through prerouting, routing, forward and postrouting, even when its connection was already checked. A flowtable remembers established connections added with <code>flow add</code>, so their packets are caught at ingress and sent straight out the right device.</p>
<pre tabindex="0"><code>$ nft -s list table inet router
table inet router {
	flowtable ft {
		hook ingress priority filter
		devices = { &#34;lan0&#34;, &#34;wan0&#34; }
	}

	chain forward {
		type filter hook forward priority filter; policy accept;
		meta l4proto { tcp, udp } flow add @ft
	}
}
</code></pre><p>With <code>flags offload</code>, NICs and switch chips that support it can handle the flows in hardware.</p>
<h2 id="conntrack-timeouts">Conntrack timeouts</h2>
<p>A <code>ct timeout</code> object holds custom timeouts for one protocol, and <code>ct timeout set</code> applies them to specific connections without touching the global sysctls (iptables needed the <code>nfct</code> tool). In this example, conntrack forgets DNS queries after 5 seconds instead of 30, so a busy DNS server doesn’t fill up the table.</p>
<pre tabindex="0"><code>$ nft -s list table ip dns
table ip dns {
	ct timeout fast_udp {
		protocol udp
		l3proto ip
		policy = { unreplied : 5s, replied : 5s }
	}

	chain input {
		type filter hook input priority filter; policy accept;
		udp dport 53 ct timeout set &#34;fast_udp&#34;
	}
}
</code></pre><h2 id="multiple-tables">Multiple tables</h2>
<p>In iptables, every tool adds its rules to the same few built-in chains. In nftables, each tool usually creates its own table, so one hook can have several chains, and a packet goes through all of them, ordered by priority. An <code>accept</code> only ends the chain it’s in, and the packet continues to the next one, but a <code>drop</code> stops it completely.</p>
<pre tabindex="0"><code>$ nft -s list ruleset
table inet mine {
	chain input {
		type filter hook input priority filter; policy accept;
		tcp dport 80 accept
	}
}
table inet theirs {
	chain input {
		type filter hook input priority filter + 10; policy accept;
		tcp dport 80 drop
	}
}
</code></pre><p>Port 80 stays closed, because <code>theirs</code> drops what <code>mine</code> accepted. <code>nft list hooks</code> shows all chains on a hook in the order they run.</p>
<pre tabindex="0"><code>$ nft list hooks ip
family ip {
	hook input {
		 0000000000 chain inet mine input [nf_tables]
		+0000000010 chain inet theirs input [nf_tables]
	}
}
</code></pre><h2 id="optimizer">Optimizer</h2>
<p><code>nft -o</code> finds rules that can be merged and turns them into sets, concatenations and verdict maps, and <code>-c</code> makes it only show what it would change. It doesn’t work on the <code>add rule</code> commands that <code>iptables-restore-translate</code> writes, only on rulesets in the format <code>nft list ruleset</code> prints, so dump the loaded ruleset first.</p>
<pre tabindex="0"><code>$ nft -s list ruleset &gt; ruleset.nft
$ nft -cof ruleset.nft
Merging:
ruleset.nft:5:3-47: 		ip saddr 10.0.0.2 tcp dport 22 counter accept
ruleset.nft:6:3-47: 		ip saddr 10.0.0.3 tcp dport 22 counter accept
into:
	ip saddr . tcp dport { 10.0.0.2 . 22, 10.0.0.3 . 22 } counter accept
Merging:
ruleset.nft:7:3-27: 		tcp dport 25 counter drop
ruleset.nft:8:3-29: 		tcp dport 80 counter accept
ruleset.nft:9:3-30: 		tcp dport 443 counter accept
into:
	tcp dport vmap { 25 counter : drop, 80 counter : accept, 443 counter : accept }
</code></pre><p>Five translated rules turn into one set lookup and one verdict map, and the map keeps a counter for each element.</p>
]]></content:encoded></item></channel></rss>