$RodHat_
Console Tips

Let pf keep the ban list so you don't have to

Published by

Let pf keep the ban list so you don't have to
Photo: AI-generated — no human photographer / RodHat AI Cover

Somewhere on your box a bot is rattling sshd right now, guessing admin / admin at four tries a second, and somewhere a younger version of you is about to open pf.conf in an editor to add one more block from line and reload the whole ruleset. Stop. Put the editor down.

You do not edit the firewall config to ban an address. You edit the firewall config once, to declare a place where banned addresses go, and then you never touch the file again. That place is a table.

A table is a list pf will hurt on your behalf

Declare it in pf.conf and reference it in a rule:

table <bruteforce> persist
block quick from <bruteforce>

persist is the load-bearing word. Without it, pf garbage-collects a table the moment no rule references it — and it means the table survives a pfctl -f /etc/pf.conf, so a ruleset reload does not blow away everything you added at runtime. That distinction has ruined people’s afternoons.

Now the table exists but is empty. You fill it without reloading anything:

pfctl -t bruteforce -T add 198.51.100.7
pfctl -t bruteforce -T show
pfctl -t bruteforce -T delete 198.51.100.7
pfctl -t bruteforce -T flush

add, delete, show, flush, replace, test, expire, zero. That is the whole runtime interface, and none of it requires re-reading the config. The addresses are live the instant they land in the table.

Let the ruleset ban its own attackers

Here is the part that made me, grudgingly, admit pf had earned its keep. You do not have to add the addresses yourself. The pass rule can do it for you:

table <bruteforce> persist
block quick from <bruteforce>

pass in on egress proto tcp to port ssh \
    keep state (max-src-conn 15, max-src-conn-rate 5/3, \
        overload <bruteforce> flush global)

max-src-conn-rate 5/3 says: more than five new connections from one source in three seconds is not a sysadmin, it is a script. overload <bruteforce> drops that source into the table. flush global kills its existing states on the way out, so the connection it already has goes down with the rest.

The block quick from <bruteforce> line at the top then does the actual work on every subsequent packet. The firewall bans its own attackers, in the data path, at line rate, while you are asleep. No fail2ban, no Python daemon tailing a log file, no parsing auth.log with a regex that breaks the day someone changes the log format.

Persist keeps the list; a file keeps it across reboot

persist survives a reload. It does not survive a reboot — the table lives in kernel memory, and memory forgets. If you want your ban list back after a power event, back it to a file and load from that file:

table <bruteforce> persist file "/var/db/pf.bruteforce"

pf reads that file when the ruleset loads. It does not write it. That is your job — dump the live table on a schedule so the on-disk copy is current when the machine next boots:

pfctl -t bruteforce -T show > /var/db/pf.bruteforce

Put that in cron at whatever interval you can stomach losing. The file is a snapshot, not a live mirror; anything added since the last dump is gone after a reboot. That is usually fine. Bots come back.

Expire the bans, or you’re running a grudge

A ban list that only grows is not security, it is a landfill. Eventually you block a whole cloud provider’s recycled address range and start dropping legitimate traffic from someone who inherited an IP a botnet used in 2024.

pf tracks a stats timestamp per entry, and expire evicts anything older than N seconds:

pfctl -t bruteforce -T expire 259200   # forget anything quiet for 3 days

Run that from cron too. The address gets banned on bad behavior, sits in the table doing nothing, and quietly ages out three days later. If it comes back swinging, the overload rule re-adds it in three seconds. The punishment fits the crime and nobody has to remember to unban anyone.

If you want to see which entries are hot before you tune the window, ask for counters:

pfctl -t bruteforce -T show -vv

Anchors: change part of the policy without reloading all of it

Tables handle addresses. Anchors handle rules. An anchor is a named sub-ruleset you can load and flush on its own, without touching the main pf.conf:

anchor "dynamic/*"

Then load rules into it at runtime, independently:

echo 'block quick from 203.0.113.0/24' | pfctl -a dynamic/maintenance -f -
pfctl -a dynamic/maintenance -s rules
pfctl -a dynamic/maintenance -F rules

This is how you push a temporary policy — a maintenance block, a per-tenant rule set, a vendor’s blacklistd integration — without a full-ruleset reload that risks a typo taking the whole firewall down mid-change. The main policy stays loaded and untouched; only the anchor moves. The same discipline that keeps a jail’s blast radius small keeps a firewall change from becoming a firewall outage.

The boring operational version

  1. Declare table <bruteforce> persist file "..." and block quick from <bruteforce> once.
  2. Let an overload rule populate it, instead of scripting pfctl -T add by hand.
  3. Dump the table to its file on a cron schedule so it survives reboot.
  4. expire old entries on a cron schedule so the list doesn’t ossify.
  5. Use anchors for temporary or per-scope rules so you never reload the whole policy to make a small change.
  6. When something looks wrong, point tcpdump at what pf is dropping before you start editing rules on a hunch.

The clever part was never the block rule. Any firewall can drop a packet. The clever part is a policy that maintains its own ban list, survives a reboot, forgives on a timer, and lets you change one corner of it without holding your breath — all declared in about four lines you wrote once and have not opened since.

That is the difference between administering a firewall and feeding one.