Skip to content
Go back

Firewall Rule Audit: Find the Dead Rules

By KingPin 14 min read
Firewall Rule Audit: Find the Dead Rules
Contents

The Box Nobody Wants to Touch

Your homelab server has been up for two years. nft list ruleset prints 60-odd rules. Half have no comment. One of them opens port 25565, and you are fairly sure you added it “temporarily” in March 2024 for a weekend of Minecraft. Another allows a subnet that no longer exists. Nobody knows what breaks if you delete any of it, so nobody deletes anything.

That is a ruleset you will re-audit forever. The way out is a measurement. A rule with a zero hit count after 30 days of real traffic is a candidate for deletion, and the firewall will tell you which rules those are if you ask it with counters. Two pieces do the work: nftables counters (the measurement) and a snapshot-and-diff routine (the process that turns counts into decisions).

Full example: Clone the working files at github.com/KingPin/sumguy-examples/security/firewall-rule-auditing

I am skipping how nftables chains work, how to migrate from iptables, and why rule order matters. Those have their own posts. This one is about an existing ruleset and the question “is anything still using this?”

Reading What You Have

Start with a look at the whole ruleset, with handles. A handle is the stable ID of a rule, and it is what you use to delete one later.

Terminal window
sudo nft -a list ruleset
table inet filter { # handle 1
chain input { # handle 1
type filter hook input priority filter; policy drop;
ct state established,related accept # handle 4
iifname "lo" accept # handle 5
tcp dport 22 accept # handle 6
tcp dport 25565 accept # handle 9
}
}

On a legacy iptables box, the counters are already there. -v adds packet and byte columns, -n skips DNS lookups, and --line-numbers gives you positions:

Terminal window
sudo iptables -nvL INPUT --line-numbers

UFW users get a numbered list, but no counters:

Terminal window
sudo ufw status numbered

UFW is a frontend. It writes iptables rules, so the counters live underneath. UFW keeps its rules in chains named ufw-user-input, ufw-user-output and ufw-user-forward (you can see them declared in /etc/ufw/user.rules). To read their hit counts:

Terminal window
sudo iptables -nvL ufw-user-input

On a modern distro, iptables is often the iptables-nft variant. Check with iptables -V: it prints (nf_tables) for the nft-backed one and (legacy) for the old one. With the nft-backed variant, the same UFW chains show up in nft list ruleset under table ip filter, and nft prints a warning that the table is managed by iptables-nft. Look, do not edit that table with nft.

Adding Counters (nftables Does Not Count by Default)

In nftables, a rule only counts if you put a counter statement in it. No statement, no numbers. Add one to a rule:

Terminal window
sudo nft add rule inet filter input tcp dport 8080 counter accept

The counter has to come before the verdict. The man page allows one terminal statement per rule, and it has to be the last one, so nft refuses accept counter outright with Error: Statement after terminal statement has no effect.

That is an anonymous counter. It lives inside the rule. Edit or replace the rule and the count starts over. A named counter is its own object in the table, so you can point several rules at it and it survives rule edits:

Terminal window
sudo nft add counter inet filter web_hits
sudo nft add rule inet filter input tcp dport 80 counter name "web_hits" accept
sudo nft add rule inet filter input tcp dport 443 counter name "web_hits" accept
sudo nft list counters
table inet filter {
counter web_hits {
packets 1204 bytes 96320
}
}

Use anonymous counters for the audit. You want one number per rule. Use named counters when you want one number for a group of rules.

To reset counts to zero, there are three forms from the man page:

Terminal window
# all named counters in one table (anonymous counters are not touched)
sudo nft reset counters table inet filter
# the anonymous counters inside every rule in a table
sudo nft reset rules table inet filter
# one rule's counter, by handle
sudo nft reset rule inet filter input handle 9

Reset on purpose, at the start of an audit window, and write down the date. A reset prints the values it cleared, so you lose nothing by running it. Since the audit uses anonymous counters, reset rules is the form you want. reset rule and reset rules need nftables 1.0.7 and kernel 6.2 or newer. Debian 12 has neither; Debian 13 and Ubuntu 24.04 do. On an older box, snapshot without resetting and let the diff do the subtraction.

Comments: Pay Now or Pay Forever

Every rule you add should carry a comment with who, why and when. It costs ten seconds today and saves an afternoon later.

Terminal window
sudo nft add rule inet filter input tcp dport 25565 counter accept comment \"ticket-1234 temporary minecraft 2024-03\"

The same idea in the other tools:

Terminal window
sudo iptables -A INPUT -p tcp --dport 25565 -m comment --comment "ticket-1234 temporary minecraft 2024-03" -j ACCEPT
sudo ufw allow 25565/tcp comment 'ticket-1234 temporary minecraft 2024-03'

Note the escaped quotes in the nft example. The man page says you have to escape them when you type the command in a shell. Inside a config file, plain quotes are fine.

