Vivian Voss

The Firewall I Never Had to Relearn

freebsd pf firewall sysadmin

FreeBSD ships an example firewall in /usr/share/examples/pf/faq-example1. Fifty lines, of which twenty-three carry rules and the rest are comments. It filters, it does address translation, it handles a small internal network, and somebody reading it aloud at a table would be understood by everybody there.

I have been writing that language since the mid-2000s, on my own machines, and the file I keep today is recognisably the one I kept then. That is an unusual thing to be able to say about anything in this trade, and it is worth looking at why it is true, because the reason is not the one people assume.

The language you can read aloud

A rule in pf.conf reads in the order you would say it:

pass in on egress proto tcp to port { 22, 80, 443 }

Action, direction, interface, protocol, destination. No chain to declare first, no table to select, no separate step to make the rule persist. The curly braces are a list, and pfctl expands them into three rules when it loads the file, which means the file stays the size of the thought rather than the size of the result.

Underneath that sits a description worth its weight: pf.conf(5) on this machine runs to 2,568 lines and documents the whole grammar. The tool that reads it, pfctl, is 422 kilobytes. The kernel module is 802.

The part I have come to value most is the -n flag. pfctl -nf /etc/pf.conf parses the file, reports what it thinks of it, and changes nothing. Firewalls get edited on live machines at awkward hours, and being able to ask first has saved me from locking myself out more than once.

What arrived in twenty years and spoke the same language

Here is the part that surprised me over time, because none of it was planned when I started.

IPv6 arrived properly. The rules did not change shape; an address family joined the grammar and the sentences carried on. Jails multiplied on my machines, each with its own address on a cloned interface, and they filter with the same words as anything else. VLANs, bridges, lagg, and eventually WireGuard: every one of them became an interface name in a rule I already knew how to write.

What arrived, and what it asked of the grammar ARRIVED WHAT IT IS, TO A RULE NEW SYNTAX? IPv6 an address family no Jails an address on a cloned interface no VLANs, bridges interface names no lagg an interface name no WireGuard, 2015 an interface name no A filter written in 2001 files all of it under the one thing it already understood: an interface has a name, and a rule may mention it. The grammar never assumed anything about what might one day be on the other end.

That last one deserves a moment. WireGuard is from 2015, pf is from 2001, and filtering a wg interface needs no new concept from either. The rule looks like every other rule, because an interface is an interface and the grammar never assumed anything about what might one day be on the other end.

Meanwhile the parts of the file that do the heavy lifting were already there. Tables hold addresses by the thousand and are updated without reloading anything:

table <bruteforce> persist
block in quick from <bruteforce>

And the rule that has saved me the most trouble over the years is the one that watches its own state:

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

Three new connections in thirty seconds from one source, and that source lands in the table and stays there. No log parser, no daemon watching a file, no regular expression that stops matching when somebody changes a log format. The filter already knows, because counting connections is what any stateful packet filter does anyway.

What it means that it is in the system

On FreeBSD the filter comes with the operating system. It is released with it, and the manual page describing it is cut from the same tree as the kernel that implements it. One variable switches it on, and the filter that starts is the one the manual on that machine describes.

The consequence is quiet and easy to overlook. When a release is prepared, the kernel, the filter, the control tool and the documentation are tested together and shipped together, by people who answer to the same release engineering. There is no interval during which the filter is newer than its manual page, and nobody sits between the authors and the operators translating one into the other.

Jails come out of that same tree, which is why giving a jail its own address and then filtering it involves no bridge between two projects and no vocabulary that belongs to neither. The interface exists, the rule names it, and both halves were released on the same day by the same people.

One capability did not arrive late at all, and I only came to appreciate how unusual that is after running it. pfsync entered FreeBSD in 5.3, the same release that brought pf itself, and CARP followed in 5.4. Together they let two machines share an address and a state table, so that one can be taken out of service while established connections carry on through the other. The state table is the difficult half of that: a failover without it drops every session in flight, and with it the far end notices nothing at all. Those two capabilities weigh 101 and 96 kilobytes as kernel modules, and configuring them is a handful of lines in the file you already have open.

