<!--date=20250205 -->


<h1>BSD vs. Linux Firewalling</h1>
<p>If you've used pf on OpenBSD and then had to write iptables rules on Linux, the culture shock is real. The tools reflect the different philosophies of the systems they come from — BSD tends toward integrated, opinionated design; Linux tends toward modularity and flexibility, which sometimes means more pieces to understand. Here's how they compare in practice.</p>

<h2>The Tools at a Glance</h2>

<table>
  <thead><tr><th></th><th>OpenBSD pf</th><th>FreeBSD ipfw</th><th>Linux nftables</th></tr></thead>
  <tbody>
    <tr><td><strong>Integration</strong></td><td>Tightly built into OpenBSD; also on FreeBSD</td><td>Native to FreeBSD</td><td>Linux kernel; works across all distros</td></tr>
    <tr><td><strong>Syntax</strong></td><td>Clean, human-readable pf.conf</td><td>Command-line oriented, less intuitive</td><td>Modern and consistent; steeper initial curve</td></tr>
    <tr><td><strong>Stateful</strong></td><td>With <code>keep state</code> — explicit and clear</td><td>With <code>keep-state</code> flag</td><td>Via conntrack; <code>ct state</code> matching</td></tr>
    <tr><td><strong>NAT</strong></td><td>Built-in, clean <code>nat</code> syntax</td><td>Built-in via ipnat or divert</td><td>Via nat table hooks</td></tr>
    <tr><td><strong>Traffic shaping</strong></td><td>ALTQ (built-in)</td><td>dummynet (built-in)</td><td><code>tc</code> (separate tool entirely)</td></tr>
    <tr><td><strong>Default policy</strong></td><td><code>set block-policy drop</code></td><td>Rule 65535 catch-all</td><td>Chain policy setting</td></tr>
  </tbody>
</table>

<h2>Syntax Comparison: The Same Rule Three Ways</h2>
<p>Allow SSH in, block everything else:</p>

<h3>OpenBSD pf</h3>
<pre>
block all
pass in on egress proto tcp to port 22 keep state
</pre>
<p>This reads almost like a sentence. <code>block all</code> is the default deny. <code>pass in</code> punches the specific hole. <code>egress</code> is a shorthand for the default route interface — you don't even have to name it.</p>

<h3>FreeBSD ipfw</h3>
<pre>
ipfw add 100 allow tcp from any to any 22 in keep-state
ipfw add 65535 deny all from any to any
</pre>
<p>ipfw processes rules by number, lowest first. The 65535 deny-all is the catch-all at the end. Rule ordering is your responsibility to manage manually — easy to make gaps or conflicts as rulesets grow.</p>

<h3>Linux nftables</h3>
<pre>
table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;
        tcp dport 22 ct state new accept
        ct state established,related accept
    }
}
</pre>
<p>nftables groups rules into tables and chains — more verbose upfront, but scales better for complex rulesets. Tables for different purposes (IPv4, IPv6, NAT) can coexist cleanly without interfering.</p>

<h2>Stateful Filtering</h2>
<pre>
# pf — keep state inline on the pass rule
pass in proto tcp to port 80 keep state

# ipfw — keep-state flag at the end
ipfw add allow tcp from any to any 80 keep-state

# nftables — conntrack matching is a separate step
tcp dport 80 ct state new accept      # allow new connections to port 80
ct state established,related accept    # allow return traffic (applies to all)
</pre>
<p>The nftables approach of separating "allow established" from "allow new" is actually clearer once you're used to it. pf's inline <code>keep state</code> is more compact. Both correctly handle return traffic automatically.</p>

<h2>NAT Comparison</h2>
<pre>
# pf — masquerade the internal network behind the external interface
nat on egress from 192.168.1.0/24 to any -> (egress)

# nftables — hook-based approach
table ip nat {
    chain postrouting {
        type nat hook postrouting priority 100;
        ip saddr 192.168.1.0/24 oifname "eth0" masquerade
    }
}
</pre>
<p>pf's NAT syntax is simpler for standard use. nftables' hook-based approach is more explicit about where in the packet processing pipeline the rule applies — which matters for complex setups, not for masquerading a home network.</p>

<h2>Rule First-Match vs. Last-Match</h2>
<p>This is the biggest gotcha when switching between systems:</p>

<table>
  <thead><tr><th>System</th><th>Evaluation model</th><th>Override behaviour</th></tr></thead>
  <tbody>
    <tr><td>pf</td><td>Last matching rule wins</td><td>Use <code>quick</code> to exit on first match</td></tr>
    <tr><td>ipfw</td><td>First matching rule wins (by rule number)</td><td>Lower-numbered rules take precedence</td></tr>
    <tr><td>nftables</td><td>First matching rule in chain wins</td><td>Use <code>continue</code> to keep evaluating</td></tr>
    <tr><td>iptables</td><td>First matching rule wins</td><td>Use jump to other chains for exceptions</td></tr>
  </tbody>
</table>

<h2>Which to Use</h2>
<p>This is mostly decided by the OS:</p>
<ul>
  <li><strong>OpenBSD:</strong> Use pf — it's deeply integrated and the rest of the system (authpf, relayd, ospfd) assumes it.</li>
  <li><strong>FreeBSD:</strong> You have a choice. pf is available and well-supported; most people prefer it over ipfw for new setups.</li>
  <li><strong>Linux:</strong> Use nftables, or ufw/firewalld on top of it if you want a simpler interface. iptables still works but new development is in nftables.</li>
</ul>

<p>The philosophical difference is real but rarely matters in practice for a single-server setup. BSD firewalls feel more opinionated and integrated; Linux firewalls feel more like infrastructure you assemble. Both get the job done.</p>

<h2>References</h2>
<ul>
  <li><a href="https://www.openbsd.org/faq/pf/" target="_blank" rel="noopener">OpenBSD pf FAQ ↗</a></li>
  <li><a href="https://wiki.nftables.org/wiki-nftables/index.php/Quick_reference-nftables_in_10_minutes" target="_blank" rel="noopener">nftables quick reference ↗</a></li>
  <li><a href="https://www.freebsd.org/doc/handbook/firewalls-ipfw.html" target="_blank" rel="noopener">FreeBSD Handbook — ipfw ↗</a></li>
</ul>
