AZiT

pf: default deny, named rules

The firewall policy, a safe way to load it, and logs that name the rule.

Why Policy, on the tour

This chapter installs the policy: everything is blocked and logged unless a rule allows it, and every rule has a name. A small helper adds that name to each log line, so the logs say allow_trusted_kodi instead of a rule number.

1. Logging helpers#

pflog2syslog reads pf's log interface, adds the rule's label to each line, and hands it to syslog. It rebuilds its rule-number-to-label map every time it starts.

/usr/local/sbin/pflog2syslogDownload
#!/bin/sh
# pf log packets -> syslog lines (tag "pf", facility local0), each ending in
# label=<rule label>. The rule number -> label map is rebuilt from the loaded
# ruleset at every start, so it always matches what pf is running.
MAP=/var/db/pf-labels
/sbin/pfctl -vvsr 2>/dev/null \
  | sed -n 's/^@\([0-9][0-9]*\) .* label "\([^"]*\)".*/\1 \2/p' > "$MAP"
/usr/sbin/tcpdump -l -n -e -ttt -i pflog0 2>/dev/null \
  | /usr/bin/awk -v map="$MAP" '
      BEGIN { while ((getline line < map) > 0) { split(line, f, " "); label[f[1]] = f[2] } }
      {
        n = ""
        if (match($0, /rule [0-9]+/)) n = substr($0, RSTART + 5, RLENGTH - 5)
        print $0 " label=" ((n in label) ? label[n] : "unknown")
        fflush()
      }' \
  | /usr/bin/logger -t pf -p local0.info
/etc/rc.d/pflog2syslogDownload
#!/bin/ksh
daemon="/usr/local/sbin/pflog2syslog"
. /etc/rc.d/rc.subr
pexp="/bin/sh ${daemon}"
rc_bg=YES
rc_reload=NO
# Stopping tcpdump ends the whole pipeline (logger and the script exit by
# themselves). Always succeed, so "rcctl restart" goes on to start again.
rc_stop() {
	pkill -f "^/usr/sbin/tcpdump -l -n -e -ttt -i pflog0" || true
}
rc_cmd $1
  • Lets rcctl start and stop the helper like any other service.

pf's lines go to their own file, rotated by size:

/etc/syslog.conf add to the endDownload
# pf: blocked and logged traffic as text
local0.info						/var/log/pf.log
/etc/newsyslog.conf add to the endDownload
/var/log/pf.log		root:wheel	640  14    10000 *     Z
laptop
scp pflog2syslog syslog.conf.append newsyslog.conf.append fw:/tmp/
scp rc.d/pflog2syslog fw:/tmp/rc.d-pflog2syslog     # two files share a name
firewall
doas install -o root -g bin -m 0555 /tmp/pflog2syslog /usr/local/sbin/pflog2syslog
doas install -o root -g wheel -m 0555 /tmp/rc.d-pflog2syslog /etc/rc.d/pflog2syslog
cat /tmp/syslog.conf.append | doas tee -a /etc/syslog.conf
cat /tmp/newsyslog.conf.append | doas tee -a /etc/newsyslog.conf
doas touch /var/log/pf.log && doas chmod 0640 /var/log/pf.log
doas rcctl restart syslogd
doas rcctl enable pflog2syslog

2. A safe way to load rules#

Every change goes through this script. It checks the syntax first, so a typo never reaches the running firewall, then installs the file, loads it and refreshes the label map.

/usr/local/sbin/pf-loadDownload
#!/bin/sh
# Usage: doas pf-load [file]     (default: /tmp/pf.conf)
# Checks a new pf.conf, installs and loads it, then restarts pflog2syslog so
# its rule number -> label map matches the new ruleset.
set -e
new=${1:-/tmp/pf.conf}
/sbin/pfctl -nf "$new"
install -o root -g wheel -m 0600 "$new" /etc/pf.conf
rm -f "$new"
/sbin/pfctl -f /etc/pf.conf
/usr/sbin/rcctl restart pflog2syslog
sleep 2
echo "pf: loaded, $(wc -l < /var/db/pf-labels | tr -d ' ') labelled rules in the map"
firewall
doas install -o root -g bin -m 0555 /tmp/pf-load /usr/local/sbin/pf-load

3. The policy#

For now, everything leaves directly through the WAN port. The kill switch comes in the next chapter.

