<!--date=20250207 -->


<h1>OpenBSD pf — The Packet Filter</h1>
<p>pf is OpenBSD's packet filter, introduced in 3.0 as a replacement for IPFilter after a licensing dispute. It's one of those tools where the design decisions are clearly right — the syntax is readable, the behaviour is predictable, and the documentation is excellent. If you've wrestled with iptables and then sat down with pf for the first time, the difference is immediately noticeable.</p>

<h2>Getting pf Running</h2>
<pre>
# OpenBSD — /etc/rc.conf.local:
pf=YES

# FreeBSD — /etc/rc.conf:
pf_enable="YES"
pf_rules="/etc/pf.conf"
</pre>

<h3>pfctl — Runtime Control</h3>

<table>
  <thead><tr><th>Command</th><th>Purpose</th></tr></thead>
  <tbody>
    <tr><td><code>pfctl -e</code></td><td>Enable pf</td></tr>
    <tr><td><code>pfctl -d</code></td><td>Disable pf</td></tr>
    <tr><td><code>pfctl -nf /etc/pf.conf</code></td><td>Validate config without loading (always do this first)</td></tr>
    <tr><td><code>pfctl -f /etc/pf.conf</code></td><td>Load config (preserves existing states)</td></tr>
    <tr><td><code>pfctl -sr</code></td><td>Show current ruleset</td></tr>
    <tr><td><code>pfctl -ss</code></td><td>Show state table</td></tr>
    <tr><td><code>pfctl -si</code></td><td>Statistics — packets, bytes, states</td></tr>
    <tr><td><code>pfctl -F states</code></td><td>Flush all states (drops active connections — use with care)</td></tr>
  </tbody>
</table>

<h2>Structure of pf.conf</h2>
<p>pf.conf must follow this order — sections out of order cause 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>

<h3>Macros</h3>
<pre>
ext_if = "em0"
int_if = "em1"
lan    = "192.168.1.0/24"
ssh_port  = "22"
web_ports = "{ 80, 443 }"

# Used with $ prefix:
pass in on $ext_if proto tcp to port $web_ports keep state
</pre>

<h3>Tables</h3>
<p>Tables hold lists of IP addresses and can be updated at runtime without reloading the entire ruleset:</p>
<pre>
table &lt;bruteforce&gt; persist    # persist: survives pf reloads
table &lt;martians&gt; const { \
    0.0.0.0/8, 127.0.0.0/8, \
    169.254.0.0/16, 172.16.0.0/12, \
    192.0.2.0/24, 224.0.0.0/3 }

block in quick from &lt;martians&gt;
block in quick from &lt;bruteforce&gt;

# Runtime table management
pfctl -t bruteforce -T add 203.0.113.42
pfctl -t bruteforce -T show              # list entries
pfctl -t bruteforce -T expire 86400      # expire entries older than 1 day
</pre>

<h3>Options and Normalization</h3>
<pre>
set skip on lo              # don't filter loopback — filtering lo causes problems
set block-policy drop       # drop blocked packets silently (vs. RST/ICMP unreachable)
set loginterface $ext_if    # collect stats on the external interface
set state-policy if-bound   # bind states to the interface they arrived on

scrub in all fragment reassemble    # reassemble fragments before filtering
scrub in on $ext_if all random-id   # randomize IP ID (fingerprint hardening)
</pre>

<h2>Last-Match Wins — and quick</h2>
<p>Unlike iptables (first match wins), pf evaluates all rules and the last matching rule wins — unless a rule has <code>quick</code>, which exits immediately on match:</p>
<pre>
block all                           # default deny — baseline
block in quick from &lt;bruteforce&gt;   # quick: exit immediately, no further evaluation
block in quick from &lt;martians&gt;

pass in on $ext_if proto tcp to port 22 keep state   # SSH — evaluated (last match wins)
pass in on $ext_if proto tcp to port { 80, 443 } keep state
</pre>

<h2>A Complete pf.conf</h2>
<pre>
ext_if = "em0"
lan    = "192.168.1.0/24"

table &lt;bruteforce&gt; persist

set skip on lo
set block-policy drop
scrub in all fragment reassemble

# NAT for internal network
nat on $ext_if from $lan to any -> ($ext_if)

# Default deny
block all

# Quick blocks
block in quick from &lt;bruteforce&gt;

# Allow established (with automatic brute-force protection)
pass in on $ext_if proto tcp from any keep state \
    (max-src-conn-rate 15/5, overload &lt;bruteforce&gt; flush global)

# Inbound services
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
pass in on $ext_if proto icmp all keep state

# Allow all outbound
pass out on $ext_if all keep state

# Internal network
pass in on em1 from $lan to any keep state
</pre>

<p>The <code>max-src-conn-rate 15/5, overload &lt;bruteforce&gt; flush global</code> part is built-in brute-force protection: more than 15 connections from one source in 5 seconds → that IP gets added to the <code>bruteforce</code> table and all its existing states are flushed. This replaces fail2ban for many use cases.</p>

<h2>NAT and Port Forwarding</h2>
<pre>
# Masquerade — whole network behind external interface
nat on $ext_if from $lan to any -> ($ext_if)

# Port forward — incoming port 80 to internal web server
rdr on $ext_if proto tcp to port 80 -> 192.168.1.10 port 80

# Hairpin NAT — internal clients accessing external IP reach internal server
rdr on em1 proto tcp from $lan to ($ext_if) port 80 -> 192.168.1.10
</pre>

<h2>Logging</h2>
<pre>
# Add 'log' to any rule to capture matching packets
pass in log on $ext_if proto tcp to port 22 keep state
block in log on $ext_if all

# Read the pflog0 interface in real time
tcpdump -n -e -ttt -i pflog0

# Analyze a saved pflog file
tcpdump -n -e -ttt -r /var/log/pflog
</pre>

<h2>Anchors</h2>
<p>Anchors let you load additional rulesets from separate files — useful for modular configs or per-user rules (authpf):</p>
<pre>
anchor "webserver"
load anchor "webserver" from "/etc/pf.webserver.conf"

# Update a sub-ruleset without reloading everything:
pfctl -a webserver -f /etc/pf.webserver.conf
</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 FAQ ↗</a></li>
  <li><a href="https://man.openbsd.org/pfctl.8" target="_blank" rel="noopener">pfctl(8) man page ↗</a></li>
</ul>
