PF: The One True Firewall
Every operating system needs a firewall. Most operating systems deserve better than what they have.
Linux has iptables — a disaster of syntax and state that inspired nftables, an apology that admits the original was wrong.
OpenBSD has PF (Packet Filter) — the firewall that reads like documentation, executes like compiled code, and shames everything else.
The Origin:
In 2001, IPFilter’s license changed. Darren Reed added restrictions that made OpenBSD uncomfortable.
OpenBSD’s response? Write a replacement. In three months.
Daniel Hartmeier implemented PF from scratch. It shipped in OpenBSD 3.0 and immediately became the gold standard for firewall configuration.
When your license terms annoy OpenBSD, they do not complain. They rewrite your software and make it better.
The Syntax Comparison:
Block incoming SSH except from trusted networks.
iptables (Linux):
iptables -A INPUT -p tcp --dport 22 -s 192.168.1.0/24 -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -s 10.0.0.0/8 -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -j DROP
nftables (Linux, improved):
nft add rule inet filter input tcp dport 22 ip saddr { 192.168.1.0/24, 10.0.0.0/8 } accept
nft add rule inet filter input tcp dport 22 drop
PF (OpenBSD):
trusted = "{ 192.168.1.0/24, 10.0.0.0/8 }"
block in on egress proto tcp to port 22
pass in on egress proto tcp from $trusted to port 22
Read the PF version aloud. It is English. It is intent. It is correct.
Read the iptables version aloud. It is commands. It is flags. It is suffering.
PF Configuration:
PF’s configuration file (/etc/pf.conf) is structured and readable:
# Macros
ext_if = "em0"
int_if = "em1"
trusted = "{ 192.168.1.0/24, 10.0.0.0/8 }"
tcp_services = "{ ssh, smtp, http, https }"
# Tables (dynamic, can be updated without reload)
table <bruteforce> persist
# Options
set skip on lo
set block-policy drop
set loginterface $ext_if
# Normalization
match in all scrub (no-df random-id max-mss 1440)
# NAT
match out on $ext_if inet from !($ext_if) to any nat-to ($ext_if)
# Default deny
block all
# Allow outbound
pass out quick on $ext_if
# Allow trusted networks
pass in on $ext_if proto tcp from $trusted to port $tcp_services
# Block bruteforce attackers (populated by fail2ban equivalent)
block quick from <bruteforce>
# Allow ping
pass in inet proto icmp icmp-type echoreq
That is a complete firewall. It is readable by humans. It does what it says.
Tables:
PF tables are dynamic address lists that can be modified without reloading rules:
# Add an address to block table
pfctl -t bruteforce -T add 203.0.113.50
# Remove an address
pfctl -t bruteforce -T delete 203.0.113.50
# List table contents
pfctl -t bruteforce -T show
# Expire entries older than 24 hours
pfctl -t bruteforce -T expire 86400
No firewall reload. No connection drops. Dynamic updates in microseconds.
Tables can hold millions of addresses efficiently. PF uses radix trees. Performance does not degrade.
Anchors:
Anchors are sub-rulesets that can be loaded, modified, and removed independently:
# Main pf.conf
anchor "fail2ban/*"
anchor "vpn"
load anchor "vpn" from "/etc/pf.vpn.conf"
# Add rules to anchor dynamically
echo "pass in on wg0 from 10.8.0.0/24" | pfctl -a vpn -f -
# Flush specific anchor
pfctl -a vpn -F rules
Modularity without complexity. Each anchor is independent. Main rules stay clean.
Stateful Filtering:
PF is stateful by default. Pass a packet out, the reply is automatically allowed:
# This single rule handles outbound AND return traffic
pass out on $ext_if
No explicit rules for established connections. No --state RELATED,ESTABLISHED nonsense. PF tracks state automatically.
State table inspection:
pfctl -s state
all tcp 192.168.1.100:54321 -> 93.184.216.34:443 ESTABLISHED:ESTABLISHED
all udp 192.168.1.100:51820 -> 10.0.0.1:51820 MULTIPLE:MULTIPLE
Queue-Based Traffic Shaping:
PF integrates with ALTQ for traffic shaping:
# Define queues
queue rootq on $ext_if bandwidth 100M
queue std parent rootq bandwidth 90M default
queue ssh parent rootq bandwidth 10M min 5M
# Assign traffic to queues
pass out on $ext_if proto tcp to port 22 set queue ssh
Priority traffic gets guaranteed bandwidth. Bulk traffic fills what remains. VoIP over SSH over downloads — proper QoS without third-party tools.
Packet Normalization:
PF scrubs malformed packets:
match in all scrub (no-df random-id reassemble tcp)
This:
- Clears the don’t-fragment bit
- Randomizes IP ID (fingerprinting resistance)
- Reassembles fragmented packets
- Fixes TCP options
Attackers cannot bypass rules with fragmentation tricks. PF sees reassembled packets only.
Logging:
PF logs to pflog interface, readable with tcpdump:
# In rules
block in log on $ext_if
# View logs in real-time
tcpdump -n -e -ttt -i pflog0
# Or capture to file
tcpdump -n -e -ttt -i pflog0 -w /var/log/pflog
Standard tools. Standard formats. No special log parsing software required.
pfsync: Firewall Failover:
Two firewalls can synchronize state:
# On both firewalls
ifconfig pfsync0 syncdev em2
ifconfig pfsync0 up
# pf.conf
set skip on pfsync0
State replicates between firewalls. When one fails, the other has all connection states. Failover without connection drops.
Enterprise features. BSD simplicity. Zero licensing fees.
CARP: High Availability:
Combined with CARP (Common Address Redundancy Protocol):
ifconfig carp0 create
ifconfig carp0 vhid 1 pass secretpass carpdev em0 192.168.1.1/24
Two firewalls share a virtual IP. PF state syncs via pfsync. When the primary dies, secondary takes the IP in milliseconds.
This is enterprise firewall clustering. OpenBSD gives it away.
Where PF Runs:
| Platform | Status |
|---|---|
| OpenBSD | Native, best implementation |
| FreeBSD | Ported, well maintained |
| NetBSD | Ported |
| macOS | Old version, deprecated |
| pfSense | FreeBSD with PF, GUI wrapper |
| OPNsense | FreeBSD with PF, GUI wrapper |
pfSense and OPNsense exist because PF is so good that people build products around it.
The iptables Legacy:
iptables was designed by committee. Rules are chains. Chains are tables. Tables are confusing.
# Which table? Which chain? What order?
iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
iptables -t filter -A FORWARD -i eth1 -o eth0 -j ACCEPT
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
Linux needed three attempts: ipfwadm → ipchains → iptables → nftables.
OpenBSD needed one: PF.
The Lesson:
A firewall configuration should be readable. A firewall configuration should express intent. A firewall configuration should not require documentation to understand.
PF achieves this. iptables does not. nftables tries and mostly fails.
When IPFilter changed its license, OpenBSD wrote something better in three months. That is the power of focused engineering.
PF is a firewall. PF is also a lesson: clarity is not weakness, complexity is not strength.
Block all. Pass what you need. Read your config aloud. If it sounds wrong, it is wrong.
PF sounds right.
— Kim Jong Rails, Supreme Leader of the Republic of Derails