My Ultimate FreeBSD Packet Filter (pf.conf)
Hello, I'm Munou.
This time, I'd like to introduce the packet filter settings I've been tinkering with recently.
Current Settings
It looks like this.
In the case of Packet Filter:
Translation rules like NAT and rdr apply to the first match.
Filter rules like pass and block apply to the last match.
This is how the specification works.
In this case, for readability, I've set all filter rules to quick so that the rule matched at that point is applied.
I'll leave comments where possible.
set skip on lo
set block-policy drop
set optimization conservative
set state-policy if-bound
set ruleset-optimization basic
wanint="vtnet0"
# WireGuard Configuration
wg_ifs = "{ wg0, wg1 }"
wg_net = "10.1.0.0/24"
wg_ports="{51820}"
table <wg_clients> const { 10.1.0.2, 10.1.0.4, 10.1.0.22 }
#### -- Scrub Rules -- ####
scrub in on $wanint all random-id max-mss 1360
scrub out on $wanint all random-id max-mss 1360
scrub in all
scrub out all
#### -- NAT Rules -- ####
# WireGuard Clients NAT
nat on $wanint inet from <wg_clients> to any -> ($wanint)
block all
#### -- Fail2Ban Rules -- ####
anchor "f2b/*"
pass out quick keep state
#### -- UDP Rules -- ####
pass in quick on $wg_ifs from $wg_net to any keep state
pass in quick on $wanint proto udp from any to ($wanint) port $wg_ports keep state
# HTTP/3 Protocol
pass in quick on $wanint proto udp to ($wanint) port 443 keep state
#### -- TCP Rules -- ####
# Mail Protocols
pass in quick proto tcp from any to ($wanint) port {25, 465, 993, 995} synproxy state
# HTTP
pass in quick on $wanint proto tcp to ($wanint) port {80, 443} modulate state
set block-policy drop
Any packets that don't match a rule are dropped.
In the case of return, for TCP, it returns RST to notify the other party and close the connection.
For UDP, since there is no TCP connection to begin with, it just returns ICMP port unreachable.
set optimization conservative
This is an optimization rule described as a conservative setting in the documentation.
In the case of aggressive, connections are expired preferentially, but in an environment like WireGuard where you want to maintain a constant connection state, the connection would be cut just because no new packets have arrived.
Also, there's even a satellite setting, which sounds cool.
high-latency A high-latency environment (such as a satellite connec- tion). satellite Alias for high-latency.
set state-policy if-bound
It maintains and remembers the state to pass packets, preventing inconsistencies.
The result of pfctl -ss is the state. For example, in the following configuration:
Client ←→ wg ←→ Server
Since information about "which interface it was created on" is linked to the state, inconsistencies are prevented by only allowing communication that passes through the same interface.
In the case of normal floating:
Inbound: wg0
Outbound: vtnet0
Return: Either vtnet0 or wg0 is OK
However, in the case of state-policy if-bound:
Inbound: wg0
Outbound: vtnet0
Return: Must be wg0, otherwise it's NG
set ruleset-optimization basic
It handles rule optimization nicely.
From the official documentation:
remove duplicate rules
remove rules that are a subset of another rule
combine multiple rules into a table when advanta-geous
re-order the rules to improve evaluation perfor-mance
In Japanese:
Remove duplicate rules
Remove rules contained within other rules
Combine multiple rules into tables as needed
Reorder rules to improve evaluation performance
In other words, it cleans up even roughly written pf rules. By simply enabling this, it's a god-tier feature that organizes rules nicely even if you write pf rules in a way that's easy for you to read.
Variable Definitions
Specifying the WAN interface name
wanint="vtnet0"
Defined as the assigned IPv4 address and an explicit outbound IP
exsrv1 = "163.44.113.145"
Variable and table definitions for WireGuard
wg_ifs = "{ wg0, wg1 }"
wg_net = "10.1.0.0/24"
wg_ports="{51820}"
table <wg_clients> const { 10.1.0.2, 10.1.0.4, 10.1.0.22 }
Scrub Rules
In this case, the MSS value is specified for both incoming and outgoing packets from the WAN.
Regarding all random-id, it seems to be for preventing the OS itself from being identified from the packets, but as far as I've checked with sudo tcpdump -n -v -i $interface on GNU/Linux, it seems to be enabled by default.
04:15:18.538048 IP (tos 0x0, ttl 52, id 61686, offset 0, flags [DF], proto TCP (6), length 52)
04:15:18.538049 IP (tos 0x0, ttl 52, id 61687, offset 0, flags [DF], proto TCP (6), length 131)
04:15:18.538179 IP (tos 0x0, ttl 64, id 65430, offset 0, flags [DF], proto TCP (6), length 52)
04:15:18.538223 IP (tos 0x0, ttl 52, id 61688, offset 0, flags [DF], proto TCP (6), length 131)
Also, set in all and out all to scrub interfaces other than the WAN interface.
scrub in on $wanint all random-id max-mss 1360
scrub out on $wanint all random-id max-mss 1360
scrub in all
scrub out all
NAT Rules
Specific WireGuard clients exit through the NAT of the WireGuard server's WAN interface.
However, in this case, they only exit via IPv4. This is because the WireGuard server does not distribute IPv6 addresses, so only inet from is used.
nat on $wanint inet from <wg_clients> to any -> ($wanint)
Fail2Ban Rules
Lastly, here are the rules for Fail2Ban to add attacker source IPs to a pf table and block them. Packets from IPs added to the pf table are directed to Fail2Ban rules via anchor "f2b/*".
It is placed above the filter rules set to quick because it needs to match before them.
Since the default rule block all is just before this, any packets that don't match here will be blocked.
/usr/local/etc/fail2ban/action.d/pf.conf:
# Option: block
#
# The action you want pf to take.
# Probably, you want "block quick", but adjust as needed.
# If you want to log all blocked use "blog log quick"
block = block quick
Since this setting adds block quick Fail2Ban rules, it is placed higher up.
In other words, first, packets from IPs added to the pf table are directed to Fail2Ban rules via anchor "f2b/*", and then:
anchor "f2b/*"
As a filter rule, the very first rule allows outgoing packets.
pass out quick keep state
UDP Rules
Allow rules for the WireGuard server.
Allow 51820/udp and allow all virtual NICs on WireGuard in $wg_ifs.
Perhaps this should be stricter just in case?
pass in quick on $wg_ifs from $wg_net to any keep state
pass in quick on $wanint proto udp from any to ($wanint) port $wg_ports keep state
In this case, since state is already maintained, all packets coming through the WireGuard tunnel will be allowed. If this order were reversed, the pass in quick on $wg_ifs from $wg_net to any keep state rule would not work because it is based on the premise of being a WireGuard tunnel.
Allow 443/udp to enable HTTP/3.
# HTTP/3 Protocol
pass in quick on $wanint proto udp to ($wanint) port 443 keep state
TCP Rules
As anyone who has used nmap knows, TCP port scans are performed very quickly, so attackers will find them immediately just by changing the port. Since nmap scans take a long time for UDP ports, I've made it more secure by only allowing SSH access via the WireGuard tunnel. In other words, I think a configuration that effectively wraps TCP in UDP is the most secure.
Since Fail2Ban is installed, if there is an SSH attack, the source IP is added to the pf table and blocked. This is essentially like a honeypot.
For mail protocols, synproxy is enabled to prevent SYN Flood attacks.
Regarding HTTP in this case, the destination IP is specified with ($wanint) to dynamically match the IP address assigned to the WAN interface. inet is not explicitly specified in order to allow both IPv4 and IPv6.
Packets arriving via IPv6 will be dynamically matched against the IPv6 address assigned to the WAN interface because the destination IP is specified with ($wanint).
# Mail Protocols
pass in quick proto tcp from any to ($wanint) port {25, 465, 993, 995} synproxy state
# HTTP
pass in quick on $wanint proto tcp to ($wanint) port {80, 443} modulate state
Well, that turned out to be quite long...