🧱 The NetBSD NPF firewall

NPF is built into the NetBSD kernel and switched off by default. Load rules, test them with real connections, log, use tables, and start it at boot, with a Linux comparison and the mistakes to avoid.

Video: The NetBSD NPF firewall (NetBSD video)
Prefer to watch? This short video runs this page's main commands on a real NetBSD 11.1 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.
npfctl validate /etc/npf.conf     # check the rules
npfctl reload /etc/npf.conf       # load them
npfctl table bad add 192.0.2.7    # block an address, no reload

NPF is the firewall in the NetBSD kernel. It is stateful, it reads a plain rules file, and it is controlled with one command, npfctl. A fresh install has it built in and switched off. This page loads rules, tests them with real connections, shows why the last matching rule wins unless it is final, logs, uses tables, and starts it at boot, with a Linux comparison.

Every example ran for real on NetBSD 11.1 (amd64) in a virtual machine, with root commands marked #. The output you see is the output the machine gave.

NPF: the firewall in the kernel

NetBSD's own firewall is called NPF, for NetBSD Packet Filter. It is part of the kernel, it is stateful, and it is controlled with one command, npfctl. A fresh install has it built in, but switched off. If you know pf from OpenBSD or FreeBSD, much of this will feel familiar, but the rule syntax is NetBSD's own.

Every example here ran for real on NetBSD 11.1 (amd64) in a virtual machine. Commands that change the system start with # and run as root, because NetBSD has no sudo by default. First see what is there:

$ man -k npf | head -n 2
NPF (7)	NetBSD packet filter
...NPF documentation website and other manual pages. libnpf(3) , bpf(4) , bpfjit(4) , npf.conf(5) , npf-params(7) , pcap-filter(7) , npfctl(8) , npfd(8) https://github.com/rmind/npf/ NPF project page http://rmind.github.io/npf/ NPF...
$ modstat | grep -E '^npf '
npf                        misc     builtin  -        4       - bpf_filter
$ grep -E '^npf' /etc/defaults/rc.conf
npf=NO			npf_rules="/etc/npf.conf"
npfd=NO			npfd_flags=""
# npfctl show
# filtering:	inactive
# config:	empty

The npf module is already in the kernel, the default for npf in rc.conf is NO, and npfctl show reports filtering inactive and an empty configuration. Nothing is filtered, and nothing will be until you load rules and start it.

Your first rules

The rules live in a file, which npfctl reads and checks. A rule set is made of groups. The group called default catches everything the other groups do not. Start with a file that passes everything, and keeps the interface the virtual machine is reached on open, so that this session survives whatever comes next:

$ cat /tmp/a.conf
group default {
	pass final on vioif0 all
	pass final on lo0 all
}
# npfctl validate /tmp/a.conf; echo "exit status $?"
group default {
	pass final on vioif0 all
	pass final on lo0 all
}

exit status 0

validate checks the syntax, and prints the rules back in a normal form. Nothing is loaded yet. To load them, start NPF and then reload with the file:

# npfctl start
# npfctl reload /tmp/a.conf
# npfctl show
# filtering:	active
# config:	loaded

group default { # id="1"
	pass final on vioif0 all # id="2"
	pass final on lo0 all # id="3"
}

The status now says filtering active and config loaded. To test, we need something to connect to. This one-line script starts a listener on the loopback interface, port 8080, for a single connection, since nc stops after the first one:

$ printf '%s\n' "(echo hello | nc -l 127.0.0.1 8080 >/dev/null 2>&1 &)" "sleep 1" > /tmp/serve.sh
$ sh /tmp/serve.sh
$ nc -zv -w 2 127.0.0.1 8080; echo "exit status $?"
Connection to 127.0.0.1 8080 port [tcp/http-alt] succeeded!
exit status 0

Everything passes, as the rules say.

Blocking: drop and return

Now block that port. A rule with block and no more drops the packets without a word, which is the safe default for a firewall. The rule says in, on lo0, TCP, to the address and port:

$ cat /tmp/b.conf
group default {
	pass final on vioif0 all
	block in final on lo0 proto tcp to 127.0.0.1 port 8080
	pass final on lo0 all
}
# npfctl reload /tmp/b.conf
$ sh /tmp/serve.sh
$ nc -zv -w 2 127.0.0.1 8080; echo "exit status $?"
nc: connect to 127.0.0.1 port 8080 (tcp) failed: Operation timed out
exit status 1

The connection timed out: the client waited, and got no answer. That is what a drop looks like from outside. To tell the sender, add return, which sends a TCP reset for TCP and an ICMP message for the rest:

$ cat /tmp/c.conf
group default {
	pass final on vioif0 all
	block return in final on lo0 proto tcp to 127.0.0.1 port 8080
	pass final on lo0 all
}
# npfctl reload /tmp/c.conf
$ sh /tmp/serve.sh
$ nc -zv -w 2 127.0.0.1 8080; echo "exit status $?"
nc: connect to 127.0.0.1 port 8080 (tcp) failed: Connection refused
exit status 1

Now the answer is immediate: connection refused. Use drop for the outside, where you do not want to help a scanner, and return for your own network, where a fast error is kinder to people.

The last match wins, unless it is final

NPF evaluates the rules in a group from top to bottom. The last rule that matches decides, unless a rule marked final matches, which ends the search at once. This is the same idea as pf, and it surprises people who know iptables. Here the block comes first and a general pass comes after it, with no final on the block:

$ cat /tmp/d.conf
group default {
	pass final on vioif0 all
	block in on lo0 proto tcp to 127.0.0.1 port 8080
	pass on lo0 all
}
# npfctl reload /tmp/d.conf
$ sh /tmp/serve.sh
$ nc -zv -w 2 127.0.0.1 8080; echo "exit status $?"
Connection to 127.0.0.1 8080 port [tcp/http-alt] succeeded!
exit status 0

The connection works, although a rule above blocks it, because the pass below matched later. Mark the block final, and the search stops there:

$ sed 's/^\tblock in on/\tblock in final on/' /tmp/d.conf > /tmp/e.conf
$ grep block /tmp/e.conf
	block in final on lo0 proto tcp to 127.0.0.1 port 8080
# npfctl reload /tmp/e.conf
$ sh /tmp/serve.sh
$ nc -zv -w 2 127.0.0.1 8080; echo "exit status $?"
nc: connect to 127.0.0.1 port 8080 (tcp) failed: Operation timed out
exit status 1

So the habit is to write final on the rules that must win, as the examples before did.

Logging

To see what a rule blocks, give it a procedure that logs. A procedure is a named list of actions, and a rule applies it with the word apply. The log goes to a virtual interface, npflog0, which tcpdump can read, as with pflog on OpenBSD:

$ cat /tmp/f.conf
procedure "log" {
	log: npflog0
}
group default {
	pass final on vioif0 all
	block in final on lo0 proto tcp to 127.0.0.1 port 8080 apply "log"
	pass final on lo0 all
}
# npfctl reload /tmp/f.conf
$ ifconfig npflog0 | head -n 1
npflog0: flags=0x1<UP>
# sh -c '(timeout 8 tcpdump -n -e -i npflog0 -c 1 > /tmp/npflog.txt 2>&1 &)'; sleep 2
$ sh /tmp/serve.sh
$ nc -zv -w 2 127.0.0.1 8080; echo "exit status $?"
nc: connect to 127.0.0.1 port 8080 (tcp) failed: Operation timed out
exit status 1
$ sleep 2; grep rule /tmp/npflog.txt | cut -c1-110
19:06:43.016095 rule 3.rules.0/0(match): block in on lo0: 127.0.0.1.65519 > 127.0.0.1.8080: Flags [S], seq 255

One line for the blocked packet: the time, the rule, the action, the interface, and both addresses with the SYN flag. The first number of the rule, rule 3 in group default, tells which rule did it.

Tables

A table holds a set of addresses, and can be changed while the rules are running, with no reload. Declare one with the type ipset, and refer to it in a rule with angle brackets:

$ cat /tmp/g.conf
table <bad> type ipset
group default {
	pass final on vioif0 all
	block in final from <bad>
	pass final on lo0 all
}
# npfctl reload /tmp/g.conf
# npfctl table bad add 192.0.2.7
npfctl: success
# npfctl table bad add 192.0.2.8
npfctl: success
# npfctl table bad list
192.0.2.8
192.0.2.7
# npfctl table bad rem 192.0.2.7
npfctl: success
# npfctl table bad list
192.0.2.8
# npfctl table bad test 192.0.2.8; echo "exit status $?"
npfctl: match
exit status 0

That is how a script, or a log watcher, blocks an address in a fraction of a second. test exits with 0 when the address is in the table. Before the next section, switch NPF off again:

# npfctl stop
# npfctl show
# filtering:	inactive
# config:	loaded

table <bad> type ipset

group default { # id="1"
	pass final on vioif0 all # id="2"
	block in final from <bad> # id="3"
	pass final on lo0 all # id="4"
}

A server ruleset, and starting it at boot

Here is a ruleset for a server. The variable ext_if names the interface, and ifaddrs() gets its addresses. In the group for the interface: anything from the bad table is blocked, ssh is allowed in and its replies out, other outgoing traffic is allowed and tracked with stateful, and the rest of what comes in is blocked and logged. Anything else, on other interfaces, is blocked:

$ cat /tmp/npf.conf
$ext_if = "vioif0"
$ext_addrs = ifaddrs($ext_if)

table <bad> type ipset

procedure "log" {
	log: npflog0
}

group "external" on $ext_if {
	block in final from <bad>
	pass in final proto tcp to $ext_addrs port ssh
	pass out final proto tcp from $ext_addrs port ssh
	pass stateful out final all
	block in final all apply "log"
}

group default {
	pass final on lo0 all
	block final all
}
# npfctl validate /tmp/npf.conf >/dev/null; echo "exit status $?"
exit status 0

The two ssh rules are stateless and come first, with final. A stateful rule for ssh froze the session that was loading the ruleset when this was tried, so the connection you are using now is kept open by plain rules. The default for rc.d is to read /etc/npf.conf, so put the file there and enable the service:

# cp /tmp/npf.conf /etc/npf.conf
# echo 'npf=YES' >> /etc/rc.conf
# /etc/rc.d/npf start
Enabling NPF /etc/npf.conf
# /etc/rc.d/npf status; echo "exit status $?"
exit status 0
# npfctl show | head -n 14
# filtering:	active
# config:	loaded

table <bad> type ipset

procedure "log"

group "external" on vioif0 { # id="1"
	block in final from <bad> # id="2"
	pass in final proto tcp to ifaddrs(vioif0) port 22 # id="3"
	pass out final proto tcp from ifaddrs(vioif0) port 22 # id="4"
	pass stateful out final all # id="5"
	block in final all apply "log" # id="6"
}

From now on it starts at boot, before the network comes up. Check that outgoing connections still work, and use ftp -4, because the group default blocks IPv6 quietly and ftp would wait for it before it tries IPv4. Then change a table, and reload with the script:

$ ftp -4 -o - http://cdn.netbsd.org/pub/NetBSD/README 2>/dev/null | head -n 3
Description of files and subdirectories in pub/NetBSD:

This directory contains files related to NetBSD.
# npfctl table bad add 192.0.2.7
npfctl: success
# /etc/rc.d/npf reload
Reloading NPF ruleset /etc/npf.conf
# npfctl table bad list
192.0.2.7
192.0.2.8

Reloading from rc.d reads /etc/npf.conf again. To switch the firewall off, stop it and take the line out of rc.conf:

# /etc/rc.d/npf stop
Disabling NPF.
# sed -i.bak '/^npf=YES/d' /etc/rc.conf
# npfctl show | head -n 2
# filtering:	inactive
# config:	empty

Linux to NetBSD side by side

The ideas are close, with different names:

 - iptables, nftables: npfctl and /etc/npf.conf.
 - A chain with a policy: a group, where the group default catches the rest.
 - First match wins in iptables: here the last match wins, unless a rule is final.
 - -j DROP and -j REJECT: block, and block return.
 - -m state or conntrack: stateful, which tracks the connection.
 - ipset or an nft set: a table of type ipset, changed with npfctl table.
 - -j LOG: a procedure with log: npflog0, applied with apply, and read with tcpdump on npflog0.
 - iptables-save and restore: the config file itself, and npfctl reload.
 - systemctl enable nftables: npf=YES in /etc/rc.conf, and /etc/rc.d/npf start.
 - pf on the other BSDs: the same ideas, a different syntax. pf uses pass and block with quick, NPF uses final.

NPF also does network address translation, with map rules, and has a rule for layer 2. Both are described in npf.conf(5).

When something goes wrong

The messages are short. The command to load a file is reload, not load, which expects a ruleset that was compiled and saved before. A mistake in the syntax is shown with the line and the column, and so is an address that does not fit:

$ cat /tmp/h.conf
group default {
	block final in on lo0 all
}
# npfctl validate /tmp/h.conf; echo "exit status $?"
/tmp/h.conf:2:15: syntax error near 'in'
exit status 1
$ cat /tmp/i.conf
$ext_if = "vioif0"
group default {
	pass final on lo0 to $ext_if port 22
}
# npfctl validate /tmp/i.conf; echo "exit status $?"
npfctl: address family mismatch
exit status 1
# npfctl load /tmp/a.conf; echo "exit status $?"
npfctl: open: '/var/db/npf.db': No such file or directory
exit status 1

The first has the words in the wrong order: block, then in, then final. The second uses the name of an interface where an address is needed, and ifaddrs() is the fix. The third is the wrong command for a text file. When a change locks you out, the VM console is the way back, so on a remote machine keep a timer that stops NPF a few minutes after you start it, and cancel the timer when you know you can still connect.