/etc/pf.confDownload
Show the whole file, 107 lines
# /etc/pf.conf - fw.internal
# Default deny. Blocked traffic is logged, and so is every new connection
# allowed by a pass rule marked "log". Every rule has a label; pflog2syslog
# adds it to each log line as label=<name>, which Vector turns into rule.name.
# --- Interfaces ----
wan = "igc0"
admin = "igc1"
apmgmt = "igc2"
ps5 = "igc3"
media = "igc4"
servers = "igc5"
trusted = "vlan10"
iot = "vlan20"
guest = "vlan30"
internal = "{ igc1 igc2 igc3 igc4 igc5 vlan10 vlan20 vlan30 }"
# --- Tables ----
table <admin_hosts> const { 192.168.1.10 192.168.10.10 }
table <k8s_nodes> const { 192.168.5.10 192.168.5.20 192.168.5.30 192.168.5.40 192.168.5.50 }
table <k8s_api> const { 192.168.5.100 }
table <k8s_services> const { 192.168.5.200/29 192.168.5.208/29 192.168.5.216/30 192.168.5.220 }
table <unifi_host> const { 192.168.5.60 }
table <unifi_ap> const { 192.168.2.2 }
table <home_assistant> const { 192.168.5.140 }
table <kodi> const { 192.168.4.10 }
table <public_dns> const { 9.9.9.9 149.112.112.112 }
table <internal_nets> const { 192.168.1.0/24 192.168.2.0/24 192.168.3.0/24 192.168.4.0/24 \
    192.168.5.0/24 192.168.10.0/24 192.168.20.0/24 192.168.30.0/24 }
table <martians> const { 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 \
    172.16.0.0/12 192.0.0.0/24 192.0.2.0/24 192.168.0.0/16 \
    198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/3 }
