<!--date=20250208 -->


<h1>Writing a Good pf.conf</h1>
<p>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.</p>

<h2>The Golden Rule: Always Validate Before Loading</h2>
<pre>
pfctl -nf /etc/pf.conf
</pre>
<p>The <code>-n</code> 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.</p>

<h2>Required Section Order</h2>
<p>pf.conf sections must appear in this order — anything out of order causes errors:</p>
<ol>
  <li>Macros and variables</li>
  <li>Tables</li>
  <li>Options (<code>set</code> directives)</li>
  <li>Normalization (<code>scrub</code>)</li>
  <li>Queueing (traffic shaping, if used)</li>
  <li>NAT and redirection (<code>nat</code>, <code>rdr</code>)</li>
  <li>Filtering rules (<code>pass</code>, <code>block</code>)</li>
</ol>

<h2>Macros — Write Them First, Thank Yourself Later</h2>
<pre>
# 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 }"
</pre>
<p>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.</p>

<h2>Tables for Dynamic Lists</h2>
<pre>
# persist: table survives pf reloads without losing entries
table &lt;bruteforce&gt; persist

# const: read-only — cannot be changed at runtime
table &lt;martians&gt; 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 &lt;whitelist&gt; persist file "/etc/pf.whitelist"
</pre>

<pre>
# 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
</pre>

<h2>Sensible Defaults</h2>
<pre>
# 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
</pre>

<h2>The Ruleset Pattern</h2>
<p>pf's last-match-wins evaluation means rules are read in order, but the last applicable rule is what executes. Use <code>quick</code> to break this for definitive blocks and allows — <code>quick</code> exits evaluation immediately on match:</p>
<pre>
# 1. Baseline: block everything
block all

# 2. Quick blocks — exit immediately when matched
block in quick from &lt;martians&gt;
block in quick from &lt;bruteforce&gt;

# 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 &lt;bruteforce&gt; 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
</pre>

<h2>Logging Selectively</h2>
<p>Log what's interesting, not everything. Full logging generates noise that buries real events:</p>
<pre>
# 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 &lt;bruteforce&gt;

# 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
</pre>

<h2>Reloading Without Dropping Connections</h2>
<pre>
# 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.
</pre>

<h2>Useful pfctl One-Liners</h2>
<pre>
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)
</pre>

<h2>References</h2>
<ul>
  <li><a href="https://man.openbsd.org/pf.conf.5" target="_blank" rel="noopener">pf.conf(5) man page ↗</a></li>
  <li><a href="https://www.openbsd.org/faq/pf/" target="_blank" rel="noopener">OpenBSD pf User's Guide ↗</a></li>
  <li>Lucas — <em>Absolute OpenBSD, 2nd Edition</em> — excellent pf chapter</li>
</ul>
