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: Always Validate Before Loading

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 machine — where you have no out-of-band access — means a trip to the datacenter. Always validate first. Always keep a second SSH session open while making changes.

Required Section Order

pf.conf sections must appear in this order — anything out of order causes errors:

  1. Macros and variables
  2. Tables
  3. Options (set directives)
  4. Normalization (scrub)
  5. Queueing (traffic shaping, if used)
  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"

# Hosts and groups
admin_hosts = "{ 192.168.1.5, 192.168.1.10 }"

# Port groups
web_ports  = "{ 80, 443 }"
mail_ports = "{ 25, 587, 993 }"

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

Tables for Dynamic Lists

# persist: table survives pf reloads without losing entries
table <bruteforce> persist

# const: read-only — cannot 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 }

# file: load initial contents from a file
table <whitelist> persist file "/etc/pf.whitelist"
# Update a table at runtime without reloading the ruleset:
pfctl -t whitelist -T replace -f /etc/pf.whitelist
pfctl -t bruteforce -T add 203.0.113.42
pfctl -t bruteforce -T flush                    # clear all entries
pfctl -t bruteforce -T show                     # list entries
pfctl -t bruteforce -T expire 86400             # expire entries older than 1 day

Sensible Defaults

# Never filter loopback — causes mysterious failures if you do
set skip on lo

# Drop blocked packets silently — gives less information to scanners
# 'return' sends RST for TCP and ICMP unreachable for UDP (gives info away)
set block-policy drop

# Collect statistics on the external interface
set loginterface $ext_if

# Reassemble fragments before filtering — prevents evasion via fragmentation
scrub in all fragment reassemble

# Randomize IP ID field — hardens against OS fingerprinting
scrub in on $ext_if all random-id

The Ruleset Pattern

pf's last-match-wins evaluation means rules are read in order, but the last applicable rule is what executes. Use quick to break this for definitive blocks and allows — quick exits evaluation immediately on match:

# 1. Baseline: block everything
block all

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

# 3. Allow established connections, with brute-force auto-blocking
#    If any source makes 15+ connections in 5 seconds → add to bruteforce
pass in on $ext_if proto tcp from any keep state \
    (max-src-conn-rate 15/5, overload <bruteforce> flush global)

# 4. Allow specific inbound services
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 all outbound
pass out on $ext_if all keep state

# 6. Internal network — trust it fully
pass on $int_if all keep state

Logging Selectively

Log what's interesting, not everything. Full logging generates noise that buries real events:

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

# Log blocked traffic on the external interface (shows what's being blocked)
block in log on $ext_if all

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

# Watch the log in real time
tcpdump -n -e -ttt -i pflog0

# Read a saved pflog file
tcpdump -n -e -ttt -r /var/log/pflog

Reloading Without Dropping Connections

# Validate first
pfctl -nf /etc/pf.conf

# Load new config — existing state table entries are preserved
pfctl -f /etc/pf.conf

# Reloading pf.conf does NOT drop established connections.
# Existing state table entries stay alive until they expire naturally.
# Only 'pfctl -F states' drops connections.

Useful pfctl One-Liners

pfctl -sr                           # show current ruleset
pfctl -ss                           # show state table
pfctl -ss | wc -l                   # count active states
pfctl -si                           # interface statistics
pfctl -t bruteforce -T show         # list entries in bruteforce table
pfctl -t bruteforce -T flush        # clear bruteforce table
pfctl -F states                     # flush state table (drops connections!)
pfctl -F all                        # flush everything (rules + states + tables)

References