# --- Options ----
set block-policy drop
set skip on lo
set loginterface $wan
# --- Normalisation and NAT ----
match in all scrub (no-df random-id)
match out on $wan inet from <internal_nets> to any nat-to ($wan)
# --- Default: block and log everything, no IPv6 ----
block log all label "drop_all"
block quick inet6 all label "drop_ipv6"
# --- DHCP (before the spoofing checks; not logged, dhcpd logs it) ----
pass in quick on $wan inet proto udp from any port bootps to any port bootpc label "allow_wan_dhcp"
pass in quick on $internal inet proto udp from any port bootpc to any port bootps label "allow_dhcp"
# --- Spoofing ----
block in log quick from urpf-failed label "block_spoofed_source"
block in log quick on $wan from <martians> label "block_wan_martians"
# --- Everything the firewall lets in may also leave (logged inbound) ----
pass out quick inet label "allow_out"
# --- Services on the firewall (not logged: very chatty) ----
pass in quick on $internal inet proto { tcp udp } to (self) port domain label "allow_dns_to_fw"
pass in quick on $internal inet proto udp to (self) port ntp label "allow_ntp_to_fw"
pass in quick on $internal inet proto icmp to (self) icmp-type echoreq label "allow_ping_to_fw"
# The PS5 and guests bypass the tunnel, so their DNS does too: a public resolver, straight out
pass in quick on { $ps5 $guest } inet proto { tcp udp } to <public_dns> port { domain 853 } label "allow_direct_dns"
block in log quick on $internal inet proto { tcp udp } to ! (self) port { domain 853 } label "block_external_dns"
# --- Admin access (logged) ----
pass in log quick on { $admin $trusted } inet proto tcp from <admin_hosts> to (self) port ssh label "allow_admin_ssh_fw"
pass in log quick on { $admin $trusted } inet proto tcp from <admin_hosts> to { <k8s_nodes> <unifi_host> <unifi_ap> <kodi> } port ssh label "allow_admin_ssh_hosts"
pass in log quick on { $admin $trusted } inet proto tcp from <admin_hosts> to <k8s_api> port 6443 label "allow_admin_k8s_api"
pass in log quick on { $admin $trusted } inet proto tcp from <admin_hosts> to <k8s_services> port https label "allow_admin_ingress"
pass in log quick on { $admin $trusted } inet proto tcp from <admin_hosts> to <unifi_host> port 11443 label "allow_admin_unifi_ui"
# WebRTC from the UniFi web UI: direct path from the admin laptop to the controller
pass in log quick on { $admin $trusted } inet proto { tcp udp } from <admin_hosts> to <unifi_host> label "allow_admin_unifi_webrtc"
pass in log quick on { $admin $trusted } inet proto icmp from <admin_hosts> to <internal_nets> icmp-type echoreq label "allow_admin_ping"
# --- Known noise: dropped without logging ----
# Broadcast and multicast discovery (NetBIOS, SSDP, ...)
block in quick on $internal inet to { 255.255.255.255 224.0.0.0/4 192.168.1.255 192.168.2.255 192.168.3.255 192.168.4.255 192.168.5.255 192.168.10.255 192.168.20.255 192.168.30.255 } label "drop_broadcast_multicast"
# Late TCP packets (FIN, ACK, RST+ACK) for connections pf no longer tracks.
# New connections (bare SYN) and tracked connections never reach this rule.
block in quick on $internal inet proto tcp from <internal_nets> flags A/A label "drop_late_tcp"
# WebRTC (UniFi UI) also tries the firewall's public address as a detour.
# Reject it immediately, so it gives up at once.
block return in quick on { $admin $trusted $servers } inet proto udp from { <admin_hosts> <unifi_host> } to ($wan) label "reject_webrtc_wan_detour"
# --- Nothing else may reach any of the firewall's own addresses ----
block in log quick on $internal to (self) label "block_to_fw"
# --- Between networks (logged) ----
pass in log quick on $trusted inet proto tcp from $trusted:network to <home_assistant> port 8123 label "allow_trusted_homeassistant"
pass in log quick on $trusted inet proto tcp from $trusted:network to <kodi> port { 18080 9090 } label "allow_trusted_kodi"
pass in log quick on $servers inet from <home_assistant> to $iot:network label "allow_homeassistant_iot"
pass in quick on $apmgmt inet proto tcp from <unifi_ap> to <unifi_host> port 8080 label "allow_ap_unifi_inform"
pass in log quick on $apmgmt inet proto udp from <unifi_ap> to <unifi_host> port 3478 label "allow_ap_unifi_stun"
pass in log quick on $apmgmt inet proto udp from <unifi_ap> to <unifi_host> port 5514 label "allow_ap_unifi_logs"
# The AC Lite checks its uplink by pinging 1.1.1.1 (not configurable in UniFi 10.x); not logged
pass in quick on $apmgmt inet proto icmp from <unifi_ap> to 1.1.1.1 icmp-type echoreq label "allow_ap_uplink_ping"
pass in log quick on $servers inet proto tcp from <unifi_host> to <unifi_ap> port ssh label "allow_unifi_ap_ssh"
# --- Internet access (logged; APMGMT gets none) ----
pass in log quick on $admin inet from $admin:network to ! <internal_nets> label "allow_admin_internet"
pass in log quick on $trusted inet from $trusted:network to ! <internal_nets> label "allow_trusted_internet"
pass in log quick on $ps5 inet from $ps5:network to ! <internal_nets> label "allow_ps5_internet"
pass in log quick on $media inet from $media:network to ! <internal_nets> label "allow_media_internet"
pass in log quick on $servers inet from $servers:network to ! <internal_nets> label "allow_servers_internet"
pass in log quick on $iot inet from $iot:network to ! <internal_nets> label "allow_iot_internet"
pass in log quick on $guest inet from $guest:network to ! <internal_nets> label "allow_guest_internet"
  • Read the tables at the top first. <admin_hosts> must contain your laptop's address, or the next load locks you out of SSH.
  • quick stops at the first rule that matches, so the file reads top to bottom like a checklist.
  • to ! <internal_nets> is how "internet only" is written: anywhere except the home networks.

Before loading, start a safety net: it switches pf off by itself after five minutes, in case the new rules lock you out of SSH. (At the console, pfctl -d as root does the same.)

laptop
scp pf.conf fw:/tmp/pf.conf
firewall
doas sh -c 'sleep 300 && pfctl -d' &
doas pf-load

pf-load ends with pf: loaded, …. Open a new SSH session from the laptop; if it works, cancel the safety net:

laptop
ssh fw 'echo still in'
firewall
doas pkill -f 'sleep 300'

The firewall now does NAT, so switch the laptop to DHCP. It gets its fixed address, 192.168.1.10, with 192.168.1.1 as router and DNS server.

Check

laptop
route -n get default | grep gateway
curl -sI https://www.openbsd.org | head -1

gateway: 192.168.1.1 and HTTP/1.1 200 OK.

Check

firewall
doas pfctl -s info | head -2
tail -f /var/log/pf.log

Status: Enabled. Browse a web page from the laptop and lines appear ending in label=allow_admin_internet; try nc -z 192.168.1.1 80 from the laptop and you see label=block_to_fw.

Check

firewall
doas pfctl -s labels | sort -k 3 -nr | head

How often each rule has been used, by name. A quick way to see which rules matter, and which never match.

Every network now has DHCP, DNS and the internet, and nothing may cross between networks except the few named exceptions.