Configuring pf on FreeBSD: A Practical Guide to Packet Filtering
How pf's rule evaluation model, tables, and anchors fit together on FreeBSD, with a ruleset you can adapt for a real host.
FreeBSD ships with three firewall packages — pf, ipfw, and ipfilter — but pf, ported over from OpenBSD, is the one most FreeBSD deployments reach for by default. Its rule syntax reads closer to prose than the terser ipfw alternative, and its state tracking, tables, and anchors make it comfortable for anything from a home router to a multi-homed server.
Enabling pf
pf is a kernel module plus a userland control utility, pfctl. Enabling it is a matter of loading the module and pointing it at a ruleset file:
# /etc/rc.conf
pf_enable="YES"
pf_rules="/etc/pf.conf"
pflog_enable="YES"
service pf start
pfctl -sr # show the currently loaded ruleset
The evaluation model: last match wins
The single most important thing to internalize about pf is that, unlike many firewalls, rules are evaluated in order and the last matching rule wins — not the first. A block rule near the top of the file can still be overridden by a pass rule further down that also matches the packet. This is what makes pf’s common idiom — a blanket default-deny followed by successive narrower pass exceptions — read naturally from top to bottom:
# /etc/pf.conf
ext_if = "vtnet0"
set skip on lo
block in all
pass out all keep state
pass in on $ext_if proto tcp to port 22 keep state
pass in on $ext_if proto tcp to port { 80, 443 } keep state
The quick keyword opts out of “last match wins” for a specific rule, causing evaluation to stop immediately if it matches — useful for hard denials you never want a later rule to accidentally override:
block quick from <bruteforce>
Tables: dynamic lists of addresses
A table is a named, efficiently-indexed set of addresses that rules can reference, and that can be updated at runtime without reloading the whole ruleset — the mechanism behind most pf-based brute-force protection:
table <bruteforce> persist
block quick from <bruteforce>
# Add an offending address at runtime
pfctl -t bruteforce -T add 203.0.113.9
# List current table contents
pfctl -t bruteforce -T show
Tools like sshguard work by parsing logs and calling pfctl -T add against a table like this one — the table is the mechanism, the log-watching daemon is just a client of it.
State tracking and keep state
keep state tells pf to record a connection’s state after the first packet matches, so subsequent packets belonging to the same flow (return traffic, in particular) are matched against the state table directly rather than re-evaluated against the whole ruleset. In modern pf, pass rules default to flags S/SA keep state implicitly for TCP, but writing it explicitly is good practice for readability:
pfctl -ss # dump the current state table
A ruleset with no pass ... keep state for outbound traffic and a strict block in will silently break return traffic on a stateless setup — one of the more common early debugging traps.
Anchors: composable sub-rulesets
An anchor is a named, separate ruleset that can be loaded, reloaded, or removed independently of the main pf.conf — useful for rules generated by another process (a container runtime, a VPN daemon) that shouldn’t require hand-editing the master file:
anchor "docker/*"
load anchor "docker" from "/etc/pf.anchors/docker"
# Replace just the "docker" anchor's rules without touching anything else
pfctl -a docker -f /etc/pf.anchors/docker
NAT and redirection
pf handles NAT in the same configuration file, using nat and rdr (redirect) rules — the latter is what powers port forwarding to a jail or an internal host:
nat on $ext_if from 192.168.1.0/24 to any -> ($ext_if)
rdr on $ext_if proto tcp to port 8080 -> 192.168.1.50 port 80
Logging and inspecting traffic
pflog_enable="YES" creates a pflog0 pseudo-interface that any rule tagged with log writes matching packets to, readable with tcpdump:
block in log quick from <bruteforce>
tcpdump -n -e -ttt -i pflog0
Testing a ruleset before committing to it
pfctl -nf /etc/pf.conf parses and validates a ruleset without loading it — syntax errors and unresolvable macros surface immediately, which matters a great deal on a remote box where a bad block in all with no matching pass could lock out your only SSH session:
pfctl -nf /etc/pf.conf && pfctl -f /etc/pf.conf
That one habit — always dry-running with -n before a live reload — is the difference between a routine config change and a trip to the data center.