A rule with a date in its comment tells your 2 AM self when to stop being afraid of it.

Shadowed Rules: Traffic Exists, the Counter Does Not

A shadowed rule never matches because an earlier rule already handled the same traffic. It sits at zero forever, even though the traffic it was written for shows up every day. Here is a small example, on a chain further down the same box:

chain input {
type filter hook input priority filter; policy drop;
ip saddr 192.168.1.0/24 counter accept comment "lan, everything" # handle 11
ip saddr 192.168.1.0/24 tcp dport 22 counter accept comment "lan ssh" # handle 12
tcp dport 22 counter accept comment "ssh anywhere" # handle 13
}

Rule 11 accepts everything from the LAN. A LAN client connecting to SSH hits rule 11 and stops. Rule 12 can never fire. After a week, the counters say so:

ip saddr 192.168.1.0/24 counter packets 48211 bytes 9301442 accept comment "lan, everything" # handle 11
ip saddr 192.168.1.0/24 tcp dport 22 counter packets 0 bytes 0 accept comment "lan ssh" # handle 12
tcp dport 22 counter packets 311 bytes 22903 accept comment "ssh anywhere" # handle 13

Rule 12 is dead weight and safe to remove: it is fully covered by rule 11. Rule 13 still counts because it catches SSH from outside the LAN. The counters found a shadow that reading the ruleset by eye might have missed on a 60-rule box.

Delete a rule by handle, not by retyping it:

Terminal window
sudo nft delete rule inet filter input handle 12

Logging the Last-Chance Traffic

Counters tell you what your rules do. They do not tell you what the default policy is catching. For that, put a rate-limited log rule right before the end of the chain:

Terminal window
sudo nft add rule inet filter input limit rate 5/minute log prefix \"nft-drop: \"

Packets that reach this rule and are under the limit get logged. Anything over five a minute is not logged, but still falls through to the policy drop. The log line goes to the kernel log:

Terminal window
sudo journalctl -k | grep nft-drop
# or
sudo dmesg | grep nft-drop

Without the limit, one port scan floods the journal. With it, you get a sample, and a sample is all you need to see “oh, that is my printer trying to reach the NAS.”

Add the log rule last in the chain. nft add appends to the end, so run it after the other rules exist.

The Snapshot-and-Diff Routine

Counters give you numbers right now. An audit needs numbers over time. The routine: save the ruleset as JSON on a schedule, then compare two snapshots.

The JSON from nft -j list ruleset is one object with a nftables array. Each rule is an element shaped like {"rule": {"family":..., "table":..., "chain":..., "handle":..., "comment":..., "expr": [...]}}. The counter is one of the entries in expr, as {"counter": {"packets": N, "bytes": N}}. If you used a named counter, that entry is {"counter": "web_hits"} instead, and the numbers live in a separate top-level counter object. The script below skips those. It reads anonymous counters only.

snapshot-fw.sh
#!/usr/bin/env bash
set -euo pipefail
dir=/var/lib/fw-snapshots
mkdir -p "$dir"
nft -j list ruleset > "$dir/$(date +%F).json"

Run it from a systemd timer:

snapshot-fw.service
[Unit]
Description=Snapshot nftables ruleset and counters
[Service]
Type=oneshot
ExecStart=/usr/local/bin/snapshot-fw.sh
snapshot-fw.timer
[Unit]
Description=Daily nftables snapshot
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
Terminal window
sudo systemctl enable --now snapshot-fw.timer

Now the diff. This script takes two snapshots and prints a table of family/table/handle, comment, packets then, packets now and delta. Rules with a delta of zero get a flag:

