🐡 OpenBSD pf Firewall

pf is already running on a fresh OpenBSD install. Read the default rules, learn why the last match wins, log and block traffic, and change tables live, with the output of a real machine.

Video: OpenBSD pf Firewall (OpenBSD video)
Prefer to watch? This short video runs this page's main commands on a real OpenBSD 8.0 machine, with on-screen captions and a synthetic voiceover (an AI voice trained on the author's own recording). Click to play, or watch it on YouTube.
pfctl -sr                       # show the active rules
pfctl -nf /etc/pf.conf         # check a file, load nothing
pfctl -f /etc/pf.conf          # load it

pf is the OpenBSD packet filter, and it is already on after the install. This page shows the default rules, the rule that surprises Linux users (the last matching rule wins), how to block and log, how to keep tables of addresses, and how to reload without cutting off your own SSH session.

Every example ran for real on OpenBSD 8.0 (amd64) in a virtual machine, as an ordinary user with doas. The output you see is the output the machine gave.

pf is already on

On Linux a firewall is something you install and switch on. On OpenBSD it is already running. pf, the packet filter, is part of the kernel, and a fresh install loads a small ruleset at boot. Every example below ran for real on OpenBSD 8.0 in a virtual machine, as an ordinary user with doas.

$ doas rcctl get pf
pf=YES
$ doas pfctl -sr
block return all
pass all flags S/SA
block return in on ! lo0 proto tcp from any to any port 6000:6010
block return out log proto tcp all user = 55
block return out log proto udp all user = 55
$ doas grep -v '^#' /etc/pf.conf

set skip on lo

block return	# block stateless traffic
pass		# establish keep-state

block return in on ! lo0 proto tcp to port 6000:6010

block return out log proto {tcp udp} user _pbuild

pfctl is the control command and -sr means show rules. The rules you see are what /etc/pf.conf says, after pf expanded them. block return all is the starting point: refuse, and tell the sender. The next rule, pass, matches every new connection (flags S/SA means the first packet of a TCP connection) and, being later, overrides the block, and it keeps state so replies come back. In practice the default lets everything through, except what the later rules block. The rule for ports 6000 to 6010 keeps X11 closed to the network. set skip on lo means the loopback interface is not filtered at all.

Reading rules: the last match wins

This is the one rule of pf that surprises people who know iptables. pf reads the rules top to bottom and the last matching rule decides, unless a rule says quick, which stops the search at once. To see it, start a tiny server on port 8080 of the loopback interface, then try to connect. The server is a script, because it will be needed again:

$ printf '%s\n' "(echo hello | nc -l 127.0.0.1 8080 >/dev/null 2>&1 &)" "sleep 1" > /tmp/serve.sh
$ ksh /tmp/serve.sh
$ nc -vw 2 127.0.0.1 8080 </dev/null
Connection to 127.0.0.1 8080 port [tcp/*] succeeded!
hello

It works, and no firewall was involved yet, because the default ruleset skips the loopback interface. Now a ruleset that blocks port 8080, followed by a plain pass:

$ printf '%s\n' 'block return in on lo0 proto tcp to port 8080' 'pass' > /tmp/a.conf
$ doas pfctl -nf /tmp/a.conf
$ doas pfctl -f /tmp/a.conf
$ ksh /tmp/serve.sh
$ nc -vw 2 127.0.0.1 8080 </dev/null
Connection to 127.0.0.1 8080 port [tcp/*] succeeded!
hello

The connection succeeded although the first rule blocks it. The pass at the end also matches, and it comes last, so it wins. -n checked the syntax and loaded nothing; -f loads the file and replaces the active rules. Put the general rule first and the exception after it, and add quick to the block:

$ printf '%s\n' 'pass' 'block return in quick on lo0 proto tcp to port 8080' > /tmp/b.conf
$ doas pfctl -f /tmp/b.conf
$ ksh /tmp/serve.sh
$ nc -vw 2 127.0.0.1 8080 </dev/null; echo "exit $?"
nc: connect to 127.0.0.1 port 8080 (tcp) failed: Connection refused
exit 1

Now the connection is refused at once. block return answers the sender with a reset, so the client learns immediately.

block drop, and the log

A reset tells the other side that something is there. block drop says nothing at all, and the client waits until it gives up. Add log and pf writes the packet to the log device pflog0 as well:

$ printf '%s\n' 'pass' 'block drop in quick log on lo0 proto tcp to port 8080' > /tmp/c.conf
$ doas pfctl -f /tmp/c.conf
$ ksh /tmp/serve.sh
$ nc -vw 2 127.0.0.1 8080 </dev/null; echo "exit $?"
nc: connect to 127.0.0.1 port 8080 (tcp) failed: Operation timed out
exit 1

Two seconds of silence, then a timeout. The log is a binary file that pflogd writes, and tcpdump reads it. Ask pflogd to flush, then read:

$ doas pkill -HUP pflogd
$ doas tcpdump -n -e -r /var/log/pflog 2>&1 | tail -n 1 | cut -c1-110
03:50:20.480281 rule 1/(match) block in on lo0: 127.0.0.1.34409 > 127.0.0.1.8080: S 2273451669:2273451669(0) w

One line per blocked packet: the rule number, the action, the interface and the two addresses. For a live view, tcpdump -n -e -i pflog0 shows packets as they are blocked. Before you go on, put the original rules back:

$ doas pfctl -f /etc/pf.conf
$ doas pfctl -sr | head -n 2
block return all
pass all flags S/SA

A small real ruleset

The default is a fine start for a client, but a server needs something stricter: block everything coming in, allow what you serve. This one has a macro for the interface, a table of addresses to block, and a limit on how fast one address may open SSH connections:

$ printf '%s\n' 'ext_if = "egress"' 'set skip on lo' 'table <bad> persist' 'block log all' 'pass out quick' 'pass in on $ext_if proto tcp to port ssh keep state (max-src-conn 20, max-src-conn-rate 50/30, overload <bad> flush global)' 'block in quick from <bad>' > /tmp/server.conf
$ cat /tmp/server.conf
ext_if = "egress"
set skip on lo
table <bad> persist
block log all
pass out quick
pass in on $ext_if proto tcp to port ssh keep state (max-src-conn 20, max-src-conn-rate 50/30, overload <bad> flush global)
block in quick from <bad>
$ doas pfctl -nvf /tmp/server.conf
ext_if = "egress"
set skip on { lo }
table <bad> persist
block drop log all
pass out quick all flags S/SA
pass in on egress proto tcp from any to any port = 22 flags S/SA keep state (source-track rule, max-src-conn 20, max-src-conn-rate 50/30, overload <bad> flush global, src.track 30)
block drop in quick from <bad> to any

egress is a group name: pf fills in whichever interface has the default route, so the file works on any machine. -nv checks the syntax and prints each rule with the macros expanded, and port ssh became port 22 from /etc/services. The ssh rule keeps state and watches the sender: an address that opens more than 50 connections in 30 seconds, or holds more than 20 at once, is put into the table bad and its connections are flushed. The last rule blocks anything in that table. The numbers must suit how you really use the machine: a limit of 5 in 30 seconds would block a script that logs in often, and the first thing it would lock out is you. Load it. The ssh session this guide is typed in stays open, because the new rule allows port 22:

$ doas pfctl -f /tmp/server.conf
$ doas pfctl -sr
block drop log all
pass out quick all flags S/SA
pass in on egress proto tcp from any to any port = 22 flags S/SA keep state (source-track rule, max-src-conn 20, max-src-conn-rate 50/30, overload <bad> flush global, src.track 30)
block drop in quick from <bad> to any

Tables you can change while it runs

A table is a set of addresses that can change without reloading the rules. Rules refer to it by name, and pfctl edits it:

$ doas pfctl -t bad -T add 192.0.2.7 192.0.2.8
1/2 addresses added.
$ doas pfctl -t bad -T show
   192.0.2.7
   192.0.2.8
$ doas pfctl -t bad -T delete 192.0.2.7
1/1 addresses deleted.
$ doas pfctl -t bad -T show
   192.0.2.8
$ doas pfctl -sT
bad

That is how a script, or the overload rule from the last section, blocks an address in a fraction of a second. Now look at the connections pf is tracking, which is what keep state creates:

$ doas pfctl -ss | head -n 3 | cut -c1-90
all tcp 10.0.2.15:22 <- 10.0.2.2:50140       TIME_WAIT:TIME_WAIT
all tcp 10.0.2.15:22 <- 10.0.2.2:48172       TIME_WAIT:TIME_WAIT
all tcp 10.0.2.15:22 <- 10.0.2.2:50144       TIME_WAIT:TIME_WAIT

Each line is one connection with its state, and the reason a reply is let in without its own rule.

Changing the firewall without locking yourself out

Everyone locks themselves out of a remote machine once. These habits make it unlikely:

 - Always check first with pfctl -nf file. A file with a syntax error is not loaded, and the old rules stay.
 - Keep your own SSH session allowed. Connections that already exist keep working when you reload, because their state stays in the table.
 - Test a new ruleset with a timer that puts the old one back. If you can still reach the machine, cancel it; if not, it repairs itself:
   doas sh -c 'pfctl -f /tmp/new.conf; sleep 60; pfctl -f /etc/pf.conf'
 - Only when it works, copy it to /etc/pf.conf. The boot scripts load that file.

The machine is back to its starting point:

$ doas pfctl -f /etc/pf.conf
$ doas pfctl -sr | head -n 2
block return all
pass all flags S/SA
$ doas rcctl get pf
pf=YES

pfctl -d turns the firewall off and pfctl -e turns it on again. Do not do that on a machine you can only reach over the network.

Linux to pf

The ideas are the same and the words are different:

 - iptables or nftables: chains and tables of rules, first match wins. pf: one list of rules, last match wins unless quick.
 - iptables -A INPUT -p tcp --dport 22 -j ACCEPT is pass in proto tcp to port 22.
 - DROP is block drop, REJECT is block return.
 - ipset or an nft set is a pf table, and pfctl -t name -T add changes it.
 - conntrack is automatic state, and pf keeps state by default on every pass rule.
 - iptables-save is pfctl -sr, and the rules file is one plain file, /etc/pf.conf.
 - fail2ban is the overload keyword, with no extra software.

When the answer is not here, man pf.conf is one of the best manual pages on the system, and /etc/examples/pf.conf is a commented example.