pf is the BSD-family packet filter — same name on FreeBSD and OpenBSD, same syntax (mostly), the cleanest firewall language on any OS. This guide writes a default-deny ruleset, allows SSH and HTTPS, drops scanner noise, and reloads safely.
Prerequisites
- FreeBSD 13/14 or OpenBSD 7.x with root access.
- A second SSH session held open while editing rules.
Step 1: Enable pf
FreeBSD — add to /etc/rc.conf:
pf_enable="YES"
pflog_enable="YES"
pf_rules="/etc/pf.conf"
OpenBSD already enables pf by default; just make sure pf=YES in /etc/rc.conf.local.
Step 2: Write /etc/pf.conf
The minimal sane production ruleset:
# /etc/pf.conf
ext_if = "vtnet0" # change to your interface
# Tables for source-restricted access
table <admin> const { 203.0.113.0/24 } # office subnet
table <bruteforce> persist
# Defaults
set skip on lo
set block-policy drop
set loginterface $ext_if
# Antispoof, scrub
antispoof quick for $ext_if
scrub in all
# Default-deny inbound
block in log all
pass out all keep state
# Allow ICMP echo (ping)
pass in inet proto icmp icmp-type echoreq
# SSH — admin only, rate-limited
pass in on $ext_if proto tcp from <admin> to ($ext_if) port 22 \
flags S/SA keep state \
(max-src-conn 5, max-src-conn-rate 5/30, overload <bruteforce> flush global)
# Public HTTP / HTTPS
pass in on $ext_if proto tcp to ($ext_if) port { 80 443 } \
flags S/SA keep state
# Drop anything else from the bruteforce table for an hour
block quick from <bruteforce>
Two patterns to learn here:
overload <table>— auto-add an offender to a table when they exceed a rate.quick— short-circuit the rule list (pf is normally "last match wins").
Step 3: Test syntax and load
sudo pfctl -nf /etc/pf.conf # parse-only — never skip
sudo pfctl -f /etc/pf.conf # load
sudo service pf reload # FreeBSD
On OpenBSD use doas instead of sudo.
Step 4: Inspect state and logs
sudo pfctl -s rules # active rules
sudo pfctl -s states # connection state table
sudo pfctl -s info # counters
sudo pfctl -t bruteforce -T show # who's been banned
# pflog — pcap-format ring buffer
sudo tcpdump -n -i pflog0
Step 5: Add a port forward / NAT
For a host doing NAT for a private network:
int_if = "vtnet1"
nat on $ext_if inet from ($int_if:network) to any -> ($ext_if)
Port-forward to an internal host:
rdr pass on $ext_if proto tcp from any to ($ext_if) port 8443 \
-> 10.0.10.50 port 443
Step 6: Clear bans and watch live
sudo pfctl -t bruteforce -T expire 3600 # expire entries older than 1h
sudo pfctl -t bruteforce -T flush # nuke the table
# Live state addition
watch -n 2 'sudo pfctl -s info | head -20'
Verify
From outside the server:
nmap -Pn -p 22,80,443 your.host.sa
# Expect: 22 filtered (unless from <admin>), 80 + 443 open
Conclusion
pf's grammar is the most readable of any firewall — once you internalise block/pass, quick, tables, and overload, you can write a production ruleset on one page. Keep it in git, deploy via Ansible, never edit on a running box without pfctl -nf first.
Next steps
- Install the OS first via Install FreeBSD 14.
- Install software via pkg + Ports.
- For the Linux counterpart see UFW for specific ports.
Comments
0 total · 0 threads