pf.conf: Writing Rules That Survive a Power Outage

Published on

FreeBSD pf Firewall Networking

The Rule You Actually Need to Remember

pf evaluates rules top to bottom, and the last matching rule wins — unless a rule uses quick, in which case evaluation stops at that rule. That single sentence is most of what makes pf rulesets behave the way they do; if you internalise it, the rest of pf.conf is much smaller than it looks.

This post is a tour of the ruleset I run on the FreeBSD router at the edge of my homelab. Nothing here is novel — it's all in pf.conf(5) — but having a real, annotated example next to the manual page is what I wish I'd had when I started.

The Sections of a pf.conf

pf.conf must appear in this order. Mixing the order produces confusing errors:

  1. Macros — variable definitions
  2. Tables — IP address sets, queryable at runtime
  3. Optionsset directives that change pf behaviour
  4. Traffic normalizationscrub
  5. Queueing — ALTQ, optional
  6. Translationnat, rdr, binat
  7. Filterblock and pass

Macros and Tables — Stay DRY

# /etc/pf.conf

# --- macros ---
ext_if    = "igc0"
lan_if    = "igc1"
srv_if    = "igc2"
iot_if    = "igc3"
int_ifs   = "{ " $lan_if " " $srv_if " " $iot_if " }"

lan_net   = "10.10.10.0/24"
srv_net   = "10.10.20.0/24"
iot_net   = "10.10.30.0/24"
rfc1918   = "{ 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 }"

icmp_ok   = "{ echoreq unreach time-exceeded }"
tcp_svc   = "{ ssh http https }"

# --- tables ---
table <bogons>     persist file "/etc/pf.bogons"     # martian/bogon ranges
table <bruteforce> persist                           # populated by overload
table <blocklist>  persist file "/etc/pf.blocklist"  # known bad IPs

Options — Sensible Defaults

# --- options ---
set skip on lo0
set block-policy drop
set state-policy if-bound
set loginterface $ext_if

# Traffic normalization: reassemble fragments, randomize TCP IDs
scrub in on $ext_if all fragment reassemble random-id

block-policy drop drops packets silently rather than sending TCP RSTs. state-policy if-bound ties state entries to specific interfaces, which makes anti-spoofing and asymmetric-routing bugs much louder instead of silently passing.

NAT and Redirects

# --- translation ---
# Outbound NAT for everything in RFC1918
nat on $ext_if inet from $rfc1918 to any -> ($ext_if)

# Optional: redirect inbound HTTPS to a server on the trusted LAN
# rdr on $ext_if inet proto tcp from any to ($ext_if) port 443 \
#   -> 10.10.20.20 port 443

Note ($ext_if) in parentheses: that resolves the address at packet time, not at load time. On a DHCP WAN, this means pf doesn't have to be reloaded when the WAN IP changes.

Default Deny — Then Add Trust

# --- filter ---
# Default deny everywhere, log on the WAN
block in  log on $ext_if all
block in  on $int_ifs all
block out on $ext_if all
block return        # default for all "block" without modifier

# Drop bogons and known-bad immediately on the WAN
block in quick on $ext_if from { <bogons> <blocklist> } to any
block in quick on $ext_if from any to { <bogons> }

# Anti-spoofing: a packet arriving on $ext_if claiming a LAN source is bogus
antispoof quick for { $lan_if $srv_if $iot_if }

The antispoof macro expands to a small set of rules that drop packets arriving on the wrong interface for their claimed source.

Outbound — Trusted LANs Reach the Internet

# LAN: trusted, can reach anywhere
pass in  on $lan_if from $lan_net to any keep state
pass out on $ext_if from $lan_net to any keep state

# Servers: can reach anywhere, plus reach trusted LAN
pass in  on $srv_if from $srv_net to any keep state
pass out on $ext_if from $srv_net to any keep state

# IoT: can reach the internet ONLY. Cannot talk to LAN or servers.
pass in  on $iot_if proto { tcp udp } from $iot_net to !$rfc1918 keep state
pass out on $ext_if from $iot_net to any keep state

That !$rfc1918 is the entire reason IoT lives on its own NIC: an Internet-connected camera or smart bulb cannot, by rule, talk to anything on another LAN. The router itself answers DHCP and DNS for them; nothing else does.

Inbound — Just Enough

# SSH only from the trusted LAN, with brute-force tarpitting
pass in on $lan_if proto tcp from $lan_net to ($lan_if) port ssh \
  flags S/SA keep state \
  (max-src-conn 5, max-src-conn-rate 5/60, \
   overload <bruteforce> flush global)

