The Unix Way ■ Episode 1
Fresh server. Fifteen minutes old. You have installed the operating system,
enabled SSH, and gone to make a cup of tea. By the time the kettle boils,
auth.log is filling up. Someone in a data centre you have never
heard of is methodically trying root, admin,
test, oracle, and every default credential known to
the collective unconscious of the internet. This is not a targeted attack. It
is Tuesday.
The question is not whether to stop it. The question is how.
The Linux Path
On Linux, the standard answer is fail2ban. A Python daemon that watches log files, matches lines against regular expressions, counts the matches, and, after a configurable threshold, calls out to the packet filter to block the offending address. The architecture is admirably straightforward: one daemon reads what another daemon has written, and if the pattern matches often enough, a third system intervenes. Three moving parts. Two log parsers. One race condition you would rather not think about.
The installation proceeds as follows. Install fail2ban. Create
jail.local. Define the regular expression for your SSH log format.
Configure the action, and here you must choose: nftables? iptables? ufw?
firewalld? The Linux firewall ecosystem offers four front ends to two back ends,
and fail2ban must be told which dialect you are speaking today. Restart the daemon.
Test. Hope.
Now the system is operational. An attacker connects. Attempts a login. Fails. The SSH daemon writes a line to the log. fail2ban reads the log. Parses the line. Matches the regex. Increments a counter. The attacker attempts again. And again. After the configured threshold (typically five, sometimes ten) fail2ban calls nftables and the address is blocked. The attacker has had fifty attempts at the door before anyone thought to lock it. If fail2ban crashes, the door stays open.
Two systems. Two failure modes. Two things that must be running, configured correctly, and speaking the same dialect for an SSH brute-force attack to be stopped. The architecture is reactive by design: something bad happens, the system notices, and then it responds. The attacker is already inside the conversation before the bouncer has been informed.
The FreeBSD Path
On FreeBSD, the answer is pf. Four lines.
table <bruteforce> persist
block quick from <bruteforce>
pass in on egress proto tcp to port ssh \
max-src-conn 20 max-src-conn-rate 10/1 \
overload <bruteforce> flush global
That is the entire configuration. No daemon. No log parsing. No regular expressions. No Python. The firewall itself, running in the kernel, tracks connection states, counts connection rates, and when a threshold is exceeded, adds the source address to a table and drops every packet from it. The word “flush global” terminates all existing connections from that address. Not just future ones. All of them. Instantly.
There is no window of fifty failed attempts. There is no daemon that must remain running. There is no regex that must match the log format of this particular SSH version. The firewall sees the connections directly, in real time, at the network layer. It does not need to be told what an attack looks like. It was told what normal looks like, and it enforces the boundary.
The Difference Is Architectural
The distinction between these two approaches is not one of capability. nftables is a competent packet filter. It can do stateful inspection. It can do rate limiting, with sufficient configuration. The distinction is one of design philosophy.
nftables filters packets. That is its job, and it does it well. But it does
not anticipate security problems. It does not ship with built-in rate limiting
per source address. It does not have overload tables. It does not offer
flush global. These features must be assembled from separate
tools, separate daemons, separate configuration files, and separate failure
modes.
pf was designed as a firewall that anticipates your problems. Rate limiting
is not an extension. Overload tables are not a third-party add-on. SYN proxy
is not a kernel module you compile separately. antispoof is a
single keyword. scrub normalises packets in one line. These
features exist because the people who wrote pf understood that a firewall
which only filters packets is solving half the problem.
Twenty-Five Years of the Same Syntax
pf was written by Daniel Hartmeier for OpenBSD in 2001. The syntax has not
changed. The pf.conf you write today is the same language as
the pf.conf from twenty-five years ago. FreeBSD imported it.
The feature set grew (synproxy, antispoof,
scrub, dynamic tables, overload actions) but the grammar
remained stable. Your configuration does not break on upgrade. Your muscle
memory transfers. Your documentation from 2005 still applies.
On Linux, in the same twenty-five years, the packet filtering story has
been: ipchains, then iptables, then
nftables, with ufw and firewalld
as competing front ends, and fail2ban configured differently for each.
The tools improve. The churn is the cost. Every migration is a rewrite.
Every rewrite is an opportunity for misconfiguration. Every misconfiguration
is a window.
The Kernel Advantage
pf runs in the kernel. This is not an implementation detail. It is the architecture. A userspace daemon can crash, can be killed by the OOM killer, can fail to start after a reboot, can have its configuration file syntax-checked only at restart. A kernel packet filter cannot crash independently of the system it protects. If the kernel is running, pf is running. There is no gap. There is no “fail2ban didn’t start because Python had a dependency conflict” scenario. There is no moment between boot and protection.
pf sees attacks in real time because it is the network stack. It does not wait for sshd to write a log line, for the filesystem to flush, for a daemon to read, for a regex to match. The packet arrives. The state table is consulted. The rate is checked. The decision is made. Microseconds, not seconds.
nftables is a capable packet filter. pf is a firewall that anticipates your problems.
Sometimes the elegant solution already exists. It is just not fashionable.