Writing a Good pf.conf

pf.conf is one of those config files that's easy to write badly and surprisingly satisfying to write well. The syntax is clean enough that a good pf.conf is almost self-documenting — you can hand it to someone who's never used pf and they can generally figure out what it's doing. Here's how to structure it properly and avoid the common mistakes.

The Golden Rule: Test Before You Load

Always, always run this before reloading pf on a remote machine:

pfctl -nf /etc/pf.conf

The -n flag parses and validates the config without loading it. A syntax error in a freshly loaded pf.conf that blocks all traffic, on a remote box, is a very bad time. If you don't have out-of-band access (console, IPMI, VNC), a bad pf.conf load can mean a trip to the datacenter.

Structure and Order

pf.conf must follow this order — sections that appear out of order will cause errors:

  1. Macros and variables
  2. Tables
  3. Options (set directives)
  4. Normalization (scrub)
  5. Queueing (if using traffic shaping)
  6. NAT and redirection (nat, rdr)
  7. Filtering rules (pass, block)

Macros: Write Them First, Thank Yourself Later

# Interfaces
ext_if = "em0"
int_if = "em1"

# Networks
lan = "192.168.1.0/24"
dmz = "10.0.0.0/24"

# Services
admin_hosts = "{ 192.168.1.5, 192.168.1.10 }"
web_ports = "{ 80, 443 }"
mail_ports = "{ 25, 587, 993 }"

When your external interface changes from em0 to vtnet0 (it will, eventually), you change one line. When you add a new admin host, you add one IP to one place. Macros pay for themselves.

Tables for Dynamic Lists

# Persistent tables survive pf reloads
table <bruteforce> persist
table <whitelist> persist file "/etc/pf.whitelist"

# Constant tables can't be changed at runtime
table <martians> const { \
    0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, \
    169.254.0.0/16, 172.16.0.0/12, \
    192.0.2.0/24, 192.168.0.0/16, 224.0.0.0/3 }

Tables loaded from files (file "/etc/pf.whitelist") are useful for maintaining lists outside pf.conf — you can update the file and reload just the table without touching the ruleset:

pfctl -t whitelist -T replace -f /etc/pf.whitelist

Sensible Defaults

# Don't filter loopback — filtering lo0 traffic causes mysterious failures
set skip on lo

# Drop blocked packets silently (don't send RST or ICMP unreachable)
# Reduces information given to scanners
set block-policy drop

# Log stats on the external interface
set loginterface $ext_if

# Normalize incoming traffic
scrub in all fragment reassemble

The Ruleset Pattern

pf rules are evaluated in order, and the last matching rule wins (unlike iptables, where the first match wins). quick breaks this — a rule with quick is evaluated immediately when matched, skipping the rest. Use quick for definitive blocks and allows.

# 1. Block everything as the baseline
block all

# 2. Quick blocks — these exit immediately when matched
block in quick from <martians>
block in quick from <bruteforce>

# 3. Allow established states — keeps return traffic flowing
pass in quick on $ext_if proto tcp from any keep state \
    (max-src-conn-rate 15/5, overload <bruteforce> flush global)

# 4. Specific service rules
pass in on $ext_if proto tcp to port 22 keep state
pass in on $ext_if proto tcp to port $web_ports keep state
pass in on $ext_if proto icmp all keep state

# 5. Allow everything outbound
pass out on $ext_if all keep state

# 6. Internal network
pass on $int_if all keep state

Logging Rules Selectively

Don't log everything — you'll generate noise that buries real events. Log what's interesting:

# Log SSH attempts for monitoring
pass in log on $ext_if proto tcp to port 22 keep state

# Log blocked traffic on the external interface
block in log on $ext_if all

# Log brute-force table additions
block in log quick from <bruteforce>

Watch the log in real time:

tcpdump -n -e -ttt -i pflog0

Reloading Without Dropping Connections

pfctl -nf /etc/pf.conf      # validate first
pfctl -f /etc/pf.conf       # reload — existing states are preserved

Reloading pf.conf doesn't drop established connections. Existing state table entries stay alive until they expire naturally or you flush them explicitly with pfctl -F states.

Useful pfctl Commands

pfctl -sr                   # show current ruleset
pfctl -ss                   # show state table
pfctl -ss | wc -l           # count active states
pfctl -si                   # statistics (packets, bytes, states)
pfctl -t bruteforce -T show  # list entries in a table
pfctl -t bruteforce -T flush # clear a table
pfctl -F states              # flush all states (drops connections!)