# DNS and DHCP — answer requests on every internal interface
pass in on $int_ifs proto { tcp udp } from any to (self) port domain keep state
pass in on $int_ifs proto udp        from any to (self) port { 67 68 } keep state

# NTP
pass in on $int_ifs proto udp from any to (self) port ntp keep state

# WireGuard listener (if used)
pass in on $ext_if proto udp from any to ($ext_if) port 51820 keep state

The SSH rule is more interesting than it looks. overload <bruteforce> moves any source that exceeds the rate limit into the bruteforce table; a separate block quick from <bruteforce> rule will then drop them, no matter what they try. flush global kills any open states they already have.

ICMP — Allow, but Allow on Purpose

# Allow useful ICMP and traceroute return paths
pass inet  proto icmp  all icmp-type  $icmp_ok keep state
pass inet6 proto icmp6 all icmp6-type $icmp_ok keep state

Blanket-blocking ICMP feels secure but breaks Path MTU Discovery and traceroute, which makes future debugging harder. Allow specific types and trust state.

Logging — pflog Is a Real Interface

Anything you tag with log shows up on the pflog0 interface. You can tcpdump it like any other interface:

$ tcpdump -n -e -ttt -i pflog0
$ tcpdump -n -e -ttt -i pflog0 'host 10.10.10.42'

Pair this with a small log shipper to feed pf decisions into the same monitoring stack as everything else. (See the Prometheus and Grafana setup.)

Loading Safely

Validate before you load. Always.

# Parse-check only
$ pfctl -nf /etc/pf.conf

# Load
$ pfctl -f /etc/pf.conf

# Inspect
$ pfctl -s rules
$ pfctl -s nat
$ pfctl -s states | head
$ pfctl -t bruteforce -T show

For changes you're nervous about, use at(1) or shutdown -r +5 as a dead-man's switch: schedule a reboot to a known-good boot environment, then load your new ruleset. If you lose the connection, the box reboots back to safety on its own.

Common Pitfalls

The Whole File, in One Place

Everything above is in pf.conf(5), but for completeness, the file as one block:

# /etc/pf.conf

# --- macros ---
ext_if    = "igc0"
lan_if    = "igc1"
srv_if    = "igc2"
iot_if    = "igc3"
int_ifs   = "{ " $lan_if " " $srv_if " " $iot_if " }"

lan_net   = "10.10.10.0/24"
srv_net   = "10.10.20.0/24"
iot_net   = "10.10.30.0/24"
rfc1918   = "{ 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 }"

icmp_ok   = "{ echoreq unreach time-exceeded }"

# --- tables ---
table <bogons>     persist file "/etc/pf.bogons"
table <bruteforce> persist
table <blocklist>  persist file "/etc/pf.blocklist"

# --- options ---
set skip on lo0
set block-policy drop
set state-policy if-bound
set loginterface $ext_if

scrub in on $ext_if all fragment reassemble random-id

# --- translation ---
nat on $ext_if inet from $rfc1918 to any -> ($ext_if)

# --- filter ---
block in  log on $ext_if all
block in  on $int_ifs all
block out on $ext_if all

block in quick on $ext_if from { <bogons> <blocklist> <bruteforce> } to any
block in quick on $ext_if from any to <bogons>
antispoof quick for { $lan_if $srv_if $iot_if }

pass in  on $lan_if from $lan_net to any keep state
pass out on $ext_if from $lan_net to any keep state

pass in  on $srv_if from $srv_net to any keep state
pass out on $ext_if from $srv_net to any keep state

pass in  on $iot_if proto { tcp udp } from $iot_net to !$rfc1918 keep state
pass out on $ext_if from $iot_net to any keep state

pass in on $lan_if proto tcp from $lan_net to ($lan_if) port ssh \
  flags S/SA keep state \
  (max-src-conn 5, max-src-conn-rate 5/60, \
   overload <bruteforce> flush global)

pass in on $int_ifs proto { tcp udp } from any to (self) port domain keep state
pass in on $int_ifs proto udp        from any to (self) port { 67 68 } keep state
pass in on $int_ifs proto udp from any to (self) port ntp keep state

pass inet  proto icmp  all icmp-type  $icmp_ok keep state
pass inet6 proto icmp6 all icmp6-type $icmp_ok keep state

Where to Go Next

If you've spotted something I should tighten, please tell me. Firewalls get better with every honest pair of eyes.

$ subscribe --to newsletter

FreeBSD, pf, and SRE notes — straight to your inbox. No spam, just signal.

Powered by Buttondown. Unsubscribe anytime. Or grab the RSS feed.

Related Posts

← Back to Blog