FreeBSD: Home NAS, part 2 – introduction to Packet Filter (PF) firewall

By | 12/24/2025
 

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.

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 block
  • direction: in or out
  • log: whether to record the event in a log file
  • quick: 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/protocols
  • src_addr and dst_addr: can be a table or a macro (more on those later), a fully qualified domain name, an interface/group name, or any
  • src_port and dst_port: similarly – ports, number(s), or names from the /etc/services file
  • tcp_flags: TCP flags that must be present in the packet header for the rule to apply
  • state:

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 two vs 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: pf applies 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-check
  • keep state: this relates to stateful/stateless behavior – pf tracks 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.

Useful Links