~tjp $_

← notes

PF firewall

Configuration

Filtering

pass|block
	[in|out]
	[log]
	[quick]
	[on <interface>]
	[proto <protocol>]
	[from <src-addr> [port <src-port>]]
	[to <dst-addr> [port <dst-port>]]
	[flags <tcp-flags>]
	[no|keep|modulate|synproxy state]
  • pass/block the action to take
  • in/out direction of the packet on the interface
  • log enables logging for the rule to the pflog service
  • quick if a match and we process this rule, skip checking later rules
  • on <interface> 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/dst addr can be a table or macro, fully qualified domain name, interface/group name, or any
  • src/dst port ports, numbers, or names from /etc/services file (or tables, macros of them)
  • tcp flags TCP flags that must be present in the packet header for the rule to apply
  • state "keep" to track the connection state in the state table

pf operates on a "last match wins" principle, so the last matching rule is the decisive one ("quick" can override precedence and make it bail out early though)

# skip loopback traffic
set skip on lo

# default deny everything
block all

# allow all outgoing traffic
pass out all keep state

# allow SSH only from a specific host
pass in proto tcp from { 192.168.0.14, 192.168.0.17 } to any port 22 keep state

The keep state in these examples activates connection tracking and causes the rules to only apply to packets that would establish new connections. Thus the "allow all outgoing traffic" rule allows inbound traffic on connections that were established in the outbound direction (so this server can fetch a web page, not just send the request).

Macros

Essentially constant variables.

allowed_ports = "{ 22, 10000 }"
allowed_ssh_clients = "{ 192.168.0.14, 192.168.0.17 }"
# ...
pass in proto tcp from $allowed_ssh_clients to any port $allowed_ports keep state

Tables

Similar to macros but designed for large lists. They can be loaded from and saved to files, and updated while pf is running.

#allowed_ssh_clients = "{ 192.168.0.14, 192.168.0.17 }"
table <allowed_ssh_clients> { 192.168.0.14, 192.168.0.17 }
# ...
pass in proto tcp from <allowed_ssh_clients> to any port 22 keep state

Tables also support negative values with !.

table <ssh_clients> { 192.168.0.0/24, !192.168.0.15 }

Store a table in a file by putting one item from the table on each line, then load it in pf.conf like this:

table <ssh_clients> persist file "/etc/ssh_clients.conf"

Modify a table while pf is running with pfctl:

pfctl -t ssh_clients -T delete '!192.168.0.15'
pfctl -t ssh_clients -T add '192.168.0.23'

But this does not update the file, only the running pf's memory. To flush the file use the show command to overwrite it.

pfctl -t ssh_clients -T show >/etc/ssh_clients.conf

To override what pf has in memory by reloading the file, use replace:

pfctl -t ssh_clients -T replace -f /etc/ssh_clients.conf

Anchors

Anchors allow you to load whole rule sets (not just tables) from separate files. Like tables, they can be manipulated in memory through pfctl as well.

# in pf.conf

# define the anchor - at this point it is a "dynamic anchor"
anchor "ssh"

# connect it to a file
load anchor "ssh" from "/etc/pf.conf.d/ssh.conf"

After changing an anchor file, you'll need to reload rules as if you had changed pf.conf.

pfctl -f /etc/pf.conf
# or
service pf reload

Packet Modification

Note: FreeBSD 15.0's pf has been updated to match OpenBSD's implementation, making configurations exchangeable.

Packet modification options (nat-to, rdr-to, binat-to) can be added to pass rules to translate addresses and ports. These create stateful connections automatically.

NAT (Network Address Translation)

NAT translates internal (private) addresses to external (public) addresses for outbound traffic.

# translate all outgoing traffic from 192.168.1.0/24 to the interface's IP
pass out on em0 inet from 192.168.1.0/24 to any nat-to (em0)

# translate to a specific address
pass out on em0 from 10.0.0.0/8 to any nat-to 203.0.113.5

Use (interface) notation instead of a fixed IP - pf will automatically update when the interface IP changes (useful for DHCP).

Port Forwarding (rdr-to)

Port forwarding redirects incoming traffic to internal servers.

# forward incoming HTTP to an internal web server
pass in on em0 proto tcp from any to any port 80 rdr-to 192.168.1.20

# forward SSH to different internal hosts based on source
pass in on em0 proto tcp from 203.0.113.14 to any port 22 rdr-to 192.168.1.20
pass in on em0 proto tcp from 203.0.113.89 to any port 22 rdr-to 192.168.1.22

# redirect to a different port
pass in on em0 proto tcp from any to any port 8080 rdr-to 192.168.1.20 port 80

# forward port ranges (5000 maps to 7000, 5001 to 7001, etc)
pass in on em0 proto tcp from any to any port 5000:5500 rdr-to 192.168.1.20 port 7000:*

Reflection (Internal Access to Forwarded Ports)

By default, internal hosts cannot access port-forwarded services using the external IP. Fix this with NAT on the internal interface:

int_if = "em1"
ext_if = "em0"
server = "192.168.1.20"

# port forward from external interface
pass in on $ext_if proto tcp to any port 80 rdr-to $server

# allow internal hosts to access via external IP (reflection)
pass in on $int_if proto tcp from 192.168.1.0/24 to ($ext_if) port 80 rdr-to $server
pass out on $int_if proto tcp to $server port 80 received-on $int_if nat-to ($int_if)

Bidirectional Mapping (binat-to)

binat-to creates both inbound and outbound mappings - useful for one-to-one IP mapping.

# map internal 10.1.2.120 to external 203.0.113.17 in both directions
pass on em0 from 10.1.2.120 to any binat-to 203.0.113.17

This is equivalent to both a nat-to and rdr-to rule.

Load Balancing

Distribute incoming connections across multiple servers:

# round-robin web requests to three servers (with weights)
pass in on em0 proto tcp to any port 80 \
  rdr-to { 192.168.1.10 weight 2, 192.168.1.11 weight 1, 192.168.1.12 weight 3 } \
  round-robin

# use source-hash to ensure same client always goes to same server
pass in on em0 proto tcp to any port 443 \
  rdr-to { 192.168.1.10, 192.168.1.11, 192.168.1.12 } source-hash