The change that did happen

The story usually told about this language is that it never changed. That is not quite right, and the truth is more interesting than the legend.

One grammar, twenty-five years 2001 pf written Hartmeier, OpenBSD 2004 FreeBSD 5.3 with pfsync 19 MAY 2010 OpenBSD 4.7 rewrites translation nat-to, rdr-to, and last-match instead of first FreeBSD takes the new forms and keeps the old ones 2015 WireGuard filtered unchanged 2026 a 2005 rule still loads The one break in the line is on the left-hand project. The right-hand one absorbed it and kept both forms.

On 19 May 2010, OpenBSD 4.7 rewrote the translation syntax. nat on ... -> ... became match out ... nat-to ..., rdr became rdr-to, binat became binat-to, and the nat-anchor and rdr-anchor lines went away entirely. Worse for anybody converting by hand, the matching semantics moved with it: translation rules had stopped at the first match, and the new form follows the ordinary last-match behaviour of filter rules. A configuration translated mechanically could load cleanly and behave differently.

OpenBSD did that deliberately and for good reasons. It is a project that removes things, and the result is a smaller, more consistent language.

FreeBSD took the other road. It gained the new forms and kept the old ones, so nat on $ext_if from 10.0.0.0/8 -> ($ext_if) still loads today. That is why my rules from 2005 still work, and it is worth saying plainly, because both halves were needed: the language was well enough designed to be worth keeping, and the project decided to keep it.

Where it came from

Daniel Hartmeier wrote the first version in 2001, after OpenBSD removed IPFilter from its tree over a licence dispute with its author Darren Reed, and he wrote it in about twenty-five days having never written kernel code before. Henning Brauer took the grammar further in the years after, and the 4.7 rewrite is largely his work. FreeBSD imported pf in version 5.3 in 2004, and it has been in the base system since.

It is in NetBSD and DragonFly. It is in macOS, which means a rather large number of devices in pockets are filtering with a descendant of that twenty-five-day effort. And pfSense and OPNsense built appliances on top of it that are, on the inside, this same file being written by a web interface.

The limit

Four things I would want a reader to weigh before taking any of this as a recommendation.

The constancy I have described belongs to FreeBSD's judgement as much as to pf's design. Anybody running OpenBSD lived through a genuine rewrite in 2010, and telling them their syntax never changed would be a good way to end a conversation.

pf on FreeBSD carried a scaling problem for years, because a single lock around the state table meant that throughput stopped improving past a certain number of cores. Work on that has been going on for several releases and the situation in 15 is much better than it was, but anybody who benchmarked it in 2018 and walked away had a real reason at the time.

The readability argument has a ceiling. Twenty-three lines fit on a page; nine hundred generated rules do not, and at that point the fact that each line is legible stops helping. Large estates generate their rules, and they are right to.

And nftables deserves its due. It replaced a genuinely awkward predecessor and brought proper set and map machinery with it, and its nft -c does the same job as pfctl -n. The Linux packet filtering story has had more churn, which is a real cost, but the current answer is a serious piece of engineering and dismissing it would be provincial.

The bill that never came

The usual shape of this section is a cost. I have spent a fortnight of evenings over twenty years reading about firewalls, and almost none of it was spent relearning one.

That is the whole of it. No migration weekend. No intermediate representation to learn, no generator whose output I would then have to read anyway, no front end that hides the layer beneath it while remaining unable to replace it. When I need to know what the machine permits, I read a file. When I need to change it, I edit the file and ask pfctl whether it agrees before anything takes effect.

The cost I did pay is smaller and worth naming: the language has corners, the manual is 2,568 lines for a reason, and I have certainly written rules that did the opposite of what I intended. Precedence and the last-match rule catch everybody once.

Twenty-three lines for a router, and a grammar that has outlasted several complete rethinks of how this problem ought to be solved. Somebody wrote it down clearly enough the first time that the writing down never had to be repeated.