I’m continuing to gradually set up my home NAS on FreeBSD, and the first thing I want to dive into is FreeBSD firewalls.
I used to work with IPFW back in the day – FreeBSD: initial setup of IPFW, from 2012.
Currently, there are three “built-in” firewalls in the system – Packet Filter (PF), IP Firewall (IPFW), and IP Filter (IPF):
- pf: currently the de facto default option, ported to FreeBSD from OpenBSD
- ipfw: the “historical” firewall for FreeBSD, added back in the 90s
- ipf: an open-source firewall ported to many different systems, including OpenBSD and FreeBSD
Firewalls are generally categorized into several types: “inclusive” vs “exclusive,” and “stateful” vs “stateless”:
- inclusive/exclusive: default behavior – either allow traffic unless there is an explicit block rule (inclusive), or block all traffic unless there is an explicit allow rule (exclusive)
- stateful/stateless: whether the firewall tracks the state of connections. In stateful mode, return traffic for an established connection is allowed automatically; in stateless mode, the firewall doesn’t remember the state and checks every packet individually against the ruleset
Let’s try pf – I’ve played around with it, and it seems quite simple, has all the necessary features, and boasts a pleasant syntax.
To manage pf, there is a dedicated CLI utility pfctl; for logging requests, there is pflog; and to monitor what’s currently happening in pf, there’s the pftop utility.
Contents
Getting Started with Packet Filter (PF)
First, let’s create a basic configuration, verify how it works, and then look into the more interesting details.
Add pf_enable=yes and pflog_enable=yes to /etc/rc.conf to start pf and pflog during system boot:
root@setevoy-nas:/home/setevoy # sysrc pf_enable=yes pf_enable: NO -> yes root@setevoy-nas:/home/setevoy # sysrc pflog_enable=yes pflog_enable: NO -> yes
But don’t start it just yet, as pf will look for the default /etc/pf.conf file, which doesn’t exist yet.
Packet Filter Rules Syntax
Detailed information can be found in the OpenBSD, PF – Packet Filtering documentation. For now, let’s quickly look at the basics:
action [direction] [log] [quick] [on interface] [af] [proto protocol]
[from src_addr [port src_port]] [to dst_addr [port dst_port]]
[flags tcp_flags] [state]
Where:
action: pass or blockdirection: in or outlog: whether to record the event in a log filequick: a very useful feature – whether to stop rule processing if a packet matches this rule, or continue checking the rest of the ruleset (example below)interface: the name of the interface or group the rule applies to (if omitted, it applies to all interfaces)protocol: TCP, UDP, or another from/etc/protocolssrc_addranddst_addr: can be a table or a macro (more on those later), a fully qualified domain name, an interface/group name, or anysrc_portanddst_port: similarly – ports, number(s), or names from the/etc/servicesfiletcp_flags: TCP flags that must be present in the packet header for the rule to applystate:no state/keep state: whether to track the connection state in the state tablemodulate state: settings for Initial Sequence Numberssynproxy state: handles the TCP handshake directly onpf(SYN/SYN-ACK) before passing it to the service (see TCP/IP: OSI and TCP/IP models, TCP packets, Linux sockets, and ports)
Important: pf operates on the “last match wins” principle – the firewall checks all rules from first to last, and the final rule that matches the packet is the decisive one.
In ipfw, by contrast, rule numbers matter, and the first matching rule triggers (“first match wins”). ipf (ipfilter) uses the same “first match wins” model.
Below is an example of how rule order affects access.
The system comes with a set of examples out of the box:
root@setevoy-nas:/home/setevoy # ls -l /usr/share/examples/pf/ total 44 -r--r--r-- 1 root wheel 1233 Jun 6 2025 ackpri -r--r--r-- 1 root wheel 925 Jun 6 2025 faq-example1 -r--r--r-- 1 root wheel 3129 Jun 6 2025 faq-example2 -r--r--r-- 1 root wheel 4711 Jun 6 2025 faq-example3 -r--r--r-- 1 root wheel 1062 Jun 6 2025 pf.conf -r--r--r-- 1 root wheel 848 Jun 6 2025 queue1 -r--r--r-- 1 root wheel 1110 Jun 6 2025 queue2 -r--r--r-- 1 root wheel 480 Jun 6 2025 queue3 -r--r--r-- 1 root wheel 900 Jun 6 2025 queue4 -r--r--r-- 1 root wheel 256 Jun 6 2025 spamd
Let’s add a simple rule for testing – block all connections except SSH from my work laptop.
Find the laptop’s IP:
[setevoy@setevoy-work ~] $ ip a s wlan0
5: wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
...
inet 192.168.0.165/24 brd 192.168.0.255 scope global dynamic noprefixroute wlan0
...
inet 192.168.0.164/24 brd 192.168.0.255 scope global secondary dynamic noprefixroute wlan0
...
Add rules to /etc/pf.conf:
# skip loopback traffic
set skip on lo
# default deny
block all
# allow SSH only from specific hosts
pass in proto tcp from { 192.168.0.165, 192.168.0.164 } to any port 22 keep state
# allow all outgoing traffic
pass out all keep state
Here we:
- ignore traffic on the loopback interface
- block everything by default
- allow incoming traffic on port 22 (SSH) from two addresses – 192.168.0.165 and 192.168.0.164
- allow all outgoing traffic
Instead of port 22, you can specify the name “ssh” – pf will then check the list of names and ports in the /etc/services file:
$ cat /etc/services | grep ssh ssh 22/tcp ssh 22/udp
To check the syntax of /etc/pf.conf without applying the rules, run pfctl -vnf:
-v: verbose (you can use twovs for even more detail)-n: no apply, syntax check only-f: the filename to check
root@setevoy-nas:/home/setevoy # pfctl -vnf /etc/pf.conf
set skip on { lo }
block drop all
pass in inet proto tcp from 192.168.0.165 to any port = ssh flags S/SA keep state
pass in inet proto tcp from 192.168.0.164 to any port = ssh flags S/SA keep state
pass out all flags S/SA keep state
Note two interesting things here:
flags S/SA:pfapplies the rule only to the start of a TCP connection (SYN) to create a state; subsequent packets for this connection are handled via the state table, bypassing a full ruleset re-checkkeep state: this relates to stateful/stateless behavior –pftracks the connection in the state table, so if a packet belongs to an established connection, it’s allowed without re-checking all rules
Even though we didn’t explicitly set TCP flags in /etc/pf.conf, pf automatically adds flags S/SA to stateful TCP rules to correctly handle new connections.
Test Run and SSH
An important point: if you are working over SSH, starting pf will terminate your connection, and if there’s a mistake in the rules, you won’t be able to reconnect.
So, here is a “life hack”:
root@setevoy-nas:/home/setevoy # sleep 120; pfctl -d
This runs pf for 2 minutes and then disables it, allowing you to verify if SSH still works.
If you were connected via SSH, the connection will drop (since there was no SYN/SYN-ACK recorded when pf started); just reconnect via SSH.
If everything is fine, start the pf and pflog services properly:
root@setevoy-nas:/home/setevoy # service pflog start Starting pflog. root@setevoy-nas:/home/setevoy # service pf start Enabling pf
The golden rule when configuring pf over SSH: do not use start/restart. The connection will break.
Instead, if you need to update the config, use pfctl -n for syntax checking first, followed by pfctl -f /etc/pf.conf or service pf reload.
Let’s use nmap from the work machine to see how our server looks “from the outside”:
[setevoy@setevoy-work ~] $ nmap -Pn -p 1-1024 192.168.0.181 ... Not shown: 1023 filtered tcp ports (no-response) PORT STATE SERVICE 22/tcp open ssh Nmap done: 1 IP address (1 host up) scanned in 7.02 seconds
As expected, only port 22 is visible, and only because nmap was run from a host allowed to use SSH.
Packet Filter Rules Ordering
A simple example of how rule order affects packet filtering, illustrating the “last match wins” approach.
For now, comment out block all, run pfctl -f /etc/pf.conf or service pf reload, and start a NetCat “server” listening on port 10000:
root@setevoy-nas:/home/setevoy # nc -v -l 10000
Now, from another machine, send some text:
[setevoy@setevoy-work ~] $ echo Test | nc 192.168.0.2 10000 192.168.0.2 10000 (ndmp) open
It reaches the “server” – everything works:
root@setevoy-nas:/home/setevoy # nc -v -l 10000 Connection from 192.168.0.165 58018 received! Test
Now, let’s test: place pass port 10000 **before** block all:
... # this will not work pass in proto tcp to any port 10000 keep state # default deny block all ...
Reload:
root@setevoy-nas:/home/setevoy # pfctl -vnf /etc/pf.conf && service pf reload
Start nc -v -l 10000 on the server again and try from the client with a timeout (-w). You’ll get a “Connection timed out” error:
[setevoy@setevoy-work ~] $ echo Test | nc -v -w 3 192.168.0.2 10000 192.168.0.2 10000 (ndmp): Connection timed out
Now move the pass rule after the block rule:
... # default deny block all # this will work pass in proto tcp to any port 10000 keep state ...
Run service pf reload and nc -v -l 10000 again. From the client:
[setevoy@setevoy-work ~] $ echo Test | nc -v -w 3 192.168.0.2 10000 192.168.0.2 10000 (ndmp) open
We have “Test” on the server again.
Packet Filter and quick in Rules
I mentioned quick in the Rules Syntax section; let’s see it in action.
Move pass 10000 back before the block rule:
... # allow nc demo pass in proto tcp to any port 10000 keep state block all ...
Reload and check with nc – you’ll get “Connection timed out” again.
Now, without changing the order, add the quick keyword to the pass in 10000 rule:
... # allow nc demo pass in quick proto tcp to any port 10000 keep state block all ...
Now the connection works because the request matches the rule with pass and quick, so pf stops processing further rules and allows the packet immediately.
Packet Filter logging with pflog
We already added pflog to /etc/rc.conf and ran service pflog start, but it isn’t recording anything yet.
To enable logging for a rule, add log after the action and (if present) the direction. For example:
... # default deny block log all ...
Check and apply the changes:
root@setevoy-nas:/home/setevoy # pfctl -nf /etc/pf.conf root@setevoy-nas:/home/setevoy # service pf reload
pflog creates a virtual interface named pflog0 for logging (the name can be changed using pflog_flags):
root@setevoy-nas:/home/setevoy # ifconfig pflog0
pflog0: flags=1000141<UP,RUNNING,PROMISC,LOWER_UP> metric 0 mtu 33152
options=0
groups: pflog
Try connecting from a “random” host that doesn’t have a pass in rule:
setevoy@test-nas-1:~ $ ssh 192.168.0.181
You can monitor logs live from the interface:
root@setevoy-nas:/home/setevoy # tcpdump -n -e -ttt -i pflog0 tcpdump: verbose output suppressed, use -v[v]... for full protocol decode listening on pflog0, link-type PFLOG (OpenBSD pflog file), snapshot length 262144 bytes 00:00:00.000000 rule 0/0(match): block in on em0: 192.168.0.113.44161 > 255.255.255.255.6667: UDP, length 172 00:00:02.301893 rule 0/0(match): block in on em0: 192.168.0.86.19569 > 192.168.0.181.22: Flags [S], seq 982711563, win 65535, options [mss 1460,nop,wscale 6,sackOK,TS val 370325371 ecr 0], length 0 00:00:01.026055 rule 0/0(match): block in on em0: 192.168.0.86.19569 > 192.168.0.181.22: Flags [S], seq 982711563, win 65535, options [mss 1460,nop,wscale 6,sackOK,TS val 370326401 ecr 0], length 0
By default, pflog writes to /var/log/pflog in pcap format rather than plain text:
root@setevoy-nas:/home/setevoy # file /var/log/pflog /var/log/pflog: pcap capture file, microsecond ts (little-endian) - version 2.4 (OpenBSD PFLOG, capture length 116)
So, read it using tcpdump with the -r flag (read from file) instead of -i:
root@setevoy-nas:/home/setevoy # tcpdump -n -e -ttt -r /var/log/pflog | head reading from file /var/log/pflog, link-type PFLOG (OpenBSD pflog file), snapshot length 116 00:00:00.000000 rule 0/0(match): block in on em0: 192.168.0.165.17500 > 255.255.255.255.17500: UDP, length 131 00:00:00.000100 rule 0/0(match): block in on em0: 192.168.0.165.17500 > 192.168.0.255.17500: UDP, length 131 ...
Logging for SSH only
Right now, we’re logging every blocked request, which can generate a lot of noise.
If you only want to log SSH events, remove log from block all and add a specific rule for port 22:
...
# default deny
block all
# log and block SSH from everyone else
block log proto tcp to any port 22
# allow SSH only from specific hosts
pass in proto tcp from { 192.168.0.165, 192.168.0.164 } to any port 22 keep state
...
Now everything is blocked by default without logging, while SSH is blocked and logged for everyone except the allowed hosts:
root@setevoy-nas:/home/setevoy # tcpdump -n -e -ttt -i pflog0 ... 00:00:00.000000 rule 1/0(match): block in on em0: 192.168.0.86.27268 > 192.168.0.2.22: [...] ...
If you want to log allowed connections too, just add log to the pass in 22 rule:
...
# allow SSH only from specific hosts
pass in log proto tcp from { 192.168.0.165, 192.168.0.164 } to any port 22 keep state
...
This will produce a “pass” entry in the log file:
... 00:00:00.000000 rule 2/0(match): pass in on em0: 192.168.0.165.60218 > 192.168.0.2.22: [...] ...
Packet Filter and Macros
Macros are essentially like variables in programming. More accurately, they act as constants – they are substituted when the configuration is loaded and do not change while pf is running.
For example, we can create lists of ports and IP addresses allowed for access:
# allowed tcp ports
allowed_tcp_ports = "{ 22, 10000 }"
# allowed ssh clients
ssh_clients = "{ 192.168.0.4, 192.168.0.165, 192.168.0.164 }"
Then use them in rules as $ssh_clients and $allowed_tcp_ports:
# allowed tcp ports
allowed_tcp_ports = "{ 22, 10000 }"
# allowed ssh clients
ssh_clients = "{ 192.168.0.4, 192.168.0.165, 192.168.0.164 }"
# skip loopback traffic
set skip on lo
# default deny
block all
# restrict ssh source addresses
pass in proto tcp from $ssh_clients to any port $allowed_tcp_ports keep state
# allow all outgoing traffic
pass out all keep state
Now, if you need to add a port or an address, you don’t have to hunt through the whole file; just change the value where the macro is declared.
Packet Filter and Tables
The idea behind Tables is similar to macros – we create a “variable” containing a set of values (IP addresses or networks) for use in rules.
However, while macros simplify configuration file management, tables are designed for large lists because searching through them is extremely fast.
Furthermore, tables can be modified while pf is running without needing a service pf reload.
Move the address list to a table named ssh_clients:
...
# ssh allowed clients
#ssh_clients = "{ 192.168.0.4, 192.168.0.165, 192.168.0.164 }"
table <ssh_clients> { 192.168.0.4, 192.168.0.165, 192.168.0.164 }
...
Now use it in the rules – but instead of $ssh_clients, specify it as <ssh_clients>:
# macros
allowed_tcp_ports = "{ 22, 10000 }"
# ssh allowed clients
#ssh_clients = "{ 192.168.0.4, 192.168.0.165, 192.168.0.164 }"
table <ssh_clients> { 192.168.0.4, 192.168.0.165, 192.168.0.164 }
set skip on lo
# allow nc demo
pass in quick proto tcp to any port 10000 keep state
block all
# allow ssh only from specific hosts
pass in proto tcp from <ssh_clients> to any port 22 keep state
...
Check:
root@setevoy-nas:/home/setevoy # pfctl -vnf /etc/pf.conf
allowed_tcp_ports = "{ 22, 10000 }"
table <ssh_clients> { 192.168.0.4 192.168.0.165 192.168.0.164 }
...
Reload, and the connection works.
You can also use negative values with ! to exclude specific addresses.
For example, allow SSH from the entire 192.168.0.0/24 network except for 192.168.0.86:
...
table <ssh_clients> { 192.168.0.0/24, !192.168.0.86 }
...
Access from the work laptop remains, but host 192.168.0.86 will be blocked:
... 00:00:01.012829 rule 1/0(match): block in on em0: 192.168.0.86.50451 > 192.168.0.2.22: [...] ...
Tables in separate files
For large lists, you can move them to separate files and include them in /etc/pf.conf. For instance, create /etc/pf_clients.conf:
root@setevoy-nas:/home/setevoy # cat /etc/pf_clients.conf 192.168.0.0/24 !192.168.0.86
In /etc/pf.conf, use persist file to load the table:
... # ssh allowed clients table <ssh_clients> persist file "/etc/pf_clients.conf" ... # allow ssh only from specific hosts pass in proto tcp from <ssh_clients> to any port 22 keep state ...
pfctl and Table Modifications
Show the table contents:
root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T show 192.168.0.0/24 !192.168.0.86
To remove !192.168.0.86:
root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T delete !192.168.0.86 1/1 addresses deleted.
Verify:
root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T show 192.168.0.0/24
Now SSH from 192.168.0.86 works.
Adding an address is similar – just use add instead of delete:
root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T add !192.168.0.100 1/1 addresses added. root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T show 192.168.0.0/24 !192.168.0.100
You can even flush a table manually – existing SSH sessions will stay active, but new connections will fail:
root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T flush 2 addresses deleted. root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T show root@setevoy-nas:/home/setevoy #
Check:
[setevoy@setevoy-work ~] $ ssh -o ConnectTimeout=3 192.168.0.2 ssh: connect to host 192.168.0.2 port 22: Connection timed out
Persisting pfctl Table Changes
Important: changes made with pfctl are only in memory and will vanish after a reload or restart.
To save them, edit /etc/pf.conf (or /etc/pf_clients.conf if using an external file).
For example, /etc/pf_clients.conf currently has:
root@setevoy-nas:/home/setevoy # cat /etc/pf_clients.conf 192.168.0.0/24 !192.168.0.86
Same as in the pf runtime:
root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T show 192.168.0.0/24 !192.168.0.86
Delete !192.168.0.86 in runtime:
root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T delete !192.168.0.86 1/1 addresses deleted.
To restore the rules from /etc/pf_clients.conf, run service pf reload or pfctl replace:
root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T replace -f /etc/pf_clients.conf 1 addresses added.
!192.168.0.86 is back.
To do the opposite – save memory changes to the file – redirect pfctl show to the file:
root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T delete !192.168.0.86 1/1 addresses deleted. root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T show > /etc/pf_clients.conf root@setevoy-nas:/home/setevoy # cat /etc/pf_clients.conf 192.168.0.0/24
Packet Filter and Anchors
Anchors let you define rules in separate files and attach them to the main /etc/pf.conf. This is similar to using external files for tables, but anchors contain full rulesets.
Furthermore, anchors can be dynamically loaded and modified using pfctl while pf is running.
Create a named anchor:
... block all # allow ssh only from specific hosts pass in proto tcp from <ssh_clients> to any port 22 keep state # new dynamic anchor here anchor "ssh" # allow all outgoing traffic pass out all keep state
This is a dynamic anchor. Since no file is specified, rules added to it will be lost on service pf restart but will survive a service pf reload.
Add a rule “on the fly”:
root@setevoy-nas:/home/setevoy # echo 'pass in proto tcp from <ssh_clients> to any port 22 keep state' | pfctl -a ssh -f -
Verify:
root@setevoy-nas:/home/setevoy # pfctl -a ssh -s rules pass in proto tcp from <ssh_clients> to any port = ssh flags S/SA keep state
Alternatively, create a file and run pfctl -a ssh -f /path/to/anchor.conf.
Another option is to populate anchors during reload/restart by specifying the file directly in /etc/pf.conf.
For example, add a new rule:
root@setevoy-nas:/home/setevoy # cat /etc/pf.anchors/ssh pass in proto tcp from 10.0.0.100 to any port 22 keep state
Describe its load in /etc/pf.conf:
... anchor "ssh" load anchor "ssh" from "/etc/pf.anchors/ssh" # allow all outgoing traffic pass out all keep state
Verify the config:
root@setevoy-nas:/home/setevoy # pfctl -vnf /etc/pf.conf ... pass out all flags S/SA keep state Loading anchor ssh from /etc/pf.anchors/ssh pass in inet proto tcp from 10.0.0.100 to any port = ssh flags S/SA keep state
Reload:
root@setevoy-nas:/home/setevoy # service pf reload Reloading pf rules.
Check the rules in anchor "ssh" again:
root@setevoy-nas:/home/setevoy # pfctl -a ssh -s rules pass in inet proto tcp from 10.0.0.100 to any port = ssh flags S/SA keep state
That’s about it for now, though pf has many more capabilities – check the links below.