fw-diff.sh
#!/usr/bin/env bash
set -euo pipefail
old=$1 new=$2
jq -r -n --slurpfile a "$old" --slurpfile b "$new" '
def counts: [ .nftables[] | select(has("rule")) | .rule
| { key: "\(.family)/\(.table)/\(.handle)",
value: { comment: (.comment // "-"),
packets: ([.expr[] | objects | select(has("counter"))
| .counter | objects | .packets] | first) } }
| select(.value.packets != null) ] | from_entries;
($a[0] | counts) as $then | ($b[0] | counts) as $now
| $now | to_entries[]
| [ .key, .value.comment, ($then[.key].packets // 0), .value.packets,
(.value.packets - ($then[.key].packets // 0)) ] | @tsv' \
| awk -F'\t' -v OFS='\t' '$5 == 0 { $6 = "CANDIDATE" } 1' \
| column -t -s $'\t'

Run it against a snapshot from 30 days ago:

Terminal window
sudo ./fw-diff.sh /var/lib/fw-snapshots/2026-09-18.json /var/lib/fw-snapshots/2026-10-18.json
inet/filter/4 ssh lan 10 10 0 CANDIDATE
inet/filter/6 ssh anywhere 311 4120 3809
inet/filter/9 ticket-1234 temporary minecraft 2024-03 0 0 0 CANDIDATE

Two gotchas. Handles are unique per table, not across the whole ruleset, and two tables can share a name across families (ip filter from iptables-nft next to your inet filter), so the key is family/table/handle. And if the counters were reset or the ruleset was reloaded between two snapshots, the “then” numbers are bigger than the “now” numbers and the delta goes negative. A reload (systemctl restart nftables) rebuilds every rule and zeroes the counters. Pick snapshots from the same uninterrupted stretch.

Retiring a Rule Without a 2 AM Phone Call

Zero hits is a strong hint, not proof. A monthly backup job might connect once on the first of the month. Retire in four steps:

  1. Comment the rule candidate-remove-2026-10 so the intent is visible in every future listing. Use nft replace rule inet filter input handle 9 ... with the full rule text and the new comment.

  2. Swap the verdict. Replace accept with a counter and a log line, so the rule stops allowing traffic and starts reporting it:

    Terminal window
    sudo nft replace rule inet filter input handle 9 tcp dport 25565 counter log prefix \"would-drop: \" comment \"candidate-remove-2026-10\"

    The packet now falls through to the next rule, and to the policy drop if nothing else accepts it. Leave this in place for a week. If journalctl -k | grep would-drop stays empty, nothing used it. If something shows up, you just found the thing that would have broken, and the cost was one log line instead of an outage. Put a limit rate in the log statement if the port is noisy.

  3. Delete by handle: sudo nft delete rule inet filter input handle 9.

  4. Persist the change. A running ruleset is lost at reboot. On Debian and Ubuntu, nftables.service loads /etc/nftables.conf at boot (its unit runs nft -f /etc/nftables.conf), so that file must match what you just did. Edit it by hand, or dump just your own table into it:

    Terminal window
    sudo nft list table inet filter | sudo tee /tmp/filter-table.txt

    Then copy the table block into /etc/nftables.conf. Do not blindly redirect nft list ruleset over that file if Docker or UFW is on the box, because you would freeze their tables into your config.

Docker Owns Some of Your Rules

By default Docker still manages iptables rules itself, in chains such as DOCKER and DOCKER-USER. The Docker docs describe an experimental nftables backend, selected with the firewall-backend daemon option. Either way, leave Docker’s chains and tables alone. Docker rebuilds them when the daemon starts. With the default iptables backend, put your own rules in DOCKER-USER. The nftables backend (experimental since Docker 29.0) has no DOCKER-USER chain; you add your own table with a forward hook chain instead.

A nft flush ruleset removes Docker’s rules along with yours, and they stay gone until the daemon restarts. Restart Docker after a flush, and prefer deleting single rules by handle so you never need the flush.

What About the Perimeter Box?

The same idea works on a router firewall. OPNsense and pfSense show per-rule hit counters in their rules UI. Same method: check counts over a month, disable (do not delete) the zero-hit rules, wait, then delete. This article is about Linux hosts, so I will leave it at that.

The SumGuy Take

A firewall without comments is a junk drawer. A firewall with counters is a junk drawer with a label maker. Add counter and comment to every rule you write from now on. Let a timer take daily snapshots, because you cannot go back and collect history you never saved.

Do not delete on a zero count alone. Disable first, log for a week, then delete. It is the forklift rule: you can move a pallet quickly, but you look behind you first. Run the audit once a quarter and the 2 AM page about “who opened that port?” never happens.

Common Questions

Do nftables rules count packets by default?

No. An nftables rule counts packets only if it contains a counter statement. Without it, the rule matches silently and nft list ruleset shows no numbers. Add counter before the verdict, as in tcp dport 22 counter accept, since nft rejects a counter placed after accept.

Does UFW have per-rule packet counters?

Not in ufw status. UFW writes iptables rules into chains such as ufw-user-input, so read counts with sudo iptables -nvL ufw-user-input. On a system where iptables is the nft-backed variant, the same chains appear in nft list ruleset under table ip filter.

Will nft flush ruleset break Docker?

Yes. A nft flush ruleset deletes every table, including the rules Docker created for container networking. Published ports and container internet access stop working until you restart the Docker daemon, which recreates them. Delete single rules by handle instead of flushing.

How long should I watch a rule before deleting it?

Watch for at least 30 days, and longer for rules tied to monthly or quarterly jobs such as backups. After the window, replace accept with a log rule for a further week. Delete the rule only if that log stays empty.

Do my nft changes survive a reboot?

No. Changes made with nft add or nft delete live in the kernel only. On Debian and Ubuntu, nftables.service loads /etc/nftables.conf at boot, so write the final ruleset into that file. Check the file with nft -c -f /etc/nftables.conf before you reboot.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
Blender MCP vs Headless bpy Scripts
Next Post
k3s Node Upgrades Without Downtime

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts